《程序员自我修养》读书总结(十一)


Author: Once Day Date: 2026年2月5日

一位热衷于Linux学习和开发的菜鸟,试图谱写一场冒险之旅,也许终点只是一场白日梦…

漫漫长路,有人对你微笑过嘛…

全系列文章可参考专栏: 书籍阅读_Once-Day的博客-CSDN博客

参考文章:


11. 运行库
11.1 入口函数和程序初始化

对于大多数 C/C++ 开发者而言,main 函数似乎是程序执行的起点。然而实际上,在 main 被调用之前,操作系统已经将控制权交给了运行库提供的入口函数(Entry Point),由该函数完成一系列关键的初始化工作后,才会跳转至用户编写的 main 函数。同样地,当 main 返回之后,入口函数还需要负责资源的清理与进程的退出。这一前后包裹 main 函数的过程,构成了程序完整的生命周期。

glibc 中,程序的真正入口是 _start,该符号由链接器默认指定为可执行文件的起始地址。_start 的主要职责是从栈上提取由内核压入的 argcargvenvp 等参数,随后调用 __libc_start_main。在这个函数内部,依次完成以下工作:设置线程相关的数据结构、注册 fini(终止清理)函数通过 __cxa_atexit、执行 init 段中的全局初始化函数,最终调用 main(argc, argv, envp)。当 main 返回后,其返回值作为参数传递给 exit,触发通过 atexit 注册的清理函数链,并最终通过 _exit 系统调用终止进程。整个流程可概括如下:

_start  →  __libc_start_main  →  __libc_init_first / __init
        →  main(argc, argv, envp)
        →  exit()  →  _exit()

MSVC 的运行库采用了类似但略有不同的结构。以控制台程序为例,链接器默认的入口函数为 mainCRTStartup。该函数首先调用操作系统 API 获取命令行字符串和环境变量,再自行解析为 argc / argv 的形式——这与 glibc 直接从内核栈上获取参数的方式不同。随后,mainCRTStartup 完成堆初始化(_heap_init)、I/O 初始化(_ioinit)、全局变量初始化(_initterm)等步骤,再调用 main 函数。对于 Windows 下的图形界面程序,对应的入口函数则是 WinMainCRTStartup,其调用的用户函数为 WinMain

对比项 glibc(Linux) MSVC(Windows)
入口符号 _start mainCRTStartup
参数获取方式 内核压栈,_start 直接读取 调用 GetCommandLineA 等 API 解析
核心初始化函数 __libc_start_main mainCRTStartup 内部序列
堆初始化 brk / mmap(由 malloc 首次调用触发) 显式调用 _heap_init
退出路径 exit_exit exitExitProcess

运行库的 I/O 初始化是入口函数中不可或缺的一环。在用户代码能够使用 printfscanf 之前,运行库需要建立起标准输入(stdin)、标准输出(stdout)和标准错误(stderr)三个流与操作系统文件描述符之间的绑定关系。在 glibc 中,这三个 FILE 结构体作为全局对象在库内部静态定义,并在初始化阶段与文件描述符 012 完成关联。MSVC 则通过 _ioinit 函数初始化一个内部的文件句柄表,将操作系统层面的句柄映射到 C 运行库的文件描述符体系中。

此外,I/O 初始化还涉及缓冲区的配置。标准流默认采用行缓冲(stdout)或无缓冲(stderr)策略,而普通文件流则为全缓冲模式。这些缓冲区并非在初始化阶段一次性分配,而通常在首次执行读写操作时按需创建,以避免不必要的内存开销。这种延迟初始化(lazy initialization)的设计在运行库的多个模块中均有体现,是一种常见的性能优化策略。

11.2 C/C++运行库

C/C++ 运行库(C Runtime Library,简称 CRT)是支撑 C/C++ 程序运行的基础设施,其职责远不止提供标准函数的实现。从宏观角度看,CRT 涵盖了两大核心功能:

  • 一是构建程序的运行环境,包括入口函数中执行的启动与退出逻辑;

  • 二是提供语言标准所规定的库函数实现。前者确保程序在进入 main 之前拥有可用的堆、I/O 通道和全局状态,后者则为开发者提供字符串处理、数学运算、文件操作等基本能力。

两者共同构成了从"裸"操作系统接口到高级语言编程模型之间的桥梁。按功能划分,CRT 所实现的模块大致可归纳为以下几类:

模块类别 典型内容 说明
启动与退出 _startmainCRTStartupatexitexit 程序生命周期的入口和出口
标准函数 string.hmath.hstdlib.h 字符串、数学、类型转换等通用工具
I/O 函数 printfscanffopenfread 格式化与文件 I/O 操作
堆管理 mallocfreecallocrealloc 动态内存分配与释放
语言实现 变长参数、setjmp/longjmp、多字节字符 支撑语言特性所需的底层机制
调试支持 assert、内存泄漏检测、栈保护 开发阶段的诊断与安全辅助

其中,堆管理模块是 CRT 中最复杂的部分之一。malloc 的实现通常需要在用户态维护空闲链表或内存池结构,仅在现有空间不足时才通过系统调用(如 Linux 下的 brk / mmapWindows 下的 HeapAlloc)向操作系统申请新的内存页。调试支持模块则在 ReleaseDebug 版本之间差异显著,例如 MSVCDebug CRT 会在 malloc 分配的内存块前后填充哨兵字节(0xFD),并在 free 时将已释放的内存填充为 0xDD,以便检测越界访问和使用已释放内存等常见错误。

C 语言标准库的历史可以追溯到 1970 年代。早期的 C 语言并不具备正式的库规范,各个 UNIX 系统各自提供风格迥异的函数集。1989 年,ANSI 发布了 C89 标准(即 ANSI C),首次对标准库的接口进行了统一定义,确立了 stdio.hstdlib.hstring.h 等 15 个标准头文件。此后 C99 标准新增了 stdbool.hstdint.hcomplex.h 等头文件,将标准头文件扩展到 24 个。C11 则进一步引入了 threads.hstdatomic.h 等多线程与原子操作相关的接口,使标准库的覆盖面从单线程环境延伸到了并发编程领域。每一次标准的演进,都对运行库的实现提出了新的要求。

C89 标准库按功能可大致分为以下几组:以 stdio.hstdlib.h 为代表的 I/O 与通用工具函数,以 string.hctype.h 为代表的字符串与字符处理函数,以 math.hfloat.h 为代表的数学与浮点支持,以 setjmp.hsignal.hstdarg.h 为代表的语言机制支撑,以及 locale.htime.herrno.h 等系统相关的辅助设施。这些头文件仅定义了接口契约,具体的实现则由各平台的运行库自行完成,因此同一段符合标准的 C 代码在不同运行库上的行为理论上一致,但性能特征和内部细节可能存在较大差异。

Linux 平台上,glibcGNU C Library)是最主流的 C 运行库实现。glibc 不仅实现了 ISO C 标准规定的全部接口,还提供了大量 POSIX 扩展和 GNU 扩展,例如 getopt_longasprintf 以及对 Linux 系统调用的直接封装。glibc 以动态链接库 libc.so.6 的形式存在于几乎所有主流 Linux 发行版中,其内部采用了高度优化的汇编实现(如 memcpystrlen 等热点函数),并针对不同处理器架构提供了专门的优化路径。除 glibc 外,嵌入式领域常用的替代方案包括 musluClibc-ng 等,它们以更小的体积和更简洁的实现换取了在资源受限环境下的适用性。

MSVC 的运行库则与 Windows 操作系统深度绑定。历史上,MSVC CRT 以版本号命名的 DLL 形式发布(如 msvcrt.dllmsvcr120.dll),不同版本的 Visual Studio 编译出的程序依赖不同版本的运行库 DLL,这在部署时常常引发版本兼容问题。自 Visual Studio 2015 起,微软对 CRT 进行了重大重构,将其拆分为通用 CRT(Universal CRT,即 ucrtbase.dll)和编译器专属的 vcruntime 两部分。ucrtbase.dll 作为 Windows 操作系统组件随系统更新分发,解决了长期存在的版本碎片化问题。

两者的核心差异在于设计哲学和生态定位。glibc 遵循 POSIX 标准,强调跨 UNIX 系统的可移植性,其源代码公开,允许社区审计和贡献。MSVC CRT 则优先保证与 Windows API 的紧密协作,提供了如 _beginthread_CrtDumpMemoryLeaks 等平台专属扩展。在链接方式上,glibc 几乎总是动态链接,而 MSVC 提供了静态链接(/MT)和动态链接(/MD)两种选项供开发者选择。尽管两者的外部接口均遵循 ISO C 标准,但在错误处理(如 errno 的线程安全实现方式)、内存分配策略、以及对未定义行为的处理倾向上,仍存在不少细微差别,这也是跨平台开发中需要特别注意的地方。

11.3 运行库与多线程

多线程编程的正确性在很大程度上取决于对数据可见性的理解——哪些数据是线程私有的,哪些是线程间共享的。一般而言,每个线程拥有三类私有资源:线程局部存储Thread Local Storage,简称 TLS)以及寄存器上下文

  • 栈是最直观的私有空间,每个线程在创建时由操作系统或运行库分配独立的栈区域,Linuxpthread 默认分配 8MB 栈空间,Windows 默认为 1MB。
  • 函数内的局部变量、参数和返回地址均保存在各自线程的栈上,天然不存在竞争问题。
  • 寄存器上下文则在线程切换时由操作系统负责保存和恢复,包括通用寄存器、程序计数器(PC/RIP)、栈指针(SP/RSP)以及浮点/向量寄存器等,确保线程恢复执行时的状态与被挂起时完全一致。

与私有资源相对,线程间共享的数据范围相当广泛。全局变量和函数内的静态变量(static 局部变量)存储在进程的 .data.bss 段中,所有线程均可直接访问和修改。堆上通过 mallocnew 分配的内存同样是共享的——只要一个线程持有某块堆内存的指针,其他线程同样可以通过该指针进行读写。

此外,程序的代码段(.text)在所有线程间共享且只读,而通过 fopen 等函数打开的文件描述符也属于进程级资源,多个线程对同一文件的并发读写如果缺乏同步保护,将导致数据交错和不一致。这些共享数据正是多线程程序中竞态条件和数据竞争的根源。

资源类型 归属 典型示例
线程私有 局部变量、函数调用链
寄存器 线程私有 RIPRSPXMM0
TLS 线程私有 errnostrtok 内部状态
全局/静态变量 线程共享 .data.bss 段中的变量
堆内存 线程共享 malloc / new 分配的内存
文件描述符 线程共享 fopen 打开的 FILE*
代码段 线程共享 .text

正因为存在大量共享资源,运行库本身必须具备线程安全性,否则即使用户代码编写正确,仍可能在库函数内部触发竞争。早期的 C 运行库在设计时并未考虑多线程场景,许多函数使用了内部静态缓冲区来保存中间状态,典型的例子包括 strtokasctimegmtime 等——它们在连续调用之间通过静态变量维持上下文,在多线程环境下极易产生数据覆盖。为此,POSIX 标准引入了带 _r 后缀的可重入版本(如 strtok_rgmtime_r),要求调用方自行提供缓冲区。MSVC 则采用了不同的策略,从 Visual Studio 2005 开始,其 CRT 默认将大部分涉及静态状态的函数改为使用 TLS 存储中间结果,从而在不改变 API 签名的前提下实现了线程安全。

errno 的线程安全化是运行库多线程改造中最具代表性的案例。在单线程时代,errno 是一个简单的全局整型变量。进入多线程时代后,如果两个线程先后调用了可能失败的库函数,后一个线程的 errno 赋值会覆盖前一个线程尚未读取的错误码。现代 CRT 的解决方案是将 errno 定义为一个宏,展开后实际调用一个返回线程私有存储指针的函数。以 glibc 为例,errno 被定义为 (*__errno_location()),该函数返回当前线程 TLS 区域中 errno 变量的地址,从而使每个线程拥有独立的 errno 副本。

在链接层面,MSVC 历史上曾将运行库分为单线程版本(/MLlibc.lib)和多线程版本(/MTlibcmt.lib)。单线程版本省略了锁操作以获得更高性能,但在多线程程序中使用会导致未定义行为。自 Visual Studio 2005 起,微软移除了单线程 CRT,所有程序统一使用多线程版本,彻底消除了因误选链接选项导致的隐患。glibc 则始终以单一版本覆盖单线程和多线程场景,线程相关的支持通过链接 libpthread.so-lpthread)来启用。

线程局部存储的实现机制因平台和使用方式而有所不同。从语言层面看,C11 标准引入了 _Thread_local 关键字,C++11 则提供了 thread_local 关键字,GCC 的扩展语法为 __threadMSVC 使用 __declspec(thread)。当编译器遇到这些声明时,会将对应变量放入可执行文件的 .tdata(已初始化)或 .tbss(未初始化)段中。在运行时,操作系统为每个线程分配该段的独立副本。以 x86-64 Linux 为例,TLS 变量的访问通过 FS 段寄存器加偏移量完成,典型的访问指令形如 mov eax, fs:[offset],几乎不产生额外的性能开销。

// GCC / Clang
__thread int tls_var = 0;

// C11 标准
_Thread_local int tls_var2 = 0;

// MSVC
__declspec(thread) int tls_var3 = 0;

除编译期的静态 TLS 外,运行库还提供了运行期动态分配 TLS 槽位的 API。POSIX 系统下为 pthread_key_create / pthread_setspecific / pthread_getspecificWindows 下对应 TlsAlloc / TlsSetValue / TlsGetValue。动态 TLS 的实现通常依赖于线程控制块(TCB)中的一个指针数组,每个槽位对应数组中的一个索引。与静态 TLS 直接通过段寄存器寻址不同,动态 TLS 需要经过函数调用和间接寻址,性能略逊,但胜在灵活——特别是在动态链接库(DLL / SO)中,由于加载时机不确定,动态 TLS 往往是更为稳妥的选择。

11.4 C++全局构造与析构

C++ 允许在全局作用域或命名空间作用域定义具有构造函数的对象,这些对象必须在 main 函数执行之前完成构造,并在 main 返回之后按逆序析构。这一语义看似简单,但其实现需要编译器与运行库的紧密配合。核心挑战在于:编译器在编译每个翻译单元时,能够确定本单元中有哪些全局对象需要构造,但无法得知最终链接时所有翻译单元的全局对象集合。因此,编译器和链接器需要一种协作机制,将分散在各目标文件中的构造函数指针汇集到统一的数据结构中,供运行库在启动阶段遍历调用。

glibcGCC 的协作体系中,这一机制通过 .init_array.fini_array 两个特殊的 ELF 段来实现。当编译器遇到一个全局 C++ 对象时,会为其生成一个包装函数(通常以编译器内部命名规则标识),该函数内部调用对象的构造函数。随后,编译器在目标文件的 .init_array 段中写入一个指向该包装函数的指针。链接阶段,链接器将所有目标文件的 .init_array 段合并为一个连续的函数指针数组。运行库在 __libc_start_main 中调用 __libc_csu_init,该函数遍历合并后的 .init_array 数组,依次调用每个函数指针,从而完成所有全局对象的构造。

编译器生成:
  .init_array:  [ &__static_init_func1, &__static_init_func2, ... ]
  .fini_array:  [ &__static_fini_func1, &__static_fini_func2, ... ]

链接器合并所有 .o 的 .init_array → 最终可执行文件中的连续数组

运行时:
  __libc_csu_init() → 遍历 .init_array → 逐一调用构造包装函数

析构过程的实现则依赖于 __cxa_atexit 函数。当每个全局对象的构造包装函数被调用时,在执行完构造函数之后,会紧接着调用 __cxa_atexit 注册该对象对应的析构函数。__cxa_atexit 的原型为 int __cxa_atexit(void (*func)(void*), void* arg, void* dso_handle),其中 dso_handle 参数用于标识该析构函数所属的共享库,这在动态库被 dlclose 卸载时尤为关键——运行库能够根据此标识仅调用属于该库的析构函数,而不影响其他模块。当 main 返回或 exit 被调用时,运行库以后进先出的顺序遍历 __cxa_atexit 注册的函数列表,确保析构顺序与构造顺序严格相反,从而满足 C++ 标准对全局对象生命周期的要求。

exit() main() .init_array glibc CRT 操作系统 exit() main() .init_array glibc CRT 操作系统 _start → __libc_start_main __libc_csu_init 遍历 .init_array 构造全局对象 + __cxa_atexit 注册析构 调用 main(argc, argv, envp) main 返回 调用 exit() 按 LIFO 顺序调用 __cxa_atexit 注册的析构函数 _exit 终止进程

值得一提的是,在较早的 GCC 版本和 ELF 规范中,全局构造与析构使用的是 .ctors / .dtors 段配合 _init / _fini 函数的方案。.ctors 段本质上也是一个函数指针数组,但其遍历逻辑由 crtbegin.ocrtend.o 中的辅助代码负责。现代 GCCglibc 已经全面转向 .init_array / .fini_array 方案,后者在语义上更清晰,且支持优先级属性——开发者可通过 __attribute__((init_priority(N))) 指定构造函数的调用顺序,链接器会据此对 .init_array 中的条目进行排序。

MSVC 的全局构造与析构机制在原理上类似,但采用了不同的段命名和组织方式。MSVC 编译器将全局对象的构造函数指针放置在以 .CRT$XCU 命名的 PE/COFF 段中。PE 格式的链接器会将名称前缀相同的段按字母序合并,因此 .CRT$XCA.CRT$XCU.CRT$XCZ 最终形成一个连续区域——XCAXCZ 分别由 CRT 启动代码定义为数组的起始和结束哨兵,XCU 则是用户目标文件贡献的构造函数指针。启动函数 mainCRTStartup 内部调用 _initterm 函数,该函数接收起始和结束指针作为参数,遍历区间内所有非空的函数指针并依次调用:

// MSVC CRT 中 _initterm 的简化实现
typedef void (__cdecl *PVFV)(void);

void __cdecl _initterm(PVFV *pfbegin, PVFV *pfend) {
    for (; pfbegin < pfend; ++pfbegin) {
        if (*pfbegin != NULL) {
            (**pfbegin)();
        }
    }
}

MSVC 的析构处理同样依赖 atexit 机制。每个全局对象在构造完成后,通过 atexit 注册其析构函数。与 glibc__cxa_atexit 携带 dso_handle 参数不同,MSVCatexit 不直接关联模块信息,因此在涉及 DLL 动态加载和卸载的场景中,需要更谨慎地管理全局对象的生命周期。MSVC 同样保证 atexit 注册的函数以 LIFO 顺序调用,析构顺序与构造顺序相反。此外,MSVC 将初始化函数指针段进一步细分为 .CRT$XI(C 初始化,如浮点环境设置)和 .CRT$XC(C++ 构造),确保 C 层面的初始化始终先于 C++ 全局对象构造执行。

对比项 glibc / GCC MSVC
构造函数指针段 .init_array .CRT$XCU
段合并方式 链接器合并同名段 PE 按段名字母序合并
遍历调用函数 __libc_csu_init _initterm
析构注册机制 __cxa_atexit(含 dso_handle atexit
优先级控制 init_priority 属性 段名字母序(如 XCL 先于 XCU
旧方案 .ctors / .dtors 无明显历史迁移

需要注意的是,C++ 标准并未规定不同翻译单元之间全局对象的构造顺序——这就是著名的静态初始化顺序问题Static Initialization Order Fiasco)。如果两个分别定义在不同 .cpp 文件中的全局对象存在依赖关系,其构造顺序取决于链接器处理目标文件的先后,这在不同构建配置下可能产生不同的结果。常用的规避方案是采用函数内静态局部变量(即 Meyers' Singleton),利用 C++11 保证的线程安全局部静态初始化语义,将构造时机推迟到首次使用时,从而消除跨翻译单元的顺序依赖。







Alt

Once Day

也信美人终作土,不堪幽梦太匆匆......

如果这篇文章为您带来了帮助或启发,不妨点个赞👍和关注!

(。◕‿◕。)感谢您的阅读与支持~~~

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐