本文是对操作系统实验课中的OS内核启动过程部分进行梳理和笔记。实际上计算机开机启动确实是个很有趣的过程。可以设想一下,计算机的启动需要程序来加载,但是计算机没有启动又运行不了程序,这有点像是陷进了先有鸡还是先有蛋的问题。
# 计算机开机启动过程
那么计算机到底是如何启动的呢?上述那个先有鸡还是先有蛋的问题最终的解决方案是通过一组固化到主板上的ROM来加载能够启动计算机的程序,并最终完成计算机的启动。现代计算机的启动过程如下图所示:

上图中,在 BIOS 之后的所有程序都是存放在硬盘中,他们需要被分别加载到内存中,才能够被CPU执行。而BIOS会为我们自动加载硬盘中的首个扇区(一个扇区一般是512字节)的内容到内存的0x7c00处执行,这个存放在首扇区的程序就叫做MBR。
同样地,由于开机后会自动进入MBR,所以我们讨论的程序主要是上图后面3个红色字体标出的程序:MBR、Bootloade与kernel。
MBR 的大小被限制为512字节,能做的事情非常有限。所以这个程序中所做的事情主要是进一步把硬盘中的Bootloader程序加载到内存的0x7e00处并跳转到该程序,
Bootloader 程序则没有大小限制,所以可以做很多初始化工作,并把操作系统的内核(kernel)从硬盘中加载到内存中(地址可以自己设定)并跳转到内核程序。
Kernel 加载完毕。自此,计算机完成了启动,之后我们便可以通过操作系统来和这台电脑交互了。(顺带一提,没有操作系统的电脑称为裸机,只是由一堆硬件组成,我们没有办法进行人机交互)
# 从硬盘加载程序到内存
上面提到的从硬盘中加载 Bootloader / Kernel 到内存中执行,在汇编程序中有两种办法实现。分别是硬盘的LBA(Logical Block Addressing)读取和CHS(Cylinder-Head-Sector 柱面-磁头-扇区)读取, 两种方式实际上只是逻辑编号上不同,前者采取线性编号,后者采取依照硬盘物理构成编号,可以互相转换。
这里给出了一个采用LBA模式通过 I/O 端口来读取硬盘的示例:(如果想用CHS模式,需要依赖于BIOS的int 13H中断。关于中断,之后会另外开一篇文章讨论)
asm_read_hard_disk:
push bp
mov bp, sp
pushad
mov ax, [bp + 2 * 3] ; 逻辑扇区低16位
; 0x1f3端口放置LBA地址7~0bit
mov dx, 0x1f3
out dx, al
; 0x1f4端口放置LBA地址15~8
inc dx ; 0x1f4
mov al, ah
out dx, al
; 0x1f5端口放置LBA地址23~16
xor ax, ax ; 0x1f5
inc dx
out dx, al
; 0x1f5端口 低4位放置LBA地址27~24,高4位是配置信息
inc dx ; 0x1f6
mov al, ah
and al, 0x0f
or al, 0xe0 ; 此处高4位置1110 代表 1_LBA_1_主硬盘
out dx, al
; 0x1f2端口控制读取的扇区数量(最多255)
mov dx, 0x1f2
mov al, 1
out dx, al ; 读取1个扇区
; 向0x1f7端口写入0x20 请求硬盘读
mov dx, 0x1f7 ; 0x1f7
mov al, 0x20 ;读命令
out dx,al
; 从0x1f7端口读出硬盘状态,0000_1000表示已经准备好交换数据
.waits: ; 等待处理其他操作
in al, dx ; dx = 0x1f7
and al,0x88
cmp al,0x08
jnz .waits
; 从0x1f0端口中读取512字节到地址ds:bx
mov bx, [bp + 2 * 2]
mov cx, 256 ; 每次读取一个字,2个字节,因此读取256次即可
mov dx, 0x1f0
.readw: ; 读出2个字节
in ax, dx
mov [bx], ax
add bx, 2
loop .readw
popad
pop bp
ret
# 在MBR中加载Bootloader
无论在mbr中加载bootloader 还是在bootloader中加载kernel都可以直接调用上述函数。不过这个函数每次被调用是读出一个扇区,所以如果假设我们的bootloader有5个扇区,我们需要在mbr中把函数调用放在一个循环体中并为他传递参数:
[bits 16]
; 初始化栈指针
mov sp, 0x7c00 ;
mov ax, LOADER_START_SECTOR ; = 1 MBR是0号扇区,所以Bootloader从1开始
mov cx, LOADER_SECTOR_COUNT ; = 5 读取的扇区数量
mov bx, LOADER_START_ADDRESS ; = 0x7e00 bootloader中被加载到这个内存地址
; 加载bootloader
load_bootloader:
push ax
push bx
call asm_read_hard_disk ; 读取硬盘
add sp, 4 ; 弹出栈顶2个元素
inc ax ; 扇区LBA编号
add bx, 512
loop load_bootloader
; 跳转到bootloader
jmp 0x0000:0x7e00
值得一提的是,在进入函数之后,我们通过bp寄存器来获取参数,此时栈段的最低4个地址的数据如下:
- [bp + 2 * 3] :调用asm_read_hard_disk前push进的ax的值
- [bp + 2 * 2] :调用asm_read_hard_disk前push进的bx的值
- [bp + 2 * 1] :调用asm_read_hard_disk时的下一条指令的地址
- [bp + 2 * 0] :调用asm_read_hard_disk后push进的原bp的值
在mbr.asm程序的最后,我们还需要放入以下内容:
times 510 - ($ - $$) db 0
db 0x55, 0xaa
第二行是给程序的最后两个字节的内容分别放入0x55,0xaa,这个是用来告诉计算机这个设备具备操作系统;第一行是把程序剩余部分都填充0(因为mbr固定为512字节)
# 在Bootloader中
在Bootloader中我们需要完成两件事:让CPU从实模式进入保护模式(即环境初始化)、从硬盘加载kernel到内存中并进入其中。
从实模式进入到保护模式时,处理器的寻址模式会发生改变,”段寄存器的值”不再直接作为段基地址,而是变成一个段选择子(类似指针)用于在全局描述符表GDT中查找段基地址(段的起始地址)和段界限(段长度)等信息。
GDT存放在内存上的某个位置,这个表的表头地址存放在GDTR寄存器中,CPU可以通过GDTR寄存器的值来找到GDT的表头位置,然后再根据段寄存器中的段选择子在表中偏移并最终查找到所需要的信息。
在Bootloader的环境初始化中,我们要做的主要就是初始化GDT和GDTR,并进入保护模式。
[bits 16]
;; GDT、GDTR初始化
; 在GDT中依次放入描述符
;空描述符
mov dword [GDT_START_ADDRESS+0x00],0x00
mov dword [GDT_START_ADDRESS+0x04],0x00
;创建描述符,这是一个数据段,对应0~4GB的线性地址空间
mov dword [GDT_START_ADDRESS+0x08], 0x0000ffff ; 基地址为0,段界限为0xFFFFF
mov dword [GDT_START_ADDRESS+0x0c], 0x00cf9200 ; 粒度为4KB,存储器段描述符
;建立保护模式下的堆栈段描述符
mov dword [GDT_START_ADDRESS+0x10], 0x00000000 ; 基地址为0x00000000,界限0x0
mov dword [GDT_START_ADDRESS+0x14], 0x00409600 ; 粒度为1个字节
;建立保护模式下的显存描述符
mov dword [GDT_START_ADDRESS+0x18], 0x80007fff ; 基地址为0x000B8000,界限0x07FFF
mov dword [GDT_START_ADDRESS+0x1c], 0x0040920b ; 粒度为字节
;创建保护模式下平坦模式代码段描述符
mov dword [GDT_START_ADDRESS+0x20], 0x0000ffff ; 基地址为0,段界限为0xFFFFF
mov dword [GDT_START_ADDRESS+0x24], 0x00cf9800 ; 粒度为4kb,代码段描述符
; 初始化描述符表寄存器GDTR(48bits,高32bits是GDT基地址,低16bits是GDT界限)
pgdt dw 0
dd GDT_START_ADDRESS
mov word[pgdt], 39 ; 描述符表的界限
lgdt [pgdt]
;; 进入保护模式
; 进入保护模式前需要打开第21根地址线
in al,0x92 ; 南桥芯片内的端口
or al,0000_0010B
out 0x92,al ; 打开A20
; 设置PE位 打开保护模式标志位
cli ; 中断机制尚未工作
mov eax,cr0
or eax,1
mov cr0,eax ; 设置PE位
; 以下进入保护模式
jmp dword CODE_SELECTOR:protect_mode_begin
进入保护模式后,CPU的通用寄存器的可用位数从原先的16bits变为32bits,所以我们的汇编程序从这一行开始变成32位编程。这里我们主要是对全部段寄存器进行初始化(放入段选择子),以及加载kernel到内存中(还是使用之前的asm_read_hard_disk函数):
; 16位的描述符选择子:32位偏移
; 清流水线并串行化处理器
[bits 32]
protect_mode_begin:
mov eax, DATA_SELECTOR ; = 0x08 加载数据段(0..4GB)选择子
mov ds, eax
mov es, eax
mov eax, STACK_SELECTOR ; = 0x10 栈段选择子
mov ss, eax
mov eax, VIDEO_SELECTOR ; = 0x18 视频段选择子(用于在qemu显示屏上显示内容的基址)
mov gs, eax
mov eax, KERNEL_START_SECTOR ; = 6 从硬盘LBA-06号扇区开始加载kernel
mov ebx, KERNEL_START_ADDRESS ; = 200 这里假设kernel程序有200个扇区的大小
mov ecx, KERNEL_SECTOR_COUNT ; = 0x20000 加载到内存0x20000的位置
load_kernel:
push eax
push ebx
call asm_read_hard_disk ; 读取硬盘
add esp, 8
inc eax
add ebx, 512
loop load_kernel
jmp dword CODE_SELECTOR:KERNEL_START_ADDRESS ; 跳转到kernel
# 进入kernel
在Bootloader的结尾程序跳转到了kernel的起始地址,接下来计算机就由操作系统内核接管了。至此,计算机完成了开机启动并进入操作系统。我们可以通过键盘鼠标等IO设备与操作系统进行交互啦!
# 其他信息
- 实验环境:ubuntu:22.04
- 编译器:nasm
- 测试环境:Qemu with gdb
- 参考资料:从实模式到保护模式