资讯动态

MBR引导加载实战:从int 13h读盘到loader跳转的完整实现

发布时间:2026/10/17 19:17:46 来源:尧图企业网站定制
前两章把开发环境搭起来、写了第一版只能打印字符的MBR之后到《操作系统真象还原》第三章“完善MBR”会第一次真正直面“引导”这件事。前两章写的那段代码在屏幕上输出一句提示就完事了但那种程度只是热身。第三章要解决的核心问题很明确让MBR具备读硬盘的能力把后续代码从磁盘搬进内存再把控制权交出去。这篇文章是跟着这一章一步步实操的记录包含我在寄存器设置、读盘函数、跳转地址和调试过程中踩过的坑以及最终能稳定跑通的代码结构和验证方法。适合已经能编译运行最小MBR、但还没彻底理解引导链路的读者也适合那些刚接触int 13h、不知道扇区地址怎么换算的人。1. 先搞清楚MBR要“完善”什么1.1 512字节的硬约束MBR全称是主引导记录它被BIOS在开机时从硬盘第1个扇区读到内存0x7c00处然后跳过去执行。这里有个非常残酷的前提这个扇区只有512字节而且末尾还必须以0x55AA两个字节结束。也就是说真正能写代码的空间只有510字节左右。510字节能干什么打印字符串、清屏、做一些简单的判断绰绰有余。但要说在里面放下一个操作系统内核哪怕是极小内核都是天方夜谭。这就逼着设计者做分层MBR只负责最基础的工作真正复杂的初始化放到后面更大的程序里。这个更大的程序通常叫loader。第三章的实质就是让MBR完成“引导加载”这个动作而不是停留在“显示一段话”。1.2 引导链的分工逻辑如果拿生活场景类比MBR就像一张名片。名片上印不下几千字的个人介绍只能印一个网址让你点进去看完整内容。在这里名片上的“网址”就是硬盘上的某个扇区位置而网页就是loader程序。MBR从磁盘上把loader读取到内存然后跳转过去执行loader再负责后续更大的事。这里有个容易混淆的点MBR和loader的分工为什么要拆开因为MBR大小固定且不可逾越而loader可以放在磁盘上任意位置大小也没有512字节限制。在第三章这个阶段loader只要做一件很小的事被MBR成功加载后打印一行自己的标识。这验证了整条链路是通的等到了后续章节loader才会逐步承担更大的责任。1.3 本章的验证目标我在动手写代码之前给自己列了一个明确的验收清单MBR启动后能输出引导提示确认它自己在运行MBR能通过BIOS中断读取硬盘指定扇区读取到的内容被放到一个约定的内存地址MBR跳转到loader的入口loader再输出一段独立的提示信息。这样划分之后“完善MBR”就不再是一句模糊的话而是四个可以逐步验证的阶段。每次只推进一个点出问题也容易定位。这在写底层代码时尤其重要因为你面对的是没有printf、没有断点库函数的环境每一小步能跑通就是巨大的胜利。2. 磁盘扇区定位CHS与LBA的关系2.1 磁盘怎么组织数据既然要让MBR读硬盘先得知道读哪个位置的扇区。老式硬盘以及虚拟机里模拟的硬盘在逻辑上由柱面Cylinder、磁头Head、扇区Sector三个维度组织数据。可以把硬盘想象成一个多层圆盘。每个盘面有一个磁头负责读写盘面上由一圈圈同心圆构成磁道磁道又被分割成一段段扇形区域每个扇形区域就是一个扇区固定容量512字节。多个盘面上同一半径的磁道合起来就叫一个柱面。这个模型现在看着有点古老但虚拟机环境里模拟的硬盘仍然是这种几何结构。很多教程直接说“第几个扇区”其实那是LBA的说法。真到了要填BIOS中断参数的时候还是得转成C/H/S三个数字。2.2 CHS怎么算容量硬盘容量 柱面数 × 磁头数 × 每磁道扇区数 × 512字节。举个我自己用的虚拟机示例参数柱面数20、磁头数16、每磁道扇区数63。算一下20 × 16 × 63 × 512 10,321,920字节刚好不到10MB。这个容量很小但用来学习引导流程完全够了。知道这个公式的意义在于你在写读盘函数时心里要对“扇区号会不会越界”有数。比如在一个只有20个柱面的盘上把柱面参数设成80那BIOS大概率会直接报错。2.3 LBA是更现代的用法LBA逻辑块寻址把磁盘上所有扇区按照从头到尾的顺序编成线性编号。第0号LBA就是硬盘上第一个物理扇区第1号LBA是第二个以此类推。MBR本身位于LBA 0loader如果放在第2个扇区它的LBA就是1。LBA和CHS之间的换算公式并不复杂。设每磁道扇区数为SPT磁头数为H扇区号S LBA % SPT 1磁头号Hd (LBA / SPT) % H柱面号C LBA / (SPT × H)以SPT63、H16为例LBA1的换算过程是S 1 % 63 1 2Hd (1 / 63) % 16 0C 1 / (63 × 16) 0。所以LBA 1对应CHS(0, 0, 2)也就是0柱面0磁头第2扇区。这个结果直接对应我后面要读的位置。2.4 两种寻址方式的取舍实际操作中BIOS的int 13h提供了两种读盘接口一种是传统的CHS方式另一种是扩展的LBA方式。CHS方式需要自己准备C/H/S三个值LBA方式则要构造一个内存数据结构把64位的起始扇区号放进去。对于这一章的学习场景我选择CHS方式。原因有三点第一我们的虚拟硬盘很小柱面、磁头、扇区编号都在个位数级别手算不费力第二CHS方式的寄存器参数直观每一个寄存器含义清楚适合初学者建立“BIOS中断就是填寄存器再调用”的直觉第三这一章的目的不是写最健壮的磁盘驱动而是理解引导链路。LBA方式等后面真正需要处理大容量磁盘时再切过去也不迟。3. int 13h读盘寄存器到底怎么填3.1 BIOS中断的基本套路BIOS中断本质上是一组固化在主板固件里的服务程序调用方式统一为把参数放进指定寄存器然后执行int指令。以int 13h为例AH寄存器存放功能号其他寄存器根据功能号不同存放对应参数。调用完成后通过进位标志CF判断是否成功CF0表示成功CF1表示失败错误码在AH里。这个“先填寄存器、再调用中断、最后看CF”的模式在引导阶段几乎无处不在。int 10h显示字符、int 13h读盘、int 15h查内存全都是同一套逻辑。我把这个模式记牢之后后面写任何BIOS相关代码都不慌了。3.2 CHS方式(功能号02h)的参数表int 13h的02h功能用于读取扇区参数如下AH 02h功能号表示读扇区AL 要读取的扇区数CH 柱面号的低8位CL 扇区号以及柱面号的高2位这个细节很重要见下方说明DH 磁头号DL 磁盘号0x80代表第一块硬盘ES:BX 数据读入内存的目标地址。CL寄存器是这一章最容易出错的地方。它的低6位存扇区号高2位要和CH寄存器配合共同组成10位的柱面号。比如柱面号是0那CH0CL的高2位也是0如果柱面号是300CH里存300的低8位CL的高2位存300的高2位。在第三章的小硬盘场景里柱面号一直是0所以大多数示例代码直接写CL2就完事但这个位域划分原理得知道否则等柱面超过255时会被bug折磨。调用完成后如果成功AL是实际读取的扇区数AH通常是0。如果失败AH是错误码常见的有0x01非法参数、0x02地址标记未找到、0x04扇区未找到等。3.3 我为读盘函数加的三次重试直接从BIOS读盘第一次就有大概率成功但也不是百分之百。某些模拟器环境或者冷启动状态下磁盘控制器可能还没完全就绪。稳妥的做法是加上重试机制失败时调用int 13h的00h功能磁盘控制器复位然后重新尝试读取。我写的简化版读盘函数大致长这样; 读取硬盘第二个扇区到 es:bx ; 使用 int 13h 功能号 02hCHS方式 read_second_sector: mov di, 3 ; 最多尝试三次 .read_try: mov ah, 0x02 ; 功能号读扇区 mov al, 1 ; 读1个扇区 mov ch, 0 ; 柱面号 0 mov cl, 2 ; 扇区号 2注意从1开始计数 mov dh, 0 ; 磁头号 0 mov dl, 0x80 ; 硬盘0 int 0x13 jnc .read_ok ; 如果成功跳出去 mov ah, 0x00 ; 失败则复位磁盘控制器 int 0x13 dec di jnz .read_try ; 三次都失败打印错误信息后死循环 jmp disk_error_handler .read_ok: ret这里有一个细节int 0x13调用会破坏一些寄存器的值所以关键参数比如目标地址ES:BX必须在每次重试前重新设置完整。我把ES:BX放在调用之前统一设置重试代码里没有再改动它们这在小例子里没问题如果你在重试代码里插入其他函数调用就必须小心保存现场。3.4 为什么ES:BX决定了数据去哪ES:BX组合成一个20位物理地址物理地址 ES × 16 BX。比如我想把loader加载到物理地址0x0900处可以设ES0x0000、BX0x0900也可以设ES0x0090、BX0x0000。两种写法地址一样但习惯上我选择让ES0、BX0x0900因为后面跳转直接用jmp 0:0x900更直观。容易犯的错是只设置了BX而忘记ES。如果ES不知道是什么时候遗留下来的值数据就会被写到完全不可预期的位置。轻则load没执行重则把内存里的关键数据覆盖掉导致屏幕花掉。4. MBR代码的完整结构与实操4.1 代码总体布局第三章的MBR代码我按功能拆成了几个部分主流程、打印字符串函数、读盘函数、以及最后的跳转指令。这样分的好处是后面无论哪个环节出了问题只需要盯住对应的函数。为了更通用我把开头设置成清屏、显示“Booting...”提示然后把loader从磁盘读进内存最后跳转。主流程代码大致是这样的[bits 16] org 0x7c00 jmp 0:start start: ; 初始化栈 mov ax, 0 mov ss, ax mov sp, 0x7c00 ; 显示引导提示 mov si, boot_msg call print_string ; 读取loader到内存 0x0900 mov ax, 0 mov es, ax mov bx, 0x0900 call read_second_sector ; 跳转执行loader jmp 0:0x0900 ; 停在这里正常情况下不会走到 halt_loop: jmp halt_loop这里栈顶设置为0x7c00是参考了常见引导代码的做法。MBR占据的是0x7c00到0x7dff这段内存栈向下增长从0x7c00开始push写的是0x7bfe、0x7bfc这些地址不会覆盖MBR本身。当然如果后面代码越写越多要留意0x7c00下方的内存会不会被其他关键数据占用至少在这个阶段是安全的。4.2 打印字符串的实现引导阶段没有C库往屏幕输出只能调int 10h的0x0E功能也就是Teletype模式每次调用输出一个字符光标自动后移。我写的打印字符串函数是逐字符循环print_string: lodsb ; 从ds:si取一个字节到al or al, al ; 判断是不是0结尾 jz .done mov ah, 0x0E mov bx, 0x0007 ; 黑底灰字 int 0x10 jmp print_string .done: ret boot_msg db Booting from MBR..., 0x0D, 0x0A, 0有个隐藏要点lodsb使用的是DS:SI所以在调用print_string之前必须确保DS已经被正确设置。我在MBR开头没有显式设置DS因为BIOS跳进MBR时DS通常和CS一致或至少能正常访问自己的代码段。但为了严谨我其实应该在start里加上mov ax, cs; mov ds, ax。后来loader代码里我补上了这一步MBR里也建议大家补上反正多一行指令不亏。显示颜色属性0x07的含义是黑底白字实际上BIOS默认就是白色但这个属性值可以显式指定。如果你想让输出更醒目可以改成0x0E结果是黄字黑底。这个是个人喜好不影响功能。4.3 读盘函数与目标地址的选择为什么loader要放到内存0x0900而不是直接放在MBR后面0x7e00这是我在看老资料时留下的习惯。0x0000到0x03FF是中断向量表0x0400到0x04FF是BIOS数据区0x0500到0x7BFF是常规可用内存。0x0900处于“常规可用内存”范围内离中断向量表有足够距离地址又低便于观察。当然放在0x1000、0x9000也完全可以只要不和已有数据冲突即可。读盘函数使用前面贴过的代码这里要说的是我最初犯过一个低级错误把目标地址设成mov bx, 0x0900之后忘掉了mov es, ax这一步。结果是读出来的数据被写进了一个随机段地址跳过去自然执行了非法指令。排查了很久才发现是ES的问题。所以这个坑是真的值得提前标记。4.4 编译、写入磁盘镜像整个MBR代码保存为mbr.s用NASM编译nasm -o mbr.bin mbr.s写进虚拟磁盘镜像的命令是dd ifmbr.bin ofdisk.img bs512 count1 convnotrunc参数说明bs512表示块大小是512字节count1表示只写一个块convnotrunc表示不要截断目标文件。如果漏掉notruncdd可能直接把磁盘镜像截成512字节后面loader数据全没了。写完MBR之后可以用十六进制工具看一眼镜像末尾是否有0x55AA。在Linux终端里可以这样确认xxd disk.img | tail -1如果最后两个字节是55 aa说明引导扇区格式正确。这个习惯我一直保持着每次写引导代码都会验证一次。5. loader的编写与联动验证5.1 loader这一章的“业务”第三章里的loader不需要承担什么复杂任务。它唯一的使命是证明“MBR读取扇区并把控制权转移”这条链路是通的。所以我的loader代码非常短加载到内存后重新初始化段寄存器打印一行标志性文字然后死循环。[bits 16] org 0x0900 start: mov ax, cs mov ds, ax mov si, loader_msg call print_string jmp $ loader_msg db Loader is running..., 0x0D, 0x0A, 0 print_string: lodsb or al, al jz .done mov ah, 0x0E mov bx, 0x0007 int 0x10 jmp print_string .done: ret times 510 - ($ - $$) db 0 dw 0xAA55注意loader末尾也补了0x55AA。它虽然不是一个真正的引导扇区但补上这个标记没有坏处还能让某些检查工具认为它是个合法启动扇区。这个习惯看个人不补也不影响MBR手动跳转过去执行。5.2 loader放进磁盘的第2个扇区上一节计算过如果loader在第2个扇区CHS就是(0, 0, 2)。写入镜像时用seek参数让dd跳过MBR占用的第一个扇区dd ifloader.bin ofdisk.img bs512 seek1 convnotruncseek1表示写入位置从第2个块开始也就是偏移512字节处。启动时BIOS读取第一个扇区执行MBRMBR用int 13h读第二个扇区到0x0900然后跳过去执行。整个流程就闭环了。5.3 几种跳转方式之间的差别MBR里我用的是jmp 0:0x0900也就是把CS设为0、IP设为0x0900。还有一种常见写法是jmp 0x0900:0x0000那是把CS设为0x0900、IP设为0。两种方式最终物理地址都是0x09000不对算一下0x0000:0x0900的物理地址是0x0000 × 16 0x0900 0x0900而0x0900:0x0000的物理地址是0x0900 × 16 0x0000 0x9000。注意差了16倍。这就是为什么跳转写法必须和加载地址一致。如果我把数据读到物理地址0x0900却用jmp 0x0900:0那跳过去的就是0x9000根本不是loader所在位置必然出错。loader编译时用org 0x0900这样它内部所有标签的偏移都基于0x0900计算同时我用jmp 0:0x0900让CS0、IP0x0900两者正好对应。如果你选择用org 0x0000把loader编译成从偏移0开始那跳转时可以改用jmp 0x0900:0。关键是保证编译时假设的基地址和运行时CS:IP组合一致。6. 常见问题与排查技巧实录6.1 常见问题速查表这一章实操中可能遇到的现象、原因和排查方向我整理成一个速查表方便你遇到问题时对照。现象可能原因排查方向启动后屏幕空白什么输出都没有MBR没写进镜像或镜像不以0x55AA结尾检查dd写入位置检查xxd末尾字节MBR的文字出来了但loader没有输出读盘失败单步执行到int 0x13后看CF和AHloader输出乱码字符串地址错误或DS未正确设置检查loader开头是否mov ax, cs; mov ds, ax跳转后卡死或反复重启跳转地址和加载地址不一致核对jmp指令与org伪指令的基地址int 0x13返回CF1错误码为0x01CHS参数非法检查CL、CH、DH的值是否越界6.2 用“探针打印”定位卡死位置引导代码没有日志系统出问题时最常见的场景是“屏幕黑屏”或“光标闪烁”你完全不知道程序跑到哪里了。我的做法是准备两个探针字符在MBR主流程开始时打印一个“M”在读盘成功之后打印一个“R”。如果屏幕上能看到M看不到R说明读盘函数挂了如果连M都没有说明MBR本身就没执行起来。这个办法看着土但在底层调试里比什么都好用。我甚至会在读盘函数的重试分支里打印“E”这样如果重试发生我能立刻看到函数内部的状态。等整体流程稳定后再把探针代码删掉或者用条件汇编控制是否编译进去。6.3 单步调试时盯哪几个寄存器如果你用的是虚拟机调试器最好带着问题去操作。我在读盘这条路径上会重点关注三处执行int 0x13之前确认AH0x02、AL0x01、CH0、CL0x02、DH0、DL0x80、ES0x0000、BX0x0900执行int 0x13之后确认CF0AH0跳到loader之后确认CS0IP0x0900。只要这三处寄存器的值都符合预期链路问题通常就迎刃而解。初学者容易一头扎进源码里反复看却忘了先检查寄存器里的实际值。实际上寄存器是引导代码里最直接的真相。6.4 陷阱提醒CL寄存器里藏着柱面高位前面提到过CL的低6位是扇区号高2位和CH组成柱面号。当柱面号为0时CL等于扇区号这个写法很自然。但一旦你想读取柱面号较大的扇区比如柱面300就必须手动计算CH存300的低8位也就是44CL的高2位存300的高2位也就是1CL的低6位存目标扇区号。所以CL 扇区号 | (高2位 6)。这一步算错int 0x13会返回0x01或者把数据从错误的地方读出来。在小硬盘阶段这个坑不会遇到但属于提前打个预防针。6.5 调通后的自我验证调通并不意味着结束。我在确认MBR能打印、loader能打印之后还会做两件事来加深理解。第一修改loader读取扇区的位置换成第3个扇区然后重新计算CHS看自己能不能独立完成“LBA→CHS”的换算和代码修改。第二故意制造一个错误比如把加载地址改成0x1000然后观察MBR不动、loader也不动再回头分析原因。亲自制造一次故障比成功跑通更能加深理解。7. 留在最后的一点实操体会说实话整个第三章最有价值的瞬间不是看到MBR打印出引导提示而是当MBR成功跳转到loader、loader打印出属于自己的文字时我忽然理解了为什么引导过程非要分成这么多步。每一步之间都有明确的责任边界BIOS把控制权给MBRMBR把控制权给loaderloader再去筹备更复杂的环境。512字节的限制不是个麻烦反而是个很好的设计约束逼着你思考什么叫最小可用、什么叫职责单一。在动手复现这一章之前我建议你先自己动手把CHS和LBA的换算公式在纸上算一遍算完再回头看代码。这个“先数学、后寄存器”的过程能帮你把磁盘抽象层和硬件调用层之间的联系打通。等这一步彻底明白后面进入loader、进入保护模式的时候内存布局和段寄存器之间的关系就不会那么绕了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑