Skip to content
cxxzsh
Go back

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

一段 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
参数输出用途
-Ehello.ii查看预处理结果
-Shello.s查看编译器生成的汇编代码
-chello.o生成目标文件,但不执行链接

这些文件是常见工具链产物,不是 C++ 标准规定必须落盘的文件。实际实现可以合并阶段,也可以在内部增加 IR、优化和代码生成流程。

目标文件里有什么

目标文件不只是机器指令的集合。它通常还会包含:

不同平台使用不同的目标文件格式:Linux 常见 ELF,macOS 使用 Mach-O,Windows 常见 COFF。

在 macOS 上,可以查看 hello.o 中的符号:

nm hello.o

在 Linux 上,也可以使用 readelfobjdump 进一步检查 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.cppcalculator.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.cppcalculator.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 会调用对应的原生构建工具。构建系统再根据依赖关系决定哪些源文件需要重新编译、哪些目标需要重新链接。

边界

本文使用的是经典的分离编译模型。真实项目还可能涉及:

这些机制会影响构建速度、二进制布局和运行时行为,但不改变本文的核心判断:先明确问题发生在哪个阶段,再选择对应的工具观察证据。

小结



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