一段 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++!
-std=c++20 指定语言标准,-Wall -Wextra -Wpedantic 打开一组常用警告,-o hello 给输出文件起名。换成 g++ 也一样能用。MSVC 的参数写法不同,但下面讲的过程是一样的。
这里的 clang++ 其实是一个编译器驱动程序(driver)。它会按顺序调用编译器本体、汇编器和链接器,一条命令背后其实是好几步。
拆成两步
把上面那条命令拆开:
clang++ -std=c++20 -Wall -Wextra -Wpedantic -c hello.cpp -o hello.o
clang++ hello.o -o hello
-c 的意思是只编译、不链接,产出目标文件 hello.o。它里面已经有机器指令了,但还不能运行,因为 std::cout 的实现在标准库里,hello.o 只记下了“这里要用到某个外部符号”。
第二条命令做链接:把目标文件和需要的库拼在一起,把这些外部引用对上,生成可执行文件。链接这一步最好也交给 clang++,别直接调 ld,因为 driver 会自动补上 C++ 标准库、启动代码这些参数,自己手写很容易漏。
一条命令直接出可执行文件,只是 driver 替你把这两步都做了。
标准怎么说,工具链怎么做
C++ 标准里只有“翻译阶段”(phases of translation)的概念。源文件先处理 #include、宏和条件编译,得到 preprocessing translation unit;再经过词法、语法、语义分析和模板实例化,成为 translation unit;最后多个 translation unit 组合起来,解决彼此之间的外部引用。
标准没要求编译器真的生成 .ii、.s、.o 这些文件,也没要求这些阶段在实现里界限分明。不过 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 # 编译到目标文件
所以日常说的“预处理 → 编译 → 汇编 → 链接”是一个工程上好用的模型:
flowchart TD
A["源文件 .cpp"] -->|"预处理"| B["预处理后的代码 .ii"]
B -->|"编译"| C["汇编代码 .s"]
C -->|"汇编"| D["目标文件 .o"]
D -->|"链接"| E["可执行程序"]
L["其他目标文件和库"] --> E
真实的编译器内部还有 IR 和一堆优化 pass,有时候也会把几步合在一起做,这张图不会告诉你这些。
hello.ii 可以打开看一眼,一个 #include <iostream> 会展开出几万行代码,头文件为什么拖慢编译,看到这个就有直观感受了。
目标文件里有什么
目标文件除了机器指令,还有几样东西:按用途分开的 section(代码、只读数据、可写数据等),定义和引用的符号表,给链接器用的重定位信息,以及调试信息。格式跟平台走,Linux 上是 ELF,macOS 上是 Mach-O,Windows 上是 COFF。
用 nm 可以看符号:
nm hello.o
Linux 上还可以用 readelf 或 objdump 看 section 和重定位。平时不用去翻这些,但查链接错误、符号可见性或者二进制体积的时候会用得上。
编译错误和链接错误
分清编译和链接,最直接的好处是构建失败时能更快知道该往哪查。
把程序拆成三个文件:
// 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 就是把它的内容原样贴进来,这样 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
macOS 上会看到:
Undefined symbols for architecture arm64:
"add(int, int)", referenced from:
_main in main.o
ld: symbol(s) not found for architecture arm64
main.cpp 编译是通过了的,因为它看到了 add 的声明,编译器只需要知道怎么调用它。但真正的函数体在 calculator.o 里,链接器找不到,就报了 undefined symbol。
大致可以这么分:语法错误、类型不匹配、名字没声明,是编译阶段的问题;缺定义、漏了目标文件或库、声明和定义对不上,是链接阶段的问题。以后接入第三方库、写 CMake,碰到最多的往往是后一种。
当然,编译链接都过了也不代表程序是对的。空指针、越界和逻辑错误要到运行时才会暴露,那是另一回事。
上面说“链接器把目标文件和库拼在一起”,其实只说了静态链接。静态链接会从静态库里挑出用到的目标文件,直接放进最终产物;动态链接只在可执行文件里记下依赖哪个动态库,程序启动时由加载器去装载、完成剩下的符号绑定。动态库的搜索路径、ABI 兼容、符号可见性,每一个都够单独写一篇,这里先不展开。
CMake 在这里做什么
源文件一多,手敲编译命令就不现实了。上面的例子用 CMake 写是这样的:
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 再去调用这些构建工具,由它们根据依赖关系决定哪些文件要重新编译、哪些目标要重新链接。最后干活的还是前面那些编译和链接命令。
这个模型没覆盖的
这篇讲的是最经典的分离编译。实际项目里还会碰到模板的实例化位置、C++20 modules、LTO、预编译头、unity build,以及静态库、动态库和插件的各种组合。这些东西会改变构建速度和产物的样子,但排查问题的思路不变:先搞清楚是哪一步出的错,再用对应的工具去看那一步的产物。