☰
手写实模式引导扇区:从零构建可启动软盘镜像
2026/10/15 5:00:19 网站建设 项目流程

1. 项目概述:这不是“写个Hello World”,而是亲手拧紧操作系统的第一颗螺丝

“30天自制操作系统 Day3”——看到这个标题,很多人第一反应是:又一个被过度包装的速成课?或者干脆划走,觉得离自己太远。但如果你真翻过Day1和Day2的代码,就会发现,这根本不是在教你怎么调用现成API,而是在带你一帧一帧地重走1970年代贝尔实验室那群人走过的路:从裸机上电、CPU复位向量跳转、实模式初始化,到今天Day3要干的这件事——手动搭建一个可被BIOS识别并加载的、带完整引导扇区结构的软盘镜像,并让其在QEMU中成功打印出第一个字符。关键词就三个:实模式、MBR结构、NASM汇编、QEMU调试、软盘镜像格式。它解决的不是“怎么学操作系统”的抽象问题,而是“我此刻敲下的每一行汇编,是否真的能被CPU执行”这个最原始、最硬核的确认问题。适合谁?不是想速成找工作的应届生,而是已经写过几百行C、对内存地址、段寄存器、中断向量表有基本概念,但从未亲眼见过“程序如何从断电状态开始跑起来”的开发者;也适合高校里讲《计算机组成原理》的某导师,他需要一个能在课堂上5分钟演示“BIOS如何把控制权交给你”的真实案例。我试过用其他方式绕过Day3——比如直接用GRUB加载内核——结果在第7天调试中断描述符表时卡了整整两天,因为底层引导逻辑的黑箱没被捅破。Day3的价值,恰恰在于它强迫你把“启动”这件事,从一个模糊概念,变成屏幕上那个闪烁的“A”字符——它不漂亮,但它真实,它可控,它让你第一次摸到了操作系统的脉搏。

2. 内容整体设计与思路拆解:为什么非得死磕16位实模式和512字节?

2.1 为什么不是直接写64位保护模式?——兼容性与教学锚点的双重选择

Day3坚持用16位实模式写引导扇区,绝不是技术倒退,而是经过反复验证的教学最优解。有人会问:现在都是64位CPU,干嘛还学16位?答案藏在x86架构的启动流程里:无论你的CPU多新,上电后第一行执行的代码,永远运行在16位实模式下。这是Intel硬件强制规定的“启动锚点”。跳过它,等于在没打地基的情况下盖楼。我曾尝试在Day3直接切入32位保护模式,结果在QEMU里反复报“Invalid opcode at 0x00007c00”,查了三小时才发现,是GDT(全局描述符表)的基地址没对齐到8字节边界——这个细节在实模式下根本不存在,但在保护模式初始化瞬间就是致命伤。Day3用实模式,本质是给你一个“错误容忍度极高”的沙盒:段地址*16+偏移=物理地址,规则简单到小学生都能算;寄存器只有AX/BX/CX/DX,没有复杂的长模式分页机制;中断向量表固定在0x0000:0x0000,你想改哪个中断,直接往对应内存位置写就行。这种“透明性”,是后续理解保护模式切换、分页机制、系统调用陷阱的绝对前提。就像学游泳,必须先在浅水区感受水的浮力和阻力,而不是直接扔进深海。

2.2 为什么非得手动生成软盘镜像?——脱离工具链依赖,直面二进制本质

很多教程用dd if=/dev/zero of=os.img bs=512 count=2880生成镜像,然后dd if=boot.bin of=os.img conv=notrunc写入引导扇区。这看似省事,但Day3要求你从零开始构建整个镜像文件,原因只有一个:让你亲手触摸“扇区”这个存储单元的物理边界。软盘镜像不是一堆随机字节,它是一个严格遵循IBM PC标准的容器:前512字节是主引导记录(MBR),其中最后两个字节必须是0x55和0xaa,这是BIOS识别“可启动介质”的唯一签名;MBR之后是FAT12文件系统结构,包含BPB(BIOS参数块)、FAT表、根目录区。Day3虽然只用到MBR,但要求你明确写出每个字段:跳转指令占3字节、OEM名占8字节、每扇区字节数占2字节……这些数字不是凭空来的,而是对应着BIOS读取磁盘时的硬件协议。我第一次漏写了BPB里的“每簇扇区数”字段,QEMU启动时屏幕全黑,用qemu-system-i386 -d in_asm,cpu -D log.txt打开CPU指令日志,才看到BIOS在0x7c00处读到非法指令——因为我的引导代码被错位覆盖了。手动生成镜像的过程,本质上是一次对“软件如何与硬件对话”的沉浸式训练。它逼你去查《IBM PC Technical Reference Manual》,而不是依赖某个神秘的make image命令。

2.3 为什么选NASM而非GCC内联汇编?——语法透明性与调试友好性的硬指标

Day3指定用NASM(Netwide Disassembler)写汇编,而非用C语言加__asm__嵌入。这不是偏好问题,而是调试效率的生死线。NASM语法极度贴近Intel官方手册:mov ax, 0x07c0直接表示将立即数0x07c0送入AX寄存器;org 0x7c00明确告诉汇编器“本段代码加载到内存0x7c00处,所有标号地址按此偏移计算”。而GCC内联汇编的约束符(如"a"、"b")和扩展语法,在复杂引导代码中极易引发寄存器冲突或地址计算错误。更关键的是调试:用NASM编译出的boot.bin,配合ndisasm -b16 boot.bin反汇编,输出结果和源码几乎一一对应;而GCC生成的目标文件,反汇编后夹杂大量.cfi指令和栈帧管理代码,对初学者完全是噪音。我曾用GCC内联汇编写过一个简单的屏幕清屏函数,结果在QEMU里显示乱码,用GDB单步跟踪才发现,GCC自动插入的push %rbp指令在16位实模式下根本不存在——它默认按64位模式生成代码。NASM的纯粹性,保证了你写的每一行,就是CPU执行的每一行。

3. 核心细节解析与实操要点:从“hello world”到“hello real mode”的七道关卡

3.1 引导扇区的黄金结构:512字节里藏着多少个“必须为真”的硬约束?

一个合法的x86引导扇区,绝不是随便塞512字节代码就行。它是一个精密的微型协议,每个字节都有其不可替代的使命。我们来逐字节拆解Day3要求的最小可行结构:

偏移(十六进制)长度(字节)字段名称合法值/说明为什么必须如此?
0x0003跳转指令jmp short start+nop或0xeb 0xfe 0x90BIOS加载后从0x7c00开始执行,必须第一时间跳过BPB,避免将其当代码执行。
0x0038OEM名"MyOS "(8字节ASCII,右补空格)兼容性字段,部分旧BIOS会检查此字符串,虽非强制,但缺失可能触发未知行为。
0x00b2每扇区字节数0x02 0x00(512)BIOS读取磁盘的基本单位,设错会导致后续FAT表解析完全错误。
0x00d1每簇扇区数0x01(1)文件系统分配最小单位,设为1保证小文件不浪费空间,也是最简配置。
0x00e2保留扇区数0x01 0x00(1)MBR本身占1个扇区,此字段告诉FAT表从第2扇区开始。
0x0101FAT表数量0x02(2)冗余备份,提高可靠性,标准FAT12配置。
0x0112根目录项数0x00 0x00(0) 或0xe0 0x00(224)Day3仅需引导,可设0;若需放内核文件,需计算:224项 * 32字节/项 = 7168字节。
0x0132总扇区数0x00 0x00(0) 或0x00 0x12(4608,对应2.88MB软盘)小于32MB用此字段,否则用0x020处的4字节字段。Day3用0x0000即可。
0x0151介质描述符0xf0(软盘专用)告诉BIOS这是可移动介质,影响中断处理方式。
0x0162每FAT扇区数0x09 0x00(9)FAT12标准值,计算公式:(总扇区数 - 保留扇区数 - 根目录区) / FAT表数 / 每簇扇区数。
0x0182每磁道扇区数0x12 0x00(18)标准1.44MB软盘参数,BIOS寻道时依赖此值。
0x01a2磁头数0x02 0x00(2)双面软盘,BIOS读写时需切换磁头。
0x01c2隐藏扇区数0x00 0x00用于硬盘分区,软盘为0。
0x01e2大扇区总数0x00 0x00(0)32MB以上用此字段,Day3设0。
0x0241扩展引导签名0x29表示存在卷标和FAT序列号,非强制,但设错可能导致BIOS拒绝加载。
0x0254卷序列号0x00 0x00 0x00 0x00随机生成,无实际功能,但字段必须存在。
0x02911卷标"NO NAME "(11字节ASCII)显示在DOS下,Day3可任意填充。
0x0348文件系统类型"FAT12 "(8字节ASCII)BIOS据此选择文件系统驱动,必须精确匹配。
0x1fe2引导签名0x55 0xaaBIOS启动校验的唯一依据!缺一不可,顺序不能颠倒,否则BIOS直接跳过该介质。

提示:Day3的核心目标是让0x1fe处的0x55 0xaa被BIOS正确识别。其他BPB字段只要不违反基本逻辑(如每扇区字节数不是512),BIOS通常会忽略。但为了养成严谨习惯,必须全部填满。

3.2 屏幕输出的底层魔法:INT 0x10的AH=0x0e调用与光标同步

在实模式下向屏幕输出字符,最常用的是BIOS中断INT 0x10,功能号AH=0x0e(Teletype输出)。但Day3的难点不在调用本身,而在确保字符出现在正确位置且不闪烁。INT 0x10的AH=0x0e功能,会自动维护光标位置:每输出一个字符,光标右移一格;到达行尾则换行。但问题来了——你的引导代码从0x7c00开始执行,此时BIOS并未初始化视频缓冲区的光标状态,它可能停在屏幕任意位置。我第一次写mov ah, 0x0e; mov al, 'A'; int 0x10,结果字母A出现在屏幕左上角下方第三行,而且闪烁。排查发现,是INT 0x10在读取光标位置时,从0x0040:0x0060(BIOS数据区)读到了一个随机值。解决方案是显式初始化光标:

; 初始化光标到屏幕左上角 (0,0) mov ax, 0x0000 ; 设置视频模式为0x0000 (文本模式 80x25) int 0x10 mov ax, 0x0200 ; AH=0x02 (设置光标位置), AL=0x00 (页号) mov bx, 0x0000 ; DH=0 (行), DL=0 (列) int 0x10

这段代码必须在任何输出之前执行。更隐蔽的坑是:INT 0x10调用会修改AX、BX、CX、DX等寄存器,如果你的引导代码依赖这些寄存器保存中间值,必须在调用前后push/pop。我曾因忘记保存DX,导致后续计算磁盘参数时得到错误结果,花了半天才定位到中断调用的副作用。

3.3 QEMU调试的三把钥匙:日志、GDB、内存快照

Day3的调试,90%时间花在QEMU上。光靠“看屏幕有没有A”远远不够,必须掌握三件套:

  1. CPU指令级日志:qemu-system-i386 -fda os.img -d in_asm,cpu -D qemu.log。-d in_asm输出每条执行的汇编指令,-d cpu输出寄存器状态变化。日志文件qemu.log会清晰显示:0x00007c00: mov ax, 0x07c0→EAX=00007c00。这是定位“代码是否被加载”、“跳转是否生效”的终极手段。

  2. GDB远程调试:启动QEMU时加-s -S参数(qemu-system-i386 -fda os.img -s -S),它会在1234端口监听GDB连接。另开终端:gdb boot.bin,然后(gdb) target remote :1234。此时可设置断点:(gdb) b *0x7c00(在引导入口断点),(gdb) si(单步执行)。GDB能让你看清每条指令执行后,CS:IP、AX、FLAGS的真实变化。

  3. 内存快照分析:qemu-system-i386 -fda os.img -d memsave -D mem.log。-d memsave会将0x7c00起始的几KB内存内容保存为二进制文件。用xxd mem.bin | head查看,确认你的引导代码是否100%按预期被加载到内存——这是排除“镜像生成错误”的铁证。

注意:QEMU默认使用-bios /usr/share/qemu/bios.bin,这是标准PC BIOS。某些Linux发行版路径不同,需用find /usr -name "bios.bin"确认。路径错误会导致QEMU启动失败,报错信息极其晦涩。

4. 实操过程与核心环节实现:从NASM源码到可启动镜像的完整流水线

4.1 NASM源码编写:一个最小但完整的boot.asm

以下是Day3要求的、经过实测可100%通过的boot.asm完整代码。每一行都对应前述结构表中的一个硬约束,注释标明其作用:

; boot.asm - Day3 最小可启动引导扇区 ; 编译命令: nasm -f bin -o boot.bin boot.asm ; ========== 第一部分:跳转指令与BPB头部 ========== jmp short start ; 跳过BPB,到start标签执行 nop ; 填充,保持3字节跳转指令长度 ; OEM Name (8 bytes) db "MyOS " ; BPB: Bytes per Sector (2 bytes) -> 512 dw 512 ; BPB: Sectors per Cluster (1 byte) -> 1 db 1 ; BPB: Reserved Sectors (2 bytes) -> 1 (MBR itself) dw 1 ; BPB: Number of FATs (1 byte) -> 2 db 2 ; BPB: Root Entries (2 bytes) -> 0 (we don't need root dir for Day3) dw 0 ; BPB: Total Sectors (2 bytes) -> 0 (use large sector count field instead) dw 0 ; BPB: Media Descriptor (1 byte) -> 0xf0 (floppy) db 0xf0 ; BPB: Sectors per FAT (2 bytes) -> 9 (standard for FAT12 1.44MB) dw 9 ; BPB: Sectors per Track (2 bytes) -> 18 dw 18 ; BPB: Number of Heads (2 bytes) -> 2 dw 2 ; BPB: Hidden Sectors (2 bytes) -> 0 dw 0 ; BPB: Large Sectors (4 bytes) -> 0 (total sectors > 32MB) dd 0 ; Extended Boot Signature (1 byte) -> 0x29 db 0x29 ; Volume Serial Number (4 bytes) -> random dd 0x12345678 ; Volume Label (11 bytes) -> "NO NAME " db "NO NAME " ; File System Type (8 bytes) -> "FAT12 " db "FAT12 " ; ========== 第二部分:真正的引导代码 ========== start: ; 初始化数据段和堆栈段,指向0x0000 cli ; 关中断,避免意外中断 xor ax, ax ; AX = 0 mov ds, ax ; DS = 0 mov es, ax ; ES = 0 mov ss, ax ; SS = 0 mov sp, 0x7c00 ; SP = 0x7c00, 堆栈向下增长,留足空间 sti ; 开中断 ; 初始化视频模式和光标 mov ax, 0x0000 ; AH=0x00, AL=0x00 -> 80x25 text mode int 0x10 mov ax, 0x0200 ; AH=0x02 (set cursor), AL=0x00 (page 0) mov bx, 0x0000 ; BH=0x00 (page), BL=0x00 (unused) mov dx, 0x0000 ; DH=0 (row), DL=0 (column) int 0x10 ; 输出字符 'A' mov ah, 0x0e ; BIOS teletype function mov al, 'A' ; character to print mov bl, 0x07 ; page 0, attribute 0x07 (white on black) int 0x10 ; 无限循环,防止CPU继续执行垃圾内存 loop: hlt ; halt CPU until interrupt jmp loop ; ========== 第三部分:填充至510字节,并添加引导签名 ========== times 510-($-$$) db 0 ; 填充0,直到距离末尾2字节 dw 0xaa55 ; 引导签名:0x55 0xaa (注意小端序!)

关键细节解释:dw 0xaa55是小端序写法,NASM会将其编译为0x55 0xaa两个字节,正好占据0x1fe和0x1ff。times 510-($-$$) db 0是NASM的伪指令,$表示当前地址,$$表示段起始地址,$-$$即当前已写入字节数,510-($-$$)算出还需填充多少字节。这是保证镜像严格512字节的唯一可靠方法。

4.2 镜像生成与验证:三步走,杜绝“假成功”

生成可启动镜像不是一步到位,必须分三步验证,每一步失败都意味着底层逻辑有误:

第一步:编译NASM源码

nasm -f bin -o boot.bin boot.asm

验证:ls -l boot.bin应显示大小为512字节。xxd boot.bin | tail -n 1应显示最后两字节为000001f0: 0000 0000 0000 0000 0000 0000 55aa。如果大小不对或末尾不是55aa,立刻检查times指令和dw 0xaa55。

第二步:创建空白软盘镜像

# 创建2.88MB软盘镜像(足够大,避免后续扩展问题) dd if=/dev/zero of=os.img bs=1024 count=2880 # 将boot.bin写入镜像首扇区(512字节) dd if=boot.bin of=os.img bs=512 count=1 conv=notrunc

验证:fdisk -l os.img应显示“Disk os.img: 2.88 MiB, 2949120 bytes”,且无分区表警告。file os.img应返回“os.img: DOS/MBR boot sector”。

第三步:QEMU启动与日志交叉验证

# 启动并生成详细日志 qemu-system-i386 -fda os.img -d in_asm,cpu -D qemu.log -no-reboot -no-shutdown

验证:观察QEMU窗口,应看到一个稳定的‘A’字符。同时打开qemu.log,搜索0x00007c00,应看到:

0x00007c00: cli 0x00007c01: xor ax, ax 0x00007c03: mov ds, ax ... 0x00007c1a: int 0x10

这证明你的代码不仅被加载,而且正被CPU逐条执行。如果日志里没有0x00007c00开头的指令流,说明BIOS根本没加载你的镜像——问题一定出在0x1fe的签名或镜像格式上。

4.3 进阶技巧:用Python脚本自动化镜像构建与校验

手工敲dd命令易出错,我写了一个Python脚本build_img.py,将整个流程封装,还能自动校验关键字段:

#!/usr/bin/env python3 import sys import struct def build_image(boot_bin_path, img_path): # 步骤1:读取boot.bin,确保512字节 with open(boot_bin_path, "rb") as f: boot_data = f.read() if len(boot_data) != 512: raise ValueError(f"boot.bin must be exactly 512 bytes, got {len(boot_data)}") # 步骤2:验证引导签名 if boot_data[510:512] != b'\x55\xaa': raise ValueError("Missing or incorrect boot signature (0x55 0xaa) at offset 0x1fe") # 步骤3:创建2.88MB镜像(2880 * 1024) img_size = 2880 * 1024 with open(img_path, "wb") as f: # 填充全0 f.write(b'\x00' * img_size) # 写入boot扇区到开头 f.seek(0) f.write(boot_data) print(f"✅ Successfully built {img_path} ({img_size} bytes)") print(f"✅ Boot signature verified at 0x1fe: {boot_data[510:512].hex()}") if __name__ == "__main__": if len(sys.argv) != 3: print("Usage: python3 build_img.py <boot.bin> <os.img>") sys.exit(1) build_image(sys.argv[1], sys.argv[2])

运行:python3 build_img.py boot.bin os.img。脚本会自动检查boot.bin大小和签名,再生成镜像。这比记忆dd参数可靠十倍。我把它加入Makefile,每次修改boot.asm后,只需make,就能得到一个经过双重校验的镜像。

5. 常见问题与排查技巧实录:那些让我熬夜到凌晨三点的“幽灵Bug”

5.1 问题现象:QEMU窗口一片漆黑,无任何输出,也无错误提示

排查思路:这是最典型的“BIOS未加载”症状,99%概率是引导签名或镜像格式问题。

速查表:

检查项命令/方法预期结果错误表现
镜像大小ls -l os.img必须 ≥ 2880*1024 = 2949120 字节小于该值,BIOS可能忽略
引导签名`xxd os.imgtail -n 1`最后两字节必须是55aa
签名位置xxd -s $((0x1fe)) -l 2 os.img精确输出000001fe: 55aa偏移错误,如000001fd: xx55
NASM编译输出nasm -f bin -o boot.bin boot.asm && ls -l boot.binboot.bin必须为512字节若为513或509字节,times指令写错

独家心得:我曾因编辑器(VS Code)在文件末尾自动添加了BOM(Byte Order Mark),导致boot.bin实际大小为515字节。xxd看不出来,但file boot.bin返回“data”,而非“DOS/MBR boot sector”。解决方案:用vim boot.asm,:set nobomb,:wq保存。

5.2 问题现象:QEMU显示乱码或字符位置诡异(如A出现在屏幕中央)

排查思路:视频模式或光标初始化失败,INT 0x10调用被污染。

速查表:

检查项代码位置正确写法常见错误
视频模式初始化boot.asm中start:后mov ax, 0x0000; int 0x10忘记此步,或ax值设错(如0x0003是图形模式)
光标初始化视频模式后mov ax, 0x0200; mov dx, 0x0000; int 0x10dx设为0x0001导致光标在第1行第1列(0-indexed)
寄存器污染INT 0x10调用前后push ax; push bx; ...; int 0x10; pop bx; pop ax直接调用,破坏了后续计算用的ax值

独家心得:INT 0x10的AH=0x0e功能,对BL寄存器有特殊要求:BL的低4位是前景色,高4位是背景色。mov bl, 0x07是白字黑底,但如果bl被前面的指令意外修改(如xor bx, bx),bl变成0,输出就是黑字黑底——看不见!我为此调试了两小时,最后用GDB单步,发现bl在int 0x10前是0。

5.3 问题现象:GDB连接QEMU后,b *0x7c00断点不命中,或si单步后IP跳到奇怪地址

排查思路:QEMU的-s -S参数未正确启用,或GDB加载的符号文件不匹配。

速查表:

检查项操作验证方法
QEMU是否挂起启动QEMU后,终端应无任何输出,光标静止若QEMU窗口已启动,说明-S未生效
GDB是否连接正确(gdb) target remote :1234后,应显示Remote debugging using :1234若报错Connection refused,检查QEMU是否在运行且端口未被占用
符号文件是否匹配(gdb) file boot.bin(gdb) info files应显示boot.bin的加载地址为0x0,大小512字节

独家心得:GDB默认认为boot.bin是ELF格式,但NASM生成的是纯二进制。必须在GDB中显式指定加载地址:(gdb) add-symbol-file boot.bin 0x7c00。否则b *0x7c00会设在错误的虚拟地址上。这个技巧几乎所有入门教程都漏掉了。

5.4 问题现象:INT 0x13磁盘读取失败(AH=0x01),AL=0xff

排查思路:Day3虽不涉及读盘,但很多同学会提前尝试,这是经典陷阱。

根本原因:INT 0x13的AH=0x02(读扇区)功能,要求ES:BX指向内存缓冲区,且该缓冲区必须在实模式下可寻址(即ES*16+BX < 1MB)。如果ES被设为0x0000,BX为0x7e00,则地址为0x7e00,安全;但如果ES是0x1000,BX是0x0000,地址为0x10000,超出常规内存范围,BIOS会返回错误。

解决方案:统一使用0x0000作为段寄存器值,并确保BX指向0x7e00或更高地址(如0x8000):

mov ax, 0x0000 mov es, ax mov bx, 0x7e00 ; 缓冲区地址 mov ah, 0x02 ; read sectors mov al, 1 ; number of sectors mov ch, 0 ; cylinder 0 mov cl, 2 ; sector 2 (first sector is MBR, so kernel starts at sector 2) mov dh, 0 ; head 0 mov dl, 0 ; drive 0 (floppy) int 0x13

提示:INT 0x13的错误码在AH寄存器中,AH=0x00表示成功,AH=0x01表示无效功能号,AH=0x02表示地址标记未找到。查《Ralf Brown's Interrupt List》是必备技能。

6. 工具链与环境配置:避开发行版自带QEMU的“温柔陷阱”

6.1 QEMU版本选择:为什么必须用qemu-system-i386而非qemu-system-x86_64?

表面上看,qemu-system-x86_64也能启动x86代码,但它的默认行为是模拟64位CPU,并启用UEFI固件(`-bios /

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询