自制操作系统第一天:从BIOS到引导扇区的完整启动链路 第一次翻开《三十天自制操作系统》目录的时候我对“第一天”真没抱太大期望。一个只能打印hello, world的引导扇区听起来就像普通编程课的第一节课。真正动手后才发现这一整天的练习其实是在零基础下复现一台电脑从加电、BIOS 自检、再到把控制权交给我们自己代码的完整链路。从汇编命令行到软盘镜像从虚拟机上看到那行字符出现在屏幕左上角的那一刻起我对“操作系统”这个词的敬畏感完全不一样了。如果你也想从零开始理解操作系统这一天值得认真走完。1. 第一天的真实目标让一台“裸机”执行我们的第一个程序1.1 第一天结束时你手里拿到的产物很多人以为《三十天自制操作系统》第一天就会写一个能运行的微型系统实际上你最后拿到手的只是一个 512 字节的引导扇区镜像。听起来很小但这 512 字节恰恰是整台机器加电后第一段由我们自己控制的代码。这段代码最后以二进制形式写入软盘镜像的第一个扇区启动时由 BIOS 自动加载到内存固定位置然后执行。它做的事情很简单设置必要的寄存器通过 BIOS 的中断服务在屏幕上逐字输出一串字符。但完成它之后你已经打通了从源码、汇编、二进制镜像、虚拟机启动到屏幕输出的整条链路。之后的每一天都是在把这个“主角”慢慢培养成真正能管理内存、进程和文件的操作系统。1.2 为什么这不是“普通程序”平时我们写的程序都是在一个已经能运转的操作系统里被加载执行的。程序启动前操作系统早就把 CPU 模式、内存布局、设备驱动、标准输入输出全部准备好了。第一天写的这个引导扇区完全不同它运行的时候硬盘上还没有操作系统内存里没有可用的 C 运行时也没有任何库函数可以调用。你面对的是最原始的硬件环境唯一的“系统服务”就是 BIOS 提供的那几个中断调用。所以这一天的核心收获不是一个能跑的程序而是建立一种“硬件现场感”内存地址是什么段寄存器为什么存在CPU 如何从实模式启动字符串在内存里怎么存放。这些东西在后来的每一天都会反复出现第一天若不把它们弄清楚越往后越吃力。1.3 需要的装备与前置知识我先把第一天需要的东西列一个清单你可以对照准备工具作用第一天是否必需备注NASM把汇编源码编译成二进制文件必需跨平台用着顺手QEMU虚拟机模拟整台电脑必需不需要真实软盘也能测试文本编辑器写汇编源码必需带语法高亮最好镜像制作工具把引导扇区补成 1.44MB 软盘镜像必需Linux/macOS 用ddWindows 可找独立工具前置知识方面你不需要懂太多能看懂MOV、ADD、CMP、JMP这类最基础汇编指令就够了。如果连汇编都没写过也别慌第一天代码量非常少逐条解释之后你完全能跟上。2. 搭建一套可复现的《三十天自制操作系统》实验环境2.1 为什么我选择 NASM 而不是书里自带工具《三十天自制操作系统》的配套工具在 Windows 上可以直接使用也封装好了 make 命令照书抄会比较省事。但我在实际环境里更习惯跨平台工作流所以我选择 NASM 加 QEMU 的组合。这样做的好处是不管你在哪个系统上都能通过包管理工具装好环境后续所有章节的汇编代码也都能使用同一套命令编译。坏处也很明显书里的一些脚本和工具链细节需要自己手动重新拼一遍不过我下面给的流程已经能直接照抄不需要额外发挥。安装 NASM 和 QEMU 很简单根据你的系统选择对应命令# Debian/Ubuntu 系 sudo apt install nasm qemu-system-x86 # macOS先安装 Homebrew 再执行 brew install nasm qemuWindows 下如果不想折腾可以直接用官方安装包装 NASMQEMU 也有现成的 Windows 版本。只要命令行里能敲出nasm -v和qemu-system-i386 --version环境就算准备完毕。2.2 写好第一份汇编源码在你的工作目录新建一个文件名字我用helloos.nas。后缀不重要重要的是把下面这份代码完整写进去。我先贴出完整版后面会逐行拆解它的含义。; helloos.nas ; 一个最小的引导扇区程序打印 hello, world ; 编译命令: nasm -f bin helloos.nas -o helloos.bin ; 镜像命令: 见下方生成的 1.44MB 软盘镜像 ORG 0x7c00 entry: MOV AX, 0 MOV SS, AX MOV SP, 0x7c00 MOV DS, AX MOV ES, AX MOV SI, msg putloop: MOV AL, [SI] ADD SI, 1 CMP AL, 0 JE fin MOV AH, 0x0e MOV BX, 0x0f INT 0x10 JMP putloop fin: HLT JMP fin msg: DB 0x0a, 0x0a DB hello, world DB 0x0a DB 0 TIMES 510-($-$$) DB 0 DB 0x55, 0xAA这段代码看起来不到三十行但每一行都有存在的理由。先别急着往下翻把它存好后我们直接编译。2.3 编译并生成可启动的软盘镜像先把源码汇编成 512 字节的二进制文件nasm -f bin helloos.nas -o helloos.bin执行完后你可以用ls -l helloos.bin看一眼文件大小正常情况应该是 512 字节。如果多了或者少了说明代码写错或者编辑器的行尾出了问题需要回去检查。helloos.bin本身只是引导扇区的内容不能直接作为软盘启动。真实软盘是 1.44MB我们需要把它放到一张“空白软盘”的第一个扇区。在 Linux 或 macOS 下用两条dd命令就能完成# 先创建 1440KB 的空白镜像 dd if/dev/zero ofhelloos.img bs1024 count1440 # 再把引导扇区写到镜像最前面注意 convnotrunc 不要漏掉 dd ifhelloos.bin ofhelloos.img convnotrunc第二条命令里的convnotrunc很关键它的意思是“写入时不要截断目标文件”这样helloos.bin会被覆盖到镜像开头而镜像后面的空白区域全部保留最终大小仍然是 1.44MB。2.4 跑起来看第一眼输出镜像准备好后用 QEMU 启动它qemu-system-i386 -fda helloos.img-fda表示把helloos.img当作第一张软盘驱动器里的软盘。QEMU 会模拟完整 BIOS 启动流程从软盘读取引导扇区然后执行我们的代码。正常情况下一个黑窗口很快出现屏幕上有两行空行然后是白色字符hello, world。看到这行字符的时候恭喜你第一天的核心目标已经达成了。3. 启动扇区魔法发生的物理前提BIOS、0x7C00 与 55AA3.1 开机那几毫秒到底发生了什么理解引导扇区为什么能运行最关键的是搞清楚电脑加电后的执行路线。按下电源后CPU 不是先去找你的程序而是先进入一种固定的 16 位实模式状态CS 段寄存器被设置为 0xF000指令指针 IP 被设置为 0xFFF0。这两个值组合起来CPU 第一个要执行的指令来自物理地址 0xFFFF0也就是 BIOS 固化代码的入口。BIOS 加电后会做自检、初始化各种硬件然后扫描可启动设备。当年标准的流程是检查软盘如果软盘的第一个扇区最后两个字节是 0x55 和 0xAABIOS 就认为这是一个合法引导扇区把这个扇区整块读取到内存地址 0x7C00接着把控制权跳转到 0x7C00 处执行。当你的代码开始跑的时候CPU 已经不在 BIOS 手里而是在我们helloos.nas那段指令手里了。这就是“夺取控制权”的最朴素含义。3.2 为什么偏偏是地址 0x7C000x7C00 这个地址看起来很奇怪并不是随便定的。设计早期 IBM PC 时内存最高只有 640KB 可用操作系统需要尽量靠后的位置给后续加载的代码留出空间。0x7C00 被选中的原因是这个位置能让引导代码在跨越 32KB 边界时不会产生额外的麻烦同时又不至于离 640KB 上限太近影响正常的内存布局。我们不需要记住这段历史的每一个细节但一定要知道BIOS 约定俗成地把引导扇区加载到 0x7C00所以我们在代码开头写了ORG 0x7c00。ORG指令告诉汇编器“我的代码将来会运行在内存地址 0x7C00 处”汇编器在计算标签地址时就会自动加上这个基地址。没有这行标签msg会被计算成从 0 开始的偏移地址运行时会因为段地址不一致而读取错误的内存位置。3.3 0x55AA 不是“标志位”而是小端存储的约定引导扇区最后两个字节是 0x55 和 0xAA很多资料把它写作“魔数 0x55AA”。这里的顺序有一个容易踩的坑。如果你按双字节字的形式看x86 是小端序存储内存里先存低字节、再存高字节。所以当你写下DW 0xAA55时实际写入磁盘的字节顺序是 0x55、0xAA这反而没问题。但为了直白表达磁盘存储顺序我在代码里直接用两条伪指令DB 0x55, 0xAA这样写更不容易产生混淆。BIOS 检查的是“最后一个扇区的第 511 字节是 0x55第 512 字节是 0xAA”不是反过来。如果你把字节顺序写错镜像同样无法启动。3.4 实模式像一个大号的“裸奔舞台”在引导扇区刚运行的时候CPU 还处于实模式这是现代操作系统最原始的 CPU 模式。实模式下程序可以直接访问物理内存可以随便使用中断没有权限保护没有虚拟内存段寄存器也还是老式的“段基址左移四位加偏移”寻址方式。这套机制对第一天来说不是负担反而是最简单直接的舞台。你不需要向操作系统申请什么你面对的整台机器都是你的。当然这也意味着一旦地址写错你可能直接把内存里乱七八糟的数据当作字符串输出也可能让虚拟机直接重置。调试起来很刺激但正是这种刺激逼着你把每条指令都弄明白。4. 逐行解析第一天汇编代码每条指令的意图4.1 ORG 与寄存器初始化先把桌面清理干净很多第一次接触这一章的人不理解为什么开头要啰啰嗦嗦设置一堆寄存器直接拿INT 0x10输出字符串不是更快吗我一开始也这么想直到有一天我把MOV SS, AX删掉再启动才明白这些初始化的必要性。代码里先把 AX 清零然后依次赋给 SS、DS、ES。SS 是栈段寄存器后面程序调用 BIOS 中断时硬件和中断服务代码会使用栈来保存临时数据。如果不设置栈顶 SP栈指针可能指向未知内存中断一进来程序就崩了。DS 是数据段寄存器我们访问[SI]时CPU 默认用 DS 参与地址计算把它设置成 0配合ORG 0x7c00标签msg的物理地址才能被正确解析。ES 则留作备用。MOV SP, 0x7c00把栈指针放到引导扇区所在位置。栈是向下增长的从 0x7C00 往下压栈不会踩到我们的代码这是一个非常经典的小技巧。4.2 字符串输出靠 INT 0x10 驱动显卡接下来是核心循环。MOV SI, msg把字符串首地址放入源变址寄存器然后进入putloop。你仔细看会发现这个循环本质上在做三件事从内存取一个字节到 AL判断是不是 0如果不是 0 就调用INT 0x10输出到屏幕。INT 0x10是 BIOS 提供的显示服务中断AH 寄存器里存放功能号。这里 AH 赋值为0x0e意思是“以电传打字机模式输出一个字符”。AL 寄存器存放字符的 ASCII 码BL 寄存器低字节存放字符颜色0x0f表示白色。执行完中断后光标会自动往后移动一个位置连续循环就能打出完整字符串。字符串末尾为什么要放一个 0因为汇编器不会帮你记录字符串长度只能用 0 作为结束标记。每读到一个字符就判断一次等于在裸木机上实现了一套最简单的字符串协议。4.3 停机与死循环程序结束以后的事当读到结束标志 0程序跳到fin标签处fin: HLT JMP fin第一眼看上去这好像是一个毫无意义的死循环。但它恰恰很重要。如果执行完最后一条指令就让 CPU 继续往后跑机器会去执行 0x7C00 后面那一堆空白数据最终行为不可预测大概率直接死机或触发异常。我们主动让 CPU 停下来用HLT指令让处理器进入低功耗待机状态直到收到中断再醒来。醒来后立刻跳回fin再次执行HLT形成一个不会跑飞的“空转停机场”。4.4 TIMES 与最后两字节让引导扇区合法化控制流写完后还差一个法律手续扇区大小必须是 512 字节最后两字节必须是 0x55 和 0xAA。我们不会手动去数每一条指令占了几个字节而是用 NASM 的填充指令解决TIMES 510-($-$$) DB 0 DB 0x55, 0xAA$$是当前段起始地址$是当前行地址$-$$就表示目前已经生成的代码长度。510-($-$$)计算出还差多少字节能凑满 510 字节然后用 0 填充最后再写入 0x55 和 0xAA。这相当于告诉汇编器“从当前位置到第 510 字节全部写 0”。如果你的代码一不小心超过了 510 字节这个表达式会变成负数NASM 直接报错避免生成一个非法引导扇区。5. 实操记录从空目录到 QEMU 窗口亮起5.1 亲自跑一遍的完整步骤我把第一天实际操作的命令整理成一个可以直接照抄的流程。先建一个干净目录然后按顺序执行mkdir day01 cd day01用编辑器创建helloos.nas把上一节那段代码完整粘贴进去然后依次运行nasm -f bin helloos.nas -o helloos.bindd if/dev/zero ofhelloos.img bs1024 count1440 dd ifhelloos.bin ofhelloos.img convnotrunc qemu-system-i386 -fda helloos.img第一次跑的时候我盯着屏幕发了半天呆然后又把 QEMU 关掉重新启动了好几次。不是因为报错而是不敢相信那几行字符真的从我们写的汇编代码里跑了出来。整个过程里最难的其实是dd convnotrunc这一步第一次少写了参数镜像被截断成了 512 字节QEMU 根本认不出引导设备折腾了好一会儿。5.2 启动后的第一屏输出你在 QEMU 里看到的内容可能和我当年稍有不同因为镜像里msg字符串开头有两个0x0a它们对应换行符。所以实际输出不是出现在屏幕第一行而是先空两行再打印hello, world。这也是一个好的细节验证了打印顺序和光标换行控制而不只是单纯把字符串原样显示出来。如果屏幕空白但虚拟机没有报错多半是你把INT 0x10的 AH 功能号写错了或者代码根本就没被写进软盘镜像。此时按CtrlAlt2可以切到 QEMU 的监视器再执行info registers查看 CPU 寄存器的值看看执行现场到底停在哪里。5.3 试着把输出改成彩色第一天的引导程序已经足够入门但我不建议你做完就问明天内容而是先在原地改一改。最安全、最有成就感的改动是修改MOV BX, 0x0f这一行。BL 寄存器里的颜色属性有多种含义低四位是前景色高四位是背景色。0x0f表示“黑底白字”改成0x1f就是蓝底白字改成0x4f就是红底白字。试着重新编译、重新生成镜像、重新启动你会发现自己已经掌握了一个调试循环改代码、编译、写镜像、启动、观察结果。这个循环会成为之后二十多天里最基本的工作方式。6. 第一天最常遇到的坑与排查方法6.1 快速排查清单这一章虽短可踩的坑一点不少。我把常见问题和排查方向整理成表现象可能原因排查方向QEMU 报找不到启动设备镜像不是 1.44MB或最后两字节不对重新用 dd 生成镜像检查 0x55 0xAA屏幕有内容但全是乱码字符串地址不对DS 段寄存器或 ORG 缺失检查 ORG 0x7c00 是否写了DS 是否清零刚刚启动就重启或卡死栈未初始化或死循环跳飞检查 MOV SS/SP 是否设置HLT 是否保留编译报错提示 TIMES 参数为负代码过长超过 510 字节减小字符串长度或精简指令镜像启动后黑屏无反应引导扇区根本没写进镜像开头检查 dd 的 convnotrunc用 hexdump 看镜像前 512 字节6.2 镜像大小不对导致“起不来”这是第一天最隐蔽的问题。很多人编译完helloos.bin后直接执行qemu-system-i386 -fda helloos.bin想当然认为 512 字节也能当软盘启动。实际上BIOS 和 QEMU 都期望读取的是一个结构完整的软盘镜像不是一个孤零零的启动扇区。软盘镜像的大小、甚至几何参数都需要符合约定直接拿 512 字节文件启动只能得到启动失败。正确的思路是始终维护一个 1.44MB 的.img文件作为虚拟软盘每次更新引导扇区时把编译出的.bin文件覆盖到.img的开头其他空白区域不要动。只要记住了convnotrunc你就再也不会被困在这一步。6.3 地址算错导致乱码如果输出不是hello, world而是闪烁的方块或乱码问题几乎都出在地址计算上。最常见的错误是忘记写ORG 0x7c00。没有 ORG 指令NASM 会把msg标签当作从 0 开始的偏移地址来计算而运行时DS0已经设好CPU 就去读取物理地址 0 附近的数据那段内存根本不是我们定义的字符串。另一个相关的坑是段寄存器和 ORG 不一致。如果你突然想用段寄存器来简化寻址比如设置 DS 为0x07c0那 ORG 就应该改成 0。两者必须配套否则字符串地址永远错位。第一天先老老实实按“DS0ORG0x7c00”这套组合来最省心。6.4 学会用 QEMU 的调试参数快速定位QEMU 不只用来跑镜像它还是绝佳的低级调试工具。当你怀疑引导流程不正确又不想一次次盲猜的时候可以加上一个调试参数记录中断和 CPU 状态qemu-system-i386 -fda helloos.img -d int,cpu_reset -D qemu.log这会把每次 CPU 收到的中断、每次复位事件都追加到qemu.log文件里。执行完看日志你能看到INT 0x10有没有被调用过CPU 有没有发生复位程序有没有跑飞。我第一次排查“为什么输出一半就重启”时靠的就是这个日志一眼发现代码在某条指令后触发了异常。6.5 一个容易忽略的小细节编辑器行尾如果你在 Windows 上写源码注意把文件保存成普通文本行尾可以是 CRLFNASM 能处理。但如果你的编辑器自作聪明地保存成了 UTF-16 编码NASM 会立刻报出一堆奇怪错误因为文件里出现大量不可见字节。建议所有汇编源码都用纯 UTF-8 或 ASCII 保存字符串也只用英文字符等以后学了中文显示方案再碰中文字符串。按我自己实际操作的体会第一天的门槛不在汇编语言而在“心理模型”。你不需要完全理解每条机器指令的电气原理但一定要在脑子里建立起一条清晰的主线BIOS 把扇区读到 0x7C00我们的代码从 0x7C00 开始执行通过 INT 0x10 把字符输出到屏幕。只要这条主线通了后面从保护模式到文件系统的内容都是在这条主线上不断加新站点。第一天下来的成就感非常足建议你把helloos.img保留好第二天做“汇编语言学习”时还能直接从这份镜像继续起步。