
博主介绍程序喵大人35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章首发gzh见文末记得订阅专栏以防走丢C基础系列专栏C语言基础系列专栏C大佬养成攻略专栏C训练营个人网站好文推荐【AIAgent项目】从零构建一个代码PRAgent【C入门】编译链接模型 - 01 一个 C 程序是怎样变成可执行文件的【C入门】编译链接模型 - 02 预处理把头文件怎样塞进源文件你在编辑器里打开main.cc看到#include math.h自然觉得编译器也能“看到”math.h。实际上编译器根本不看math.h这个文件。它看的是预处理之后的那份合成文本main.cc加上它通过#include直接和间接拉进来的所有头文件内容经过宏替换和条件编译剪裁之后的最终形态。C 标准给这份合成文本起了一个专门的名字叫翻译单元translation unit。一个翻译单元对应一个.cc文件及其预处理展开后的完整文本。项目里有几个.cc经过预处理就形成几个翻译单元编译器独立处理每一个处理顺序无关。拿出实际的代码来看这个过程。准备三个文件math.h声明Add函数math.cc提供Add的实现main.cc调用Add。// math.h#ifndefMATH_H_#defineMATH_H_intAdd(intleft,intright);#endif// math.cc#includemath.hintAdd(intleft,intright){returnleftright;}// main.cc#includemath.h#includeiostreamintmain(){std::coutAdd(20,22)\n;return0;}验证的第一步是确认两个.cc可以各自独立编译成目标文件clang-stdc20-Wall-c main.cc-o main.o// 通过clang-stdc20-Wall-c math.cc-o math.o// 通过-c的含义是“只编译不链接”。执行第一条命令时math.cc甚至不需要存在于磁盘上。编译器处理main.cc这个翻译单元时面对的是main.cc预处理后的文本最先是math.h里Add的函数声明也就是int Add(int, int);这一行接着是iostream展开后近十万行的标准库声明最后是main函数的定义。编译器从这份文本里读到了Add的声明于是它有能力检查Add(20, 22)这个调用点参数数量对不对、每个参数的类型能不能隐式转换到int、返回值类型和运算符的期望是否匹配。这些检查全部在翻译单元内部完成无需任何外部信息。但编译器在main.cc的翻译单元里找不到Add的函数体。函数体在另一个文件math.cc里而编译器处理main.cc时根本不会去读math.cc。编译器的处理逻辑是声明说这个函数存在参数类型也吻合这一关算过。函数体在哪里、是不是真的存在留给链接器去操心。编译器在产出的main.o中为Add创建一个“未定义符号”记录标注“此处需要一个外部符号Add请后续阶段填入地址”。math.cc编译时的情况刚好反过来预处理展开后编译器看到了math.h的声明和Add的函数体它为Add生成机器码产出的math.o中写入了Add的“已定义符号”记录标注“此处提供Add的实体谁需要可以来取”。两个翻译单元在编译期完全独立彼此不知道对方的存在。编译器处理main.cc时看不到math.cc里的函数体处理math.cc时看不到main.cc里的调用点。这是 C 分离编译separate compilation模型的核心机制每个源文件是一个独立的编译单位编译器的语法检查、类型检查和代码生成全部限定在当前翻译单元的文本范围内。跨翻译单元的信息交换全部推迟到链接阶段通过目标文件中的符号表来完成。分离编译的直接工程收益是增量构建。修改math.cc中Add的实现后只需重新编译math.cc这一个翻译单元生成新的math.o再和未变化的main.o重新链接。main.cc的翻译单元不需要重编因为它的预处理输入main.cc加上它包含的所有头文件的内容没有任何变化。在一个中型 C 项目里源文件数量轻松到达几十上百个全量编译可能要几分钟甚至十几分钟增量编译只重编受影响的几个翻译单元时间降到几秒。构建系统Make、Ninja 等的核心任务之一就是追踪每个翻译单元的输入依赖源文件加上它通过#include直接和间接依赖的所有头文件一旦某个输入文件发生变化该翻译单元就需要重新编译。以 Ninja 构建系统为例开发者每次执行构建时Ninja 会比较每个翻译单元的所有输入文件的时间戳或内容哈希只对输入发生过变化的翻译单元调用编译器。CMake 的依赖扫描机制会自动为每个编译目标生成.d依赖文件记录该翻译单元确切引用了哪些头文件包括间接包含使得依赖追踪精确到文件级别避免因为一个未被实际包含的头文件变化而触发不必要的重编译。反过来看分离编译也决定了头文件改动的高昂代价。头文件本身不属于任何一个翻译单元但它被 include 进多个翻译单元。改动一行头文件每个 include 了它的翻译单元的预处理结果都会变化导致所有相关翻译单元都需要重新编译。即使那些.cc文件的逻辑完全没有改动只要预处理输入变了编译器就必须重新处理整个翻译单元。在大型 C 项目中避免在广泛包含的头文件中引入不必要的改动是控制编译时间的一个几乎本能的工程习惯。有些团队甚至会统计每个头文件的“包含扇出”fan-out即有多少个翻译单元直接或间接地 include 了它以此评估修改该头文件的编译代价。编译器在翻译单元内部做的检查相当全面。语法错误比如少了一个分号或者括号不匹配、类型不匹配比如把std::string传给接受int形参的函数、缺少声明比如用了一个从未声明过的标识符、访问权限违规比如在类外访问private成员、模板实参推导失败这些全都在单个翻译单元的范围内就能判定。编译器的诊断能力来自它对翻译单元内全部信息的完整掌握所有类型定义、所有函数声明、所有模板定义、所有变量的声明只要在当前翻译单元的文本中可见编译器就能基于它们做出判断。编译器管不到的那些问题则会在链接阶段暴露。最典型的跨翻译单元问题有两类。第一类是声明与定义不一致。main.cc的翻译单元和math.cc的翻译单元分别 include 了同一个math.h按理说它们对Add的签名应该达成共识。但如果有人直接绕过math.h在main.cc里手动写了double Add(double, double);这样的声明同时在math.cc里仍然用math.h的int Add(int, int)定义两个翻译单元各自编译都能通过各自声明自洽链接阶段却会因为符号名不匹配而失败。C 的函数重载依赖 name mangling 把参数类型编码进符号名Add(int, int)和Add(double, double)生成的符号名完全不同链接器找不到匹配的符号报undefined reference。第二类是遗漏定义。main.cc的翻译单元里声明了int Add(int, int)编译器相信这个声明让调用点通过了类型检查。但这个声明对应的函数体从未在任何翻译单元中被定义。编译器对这一情况毫不知情它不跨翻译单元检查链接器在遍历所有目标文件后发现了这个无处可落的未定义符号报出另一个经典的undefined reference。理解这两类问题之后可以做一个实验来加深直觉。故意只链接main.o而漏掉math.oclangmain.o-o app链接器会输出undefined reference to Add(int, int)并拒绝生成可执行文件。注意这个错误的措辞它说的是“没找到对 Add 的引用对应的定义”跟“没找到 Add 的声明”是两回事。编译阶段已经确认声明存在链接阶段的报错指向的是缺失的实体。补上math.o后一切正常clangmain.o math.o-o app./app # 输出42这两条命令的对比把分离编译模型的两个阶段呈现得非常清楚。编译期只验证接口合规性链接期才验证实体完整性。这个两阶段模型是排查 C 多文件程序错误的基本心智框架。头文件在这个模型中的角色是翻译单元之间的接口合约。math.h里int Add(int, int);这一行声明是Add函数对所有翻译单元做出的统一承诺。所有 include 了math.h的翻译单元都对Add的签名形成了同一个理解编译器用这个理解来检查每个翻译单元内的调用点和定义点。如果多个翻译单元看到的声明不一致C 标准将其归为 IFNDRill-formed, no diagnostic required通俗说就是“程序不合法但编译器不保证能检测出来”实际工程中表现为难以追踪的链接错误或运行时异常。一个容易漏掉的细节是同一个头文件在不同的翻译单元里展开后的实际内容可能不同。如果math.h的内容受到宏状态的影响比如#ifdef EXTENDED_MATH条件下多声明了一个函数而两个.cc分别在 include 它之前定义和不定义EXTENDED_MATH那么两个翻译单元看到的接口就不一致。编译器各自独立检查各自都能通过链接阶段却可能因为一个.o期望的符号在另一个.o中不存在而出错。保持头文件自包含self-contained并且在 include 头文件之前不依赖特定的宏状态是避免这类问题的基本原则。翻译单元的边界还决定了符号的可见性。在 C 里写在一个.cc文件中的普通函数和全局变量默认具有外部链接external linkage它们产生的符号对所有翻译单元可见可以在链接阶段被其他目标文件引用。如果某个函数只在当前的.cc中使用把它放进匿名命名空间unnamed namespace或者用static修饰它的链接属性就变成内部链接internal linkage符号只在当前翻译单元内部可见不会暴露给链接器其他翻译单元的同名符号与之完全无关。内部链接机制在工程中的实际价值是避免符号冲突。假设两个不同的.cc文件里各有一个叫Helper的自由函数各自完成完全不同的辅助工作。如果两个Helper都使用默认的外部链接链接器会在两个目标文件中看到两个同名的外部可见符号报multiple definition错误即使这两个函数的实现完全不同。把Helper放进各自.cc的匿名命名空间中两个Helper的符号分别限定在各自的翻译单元内互不影响。这是 C 推荐的替代 C 风格static全局函数的方式。匿名命名空间的机制背后是编译器偷偷给每个翻译单元的匿名命名空间分配一个唯一的内部名字所以不同翻译单元里的匿名命名空间在符号层面是完全不同的命名空间自然不会产生符号冲突。static全局函数在 C 时代是标准做法C 保留它仅为了兼容新代码建议统一用匿名命名空间。另外const修饰的全局变量默认也是内部链接这一点和普通全局变量不同。如果你在头文件里定义const int kValue 10;每个 include 它的翻译单元各有一份独立的副本不会触发multiple definition因为const全局变量默认带内部链接属性。翻译单元的独立编译还有一个不太直观但很实用的推论同一个翻译单元内的静态局部变量在函数体内用static修饰的局部变量和全局变量其初始化顺序在同一个翻译单元内按照定义顺序执行跨翻译单元则没有确定顺序。这个事实有时被称为“static initialization order fiasco”是 C 中一个需要额外注意的工程陷阱。本系列不展开这个话题但理解它的根因仍然在翻译单元的独立编译模型上编译器无法在处理一个翻译单元时预先规划另一个翻译单元中全局对象的初始化时机。翻译单元这个概念和模板编程有直接的因果关系。模板定义通常需要放在头文件中原因不在模板的语言规则本身而在编译器的翻译单元模型编译器在实例化模板时需要看到完整的模板定义不仅仅是声明而编译器一次只处理一个翻译单元它无法跨翻译单元去另一个.cc文件中寻找模板的定义体。反过来说如果把模板定义放在.cc文件中只有该.cc自己的翻译单元内部才能实例化那个模板其他翻译单元只看到声明却看不到定义实例化会失败。这是所有模板库STL、Boost 等大量使用 header-only 模式的技术根因。模板的翻译单元行为还带来一个后续问题同一个模板的同一组模板实参的实例化代码可能在多个翻译单元的目标文件中各出现一份完全相同的代码。链接器需要能够识别并合并这些重复的实例化代码这个过程叫“链接器去重”。这个现象同时说明了为什么 ODR 需要允许类定义、模板定义和内联函数定义进入多个翻译单元如果不允许header-only 的模板库根本无法工作。第 5 章会具体展开 ODR 的这些细节规则。一个值得纠正的直觉是“源文件”和“翻译单元”的对应关系。日常交流中说“一个.cc是一个翻译单元”已经足够接近真相但严格来说翻译单元不包含被预处理指令丢弃的文本比如#ifdef未命中分支中的代码也不包含预处理指令本身。翻译单元指的是预处理之后的合成文本而不是磁盘上的那个.cc文件。这个区分在实际排查中偶尔有用当条件编译剪掉了你以为应该被编译器看到的代码时预处理输出能帮你看到翻译单元的真实面貌。翻译单元还解释了为什么很多 C 项目的增量编译会被一个小小的头文件改动拖慢。构建系统通常以翻译单元为最小重编译单位某个.cc直接或间接依赖的头文件变了这个.cc对应的翻译单元就需要重新生成并重新编译。一个底层公共头文件可能被几十个甚至几百个.cc间接包含改动一行声明就会让一大片目标文件失效。把稳定接口放进头文件把实现细节留在.cc本质上是在控制翻译单元之间的依赖传播范围。回看整条编译链路翻译单元是源码和编译器之间的桥梁。它把程序员组织在多个文件中的源码.cc和.h的分工汇聚成编译器可消费的单一文本输入同时把编译器的管辖权明确限定在这个单一文本的范围之内。编译器对翻译单元内的一切可以给出确定性的验证结论语法正确、类型匹配、声明存在对翻译单元之外的一切编译器只能选择信任声明把实体查找推迟给链接器。从翻译单元的角度审视工程决策会自然得出几条原则。头文件的内容会被复制进每一个 include 它的翻译单元因此头文件应当尽量精简只放需要跨翻译单元共享的接口函数声明、类型定义、模板定义实现细节尽量放在.cc文件中。翻译单元之间的接口一致性依赖头文件来保证因此头文件需要自包含不依赖特定的 include 顺序或外部宏状态。改动头文件的代价比改动.cc高得多因此在设计头文件时需要比.cc文件更谨慎地考虑稳定性和向后兼容性。下一章把翻译单元内的编译器检查落实到一个更具体的语言层面声明告诉编译器“有这个实体”定义才真正提供这个实体。理解了翻译单元之后声明和定义在不同翻译单元中分别扮演的角色就变得非常直观。多文件工程里的很多报错本质上都在这条分界线上发生。把这条边界记住后面看声明、定义和符号会顺很多也会更稳。码字不易欢迎大家点赞关注评论谢谢