32-64 内存空间切换

  不知道大家有没有一个疑问,就是咱们的电脑明明是64位,却可以运行32位的程序,而32位的指令集和64位的指令集不光不同,就连寄存器也不同,反正我是一直有这个困惑的,直到这几天看了一篇博客http://blog.rewolf.pl/blog/?p=102,自己也学习了相关知识才弄懂个大概,接下来我从底层实现来讲解一下切换32位和64位内存空间的原理。在这篇文章中,我不仅会讲解内存空间的切换问题,还会讲解页表,全局描述表有关的问题,篇幅较长,还希望大家有耐心看完。

  首先,我们需要知道的是,在CPU中有一组寄存器是专门控制处理器工作模式和内存管理的,就是CR系列的控制寄存器,在CR系列的寄存器中有一些“开关”是切换模式且必不可少的,所以我们首先介绍一下CR系列的寄存器。

CR系列寄存器

  CR系列的寄存器有很多,我们着重讲解CR0,CR3,CR4这三个寄存器。我们都知道在32位和64位时寄存器的大小是不同的,但是对于这一组寄存器在64位时高32位保留,也只使用低32位的大小,所以我们只需要记忆低32位即可。

CR0

最主要的寄存器控制处理器工作模式和内存管理属性,大小为32位,类似于标志寄存器FLAGS,也是通过位bit来表述权限和性质的,我们以表格形式展开:

位 (Bit)名称作用
0PE (Protection Enable)=1 → 启用保护模式;=0 → 实模式
1MP (Monitor Coprocessor)控制 WAIT/FWAIT 与 TS 位的关系
2EM (Emulation)=1 → 禁用 x87 FPU 指令(模拟浮点)
3TS (Task Switched)控制任务切换时的 FPU 使用
4ET (Extension Type)387/287 协处理器标志(基本已废弃)
5NE (Numeric Error)控制 x87 FPU 错误报告机制(#MF 异常)
16WP (Write Protect)=1 → CPL=0 也要遵守页表写保护位
18AM (Alignment Mask)控制对齐检查(配合 EFLAGS.AC)
29NW (Not Write-through)=1 → 禁止写通缓存
30CD (Cache Disable)=1 → 禁用缓存(一般不用,性能极差)
31PG (Paging)=1 → 开启分页(必须配合 PE=1)

我们需要注意的几个位在此强调:

1.第16位:在内核中我们一般可以通过修改第16位为1来短暂开启全局写权限(CR0.WP=1),类似于VirtualProtect修改页面属性,当然也可以通过Mdl来实现,此处不赘述。

2.第0位和第31位:不要误会,此处的PG不是64位的Patch Guard,不要有压迫感^-^。我们都知道,从最初的Intel处理器开始,不同位数的处理器运行的模式都不同,从16位处理器开始的实模式,到32位开始的保护模式,再到64位的长模式,都有对应的指令集和寄存器。而CR0.PE就是切换实模式和保护模式的开关(当然我们肯定不会使用实模式)。对于CR0.PG,其实就是分页,按照固定的页面大小来对内存进行“分隔存储”,在64位必须要开启

在此处,我们给出对应的权限搭配所对应的模式:

PE=0, PG=0实模式(16位)
PE=1, PG=0保护模式(32位无分页)
PE=1, PG=1分页保护模式(32位虚拟内存)
PE=1, PG=1, NE=1,CR4.PAE=1,EFER.LME=1长模式(64位)

可以看出,64位的权限位是最多的,我们在后面都会一一讲到,此处先理解PE=1, PG=1即可

CR4

类似于CR0,也是通过位bit来表述权限和性质的,不同的是CR4是用来描述一些CPU扩展属性的,从386的时代还没有普遍使用,到486时代才开始大范围使用。我们以表格形式展开:

位 (Bit)名称作用
0VME (Virtual-8086 Mode Extensions)开启 V86 模式增强
1PVI (Protected-mode Virtual Interrupts)虚拟中断支持
2TSD (Time Stamp Disable)=1 → CPL≠0 时禁止执行 RDTSC
3DE (Debugging Extensions)控制调试寄存器特性
4PSE (Page Size Extension)支持 4MB 页(32 位),或大页(64 位)
5PAE (Physical Address Extension)支持 36 位物理地址 / PAE 页表模式(64 位模式必需)
6MCE (Machine Check Enable)启用机器检查异常 (#MC)
7PGE (Page Global Enable)开启全局页(TLB 不被刷新)
8PCE (Performance-Monitoring Counter Enable)控制 RDPMC 特权级
9OSFXSR操作系统支持 FXSAVE/FXRSTOR(SSE 上下文切换)
10OSXMMEXCPT操作系统支持 SSE 异常处理
11UMIP用户态禁止 SGDT/SLDT/SIDT/SMSW/STR 等指令
12LA57启用 57 位线性地址(5 级页表)
13VMXE启用 VMX(虚拟化,Intel VT-x)
14SMXE启用 SMX(安全模式扩展)
16FSGSBASE用户态允许读写 FS/GS 基址(RDFSBASE/WRFSBASE
17PCIDE启用 PCID(进程上下文标识,加速 TLB)
18OSXSAVE操作系统支持 XSAVE/XRSTOR(AVX 上下文切换)
20SMEPSupervisor Mode Execution Prevention(内核禁止执行用户态代码)
21SMAPSupervisor Mode Access Prevention(内核禁止访问用户态数据,除非临时打开)
22PKEProtection Key Enable(内存保护键)

大家不要一看到这么多权限位就懵了,我们只挑选重要的东西进行记忆理解。

1. 第5位:在64位时,PAE必须设置为1,因为需要物理地址从 32 位(最大 4GB)扩展到 36 位(最大 64GB),否则我们的地址空间就不够使用了。需要注意的是,我们一旦开启该开关,页表的层级就会从2级增加到3级。

2.第12位:是否开启五级页表,也就是PML5,类似于句柄表,页表也是按照层级来存储的,如果大家对这一块不是很了解的话,我们下一期也可以重点讲一讲页表。

3.第17位:每一个进程在我们的任务管理器中都是有唯一标识的,也就是PID,我们只要一打开任务管理器就可以看到,如图:

第二列就是每个进程对应的PID,我们都知道一个进程运行起来是有自己的虚拟内存的,而有虚拟内存就应该有物理内存来存储,那么由虚拟内存->物理内存的映射就存储在我们的页表中。17位的标志就是是否要为进程创建PID(当然在现代的操作系统中都是默认打开的)

CR3

进程中十分重要的一个结构,存储着进程虚拟内存对应的物理内存地址,也就是顶层页表指针。不同于前两个寄存器,CR3寄存器是两个部分组成(64位开启了PCID):

63                              12 11   0
+--------------------------------+------+
| PML4 Base Address              | PCID |           //PCID和页表基地址一一对应
+--------------------------------+------+
    

对于高51位,都用来存储页表的顶层指针(指向物理内存的映射),低12位用来存储PCID,也就是每个进程的PID。每一个进程都有自己独有的PID,和自己独有的物理内存,是一一对应的,但是如果我们修改了一个进程的页表指针,神奇的事情就出现了,我们进程中所作的修改居然可以应用到别的进程中,这就是内核中的一个函数KeAttachProcess,用于附加进程。这就是页表的魅力所在。

我们在前面已经给出了64位对应的权限位,到此处已经完成了CR0.PE=1,CR0. PG=1,CR0. NE=1,CR4.PAE=1的介绍,接下来就是最后一个EFER.LME=1的介绍

MSR寄存器

MSR寄存器是内核中十分重要的寄存器,存储着许多有用的信息,比如MSR就存储着内核函数调用的入口KiFastCall函数和KiSystemService函数的地址信息。我们要介绍的是0x80偏移处的地址:

ULONG_PTR v1= _readmsr(0x80)

获得该寄存器中的值,我们按图表讲解:

位 (Bit)名称作用
0SCE (System Call Extensions)支持 SYSCALL/SYSRET 指令
8LME (Long Mode Enable)启用 长模式(64 位模式)
10LMA (Long Mode Active)CPU 实际处于长模式时由硬件自动置位
11NXE (No-Execute Enable)启用 NX 页(防止执行)
12SVMESVM 虚拟化扩展(AMD)

我们只关心LME和LMA:当我们想进入长模式时,就必须尝试修改LME为1,然后系统就根据其他寄存器中的值(CR0和CR4等,我们之前说的那些)判断有没有进入的条件,如果可以则进入,此时系统会自动将EFER.LME置值为1,我们也可以通过该标志来检查到底处于哪一种模式

那么说到现在,我们底层就已经说了大半了,但是我们只是修改了一些权限位,并没有进入到真正的内存中去理解,我们在接下来的GDT中全部托出

GDT和段选择子

GDT(Global Descriptor Table):

全局描述符表,在操作系统启动时初始化一次,供整个系统共享,由多个条目构成,每一个条目占据8个字节,用来描述段寄存器的性质和基地址(内存中是按照段来划分区域的)

其实在之前还有一个表是LDT表,每个进程和线程有自己的局部LDT表起到类似于GDT的作用,但是现代操作系统几乎不使用LDT,大部分使用平坦段GDT+页表来管理内存

GDT存储的数据:

  • 内核代码段(Kernel Code)

  • 内核数据段(Kernel Data)

  • 用户代码段(User Code)

  • 用户数据段(User Data)

  • TSS(Task State Segment)

没看懂没有关系,我们在下面使用Windbg调试一遍

段选择子:

段选择子是一个索引指向GDT,真正决定段寄存器基地址和权限的还是GDT表中的数据

15        3 2 1 0
+-----------+---+
|   Index   |TI |RPL|
+-----------+---+
Index (13 位):GDT表项索引
TI (1 位):0
RPL (2 位):请求特权级(0~3)

一个段选择子占据16位,高13位是GDT中的索引,低三位是标识符

!!!重点来了:32位和64位的CS段寄存器不同,这个就是我们切换32-64位的核心关键:

CS描述Index
0x23用户态 32 位代码段(平坦段,32 位寄存器)4
0x33用户态 64 位代码段(平坦段,64 位寄存器)6

在0x23指向的段描述符中,权限位是D/B = 1 (标志32位指令集)

在0x33指向的段描述符中,权限位是L = 1 (64 位指令集)

0x23=00000000^00100011             //后三位不看,仅看前13位,是4,则在GDT中的Index就是4
------------------|---
0x33=00000000^00110011             //后三位不看,仅看前13位,是6,则在GDT中的Index就是6

我们必须要切换CS才能完成内存空间的转化,当然此时可能有人问了:代码段转换了,我们的DS数据段寄存器不需要转换一下吗?

实际上:64位中CS,FS,GS是有效段寄存器(CS用于存储指令,FS和GS用于实现TLS),而其他的段寄存器机制基本都被CPU废除(DS/SS/ES的段基址都是0)

使用Windbg调试GDT表

我们首先查看GDT表的基地址

kd> r gdtr
gdtr = fffff800`00b95000
kd> dq fffff80000b95000
fffff800`00b95000  00000000`00000000 00000000`00000000           //第一行排除不看
fffff800`00b95010  00209b00`00000000 00cf9300`0000ffff           //从这里开始按照8字节是一个条目描述段地址
fffff800`00b95020  00cffb00`0000ffff 00cff300`0000ffff           //可以进行手动分析GDT的每一个条目
fffff800`00b95030  0020fb00`00000000 00000000`00000000
fffff800`00b95040  00008bb9`60800067 00000000`fffff800
fffff800`00b95050  ff40f3fe`00003c00 00000000`00000000
fffff800`00b95060  00cf9a00`0000ffff 00000000`00000000
fffff800`00b95070  00000000`00000000 00000000`00000000

我们不查看前16个字节,索引从第二行开始计数,按照每8个字节为一个条目开始记录信息,而这8个字节的结构体定义如下:

//8个字节对应的结构体
typedef struct _KGDTENTRY64 {
    USHORT LimitLow;        // 段界限低 16 位
    USHORT BaseLow;         // 段基址低 16 位
    UCHAR  BaseMiddle;      // 段基址中间 8 位
    UCHAR  Flags1;          // 访问标志(Access Byte)
    UCHAR  Flags2;          // 标志 + 高位段界限
    UCHAR  BaseHigh;        // 段基址高 8 位
    ULONG  BaseUpper;       // 段基址 32~63 位(只在 64 位)
    ULONG  MustBeZero;      // 保留,必须为 0
} KGDTENTRY64, *PKGDTENTRY64;

描述每一个段寄存器的起始位置和权限,真正的存储数据的地方。我们可以按照表格数据来挨个解码获得对应段寄存器的信息

此时,如果想完成32-64位的转换,就务必要实现CS段寄存器的转换

汇编实现32-64 Switch

如果前面都没有耐心看,没关系,我们直接跳到最后一步看代码,十分的简单:

32位我们只需要将0x33压入返回地址(rsp+4),然后使用远返回(retf指令,可以切换段寄存器的返回指令)直接返回到CS为0x23的段寄存器中,完成64->32位的切换

//进入32位的代码
X86_Start MACRO                      ;切换到32位模式的用户态代码
    LOCAL xx, rt                     ;声明临时标签
    call  $+5
    xx     equ$                      ;equ是伪指令,用于将后面的东西赋值给前面的东西(类似等于号)
    mov   dword ptr [rsp+ 4], 23h    ;修改返回地址
    add   dword ptr [rsp], rt - xx
    retf                             ;远返回,切换到32位代码段(和远跳转是一样的)
    rt:
ENDM
//进入64位的代码
X86_End MACRO              ;切换回64位的用户态代码
    db 6Ah, 33h            ; push  33h
    db 0E8h, 0, 0, 0, 0    ; call  $+5
    db 83h, 4, 24h, 5      ; add   dword ptr [esp], 5
    db 0CBh                ; retf
ENDM

返回64位直接使用十六进制编写代码,注释也是很详细,将0x23压入堆栈,然后远返回即可返回到64位对应的段寄存器中

总结

虽然底层原理讲解位数内存切换很复杂,但是代码实现起来却没有那么麻烦。但是对于切换内存空间之后如何进行下一步指令集的操作,还需要进一步的研究。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值