《操作系统真象还原》这本书,开篇第一章就把绝大多数人劝退过一次。你以为会先讲实模式、段寄存器、中断向量,结果翻开目录看到的是“部署工作环境”。我当时也觉得很奇怪,写个操作系统为什么要从虚拟机开始折腾,直到自己天真地在一个物理机上跑刚写完的引导扇区,把一块硬盘的分区表清空之后,才明白这章存在的意义。
第一章的确是整本书最没有“表演性”的一章,但它解决的是最现实的问题:你怎么在安全、可控、可重复的前提下,反复运行一个随时可能崩溃、把整个内存搞乱甚至破坏磁盘的“半成品操作系统”。这一章做的工作说白了就三件事:准备好一台可以随便反复重启的虚拟机,在上面装一个功能完整的 Linux 发行版,再配齐汇编器、编译器、构建工具、模拟器和调试器。
这章不要求你懂硬件底层,也不需要太高端的编程能力,真正考验的是动手的耐心和碰到问题时排查的思路。适合所有打算从零手写操作系统的读者,也适合那些已经读过不少理论书、但迟迟没有开始实践的人。很多人卡在第一章不是因为复杂,而是觉得环境搭建太琐碎、太没成就感。但其实环境的好坏直接决定你后面读每一章时的心情,这个坑如果能一次踩平,后面能省下一大堆时间。
1. 为什么整本书敢从“部署环境”开始说事
1.1 写操作系统天然是“破坏性”开发
常规的应用程序开发,最坏情况下程序崩溃,操作系统把它杀掉就完了,不会影响系统本身。但操作系统的开发完全不是这个画风。你的代码从引导扇区开始,一运行就直接接管 CPU,拥有整个机器的控制权,一句错误的汇编指令就可能让系统死机,一段错误的磁盘读写就能抹掉文件系统。
我刚开始学的时候想过先在物理机上直接试,把编译好的引导扇区写到 U 盘里,再用 U 盘启动,结果只做了一次就差点把电脑搞废。后来才明白,普通应用开发是“在系统里写代码”,而操作系统开发是“写一个系统给别人/别的机器跑”,开发和运行的环境必须彻底隔离。虚拟机这种方案天然就是为这类场景准备的:它在真实硬件上模拟出一台完整的电脑,你在这个虚拟电脑里怎么折腾,都不会伤害到宿主系统。
1.2 “反复重启、反复翻车”是操作系统开发的主旋律
操作系统从按下电源键那一刻就要接管硬件,所以你开发过程中的每次验证,几乎都要经历完整的“启动流程”:计算机上电、BIOS/UEFI 固件初始化、把引导扇区的代码加载到内存、执行、打印字符串或跳转内核。这个流程里任何一环出错,表现就是屏幕黑着不动、无限重启、或者一堆看不懂的数字滚屏。
这种开发模式意味着你需要极其频繁地重启机器。物理机重启一次几十秒甚至几分钟,而且中途如果写坏了磁盘、破坏了引导,只能拿另一台电脑去救。虚拟机里重启只需要几秒钟,还支持随时保存快照,改一行代码、跑一遍、翻车了、迅速回滚重来。所以我现在的经验是,凡是涉及引导扇区、MBR、分区表、内核入口这类内容的实验,尽量全在虚拟机里做,安全第一,效率第二。
1.3 环境搭建本质是搭一条“编辑-构建-运行-调试”的循环
整个第一章看起来是装软件、敲命令,其实真正搭建的是一个开发闭环。我给你拆一下这条链路:
- 你用编辑器写汇编代码,比如一个 boot.asm
- 用 NASM 把它编译成原始二进制文件 boot.bin
- 用 dd 等工具把这个二进制写到磁盘镜像的指定位置
- 让模拟器 QEMU 加载这个磁盘镜像并启动
- 程序跑挂了,用调试器单步跟踪、查看寄存器和内存
这条链路一旦通了,你后面学习引导扇区编程、护模式切换、分页机制、内核加载时,就只需要关心代码逻辑本身,而不是反复被环境问题绊住。很多人前几章读得痛苦,倒不是书里的汇编指令看不懂,而是编译出来的镜像根本起不来,最后发现是环境没搭对。所以“部署工作环境”这个章节名字看着朴素,实际优先级非常高。
2. 整体设计思路:心里要装两套“运行环境”
2.1 开发机与测试模拟器的分工
我看过很多初学者把环境理解得很简单:“装一台虚拟机,里面装 Linux,然后在里面跑 QEMU 不就行了?”确实是这个思路,但有几个细节值得想清楚。
第一层是“真正的电脑替身”,也就是虚拟机软件。它给你提供一个完整的、独立的硬件环境,你在这个虚拟机里安装 Linux 发行版,作为日常编写代码、执行编译的场所。这样做的原因前面说过:让你的开发过程与物理机彻底隔离。
第二层是“被开发系统运行的沙盘”,也就是模拟器。它不是一个真正的“电脑替身”,而是一个可以模拟 CPU、内存和外设的软件。你在 Linux 终端里敲一条命令,让模拟器读取你的磁盘镜像,把引导扇区加载到虚拟内存的 0x7C00 地址上执行。
这个分工很多人一开始没搞清楚,于是会在同一个环境里同时混用虚拟机和模拟器,最后出问题时不知道是哪个环节坏了。我的建议是:虚拟机只管“生产镜像”,模拟器只管“消费镜像”,二者通过一个磁盘镜像文件连接起来。编译好的二进制文件既不直接跑在虚拟机的普通文件系统里,也不直接跑在物理机上,而是被封装成模拟器能识别的启动介质。
2.2 虚拟机硬件配置怎么给最合适
搭建虚拟机时,很多人会本能地给很大配置:8 个 CPU 核心、16G 内存、100G 磁盘。这完全没必要。写操作系统早期阶段,要编译的东西极其轻量,核心其实是反复重启模拟器和进制转换的计算,不是重量级并行编译。我给一个经过实操验证的配置参考:
| 配置项 | 建议值 | 理由 |
|---|---|---|
| CPU 核心数 | 2 | 留给 QEMU 和编译进程跑并行,但不用更多 |
| 内存 | 2GB - 4GB | 足够装桌面版 Linux 和跑 QEMU |
| 磁盘容量 | 20GB 左右 | 系统、工具、源码和镜像全放进去还有富余 |
| 网络模式 | NAT | 能上网更新软件包,又不会让外部机器直接连入 |
磁盘格式我这里多说一句。很多虚拟机软件会问你“固定大小”还是“动态扩展”。固定大小是立刻把 20G 空间划给虚拟机,动态扩展是按需增长。我建议选固定大小,因为后面你会频繁用 dd 写磁盘镜像,且要对引导扇区进行精确的偏移写入,动态扩展格式在性能上偶尔会有一些不可预期的延迟。另外,在后续学习磁盘读写相关章节时,固定大小的磁盘对齐方式更直观,排查问题更简单。
2.3 引导方式必须提前想清楚:传统 BIOS 还是 UEFI
这一条被很多人忽略,但影响非常大。现在的物理机和虚拟机默认都会启用 UEFI 引导,而这本书前期讲的是 BIOS 启动流程:开机自检、BIOS 读取 MBR、把 512 字节的引导扇区加载到内存 0x7C00、跳转执行。这套流程对应的是传统 BIOS(也叫 Legacy BIOS)引导模式。
我见过不少读者在虚拟机里默认使用 UEFI,结果自己写的引导扇区始终不被加载,卡在固件界面或者在屏幕上打印不出东西。排查半天才意识到,UEFI 根本不按传统 BIOS 的方式读取 MBR 引导扇区,而是去读 FAT32/ESP 分区里的文件。所以,如果按这本书的顺序学习,建议在虚拟机配置里把固件类型手动改成传统 BIOS(Legacy BIOS),关闭安全引导选项。至少在前期引导扇区和 MBR 阶段,你希望得到一个和十几年前 x86 启动流程一致的环境,而不是被固件层多插手一道。
2.4 工具链不是装得越多越好,够用才是原则
第一章涉及的工具非常经典,几乎是操作系统开发的标准套餐。我的理解是,不要把环境理解成“装软件”,而是理解成“准备一套能把可读代码翻译成可执行机器码并验证结果的手艺”。下面这张表是我整理的最小工具集:
| 工具 | 职责 | 出现时机 |
|---|---|---|
| 文本编辑器 | 编写汇编/C 源码 | 每一章 |
| NASM | 把汇编源码编译成二进制文件 | 引导扇区、MBR、内核入口 |
| GCC | 编译 C 语言代码 | 进入 C 语言编写内核阶段 |
| GNU Make | 自动组织和执行编译流程 | 章节练习 |
| QEMU | 加载磁盘镜像、模拟 x86 机器 | 每次运行验证 |
| GDB | 单步调试程序,查看寄存器和内存 | 排查问题 |
| dd/xxd/objdump | 生成镜像、检查二进制内容 | 验证镜像完整性 |
有一点我想专门解释一下。很多人听说过“交叉编译器”,以为写操作系统必须用专门为裸机目标准备的交叉编译工具链。其实在 x86 平台写引导扇区、写内核时,本机的 GCC 加一个-m32选项就够了。交叉编译更适合目标体系结构和开发机不一致的场景,比如在 ARM 的笔记本上开发 x86 内核,或者反过来。这本书全程面向 x86,所以不需要额外搭建一套复杂的交叉编译环境,精简反而是正确的。
3. 实操全过程:把环境从零搭到能跑引导扇区
3.1 创建虚拟机与安装 Linux 的关键细节
先讲虚拟机的创建。安装好虚拟化软件后,新建虚拟机时选择“自定义配置”,把操作系统类型定为其他/通用 Linux 即可。内存给 2048MB 或 4096MB,磁盘给 20GB 固定大小,CPU 选 2 核,最关键的是在固件设置里选择传统 BIOS,关闭安全引导。
虚拟机创建完成后,把 Linux 发行版的安装镜像挂载到虚拟光驱,开机进入安装程序。这里我的建议是,不必选择最激进的最小化安装,但也不需要装完整的桌面全家福。选一个默认桌面环境,同时勾选“开发工具”软件组,后续能省去不少手装依赖的麻烦。分区方面直接用引导程序自动分区即可,前期你不需要单独隔离 /boot 或 /home,一个根分区反而是最简单、最不容易出错的。
我在安装时碰到过一个看起来不起眼但很折腾的问题:虚拟机窗口的屏幕分辨率很低,安装界面显示不全。解决办法其实非常简单,切换到集成显示驱动,或者在安装完成后安装增强工具/驱动包,分辨率会立刻正常。这类问题不在书里,但属于初学者最容易卡住的环境细节。
3.2 进入系统后,把基础工具一次装齐
进入 Linux 系统后的第一件事,不是写代码,而是更新软件源并安装工具。以 Debian 系常用的 apt 为例,完整的安装命令是:
sudo apt update sudo apt install -y build-essential nasm make qemu-system-x86 gdb binutils这是最小完整套装。build-essential会带进来 GCC、G++ 和 make 等基础编译工具;nasm是汇编器;qemu-system-x86是模拟器;gdb是调试器;binutils提供 objdump、readelf 这些用于分析二进制文件的工具。另外我建议顺手加装xxd,它在验证十六进制内容时非常好用:
sudo apt install -y xxd有个细节值得注意:如果你用的是非 Debian 系的发行版,比如基于 RPM 的系统,包管理器命令不同,但等价包基本都有。千万不要因为装不上某个包就换发行版,系统的选择不是核心问题,懂得查文档、找等价包才是核心能力。
3.3 验证环境:做一个最简单、却能证明工具链完整的引导扇区
工具装完后,我建议立刻做一次“环境冒烟测试”。所谓冒烟测试,就是从最简单的路径验证整个链路可以跑通。我来展示一条完整的链路,这个测试如果通过,就说明你的环境真的部署好了。
先在项目目录下创建一个文件夹结构:
mkdir -p ~/os/boot ~/os/build然后创建一个最简引导扇区源文件boot/boot.asm,内容如下:
[org 0x7c00] mov ax, 0x03 int 0x10 mov si, msg print: lodsb or al, al jz hang mov ah, 0x0e mov bh, 0 int 0x10 jmp print hang: hlt jmp hang msg db "Hello, OS work env!", 0 times 510-($-$$) db 0 db 0x55, 0xaa这段代码的逻辑是:把显示模式设置为文本模式,然后把字符串“Hello, OS work env!”逐字显示在屏幕上,死循环等待停机。最后两行很关键,times 510-($-$$) db 0表示把扇区填充到 510 字节,最后一个0x55, 0xaa是引导扇区有效标志。这个标志是 BIOS 判断一个扇区能不能引导的必要条件。
接下来用 NASM 编译它:
nasm -f bin boot/boot.asm -o build/boot.bin-f bin是“生成原始二进制文件”,也就是去掉所有文件格式封装,直接输出裸机器码。这一步如果报错,多半是汇编语法问题,可以先对照上面的代码逐行检查。编译完成后,用命令查看一下文件大小和末尾两个字节:
ls -l build/boot.bin xxd build/boot.bin | tail预期是文件大小为 512 字节,末尾两个字节是55 aa。如果大小不是 512,大概率是填充长度计算错了。这一步能看到真实字节内容,比盲目判断要靠谱得多。
接着生成一个磁盘镜像,并把引导扇区写到它的第一个扇区:
dd if=/dev/zero of=build/myos.img bs=512 count=2880 dd if=build/boot.bin of=build/myos.img bs=512 count=1 conv=notrunc第一条命令生成一个 2880 个扇区、每个扇区 512 字节的空白镜像,本质上是模拟一张 1.44MB 软盘。第二条命令把 boot.bin 覆盖到镜像的第一个扇区,conv=notrunc参数是为了避免 dd 在写入时把文件截断成 512 字节。
最后启动模拟器验证:
qemu-system-x86_64 -boot a -drive file=build/myos.img,format=raw -m 32这里的-boot a表示从软盘启动,format=raw告诉 QEMU 这是原始格式镜像。如果屏幕上打印出了问候语,恭喜,从编辑到编译到写镜像到模拟启动的整套环境正式闭环。如果没打印,后面的故障排查章节会告诉你从哪个方向找原因。
3.4 学习曲线拐点:用 Makefile 固化重复劳动
环境通了以后,你会发现一个很尴尬的问题:每次改一行汇编代码,都要敲四条命令,从nasm到两条dd再到qemu-system-x86_64,非常容易遗漏。这时候就该用 Makefile 把这些步骤固化下来。
在项目根目录创建Makefile:
BUILD_DIR := build BOOT_SRC := boot/boot.asm BOOT_BIN := $(BUILD_DIR)/boot.bin IMG := $(BUILD_DIR)/myos.img .PHONY: image qemu clean image: mkdir -p $(BUILD_DIR) nasm -f bin $(BOOT_SRC) -o $(BOOT_BIN) dd if=/dev/zero of=$(IMG) bs=512 count=2880 status=none dd if=$(BOOT_BIN) of=$(IMG) bs=512 count=1 conv=notrunc status=none qemu: image qemu-system-x86_64 -boot a -drive file=$(IMG),format=raw -m 32 clean: rm -rf $(BUILD_DIR)以后再想启动测试,只需要执行:
make qemu这条命令会自动先做镜像再启动模拟器。我强烈建议从第一章就开始使用构建脚本,因为这能让你把精力集中在代码逻辑本身。后面当你同时编译引导扇区文件和内核映像时,Makefile 还能帮你管理依赖关系,不至于因为某个产物过期而改了代码却看不到效果。
3.5 规划好目录结构,后面会感谢现在的自己
最后一项准备工作是目录规划。不要把所有源码都塞在一个文件夹里,那到了写内核阶段会非常混乱。我现在的目录结构大致是这样的:
os/ ├── boot/ # 引导扇区、MBR 相关源码 ├── kernel/ # 内核核心代码 ├── lib/ # 公共库函数 ├── include/ # 头文件 ├── build/ # 编译产物、镜像 ├── tools/ # 辅助脚本 └── Makefile前期环境搭建时可能用不到全部目录,但提前建好后续就不用反复搬文件。操作系统开发有个特点,文件之间的联动关系很强,目录清晰能很大程度降低“这个头文件到底放到哪里去了”这类低级困惑。
4. 踩坑记录与排查思路实录
4.1 常见问题速查表
我把环境搭建阶段最常见的几类情况整理成一张速查表,碰到问题时可以直接照着查:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 虚拟机无法启动 | 宿主机 CPU 虚拟化功能被关闭 | 检查 BIOS/固件设置,打开 VT-x/AMD-V 选项 |
| QEMU 启动报 KVM 相关错误 | CPU 加速不可用 | 检查虚拟化支持,或者改用纯软件模拟方式 |
| 启动后黑屏没有任何输出 | 引导顺序不对或镜像没写对 | 确认-boot a参数和 dd 写入位置 |
| 屏幕上出现乱码 | 显示模式或汇编输出代码有误 | 检查 int 0x10 的调用参数 |
| NASM 报 “instruction expected” | 汇编语法错误 | 逐行查看,特别是标签后面是否少写了冒号 |
| Make 提示 nothing to be done | 目标的依赖文件时间戳问题 | 检查.PHONY声明和文件路径 |
| 镜像写入后文件变大 | 缺少conv=notrunc参数 | 加上参数让 dd 不要截断文件 |
4.2 最隐蔽的坑:镜像文件看起来没问题,但就是引导不起来
这类问题让我印象最深。有一次我编译好的 boot.bin 用xxd检查,512 字节完整,末尾55 aa也在,但 QEMU 启动后屏幕完全空白。我排了半天才发现,问题出在dd写入时把镜像文件搞成了 512 字节,整个软盘镜像变成了只有引导扇区、没有后续空闲扇区。本应存在的 2880 个扇区,只剩了一个,BIOS 读到引导扇区后试图从软盘装载其他内容时直接失败。
解决这个问题的正确姿势是:先创建完整空白镜像,再往里面写入引导扇区,写的时候一定要加conv=notrunc。这个参数的意思是“写入但不截断目标文件”,不加的话,dd 把 boot.bin 的内容写入后,如果 boot.bin 比镜像小,它会把镜像缩短成 boot.bin 的大小。我当时就是先把空白镜像创建好了,但 dd 的时候忘了加参数,导致镜像被“截断”得不成样子。
排查这类问题的通用思路是:不要只看源文件对不对,要看“最终的镜像文件”对不对。用ls -l build/myos.img查看镜像大小,正常应该是 1.44MB 左右,如果只有 512 字节,那不用看 QEMU 了,镜像本身就是坏的。
4.3 引导扇区调试:别只会“黑屏重启”,要学会单步跟踪
普通应用开发调试用打印日志,操作系统内核刚开始连标准输出都没有,日志根本不知道往哪打。更有效率的手段是用调试器单步执行,一步步看 CPU 在执行什么。这里推荐 QEMU 配合 GDB 的方式。
QEMU 支持在暂停模式下启动并等待远程调试器连接:
qemu-system-x86_64 -boot a -drive file=build/myos.img,format=raw -m 32 -S -s-S表示启动时暂停 CPU,-s表示开启一个调试端口。然后另开一个终端,启动 GDB:
gdb在 GDB 里连接这个端口并加载符号/原始二进制:
target remote :1234 file build/boot.bin之后就可以用si单步执行汇编指令,用info registers查看各寄存器值,用x/16xb 0x7c00查看内存字节。这个调试方式在后续所有涉及引导扇区、护模式切换的章节里都非常有用,建议在环境搭建阶段就提前熟悉起来。不要等到体系结构转换失败面对一团乱码时才学它。
4.4 一些实用的小工具技巧
环境搭建过程中,有几个小命令能省很多事。第一个是xxd,我前面提过,查看二进制文件内容极其直观,比如验证55 aa签名、检查汇编器是否把指令正确编码:
xxd build/boot.bin第二个是objdump,它能把二进制文件反汇编成汇编指令,用来确认源码和机器码之间的对应关系。比如我们想看看 boot.bin 里第一条指令是什么:
objdump -D -b binary -m i8086 build/boot.bin第三个是快照功能。虚拟机软件自带快照,意思是把当前系统状态完整保存一份。第一次配置好完整链条后,我建议立刻打一个快照,起名“环境初始可用”。后面一旦改系统的库、装新软件导致环境紊乱,随时可以回滚到这个状态,不需要从头重装系统。我自己就是靠着这个习惯,在后面的每一个阶段实验开始前先打快照,翻车后一键复原,效率提升非常明显。
5. 环境搭好之后的一点个人体会
我第一次把这些东西全部跑到屏幕上出现输出时,其实并没有立刻继续往下看书,而是试着改了引导扇区里的字符串内容,重新编译、写镜像、启动,来回倒腾了几次。这个看起来毫无技术含量的动作,反而让我建立起了操作系统开发的“手感”:改代码、编译、运行、看结果、再改。后来我才发现,这正是整本书学习和真正写一个系统之间的节奏差异。之前读理论书总觉得懂了,却始终没有形成的错觉,就是缺了这一条“反馈闭环”。
环境搭建这件事本身很枯燥,但它是整个项目唯一的“慢就是快”的思路。现在把虚拟机、Linux、汇编器、模拟器、调试器这条链一次走通,后面学习相关的章节时,你只需要面对代码本身,不需要在环境和工具之间来回横跳。如果没有这一步,等你学到内存分帧时,还要回头处理一个 NASM 命令写错的问题,那种切换成本才是真的高。
另外,我建议把项目目录初始化成 Git 仓库,每完成一个阶段就做一次提交,记录“这个阶段编译器版本是什么、代码状态是什么”。操作系统开发会牵扯到大量的二进制约定位问题,版本管理能在你改了一百多行代码后找不到当初能跑的那份源码时救你一命。环境不是只搭一遍就算完,而是根据你学到的新阶段不断调整配置,比如后面写 C 语言内核时要确认-m32参数行为的一致性,这个可以慢慢来。
按我个人的实操经验来说,第一章完结前,你可以把目标定得朴素一点:能够用一条 Makefile 命令完成编译和启动,能够在 GDB 里看到自己的引导扇区正确运行,能够不借助外部帮助理解屏幕上每一种失败的含义。做到这三条,再往第二章翻页,基础就稳了。