不知道大家有没有一个疑问,就是咱们的电脑明明是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) | 名称 | 作用 |
|---|---|---|
| 0 | PE (Protection Enable) | =1 → 启用保护模式;=0 → 实模式 |
| 1 | MP (Monitor Coprocessor) | 控制 WAIT/FWAIT 与 TS 位的关系 |
| 2 | EM (Emulation) | =1 → 禁用 x87 FPU 指令(模拟浮点) |
| 3 | TS (Task Switched) | 控制任务切换时的 FPU 使用 |
| 4 | ET (Extension Type) | 387/287 协处理器标志(基本已废弃) |
| 5 | NE (Numeric Error) | 控制 x87 FPU 错误报告机制(#MF 异常) |
| 16 | WP (Write Protect) | =1 → CPL=0 也要遵守页表写保护位 |
| 18 | AM (Alignment Mask) | 控制对齐检查(配合 EFLAGS.AC) |
| 29 | NW (Not Write-through) | =1 → 禁止写通缓存 |
| 30 | CD (Cache Disable) | =1 → 禁用缓存(一般不用,性能极差) |
| 31 | PG (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) | 名称 | 作用 |
|---|---|---|
| 0 | VME (Virtual-8086 Mode Extensions) | 开启 V86 模式增强 |
| 1 | PVI (Protected-mode Virtual Interrupts) | 虚拟中断支持 |
| 2 | TSD (Time Stamp Disable) | =1 → CPL≠0 时禁止执行 RDTSC |
| 3 | DE (Debugging Extensions) | 控制调试寄存器特性 |
| 4 | PSE (Page Size Extension) | 支持 4MB 页(32 位),或大页(64 位) |
| 5 | PAE (Physical Address Extension) | 支持 36 位物理地址 / PAE 页表模式(64 位模式必需) |
| 6 | MCE (Machine Check Enable) | 启用机器检查异常 (#MC) |
| 7 | PGE (Page Global Enable) | 开启全局页(TLB 不被刷新) |
| 8 | PCE (Performance-Monitoring Counter Enable) | 控制 RDPMC 特权级 |
| 9 | OSFXSR | 操作系统支持 FXSAVE/FXRSTOR(SSE 上下文切换) |
| 10 | OSXMMEXCPT | 操作系统支持 SSE 异常处理 |
| 11 | UMIP | 用户态禁止 SGDT/SLDT/SIDT/SMSW/STR 等指令 |
| 12 | LA57 | 启用 57 位线性地址(5 级页表) |
| 13 | VMXE | 启用 VMX(虚拟化,Intel VT-x) |
| 14 | SMXE | 启用 SMX(安全模式扩展) |
| 16 | FSGSBASE | 用户态允许读写 FS/GS 基址(RDFSBASE/WRFSBASE) |
| 17 | PCIDE | 启用 PCID(进程上下文标识,加速 TLB) |
| 18 | OSXSAVE | 操作系统支持 XSAVE/XRSTOR(AVX 上下文切换) |
| 20 | SMEP | Supervisor Mode Execution Prevention(内核禁止执行用户态代码) |
| 21 | SMAP | Supervisor Mode Access Prevention(内核禁止访问用户态数据,除非临时打开) |
| 22 | PKE | Protection 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) | 名称 | 作用 |
|---|---|---|
| 0 | SCE (System Call Extensions) | 支持 SYSCALL/SYSRET 指令 |
| 8 | LME (Long Mode Enable) | 启用 长模式(64 位模式) |
| 10 | LMA (Long Mode Active) | CPU 实际处于长模式时由硬件自动置位 |
| 11 | NXE (No-Execute Enable) | 启用 NX 页(防止执行) |
| 12 | SVME | SVM 虚拟化扩展(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位对应的段寄存器中
总结
虽然底层原理讲解位数内存切换很复杂,但是代码实现起来却没有那么麻烦。但是对于切换内存空间之后如何进行下一步指令集的操作,还需要进一步的研究。

1185

被折叠的 条评论
为什么被折叠?



