C++ / a working model

77 / 80   ·   C++11   ·   约 13 分钟

从源文件到程序:Compilation 与 Linking

先记住这句话

编译器能看见声明,不等于链接器能找到定义。按预处理、编译、汇编、链接定位错误,再用一个本地三文件项目理解静态库、动态库与 CMake 目标依赖;这些构建方式属于工具链约定,不是语言规定的文件格式。

本篇内容
  1. 四个阶段,各自回答什么问题
  2. 把同一程序拆成三个本地文件
  3. 静态库与动态库:链接成功还不够
  4. 用目标表达构建关系
  5. 运行示例
  6. 动手练习

四个阶段,各自回答什么问题

传统头文件构建中,一个源文件经过头文件展开、宏替换和条件编译,形成翻译单元。编译器进行语法、类型检查和代码生成,汇编器生成目标文件,链接器合并目标文件并解析符号。工具可以融合阶段,链接时优化也能跨文件优化;不要把教学流程误当成必须启动四个进程。

保存下面程序为 main.cpp。在安装 GCC 的本机依次执行 g++ -std=c++20 -E main.cpp -o main.iig++ -std=c++20 -S main.cpp -o main.sg++ -std=c++20 -c main.cpp -o main.og++ 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 buildcmake --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.cppg++ main.o math.o -o app,再运行 ./app,预期输出 5。

继续查证

标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。

回到目录