【程序员自我修养】第一章:温故而知新——Hello World 背后的 12 层间接
第一章:温故而知新
你有没有认真想过:
printf("Hello World\n")是怎么变成屏幕上的字符的?答案是:你的代码穿过了 12 层间接。本章会把这 12 层一次性铺开。
1.1 从 Hello World 说起
一个 5 行的程序,背后有多少问号
1 | #include <stdio.h> |
这五行代码,对应了 9 个真实问题(书中开篇的经典清单):
| # | 问题 | 答案所在章节 |
|---|---|---|
| 1 | 编译器把 C 转换成机器码的过程中做了什么? | Ch2 编译 |
| 2 | 编译出来的可执行文件里有什么? | Ch3 目标文件 |
| 3 | #include <stdio.h> 到底包含进来什么?C 语言库又是什么? | Ch11 运行库 |
| 4 | 不同编译器(VC/GCC)+ 不同 CPU(x86/ARM)+ 不同 OS,最终结果一样吗? | Ch3/Ch5 |
| 5 | Hello World 是怎么运行起来的?main 之前发生了什么?之后呢? | Ch6 装载 + Ch11 CRT |
| 6 | 没有操作系统,Hello World 能跑吗? | Ch6 + Ch13 MiniCRT |
| 7 | printf 是怎么实现的?为什么参数数量可变? | Ch11 + Ch12 系统调用 |
| 8 | 运行时它在内存中长什么样? | Ch10 内存 + Ch6 进程 |
| 9 | 打印结束后,进程是被谁回收的? | Ch6 进程生命周期 |
这 9 个问题,就是本书 13 章的目录。 本章作为开篇,先把背景知识铺好。
完整的编译四步
1 | $ gcc hello_world.c -o hello_world # 一句话搞定 |
但**gcc 这一行里实际跑了 4 个工具**:
flowchart LR
A[".c 源文件"]:::sky -->|cpp<br/>预处理| B[".i 展开文本"]:::rose
B -->|cc1<br/>编译| C[".s 汇编"]:::rose
C -->|as<br/>汇编| D[".o 目标文件"]:::mint
D -->|ld<br/>链接| E["a.out 可执行"]:::mint
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#FFB3C6,stroke:#F48FB1,color:#333
style C fill:#FFB3C6,stroke:#F48FB1,color:#333
style D fill:#B5EAD7,stroke:#80CBC4,color:#333
style E fill:#B5EAD7,stroke:#80CBC4,color:#333可以手动验证:
1 | $ gcc -E hello_world.c -o hello_world.i # 1. 预处理 |
💡 细节:
.i文件比.c大 200 倍,是因为<stdio.h>被展开成了一万四千多行宏定义。
预处理:把 # 开头的全部展开
预处理阶段,cpp 工具做了 4 件事:
| 工作 | 示例 |
|---|---|
处理 #include | 把 <stdio.h> 整个文件递归插入 |
处理 #define 宏 | MAX 100 → 所有 MAX 替换为 100 |
处理条件编译 #if/#ifdef | 决定哪些代码块参与编译 |
| 删除注释 | // 和 /* */ 全部干掉 |
1 | #include <stdio.h> |
1 | $ gcc -E macro_demo.c | tail -20 |
预处理后,SQUARE(2.5) 变成 ((2.5) * (2.5)),#ifdef DEBUG 在不定义时整段消失。
编译:从 C 到汇编
cc1(GCC 的 C 编译器)把预处理后的 .i 翻译成目标 CPU 的汇编。这一步最复杂,通常分 5 趟:
graph TB
A["词法分析<br/>Lex"] --> B["语法分析<br/>Yacc"]
B --> C["语义分析<br/>类型检查"]
C --> D["中间代码<br/>RTL/GIMPLE"]
D --> E["目标代码<br/>汇编"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style C fill:#FFDAB9,stroke:#FFAB76,color:#333
style D fill:#FFF9C4,stroke:#F9A825,color:#333
style E fill:#B5EAD7,stroke:#80CBC4,color:#333生成的汇编(x86-64 简化版):
1 | .file "hello_world.c" |
注意 call puts@PLT:这里的 puts@PLT 还不是真实地址,它指向过程链接表(Procedure Linkage Table)。为什么?因为 printf/puts 在动态库 libc.so 里,链接时还不知道加载到哪。
汇编:从汇编到机器码
as 把 .s 翻译成机器码,生成可重定位目标文件 .o:
1 | $ gcc -c hello_world.c -o hello_world.o |
.o 文件里没有绝对地址,所有符号都相对于段内偏移。这就是为什么叫”可重定位”。
链接:把多个 .o 拼成可执行文件
我们的 hello_world.o 只有 1.3KB,但生成的可执行文件有 16KB,多出来的全是 libc.so 里的 puts 实现。
1 | $ gcc hello_world.c -o hello_world |
ld 做了 3 件事:
| 步骤 | 工作 | 输出 |
|---|---|---|
| ① 空间地址分配 | 扫描所有 .o,规划每个段在最终文件中的位置 | 段布局表 |
| ② 符号决议 | 把 printf → libc.so!puts 这种引用补全 | 符号表 |
| ③ 重定位 | 把 .o 里的相对偏移改成最终文件的绝对偏移 | 可执行文件 |
1.2 万变不离其宗
计算机硬件的两条主线
graph TB
subgraph CPU["CPU(大脑)"]
CU["控制单元<br/>CU"]
ALU["算术逻辑单元<br/>ALU"]
REG["寄存器组<br/>8/16 个 64-bit"]
end
subgraph MEM["内存(工作台)"]
RAM["主存<br/>DDR4/DDR5"]
CACHE["多级缓存<br/>L1/L2/L3"]
end
subgraph IO["I/O 设备(四肢)"]
DISK["磁盘/SSD"]
NIC["网卡"]
TTY["终端/GPU"]
end
BUS["系统总线<br/>数据+地址+控制"]
CPU <--> BUS
MEM <--> BUS
IO <--> BUS
style CPU fill:#C7CEEA,stroke:#9FA8DA,color:#333
style MEM fill:#E8D5F5,stroke:#CE93D8,color:#333
style IO fill:#FFDAB9,stroke:#FFAB76,color:#333
style BUS fill:#FFF9C4,stroke:#F9A825,color:#333不管你是用 80386 还是 i9-13900K,CPU、内存、I/O 三大件没变过。变的只是:
- CPU 频率:从 5MHz 到 5.8GHz,提升 1000 倍
- CPU 核心数:从 1 个到 64 个
- 内存容量:从 640KB 到 128GB
- I/O 设备:从软盘到 NVMe SSD + 100G 网卡
总线演进史(一张图看懂)
graph LR
A["1971: 8080<br/>单总线"] --> B["1985: 80386<br/>CPU+内存+I/O 三总线"]
B --> C["2000: Pentium 4<br/>北桥+南桥<br/>FSB/PCI/IDE"]
C --> D["2010: Core i7<br/>QPI/DMI/PCIe<br/>南北桥合并"]
D --> E["2020: Apple M2<br/>统一内存架构<br/>UMA"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style C fill:#FFDAB9,stroke:#FFAB76,color:#333
style D fill:#B5EAD7,stroke:#80CBC4,color:#333
style E fill:#FFB3C6,stroke:#F48FB1,color:#333核心观点:变的是带宽和拓扑,不变的是”CPU 负责算,内存负责存,I/O 负责进“。
SMP 与多核:女人生孩子的比喻
“一个女人花 10 个月生孩子,10 个女人不能在 1 个月生孩子。”
这就是Amdahl 定律的最朴素表达:
1 | 加速比 = 1 / (s + p/n) |
其中 s 是串行部分比例,p 是可并行部分比例,n 是 CPU 核数。
1 | # Amdahl 定律演示 |
结论:CPU 核数再多,串行部分永远是天花板。这就是为什么数据库、操作系统内核都在拼命减少锁竞争。
1.3 站得高,望得远:软件的”千层饼”
层次结构:每个问题都能加一层间接
graph TB
L7["应用层<br/>浏览器/游戏/数据库"]
L6["开发工具层<br/>编译器/链接器/调试器"]
L5["高级语言运行时<br/>JVM/.NET/CPython"]
L4["操作系统用户态<br/>系统调用/Shell/库"]
L3["操作系统内核<br/>进程管理/虚拟内存/文件系统/驱动"]
L2["硬件抽象层<br/>BIOS/UEFI"]
L1["物理硬件<br/>CPU/内存/磁盘/网卡"]
L7 --> L6 --> L5 --> L4 --> L3 --> L2 --> L1
style L7 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style L6 fill:#E8D5F5,stroke:#CE93D8,color:#333
style L5 fill:#FFDAB9,stroke:#FFAB76,color:#333
style L4 fill:#FFF9C4,stroke:#F9A825,color:#333
style L3 fill:#B5EAD7,stroke:#80CBC4,color:#333
style L2 fill:#FFB3C6,stroke:#F48FB1,color:#333
style L1 fill:#F5F5F5,stroke:#9E9E9E,color:#333“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。” —— Butler Lampson / David Wheeler(出处不可考)
本书的 4 大部分就对应这张图的中间 4 层:
| 部分 | 章节 | 对应层 |
|---|---|---|
| 第 1 部分 简介 | Ch1 | 全图概览 |
| 第 2 部分 静态链接 | Ch2-5 | L6 链接器 |
| 第 3 部分 装载与动态链接 | Ch6-9 | L4 用户态 |
| 第 4 部分 库与运行库 | Ch10-13 | L4-L5 运行库 |
1.4 操作系统做什么
3 个核心职责
graph LR
OS["操作系统"] --> A["1. 资源管理<br/>CPU/内存/IO"]
OS --> B["2. 提供抽象<br/>进程/文件/线程"]
OS --> C["3. 隔离保护<br/>用户态/内核态"]
style OS fill:#E8D5F5,stroke:#CE93D8,color:#333
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#B5EAD7,stroke:#80CBC4,color:#333
style C fill:#FFB3C6,stroke:#F48FB1,color:#333CPU 调度的 4 个时代
| 时代 | 调度方式 | 缺点 | 代表 |
|---|---|---|---|
| 1. 监控程序 | 一个程序跑完才换下一个 | 交互卡死 | DOS |
| 2. 多道程序 | 时间片到了切换 | 仍可能死循环霸占 CPU | 早期 Unix |
| 3. 协作式分时 | 程序主动让出 CPU | 不让就卡 | Windows 3.1, Mac OS 9 |
| 4. 抢占式多任务 | 操作系统强制切换 | 无(现代主流) | Linux, Windows NT, macOS |
1 | // 协作式分时系统的"死循环地狱" |
而 Linux 用时钟中断(通常 100Hz 或 1000Hz)强制剥夺 CPU:
sequenceDiagram
participant OS as 操作系统
participant P1 as 进程 A
participant P2 as 进程 B
participant CLK as 时钟中断
OS->>P1: 调度 A 运行
Note over P1: A 在跑...
CLK->>OS: 100Hz tick
OS->>P1: 强制保存上下文
OS->>P2: 恢复 B 上下文
Note over P2: B 开始跑
CLK->>OS: 100Hz tick
OS->>P2: 强制保存
OS->>P1: 恢复 A设备驱动:硬件厂商的”翻译官”
读取文件 /home/user/test.dat 的全流程:
sequenceDiagram
participant App as 用户程序
participant FS as 文件系统 (VFS)
participant Driver as 磁盘驱动
participant HW as 硬盘硬件
App->>FS: read(fd, buf, 4096)
FS->>FS: 查 inode<br/>确定数据在<br/>1000-1007 扇区
FS->>Driver: request: 读扇区 1000-1007
Driver->>HW: out 0x1F2, 0x08<br/>out 0x1F3-0x1F6, LBA<br/>out 0x1F7, 0x20 (READ)
HW-->>Driver: DMA 把数据送到内存
Driver-->>FS: 完成
FS-->>App: 返回 4096 字节核心:应用程序只看到
read()这个干净接口,下面 6 层脏活全是 OS 干的。
1.5 内存不够怎么办
三个原始问题
如果所有程序直接跑在物理内存上:
| 问题 | 描述 | 后果 |
|---|---|---|
| 地址空间不隔离 | 程序 A 误写程序 B 的内存 | 任意崩溃、恶意攻击 |
| 内存使用效率低 | 整段换入换出粒度太大 | 大量磁盘 I/O |
| 地址不确定 | 每次装载位置不一样 | 程序无法硬编码跳转地址 |
方案 1:分段(Segmentation)
按程序为单位切分:
graph TB
subgraph PHYS["物理内存 4GB"]
P1["OS 内核 0-1MB"]
P2["程序 A<br/>100MB-200MB"]
P3["程序 B<br/>300MB-500MB"]
end
subgraph VA["程序 A 的虚拟地址空间"]
VA1["0-100MB"]
end
subgraph VB["程序 B 的虚拟地址空间"]
VB1["0-200MB"]
end
VA1 -->|段表映射| P2
VB1 -->|段表映射| P3
style P1 fill:#FFB3C6,stroke:#F48FB1,color:#333
style P2 fill:#B5EAD7,stroke:#80CBC4,color:#333
style P3 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style VA fill:#FFF9C4,stroke:#F9A825,color:#333
style VB fill:#FFF9C4,stroke:#F9A825,color:#333优点:解决 ① 隔离 ③ 地址不确定
缺点:粒度还是太大,A 只用了 1MB 也要换出整 100MB → 没解决 ②
方案 2:分页(Paging)—— 现代 OS 的基石
把内存切成固定大小的页(4KB 最常见):
graph TB
subgraph VP_A["进程 A 虚拟空间"]
A0["VP0<br/>4KB"]:::sky
A1["VP1<br/>4KB"]:::sky
A2["VP2<br/>4KB"]:::sky
A3["VP3<br/>4KB"]:::sky
end
subgraph PP["物理内存"]
P0["PP5"]:::mint
P1["PP7"]:::mint
P2["PP3"]:::mint
end
subgraph DISK["磁盘(交换区)"]
D0["DP12"]:::rose
end
A0 -.->|MMU 映射| P0
A1 -.->|MMU 映射| P1
A2 -.->|缺页中断| D0
A3 -.->|MMU 映射| P2
style VP_A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style PP fill:#B5EAD7,stroke:#80CBC4,color:#333
style DISK fill:#FFB3C6,stroke:#F48FB1,color:#333关键点:
- 虚拟页
VP可能映射到物理页PP、也可能没映射(在磁盘上) - 没映射时硬件触发缺页中断(Page Fault),OS 把磁盘页换入物理内存
- 粒度小(4KB)→ 换入换出高效 → 充分利用程序局部性
用一个 50 行的 C 程序验证分页
1 | #include <stdio.h> |
1 | $ gcc page_demo.c -o page_demo |
结论:malloc 100MB 不等于占 100MB 物理内存,没访问的页只在虚拟地址空间挂着。这就是”分页”的力量。
1.6 众人拾柴火焰高:线程的诞生
进程 vs 线程
graph TB
subgraph PROC["进程 = 资源单位"]
A["地址空间<br/>代码/数据/堆/栈"]
B["文件描述符表"]
C["信号处理"]
end
subgraph THREAD["线程 = 执行单位"]
T1["线程 1: 栈+PC+寄存器"]
T2["线程 2: 栈+PC+寄存器"]
T3["线程 3: 栈+PC+寄存器"]
end
PROC --> THREAD
style PROC fill:#C7CEEA,stroke:#9FA8DA,color:#333
style THREAD fill:#E8D5F5,stroke:#CE93D8,color:#333
style T1 fill:#B5EAD7,stroke:#80CBC4,color:#333
style T2 fill:#B5EAD7,stroke:#80CBC4,color:#333
style T3 fill:#B5EAD7,stroke:#80CBC4,color:#333| 维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享进程地址空间 |
| 创建开销 | 大(~ms) | 小(~μs) |
| 通信 | IPC(管道/共享内存/信号) | 共享变量(需加锁) |
| 切换开销 | 大(刷 TLB/缓存) | 小(同进程内) |
| 一个崩溃 | 不影响其他进程 | 整个进程死 |
第一个多线程程序
1 | #include <stdio.h> |
1 | $ gcc first_thread.c -pthread -o first_thread |
1.7 本章小结
graph LR
A["Hello World 源码"] -->|cpp| B[".i 展开"]
B -->|cc1| C[".s 汇编"]
C -->|as| D[".o 目标"]
D -->|ld| E["a.out 可执行"]
E -->|execve| F["进程"]
F -->|缺页| G["物理内存"]
F -->|sys_write| H["屏幕"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#FFB3C6,stroke:#F48FB1,color:#333
style C fill:#FFB3C6,stroke:#F48FB1,color:#333
style D fill:#FFB3C6,stroke:#F48FB1,color:#333
style E fill:#E8D5F5,stroke:#CE93D8,color:#333
style F fill:#B5EAD7,stroke:#80CBC4,color:#333
style G fill:#B5EAD7,stroke:#80CBC4,color:#333
style H fill:#FFF9C4,stroke:#F9A825,color:#333这一章只回答了一个问题:Hello World 是个什么东西?
- 是一段5 行 C 源码(程序员视角)
- 是一坨1.3KB 的机器码 + 17KB 预处理文本(编译器视角)
- 是一个16KB 的 ELF 可执行文件(链接器视角)
- 是一段虚拟地址空间里的指令流(进程视角)
- 是一次**
sys_write系统调用**(内核视角) - 是一行tty 子系统输出(设备视角)
这一章的”温故”,是把后面 12 章的舞台搭好。 接下来每一章,都只讲这 12 层中的一层。
实践练习
练习 1:手动跑一遍编译四步
1 | $ cat > a.c <<'EOF' |
练习 2:观察虚拟地址 vs 物理地址
1 | $ cat > vmem.c <<'EOF' |
练习 3:写一个多线程 hello
把上面的 first_thread.c 改成 10 个线程,观察输出交错。
思考题
- 为什么
.i文件比.c大 200 倍?膨胀的内容是什么? - 假设 CPU 频率不再提升,从硬件角度还能怎么让程序跑得更快?
- 协作式分时系统为什么会被抢占式取代?前者有什么不可解决的缺陷?
- 分页机制的”粒度”为什么选 4KB?选 4MB 或 512B 行不行?各有什么问题?
- 进程和线程的本质区别是什么?为什么说”线程是轻量级进程”是片面的?
参考资料
- 俞甲子、石凡、潘爱民. 程序员的自我修养:链接、装载与库. 电子工业出版社, 2009.
- Randal E. Bryant, David O’Hallaron. Computer Systems: A Programmer’s Perspective, 3rd Edition.
- John R. Levine. Linkers and Loaders. Morgan Kaufmann, 1999.
- Intel® 64 and IA-32 Architectures Software Developer’s Manual
- System V ABI
📚 程序员的自我修养 系列导航
本文是《程序员的自我修养》系列第 1/15 篇。
| 方向 | 章节 |
|---|---|
| 上一篇 ◀ | 系列总览 |
| 下一篇 ▶ | 第二章:编译和链接 |