C++ 编译与链接
C++ 源文件通常分别编译成目标文件,再由链接器把符号引用与定义连接起来。一个 .cpp 能通过编译,不代表整个程序已经完整:声明可以让编译器检查调用是否合法,但真正的函数定义可能还没有加入链接。理解这条链路,才能区分语法错误、缺少定义、重复定义与运行时缺少库。本文使用 C++17 和 Clang 命令演示。
1. 面试核心
先记住这几条,再读正文- 预处理处理头文件、宏和条件编译,编译分析程序并生成低层表示,汇编形成目标文件,链接负责解析跨目标文件的符号与重定位。卡 · 翻译流程与产物
- 一个源文件经预处理后构成翻译单元,头文件的内容会进入包含它的各个翻译单元;头文件保护不等于跨文件的重复定义保护。
- 声明告诉编译器实体的类型与名字,定义提供实体本身,
extern声明不等于自动创建共享变量。卡 · 声明、定义与跨文件调用 inline不保证机器码内联展开,其语言规则还允许符合条件的同一实体在多个翻译单元中定义。卡 · ODR、inline 与优化- 静态链接和动态链接影响代码的组合、依赖与装载方式,不能脱离裁剪、符号和构建选项断言库文件谁一定更大。卡 · 静态库、动态库与装载
2. 翻译流程与产物
翻译单元可以理解为一个源文件加上经过预处理后进入其中的声明和定义。编译器通常按翻译单元分别工作,之后再合并产物。
| 阶段 | 主要工作 | 典型产物或现象 |
|---|---|---|
| 预处理 | 展开 #include、宏和条件编译,处理预处理指令 | 展开后的源文本 |
| 编译 | 词法语法分析、类型和语义检查、生成中间表示、优化及代码生成 | 汇编代码或其他中间产物 |
| 汇编 | 将汇编转换为目标机器指令并记录符号、重定位信息 | .o 或 .obj 目标文件 |
| 链接 | 解析引用、组合节、处理重定位和依赖 | 可执行文件、共享库等 |
| 装载与运行 | 映射程序和所需库,建立运行环境,执行启动代码 | 运行中的进程 |
这是便于分析的工程阶段,不要求每个编译器都把每一步写成独立文件。工具链可以把阶段组合起来;链接时优化 LTO 还可以在跨翻译单元信息可用后继续优化。
目标文件通常不只是“机器码”。它还包含后续链接需要的符号与重定位信息,例如“这里调用的 increment 应该连接到哪个定义”。因此编译时允许有尚未解析到地址的外部引用。
3. 声明、定义与跨文件调用
声明介绍名字、类型等信息,定义进一步提供函数体或对象所需的定义。一个实体可以有相容的多次声明,但定义必须满足单一定义规则。
下面三个文件组成一个完整例子。
counter.h:
#ifndef COUNTER_H
#define COUNTER_H
extern int counter;
void increment();
#endif
counter.cpp:
#include "counter.h"
int counter = 0;
void increment() { ++counter; }
main.cpp:
#include <cassert>
#include "counter.h"
int main() {
increment();
assert(counter == 1);
}
extern int counter; 是声明,存储与初始值由 counter.cpp 中的定义提供。extern int counter = 0; 则含初始化,会成为定义,不能仅因出现 extern 就把它当声明。
可以分别检查各阶段:
clang++ -std=c++17 -E main.cpp -o main.ii
clang++ -std=c++17 -S main.cpp -o main.s
clang++ -std=c++17 -c main.cpp -o main.o
clang++ -std=c++17 -c counter.cpp -o counter.o
clang++ main.o counter.o -o counter-demo
./counter-demo
最后程序正常结束,断言成立。只用 main.o 链接时会缺少 increment 和 counter 的定义;这与函数调用写错参数而在编译阶段失败不同。头文件保护只避免同一个翻译单元重复展开头文件,并不能阻止两个 .cpp 各自包含同一份普通变量定义后发生链接冲突。
链接属性决定不同声明是否可以指向同一实体。命名空间作用域的 static 函数或变量通常具有内部链接,仅属于当前翻译单元;外部链接实体则可以跨翻译单元连接。这里的 static 与类静态成员、局部静态变量的其他作用需要按上下文区分。
4. ODR、inline 与优化
单一定义规则(One Definition Rule,ODR)约束同一实体的定义。它不等于“整个程序任何东西只能出现一次”:类、模板、符合条件的 inline 实体可以在多个翻译单元中具有匹配定义,但仍须满足相同定义及名字查找等要求。违反规则不保证一定被链接器诊断。
// 可以放在头文件,供多个翻译单元包含。
inline int doubled(int value) { return value * 2; }
// C++17 支持 inline 变量。
inline int shared_counter = 0;
在符合 ODR 条件时,这些外部链接的 inline 实体在整个程序中仍表示同一个实体。inline 并不要求编译器在每个调用处复制函数机器码,未写 inline 的函数也可能被优化为内联调用。关键字的语言规则与优化器的实际展开是两层问题。
编译器还可能进行常量传播、无用代码删除、公共表达式消除和循环相关优化。很多优化使用中间表示(IR)来分析程序,而不直接操作源代码文本。静态单赋值形式(SSA)让每个名字版本只被赋值一次,使定义与使用的关系更明确。例如源代码先后对 value 赋值,在 IR 中可以表示为 value1、value2;控制流汇合处再表示选择哪个定义。
SSA 是帮助优化分析的表示方式,不是“变量不能改变”的源语言限制,也不意味着某个优化一定启用。分析优化结果时,要说明编译选项和可观察行为;包含未定义行为的程序不能用来证明编译器必须保留某种输出。
5. 静态库、动态库与装载
静态库通常是目标文件的归档。链接器从中选取需要的目标文件或代码,再按具体规则组合与裁剪;静态库本身不会让代码自动运行。动态库提供运行时可加载的共享代码与符号接口,可由程序装载时依赖,也可以在运行期间显式打开。
| 比较项 | 静态链接相关行为 | 动态链接相关行为 |
|---|---|---|
| 程序产物 | 需要的库代码可以进入最终程序 | 程序记录对动态库的依赖或显式加载路径 |
| 更新与兼容 | 更新库通常需要重新链接和交付程序 | 可以替换兼容库,但必须满足 ABI、签名和平台装载规则 |
| 运行依赖 | 减少相应外部库文件依赖,但不代表完全没有系统依赖 | 需要可找到且兼容的库及其间接依赖 |
| 大小与内存 | 取决于选取、裁剪、重复代码和调试信息 | 取决于导出、重定位、符号与平台共享机制 |
不能直接断言同一份源码生成的静态库一定大于或小于动态库。库文件可能保留不同的目标文件、调试符号和导出信息;最终应用大小还受链接器裁剪和是否打包库影响。应先统一构建模式、架构、符号与统计对象,再比较。
ABI 是二进制接口约定,涉及调用约定、符号命名、对象布局等。两个库的 C++ 头文件看起来一致,不代表不同工具链产物必定兼容。extern "C" 可以声明 C 语言链接,常用于互操作边界,但不会自动让任意 C++ 类布局变成 C 可用接口。
移动平台还需要检查架构、系统 API 可用性、签名与包内库位置。能在本机链接并不能证明 Android 或 iOS 装载成功;这部分由各自的构建与原生接口文章继续解释。
6 · 面试题卡片
本页题目与参考回答
6.1 C++ 源代码到可执行程序经历哪些阶段?
考察重点:预处理、语义检查、目标文件、符号和装载。
相关内容:翻译流程与产物、声明、定义与跨文件调用。
参考回答
通常先预处理头文件和宏,再编译、汇编形成目标文件,最后链接符号与重定位生成程序。 编译会完成语法和类型等检查,并经中间表示优化、生成低层代码;目标文件可以保留未解析的外部引用。运行时装载器再映射程序和依赖,建立执行环境。具体工具可以合并阶段,LTO 也会把部分优化推迟到链接阶段。
6.2 extern 声明为何能编译,却仍可能链接失败?
考察重点:声明、定义和跨翻译单元连接。
相关内容:声明、定义与跨文件调用。
参考回答
声明只提供名字与类型,允许编译器检查使用方式,不保证定义已经参与最终链接。 extern int counter; 需要相应定义;如果定义所在目标文件或库漏加,链接仍会失败。带初始化的 extern int counter = 0; 是定义,不能重复放进普通头文件来“共享”变量。头文件保护只管同一翻译单元的重复包含。
6.3 inline 是否保证函数在调用处展开?
考察重点:关键字规则、ODR 与编译优化。
相关内容:ODR、inline 与优化。
参考回答
不保证,是否进行机器码内联由实现和优化条件决定。 inline 的重要语言作用是允许符合 ODR 条件的实体在多个翻译单元中定义,仍表示同一实体。没写 inline 也可能被优化展开;写了 inline 也不能允许不同翻译单元放入互相矛盾的定义。
6.4 静态链接和动态链接如何区别,库文件大小能直接比较吗?
考察重点:代码组合、运行依赖、兼容与比较条件。
相关内容:静态库、动态库与装载。
参考回答
静态链接把选取的库代码组合进产物,动态链接保留对运行时库的依赖并在相应阶段解析。 两者影响更新、部署和 ABI 兼容,但不能直接断言库文件谁更大。需要统一架构、优化、调试符号、导出与裁剪条件,并明确比较的是库文件、最终程序还是完整安装包。
7. 参考资料
- Clang:Command Guide
- C++17 草案 N4659:One-definition rule
- C++17 草案 N4659:The inline specifier
- C++17 草案 N4659:Program and linkage
- C++17 草案 N4659:翻译阶段与链接属性
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明