操作系统真象还原<第二部分>

(。・∀・)ノ゙嗨起来!!!

保护模式

实模式的缺点:

  1. 操作系统和用户程序处于同一特权级。
  2. 用户程序所引用的地址都是指向真实的物理地址。
  3. 用户程序可以自行修改段基址,可以访问所有内存。
  4. 访问超过 64KB 的内存区域要切换段基址。
  5. 一次只能运行一个程序。
  6. 共 20 条地址线,最大可用内存 1 MB 。

寄存器扩展

各寄存器在原有 16 位的基础上,再次向高位扩展了 16 位,成为了 32 位寄存器。经过 extend 后的寄存器,统 一在名字前加了 e 表示扩展。

image-20201016212543280

段描述符缓冲寄存器(Descriptor Cache Registers) : ,在 80286 的保护模式中,为了提高获取段信息的效率,对段寄存器率先应用了缓存技术,将段信息用一个寄存器来缓存。CPU 每次将千辛万苦获取到的内存段信息,整理成“完整的、通顺、不蹩脚”的形式后,存入段描述符缓冲寄存器, 以后每次访问相同的段时,就直接读取该段寄存器对应的段描述符缓冲寄存器。

段描述符缓冲寄存器结构

image-20201016212645319

保护模式之寻址扩展

image-20201016213443567

段描述符

段描述符格式

image-20201016214958082

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
段描述符的低32位 
: 0-15 段界限的前 0~15 位
: 16-31 存储段基址的 0~15 位
段描述符的高32位
: 0-7 段基址的 16~23
: 8-11 type字段 -> 描述符的类型
: 12 S字段 -> 是否是系统段
: 13-14 DPL(Descriptor Privilege Level)描述符特权级 -> 有 0、1、2、3 级特权,数字越小,特权级越大
: 15 P字段(Present) -> 段是否存在
: 16-19 段界限的第 16~19 位
: 20 AVL字段 -> 操作系统可以随意用此位(馈赠)
: 21 L字段 -> 是否设置64位代码段
: 22 D/B字段 -> 指示有效地址(段内偏移地址)及操作数的大小
: 23 G字段 -> 指定段界限的单位大小
: 24-31 是段基址的第 24~31 位

继续解释下字段。( 需要的时候来查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
高32位 0-7位是段基址的16-23 + 24-31位是段基址的24-31位 + 低32位 16-31位是段基址0-15位 == 32位基地址。

高32位 16-19位是段界限的16-19 + 低32位 16-31位是段界限0-15 == 20位的段界限。

高32位 8-11位是 type 字段,共4位,用来指定本描述符的类型。(用于表示内存段或门的子类型)

S字段 : CPU 眼里分为两大类,要么描述的是系统段,要么描述的是数据段,这是由段描述符中的 S 位决定的,用它指示是否是系统段。在 CPU 眼里,凡是硬件运行需要用到的东西都可称之为系统,凡是软件(操作系统也属于软件,CPU 眼中,它与用户程序无区别)需要的东西都称为数据,无论是代码,还是数据,甚至包括栈,它们都作为硬件的输入,都是给硬件的数据而已,所以代码段在段描述符中也属于数据段(非系统段)。S 为 0 时表示系统段,S 为 1 时表示数据段。type 字段是要和 S 字段配合在一起才能确定段描述符的确切类型,只有 S 字段的值确定后,type 字段的值才有具体意义。

DPL字段 : 这是保护模式提供的安全解决方案,将计算机世界按权力划分成不同等级,每一种等级称为一种特权级。由于段描述符用来描述一个内存段或一段代码的情况(若描述符类型为“门”),所以描述符中的 DPL是指所代表的内存段的特权级。这两位能表示 4 种特权级,分别是 0、1、2、3 级特权,数字越小,特权级越大。特权级是保护模式下才有的东西,CPU 由实模式进入保护模式后,特权级自动为 0。因为保护模式下的代码已经是操作系统的一部分啦,所以操作系统应该处于最高的 0 特权级。用户程序通常处于 3 特权级,权限最小。某些指令只能在 0 特权级下执行,从而保证了安全。

P字段 : 段是否存在。如果段存在于内存中,P 为 1,否则 P 为 0。P 字段是由 CPU 来检查的,如果为 0,CPU 将抛出异常,转到相应的异常处理程序,此异常处理程序是咱们来写的,在异常处理程序处理完成后要将 P 置 1。也就是说,对于 P 字段,CPU 只负责检查,咱们负
责赋值。不过在通常情况下,段都是在内存中的。当初 CPU 的设计是当内存不足时,可以将段描述符中对应的内存段换出,也就是可以把不常用的段直接换出到硬盘,待使用时再加载进来。但现在即使内存不足时,也没有将整个段都换出去的,现在基本都是平坦模型,一般情况下,段都要 4GB 大小,换到硬盘不也是很占空间吗?而且这些平坦的段都是公用的,换出去就麻烦啦。所以这些是未开启分页时的解决方案,保护模式下有分页功能,可以按页(4KB)的单位来将内存换入换出。

AVL字段 : 从名字上看它是 AVaiLable,可用的。不过这“可用的”是对用户来说的,也就是操作系统可以随意用此位。对硬件来说,它没有专门的用途,就当作是硬件给软件的馈赠吧。

L字段 : 用来设置是否是 64 位代码段。L 为 1 表示 64 位代码段,否则表示 32位代码段。这目前属于保留位,在我们 32 位 CPU 下编程,将其置为 0 便可。

D/B字段 : 用来指示有效地址(段内偏移地址)及操作数的大小。有没有觉得奇怪,实模式已经是 32 位的地址线和操作数了,难道操作数不是 32 位大小吗?其实这是为了兼容 286 的保护模式,286 的保护模式下的操作数是 16 位。既然是指定“操作数”的大小,也就是对“指令”来说
的,与指令相关的内存段是代码段和栈段,所以此字段是 D 或 B。

对于代码段来说,此位是 D 位,若 D 为 0,表示指令中的有效地址和操作数是 16 位,指令有效地址用 IP 寄存器。若 D 为 1,表示指令中的有效地址及操作数是 32 位,指令有效地址用 EIP 寄存器。对于栈段来说,此位是 B 位,用来指定操作数大小,此操作数涉及到栈指针寄存器的选择及栈的地址上限。若 B 为 0,使用的是 sp 寄存器,也就是栈的起始地址是 16 位寄存器的最大寻址范围,0xFFFF。
若 B 为 1,使用的是 esp 寄存器,也就是栈的起始地址是 32 位寄存器的最大寻址范围,0xFFFFFFFF。段描述符的第 23 位是 G 字段,Granularity,粒度,用来指定段界限的单位大小。所以此位是用来配合段界限的,它与段界限一起来决定段的大小。若 G 为 0,表示段界限的单位是 1 字节,这样段最大是 2的 20 次方*1 字节,即 1MB。若 G 为 1,表示段界限的单位是 4KB,这样段最大是 2 的 20 次方*4KB 字节,即 4GB。

image-20201017005810206

TYPE:
位0:A(accessed)位,表明描述符是否已被访问;把选择子装入段寄存器时,该位被标记为1
位3:E(EXECUTABLE?)位,0说明所描述段为数据段;1为可执行段(代码段)

当为数据段时,
位1为W位,说明该数据段是否可写(0只读,1可写)
位2为ED位,说明该段的扩展方向(0向高位扩展,1向低位扩展)
当为可执行段是,
位1为R位,说明该执行段是否可读(0只执行,1可读)
位2为C位,0说明该段不是一致码段(普通代码段),1为一致码段

全局描述符表 GDT、局部描述符表 LDT 及选择子

全局描述符表GDT(Global Descriptor Table):在整个系统中,全局描述符表GDT只有一张(一个处理器对应一个GDT),GDT可以被放在内存的任何位置,但CPU必须知道GDT的入口,也就是基地址放在哪里,Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存中某个位置之后,可以通过LGDT指令将GDT的入口地址装入此寄存器,从此以后,CPU就根据此寄存器中的内容作为GDT的入口来访问GDT了。GDTR中存放的是GDT在内存中的基地址和其表长界限。

image-20201016234534734

不过,对此寄存器的访问,不能够用 mov gdtr,xxx 这样的指令为 gdtr 初始化,有专门的指令来做这件事,这就是 lgdt 指令。虽然我们是为了进入保护模式才讲述的 lgdt,因此看上去此指令是在实模式下执行的,但实际上,此指令在保护模式下也能够执行。言外之意便是进入保护模式需要有 GDT,但进入保护模式后,还可以再重新换个 GDT 加载。在保护模式下重新换个 GDT 的原因是实模式下只能访问低端 1MB 空间,所以 GDT 只能位于 1MB 之内。根据操作系统的实际情况,有可能需要把 GDT 放在其他的内存位置,所以在进入保护模式后,访问的内存空间突破了 1MB,可以将 GDT 放在合适的位置后再重新加载进来。

段选择子包括三部分:描述符索引(index)、TI(指示从GDT还是LDT中找)、请求特权级(RPL)。

  1. index部分表示所需要的段的描述符在描述符表的位置,由这个位置再根据在GDTR中存储的描述符表基址就可以找到相应的描述符gdt_entry。然后用描述符gdt_entry中的段基址SEL加上逻辑地址OFFSET就可以转换成线性地址SEL:OFFSET(看下面给的例子应该就是它们的和SEL+OFFSET)

  2. 段选择子中的TI值只有一位0或1,0代表选择子是在GDT选择,1代表选择子是在LDT选择。

  3. 请求特权级(RPL)则代表选择子的特权级,共有4个特权级(0级、1级、2级、3级),0级最高。关于特权级的说明:任务中的每一个段都有一个特定的级别。每当一个程序试图访问某一个段时,就将该程序所拥有的特权级与要访问的特权级进行比较,以决定能否访问该段。系统约定,CPU只能访问同一特权级或级别较低特权级的段

    image-20201017002922334

局部描述符表LDT(Local Descriptor Table) : 局部描述符表可以有若干张,每个任务可以有一张。我们可以这样理解GDT和LDT:GDT为一级描述符表,LDT为二级描述符表。LDT和GDT从本质上说是相同的,只是LDT嵌套在GDT之中。LDTR记录局部描述符表的起始位置,与GDTR不同LDTR的内容是一个段选择子。由于LDT本身同样是一段内存,也是一个段,所以它也有个描述符描述它,这个描述符就存储在GDT中,对应这个表述符也会有一个选择子,LDTR装载的就是这样一个选择子。LDTR可以在程序中随时改变,通过使用lldt指令。

现在来梳理下保护模式下的寻址方式

  1. 段寄存器存放段选择子;
  2. CPU 根据段选择子从GDT中找到对应段描述符;
  3. 从段描述符中取出段基址。
  4. 根据之前的公式,结合段基址和段内偏移,计算出物理地址。

image-20201017004131211 image-20201016213941806

1
2
例如选择子是 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 作为访存地址。
值得注意的是上面括号中提到了 GDT 中的第 0 个段描述符是不可用的,原因是定义在 GDT 中的段描述符是要用选择子来访问的,如果使用的选择子忘记初始化,选择子的值便会是 0,这便会访问到第 0 个段描述符。为了避免出现这种因忘记初始化选择子而选择到第 0 个段描述符的情况,GDT 中的第 0 个段描述符不可用。也就是说,若选择到了 GDT 中的第 0 个描述符,处理器将发出异常。

打开 A20 地址线

地址(Address)线从 0 开始编号,在 8086/8088 中,只有 20 位地址线,即 A0~A19。20 位地址线表 示的内存是 2 的 20 次方,最大是 1MB,即 0x0~0xFFFFF。内存若超过 1MB,是需要第 21 条地址线支 持的。所以说,若地址进位到 1MB 以上,如 0x100000,由于没有第 21 位地址线,相当于丢掉了进位 1, 变成了 0x00000。

image-20201017035122422

对于 80286 后续的 CPU,通过 A20GATE 来控制 A20 地址线。

IBM 在键盘控制器上的一些输出线来控制第 21 根地址线(A20)的有效性,故被称为 A20Gate。 如果 A20Gate 被打开,当访问到 0x100000~0x10FFEF 之间的地址时,CPU 将真正访问这块物理内存。如果 A20Gate 被禁止,当访问 0x100000~0x10FFEF 之间的地址时,CPU 将采用 8086/8088 的地址回绕。 上面描述了地址回绕的原理,但地址回绕是为了兼容 8086/8088 的实模式。如今我们是在保护模式下, 我们需要突破第 20 条地址线(A20)去访问更大的内存空间。而这一切,只有关闭了地址回绕才能实现。 而关闭地址回绕,就是上面所说的打开 A20Gate。

打开 A20Gate 的方式是 将端口 0x92 的第 1 位置 1 就可以了

1
2
3
in al,0x92
or al,0000_0010B
out 0x92,al

保护模式的开关,CR0 寄存器的 PE 位

image-20201017035748978

image-20201017035808867

当打开 CR0 寄存器的 PE 位后,CPU 才真正进入保护模式,所以这是进入保护模式三步中的最后一步。

PE 为 0 表示在实模式下运行,PE 为 1 表示在保护模式下运行。

1
2
3
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

保护模式内存管理

IA-32架构内存管理机制分两部分:分段机制和分页机制。分段机制使代码段、数据段和堆栈段相互独立,这样多任务才可以在一个处理器上运行还不会被其它程序干扰。分页机制为传统需求页、虚拟内存系统提供了实现机制。其中虚拟内存系统用于实现程序代码按需映射到物理内存中。分页也使多任务之前相关隔离。在保护模式下,分段机制是必须实现的,没有模式位用来关键分段;但是分页机制是可选的。

这两种机制可以支持单任务系统、多任务系统或者支持共享内存的多处理器系统。如下图分段制机制提供处理器可寻址内存空(线性地址)到段地址空间的地址变换。段可用来存放数据、代码、堆栈以及系统数据结构TSS和LDT。假如一个处理器上有多个任务(程序)同时运行,那么每个任务都有自己段集。然后处理器可以加强这些段之前的界线,并且确保一个程序不会通过写入另一个程序的段而干扰程序的执行。段机制对段进行了分类,这样对特别类型的段的访问能够受限制。

image-20201020154053429

一个系统中的所有段都在处理器的线性地址空间中。为了定位段中的一个字节,必须提供逻辑地址(logical address,也叫远指针)。一个逻辑地址由段选择符和偏移值组成。段选择符唯一标志一个段,除此之外,段选择符还提供描述符表(比如GDT)中称为段描述符的数据结构的偏移地址。每一个段都有一个段描述符,它用来指定段的大小,访问权限,段的特权级,段类型和线性地址空间中的段基地址。逻辑地址中的偏移地址加上段基地址就可以定位段中的一个字节。所以基地址加上偏移地址就形成了处理器线性地址空间中的线性地址(linear address)。

假如没有启用分页,那么处理器的线性地址就直接映射到物理地址空间。物理地址空间的大小范围由处理器的数据总线位数决定。

因为多任务系统定义的线性地址空间通常比实际物理内存大,所以需要某种虚拟化线性地址空间的方法。虚拟化线性地址空间就是通过处理器的分页机制实现的。

分页支持的虚拟内存环境,需要由小块的物理内存(RAN或ROM)和一些硬盘存储介质来模拟大容量的线性地址空间。当使用分页时,每个段被分成很多页(典型的页大小为4KB),页存储在物理内存或者硬盘中。操作系统通过维护一个页目录和一组页表集来跟踪页。当一个程序尝试访问线性地址空间中的地址时,处理器通过页目录和页表把线性地址空间转换到物理地址空间中,然后在物理内存位置完成相应的操作请求(读或写)。

假如被访问的页不在当前物理内存中,那么处理器将中断当前程序执行(通过产生页错误异常),然后操作系统或执行指令从硬盘读取页到物理内存中并继续执行当前中断的程序。

当操作系统正确的完成分页时,硬盘和物理内存的交换对于程序正确的执行是透明的。

基本的平坦模型

系统最简单的内存模型称为“基本平坦模型”,这种模型下操作系统和应用程序都可以访问连续的、不分段的地址空间。对于系统设计者和应用程序开发者来说最大限度的隐藏了分段机制。

为了实现内存基本平坦模型,至少需要两个段描述符,一个用来指向代码段,另一个指向数据段,如下图所示。这两个段都映射到整个线性地址空间:也就是说,两个段描述符都有同样的基地址为0和同样的段限长4GBytes。通过设置4GBytes的段限长,当访问超过出了段限长分段机制也不会产生异常,甚至所访问的地址并没有物理内存。ROM(EPROM)通常过被放在物理内存最高地址处,因为处理器一开始执行FFFFFFF0H处RAM(DRAM)被放在地址空间最低处,因为在系统复位后DS数据段基地址被初始化为0。

image-20201020154412911

保护模式平坦模型

保护模式平坦模型和基本平坦模型类似,只不过段限长的范围设置成实际物理内存大小。

当访问实际不存在的物理地址空间时会生成一个普通的保护异常,如图8所示。这种模型提供了最低级别的硬件保护来访问一些程序bug。

这种保护模型平坦模型可以变得更复杂来提供更多保护。例如,为了分页机制中提供普通用户和超级用户的代码和数据,必须定义四个段:普通用户特权级为3的代码和数据段,超级用户特权级为0的代码和数据段。通常这些段相互重叠并且从线性地址0开始。这种平坦分段模型加上一个简单的分页结构就可以在应用程序和操作系统之间起保护作用,如果为每个任务增加一个单独的页结构,那么就可以给应用程序之前提供保护了。同样的设计被用于一些流行的多任务操作系统。

image-20201020154508729

多段模型

一个多段模型,充分发挥了段机制的对代码、数据结构和程序提供硬件保护的能力。每个程序都有自己的段描述符表和自己的段。段可以完全属于程序私有也可以和其它程序之前共享。

访问权限的检查不仅仅用来保护地址越界,也可以保护某一特定段不允许操作。例如代码段是只读段,硬件可以阻击向代码段进行写操作。为段建立的访问权限信息也可以用来建议保护级别。保护级别可以用来防止未认证的应用层程序访问操作系统程序

image-20201020154628781

进入保护模式

要进入保护模式,要完成 3 步:

  1. 设置 GDT 表。
  2. 打开 A20 地址线。
  3. 将 cr0 寄存器的 pe 位置 1 。

完善代码

首先我们需要增大 mbr.S 中加载 loader.bin 的读入扇区数。

loader.bin 是由 mbr.bin 中的函数 rd_disk_m_16 负责加载的,其参数“读入扇区数”存入 cx 寄存器中。 所以,如果 loader.bin 的大小超过 mbr 所读取的扇区数,切记一定要修改 mbr.S 中函数 rd_disk_m_16 的读 入扇区数。

修改后的 mbr.S -> 将扇区数修改为 4 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
 ;主引导程序
;------------------------------------------------------------
%include "boot.inc"
SECTION MBR vstart=0x7c00
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00
mov ax,0xb800
mov gs,ax

;清屏
;利用 0x06 号功能,上卷全部行,则可清屏
; -----------------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;------------------------------------------------------
;输入:
;AH 功能号= 0x06
;AL = 上卷的行数(如果为 0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角的(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值:
mov ax, 0600h
mov bx, 0700h
mov cx, 0 ; 左上角: (0, 0)
mov dx, 184fh ; 右下角: (80,25),
; 因为 VGA 文本模式中,一行只能容纳 80 个字符,共 25 行
; 下标从 0 开始,所以 0x18=24,0x4f=79
int 10h ; int 10h

; 输出字符串:MBR
mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4

mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4

mov byte [gs:0x04],'M'
mov byte [gs:0x05],0xA4
;A 表示绿色背景闪烁,4 表示前景色为红色

mov byte [gs:0x06],'B'
mov byte [gs:0x07],0xA4

mov byte [gs:0x08],'R'
mov byte [gs:0x09],0xA4

mov eax,LOADER_START_SECTOR ; 起始扇区 lba 地址
mov bx,LOADER_BASE_ADDR ; 写入的地址
mov cx,4 ; 待读入的扇区数
call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区)

jmp LOADER_BASE_ADDR

;-------------------------------------------------------------------------------
;功能:读取硬盘 n 个扇区
rd_disk_m_16:
;-------------------------------------------------------------------------------
; eax=LBA 扇区号
; bx=将数据写入的内存地址
; cx=读入的扇区数
mov esi,eax ;备份 eax
mov di,cx ;备份 cx
;读写硬盘:
;第 1 步:设置要读取的扇区数
mov dx,0x1f2
mov al,cl
out dx,al ;读取的扇区数

mov eax,esi ;恢复 ax

;第 2 步:将 LBA 地址存入 0x1f3 ~ 0x1f6

;LBA 地址 7~0 位写入端口 0x1f3
mov dx,0x1f3
out dx,al

;LBA 地址 15~8 位写入端口 0x1f4
mov cl,8
shr eax,cl
mov dx,0x1f4
out dx,al

;LBA 地址 23~16 位写入端口 0x1f5
shr eax,cl
mov dx,0x1f5
out dx,al

shr eax,cl
and al,0x0f ;lba 第 24~27 位
or al,0xe0 ; 设置 7~4 位为 1110,表示 lba 模式
mov dx,0x1f6
out dx,al

;第 3 步:向 0x1f7 端口写入读命令,0x20
mov dx,0x1f7
mov al,0x20
out dx,al

;第 4 步:检测硬盘状态
.not_ready:
;同一端口,写时表示写入命令字,读时表示读入硬盘状态
nop
in al,dx
and al,0x88 ;第 4 位为 1 表示硬盘控制器已准备好数据传输
;第 7 位为 1 表示硬盘忙
cmp al,0x08
jnz .not_ready ;若未准备好,继续等

;第 5 步:从 0x1f0 端口读数据
mov ax, di
mov dx, 256
mul dx
mov cx, ax
; di 为要读取的扇区数,一个扇区有 512 字节,每次读入一个字
; 共需 di*512/2 次,所以 di*256
mov dx, 0x1f0

.go_on_read:
in ax,dx
mov [bx],ax
add bx,2
loop .go_on_read
ret

times 510-($-$$) db 0
db 0x55,0xaa

接着需要修改 include/boot.inc 。里面是一些配置信息,loader.S 中用到的配置都是定义在 boot.inc 中的符号。

( \ 建议去掉,可能报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
 ;------------- loader 和 kernel ----------

LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2

;-------------- gdt 描述符属性 -------------
DESC_G_4K equ 1_00000000000000000000000b
DESC_D_32 equ 1_0000000000000000000000b
DESC_L equ 0_000000000000000000000b
;64 位代码标记,此处标记为 0 便可
DESC_AVL equ 0_00000000000000000000b
; CPU 不用此位,暂置为 0
DESC_LIMIT_CODE2 equ 1111_0000000000000000b
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2
DESC_LIMIT_VIDEO2 equ 0000_000000000000000b
DESC_P equ 1_000000000000000b
DESC_DPL_0 equ 00_0000000000000b
DESC_DPL_1 equ 01_0000000000000b
DESC_DPL_2 equ 10_0000000000000b
DESC_DPL_3 equ 11_0000000000000b
DESC_S_CODE equ 1_000000000000b
DESC_S_DATA equ DESC_S_CODE
DESC_S_sys equ 0_000000000000b
DESC_TYPE_CODE equ 1000_00000000b
;x=1,c=0,r=0,a=0 代码段是可执行的,非一致性,不可读,已访问位 a 清 0
DESC_TYPE_DATA equ 0010_00000000b
;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写,已访问位 a 清 0

DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + \
DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + \
DESC_P + DESC_DPL_0 + DESC_S_CODE + \
DESC_TYPE_CODE + 0x00
DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + \
DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + \
DESC_P + DESC_DPL_0 + DESC_S_DATA + \
DESC_TYPE_DATA + 0x00
DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + \
DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + \
DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_D ATA + 0x00

;-------------- 选择子属性 ---------------
RPL0 equ 00b
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 000b
TI_LDT equ 100b

主要是新增段描述符的属性及选择子,都是以的方式实现的。

equ 是 nasm 提供的伪指令,意为 equal,即等于,用于给表达式起个意义更明确的符号名,其指令格式是: 符号名称 equ 表达式

描述符中的各个字段都是由 equ 来定义的,符号名一律采用 *“DESC字段名字段相关信息” *的形式。

在保护模式中,我们还是学习 Linux 等主流操作系统的内存段,用平坦模型。平坦模型之前已经提到 过了,就是整个内存都在一个段里,不用再像实模式那样用切换段基址的方式访问整个地址空间。在 32 位保护模式中,寻址空间是 4G,所以,平坦模型在我们定义的描述符中,段基址是 0,段界限*粒度等于 4G。粒度我们选的是 4k,故段界限是 0xFFFFF。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DESC_G_4K : 表示描述符的 G 位为 4K 粒度,其值等于(equ)1_00000000000000000000000b。 其中结尾的 b 表示二进制,之所以这样用二进制写属性位,就是为了在格式中的位置容易对比,最左边的 1 正好处在第 23 位,也就是段描述符中 G 的位次。1 右边的字符 “_” 没有特别的意义,只是我人为加上去的,这样人眼“看”起来显得比较清晰明朗,nasm 编译器做得很人性化,为了人看得方便,它特意支持这种分隔符的写法,在编译阶段会忽略此分隔符。

DESC_TYPE_CODE equ 1000_00000000b : 这是定义了一个代码段的 type 字段,右边的二进制串的 高 4 位就是 type 中的 4 位,右边用 ' _' 字符来分隔,确实直观了很多,如果您忘记了 type 中各位的意义, 赶紧回去翻看段描述符格式。此定义的意义在注释中已写得很清楚了:x=1,c=0,r=0,a=0 代码段是可执行的,非一致性,不可读,已访问位 a 清 0 。

DESC_CODE_HIGH4 : 就是定义了代码段的高 4 字节。equ 后面那一串加法表达式,就是在凑足段描述符这高 4 字节内容。其中(0x00 << 24)表示“段基址 24~31”字段,该字段位于段描述符高 4 字节中的第 24~31 位,由于平垣模式段基址是 0,所以咱们用 0 偏移 24 位填充该字段。当然这只是一部分段基址,段基址在 8 字节的段描述符中存在 3 处,它们在每处都会是 0 。

DESC_D_32 : 表示描述符中的 D/B 字段,对代码段来说是 D 位,在此表示 32 位操作数。
DESC_L : 表示段描述符中的 L 位,其值见代码 4-2 的第 9 行,为 0,表示为 32 位代码段。
DESC_AVL : 等于 0,此位没实际意义,是留给操作系统用的。
DESC_LIMIT_CODE2 : 是代码段的段界限的第 2 部分(段界限的第 1 部分在段描述符的低 4 字节中),此处值为 1111b,它与段界限的第 1 部分将组成 20 个二进制 1,即总共的段界限将是 0xFFFFF 。

DESC_P : 表示段存在。
DESC_DPL_0 : 表示该段描述符对应的内存段的特权级是 0,即最高特权级。当 CPU 在该段上运行时,将有至高无上的特权。
DESC_S_CODE : 表示代码段的 S 位,此值为 1,表示它是个普通的内存段,不是系统段。
DESC_TYPE_CODE : 上面有介绍,意义为 x=1,c=0,r=0,a=0 ,即代码段是可执行的,非一致性,不可读,已访问位 a 清 0。
0x00 : 是段基址的第 16~23 位,位于段描述符高 4字节的起始 8 位,如前所述,由于是平坦模型,所以段基址的任意部分都是 0。

这里并没有把选择子定义到这里,因为选择子中的高 13 位是用来索引段描述符的,它的值取决于段描述符的具体位置,而段描述符我们在 loader.S 中定义,所以最终的选择子是在 loader.S 中定义的,这样修改段描述符的位置时,顺便就把选择子修改了,否则放在多个文件中容易遗漏。

开始修改 loader.S 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
    %include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start

;构建 gdt 及其内部的描述符
GDT_BASE: dd 0x00000000
dd 0x00000000

CODE_DESC: dd 0x0000FFFF
dd DESC_CODE_HIGH4

DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4

VIDEO_DESC: dd 0x80000007 ;limit=(0xbffff-0xb8000)/4k=0x7
dd DESC_VIDEO_HIGH4 ;此时 dpl 为 0

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; 此处预留 60 个描述符的空位
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0
; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ; 同上
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 同上

; 以下是 gdt 的指针,前 2 字节是 gdt 界限,后 4 字节是 gdt 起始地址

gdt_ptr dw GDT_LIMIT
dd GDT_BASE
loadermsg db '2 loader in real.'
loader_start:

;------------------------------------------------------------
;INT 0x10 功能号:0x13 功能描述:打印字符串
;------------------------------------------------------------
; 输入:
; AH 子功能号=13H
; BH = 页码
; BL = 属性(若 AL=00H 或 01H)
; CX=字符串长度
; (DH、 DL)=坐标(行 列、 )
; ES:BP=字符串地址
; AL=显示输出方式
; 0—字符串中只含显示字符,其显示属性在 BL 中
; 显示后,光标位置不变
; 1—字符串中只含显示字符,其显示属性在 BL 中
; 显示后,光标位置改变
; 2—字符串中含显示字符和显示属性。显示后,光标位置不变
; 3—字符串中含显示字符和显示属性。显示后,光标位置改变
; 无返回值
mov sp, LOADER_BASE_ADDR
mov bp, loadermsg ; ES:BP = 字符串地址
mov cx, 17 ; CX = 字符串长度
mov ax, 0x1301 ; AH = 13, AL = 01h
mov bx, 0x001f ; 页号为 0(BH = 0) 蓝底粉红字(BL = 1fh)
mov dx, 0x1800
int 0x10 ; 10h 号中断

;-------------------- 准备进入保护模式 -------------------------------
;1 打开 A20
;2 加载 gdt
;3 将 cr0 的 pe 位置 1


;----------------- 打开 A20 ----------------
in al,0x92
or al,0000_0010B
out 0x92,al

;----------------- 加载 GDT ----------------
lgdt [gdt_ptr]

;----------------- cr0 第 0 位置 1 ----------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线


[bits 32]
p_mode_start:
mov ax, SELECTOR_DATA
mov ds, ax
mov es, ax
mov ss, ax
mov esp,LOADER_STACK_TOP
mov ax, SELECTOR_VIDEO
mov gs, ax

mov byte [gs:160], 'P'

jmp $
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
添加个库目录 + 编译
--nasm -I include/ -o mbr.bin mbr.S
下面将生成的 mbr.bin 写入我们的虚拟硬盘,还是用 dd 命令。
--sudo dd if=/your_path/mbr.bin of=/your_path/bochs/hd60M.img bs=512 count=1 conv=notrunc
--sudo dd if=/home/fyz/sc/bochs-2.6.2/mbr.bin of=/home/fyz/sc/bochs-2.6.2/hd60M.img bs=512 count=1 conv=notrunc

记录了1+0 的读入
记录了1+0 的写出
512 bytes copied, 0.000546068 s, 938 kB/s

添加个库目录 + 编译
--nasm -I include/ -o loader.bin loader.S
将生成的 loader.bin 写入硬盘第 2 个扇区。第 0 个扇区是 MBR,第 1 个扇区是空的未使用。( 我就喜欢 你咋的
--sudo dd if=./loader.bin of=/your_path/bochs/hd60M.img bs=512 count=2 seek=2 conv=notrunc
--sudo dd if=./loader.bin of=/home/fyz/sc/bochs-2.6.2/hd60M.img bs=512 count=2 seek=2 conv=notrunc
(由于是612字节大小 所以 count=2

记录了1+1 的读入
记录了1+1 的写出
612 bytes copied, 0.000632655 s, 967 kB/s

模拟
--sudo bin/bochs -f bochsrc.disk

运行成功

image-20201020173853277

image-20201020174409769

PE位 已经变成1 成功进入了保护模式。

解释下为什么要加 jmp 。。。

起到刷新流水线的作用

为什么要用 jmp 远转移。

1.段描述符缓冲寄存器未更新,它还是实模式下的值,进入保护模式后需要填入正确的信息。

2.流水线中指令译码错误。

在jmp后面的代码是 32 位指令格式,而 CPU 是将其按照 16 位指令格式来译码的,译码之后在其执行时,必然是错误的。

解决问题的关键就是既要改变代码段描述符缓冲寄存器的值,又要清空流水线

代码段寄存器 cs,只有用远过程调用指令 call、远转移指令 jmp、远返回指令 retf 等指令间接改变, 没有直接改变 cs 的方法,如直接 mov cs,xx 是不行的。另外,之前介绍过了流水线原理,CPU 遇到 jmp 指令时,之前已经送上流水线上的指令只有清空,所以 jmp 指令有清空流水线的神奇功效。

流水线

1.流水线是什么呢?

计算机中的流水线是把一个重复的过程分解为若干个子过程,每个子过程与其他子过程并行进行。由于这种工作方式与工厂中的生产流水线十分相似, 因此称为流水线技术。从本质上讲,流水线技术是一种时间并行技术

2.指令重叠方式

顺序执行:控制简单,节省设备;但是速度慢,功能部件的利用率低
重叠执行方式 :指令的执行时间缩短 ,功能部件的利用率明显提高 ;但是需要增加一些硬件;控制过程稍复杂

3.流水线工作设计

基本思想:延伸重叠方式,使指令解释过程进一步细化, 提高各部件的利用率,以提高指令执行速度
理想目标:完成任务的时间与操作处理过程无关,只与提供操作的速度有关

4.流水线的描述方法

时间—空间图 I

横坐标:表示时间,即各个任务在流水线中所经过的时间
纵坐标:表示空间,即流水线的各个子过程,也称为级、 段、流水线深度(Stage)

image-20201021151156990

时间—空间图 II

横坐标:表示时间,即各个任务或指令在流水线中 所在该时刻所对应的子过程
纵坐标:表示某个任务或某条指令,即流水线依次 处理的任务或指令

image-20201021151312547

IF : Instruction Fetch,取指令,用到部件:指令存储器,Adder( 全加器,full-adder,是用门电路实现两个二进制数相加并求出和的组合线路,称为一位全加器。一位全加器可以处理低位进位,并输出本位加法进位。多个一位全加器进行级联可以得到多位全加器。常用二进制四位全加器74LS283)
ID : Instruction Decode,译码(应该是取数同时译码的过程),用到部件:指令译码器寄存器堆读口(这里面的寄存器堆的读口和写口可以看作两个不同的部件),这块有大量寄存器,WB也是从写口将数据写到这块的寄存器中。
EX : Exec,执行,计算内存单元地址。用到部件:ALU,扩展器
MEM :访存,从数据存储器中读。用到部件:数据存储器。
WB : Write Back,写回,将数据写到寄存器中。用到部件:寄存器堆写口。

乱序执行

乱序执行,是指在 CPU 中运行的指令并不按照代码中的顺序执行,而是按照一定的策略打乱顺序执行,也许后面的指令先执行,当然,得保证指令之间不具备相关性。即 :指令流出顺序 != 指令流入顺序

举个简单的例子,比如如下两行代码就无法乱序执行。

1
2
mov eax, [0x1234]
add eax, ebx

第 2 行的 add 加法,需要知道 eax 的值,但 eax 的值需要在第 1 行中的 mov 操作后才能确定,而且内存访问相对来说非常慢,第 2 步不得不等待第 1 步完成后才能进行。所以只能是先执行第 1 步,再执行第 2 步。

稍微改点后。

1
2
mov eax, [0x1234]
add ecx,ebx。

这样就可以在执行第 1 步内存访问后的等待中执行第 2 步啦。由于第 2 步不依赖第 1 步,所以有利于放在流水线上。

x86 最初用的指令集是 CISC 。意为复杂指令集计算机,为什么复杂呢?当初的 CPU 工程师们为了让 CPU 更加强大,不断地往 CPU 中添加各种指令,甚至在 CPU 硬件一级直接支持软件中的某些操作,以至于指令集越来越庞大笨重复杂。

例如 push 指令,它相当于多个子操作的合成,拿保护模式中的栈来说,

push eax 相当于:push 指令先将栈指针 esp 减去操作数的字长,如 sub esp,4
再将操作数 mov 到新的 esp 指向的地址,如 mov [esp],eax

与 CISC 指令集相对应的是 RISC(Reduced Instruction Set Computer),意为精简指令集计算机。根据二八定律,最常用的指令只有 20%,但它们占了整个程序指令数的 80%。而不常用的指令占 80%,但它们只占整个程序指令数的 20%。

还是拿栈举例,如下三行代码

1
2
3
mov eax , [0x1234]
push eax
call function

第 1 步需要内存访问,由于内存较慢,所以寻址等待过程中可以做其他事。
第 2 步的 push 指令拆分成 sub esp,4mov [esp],eax
第 3 步的 call 函数调用,需要在栈中压入返回地址,所以说 call 指令需要用栈指针。

由于第 2 步中的微操作 sub esp,4,可以让 CPU 知道 esp 的最新值,不用等到 mov [esp], eax 完成, 第 3 步 call 指令向栈中压入返回地址的操作就可以执行了。故第 2 步未执行完就开始第 3 步的执行了,也 许第 3 步先于第 2 步完成。

乱序执行的好处就是后面的操作可以放到前面来做,利于装载到流水线上提高效率。

缓存

缓存是 20 世纪最大的发明,其原理是用一些存取速度较快的存储设备作为数据缓冲区,避免频繁访问速度较慢的低速存储设备,归根结底的原因是低速存储设备是整个系统的瓶颈,缓存用来缓解“瓶颈设备”的压力。

在上面提到过,CPU 中有一级缓存 L1二级缓存 L2, 甚至三级缓存 L3 等。它们都是 SRAM,即静态随机访问存储器,它是最快的存储器啦。之所以把 SRAM 和寄存器放到一块说,是因为很多同学在感观上觉得寄存器是 CPU 直接使用的存储单元,所以寄存器比 SRAM 更快。其实它们在速度上是同一级别的东西,因为寄存器和 SRAM 都是用相同的存储电路实现的, 用的都是触发器,它可是工作速度极快的,属于纳秒级别。

有哪些东西可以被缓存呢?无论是程序中的数据,还是指令,在 CPU 眼里全是一样形式的二进制 01 串,没有任何区别,都是 CPU 待处理的“数据”。所以我们眼中的指令和数据都可以被缓存到 SRAM 中。

什么时候能缓存呢?可以根据程序的局部性原理采取缓存策略。局部性原理是:程序 90%的时间都运行在程序中 10%的代码上。

局部性分为以下两个方面。 一方面是时间局部性:最近访问过的指令和数据,在将来一段时间内依然经常被访问。另一方面是空间局部性:靠近当前访问内存空间的内存地址,在将来一段时间也会被访问。

例如 我们经常写的循环嵌套代码。

1
2
3
4
5
6
7
8
9
10
int array[100][100];
int sum = 0;

数组 array 元素被赋值,略

for (int i=0, i<100,i++) {
for(int j=0;j<100,j++) {
sum+=array[i][j];
}
}

以上是将二维数组中的所有元素相加求和的代码。循环中经常被用到的地址是 sum 所在的地址,经常被用到的指令是加法求和指令,这是在时间上的局部性。未来要访问的地址是与当前访问地址&array[ i ] [ j ] 相邻的地址&array[ i ] [ j+1 ],它们之间只差一个整型变量的大小,这是空间上的局部性的。(当然,这些局部性都是编译器编译的结果,编译器就是这样安排的。)CPU 利用此特性,将当前用到的指令和当前位置附近的 数据都加载到缓存中,这就大大提高了 CPU 效率,下次直接从缓存中拿数据,不用再去内存中取啦。 当然,上面说的是理想的状态,如果缓存中没有相应的数据,还是要去内存中加载,然后再放到缓存中。

流水线相关及冲突

1.流水线相关(dependence): 两条指令之间存在某种依赖关系。

数据相关
1.先写后读:后面指令用到前面指令的结果
2.或间接使用

名相关:换名技术(使用相同的寄存器或存储器单元名称/地址
1.反相关:先读后写
2.输出相关:先写后写
3.真相关:先写后读

控制相关
由分支指令引起的相关

2.流水线冲突(hazards)

​ 流水线冲突是指对于具体的流水线来说,由于“相关”的存在,使得指令流中的下一条指令不能在指定的时钟周期执行

  • 数据冲突:当指令在流水线中重叠执行时,因需要用到前面指令的执行结果而发生的冲突

  • 控制冲突:流水线遇到分支指令和其他会改变PC值的指令所引起的冲突

  • 结构冲突:因硬件资源满足不了指令重叠执行的要求而发生的冲突,比如说,前面后面指令同时访问存储器

例如 分支预测

CPU 中的指令是在流水线上执行。分支预测,是指当处理器遇到一个分支指令时,是该把分支左边的指令放到流水线上,还是把分支右边的指令放在流水线上呢? 如 C 语言程序中的 if、switch、for 等语言结构,编译器将它们编译成汇编代码后,在汇编一级来说, 这些结构都是用跳转指令来实现的,所以,汇编语言中的无条件跳转指令很丰富,以至于称之为跳转指令 “族”,多得足矣应对各种转移方式。

预测的算法

对于无条件跳转,没啥可犹豫的,直接跳过去就是了。所谓的预测是针对有条件跳转来说的,因为不 知道条件成不成立。最简单的统计是根据上一次跳转的结果来预测本次,如果上一次跳转啦,这一次也预 测为跳转,否则不跳。

最简单的方法是 2 位预测法。用 2 位 bit 的计数器来记录跳转状态,每跳转一次就加 1,直到加到最大值 3 就不再加啦,如果未跳转就减 1,直到减到最小值 0 就不再减了。当遇到跳转指令时,如果计数器 的值大于 1 则跳转,如果小于等于 1 则不跳。这只是最简单的分支预测算法,CPU 中的预测法远比这个复杂,不过它们都是从 2 位预测法发展起来的。

CPU的实现

Intel 的分支预测部件中用了分支目标缓冲器(Branch Target Buffer,BTB)。

image-20201021164338183

如果 BTB 中没有相同记录该怎么办呢?这时候可以使用 Static Predictor,静态预测器。为什么称为静态呢?这是因为存储在里面的预测策略是固定写死的,它是由人们经过大量统计之后,根据某些特征总结出来的。比如,转移目标的地址若小于当前转移指令的地址,则认为转移会发生,因为通常循环结构中都 用这种转移策略,为的是组成循环回路。所以静态预测器的策略是:若向上跳转则转移会发生,若向下跳转则转移不发生。

image-20201021164606445

程序在实际执行转移分支指令后,再将转移记录录入到 BTB。 还记得之前反复强调的重叠吗?其实是用在这的。如果分支预测错了, 也就是说,当前指令执行结果与预测的结果不同,这也没关系,只要将流水线清空就好了。因为处于执行阶段的是当前指令,即分支跳转指令。处于“译 码”“取指”的是尚未执行的指令,即错误分支上的指令。只要错误分支上的 指令还没到执行阶段就可以挽回,所以,直接清空流水线就是把流水线上错 误分支上的指令清掉,再把正确分支上的指令加入到流水线,只是清空流水线代价比较大。

内存段的保护

保护模式中的保护二字体现在哪里?其实主要体现在段描述符的属性字段中。每个字段都不是多余 的。这些属性只是用来描述一块内存的性质,是用来给 CPU 做参考的,当有实际动作在这片内存上发生 时,CPU 用这些属性来检查动作的合法性,从而起到了保护的作用。

向段寄存器加载选择子时的保护

判断段描述符是否超越界限

image-20201021172649044

选择子的高 13 位是段描述符的索引值,第 0~1 位是 RPL,第 2 位是 TI 位。

  1. 先判断选择子否是正确的

    是选择子的索引值一定要小于等于 描述符表(GDT 或 LDT)中描述符的个数。即 段描述符的最后 1 字节一定要在描述符表(GDT 或 LDT)的界限地址之内。

  2. 选择子中的索引值

    描述符表基地址+选择子中的索引值*8+7 <= 描述符表基地址+描述符表界限值。

检查过程如下:处理器先检查 TI 的值,如果 TI 是 0,则从全局描述符表寄存器 gdtr 中拿到 GDT 基地址和 GDT 界限值。如果 TI 是 1,则从局部描述符表寄存器 ldtr 中拿到 LDT 基地址和 LDT 界限 值。有了描述符表基地址和描述符表界限值后,把选择子的高 13 位代入上面的表达式,若不成立, 处理器则抛出异常。

注意: GDT 中的第 0 个描述符是空描述符,如果选择子的索引值为 0 则会引用到它。所以,不允许往 CS 和 SS 段寄存器中加载索引值为 0 的选择子。虽然可以往 DS、ES、FS、GS 寄存器中加载值为 0 的选择子,但真正在使用时 CPU 将会抛出异常, 毕竟第 0 个段描述符是哑的,不可用。

段描述符中还有个 type 字段,这用来表示段的类型,也就是不同的段有不同的作用。

在选择子检查过后,就要检查段的类型了。 这里主要是检查段寄存器的用途和段类型是否匹配。

大的原则如下:

  1. 只有具备可执行属性的段(代码段)才能加载到 CS 段寄存器中。
  2. 只具备执行属性的段(代码段)不允许加载到除 CS 外的段寄存器中。
  3. 只有具备可写属性的段(数据段)才能加载到 SS 栈段寄存器中。
  4. 至少具备可读属性的段才能加载到 DS、ES、FS、GS 段寄存器中。

如果 CPU 发现有任意上述规则不符,检查就不会通过。

image-20201021173722675

检查完 type 后,还会再检查段是否存在

CPU 通过段描述符中的 P 位来确认内存段是否存在
如果 P 位为 1,则表示存在,这时候就可以将选择子载入段寄存器了,同时段描述符缓冲寄存器也会更新为选 择子对应的段描述符的内容,随后处理器将段描述符中的 A 位置为 1,表示已经访问过了。
如果 P 位为 0, 则表示该内存段不存在,不存在的原因可能是由于内存不足,操作系统将该段移出内存转储到硬盘上了。 这时候处理器会抛出异常,自动转去执行相应的异常处理程序,异常处理程序将段从硬盘加载到内存后并 将 P 位置为 1,随后返回。CPU 继续执行刚才的操作,判断 P 位。

注意: 以上所涉及到的 P 位,其值由软件(通常是操作系统)来设置,由 CPU 来检查。A 位由 CPU 来设置。

代码段和数据段的保护

对于代码段和数据段来说,CPU 每访问一个地址,都要确认该地址不能超过其所在内存段的范围。

实际段界限的值为: (描述符中段界限+1)*(段界限的粒度大小:4k 或者 1)-1

对于 G 位为 1 的 4k 粒度大小的段来说,其实用下面这个公式更为直接。
实际段界限大小 = 描述符中段界限0x1000+0xFFF
其中,0xFFF 是 4k(0x1000)中以 0 为起始的最后一字节。所以此公式的意义是以 0 为起始的段偏移量,即段界限。

实际的段界限大小,是段内最后一个可访问的有效地址。由于有了段界限的限制,我们给 CPU 提交的每 一个内存地址,无论是指令的地址,还是数据的地址,CPU 都要帮我们检查地址的有效性。首先地址指向的 数据是有宽度的,CPU 要保证该数据一定要落在段内,不能“骑”在段边界上。

访问内存就要用分段策略,这是它的宿命,逃不掉的 ^_^。代码段既然也是内存中的区域,所以对于代码段的访问也要用“段基址:段内偏移地址”的形式, 在 32 位保护模式下,段基址存放在 CS 寄存器中,段内偏移地址,即有效地址,存放在 EIP 寄存器中。

CPU 得确保指令“完全、完整”地任意一部分都在当前的代码段内 即 EIP 中的偏移地址+指令长度-1≤实际段界限大小

CPU 也要保证操 作数要“完全、完整”地任意部分都要在当前数据段内。 即 偏移地址+数据长度-1≤实际段界限大小

image-20201021183203196

举个例子,假设数据段描述符的段界限是 0x12345,段基址为 0x00000000。 如果 G 位为 0,那么实际段界限便是 0x12345。如果 G 位为 1,那么实际段界限便是 0x12345 * 0x1000+0xFFF=0x12345FFF。如果访问的数据地址是 0x12345FFF,还要看访问的数据宽度。 若数据大小是 1 字节,如 mov ax,byte [0x12345fff],这种内存操作一点问题都没有,数据完全在实 际段界限之内。 若该数据大小是 2 字节,如 mov ax,word [0x12345fff],这种内存操作超过了实际的段界限,数据所在地址分别是 0x12345FFF 和 0x12346000 这两个字节,CPU 会抛异常。

栈段的保护

虽然段描述符 type 中的 e 位用来表示段的扩展方向,但它和别的描述符属性一样,仅仅是用来描述段的性质,即使 e 等于 1 向下扩展,依然可以引用不断向上递增的内存地址,即使 e 等于 0 向上扩展,也依然可以引用不断向下递减的内存地址。栈顶指针[e]sp 的值逐渐降低,这是 push 指令的作用,与描述符是否向下扩展无关,也就是说,是数据段就可以用作栈。

CPU 对数据段的检查,其中一项就是看地址是否超越段界限。如果将向上扩展的数据段用作栈,那 CPU 将按照上一节提到的数据段的方式检查该段。如果用向下扩展的段做栈的话,情况有点复杂,这体现在段界限的意义上。

对于向上扩展的段,实际的段界限是段内可以访问的最后一字节。
对于向下扩展的段,实际的段界限是段内不可以访问的第一个字节

为了避免碰撞,将段界限地址+1 视为栈可以访问的下限。

image-20201021191555885

Reward
  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • © 2015-2021 John Doe
  • Powered by Hexo Theme Ayer
  • PV: UV:

请我喝杯咖啡吧~

支付宝
微信