一段 C++ 代码是怎么变成程序的?
先写一个最小的 Hello World:
// hello.cpp
#include <iostream>
int main()
{
std::cout << "Hello, C++!\n";
}
然后在终端里执行:
clang++ -std=c++20 -Wall -Wextra -Wpedantic hello.cpp -o hello
./hello
Hello, C++!
Table of contents
Open Table of contents
先看懂这条命令
| 参数 | 作用 |
|---|---|
clang++ | 调用 Clang 的 C++ 编译器驱动程序 |
-std=c++20 | 按 C++20 标准处理代码 |
-Wall -Wextra -Wpedantic | 在 Clang 和 GCC 中开启一组常用警告 |
hello.cpp | 输入的 C++ 源文件 |
-o hello | 把最终生成的可执行程序命名为 hello |
clang++ 不只是把文本翻译成机器指令。它是一个编译器驱动程序,会协调编译和链接过程,最终生成可执行程序。
实际使用时,通常会同时指定 C++ 标准版本并开启常用警告。
本文的命令适用于 macOS 和 Linux。使用 GCC 时,可以把 clang++ 换成 g++。Windows 下常用的 MSVC 命令行参数、目标文件扩展名和运行方式不同,但基本过程相同。
工程上的实用模型
把过程拆成两步更容易理解:
clang++ -std=c++20 -Wall -Wextra -Wpedantic -c hello.cpp -o hello.o
clang++ hello.o -o hello
第一条命令里的 -c 表示只编译,不链接。它会生成目标文件 hello.o。
目标文件通常包含机器指令、符号和重定位信息,但还不是可以直接运行的程序。代码可能依赖其他目标文件或库,例如示例中的输出操作需要 C++ 标准库提供实现。
第二条命令会执行链接。链接器负责组合目标文件和所需的库,并解析符号引用,最终生成可执行程序 hello。
链接时仍然使用 clang++,而不是直接调用系统链接器。编译器驱动程序会补上 C++ 程序通常需要的标准库和运行时参数。
直接使用最初的一条命令时,编译器驱动程序会自动完成这两步。
标准翻译模型与工具链产物
C++ 标准描述的是翻译阶段,不要求编译器必须生成 .ii、.s 或 .o 文件,也不要求这些阶段在实现中严格分离。
源文件经过 #include、条件编译和宏展开后,形成 preprocessing translation unit。随后,预处理 token 会转换为 token,构成 translation unit。语法和语义分析、需要的模板实例化以及部分常量求值都发生在翻译过程中。最后,已经翻译的 translation units 会被组合,外部实体引用得到解析。
工程上通常使用下面的简化模型观察工具链:
源文件 .cpp
|
| 预处理:展开 #include、宏和条件编译
v
预处理后的代码
|
| 编译:检查语法和类型,生成汇编代码
v
汇编代码
|
| 汇编:生成机器指令
v
目标文件 .o
|
| 链接:组合目标文件和库,解析符号引用
v
可执行程序
Clang 和 GCC 的驱动程序提供了参数,可以停在常见的中间产物:
clang++ -std=c++20 -E hello.cpp -o hello.ii
clang++ -std=c++20 -S hello.cpp -o hello.s
clang++ -std=c++20 -c hello.cpp -o hello.o
| 参数 | 输出 | 用途 |
|---|---|---|
-E | hello.ii | 查看预处理结果 |
-S | hello.s | 查看编译器生成的汇编代码 |
-c | hello.o | 生成目标文件,但不执行链接 |
这些文件是常见工具链产物,不是 C++ 标准规定必须落盘的文件。实际实现可以合并阶段,也可以在内部增加 IR、优化和代码生成流程。
目标文件里有什么
目标文件不只是机器指令的集合。它通常还会包含:
- sections,例如代码、只读数据和可写数据
- symbols,例如当前文件定义或引用的函数与变量
- relocations,记录链接器需要在组合目标文件时修正的位置
- 调试信息和其他平台相关元数据
不同平台使用不同的目标文件格式:Linux 常见 ELF,macOS 使用 Mach-O,Windows 常见 COFF。
在 macOS 上,可以查看 hello.o 中的符号:
nm hello.o
在 Linux 上,也可以使用 readelf 或 objdump 进一步检查 sections 和 relocations。理解这些内容有助于排查链接错误、符号可见性和二进制体积问题,但不需要在日常构建时逐项检查。
为什么要区分编译和链接
排查构建失败时,先判断问题发生在编译阶段还是链接阶段,通常能更快定位原因。
例如,把程序拆成三个文件:
// calculator.h
#pragma once
int add(int lhs, int rhs);
// main.cpp
#include <iostream>
#include "calculator.h"
int main()
{
std::cout << add(20, 22) << '\n';
}
// calculator.cpp
#include "calculator.h"
int add(int lhs, int rhs)
{
return lhs + rhs;
}
头文件通常不会被单独编译。预处理器会把 #include "calculator.h" 替换为头文件的内容,让编译器在处理 main.cpp 和 calculator.cpp 时都能看到 add 的声明。
分别编译两个源文件,再链接:
clang++ -std=c++20 -Wall -Wextra -Wpedantic -c main.cpp -o main.o
clang++ -std=c++20 -Wall -Wextra -Wpedantic -c calculator.cpp -o calculator.o
clang++ main.o calculator.o -o app
./app
程序会输出:
42
如果链接时漏掉 calculator.o:
clang++ main.o -o app
链接器会报告找不到 add(int, int) 的定义。在 macOS 上,错误信息类似:
Undefined symbols for architecture arm64:
"add(int, int)", referenced from:
_main in main.o
ld: symbol(s) not found for architecture arm64
不同平台和编译器的错误文本会有差异,但核心信息相同:calculator.h 里的声明足以让 main.cpp 通过编译,链接器仍然需要找到 add(int, int) 的定义。
| 类型 | 常见原因 |
|---|---|
| 编译错误 | 语法错误、类型不匹配、名称未声明 |
| 链接错误 | 缺少定义、漏掉目标文件或库、声明与定义不一致 |
区分这两类问题很重要。后续使用标准库、第三方库和 CMake 时,还会不断遇到链接问题。
编译和链接成功也不代表程序行为一定正确。空指针解引用、越界访问和业务逻辑错误通常要到运行或测试时才能暴露,它们属于另一类问题。
静态链接和动态链接
“链接器把目标文件和库组合起来”仍然是简化描述。
- 静态链接通常会从静态库中提取需要的目标文件,将所需内容纳入最终产物。
- 动态链接通常会在可执行文件中保留对动态库的依赖。程序启动时,加载器仍需装载动态库并完成相应工作。
符号解析、重定位、动态库搜索路径、ABI、符号可见性和启动代码都可以单独展开。当前文章只建立边界,不继续深入。
CMake 解决什么问题
只有一个源文件时,手动调用编译器很直接。源文件逐渐增多后,实际项目通常会用 CMake 等工具描述构建规则,再生成原生构建系统使用的输入文件。
对于前面的 main.cpp 和 calculator.cpp,可以先写一个最小的 CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(cpp_basics LANGUAGES CXX)
add_executable(app main.cpp calculator.cpp)
target_compile_features(app PRIVATE cxx_std_20)
然后执行:
cmake -S . -B build
cmake --build build
./build/app
CMake 不会取代编译器,也不是编译器。它根据 CMakeLists.txt 生成原生构建系统使用的输入文件,例如 Ninja、Make、Visual Studio 或 Xcode 项目文件。
cmake --build build 会调用对应的原生构建工具。构建系统再根据依赖关系决定哪些源文件需要重新编译、哪些目标需要重新链接。
边界
本文使用的是经典的分离编译模型。真实项目还可能涉及:
- 模板实例化和显式实例化
- C++20 modules 和 header units
- 静态库、动态库、插件和跨语言 ABI
- link-time optimization(LTO)
- unity build、预编译头文件和增量构建
- 编译器前端、IR 优化和后端代码生成
这些机制会影响构建速度、二进制布局和运行时行为,但不改变本文的核心判断:先明确问题发生在哪个阶段,再选择对应的工具观察证据。
小结
- C++ 标准描述翻译阶段,不强制实现生成
.ii、.s和.o文件。 .cpp -> .o -> 可执行程序是工程上常用的简化模型。- 目标文件通常包含 sections、symbols 和 relocations,不只是机器指令。
- 头文件中的声明可以让源文件通过编译,链接器仍然需要找到定义。
- CMake 生成原生构建系统使用的输入文件,底层仍然会调用编译器和链接器。