第 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
2
3
4
5
6
7
8
9
10
11
12
13
14
host build
C + entry.asm -> kernel.elf -> kernel.bin + header.bin

real mode
read and validate header -> read payload at 0x10000
verify checksum -> collect E820 -> enable A20

protected mode
fill destination with 0xa5 -> copy file bytes to 0x100000
jump to _start with ESI = 0x5000

_start
clear DF -> clear BSS -> establish stack
push BootInfo pointer -> call kernel_main

目的区填 0xa5 是本篇的实验措施。没有这一步,QEMU 恰好提供的初始零值可能掩盖漏清 BSS 的错误。填充范围受镜像头的 memory_size 约束,最大 64 KiB。

链接地址必须与装载地址一致

分页尚未开启,代码段和数据段基址为零。当前内核的链接地址、线性地址、物理地址使用相同数值。链接器把函数放在 0x100000,启动器就必须把相应字节复制到这个位置;只跳到某个地址不会使文件自动出现在内存里。

本篇链接脚本 kernel/linker.ld 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
OUTPUT_FORMAT(elf32-i386)
ENTRY(_start)
PHDRS { code PT_LOAD FLAGS(5); data PT_LOAD FLAGS(6); }
SECTIONS {
. = 0x100000;
__kernel_start = .;
.text : { __text_start = .; *(.entry) *(.text*) __text_end = .; } :code
.rodata : { *(.rodata*) } :code
. = ALIGN(16);
.data : { __data_start = .; *(.data*) __data_end = .; } :data
__file_end = .;
.bss (NOLOAD) : { __bss_start = .; *(.bss*) *(COMMON) __bss_end = .; } :data
.stack (NOLOAD) : { . = ALIGN(16); __stack_bottom = .; . += 16384; __stack_top = .; } :data
__kernel_end = .;
/DISCARD/ : { *(.comment) *(.note*) *(.eh_frame*) }
ASSERT(__kernel_end - __kernel_start <= 65536, "kernel memory exceeds loader bound")
}

.entry 排在 .text 的开头,本次 _start 因而位于 0x100000objcopy 不负责把 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
2
3
4
5
6
magic       = 0x334f5342
version = 1
file_size = 1012
memory_size = 17488
entry = 0 # 相对 0x100000 的偏移
checksum = sum(kernel.bin bytes) mod 65536

启动器检查 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bits 32
section .entry
global _start
extern kernel_main, __bss_start, __bss_end, __stack_top
_start:
cld
mov ebx, esi
%ifndef SKIP_BSS_CLEAR
mov edi, __bss_start
mov ecx, __bss_end
sub ecx, edi
xor eax, eax
rep stosb
%endif
mov esp, __stack_top
and esp, -16
sub esp, 12
push ebx
call kernel_main
.halt:
cli
hlt
jmp .halt

cld 清除方向标志,让 rep stosb 按递增地址写入,也满足采用的 i386 SysV 函数调用约定。清零范围是 [__bss_start, __bss_end),不把栈当作零初始化全局对象处理。

栈顶先向下按 16 字节对齐,预留 12 字节,再压入一个 4 字节参数。这样 call 之前 ESP % 16 == 0call 压入返回地址后,C 函数第一条指令处 ESP % 16 == 12,参数在 [ESP+4]。不能只执行 and esp,-16 就压参调用,否则调用前对齐会被参数打破。

kernel_main 返回后停在入口的 hlt 循环。本篇没有中断处理和调度,返回值暂不使用,也没有可以返回的 BIOS 调用栈。

C 自检与四个内存函数

C 端按第 04 篇字段顺序声明 BootInfo,并用 _Static_assert 检查结构大小与 video 偏移。进入函数后先检查魔数、版本和大小,再执行数据与调用实验:

1
2
3
4
5
volatile uint32_t initialized = 0x12345678;
volatile uint32_t zeroed;
volatile uint8_t zero_array[37];

static uint32_t add_one(uint32_t value) { return value + 1; }

initialized 必须保持 0x12345678add_one(41) 必须返回 42,zeroedzero_array 必须全部为零。检查通过后才输出成功标识。使用 volatile 便于本篇实验观察实际内存,不能把它理解为并发同步手段。

GCC 对独立环境仍要求提供 memcpymemmovememsetmemcmp。本篇在 kernel/runtime.c 提供字节循环实现,函数签名采用 size_tmemmove 根据平坦地址的先后选择复制方向,支持重叠范围。C 自检先复制 abcd,向右重叠移动得到 aabcd,再向左移动恢复 abcd,同时检查填零和比较结果。

这些是当前启动所需的内存函数,没有实现完整 libc。构建关闭 PIE、栈保护、SSE/SSE2/MMX,使用 soft-float,并核对最终 ELF 没有未定义符号。这个结果仅说明当前程序不依赖额外支持例程;以后加入 64 位除法等运算,需要重新检查编译器生成的引用。

复现正常启动

下载 本篇完整源码,解压后进入容器:

1
2
3
unzip os-day-05-source.zip
cd os-day-05/examples/build-an-os
podman run --rm -it -v "$PWD:/work" -w /work localhost/build-an-os:day01 sh

容器内执行 make buildmake check-kernel,再执行 make run。工具链镜像的准备方式见第 01 篇。本篇实际屏幕如下:

第05篇实际运行屏幕:C内核启动

第一行来自第 04 篇保护模式入口,第二行 KERNEL_MAIN OK 来自 C。make check-kernel 会在 _startkernel_main 第一条指令处分别断住,检查整段 BSS 从 0xa5 变为零,同时比较高端内存中的文件字节与 kernel.bin 完全一致。

1
2
3
DIRTY_BSS_PROVEN
KERNEL_ENTRY=0x10009c ESP=0x10443c ARG=0x5000 DATA=0x12345678 BSS=0
PASS: ELF/header/copy, dirty then cleared BSS, cdecl argument, aligned call, data and no paging

这里的地址对应本篇固定版本,修改代码后会变化。手工调试时先运行 make debug,在同一容器内另开终端执行 gdb-multiarch build/kernel.elf,设置 target remote 127.0.0.1:1234hbreak *kernel_main 后继续。断在函数第一条指令,才能按上面的入口栈公式读取参数;进入函数序言之后 ESP 可能已经改变。

故意跳过清零

make check-kernel 还会运行独立的 mbr-badbss.img。它用 SKIP_BSS_CLEAR 重新汇编入口,生成与故障 binary 相匹配的镜像头,正常镜像保持完整。

1
2
3
4
D03 STAGE2 OK
D03 PAYLOAD OK
D04 PM OK - BootInfo v1
KERNEL BSS FAIL

故障版本已经通过读盘、校验和与保护模式切换,失败发生在 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;尚无物理机器兼容性结论。

练习与导航

  1. 增加一个未初始化全局数组,重新执行 make check-kernel,观察 BSS 和栈的边界如何变化。
  2. 删除 memset 实现后执行 make build,观察链接器的未定义符号错误,恢复实现后重新检查。
  3. 将链接脚本的栈大小改为 65536,观察装载上限断言是否拒绝该布局。

上一篇:04 - 进入保护模式。下一篇:06 - 输出与故障现场