在普通应用项目里,编译成功后通常可以直接运行可执行文件。自制操作系统还缺少一段工作:谁把文件里的指令放进内存,谁准备栈,CPU 又从哪里开始执行?

本篇先把这些问题拆开验证。第一条实验生成 ELF32/i386 文件,检查源代码、符号、链接地址和机器指令的对应关系。第二条实验把一块全零磁盘接到 QEMU,观察固件启动失败,再用 GDB 停住并单步执行 CPU。

两条实验还没有连接起来。sample.elf 不会被写进空磁盘,也不会由 BIOS 加载。本篇完成后得到的是可用的编译与调试环境;第 02 篇才开始写 BIOS 能执行的启动扇区。

宿主、目标与固件

本次实验的宿主链路是 ARM macOS 上的 Podman Linux 虚拟机,容器内运行 Debian 12 aarch64。交叉编译器本身是一段 ARM Linux 程序,它生成的指令却属于 32 位 x86。QEMU 再通过 TCG 动态翻译运行这个 x86 客体,不要求宿主 CPU 支持直接执行 x86 指令,机制见 QEMU TCG 文档

1
2
3
4
5
6
7
8
ARM macOS
└─ Podman Linux VM / aarch64
└─ Debian 12 容器
├─ i686-elf-gcc + NASM + ld → ELF32 / i386
└─ qemu-system-i386 / TCG
└─ 模拟 PC + SeaBIOS + 全零磁盘

GDB 远程调试

i686-elf 指定的是编译目标。系统自带的 cc 通常面向宿主操作系统;在 macOS 上,即使编译器可以输出某种 x86 目标文件,也不能据此推断它具有本教程需要的裸机链接配置和运行时。系列统一使用 i686-elf-gcci686-elf-ld 和相同前缀的检查工具。

QEMU 的 PC 模型也需要固定。pc 是随版本变化的别名,本篇使用带版本的 pc-i440fx-7.2,指定 qemu32 CPU、单处理器、64 MiB 内存和 TCG。BIOS 文件显式传给 QEMU,避免无意中切换固件。QEMU PC 文档列出了这类模拟 PC 的设备组成。

获取同一份配套代码

本篇从没有配套项目的状态开始,本篇版本名为 os-day-01。完整源码固定在 e1a73a0ea7e650814f40dfe18205d28866401d58,目录是 examples/build-an-os/。这个版本只包含工具链样例,没有 kernel_main、磁盘加载器或用户程序。

下载本篇完整源码,解压后进入实验目录。附件对应上述提交,包含 Dockerfile、样例、检查脚本和环境记录,不依赖远程分支的后续变动。

1
2
unzip os-day-01-source.zip
cd os-day-01/examples/build-an-os

本篇的目录只有当前实验需要的部分:

1
2
3
4
5
6
7
8
9
10
examples/build-an-os/
Dockerfile 工具链构建环境
Makefile build / run / debug / check / help
sample/
demo.c 一个普通 C 函数
entry.asm ELF 的汇编入口
linker.ld ELF32 链接布局
tools/check.sh 正常路径与故障路径检查
docs/ 工具版本与实测记录
build/ 自动生成,不进入版本管理

建立 Linux 编译环境

需要一个已经可用的 Podman。macOS 的 Podman 通过 Linux 虚拟机运行容器;Windows 的安装入口和 Linux 原生安装入口见 Podman 官方安装说明。本批只验证上述 macOS → ARM Linux 路径,不把 Windows、WSL 或其他 Linux 发行版记为已验证环境。

macOS 已安装但尚未启动虚拟机时执行:

1
podman machine start

构建并进入教程镜像:

1
2
podman build -t localhost/build-an-os:day01 .
podman run --rm -it -v "$PWD:/work" -w /work localhost/build-an-os:day01 sh

后文的 make、GDB 和 GNU 工具命令都在这个容器 Shell 中执行。首次构建要下载系统包和 GNU 源码,并编译交叉编译器;可以先预留 20 至 40 分钟;网络较慢时下载源码会占去大部分时间。完成后的镜像可重复运行,退出实验容器不会删除挂载目录里的源码和检查结果。

镜像从源码构建 Binutils 2.42 和 GCC 13.2.0,目标统一为 i686-elf。GCC 只构建 C 编译器,没有生成目标 libc 或完整目标运行库。这个范围足够编译本篇的加一函数;后续内核用到编译器辅助函数时,需要补齐对应支持,不能因本篇链接成功就认为完整 C 运行环境已经具备。

工具版本、系统包版本、源代码压缩包摘要和 BIOS SHA-256 记录在附件的 docs/environment.md。基础镜像摘要和 GNU 源码版本固定;系统软件仓库仍可能变化,make check 会核对关键工具版本和 BIOS 摘要。重建后的包清单仍应对照记录,不能把重新运行 Dockerfile 等同于逐字节重现原镜像。

编译、汇编、链接分别做什么

1
2
3
4
5
sample/demo.c ── GCC ──→ build/demo.o ──┐
├─ ld + linker.ld → build/sample.elf
sample/entry.asm ─ NASM → build/entry.o ┘

全零字节 ── dd ──→ build/blank.img ──→ QEMU / BIOS

demo.c 保留一个足够小、容易找到机器指令的函数:

1
2
3
4
unsigned add_one(unsigned value)
{
return value + 1;
}

编译入口是:

1
make build

Makefile 会用 -std=c11 -ffreestanding -O0 -g 编译 C,并打开常见警告、把警告作为错误。-O0 方便观察调用过程,-g 保留调试信息。-fno-pie-fno-stack-protector 避免这个最小样例依赖尚未准备的位置无关启动约定或栈保护运行时。

-ffreestanding 表示程序运行在独立环境中。它不会自动提供入口、栈、标准库或链接布局;即使没有显式调用,编译器也可能需要 memcpymemset 等支持函数。GCC 对独立环境的说明见 Standards

NASM 将 entry.asmelf32 格式汇编。这里的 bits 32 指定编码方式,不能把正在实模式运行的 CPU 自动切换为保护模式。当前入口只是链接样例;CPU 模式切换会在第 04 篇实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bits 32
section .text
global _start
extern add_one
_start:
mov esp, 0x90000
sub esp, 12
push dword 7
call add_one
add esp, 16
cli
.halt:
hlt
jmp .halt

这段代码展示了一次简单的栈传参调用。先预留 12 字节,再压入 4 字节参数,使 call 前的栈指针保持 16 字节对齐。参数 7 被压栈,call 保存返回地址并转移到 add_one;函数返回后,调用者把栈指针加 16,回收参数与对齐空间。源码还含 GNU 栈属性节;链接命令用 -z noexecstack 声明不需要可执行栈。这个 ELF 属性不会替尚未实现的内核配置页表权限。完整内容以配套文件为准。

入口中的 0x90000 只是样例约定,本篇没有证明这块内存可用,也没有执行到这里。把这段 ELF 直接交给 BIOS 不会使这些前提自动成立。

链接脚本规定产物格式和入口:

1
2
3
4
5
6
7
8
9
10
OUTPUT_FORMAT(elf32-i386)
ENTRY(_start)
SECTIONS
{
. = 0x100000;
.text : { *(.text*) }
.rodata : { *(.rodata*) }
.data : { *(.data*) }
.bss : { *(.bss*) *(COMMON) }
}

. = 0x100000 设置后续布局的地址起点,ENTRY(_start) 把入口符号写入 ELF 头。链接器能据此计算 call 的目标和符号地址,但不会把文件装进模拟机内存。装载器还需要读取文件、准备内存并跳到入口。GNU ld 脚本文档说明了这些布局命令。

检查生成的文件

执行以下命令,分别查看文件头和反汇编:

1
2
i686-elf-readelf -h build/sample.elf
i686-elf-objdump -dr -Mintel build/sample.elf

文件头应显示 ELF32Intel 80386 和入口 0x100000Intel 80386 是 ELF 中的机器类型名称,与编译目标前缀 i686-elf 并不矛盾;本篇关注的是 ELF32/i386 目标格式。

反汇编中应能找到 <_start><add_one>,并看到入口处指向 add_onecall。对象文件阶段还可能需要重定位,链接后该调用的目标已经确定。不要把 ELF 头中的入口地址当成文件偏移;readelf -l 可以继续查看可装载段的文件偏移和虚拟地址。

实际检查摘要与 GDB 结果见本篇后面的实验记录。完整输出保存在 build/elf-header.txtbuild/disassembly.txt,可以对照源码逐条阅读。

故意漏掉一个目标文件

只链接汇编入口,故意不提供 demo.o

1
i686-elf-ld -T sample/linker.ld build/entry.o -o build/missing.elf

预期结果是非零退出,并报告 undefined reference to add_oneextern add_one 允许汇编器先记录一个未解决的引用;最终链接时,这个符号必须有定义。出现这种错误,应该先查参与链接的目标文件和符号,而不是启动 QEMU 查 CPU。

恢复方式是重新执行 make build,让 entry.odemo.o 一起参与链接。make check 会自动运行同一个故障实验,并要求它确实失败。

启动一块没有引导程序的磁盘

build/blank.img 是 1 MiB 的全零文件。Makefile 指定 raw 格式、IDE 接口、硬盘启动,禁用网卡,并将它接到声明的模拟 PC 上:

1
make run

此时 BIOS 找不到有效引导程序是预期行为。这个实验证明 QEMU 能初始化机器、运行固件并尝试从磁盘启动;它不证明 ELF 样例已经执行。

-nographic 将该环境的固件文本输出接到终端。退出 QEMU 的按键是先按 Ctrl-a,再按 x。若终端被其他程序占用,先确认键盘输入送到的是运行 make run 的容器终端。

在第一条固件指令前停住 CPU

调试实验使用同一份空磁盘。make debug 增加 -S-gdb tcp:127.0.0.1:1234:前者使 CPU 等待,后者提供 GDB 接口。两者含义独立,只开调试端口不保证 CPU 从复位点暂停。QEMU GDB 文档给出了这种用法。

在容器里把 QEMU 放到后台,然后启动 GDB:

1
2
make debug &
gdb-multiarch -q

在 GDB 提示符下依次执行:

1
2
3
4
5
6
set architecture i386
target remote 127.0.0.1:1234
info registers eip cs
x/16bx 0xfffffff0
stepi
info registers eip cs

端口只监听容器内部回环地址。GDB 与 QEMU 在同一容器里连接,不需要把 1234 暴露到宿主或网络。

复位现场的 EIP 是段内偏移。x86 复位时还有特殊的 CS 隐藏基址,不能只把可见的 CS 左移 4 位来推算第一条指令地址。因此这里读取 0xfffffff0 的原始字节,不把 0xfff0 当成同一处物理内存,也不把 32 位反汇编设置误当成 CPU 已经进入保护模式。QEMU 7.2 的 x86 复位实现可用于对照这个模拟器中的初始状态。

执行 stepi 后,寄存器发生变化,说明调试器确实控制了客体执行。这个断点位于固件,不在 _start;把 sample.elf 加载为 GDB 符号文件,也不会自动将其装载到客体中。

手动实验结束时,在 GDB 中执行 monitor quit 终止这个 QEMU 进程,再执行 quit 退出 GDB;若询问是否退出活动调试会话,输入 y。远程连接随 QEMU 退出而关闭是正常现象。

本批实验记录

2026-09-05,在上述 ARM macOS → Debian 12 aarch64 环境中构建并运行配套项目。工具版本为 GCC 13.2.0、Binutils 2.42、NASM 2.16.01、QEMU 7.2.22、GDB 13.1,固件为 SeaBIOS 1.16.2。make -B check 退出码为 0,关键输出如下:

1
2
3
4
5
6
Class:                             ELF32
Machine: Intel 80386
Entry point address: 0x100000
RESET_EIP=0xfff0
STEP_EIP=0xe05b
PASS: ELF32/i386 entry, disassembly, missing-symbol rejection, blank disk, QEMU reset and single-step

反汇编中,地址 0x10000acall 指向 0x100016 <add_one>;该函数中的 add eax,0x1 对应 C 源码里的加一操作。这是静态文件检查结果,函数尚未在客体中执行。

手动执行 make run 后,固件输出 Boot failed: not a bootable disk,最终停在 No bootable device.;按 Ctrl-ax 后输出 QEMU: Terminated 并返回 Shell。手动连接 GDB 也观察到上述 EIP 变化。完整输出保存在附件的 docs/day01-evidence.md

自动复查入口:

1
make check

检查会验证 ELF 机器类型和入口、函数反汇编、漏符号时的链接失败、空盘容量与签名位置,以及 GDB 复位现场和单步后的变化。它有启动超时,退出时清理自己启动的 QEMU。手动调试前一次启动的 QEMU 应先退出,否则 1234 端口冲突会使自动检查失败。

这些结果只覆盖第 01 篇的工具链和固件调试。实模式启动代码、内核装载、BSS 清零和 C 入口运行状态,分别在后面的实现篇验证。

练习与导航

  1. add_one 中的 1 改成 2,重新构建,观察反汇编中哪条指令变化。恢复源码后再执行 make check
  2. i686-elf-readelf -l build/sample.elf 找到入口所属段,对比文件偏移与虚拟地址。解释为什么 0x100000 不意味着 ELF 文件前面必须有 1 MiB 的零。
  3. 只去掉调试启动命令里的 -S,保留 GDB 端口,观察连接时是否仍停在复位现场。记录这与正常检查的差异。

上一篇:00 - 从启动扇区到窗口。下一篇:02 - 第一个启动扇区:让 BIOS 执行自己的指令