本章是操作系统从 0 到 1 系列的第四篇文章,主要讲解了 loader 需要完成的任务以及其中涉及到的知识点。本章先总体介绍 loader 的工作,然后逐步实现部分 loader 的工作(虚拟内存之前)并讲解相关知识点,由于虚拟内存相关的知识庞大且重要,我们会在下一章进行详细探讨。
Concepts you may want to Google beforehand: GDT, protected mode, segment register, interrupts, pipeline
# Loader 的工作
总的来说,loader 的工作为以下几项(也是 loader 到 kernel 的步骤):
- 禁用中断
- 使能 A20 地址线(现在的设备都是默认使能的,所以可以跳过)
- 建立 GDT
- 进入保护模式并刷新 cpu 管道、初始化段寄存器和栈
- 建立 kernel 页目录(
page directory
)和页表(page tables
) - 打开虚拟内存(
virtual memory
),进入paging
模式 - 加载
kernel
镜像到内存 - 执行
kernel
代码,控制权转交给kernel
# Loader 编写
# 禁用中断
禁用中断通过设置 IF
flag 来实现,当 IF
flag 被设置为 0 时,系统会屏蔽中断命令;禁用中断非常简单,只需要一行命令即可:
cli |
如果你看了很多教程的话,可能会发现,有的教程中有这行代码,而有的没有,甚至在自己写的 loader 中不禁用中断可能也不会有什么问题,但是,这里是 “可能”,如果中断发生在实模式进入保护模式之前和之后,系统都没有问题,但在模式切换进行时发生中断就会发生异常。这个情况概率比较低,少量的验证是没法复现此问题的,所以我们最好还是加上 cli
命令。
# 使能 A20 地址线
由于现在设备会在启动时自动使能 A20 地址线,所以这一步可以跳过,不过我们的教程为了完整性,还是稍微讲一下。
「A20 其实是指第 21 根地址线,因为地址线是从 0 开始计数的。」
需要该步骤也是历史原因导致的,我们之前讲过,在 CPU 发展早期(8086 时代),CPU 地址线只有 20 位,此时寻址还需要还需要经过转换。经过 CPU 的不断发展(80286 时代),地址线从 20 根扩展到了 24 根,能访问的内存达到了 224M,但是在 CPU 设计时是向下兼容的,所以,80286 CPU 在实模式时表现应该和 8086 CPU 一致,但其实 80286 芯片却存在一个 Bug:因为 80286 有了 A20 地址线,如果程序员访问 100000H ~ 10FFEFH
之间的内存,系统将实际访问这块内存,而不是像 8086/8088 一样从 0 开始。
所以为了解决上述兼容问题,IBM 使用实模式下本应无用的 A20 地址线来进行设置,当 A20 被使能时,程序员给出 100000H ~ 10FFEFH
之间的地址的时候,系统将真正访问这块内存区域;若 A20 为被使能,则会按照实模式下对地址取模进行访问。
使能 A20 的代码非常简单,只需要记住即可
in al, 92h ; 2. enable A20 | |
or al, 00000010b | |
out 92h, al |
# 建立 GDT
什么是GDT?
GDT( Global Descriptor Table)即全局描述符表,又叫段描述符表,本质其实就是一种专用于 IA-32
和 x86-64
体系结构的二进制数据结构,该表位于内存中,具体位置保存在 GDTR
寄存器中,其条目描述并规定了不同内存分区的各种特征,包括基地址、大小和访问特权如可执行、可写等。 在 Intel 的术语中,这些内存区域被称为 segment
。
我们在上一章中提到过,CPU 通过 段基地址
+ 段内偏移
的方式来扩展寻址空间,类似的,在保护模式下,CPU 通过 GDT 中记录的基地址和偏移地址实现寻址,因此可以通过 GDT 中记录的段的信息来进行内存的保护(对比实模式下,所有程序都可以访问任意内存)。
# GDTR
根据手册,GDT 的地址应存储在 GDTR
寄存器中,这个过程通过汇编指令 LGDT
进行加载,参数指向 GDT Descriptor
结构:
- Size:表示 GDT 的大小,以字节为单位,实际大小值为
Size+1
,这是因为Size
的最大值是 65535,但是 GDT 的最大值为 65536(8192 条数据);同时,GDT 的Size
不能为 0 - Offest:GDT 的线性地址(并不是物理地址,而是分页地址)
同时,上图中我们也可以看出 LGDT
命令在 32 位和 64 位下采用不同的方式载入数据, Offest
在 32 位下只有 4 字节长度,而在 64 位下有 8 字节。
# GDT
GDT 中的条目占 8 字节,在内存中的形式如下:
注意,GDT 中的第一个条目 (Entry 0) 永远为空,所以段数据应从第二个条目开始存储;表中的项由段选择器(Segment Selectors)存取,段选择器通过汇编指令或中断等硬件函数加载到段寄存器中。
GDT 中每一项都有一个名为 Segment Descriptor
复杂的结构,该结构长度为 8 Bytes,示意图如下:
- Base:一个 32 位值(不连续的黄色部分),记录了该段的起始地址「图中 Base address 后的括号说明了改部分在整个 Base 中的位数」
- Limit:一个 20 位值(不连续的粉红色部分),记录最大可寻址单元,以 1 字节为单位(最大寻址 1M),或以 1 页(4 KB)为单位;因此,在 32 位下,如果选择以页为单位且 limit 设为
0xFFFFF
,那么该段将覆盖整个 4 GB(4KB*1M )空间「图中括号内数字含义同 Base」
64位下的GDT
上述图片展示的是 32 位模式下的 GDT,而在 64 位模式下,段保护机制基本被废弃了,更多的是使用页机制,因此,服务于段机制的 GDT 表项就少很多;又由于 64 位下引入的 IA-32e
指令集不再兼容老旧的 8086 模式,仅有代码段 CS,数据段 DS 仍和 32 位一样使用 64 位,即 8 字节的描述符,其他的段寄存器例如:DS、ES、SS 这样的段寄存器已经不再使用了,GDT 项存在的意义就更小了。
另一个导致 GDT 在 64 位下不受宠的原因是,其实从 win XP 系统开始,系统进入保护模式后 GDT 的项就没用了,一直就那么空在内存中,甚至还有从系统启动到结束就没用过的表项,不但占地方还给了黑客可趁之机,很多大神看上了这一大片有可读写可执行属性的内存,就把 Shellcode 或者全局数据区布置在这了。
Access Byte:一个 8 位值(40-47),代表了该段的访问权限字段,字段参数及含义如下:
P: 段是否存在,存在 = 1
DPL: 描述符权限级别字段(2 位), 即该段的 CPU 权级. 0 = 最高权限(内核), 3 = 最低权限(用户应用).
S: 描述符类型字段。如果设置为
0
,则定义了一个系统段 (例如 TSS「 Task State Segment 」、LDT「 Local Descriptor Table 」);如果设置为1
,则定义一个代码段或数据段。这两种描述符的 Type 字段结构有所不同,接下来分别来说:
Flags:一个 4 位值(52-55 位)
- G:粒度位,指示
Limit
的单位。0
表示Limit
单位为 1 字节 (字节粒度)。如果设置为1
,则Limit
单位为 4KB 块 (页面粒度)。 - DB:大小位,
0
代表定义了一个 16 位保护模式段,1
表示定义了一个 32 位保护模式段;一个 GDT 可以同时有 16 位和 32 位的位选择器 - L:长模式代码标志,
1
表示该段定义为 64 位代码段,此时DB
应该设置位0
;0
表示为其他类型段 - A:保留位,设为 0 即可
- G:粒度位,指示
# asm
以上就是所有有关 GDT
的理论知识啦,现在让我们动手写一下我们的操作系统的 GDT
吧!梳理一下:
GDT
的第一项必须为空项- 至少定义两个段:代码段、数据段(
base
和length
先一样即可) - 获取
GDT
的地址 - 创建
GDT Descriptor
结构用于后续填充GDTR
32bit-gdt.asm
gdt_start: ; don't remove the labels, they're needed to compute sizes and jumps | |
; the GDT starts with a null 8-byte | |
dd 0x0 ; 4 byte | |
dd 0x0 ; 4 byte | |
; GDT for code segment. base = 0x00000000, length = 0xfffff | |
; for flags, refer to https://gality.cn/os/03-loader/#gdt | |
; again, 1 bytes = 8 bits | 1 word = 2 bytes = 16 bits | |
gdt_code: | |
dw 0xffff ; segment length, bits 0-15 | |
dw 0x0 ; segment base, bits 0-15 | |
db 0x0 ; segment base, bits 16-23 | |
db 10011010b ; flags (8 bits), little-endian, so compare the doc. from P to A (high to low) | |
db 11001111b ; flags (4 bits) + segment length, bits 16-19 | length=0xfffff | |
db 0x0 ; segment base, bits 24-31 | |
; GDT for data segment. base and length identical to code segment | |
; some flags changed, again, https://gality.cn/os/03-loader/#gdt | |
gdt_data: | |
dw 0xffff | |
dw 0x0 | |
db 0x0 | |
db 10010010b | |
db 11001111b | |
db 0x0 | |
gdt_end: ; don't remove this label | |
; GDT Descriptor | |
gdt_descriptor: | |
dw gdt_end - gdt_start - 1 ; size (16 bit), always one less of its true size | |
dd gdt_start ; address (32 bit) | |
;define some constants for later use | |
CODE_SEG equ gdt_code - gdt_start | |
DATA_SEG equ gdt_data - gdt_start |
上面的代码中其实还隐藏了一个知识点,仔细想想也确实会有一个并不是很合逻辑的地方。我们知道在高级语言,例如 C语言
中,一个一个的创建变量并不能保证变量在内存中是连续存储的,除非用数组;但是我们的代码里面并没有任何的特殊数据结构的形式,那么 GDT
的每一项在内存中是一定在一起的吗?
如果是,那么这代表什么?如果不是,那么实际又是怎么实现的呢?
答案:一定是在一起。这是因为汇编源代码里的指令和数据部分是可以自由混杂排布的,而且最终编译出来的二进制中,它们排布顺序完全遵循源代码的排布。所以你可以任意安排你的指令和数据所处的位置,只需记得要使用各种跳转指令来控制代码运行即可。
但是,整个 loader
的起始位置,即入口代码必须在 0x9000
处,因为这是和 mbr
约定好的跳转地址(详见上一章),至于后面全部可以自由发挥和排布。
其实,这个特性又是另外一个问题的答案,不知道有没有读者在看前面的代码时有这么一个疑问,为什么都要在代码结束加上 jmp $
,一定要加吗?
当然是的,这个问题的答案同样是来源于上述特性,如果不加上 jmp $
来使代码进入死循环,那么代码将继续向后执行,将随机乱码理解成代码去执行,这样会导致未知错误。
# 保护模式
# 设置 GDTR
上一步中我们已经定义了 GDT
和 GDT Descriptor
结构,只需要使用 lgdt [gdt_descriptor]
命令来将 GDT Descriptor
的地址加载到 GDTR
寄存器中即可。
# 进入保护模式
设置完 GDT
后我们就可以使 CPU 进入保护模式了,进入保护模式非常简单,只需要设置 CPU 的 cr0
寄存器即可。 cr0
寄存器是一个控制寄存器,共 32 位,每一位都对应着不同的控制能力,我们这里无需管其他位的能力,只需要关注第 0 位 PE(Protected Enable)位即可,当 PE=0
时,启动保护模式; PE=1
时,则在实模式运行。
我们在对 cr0 寄存器进行操作时还必须注意需要保留其他控制位值不变,想到了什么操作?没错,就是 OR
,该操作可以仅将第 0 位改变成 1
,并保持其他位不变。
还有一点需要注意的是, OR
操作不能直接对 cr0
寄存器进行操作,所以我们需要借助 eax
寄存器来作为桥梁,完成对 cr0
寄存器的赋值,代码是不是已经呼之欲出了?
进入保护模式
mov eax, cr0 | |
or eax, 0x1 ; set 32-bit mode bit in cr0 | |
mov cr0, eax |
此时,我们就正式进入保护模式了。
# 刷新 CPU 管道
为什么要刷新 CPU 管道(pipeline)呢?是因为 CPU 是以 pipeline 的方式工作的,需要执行的指令都在 pipeline 中,若两个不同指令依赖同一块数据,同时修改这块数据就可能导致危险,这种情况下,就需要刷新 CPU 管道来防止这种危险发生(这里查到的资料是这样的,但是感觉稍微有一点点问题,有熟悉的师傅欢迎评论区留言呀~)。
刷新 CPU 管道也非常简单,可以通过一个远跳命令实现:
jmp CODE_SEG:init_pm ; far jump by using a different segment to refresh pipeline |
# 初始化段寄存器
上一章中我们已经介绍了寄存器的默认用法及含义,这里详细介绍下段寄存器各自的用途
cs
寄存器直接由 CPU 设置(想想上一章中讲的)我们无需初始化,所以我们就需要初始化其他的寄存器,至于用什么初始化,当然是我们提前定义好的 DATA_SEG
变量啦,有一点需要注意,段寄存器同样也不能直接进行立即数的赋值,我们需要借助 ax
(16 位)来帮助赋值。
[bits 32] | |
init_pm: ; we are now using 32-bit instructions | |
mov ax, DATA_SEG ; update the segment registers | |
mov ds, ax | |
mov ss, ax | |
mov es, ax | |
mov fs, ax | |
mov gs, ax |
# 初始化栈
还记得上面这个图吗,我们将 loader
的地址设置在了 0x8000
后 0x1000
的空间内,此时 0x8000
前后的空间和 mbr
前的空间都是空着的,我们用 mbr
前面的空间来布置栈,就像示意图中标识的那样,将栈底设置为 0x7B00
(栈向低地址增长),代码同样非常简单。
mov ebp, 0x7B00 | |
mov esp, ebp |
将以上代码合并起来,就形成了从实模式到保护模式的代码
[bits 16] ; real mode | |
switch_to_pm: | |
cli ; 1. disable interrupts | |
in al, 92h ; 2. enable A20 | |
or al, 00000010b | |
out 92h, al | |
lgdt [gdt_descriptor] ; 3. load the GDT descriptor | |
mov eax, cr0 | |
or eax, 0x1 ; 4. set 32-bit mode bit in cr0 | |
mov cr0, eax | |
jmp CODE_SEG:init_pm ; 5. far jump by using a different segment to refresh cpu pipeline | |
[bits 32] ; protected mode | |
init_pm: ; we are now using 32-bit instructions | |
mov ax, DATA_SEG ; 6. update the segment registers | |
mov ds, ax | |
mov ss, ax | |
mov es, ax | |
mov fs, ax | |
mov gs, ax | |
mov ebp, 0x7B00 ; 7. update the stack right at the top of the free space | |
mov esp, ebp | |
call BEGIN_PM ; 8. Call a well-known label with useful code |
此时,我们不妨写一个 main 函数来调用上述过程,然后测试一下程序:
[org 0x7c00] ; bootloader offset | |
mov bp, 0x9000 ; set the stack | |
mov sp, bp | |
mov bx, MSG_REAL_MODE | |
call print ; This will be written after the BIOS messages | |
call switch_to_pm | |
mov bx, MSG_ERROR ; this will actually never be executed | |
call print | |
jmp $ | |
%include "../02-mbr/boot-print.asm" ; must be the first included | |
%include "32bit-gdt.asm" | |
%include "32bit-switch.asm" | |
[bits 32] | |
BEGIN_PM: ; after the switch we will get here | |
; need patch | |
jmp $ | |
MSG_REAL_MODE db "Started in 16-bit real mode", 0 | |
MSG_ERROR db "Loaded 32-bit protected mode ERROR", 0 | |
; bootsector | |
times 510-($-$$) db 0 | |
dw 0xaa55 |
然后编译执行就可以了
nasm -fbin 32bit-main.asm -o loader.bin | |
qemu-system-i386 -fda loader.bin |
实际测试发现 boot-print.asm
必须第一个 include,否则 nasm
在编译的时候会把 [bx]
转换成 [edi]
不知道是 bug 还是我不知道的什么原因,总之,首先 include 就不会有问题了。
至此,CPU 已经进入了保护模式(32 位),接下来我们需要完成虚拟内存到物理内存的映射,为 kernel 的工作打下基础,由于这部分内容体量相对庞大且至关重要,所以我们放在下下一章详细去讲(下一章先讲点轻松的~)。