编译器到底是什么?一段 C++ 代码如何变成可执行程序
LLVM 与异构编译器 · S1 · 第 01 期
适合能读基本 C++、会使用 Linux 终端的读者。视频制作中。
知识体系 / 编译器与 LLVM 全景
我们经常用一句话解释编译器:把高级语言翻译成机器码。
这句话适合作为直觉,却不足以解释真实的工具链。一条看似简单的命令:
clang++ hello.cpp -o hello
背后通常包含预处理、前端分析、中间表示、优化、代码生成、汇编和链接。程序真正开始执行之前,操作系统还要完成加载;如果程序使用共享库,动态链接器也会参与。
编译器把程序转换为目标表示;工具链还包括汇编器、链接器等组件。clang++
是组织这些工作的 Driver
入口。本文沿着这条命令观察各阶段的职责,运行时装载则单独说明。
本文采用 Clang/LLVM、传统 C++ 头文件和 Linux 的本机 ELF 目标,不启用 LTO(链接时优化),也不讨论 JIT。其他平台可能采用 Mach-O、COFF 等格式。以下阶段按职责划分,并不要求它们各自运行一个进程。Clang 工具链说明
读完你应该能回答什么
clang++为什么既能报告语法错误,也能报告链接错误?.ii、.ll、.s、.o和可执行文件分别是什么?- 目标文件里已经有机器指令,为什么通常仍不能直接运行?
undefined symbol为什么通常属于链接问题?- 一段 C++ 代码怎样从源码进入进程?
从 hello.cpp 开始
创建 hello.cpp:
#include <iostream>
int main() {
std::cout << "Hello, compiler!\n";
return 0;
}
构建并运行:
clang++ hello.cpp -O0 -o hello
./hello
输出:
Hello, compiler!
头文件提供声明,也可能包含模板和内联定义。标准库中另一些实现通过链接和运行环境提供。对这个小程序,源码、标准库、目标平台的 ABI 和操作系统都参与了最终结果。
-O0
便于观察中间产物,但不要把它理解为“完全没有任何转换”。编译器仍然需要完成语义降低和目标代码生成。
全景图:从 Source 到 Process
| 职责 | 可观察的表示或产物 |
|---|---|
| 预处理 | 展开后的源码文本 .ii |
| 前端解析和语义分析 | AST |
| 生成 IR 与优化 | LLVM IR,文本 .ll 或 bitcode .bc |
| 目标代码生成 | 目标汇编 .s,或直接送入集成汇编器 |
| 汇编 | 本例的原生目标文件 .o |
| 链接 | 可执行文件;带 -shared 等选项时可生成共享库 |
| 执行与装载 | 进程映像、运行时状态 |
这个表是概念模型,不要求每个阶段都对应一个独立进程。Clang 常用集成汇编器直接生成目标文件;链接时也可能调用系统链接器或 LLD。默认构建通常不把 AST、IR 或汇编文本逐一保存到磁盘。Clang 阶段与集成汇编器
Preprocess:决定前端实际接收哪些 Token
预处理主要处理:
#include:把头文件内容纳入当前翻译单元;#define:展开宏;#if、#ifdef:按条件保留代码区域。
观察预处理结果:
clang++ -E hello.cpp -o hello.ii
wc -l hello.cpp hello.ii
sed -n '1,80p' hello.ii
包含 <iostream>
后,输出会显著增大,因为大量声明进入当前翻译单元。不要尝试从头滚动到尾。录屏或调试时,更有价值的是确认宏是否展开、条件分支是否命中、声明来自哪个头文件。
预处理阶段并不负责完整的 C++ 类型系统。宏展开后的代码仍要交给前端进行语法和语义检查。
Front End:判断程序在 C++ 规则下是否成立
词法处理从预处理阶段就已开始。Clang 的解析器请求预处理后的 Token,并与语义分析配合;下表描述职责,不是三个彼此独立的执行阶段:
| 工作 | 输入与输出 | 主要问题 |
|---|---|---|
| Lexing | 字符序列到 Token | 哪些字符组成关键字、标识符、字面量和运算符 |
| Parsing | Token 到 AST 结构 | 程序是否符合语法,以及表达式和声明怎样嵌套 |
| Semantic Analysis | AST 与声明到类型化程序 | 名字查找、类型检查、重载决议、访问控制和模板规则 |
只运行到语法和语义检查:
clang++ -fsyntax-only hello.cpp
观察 AST:
clang++ -Xclang -ast-dump -fsyntax-only hello.cpp
对于包含标准库的程序,AST 同样会很大。可改为观察下文
calc.cpp 的 AST,减少标准库内容。
broken.cpp 中的 text + 1
是合法指针运算。真正的错误是把指针作为 int main()
的返回值。这里应检查表达式的类型是否满足函数返回类型的要求。Clang
前端实现说明
常见的
use of undeclared identifier、类型不兼容和重载歧义,通常来自这一层。
LLVM IR:连接前端、优化器与后端的重要边界
Clang 可以把 C++ 降低为 LLVM IR:
clang++ -S -emit-llvm -O0 hello.cpp -o hello.ll
为了观察简洁的 IR,建议使用不依赖 iostream 的小函数:
int add(int a, int b) {
return a + b;
}
下面只用于说明函数、加法和返回指令的形式,是手写教学简写,不是上面
-O0 命令的完整输出:
define i32 @_Z3addii(i32 %a, i32 %b) {
entry:
%sum = add i32 %a, %b
ret i32 %sum
}
实际输出通常还带属性、目标数据布局等信息;完整的源码调试信息一般需要
-g。C++ 函数名通常会修饰。本例的有符号加法在 Clang IR
中可能带 nsw,表示相应溢出产生
poison;这里省略它只为认读指令,不把简写作为等价实现或测试预期。LLVM IR 的 add
指令
LLVM IR 不是编译过程中的唯一表示。它前面有 Token 与 AST,代码生成内部还有更接近目标机器的表示。LLVM IR 也不是要求程序必须解释执行的“虚拟机字节码”;它是一套具有精确定义、适合分析和变换的中间表示。
Optimize:哪些工作可以在编译期完成
优化器可能进行常量折叠、删除不可达代码、消除冗余计算、函数内联、循环变换和内存访问优化。
calc.cpp 内容如下;它用于单独观察,不要和
add.cpp 一起链接:
int add(int a, int b) {
return a + b;
}
int folded() {
int x = 2 + 3;
return x * 4;
}
比较两个优化级别:
clang++ -S -emit-llvm -O0 calc.cpp -o calc.O0.ll
clang++ -S -emit-llvm -O2 calc.cpp -o calc.O2.ll
diff -u calc.O0.ll calc.O2.ll || true
diff 检测到差异会返回 1。folded()
的常量求值在 O0 也可能发生,不能据此声称“只有 O2 才会得到
20”。这里只观察代码变化,没有进行性能测量。
两个语义约束:
- 对定义良好的程序,优化必须保持语言和 IR 语义要求的可观察行为。
- 如果源码触发未定义行为,编译器可以做出更激进、也更容易让人意外的推导。
源码行和机器指令通常不会一一对应。代码更短也不必然更快;性能需要在目标机器和实际负载上测量。C++ 抽象机与可观察行为
CodeGen:把通用表示落实到目标机器
生成目标汇编:
clang++ -S -O0 hello.cpp -o hello.s
代码生成要结合:
- Target Triple 与 CPU Features;
- 指令集和合法数据类型;
- 寄存器、调用约定与 ABI;
- 指令成本和调度约束。
LLVM 后端会经历操作合法化、指令选择、寄存器分配、指令调度以及函数序言和尾声等工作。同一份源代码面向 x86-64、AArch64 或 GPU 时,可能需要生成不同 IR。已生成的 IR 可能带有特定数据布局、调用约定和 intrinsic,不能保证原样换目标。
交叉编译需要兼容目标的 IR、头文件、库和工具配置。Clang 交叉编译说明
Assemble:目标文件已经含有代码,但仍不是完整程序
生成目标文件:
clang++ -c -O0 hello.cpp -o hello.o
在常见 ELF 环境中,可以观察三类核心信息:
file hello.o
llvm-readelf -S hello.o
llvm-nm -C hello.o
llvm-readelf -r hello.o
| 结构 | 作用 |
|---|---|
| Sections | 保存代码、只读数据、可写数据、符号表和重定位等信息 |
| Symbols | 记录函数和全局对象等名字,包括当前文件的定义与外部引用 |
| Relocations | 描述需要按符号、位置等信息计算或修补的项,可能由静态或动态链接器处理 |
这里的原生目标文件通常已有机器指令,还可能含有未定义符号和重定位。它按可重定位模块组织,不能作为本例的进程镜像直接装载。启用
LTO 时,.o 也可能承载 LLVM
bitcode,后缀本身不能证明内容格式。LLVM
链接时优化
Link:组合翻译单元、库和启动代码
创建 add.h:
#pragma once
int add(int lhs, int rhs);
创建 add.cpp:
#include "add.h"
int add(int lhs, int rhs) {
return lhs + rhs;
}
创建 main.cpp:
#include <iostream>
#include "add.h"
int main() {
std::cout << "2 + 3 = " << add(2, 3) << '\n';
return 0;
}
分别编译:
clang++ -c main.cpp -o main.o
clang++ -c add.cpp -o add.o
llvm-nm -C main.o | grep add
llvm-nm -C add.o | grep add
main.o 中通常可以看到对 add(int, int)
的未定义引用,add.o 中可以看到它的定义。
故意漏掉 add.o:
clang++ main.o -o app
链接器会报告 undefined reference 或
undefined symbol。前端已经看到 add
的声明,因此允许生成调用;直到链接时,工具链才需要找到兼容定义。
补回目标文件:
clang++ main.o add.o -o app
./app
这次运行输出
2 + 3 = 5。链接器读取目标文件和库,选择需要的归档成员、解析符号、安排布局,并应用重定位。
严谨地说,动态链接程序在静态链接完成后仍可能保留动态重定位和对共享库符号的依赖。动态链接器在装载时处理依赖与重定位;采用延迟绑定时,一部分函数符号可能到首次调用才绑定。不是所有符号都能或都会延迟绑定。Linux 动态链接器说明因此,“成功生成可执行文件”不等于所有地址关系都已经永久写死。
Load and Run:文件最终成为进程
可执行文件描述了操作系统如何建立进程映像。以 ELF 为例,Program Header 描述需要映射的 Segment 及其权限。可以查看:
readelf -l hello
运行程序时,内核装载可执行文件;若存在程序解释器和共享库依赖,动态链接器继续装载所需对象并处理相关重定位。随后,控制流经过运行时启动代码,在正常的宿主
C++ 程序中最终进入 main;静态初始化也可能发生在
main 之前。
这也是为什么“编译通过”“链接通过”和“程序能正确运行”是三个不同结论。
clang++ 是 Driver:用选项观察停止位置
Clang 官方文档把 clang/clang++ 描述为
Driver。它根据输入文件、目标平台和命令行选项组织底层阶段与工具。
| 选项 | 停止位置 | 代表产物 |
|---|---|---|
-E |
Preprocess | .ii |
-S -emit-llvm |
LLVM IR | .ll |
-S |
CodeGen | .s |
-c |
Assemble | .o |
| 本例无停止选项 | Link | executable;共享库需要 -shared 等选项 |
打印 Driver 计划执行的命令,但不真正执行:
clang++ -### hello.cpp -o hello
具体输出取决于平台与 Clang 配置。你可能看到
-cc1、集成汇编选项和系统链接器,也可能看到不同的工具路径。这里观察的是
Driver 如何组织流程,不应把某一台机器上的绝对路径当作固定知识。
错误阶段速查
| 阶段 | 常见现象 | 优先检查 |
|---|---|---|
| Preprocess | header not found、宏分支异常 | include 路径、宏定义、条件编译 |
| Front End | syntax error、type mismatch、ambiguous call | Token、AST、类型、名字与重载 |
| CodeGen / Assemble | unsupported instruction、constraint failure | 目标三元组、CPU 特性、后端合法化与约束 |
| Link | undefined symbol、multiple definition | 输入目标文件、库顺序、符号签名、ABI |
| Load / Run | 缺少共享库、非法指令、崩溃或错误结果 | 动态依赖、部署目标、运行库、ABI 与程序状态 |
undefined symbol
也可能由动态链接器报告。应结合执行的命令、发出诊断的工具以及完整上下文判断,不能只匹配报错中的几个词。诊断阶段也不总是根因所在,仍需回查输入与源码。
一组可复现的完整命令
clang++ --version
clang++ -dumpmachine
uname -a
clang++ -E hello.cpp -o hello.ii
clang++ -S -emit-llvm -O0 hello.cpp -o hello.ll
clang++ -S -O0 hello.cpp -o hello.s
clang++ -c -O0 hello.cpp -o hello.o
clang++ hello.o -o hello
./hello
file hello.ii hello.ll hello.s hello.o hello
llvm-readelf -S hello.o
llvm-readelf -r hello.o
llvm-nm -C hello.o
readelf -l hello
clang++ -### hello.cpp -o hello
上面的命令分别从 hello.cpp
开始生成各阶段产物,并不是逐行消费上一行文件。最后两行构建命令才把
hello.o
交给链接器。建议在单独目录执行这些命令,并记录工具版本与输出。
录制或提交问题时,请同时保留 Clang 版本、Target Triple、操作系统、标准库和完整命令。不同环境出现不同输出,通常是工具链条件不同,而不是流水线失效。
四个容易混淆的边界
clang++ 与“编译器”
日常交流中,把 clang++
叫作编译器命令没有问题。分析系统时,要知道它是 Driver
入口,会组织多个阶段。
LLVM 与 LLVM IR
LLVM 是一组编译器和工具链基础设施。LLVM IR 是其中一种核心中间表示。两者范围不同。
目标文件与可执行文件
目标文件通常面向链接,仍保留未定义符号和重定位。可执行文件具有可装载布局,但动态链接程序仍可能依赖装载期工作。
构建成功与运行正确
编译、链接、加载、运行和结果正确性是不同层级。任何一层通过,都不能自动推出下一层通过。
回到开头的问题
clang++ 组织构建,前端理解
C++,后端生成目标代码,汇编器和链接器形成可执行文件。运行文件后,操作系统与运行时负责装载和执行。
排查问题时,先看最后成功生成了什么,再确定下一步需要哪个组件和哪些输入。对这个例子,缺少
add.o
应先检查链接命令;它不能靠重写头文件中的声明来解决。
下一期:GCC、Clang、LLVM 到底是什么关系?
References
- Clang Command Guide
- Clang Toolchain
- Introduction to the Clang AST
- LLVM Language Reference Manual
- LLVM Code Generator
- LLVM Passes
- LLD: The LLVM Linker
- The ELF, COFF and Wasm Linkers
- Executable and Linking Format specification
- Linux elf(5)
- llvm-readelf command guide
- llvm-nm command guide
更新与勘误
2026-09-15:统一环境说明,调整目录和正文排版。编译、链接、装载的职责边界及 LLVM IR 的目标约束已补充说明。
发现错误时,欢迎在评论区指出具体段落;命令相关的问题请附工具版本、运行命令与关键输出。经确认的技术性修正会记录在这里。
本文命令以 Linux 上的 Clang 为例,输出会随工具版本与目标平台变化;完整 Clang 流程尚待实机复核。
系列下一期
GCC、Clang、LLVM 到底是什么关系(筹备中)。

写下你的评论
要发表评论,您必须先登录。