这周刚把操作系统课的 lecture 20 讲义重新整理完,题目还是那个老问题:用 C 语言和用高级语言(HLL)实现 OS,到底谁更靠谱。这个题目每年都会被学生提出来,而且问的角度越来越刁钻:C 写了这么多年,到底哪里不可替代?Rust、Go 这些高级语言能不能直接拿来写内核?如果真能用 HLL 写 OS,为什么市面上主流的内核还是 C 为主?
这篇内容其实是 lecture 20 讲义的展开版。我会把 C 和 HLL 在操作系统实现这件事上的优势、代价、适用边界一个个拆开讲,同时把我自己做的一组对比实验过程也放进来。适合正在学操作系统、准备做课程设计、或者对内核语言选型有疑问的人看,保证不用去翻一堆论文也能把这个问题理解透。
1. 为什么OS实现语言会成为一门课的核心议题
1.1 先明确术语:C语言和HLL到底指什么
在正式开始比较之前,得先把"HLL"这个说法钉死。教科书里有一种习惯,把汇编以外的所有语言都叫高级语言,按这个定义,C 也是 HLL。但在操作系统课程和内核开发的语境里,我们讨论的 HLL 是狭义的高级语言,指的是那些比 C 拥有更强抽象能力、更复杂的运行时或者更现代的类型系统的语言,典型代表是 Rust、Go、Java、C#,以及更早的 Lisp、Oberon 这类。
这个术语混乱其实反映了真实情况:C 的语言形态很特殊,它站在"高级"和"低级"的分界线上。C 提供了函数、结构体、指针运算,这比汇编不知道高到哪里去了;但 C 又几乎不隐藏硬件细节,没有运行时,没有垃圾回收,没有异常处理,内存模型基本就是物理内存模型的直接映射。所以当你用 C 写内核时,你能感觉到你跟硬件之间几乎没有中间层。而当你用 Rust 或者 Go 写内核时,语言本身自带的抽象设施会先替你挡一层,这个"挡一层"既是保护,也是约束。
lecture 20 里比较的 C 和 HLL,实际上就是拿"C 这种贴近硬件的语言"和"更现代的抽象语言"做对比,看谁更适合去承担操作系统实现这项工作。
1.2 语言选型决定了OS的上限
很多人觉得 OS 的核心是调度算法、内存管理策略、文件系统设计,语言只是实现细节。但做了一段时间内核相关的开发后你会发现,语言选型几乎决定了 OS 的上限。这个"上限"包括四件事:性能上限、安全上限、可维护性上限、可验证性上限。
先说性能。内核里大量代码在访问硬件寄存器、操作物理页表、切换上下文、处理中断,这些操作要求每一项指令都可预测,每一次内存访问都知道去向。如果语言的运行时在背后偷偷做了一些事,比如垃圾回收触发了一次内存移动,或者 JIT 编译在某个时刻暂停了线程,那么内核的性能就是不可预测的。实时性受影响,调度器的行为也变得难以分析。
再说安全上限。操作系统的首要职责是隔离,隔离进程、隔离用户态和内核态、隔离设备和驱动。隔离的关键前提是内核自身不能有内存安全漏洞。C 语言给了开发者完全的内存控制权,也给了完全的内存破坏权,一个野外指针就能让整个系统崩溃。HLL 通过类型系统和所有权机制,把大量的内存错误在编译期挡住,这是一个本质性的差别。
可维护性也很现实。内核代码动辄数十万行甚至上千万行,靠人肉维护 C 代码的边界非常困难,宏定义、函数指针、结构体嵌套,时间一长老代码就变得像盘丝洞。HLL 的模块化、泛型、错误处理机制能让代码的可维护性好一些,但代价是语言复杂度上来了,学习成本上来了。
可验证性是我个人最看重的一点。安全关键系统希望证明内核没有数据竞争、没有空指针解引用,C 太难形式化验证了,微软、剑桥那边跑了几十年,seL4 用 C 和汇编做了完整验证,过程极其痛苦。而 Rust 这类语言天生就能被工具分析,给验证工作省掉大量力气。语言选型这一步走得对不对,后面补回来的成本完全不同。
1.3 lecture20想解决的核心问题是什么
把这一讲从前面的铺垫提炼一下,真正要解决的核心问题有三个:第一,C 在 OS 实现里那些"不可替代"的优势,到底是从哪来的,是技术原因还是历史惯性;第二,HLL 实现 OS 在现实里能做到什么程度,碰到了哪些跨不过去的坎;第三,如果要在 C 和 HLL 之间选,边界应该画在哪里,哪些模块用 C,哪些模块可以换成 HLL。
这三点我不会空谈,会结合真实的 OS 项目、我自己做的对比实验,以及这些年踩过的坑,一项一项展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. C实现OS:那些价值不可替代的优势从哪里来
2.1 对硬件的直接表达能力
C 语言在设计时有一个核心假设:程序员知道自己要操作什么硬件。这一假设在 OS 开发里天然成立。举个例子,你要往串口寄存器写一个字节,在 C 里你只需要把一个指针指向固定的物理地址,然后按位赋值、按位读取,配合内联汇编处理屏障指令,就行了。
c复制#define UART_BASE 0x10000000
#define UART_THR (*(volatile unsigned char *)(UART_BASE + 0x0))
void uart_putc(char c) {
while ((UART_BASE + 0x5) == 0); // 天然忙等
UART_THR = c;
}
这段代码在编译之后几乎就是"读状态寄存器、判断、写数据寄存器"三条指令,没有任何多余动作。这种直接表达能力的关键在于 C 的指针模型和硬件地址空间完美匹配。页表、中断描述符表、设备寄存器这些本质上就是内存里的结构体,C 的结构体布局规则又恰恰和硬件手册上的寄存器排列一一对应。
而 HLL 通常会想办法把指针藏起来,或者对指针操作加一堆约束。Rust 里的裸指针必须包在 unsafe 块里,这是好的安全设计,但也说明语言默认你不该直接碰硬件,得先做一层封装。Go 干脆不让用户随便取任意地址,除非通过 unsafe 包绕过。抽象
