《真象还原》第4章笔记
4 保护模式入门
保护模式
为什么要有保护模式
- 安全原因
- 实模式下操作系统和用户程序属于同一特权极
- 用户程序引用的地址都指向真是物理地址
- 用户程序可以自由的修改段地址,可以自由改
- 使用原因
- 访问64kb就要切换地址
- 一次只能运行一个程序
- 内存最大只能用1mb,太小了
因此程序内部的地址(虚拟地址)需要被转化为物理地址后再去访问,程序对此一无所知。顺便说一句,地址转换是由处理器和操作系统共同协作完成的,处理器在硬件上提供地址转换部件,操作系统提供转换过程中所需要的页表。
实模式不是 32 位 CPU,变成了 16 位
- 实模式时,指的是 32 位的 CPU 运行在 16 位模式下的状态,不是 CPU 变身成的16位。
保护模式之寄存器扩展
32位寄存器在原本寄存器的情况
左边已经标注名字的寄存器有通用寄存器组,名字前统一加了字符 E 表示扩展
全局描述符表
- 为了更加安全,怎么也得多添加点约束条件才靠谱。这些“约束条件”便是对内存段的描述信息。一个寄存器放不下,所以得放到一个表里面
- 表里面有
段描述符
,大小为64字节,用来描述各个内存段的起始地址、大小、权限等信息 - 所以说现在段里面保存的不是段基址,叫
段选择子
。 - 问题
- 段描述符是在内存中,访问内存cpu来说属于比较慢的动作。
- 段描述符一个数据要分三个地方存。
段描述符缓冲寄存器
- 以后每次访问相同的段时,就直接读取该段寄存器对应的段描述符缓冲寄存器
- 为了解决每次从全局描述表里面拿东西速度太慢了
- 既然是缓存,就一定要有个失效时间。段描述符缓冲寄存器的失效时间是多少?
80286 虽然有了保护模式,但其依然是 16 位的 CPU,其通用寄存器还是 16 位宽。但其与 8086 不同的是其地址线由 20 位变为了 24 位,即寻址空间变成了 2 的 24 次方,等于 16MB 大小。
80286 的问题是:单独的一个寄存器无法访问到全部内存空间,也就是若用寄存器存储段内偏移地址,只能访问到 64KB 大小的段。
所以说没啥意义,80386就把地址线改为32位了。
CPU 有三种模式:实模式、虚拟 8086 模式、保护模式。(虚拟 8086 模式是为了向下兼容)
保护模式之寻址扩展
实模式内存寻址
1
2
3
4
5
6
7mov ax,[si]
mov ax,[di]
mov ax,[bx]
mov ax,[bx+si]
mov ax,[bx+si+0x1234]
mov ax,[bx+di]
mov ax,[bx+di+0x1234]模式下对于内存寻址来说,其中的基址寻址、变址寻址、基址变址寻址
这三种形式中的基址寄存器只能是 bx、bp,变址寄存器只能是 si、di在保护模式中
两种寻址方式不同
在保护模式下,所有 32 位的通用寄存器,变址寄存器也是一样,(不止是bx,bp,si,di)。
1
2
3mov eax,[eax+edx*8+0x12345678]
mov eax,[eax+edx*2+0x8]
mov eax,[ecx*4+0x1234]虽然 esp 无法用作变址寄存器,但其可用于基址寄存器。所以,如下代码是正确的
1
2mov eax,[esp]
mov eax,[esp+2]
保护模式之 运行模式反转
- cpu为了兼顾这两种模式
- 在实模式下,cpu并不是变成了纯粹的16位cpu。
- 由于保护模式中的寻址方式和操作数类型同实模式下完全不同,故相应的编码也不同。比如在实模式下,用二进制 010 表示 dx 寄存器,在保护模式下的010 就表示 edx 寄存器(根据编码确定指令、寻址方式、寄存器,这是译码器的工作)。
- 好处坏处
- cpu同时支持16和32指令
- cpu不知道你要16位还是32位指令
- 避免cpu不知道(提供伪指令
bits
,来告诉cpu在那个模式下完成)
Bits伪指令
- 格式:
1
2[bits 16] ;下面的代码帮我编译成 16 位的机器码
[bits 32] - 使用情况:您清楚所写的代码是运行在哪种模式下,您需要向编译器明确指出将其编译成哪种模式的机器码。(由于人很清楚进入保护模式没有,cpu不知道)
- 进入保护模式步骤
- 打开A20
- 加载gdt
- 将cr0的pe位置1。
- 但是编译器进去保护模式可以依照步骤也可以不按顺序,非常自由,所有cpu不知道,需要bits伪指令。
- 代码
1
2
3
4
5
6
7[bits 16]
mov ax, 0x1234
mov dx, 0x1234
[bits 32]
mov eax, 0x1234
mov edx, 0x1234
指令 | 机器码 |
---|---|
[bits 16] | 伪指令,无机器码 |
mov ax, 0x1234 | B83412 |
[bits 32] | 伪指令,无机器码 |
mov eax, 0x1234 | B834120000 |
第 2 行,在 16 位模式,mov ax, 0x1234 的机器码 B83412,第 5 行 32 位模式下 mov eax, 0x1234,其机器码是 B834120000
反转前缀
80286 提供了反转前缀,用来解决保护模式与实模式指令编码冲突的问题。反转前缀为 66H,当 80286 处于保护模式时,66H 反转指令中的寄存器字段,使其成为 32 位寄存器。当 80286 处于实模式时,66H 不起作用,指令中的寄存器字段保持不变,依然为 16 位寄存器。
前缀
指里面存放的是指令选项之类的东东,比如指令重复前缀 rep、段跨越前缀“段寄存器:”,还有咱们马上要介绍的操作数反转前缀
0x66
和寻址方式反转前缀
0x67
0x66 反转前缀
1
2
3
4
5
6
7[bits 16]
mov ax, 0x1234
mov eax, 0x1234
[bits 32]
mov ax, 0x1234
mov eax, 0x1234指令 机器码 [bits 16] 伪指令,无机器码 mov ax, 0x1234 B83412 mov eax, 0x1234 66 B834120000 [bits 32] 伪指令,无机器码 mov ax, 0x1234 66 B83412 mov eax, 0x1234 B834120000 - 寻址方式反转前缀 0x67
1
2
3
4
5
6
7
8[bits 16]
mov word [bx], 0x1234
mov word [eax], 0x1234
mov dword [eax], 0x1234
[bits 32]
mov dword [eax], 0x1234
mov word [eax], 0x1234
mov dword [bx], 0x1234
指令 机器码 [bits 16] 伪指令,无机器码 mov word [bx], 0x1234 C7073412 mov word [eax], 0x1234 67 C70E3412 mov dword [eax], 0x1234 6667 C70534120000 [bits 32] 伪指令,无机器码 mov dword [eax], 0x1234 C70534120000 mov word [eax], 0x1234 66 C70E3412 mov dword [bx], 0x1234 67 C70734120000 - 为什么实例中用了 eax 和 bx 两种寄存器,而不是 ebx 和 bx?
- 因为实模式下的基址只有用寄存器 bx、bp。
保护模式之指令扩展
首先加减乘除
- add
1
2
3add al, cl ;支持 8 位操作数
add ax, cx ;支持 16 位操作数
add eax, ecx ;支持 32 位操作数 - sub
1
2
3sub al, cl ;支持 8 位操作数
sub ax, cx ;支持 16 位操作数
sub eax, ecx ;支持 32 位操作数 - inc
1
2
3inc al ;支持 8 位操作数
inc ax ;支持 16 位操作数
inc eax ;支持 32 位操作数 - dec
1
2
3dec al ;支持 8 位操作数
dec ax ;支持 16 位操作数
dec eax ;支持 32 位操作数
- add
并不是所有的指令都要支持以上 3 种宽度的操作数,比如对于 loop 指令,实模式下要用 cx 寄存器来存储循环次数,在保护模式下,要用 ecx。
imul && mul
mul指令是无符号数相乘指令
如果乘数是 8 位,则把寄存器 al 当作另一个乘数,结果便是 16 位,存入寄存器 ax。
如果乘数是 16 位,则把寄存器 ax 当作另一个乘数,结果便是 32 位,存入寄存器 eax。
如果乘数是 32 位,则把寄存器 eax 当作另一个乘数,结果便是 64 位,存入 edx:eax,其中 edx 是积的高 32 位,eax 是积的低 32 位。
1
2
3imul eax, ebx, 10 ;将 ebx 乘以 10,结果存入 eax
imul eax, 10 ;将 eax 乘以 10,结果存入 eax
imul eax ;将 eax 乘以自身,结果存入 eaxpush (在 16 位的实模式下,CPU照样可以处理 32 位的数据)
- 操作类型
- 立即数(可以压入8位,16位,32位)
- 寄存器
- 内存
16push.S
- 操作类型
1 |
|
在实模式上
下一条指令 | 下一条的指令的机器码 | 当前esp值 |
---|---|---|
mov sp,0x0900 | BC0090 | 0x00007c00 |
push 0x0007 | 6A07 | 0x00000900 |
push 0x0008 | 6A08 | 0x000008fe |
push 0x00000009 | 666a09 | 0x000008fc |
jmp -2 | EBFE | 0x000008f8 |
第 1 行的当前 esp 是 0x7c00。为了更清楚地观察到栈指针变化,准备用 mov sp, 0x900 指令将 sp 赋值为 0x900。所以,在第 2 行的“当前 esp 值”列已经更新成了 0x900
第 2 行的下一条指令是 push 0x0007,其操作码是 0x6a,这是压入一个字的操作码。大伙儿可以对照下源文件 16push.S 的第 3 行,原本是 push byte 0x7。可见,如前所述,CPU 并不是真地压入 1 字节。byte 并不是 CPU的指令,而是编译器提供的伪指令,它给编译器指出数据的宽度。第 3 行的“当前 esp 值”列,其值为 0x8fe,这正是 push 0x0007 对 sp 指针的影响,0x900-0x8fe=2,可见,sp 的值减了 2,即向栈中压入了 2 字节的数据。
32push.S
1 |
|
mov cr0, eax
之前都是进入保护模式。
表 4-5 保护模式下 push 指令操作数
下一条指令 | 下一条的指令的机器码 | 当前 esp 值 |
---|---|---|
push 0x00000007 | 6a07 | 0x00000900 |
push 0x0008 | 666a08 | 0x000008fc |
push 0x00000009 | 6a09 | 0x000008fa |
jmp .-2 | ebfe | 0x000008f6 |
这个指令有点没看懂,以后在看吧🥵
全局描述符
段描述符
在保护模式下,真正的地址存储在内存下。既然内存很大,不如多放点信息。
实模式下存在问题
- 用户程序可以破坏存储代码的内存区域,要添加内存段属性来阻止这种行为。
- 用户程序和操作系统是同一级别的,所有要添加特权级来区分用户程序和操作系统的地位。
- 访问内存段的必要属性条件
- 内存段是一片内存区域,访问内存就要提供段基址,所以要有段基址属性。
- 约束条件
- 段描述符的结构
段描述符是 8 字节大小,认为分成来低 32 位和高 32 位
(保护模式下地址总线宽度是 32 位,段基址需要用 32 位地址来表示。)
内存段
段界限
- 表示段边界的扩展最值,即最大扩展到多少或最小扩展到多少。
- 扩展方向只有上下两种。对于数据段和代码段,段的扩展方向是向上,即地址越来越高。
- 段界限用 20 个二进制位来表示,单位是用字节,所以最小的大小是4kb(一个字节8个0000-0000,2的32次方),
上面所说的 1MB 和 4GB 只是个范围,并不是具体的边界值。由于段界限只是个偏移量,是从 0 算起的,所以实际的段界限边界值=
(描述符中段界限+1)*(段界限的粒度大小:4KB 或者 1)-1。
全局描述符表 GDT、局部描述符表 LDT 及选择子
- 全局描述符表 GDT
一个段描述符只用来定义一个内存段。代码段要占用一个段描述符,数据段和栈段。
格式
1
lgdt48 位内存数据
- 48位内存数据分为两部分,前面16位是GDT以字节为单位的界限值。后 32 位是 GDT 的起始地址。
- 由于 GDT 的大小是 16 位二进制,其表示的范围是 2的16次方等于65536字节。每个描述符大小是8字节,故,GDT中最多可容纳的描述符数量是65536/8=8192个,即 GDT 中可容纳 8192 个段或门。
- 段的选择子
实模式:段中存储的是段基地址,即内存段的起始地址。
在保护模式里:由于段基址已经存入了段描述符中,所以段寄存器存放的是
段选择子
(selector)。选择子基本上是索引值。由于段寄存器是 16 位,所以选择子也是 16 位。
- 前两位(0~1位)用来储存RPL——请求特权级。(分0,1,2,3,四个特选极)
- 第2位是TI位,0是GDT中索引描述符,TI为1表示LDT中索引描述符
- (3~15位)是描述符的索引值,用此值在 GDT 中索引描述符
由于选择子的索引值部分是 13 位,即 2 的 13 次方是 8192,故最多可以索引 8192 个段,这和 GDT中最多定义 8192 个描述符是吻合的。
- 选择子的作用是确定段描述符,确定段描述符的目的,一是特权级和段基地址。
例如选择子是 0x8,将其加载到 ds 寄存器后,访问
ds:0x9
这样的内存,其过程是:0x8 的低 2 位是RPL,其值为 00。第 2 是 TI,其值 0,表示是在 GDT 中索引段描述符。用 0x8 的高 13 位 0x1 在 GDT 中索引,也就是 GDT 中的第 1 个段描述符(GDT 中第 0 个段描述符不可用)。假设第 1 个段描述符中的 3个段基址部分,其值为 0x1234。CPU 将 0x1234 作为段基址,与段内偏移地址 0x9 相加,0x1234+0x9=0x123d。用所得的和 0x123d 作为访存地址。
打开 A20 地址线
实模式下地址回绕
由于实模式下地址线是20位,最大地址是1MB,但是“段基址:段内偏移地址”的形式的最大地址是:0xFFFF:0xFFFF(0x10FFEF)。
但物理内存中却没有与之对应的部分。为了让“段基址:段内偏移地址”策略继续可用,CPU 采取的做法是将超过 1MB 的部分自动回绕到 0 地址(相当于把地址对 1MB 求模)
地址回绕是如何做到的
对于只有 20 位地址线的 CPU
- 其实不需要什么操作就可以实现地址回绕。其实就是进位丢掉🤣
对于8286后续的CPU,通过A20GATE 来控制 A20 地址线。
- 由于80286有24跟地址线,如果访问 0x100000~0x10FFEF 之间的内存,系统将直接访问这块物理内存,并不会像 8086/8088 那样回绕到 0。
- A20Gate :IBM 在键盘控制器上的一些输出线来控制第 21 根地址线(A20)的有效性
- 如果A20Gate打开,就访问CPU真实物理地址,如果关闭,就绕回。
- A20Gate开启方式:将端口 0x92 的第 1 位置 1
1
2
3in al, 0x92
or al,0000_0010B
out 0x92,al - 由于在键盘控制器上,所以用
in
,out
保护模式的开关,CR0 寄存器的 PE 位
我们要用到 CR0寄存器的第 0 位,即 PE 位,Protection Enable,此位用于启用保护模式,是保护模式的开关,当打开此位后,CPU 才真正进入保护模式,所以这是进入保护模式三步中的最后一步。
PE 为 0 表示在实模式下运行,PE 为 1 表示在保护模式下运行。所以,我们的任务是将此位置 1。
1 |
|
终于进入保护模式了!
第一个是 mbr.S,由于 loader.bin 超过了 512 字节,所以我们要把 mbr.S 中加载 loader.bin 的读入扇区数增大,目前它是 1 扇区,为了避免将来再次修改,直接改成读入 4 扇区。
1 |
|
project/c4/a/boot/mbr.S
52 mov cx,4 ; 待读入的扇区数
53 call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区)
boot.inc文件也要改,下面是增加部分
1 |
|
在保护模式中,我们还是学习 Linux 等主流操作系统的内存段,用平坦模型。
平坦模型在我们定义的描述符中,段基址是 0,段界限*粒度等于
4G。粒度我们选的是 4k,故段界限是 0xFFFFF。
loader.S 文件保护模式
1 |
|
刷新流水线。这是啥意思呢?
流水线是 CPU 为提高效率而采取的一种工作方式,CPU 将当前指令及其后面的几条指令同时放在流水线中重叠执行。
处理器微架构简介
流水线
如果改为并行的方式砸钉子,效率必然大大提高。
取钉子的方式,那么对于编译7️⃣
乱序执行
乱序执行,是指在 CPU 中运行的指令并不按照代码中的顺序执行,而是按照一定的策略打乱顺序执行,也许后面的指令先执行,当然,得保证指令之间不具备相关性
比如说下面就没法打乱顺序,因为要知道eax才能进行add加法。
1 |
|
修改一下
1 |
|
这样就可以 乱序执行了,因为ecx和eax之间没有依赖关系。
拿保护模式中的栈来说,push eax 相当于:
1 |
|
push 指令先将栈指针 esp 减去操作数的字长,如 sub esp,4。
再将操作数 mov 到新的 esp 指向的地址,如 mov [esp],eax。
这两个子操作合成了一个指令,其中每一个子操作称为微操作。
有20%的指令占80%的程序指令数(那里都有82定律)。这些基本都不可再分的指令叫做微操作级别指令
缓存
缓存原理是存取速度较快的设备作为数据缓冲区,避免频繁操作速度较慢的设备,从而提高效率。
原因是低速存储设备是整个系统的瓶颈,缓存用来缓解“瓶颈设备”的压力。
这些缓存都是在内存 DRAM 中实现的,即动态随机访问存储器
这些数据要么在数据库里面,要么在硬盘上,速度肯定比内存慢,所以用内存来缓存这些数据,提高效率。
CPU 为什么要用缓存?因为待执行的指令和相关数据存储在低速的内存中,让 CPU 这种高速设备等待慢速的内存,着实太浪费 CPU 资源了。所以需要用一个比内存更快的存取设备做缓冲区,尽量和 CPU 一个速度,让 CPU 不要等待。于是 SRAM 成了 CPU的救世主,成为 CPU 和内存之间数据缓存的不二之选。
分支预测
分支预测,是指 CPU 预测下一条指令的地址,并提前将指令取到指令缓存中,从而提高指令的执行效率。
比如说c语言里的if for这种语句编译器会编译成汇编代码后,在汇编一级来说,这些结构使用跳转指令来实现的。
1 |
|
gcc -S -o ~/test/while.S ~/test/while.c 回车,
这个生成的汇编语言并不是我们熟悉的 Intel 语法,而是 AT&T 语法。
预测方法:最简单的是使用2位预测法。
使用远跳转指令清空流水线,更新段描述符缓冲寄存器
1 |
|
在mbr.S里面,进入保护模式的时候要用这个刷新流水线。为什么要刷新流水线呢?
- 段描述符缓冲寄存器没有刷新,进入保护模式要用jmp刷新。
- 由于实模式下的段基址只有 20 位,所以段描述符缓冲寄存器中的低 20 位有效,用于存储段基址,其他位都为 0.
- 流水线中指令译码错误
- 如果未使用 bits 伪指令来设置运行环境,编译器就将代码按照 16 位实模式编译。
- [bits 32]会让之后变成32编译的格式,否则默认是16位编译的格式。流水线预计的还是使用16位的操作码来搞的。
保护模式之内存段的保护
向段寄存器加载选择子时的保护
当引用一个内存段时,实际上就是往段寄存器中加载个选择子,为了避免出现非法引用内存段的情况,在这时候,处理器会在以下几方面做出检查。
- 检测选择子的值验证段描述符是否超越界限
选择子的高 13 位是段描述符的索引值,第 0~1 位是 RPL,第 2 位是 TI 位。
- 的索引值一定要小于等于描述符表(GDT 或 LDT)中描述符的个数.
处理器先检查 TI 的值,如果TI 是 0,则从全局描述符表寄存器 gdtr 中拿到 GDT基地址和 GDT 界限值。如果 TI 是 1,则从局部描述符表寄存器 ldtr 中拿到 LDT 基地址和 LDT 界限值。有了描述符表基地址和描述符表界限值后,把选择子的高 13 位代入上面的表达式,若不成立,处理器则抛出异常。过程如图 4-16 所示。
2. 检查段的类型
主要是检查段寄存器的用途和段类型是否匹配。
- 如果段寄存器是 cs,则段类型必须是代码段。
- 只具备执行属性的段(代码段)不允许加载到除 CS 外的段寄存器中
- 如果段寄存器是 ds、es、fs、gs,则段类型必须是数据段。(可读属性)
- 如果段寄存器是 ss,则段类型必须是堆栈段。(可写属性)
代码段和数据段的保护
实际的段界限的值:
(描述符中段界限+1)*(段界限的粒度大小:4k 或者 1)-1。
偏移地址+数据长度-1≤实际段界限大小