【程序员自我修养】第十七章:运行库深挖——TLS、全局对象构造析构与 stdio 缓冲
第十七章:运行库深挖——TLS、全局对象构造析构与 stdio 缓冲
一句话结论:运行库的三个深坑,都藏在你以为”理所当然”的细节里——
__thread变量的访问是 两次内存引用(取 TCB + 偏移),全局对象的析构顺序是 构造的严格反序(靠__cxa_atexit的 LIFO 栈保证),printf("...")不会立刻输出是因为 stdio 替你做了缓冲(碰到fork会导致重复输出)。这三件事看似独立,实则被 glibc 启动流程串成了一条完整的调用链。
0. 前言:为什么把三个主题合一篇?
第十章「运行库」在 5 万字内塞进了:入口函数、堆、locale、stdio、变参、TLS、全局对象等等。每个主题都”浅浅地讲了一下”,但留了三个明显的坑:
| 主题 | 第 10 篇讲了什么 | 没讲什么 |
|---|---|---|
| TLS | __thread 关键字 + pthread_key_* | %fs 寄存器、TCB 布局、x86-64 vs x86 差异、性能开销、析构机制 |
| 全局对象 | .ctors 段、__CTOR_LIST__ | __attribute__((constructor(N))) 优先级、Itanium ABI、__cxa_atexit 栈、atexit vs __cxa_atexit |
| stdio 缓冲 | 行缓冲 / 全缓冲 / 无缓冲 | FILE 结构体、_IO_FILE 完整字段、fread 内部、fork 缓冲复制、_exit vs exit 区别 |
本篇把每一个主题都深挖到 C 标准 + glibc 源码 + 汇编 三个层次。读完你应该能:
- 在面试中讲清楚
__thread变量为什么比全局变量”快”(其实是访问路径不同) - 用
__attribute__((constructor(N)))实现可控制顺序的插件系统 - 解释
printf+fork的经典坑为何会发生,以及如何避免 - 写一个 mini stdio 库验证三种缓冲模式
第一部分:线程局部存储(TLS)
1.1 什么是 TLS,为什么需要它?
线程局部存储(TLS, Thread Local Storage)解决一个根本问题:全局变量是进程级的,所有线程共享;但有时你希望”看起来是全局”的东西,其实每个线程应该有一份独立的副本。
典型场景:
| 场景 | 为什么需要 TLS |
|---|---|
errno | 多线程同时调用 libc,每个线程的错误码必须独立 |
strtok 内部状态 | strtok 不是线程安全的,因为内部用了静态指针 |
rand() 种子 | 否则多线程下随机数序列会互相污染 |
locale | 多语言应用中每个线程可能使用不同 locale |
| 线程局部缓存 | 避免锁竞争 |
直观的方案是”在 pthread_create 时把线程私有数据塞到线程结构体里”,但 libc 内部要访问 errno 时不知道当前线程是谁——这就是 TLS 存在的核心原因:让 libc 能在 O(1) 时间内拿到”当前线程的私有数据”。
1.2 四种 TLS API 对比
Linux/Unix 平台上,有 四套完全不同的 TLS 机制,性能、语义、ABI 都不一样:
| 机制 | 标准 | 性能 | 类型 | 析构 | 典型用途 |
|---|---|---|---|---|---|
__thread | GCC 扩展 | 最快 | 编译期类型 | 无(基本类型) | 简单 POD 数据 |
thread_local | C++11 / C23 | 同 __thread | 编译期类型 | 有(C++) | C++ 对象的线程局部存储 |
_Thread_local | C11 | 同 __thread | 编译期类型 | 无 | C 语言的标准化方案 |
pthread_key_* | POSIX | 较慢(函数调用) | void* | 有 | libc 内部 errno、动态注册 |
来看一个简单对比:
1 | // 方式 1:GCC __thread 扩展 |
1.2.1 __thread vs thread_local:实际区别
很多教程说”它们等价”,但有 两个关键差异:
1 | // __thread 不能用于类类型 |
总结成一张表:
| 特性 | __thread | thread_local |
|---|---|---|
| 支持类类型 | ❌ | ✅ |
| 动态初始化 | ❌ | ✅ |
| 析构函数 | ❌ | ✅(C++) |
| 编译期检查类型 | ❌(GCC 扩展) | ✅(C++ 标准) |
| 性能 | 最快 | 几乎一样(简单类型) |
| 跨平台 | 仅 GCC/Clang | C++11 通用 |
1.3 实现原理:%fs 寄存器 + TCB
TLS 的性能关键在于:访问一个 __thread 变量需要多少次内存引用?答案是 2 次(取 TCB + 偏移),这是因为它用了 段寄存器 作为”快速路径”。
1.3.1 x86-64 上的 %fs 寄存器
x86-64 提供 6 个段寄存器:%cs、%ss、%ds、%es、%fs、%gs。在用户态,%fs 基址(base)寄存器指向当前线程的 TCB(Thread Control Block,glibc 里的叫法,对应 Windows 的 TEB)。
graph TB
subgraph "用户态"
A["mov %fs:0x0, %rax\n// 取 TCB 自身地址"]
B["mov %fs:0x10, %rbx\n// 取 TLS 块指针"]
end
A -->|"%fs:0x0 = TCB 地址"| C["🟣 TCB\nstruct pthread"]
B -->|"%fs:0x10 = TLS 块"| D["🟢 TLS 块\n__thread 变量副本"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#C7CEEA,stroke:#9FA8DA,color:#333
style C fill:#E8D5F5,stroke:#CE93D8,color:#333
style D fill:#B5EAD7,stroke:#80CBC4,color:#333在 glibc 中,struct pthread 的前两个字段分别是:
1 | // glibc: nptl/descr.h |
1.3.2 TCB 的内存布局
每个线程在内核态的栈顶附近有一段 TLS 区域,布局如下:
graph TB
subgraph "线程栈(高地址 → 低地址)"
A["🔵 内核栈\n(pthread_create 切换到内核时用)"]
B["🟡 TCB 头(struct pthread)\n%fs:0x0 指向此处的 self"]
C["🟢 TLS 块\n每个 __thread 变量一份"]
D["🟠 线程局部数据\nmalloc 保护区"]
E["🔴 用户栈(普通函数调用)"]
end
A --> B --> C --> D --> E
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style C fill:#B5EAD7,stroke:#80CBC4,color:#333
style D fill:#FFDAB9,stroke:#FFAB76,color:#333
style E fill:#FFB3C6,stroke:#F48FB1,color:#333关键点:TLS 块在 TCB 之前(高地址方向),但 __thread 变量的”地址”是 从 TCB 起始地址减去偏移 算出来的。这种”负偏移”的目的是让 __thread 变量在用户栈的低地址区域,从而靠近普通局部变量,利于 CPU 缓存。
1.3.3 glibc 中 __thread 的实现细节
glibc 通过 __builtin_thread_pointer()(GCC 内建)访问 TCB:
1 | // glibc: sysdeps/x86_64/nptl/tls.h |
对于一个 __thread int x = 5; 的访问,编译器生成的汇编是:
1 | ; 访问 __thread int x |
1.3.4 一次内存引用的优化
glibc 实际上把 TLS 块指针放在 %fs:0x10(即 self 字段),所以访问 __thread 变量可以优化为:
1 | movq %fs:0x10, %rax ; 1 次:取 TLS 块基址 |
注意 %fs:0x10 是 段基址 + 偏移 形式,CPU 在计算有效地址时一次完成,但仍然需要 1 次内存读(读 TLS 基址)。所以准确地说,每次 __thread 访问是 2 次内存引用:1 次读 TLS 基址 + 1 次读实际变量。
1.4 TLS 的性能开销:一次实测
1.4.1 性能测试代码
写一个 benchmark,比较全局变量 vs __thread vs pthread_key 的访问速度:
1 | // bench_tls.c |
1.4.2 预期结果
在一台普通 x86-64 Linux 上,结果大致是:
| 方式 | 用时 | 相对速度 | 备注 |
|---|---|---|---|
| 全局变量 | 0.18 sec | 1.0x | 单次内存引用 |
__thread | 0.30 sec | 0.6x | 2 次内存引用 |
pthread_key | 1.50 sec | 0.12x | 涉及函数调用 + 间接访问 |
关键发现:
__thread比全局变量慢约 1.6x,因为多了 1 次内存引用pthread_key比__thread慢约 5x,因为多了一次函数调用 + 一次pthread_getspecific()内部的解引用- 实际生产中,
__thread仍然比加锁访问全局变量快几个数量级
1.4.3 volatile 关键字的影响
上面的代码里用了 volatile int sink 防止编译器优化掉。注意 TLS 测试中不要用 volatile 修饰 __thread 变量,否则编译器会强制每次都从内存读,差异会更明显:
1 | // 不要这样 |
1.5 TLS 析构函数与 DTV
1.5.1 C++ thread_local 的析构
C++11 的 thread_local 允许非 POD 类型,并且 会调用析构函数。这靠的是 DTV(Dynamic Thread Vector)。
1 | // cpp_tls_dtor.cpp |
运行结果(注意析构在 std::thread::join() 时触发,而不是进程退出):
1 | ctor on thread 140... |
1.5.2 DTV 是什么?
DTV(Dynamic Thread Vector)是一个动态分配的指针数组,每个线程一份,用于支持 dlopen 动态加载的共享库中的 __thread 变量。
graph TB
A["🟣 TCB 头\nstruct pthread\ntcb @ %fs:0\ndtv @ %fs:0x8\nself @ %fs:0x10"]
B["🟢 DTV 数组\ndtv_t dtv[]\n— 每个 module 一个 slot\n— slot 指向对应 module 的 TLS 块"]
C1["🔵 module 1 TLS 块\nlibc 的 __thread 变量"]
C2["🟡 module 2 TLS 块\n用户 .so 的 __thread 变量"]
C3["🟠 module N TLS 块\n通过 dlopen 动态加载"]
A -->|"%fs:0x8"| B
B -->|"dtv[1]"| C1
B -->|"dtv[2]"| C2
B -->|"dtv[N]"| C3
style A fill:#E8D5F5,stroke:#CE93D8,color:#333
style B fill:#B5EAD7,stroke:#80CBC4,color:#333
style C1 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style C2 fill:#FFF9C4,stroke:#F9A825,color:#333
style C3 fill:#FFDAB9,stroke:#FFAB76,color:#3331.5.3 __cxa_thread_atexit_impl
C++ 的 thread_local 析构由 __cxa_thread_atexit_impl 注册:
1 | // glibc: cxa_thread_atexit.c |
每个线程有 自己独立的 atexit 栈。线程退出时按 LIFO 顺序 调用这些析构函数。
1.5.4 与 atexit 的区别
| 函数 | 注册位置 | 触发时机 | 顺序 |
|---|---|---|---|
atexit(fn) | 全局 atexit 栈(__exit_funcs) | 进程 exit() | LIFO |
__cxa_atexit(fn, arg, dso) | 全局 atexit 栈 | 进程 exit() | LIFO |
__cxa_thread_atexit_impl | 线程局部 atexit 栈 | 线程退出 | LIFO |
pthread_key 析构 | 线程键 | pthread_exit / pthread_key_delete | 未定义 |
关键差异:__cxa_atexit 在进程退出时调用;__cxa_thread_atexit_impl 在 线程退出时 调用。这就是为什么 C++ thread_local 对象的析构发生在 std::thread::join() 时,而不是进程退出时。
1.6 实战 1:pthread_key 的 RAII 包装
POSIX 的 pthread_key_* API 是 C 风格,难用且易泄漏。包一个 RAII 包装:
1 | // tls_wrapper.hpp |
使用示例:
1 | // main.cpp |
1.7 实战 2:手写 TCB 模拟 TLS
为了彻底理解 %fs + TCB 的原理,写一个用 __thread 模拟的简化版:
1 | // mini_tls.h |
测试代码:
1 | // test_mini_tls.c |
编译运行:
1 | $ gcc -O2 test_mini_tls.c -o test_mini_tls -lpthread |
可以看到 self 和 base 在不同线程都不同——这就是 TCB 隔离的本质。
1.8 实战 3:测量访问路径的汇编
写一段代码,让编译器输出 __thread 访问的汇编:
1 | // view_asm.c |
1 | $ gcc -O2 -S view_asm.c -o view_asm.s |
read_x 的关键汇编:
1 | read_x: |
x@TPOFF 是 GCC 引入的”线程指针相对偏移”符号优化。实际生成的代码在 O2 优化下经常是 1 条指令:
1 | read_x: |
但这并不矛盾——%fs:x@TPOFF 仍然需要 1 次内存读(读 TLS 块基址 + 偏移),只是被合并到一条指令里。
性能小结
| 访问方式 | x86-64 指令数 | 内存引用数 | 备注 |
|---|---|---|---|
| 全局变量 | 1 | 1 | mov symbol(%rip), %reg |
__thread(无优化) | 2 | 2 | 显式两步 |
__thread(O2) | 1 | 2 | %fs:off 一条指令 |
pthread_key | 3+ | 2+ | 函数调用 + 解引用 |
1.9 TLS 的限制与陷阱
| 陷阱 | 原因 | 解决方案 |
|---|---|---|
DLL 中 __declspec(thread) 在 Windows Vista 之前 + LoadLibrary 装载时不能用 | TLS 表初始化时机问题 | 升级到 Vista+,或用显式 TLS |
__thread 不能用于类类型 | 编译器扩展的限制 | C++ 用 thread_local,C 用 pthread_key |
动态库中的 __thread 访问可能 panic | DTV 未初始化 | 链接时保证 DTV slot 已分配 |
C++ thread_local 析构顺序 | 跨翻译单元未定义 | 不要依赖它;用显式清理函数 |
| 性能开销 | 2 次内存引用 | 频繁访问的 TLS 变量可缓存到局部变量 |
fork 后子进程继承 TLS | fork 只复制内存 | 子进程要重新初始化 |
第二部分:C++ 全局对象的构造与析构
2.1 为什么全局对象需要”特殊照顾”?
考虑一个看似简单的全局对象:
1 | // global_obj.cpp |
你以为 main 之前什么都没发生?错。在 main 执行前:
std::string global_name的构造函数必须被调用,分配堆内存- 拷贝
"Hello"进去 - 进程退出时还要调用
~std::string析构函数,释放堆内存
谁来负责这件事?——glibc 的 __libc_start_main、GCC 提供的 crtbegin.o/crtend.o、编译器为每个翻译单元生成的 _GLOBAL__I_* 函数。
2.2 .init_array / .fini_array 段
现代 GCC/glibc 用 .init_array 和 .fini_array 段存储构造/析构函数指针(比传统的 .ctors/.dtors 更新):
1 | $ readelf -S a.out | grep -E "init|fini" |
段的本质是 “函数指针数组”——链接器把所有翻译单元贡献的指针合并到一个连续数组里。
graph TB
subgraph "可执行文件布局"
A["🔵 .text\n代码段"]
B["🟢 .init_array\n构造函数指针数组\n[fn1, fn2, fn3, ...]"]
C["🟡 .fini_array\n析构函数指针数组\n[fini_fn1, fini_fn2, ...]"]
D["🟣 .data/.bss\n已初始化/未初始化数据"]
end
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#B5EAD7,stroke:#80CBC4,color:#333
style C fill:#FFF9C4,stroke:#F9A825,color:#333
style D fill:#E8D5F5,stroke:#CE93D8,color:#3332.2.1 链接器如何拼装 .init_array
对于三个翻译单元 a.cpp、b.cpp、c.cpp,每个都贡献一个 _GLOBAL__I_* 函数。链接器把它们 按翻译单元被链接的顺序 排列:
graph LR
subgraph "a.cpp"
A1["_GLOBAL__I_a"]
end
subgraph "b.cpp"
B1["_GLOBAL__I_b"]
end
subgraph "c.cpp"
C1["_GLOBAL__I_c"]
end
A1 -->|"link order"| B1 --> C1
style A1 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B1 fill:#E8D5F5,stroke:#CE93D8,color:#333
style C1 fill:#B5EAD7,stroke:#80CBC4,color:#333注意:段合并的顺序由链接命令行的文件顺序决定,这意味着”全局对象构造顺序”在不同项目里可能不同,这是 C++ 标准明确未定义行为。
2.3 crtbegin / crtend:链接器的”两端护栏”
为了让 __init_array_start 和 __init_array_end 这两个符号有定义,链接器在 用户目标文件之前 链接 crtbegin.o,在 之后 链接 crtend.o:
1 | // gcc: gcc/Crtstuff.c (简化) |
链接命令大致是:
1 | ld crti.o crtbegin.o a.o b.o c.o crtend.o crtn.o |
合并后 .init_array 段的内容:
1 | [ -1 ] ← crtbegin.o(占位,链接器会改写为函数数量) |
2.4 完整的启动调用链
从 _start 到 main,再到 exit(),完整的调用链:
graph TB
A["🔵 _start\n(汇编入口)"]
B["🟣 __libc_start_main\n(glibc 入口)"]
C["🟢 __libc_csu_init\n(csu/libc-start.c)"]
D["🟡 _init 函数\n(.init 段内容)"]
E["🟠 __do_global_ctors_aux\n(crtbegin.o)"]
F["🟤 遍历 .init_array\n依次调用 _GLOBAL__I_*"]
G["🔴 main\n(用户代码)"]
H["🟣 __libc_csu_fini\n(csu/libc-start.c)"]
I["🟡 _fini 函数\n(.fini 段内容)"]
J["🟠 __do_global_dtors_aux\n(crtbegin.o)"]
K["🔴 exit()\n(遍历 atexit 栈)"]
L["🟢 遍历 __cxa_atexit 栈\nLIFO 调用析构"]
A --> B --> C --> D --> E --> F --> G
G -->|"return / exit"| H --> I --> J --> K --> L
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style C fill:#B5EAD7,stroke:#80CBC4,color:#333
style D fill:#FFF9C4,stroke:#F9A825,color:#333
style E fill:#FFDAB9,stroke:#FFAB76,color:#333
style F fill:#FFB3C6,stroke:#F48FB1,color:#333
style G fill:#C7CEEA,stroke:#9FA8DA,color:#333
style H fill:#E8D5F5,stroke:#CE93D8,color:#333
style I fill:#FFF9C4,stroke:#F9A825,color:#333
style J fill:#FFDAB9,stroke:#FFAB76,color:#333
style K fill:#B5EAD7,stroke:#80CBC4,color:#333
style L fill:#B5EAD7,stroke:#80CBC4,color:#3332.4.1 __libc_start_main 简化版
glibc 的 csu/libc-start.c(简化到 100 行):
1 | // 简化版 __libc_start_main |
2.4.2 真实的 glibc 启动流程
glibc 2.31+ 的真实流程(csu/libc-start.c):
1 | STATIC int |
注意几个细节:
| 细节 | 说明 |
|---|---|
fini 是函数指针 | 在动态链接时是 __libc_csu_fini,在静态链接时是 _fini |
__cxa_atexit 顺序 | LIFO(后注册先调用) |
init 遍历 .init_array | 顺序由链接器决定 |
rtld_fini 动态链接器析构 | 先于 __libc_csu_fini |
2.5 __attribute__((constructor(N))):可控优先级
GCC 提供 __attribute__((constructor(N))) 和 __attribute__((destructor(N))),可以指定优先级(数字越小越早构造/越晚析构):
1 | // priority_test.c |
1 | $ gcc priority_test.c -o priority_test |
规则总结:
| 属性 | 构造顺序 | 析构顺序 |
|---|---|---|
constructor(100) | 最早 | 最晚 |
constructor(200) | 较晚 | 较早 |
constructor(无 N) | 默认 65535 | 默认 65535 |
| 同优先级不同翻译单元 | 链接器顺序(未定义) | 链接器顺序(未定义) |
为什么默认是 65535?——这样所有 __attribute__((constructor))(不带 N)的函数会 晚于 用户显式指定优先级的函数构造。
2.6 构造 vs 析构顺序的对应关系
2.6.1 同一段内的顺序
.init_array 内 正序 构造,.fini_array 内 正序 析构:
graph LR
A["fn_A\ninit_array[0]"]
B["fn_B\ninit_array[1]"]
C["fn_C\ninit_array[2]"]
A -->|"构造"| B -->|"构造"| C
C -.->|"析构"| B -.->|"析构"| A
style A fill:#B5EAD7,stroke:#80CBC4,color:#333
style B fill:#B5EAD7,stroke:#80CBC4,color:#333
style C fill:#B5EAD7,stroke:#80CBC4,color:#3332.6.2 跨段:__cxa_atexit 的 LIFO 栈
glibc 用 __cxa_atexit 注册析构到 LIFO 栈,完美保证”先构造后析构”:
graph TB
A["__cxa_atexit(fn_A)"]
B["__cxa_atexit(fn_B)"]
C["__cxa_atexit(fn_C)"]
D["exit() 调用栈\nLIFO 弹出"]
A -->|"push"| B -->|"push"| C -->|"push"| D
D -->|"pop & call fn_C"| E["析构 fn_C"]
E -->|"pop & call fn_B"| F["析构 fn_B"]
F -->|"pop & call fn_A"| G["析构 fn_A"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#C7CEEA,stroke:#9FA8DA,color:#333
style C fill:#C7CEEA,stroke:#9FA8DA,color:#333
style D fill:#E8D5F5,stroke:#CE93D8,color:#333
style E fill:#FFDAB9,stroke:#FFAB76,color:#333
style F fill:#FFDAB9,stroke:#FFAB76,color:#333
style G fill:#FFDAB9,stroke:#FFAB76,color:#3332.6.3 .init_array vs .init 的顺序
两者都被遍历,但 .init 在前(先于 .init_array):
1 | // glibc csu 简化 |
2.7 atexit 机制详解
2.7.1 atexit 的实现
C 标准的 atexit 允许注册最多 32 个函数(早期标准),POSIX 放宽到无限:
1 | // glibc 简化版 |
exit_function_list 是个 链表的数组(链式哈希表的味道),每个 slot 存一个 exit_function:
1 | struct exit_function { |
2.7.2 atexit vs __cxa_atexit
| 特性 | atexit | __cxa_atexit |
|---|---|---|
| 标准 | C89/POSIX | Itanium C++ ABI(GCC 扩展) |
| 参数 | void (*)(void) | void (*)(void *), arg, dso_handle |
| 数量限制 | 早期 32 个 | 无限制 |
| 析构顺序 | LIFO | LIFO |
| 共享库卸载 | 整库一次性清理 | 按 dso_handle 精确清理 |
| 用途 | C 程序清理 | C++ 全局对象析构 |
2.7.3 进程退出时 exit() 的简化实现
1 | // 简化版 exit |
2.8 Itanium C++ ABI 中的关键函数
Itanium C++ ABI 是 GCC/Clang 共同遵守的规范,定义了 C++ 跨编译器/链接器的行为。其中与全局对象相关的两个关键函数:
2.8.1 __cxa_atexit
1 | // ABI 规范定义 |
参数含义:
| 参数 | 含义 |
|---|---|
func | 析构函数 |
arg | 传给析构函数的参数(通常是对象的 this 指针) |
dso_handle | 唯一标识 func 所在的 DSO(共享库或可执行文件) |
dso_handle 的作用:当动态库被 dlclose 卸载时,所有用这个 handle 注册的析构函数会被精确清理,避免悬空调用。
2.8.2 __cxa_finalize
__cxa_finalize(dso_handle) 会按 LIFO 顺序调用所有用该 handle 注册的析构函数。如果 dso_handle 为 NULL,则清理所有:
1 | extern "C" void __cxa_finalize(void *dso_handle); |
2.8.3 guard variables(构造保护)
C++ 规定全局对象只构造一次,靠 guard variable 实现:
1 | // 编译器为每个全局对象生成 |
__cxa_guard_acquire 用原子操作保证多线程下构造只发生一次。
2.9 实战 1:验证构造顺序的实验
1 | // order_test.c |
1 | $ gcc -O0 order_test.c -o order_test |
2.10 实战 2:用 constructor 实现插件系统
1 | // plugin.h |
1 | // plugin.c |
1 | // plugin_a.c |
1 | // main.c |
编译运行:
1 | $ gcc -O0 plugin.c plugin_a.c main.c -o plugin_demo |
把多个 plugin 文件链接进来,无需修改 main,就自动被注册。
2.11 实战 3:阅读 glibc __libc_start_main 简化版
为了更直观,把 glibc 的 __libc_start_main 简化到 50 行:
1 | // mini_libc_start_main.c |
2.12 构造/析构顺序的”坑”
2.12.1 跨翻译单元的”static initialization order fiasco”
1 | // a.cpp |
这是 C++ 著名坑——global_a 的初始化依赖 compute(),而 compute() 又依赖 x。x 和 global_a 在不同翻译单元,构造顺序未定义。
2.12.2 解决方案
| 方案 | 原理 | 局限 |
|---|---|---|
| Schwarz counter(计数器) | 同一头文件定义 initialize 模板,按需构造 | 增加二进制大小 |
Construct On First Use 惯用法 | 把全局对象改成函数内 static 变量 | 改 API |
| 显式调用 init 函数 | 手动控制 | 容易漏 |
| 避免跨 TU 依赖 | 重构 | 不一定可行 |
1 | // Construct On First Use 惯用法 |
2.13 常见误区
| 误区 | 真相 |
|---|---|
| “C++ 全局对象构造在 main 之前是确定的” | ❌ 跨 TU 顺序未定义 |
| “析构顺序就是构造顺序的逆序” | ⚠️ 大体正确,但细节依赖 atexit 实现 |
“用 -nostdlib 就能完全控制启动” | ⚠️ 还要 -lgcc 链接 __main |
| “atexit 最多 32 个” | ❌ 早期是,现代 POSIX 无限 |
| “析构一定在 exit 时调用” | ❌ _exit() 不会触发;abort() 也不会 |
| “析构一定按 LIFO” | ⚠️ 大体正确,但 _exit 中断时不保证 |
第三部分:stdio 缓冲
3.1 为什么需要 stdio 缓冲?
最直接的回答:系统调用贵。一次 write(fd, buf, n) 大约需要 1-10 微秒(涉及内核切换),而一次内存拷贝只要几十纳秒。缓冲的目的就是把 多次小写入合并成一次系统调用。
| 操作 | 不缓冲 | 全缓冲 | 性能提升 |
|---|---|---|---|
fputc('a') × 100 | 100 次 write | 1 次 write | ~50-100x |
fprintf("a") × 10000 | 10000 次 write | 100 次 write | ~10-50x |
但是 缓冲也带来三个著名问题:
printf不立即输出(行缓冲、终端、n)fork后子进程会重复输出- 进程崩溃时数据丢失
3.2 三种缓冲模式
POSIX/C 标准规定 三种缓冲模式:
| 模式 | 触发条件 | 缓冲大小 | 典型 fd |
|---|---|---|---|
| 全缓冲 | 打开普通文件时默认 | BUFSIZ(通常 8192) | 磁盘文件 |
| 行缓冲 | 终端设备 | BUFSIZ | stdout(tty) |
| 无缓冲 | 显式设置或 stderr | 0 | stderr、setvbuf(_, NULL, _IONBF, 0) |
来看实际的 stdio.h 宏定义:
1 | // /usr/include/stdio.h |
3.2.1 三种模式的对比实验
1 | // buf_mode_test.c |
3.3 FILE 结构体:stdio 的核心
FILE * 实际上指向 struct _IO_FILE(glibc 内部也叫 _IO_FILE_plus)。这个结构体是 stdio 一切魔法的源头:
3.3.1 简化版 FILE 结构
1 | // 简化版(glibc 真实结构超过 200 字节) |
3.3.2 关键字段语义
| 字段 | 含义 | 用途 |
|---|---|---|
_flags | 状态位 | _IO_MAGIC | _IO_NO_READS | _IO_NO_WRITES | ... |
_IO_read_ptr | 当前读位置 | 指向缓冲区内下一个要读的位置 |
_IO_read_end | 读结束位置 | 缓冲区内有效数据的结束 |
_IO_write_ptr | 当前写位置 | 指向缓冲区内下一个要写的位置 |
_IO_write_base | 写基址 | 缓冲区内已写数据的开始 |
_IO_buf_base | 缓冲区起始 | 整个缓冲区的开始 |
_IO_buf_end | 缓冲区结束 | 整个缓冲区的结束 |
_fileno | 文件描述符 | 底层 fd(stdin=0, stdout=1, stderr=2) |
_chain | 链表指针 | 三个标准流串成链表,进程退出时统一清理 |
_lock | 互斥锁 | 多线程下保护 FILE |
3.3.3 FILE 链表
stdout、stderr、stdin 通过 _chain 字段串成一个链表,进程退出时 _IO_cleanup 遍历这个链表统一 flush:
graph LR
A["🟢 stdout\nFILE* (_IO_2_1_stdout_)"]
B["🟡 stderr\nFILE* (_IO_2_1_stderr_)"]
C["🔵 stdin\nFILE* (_IO_2_1_stdin_)"]
A -->|"_chain"| B -->|"_chain"| C
style A fill:#B5EAD7,stroke:#80CBC4,color:#333
style B fill:#FFF9C4,stroke:#F9A825,color:#333
style C fill:#C7CEEA,stroke:#9FA8DA,color:#3333.4 缓冲区的内存布局
以全缓冲写为例,fprintf 写入时,缓冲区状态变化:
graph TB
subgraph "FILE _IO_write_* 指针关系"
P1["_IO_write_base"]
P2["_IO_write_ptr"]
P3["_IO_write_end"]
P4["_IO_buf_base"]
P5["_IO_buf_end"]
end
subgraph "缓冲区内存"
M1["已写入的字符(待 flush)"]
M2["剩余空间(可写)"]
M3["未使用区域"]
end
P1 --> M1
P2 --> M1
P3 --> M2
P4 --> M1
P5 --> M3
style P1 fill:#B5EAD7,stroke:#80CBC4,color:#333
style P2 fill:#FFB3C6,stroke:#F48FB1,color:#333
style P3 fill:#FFDAB9,stroke:#FFAB76,color:#333
style P4 fill:#E8D5F5,stroke:#CE93D8,color:#333
style P5 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style M1 fill:#FFF9C4,stroke:#F9A825,color:#333
style M2 fill:#B5EAD7,stroke:#80CBC4,color:#333
style M3 fill:#F5F5F5,stroke:#999,color:#333关键关系:
_IO_write_base≤_IO_write_ptr≤_IO_write_end≤_IO_buf_end_IO_write_ptr - _IO_write_base= 已写入字节数_IO_write_end - _IO_write_ptr= 剩余可写字节数
3.5 fread 内部:缓冲读
fread 的逻辑可以总结为:
graph TB
A["🔵 调用 fread(buf, size, n, fp)"]
B{"缓冲区有足够数据?\n_IO_read_end - _IO_read_ptr >= 需要的字节数"}
C["🟢 从缓冲区拷贝到 buf\n返回 1"]
D["🟡 调用 __underflow()\n从 fd 读入更多数据到缓冲区"]
E{"读取成功?"}
F["🔴 返回 0 或部分读取"]
G["🔵 继续从缓冲区读取"]
A --> B
B -->|"是"| C
B -->|"否"| D
D --> E
E -->|"是"| G
E -->|"否"| F
G --> B
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:#FFDAB9,stroke:#FFAB76,color:#333
style E fill:#FFF9C4,stroke:#F9A825,color:#333
style F fill:#FFB3C6,stroke:#F48FB1,color:#333
style G fill:#C7CEEA,stroke:#9FA8DA,color:#3333.5.1 简化版 fread 实现
1 | // mini_fread.c |
3.6 fwrite 内部:缓冲写
1 | // mini_fwrite.c |
真实 fwrite 会先把数据拷贝到 _IO_write_ptr 指向的缓冲区,当缓冲区满时调用 _IO_OVERFLOW 触发 flush(调用 write(fd, ...))。
3.7 printf 内部:变参 + 缓冲
printf 的流程是:
graph TB
A["🟣 printf fmt, args..."]
B["🟢 vsnprintf 内部缓冲\n(栈上 tmpbuf[BUFSIZ])"]
C["🟡 vfprintf 解析格式串\n% d s x p f..."]
D["🟠 逐字符写入到 tmpbuf"]
E["🔴 判断 stdout 缓冲模式"]
F{"stdout 缓冲模式?"}
G["🟢 全缓冲:\n拷贝到 stdout 缓冲区\n满时 _IO_OVERFLOW"]
H["🟡 行缓冲:\n遇到 \\n 才 flush\n(写满时也 flush)"]
I["🔵 无缓冲:\n直接 write(1, ...)"]
A --> B --> C --> D --> E --> F
F -->|"_IOFBF"| G
F -->|"_IOLBF"| H
F -->|"_IONBF"| I
style A fill:#E8D5F5,stroke:#CE93D8,color:#333
style B fill:#B5EAD7,stroke:#80CBC4,color:#333
style C fill:#FFF9C4,stroke:#F9A825,color:#333
style D fill:#FFDAB9,stroke:#FFAB76,color:#333
style E fill:#FFB3C6,stroke:#F48FB1,color:#333
style F fill:#FFF9C4,stroke:#F9A825,color:#333
style G fill:#B5EAD7,stroke:#80CBC4,color:#333
style H fill:#FFF9C4,stroke:#F9A825,color:#333
style I fill:#C7CEEA,stroke:#9FA8DA,color:#333关键点:printf 先把格式化结果写入 stdio 缓冲区(stdout),等缓冲区满 / 遇到 \n / fflush / exit 时才真正写入 fd。
3.8 fflush 与 crash 数据丢失
fflush(fp) 强制把 fp 缓冲区的数据写入底层 fd。这与”刷新内核缓冲区”无关——内核缓冲区由 fsync、sync 控制。
1 | // fflush 的简化实现 |
3.8.1 进程崩溃时数据丢失
| 退出方式 | 是否 flush stdio |
|---|---|
exit(0) | ✅ 触发 atexit,刷新所有 FILE |
return 0 from main | ✅ 等同于 exit(0) |
_exit(0) | ❌ 立即终止,stdio 缓冲丢失 |
abort() | ❌ 同上 |
kill -9 | ❌ 不给进程任何机会 |
| 未捕获信号 | ⚠️ 取决于信号处理 |
1 | // 错误示例 |
3.9 setvbuf / setbuf / setlinebuf:切换缓冲
| 函数 | 用途 | 等价 setvbuf |
|---|---|---|
setvbuf(fp, buf, mode, size) | 完整控制 | — |
setbuf(fp, buf) | 旧 API,等价 setvbuf(fp, buf, _IOFBF, BUFSIZ) | setvbuf(fp, buf, _IOFBF, BUFSIZ) |
setbuffer(fp, buf, size) | 类似 setbuf 但可指定大小 | setvbuf(fp, buf, _IOFBF, size) |
setlinebuf(fp) | 切换到行缓冲 | setvbuf(fp, NULL, _IOLBF, 0) |
setvbuf(fp, NULL, _IONBF, 0) | 无缓冲 | — |
1 | // 实际用法 |
3.10 printf / fprintf / sprintf / snprintf 对比
| 函数 | 输出目标 | 缓冲行为 | 安全性 |
|---|---|---|---|
printf(fmt, ...) | stdout | 缓冲 | — |
fprintf(fp, fmt, ...) | 任意 FILE* | 受 fp 缓冲控制 | — |
sprintf(buf, fmt, ...) | 字符数组 | 不缓冲,直接写 | ❌ 缓冲区溢出 |
snprintf(buf, n, fmt, ...) | 字符数组,最多 n-1 字节 | 不缓冲 | ✅ 推荐 |
vsprintf(buf, fmt, va) | 字符数组 | 不缓冲 | ❌ |
vsnprintf(buf, n, fmt, va) | 字符数组 | 不缓冲 | ✅ |
dprintf(fd, fmt, ...) | 任意 fd | 不缓冲(直接 write) | ✅ |
关键区别:
printf/fprintf走 stdio 缓冲sprintf/snprintf不缓冲,直接写内存dprintf绕过 stdio,直接调用write——常用于系统日志、fork场景
3.11 printf + fork 的经典坑
这是 最经典的 stdio 缓冲问题:
1 | // fork_printf_bug.c |
实际运行结果:
1 | $ ./fork_printf_bug | cat |
原因:
graph TB
A["父进程 printf 写入 stdout 缓冲\n(行缓冲,未 flush)"]
B["🟡 父进程缓冲内容:\nhello world\\n"]
C["fork() 系统调用\n复制整个进程地址空间"]
D["🟢 子进程拥有相同缓冲副本"]
E["🟠 父进程 exit\nflush 父进程缓冲"]
F["🟠 子进程 _exit\nflush 子进程缓冲"]
A --> B --> C --> D
C --> E
D --> F
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#FFF9C4,stroke:#F9A825,color:#333
style C fill:#E8D5F5,stroke:#CE93D8,color:#333
style D fill:#B5EAD7,stroke:#80CBC4,color:#333
style E fill:#FFDAB9,stroke:#FFAB76,color:#333
style F fill:#FFB3C6,stroke:#F48FB1,color:#333解决方案:
| 方案 | 代码 | 原理 |
|---|---|---|
| 在 fork 前 flush | fflush(stdout); fork(); | 缓冲被清空,复制的是”已写出”的状态 |
用 \n | printf("hello world\n"); | 行缓冲自动 flush |
用 dprintf(1, ...) | dprintf(1, "hello world\n"); | 绕过 stdio 缓冲 |
用 setvbuf 关闭缓冲 | setvbuf(stdout, NULL, _IONBF, 0); | 不缓冲 |
在 fork 后 _exit | 子进程用 _exit(0) | 不触发 atexit flush |
1 | // 推荐:子进程 _exit,绕过 stdio flush |
3.12 stdio 缓冲与多线程
每个 FILE 自带锁(_lock 字段),所以 fprintf 等操作是线程安全的。但 性能上可能有锁竞争:
1 | // 多线程下 fprintf 是线程安全的(glibc 默认) |
| 操作 | 是否线程安全 | 备注 |
|---|---|---|
fprintf | ✅ | 有锁 |
printf | ✅ | 内部锁 stdout |
sprintf | ✅ | 写内存,无共享 |
snprintf | ✅ | 同上 |
直接操作 FILE 字段 | ❌ | 不要绕过 API |
3.13 实战 1:写一个 mini stdio
为了完全理解 stdio 缓冲的工作机制,实现一个简化版:
1 | // mini_stdio.h |
1 | // mini_stdio.c |
测试:
1 | // test_mini_stdio.c |
3.14 实战 2:验证 printf + fork 的重复输出
1 | // verify_fork_printf.c |
预期输出(如果 stdout 重定向到文件,无缓冲):
1 | $ ./verify_fork_printf |
3.15 实战 3:实现一个简易的日志库
1 | // mini_logger.h |
1 | // mini_logger.c |
使用:
1 | // test_logger.c |
3.16 高级主题:自定义 stdio vtable
glibc 的 stdio 设计允许你 替换底层实现——这叫 _IO_jump_t 或 vtable:
1 | // 进阶:自定义 vtable(不推荐生产用) |
你可以实现自己的 vtable,让所有 stdio 操作走你的逻辑——比如:网络 socket 包装、加密流、内存映射等。
3.17 stdio 的常见坑
| 坑 | 原因 | 解决 |
|---|---|---|
printf 不立即输出 | stdio 缓冲 | fflush(stdout) 或 setvbuf 无缓冲 |
printf + fork 重复 | 缓冲被复制 | fork 前 fflush;子进程用 _exit |
fclose 之后访问 FILE | UAF | 置 NULL |
fwrite 部分写 | 错误处理缺失 | 检查返回值 |
fseek + fread 错乱 | 文本/二进制模式 | Linux 无差异;Windows 需 “rb”/“wb” |
| 多线程 printf 顺序错乱 | 共享 stdout | 加锁或用 per-thread buffer |
_exit 丢失缓冲 | 跳过 atexit | fflush 再 _exit |
缓冲与 mmap 同一文件 | 数据不一致 | 用 fadvise 或裸 read/write |
第四部分:三者联系——运行库是一个整体
到这里你已经分别学了 TLS、全局对象、stdio 缓冲。它们看似独立,其实被 glibc 的启动流程 紧密耦合:
graph TB
A["🔵 _start\n汇编入口"]
B["🟣 __libc_start_main\n初始化 glibc 内部"]
C["🟢 1. 初始化 TLS\n(分配 TCB、设置 %fs)"]
D["🟡 2. 初始化 stdio\n(建立 stdout/stderr/stdin)"]
E["🟠 3. 初始化 locale"]
F["🟤 4. 初始化堆"]
G["🔴 5. 遍历 .init_array\n全局对象构造"]
H["🟢 6. main 函数"]
I["🟡 7. exit\nLIFO 析构"]
J["🟠 8. 刷新 stdio 缓冲"]
K["🔵 9. 关闭 TLS\n清理线程资源"]
A --> B --> C --> D --> E --> F --> G --> H
H -->|"return / exit"| I --> J --> K
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style C fill:#B5EAD7,stroke:#80CBC4,color:#333
style D fill:#FFF9C4,stroke:#F9A825,color:#333
style E fill:#FFDAB9,stroke:#FFAB76,color:#333
style F fill:#FFB3C6,stroke:#F48FB1,color:#333
style G fill:#E8D5F5,stroke:#CE93D8,color:#333
style H fill:#C7CEEA,stroke:#9FA8DA,color:#333
style I fill:#E8D5F5,stroke:#CE93D8,color:#333
style J fill:#FFF9C4,stroke:#F9A825,color:#333
style K fill:#B5EAD7,stroke:#80CBC4,color:#3334.1 启动顺序的依赖关系
| 阶段 | 依赖 | 不能颠倒的原因 |
|---|---|---|
| TLS 初始化 | 内核(clone) | glibc 自己要用 TLS 存 thread-local 数据 |
| stdio 初始化 | 无 | 独立 |
| locale 初始化 | 无 | 独立 |
| 堆初始化 | 无 | 独立 |
| 全局对象构造 | 堆、stdio、locale | 构造函数可能 new、可能 printf |
| main | 一切 | — |
| 析构 | 一切 | 析构函数可能 delete、可能 fclose |
| stdio flush | 析构 | flush 时可能调用析构注册的回调 |
| TLS 清理 | 一切 | 线程退出时清理 |
4.2 一些”串联”的小例子
4.2.1 全局对象里用 stdio
1 | // a.cpp |
- 构造发生在
__libc_csu_init遍历.init_array时,早于 main - 析构发生在
exit()触发 atexit 时,晚于 main - 因为
printf也是 stdio 的一部分,你看到输出时已经涉及”全局对象 + stdio”两个子系统
4.2.2 全局对象里用 TLS
1 | // b.cpp |
- TLS 变量在 每个线程创建时 初始化,包括主线程
- thread_local 析构在 线程退出时 调用,而不是进程退出时
4.2.3 全局对象里同时用 stdio + TLS
1 | // c.cpp |
- 主线程的
Worker构造在 main 之前 - 子线程的
tls_count增加 100(与主线程独立) - 进程退出时,主线程的
Worker析构;子线程的 tls_count 不受影响
4.3 一些”反直觉”的运行时真相
- TLS 析构不按 LIFO——按 注册顺序的逆序(相同效果,但 LIFO 是 atexit 的特性,不是 thread_local 的)
- 析构函数中如果用 stdio,可能死锁——因为 stdio 在析构中也要清理
- C++ 全局对象的构造如果抛异常,会导致程序直接终止——没有 try/catch
- 同一 DLL 被多次
dlopen引用计数,但 TLS 不增加——这是著名的 bug 来源 printf在 main 之前能工作——因为 stdio 初始化在全局对象构造之前
第五部分:面试与实战思考题
5.1 思考题
- 为什么
__thread变量的访问比全局变量慢? 在 x86-64 上具体多几次内存引用? - TLS 在 IA-32(x86-32 位)上是怎么实现的? 和 x86-64 有什么不同?
- 为什么 Itanium C++ ABI 用
__cxa_atexit而不是 POSIXatexit? 二者在共享库场景下有什么不同? - “Static initialization order fiasco” 的本质是什么? 给出三个解决方案,并说明每个方案的局限。
printf("a"); fork(); printf("b"); exit(0);在 stdout 重定向到文件时输出什么? 解释为什么。- 设计一个无锁的多线程日志库,要求:线程安全、避免锁竞争、保证消息顺序。 你会用什么结构?
- 为什么
_exit不会刷新 stdio 缓冲? 实现上具体跳过了什么? - glibc 的
.init_array遍历顺序由什么决定? 如何强制控制?
5.2 调试技巧
| 问题 | 调试方法 |
|---|---|
| 全局对象没构造 | gdb 在 main 打断点,看 info functions |
| 析构顺序错乱 | gdb watch 全局变量,backtrace |
| printf 不输出 | fflush 强制;用 strace 看 write 调用 |
| TLS 访问段错误 | gdb 看 %fs 寄存器值;检查 arch_prctl |
| fork 后输出重复 | dprintf 替换;或 fork 前 fflush |
atexit 没调用 | 检查是否 _exit / abort 退出 |
5.3 工具与命令速查
1 | # 查看 .init_array / .fini_array 内容 |
总结
本篇把第十章的三个深坑拆开深挖:
| 主题 | 关键 takeaway |
|---|---|
| TLS | 用 %fs 段寄存器 + TCB 实现,访问是 2 次内存引用;__thread 最快,pthread_key 最慢但灵活 |
| 全局对象构造析构 | .init_array 段 + __cxa_atexit LIFO 栈;__attribute__((constructor(N))) 控制顺序;跨 TU 顺序未定义 |
| stdio 缓冲 | 三种缓冲模式(无/行/全),FILE 结构体是核心;printf 不立即输出,fork 后会重复,_exit 丢失缓冲 |
核心建议:
- 用
thread_local而不是__thread——C++11 标准,类型安全,跨编译器 - 避免依赖跨翻译单元的全局对象构造顺序——用 Construct On First Use 惯用法
- 日志、错误流用无缓冲——不要在
printf之后假设已经输出 fork之后立刻_exit或fflush——避免 stdio 缓冲问题- 理解
_exit和exit的本质区别——前者是 syscall,后者是 libc 函数 - 用
dprintf/vdprintf写日志——绕过 stdio 缓冲,更可控
延伸阅读
- glibc 源码:
csu/libc-start.c、libio/、nptl/descr.h - Itanium C++ ABI:https://itanium-cxx-abi.github.io/cxx-abi/abi.html#dso-dtor
- 论文:”The 90 Minute Guide to TLS” (Ulrich Drepper)
- man 手册:
pthread_key_create(3),__cxa_atexit(3),setvbuf(3)
本系列导航
| 编号 | 标题 | 链接 |
|---|---|---|
| 0 | 总览:重新认识程序员的自我修养 | 01-温故而知新.md |
| 1 | 编译和链接 | 02-编译和链接.md |
| 2 | 目标文件里有什么 | 03-目标文件里有什么.md |
| 3 | 静态链接 | 04-静态链接.md |
| 4 | Windows PE/COFF | 05-windows-pe-coff.md |
| 5 | 动态链接 | 05-动态链接.md |
| 6 | 可执行文件的装载与进程 | 06-可执行文件的装载与进程.md |
| 7 | 动态链接的实现 | 07-动态链接的实现.md |
| 8 | Linux 共享库的组织 | 08-Linux共享库的组织.md |
| 9 | 内存管理 | 09-内存管理.md |
| 10 | 运行库(基础) | 10-运行库.md |
| 11 | 系统调用 | 11-系统调用.md |
| 12 | 线程库 | 12-线程库.md |
| 13 | 调试 | 13-调试.md |
| 14 | Windows 动态链接 | 15-windows-dynamic-linking.md |
| 17 | 运行库深挖:TLS / 全局对象 / stdio 缓冲 | (本文) |
写在最后:运行库的三个主题看似独立——TLS 管”线程私有”,全局对象管”启动/退出”,stdio 管”输入输出”——但它们被
_start→__libc_start_main这条调用链串成了一个整体。理解这个整体,是写出健壮 C/C++ 程序的基础。下一次当你写出”全局对象里调用 printf 失败”、”fork 之后日志重复”时,回头看看这张图——所有答案都在里面。
下一章预告:内存池与 slab 分配器深挖——从 ptmalloc2 到 jemalloc,看 malloc 内部如何为多线程优化。