【程序员自我修养】第十一章:运行库——main 之前和之后到底干了什么
第十一章:运行库
一句话结论:你写的
main()并不是程序真正的入口——在它运行之前,内核装载器已经把控制权交给了_start,而_start又会调用__libc_start_main,由它来负责初始化线程局部存储(TLS, Thread Local Storage)、堆、stdio 缓冲、locale,遍历.init_array调用所有 C++ 全局构造函数,最后才把舞台让给你的main()。
前言:你以为 main 是入口,其实它只是”嘉宾”
写 C/C++ 这么多年,你有没有想过这些问题:
- 为什么有时候全局对象的构造函数不在 main 之前运行?
- 为什么
printf的参数个数可以变化?编译器到底做了什么? - 为什么静态链接 glibc 的二进制动辄几 MB,而 Alpine 上的 musl 只有几百 KB?
- 共享库 A 依赖共享库 B,到底谁先初始化?
如果你对这些问题有一点点模糊,这一章就是为你准备的。
读完本章,你将掌握:
| 你将获得 | 对应章节 |
|---|---|
程序真正的启动链路(内核 → _start → __libc_start_main → main) | 11.1 |
| glibc、MSVC CRT、musl 三大运行库的差异与选型 | 11.2 |
| C 语言运行库的实现:堆、stdio、locale | 11.3 |
C++ 全局对象构造的”鸡生蛋”问题与 .init_array | 11.4 |
printf 的变参原理与简化实现 | 11.5 |
fread/fwrite 与 stdio 缓冲策略 | 11.5 |
| 自己动手写一个 mini CRT | 11.6 |
11.1 入口函数和程序初始化
11.1.1 main 之前到底发生了什么?
教科书告诉你:程序从 main 开始。但真相是——main 是被调用的一方,它甚至不知道是谁在调用它。
来看一段最简单的程序:
1 |
|
把它编译成可执行文件后,用 objdump 看一下入口点:
1 | $ gcc demo.c -o demo |
注意:0x4011d0 并不是 main 的地址。用 nm 看:
1 | $ nm demo | grep -E "main|_start|__libc_start_main" |
入口是 _start,而不是 main。
完整的启动链路是这样的:
sequenceDiagram
participant K as 🐧 内核
participant ST as 📍 _start (crt1.o)
participant L as 🏛️ __libc_start_main (libc.so)
participant I as 🔧 .init_array
participant M as 🎯 main
participant F as 🧹 .fini_array
participant EX as 💀 _exit
K->>ST: execve 完成后跳转
ST->>L: 准备好 argc/argv/envp
L->>L: 初始化 TLS / 堆 / stdio / locale
L->>I: 遍历调用所有构造函数
L->>M: 调用 main(argc, argv, envp)
M-->>L: return code
L->>F: 遍历析构函数 (atexit 链)
L->>EX: _exit(status)
EX->>K: 回收进程各阶段的职责可以用一张表说清:
| 阶段 | 谁负责 | 关键动作 |
|---|---|---|
| 0. 内核装载 | 内核 execve | 解析 ELF、建立栈、跳到 _start |
| 1. 启动汇编 | crt1.o 中的 _start | 设置栈指针、清 BSS(Block Started by Symbol,未初始化数据段)、调用 __libc_start_main |
| 2. CRT 初始化 | libc.so 的 __libc_start_main | 初始化 TLS、堆、stdio 缓冲、locale |
| 3. C++ 全局构造 | .init_array 中的函数指针 | 由编译器插入,按顺序调用全局构造函数 |
| 4. 用户代码 | main | 终于到你了! |
| 5. C++ 全局析构 | .fini_array + atexit 链 | 倒序调用 |
| 6. 退出 | _exit(status) | 回到内核 |
11.1.2 _start:那个被所有人忽略的入口
_start 通常是一段极简的汇编,glibc 在 sysdeps/x86_64/start.S 里:
1 | _start: |
它的工作只有三件:
- 把
argc、argv、envp从栈上取出 - 对齐栈指针(System V AMD64 ABI 要求 16 字节对齐)
- 调用
__libc_start_main
它本身不调用任何 C 库函数,因为此刻 C 库还没初始化好。
11.1.3 __libc_start_main 源码剖析
打开 glibc 的 csu/libc-start.c,去掉宏我们能看到核心逻辑:
1 | STATIC int |
可以看到,__libc_start_main 主要做了 5 件大事:
| 步骤 | 作用 | 失败后果 |
|---|---|---|
注册 fini 到 atexit 链 | 让析构函数在 main 返回后被执行 | C++ 全局对象泄漏 |
| 初始化 pthread 自举 | 让 errno 等 TLS 变量可用 | 多线程代码崩溃 |
| 初始化 stdio | 让 stdin/stdout/stderr 可用 | printf 崩溃 |
调用 init 回调 | 这才是 .init_array 真正被遍历的地方 | C++ 构造函数不跑 |
调用 main | 进入用户代码 | — |
注意第 3 步:fini 不是直接被调用,而是先通过 __cxa_atexit 注册。这是为什么析构函数会在 exit() 时按 LIFO(后进先出)顺序被调用的原因。
11.1.4 atexit() 注册退出处理函数
atexit() 让你在程序正常退出时执行某些清理逻辑。它的原理是一个函数指针栈:
1 | /* glibc 简化版实现 */ |
来看一个 atexit 的实际使用:
1 |
|
运行结果:
1 | Hello |
atexit 的特性可以用一张表说清:
| 特性 | 说明 |
|---|---|
| 注册上限 | C 标准规定至少 32 个,glibc 实现也支持 32 个 |
| 调用顺序 | LIFO,后注册先执行 |
| 触发条件 | exit()、main 返回;_exit() 不会触发 |
| 线程安全 | glibc 实现是线程安全的 |
与 .fini_array 的关系 | .fini_array 中的函数被 __cxa_atexit 注册到 atexit 链 |
11.2 C/C++ 运行库
11.2.1 CRT 是什么?
CRT(C Run-Time Library,C 运行库)是程序运行所依赖的一组库的统称。它包含了:
| 组成部分 | 典型实现 | 提供的能力 |
|---|---|---|
| 启动代码 | crt0.o / crt1.o / Scrt1.o | _start、__libc_start_main |
| C 标准库 | libc.so / libc.a | printf、malloc、fopen 等 |
| 数学库 | libm.so | sin、cos、sqrt 等 |
| C++ 标准库 | libstdc++.so / libc++.so | std::vector、std::cout 等 |
| 线程库 | libpthread.so(glibc 已并入 libc) | pthread_create 等 |
可以用一张图来看 CRT 各模块的依赖关系:
graph TB
subgraph "应用层"
A["📱 你的程序"]
end
subgraph "CRT 顶层"
B["🏛️ C 标准库 libc"]
C["📐 数学库 libm"]
D["⚙️ C++ 标准库 libstdc++"]
end
subgraph "CRT 底层"
E["🔌 系统调用封装"]
F["📞 内核 syscall"]
end
A --> B
A --> C
A --> D
B --> E
C --> E
D --> E
E --> F
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:#FFB3C6,stroke:#F48FB1,color:#333
style E fill:#FFF9C4,stroke:#F9A825,color:#333
style F fill:#B5EAD7,stroke:#80CBC4,color:#33311.2.2 glibc vs musl vs MSVC CRT 对比
这是面试常考题,也是工程选型必须搞清楚的:
| 维度 | glibc | musl | MSVC CRT |
|---|---|---|---|
| 平台 | Linux | Linux | Windows |
| 体积 | 数 MB(动态)/ 数十 MB(静态) | 数百 KB(动态)/ 1-2 MB(静态) | 数十 MB |
| 启动速度 | 中等 | 极快 | 较慢 |
| POSIX 兼容 | 完整 + GNU 扩展 | 严格 POSIX | 部分(_MSC_VER) |
| 多线程 | NPTL(Native POSIX Thread Library)成熟 | 简单稳定 | Windows 线程模型 |
| locale | 完整 ICU 级支持 | 简化 | 完整 |
| 性能优化 | 大量(SSE、AVX、NUMA 优化) | 保守 | 大量 |
| 典型发行版 | Debian、Ubuntu、RHEL、CentOS | Alpine、Void | Windows |
| 静态链接 | 不推荐(麻烦的 NSS) | 首选 | 常见 |
| 调试信息 | 完整 | 简洁 | 完整 |
| 维护方 | GNU 社区 | musl 作者 Rich Felker | Microsoft |
| ABI 稳定性 | 稳定(glibc 兼容旧版) | 严格(musl 上编译的不能跑在 glibc) | 与 Windows 版本绑定 |
一个重要的事实:在 Alpine Linux 上静态编译的二进制,不能在 Ubuntu(glibc)上运行——因为 musl 和 glibc 不是二进制兼容的。这就是为什么 alpine:latest 镜像比 ubuntu:latest 小 80 倍。
来看一段测试代码:
1 | /* glibc 扩展:sysconf */ |
11.2.3 三大 CRT 的链接产物对比
| 维度 | glibc 动态 | glibc 静态 | musl 静态 | MSVC |
|---|---|---|---|---|
| 链接命令 | gcc demo.c | gcc -static demo.c | musl-gcc -static demo.c | cl demo.c |
| 二进制大小 | ~17 KB | ~1.4 MB | ~20 KB | ~100 KB |
| 运行时依赖 | libc.so.6、ld-linux.so | 无 | 无 | vcruntime140.dll、ucrtbase.dll |
| 容器友好度 | 中 | 差 | 优 | 不适用 |
11.2.4 入口点在不同 CRT 中的差异
| CRT | 入口符号 | 入口文件 |
|---|---|---|
| glibc | _start | csu/../crt1.o(动态链接用) |
| glibc (静态) | _start | csu/../start.o |
| musl | _start | crt/rcrt1.c |
| MSVC | mainCRTStartup | crt0.c.obj |
| MSVC (DLL) | _DllMainCRTStartup | crtdll.c |
注意:MSVC 的入口点 不是 _start,而是 mainCRTStartup。它会直接调用 main(),并跳过 __libc_start_main 这种中间层。但底层做的事情几乎一样——初始化 CRT、调用 main、清理退出。
11.3 C 语言运行库
11.3.1 CRT 必须提供哪些能力?
| 能力 | 头文件 | 关键函数 |
|---|---|---|
| 字符/字符串处理 | <string.h>、<ctype.h> | strlen、memcpy、isalpha |
| 内存管理 | <stdlib.h> | malloc、free、realloc |
| stdio | <stdio.h> | fopen、fread、printf |
| 数学 | <math.h> | sin、cos、pow |
| 时间 | <time.h> | time、clock、strftime |
| locale | <locale.h> | setlocale |
| 进程环境 | <stdlib.h> | getenv、exit、atexit |
| 错误处理 | <errno.h>、<string.h> | errno、strerror |
11.3.2 堆管理:malloc/free 的实现
CRT 的堆管理是面试高频题。简化版的实现:
1 | /* mini_heap.c - 最简堆分配器 */ |
真正的 malloc(ptmalloc2,glibc 默认实现)要复杂得多,使用 arena + chunk + bin 三级结构:
graph TB
subgraph "Arena 1 (主线程)"
A1["main_arena<br/>top chunk"]
A2["fastbins<br/>(≤128B)"]
A3["smallbins<br/>(≤1KB)"]
A4["largebins<br/>(>1KB)"]
A5["unsorted bin"]
A1 --> A5
A5 --> A2
A5 --> A3
A5 --> A4
end
subgraph "Arena 2 (线程 2)"
B1["thread arena 2"]
end
subgraph "Arena N (线程 N)"
C1["thread arena N"]
end
style A1 fill:#FFB3C6,stroke:#F48FB1,color:#333
style A2 fill:#FFDAB9,stroke:#FFAB76,color:#333
style A3 fill:#FFF9C4,stroke:#F9A825,color:#333
style A4 fill:#B5EAD7,stroke:#80CBC4,color:#333
style A5 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B1 fill:#E8D5F5,stroke:#CE93D8,color:#333
style C1 fill:#E8D5F5,stroke:#CE93D8,color:#333各 bin 的特点:
| Bin 类型 | 大小范围 | 分配速度 | 用途 |
|---|---|---|---|
| fastbin | 16-128 字节(8 字节对齐) | 极快 | 频繁分配小对象 |
| smallbin | < 1KB | 快 | 通用 |
| largebin | ≥ 1KB | 中等 | 大对象 |
| unsorted bin | 任意 | — | 临时存放,分配时优先遍历 |
来看一段堆调优相关的示例:
1 | /* 强制使用 mmap 分配大对象 */ |
11.3.3 stdio 缓冲
stdio 不是直接调用 read/write 系统调用,而是在用户态做了一层缓冲。这一层是性能优化的关键。
1 | /* glibc FILE 结构体关键字段(简化) */ |
三种缓冲模式的对比:
| 模式 | 触发条件 | 刷出时机 | 典型场景 |
|---|---|---|---|
| 全缓冲 | 普通文件 | 缓冲区满 | 日志文件、二进制文件 |
| 行缓冲 | 终端(isatty) | 遇到 \n | stdout(终端) |
| 无缓冲 | stderr | 立即 | 错误日志 |
来看一个经典的”为什么 stderr 要无缓冲”的例子:
1 |
|
运行结果(重定向到文件时):
1 | stderr: world |
stdout 那行没出现! 因为 stdout 是全缓冲,_exit 不刷缓冲。如果把 _exit(0) 改成 exit(0) 或 return 0,就正常了。这是程序员的常见踩坑点。
可以强制切换缓冲模式:
1 |
|
三种缓冲模式的 Mermaid 决策图:
flowchart TD
A["📂 打开文件 / stdout"] --> B{"isatty?<br/>是终端吗?"}
B -->|"是 (终端)"| C["📝 行缓冲<br/>遇到 \\n 刷出"]
B -->|"否 (文件/管道)"| D{"stderr?"}
D -->|"是"| E["⚡ 无缓冲<br/>立即刷出"]
D -->|"否"| F["📦 全缓冲<br/>缓冲区满才刷出"]
C --> G["🔄 用户调用 fflush"]
E --> G
F --> G
G --> H["💾 系统调用 write"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#FFF9C4,stroke:#F9A825,color:#333
style C fill:#B5EAD7,stroke:#80CBC4,color:#333
style D fill:#FFF9C4,stroke:#F9A825,color:#333
style E fill:#FFB3C6,stroke:#F48FB1,color:#333
style F fill:#E8D5F5,stroke:#CE93D8,color:#333
style G fill:#FFDAB9,stroke:#FFAB76,color:#333
style H fill:#B5EAD7,stroke:#80CBC4,color:#33311.3.4 locale:地域化
locale 决定了 printf("%f", 3.14) 显示成 3.14 还是 3,14(欧洲)。CRT 必须支持用户切换 locale:
1 |
|
预期输出(取决于系统是否安装了 locale):
1 | C locale: 3.14 |
glibc 与 musl 在 locale 上的差异:
| 特性 | glibc | musl |
|---|---|---|
| 支持的 locale 数量 | 数百个 | 约 12 个基础 + UTF-8 |
| 实现方式 | gconv + charmap | 内置查表 |
| 性能 | 中等 | 极快(简单查表) |
| 自定义 locale | 支持 | 不支持 |
11.3.5 printf 的实现:变长参数 vs va_list
printf 是 CRT 最复杂的函数之一。它的难点是:参数个数可变。
编译器使用 C 标准规定的 va_list 机制来处理变参:
1 |
|
各宏的作用:
| 宏 | 作用 |
|---|---|
va_list | 声明一个变参迭代器类型 |
va_start(ap, last) | 初始化 ap,指向 last 之后的第一个参数 |
va_arg(ap, type) | 取一个 type 类型的参数,ap 自动后移 |
va_end(ap) | 清理(某些平台上有实际工作) |
变参的 ABI:在 x86_64 上,前 6 个整型参数通过寄存器传递(rdi, rsi, rdx, rcx, r8, r9),之后的通过栈传递。va_start 会同时初始化寄存器区和栈区。
来看一段调试变参的代码:
1 |
|
更完整的简化版 printf(处理更多格式):
1 |
|
11.4 C++ 全局构造和析构
这是本章最 tricky 的部分。C++ 全局对象的构造函数到底什么时候跑?
11.4.1 .init_array 和 .fini_array 段
编译器在编译每个翻译单元时,会把全局对象的构造函数指针塞进 .init_array 段,析构函数指针塞进 .fini_array 段。用 readelf 可以看到:
1 | $ g++ demo.cpp -o demo |
1 | $ objdump -s -j .init_array demo |
这些指针是编译器自动插入的,对每个全局对象:
1 | class Foo { public: Foo(); }; |
__libc_csu_init(在 csu/elf-init.c)会遍历 .init_array:
1 | /* glibc 简化版 */ |
来看一个简单的全局对象构造示例:
1 | /* global_ctor.cpp */ |
运行结果:
1 | Logger constructed at 0x... |
11.4.2 全局构造的”鸡生蛋”问题
问题:编译器负责插入 .init_array,但编译器无法知道所有全局对象的依赖顺序。如果 A 依赖 B,但 B 的构造函数后跑,程序就会 crash。
来看这个典型的”鸡生蛋”案例:
1 | /* chicken_egg.cpp */ |
A 构造时 B 还没构造,访问 b.say() 就 UB(Undefined Behavior,未定义行为)。这就是”鸡生蛋”问题。
来看用 __attribute__((init_priority)) 解决:
1 | /* chicken_egg_solved.cpp */ |
优先级数值越小,越早构造:
| 优先级 | 含义 |
|---|---|
init_priority(101) | 最早构造(库内部保留区间) |
init_priority(200) | 默认区间起点 |
init_priority(65535) | 最晚构造 |
11.4.3 cxxabi:构造与析构的真实调用机制
真正的 C++ 构造函数调用比表面看起来复杂得多。编译器会生成 _GLOBAL__sub_I_xxx 之类的函数:
1 | /* 编译器生成的桩函数 */ |
cxxabi(C++ ABI)还规定了异常处理(EH, Exception Handling)、RTTI(Run-Time Type Information,运行时类型信息)等机制的数据布局。这些都通过 DWARF(一种调试信息格式)/Itanium ABI(Itanium C++ ABI 是事实上的标准)来描述。
__cxa_atexit 才是析构的核心:
1 | /* cxxabi 简化版 */ |
来看一个完整示例展示完整生命周期:
1 | /* lifecycle.cpp */ |
输出:
1 | [ctor] global_t1 |
11.4.4 attribute((constructor)) 优先级
GCC 提供的扩展,允许你在 C 代码中也注册构造函数:
1 |
|
不同优先级的对比:
| 优先级 | 调用时机 |
|---|---|
constructor | 等价于 constructor(65535),最晚 |
constructor(101) | 最早,CRT 内部保留 |
constructor(200) | 早期 |
constructor(500) | 中期 |
constructor(65535) | 最晚 |
11.4.5 共享库之间的构造顺序
这是最难的部分。动态库 A 依赖动态库 B,谁先初始化?
sequenceDiagram
participant L as 🚀 ld.so 动态链接器
participant A as 📦 libA.so
participant B as 📦 libB.so
participant C as 📦 libC.so
L->>B: 装载 B(依赖图最底层)
L->>B: 调用 B 的 .init_array
L->>A: 装载 A
L->>A: 调用 A 的 .init_array
L->>C: 装载 C
L->>C: 调用 C 的 .init_array
L->>main: 调用 main()核心规则:动态链接器按依赖关系的逆拓扑序(先叶子后根)装载和初始化共享库。
来看一个反例:
1 | /* libA.c */ |
如果 main 链接 libA,而 libA 依赖 libB,那么 libB 的 b_init 先跑。
各规则总结:
| 规则 | 说明 |
|---|---|
| 依赖优先 | 被依赖者先初始化 |
| 同一依赖层 | 按装载顺序(不可靠) |
| 显式优先级 | init_priority 数字小的优先 |
| 析构反序 | 先初始化的最后析构 |
| RTLD_LOCAL | 不让外部看到,构造行为可能不同 |
实战技巧:永远不要在共享库的构造函数里访问其他共享库的全局对象,除非用 dlopen 显式控制顺序。
11.5 fread/fwrite 实现剖析
11.5.1 FILE 结构体再深入
glibc 的 FILE 是 _IO_FILE 的别名,内部围绕缓冲区展开:
1 | struct _IO_FILE { |
11.5.2 简化版 fread
1 |
|
各字段含义:
| 字段 | 含义 |
|---|---|
fd | 底层文件描述符 |
buf | 用户态缓冲区 |
pos | 当前读取位置(buf 内的偏移) |
end | 缓冲区内有效数据结束位置 |
11.5.3 简化版 fwrite 与缓冲刷出
1 |
|
11.5.4 性能对比实验
为什么 stdio 比裸 read/write 快?
| 方式 | 系统调用次数 | 用户态拷贝 |
|---|---|---|
裸 read(fd, buf, 1) | 100 万次 | 100 万次 |
fread(buf, 1, 1, fp) | 约 250 次(每 4KB 一次) | 100 万次(缓冲内) |
来看一个测试:
1 | /* bench_io.c */ |
预期结果(Linux, 数字机器上是数量级关系):
1 | raw write : 0.42 s |
fwrite 比裸 write 快约 80 倍,因为系统调用次数从 100 万降到了约 250。
11.5.5 各种 fread 失败场景
1 | /* 错误处理 */ |
ferror 与 feof 的区别:
| 标志 | 含义 | 触发 |
|---|---|---|
ferror(fp) | I/O 错误 | 底层 read 返回 -1 |
feof(fp) | 已到达 EOF | 底层 read 返回 0 |
clearerr(fp) | 清除两个标志 | 错误处理后 |
11.6 实战:写一个 mini CRT
这部分是整章最硬核的实战。我们将一步步写出一个能跑”Hello, World”的最小运行库。
11.6.1 mini_crt.h
1 | /* mini_crt.h - 最小 CRT 接口 */ |
11.6.2 mini_crt_entry.c:入口函数
1 | /* mini_crt_entry.c - 我们的 _start */ |
11.6.3 mini_crt_init.c:底层系统调用
1 | /* mini_crt_init.c - 不依赖 libc 的纯系统调用 */ |
11.6.4 mini_crt_io.c:puts 和 putchar
1 | /* mini_crt_io.c - 简易 stdio */ |
11.6.5 mini_crt_heap.c:堆分配
1 | /* mini_crt_heap.c - 简化版 malloc */ |
11.6.6 mini_crt_atexit.c:atexit 链
1 | /* mini_crt_atexit.c */ |
11.6.7 mini_crt.lds:链接脚本
1 | /* mini_crt.lds */ |
11.6.8 hello.c:用户程序
1 | /* hello.c */ |
11.6.9 编译与运行
1 | # 编译 mini_crt |
预期输出:
1 | Hello, World from mini CRT! |
11.6.10 各文件大小
| 文件 | 行数 | 作用 |
|---|---|---|
mini_crt.h | ~20 | 接口 |
mini_crt_entry.c | ~25 | _start 替代品 |
mini_crt_init.c | ~40 | 系统调用 |
mini_crt_io.c | ~15 | stdio 简化版 |
mini_crt_heap.c | ~35 | 堆分配 |
mini_crt_atexit.c | ~20 | 退出处理 |
mini_crt.lds | ~20 | 链接脚本 |
hello.c | ~12 | 用户程序 |
| 合计 | ~187 | 一个完整的 mini CRT |
这就是运行库的全部秘密。
11.7 全章总结
11.7.1 程序启动的完整时序
flowchart TD
A["🐧 内核 execve"] --> B["📍 _start 汇编"]
B --> C["🏛️ __libc_start_main"]
C --> D["🔧 pthread / TLS 初始化"]
D --> E["📚 stdio 初始化"]
E --> F["🌍 locale 初始化"]
F --> G["🗃️ 堆 arena 初始化"]
G --> H["🔨 遍历 .init_array"]
H --> I["🎯 调用 main"]
I --> J["📤 main 返回"]
J --> K["🔁 倒序调用 atexit 链"]
K --> L["🗑️ 遍历 .fini_array"]
L --> M["💀 _exit 系统调用"]
M --> N["👻 内核回收进程"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style C fill:#E8D5F5,stroke:#CE93D8,color:#333
style D fill:#FFDAB9,stroke:#FFAB76,color:#333
style E fill:#FFDAB9,stroke:#FFAB76,color:#333
style F fill:#FFF9C4,stroke:#F9A825,color:#333
style G fill:#FFF9C4,stroke:#F9A825,color:#333
style H fill:#FFB3C6,stroke:#F48FB1,color:#333
style I fill:#B5EAD7,stroke:#80CBC4,color:#333
style J fill:#B5EAD7,stroke:#80CBC4,color:#333
style K fill:#FFB3C6,stroke:#F48FB1,color:#333
style L fill:#FFB3C6,stroke:#F48FB1,color:#333
style M fill:#E8D5F5,stroke:#CE93D8,color:#333
style N fill:#C7CEEA,stroke:#9FA8DA,color:#33311.7.2 各 CRT 关键路径对照
| 阶段 | glibc | musl | MSVC |
|---|---|---|---|
| 入口 | _start (crt1.o) | _start (rcrt1.c) | mainCRTStartup |
| CRT 初始化 | __libc_start_main | __libc_start_main | mainCRTStartup 内联 |
| 全局构造 | .init_array | .init_array | _initterm |
| 退出 | exit → _exit | exit → _exit | _cexit |
| 体积 | 数 MB | 数百 KB | 数十 MB |
| 启动时间 | ~5 ms | ~0.5 ms | ~20 ms |
11.7.3 五大易踩坑点
| 坑 | 现象 | 原因 | 解决 |
|---|---|---|---|
_exit 不刷新 stdout | 输出丢失 | _exit 不刷缓冲 | 用 exit 或 return |
| 全局构造依赖未定义 | 偶发 crash | 链接顺序未定 | init_priority 或单例 |
| musl 静态二进制无法在 glibc 上跑 | version 'GLIBC_2.XX' not found | ABI 不兼容 | 选对应 libc 编译 |
| 共享库构造函数中访问全局变量 | 段错误 | 对方可能未初始化 | dlopen 显式控制 |
| printf 参数类型不匹配 | 输出乱码 | 变参类型擦除 | 用 %d/%s 等正确格式 |
11.8 思考题与动手建议
思考题
如果把
__libc_start_main改成不调用main,而是直接exit(0),程序会输出什么?
提示:试试看,再思考为什么。glibc 的
atexit链满了怎么办?
提示:读glibc stdlib/exit.c的__run_exit_handlers。printf("%d %s %f", 3.14, "hello")会怎样?
提示:参数类型不匹配会怎样?为什么 musl 静态编译的 Go 二进制能在任何 Linux 上跑?
提示:Go 自己的运行时与 CRT 是什么关系?如果你的
.init_array里调用的构造函数里又 malloc,会发生什么?
提示:堆初始化和.init_array调用顺序。
动手建议
| 难度 | 任务 | 涉及技能 |
|---|---|---|
| 入门 | 用 objdump/readelf 查看一个 Hello World 的 .init_array 内容 | 二进制分析 |
| 入门 | 用 ltrace 追踪 printf 调用的库函数 | 动态追踪 |
| 中级 | 写一个 mini CRT,能跑 mini_puts 和 mini_malloc | 内联汇编、链接脚本 |
| 中级 | 在 mini CRT 中实现 mini_printf,支持 %d %s %x | 变参、字符串转换 |
| 高级 | 给 mini CRT 加 mini_fread/mini_fwrite,并实现全缓冲 | stdio 缓冲 |
| 高级 | 用 LD_DEBUG=all ./program 查看动态库的初始化顺序 | 动态链接器调试 |
| 挑战 | 写一个不依赖任何 libc 的 C++ 程序(带全局对象) | cxxabi、Itanium ABI |
延伸阅读
| 资源 | 说明 |
|---|---|
glibc 源码 csu/libc-start.c | 入口函数实现 |
glibc 源码 stdlib/exit.c | atexit 与 exit 实现 |
glibc 源码 stdio-common/vfprintf.c | printf 内核 |
| Itanium C++ ABI 文档 | C++ ABI 标准 |
man ld.so | 动态链接器手册 |
| Ulrich Drepper《How to Write Shared Libraries》 | 共享库权威指南 |
结尾金句:运行库是连接用户代码与操作系统内核的隐形桥梁。理解它,你就理解了程序从诞生到消亡的完整生命周期——内核装载、CRT 初始化、全局构造、main 执行、退出清理。下次再有人说”程序从 main 开始”,你可以微笑着纠正他:main 只是幕前的演员,真正的导演是
_start和__libc_start_main。
📚 程序员的自我修养 系列导航
本文是《程序员的自我修养》系列第 11/15 篇。
| 方向 | 章节 |
|---|---|
| ◀ 上一篇 | 第十章:内存管理 |
| 下一篇 ▶ | 第十二章:系统调用 |