第二十章 编译模型:翻译单元、链接与 ODR

← 返回目录

本章目标:理解 .hpp/.cpp 如何变成可执行文件、 什么是翻译单元、链接错误原理、include guard、 内联函数/变量的链接规则、静态库与动态库。 目标是:以后遇到任何编译/链接错误都能自己解决。

20.1 编译过程全景:源码 → 可执行文件

hello.cpp ──预处理──▶ hello.i ──编译──▶ hello.s ──汇编──▶ hello.o
                                                      │
libmath.a ────────────────────────────────────────────┤ 链接
                                                      ▼
                                                可执行文件

四个阶段(clang++ 一条命令全做完,但要知道它们在干嘛):

  1. 预处理:处理 #include / #define / #if

(把头文件内容"粘贴"进来,宏展开)

    clang++ -E hello.cpp        # 看预处理结果
  1. 编译:C++ 源码 → 汇编代码(最慢的一步)
    clang++ -S hello.cpp        # 生成 hello.s
  1. 汇编:汇编 → 机器码目标文件
    clang++ -c hello.cpp        # 生成 hello.o
  1. 链接:把多个 .o 合并、解析符号引用 → 可执行文件
    clang++ a.o b.o -o app

人话模型:

只记录了一个"待填的符号名"

20.2 翻译单元(Translation Unit)

一个 .cpp 文件 + 它 #include 的所有头文件(预处理后) = 一个翻译单元。编译器的基本工作单位。

关键推论(无数 bug 的根源):

  1. 每个翻译单元各自独立编译,互不可见
  2. 同一个头文件被 N 个 .cpp 包含,内容就"粘贴"了 N 次
  3. 如果头文件里定义了"普通函数",N 次粘贴 = N 份定义

→ 链接时"重复定义"错误(ODR 违反)

// math.hpp
int add(int a, int b) { return a + b; }   // 普通函数定义!

// a.cpp 和 b.cpp 都 #include "math.hpp"
→ 出现两份 add 的定义 → 链接报"redefinition"

解决办法(三选一):

  1. 头文件只放声明,定义放 .cpp(最正统)
  2. inline 函数:允许重复定义,且最终合并为一份
  3. C++17 inline 变量:同理

20.3 声明 vs 定义(再强调一次)

extern int global_counter;   // 声明:告诉编译器"有这东西"
int global_counter = 0;      // 定义:真正分配存储

void f();                    // 函数声明
void f() {}                  // 函数定义

规则(ODR,One Definition Rule,一个定义原则):

20.4 include guard:防止重复包含

// myheader.hpp
#pragma once                // 现代写法(编译器都支持)
// 等价传统写法:
#ifndef MYHEADER_HPP
#define MYHEADER_HPP
...
#endif

作用:同一个翻译单元里,头文件内容只"粘贴"一次。 (防止 a.hpp 和 b.hpp 都 include c.hpp 时出现两份 c 的内容)

20.5 inline:允许多份定义的"合并"

// header.hpp
inline int square(int x) { return x * x; }   // 头文件里放函数定义
inline constexpr int LIMIT = 100;            // C++17 inline 变量

规则:inline 函数/变量允许在多个翻译单元重复定义, 链接器负责"合并成一份"。 代价:内联展开可能增加代码体积(现代编译器自行判断,inline 只是"允许多次定义"的许可证,不一定真内联)。

什么时候需要 inline?

20.6 extern:跨文件共享变量

// globals.cpp
int counter = 0;              // 定义

// use.cpp
extern int counter;           // 声明"用别人的"
void inc() { ++counter; }

注意:全局变量是魔鬼(难以测试、线程不安全、初始化顺序问题)。 尽量别用;非用不可时:

20.7 链接错误大全(附解决方案)

  1. "undefined reference to `foo()`"

原因:声明了 foo 但没实现 / 忘了链接包含实现的 .o / 库 解决:找到实现所在的 .cpp 加进编译;或 -lxxx 链接库

  1. "multiple definition of `foo()`"

原因:foo 在头文件里被定义且被多个 .cpp 包含 解决:定义移 .cpp 或用 inline

  1. "undefined reference to `vtable`"

原因:类声明了虚函数,但某个虚函数没实现 解决:给所有虚函数补实现(或 = 0 / = default)

  1. "cannot open output file" / "no such file or directory"

原因:目标目录不存在 / 输出名冲突(比如 -o 和输入同名) 解决:检查路径

  1. 模板的 "undefined reference"

原因:模板实现写在 .cpp 里,但没被实例化 解决:模板实现必须放在头文件(或显式实例化)

20.8 静态库与动态库

把一堆 .o 打包就是库。 静态库 .a:链接时把代码"复制"进可执行文件 动态库 .so:运行时才加载,可共享

# 制作静态库(Termux 有 ar)
clang++ -c a.cpp -o a.o
ar rcs libmy.a a.o
# 使用
clang++ main.cpp libmy.a -o app
# 或 clang++ main.cpp -L. -lmy -o app   (-L 找库目录,-l 库名)

# 制作动态库
clang++ -shared -fPIC a.cpp -o libmy.so
# 使用
clang++ main.cpp -L. -lmy -o app
LD_LIBRARY_PATH=. ./app    # 告诉运行时去哪找库

20.9 命名空间:避免名字冲突

namespace mylib {
    int version() { return 1; }
    namespace detail { int helper() { return 2; } }   // 嵌套
}
namespace alias = mylib;                     // 别名

mylib::version();
using namespace mylib;       // 全局引用(少用,会污染)
using mylib::version;        // 局部引用(推荐)

匿名命名空间 = 文件内部私有(效果类似 static):

namespace {
    int only_this_file() { return 3; }
}

20.10 模板与多文件:为什么实现必须在头文件

模板是"模具":编译器看到 template 定义时不生成代码, 只有"用到具体类型"时当场实例化。 如果实现放 .cpp:a.cpp 里的模板函数没人用它(没实例化), main.cpp 用它时又看不到实现 → undefined reference。

解决(只有两条路):

  1. 实现放头文件(最常用)
  2. .cpp 底部显式实例化:
   template std::vector<int> foo<int>();   // 写明实例化哪些类型

20.11 module(C++20,现代化替代头文件)

// math.cppm(模块单元)
export module math;
export int add(int a, int b) { return a + b; }

// main.cpp
import math;
int main() { return add(1, 2); }

// 编译(clang 21 支持)
clang++ -std=c++20 --precompile math.cppm -o math.pcm
clang++ -std=c++20 -fmodule-file=math.pcm main.cpp math.pcm -o app

优点:只编译一次、不污染宏、不重复解析头文件。 现状:工具链已可用,但生态仍在迁移,老项目还是 #include。

本章小结

练习题(配套测试:测试_第20章_编译模型.cpp)


  1. 用 clang++ -E / -S / -c 各跑一遍,观察中间产物。
  2. 故意制造 undefined reference 和 redefinition 各一次,

读错误信息并修复。

  1. 写一个静态库 libmy.a 并链接使用。
  2. 用命名空间组织两组同名函数。