Songqian Li's Blog

去历史上留点故事

4.1 保护模式概述

在本章大家会见到全局描述符表、中断描述符表、各种门结构,这是 CPU 提供给应用的,咱们用好就行。
保护模式强调的是“保护”,它是在 Intel 80286 CPU 中首次出现,这是继 8086 之后,Intel 紧接着退出的一款产品。

“想要啥就有啥”井不是真正的幸福,而是发自内心地感恩、珍惜目前所拥有的一切。

4.1.1 为什么要有保护模式

让我们看看 CPU 实模式的不幸,大家就清楚保护模式的幸福了。

  1. 实模式下操作系统和用户程序属于同一特权级;
  2. 用户程序所引用的地址都是指向真是的物理地址,也就是说逻辑地址等于物理地址;
  3. 用户程序可以自由修改段基址,可以访问所有内存,没人拦得住;

以上 3 个原因属于安全缺陷,没有安全可言的 CPU 注定是不可依赖的,这从基因上决定了用户程序乃至操作系统的数据都可以被随意的删改,一旦出事往往都是灾难性的,而且不容易排查。

  1. 访问超过 64KB 的内存区域时要切换段基址,转来转去容易晕;
  2. 一次只能运行一个程序,无法充分利用计算机资源;
  3. 共 20 条地址线,最大可用内存为 1MB;

第(4)、(5)条是使用方面的缺陷,似乎当时(20 年前)还可以忍受,但第(6)条简直就是硬伤。
为了克服这种低劣的内存管理方式,处理器厂商开发出保护模式。这样,物理内存地址不能直接被程序访问,程序内部的地址(虚拟地址)需要被转化为物理地址后再去访问,程序对此一无所知。顺便说一句,地址转换是由处理器和操作系统共同协作完成的,处理器在硬件上提供地址转换部件,操作系统提供转换过程中所需要的页表。

4.1.2 实模式不是 32 位 CPU 变成了 16 位

32 位 CPU 具有保护模式和实模式两种运行模式,可以兼容实模式下的程序。兼容实模式,是指能够正确处理好实模式下的程序,并不是说在实模式下运行时就完全变成了纯 16 位的 CPU。
说实模式时,指的是 32 位的 CPU 运行在 16 位模式下的状态。

4.2 初见保护模式

4.2.1 保护模式之寄存器扩展

CPU 发展到 32 位后,地址总线和数据总线也发展到 32 位, 其寻址空间更达到了 2 的 32 次方, 4GB 内存寻址空间上去了,内存寻址方式还得兼容老办法,即“段基址:段内偏移地址”,寄存器可以用来指 定段内偏移地址,还是 16 位的话,如何承担 4GB 寻址的重任?所以,寄存器宽度也要跟上才行。
寄存器要保持向下兼容,不能推翻之前的方案从头再来,必须在原有的基础上扩展( extend ),各寄存器在原有 16 位的基础上,再次向高位扩展了 16 位,成为了 32 位寄存器。经过 extend 后的寄存器,统 一在名字前加了 表示扩展,如图所示。
image.png
图中,左边己经标注名字的寄存器有通用寄存器组,名字前统一加了字符 表示扩展,同样, EFLAGS 寄存器和 EIP 分别在 FLAGS 基础上扩展而成。图下边的 6 个段寄存器,依然是 16 位。
偏移地址还和实模式下的一样,但段基址可不是简单的一个地址的事了。为了更加安全,怎么也得多添加点约束条件才靠谱。这些“约束条件”便是对内存段的描述信息。由于信息太多了,肯定用一个寄存器是放不下了,所以专门找了个数据结构一一全局描述符表。其中每一个表项称为段描述符,其大小为 64 字节,用来描述各个内存段的起始地址、大小、权限等信息。全局描述符表很大,所以放在了内存中,由 GDTR 寄存器指向它就行。
这样,段寄存器中保存的不是段基址了,里面保存的内容叫“选择子”,selector ,该选择子其实就是个数,用这个数来索引全局描述符表中的段描述符,把全局描述符表当成数组,选择子就像数组下标一样。
此时要注意两件事:

  1. 段描述符是在内存中,访问内存对 CPU 来说是比较慢的动作,效率不高。
  2. 段描述符的格式很奇怪,一个数据要分三个地方存,所以 CPU 要把这些七零八落的数拼合成一 个完整数据也是要花时间的。

为了提高获取段信息的效率,在 80286 的保护模式中,对段寄存器率先应用了缓存技术,将短信息同一个寄存器来缓存,这就是段描述符缓冲寄存器 (Descriptor Cache Registers)。当然在实模式下,段基址左移思维后的结果就被放入段描述符缓冲寄存器中了。
只要往段寄存器中赋值,CPU 就会更新段描述符缓冲寄存器。例如在保护模式下加载选择子,CPU 就会重新访问 GDT,再将获取的段信息重新放回段描述符缓冲寄存器,或在实模式下为段寄存器赋予段基址,无论是否与之前段基址相同,段基址左移 4 位后的结果就被送入段描述符缓冲寄存器。
image.png

4.2.2 保护模式之寻址扩展

在保护模式下,基址寄存器不再只是 bx、bp,而是所有 32 位的通用寄存器。变址寄存器也是一样,不再只是 si、di,而是除 esp 之外的所有 32 位通用寄存器,偏移量由实模式的 16 位变成了 32 位。并且,还可以对变址寄存器乘以一个比例因子,注意比例因子只能是 1、2、4、8。
image.png
而且虽然 esp 无法用作变址寄存器,但其可用于基址寄存器。

1
2
mov eax, [esp]
mov eax, [esp+2]

4.2.3 保护模式之运行模式反转

进入保护模式需要三个步骤:

  1. 打开 A20 地址线
  2. 加载 gdt
  3. 将 cr0 的 pe 位置 1

bits 16 是让编译器将代码编译成 16 位,bits 32 是将代码编译为 32 位。

1
2
3
4
5
6
7
[bits 16]
mov ax, 0x1234
mov dx, 0x1234

[bits 32]
mov eax, 0x1234
mov edx, 0x1234

image.png
反转操作数大小前缀0x66,寻址方式反转前缀0x67,加了反转反转前缀后:

  • 假设当前运行模式是 16 位实模式,操作数大小将变为 32 位
  • 假设当前运行模式是 32 位保护模式,操作数大小将变为 16 位

这个转换只是临时的,只在当前指令有效

4.2.4 保护模式之指令扩展

在 16 位的实模式下,CPU 的操作数是 16 位。在 32 位的保护模式下,操作数扩展到了 32 位,于是涉及到操作数变化的指令也要跟着扩展,既要兼容 16 的操作数,也要支持 32 位的操作数。
比如 add,不仅要支持 8 位、16 位,还得支持 32 位的操作数,如:

1
2
3
add al, cl
add ax, cx
add eax, ecx ; 支持32位操作数

减法也是一样:

1
2
3
sub al, cl
sub ax, cx
sub eax, ecx ; 支持32位操作数

乘法也是一样,如果乘数是 8 位,则把寄存器 al 当做另一个乘数,结果便是 16 位,存入寄存器 ax。
如果乘数是 16 位,则把寄存器 ax 当做另一个乘数,结果便是 32 位,存入寄存器 eax。
如果乘数是 32 位,则把寄存器 eax 当做另一个乘数,结果便是 64 位,存入 edx:eax,其中 edx 是积的高 32 位,eax 是积的低 32 位。
对于 push 指令需要根据操作数的类型分别讨论:

  1. 立即数——处于对齐的考虑,立即数会被扩展成各模式下的默认操作数宽度

image.png
image.png

  1. 寄存器
    1. 对于段寄存器来说,无论在哪种模式下,都是按当前模式的默认操作数大小压入的
    2. 对于通用寄存器和内存,无论是在实模式还是保护模式:如果压入的是 16 位数据,栈指针减 2;如果压入的是 32 位数据,栈指针减 4;

4.3 全局描述符表

4.3.1 段描述符

实模式下存在的问题:

  1. 实模式下的用户程序可以破坏存储代码的内存区域,所以要添加个内存段类型属性来阻止这种行为;
  2. 实模式下的用户程序和操作系统是同一级别的,所以要添加个特权级属性来区分用户程序和操作系统的地位;
  3. 访问内存段的必要属性条件:
    1. 内存段是一片内存区域,访问内存就要提供段基址,所以要有段基址属性;
    2. 为了限制程序访问内存的范围,还要对段大小进行约束,所以要有段界限属性;
  4. 其他约束条件

这些用来描述内存段的属性被放到了一个称为段描述符的结构中,该结构专门用来描述一个内存段,大小为 8 字节。
image.png保护模式下地址总线宽度是 32 位,段基址需要用 32 位地址来表示。
实际的段界限边界值=(描述符中段界限+1)(段界限的粒度大小:4KB 或者 1)-1
如果 G 位为 0,表示段界限粒度大小为 1 字节,段界限粒度实际大小就等于描述符中的段界限值。
如果 G 位为 1,表示段界限粒度大小为 4KB 字节。如果是平坦模型,段界限为 0xFFFF,G 为 1,段界限边界值=0x100000
0x1000-1=0xFFFFFFFFF。
内存访问需要用到“段基址:段内偏移地址”,段界限其实是用来限制段内偏移地址的,段内偏移地址必须位于段的范围之内,否则 CPU 会抛异常。根据段的扩展方向,此“段界限*单位”便是段内偏移地址的最大值。
S字段用来表示是否为系统段,0 时为系统段、1 时为数据段。CPU 眼中硬件运行需要用到的东西都称之为系统,软件需要的东西都称为数据,无论代码还是数据甚至栈都算是给硬件的数据,所以代码段在段描述符中属于数据段。
type是用来指定内存段或门的子类型,该字段共 4 位。
image.png
表中的A位表示 Accessed 位,这是由 CPU 来设置的,每当该段被 CPU 访问过后,CPU 就将此位置 1.
C表示一致性代码段,也称为依从代码段,Conforming。一致性代码段是指如果自己是转移的目标段,并且自己是一致性代码段,自己的特权级一定要高于当前特权级,转移后的特权级不与自己的 DPL 为主,而是与转移前的低特权级一致,也就是听从、依从转移前的低特权级。C 为 1 时则表示该段是一致性代码段,C 为 0 时则表示该段为非一致性代码段。
R表示可读性,R 为 1 可读,为 0 不可读。这个属性一般用来限制代码段的访问。如果指令执行过程中,CPU 发现某些指令对 R 为 0 的段进行访问,如使用段超越前缀 CS 来访问代码段,CPU 将抛出异常。
X表示该段是否可执行,EXecutable。可执行为 1,不可执行为 0。
E是用来标识段的扩展方向, Extend 表示向上扩展,即地址越来越高,通常用于代码段和数据段。 表示向下扩展,地址越来越低,通常用于栈段。
W指段是否可写。W 为 1 表示可写,通常用于数据段;为 0 表示不可写,通常用于代码段。对于 W 为 0 的段有写入行为,同样会引发 CPU 抛出太长,。
DPL字段,Descriptor Privilege Level ,即描述符特权级,这是保护模式提供的安全解决方案,将计算机世界按权力划分成不同等级,每一种等级称为一种特权级。
由于段描述符用来描述一个内存段或一段代码的情况(若描述符类型为“门”),所以描述符中的 DPL 是指所代表的内存段的特权级。
这两位能表示 4 种特权级,分别是 0、1、2、3 级特权,数字越小,特权级越大。特权级是保护模式下才有的东西,CPU 由实模式进入保护模式后,特权级自动为 0。因为保护模式的代码已经是操作系统 的一部分啦,所以操作系统应该处于最高的特权级。用户程序通常处于 3 特权级,权限最小。某些指令只能在 0 特权级下执行,从而保证了安全。
P字段,Present,表示段是否存在。若段存在于内存中,P 为 1,否则 P 为 0。P 字段是由 CPU 来检查的,若为 0,CPU 将抛出异常,转到相应的异常处理程序,此异常处理程序是咱们来写的,在异常处理程序处理完成后要将 P 置 1。对于 P 字段,CPU 只负责检查,咱们负责赋值。
AVL字段,Available。不过这可用是对其用户来说的,即对操作系统来说。对硬件来说没有专门的用途。
L字段,用来设置是否是 64 位代码段,L 为 1 表示 64 位代码段,否则表示 32 位代码段。这目前属于保留位,在 32 位 CPU 下编程将其置为 0 即可。
D/B字段,用来指示有效地址(段内偏移地址)及操作数的大小。

  • 对于代码段来说,此位是 D 位,若 D 为 0,表示指令中的有效地址和操作数是 16 位,指令有效地址 IP 寄存器。若 D 为 1,表示指令中的有效地址及操作数是 32 位,指令有效地址用 EIP 寄存器。
  • 对于栈段来说,此位是 B 位,用来指定操作数大小,此操作数涉及到栈指针寄存器的选择及栈的地址上限。若 B 为 0,使用的是 sp 寄存器,也就是栈的起始地址是 16 位寄存器的最大寻址范围, 0xFFFF,使用的是 esp 寄存器,也就是栈的起始地址是 32 位寄存器的最大寻址范围,0xFFFFFFFF。

G字段,Granularity,粒度,用来指定段界限的单位大小。G 为 0,表示单位为 1 字节,段最大是 1MB;G 为 1,表示单位是 4KB,段最大是 4GB。

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

一个段描述符只用来定义(描述)一个内存段。代码段要占用一个段描述符、数据段和栈段等,多个内存段也要各自占用一个段描述符,这些描述符放在哪里呢?答案是放在全局描述符表,就是本节开头所说的 GDT (Global Descriptor Table )。全局描述符表 GDT 相当于是描述符的数组,数组中的每个元素都是 8 字节的描述符。可以用选择子(马上会讲到)中提供的下标在 GDT 中索引描述符。
image.png
GDT 中最多可容纳的描述符数量是 65536/8 = 8192 个,即 GDT 中可容纳 8192 个段或门。

image.png
由于段寄存器是 16 位,所以选择子也是 16 位,在其低 2 位即第 0~1 位, 用来存储 RPL,即请求特权级,可以表示 0、1、2、3 四种特权级。 在选择子的第 2 位是 TI 位,即 Table Indicator,用来指示选择子索引的描述符是在 GDT 中,还是 LDT 中。选择子的高 13 位,即第 3~15 位是描述符的索引值,用此值在 GDT 中索引描述符,是 8192,故最多可以索引 8192 个段。
image.png
选择子的作用主要是确定段描述符,确定描述符的目的,一是为了特权级、界限等安全考虑,最主要 的还是要确定段的基地址。
GDT 中的第 0 个段描述符是不可用的,原因是定义在 GDT 中的段描述符是要用选择子来访问的,如果使用的选择子忘记初始化,选择子的值便会是 0,这便会访问到第 0 个段描述符。因此若现在访问到 GDT 中的第 0 个描述符,处理器将发出异常。
局部描述符表,叫 LDT, Local Descriptor Table,它是 CPU 厂商为在硬件一级原生支持多任务而创造的表,按照 CPU 的设想,一个任务对应一个 LDT。但其实在现代操作系统中很少有用 LDT 的。
LDT 中的第 0 个段描述符时可用的,因为提交的选择子中的 TI 位用于指定是 GDT 还是 LDT,即 TI 为 1 必然是经过显式初始化。

4.3.3 打开 A20 地址线

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

  • 如果 A20Gate 被打开,当访问 0x100000~0x10FFEF 之间的地址时, CPU 将真正访问这块物理内存。
  • 如果 A20Gate 被禁止,当访问 0x100000~0x10FFEF 之间的地址时, CPU 将采用 8086/8088 的地址回绕。(内存进位到 1MB 以上时,会丢掉进位 1)

打开 A20Gate 的方式,就是将端口 0x92 的第 1 位置置 1 即可:

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

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

这一步将突破 1MB 内存的束缚,踏入 4G。
控制寄存器是 CPU 的窗口,既可以用来展示 CPU 的内部状态,也可用于控制 CPU 的运行机制。
我们用 CR0 寄存器的第 0 位,即 PE 位,Protection Enable,来启用保护模式。
image.png
image.png
PE 为 0 表示在实模式下运行, PE 为 1 表示在保护模式下运行。示例代码:

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

4.3.5 进入保护模式(实验)

将待读入的扇区数由 1 改到 4,即 mbr.S 的第 52 行改为:

1
2
mov cx, 4						# 读入扇区数
call rd_dist_m_16

添加配置信息:

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
;---------------loader and kernel---------------

LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2

;---------------GDT 描述符属性------------------
DESC_G_4K equ 1_00000000000000000000000b ;颗粒度:4K
DESC_D_32 equ 1_0000000000000000000000b ;操作数和地址大小:32位
DESC_L equ 0_000000000000000000000b ;是否是64位代码段:否
DESC_AVL equ 0_00000000000000000000b ;不用此位,暂设置为:0
DESC_LIMIT_CODE2 equ 1111_0000000000000000b ;段界限19-16位
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2 ;段界限19-16位
DESC_LIMIT_VIDEO2 equ 0000_000000000000000b ;?????
DESC_P equ 1_000000000000000b ;表示段存在
DESC_DPL_0 equ 00_0000000000000b ;特权级:0
DESC_DPL_1 equ 01_0000000000000b ;特权级:1
DESC_DPL_2 equ 10_0000000000000b ;特权级:2
DESC_DPL_3 equ 11_0000000000000b ;特权级:3
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 ;Type字段-代码段:x=1,c=0,r=0,a=0
DESC_TYPE_DATA equ 0010_00000000b ;Type字段-数据段:x=0,e=0,w=1,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_DATA + 0x0B

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

这里要注意一个问题:在 DESC_VIDEO2_HIGH4 中,即显卡段的高 4 字节部分,显存的起始地址是 0xb8000,在段描述符低 4 字节中段基址 0-15 位存储的是 0x8000,所以段描述符高 4 字节最初 8 位是段基址的 23-16 位的值应该是 0xB,而不是 0x00。

上述配置要对照段描述符格式,了解其内在含义:
image.png
下面介绍 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
%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 = 7
dd DESC_VIDEO_HIGH4;此时dpl为0

GDT_SIZE equ $ - GDT_BASE ;获取 GDT 大小
GDT_LIMIT equ GDT_SIZE - 1 ;获取 段界限
times 60 dq 0 ;预留60个空位,为以后填入中断描述符表和任务状态段TSS描述符留空间 (times 60 表示后面的内容循环60次,是nasm提供的伪指令)
SELECTOR_CODE equ (0x0001 << 3) + TI_GDT + RPL0 ; 相当于(CODE DESC - GOT BASE) /8 + TI_GDT + RPLO
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 ;字符串长度
mov ax, 0x1301 ;AH=13h,AL=01h
mov bx, 0x001f ;页号为0(BH=0h),蓝底粉红字(BL=1fh)
mov dx, 0x1800 ;
int 0x10 ;int 10 BIOS中断

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


;------------ 1. 打开A20 ------------------
in al, 0x92
or al, 00000010B
out 0x92, al

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


;------------ 3.将CR0的PE位置1 ------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线


[bits 32] ;编译成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 $

代码解释:

  • 第 3 行的LOADER_STACK_TOP适用于 loader 在保护模式下的 esp 初始化,所以用了相同的内存地址作为栈顶。LOADER_BASE_ADDR的值是0x900,这是 loader 被加载到内存中共的位置,在此地址之下便是栈。

  • dd 是伪指令,意为 define double-word,即定义双字变量,一个字是 2 字节,所以双字就是 4 字节数据。程序编译后的地址是从上到下越来越高。

  • 6~17 行是在构建 GDT,并直接填充段描述符;

  • DATA_STACK_DESC是数据段和栈段的段描述符,这里数据段和栈段共同使用一个段描述符。其定义的原理和CODE_DESC一样。按理说,栈应该是向下扩展的,数据段是向上扩展的,一个段描述符只能定义一种扩展方向,type 字段中的 e 要么是 0(向上扩展),要么是 1(向下扩展)。栈也能用向上扩展的数据段吗?当然可以,只不过在这种情况下,栈段的段界限按照数据段的规则来检查了。段描述符中的各字段只是用来供 CPU 检查的,CPU 不知道此段是用来干什么的,只有用此段的人才知道。栈段向下扩展,是指栈指针 esp 指向的地址逐渐减小,不过那是 push 指令的作用,和段描述符的扩展方向无关,此扩展方向是用来配合段界限的, CPU 在检查段内偏移地址的合法性时,就需要结合扩展方向和段界限来判断。而且,用向上扩展的数据段做栈段,比用向下扩展的段更容易。我们还是挑简单的来,直接用普通的数据段做栈段,所以 type 中的 e 为 0。

  • 对于显存段描述符VIDEO_DESC,文本模式显示适配器的内存地址是0xb8000~0xbffff,内存地址 0xc0000 显示适配器 BIOS 所在区域。为了方便显存操作,显存段不采用平坦模型。我们直接把段基址置为文本模式的其实地址 0x8000,段大小为 0xbfff-0xb8000=0x7fff,段粒度为 4k,因而段界限 limit 等于 0x7fff/4k=7.

  • 19~20 行,显示通过地址差来获得 GDT 的大小,进而用 GDT 大小-1 得到段界限,这是为了加载 GDT 做准备;

  • 21 行是为了将来往 GDT 中添加其他描述符,提前保留空间。以后要往 GDT 中添加中断描述符表 IDT 和任务状态段 TSS 描述符。dq 用来定义了 8 字节数据,即 define quad-word,定义 4 字即 8 字节。times 是 nasm 提供的伪指令,用来重复执行 times 后面的表达式。

  • 22~24 行是构建代码段、数据段、显存段的选择子;

  • 28~29 行是定义 GDT 的指针,此指针是 lgdt 加载 GDT 到 gdtr 寄存器时用的;

  • 30 行定义一个字符串,用于显示一下要进入保护模式了;

  • 52 行的 BIOS 调用,利用 int 0x10 打印字符串的功能;

  • 55 行的“mov dx, 0x18000”,其中行数 dh 为 0x18,列数 dl 为 0x00。由于在文本模式下的行数是 25 行,即 0~24 行,所以 0x18 的十进制为 24,即最后一行,所以,“2 loader in real.”将出现在最后一行的行首。

  • 58~76 行是进入保护模式的三个步骤,分别如下:

    1. 打开 A20 地址线;
    2. 在 gdtr 寄存器中在家 GDT 的地址及偏移量(界限值);
    3. 将 cr0 寄存器的 pe 位置 1;
  • 78 行跳入的地址是 82 行的 p_mode_start,78~82 行之间没有任何指令或数据为什么还要用跳转指令?为了刷新流水线。

    流水线是 CPU 的工作方式,会把当前指令和后面的几个指令同时放在流水线中重叠执行,由于之前的代码是 16 位,接下来的代码变成 32 位了,指令按照 16 位进行译码会出错,通过刷新流水线可以解决这个问题。

  • 83~89 行,是用选择子初始化成各段寄存器;

  • 90 行,是往显存第 80 个字符的位置(第 2 行首字符的位置)写入字符 P。默认的文本显示模式是 8025,即每行是 80 个字符(0~79),每个字符占 2 字节,故传入偏移地址是 802=160。

image.png

4.4 处理器微架构简介

4.4.1 流水线

流水线是 CPU 提高效率的一种出路,以后介绍的各种优化方法,其实都是围绕如何让流水线更加有效而展开的。

4.4.2 乱序执行

乱序执行,是指在 CPU 中运行的指令并不按照代码中的顺序执行,而是按照一定的策略打乱顺序执行,也许后面的指令先执行。当然,得保证指令之间不具备相关性。
x86 发展到后来,虽然还是 CISC 指令集,但其内部已经采用 RISC 内核,译码对于 x86 体系来说,除了按照指令格式分析机器码外,还要将 CISC 指令分解成多个 RISC 指令。当一个“大”操作被分解成多个“微”操作时,它们之间通常独立无关联,所以非常适合乱序执行。
乱序执行的好处就是后面的操作可以放到前面来做,利于装载到流水线上提高效率。

4.4.3 缓存

缓存的原理是用一些存取速度较快的存储设备作为数据缓冲区,避免频繁访问速度较慢的低速存储设备,归根结底的原因是低速存储设备是整个系统的瓶颈。
前面在介绍实模式下的寄存器时,也说到了 CPU 中的缓存。 CPU 中有一级缓存 L1 、二级缓存 L2,
甚至三级缓存 L3 等。它们都是 SRAM,即静态随机访问存储器,它是最快的存储器。寄存器跟 SRAM 在速度上是同一级别的东西,因为寄存器和 SRAM 都是用相同的存储电路实现的, 用的都是触发器,它可是工作速度极快的,属于纳秒级别。
什么时候能缓存昵?可以根据程序的局部性原理采取缓存策略。

4.4.4 分支预测

CPU 中的指令是在流水线上执行。分支预测,是指当处理器遇到一个分支指令时,是该把分支左边的指令放到流水线上,还是把分支右边的指令放在流水线上呢?
举例说明:

1
2
3
4
5
6
void main() {
int i = 0;
while (i < 10) {
i++;
}
}

里面的 while 结构,就是执行了 10 次 i++。

1
gcc -S -o ~/test/while.S ~/test/while.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
	.file"while.c"
.text
.global main
.type main, @function
main:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
mov1 $0, -4(%ebp)
jmp .L2
.L3:
addl $1, -4(%ebp)
.L2:
cmpl $9, -4(%ebp)
jle .L3
leave
ret
.size main, .-main
.ident "GCC: (GNU) 4.4.6 20120305 (Ret Hat 4.4.6-4) "
.section .note.GNU-stack,"",@progbits

这个生成的汇编语言并不是我们熟悉的 Intel 语法,而是 AT&T 语法。
简要说明:

  • 前 4 行用于声明代码段,导出 main 函数符号;
  • 第 5 行是 main 函数的其实地址,高级语言中函数名在汇编语言中只是个符号,而符号便是地址;
  • 局部变量是在栈中分配空间的,所以第 6~8 行是在创建堆栈框架,也就是为局部变量 i 在栈中分配空间,-4(%ebp)便是指局部变量 i;
  • 第 9 行是为变量 i 赋值为 0;AT&T 语法中,寄存器前要用%来指示,立即数前要用$来指示。-4(%ebp)表示内存地址"ebp 寄存器的值减 4"处的内存内容,相当于 Intel 汇编语法形式[ebp-4]。AT&T 语法中是源操作数在左,目的操作数在右,和 Intel 语法相反。所以第 9 行是将 0 送入了变量 i 所在的栈空间。
  • 第 10 行就是简单的无条件跳转,直接进入 while 循环结构丶条件表达式判断,也就是第 13 行;
  • 第 14 行就是 while 括号中条件表达式,用变量 i 的值和立即数 9 作比较;
  • 第 15 行的 jle 的意思是若 14 行的比较结果是小于等于 9,则跳到 11 行,继续执行第 12 行的加法,可见 11-12 行是循环体;
  • 程序执行流是由第 15 行跳到第 11 行,这样组成了循环结构的回路;程序执行 while 循环后就结束了,所以局部变量 i 所在的栈空间要被回收,第 16 行的指令 leave 用于堆栈框架的回收工作;
  • 第 17 行是 main 函数退出。由于 main 也是被调用的,所以 gee 显式地帮咱们加了个 ret 以示退出;

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

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

Intel 的分支预测不见中用了分支目标缓冲器(Branch Target Buffer,BTB),BTB 中记录着分支指令地址,CPU 遇到分支指令时,先用分支指令的地址在 BTB 中查找,若找到相同地址的指令,根据跳转统计信息判断是否把响应的预测分支地址上的指令送上流水线。在真正执行时,根据实际分支流向,更新 BTB 中跳转统计信息。
image.png
如果 BTB 中没有相同记录可以使用 Static Predictor,静态预测器。静态预测器中存储的预测策略是固定写死的,它是人们经过大量统计之后,根据某些特征总结出来的。

比如,转移目标的地址若小于当前转移指令的地址,则认为转移会发生,因为通常循环结构中都 用这种转移策略,为的是组成循环回路。所以静态预测器的策略是:若向上跳转则转移会发生,若向下跳 转则转移不发生,如图 4-15 所示。

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

4.5 使用远跳转指令清空流水线,更新段描述符缓冲寄存器

为什么使用 jmp 远转移,是因为我么有两个问题要解决:

  1. 首先,段描述符缓冲寄存器未更新,它还是实模式下的值,进入保护模式后需要填入正确的信息。32 位段描述符缓冲寄存器是为了加速段描述符中信息的访问而设的,如今的 32 位 CPU 的保护模式也依然要用到段描述符缓冲器。32 位 CPU 虽然兼容实模式,但在实模式下运行时段描述符缓冲寄存器存的是段基址左移 20 位的记录,也就是说在实模式下的段描述符缓冲寄存器中只有低 20 位有效,用于存储段基址,其他位都为 0。所以在保护模式和实模式下的段描述符缓冲寄存器中的数据是不同的,但在不重新引用一个段时,段描述符缓冲寄存器中的内容是不会更新的,这必然会造成错误,所以需要马上更新段描述符缓冲寄存器,也就是要想办法往相应段寄存器中加载选择子。
  2. 流水线中指令译码错误。CPU 采用流水线时指令间是重叠执行的。76 行前的指令是 16 位的,76 行后 CPU 进入保护模式,故 78 行的指令已经是在保护模式下了,但它依然还是 16 位的指令,相当于处于 16 位保护模式下。为了让其使用 32 位偏移地址,所以添加了伪指令 dword,故其机器码前会加 0x66 反转前缀。而第 81 行后的代码是在[bits 32]后,所以全是 32 位指令。但是在第 76 行代码执行时,第 78 行和之后的部分指令已经被送上流水线了,但是段描述符缓冲寄存器在实模式下时已经在使用了,流水线上的指令全是按照 16 位操作数来译码的。这就出问题了,83 行开始的指令明明是 32 位指令,而 CPU 却按照 16 位指令格式来译码,故译码之后在其执行时必然是错误的。

所以解决问题的关键就是既要改变代码段描述符缓冲寄存器的值,又要清空流水线。
如何改变段寄存器?
代码段寄存器 cs,只有用远过程调用指令 call、远转移指令 jmp、远返回指令 retf 等指令间接改变,没有直接改变 cs 的方法,如直接 mov cs, xx 是不行的。
如何清空流水线?
之前介绍过了流水线原理, CPU 遇到 jmp 指令时,之前已经送上流水线上的指令只有清空,所以 jmp 指令有清空流水线的神奇功效。
故,用无条件远跳转指令 jmp 来解决上述问题是一举两得的方法。

由于jmp dword SELECTOR_CODE: p_mode_start己经身处保护模式,所以 CPU 将此指令中的SELECTOR_CODE 认为是选择子。因为当前段描述符缓冲寄存器中的 D 位是 0. 所以操作数是 16 位,当前属于 16 位保护模式。故,在这里也可以把 dword 去掉,毕竟当前操作数大小就是 16 位。而且 p_mode_start 的地址并没有超过 16 位,用 dword 表示的 32 位地址井没有发挥其功效。这 两者的区别见表 4-16。

image.png
第一行的机器码为 66ea4b0b00000800。加了伪指令 dword 后,编译器引用 32 位地址,所以加了0x66 是指反转操作数大小前缀。 第二行的机器码为 ea480b0800,这是引用的 16 位地址。 至于操作数中的偏移地址,一个是 0xb4b,一个是 0xb48,它们之间差了 3,是由不同指令本身所占空间不同导致的,这两个指令的机器码大小确实是差了 3 字节。

4.6 保护模式之内存段的保护

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

当引用一个内存段时,实际上就是往段寄存器中加载个选择子,加载选择子要避免出现非法引用内存段的情况。

为此处理器会在以下几方面做出检查:

  1. 根据选择子的值验证段描述符是否超越界限。选择子的索引值一定要小于等于描述符表中描述符的个数。每个段描述符是 8 字节,所以选择子的索引值要满足:

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

  1. 检查段的类型。检查段描述符的 type 字段,主要是检查段寄存器的用途和段类型是否匹配,主要原则:
    • 只有具备可执行属性的段(代码段)才能加载到 CS 段寄存器中;
    • 只具备执行属性的段(代码段)不允许加载到除 CS 外的段寄存器中;
    • 只有具备可写属性的段(数据段)才能加载到 SS 栈段寄存器中;
    • 至少具备可读属性的段才能加载到 DS 、 ES、FS、 GS 段寄存器中;

image.png

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

通常段描述符中的 P 位,其值由软件(通常是操作系统)来设置,由 CPU 来检查。 A 位由 CPU 来设置。

4.6.2 代码段和数据段的保护

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

实际段界限的值为:(描述符中段界限+1) * (段界限的粒度大小: 4k或1) - 1
实际的段界限大小,是段内最后一个可访问的有效地址。在 32 位保护模式下,段基址存放在 CS 寄存器中,段内偏移地址放在 EIP 寄存器中。但指令本身也是有长度的,所以实际要满足:
EIP中的偏移地址+指令长度-1<=实际段界限大小
如果不满足条件,如图 4-17 所示,指令未完整落在本段内,CPU 则会抛出异常。
image.png

4.6.3 栈段的保护

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

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

image.png
32 位保护模式下棋的战顶指针是 esp 寄存器,枝的操作数大小是由 B 位决定的,我们这里假设 B 为 1,即操作数是 32 位。栈段也是位于内存中,所以它也要受控于段描述符中的 G 位。

  • 如果 G 为 0,实际的段界限大小=描述符中的段界限。
  • 如果 G 为 1,实际的段界限大小=描述符中段界限*0x1000+0xFFF

每次向栈中压入数据时就是 CPU 检查栈段的时机,它要求必须满足以下条件:
实际段界限+1<= esp-操作数大小 <= 0xFFFFFFFF
由于 esp 只是栈段内的偏移地址,其真正物理地址还要加上段基址。假设段基址为 0,故该栈段:
最大可访问地址为 0+0xFFFFFFFF=0xFFFFFFFF
最小可访问地址为 0+0xFFFFDFFF+1=0xFFFFF000
栈段空间大小为 0xFFFFFFFF-0xFFFFE000=8KB

相关文章
评论
分享
  • 《操作系统真象还原》:第八章 内存管理系统

    8.1 makefile 简介 这部分可参考阮一峰的讲解:https://www.ruanyifeng.com/blog/2015/02/make.html 8.1.1 makefile 是什么 makefile 是 Linu...

    《操作系统真象还原》:第八章 内存管理系统
  • 《操作系统真象还原》:第七章 中断

    7.1 中断是什么,为什么要有中断 运用中断能够显著提升并发,从而大幅提升效率。 7.2 操作系统是中断驱动的 略 7.3 中断分类 把中断按事件来源分类,来自 CPU 外部的中断就称为外部中断,来自 CPU 内部的中断称为内部...

    《操作系统真象还原》:第七章 中断
  • 《操作系统真象还原》:第六章 完善内核

    6.1 函数调用约定简介 咱们实验使用cdecl。这里提一下stdcall,cdecl与stdcall的区别在于由谁来回收栈空间。 stdcall是被调用者清理参数所占的栈空间。 举例来说: 12int subtract(int ...

    《操作系统真象还原》:第六章 完善内核
  • 《操作系统真象还原》:第五章 保护模式进阶——加载内核

    5.3 加载内核 5.3.1 用 C 语言写内核 第一个 C 语言代码: 1234int main(void) { while(1); return 0;} 这个内核文件什么都没做,通过while(1)这个死循...

    《操作系统真象还原》:第五章 保护模式进阶——加载内核
  • 《操作系统真象还原》:第五章 保护模式进阶——内存分页机制

    从这一刻起,我们才算开始了真正的操作系统学习之旅 5.1 获取物理内存容量 5.1.1 Linux 获取内存的方法 在 Linux 2.6 内核总是用detect_memory函数来获取内存容量的。其函数本质上是通过调用 BI...

    《操作系统真象还原》:第五章 保护模式进阶——内存分页机制
  • 《操作系统真象还原》:第三章 完善MBR——I/O接口

    3.3 让我们对显示器说点什么吧 3.3.1 CPU 如何与外设通信——IO 接口 IO 接口功能: 设置数据缓冲,解决 CPU 与外设的速度不匹配 设置信号电平转换电路 设置数据格式转换 设置时序控制电路来同步 CPU 和外部...

    《操作系统真象还原》:第三章 完善MBR——I/O接口
  • 《操作系统真象还原》:第三章 完善MBR——CPU的实模式

    针对汇编 几个知识点: 第 1 行和第 4 行的 mov 操作,机器码第 1 个宇节都是B8,而另外第 2、3 行同样是 mov 指令,机器码却有天壤之别,似乎找不到共性。原因是机器码是由很多部分组成的,比如指令前缀、主操作码字...

    《操作系统真象还原》:第三章 完善MBR——CPU的实模式
  • 《操作系统真象还原》:第二章 编写 MBR

    先了解 CPU 的两种工作模式:实模式和保护模式 实模式(英语:Real mode)是 Intel 80286 和之后的 x86 兼容 CPU 的操作模式。实模式的特性是一个 20 比特的区段存储器地址空间(意思为只有 1MB 的存...

    《操作系统真象还原》:第二章 编写 MBR
  • 《操作系统真象还原》:第一章 环境配置

    第 0 章:一些你可能正感到迷惑的问题 摘记 0.28 MBR、EBR、DBR 和 OBR 各是什么 MBR 位于整个硬盘最开始的块, EBR 位于每个子扩展分区,各子扩展分区中只有一个逻辑分区。 MBR 和 EBR 位于分...

    《操作系统真象还原》:第一章 环境配置
  • 6月阅读总结

    “零拷贝”技术 Sogou C++ Workflow:搜狗公司的 C++服务器引擎,支持 500k QPS Reducing CPU scheduler latency in Linux:CPU 调度算法 BMQ 和 CFS 的对比...

    6月阅读总结