Skip to content
Go back

从一条编译命令开始理解 C++ 程序

一段 C++ 代码是怎么变成程序的?

contents 6 sections
  1. 1拆成两步
  2. 2标准怎么说,工具链怎么做
  3. 3目标文件里有什么
  4. 4编译错误和链接错误
  5. 5CMake 在这里做什么
  6. 6这个模型没覆盖的

一段 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,以及静态库、动态库和插件的各种组合。这些东西会改变构建速度和产物的样子,但排查问题的思路不变:先搞清楚是哪一步出的错,再用对应的工具去看那一步的产物。



← Previous Post
RAII:为什么资源应该绑定对象生命周期
Next Post →
重新开始写博客