在手写操作系统之前,我建议你先搞清楚一件最基础也最容易被忽略的事:按下电源键之后,CPU到底在做什么,又是谁把硬盘里的代码搬进内存的。我当年啃《操作系统真象还原》前两章时,最大的感受就是“掌权”这两个字实在太形象了——第二章的MBR主引导记录,就是我们从硬件手里接管电脑的第一个程序。它不依赖任何操作系统,纯裸机环境,直接在实模式下运行,代码量很小但每一行都有讲究。这篇文章就围绕第二章的核心实践来展开,我会带你手写一个能打印字符的MBR,同时把实模式、内存布局、BIOS中断调用、引导扇区这些概念一次讲透。
这篇内容很适合两类人看:一类是正在跟书同步做实验的初学者,想知道“为什么这样写”而不只是“怎么写”;另一类是搞过Linux、看过不少启动流程,但没亲手在裸机上写过代码的开发者。看完之后,你能独立写出一份512字节的MBR,并且清楚它为什么可以开机自举,也能自己设计简单的引导程序打印信息甚至加载后续内核。
1. 开机到MBR之间发生了什么
1.1 CPU启动瞬间的实模式真相
只要你接触过PC底层开发,一定听过“实模式”这个词。但很多人对它的理解停留在“一段历史遗留状态”,其实它决定了你写MBR时所有的内存访问方式。
x86 CPU从加电那一刻起,会先进入实模式。实模式的本质是16位寻址环境,寄存器宽度16位,指令默认按16位解析,寻址方式为“段寄存器左移4位 + 偏移地址”。这种模式下,CPU最多只能访问1MB内存空间,也就是0x00000到0xFFFFF。其中很多地址还被硬件设备、BIOS数据区、视频缓冲区占用,能让你自由折腾的内存非常有限。
为什么CPU要这样设计?为了保证向前兼容,现代x86处理器为了能启动老旧的16位引导程序,必须保留实模式作为硬件初始状态。比如你用的是酷睿i9,启动瞬间也会先进入实模式,跑一段16位代码,再切换进32位或64位保护模式。这个切换动作往往就发生在MBR或引导加载器内部。
真正让新手困惑的是:为什么MBR要用16位汇编写?因为CPU此刻就在实模式,指令集天然是16位的,你写32位代码它根本不认。除非你在MBR开头就执行切换到保护模式的指令,但那样代码量会急剧膨胀,而且也没有必要。第二章的目的是“先掌权”,做一个最简的引导,所以老老实实用16位汇编就对了。
1.2 BIOS中断:没有操作系统时唯一的外设接口
刚开机时,内存里除了BIOS固件映射的数据,什么都没有。你要访问硬盘、屏幕、键盘,不能像在Linux里一样通过系统调用,只能靠BIOS提供的硬件中断服务程序。这套服务通过软中断指令触发,最常见的就是int 0x10用于显示,int 0x13用于磁盘读写,int 0x16用于键盘输入。
很多初学者不理解“中断”在这个语境下的含义。它不是硬件主动打断CPU,而是你主动发起的一个软中断调用,类似于一个固定入口的系统服务。BIOS在启动时已经把这些服务例程安装到了内存低端,并配置好中断向量表。CPU执行int 0x10时,会根据中断号查表,跳转到对应的BIOS功能函数。
在MBR阶段,我们最常用的就是int 0x10的显示功能和int 0x13的磁盘读取功能。前者用于输出字符到屏幕,后者用于把硬盘上的后续扇区加载进内存。第二部分代码实现在Linux下用qemu跑MBR,打印文字;第三部分是练习读取磁盘扇区的流程。
2. MBR的使命与磁盘布局
2.1 为什么是512字节,为什么是0x7C00
MBR不是一段任意大小的代码,它必须放在硬盘的第一个扇区,也就是0号扇区。这个扇区有固定的512字节大小,最后两个字节必须是0x55和0xAA,作为引导扇区的合法标志。
BIOS自检完成后,会读取0号扇区到内存中的物理地址0x7C00,然后跳转到这个地址执行。为什么偏偏是0x7C00?这是历史原因:早期操作系统需要为BIOS自身数据区保留足够空间,同时也要给后续加载的引导代码留出足够连续的可用区域,0x7C00恰好是在系统最低内存区上方一个相对安全的位置。我们只需要知道这是一条规则,不用过度纠结,反正所有的MBR代码都会在0x7C00处运行。
注意,0x7C00不是一个“默认选择”,而是BIOS固件写死的加载地址。所以你在写MBR时,如果涉及到绝对内存地址的访问,必须基于0x7C00做偏移计算。比如你的代码中定义了一个标签main,它在代码段里的位置是0x7C00加偏移,但你不能直接引用这个物理地址,还是需要用段选择子和偏移组合来定位。
2.2 MBR与GPT:都叫主引导,能互相替代吗
热词里经常出现“MBR和GPT分区的区别”,其实它们是两个层面的东西。MBR是一种磁盘分区表和引导代码共存的布局方案,它位于0号扇区。GPT是另一种更新的磁盘布局方案,全称是GUID分区表,它把分区表信息放到多个扇区,并且支持2TB以上磁盘。
在UEFI时代,GPT是主流,但MBR依然没有完全消失。你按传统BIOS模式启动时,固件还是会尝试读取0号扇区的MBR代码并跳转执行。而UEFI模式下,固件不再执行MBR代码,而是直接读取EFI系统分区里的EFI\BOOT\BOOTX64.EFI文件。所以如果你在做裸机实验,建议还是用MBR+BIOS模式,因为UEFI会绕过你的MBR代码,让你觉得“明明写对了怎么没反应”。
《操作系统真象还原》第二章写的MBR,就是传统BIOS启动路径下最原始的那段代码。你可以把它理解成“操作系统之前的第一块跳板”。它的使命不是完成所有工作,而是把后续更复杂的程序(比如加载器、内核)从磁盘调入内存,然后跳过去执行。
2.3 引导扇区结构的完整视图
一个标准的MBR扇区包含三个部分:引导代码、磁盘分区表、结束标志。
磁盘分区表从偏移量0x1BE开始,一共64字节,每16字节描述一个分区,最多记录4个主分区。结束标志是0x55AA,位于偏移量0x1FE。剩下的0到0x1BD之间大约446字节,才是引导代码的可用空间。
我们在第二章实操时,并不需要真正构建一个带分区表的MBR,因为qemu测试可以用裸镜像文件直接加载0号扇区,不关心分区表。但你要理解,真实硬盘上的MBR,那64字节分区表是不能随便覆盖的,否则磁盘就“分区丢失”了。这也是为什么很多引导程序会主动避开分区表区域的写入。
我自己实验时,用了最简单的nasm编译,将代码写到512字节镜像文件里,后两个字节手动补上0x55和0xAA。如果分区表区域是空白,也没有问题,因为BIOS不检查分区表是否有内容,只认结束标志。
3. 搭建最小开发环境:nasm + qemu
3.1 为什么选nasm和qemu
编写MBR可以使用任意一款x86汇编器,常见的有nasm、as、masm。我强烈推荐nasm,因为它的语法简洁直接,支持16位模式,而且能生成纯二进制文件(-f bin),非常适合写引导扇区。不需要链接器,不需要目标文件,直接一条命令就能输出512字节的镜像。
qemu是另一个核心工具。它是一个开源的机器模拟器,能模拟完整的x86硬件环境,包括BIOS、磁盘、显示器、键盘。你甚至可以在qemu里单步调试MBR代码,配合gdb看到CPU内部寄存器的实时变化。这比拿真实机器反复重启测试高效太多。
实验环境我建议用Linux终端,如果是在Windows上,也可以用WSL,或者直接装一个虚拟机跑Linux。MBR开发本身不依赖GUI,所以在纯命令行环境下做最舒服。
3.2 安装与验证工具链
以Ubuntu/Debian为例,安装只需要两条命令:
sudo apt update sudo apt install nasm qemu-system-x86如果你的发行版是Fedora或CentOS,对应命令是:
sudo dnf install nasm qemu-system-x86安装完成后,验证一下nasm版本:
nasm -v能正常输出版本信息就说明工具链可用。qemu验证稍麻烦一些,你不需要马上加载镜像,可以先跑一个最简单的命令看看是否报错:
qemu-system-x86_64 --version如果显示出版本,那就没问题了。
这里要提醒一个细节:我们在实验中使用的是传统BIOS启动方式,不是UEFI。qemu默认对qemu-system-x86_64用的是SeaBIOS模拟固件,它支持传统MBR启动。所以只要你的镜像文件最后有0x55AA,qemu就会尝试从它的0号扇区引导。如果你用了-bios参数指定了UEFI固件,那MBR代码就不会被执行,这一点不要搞混。
3.3 编译MBR的命令行细节
nasm编译MBR的命令非常简单:
nasm -f bin -o mbr.bin mbr.S-f bin表示输出纯二进制,mbr.S是汇编源码。生成的mbr.bin默认不会自动补齐512字节,如果代码不足512字节,你需要手动填充到512;如果超过512字节,就会得到一个大于512字节的文件,这种文件是不能作为MBR镜像使用的。
为什么nasm不自动填充到一个扇区?因为它只是把代码按二进制打包,不会理解“这是MBR”这个语义。所以填充工作得你自己在源码里完成。最常见的做法是在汇编文件末尾用times 510-($-$$) db 0这种伪指令,把当前偏移量填充到510字节,然后写入两个字节的结束标志。
$$在nasm里表示当前段起始地址,$表示当前地址,$-$$就是代码已经占用的字节数。510减去这个数,就是要填充的0字节数量。这个技巧几乎是MBR框架的标准写法,每个手写引导程序的人都会遇到。
4. 手写第一版MBR:向屏幕输出字符
4.1 核心代码逐行拆解
现在进入正题。我们要实现的功能很简单:MBR在屏幕上打印一串字符,然后死循环在这。这个程序虽然短,但已经包含了MBR最关键的元素:实模式初始化、BIOS中断调用、字符串存储、填充逻辑。
先看完整代码:
; mbr.S ; 主引导记录:打印字符串后进入死循环 SECTION MBR vstart=0x7c00 mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 ; 清屏(使用 BIOS 0x06 功能) mov ax, 0x0600 mov bx, 0x0700 mov cx, 0x0000 mov dx, 0x184f int 0x10 ; 打印字符串 mov ax, 0x1301 mov bx, 0x0002 mov cx, msg_end - msg_start mov dx, 0x0000 mov bp, msg_start int 0x10 jmp $ msg_start: db "Hello, MBR!" db 0x0d, 0x0a, "Welcome to OS world!" msg_end: times 510-($-$$) db 0 db 0x55, 0xaa别急着抄代码,先理解每一部分为什么这样写。
SECTION MBR vstart=0x7c00是一个关键定义,vstart告诉nasm,代码的虚拟起始地址是0x7C00。这样当代码访问msg_start这样的标签时,nasm计算出的地址就是基于0x7C00的绝对地址。如果你不加vstart,标签地址可能会从0开始算,那么mov bp, msg_start就会把错误的地址传给BIOS,导致打印出乱码甚至死机。
接着是初始化寄存器。mov ax, cs然后把ax赋给ds、es、ss,目的是让数据段、附加段、堆栈段都指向当前代码段。cs的值在BIOS跳进MBR时是多少?通常是0x0000,因为BIOS会执行jmp 0x0000:0x7c00,即段地址0,偏移0x7C00。这种写法相当于把段地址统一,无论cs是多少,都能让后续的内存访问基于0x7C00进行。
mov sp, 0x7c00是设置栈顶位置。实模式下栈向下增长,栈顶设为0x7C00,那么栈区会从0x7C00往下生长,不会覆盖MBR代码。这个值其实就是一个经验值,保证栈空间够用且安全。
4.2 清屏和打印用的BIOS中断
代码里用了两次int 0x10。
第一次清屏,用的是0x06号功能,也就是“上滚窗口”。AH=0x06, AL=0表示清空整个窗口。BH=0x07是显示属性,0x07表示黑底白字。CH=0, CL=0是窗口左上角行和列,DH=0x18, DL=0x4F是右下角,0x18是24、0x4F是79,正好覆盖80x25的整个文本屏幕。
第二次打印字符串,用的是0x13号功能。AH=0x13,AL=0x01表示在写字符串的同时更新光标位置。BH=0是页码,BL=0x02是字符串属性,0x02也是绿字黑底。CX是字符串长度,DX是起始行和列,这里都是0。ES:BP指向字符串首地址,这就是为什么我们前面把ES设置为CS,然后让BP等于msg_start。
这里有个容易踩的坑:mov bp, msg_start之后,BP里的地址是基于SECTION MBR vstart=0x7c00计算出来的偏移。由于段寄存器ES的值是CS,而CS在BIOS跳入时可能是0,所以ES:BP指向的实际地址就是0:0x7C00+offset。如果你之前乱改了ES,那打印就会失败。
还有一点需要说明:0x13中断要求ES:BP指向字符串,它只认BP不认SI或DI。所以千万别写成mov si, msg_start,那样打印出来的是乱码。
4.3 编译、写入镜像、运行验证
把上面的代码保存为mbr.S,然后执行:
nasm -f bin -o mbr.bin mbr.S查看文件大小:
ls -l mbr.bin正常应该是512字节。然后再查看二进制内容:
xxd mbr.bin确认最后两个字节是55 aa。
如果文件不足512字节,说明你的times填充逻辑有问题,检查$和$$的用法。如果你写的代码超过了512字节,那说明你在字符串部分放太多内容了,需要精简。
运行qemu验证:
qemu-system-x86_64 -drive file=mbr.bin,format=raw -machine pc不出意外,你会在弹出的窗口中看到绿色的Hello, MBR!以及第二行Welcome to OS world!。程序会卡在jmp $这条死循环上,不会退出,这是对的,因为MBR的使命暂时完成了。
如果屏幕黑屏或者花屏,先别怀疑代码。可能是qemu没跑到你的MBR代码,比如镜像文件不是512字节,或者结束标志没写对。用xxd检查之后逐个排查。
5. MBR中的陷阱与调试技巧
5.1 地址计算错误是头号杀手
我修改了很多次这个实验,最终发现90%的问题都出在内存地址上。实模式的“段:偏移”双分量设计,给了程序灵活性,也带来了理解门槛。
你写的MBR代码位于内存地址0x7C00,但nasm在给你的标签生成地址时,完全取决于你写的vstart还是org。如果不加任何指示,nasm默认从0开始算,那么msg_start就是0x0013之类的偏移。此时你需要人为设置ds为0x07C0,才能让ds:si组合出0x7C13。而如果设置了vstart=0x7c00,nasm会生成0x7C13作为msg_start,此时只要段基址是0,地址就正确。
我的建议是:不要用段地址0x07C0 + 小偏移的写法,这虽然经典但容易算错。直接用vstart=0x7c00,让汇编器替你算绝对地址,然后保持DS=0、ES=0。这样所有标签的地址值都是0x7C00+offset,符合直觉。
如果你在调试时看到打印出来的字符是乱码,多半是bp或ds:bp指向了错误地址。用gdb单步调试可以确认,但最快的检查方法是临时在打印前加一条mov bx, 0x0007,用int 0x10的0x0E功能单个打印一个已知字符,比如'A',看看能否正常显示,以此判断段寄存器设置是否正确。
5.2 用qemu的调试接口查看寄存器
qemu默认启动的窗口看起来像一个黑盒子,其实它内置了调试功能,只是需要额外加参数。我们可以在启动时开启gdb服务:
qemu-system-x86_64 -drive file=mbr.bin,format=raw -machine pc -s -S这里的-S让CPU在启动后立刻暂停,-s表示在本地1234端口开启gdb调试服务。
然后另开一个终端,执行:
gdb target remote :1234 b *0x7c00 c info registers在gdb里打断点可以直接打在0x7C00地址上,因为这个地址是BIOS跳转到MBR的固定入口。运行到断点后,查看寄存器和内存,就能确认CS、DS等寄存器是否符合预期。
我实习时最常用的命令是x/16xb 0x7c00查看内存里的机器码是不是我们编译出来的那几条。如果机器码对不上,说明加载的不是我们的MBR。这时再检查镜像文件是不是512字节,或者结束标志是不是没有放对位置。
5.3 不要踩的坑:栈溢出与死循环
MBR代码中设置堆栈是必要的,因为BIOS中断调用会压栈。如果你不设栈,SP的值在BIOS跳转时可能是任意的,中断调用会把返回地址写到不可预测的内存位置,轻则程序崩溃,重则破坏BIOS数据。
我在默认没有设置sp的情况下第一次跑,结果打印完字符串就花了屏。后来改成mov sp, 0x7c00就好了。栈向下增长,从0x7C00往低地址写,不会影响MBR代码本身,这是一个安全的选择。
另一个坑是死循环的位置。如果你在主流程末尾写jmp $,程序会永远停留在这个循环里。如果你是写一个真实的引导程序,这个位置通常会放加载内核的跳转指令,跳到0x10000之类的地址继续执行。但在第二章的实验里,死循环反而是我们期望的终止状态,表示“引导代码已经运行完毕,进入待机”。
5.4 用org还是vstart,给你一个结论
网上很多老教程直接用org 0x7c00,这个也很好。org告诉nasm代码被加载到0x7C00,所有标签按绝对地址计算。而vstart是NASM针对“虚拟起始地址”的伪指令,它让段内偏移从0x7C00开始算,但不会在生成二进制时添加任何头部信息。二者者在纯二进制模式下效果几乎一样。
推荐用vstart=0x7c00,因为它更明确地表达“这个段的代码会被加载到0x7C00,且偏移按此计算”。它也方便你后续把同一份代码放到不同段里复用,不过MBR阶段不需要这种能力。只要你选一个,并保持一致,就不会出错。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| qemu黑屏 | 镜像非512字节,或0x55AA位置错误 | xxd检查最后两字节,用ls -l确认大小 |
| 屏幕显示乱码 | ES:BP指向错误,或字符串长度不对 | 检查vstart设置,检查CX的取值是否等于字符串实际长度 |
| 打印后死机 | 未设置栈或栈顶不正确 | 初始化SS和SP,设置SP为0x7C00 |
| 运行无反应 | qemu用了UEFI模式 | 确认启动命令不带-biosUEFI固件,使用默认SeaBIOS |
| 编译报错 | 标签地址超出16位 | 检查是否误用了32位指令,确认代码量在512字节内 |
6. 从打印字符串到读硬盘扇区
6.1 为什么要读硬盘
只打印一行字,MBR还没发挥真正作用。操作系统的代码不可能都塞进512字节的MBR里,所以MBR要做的事情其实是“加载后续的代码”。
最常见的设计是:MBR读取硬盘上特定扇区的内容到内存的某个位置,然后跳过去执行。这个“特定扇区”通常是2号扇区或更多连续扇区,取决于你要加载的内容大小。
BIOS为读取磁盘提供了int 0x13的0x02功能,也就是读扇区。它的输入参数很多,核心是按下表设置寄存器:
| 寄存器 | 含义 |
|---|---|
| AH | 0x02 表示读扇区 |
| AL | 读多少个扇区 |
| CH | 柱面号低8位 |
| CL | 扇区号(位0-5)和柱面号高2位(位6-7) |
| DH | 磁头号 |
| DL | 驱动器号(0x80表示第一个硬盘) |
| ES:BX | 数据缓冲区地址 |
这个接口是CHS寻址方式,而不是LBA。硬盘在古老的CHS模式下,用“柱面-磁头-扇区”三个值定位。它的地址转换比较绕,CL寄存器既有扇区号又有柱面号高位,非常容易写错。好消息是,很多BIOS支持扩展读功能,int 0x13的0x42号功能能直接传LBA逻辑块地址,更符合直觉。
6.2 用LBA模式读硬盘扇区
LBA读取方式需要构造一个“磁盘地址包”(Disk Address Packet, DAP),放在内存里,然后把它的地址传给DS:SI。结构体如下:
struct DAP { uint8_t size; // 16 uint8_t reserved; // 0 uint8_t count; // 扇区数 uint8_t reserved2; // 0 uint16_t buffer_offset; uint16_t buffer_segment; uint64_t lba; // 起始LBA } __attribute__((packed));在汇编里,我们可以用times和db伪指令构造这个数据结构,然后调用int 0x13的0x42功能。
一个简单的读取示例(只展示关键部分):
mov si, dap mov ah, 0x42 mov dl, 0x80 int 0x13 jc read_error如果进位标志被置位,表示读取失败。这时可以读取AH寄存器里的错误码来定位问题。这个错误码很有用,比如0x01表示无效命令,0x04表示扇区未找到。
在《操作系统真象还原》第二章里,作者并没有深入LBA,而是先用CHS读取。但对新手来说,直接用LBA扩展更省心,因为现代硬盘和qemu虚拟硬盘都支持LBA。你在实验时不妨直接写一个基于LBA的读扇区版本,加载一个小程序,比CHS版本更简单也更容易调试。
6.3 加载到哪个内存地址最合适
读出来的扇区不能乱放。如果放到0x7C00,就会覆盖正在运行的MBR代码,虽然你已经不再需要MBR了,但覆盖过程中可能发生指令取指错误,所以最好放到别的安全地址。
传统Boot Loader习惯把后续代码放到0x10000(64KB处)或0x1000(4KB处)。具体看你的代码量。第二章的加载器比较简单,放到0x10000是一个不会和BIOS数据区冲突的地址,同时在实模式可寻址1MB范围内。
读取完成后,直接用jmp 0x1000:0x0000跳转过去执行,段地址0x1000,偏移0。如果你的后续代码是32位代码,还需要先切换保护模式,那就复杂多了。但在第二章,加载的还是16位代码,直接跳转即可。
6.4 读盘失败的高频原因
我用qemu测试LBA读取时,遇到过最多次的问题就是dl设置错了。dl必须设为0x80表示第一块硬盘。如果你在调试时把dl设成0,BIOS会认定是软驱设备,读取结果很可能直接失败。
另外,DAP结构体必须放在某个可访问的内存地址,且保证读取期间不会被覆盖。我习惯把DAP放在MBR代码段之后的空间,比如0x7E00,这样它既在1MB内,又不会与栈冲突。要注意ds:si指向的是DAP,而不是磁盘缓冲区,很多人把这两个地址弄混,导致读出来全是0。
最后,启动qemu时指定镜像要加上format=raw,否则qemu可能按其他格式解析文件格式头,导致扇区内容错位。这一点在《操作系统真象还原》的实验里也反复提醒过。
7. 让MBR看起来更专业:字符串与状态反馈
7.1 打印加载状态信息
一段不打印任何信息的加载过程,调试起来非常痛苦。你根本不知道代码卡在哪个环节。所以我建议你在MBR里加上简单的状态打印。
比如:
- 开机后打印
[MBR] - 读取磁盘前打印
[Loading...] - 读取成功打印
[OK] - 失败打印
[Fail]
实现并不复杂,写一个通用的字符串打印子程序:
print: lodsb or al, al jz .done mov ah, 0x0e int 0x10 jmp print .done: ret这个子程序要求ds:si指向以0结尾的字符串,使用int 0x10的0x0e方式逐个输出字符。使用0结尾的好处是不用每次手动计算字符串长度。
在实模式下,函数调用没有保护现场的概念,全是裸寄存器。所以调用print之前要把si设置好,如果子程序里用了其它寄存器,你就要在调用前保存或者接受可能被修改的事实。我通常会在print内部保存要用到的寄存器:
print: push ax push si .loop: lodsb or al, al jz .done mov ah, 0x0e int 0x10 jmp .loop .done: pop si pop ax ret这样调用方就不用担心ax和si被破坏。
7.2 扇区读取结果的可视化验证
除了打印字符串,你还可以在读取完扇区后,打印缓冲区里第一个字的十六进制值,用来验证数据对不对。但实模式下没有现成的整数转字符串函数,你需要自己写一个十六进制打印函数。
一个简单的思路:一个字节分成高4位和低4位,分别查表转成字符,然后调用int 0x10输出。代码大概长这样:
print_hex: mov cx, 4 .loop: rol dx, 4 mov al, dl and al, 0x0f add al, '0' cmp al, '9' jbe .digit add al, 'A' - '0' - 10 .digit: mov ah, 0x0e int 0x10 loop .loop ret这种调试辅助代码虽然占空间,但能让你在裸机环境下最快定位问题。调试完再删掉也不迟。毕竟MBR只有446字节可用,放太多调试代码会影响后续功能,但这些技巧在写更大的Boot Loader时同样有效。
8. 第二章之后的路:MBR还能做什么
8.1 从MBR到加载器
《操作系统真象还原》第二章只是起步,下一章就要写加载器,把内核从硬盘读到内存,再进入保护模式。MBR只是512字节的引导点,真正的操作系统加载逻辑通常放在后续扇区。
在后续章节中,你通常会写一个名为LOADER的第二个引导程序,它被MBR读取到0x100或0x10000等地址,然后负责更多工作:A20地址线开启、GDT设置、进入保护模式、加载内核。MBR本身保持极简,这有实际原因——512字节实在写不了复杂功能,即使能硬塞进去,也会让维护变得灾难。
从第二章到第三章,你会慢慢体会到“分别加载”这种架构思路:MBR只负责加载LOADER,LOADER负责加载内核,各司其职,解耦清晰。这种结构也被真实的GRUB等Boot Loader沿用。
8.2 真机启动的不一样之处
使用qemu调试非常顺利,但你最终可能会想在一块真实主板上启动你的MBR。真机环境有更多差异:
- 有些BIOS会校验MBR的磁盘签名(偏移0x1B8处的4字节),不合法时会拒绝引导,但实际上多数老BIOS只认0x55AA。
- 某些主板的BIOS在引导时会修改
dl,所以你的MBR代码必须把dl视为当前引导驱动器号,而不是写死0x80。 - 实模式下硬盘可能以CHS方式访问,旧机器不支持LBA扩展,你得写两套读取方案。
如果在真实机器上测试,推荐用U盘或者SD卡,使用dd写入镜像。写入命令:
sudo dd if=mbr.bin of=/dev/sdX bs=512 count=1 conv=notrunc把/dev/sdX换成你的U盘设备名,千万别写错。这是最经典也最危险的命令之一,搞错目标盘会清空数据。在没有十足把握之前,先用qemu模拟足够久。
8.3 书上的实验与我的扩展实践
第二章的例题一般只要求打印字符串,但我建议你把它扩展一下:
- 增加读取硬盘扇区的功能;
- 增加一个独立的小程序放在第2个扇区,让MBR把它加载到
0x10000并打印信息; - 用LBA方式读扇区,对比CHS方式的复杂度。
我自己做扩展时,发现最头疼的是生成一个“多扇区镜像”。通常的做法是:单独汇编MBR生成mbr.bin,单独汇编第二段程序生成loader.bin,然后用dd把两个文件拼接成一个大镜像。拼接命令:
dd if=/dev/zero of=disk.img bs=512 count=2052 dd if=mbr.bin of=disk.img bs=512 count=1 conv=notrunc dd if=loader.bin of=disk.img bs=512 seek=1 conv=notrunc第一条命令创建一个约1MB的空白镜像,第二条把MBR写入0号扇区,第三条把loader写到1号扇区。qemu启动时用disk.img作为磁盘。这种组合方法在后面章节里几乎是标准操作。
8.4 独立完成第二章项目的小结标准
你可以用下面几个标准检查自己是否真正掌握了第二章:
- 能不看代码默写一个打印字符串的MBR框架,并解释每行作用;
- 能解释为什么MBR的加载地址是0x7C00,为什么结束标志是0x55AA;
- 能调试一个读取扇区失败的问题,而不是只会重写代码;
- 能说明MBR和GPT引导方式的区别,以及为什么实验环境用MBR。
如果这些都能做到,那你可以放心进入第三章,开始写加载器和保护模式了。
我个人的经验是,操作系统的学习最容易卡在“代码明明能跑,但不知道它为什么能跑”的阶段。《操作系统真象还原》第二章的价值恰恰在于把最基本的启动链路掰开揉碎,让你亲眼看到自己写的代码在实模式下的运行效果。不要急着往后翻,把这一章里的每个寄存器都“亲手摸一遍”,后面才会顺。
最后再分享一个小技巧:不要把MBR实验只跑一遍就扔掉。你可以试着在打印字符串的基础上,把字符串内容改成彩色显示,或者用BIOS中断读取键盘输入,再决定是否跳转到另一个扇区的代码。这样的进阶实验会让你对中断调用的熟练度大幅提升,而这也正是后续Loader开发中最需要的技能。