从零编写操作系统 05 - 跳进 C:入口、链接脚本与内存布局
第 04 篇的保护模式入口停在一段 32 位汇编里。本篇从这里继续,把磁盘中的真实内核复制到物理地址 0x100000,准备 BSS 和栈,再调用 kernel_main。
屏幕上的 KERNEL_MAIN OK 有几个前提:已初始化全局变量保持原值,未初始化全局变量为零,普通函数调用能返回,BootInfo 指针和调用栈符合约定。这些条件都在本篇镜像里检查;另一个故障镜像故意跳过 BSS 清零,输出 KERNEL BSS FAIL。
编译器不负责启动内核
GCC 生成指令,链接器安排地址,磁盘读取和运行环境由启动代码提供。-ffreestanding 选择独立环境,不能代替加载器,也不会附带完整标准库。普通应用依赖的启动对象、进程栈和零初始化内存,在这里都需要有明确来源。
第 03、04 篇的 payload 是 20000 字节测试数据。本篇将它替换为 C 和汇编编译得到的 kernel.elf,再经 objcopy -O binary 得到平坦文件。第二阶段仍读取固定 LBA 的镜像头与 payload,不解析 ELF 的 program headers。
1 | |
目的区填 0xa5 是本篇的实验措施。没有这一步,QEMU 恰好提供的初始零值可能掩盖漏清 BSS 的错误。填充范围受镜像头的 memory_size 约束,最大 64 KiB。
链接地址必须与装载地址一致
分页尚未开启,代码段和数据段基址为零。当前内核的链接地址、线性地址、物理地址使用相同数值。链接器把函数放在 0x100000,启动器就必须把相应字节复制到这个位置;只跳到某个地址不会使文件自动出现在内存里。
本篇链接脚本 kernel/linker.ld 如下:
1 | |
.entry 排在 .text 的开头,本次 _start 因而位于 0x100000。objcopy 不负责把 ELF 入口移到第一字节,它按照所复制 section 的装载地址生成原始字节;入口偏移另外写入镜像头。
__bss_start 这样的链接符号表示地址,没有额外分配一个保存地址数值的 C 变量。汇编可以把它作为立即数使用;以后若在 C 里引用,可声明 extern char __bss_start[],直接使用这个地址。
ELF 中的 FLAGS(5) 和 FLAGS(6) 分别标记读执行、读写段。本篇没有分页,这些文件元数据还不是 CPU 施加的内存访问权限,代码区写保护要等到第 11 篇实现。
文件大小与内存大小
本次构建的 binary 长度为 1012 字节,只需读取两个扇区;内存范围却有 17488 字节,包含 BSS、16 KiB 栈和对齐间隙。
| 区域或边界 | 本次构建地址 |
|---|---|
| text | [0x100000, 0x10037e) |
| data | [0x1003f0, 0x1003f4) |
| 文件结束 | 0x1003f4 |
| BSS | [0x100400, 0x100445) |
| 栈 | [0x100450, 0x104450) |
只读数据和部分对齐间隙也在文件中。BSS 与栈在本次产物中不占 binary 字节;NOLOAD 不执行运行时清零,清零动作仍在入口汇编里。
tools/kernel-header.sh 从 ELF 的符号表和入口信息计算头部字段,并核对 binary 长度等于 __file_end - __kernel_start。头部沿用第 03 篇的 v1 格式:
1 | |
启动器检查 64 <= file_size <= memory_size <= 65536,且 entry < file_size。第 03 篇用于识别测试数据的首尾字符串检查已经移除,真实内核使用文件长度和校验和检查;字节和仍只用于发现简单损坏,不提供认证。
BootInfo v1 保持 40 字节,位于 0x5000。其中 payload 地址仍指向低端暂存区 0x10000,长度变为 1012,与高端执行地址含义不同。固定 QEMU 配置的 E820 记录表明 1 MiB 起始处是可用 RAM,当前尚未实现对任意固件地址图自动选择装载位置。
调用 C 之前的栈与 BSS
第二阶段复制完成后,把 BootInfo 地址放入 ESI,跳到 _start。入口代码完整保存在 kernel/entry.asm:
1 | |
cld 清除方向标志,让 rep stosb 按递增地址写入,也满足采用的 i386 SysV 函数调用约定。清零范围是 [__bss_start, __bss_end),不把栈当作零初始化全局对象处理。
栈顶先向下按 16 字节对齐,预留 12 字节,再压入一个 4 字节参数。这样 call 之前 ESP % 16 == 0;call 压入返回地址后,C 函数第一条指令处 ESP % 16 == 12,参数在 [ESP+4]。不能只执行 and esp,-16 就压参调用,否则调用前对齐会被参数打破。
kernel_main 返回后停在入口的 hlt 循环。本篇没有中断处理和调度,返回值暂不使用,也没有可以返回的 BIOS 调用栈。
C 自检与四个内存函数
C 端按第 04 篇字段顺序声明 BootInfo,并用 _Static_assert 检查结构大小与 video 偏移。进入函数后先检查魔数、版本和大小,再执行数据与调用实验:
1 | |
initialized 必须保持 0x12345678,add_one(41) 必须返回 42,zeroed 和 zero_array 必须全部为零。检查通过后才输出成功标识。使用 volatile 便于本篇实验观察实际内存,不能把它理解为并发同步手段。
GCC 对独立环境仍要求提供 memcpy、memmove、memset、memcmp。本篇在 kernel/runtime.c 提供字节循环实现,函数签名采用 size_t;memmove 根据平坦地址的先后选择复制方向,支持重叠范围。C 自检先复制 abcd,向右重叠移动得到 aabcd,再向左移动恢复 abcd,同时检查填零和比较结果。
这些是当前启动所需的内存函数,没有实现完整 libc。构建关闭 PIE、栈保护、SSE/SSE2/MMX,使用 soft-float,并核对最终 ELF 没有未定义符号。这个结果仅说明当前程序不依赖额外支持例程;以后加入 64 位除法等运算,需要重新检查编译器生成的引用。
复现正常启动
下载 本篇完整源码,解压后进入容器:
1 | |
容器内执行 make build、make check-kernel,再执行 make run。工具链镜像的准备方式见第 01 篇。本篇实际屏幕如下:
第一行来自第 04 篇保护模式入口,第二行 KERNEL_MAIN OK 来自 C。make check-kernel 会在 _start 和 kernel_main 第一条指令处分别断住,检查整段 BSS 从 0xa5 变为零,同时比较高端内存中的文件字节与 kernel.bin 完全一致。
1 | |
这里的地址对应本篇固定版本,修改代码后会变化。手工调试时先运行 make debug,在同一容器内另开终端执行 gdb-multiarch build/kernel.elf,设置 target remote 127.0.0.1:1234、hbreak *kernel_main 后继续。断在函数第一条指令,才能按上面的入口栈公式读取参数;进入函数序言之后 ESP 可能已经改变。
故意跳过清零
make check-kernel 还会运行独立的 mbr-badbss.img。它用 SKIP_BSS_CLEAR 重新汇编入口,生成与故障 binary 相匹配的镜像头,正常镜像保持完整。
1 | |
故障版本已经通过读盘、校验和与保护模式切换,失败发生在 C 的零初始化检查。因为目的区被预先写成非零值,漏清零不会因模拟器初始 RAM 内容而偶然通过。
完整 make check 保留前几篇的空盘、坏签名、坏头、截短、校验和、A20、E820 容量及坏 GDT 实验。当前内核只有两个扇区,正常 32 扇区批次只需一次读取;额外的单扇区批次变体实际读取两批,继续验证读盘循环的递进路径。
本篇版本与参考资料
本篇源码提交为 1dc5f44af6932021332cc798d83459a4e3695132,归档目录为 os-day-05/examples/build-an-os,详细运行记录见 docs/day05-evidence.md。实验使用 QEMU 7.2.22、pc-i440fx-7.2、qemu32、TCG、64 MiB RAM 和 SeaBIOS;尚无物理机器兼容性结论。
- GCC 13.2 独立环境说明:启动环境与内存函数要求。
- GNU ld 输出 section 类型及代码引用链接符号:核对 NOLOAD 和地址符号语义。
- Intel386 psABI 1.0 原始规范:第 2.2 节规定函数入口栈对齐与方向标志。
- GNU objcopy 手册:binary 输出的地址起点以及符号信息的丢弃。
练习与导航
- 增加一个未初始化全局数组,重新执行
make check-kernel,观察 BSS 和栈的边界如何变化。 - 删除
memset实现后执行make build,观察链接器的未定义符号错误,恢复实现后重新检查。 - 将链接脚本的栈大小改为 65536,观察装载上限断言是否拒绝该布局。
上一篇:04 - 进入保护模式。下一篇:06 - 输出与故障现场。

