77 / 80 · C++11 · 约 13 分钟
从源文件到程序:Compilation 与 Linking
编译器能看见声明,不等于链接器能找到定义。按预处理、编译、汇编、链接定位错误,再用一个本地三文件项目理解静态库、动态库与 CMake 目标依赖;这些构建方式属于工具链约定,不是语言规定的文件格式。
四个阶段,各自回答什么问题
传统头文件构建中,一个源文件经过头文件展开、宏替换和条件编译,形成翻译单元。编译器进行语法、类型检查和代码生成,汇编器生成目标文件,链接器合并目标文件并解析符号。工具可以融合阶段,链接时优化也能跨文件优化;不要把教学流程误当成必须启动四个进程。
保存下面程序为 main.cpp。在安装 GCC 的本机依次执行 g++ -std=c++20 -E main.cpp -o main.ii、g++ -std=c++20 -S main.cpp -o main.s、g++ -std=c++20 -c main.cpp -o main.o、g++ main.o -o app,最后执行 ./app。缺少头文件发生在前端,找不到函数定义通常发生在链接阶段。
把同一程序拆成三个本地文件
新建 math.hpp,内容为 namespace demo { unsigned add(unsigned, unsigned); }。新建 math.cpp,先写 #include "math.hpp",再放下例末尾的函数定义。main.cpp 保留标准头文件和 main,把原来的 demo 声明块改为包含 math.hpp,并删去末尾定义。这里头文件只有可重复声明;实际项目仍应设置包含保护。
执行 g++ -std=c++20 -c main.cpp math.cpp,再执行 g++ main.o math.o -o app。main.cpp 只需签名就能检查调用,math.cpp 提供唯一实现。修改实现只需重编 math.cpp 并重新链接;修改公共声明会影响所有包含它的翻译单元。实现文件也包含自身头文件,才能及时发现签名不一致。
静态库与动态库:链接成功还不够
以下是 Linux/GNU 工具链示例。ar rcs libmath.a math.o 把目标文件打包,再用 g++ main.o ./libmath.a -o app-static 静态链接这个库;这不表示系统运行库也全部静态链接。传统静态链接器按需求抽取成员,库的顺序可能影响符号解析。
执行 g++ -std=c++20 -fPIC -shared math.cpp -o libmath.so,再执行 g++ main.o -L. -lmath -Wl,-rpath,'$ORIGIN' -o app-shared。将程序和共享库放在同一目录即可运行。共享库要在运行时可找到,并保持 ABI 兼容;类布局、异常运行时和编译选项都会影响兼容性。文件扩展名、加载路径和符号修饰不是 ISO C++ 的统一承诺。
用目标表达构建关系
为上述三个文件创建 CMakeLists.txt,将以下七项各占一行:cmake_minimum_required(VERSION 3.16)、project(local_demo LANGUAGES CXX)、add_library(math STATIC math.cpp)、target_include_directories(math PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")、target_compile_features(math PUBLIC cxx_std_11)、add_executable(app main.cpp)、target_link_libraries(app PRIVATE math)。
执行 cmake -S . -B build、cmake --build build;单配置生成器通常运行 ./build/app。PUBLIC 表示本目标及使用者需要的条件,PRIVATE 只作用于当前目标,INTERFACE 只向使用者传播。让依赖附着于目标,比全局堆叠搜索路径更能防止无关库互相污染。
容易答错的地方
- #include 让声明或定义进入当前翻译单元,不会自动把另一个 .cpp 加入链接;不要通过包含 .cpp 来补救缺失的构建依赖。
- 链接成功不保证动态库在部署机器可加载;-L 是链接期搜索路径,不是运行时加载路径。
运行一个例子
最低标准 C++11 · 完整程序 · 下载 .cpp
#include <cassert>
#include <iostream>
namespace demo {
unsigned add(unsigned a, unsigned b);
}
int main() {
const auto result = demo::add(2u, 3u);
assert(result == 5u);
assert(demo::add(0u, 7u) == 7u);
std::cout << result << '\n';
}
namespace demo {
unsigned add(unsigned a, unsigned b) {
return a + b;
}
}
在本地编译
g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread tooling-compilation.cpp -o example && ./example预期结果
5
CHECK YOUR UNDERSTANDING
合上答案,试着解释。
三文件版本中,只执行 g++ main.o -o app 为什么失败?如果把 add 定义复制进 main.cpp,同时仍链接 math.o,又会怎样?给出正确命令。
查看参考答案
第一种情况有 add 的调用却没有提供定义,通常得到 undefined reference。第二种情况让同一个非 inline 外部函数有两个定义,违反 ODR,通常得到 multiple definition;不能依赖链接器总能诊断。保留 math.hpp 的声明、math.cpp 的唯一定义,执行 g++ -std=c++20 -c main.cpp math.cpp 与 g++ main.o math.o -o app,再运行 ./app,预期输出 5。
继续查证
- GCC:Overall Options(-E、-S、-c)
- GCC:Link Options(库搜索与链接顺序)
- CMake:Adding a Library(4.1 教程)
- CMake:target_compile_features 的传播范围
标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。