chapter 11 保护模式
全局描述符表
32位保护模式下任何段使用之前都需要注册登记,否则不让用,
注册时还需要说明该段的访问权限,如果一个只能读写的段非要在上面执行代码会被制止
如果访问范围超过了段的界限也会除法处理器产生内部异常中断
注册登记段信息的地方就是描述符表,描述符表有全局的GDT也有局部的LDT
全局描述符表是给整个系统服务的,处理器从实模式进入保护模式之前必须设置好全局描述符表,即GDT是在实模式下建立的,那么其内存地址应该不超过8086的寻址范围1M,(在进入保护模式之后搬到别的地方另说)
处理器怎么直到GDT放到内存上哪里了呢?全局描述符表寄存器GDTR就是干这个事的--它专门记录全局描述符表在内存中的位置
GDTR有48位,高32位记录的是全局描述符表的基地址,低16位记录的是该表的界限,因此该表可以在在32位可寻址的4G内存的任何地方,长度最长是64KB.
又全局描述符表的表项一条是8字节,因此该表最大可以有64K/8=8K条记录
段选择子
段描述符
用段寄存器中存放的选择子中的索引查段描述符表得到段描述符,段描述符相当于一个保存段信息的结构体
段描述符就是描述符表GDT或者LDT等的表项,每个段描述符长8字节
这个段描述符长的很不顺溜,段基地址被分成了三块,段界限被分成两块
这样设计是为了和废物16位保护模式兼容
基址和界限
段基地址共32位,段界限共20位,即一个段的基地址可以是4G地址空间中的任何地方,段大小最大是1M(或4G,取决于粒度G的规定)
粒度G
Granularity,粒度,用于解释段界限的含义
段界限占用了16位,如果以1B为单位,则一个段最大是\(2^{20}\times 1B=1MB\)大小
然而4G的地址空间应该允许以G为量级的段
当段界限的单位是4KB时则一个段最大是\(2^{20}\times 4KB=4G\)
为啥要以4KB为单位?因为分页时一页的大小就是4KB,这样规定粒度方便给一个段分配页数
G=0表示段界限的单位是1B
G=1表示段界限的单位是4KB
段描述符类型S
S=0表示系统段
S=1表示代码段或者数据段
描述符特权级DPL
Descpirtor Privilege Level
指定要访问该段需要最低的权限
即0环还是3环,规定段级别,
0环为最高级,只能由系统访问
3环为最低级,可以由系统或者用户程序访问.
段存在位P
Segment Present
P=0表示段不存在于物理内存中,即建立了描述符但是尚未建立对应物理页,或者刚才该段在内存中存在但是现在被交换到了磁盘中,也需要把P置0
P=1表示该段已经在物理内存中了
该位用于触发缺页中断,属于虚存调度策略
默认操作数大小D/B
Default Operation Size
用于兼容16位保护模式
对于代码段,该位是D位
D=0表示指令中的偏移地址和操作数都是16位的,比如使用ax,ip等16位的寄存器,不使用eax,eip等32位寄存器,即使eax的高16位有东西也忽略
D=1则是32位的
对于数据段,该位是B位
B=0表示16位的,B=1表示32位的
B=0使用16位栈顶指针sp,不使用esp,栈边界也是16位的
描述符子类型TYPE
TYPE占用了4位,分别是X(执行),E/C(拓展方向/特权依从),W(写)A位
A位不管是代码段还是数据段,都表示是否已访问(Access),属于虚存调度的范畴
对数据段
E指定的拓展方向,该段是往地址增大的方向生长,比如堆;还是往地址减小的方向生长,比如栈
W=0表示只读,W=1表示读写,不管怎么找,必须有读的权限
X=0表示不可执行,X=1表示可执行,可以猜测NX保护就是修改的该位
对代码段
C表示是否特权级依从,这里的特权级就是DPL指定的段描述符特权级
C=0表示可以被同级段调用
C=1表示可以被低级段调用
R表示是否可读,
R=0不可读
R=1可读
不管怎么着,代码段一定是不可写,可执行的
这里的可读不可读是对程序的限制,不是对处理器的限制,处理器从代码段取代码是不受限制的,但是程序如果尝试使用[cs:offset]从代码段取东西看看,是不被允许的
软件可用位AVL
操作系统使用,处理器不管这一位.算是预留的一位
64位代码段标记L
L=1表示64位
L=0表示32位
32位下该位置0
走向保护模式
例子中将栈安排在0x7C00开始往低地址方向生长
主引导程序512个字节占据从0x7C00开始到0x7E00
从0x7E00开始的64K到0x17DFF是GDT
计算GDT所在的逻辑段地址
1 | ;计算GDT所在的逻辑段地址 |
0x7c00
是本程序加载到内存中的位置
cs:gdt_base
是该标号的汇编地址
两者加起来才得到该标号的物理地址也就是gdt_base
的地址
把这个地址开始的四个字节0x00007e00
放到dx:ax里,然后除以16,商放到ds里作为段地址,余数放到bx里作为段内起始偏移地址
此时还处在实模式,因此段地址除以16再交给段寄存器
从ds:bx开始就是全局段描述符表了
创建段描述符
Intel处理器要求0号段描述符为空,有意义的段描述符从1号开始
1 | ;创建0#描述符,它是空描述符,这是处理器的要求 |
这里1号段描述符的意义是:
段基址0x00007c00,恰好是主引导记录加载到内存中的地址
段界限0x001ff,段长度为512字节
G=0,粒度为字节,
D=1,32位段
L=0,非64位段
AVL=0
P=1,目前位于内存中
DPL=00,0环
S=1,代码段
TYPE(XCRA)=1000,只能执行,向上拓展
初始化段描述符表寄存器
1 | ;初始化描述符表寄存器GDTR |
算上没有意义的0号描述符,一共有四个描述符,共32字节,因此GDT表的界限应该是31,放到gdt_size中
lgdt指令用于加载GDT表地址到GDTR寄存器,其操作数是一个48位数,也就是内存中6个字节,高32位是GDT地址,低16位是GDT界限
这里使用lgdt [cs: gdt_size+0x7c00]
意思是从gdt_size标号开始的48位,低16位作为GDT界限,高32位作为GDT地址,放到GDTR中
而gdt_size开始的内存是这样定义的:
1 | gdt_size dw 0 ;低地址,一个字,16位 |
由于先前用mov word [cs: gdt_size+0x7c00],31
已经设置好了gdt_size
这里lgdt准确地将GDT的地址和界限放到了GDTR中
A20与地址回绕
8086只有20根地址总线A0-A19,不存在A20这根线.
在8086时,地址最大值是0xFFFFF,再加1就高位截断了成了0x00000,这就是地址回绕.书上说当时很多程序员利用这个"特性"编程,并且好像还那个以此为自豪
80286时地址线就有24根儿了,0xFFFFF+1=0x100000,因为位数足够多,不会高位截断,也就不再回绕了,这样原来利用地址回绕写的程序全都寄了.
为了保持兼容性,保持一下这些程序员的自尊心,IBM在A20上设置了一个开关,兼容8086时就不用A20,让他一直置0,这就有了0x0FFFFF+1=0x000000,又绕起来了.IBM把A20和键盘控制器上一个开关按位与了再接到内存条子上,这个开关的端口号0x60.
向0x60端口写入数据,第一位置1则该键向与门输出1,此时A20生效
向0x60端口写入数据,第一位置0则该键向与门输出0,此时A20失效
80486以后处理器有了A20M#引脚,低电平时A20失效
向0x92端口的第二位(位1)置1就打开了A20,A20有效.置0则A20失效.
开机时自动置有效
0x60和0x92关于A20的控制是或,即只要有一个开关打开,A20就有效
而要从实模式转换为32位保护模式,显然需要打开A20
1 | in al,0x92 ;南桥芯片内的端口 |
in就是从0x92读取一个字节的数据放到al寄存器
然后通过按位或将al的第二位(位1)置高,其他位不变
然后out将al输出到0x92一个字节
这就设置好了0x92端口处的快速A20和初始化寄存器.A20就打开了
关闭中断
进入保护模式后,BIOS提供的实模式下的中断功能不能再使用,而保护模式的中断环境尚未设置,因此进入保护模式前需要先关闭中断
1 | cli |
CR0与保护模式
控制CPU运行模式的开关在CR0寄存器
CR0的最低位(位0)如果是1则CPU进入保护模式,置0则为实模式
至于CR0其他位干啥的现在不关心
1 | mov eax,cr0 ;cr0放到eax |
此后CPU就工作在保护模式了
32位机器上的段寄存器
高16位是段选择子,对外可见,并且兼容8086的段寄存器用法
描述符高速缓存器不可见,存放段基地址,段界限,段属性
为啥叫缓存器呢?
32位实模式段寄存器用法
8086实模式下,段寄存器中直接放段基址,段寄存器就是16位,没有描述符高速缓存器这种东西,寻址的时候就段寄存器×16+偏移量
而32位机器的实模式,前16位和8086的段寄存器作用相同,但是有高速缓存器这种东西
它缓存了个啥呢?寻址的时候不是要段寄存器×16吗,高速缓存器就缓存了这个值(聊胜于无吧)
给段寄存器赋值的时候就把该值✖16然后放到高速缓存器中了
对外表现仍然像8086的20位实模式,只不过由于高速缓存器的存在,速度更快了
32位保护模式段寄存器的用法
32位保护模式下,段寄存器CS,DS等等仍然是16位的,显然让他们继续保存段基址已经放不下了,他们确实也不再直接保存段地址,而是保存的段选择子,
段选择子是段描述符表的下标,
即用段选择子去查相应的段描述符表,得到的表项是段描述符,
段描述符中包含了段基址,界限,段类型等等各种信息
1 | 实模式:查段寄存器立刻获得段基址 |
高13位就是段描述符表中的下标,13位可以寻址8K条记录,这和段描述符表最大记录数量是一致的
再低一位是全局/局部 段描述表标志,如果是0则该选择子中的索引是全局描述符表的下标
如果是1则该选择子中的索引是局部描述符表的下标
最低的两位是请求特权级RPL,表示给出该选择子的程序的特权级
这里要区分段描述符中的DPL和段选择子的RPL
DPL表示的是该段的特权级
RPL表示需要访问该段的程序的特权级
高速缓存器的作用是啥呢?在段描述符表中也有段的基址,界限,属性,为啥又要在描述符高速缓存器中再写一遍?这就是"缓存"的作用.如果没有高速缓存寄存器,那么每次使用这个段,都需要用段选择字查段描述符表获得段描述符指定的基址和界限,这就涉及到内存访问了.如果第一次放问该段时查表获得了段基址,把他存到高速缓存器中,那么下一次使用这个段的时候,就不需要访问内存了.显然访问寄存器速度比访问内存快
保护模式下的内存访问
数据段当初是这样创建的
1 | ;创建#2描述符,保护模式下的数据段描述符(文本模式下的显示缓冲区) |
段基址指向0xb8000,即显存区域
mbr程序中,将ds寄存器置为数据段的选择子,数据段描述符在全局段描述符表的第3项,下标为2,权限为00,因此将0x10放到ds中作为段选择子
1 | flush: |
加载描述符高速缓存器
当mov ds,cx
这条改变段寄存器ds的指令执行之后,处理器会自动查GDT表获取段基址,界限,属性,填到高速缓存器中
这个过程用图表示为
用段寄存器中的索引值乘以8是因为,GDT表的表项8字节,加上GDTR中存放的GDT表基地址,就得到了相应表项的起始地址,从该段描述符中获取相关信息填到高速缓存器中
地址翻译
由于访问内存前首先要设置段寄存器,只要是使段寄存器发生变化的指令,比如mov,jmp far,call far等,都会导致处理器自动加载高速缓存器
那么当实际需要访问内存的时候,高速缓存器已经加载好了,基地址是0xb8000,
这时高速缓存器中的基址等信息和用选择子查GDT表获取到的基址等信息是相同的,因此不需要再查段地址了
mov byte [0x00],'P'
这条指令,默认使用ds指向的数据段,偏移量0x00,
寻址的时候只需要从ds段寄存器的描述符高速缓存器中,
把缓存好的数据段界限拿出来,和偏移量比一下,看看该偏移量是否越界了,如果没有则
把缓存好的数据段基址拿出来,加上该偏移量,得到32位线性地址0xb8000
地址总线上0x000b8000信号置高
然后把'P'的ASCII码放到数据总线上,置高
然后CPU发出内存写指令,'P'就写到内存的0x000b8000位置了,这个位置恰好又是显存映射区,因此直接输出到屏幕了
这里地址翻译的结果是"线性地址",不是物理地址,这是因为,如果使用了分页机制,那么该线性地址有可能不等于物理地址,其所在虚拟页号不一定等于物理页号
如果没有使用分页机制,那么可以说线性地址就是物理地址
取指过程也类似
清空流水线
在进入保护模式前,段寄存器以及高速缓存器已经有东西了,进入保护模式需要更新这些值.并且很多实模式的指令已经在流水线上了,进入保护模式后不再适用,需要清空流水线
使用远jmp或者远call,既可以更新段寄存器,又可以把流水线扬了
因此在设置PE位之后有一个jmp dword跳转
jmp dword 32位远跳转指令
1 | mov eax,cr0 ;cr0放到eax |
这里jmp dword 0x0008:flush
意思是跳转到一个32位地址,段选择子是0x0008,(即GDT索引为0x1,G=0,RPL=00)偏移量为flush
dword修饰意思是使用32位的偏移量,编译成的机器码带有前缀0x66,表示处理器会按32位的方式执行该指令
由于当前已经处于16位保护模式,又dword表明使用32位方式执行,因此cs的段选择子会被置为0x8,高速缓存器也会查GDT后填入0x7c00基址,0x1ff界限
flush就交给EIP寄存器
因为代码段可能有转移,刚才顺序执行的流水线无效了,全都扬了
然后[bits 32]是nasm伪指令,意思是后面的代码编译成32位模式
但是保护模式下不允许使用mov指令修改CS寄存器内容,就算用ax寄存器中转也白搭.
只是对于CS寄存器有这个限制,其他段寄存器没有限制
保护模式的栈
堆栈段描述符
GDT中堆栈段描述符长这样
1 | ;创建#3描述符,保护模式下的堆栈段描述符 |
线性基地址0
段界限0x7A00,最大7A00字节
粒度G=0字节
D=1,32位段,默认push压栈4个字节,使用esp(如果是D=0,16位,则默认push压栈字,使用sp)
S=1,数据段
P=1,在内存中
DPL=0,0环
TYPE=0010 可读写,向下生长
初始化堆栈
1 | mov cx,00000000000_11_000B ;加载堆栈段选择子 |
选择子意思是下标0x11=3,查全局段描述符表,0环权限
将该段选择子放到ss堆栈段寄存器,将引起处理器自动查GDT表获取段基址放到段描述符高速缓存器中
然后将esp置为0x7c00表示栈顶指针位置,
对于esp,有一个要求,esp>粒度×界限,也就是说esp最低要保证栈空间满足粒度呈×界限这么多,但是高不封顶
啥意思呢?你不是esp要比粒度×界限大吗,我大一个字节也是大,大10个字节也是大,esp顶到天上也是大
esp的变化方向将会是0x7c00->0
使用堆栈
1 | mov ebp,esp ;保存堆栈指针 |
ebp获得esp拷贝
push byte指令导致'.'压栈,但是实际压入栈中的是一个双字,esp会减4,在esp+1放上'.',在esp+2,esp+3,esp+4都放0
为了证实这一点,ebp直接-4,如果刚才的理论正确,则ebp此时应该等于esp,那么cmp指令将会把ZF=1置起来.那么jnz跳转不实现,那么将栈上刚压入的一个四字推给eax寄存器,esp+4恢复原样,然后al字节放到显存[0x1e]上打印句点到屏幕
也就是说只要是运行起来最后有句点,说明理论正确
运行结果确实有句点,证明push byte '.'
导致栈顶下降了4字节
调试
可能会预见的问题
每次虚拟机运行关闭之后,都会在虚拟硬盘目录下面产生一共.lock文件
只要是有这个东西下一次开机虚拟机准起不来
扬了就行了
观察处理器上电后的段寄存器状态
使用bochs调试运行nobody.vhd,bochs会自动在第一条指令指向前停下
此时用r观察所有寄存器状态
1 | <bochs:1> r |
除了程序计数器rip,其他寄存器全是0
使用sreg观察所有段寄存器状态
bochs可以观察高速缓存器的内容
dh,dl是段描述符的内容,显然此时还没有建立GDT,dh和dl的值是bochs根据高速缓存器的值造的
1 | <bochs:2> sreg |
只有cs段寄存器的基地址是0xf0000,其他都是0
lgdt之后全局gdtr寄存器的变化
lgdt以内存操作数的低16位为界限,高32位为基址,加载gdt表信息到gdtr寄存器
怎么观察这个事呢?
首先需要找到lgdt的地址,可以在0x7c00处下断点,按c执行到此,然后u/20反汇编20条指令,观察这些指令的地址
这就找到了lgdt的地址,然后再0x7c5f上下断点,按c执行到此
执行之前sreg打印一下gdtr的状态
1 | gdtr:base=0x00000000000f9ad7, limit=0x30 |
s单步执行之后再打印一下gdtr的状态
1 | gdtr:base=0x0000000000007e00, limit=0x1f |
使用xp/8 0x7e00观察GDT表
1 | <bochs:13> 0x/8 0x7e00 |
0x7e00处是全空的0下标段描述符,后面的段描述符是有实际意义的
置PE位后段寄存器的变化
通过设置CR0的PE位,处理器进入保护模式,段寄存器仍然保存了实模式下的内容,除非有修改段寄存器的指令
怎么观察这个事呢?
首先需要找到即将进入保护模式的指令
可以使用u/balabala 反汇编一坨指令找他
1 | 0000000000007c73: ( ): mov cr0, eax ; 0f22c0 |
在0x7c73下断点然后c执行到此
执行前后用sreg观察段寄存器是没有任何变化的
后面jmp dword远跳转就会改变段寄存器了,首先反汇编找到该远跳转的地址
1 | 0000000000007c76: ( ): jmpf 0x0008:0000007e ; 66ea7e0000000800 |
在0x7c76下断点然后c执行到此
执行前先sreg打印一下CS的状态
1 | cs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7 |
s单步执行后再sreg观察cs的状态
1 | cs:0x0008, dh=0x00409900, dl=0x7c0001ff, valid=1 |
此时dh,dl都指向了GDT中的信息,cs存放的是选择子
高速缓存器中的base和limit业已设置好了
观察控制寄存器CR0的变化
重新调试运行,找到设置PE位的指令
1 | 0000000000007c73: ( ): mov cr0, eax ; 0f22c0 |
在0x7c73下断点然后c运行到此
执行前creg打印一下CR0的状态
1 | <bochs:6> creg |
此时的pe=0表明处理器工作在实模式
然后s单步执行之后creg观察CR0
1 | <bochs:7> s |
果然PE=1了,表明处理器工作在保护状态