
1. 项目概述从“能启动”到“能干活”的MBR如果你跟着《操作系统真象还原》这本书走到了第三章恭喜你你已经成功让计算机从硬盘上读取了你的代码并执行了。屏幕上那个小小的“1 MBR”或者你自定义的字符串就是MBR主引导记录生命最初的啼哭。但兴奋过后一个现实的问题立刻摆在眼前这个只有512字节的小程序除了打印点字符还能做什么它离一个真正的“引导程序”还差得远。第三章“完善MBR”的核心任务就是把这块512字节的“弹丸之地”从一个简单的演示程序升级为一个具备初步工作能力的“引导加载器”雏形。简单来说前两章我们造了一个能响应的“空壳”第三章我们要往这个壳子里注入灵魂——让它具备从硬盘加载其他程序比如后续的操作系统内核加载器的能力。这就像你造了一艘小艇MBR之前只验证了它能浮在水上能被BIOS加载执行现在你要给它装上桨和导航系统让它能驶向远方的大船内核并将其接过来。这个过程涉及对计算机底层硬件如硬盘端口、内存布局的精确操作是打通从“固件”到“软件”的关键一步。无论你是想深入理解操作系统启动的每一个齿轮如何咬合还是立志于动手实现自己的迷你系统这一章都是无法绕过的硬核实践。2. 核心需求解析为什么MBR需要“完善”一个“完善”的MBR其使命远不止于向屏幕输出信息。在传统的Legacy BIOS启动模式下MBR是上电自检POST后由BIOS加载到内存并执行的第一段“软件”。它被赋予了几项关键职责而原书前两章的示例仅仅完成了最微不足道的一小部分。2.1 核心职责一探查与规划内存BIOS将MBR加载到物理地址0x7c00处并跳转执行。此时MBR对系统的了解几乎为零。它需要知道哪些内存区域是可用的、哪些是被系统保留的比如用于显存、BIOS数据区等。虽然在本章的具体实现中我们可能暂时简化这一步但一个健壮的引导程序必须具有内存探测的能力以便为后续加载的代码安排安全的落脚点。理解内存布局是避免程序间互相覆盖、导致系统崩溃的基础。2.2 核心职责二加载后续的引导程序或内核加载器512字节的容量扣除引导签名和分区表留给代码的空间大约只有440字节左右。这点空间连一个稍微复杂点的显示器模式设置都做不完更别提加载现代操作系统内核了。因此MBR的核心任务就是作为“第一级引导加载器”从硬盘上找到并加载体积更大、功能更强大的“第二级引导加载器”比如GRUB的core.img或者你自己写的loader.bin。这个过程就是“加载加载器”是启动链条中承上启下的关键一环。2.3 核心职责三传递必要的硬件信息BIOS在启动过程中检测并初始化了硬件这些信息如检测到的内存大小、显卡模式等通常保存在特定的内存区域。一个完善的MBR在跳转到第二级加载器之前可以将这些信息的指针或数据本身传递过去为后续阶段节省重新探测硬件的开销。这需要遵循一定的约定例如通过寄存器或特定的内存地址来传递参数。2.4 核心职责四完成必要的早期硬件初始化在某些场景下MBR可能还需要进行一些最基本的硬件设置。最常见的就是将处理器从实模式16位切换到保护模式32位甚至长模式64位的前期准备。虽然完整的切换通常由第二级加载器完成但MBR可能需要准备好全局描述符表GDT等数据结构。本章的“完善”主要聚焦于第二个职责——实现从硬盘加载数据到内存这一核心功能这是后续所有高级功能的基础。3. 技术选型与设计思路在实模式下与硬盘对话要在实模式下从硬盘加载数据我们面临几个关键选择。这些选择决定了代码的复杂度、兼容性和可靠性。3.1 访问方式CHS vs LBACHS柱面-磁头-扇区这是古老的硬盘寻址方式直接对应硬盘的物理结构。需要知道磁头数、柱面数、每磁道扇区数。计算复杂且对于容量大于8GB的硬盘存在寻址限制24位CHS寻址上限。但它的兼容性最好是所有硬盘都支持的基础方式。LBA逻辑块地址将整个硬盘扇区线性编号从0开始。操作简单直观是现代操作系统和硬盘的主要寻址方式。但需要硬盘控制器支持LBA模式。设计决策对于学习性和兼容性优先的引导程序通常先实现CHS读取作为基础。因为即使在最老的机器上BIOS也会提供CHS参数且确保能工作。在确认CHS工作正常后可以增加LBA读取特别是LBA28或LBA48来支持大容量硬盘和简化操作。原书第三章很可能从CHS开始讲解这是理解硬盘物理结构的绝佳途径。3.2 编程接口BIOS中断 vs 端口直接控制BIOS中断INT 13hBIOS提供了一组用于磁盘服务的中断例程如INT 13h, AH02h用于读扇区。使用简单只需设置好寄存器参数即可调用。BIOS会自动处理底层细节兼容性好。端口直接控制PIO模式直接通过in和out指令与硬盘控制器IDE/AHCI的I/O端口通信。这种方式更底层、更高效且不依赖BIOS在进入保护模式后BIOS中断不可用是唯一选择。但编程复杂需要详细了解硬盘控制器的寄存器定义和状态机。设计决策在MBR阶段优先使用BIOS中断。原因有三1) MBR运行在实模式下BIOS中断完全可用且稳定2) 代码简洁易于理解和调试3) 我们的目标是尽快加载第二级加载器而不是追求极致性能。将复杂的端口操作留给后续阶段更为合适。因此本章完善MBR的核心就是学会正确调用INT 13h中断来读取硬盘扇区。3.3 加载目标地址与大小的规划MBR自己被加载到0x7c00那么它应该把读出来的数据放到哪里这需要精心规划避免覆盖自己或重要数据区。常见选择0x90000这是一个在早期内存布局中相对安全的高地址区域。0x7c00到0x7e00是MBR0x7e00往后到约0x9ffff是可用区域约609KB。将第二级加载器加载到0x90000576KB处留下了足够的缓冲区也远离了MBR和BIOS数据区。大小限制通过BIOS中断INT 13h一次能读取的扇区数是有限的通常受限于磁盘参数和BIOS实现。我们需要设计一个循环分批读取直到将整个第二级加载器比如一个2KB的loader.bin完整载入内存。基于以上分析本章的实现思路就清晰了编写一个使用BIOS中断INT 13h、以CHS模式或简单LBA、循环读取多个扇区到目标内存如0x90000的汇编程序并跳转到该内存地址执行。4. 关键代码实现与逐行解析下面我们以一个典型的“完善MBR”代码框架为例进行拆解。假设我们要从硬盘的第二个扇区LBA1注意扇区号通常从0开始计数开始读取4个扇区即2KB的内容到内存0x90000处然后跳转过去。; 文件名mbr.asm ; 描述完善的MBR具备加载第二级加载器的功能 SECTION MBR vstart0x7c00 ; 告诉编译器代码加载的基地址是0x7c00 mov ax, cs mov ds, ax mov es, ax mov ss, ax mov fs, ax mov sp, 0x7c00 ; 设置栈指针栈向下生长 ; 功能清屏 mov ax, 0x0600 mov bx, 0x0700 mov cx, 0 mov dx, 0x184f int 0x10 ; 功能设置光标位置到(0,0) mov ah, 0x02 mov bh, 0 mov dx, 0 int 0x10 ; 功能打印提示信息 mov si, msg_loading call print_string ; 核心功能从硬盘加载loader mov eax, 1 ; 起始扇区LBA号第2个扇区 mov bx, 0x9000 ; 目标内存段地址 (ES:BX - 0x9000:0x0000 0x90000) mov cx, 4 ; 待读取的扇区数 call read_disk_lba ; 调用读取函数 ; 加载完成跳转到loader jmp 0x9000:0x0000 ; 远跳转跳转到loader的入口点 ; 函数打印字符串以0结尾 print_string: mov ah, 0x0e .print_loop: lodsb ; 从[ds:si]加载一个字节到alsi自增 cmp al, 0 je .print_done int 0x10 jmp .print_loop .print_done: ret ; 函数使用LBA28模式读取硬盘扇区 ; 参数eaxLBA扇区号 es:bx目标内存地址 cx读取扇区数 read_disk_lba: ; 1. 备份参数 mov esi, eax mov di, cx ; 2. 设置读取的扇区数 mov dx, 0x1f2 mov al, cl out dx, al ; 3. 设置LBA地址28位 mov eax, esi ; LBA低8位 - 端口0x1f3 mov dx, 0x1f3 out dx, al ; LBA 8-15位 - 端口0x1f4 shr eax, 8 mov dx, 0x1f4 out dx, al ; LBA 16-23位 - 端口0x1f5 shr eax, 8 mov dx, 0x1f5 out dx, al ; LBA 24-27位 设备选择与LBA模式 - 端口0x1f6 shr eax, 8 and al, 0x0f ; 低4位是LBA的最高4位 or al, 0xe0 ; 设置高4位1110表示LBA模式主设备 mov dx, 0x1f6 out dx, al ; 4. 发送读命令0x20 mov dx, 0x1f7 mov al, 0x20 out dx, al ; 5. 等待硬盘准备就绪 .not_ready: in al, dx and al, 0x88 ; 检查BSY位(bit7)和DRQ位(bit3) cmp al, 0x08 ; BSY0且DRQ1时表示数据就绪 jnz .not_ready ; 6. 从数据端口(0x1f0)读取数据 mov dx, 0x1f0 mov cx, di ; 恢复要读取的扇区数 .read_sector_loop: push cx mov cx, 256 ; 一个扇区512字节每次读一个字(2字节)共256次 .read_word: in ax, dx ; 从端口0x1f0读一个字到ax mov [es:bx], ax ; 存储到目标内存 add bx, 2 loop .read_word pop cx loop .read_sector_loop ret ; 数据区 msg_loading db Loading loader..., 0 ; 填充剩余空间并添加引导签名 times 510-($-$$) db 0 db 0x55, 0xaa代码关键点解析vstart0x7c00这是NASM编译器的伪指令至关重要。它告诉编译器所有标号的地址都基于0x7c00计算。例如msg_loading在文件中的偏移可能是0x00c0但在内存中它的实际地址是0x7c00 0x00c0 0x7cc0。如果没有这个跳转和内存访问的地址会全部错乱。栈的设置mov sp, 0x7c00将栈顶设在MBR的起始处栈向下向低地址生长。这为函数调用call提供了空间但要注意别让栈数据覆盖了MBR自身的代码通常不会因为函数调用不深。read_disk_lba函数这是本章的灵魂。它使用了LBA28模式和**PIO编程输入输出**方式读取硬盘。步骤分解步骤2-4向硬盘控制器的各个端口0x1f2-0x1f7写入参数和命令。0x1f6端口的0xe0设置了LBA模式和选择主盘。步骤5轮询状态端口0x1f7等待硬盘不忙BSY0且数据请求就绪DRQ1。这是一个阻塞等待循环。步骤6从数据端口0x1f0连续读取数据。注意in ax, dx一次读取16位一个字一个扇区512字节需要读256次。外层循环控制读取多个扇区。跳转指令jmp 0x9000:0x0000。这是一个远跳转同时改变了CS和IP寄存器。CS被设为0x9000IP设为0处理器将从物理地址CS*16 IP 0x90000处开始执行。这正是我们加载第二级加载器的地方。引导签名最后的times 510-($-$$) db 0用于将MBR填充到510字节db 0x55, 0xaa是固定的引导签名第511和512字节。BIOS依靠这个签名来识别一个设备是否可引导。5. 从编写到运行完整实操流程理解了代码我们来看看如何将它变成可以运行的MBR并测试其加载功能。5.1 环境准备与工具链汇编编译器推荐使用NASMNetwide Assembler。它轻量、高效是x86汇编的常用选择。虚拟机使用Bochs或QEMU。Bochs调试功能极其强大适合深入学习QEMU启动快速。这里以Bochs为例。磁盘映像工具可以使用dd命令Linux/macOS或WinImage等工具创建空白磁盘映像。5.2 编译与写入MBR编写汇编代码将上面的代码保存为mbr.asm。编译打开终端执行nasm -f bin mbr.asm -o mbr.bin。-f bin指定输出为纯二进制格式这正是MBR需要的。创建磁盘映像dd if/dev/zero ofboot.img bs512 count2048。这创建一个1MB512*2048的空白映像。写入MBRdd ifmbr.bin ofboot.img bs512 count1 convnotrunc。将编译好的mbr.bin写入映像的第一个扇区convnotrunc确保不截断原文件。5.3 创建并放置第二级加载器Loader为了测试MBR的加载功能我们需要一个简单的“Loader”程序。创建一个loader.asmSECTION LOADER vstart0x90000 mov si, msg_loader call print jmp $ ; 无限循环 print: mov ah, 0x0e .print_loop: lodsb cmp al, 0 je .done int 0x10 jmp .print_loop .done: ret msg_loader db Hello from Loader!, 0编译它nasm -f bin loader.asm -o loader.bin。注意这里没有vstart0x7c00因为我们希望它的代码基于0x90000编址。 将loader.bin写入磁盘映像的第二个扇区dd ifloader.bin ofboot.img bs512 seek1 convnotrunc。seek1表示跳过第一个扇区MBR从第二个开始写。5.4 配置与运行Bochs创建Bochs配置文件bochsrcmegs: 32 romimage: file$BXSHARE/BIOS-bochs-latest vgaromimage: file$BXSHARE/VGABIOS-lgpl-latest ata0-master: typedisk, pathboot.img, modeflat boot: disk display_library: sdl2运行Bochsbochs -f bochsrc -q。在Bochs提示符下输入c继续执行。如果一切正常你将先看到“Loading loader...”然后屏幕清空因为跳转后CS:IP改变可能中断向量表等未妥善处理导致显示异常或者Loader自己清屏最后看到“Hello from Loader!”。使用Bochs的内置调试器如b 0x7c00设置断点可以单步跟踪整个加载过程观察寄存器和内存的变化这是不可多得的学习体验。6. 深度原理实模式下的硬盘访问机制要真正驾驭这段代码必须理解其背后的硬件原理。我们使用的是IDE硬盘的PIO模式。6.1 硬盘控制器端口映射在x86架构中通过I/O端口与硬件通信。主IDE通道被映射到一组固定的端口号0x1f0-0x1f7等。每个端口都有特定功能0x1f0数据端口读写数据都必须经过它。0x1f1错误寄存器读或特性寄存器写。0x1f2扇区数寄存器。指定要读/写的扇区数量。0x1f3-0x1f5LBA低、中、高字节寄存器。0x1f6设备/磁头寄存器。低4位是LBA的最高4位第4位0-based选择主从盘0主1从第6位设置LBA模式1为LBA。0x1f7命令/状态寄存器。写入时是命令端口如0x20读0x30写读取时是状态端口。6.2 LBA28寻址详解我们代码中使用的是LBA28模式即使用28位二进制数来寻址扇区最大支持2^28 * 512 bytes 128GB的硬盘。28位的LBA地址被拆分到四个8位端口LBA 0-7位 -0x1f3LBA 8-15位 -0x1f4LBA 16-23位 -0x1f5LBA 24-27位 -0x1f6的低4位 向0x1f6写入0xe0二进制1110 0000的含义是高4位1110其中第7、5位固定为1第6位1表示启用LBA模式第4位0表示选择主设备Master。6.3 状态寄存器与等待循环发送读命令后硬盘需要时间寻道和准备数据。程序必须等待。通过反复读取状态寄存器0x1f7来检查BSY (Bit 7)硬盘忙。为1时其他位无效。DRQ (Bit 3)数据请求就绪。为1时表示数据已准备好可以通过数据端口读取。 我们的等待循环检查(AL 0x88) 0x08即等待BSY为0且DRQ为1。这是一种简单的轮询方式。更健壮的代码还应检查错误位ERR, Bit 0和磁盘就绪位RDY, Bit 6。6.4 数据读取与内存写入数据就绪后从数据端口0x1f0连续读取。每次in ax, dx指令会从端口读取16位2字节到AX寄存器然后自动递增内部地址指针指向下一个字。这就是为什么我们用一个256次的循环读完一个扇区。读取的数据通过mov [es:bx], ax写入ES:BX指向的内存。ES在调用前已被设置为0x9000BX初始为0每次写入后BX加2从而线性填充从0x90000开始的内存区域。7. 常见问题、调试技巧与避坑指南即使代码看似正确第一次尝试也常常失败。以下是我在实践和教学中总结的常见问题点。7.1 问题一编译通过但Bochs启动后黑屏或重启可能原因1引导签名错误或位置不对。务必确保0x55在第511字节偏移0x1FE0xAA在第512字节偏移0x1FF。使用hexdump -C mbr.bin | tail -n 5检查二进制文件末尾。可能原因2代码中有非法操作或未初始化段寄存器。确保在操作内存如mov [bx], ax前正确设置了DS或ES寄存器。我们的代码开头就统一设置了DSESSSCS。可能原因3栈设置不当导致意外覆盖代码。虽然不常见但如果进行了深度递归或大量压栈栈向下生长可能会覆盖0x7c00以下的代码。确保栈空间足够。7.2 问题二能看到“Loading loader...”但之后黑屏或乱码看不到Loader的信息可能原因1Loader没有正确写入磁盘映像的第二个扇区。使用dd命令时务必确认seek1参数正确且loader.bin大小不超过你预留的扇区数。可以用hexdump -C boot.img | less查看磁盘映像内容确认在偏移0x200512字节之后是你的Loader代码。可能原因2读取扇区数错误或目标内存地址错误。检查调用read_disk_lba时传入的参数eax起始LBA、cx扇区数、es:bx目标地址。确保Loader确实被读到了0x90000。可以在Bochs调试器中在读取函数返回后使用xp /1024b 0x90000命令查看该内存区域的内容是否与loader.bin的二进制内容一致。可能原因3Loader自身的代码或编址问题。Loader中使用了vstart0x90000这意味着其中的标号如msg_loader地址是基于0x90000计算的。如果Loader里用了错误的段寄存器或跳转也会失败。一个简单的调试方法是让Loader的第一条指令就是一条显式输出字符的指令如mov ah, 0x0e; mov al, L; int 0x10先确认跳转成功。7.3 问题三Bochs报告磁盘读错误例如“ATA0-0: read failed”可能原因LBA地址超出磁盘范围或磁盘模式不对。我们创建的boot.img只有2048个扇区1MB。如果你尝试读取的LBA地址大于等于2048就会出错。确保eax参数正确。另外虽然我们设置了LBA模式但极少数非常古老的虚拟硬件或配置可能不支持可以尝试回退到CHS模式进行对比测试。7.4 高级调试技巧Bochs魔法断点在Bochs配置文件中加入magic_break: enabled1然后在汇编代码中插入xchg bx, bx指令。Bochs遇到这条指令会自动中断进入调试器。这是实模式下的软件断点神器。内存与寄存器检查在调试器中多用x /nuf addr命令查看内存如x /16xb 0x7c00看MBR开头用r或info r查看寄存器状态。特别注意CS:IP和ES:BX的值。单步执行与反汇编使用s单步执行n跳过函数调用。使用u /10对当前CS:IP进行反汇编确保代码执行流符合预期。7.5 避坑心得始终保持段寄存器清晰在实模式下任何内存访问都隐式或显式地依赖于段寄存器。在写函数时想清楚这个函数会修改哪些段寄存器ES?DS?调用者是否需要保存它们。一个良好的习惯是函数如果修改了ES或DS在返回前恢复它们。小心LOOP指令LOOP指令使用CX寄存器作为计数器。如果你的函数内部也用了CX务必在循环开始前push cx循环结束后pop cx或者使用其他寄存器如DI作为循环计数器避免冲突。硬盘操作是慢速操作等待循环是必须的但最好加入超时机制。例如循环超过一定次数如10000次后仍未就绪则视为错误跳转到错误处理流程如打印错误码后挂起。从简单到复杂验证不要一次性写完整套加载逻辑。先写一个MBR只读一个扇区到内存然后把这个扇区的内容可以是一段简单的显示代码写回屏幕验证读取过程本身是否正确。然后再增加循环读取多个扇区和跳转逻辑。完善MBR的过程是真正开始与计算机硬件对话的第一步。它打破了“魔法”的屏障让你清晰地看到操作系统那宏伟的大厦最初就是从这512字节的泥土中一砖一瓦地构建起来的。当你看到自己编写的Loader成功被MBR拉起并执行时那种对系统掌控感的理解是任何理论教材都无法给予的。这不仅仅是完成书中的一章练习更是为你后续理解更复杂的保护模式切换、内存分页、驱动加载打下了坚实的地基。