☰
从零手写操作系统:引导扇区制作与运行全记录
2026/10/20 4:34:37 网站建设 项目流程

从零开始手写操作系统,我做的第一件事不是去啃内核源码,而是老老实实写出并运行了一个引导扇区。所谓引导扇区,就是电脑通电后,固件从启动介质加载到内存里的第一段可执行代码,一共512字节,最后两个字节必须是0x55 0xAA。这篇博客是我的实操全记录:工具链怎么选、汇编代码每一行什么意思、怎么编译成可启动镜像、怎么用虚拟机跑起来,以及我踩过的所有坑。内容面向第一次接触裸机编程的开发者,也适合想从底层一步步搭建自己内核的进阶玩家。看完你就能亲手做出一个能在屏幕上打印字符串的引导扇区,而且每一步都能复现。

1. 为什么所有操作系统教程都从引导扇区开始

1.1 引导扇区是什么:一个只有512字节的“启动契约”

引导扇区不是一个抽象概念,它就是一个非常具体的东西:存储介质(软盘、硬盘、U盘)最开始的那个扇区,物理上占512字节。计算机的固件(传统叫BIOS,现在还有UEFI)在开机时,会读这个扇区,检查最后两个字节是不是0x55 0xAA,如果是,就把这512字节当作可执行代码加载进内存,然后把CPU的控制权交给它。

这512字节里承载着整个系统的“出生证”。没有这段代码,CPU就不知道该去哪里找操作系统,只能呆在固件界面报错或者让你插启动盘。你可以把它理解成大楼门口的保安——他不在,访客根本进不了门。所有操作系统,哪怕再庞大,起步阶段都必须先在这么一小块地方站稳脚跟,才能去加载后续的庞大代码。

我当年以为写操作系统是从“main函数”开始的,后来才意识到,机器不会帮你调用任何main。你必须用它规定好的方式,写出一段能在裸金属上从零运行的代码。这种“从零”的压迫感,正是引导扇区迷人的地方。

1.2 开机的瞬间:BIOS是怎么把控制权交给你的

要理解引导扇区,必须理解开机流程。按下电源键之后,CPU会先跑去执行固件代码,固件完成自检和硬件初始化,然后它按启动顺序扫描启动设备。找到设备后,固件会把设备第一个扇区读进内存的固定地址0x7C00,检查扇区末尾的0x55 0xAA,确认无误,就从0x7C00开始执行你的代码。

借用生活场景:固件就像快递柜的柜机,第一步先自检有没有电、门有没有关好,然后吐出一个箱子给你,这个箱子就是你的引导扇区。至于箱子里装的是一块石头还是一把钥匙,它不关心,只要箱子外壳贴了“0x55 0xAA”这个条码,它就把箱子递到你手上。之后怎么用,全看你自己。

这里有个细节值得展开。0x7C00这个地址不是随便定的,它是早期PC设计者协调BIOS、中断向量表、数据和用户程序之后,硬生生挤出来的一个“约定地址”。在实模式下,CPU只能访问1MB地址空间,高地址区域还要留给BIOS自身和其他硬件映射,低地址区域又要放中断向量表和系统数据,最后选定了0x7C00这个误差最小、互不干扰的位置。理解了这一点,你也就理解了为什么所有引导扇区代码第一行都要写org 0x7c00。

1.3 为什么不用现成的引导程序,非要自己写

你可能会问,现在有各种成熟的引导器,装系统从来没见谁手写引导扇区,为什么教程都逼你从头写?原因很简单:引导扇区不是工具,而是一份“契约”。从业者之间的默契是,哪怕是再成熟的引导器,你在分析它的启动卡顿和安全问题时,还是要回到这段512字节的协议上去。亲手写过一遍,你才能真正理解固件、引导器、内核三者之间是怎么交接的。

另一方面,引导扇区是整个自制操作系统项目的第一块砖。你想自己实现从实模式到保护模式、从加载内核到分页内存,每一步都躲不开“最初这512字节从那句org 0x7c00开始”的编排。自己写一遍,后面调试第二阶段引导器的时候你才知道问题出在“固件没认我的扇区”,还是“代码跳错了地址”。

说句实话,生产环境里没人会手写整个引导扇区,那太原始了。但这个练习的教育价值,就是把所有黑盒拆开给你看。至少我现在遇到启动相关问题,脑子里会有一个清晰的排查顺序,而不是对着黑屏瞎猜。

2. 开始之前:把工具链准备好

2.1 用到的三样东西

要写并运行一个引导扇区,其实不需要什么重型IDE,三样东西足够了:

工具作用我为什么选它
NASMx86汇编编译器语法干净,跨平台,输出的bin文件纯粹
QEMU模拟器免费开源,可调试,不用反复插拔U盘
文本编辑器写代码随便一个你顺手的都行,我用普通编辑器

NASM的全称是Netwide Assembler,它的核心价值是能把汇编源文件直接编译成纯二进制(-f bin),没有任何ELF头、PE头之类的附加结构。引导扇区要求的就是这份“赤裸裸”:512字节,整份文件都可以被加载执行。QEMU则是我测试裸机代码的首选,它可以在不接触真实硬件的情况下模拟完整的启动过程,还能输出调试日志,后面讲排错时会用到。

2.2 10分钟装好并验证环境

不同系统装法不一样,但都很简单。在主流开源Unix类系统上,一套命令搞定:

sudo apt-get install nasm qemu-system-x86

在macOS系上可以用包管理器安装:

brew install nasm brew install qemu

Windows用户直接去NASM官网和QEMU官网下载安装包即可。

装完之后别急着写代码,先验证两件事,避免后面出现“命令找不到”的尴尬:

nasm -v qemu-system-x86_64 --version

我第一次折腾的时候,就是没装QEMU就兴冲冲写完代码,结果编译通过了,却找不到模拟器。别笑,这种低级失误会浪费你半小时。找本子记下来:先装工具,再验证工具,最后才写代码。

2.3 为什么我推荐NASM而不是其他汇编器

有人会用GNU as或者古老的MASM,但我只推荐NASM做引导扇区练习,理由有三。

第一,NASM的语法直观,mov ah, 0x0e就是往寄存器写值,不搞花哨伪操作,对新手极其友好。第二,NASM的-f bin输出格式是纯二进制,非常适合引导扇区这种“文件内容就是内存内容”的场景,你用其他汇编器还得处理各种段定义和重定位信息。第三,NASM跨平台,不管你在哪个系统上写代码,编译出来的bin文件行为一致。

这里补充一个基础概念:vs的实模式。现代CPU刚开机时并不会进入64位模式,而是先进入16位的实模式。实模式下CPU可访问的内存只有1MB,段寄存器加偏移地址共同计算物理地址。写引导扇区时,几乎全部代码都运行在这个模式下,所以要写bits 16告诉汇编器:“下面的代码是16位的,别给我生成32位指令。”

3. 从Hello到可启动镜像:引导扇区实操全解

3.1 最简版本:让CPU输出一个字符

先写一个最朴素的引导扇区,不搞花哨结构,目标是让CPU在屏幕上打出一个字符。保存为boot.asm:

org 0x7c00 bits 16 start: mov ah, 0x0e mov al, 'H' int 0x10 cli hlt times 510-($-$$) db 0 dw 0xaa55

逐行看。

org 0x7c00告诉NASM,这段代码将来会被加载到0x7C00这个地址,遇到标签引用的时候,按这个基址计算绝对地址。如果少了这一行,代码里的标签地址会从0开始算,跳转和访问数据就会错乱。

bits 16声明当前是16位实模式代码,前面解释过了。

然后是真正的输出逻辑。int 0x10是BIOS提供的中断,相当于一个系统调用,而ah=0x0e表示“向屏幕写入一个字符,并自动推进光标”。把要显示的字符放进AL寄存器,执行中断,BIOS就会替你完成显卡操作。这是实模式时代最经典的黑科技:你不需要了解显卡的具体寄存器,只要调BIOS服务就行。

cli关闭中断,hlt让CPU进入暂停状态。这两句是为了让程序执行完后不要乱跑,否则CPU会继续往下执行那些填充的0字节,把垃圾当指令执行,结果不可预测。虽然QEMU里大概率没事,但养成好习惯总没错。

最后两行是引导扇区格式的精髓。第一行times 510-($-$$) db 0,其中$表示当前行的位置,$$表示代码段起始位置,所以$-$$就是当前代码已经占了多少字节。times会把这个表达式的结果当成次数,全部填充0,把扇区补到510字节。第二行dw 0xaa55写入2字节,注意x86是小端序,0xaa55在内存里是55 AA。这样整个文件正好512字节,末尾两个字节是引导标志,BIOS就靠它识别这是一个有效引导扇区。

你可能会问,为什么非要510?因为512字节减掉最后两个魔数,剩下510字节可用。魔数不能提前写,否则会被BIOS误判成有效扇区,但实际上代码还没写完。

3.2 打印一整串字符串:函数式写法的改进

只打印一个H太没排面了,改成打印一整个快乐字符串。这里引入字符串、指针、循环和子程序的概念,是引导扇区代码里最值得练手的一个版本:

org 0x7c00 bits 16 start: mov si, msg call print_string hang: cli hlt jmp hang print_string: mov ah, 0x0e .loop: lodsb test al, al jz .done int 0x10 jmp .loop .done: ret msg db "Hello, OS from boot sector!", 0 times 510-($-$$) db 0 dw 0xaa55

这段代码的工作流程很清晰。mov si, msg把字符串地址放到SI寄存器,然后call print_string进入子程序。lodsb是x86的字符串指令,它会把ds:si指向的内存字节加载到AL,然后自动让SI指向下一个字节。所以每循环一次,AL就拿到字符串的下一个字符。

test al, al检查AL是不是0。C风格的字符串以空字符结尾,所以只要读到0就说明字符串到头了。jz .done跳出去执行ret返回。否则int 0x10把AL里的字符打出来,继续循环。

这段代码虽然短,但已经具备了一个函数的基本形态:入口、循环、终止条件、返回。字符串就放在代码区后面的数据区里。注意代码最后写了db数据,如果不在数据后面补齐到510字节,数据区会被CPU当成指令执行——好在我们有times 510-($-$$) db 0兜底,它会自动把数据区之外的区域全部填零,确保执行流不会误入数据区太深。

3.3 编译成512字节,并制作成可启动镜像

写好代码,编译很简单:

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

-f bin表示输出纯二进制文件。编译完先检查文件大小和内容:

xxd boot.bin | tail

你应该能看到文件末尾是55 aa。用ls -l boot.bin确认大小正好512字节。

下一步,把boot.bin变成虚拟机能启动的镜像。我习惯创建一个1.44MB的软盘镜像,再把引导扇区写进最前面:

dd if=/dev/zero of=boot.img bs=512 count=2880 dd if=boot.bin of=boot.img bs=512 count=1 conv=notrunc

第一条命令生成一个充满0的1.44MB软盘镜像,共2880个扇区。第二条命令把boot.bin覆盖到镜像的第一个扇区。conv=notrunc必须加上,它的作用是告诉dd不要截断目标文件,否则dd可能只保留写入的那一部分,生成的镜像还是512字节,虽然也能跑,但不够真实。

为什么不直接把boot.bin当作镜像给模拟器?也可以,QEMU能识别512字节的裸文件,但用完整软盘镜像更接近真实启动过程,而且后面我们要扩展成加载FAT文件系统或第二阶段引导器时,这份完整镜像就是基础。所以从早期就养成“引导扇区+完整磁盘镜像”的习惯,不吃亏。

顺便说一句,如果你在真机上测试,把boot.img写到U盘,然后在BIOS里开启传统引导模式(Legacy Boot)并选择U盘启动即可。不过真机调试很痛苦,强烈建议所有代码先在QEMU里跑通再说。

4. 第一次运行与现场排错

4.1 用QEMU启动,亲眼看到屏幕输出

镜像做完了,激动人心的时刻来了。执行:

qemu-system-x86_64 -fda boot.img

-fda表示把boot.img当作第一个软盘设备。QEMU会启动一个虚拟PC,执行固件初始化,然后读取软盘引导扇区,跳到0x7C00执行你的代码。正常情况下,屏幕上会显示:

Hello, OS from boot sector!

当时我看到这个输出,说实话挺兴奋的。一个自己写的、只有512字节的程序,在没有任何操作系统的机器上独立运行起来了。这种感觉和写Hello World完全不一样,因为这里没有任何语言运行时替你兜底,也没有操作系统给你提供process model,一个字节一个字节都是自己安排的。

补充一个知识点:这个版本是用传统BIOS启动,也就是Legacy Boot。如果你用的是纯UEFI机器,可能会配置不出来,需要在虚拟机里开启或者使用支持BIOS引导的固件选项。QEMU的默认机型通常兼容传统BIOS启动,所以没有这个问题。

4.2 排错速查表:我踩过的坑和绕坑姿势

新手很容易卡在莫名其妙的地方,我把自己遇到过的几类问题整理成表格,遇到症状直接查。

症状常见原因解决方法
QEMU提示无启动设备,黑屏或直接拒绝启动镜像里没有有效引导标志0x55 0xAA用xxd检查文件末尾,确认55 aa存在且顺序正确
启动后光标闪烁,但什么也不打印代码填充区执行了垃圾指令,或者进入了无中断的hlt状态检查代码是否少于510字节,确认cli/hlt是否在正确位置
屏幕出现乱码或奇怪字符缺少org 0x7c00,地址计算错误在源码第一行加org 0x7c00,重新编译
字符显示了一半就卡住字符串没有以0结尾,test al, al永远不为零检查字符串数据,末尾加db 0
虚拟机无限重启或反复执行缺少cli/hlt,代码执行流穿过了数据区加cli,hlt,jmp hang兜底
编译报错“times value negative”代码超过了510字节,填充数变成负数精简代码,或者把提示信息缩短

我自己第一次遇到的坑,是制作镜像时忘了加conv=notrunc,结果boot.bin覆盖软盘镜像后,整个镜像变成只有512字节,QEMU识别失败。另外一次是写字符串时忘了结尾的0,屏幕上输出了一段乱码和内存垃圾,因为lodsb一直在往后读,不知道停。这两个问题都很隐蔽,不检查文件内容光看屏幕很难想到。

4.3 两个提升调试效率的小技巧

纯靠肉眼观察屏幕输出,效率太低了。这里分享两个我用着很顺的调试手段。

一是把QEMU的指令级日志打开。执行:

qemu-system-x86_64 -fda boot.img -d int,cpu -D log.txt

-d int,cpu记录每次中断和CPU状态,-D log.txt输出到文件。运行一小段时间后关掉QEMU,打开log.txt,你能看到0x7C00处的执行轨迹。如果代码跑飞了,日志末尾的指令就能告诉你CPU到底在执行什么骚操作。

二是用GDB连接QEMU做单步调试。启动QEMU时加两个参数:

qemu-system-x86_64 -fda boot.img -s -S

-s开起GDB调试端口,-S让CPU在第一条指令前暂停。然后另开一个终端,用GDB连接过去,设置add-symbol-file或者直接看反汇编,就能逐条指令地看CPU怎么执行你的引导扇区。对于引导扇区这种非正规代码,单步调试的价值极大,因为你没有操作系统帮你打印日志,只能靠硬件级的观察。

这两个技巧的核心思路是:引导扇区运行环境太原始,没有printf,没有断点API,只能靠“CPU自己说它干了什么”。 掌握日志和单步调试,后面写第二阶段引导器时会救你很多次。

5. 这个引导扇区到底能走多远

5.1 512字节的瓶颈:为什么刚跑通就开始不够用

刚把“Hello, OS from boot sector!”打印出来,你很快会发现这个引导扇区就干不了更多事了。不是说它没用,而是它的使命已经完成了:它证明了存储介质可以被固件识别,你的代码能在裸机上执行,并且能利用BIOS服务跟外部世界打交道。

但真正的操作系统远不止这么简单。你想读取硬盘上更大的程序,就要理解文件系统格式;你想在显示器上画像素和窗口,就要接触显卡和VBE;你想用上现代CPU的32位、64位特性,就要从实模式切换到保护模式,配置各种描述符表。保守估计,一个最小可用的引导加载器程序,少说也需要几KB的代码。512字节别说放内核,放一个稍复杂点的文件系统判断逻辑都可能放不下。

所以引导扇区的正确用途不是“装下所有东西”,而是“加载和下一条路接上”。它读了磁盘上后续的扇区,把控制权交给一个更大的程序,那个程序才负责真正的系统加载。我的路线规划是:引导扇区只负责读入第二阶段引导器,第二阶段引导器负责加载内核进入保护模式,然后内核接管一切。

5.2 下一步路线图:从引导扇区到内核

顺着这个思路,下一步的路线很明确,而且每一环都能复用你已经学会的知识:

  1. 写一个读磁盘函数,利用BIOS中断(int 0x13)从软盘或硬盘读取指定扇区到内存。
  2. 把第二段代码放在引导扇区后面的扇区里,让引导扇区把它读进内存并跳转过去。
  3. 在第二阶段代码里做更多事情:检测内存、初始化显示模式、设置GDT。
  4. 切换到保护模式,这是“从一个玩具变成真正的操作系统雏形”的转折点。
  5. 有了保护模式,就可以加载C语言写的内核了——到那个时候,你才终于拥有一个正常的编程环境。

每一步都踩在上一步的基础上。引导扇区是第一块台阶,没有它你哪都去不了。

5.3 给新手的第一份课后作业

看完这篇博客,我建议你自己动手完成三件小事,比单纯照着敲一遍代码有用得多:

第一,把字符串内容改成你喜欢的任意一句话,重新编译出镜像,让QEMU跑起来。

第二,给print_string增加换行功能。思路是打印完字符串后,再打印一个回车符0x0d和换行符0x0a。别看这个改动简单,它会逼你思考BIOS中断里ah=0x0e的光标推进机制。

第三,做一个能显示数字的函数。比如传入一个十六进制数值,调用BIOS中断逐个把字符打出来。这是后面做内存检测、调试输出必备的基础。

这三件事做完,你对BIOS中断、寄存器传参、循环和代码布局的理解,会比看十篇教程都要扎实。做完之后,欢迎回来继续做第二阶段引导器。

我自己这几轮折腾下来,最大的体会是:写引导扇区这事,代码量虽然小,但每一步都在跟“约定”打交道。你写的不是功能,而是接口。它逼你把“开机发生了什么”这个问题从根上想清楚,而不是满足于“操作系统好像会自动启动”。这种把黑盒拆开的经历,是高级语言开发永远给不了你的。所以别嫌512字节少,这个世界的入口有时候就是512字节那么大。

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

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

立即咨询