本章是操作系统从 0 到 1 系列的第三篇文章,主要讲解了计算机从通电到启动操作系统前的过程。在本章中,我将会介绍引导启动中的核心概念和知识,并最终自己实现一个 MBR。
Concepts you may want to Google beforehand: assembler, BIOS, BOOT, Real mode, interrupts, CPU registers, control structures, function calling, strings
# BIOS 和 BOOT
即使不了解计算机的人也可能对于 boot
和 bios
这两个词有所耳闻,然而即使是计算机专业的人也可能会混淆这两者的概念,这里我们先来区分一下这两个概念,以便于我们后续的展开。
- BIOS 是英文 "Basic Input Output System" 的缩略语,直译过来后中文名称就是 "基本输入输出系统"。它是一组固化到计算机内主板上一个 ROM 芯片上的程序,由主板的制造厂商编写代码;我们有时听到的 “刷 BIOS” 就是指将主版上的 BIOS 程序进行更换。
- BOOT 其实是指
Boot Loader
,boot 本身就是 “引导” 的意思,是指一种用来引导操作系统激活的程序,广泛应用于各种系统之中。
# 对比
BIOS 保存着计算机最重要的基本输入输出程序、系统设置信息、开机后自检程序和系统自启动程序,其主要功能是为计算机提供最底层的、最直接的硬件设置和控制。可从 CMOS 中读写系统设置的具体信息。
当按下电脑开机键的一瞬间,CPU 会先被激活去寻找 BIOS,接着 BIOS 会先在 Flash Memory 中执行,初始化计算机各种硬件并检测完整性,再执行 CMOS 中用户所喜好的设置,然后 BIOS 将自己解压缩到计算机的主存储器中。再启动引导程序 boot,然后由 boot 将操作系统加载到内存中执行。
Boot 的过程其实就是由 mbr
+ loader
一起实现的,后面会详细讲解什么是 mbr
,什么是 loader
,耐心看下去吧~
# MBR 实现
尽管很多教程中直接提供了 mbr
和 loader
供我们使用而无需自己编写,但是我仍推荐至少自行实现一次这两者,原因如下;
- 有助于提高汇编能力,这一点在后面也至关重要。
- 它的编写并不困难,可以借助
mbr
+loader
的编写来入门操作系统的编写。 - 从 CPU 通电开始写起有助于建立对于计算机的全面认知,为后面的内核、可执行程序的加载,以及虚拟内存的建立做好准备。
- boot 阶段会初步搭建起 segment 以及虚拟内存的框架,为后续 kernel 编写打下基础
💗真是激动人心,我们要开始编写自己的 mbr
了!
# 从通电开始
一个经典的问题:计算机通电后是怎么就进入到了操作系统呢?
计算机通电后,CPU 处于实模式,在实模式下,CPU 寻址方式为:由 16 位段寄存器值 * 10H 加 16 位偏移地址来形成 20 位的寻址空间,即只有 1M 的空间是可以被 CPU 读取的,而其他空间 CPU 都无法使用,CPU 必须借助这 1M 空间内的存储的各种程序来引导操作系统启动。
为什么是20位寻址空间呢?
这与 CPU 的发展历史有关,在 CPU 早期 (8086 时期),由于 CPU 性能有限,一共只有 20 位地址线,可寻址空间只有 1M (220 bytes),以及 8 个 16 位的通用寄存器,和 4 个 16 位的段寄存器;16 位的物理地址只能访问 64KB (216 bytes) 的内存。所以,为了能够通过这些 16 位的寄存器去构成 20 位的寻址地址,访问 1 MB 的内存,必须采取一种特殊的方式。
特殊方式即由 段基址 + 段内偏移
的方式来将 16 位的地址扩展为 20 位,具体方式如下:
- 段基址:由段寄存器提供,并将寄存器值左移 4 位,将结果作为基地址。
- 段内偏移地址:由通用寄存器来提供 (如 EIP)
将二者相加,得到一个 20 位的地址,覆盖 CPU 20 位的寻址空间,这里给出一个例子以便于更形象的说明:
例如,段基址为 0x1000
,段内偏移为 0x1234
,则此时物理地址可以由下式计算得出:
因此,这至关重要的 1MB 空间的划分是已经固定好的,每一块都有规定的用途的,被映射到不同的设备上:
# BIOS 的工作
我们前面提到,CPU 通电后,将会执行 BIOS 程序,那么 BIOS 具体的工作有哪些呢?包括以下几步:
- 开机后 CPU 的指令寄存器
EIP
被设置为地址0xFFFF0
,这一地址指向 BIOS 固件上的代码,CPU 从这里执行开机后的第一条指令; - CPU 开始执行 BIOS 上的代码,这一部分主要是硬件输入输出设备相关的检查,以及建立一个最初的中断向量表(后面会提到);
- BIOS 代码最后阶段的工作,就是检查启动盘上的
mbr
分区,所谓 mbr 分区就是指磁盘上的第一个 512 bytes 的内容,又叫引导分区
;BIOS 会对这 512 bytes 做一个检查:它的最后 2 个字节必须是特定的两个 magic number:0x55
和0xaa
,否则它就不是一个合法的启动盘; - 检查通过后,BIOS 将这 512B 加载到内存
0x7C00
处,到 0x7E00 为止,然后指令跳转到 0x7C00 开始执行;至此 BIOS 退出舞台;
我们可以将这个过程画成一个图,来帮助我们理解
- 黄色部分是
mbr
,被 bios 从磁盘复制到内存中去执行 - 粉色部分是
bios
代码 - 黄色部分的两边的白色部分 (0x500 开始) 是我们可以自由使用的空间
# mbr 的工作
mbr
的大小被限制在了 512 bytes,这对于引导一个操作系统的重任来说有点太小了,里面根本放不下太多的数据和代码,所以 mbr
的工作其实只是将一个 laoder
加载到内存,然后跳转到 loader
去继续执行。
我们还是用一个示意图来表示这个过程的变化和内存布局:
「假设我们把 loader
加载到 0x8000
处(可以任意指定加载的位置,只需要 mbr
中对应起来就可以), loader
大约 4KB 大小」
上一节中我们说过, mbr
被加载到内存后,前后都有一块可供我们自由使用的空间,前面那块 (0x500 开始) 较小,大约 30KB,而后面这块 (0x7E00 开始) 就大的多了,大约有 608KB 左右的空间,我们就可以利用这块内存空间,将 loader
加载进内存并执行。
# 牛刀小试
理论知识已经学了很多了,但是直接就开始写一个完整 mbr
还是有点难度的,不如我们从 hello world
开始。
🎯编写一个 mbr,并打印 'Hello World!'
原理:通过 0x10H 中断触发 bios 中的打印服务,向屏幕输出字符,该中断的调用规则为:
Teletype output | AH=0Eh | AL = Character, BH = Page Number, BL = Color (only in graphic mode) | no return |
---|
然后我们就可以编写代码了:
mov ah, 0x0e ; set tty mode | |
mov al, 'H' | |
int 0x10 | |
mov al, 'e' | |
int 0x10 | |
mov al, 'l' | |
int 0x10 | |
int 0x10 ; 'l' is still on al | |
mov al, 'o' | |
int 0x10 | |
mov al, ' ' | |
int 0x10 | |
mov al, 'W' | |
int 0x10 | |
mov al, 'o' | |
int 0x10 | |
mov al, 'r' | |
int 0x10 | |
mov al, 'l' | |
int 0x10 | |
mov al, 'd' | |
int 0x10 | |
mov al, '!' | |
int 0x10 | |
jmp $; jump to current address = infinite loop | |
times 510 - ($-$$) db 0 ; fill with 0,length = (510 - previous code) | |
dw 0xaa55 ; x86 is little-endian |
我们把上面的代码保存成 boot_hello_world.asm
, 然后用 nasm
编译他:
nasm -fbin boot_hello_world.asm -o boot_hello_world.bin
然后你就可以通过 xxd
来查看生成的 bin
文件:
我们可以看到,结尾时 Magic Number 0x55aa
,前面是我们写的 asm 代码转成的机器码,中间用 0 填充,一共 0x200
也就是 512bytes。
接着就可以用 qemu 来执行他了
# mac | |
qemu-system-i386 boot_hello_world.bin | |
# linux | |
qemu boot_hello_world.bin |
不出意外的话,你就会看到 Hello World!
了🎉
# Problem Time
以下哪行代码可以使
Hello World!
以绿色字体输出:
# 更进一步 (打印信息)
上面我们已经学会了打印字符,那么接下来我们尝试编写几个打印函数用于输出错误或信息,帮助我们在后面的过程中定位问题。
# 打印新行
先简单一点,我们写一个打印换行符的函数 print_nl()
,其实就是依次打印 ascii 表特殊字符中的换行符,很简单,做一点点解释
\r
:对应 ascii 码为 13 的字符,表示回车,将当前位置移到本行头\n
:对应 ascii 码为 10 的字符,表示换行,将当前位置移动到下一行
print_nl()
; print a new line | |
print_nl: | |
pusha | |
mov ah, 0x0e ; tty mode | |
mov al, 0x0a ; '\r' | |
int 0x10 | |
mov al, 0x0d ; '\n' | |
int 0x10 | |
popa | |
ret |
# 打印字符串
然后加大一点难度,通过循环来实现一个打印字符串的函数 print()
,本质就是一个一个的打印字符,一些要点如下:
- 因为调用
print()
的场景是程序运行过程中,所以函数开始要pusha
保存 “现场”,在调用结束前还需要用popa
还原 “现场” - 通过
bx
寄存器来传递参数 (寄存器有默认的用法,这里推荐用bx
来传递参数) - 可以通过
cmp
和je
命令来进行条件判断和分支跳转。
print( [bx]string s )
; print a string pointed with bx | |
print: | |
pusha | |
; keep this in mind: | |
; while (string[i] != 0) { print string[i]; i++ } | |
; the comparison for string end (null byte) | |
print_start: | |
mov al, [bx] ; 'bx' is the base address for the string | |
cmp al, 0 ; if '\0', then stop loop | |
je print_end | |
; the part where we print with the BIOS help | |
mov ah, 0x0e | |
int 0x10 ; 'al' already contains the char | |
; increment pointer and do next loop | |
add bx, 1 | |
jmp print_start | |
print_end: | |
popa | |
ret |
# 以十六进制打印数字
同样的道理,我们来将数字以 16 进制打印出来,由于我们直接是直接对接底层 CPU 的处理,没有现成的进制转换函数供我们使用,此时我们需要自己处理进制转换(数字到字符)和格式化输出,步骤如下:
- 参数:通过
dx
寄存器来传递参数 - 预处理:用寄存器来记录需要被转换的值,寄存器为 16 位,每次处理 4 位(一个 16 进制字符),所以要建立一个循环 4 次的函数
- 取数字:可以通过和
0x000f
进行与操作
的方式,来仅取出 4 位数,方便后续处理 - 进制转换:原数字 +
0x30
就会转换成对应的数字的字符,与 9 做比较,如果更大,就加 7(对应 ascii 表的A
->F
) - 格式化处理:结合索引,通过移位的方式来将字符放在合适的位置,
- 打印:调用上面写的
print()
函数
注意:字符串在定义时末尾必须加上 0
,来作为字符串结束的标识(例如 HEX_OUT
)
print_hex( [dx]number n )
; receiving the data in 'dx' | |
; For the examples we'll assume that we're called with dx=0x1234 | |
print_hex: | |
pusha | |
mov cx, 0 ; our index variable | |
; Strategy: get the last char of 'dx', then convert to ASCII | |
; Numeric ASCII values: '0' (ASCII 0x30) to '9' (0x39), so just add 0x30 to byte N. | |
; For alphabetic characters A-F: 'A' (ASCII 0x41) to 'F' (0x46) we'll add 0x40 | |
; Then, move the ASCII byte to the correct position on the resulting string | |
print_hex_start: | |
cmp cx, 4 ; loop 4 times | |
je print_hex_end | |
; 1. convert last char of 'dx' to ascii | |
mov ax, dx ; we will use 'ax' as our working register | |
and ax, 0x000f ; 0x1234 -> 0x0004 by masking first three to zeros | |
add al, 0x30 ; add 0x30 to N to convert it to ASCII "N" | |
cmp al, 0x39 ; if > 9, add extra 8 to represent 'A' to 'F' | |
jle step2 | |
add al, 0x07 ; 'A' is ASCII 65 instead of 58, so 65-58=7 | |
step2: | |
; 2. get the correct position of the string to place our ASCII char | |
; bx <- base address + string length - index of char | |
mov bx, hex_string + 5 ; base + length | |
sub bx, cx ; our index variable | |
mov [bx], al ; copy the ASCII char on 'al' to the position pointed by 'bx' | |
ror dx, 4 ; 0x1234 -> 0x4123 -> 0x3412 -> 0x2341 -> 0x1234 | |
; increment index and loop | |
add cx, 1 | |
jmp print_hex_start | |
print_hex_end: | |
; prepare the parameter and call the function | |
; remember that print receives parameters in 'bx' | |
mov bx, hex_string | |
call print | |
popa | |
ret | |
hex_string: | |
db '0x0000', 0 ; reserve memory for our new string |
完成以上打印函数后,我们可以将他们保存在 print.nasm
中,然后通过 %include "boot_print.asm"
的方式在别的 asm
中使用我们定义好的打印函数
# 一步登天
好了,现在你已经学会 1+1 了,现在请计算 9.121*7.32/6.97 吧
根据我们之前说的,仔细想想,编写的 mbr
的功能,拆解一下其实并不复杂,本质就是读取一块硬盘,并将 loader
加载到内存中,然后跳转到 loader
执行就可以了,稍微复杂的点在于,由于历史原因,读磁盘时采用的是 CHS 的模式,这点也你会在后面更近一步学习,接着往下看吧。
「以下代码的部分希望读者能自己根据原理完成,如果觉得较为困难,可以打开折叠栏查看」
# nasm 读硬盘
好消息是我们无需去直接跟磁盘打交道,bios 中同样写好了读写磁盘的程序,我们只需要跟打印程序一样通过中断来调用即可。我么可以通过 0x13H 中断来读写磁盘,该中断的调用参数为:
Register | Value |
---|---|
AH | 02H => bios 磁盘服务程序中的读磁盘程序 |
AL | 要读的扇区个数 (1-128 dec.) |
CH | 磁道号的低 8 位 (0-1023 dec.) |
CL | 低 5 位为所读起始扇区号, 6-7 位表示磁道号的高 2 位 (1-17 dec.) |
DH | 所读磁盘的磁头号 (0-15 dec.) |
DL | 需要进行读操作的驱动器号 (0=A:, 1=2nd floppy, 80h=drive 0, 81h=drive 1) |
ES:BX | 缓冲区地址 |
返回值为:
Register | Value |
---|---|
AH | status (see INT 13,STATUS) |
AL | number of sectors read |
CF | 0 = successful OR 1 = error |
在 mbr 启动的时候,CPU 处于实模式,此时只能使用 bios 按 CHS 的寻址方式来找到 loader
的位置。
CHS到底是什么呢?
CHS 即 (Cylinder/Head/Sector),是非常古老的硬盘参数了,很久以前,硬盘的容量还很小,人们采用类似软盘的结构生产硬盘,即硬盘盘片的每一条磁道都具有相同的扇区数,由此产生了磁头数 (Heads),柱面数 (Cylinders),扇区数 (Sectors) 以及相应的寻址方式.
其中:
- 磁头数 (Heads):表示硬盘总共有几个磁头,也就是有几面盘片,最大为 255 (用 8 个二进制位存储);
- 柱面数 (Cylinders):表示硬盘每一面盘片上有几条磁道,最大为 1023 (用 10 个二进制位存储) => 每条磁道都有编号,叫做磁道号;
- 扇区数 (Sectors):表示每一条磁道上有几个扇区,最大为 63 (用 6 个二进制位存储).
每个扇区一般是 512 个字节,理论上讲这不是必须的,但好象没有取别的值的。
因此,数据寻址方式为:先找到磁头,然后再通过柱面和扇区来确定唯一一个磁道,磁道中记录了数据。(如果你想了解更多有关早期硬盘的知识)
# 参数值讲解
Register | Value | Remark |
---|---|---|
AL | 0x02 | 要读两个扇区 (根据实际情况) |
CH | 0x0 | 仍然是 0 号磁道,只不过是去读第二个扇区) |
CL | 0x02 | mbr 总是在 fisrt of cylinder 0 of head 0 of hdd 0 ,因此任何字节都只能在第二个扇区之后 |
DH | 0x00 | 读取第一个磁头 (即第一个盘片) |
DL | 由 bios 自动设置 |
我们这里先将读磁盘内容的命令封装成一个函数,同时增加一点错误输出函数来帮助我们定位可能存在的错误
disk_load([dh]sector n)
; load 'dh' sectors from drive 'dl' into ES:BX | |
disk_load: | |
pusha | |
push dx ; store the argument in 'dh' | |
mov ah, 0x02 ; 0x02 = 'read disk' | |
mov al, dh ; al <- number of sectors to read (0x01 .. 0x80) | |
mov cl, 0x02 ; cl <- sector (0x01 .. 0x11) | |
mov ch, 0x00 ; ch <- cylinder (0x0 .. 0x3FF, upper 2 bits in 'cl') | |
mov dh, 0x00 ; dh <- head number (0x0 .. 0xF) | |
; [es:bx] <- pointer to buffer where the data will be stored | |
; caller sets it up for us, and it is actually the standard location for int 13h | |
int 0x13 ; BIOS interrupt | |
jc disk_error ; if error (stored in the carry bit) | |
pop dx | |
cmp al, dh ; BIOS also sets 'al' to the # of sectors read. Compare it. | |
jne sectors_error | |
popa | |
ret | |
disk_error: | |
mov bx, DISK_ERROR | |
call print | |
call print_nl | |
mov dh, ah ; ah = error code, dl = disk drive that dropped the error | |
call print_hex ; check out the code at http://stanislavs.org/helppc/int_13-1.html | |
jmp disk_loop | |
sectors_error: | |
mov bx, SECTORS_ERROR | |
call print | |
disk_loop: | |
jmp $ | |
DISK_ERROR: db "Disk read error", 0 | |
SECTORS_ERROR: db "Incorrect number of sectors read", 0 |
此时还没有结束,让我们写一个 main
函数在将所有之前写的功能串联起来吧,注意这里还有如下几点需要注意:
- 开头要加上
[org 0x7c00]
,该命令会将后续出现的所有地址在寻址时加上该偏移,因为这里才是mbr
真实执行的地址,也即我们各种数据储存的 “基地址”,如果不加的话,在寻址时会出现错误。 - 在安全的位置设置我们自己的堆栈
- 记得要设置
bx
寄存器指向loader
的加载地址(因为es:bx
指向读取出的硬盘数据的存储位置) - 由于我们还没有写
loader
,可以暂时先将sector 2
设置成某特殊字符,来检验读取效果
main.asm
[org 0x7c00] | |
mov bp, 0x7B00 ; set the stack safely away from us | |
mov sp, bp | |
mov bx, 0x8000 ; es:bx = 0x0000:0x8000 = 0x08000 | |
mov dh, 2 ; read 2 sectors | |
; the bios sets 'dl' for our boot disk number | |
; if you have trouble, use the '-fda' flag: 'qemu-system-i386 -fda file.bin' | |
call disk_load | |
mov dx, [0x8000] ; retrieve the first loaded word, 0xdada | |
call print_hex | |
call print_nl | |
mov dx, [0x8000 + 512] ; first word from second loaded sector, 0xface | |
call print_hex | |
jmp $ | |
%include "boot_print.asm" | |
%include "boot_sect_disk.asm" | |
; Magic number | |
times 510 - ($-$$) db 0 | |
dw 0xaa55 | |
; boot sector = sector 1 of cyl 0 of head 0 of hdd 0 | |
; from now on = sector 2 ... | |
times 256 dw 0xdada ; sector 2 = 512 bytes | |
times 256 dw 0xface ; sector 3 = 512 bytes |
🎉以上就是本章的全部内容,下一章将会继续讲解 loader
的实现~