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, ax=0xE820
这是获取内存信息最强大、最标准的方法。BIOS或UEFI固件会返回一个描述内存中每一段“地址范围描述符”的列表。每个描述符告诉我们一段内存的起始地址、长度以及最重要的——它的类型(是否可用、是否保留给硬件、是否是ACPI数据等)。
为什么必须用E820?早期的int 0x15还有其他子功能(如ah=0x88)只能获取1MB以上的扩展内存大小,信息非常粗略。而E820映射能精确描绘出内存的全貌,避开那些被硬件占用的“坑”(比如显存、BIOS ROM、PCI设备映射区),确保我们的操作系统不会错误地使用这些区域导致系统崩溃。
数据结构与调用约定:调用前,我们需要在ES:DI指向一个缓冲区,用于接收一个“地址范围描述符结构体”(ARDS)。这个结构体通常是20字节,包含:
BaseAddrLow/High: 64位起始地址(在实模式下我们通常只关心低32位)。LengthLow/High: 64位长度。Type: 内存类型(1=可用RAM,2=保留,其他值表示特殊用途)。
调用后,BIOS会填充这个结构体,并设置CF标志。如果CF=0且EAX被设置为一个魔数0x534D4150(‘SMAP’的ASCII码),说明本次获取成功,并且可能还有后续条目。我们需要循环调用,直到CF=1或返回的EAX不是魔数。
注意:不同机器、不同BIOS实现返回的ARDS条目数量和顺序可能不同。我们的代码必须能处理任意数量的条目,并将其妥善保存。通常我们会预留一个数组(比如256个条目)来存储它们。
2.2 一个简单的备用方案:int 0x15, ah=0x88
这个子功能更简单,它直接返回从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 ah=0x88获取的扩展内存大小(KB),兼容用 mem_upper resd 1 ; 0x15 ax=0xE820获取的可用内存总大小(KB),更准确 ards_num resw 1 ; ARDS条目数量 ards_buf resb 20*256 ; ARDS缓冲区,假设最多存256个条目 endstruc字段设计解析:
cyls: 这是从BIOS中断int 0x13, ah=0x08(获取磁盘参数)得到的关键信息。Loader在读取磁盘更多扇区时,需要知道柱面边界,防止跨磁头/柱面读取失败。这是很多自制OS教程容易忽略但实际非常重要的一个坑。vmode,scrnx,scrny,vram: 这些图形信息可以通过int 0x10, ax=0x4F00等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 vstart=0x7c00 ; 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 ; 如果CF=1,说明调用失败或不支持 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. (可选)获取简易内存大小(ah=0x88)作为参考 mov ah, 0x88 int 0x15 jc .skip_mem88 mov [BOOT_INFO_ADDR + BOOT_INFO.mem_lower], ax ; 存入结构体 .skip_mem88: ; 6. 获取显卡VBE信息(可选,为图形模式准备) ; 此处省略具体VBE调用代码,大致流程: ; - 调用0x10, ax=0x4F00获取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, ax=0x4200(扩展读)或更传统的CHS方式,将指定扇区的Loader代码读入内存0x900处。这里假设Loader不大,如果Loader很大,可能需要分次读取或启用保护模式后再读取。
5. 从MBR到Loader:无缝的信息对接
MBR的工作在跳转到0x900的那一刻就结束了。现在,接力棒交给了Loader。Loader的汇编入口代码应该类似这样:
; loader.asm 开头部分 section loader vstart=LOADER_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和内核根据自身需要来解析这些信息。例如,内核的内存管理模块只关心
Type=1的可用内存区域。 - 可扩展:如果未来需要传递更多信息(比如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段寄存器必须被正确初始化(我们通常设ES=DS)。 - 检查
ARDS结构体大小ECX是否设置为20。有些老资料写24,但20是标准。 - 首次调用前,确保
EBX=0。 - 在虚拟机中测试时,某些虚拟机(特别是非常老的或配置异常的)对E820支持可能有问题。可以尝试换用QEMU或更新版本的VirtualBox。
- 检查缓冲区地址
3. 磁盘读取错误:
- 现象:Loader加载后,跳转过去执行立刻崩溃,或者打印乱码。
- 排查:
- 扇区计算:这是最常见的坑。确保你的
rd_disk_m_16函数使用的LBA扇区号是正确的。MBR在扇区0,Loader通常从扇区1开始。特别注意:很多LBA转CHS的公式有误,或者忽略了磁头、扇区的计数从0还是1开始。最稳妥的方法是直接使用BIOS的扩展读功能(int 0x13, ax=0x4200),它直接使用LBA地址,避免转换错误。 - 目标内存地址:确保加载到的内存地址(如
0x900)没有覆盖掉重要的数据,比如你的代码或栈。0x7c00~0x7e00是MBR,0x900之后是比较安全的选择。 - 读取长度:确保读取的扇区数足够覆盖整个Loader镜像文件。如果Loader在链接时地址计算错误,可能导致部分代码没被加载。
- 扇区计算:这是最常见的坑。确保你的
4. 信息传递失败:
- 现象:Loader里访问
[ESI]或[EBP]时,读到的数据是乱的。 - 排查:
- 在MBR跳转前,用Bochs查看
ESI寄存器的值,确认它确实是BOOT_INFO_ADDR(如0x8000)。 - 在Loader入口,立即查看
EBP的值,确认它和MBR设置的ESI一致。 - 检查
BOOT_INFO结构体在boot.inc中的定义,确保Loader和MBR包含的是同一个头文件,结构体布局完全一致。结构体对齐问题在汇编层面不明显,但字段偏移必须精确。
- 在MBR跳转前,用Bochs查看
7. 超越第三章:MBR的局限与现代引导的思考
当你成功完成这个“完善版MBR”后,你已经掌握了传统BIOS+MBR引导架构的核心。然而,必须清醒认识到,这是一个传统且有局限的方案:
- 磁盘容量限制: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将利用这些信息,带你进入一个更广阔的世界——保护模式。