9.2 项目搭建与引导选型
在启动一个操作系统内核工程时,合理的目录架构与工程构建组织,是确保系统各层级组件解耦、降低调试复杂度的基础。内核开发属于典型的独立环境(Freestanding Environment)编程,不依赖主机系统的 C 运行时库与动态链接器,因此工程的编译参数、链接脚本以及初始引导方式均需针对底层目标平台量身定制。
在引导阶段,开发者通常面临两条技术路线:一是直接采纳业界标准的引导协议模板,将精力聚焦于内核核心子系统的研发;二是从磁盘扇区引导代码开始从零构建引导器。
1. 基于成熟引导规范的工程模板
现代操作系统开发往往优先基于现成的引导规范(如 Multiboot 或 Limine Boot Protocol)起步。标准引导协议已在硬件加电后完成了实模式到保护模式或长模式的基础跳跃,并收集好系统物理内存映射、帧缓冲区(Framebuffer)基址等关键硬件信息,通过标准化结构体移交给内核入口。
Plos-clan Multiboot i386 模板(multiboot-xmake-template)
该模板面向 32 位 x86 保护模式架构,采用经典的 Multiboot 协议,适用于支持传统 Legacy BIOS(MBR 分区表)引导的计算机或虚拟机环境。32 位架构通常只需构建二级页表即可覆盖 4 GiB 寻址空间,整体心智负担相对可控;不过,在纯 UEFI 固件的现代物理机上,需要开启 Compatibility Support Module(CSM)兼容模块方可运行。Plos-clan Limine x86_64 模板(limine-xmake-template)
该模板面向 64 位 x86_64 长模式架构,采用现代 Limine 引导协议,原生兼容 UEFI 固件与 GPT 分区方案。其天然支持现代物理硬件,能够直接访问 4 GiB 以上的大容量物理内存,并提供高分辨率的图形帧缓冲区。但相应地,64 位环境要求内核必须处理四级甚至五级页表映射,地址规范检查与特权级保护机制也更加复杂。
2. 自主构建引导程序的路径与考量
如果希望完整经历从硬件加电第一条指令开始的自举全过程,也可以选择自主编写引导装载程序。不同固件标准下的实现路径截然不同:
2.1 基于 MBR 的 Legacy BIOS 引导
在传统的 BIOS 架构下,主板加电自检完成后,固件会自动将磁盘 0 柱面 0 磁头 1 扇区的 512 字节主引导记录(MBR)加载到物理内存 0x7C00 处,并将 CPU 控制权转交于此。
由于首扇区除去分区表与魔数后仅剩 446 字节可用空间,自制引导器通常需要设计多级跳转(Two-stage Bootloader):由首阶段汇编代码利用 BIOS INT 13h 中断将次级引导扇区载入内存,再由次级程序开启 A20 地址线、通过 INT 15h (E820) 探测物理内存分布、加载全局描述符表(GDT),最终切换 CR0 寄存器的保护使能位,从 16 位实模式跃迁至 32 位保护模式。早期教材(如《30 天自制操作系统》)大多基于这一路径展开,但由于依赖软盘镜像与过时的 16 位实模式机制,其生成的目标介质与现代计算机固件兼容性相对受限。
2.2 基于 GPT 的 UEFI 固件引导
现代计算机普遍基于统一可扩展固件接口(UEFI)。UEFI 固件本身运行在 64 位长模式下,原生识别 GPT 分区表与 FAT32 文件系统,不再受限于 512 字节的主引导扇区。
自主开发 UEFI 引导程序时,可以通过开源的 TianoCore EDK II 开发套件,或借助轻量级工具链直接编译符合 PE/COFF 格式的 .efi 可执行文件。引导程序可调用 UEFI Boot Services 提供的标准 API 直接读取文件系统、获取精确的物理内存布局图并建立初始图形上下文;在退出引导服务(ExitBootServices)之前将这些信息整理为统一的启动参数块,随后直接跳转到内核映像入口。
3. 习题
1 节给了两个模板,一个面向 32 位 LegacyBIOS,一个面向 64 位 UEFI。先不下载模板,用编译器把「目标位宽」这件事坐实。
写一个 probe.c,只包含 <stdint.h>,定义两个返回 uintptr_t 的函数,分别返回 sizeof(void *) 与 sizeof(long),再加一个空 main。然后:
- 用
zig cc -target x86-freestanding-none -c probe.c -o probe32.o和zig cc -target x86_64-freestanding-none -c probe.c -o probe64.o各编译一次(没有 zig 时用gcc -m32 -c与gcc -c)。 - 用
readelf -h或objdump -f记录两个目标文件的Class与Machine。 - 把
probe.c里的sizeof(void *)结果直接写成一个_Static_assert,再分别对两个目标编译,记录哪一次被断言拦下。
回答:
- 本页 1 节的两个模板分别对应第 1 步里的哪一个目标?写出模板名与目标的对应关系。
- 为什么 64 位系统「编写会很难」不只是多写几行代码?结合 9.1 节第 6 节和本页 2.2 节,至少说出两处结构上的差别。
验收:两个目标文件的 Class/Machine 各一行,格式如 Class: ELF32、Machine: Intel 80386;断言被拦下的那一次贴出完整报错。
建一个最小内核工程,只用 9.1 节第 2 节列出的工具,把「源码 → 32 位 ELF → 可加载二进制」这条链跑通。目录自定,文件与要求如下。
boot.asm(nasm,BITS 32):
- 定义
_start,先cli,把esp指向stack_top,再call kernel_main;返回后原地hlt循环。 - 在
.bss里用resb 16384开一段栈,用ALIGN 16对齐,标出stack_bottom与stack_top。
main.c:
- 定义
void kernel_main(void),向0xB8000处写入'O'与'K'两个字符、属性字节0x0F,然后死循环。
link.ld:
ENTRY(_start),起始地址1M,依次放.text、.rodata、.data、.bss(含COMMON)。
构建命令按顺序是 nasm -f elf32 boot.asm -o boot.o、zig cc -target x86-freestanding-none -O2 -ffreestanding -Wall -Wextra -c main.c -o main.o、zig cc -target x86-freestanding-none -nostdlib -Wl,-T,link.ld -o kernel.elf boot.o main.o,最后 objcopy -O binary kernel.elf kernel.bin。
验收:
readelf -h kernel.elf显示Class: ELF32、Machine: Intel 80386、入口0x100000。nm -u kernel.elf没有任何输出,说明内核不依赖任何外部符号。objcopy生成的kernel.bin小于 4 KiB,size kernel.elf里bss约 16384 字节而不占文件体积,说明原因。- 把
main.c里的死循环换成return;,重新链接,说明为什么栈上压入的返回地址仍然有效(kernel_main是由汇编call进来的)。
提示:本地实测的参考值是 text=50、bss=16384、kernel.bin 50 字节。写完后用验收第 3 条的数字对照,差距过大时先查链接脚本里的起始地址。
9.1 节第 3 节提到,正文里标注的函数注明了它原本定义在哪个源文件,是为了方便读者安排结构。把这条建议变成一次实验。
(1) 写 kmalloc.h,里面直接给出函数定义:
#ifndef KMALLOC_H
#define KMALLOC_H
#include <stdint.h>
uint32_t kmalloc(uint32_t size) {
static uint32_t top = 0x00100000u;
uint32_t addr = top;
top += size;
return addr;
}
#endif2
3
4
5
6
7
8
9
10
11
12
13
再写 a.c(main 里调用两次并打印)与 b.c(导出 next_block,里面调用一次),两者都 #include "kmalloc.h"。用 gcc -std=c17 -Wall -Wextra a.c b.c -o dup.exe 编译,记录完整的链接错误,指出报错里出现的是哪两个目标文件。
(2) 把定义搬进 kmalloc.c,头文件只留声明,再把三个文件一起编译。确认能链接,a.exe 打印两次递增的地址。
(3) 改回「定义在头文件、函数前加 static inline」。这时 a.c 与 b.c 一起编译链接都不报错,但两个源文件各自持有一份 top。运行下面的 main:
uint32_t next_block(void);
int main(void) {
uint32_t p = kmalloc(16);
uint32_t q = kmalloc(16);
uint32_t r = next_block();
printf("a: 0x%X 0x%X
", p, q);
printf("b: 0x%X
", r);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
实测输出是 a: 0x100000 0x100010 与 b: 0x100000。解释 r 为什么回到 0x100000,以及这种写法在操作系统里为什么危险(提示:两个模块各自认为自己在分配物理页框)。
(4) 头文件里的 static 对象与 extern 声明各适合什么场合?用一句话回答,并指出 9.6 节里 extern page_directory_t *current_directory; 属于哪一种。
验收:第 (1) 问的报错包含 multiple definition of 'kmalloc' 并列出两个 .o;第 (3) 问的输出与上面一致,解释落在「每个翻译单元一份对象」上。
2.2 节说「引导器可以获取到内存布局表后传入内核」。把这次交接做完整:写一个 boot_info 结构,一个写出它的加载器,一个读取并校验它的内核,两者用文件模拟内存交接。
#define BOOT_INFO_MAGIC 0x4D445242u
#define MAX_ENTRIES 8u
typedef struct {
uint64_t base;
uint64_t length;
uint32_t type; /* 1=可用, 2=保留, 3=ACPI */
uint32_t reserved;
} mem_entry;
typedef struct {
uint32_t magic;
uint32_t count;
mem_entry entries[MAX_ENTRIES];
} boot_info;2
3
4
5
6
7
8
9
10
11
12
13
14
15
(1) loader.c 写出一份 boot.bin:magic 正确,count 为 3,三项依次是 {0, 0xA0000, 1}、{0xA0000, 0x60000, 2}、{0x100000, 0x3F000000, 1}。kernel.c 读回整个结构,按下面的规则校验,然后打印:
valid=1 usable=1008 MiBusable 是所有 type == 1 项的 length 之和,按 MiB 向下取整。
(2) 校验规则四条:magic 不对拒绝;count > MAX_ENTRIES 拒绝;某项 length == 0 拒绝;某项 base + length 回绕(结果小于 base)拒绝。在 main 里依次构造四种坏数据,输出必须是:
overflow valid=0
zero length valid=0
count too large valid=0
bad magic valid=02
3
4
base + length 的回绕用 base = UINT64_MAX - 16、length = 64 构造。
(3) 说明 base + length 这一条为什么必须用无符号 64 位算、不能先转成 uint32_t。再说明 count 那条为什么必须在读 entries 之前检查,而不是读完再判断。
(4) 结构里留了一个 reserved 字段。说明引导协议里为什么常留这样的字段,以及内核读到非 0 值时应当怎么处理。
验收:第一问一行、第二问四行都逐字一致;loader.c 与 kernel.c 都只用标准 C,boot.bin 的大小等于 sizeof(boot_info)(本机为 200 字节);第 (3) 问的两条都点到具体出错方式(回绕、越界读)。