Hang Zhengyang

C++ 编译与构建流程总览

从源代码到可执行文件,C++ 的典型流程可以拆成几步:

预处理(Preprocessing)→ 编译(Compilation)→ 汇编(Assembly)→ 链接(Linking)

再加上一些工程层面的工具(如 CMake、构建系统),就构成了完整的 C++ 构建管线。


一、预处理(Preprocessing)

预处理由预处理器完成,主要负责:

  • 处理 #include(把头文件内容“展开”进来)
  • 处理 #define / 宏替换
  • 处理条件编译(#if / #ifdef / #ifndef 等)

可以通过 -E 选项只运行预处理:

g++ -E main.cpp -o main.ii

简单示例:

#include <iostream>

#define PI 3.14

int main() {
    std::cout << PI << std::endl;
}

预处理之后,大致会变成:

  • 把 <iostream> 中的声明全部“展开”;
  • 把 PI 替换成 3.14。

注意:这一阶段只是文本级别的替换和展开,没有语义分析。


二、编译(Compilation):从 C++ 到汇编

编译阶段将 预处理后的 C++ 源码 转成 目标架构的汇编 / 目标代码,主要包括:

  • 词法分析、语法分析(检查语法错误)
  • 语义分析(类型检查、名字查找等)
  • 优化(根据编译选项,如 -O2、-O3)
  • 生成汇编 / 目标文件(.s / .o)

常见用法:

g++ -c main.cpp -o main.o    # 只编译生成目标文件
g++ -S main.cpp -o main.s    # 生成汇编代码

示例代码:

int add(int a, int b) {
    return a + b;
}

int main() {
    int x = add(1, 2);
    return x;
}

在这个阶段,编译器会做的事情包括:

  • 检查 add 的声明与定义是否一致;
  • 检查参数类型、返回类型;
  • 把 add(1, 2) 翻译成对应的调用指令;
  • 生成 main.o 这样的目标文件。

三、汇编(Assembly):从汇编到机器码

很多现代编译器在 同一条命令内部 会自动完成“编译 + 汇编”,但从概念上可以拆开看:

  • 编译:生成汇编代码(.s)
  • 汇编:把汇编翻译成机器码,生成目标文件(.o 或 .obj)

单独看汇编阶段的命令(视工具链而定):

as main.s -o main.o

一般日常开发中不需要手动调用 as,只要知道:

  • main.o 是“已经翻译成机器码,但还没链接”的中间产物。

四、链接(Linking):把所有目标文件拼成可执行文件

链接器负责把多个目标文件和库组合成一个最终的可执行文件或静态/动态库:

  • 解决符号引用(例如:main.o 里调用的 std::cout 在哪里?)
  • 合并各个目标文件中的代码段、数据段
  • 处理静态库(.a / .lib)和动态库(.so / .dll)

常见命令:

g++ main.o add.o -o app          # 把多个 .o 链接成可执行文件
g++ main.o -lm -o app            # 链接时显式引用数学库 libm

示例结构:

  • main.cpp 里调用 add 函数;
  • add.cpp 里实现 int add(int, int);;

流程:

g++ -c main.cpp -o main.o
g++ -c add.cpp  -o add.o
g++ main.o add.o -o app

如果链接时找不到某个符号(比如缺少库),就会出现:

  • “undefined reference to xxx” 这样的链接错误。

五、静态库与动态库(Static / Shared Libraries)

在实际项目中,常见的不只是可执行文件,还有库:

  • 静态库:在链接阶段被复制进最终二进制,如 .a、.lib
  • 动态库:运行时由操作系统加载,如 .so、.dll

简单构建静态库的例子:

g++ -c foo.cpp -o foo.o
ar rcs libfoo.a foo.o
g++ main.cpp -L. -lfoo -o app

简单构建动态库的例子(Unix-like):

g++ -fPIC -c foo.cpp -o foo.o
g++ -shared foo.o -o libfoo.so
g++ main.cpp -L. -lfoo -o app

动态库在运行时还涉及:

  • 动态链接器如何找到 .so / .dll(如 LD_LIBRARY_PATH、系统默认路径)

六、优化与调试信息(Optimization & Debug Info)

编译时可以控制优化等级和调试信息:

  • -O0:关闭优化,便于调试
  • -O2 / -O3:启用中/高级优化
  • -g:生成调试信息(用于 gdb / lldb)

示例:

g++ -g -O0 main.cpp -o app_debug     # 调试版
g++ -O2 main.cpp -o app_release      # 发布版

优化会影响:

  • 代码执行效率(更快)
  • 二进制体积(可能更小或略大)
  • 调试体验(行号对应关系不再一一精确)

七、单文件与多文件工程

小项目可以直接一条命令搞定:

g++ main.cpp -o app

但在多文件工程中,通常会拆成两步:

  1. 各自编译成 .o(支持增量编译)
  2. 统一链接成最终可执行文件

例如:

g++ -c main.cpp -o main.o
g++ -c foo.cpp  -o foo.o
g++ -c bar.cpp  -o bar.o

g++ main.o foo.o bar.o -o app

这样修改 foo.cpp 时,只需要重新编译 foo.o,不必重新编译所有源文件。


八、构建系统与 CMake(工程层)

在实际工程里,手写一堆 g++ 命令会非常繁琐,因此会用:

  • Makefile / ninja
  • CMake(生成上述构建文件)

典型 CMakeLists.txt 示例:

cmake_minimum_required(VERSION 3.16)
project(MyApp CXX)

set(CMAKE_CXX_STANDARD 17)

add_executable(MyApp
    main.cpp
    foo.cpp
    bar.cpp
)

使用方式:

mkdir build
cd build
cmake ..
cmake --build .

CMake 会负责:

  • 生成合适的编译命令(含 -I、-L、-l、-O2、-g 等);
  • 管理依赖关系(哪些文件变了需要重新编译);
  • 支持跨平台构建(如 Unix makefiles、Ninja、Visual Studio 工程等)。

九、命令与概念对照小结

  • -E:只预处理
  • -c:只编译 + 汇编,生成 .o
  • 无 -c 且传入多个 .o:进入链接阶段,生成可执行文件
  • .a / .lib:静态库
  • .so / .dll:动态库

整体流程可以简化为:

  1. 预处理:展开头文件和宏
  2. 编译:C++ → 汇编 / 中间表示(语义检查 + 优化)
  3. 汇编:汇编 → 机器码(.o)
  4. 链接:把所有 .o 和库拼成最终可执行文件或新的库

理解这条从源码到可执行文件的“流水线”,有助于你:

  • 看懂编译 / 链接错误;
  • 合理拆分工程和库;
  • 更好地控制优化、调试和发布流程。