我们平时习惯这样敲一行命令:
g++ main.cpp math_utils.cpp -o app
或者在 IDE 里点一下「Build」,几秒钟后就多了一个可以运行的程序。表面上看,只是「把几个 .cpp 编译一下」;但在这行命令背后,其实藏着一条不短的装配线:
预处理 → 编译 → 汇编 → 链接
每一步都在悄悄改变你那几行 C++ 代码的“形态”。
这一篇,我们就系统性地把这条流水线拆开来看看:
#include本质上只是一种“高级版复制粘贴”;- 编译器真正看到的不是你的
.cpp,而是“翻译单元”; - 每个
.o/.obj都是一块带符号表的“半成品二进制”; - 链接器就是最后那个把所有半成品拧成一个整体的人;
- 常见的链接错误(
undefined reference、multiple definition等)到底在抱怨什么。
你在前一篇已经看过“声明 / 定义分离”和头文件的故事,这一篇会把视角再拉高一点:从整条流水线上理解 C/C++ 的编译与链接模型。
1. 从一行 g++ 命令看“黑盒流程”
先从这行最常见的命令开始:
g++ main.cpp math_utils.cpp -o app
肉眼看到的是:
- 输入:两个源文件
main.cpp、math_utils.cpp; - 输出:一个可执行文件
app。
但在编译器和工具链内部,大致会经过这几个阶段:
-
预处理(Preprocessing)
处理所有以#开头的指令:#include、#define、#if...
结果是生成一个“展开了所有头文件、宏”的中间文本。 -
编译(Compilation)
把预处理后的 C++ 源码,翻译成汇编代码(或直接翻成机器码)。 -
汇编(Assembly)
把汇编代码转成二进制的目标文件(.o/.obj),同时写上一些符号和重定位信息。 -
链接(Linking)
把多个目标文件和库(.a/.lib/.so/.dll)拼在一起,解决所有“谁调用了谁”的关系,生成最终的可执行文件或新的库。
很多教材会把这四步画成一个方块图,但如果你心里没有一个更具体的画面,很难在看到错误信息时反应过来:
“哦,这个错误其实是链接阶段在叫,而不是编译阶段。”
下面就按顺序,把这四个阶段具体化。
2. 预处理:#include 是怎样的“复制粘贴机”?
你常见的 C++ 源文件,大概长这样:
#include <iostream>
#include "math_utils.hpp"
int main() {
std::cout << add(1, 2) << '\n';
}
在真正开始“理解 C++ 语法”之前,预处理器会先做一件看似粗暴的事情:
- 碰到
#include <...>/#include "...",就把对应头文件的内容原封不动地拷贝进来; - 碰到
#define,就按照规则做简单的文本替换; - 根据
#if/#ifdef等条件,裁剪掉某些代码块。
所以,在预处理结束后,编译器眼里看到的,不再是多文件的工程,而是一大坨已经展开了所有 #include 的纯文本源码。如果你在命令行上加一些选项(比如 -E),甚至可以让编译器只做这一步,把结果吐出来:
g++ -E main.cpp > main.ii
main.ii 里往往是几千上万行:
- 你的
main.cpp那几行, - 再加上
<iostream>里的一切声明, - 再加上
math_utils.hpp里的声明, - 以及这些头文件自己
#include的其他头文件……
这整个东西,加上它所包含的所有头文件, 就构成了一个 翻译单元(translation unit)。
之后的步骤,都是以“翻译单元”为单位来进行的,而不是你肉眼看到的 main.cpp / math_utils.cpp 这些源文件本身。
3. 翻译单元:编译器真正处理的“一个文件”
在前一篇讲“声明与定义”时,你已经见过一个事实:
编译器在处理某个
.cpp时,看不到其他.cpp的内容。
现在可以把这句话说得更精确些:
编译器每次只处理 一个翻译单元, 它看到的是“一个
.cpp经过预处理后拼起来的总和”。
比如,你有这两个源文件:
main.cpp:#include "math_utils.hpp"+main函数;math_utils.cpp:同样#include "math_utils.hpp",再加上add等函数的定义。
预处理后,会得到两个看起来很像,但并不相同的翻译单元:
TU_main:math_utils.hpp的声明 +main的定义;TU_math_utils:math_utils.hpp的声明 +add等函数的定义。
每一个翻译单元,后面都会走完“编译 → 汇编”这两步,生成一个对应的目标文件:
TU_main→ 编译/汇编 →main.o;TU_math_utils→ 编译/汇编 →math_utils.o。
正因为编译器只知道当前翻译单元里有什么定义,其他都当成“外部的”,才需要你在头文件里写声明,告诉它:“别担心,add 在别处会有一个定义,签名长这样。”
关于声明 / 定义和头文件,你可以回头再翻一眼前一篇;这一篇更关注的是:从翻译单元到目标文件,再到最后链接在一起时,发生了什么。
4. 编译:把 C++ 变成汇编
有了翻译单元之后,编译器终于可以开始做它的“本职工作”了:
- 词法分析、语法分析:把源码拆成 token,构建语法树;
- 语义分析:做类型检查、名字解析、模板实例化等;
- 中间表示和优化:把代码翻译成内部 IR,做各种优化;
- 生成汇编代码或机器码。
从你的角度看,最重要的事实只有一个:
对于每个翻译单元,编译器会单独生成一份对应的机器代码, 这些机器代码暂时被“装”在一个目标文件里,等待后面的链接。
在这个阶段,如果你写错了类型、少写了分号、模板参数对不上,大部分错误都会在“编译阶段”被报出来。典型的比如:
no matching function for call to ...cannot convert 'X' to 'Y'之类的类型转换错误;- 模板实例化失败,长篇大论的 error log。
只要能顺利生成 .o / .obj 文件,就说明编译阶段基本过关了,接下来轮到汇编器和链接器出场。
5. 汇编与目标文件:带符号表的“半成品二进制”
很多时候,编译器会先生成一份汇编代码,再交给汇编器(assembler)转成机器码,写进目标文件中;有的实现会直接从中间表示生成机器码,但对你来说效果差不多:
- 得到一个
.o(Linux/macOS)或.obj(Windows)文件; - 里面已经是真实的机器指令,但还不能单独运行。
为什么不能直接运行?因为它还缺两样东西:
- 其他目标文件 / 库里的实现(比如
std::cout,printf等); - 一些“地址信息”还没有最终确定。
为了把这些事留到链接阶段解决,目标文件里除了代码段和数据段,还会额外放几样“元数据”:
- 符号表(symbol table):
- 哪些函数 / 全局变量是“我这边定义的”;
- 哪些名字是“我需要别人提供的”。
- 重定位信息(relocation entries):
- 某条指令里的第 N 个字节,是一次对
foo函数的调用; - 某个数据里的某个偏移,是指向
global_var的地址。
- 某条指令里的第 N 个字节,是一次对
你可以把每个 .o 想象成一块半成品零件:
- 上面已经刻好了真实的齿轮、孔位(机器码);
- 旁边附带着一本“接线说明书”(符号表 + 重定位信息);
- 真正的“装配”(把所有零件对齐、螺丝拧紧)要到链接器那里才完成。
6. 链接:把所有半成品拧成一个程序
现在,假设我们已经有了两个目标文件:
main.o:里面用到了add函数,但自己没实现;math_utils.o:里面实现了int add(int, int)。
再加上一些标准库的目标文件 / 库,链接器的任务就是:
- 把所有要链接的目标文件 / 库的符号表汇总起来;
- 对每一个“未定义的符号”,在别的目标文件里找一个“恰好定义了这个符号”的实现;
- 根据重定位信息,
- 把调用
add的那条指令里的地址补全; - 把指向全局变量的指针补上真实地址;
- 把调用
- 把所有代码段、数据段排个最终“座位表”,写进一个可执行文件或新的库里。
一旦在某个环节出了差错,链接器就会抱怨,最典型的两类错误是:
-
undefined reference to 'foo':
有地方用了foo,但在所有输入的目标文件 / 库里,都找不到对应的定义。 -
multiple definition of 'foo':
至少在两个不同的目标文件里,都看到了foo的定义,它不知道该选哪一个(这就是前一篇讲过的 ODR 违背)。
如果一切顺利,链接器就会生成:
- 一个可执行文件(Linux 上常见的是 ELF 格式);
- 或者一个新的库(静态库 / 动态库)。
从这一步开始,操作系统就能接手,把它加载到内存里运行了。
7. 静态库 vs 动态库:链接到哪一步为止?
光有 .o 还不方便分发,于是人们又在链接器前面加了一个小工具:
- 把一堆
.o打包成一个 静态库(.a/.lib)。
7.1 静态库:提前把代码“拷贝”进来
静态库本质上就是“.o 文件的压缩包 + 目录”:
- 链接时,如果你的程序用到了某个符号,比如
foo; - 链接器会在指定的静态库里查找,看看有没有某个
.o定义了foo; - 如果有,它就把对应的那几个
.o解包、拷贝进最终的可执行文件; - 如果没有,就继续在其他库或目标文件里找。
所以,使用静态库时:
- 最终生成的可执行文件里,已经包含了这些库的代码;
- 部署时,可以只带一个可执行文件,不必额外拷贝
.a。
7.2 动态库:把一部分工作推迟到程序启动时
动态库(Linux 上的 .so,Windows 上的 .dll),做的是另一种取舍:
- 链接阶段只记录“这里将来要用到
libX.so里的foo”; - 不把那部分代码拷贝进可执行文件;
- 由操作系统的加载器(loader)在程序启动或运行过程中,把需要的
.so/.dll映射进内存,并把符号绑定好。
好处包括:
- 多个进程可以共享同一份动态库代码,节省内存;
- 升级库时,可以在不重新链接主程序的前提下替换
.so(前提是接口兼容)。
坏处则是:
- 部署时要注意把对应的
.so/.dll带上; - 启动时如果找不到库或符号,就会在运行时失败(例如 Linux 上的
error while loading shared libraries)。
无论静态还是动态,链接器都在扮演“撮合符号”的角色:
- 只是在静态库场景下,它负责把代码“剪切粘贴”进你的可执行文件;
- 在动态库场景下,它更多是登记一本“借书清单”,真正的“拿书”动作推迟到运行时。
8. 把常见错误对号入座
理解了上面这些环节,再看经常见到的报错,就不那么抽象了。
8.1 undefined reference to ...
- 出现阶段:链接阶段;
- 含义:
- 某个翻译单元里用了
foo(在符号表里作为“未定义符号”挂着); - 但在所有参与链接的目标文件 / 库里,都找不到
foo的“定义”。
- 某个翻译单元里用了
常见原因:
- 写了函数声明,但忘了写定义,或者定义没被编进当前工程;
- 库名写错 / 链接顺序不对(在某些平台上,静态库的链接顺序是有影响的);
- 只写了模板声明,没有把模板定义放在头文件里,导致某个实例化的定义缺席。
8.2 multiple definition of ...
- 出现阶段:同样是链接阶段;
- 含义:
- 链接器在两个或更多目标文件里,都看到了同一个符号的定义;
- 它不知道该选哪一个,按照 ODR 规则,这属于错误。
常见原因:
- 在头文件里直接定义了非
inline的全局函数或变量,并被多个.cpp#include; - 某个
.cpp被错误地编译了两次、或同时被两个目标 / 库引用; - 忘记给只应有一份实例的东西(如全局对象)加上
extern声明、把定义集中到一个.cpp。
8.3 cannot find -lxxx / No such file or directory
- 出现阶段:链接命令本身就执行失败;
- 含义:
- 你在命令行或构建脚本里写了
-lxxx或指定了某个库路径; - 链接器在搜索路径里根本找不到这个库文件(
libxxx.a/libxxx.so等)。
- 你在命令行或构建脚本里写了
常见原因:
- 库没有安装 / 没放在链接器搜索路径里;
- 写错了库名或路径;
- 在不同平台上混用了不兼容的库(二进制 ABI 不匹配等)。
一旦你能把这些错误和“流水线的哪一步”对应起来,排查问题的思路就会清晰很多:
- 语法 / 类型错误:优先怀疑编译阶段;
undefined reference/multiple definition:优先怀疑链接阶段和符号导出 / 引入;- 找不到库文件:优先检查构建脚本和链接命令本身。
9. 小结:把黑盒拆开以后
这一篇,我们从一行 g++ main.cpp 出发,沿着工具链的路径往下走了一圈:
- 预处理阶段把所有
#include和宏展开,形成一个个翻译单元; - 编译阶段在每个翻译单元内部做语义检查和优化,生成汇编 / 机器码;
- 汇编阶段把这些指令和数据写进目标文件,顺带附上一本“符号和重定位说明书”;
- 链接阶段根据这些说明书,把所有半成品拼成一个整体,并解决所有符号引用。
把这条流程和前一篇“声明 / 定义分离”的内容放在一起看,你会发现它们严丝合缝地扣在了一起:
- 头文件里的声明,服务的是“单独编译”的需要,让每个翻译单元在看不到别的
.cpp时也能完成类型检查; - 源文件里的定义,服务的是“唯一实现”的需要,让链接器在全局范围内总能为每个符号找到且只找到一份真正的实现;
- 链接错误,则是当这两者之间失衡时,系统给你的“最后通牒”。
等你脑子里有了这样一条“从源文件到程序”的时间线,再看到各种“未定义引用”“多重定义”报错时,就不再只是照着报错堆命令行,而是能顺着这条线往回推:
它是在流水线的哪一步抱怨?
这一步需要哪些前提条件?
我是不是在某个翻译单元、某个头文件、某个库上漏了一环?
回答清楚这些问题,你和“黑盒”之间的距离,就又近了一大步。