从8086CPU开始,为了让程序在内存中能自由浮动而又不影响它的正常执行,CPU将内存划分成逻辑上的段来给程序使用。
x86继续沿用了这一模式,但是保护模式将其管理起来,进行保护。而段式管理正是用来对段进行管理的。
在保护模式下,会将每个段的信息先进行登记。
和段有关的信息需要8 个字节来描述,所以称为段描述符 (Segment Descriptor),每个段都需要一个描述符。为了存放这些段描述符,就在内存中开辟了一段空间,在这段空间中所有的段描述符以数组的形式存放在一起,这就构成了一个段描述表(Descriptor Table)。最主要的描述符表是全局描述符表(Global Descriptor Table GDT),这个全局段描述符表在任何时刻都可以使用,在进入保护模式前必须要先定义全局描述表的内容才行。
然而段描述符由八个字节来组成,用起来太大了而且不方便,于是保护模式又有了段选择子(Segment Selector),段选择子作为一种结构体,通过对段选择子的解析可以得到段描述符的地址,然后就可以方便的进行读取了。
注:这些专有名词会在接下来讲到。
可以看到段式管理的主要内容其实只有两个部分:
1 内存管理: 为地址的转换提供基础平台。CPU在通过地址访问内存的时候会自动通过地址转换的基础平台进行转换。在段式内存管理中,分段机制的内存管理主要是提供从逻辑地址(logical address)转化为CPU的线性地址(Linear address)的基础平台。
2 保护措施: 控制访问行为,避免资源被随意访问。
而无论是段式管理还是页式管理,都包含这两方面。
我们知道保护模式是用来保护计算机的一些资源,在段式管理下会用到以下硬件资源:
主要包含:
CR0和CR4
GDTR,LDTR,IDTR,TR
段选择子寄存器(Segment selector register):ES,CS,SS,DS,FS和GS寄存器。
主要包含:
GDT,LDT,IDT。
TSS段。
段描述符(Segment Descriptor)。
段选择子(Selector)。
segment selector数据结构图是十六的,它是一个段的标识符:
字段 | 描述 |
---|---|
RPL(Requested Privilege Level) | 表示请求者所使用的权限级别,也就是特权级,只不过是请求者所使用的特权级。 |
TI(Table Indicator) | 描述符索引表,其中为0表示GDT也就是全局描述符表,为1表示LDT(local Descriptor Table)局部描述符表。 |
Index | 表示描述符表中的偏移地址,描述符表是一个数组。通过数组+下标的形式就很容易得到想要的内容了。 |
在x86中有三类描述符表,GDT(Global Descriptor Table),LDT(Local Descriptor Table)和IDT(Interrupt Descriptor Table)。
这些段描述符表由段描述符寄存器(Descriptor Table Register)进行定位,分别是 GDTR,LDTR和IDTR。
这里主要讲解GDTR和GDT表。
GDTR:(32bit)
由32位的GDT基地址和16位的GDT界限值组成,这里的界限值相当于定义数组时的数组的大小,其中基地址在GDTR的高32位,界限在GDTR的低十六位中保存。
32位的地址范围是0x00000000 到0xFFFFFFFF,共2的32次方字节的内 存,即4GB内存。
边界是以数组的方式来处理的,所以是从0开始。
理论上,全局描述符表可以位于内存中的任何地方。但是,由于在进入保护模式之后,处理器立即要按保护模式的内存访问模式工作,所以,必须在进入保护模式之前定义GDT。由于在实模式下只能访问1MB 的内存,所以GDT通常都定义在1MB 以下的内存范围中。 当然,在进入保护模式之后可以换个位置重新定义GDT。
在32 位模式下,传统的段寄存器,如CS、SS、 DS、ES,保存的不再是16位段基地址,而是段的选择子,用于选择所要访问的段,因此,实际上它的名字叫做段选择器。
除了段选择子之外,每个段寄存器还包括一个不可见部分,称为描述符高速缓存器,里面有段的基地址、段的范围和段的访问限制。这部分内容程序不可访问,由处理器自动使用,也就是上图的Hidden Part部分,其中base Address就是段的基地址,Limit就是段的范围,Access Information由段描述符的其它字段组成。
标准段描述符:
段描述符字段 | 含义 |
---|---|
Base 31:24 | 段基地址的31-24bit |
Base 23:16 | 段基地址的23-16bit |
Base Address 15:00 | 段基地址的15-0bit |
Segment Limit 15:00 | 段界限的15-0bit |
Seg Limit 19:16 | 段界限的19-16bit |
G | 为段界限服务,为0则段界限以bit为单位,就是20位,有1MB空间,为1则以4MB为单位,就有4GB空间。 |
L | L 位是64 位代码段标志(64-bit Code Segment),保留此位给64 位处理器使用。为1则是,为0则不是。 |
AVL | CPU不使用该字段,有操作系统使用。 |
D/B | 默认操作数,1为32bit,0为16bit |
P | 标识该段是否有效,0为无效,1为有效。 |
DPL(Descriptor Privilege Level) | 段的特权级。 |
S | 段的类型,为0时表示系统段,为1时表示代码或者数据段 |
TYPE | 用来描述段描述符经过S字段区分后的子类型 |
段描述符中有两个字段S和TYPE,可以对段描述符进行分类。
首先是S字段,会把段描述符分成系统段和普通的代码段或数据段。
S为0时,是系统段,其实不管是什么类型的段描述符都是前面那个的
标志段描述符的变种。
然后根据TYPE字段又有很多变种:
S == 1时:
是普通的代码段数据段,然后根据TYPE继续细分:
在普通的代码段/数据段中,TYPE又可以继续细分了。
字段 | 内容 |
---|---|
X | X表示是否可以执行(executable)。数据段总是不可执行的,X=0;代码段总是可以执行的,因此,X=1。 |
E | E位指示段的扩展方向。E=0 是向上扩展的,也就是向高地址方向扩展的,是普通的数据段;E=1 是向下扩展的,也就是向低地址方向扩展的,通常是栈段。 |
W | W 位指示段是否可写,W=0 的段是不允许写入的; W =1 的段是可以正常写入的。 |
A | A 位是已访问(Accessed)位,用于指示它所指向的段最近是否被访问过。 |
R | R字段表明是否可读。这里的R 属性并非用来限制处理器,而是用来限制程序和指令的来读取该段。一个典型的例子是使用段超越前缀“CS:”来访问代码段中的内容。 |
C | C 位指示段是否为特权级依从的(Conforming)。C=0 表示非依从的代码段,这样的代码段可以从与它特权级相同的代码段调用,或者通过门调用;C=1表示允许从低特权级的程序转移到该段执行。 |
在进入保护模式前需要建议一个完整的保护模式运行环境,必须要有:
GDT,IDT和TSS段。IDT和TSS可以在进入保护模式后再建议。也就是说GDT必须在进入保护模式前,在实模式时就必须先建立。
控制实模式和保护模式切换的开关是在一个叫CR0 的寄存器里。
CR0 是处理器内部的控制寄存器(Control Register,CR)。之所以有个0后缀,是因为还有CR1、CR2、CR3 和CR4 控制寄存器,甚至还有CR8。
CR0是一个32位的寄存器,包含了一系列用于控制处理器操作模式和运 行状态的标志位。
CR0的第零位是保护模式允许位(Protection Enable,PE)。
对于该PE位,官方的解释是这样的: Enables protected mode when set; enables real-address mode when clear. This flag does not enable paging directly. It only enables segment-level protection. To enable paging, both the PE and PG flags must be set.
也就是说它可以开启保护模式,而且只能开启段式管理,如果要开启页式管理,还需要设置CR0的PE和PG标志位。
这里我们用WinDbg和Bochs分别看一下实模式和保护模式下的cr0的值就清楚了。
首先是Windbg:
此时我的Windows已经进入了保护模式了。
然后查看cr0的值,通过计算器可以得到最后一位的值为1,也就是说PE标志位的值为1。
然后查看bochs下实模式的cr0值:
可以看到PE标志位的值为0.
首先回忆一下8086的访问模式,在实模式下,访问内存用的是逻辑地址,即将段地址乘以16,再加上偏移地址就是内存的物理地址了。
保护模式的段式内存访问也是用逻辑地址来处理,但是保护模式对段的处理和实模式不一样。
首先是修改段寄存器的内容不一样:
实模式下直接赋值就行了,但是保护模式不同。
当处理器在执行任何改变段选择器的指令时(比如pop、mov、jmp far、call far、iret、retf)就需要将该段的段选择子传送到段寄存器并不是实模式下的逻辑段地址,然后段寄存器再通过段选择子访问段描述符表,接着从段描述符表中得到段描述符然后进行验证,判断是否能够利用该段,通过验证后再把该段的段描述符的内容传送到段寄存器的段描述符高速缓冲区里面,然后再进行逻辑处理。
3.4.2 内存访问:
然后是通过逻辑地址的访问也不同,保护模式的段寄存器里存放着段选择子然后还有段描述符的高速缓冲区,在切换段的时候会把内容自动赋值给段寄存器,然后再进行内存访问时就会自动利用段寄存器种的高速缓冲区得到段的基地址然后再通过偏移地址得到内容,当然这里面肯定也有验证,比如说验证是否超过了段的大小。
比如说:
mov byte [0x00],'p'
这里没有加段前缀但是无妨,CPU允许这样用,这样就是默认使用ds数据段寄存器。
CPU会通过ds的段描述符高速缓冲区得到基地址,然后将基地址+偏移地址也就是这里的0x00,得到物理地址,再把'p'的ASCII码赋值进去。
别的也一样。
3.5 保护模式下的段式管理的保护措施:
注,这里暂且不谈系统段。
处理器引入保护模式的目的是提供保护功能,而段式管理提供的保护功能肯定就是对段进行保护,而段又由段寄存器进行表示,这个段式管理肯定就是对段和段寄存器的处理。
在自己编程程序的时候难免会有问题,比如,向代码段写入数据、访问段界限之外的内存位置等。又或许有些恶意的程序就是来干这些的。
段式管理的对段的保护措施无外乎三种:
段Limit检查。
段type检查。
段特权级(privilege)检查。(特权级检测很复杂后面单独讲)
这三种检查对不同的段检查细节上各有不同。
还有一些比较通用的检查:
比如说这样一段程序:
jmp 0x0010:0x00000000 mov ax,0x0008 ;0000 0000 0000 1000 mov ds,ax mov es,ax mov fs,ax mov gs,ax mov ss,ax
可以看到修改cs段寄存器时要特殊点,因为intel不允许直接修改cs段寄存器,只允许通过intel指令进行调用(比如jmp far、call far、iret、retf)。
首先以上的段寄存器赋值的段选择子的TI位都是0所以段描述符表都是GDT表,然后通过对段选择子的index*8+GDT表的起始地址得到段描述符的地址,这里的段描述符地址必须在描述符表的地址范围内。
所以这里首先就有一个验证条件:段描述符地址是否越界:
段选择子.index*8+7<=GDTR.边界值。
如果检查到指定的段描述符,其位置超过表的边界时,处理器中止 处理,产生异常中断13,同时段寄存器中的原值不变。
比如这里我们拿Windbg验证一下:
首先查看gdtr的值:
本来我像看到gdtr的边界值字段的,但是查不到如何通过WinDbg查看到gdtr的边界值。
由于gdtr的边界值字段是有16位组成的,所以它的最大范围就是64KB,我们只要索引号*8+7比这个值大不就行了。由于段选择子的index范围是13位也就是2的13次方,最大边界为2的16次方,然后8=2的三次方。
2的三次方*2的13次方= 2的16次方,再加一个7肯定就比2的16次方大了。
所以这里直接拉满就行了,给index赋值为1111111111111,然后段选择子配下来为:
1111 1111 1111 1000;也就是FFF8
然后写一个程序给双机调试的调试机用:
#include<stdio.h> int main() { _asm { int 3 mov ax,0xFFF8 mov ds,ax } return 0; }
然后在调试机中运行后会自动停到int3断点处:
然后我们step into,步入汇编指令查看将 0xfff8这个段选择子给ds会发生什么:
可以看到这里就直接产生异常了。由于这里我们是在Windows上运行的所以只能看到Windows的异常,如果要看到CPU的就需要通过前面的设置进入我们自己的保护模式,不进入操作系统。
这里我自己找了个汇编代码,然后可以看到CPU的exception()是13。
P字段表示该段描述符是否有效,这个意思就是该段描述符是否在内存中,可以直接读取。如果为0就表示该段描述符在其它地方,比如说硬盘里。如果获取了P为0的段描述符CPU就会引发一个异常中断来将该段描述符读取到内存里面来。
其实就是对段的类型进行检查,想象一下,要是将你的数据段加载到了代码段,会是多可怕的事情。因为CPU会依然顺着往下跑,一段指令是数据还是代码全靠人为,CPU只知道对机器编码进行运作。这样的话,随便一改就有可能导致很严重的后果,所以有了这个段的type字段的检查。其实就是段描述符中的type字段。
所以CPU会检查描述符的类别是否和段寄存器的用途匹配,判断是否段寄存器只加载了该段寄存器的段描述符,比如说CS段寄存器加载可执行不可更改,读无所谓的段描述符。
段type的检查规则如下:
稍微思考下就可以知道规则是这样的原因了。
每个段都有自己的段界限。
这里的Limit就是段的界限,它还需要和字段G进行搭配使用。
如果G=0,实际使用的段界限就是描 述符中记载的段界限;如果G=1,则实际使用的段界限为:
Limit*0x1000+0xFFF
由于段的扩展方式不一样,有的从高地址往低地址,有的从低地址往高地址,再加上段的访问方式不同,所以这里的Limit会根据段的不同而进行不同的处理。
代码段是从低地址往高地址的方向扩展的。而且代码段的偏移地址是有EIP寄存器来进行处理。因为Limit是段的范围,也就是相对基地址的值,而EIP就是相对基地址的偏移地址。所以这里EIP可以直接和code段的Limit直接进行比对。
规则如下:
0<=EIP+指令长度-1<=Limit
栈段其实也是一个数据段,只不过比较特殊而已,栈段是从高地址向低地址方向扩展的通常是给SS段寄存器使用,然后ESP来记录偏移地址。
对栈操作的指令一般是push、pop、ret、iret 等。这些指令在代码段 中执行,但实际操作的却是栈段。
其实和代码段是差不多的,ESP类比EIP,EIP可以直接和Limit进行对比,然后根据ESP的地址位置进行比较。
0<=ESP-1<=Limit
数据段一般是默认从低地址往高地址,这样来和栈段进行区别。
数据段就没有偏移地址寄存器来帮忙了,就直接检查偏移地址和Limit的值就好了。
0<=偏移地址-1<=Limit
段式管理是x86保护模式所必须配置的机制。主要内容是对段进行管理和检测。