9.1 MdrOS 项目概述
构建一个操作系统内核是系统编程领域中极具挑战也最能洞察软硬件协同机制的工程实践。这一过程要求开发者对计算机体系结构、处理器运行模式、总线通信以及 C 语言与汇编语言的底层机制拥有扎实且连贯的理解。
本模块以面向 x86 架构的开源教学操作系统 MdrOS 为蓝本,梳理从处理器加电复位、引导加载、内核基础设施搭建,到多任务调度与硬件驱动集成的完整演进脉络。需要说明的是,本篇内容聚焦于关键架构设计决策、硬件交互规范与高频隐蔽陷阱的剖析,提供全局视野与工程参照,而非简单罗列代码拼图。
1. 核心概念与系统边界
从计算机系统的全局视角审视,操作系统的核心职责在于向下接管、抽象并调度底层硬件设施,向上为应用程序提供受控、统一执行环境。
一个完备的内核通常由若干协同运转的子系统有机聚合而成:
- 物理与虚拟内存管理:负责管理物理内存页框分布,构建分层页表以支撑虚拟地址空间的隔离与保护;
- 中断与异常分发体系:构建中断描述符表,建立统一的中断现场保护与服务例程调度机制;
- 设备驱动与总线交互:经由端口 I/O 或内存映射 I/O(MMIO)与可编程外设控制器通信,驱动定时器、键盘及图形显示等部件;
- 抢占式多任务与上下文切换:维护任务控制块(TCB),借助硬件时钟中断触发调度策略,平滑切换处理器上下文;
- 存储介质抽象与文件系统:向上屏蔽块设备的物理寻址差异,提供层次化命名空间与文件组织能力;
- 系统调用与用户态隔离:界定特权级边界,拦截并校验来自用户空间的系统请求,确保特权级转换的安全可控。
1.1 硬件加电至内核接管脉络
现代 x86 计算机从物理触发到内核完全接管控制权,需要经历跨越固件、引导程序与内核自身的精密时序转移:
- 物理供电与加电自检(POST):电源接通后,主板电源管理芯片向 CPU 发出复位信号,处理器内部寄存器置为初始预设状态,执行固件内置的加电自检程序,探测并校验内存与核心外设。
- 固件初始化与寻址:主板固件(Legacy BIOS 或 UEFI)完成基本硬件枚举与运行时环境构建,依据非易失性配置中的启动顺序,在持久化存储介质上定位可引导扇区或 ESP 系统分区。
- 加载次级引导程序(Bootloader):固件将预设存储块载入指定物理内存地址,完成基础完整性校验后,将执行权限递交给引导加载程序。
- 运行环境跨越式配置:引导程序负责探查物理内存布局图(Memory Map)、配置基础显示模式、启用 A20 地址线,并在必要时协助完成由实模式向保护模式或长模式的跃迁,随后自存储设备中读取内核可执行镜像(如 ELF 文件)并重定位至对应内存区间。
- 控制权移交内核:引导程序设置好初始栈与启动参数块,以跳转指令直接跃迁至内核入口符号,处理器控制权由此正式转交操作系统。
内核接管控制权后,将根据既定系统架构逐步解构硬件状态,初始化页表映射、重构特权描述符表,并分阶段激活核心驱动。
2. 工程环境与构建工具链
开发底层内核需要脱离标准 C 运行时库(Hosted Environment),采用支持自举与独立二进制生成的工具链:
- xmake:现代化的跨平台构建系统,通过简洁的声明式配置调度多语言编译过程,支持自动感知与拉取工具链依赖;
- Clang / zig cc 编译器:支持生成无外部运行时绑定的裸机独立目标文件(Freestanding),具备严格的类型检查与现代 C 语言标准支持能力;
- NASM 汇编器:用于编写与处理器底层模式切换、中断上下文压栈及特殊控制寄存器操作直接绑定的微观汇编例程。
3. 源码工程组织建议
在内核开发过程中,为了维持模块高内聚与低耦合,应当严格划分公共接口头文件与具体硬件实现的存放目录。
后文中出现的代码片段,往往从实际工程目录中的特定驱动或体系结构组件中提炼而来。建议在搭建自己的代码树时,预先理清跨平台抽象、硬件相关代码(Arch-specific)以及独立内核库函数的物理存放边界,使通用算法与平台特性解耦,便于长期演进与逻辑复用。
4. 体系结构与硬件规范参考
底层开发高度依赖微体系结构手册与硬件总线协议的精确约定,在涉及具体外设控制器接口细节时,建议常备以下技术资料:
- OSDEV Wiki:由全球系统开发者共同沉淀的内核开发知识库,覆盖了 x86 平台绝大多数经典控制器与引导协议的实现细节;
- Intel 64 and IA-32 Architectures Software Developer's Manual:Intel 官方开发者手册(简称 SDM),是理解保护模式、长模式、页表结构及特权指令权威语义的最终依据;
- 各类开源操作系统内核实现:在遇到复杂状态机或未文档化硬件行为时,研读成熟开源内核的设备驱动与初始化流程能提供重要的验证参考。
5. 教程目录
番外
6. 核心工程准则
在进入具体的代码实现之前,建立清晰的系统级预期与工程权衡具有长远价值:
- 聚焦核心机制的完备性与稳定性:图形界面(GUI)仅仅是人机交互的外在呈现,内核的基石在于对物理硬件资源的严谨管控。优先保证物理与虚拟内存管理、中断响应、进程调度与基础驱动的健壮运转,远比过早铺陈视觉功能更为重要。
- 客观看待早期入门教程的设计折中:部分经典入门读物(例如《30 天自制操作系统》)为了控制学习曲线,在内存保护、分页模型与异常隔离方面省略了诸多机制。在构建符合现代规范的系统时,应当以体系结构官方文档为基准,避免直接沿用简化的过渡方案。
- 充分评估目标运行模式的复杂度差异:在 x86 架构下,32 位保护模式使用经典的二级页表即可覆盖 4 GiB 虚拟地址空间;而在 64 位长模式下,除了需要构建至少四级页表体系外,还必须严格遵循规范地址检查,上下文保存与深层页表维护的复杂度均显著提升。
- 正视不同体系结构间的底层语义分野:x86、ARM 与 RISC-V 在内存一致性模型、异常分级以及特权切换指令上存在根本性差异。在移植或参考其他硬件平台的实现时,必须充分核实其底层硬件假设。
- 以真实物理机运行为系统成熟度检验标准:模拟器(如 QEMU、Bochs)通常为了调试便利与执行效率,对部分非规范硬件行为展现出较高的容忍度(例如隐式清零或宽松的对齐检查)。唯有在真实物理主板上顺利引导并稳定调度的系统,才算真正经受住了硬件多样性与真实时序的考验。
7. 高频陷阱与硬件约束
WARNING
在操作系统内核的开发中,调试手段往往非常受限。此处梳理的注意事项均源于真实硬件与虚拟机调试中反复出现的关键陷阱,提前了解这些约束有助于规避难以排查的暗病。
4 KiB 边界在十进制下对应
4096字节,十六进制表示为0x1000。
7.1 内存管理约束
- 控制寄存器 CR3 对齐:CR3 寄存器用于保存当前顶级页表的物理基地址,其低 12 位保留用作属性配置,CPU 硬件在寻址时会自动屏蔽这些位。因此,装填进 CR3 的页表物理基地址必须严格满足 4 KiB 对齐。
- 物理页框分配基准:物理页框分配器产出的每一个页框起始地址,都必须保证 4 KiB 对齐,方可直接装填到页表项(PTE)的高位地址字段中。
- 栈指针(RSP/ESP)对齐要求:执行函数调用与系统调用时,栈指针通常要求满足 16 字节对齐。部分现代硬件指令(如涉及 SSE/AVX 向量计算的代码)若在未对齐的栈空间访问局部对象,将直接触发通用保护异常(#GP)。
- 引导器预留内存防覆写:在使用 Limine、Multiboot 等现代引导器时,引导程序自身已在物理内存中建立了初始映射并占用了部分页框。实现物理页分配器时,必须解析引导器传递的内存映射图(Memory Map),将内核镜像所在区间、引导器保留区及页表本身彻底标记为已占用,避免重复分配引发静默覆写。
- 页表物理内存连续性:单个页表结构(无论是 32 位的页目录、页表,还是 64 位的四级页表项集合)在物理内存上必须保持连续的 4 KiB 空间,且基址按 4 KiB 对齐。
- TLB 缓存一致性刷新:修改或撤除既有页映射后,必须通过
invlpg指令或重载 CR3 刷新转换后援缓冲器(TLB),否则 CPU 仍会命中旧有的地址转换缓存,导致内存读写落入过时或无效的物理地址。
7.2 设备与中断驱动约束
- MMIO 寄存器交互必须使用物理地址:外设控制器内部通常缺乏系统级 MMU 支持,通过内存映射 I/O(MMIO)向设备下发命令或配置 DMA 缓冲区时,写入寄存器的地址必须是真实的物理地址,传入虚拟地址将导致设备寻址失败或破坏未定义内存。
- 中断结束信号(EOI)的及时应答:处理外部硬件中断请求(IRQ)时,服务例程必须在处理完毕后向中断控制器(PIC 或 APIC)发送中断结束(EOI)指令。若未发 EOI,中断控制器的在服务寄存器将持续处于置位状态,后续该级别及更低级别的硬件中断都会被无限期阻塞。
- PS/2 键盘缓冲区的时序交互:PS/2 键盘控制器在扫描码被软件读取之前,不会产生后续的新中断。如果驱动在中断例程中遗漏了读取数据端口的操作,整个键盘子系统将陷入死锁。
- 中断退出与 EOI 发送顺序:在 PS/2 等高频触发设备的处理中,EOI 的发送时机应当放在数据读取之后,若过早释放 EOI,外设新产生的脉冲可能导致当前中断上下文被不可控地嵌套重入。
7.3 CPU 与特权指令细节
- 段寄存器基址写入的兼容性:在部分虚拟化平台与真机环境中,针对
fs和gs寄存器的wrfsbase与rdgsbase扩展指令可能未被开启或支持不全。采用wrmsr与rdmsr指令直接操作对应的模型特定寄存器(MSR)是更为稳妥的兼容方案。 - 多核心环境下的核心拓扑识别:不同代际的处理器在
cpuid指令的枚举细节上存在差异。在多核(SMP)初始化的逻辑中,推荐直接自 ACPI MADT 静态表或各个核心的 Local APIC ID 获取 CPU 编号,避免依赖cpuid返回的非标准逻辑编号。 - 扩展指令的按核使能:诸如快速系统调用(
syscall/sysret)等高级特性,依赖于特定的 MSR 控制位。在多处理器体系下,这类控制寄存器的使能属于核内局部状态,必须在每个核心的自举例程中分别执行一次。 - 避免在核心循环中滥用低功耗指令:在未进入空闲休眠阶段的业务循环中,随意插入
pause或hlt会导致处理器调频策略发生偏移甚至陷入等待挂起,影响实时响应性能。
7.4 并发同步与调度时钟
- 锁粒度与加锁次序管控:内核中的自旋锁与互斥锁应当严格遵循既定的依赖序,禁止交叉逆序加锁,在持有自旋锁期间必须禁用本地中断,防止中断服务例程重入导致死锁。
- 时钟中断频率与调度开销平衡:可编程定时器(PIT 或 APIC Timer)的中断频率需要合理设定。频率过高会导致上下文切换占据过多 CPU 周期,甚至出现上一次调度例程尚未退出便被下一次时钟中断嵌套打断的情况。
- 异步临界区的全路径防护:内核与中断例程共享的数据结构,必须在访问前关闭中断或获取自旋锁,防止并发执行流引发竞态条件导致结构损坏。
7.5 内核工程与鲁棒性实践
- 启用严格编译器诊断选项:在裸机开发中,排错手段有限且断点难以稳定捕获。开启
-Wall -Wextra -Wconversion并将警告视同错误(-Werror),能把绝大多数类型截断、有符号无符号混用及对象未初始化问题拦截在编译期。相关提问与排错准则也可参阅 编程交流生存指南。 - 对用户态输入践行零信任原则:内核作为特权分界线,绝不能盲目信任来自用户空间的指针或长度。在处理系统调用参数时,必须严格校验指针是否处于用户合法地址范围之内,严防空指针引用或越界跨入内核特权空间。
- 前置防御性断言与自检:在关键内存分配、物理寻址和模式转换接口内部,应积极嵌入编译期断言与运行时检查。一旦出现预设约束被打破,立即通过控制台输出确切上下文,缩短定位系统异常的反馈路径。
8. 习题
8.1 巩固
(1) 按 1.1 节把从按下电源键到内核接管的五个阶段排成一张表。每行写清这一步由固件还是由软件完成,以及它把控制权交给谁。
(2) 在自己的机器上逐条检查 2 节列出的工具,把版本号记下来:xmake --version、nasm -v、gcc --version(没有 gcc 时用 zig version)。没有安装的写「未安装」,并按 2 节的说法注明它由谁装。
(3) 用 5 节的目录回答:页表、文件系统、任务调度分别在哪一节?写出这三节在目录里的序号与标题,并写出对应的链接路径。
验收:五个阶段齐全,每一步的负责人写对;版本号是命令的真实输出;三节序号与标题和 5 节一致。
7.1 节要求页目录基址、页框基址按 4 KiB 对齐,栈按 16 字节对齐。写一个 align.c,实现三个函数:
int is_aligned(uintptr_t addr, size_t align);
uintptr_t align_down(uintptr_t addr, size_t align);
uintptr_t align_up(uintptr_t addr, size_t align);2
3
三个函数都只接受 2 的幂作为 align。在 main 里对 0x100000、0x100001、0x100FFF、0x101000、0x1FFFFF 各打印一行,4K 列用 4096、16B 列用 16,输出应当逐字如下:
0x100000 4K=1 down=0x100000 up=0x100000 16B=1 up16=0x100000
0x100001 4K=0 down=0x100000 up=0x101000 16B=0 up16=0x100010
0x100FFF 4K=0 down=0x100000 up=0x101000 16B=0 up16=0x101000
0x101000 4K=1 down=0x101000 up=0x101000 16B=1 up16=0x101000
0x1FFFFF 4K=0 down=0x1FF000 up=0x200000 16B=0 up16=0x2000002
3
4
5
然后用这套函数回答两个问题:
align_up为什么不能只写成addr & ~(align - 1)?用0x100001的结果说明少了哪一步。0x1FFFFF距下一个 4 KiB 边界还差几个字节?如果把它当作页框基址直接使用,会覆盖哪个地址范围?
验收:五行输出与上面一致;两个回答都用到 0x1FFFFF 或 0x100001 的实际数字。
新建 consts.c,写入下面四个常量与三条断言:
#include <stdint.h>
#define KERNEL_BASE 0x00100000u
#define STACK_SIZE 16383u
#define PAGE_SIZE 4096u
#define VGA_BASE 0xB8000u
_Static_assert(KERNEL_BASE % PAGE_SIZE == 0, "kernel base must be 4 KiB aligned");
_Static_assert(STACK_SIZE % 16 == 0, "stack size must be a multiple of 16");
_Static_assert(VGA_BASE == 0xB8000u, "VGA text buffer address changed");
int main(void) {
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
(1) 原样编译,记录报错。报错里会出现 static assertion failed: "stack size must be a multiple of 16",行号随文件内容变化。把 STACK_SIZE 改成 16384,确认编译通过。
(2) 再加一条断言,确认当前编译目标是 32 位:
_Static_assert(sizeof(void *) == 4, "kernel must be built for i386");先用默认选项编译,再用 gcc -m32 -c consts.c 编译,说明哪一次失败、这条断言防住的是哪一类错误。没有 32 位目标的机器(例如未装 multilib 的 Linux)可以只记录默认目标下的失败信息。
(3) 这三条检查为什么放在编译期,而不是写成启动时执行的 if 加打印?
验收:两次编译的命令与结果都贴出,-m32 那次没有任何输出;第 (3) 问指出编译期失败时还没有生成可加载的映像。
8.2 编程
写一个物理页框分配器 frame.c,按 7.1 节的要求保证分配出的页框基址都是 4 KiB 对齐。接口如下:
void fb_init(uint32_t bytes); /* 按字节数计算页框数,清空位图 */
int fb_alloc(void); /* 返回编号最小的空闲页框号,没有则返回 -1 */
void fb_free(uint32_t frame); /* 释放一个页框 */
uint32_t fb_used(void); /* 已占用的页框数 */2
3
4
位图用 uint8_t bitmap[512],第 n 个页框对应第 n/8 字节的第 n%8 位,置 1 表示占用。main 依次做四件事,输出与下面一致:
allocated=1000 last=999
after free=500
reuse=1 used=5012
3
步骤是:以 16 MiB 初始化;连续分配 1000 次;释放编号为奇数的页框(1、3、…、999);再分配一次。每次分配后检查 frame * 4096 能整除 4096,不满足就立刻退出。
再做三件事:
- 到 4 节给的 OSDEV Wiki 里找 Page Frame Allocation 一页,写出你的实现与它的位图实现不同的一点,并说明在什么情况下你的写法更慢。
- 把
fb_init的字节数改为0,说明fb_alloc应当返回什么,你的代码是否做到了。这一条不要求修改代码,只记录实际行为。 - 按 3 节的建议给这个分配器安排位置:画出你准备用的目录树,标出
frame.c、它的头文件、测试用的main.c各放哪,并说明 9.6 节的memory.c日后要复用它时,需要改哪几个文件的包含路径。
验收:三行输出逐字一致;1000 次分配中每个页框号都不重复;第 2 问按实际运行结果回答,不按猜测回答;第 3 问的目录树与 9.2 节的构建方式相容(源文件能被一条编译命令找到)。
7.2 节列了两条设备侧的规定:中断处理结束要发 EOI,PS/2 键盘要取走扫描码才会发下一次中断。用一个主机上的小模型验证这两条各自独立。按下述语义实现 irq.c:
pic有bool pending[16]、int in_service、int delivered,初始化时in_service = -1。pic_raise(irq)置pending[irq]。pic_ack():若in_service >= 0返回 -1;否则取编号最小的 pending 项,清掉它,记入in_service,delivered加 1,返回该编号。pic_eoi(irq):若in_service == irq,把它置回 -1。- 键盘有
int queued、bool data_ready。key_press()先queued++;若data_ready为假,置真并向 1 号 IRQ 发请求。 key_read():若data_ready为真,queued--并置假。serve(read_data, send_eoi):先ack;若拿到 1 号 IRQ,按两个布尔参数决定是否key_read和pic_eoi。run(name, read_data, send_eoi):按三次「按键、处理」循环,最后打印delivered与queued。
四组参数依次为 (true, true)、(true, false)、(false, true)、(false, false),输出应当如下:
read+eoi delivered=3 queued=0
read delivered=1 queued=2
eoi delivered=1 queued=3
neither delivered=1 queued=32
3
4
逐行解释这四个结果。要求分别指出:哪一组被「没发 EOI」卡住,哪一组被「没读扫描码」卡住,为什么 read 与 neither 的 delivered 相同而 queued 不同。最后回答:现实中的 PS/2 控制器在数据未取走时,中断线上还会不会继续拉高?
验收:四行输出一致;解释里把 PIC 的 in_service 和键盘的 data_ready 当成两个独立条件,而不是笼统地说「中断没处理完」。
7.5 节建议编译器开严格模式。把下面的 strict.c 用 gcc -std=c17 -Wall -Wextra -Wconversion -Werror -c strict.c 编译,恰好得到三条错误:
#include <stdint.h>
static uint32_t page_directory[1024];
uint16_t low_flags(uint32_t entry) {
return entry;
}
int sum(const uint16_t *p, int n) {
int s = 0;
for (unsigned i = 0; i < n; i++) {
s += p[i];
}
return s;
}
void map_page(uint32_t *table, unsigned index, uint32_t frame, int user,
int flags) {
table[index] = (frame & 0xFFFFF000u) | 0x3u;
if (user) {
table[index] |= 0x4u;
}
}
int main(void) {
uint16_t v[3] = {1, 2, 3};
map_page(page_directory, 3, 0x12345000u, 1, 0);
return (low_flags(page_directory[3]) == 0x5007u && sum(v, 3) == 6) ? 0 : 1;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
error: conversion from 'uint32_t' {aka 'unsigned int'} to 'uint16_t' {aka 'short unsigned int'} may change value [-Werror=conversion]
error: comparison of integer expressions of different signedness: 'unsigned int' and 'int' [-Werror=sign-compare]
error: unused parameter 'flags' [-Werror=unused-parameter]2
3
(1) 三条都改掉,要求这条命令无输出,且 low_flags(0x5007u) 仍返回 0x5007、sum 对 {1,2,3} 仍返回 6。low_flags 的形参类型不许改。
(2) 第三条有两种改法:删掉 flags,或用 (void)flags;。说明哪一种更适合这里,以及如果将来调用方真要传标志位,接口应该怎么改。
(3) 去掉 -Werror 只保留 -Wall -Wextra 编译:原样跑一次,再在 main 里把 page_directory 的引用临时删掉跑一次。对比两次的警告条数,说明为什么「对象未被使用」会在后一次里冒出来,而严格模式下的报错条数又为什么与这一次不同。
验收:-Wconversion -Werror 下无输出、退出码 0;第 (3) 问给出两次的条数并解释差异来源。
8.3 综合与挑战
(1) [5.4] 写一个带 TLB 的分页模拟器 tlb.c。页表 1024 项,TLB 8 项,采用轮转替换;translate 命中 TLB 时直接返回,未命中则查页表,页表项为 0 表示未映射。按顺序执行并打印:
first VA 0x00300123 -> PA 0x00100123
stale VA 0x00300123 -> PA 0x00100123
flushed VA 0x00300123 -> unmapped
remapped VA 0x00300123 -> PA 0x00200123
hits=1 misses=32
3
4
5
四步依次是:把虚拟页 0x00300000 映射到物理页 0x00100000 并访问;解除映射后不刷新 TLB 再访问;执行刷新后再访问;重新映射到 0x00200000 再访问。结合 7.1 节最后一条,说明第二步为什么仍然返回旧地址,以及真实 CPU 上不刷新会看到什么。
(2) [4.3] 写一个页表开销计算器,按 32 位二级分页计算映射给定物理内存需要的页表页数:页目录 1 页,每 1024 个页表项占 1 页,向上取整。对 1、16、64、128、4096 MiB 各打印一行:
1 MiB: 32-bit 2 tables 8192 bytes
16 MiB: 32-bit 5 tables 20480 bytes
64 MiB: 32-bit 17 tables 69632 bytes
128 MiB: 32-bit 33 tables 135168 bytes
4096 MiB: 32-bit 1025 tables 4198400 bytes2
3
4
5
再用同样方法算 64 位四级分页(PML4、PDPT、PD、PT,每级 512 项)在 64 MiB 下的页数,输出 64 MiB: 64-bit 35 tables 143360 bytes (4 levels)。对照 6 节第四条,说明为什么多一级页表会让 64 位系统更难写,而不只是多占几页内存。
(3) [5.4] 7.5 节要求内核严格检查用户态传来的指针。实现并测试 range_ok:
#define USER_START 0xB0000000u
#define USER_END 0xB0F00000u
int range_ok(uintptr_t src, size_t len);2
3
4
规则:src 为 0 拒绝;src 不在 [USER_START, USER_END) 内拒绝;len 超过 USER_END - src 拒绝。对下列七组输入打印 accepted 或 rejected:
null rejected
below rejected
last byte accepted
cross end rejected
far above rejected
huge len rejected
wrap rejected2
3
4
5
6
7
七组依次是 (0,4)、(USER_START-1,4)、(USER_END-1,1)、(USER_END-2,4)、(0xFFFFFFFF,1)、(USER_START,(size_t)-1)、(USER_START,(size_t)-USER_START+8)。说明哪一条检查挡住了最后两组,以及如果先做 src + len 再比较上界会发生什么。
验收:三段输出与上面一致;第 (2) 问的 64 位结果也要算对;第 (3) 问指出先加后比会因无符号回绕而放行越界区间。