现代程序员通常把 C 语言 和 Unix 当作两个独立的主题来学习。
C 是一种编程语言。
Unix 是一种操作系统。
但从历史上看,两者之间有着非常深的联系。
它们几乎是一起成长起来的。
Unix 的发展帮助塑造了 C,而 C 又让 Unix 获得了足够强的可移植性,使它能够从贝尔实验室传播到大学、企业,并最终影响整个计算机产业。
几十年过去了,这种关系依然重要。
Linux 并不是 Unix,但它继承了大量 Unix 的设计思想和体系结构。
更重要的是,Linux 内核直到今天仍然主要使用 C 语言编写。
这并不仅仅是因为 Linux 很老。
真正的原因在于,C 仍然能够提供操作系统内核极其需要的能力:
精确控制内存、数据表示、硬件以及机器级行为,同时又不必让程序员用汇编语言编写整个操作系统。
要理解为什么 Linux 至今仍然如此依赖 C,我们需要回到这一切开始的地方。
1. Unix 出现之前:操作系统与硬件紧密绑定
早期计算机与今天的计算机有着巨大的差异。
当时几乎没有统一的标准。
为一台计算机编写的程序,通常无法简单地搬到另一台计算机上运行。
操作系统尤其依赖它所控制的具体硬件。
大量系统软件都是使用 汇编语言 编写的。
汇编语言允许程序员直接操作处理器指令和寄存器。
在控制硬件时,这种能力非常有用。
但是汇编语言有一个非常大的缺点:
它与特定的处理器架构紧密绑定。
例如,为一种指令集编写的程序,并不能自动在另一种指令集上运行。
从概念上看:
|
1 2 3 4 5 6 |
机器 A ↓ 机器 A 的汇编语言 ↓ 操作系统 A |
如果想把这个操作系统移植到机器 B 上,就可能需要重写大量代码。
Unix 正是在这样的环境中诞生的。
2. Unix 诞生于贝尔实验室
Unix 于 20 世纪 60 年代末到 70 年代初在贝尔实验室开发。
早期的重要贡献者包括:
- Ken Thompson
- Dennis Ritchie
Unix 最初并不是用 C 编写的。
事实上,那时候成熟形态的 C 语言甚至还不存在。
早期 Unix 的开发与汇编语言,以及贝尔实验室当时所拥有的计算机硬件紧密相关。
其中一台重要的机器是 PDP-7。
后来 Unix 又被移植到了 PDP-11。
但将一个操作系统从一种计算机架构迁移到另一种架构,很快暴露出了一个重要问题。
如果操作系统主要使用汇编语言编写,那么每支持一种新的处理器架构,都可能意味着需要进行大规模重写。
因此,人们需要一种更好的系统编程语言。
3. 在 C 之前,还有 B
Ken Thompson 曾经开发过一种叫做 B 的编程语言。
而 B 本身又受到更早的 BCPL 语言影响。
其大致的历史关系可以表示为:
|
1 2 3 4 5 6 |
BCPL ↓ B ↓ C |
与汇编语言相比,B 很小巧,也更加方便。
但是 Unix 开始运行的计算机拥有一些 B 无法很好处理的硬件特性。
例如,PDP-11 是一台可以按字节寻址的计算机。
系统程序员需要更方便地处理:
- 字节
- 字符
- 内存地址
- 结构化数据
- 机器字
Dennis Ritchie 开始扩展和重新设计这种语言。
这些改进最终形成了 C 语言。
从这里开始,C 与 Unix 的历史几乎已经无法分开。
4. C 诞生于系统编程环境
C 并不是为了编写商业软件、网站或者图形界面而设计的。
它诞生于一个程序员正在构建操作系统的环境。
这深刻影响了 C 语言本身的设计。
想一想,一个操作系统需要操作哪些东西。
它需要处理:
|
1 2 3 4 5 6 7 8 9 10 |
内存 进程 文件 设备 缓冲区 地址 中断 硬件寄存器 数据结构 |
因此,一种系统编程语言必须能够非常接近机器底层。
但与此同时,它还应该提供足够的抽象能力,让大型软件项目能够被有效管理。
C 正好处在这个非常特殊的中间位置。
它提供函数和结构化控制流程:
|
1 2 3 4 5 6 |
if else for while switch |
它提供结构体:
|
1 2 3 4 5 |
struct process { int pid; int state; }; |
但与此同时,它又通过指针直接暴露内存地址:
|
1 2 |
int *ptr; |
它还允许程序员直接操作单独的二进制位:
|
1 2 |
flags |= 0x04; |
程序员甚至可以直接计算内存位置。
这种组合极其强大。
与编写大量汇编代码相比,C 更容易组织和维护;与此同时,它又足够接近硬件,因此非常适合操作系统开发。
5. Unix 被重写为 C
20 世纪 70 年代初,软件历史上发生了一件极其重要的事情。
Unix 的大部分代码被重新使用 C 语言编写。
在当时,这是一种非常激进的做法。
人们普遍认为,操作系统必须大量使用汇编语言,因为操作系统需要直接控制硬件。
但 Unix 的开发者证明了:
高级语言同样可以用来编写操作系统的大部分代码。
当然,并不是所有代码都能立刻改成 C。
一些与特定处理器架构紧密相关的部分,仍然需要使用汇编语言。
但操作系统中非常大的一部分代码,现在可以变成这样:
|
1 2 3 4 5 6 7 8 9 |
Unix Kernel │ ├── C ├── C ├── C ├── C │ └── 少量汇编 |
这改变了操作系统与计算机硬件之间的关系。
6. C 让 Unix 具有了可移植性
这可能是 C 对 Unix 最大的贡献。
假设一个操作系统拥有十万行与特定处理器架构相关的汇编代码。
如果要把它移植到另一种 CPU 上,就可能需要重写其中的大量代码。
但如果操作系统的大部分代码都是用 C 编写的,情况就完全不同。
从概念上看:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
Unix 源代码 │ │ C ▼ C 编译器 │ ├────────────► 机器 A │ ├────────────► 机器 B │ └────────────► 机器 C |
编译器负责完成 C 程序与不同机器指令集之间的大量转换工作。
当然,移植操作系统仍然需要做很多工作。
硬件初始化、内存管理、中断处理、上下文切换以及其他一些底层部分,仍然可能包含与处理器架构相关的代码。
但是,整个内核已经不需要全部重写。
这让 Unix 比许多早期操作系统拥有更强的可移植性。
而可移植性又帮助 Unix 迅速传播。
7. Unix 也帮助 C 传播
这种影响并不是单向的。
C 帮助 Unix 获得了可移植性。
而 Unix 又帮助 C 成为了一种流行的编程语言。
随着 Unix 进入大学和研究机构,使用 Unix 的程序员自然接触到了 C。
如果你想理解 Unix 的源代码,你需要理解 C。
如果你想修改 Unix,你需要学习 C。
如果你想在 Unix 环境中开发软件,那么 C 自然成为最值得学习的语言之一。
这两种技术不断相互促进:
|
1 2 3 4 5 6 7 8 |
C 让 Unix 可以移植 ↓ Unix 开始传播 ↓ C 也随之传播 ↓ 越来越多 Unix 软件使用 C 编写 |
一个庞大的软件生态系统逐渐围绕 C 和 Unix 建立起来。
8. Unix 的接口本身也充满了 C 的味道
C 与 Unix 的关系,并不仅仅是“Unix 使用 C 编写”这么简单。
许多经典的 Unix 编程接口,本身就非常自然地与 C 语言结合在一起。
例如打开一个文件:
|
1 2 |
int fd = open("data.txt", O_RDONLY); |
读取文件:
|
1 2 |
read(fd, buffer, size); |
关闭文件:
|
1 2 |
close(fd); |
或者创建一个新进程:
|
1 2 |
fork(); |
执行另一个程序:
|
1 2 |
execve(...); |
这些接口后来成为 Unix 编程中最基本的组成部分。
操作系统提供给程序的大量接口,都是在一个以 C 为主导的时代中设计出来的。
这种历史影响直到今天依然清晰可见。
9. Linux 晚了将近二十年才出现
现在把时间向前推进将近二十年。
1991 年,Linus Torvalds 开始开发后来成为 Linux 的内核。
Linux 并不是原来的 Unix 内核。
它是独立开发出来的。
但是 Linux 深受 Unix 模型影响。
Linux 采用了许多熟悉的 Unix 概念,例如:
因此,Linux 属于更广泛的 Unix-like,也就是类 Unix 操作系统家族。
而当 Torvalds 开始编写 Linux 内核时,有一种语言几乎成为了最自然的选择。
C。
10. 为什么 Linux 使用 C 编写?
到了 1991 年,已经存在很多种编程语言。
那么为什么 Linux 仍然选择 C?
因为内核编程有一些非常特殊的要求。
内核处在软件和硬件之间。
从概念上看:
|
1 2 3 4 5 6 7 8 9 10 11 |
应用程序 │ ▼ 系统调用 │ ▼ Linux Kernel │ ▼ 硬件 |
内核必须管理:
|
1 2 3 4 5 6 7 8 9 |
CPU 内存 存储 设备 中断 进程 虚拟内存 网络 |
这些工作需要一种很多高级语言都会刻意隐藏的控制能力。
而 C 会把这些细节暴露给程序员。
11. 内核需要直接控制内存
以内存管理为例。
应用程序开发者可能认为,内存只是通过某个函数申请,或者由语言运行时自动管理的东西。
但是内核不能完全依赖这样的运行时环境。
因为内核本身就在创建和管理程序赖以运行的环境。
它必须直接处理:
- 物理内存
- 虚拟地址
- 页表
- 内存映射
- 缓冲区
- 内核对象
C 的指针非常自然地适合这种工作。
例如:
|
1 2 |
struct task_struct *task; |
一个变量可以保存某个内核数据结构的地址。
内核可以通过指针访问数据、计算偏移量、操作内存,并组织复杂的数据结构,而且几乎没有额外的隐藏机制。
这正是底层系统软件所需要的能力。
12. C 让程序员控制数据的表示方式
硬件经常要求数据具有非常精确的布局。
例如,一个设备寄存器可能使用不同的二进制位表示不同状态:
|
1 2 3 4 5 |
bit 0 → enable bit 1 → interrupt bit 2 → error bit 3 → ready |
C 提供各种位操作:
|
1 2 3 4 5 6 7 |
& | ^ ~ << >> |
因此,内核可以直接操作这些数值。
结构体也可以用来描述复杂的内核对象。
例如:
|
1 2 3 4 5 6 |
struct device { int id; unsigned long flags; void *data; }; |
C 让程序员能够在很大程度上控制信息在内存中的表示方式。
当软件必须直接与硬件通信时,这一点非常重要。
13. C 几乎不需要复杂的运行时环境
Python、Java 和 C# 等语言通常依赖比较庞大的运行时环境。
Python 依赖解释器。
Java 通常依赖虚拟机。
这些运行时系统对于应用程序开发具有巨大的价值。
但操作系统内核面临一个“先有鸡还是先有蛋”的问题。
在应用程序运行时环境能够存在之前,操作系统首先必须已经运行。
因此,内核不能假设它下面已经存在一个庞大的语言运行时环境。
相比之下,C 需要的运行时基础设施非常少。
它与机器之间的关系更接近:
|
1 2 3 4 5 6 7 8 |
C 源代码 ↓ 编译器 ↓ 机器指令 ↓ CPU |
这种简单直接的关系,在软件栈最底层尤其有价值。
14. 但 Linux 并不是纯 C
说 Linux 是用 C 编写的,并不意味着 Linux 内核中的每一条指令都来自 C 源代码。
有些操作天然与特定处理器架构相关。
处理器可能需要特殊指令来完成:
- 启动
- 进入内核模式
- 上下文切换
- 中断处理
- 操作控制寄存器
这些操作有时必须使用汇编语言完成。
因此,从历史上看,Linux 内核大致可以表示为:
|
1 2 3 4 5 6 7 8 |
Linux │ ┌──────┴────────┐ │ │ ▼ ▼ C 汇编 大部分代码 特定底层部分 |
不同 CPU 架构需要不同的底层实现。
Linux 支持的处理器架构包括:
|
1 2 3 4 5 6 |
x86 ARM RISC-V PowerPC 以及其他架构 |
内核中的大部分代码仍然可以共享同一套 C 源代码。
与处理器架构相关的部分,则负责处理不同硬件之间的差异。
本质上,这正是几十年前 C 让 Unix 变得重要的同一种可移植性思想。
15. 编译器就是连接不同处理器的桥梁
假设 Linux 内核中存在这样一段 C 代码:
|
1 2 |
value = a + b; |
在 x86 处理器上,编译器会生成相应的 x86 指令。
在 RISC-V 上,它会生成 RISC-V 指令。
因此,从概念上看:
|
1 2 3 4 5 6 7 8 9 |
Linux C 源代码 │ ▼ 编译器 │ ┌───┼─────┐ ▼ ▼ ▼ x86 ARM RISC-V |
这并不意味着 Linux 内核会自动拥有可移植性。
每一种处理器架构仍然需要专门的内核支持。
但 C 允许大量内核逻辑在不同架构之间共享。
而编译器则负责完成大量指令级别的转换工作。
16. 为什么不使用现代语言完全取代 C?
现代编程语言提供了许多 C 所没有的功能。
一些语言提供:
- 自动内存管理
- 更强的类型系统
- 更安全的内存操作
- 更高级的抽象能力
- 并发功能
- 包管理系统
因此,一个很自然的问题是:
为什么 C 对 Linux 仍然如此重要?
其中一个原因是兼容性。
Linux 内核拥有几十年积累下来的成熟 C 代码。
另一个原因是工具链。
编译器、调试器、静态分析工具、处理器架构支持以及内核开发方法,几十年来都围绕 C 建立起来。
但还有一个更加深层的原因。
C 与操作系统内核的思维模型非常契合。
程序员可以直接思考:
|
1 2 3 4 5 6 7 8 |
地址 数值 结构体 二进制位 寄存器 内存区域 机器指令 |
而不需要在源代码与硬件之间增加一个庞大的抽象层。
C 的抽象程度足够高,可以用来构建数百万行的大型软件。
但它又足够底层,能够把机器本身暴露给程序员。
这种组合非常少见。
17. Linux 也开始使用 Rust
C 已经不再是 Linux 内核中唯一允许使用的语言。
现代 Linux 内核开发已经开始在部分内核组件中引入 Rust。
Rust 很有吸引力,因为它能够在编译成本地机器代码的同时,提供更强的内存安全保证。
这是一个非常重要的发展。
但这并不意味着 Linux 已经突然停止依赖 C。
Linux 庞大的现有内核代码库仍然主要使用 C 编写。
核心内核子系统、处理器架构支持、设备驱动、内存管理、调度、网络以及几十年积累下来的基础设施,都是围绕 C 建立起来的。
未来 Rust 可能越来越多地作为 C 的补充。
但是,如果想深入理解 Linux,你仍然会在几乎所有地方遇到 C。
18. C 是观察操作系统的一扇窗口
这就是为什么学习 C 会改变程序员理解 Linux 的方式。
来看一个简单的 C 指针:
|
1 2 |
int *p; |
刚开始学习时,指针可能只是一个令人觉得麻烦甚至奇怪的语言特性。
但当你开始学习操作系统之后,指针突然变得非常自然。
因为内核一直都在处理地址。
学习结构体:
|
1 2 |
struct task_struct |
你就开始理解内核如何表示一个进程。
学习数组和缓冲区,你会开始理解 I/O。
学习位操作,硬件控制就不再那么神秘。
学习函数调用和栈,你会越来越接近理解系统调用和上下文切换。
因此,在 Unix 世界中,C 并不仅仅是一种普通编程语言。
它已经成为描述操作系统如何工作的一部分“词汇”。
19. 从 C 到 Unix,再到 Linux
现在,我们可以清楚地看到这条历史发展路径:
|
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 30 31 32 |
汇编语言 │ ▼ 早期 Unix │ ▼ B │ ▼ C │ ▼ Unix 大部分代码改用 C 编写 │ ▼ Unix 获得可移植性 │ ▼ Unix 开始传播 │ ▼ C 开始传播 │ ▼ 形成 Unix 编程文化 │ ▼ Linux │ ▼ Linux 内核主要使用 C |
这是计算机历史上最重要的技术关系之一。
C 并不是碰巧在 Unix 出现的同一时期流行起来。
C 语言本身的发展,与构建 Unix 操作系统的实际需求紧密相关。
而 Unix 又给了 C 一个环境,让这种语言能够真正证明自己可以做什么。
Conclusion
C 很老。
Unix 也很老。
但正因为它们历史悠久,人们反而很容易低估它们留下的影响。
C 帮助解决了早期操作系统开发中一个最根本的问题:
怎样才能编写一种足够接近硬件、能够直接控制计算机的软件,同时又避免使用与特定处理器架构绑定的汇编语言来编写整个操作系统所带来的巨大成本?
C 给出了一个非常强大的答案。
它同时提供:
|
1 2 3 4 5 6 7 8 9 10 |
底层内存访问 + 结构化编程 + 高效编译 + 极少的运行时需求 + 面向硬件的数据操作 |
Unix 则证明了这种组合究竟有多强大。
当 Unix 的大部分代码被重新使用 C 编写之后,操作系统的可移植性大幅提高。
Unix 开始传播。
C 也随之传播。
几十年后,Linux 继承了大量 Unix 的设计模式,并延续了操作系统与 C 之间这种基本关系。
这就是为什么 C 直到今天仍然非常重要。
Python 可以把内存隐藏起来。
Java 可以把机器指令隐藏起来。
现代框架甚至可以把操作系统几乎完全隐藏起来。
而 C 所走的方向恰恰相反。
它把地址、内存布局、二进制位、指针以及数据结构直接暴露出来。
而 Linux 恰恰生活在这些细节最重要的软件层级。
因此,深入理解 C,也意味着离操作系统更近一步。
而如果你想真正深入理解 Linux,那么迟早都会遇到 C。
C 与 Unix 一起成长。Linux 继承了这份遗产。而 C 与操作系统之间的关系,直到今天仍然存在于现代计算机内部。
