MBR --> Loader --> Kernel
本节代码只涉及 MBR 和 Loader 部分,暂未考虑内核代码。同时,为规范操作,我们使用 [加载器-用户程序] 方式将 Loader 从硬盘载入内存。这种方式非常漂亮,同时能让你理解重定位的本质,详细请阅读程序加载器-重定位 (本节前置要求,务必阅读)。

本节代码对应分支 protected-mode

配置文件
本节的 MBR 可以直接引用 程序加载器 一文中的 MBR 代码,并在文件头引入配置文件:

1
2
3
4
%include "loader.inc"

SECTION mbr align=16 vstart=0x7c00
;....................以下省略....................

其中 loader.inc 文件内容如下:

1
2
3
;文件说明:loader.inc
BASE_ADDR equ 0x900 ;最好不超过0xFFFF,原因在下文解释
START_SECTOR equ 2 ;从硬盘的第2(lba)扇区将加载器载入内存

接着,定义保护模式的配置文件 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
;文件说明:boot.inc ,包含保护模式中要用到的段选择子,描述符等内容
;以下为段描述符的子属性
DESC_G_4K equ 1000_0000_0000_0000_0000_0000B
DESC_DB_32 equ 100_0000_0000_0000_0000_0000B
DESC_L equ 00_0000_0000_0000_0000_0000B
DESC_AVL equ 0_0000_0000_0000_0000_0000B
DESC_LIMIT_CODE2 equ 1111_0000_0000_0000_0000B
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2
DESC_LIMIT_VIDEO2 equ 0000_0000_0000_0000_0000B
DESC_P equ 1000_0000_0000_0000B
DESC_DPL_0 equ 000_0000_0000_0000B
DESC_DPL_1 equ 010_0000_0000_0000B
DESC_DPL_2 equ 100_0000_0000_0000B
DESC_DPL_3 equ 110_0000_0000_0000B
DESC_S_SYS equ 0_0000_0000_0000B
DESC_S_DATA equ 1_0000_0000_0000B
DESC_TYPE_CODE equ 1000_0000_0000B;只执行1000
DESC_TYPE_DATA equ 0010_0000_0000B;可读写0010

;以下为段描述符的高四位(低四位在loader.s中定义)
DESC_CODE_HIGH4 equ (0x00<<24) + DESC_G_4K + DESC_DB_32 + DESC_L + DESC_AVL +\
DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_DATA +\
DESC_TYPE_CODE + 0X00
DESC_DATA_HIGH4 equ (0x00<<24) + DESC_G_4K + DESC_DB_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_DB_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

下面对以上宏定义进行说明:

  • 将各个子属性进行宏定义,最后相加组成段选择子的高四字节(低四字节后续定义)。相比一大串莫名奇妙的数字,这样更加直观。
  • 第 7, 8 行,将 DATA 和 CODE 的 4 位段界限全设置为 1(后面会将 DATA 和 CODE 的另外16位段界限全设为1),由于 G=1,粒度为 4KB,所以实际段大小为 4GB。第 21,24 行 0x00<<24 以及末尾加上 0x00,这是在将高4字节中的段基址设为 0(后面会将 DATA 和 CODE 的另外16位段基址全设为0),所以段基址为 0。将段基址设为 0,段界限设为 4GB,这样做是为了形成平坦模型 ,即整个内存都在一个段中。平坦模型使用起来很方便,后期我们会慢慢体会到。
  • 第 29 行,为啥最后加的 0x0b?之前说过,文本显示适配器的内存地址为 0xb8000~0xbffff为了方便显存的操作,显存段不使用平坦模型 ,所以将段基址设置为 0xb8000,其中的 b 在段描述符的高 4 字节上,这就是为啥 26 行末尾加 0x0b;显存的段大小为 0xbffff-0xb8000=0x7fff ,粒度为 4KB,因此段界限为 0x7fff÷4KB=7 ,这将在段描述符的低 4 位设置,高 4 位直接设 0 即可。
  • 第 15,16 行,SYS 表明该段是系统段;DATA 不是指数据段,而是相对于 SYS 而言的,代码段/数据段/栈段都属于 DATA 。
  • 以上宏定义并未定义栈段,这是因为此处将栈段和数据段定义在了一起,即 DATA 段。关于为什么栈段和数据段能够放在一个段中,参见内存段与段寄存器保护
  • _ 仅作分隔符,方便阅读,编译时会自动忽略。

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
;文件说明:loader.s
%include "boot.inc"
%include "loader.inc"

SECTION loader vstart=BASE_ADDR ;定义用户程序头部段
program_length dd program_end ;程序总长度[0x00]
;用户程序入口点
code_entry dw start-BASE_ADDR ;偏移地址[0x04]
dd section.loader.start ;段地址[0x06]
realloc_tbl_len dw 0 ;段重定位表项个数为0
;=========================================================
;GDT
;第0描述符不可用
GDT_BASE dd 0x00000000
dd 0x00000000
;第1描述符CODE
DESC_CODE dd 0x0000FFFF
dd DESC_CODE_HIGH4
;第2描述符DATA
DESC_DATA dd 0x0000FFFF
dd DESC_DATA_HIGH4
;第3描述符VIDEO
DESC_VIDEO dd 0x80000007
dd DESC_VIDEO_HIGH4

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
;GDT指针
gdt_ptr dw GDT_LIMIT
dd GDT_BASE

SELECTOR_CODE equ ((DESC_CODE - GDT_BASE)/8)<<3 + TI_GDT + RPL0
SELECTOR_DATA equ ((DESC_DATA - GDT_BASE)/8)<<3 + TI_GDT + RPL0
SELECTOR_VIDEO equ ((DESC_VIDEO- GDT_BASE)/8)<<3 + TI_GDT + RPL0

loader_msg db 'r',11000010b,'e',11000010b,'a',11000010b,'l',11000010b,'-',11000010b
db 'm',11000010b,'o',11000010b,'d',11000010b,'e',11000010b
;=======================================================

start: ;程序入口
mov ax,0 ;转移到loader代码后,
mov ds,ax ;将各段寄存器清0是头等大事
mov es,ax
mov ss,ax
mov gs,ax
mov fs,ax
print:
mov ax,0xb800 ;彩色字符模式视频缓冲区
mov es,ax
mov si,loader_msg ;ds:si
mov di,0 ;es:di
mov cx,18 ;9个字符,占18字节
cld
rep movsb
;========================================================
;1.打开A20
;2.加载GDT
;3.置PE=1
prepare:
;关中断
cli
;打开A20
in al,0x92
or al,0000_0010B
out 0x92,al
;加载GDT
mov ax,0
mov ds,ax
lgdt [gdt_ptr]
;CR0的第0位置1
mov eax,cr0
or eax,0x0000_0001
mov cr0,eax
;此后进入保护模式
jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线,装载CODE选择子
;======================================================
[bits 32]
p_mode_start:
mov ax,SELECTOR_DATA
mov ds,ax
mov es,ax
mov ss,ax
mov esp,BASE_ADDR ;可以找其他合适的地方作为栈顶,这里使用BASE_ADDR
mov ax,SELECTOR_VIDEO
mov gs,ax

mov byte [gs:160],'p'
mov byte [gs:161],11000010b
jmp $
;========================================================
program_end equ $-BASE_ADDR

以上代码的解释:

  • 为什么此 loader 段的 vstart 不能像程序加载器中的 loader 段一样设为 0 ?有以下两个原因:

    1. 注意第 75 行代码,该代码执行后,代码段的段基址为 0(因为CODE段描述符的段基址之前被全设为0了),进入了平坦模式,所以在内存中寻址并取得指令就全靠偏移地址啦!该行代码将直接跳转至 p_mode_start 处。那么,p_mode_start 的值为多少呢?这个问题至关重要。如果我们将 loader 段的 vstart 设为 0,那么标号 p_mode_start 的值就为 79 行代码相对于文件头的偏移量,本文件编译后所得二进制文件大小大概为 172 字节,所以 p_mode_start 相对于文件头的偏移大概为 140(0x8C) 字节,即 p_mode_start=0x8C 。问题在于,我们已经将此 loader 载入到内存 0x900 处,如果跳转到 0x8C 处,显然将执行错误的代码。实际应该跳转到 0x98C 处,而 vstart=BASE_ADDR 便能将 p_mode_start 以 0x900 开始计算偏移,这样就能跳转到正确位置啦!说清楚真不容易。。

    2. 再注意第 30 行的 GDT_BASE。要知道,GDT_BASE 为 32 位段基地址,CPU 是直接在内存中的 GDT_BASE 处来找到 GDT 的 。说到这读者就应该懂了吧?原因和上点相同。

      vstart 不好理解,具体参见 程序加载器

  • 整个 loader 自成一段,这是为了方便。你也可以将以上代码分成数据段和代码段,但务必注意,除 loader 段外的其他段不能用 vstart 修饰

  • 第 8 行,偏移地址为什么是 start-BASE_ADDR ,因为在 MBR 最后的跳转指令(71行) jmp far [0x04] 的效果是 jmp 0x900:偏移地址 ,所以要此处放置的必须是 start 标号相对于本文件开头的偏移量。

  • 第 29 行,注意 GDT 的 LIMIT 等于 SIZE-1(因为偏移从0开始算)。

  • 第 23 行,(DESC_CODE - GDT_BASE)/8 得到索引值(段描述符为8字节),<<3 将索引值移到正确的位置上。

  • 第 61 行,关闭中断。保护模式下的中断机制和实模式不同,原有的中断向量表不再适用,BIOS 中断无法继续使用

  • 第 87 行,之前我们说过,为了方便显存操作,对 VIDEO 段仍使用分段模型而非平坦模型。

  • 注意!最后 program_end equ $-BASE_ADDR 得到整个文件二进制代码的大小。

最后需要单独强调的是,第 41~46 行代码并非必须要执行。这主要针对的是打印信息,即后面要用到的 ds 寄存器。如果 ds 不清零,则后续寻找字符时,ds:loader_msg 就是错误的地址,原因见上面第 1、2 点。然而如果按照我们上面的这种方式,则该 loader 必须加载到内存 0xFFFF 以内!否则打印无法正常进行(保护模式仍然能够正确进入)。这是因为打印时还在实模式,有效地址最大还是 16 位,如果 loader_msg 的标号超过 0xFFFF,那么有效地址就无法容纳 loader_msg。如果想把这 loader 加载到内存任意位置,则无需清零段寄存器,且第 50 行代码需要改为:mov si,loader_msg-BASE_ADDR
执行结果如下:
image-20221105222138942