笔记03.1 - Lab 1:boot.S

解析boot.S

以下是 JOS Boot Loader 部分关于 boot.S 的解析,关于 boot.S 的介绍请阅读【学习笔记03 - Lab 1:Booting a PC】
以下内容摘抄自《系统的启动和初始化》一书

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
#include <inc/mmu.h> 

# 启动 CPU ,切换到 32 位保护模式,跳转到 C 代码
BIOS 将这份代码从硬盘的第一个扇区读取到内存的 0x7c00 物理地址上
# 设置 %cs=0 %ip=7c00 并在 real mode 下开始执行

# 首先 boot 程序会进行初始化,先把代码段选择子与数据段选择子以及保护模式
# 启动标识设置为常量,然后关中断并且将 ds、 es、 ss 这些段寄存器全部清零
.set PROT_MODE_CSEG, 0x8 # 内核代码段选择子常量,gdt 表中第二项
.set PROT_MODE_DSEG, 0x10 # 内核数据段选择子常量,gdt 表中第三项
.set CR0_PE_ON, 0x1 # 内核保护模式启动标识位 常量

.globl start
start:
.code16 # 16 位模式
cli # 关中断,只能在内核模式下执行
cld # 使方向标识位 DF 复位,内存地址向高地址增加

# 设置重要的数据段寄存器(DS, ES, SS)
xorw %ax,%ax # ax 清零
movw %ax,%ds # 数据段寄存器清零
movw %ax,%es # 附加段寄存器清零
movw %ax,%ss # 堆栈段寄存器清零

# A20对实模式和保护模式的影响:
# 实模式下:在8086/8088中,分段模式能够表示的最大内存为FFFFh:FFFFh=FFFF0h+FFFFh=10FFEFh=1M+64K-16Bytes(1M多余出来的部分被称做高端内存区HMA)。但8086/8088只有20位地址线,如果访问100000h~10FFEFh之间的内存,则必须有第21根地址线。所以当程序员给出超过1M(100000H-10FFEFH)的地址时,系统并不认为其访问越界而产生异常,而是自动从重新0开始计算,系统计算实际地址的时候是按照对1M求模的方式进行的,这种技术被称为wrap-around。到了80286,系统的地址总线发展为24根,如果A20 Gate被打开,则当程序员给出100000H-10FFEFH之间的地址的时候,系统将真正访问这块内存区域;如果A20 Gate被禁止,则当程序员给出100000H-10FFEFH之间的地址的时候,系统仍然使用8086/8088的方式。在80286以及更高系列的PC中,即使A20 Gate被打开,在实模式下所能够访问的内存最大也只能为10FFEFH,尽管它们的地址总线所能够访问的能力都大大超过这个限制。
# 保护模式下:如果A20 Gate被禁止,则其第20-bit在CPU做地址访问的时候是无效的,永远只能被作为0;如果A20 Gate被打开,则其第20-bit是有效的,其值既可以是0,又可以是1。所以,在保护模式下,如果A20 Gate被禁止,则可以访问的内存只能是奇数1M段,即1M,3M,5M…,也就是00000-FFFFF, 200000-2FFFFF,300000-3FFFFF…。如果A20 Gate被打开,则可以访问的内存则是连续的。
# 下面的代码打开 A20 地址线
seta20.1:
inb $0x64,%al # 从 0x64 端口读入一个字节的数据到 al 中
# 等待空闲的时候

testb $0x2,%al # 测试 al 的第 2 位是否为 0
jnz seta20.1 # 如果 al 的第 2 位不为 0,循环检查

movb $0xd1,%al # 将 0xd1 写入到 al 中
outb %al,$0x64 # 将 al 中的数据写入到端口 0x64 中

seta20.2:
inb $0x64,%al # 从 0x64 端口读入一个字节的数据到 al 中
# 等待空闲的时候

testb $0x2,%al # 测试 al 的第 2 位是否为 0
jnz seta20.2 # 如果 al 的第 2 位不为 0,循环检查

movb $0xdf,%al # 将 0xdf 写入到 al 中
outb %al,$0x60 # 将 al 中的数据写入到端口 0x60 中

# 将系统从实模式切换到保护模式
# 首先用 “lgdt gdtdesc” 这条指令将 GDT 表的首地址加载到 GDTR, 然后将 cr0 寄存器的最低位置 1, 标志着系统进入保护模式,最后用一个跳转指令让系统开始使用 32 位的寻址模式。

# 在装载 cr0 之前需要先使用指令 lgdt gdtdesc 加载段表,原因是:
# 开启保护模式之后,基址:偏移 这种寻址方式就变成了 段选择子:偏移 这种方
# 式,而所谓的段选择子就是段表中的索引,因此为了正确的进行段式地址变换,还
# 需要加载段表。
lgdt gdtdesc # 将全局描述符表标识符加载到全局描述符表寄存器

# 开启保护模式
# cr0 中的第 0 位为 1 表示处于保护模式
# cr0 中的第 0 位为 0 表示处于实模式
movl %cr0, %eax # 把控制寄存器 cr0 的数据加载到 eax 中
orl $CR0_PE_ON, %eax # 将 eax 中的第 0 位设置为 1
movl %eax, %cr0 # 将 eax 中的值装入 cr0 中

# 跳转到 32 位模式中的下一条指令,将处理器切换为 32 位工作模式
# 由于是在保护模式中,所以 $PROT_MODE_CSEG 被当作段选择子,而 $protcseg 是偏移地址。从后面的 GDT 表中可以看到,段选择子的值是 0x8,于是对应的段描述符会是表中的第二项(因为段选择子的低三位表示了RPL和TI),即是 SEG(STA_X|STA_R, 0x0, 0xffffffff)这一项,0x0 表示段首地址是 0,所以最终得到的线性地址为 0+$protcseg,程序便会跳到 protcseg 所标识的位置来执行。由于此时尚未开启分页,因此该链接地址会被当做加载地址,所以 boot loader 的加载地址必须和链接地址保持一致。 lab1 中的 boot/Makefrag 文件的第 28 行实际上规定了 Boot Loader 的固定链接地址是 0x7C00 。
# 将代码段选择子常量 $PROT_MODE_CSEG 加载到 cs 中,cs 对应的高速缓冲存储器会自动加载代码段描述符,同样将 $protcseg 加载到 ip 中(此后如果没有修改段寄存器的内容,cs 的内容将不会改变)。

ljmp $PROT_MODE_CSEG, $protcseg

# 进入保护模式后,程序重新对段寄存器进行初始化并且赋值堆栈指针,然后调用
# bootmain 函数。可以看到,在 “call bootmain” 之后便是一个无限循环的跳转指令,
# 之所以是无限循环就是这个函数调用永远都不会有返回的可能性,这句程序仅仅只是
# 让整个代码看起来有完整性。
.code32 # 32 位模式
protcseg:
# 设置保护模式下的数据寄存器
movw $PROT_MODE_DSEG, %ax # 将数据段选择子常量装入到 ax 中
# 将 ax 装入到其他数据段寄存器中,在装入的同时,这些段寄存器对应的高速缓冲
# 寄存器会自动加载数据段描述符
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment

# 设置栈指针,并且调用 main.c 中的 bootmain 函数
# 值得注意的是,这是进入保护模式之后第一个栈顶,在任何函数调用前都要初始化栈,boot.S 里很巧妙的将 start 作为栈的基址,因为栈空间是向下增长的,$start 的地址是 0x7c00,在正式的设定之前,0-start 这段空间做为栈,应该足够了。
movl $start, %esp
call bootmain

# 如果 bootmain 返回的话,就一直循环
spin:
jmp spin

.p2align 2 # 强制 4 字节对齐
# GDT 表的存放位置是 4 字节对齐的,也
# 就是说 GDT 表的物理首地址是 4 的倍数
# GDT 全局描述符表描述符
gdt:
SEG_NULL # 空表项,连续 8 个值为 0 的 字节
SEG(STA_X|STA_R, 0x0, 0xffffffff) # 代码段表项
SEG(STA_W, 0x0, 0xffffffff) # 数据段表项
# SEG 宏的第一个参数是 type,第二个是base,
# 第三个是 limit,所以我们可知定义的第二、
# 第三个段均是基址为 0,长度是 4G 的段

# 全局描述符表对应的描述符
gdtdesc:
.word 0x17 # gdt 表长度 - 1,说明 gdt 表长度为 24 字节
.long gdt # gdt 表物理地址

补充概念

物理地址:将主板上的物理内存条所提供的内存空间定义为物理内存空间,把内存看成一个从 0 字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址。
逻辑地址:逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。Intel中段式管理中,对逻辑地址要求:一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量],也就是说,给定 0xFFFFFFFF ,应该表示为[A的代码段标识符: 0xFFFFFFFF],这样才完整一些。
线性地址:也叫链接地址。Intel为了兼容,将远古时代的段式内存管理方式保留了下来。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址(这种情况下,个人认为逻辑地址的概念已经由[段标识符:段内偏移量]变为[段内偏移量]了)。Linux中逻辑地址等于线性地址。jos 的代码段和数据段基址都是从 0x0 开始,长度 4G(内核gdt指定),这样线性地址=逻辑地址+ 0x0,相当于禁用了段机制的寻址功能,也就是说逻辑地址等于线性地址了。这样的情况下Linux只用到了GDT,不论是用户任务还是内核任务,都没有用到LDT。线性地址空间是指一段连续的、不分段的、范围为 0~4GB 的地址空间。一个线性地址就是线性地址空间的一个绝对地址。
虚拟内存空间中的地址转换为物理地址: CPU 将一个虚拟内存空间中的地址转换为物理地址,需要进行两步:首先将给定一个逻辑地址(其实是段内偏移量,这个一定要理解!!!),逻辑地址实际上就是程序自己假设在内存中存放的位置,即编译器在编译的时候会认定程序将会连续的存放在从起始处的线性地址开始的内存空间,于是像 protcseg 这样的地址标识符就被编译成了那段代码开始处的偏移地址。 CPU 要利用其段式内存管理单元,先将逻辑地址加上基址,转换成一个线性地址,再利用其页式内存管理单元,转换为最终物理地址。JOS 实验的 Boot Loader 阶段段基地址为0,偏移量即为逻辑地址,由于没有启用分页,线性地址甚至直接使用为物理地址。后续操作中,内核将被加载到低地址,但使用高地址进行访问,其映射工作就是通过开启分页机制来完成的。

Boot Loader 的起始链接地址:lab1 中的 boot/Makefrag 文件的第 28 行实际上规定了 Boot Loader 的链接地址:$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o $@.out $^。其中“ 0x7C00”便是规定的链接地址。假设我们修改了 Boot Loader 的起始链接地址(不再是固定的 7c00 ),重新编译代码后,boot.asm 反汇编文件展示的指令代码地址和指令跳转全是按照链接地址重新计算过的。首先我们先在物理内存的 0x7c00 处设置一个断点,因为 Boot Loader 一定会加载在这个位置,所以 0x7c00 处的第一条指令便是 boot.S 的第一条可执行指令。gdb 对应的是物理地址,单步调试时每一行显示的是物理地址。 Boot Loader 加载到 0x7c00 后,由于尚未涉及到跳转,因此取下一条指令时只要 ip 累加即可继续正确执行。涉及到 jne 等跳转指令时,如果条件未满足,仍然继续向下取指令执行。当碰上如 ljmp 等指令进行跳转时,目标链接地址是重新计算过的,该地址在 boot.asm 中对应的指令并不同于此时内存中相应位置的指令,因此当以该地址取内存中的指令时,导致出错。综上,为了正确地跳转到下一条指令的位置,需要使加载地址等于链接地址,因此 Boot Loader 的起始链接地址必须固定为 0x7c00 。

显示 Gitment 评论