在了解保护模式以及分段和分页机制之前,需要知道一些必要的知识。
CPU对内存进行读写需要几类信息,没有这些信息,CPU不知道做什么。这些信息是:
• 地址信息
• 控制信息(读还是写)
• 数据信息
在计算机中专门连接CPU和其他芯片的导线称为总线。地址总线用来传输地址信息,控制总线用来传输控制信息,数据总线用来传输数据。
这三根总线解决了CPU对存储器读写的问题
我们知道地址总线是用来传输地址的,可见地址总线能传送多少个不同的信息,CPU就能对多少个存储单元进行寻址。8086CPU的地址总线是20位,因此可以对1M的存储单元进行寻址。
数据总线用来传输数据,数据总线的宽度决定了CPU跟外界的数据传送速度,8086CPU架构的数据总线宽度是16位,所以一次最多可以传输16位二进制数据。
地址总线也好、数据总线也好在计算机里属于外部总线,在CPU内部是由控制器、寄存器、运算器还有内部总线组成的,段的出现就是跟CPU里面的寄存器有关了。
我们以8086CPU为例,它有14个寄存器,分别是通用寄存器AX\BX\CD\DX\SI\DI\SP\BP,段寄存器CS\SS\DS\ES,以及IP\PSW,这些寄存器都是16位的。
当CPU需要向某个地址存取的时候,它需要将地址通过地址总线传输到存储装备上,可是8086CPU的地址总线是20位的,而CPU的寄存器都是16位的,因此有一部分地址CPU是无法一次表示的。
所以8086CPU使用了一种合成物理地址的方法来合成大于16位的地址。
这种合成的方法第一次用到了段地址。具体的算法是:物理地址 = 段地址 * 16 + 偏移地址,这种算法完美的表示了20位的地址,16相当于2的4次方,在二进制里每乘以2相当于左移1位,所以乘以16相当于左移4位,而寄存器跟地址总线差刚好4位。
合成的步骤大概是:
1. CPU中提供一个16位的段地址和一个16位的偏移地址。
2. 段地址和偏移地址通过内部总线传输到地址加法器的部件中
3. 地址加法器将两个16位地址合成为一个20位的地址
4. 地址加法器通过内部总线将20位地址送入输入输出控制电路
5. 输入输出控制电路将20位地址送上地址总线
6. 地址总线将地址送到存储器
举个例子,有个20位地址0x123c8,它在CPU中是这样被合成出来的:
8086CPU有4个段寄存器,分别是:
1. CS,代码段寄存器
2. DS,数据段寄存器
3. ES,拓展段寄存器
4. SS,栈段寄存器
在内部要提供段地址,段地址就保存在段寄存器当中。
x86 CPU有3种模式,分别是实模式、保护模式和虚拟8086模式。
现在的操作系统一般都运行在保护模式下。保护模式最主要的特点就是它的段机制和分页机制。
前面我们说过8086CPU里面的段地址,现在几乎都是32位以上的操作系统,段机制有了很大的变化。32位系统是在保护模式下的,不可能通过一个合成的地址就能直接访问物理内存,那样做对系统来说是非常不安全的,保护模式的意义也正在于此。
8086CPU的段寄存器是4个,到了Windows 32位系统增加到了8个,它们是CS\DS\ES\SS\FS\GS\LDTR\TR
在32位系统中段寄存器的大小扩展到了96位,其中有80位是不可见的,只有16位是可见的,这可见的16位叫做段选择子,后面会介绍不可见的80位和段选择子。
段寄存器的结构如下:
段寄存器的属性一般如下图中所示:
段寄存器的属性和Limit一般都如上图,可读是指段寄存器可以放在源操作数处,可写是指段寄存器可以放在目的操作数处,可执行是指段寄存器可以放在IP指向的位置。
mov ax,es ;这种方式只能读16位的可见部分
mov ds,ax ;写时是写96位
SLDT/LLDT ;读写LDTR
STR/LTR ;读写TR
除了使用mov指令,还可以使用LES\LSS\LDS\LFS\LGS指令修改寄存器,CS不能通过上述的方法进行更改,CS为代码段,CS的改变会导致EIP的改变,要改CS必须要和EIP一起改,因此Intel并没有提供专门修改CS的指令。
LES这样的指令使用时有固定的格式:
char buffer[6]
__asm
{
les ecx,fword ptr ds:[buffer] ;高2个字节给es,低4个字节给ECX,fword代表6个字节
}
当执行mov ds,ax这样的指令时,ax就充当段选择子的角色,它本身是一个数据结构:
系统会将ax分解成上图中的结构。
Index:下标,在GDT表中的下标,系统会通过Index到GDT表中去找到对应的段描述符
TI:0表示GDT,1表示LDT,在Windows中一直为0
RPL:请求特权级别,0是内核级别,3是用户级别
凡是赋值给段寄存器的值都是段选择子。
GDT叫做全局描述符表,LDT是局部描述符表,它们都是数组,数组里的元素用来保存段描述符或门描述符。上面说过当执行mov ds,ax这样的指令时,系统根据ax中的值到GDT或者LDT中查表,系统会把查到的内容加载到段寄存器中不可见的80位中,而ax会加载到可见的16位中,这可见的部分保存的就是段选择子。
我们可以通过在WinDbg中执行r gdtr找到GDT表的地址,GDT表的位置保存在GDT寄存器中。GDT寄存器长度48位,其中32位保存GDT表地址,还有16位保存GDT的长度,通过在WinDbg中执行r gdtl可以获得表的长度。
上面说过系统会在段寄存器被赋值时去查GDT表,查到表中的某一块内容后会将它加载到段寄存器中,这一块内容可以是门描述符,也可以是段描述符,它构成了段寄存器中不可见的80位,门描述符以后我们再说,我们先说段描述符。
段描述符长度是64位,在WinDbg中,我们可以通过r gdtr查找到GDT表。
然后通过dq 8003f000查看GDT表中的成员,dq指令会以64位为一组的方式显示。
我们查看成员00cf9b00``0000ffff,00f9b00对应段描述符的高32位,0000ffff对应段描述符的低32位,结合段描述符的结构体就可以分析出成员对应的数值,我们还需要了解各成员的含义。
Base:低32位中的16-31位,高32位中的0-7位、24-31位,一共占32位,它其实就是段地址的基址,在8086CPU中用Base*16+偏移等于物理地址,在保护模式中Base一般都是0,它对应段寄存器中的Base。
Segment Limit:段限,0-15位以及16-19位,占20位,它会结合G位,决定段的上限,G=0,单位是字节,G=1,单位是4KB,4KB转成十进制是4096,也就是说0-4095是有效的,4095转成十六进制是0xFFF,而20位Limit最大值是0xFFFFF,加一起刚好是8个F,也就是32位,它对应段寄存器中的Limit。
P:如果为1,段描述符有效,如果为0,段描述符无效
S:s=1,代码段或者数据段,s=0,系统段
Type:标记段属性,具体如下:
D/B:D/B位会在多种情况下产生影响
在段选择子赋值给段寄存器时会进行一系列的权限检查,在了解权限检查之前先了解几个概念。
CPL:当前特权级,CPL是段寄存器而言的,它是段寄存器最低2位,CPL代表当前程序是处于0环还是3环
RPL:RPL是对段选择子而言的,它是段选择子的最低2位,它代表了用什么权限去访问一个段
DPL:段描述符中属性的成员,规定了访问该段需要的特权级别
数据段权限检查:CPL <= DPL 并且 RPL <= DPL
代码段权限检查:如果是非一致代码段,要求:CPL=DPL 并且 RPL<=DPL,如果是一致代码段,要求:CPL>=DPL。后面会解释什么是一致代码段和非一致代码段。
之前说过段寄存器的修改可以通过mov或者LDS这样的指令进行,但是有个例外,CS段不可以通过这种方式进行修改,因为CS必须和IP一起改,CS和IP一起改的指令有JMP FAR\CALL FAR\RETF\INT\IRETED等。虽然不能单独改CS,但是可以单独改IP,指令有JMP/CALL/JCC/RET
下面我们来讲解使用JMP FAR指令实现代码间跳转。
代码间跳转的指令一般是类似这样的:JMP FAR 0x20:0x004183D7,冒号前面的是段选择子,后面的是EIP。
1. 段选择子拆分
先来分析0x20,对应的二进制是:0000 0000 0010 0000,所以RPL是00,TI是0,Index是4。
2. 段描述符检查
要实现JMP FAR对于Index处的段描述符是有要求的,它需要是代码段、调用门、TSS任务段或任务门,除了代码段,其他的都是系统段,系统段后面会介绍。
3. 权限检查
如果是非一致代码段,要求:CPL=DPL 并且 RPL<=DPL,如果是一致代码段,要求:CPL>=DPL。
解释一下什么是一致代码段和非一致代码段,系统有一些内核数据希望提供给用户层使用这段数据就可以放在一致代码段里面,如果系统不希望一些内核数据被用户层使用,就可以放在非一致代码段里面。
4. 加载段描述符
如果通过了权限检查,段寄存器就可以开始加载段描述符了。
5. 代码执行
CPU将CS.Base + Offset的值写入EIP,然后执行CS:EIP,段间跳转结束,CS会被改变。
代码间跳转虽然有可能访问内核的数据,但是特权级是不会改变的,如果想要提大CPL的特权级只能通过调用门描述符。
短调用就是很常见的CALL指令,执行CALL以后会向栈中先压入返回地址,ESP随之改变,CALL指令其实相当于PUSH+JMP指令,因此短调用影响的寄存器有ESP和EIP。CALL一般使用RETN进行返回。对应的堆栈图:
使用JMP FAR可以实现长跳转,要实现长调用就要用CALL FAR,跟JMP FAR不同的是,CALL FAR会影响堆栈并且可以提权。它的指令格式是CALL CS:EIP(EIP是废弃的),CALL FAR一般使用RETF进行返回。
CALL FAR分为两种情况,一种是跨段不提权,它的堆栈图如下,它影响了ESP、EIP、CS:
还有一种是跨段提权(通过调用门),跨段提权的堆栈图比较复杂一点:
它影响的寄存器有ESP、EIP、CS、SS。为什么要保存ESP和SS?因为提权后会进行堆栈切换,为了保存以前的堆栈需要提前保存ESP和SS。
那么有个问题,进行堆栈切换时SS和ESP从哪里来?这个放在下一篇文章说到TSS段的时候再说。