从MBR到Loader:BIOS中断与硬件信息传递的实战指南 1. 从“能启动”到“能干活”MBR的使命进阶如果你跟着《操作系统真象还原》这本书走到了第三章并且成功让屏幕上打印出了“1 MBR”或者类似的字符恭喜你你已经迈出了从零构建操作系统最坚实的第一步。但此刻你可能会有一个疑问我的MBR主引导记录不是已经成功运行并且把控制权交给Loader加载程序了吗为什么还要“完善”它这恰恰是初学者最容易产生误解也是从“玩具代码”迈向“工程实践”的关键分水岭。我们之前写的MBR其核心任务只有一个把自己从磁盘的固定位置0柱面0磁头1扇区加载到内存0x7c00处然后执行。它就像一个尽职的传令兵只负责完成“启动”这个单一动作。然而一个真正能在物理机上运行、具备实用价值的MBR它的职责远不止于此。它必须为后续操作系统的加载铺平道路而铺路的前提是它需要了解它所处的“战场环境”。这个“战场环境”就是计算机的硬件配置信息。想象一下Loader程序需要知道内存有多大、显卡是什么模式、磁盘有多少个才能正确地加载内核、设置显示模式、读取文件系统。这些信息谁最清楚是计算机的固件——在传统BIOS启动的机器上就是BIOS本身。BIOS在启动初期会探测硬件并将关键信息以“内存表”的形式存放在内存的某个固定区域。而MBR作为BIOS之后第一个获得CPU控制权的程序是获取这些信息的唯一窗口。因此“完善MBR”的本质是赋予它硬件探测与信息收集的能力。它不再是一个哑巴启动桩而要成为一个侦察兵把Loader和内核急需的“战场地图”准备好并安全地传递下去。这涉及到BIOS中断的深入使用、内存布局的理解、以及数据结构的设计。本章我们就来亲手实现这个功能完备的MBR。2. 侦察兵的工具箱深入理解BIOS中断0x15在实模式下我们与硬件交互几乎完全依赖于BIOS中断。对于获取系统信息int 0x15是我们的核心工具。它功能强大子功能众多我们需要用到其中两个关键的子功能来获取内存信息。2.1 获取内存布局int 0x15, ax0xE820这是获取内存信息最强大、最标准的方法。BIOS或UEFI固件会返回一个描述内存中每一段“地址范围描述符”的列表。每个描述符告诉我们一段内存的起始地址、长度以及最重要的——它的类型是否可用、是否保留给硬件、是否是ACPI数据等。为什么必须用E820早期的int 0x15还有其他子功能如ah0x88只能获取1MB以上的扩展内存大小信息非常粗略。而E820映射能精确描绘出内存的全貌避开那些被硬件占用的“坑”比如显存、BIOS ROM、PCI设备映射区确保我们的操作系统不会错误地使用这些区域导致系统崩溃。数据结构与调用约定调用前我们需要在ES:DI指向一个缓冲区用于接收一个“地址范围描述符结构体”ARDS。这个结构体通常是20字节包含BaseAddrLow/High: 64位起始地址在实模式下我们通常只关心低32位。LengthLow/High: 64位长度。Type: 内存类型1可用RAM2保留其他值表示特殊用途。调用后BIOS会填充这个结构体并设置CF标志。如果CF0且EAX被设置为一个魔数0x534D4150‘SMAP’的ASCII码说明本次获取成功并且可能还有后续条目。我们需要循环调用直到CF1或返回的EAX不是魔数。注意不同机器、不同BIOS实现返回的ARDS条目数量和顺序可能不同。我们的代码必须能处理任意数量的条目并将其妥善保存。通常我们会预留一个数组比如256个条目来存储它们。2.2 一个简单的备用方案int 0x15, ah0x88这个子功能更简单它直接返回从1MB地址开始处连续的、可用的扩展内存大小以KB为单位结果存放在AX寄存器中。它的上限是64MB即0xFFFF KB对于现代大内存机器这个信息是不完整的。它的价值何在兼容性与兜底在极少数古老的、不支持E820映射的机器上这是获取扩展内存的唯一方法。快速校验我们可以先调用0x88获取一个粗略值再与E820映射中计算出的可用内存总和进行交叉验证作为代码健壮性的一种检查。在我们的完善版MBR中应当优先使用E820映射并将0x88的结果作为一个补充或备份信息记录。Loader或内核在初始化内存管理时必须依据E820提供的信息而不是0x88的粗略值。3. 为Loader准备行囊设计信息传递结构MBR侦察到的信息必须有效地传递给Loader。我们不能指望Loader自己再去调用一遍BIOS中断虽然理论上可以但这破坏了层次且Loader可能运行在MBR设置的不同环境下。标准的做法是由MBR将这些信息整理好放在内存中一个Loader已知的固定位置。这就需要一个双方约定好的“通信协议”或数据结构。在boot.inc这样的公共头文件中我们可以定义这个结构; 引导信息结构体 struct BOOT_INFO cyls resw 1 ; 启动磁盘的柱面数来自BIOS leds resw 1 ; 键盘LED状态可选 vmode resw 1 ; 显卡模式可选 scrnx resw 1 ; 屏幕X方向像素数 scrny resw 1 ; 屏幕Y方向像素数 vram resd 1 ; 显存起始地址 mem_lower resw 1 ; 0x15 ah0x88获取的扩展内存大小(KB)兼容用 mem_upper resd 1 ; 0x15 ax0xE820获取的可用内存总大小(KB)更准确 ards_num resw 1 ; ARDS条目数量 ards_buf resb 20*256 ; ARDS缓冲区假设最多存256个条目 endstruc字段设计解析cyls: 这是从BIOS中断int 0x13, ah0x08获取磁盘参数得到的关键信息。Loader在读取磁盘更多扇区时需要知道柱面边界防止跨磁头/柱面读取失败。这是很多自制OS教程容易忽略但实际非常重要的一个坑。vmode,scrnx,scrny,vram: 这些图形信息可以通过int 0x10, ax0x4F00等VBE中断获取。虽然MBR阶段不一定需要设置图形模式但提前探测出硬件支持的最佳模式如1024x768x32并将参数告诉Loader能让Loader在进入保护模式后无缝初始化图形界面避免模式切换的黑屏或闪烁。ards_num和ards_buf: 这是核心。MBR将获取到的所有ARDS条目连续存放在ards_buf中并用ards_num告知Loader有多少个有效条目。Loader或内核的内存初始化代码就直接解析这个缓冲区即可。在MBR的汇编代码末尾在跳转到Loader之前我们需要将这个BOOT_INFO结构体的基地址比如我们约定放在0x8000存入一个特定的寄存器例如ESI或EBX作为参数传递给Loader。这样Loader一开场就能拿到所有“情报”。4. 实战编写功能完整的MBR汇编代码理论清晰后我们来看代码实现。以下是一个高度精简但功能完整的框架重点展示流程和关键点。; boot.asm %include boot.inc section MBR vstart0x7c00 ; 1. 初始化段寄存器与栈 mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 ; 2. 清屏并打印提示信息可选用于调试 mov ax, 0x0600 mov bx, 0x0700 mov cx, 0 mov dx, 0x184f int 0x10 mov bp, msg_mbr mov cx, msg_len mov ax, 0x1301 mov bx, 0x0007 int 0x10 ; 3. 获取磁盘参数柱面数 mov dl, 0x80 ; 驱动器号0x80第一块硬盘 mov ah, 0x08 int 0x13 jc .disk_err and cl, 0x3f ; 最大扇区号低6位 movzx ecx, cl mov [BOOT_INFO_ADDR BOOT_INFO.cyls], cx ; 存入结构体 ; 4. 获取内存信息E820映射 xor ebx, ebx ; 初始调用时EBX必须为0 mov edi, BOOT_INFO_ADDR BOOT_INFO.ards_buf ; 缓冲区位置 .get_mem_map: mov eax, 0x0000E820 mov edx, 0x534D4150 ; SMAP mov ecx, 20 ; ARDS结构大小 int 0x15 jc .e820_fail ; 如果CF1说明调用失败或不支持 cmp eax, 0x534D4150 ; 返回的EAX必须是SMAP jne .e820_fail add edi, 20 ; 指向下一个ARDS缓冲区位置 inc word [BOOT_INFO_ADDR BOOT_INFO.ards_num] ; 条目数1 test ebx, ebx ; 如果EBX返回0表示这是最后一个条目 jnz .get_mem_map ; 否则继续获取 .e820_done: ; 5. 可选获取简易内存大小ah0x88作为参考 mov ah, 0x88 int 0x15 jc .skip_mem88 mov [BOOT_INFO_ADDR BOOT_INFO.mem_lower], ax ; 存入结构体 .skip_mem88: ; 6. 获取显卡VBE信息可选为图形模式准备 ; 此处省略具体VBE调用代码大致流程 ; - 调用0x10, ax0x4F00获取VBE控制器信息。 ; - 遍历模式列表寻找合适的图形模式如1024x768x32。 ; - 将模式号、分辨率、显存地址存入BOOT_INFO结构体。 ; 7. 加载Loader程序到内存 ; 假设Loader在硬盘上从第2个扇区开始共占用4个扇区 mov eax, LOADER_START_SECTOR ; 1 mov bx, LOADER_BASE_ADDR ; 0x900 mov cx, LOADER_SECTOR_COUNT ; 4 call rd_disk_m_16 ; 调用磁盘读取函数 ; 8. 准备跳转参数并移交控制权 mov esi, BOOT_INFO_ADDR ; 将BOOT_INFO结构体地址存入ESI jmp LOADER_BASE_ADDR ; 跳转到Loader .disk_err: .e820_fail: ; 错误处理打印错误信息并挂起 mov si, msg_err call print_str jmp $ ; 子程序打印字符串DS:SI print_str: lodsb or al, al jz .done mov ah, 0x0e int 0x10 jmp print_str .done: ret ; 子程序读取磁盘LBA28模式 rd_disk_m_16: ; ... (具体的磁盘读取代码设置端口循环读取) ret msg_mbr db MBR is running... msg_len equ $ - msg_mbr msg_err db E820 Error!, 0 times 510-($-$$) db 0 dw 0xaa55代码关键点剖析顺序与容错先获取磁盘参数再获取内存。每个int调用后都检查进位标志CF并进行简单的错误处理如打印错误后挂起。在实际操作系统中错误处理会更复杂可能尝试备用方案。缓冲区管理ards_buf是一个静态分配的连续空间。ards_num在循环中递增最终记录了有效条目数。Loader解析时只需用这个数字控制循环即可。参数传递最关键的一步在跳转前mov esi, BOOT_INFO_ADDR。我们将结构体的线性地址在实模式下就是段基址*16偏移存入ESI寄存器。这是x86架构下一种常见的参数传递约定。Loader的入口代码第一件事就是从ESI中取出这个地址。磁盘读取rd_disk_m_16是一个需要你自行实现的函数它使用int 0x13, ax0x4200扩展读或更传统的CHS方式将指定扇区的Loader代码读入内存0x900处。这里假设Loader不大如果Loader很大可能需要分次读取或启用保护模式后再读取。5. 从MBR到Loader无缝的信息对接MBR的工作在跳转到0x900的那一刻就结束了。现在接力棒交给了Loader。Loader的汇编入口代码应该类似这样; loader.asm 开头部分 section loader vstartLOADER_BASE_ADDR loader_entry: ; 1. 从ESI寄存器接收BOOT_INFO结构体指针 mov ebp, esi ; 将指针保存到EBP方便后续访问 ; 此时[EBP]开始的内存就是完整的BOOT_INFO ; 2. 可以立即使用这些信息例如打印内存条数调试用 mov ax, [ebp BOOT_INFO.ards_num] call print_hex_word ; 3. 后续工作开启A20地址线、加载GDT、进入保护模式... ; 在进入保护模式后可以通过EBP这个指针需转换为保护模式下的地址来访问BOOT_INFO。 ; 例如在初始化内存分页时遍历ards_buf中的每个条目。为什么这样设计是优雅的解耦MBR只负责收集原始硬件信息不进行任何解释或处理。Loader和内核根据自身需要来解析这些信息。例如内核的内存管理模块只关心Type1的可用内存区域。可扩展如果未来需要传递更多信息比如ACPI RSDP指针、SMBIOS表地址等只需在BOOT_INFO结构体中添加字段MBR负责填充Loader负责传递内核负责使用。协议是稳定的。与主流规范接轨这种传递硬件信息的方式与Multiboot等引导协议的思想是相通的。理解了这个过程再看GRUB如何向内核传递multiboot_info_t结构体就会豁然开朗。6. 调试技巧与常见“坑点”自己动手编写底层引导代码调试是最大的挑战。因为你没有现成的printf。以下是一些实用的技巧和容易踩的坑1. 使用Bochs/QEMU的调试功能内存查看在Bochs中xp /Nx 0x8000可以查看0x8000处的内存验证BOOT_INFO是否被正确写入。寄存器监控单步执行s或n观察每次int调用前后寄存器的变化特别是AX,BX,CX,DX,CF标志。反汇编u /N可以从当前CS:IP反汇编指令确保代码流符合预期。2. ARDS获取失败或数据异常现象循环获取ARDS时条目数量为0或者Type字段全是奇怪的值。排查检查缓冲区地址ES:DI设置是否正确。在实模式下ES段寄存器必须被正确初始化我们通常设ESDS。检查ARDS结构体大小ECX是否设置为20。有些老资料写24但20是标准。首次调用前确保EBX0。在虚拟机中测试时某些虚拟机特别是非常老的或配置异常的对E820支持可能有问题。可以尝试换用QEMU或更新版本的VirtualBox。3. 磁盘读取错误现象Loader加载后跳转过去执行立刻崩溃或者打印乱码。排查扇区计算这是最常见的坑。确保你的rd_disk_m_16函数使用的LBA扇区号是正确的。MBR在扇区0Loader通常从扇区1开始。特别注意很多LBA转CHS的公式有误或者忽略了磁头、扇区的计数从0还是1开始。最稳妥的方法是直接使用BIOS的扩展读功能int 0x13, ax0x4200它直接使用LBA地址避免转换错误。目标内存地址确保加载到的内存地址如0x900没有覆盖掉重要的数据比如你的代码或栈。0x7c00~0x7e00是MBR0x900之后是比较安全的选择。读取长度确保读取的扇区数足够覆盖整个Loader镜像文件。如果Loader在链接时地址计算错误可能导致部分代码没被加载。4. 信息传递失败现象Loader里访问[ESI]或[EBP]时读到的数据是乱的。排查在MBR跳转前用Bochs查看ESI寄存器的值确认它确实是BOOT_INFO_ADDR如0x8000。在Loader入口立即查看EBP的值确认它和MBR设置的ESI一致。检查BOOT_INFO结构体在boot.inc中的定义确保Loader和MBR包含的是同一个头文件结构体布局完全一致。结构体对齐问题在汇编层面不明显但字段偏移必须精确。7. 超越第三章MBR的局限与现代引导的思考当你成功完成这个“完善版MBR”后你已经掌握了传统BIOSMBR引导架构的核心。然而必须清醒认识到这是一个传统且有局限的方案磁盘容量限制MBR分区表只能支持最大2TB的磁盘且最多4个主分区。安全性缺失没有数字签名验证任何能写入磁盘第一个扇区的代码都可以成为MBR这是引导型病毒的温床。功能单一MBR只有440字节的代码空间扣除分区表和魔数难以实现复杂的文件系统驱动和协议。因此在现代计算机中UEFI GPT 已成为主流。UEFI固件本身就是一个简化的操作系统环境它直接读取FAT32格式的ESP分区上的EFI应用程序.efi文件如bootx64.efi。这个EFI应用程序相当于我们的Loader可以从文件系统中加载内核完全绕过了MBR和扇区加载的繁琐过程并支持安全启动Secure Boot。那么学习MBR还有意义吗绝对有。通过亲手实现MBR你深入理解了计算机启动的“原子操作”从加电到第一条指令执行的全链条。实模式编程的残酷环境没有操作系统服务一切靠BIOS和硬件的直接交互。硬件抽象层HAL的起源如何通过标准接口BIOS中断探测异构的硬件。这些知识是理解UEFI、操作系统内核内存初始化、甚至虚拟机监控程序VMM的坚实基础。当你以后看到Linux内核的setup.s或x86/boot目录下的代码时你会感到无比亲切因为它们解决的是同样的问题只是在一个更宏大、更复杂的尺度上。完成本章你的收获不仅仅是一个能打印内存信息的MBR而是一套从裸机环境中主动获取资源并建立抽象的底层思维模式。这是操作系统开发者乃至所有系统软件工程师的核心能力。接下来Loader将利用这些信息带你进入一个更广阔的世界——保护模式。