编译器到底是什么?一段
C++ 代码如何变成可执行程序
lihao · 发布:2026-09-15 · 更新:2026-09-19 · 技术文章
LLVM 与异构编译器 · S1 · 第 01 期
适合能读基本 C++、会使用 Linux 终端的读者。视频制作中。
知识体系 /
编译器与 LLVM 全景
阅读路线 实验复现入口 更新与勘误 RSS 订阅
本文目录 我们经常用一句话解释编译器:把高级语言翻译成机器码。
这句话适合作为直觉,却不足以解释真实的工具链。一条看似简单的命令:
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:
int main() {
const char *text = "hello";
return text + 1;
}text + 1 指向字符串中的下一个字符,指针运算本身合法;问题是 main 的返回类型为 int,而返回表达式是 const char*,没有所需的隐式转换。这里应检查表达式类型与返回类型的关系,而不是把合法的指针运算当作语法错误。此例按 C++ 类型规则构造,未新增本轮运行记录。C++ 返回语句规则 ;指针加法规则 ;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
也可能由动态链接器报告。应结合执行的命令、发出诊断的工具以及完整上下文判断,不能只匹配报错中的几个词。诊断阶段也不总是根因所在,仍需回查输入与源码。
代码 · 环境 · 验证范围
实验复现入口
环境 Linux;Clang/LLVM、C++ 标准库及 ELF 查看工具。
输入与步骤 从本文 hello.cpp 开始,分别生成预处理文本、LLVM IR、汇编、目标文件与可执行文件。
验证状态 教学命令已整理;完整 Clang 流程待实机复核,尚无固定的已验证版本组合。
复现时保留 Clang 版本、Target Triple、操作系统、标准库、完整命令与实际输出。 源代码 完整命令 排查线索 实验室入口
一组可复现的完整命令
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 生成相应产物,并不是逐行消费上一行文件。其中 clang++ hello.o -o hello 才把已经生成的目标文件交给链接阶段;最后一行 -### 从 hello.cpp 开始打印另一份驱动计划,并不执行。建议在单独目录执行这些命令,并记录工具版本与输出。
录制或提交问题时,请同时保留 Clang 版本、Target
Triple、操作系统、标准库和完整命令。不同环境出现不同输出,通常是工具链条件不同,而不是流水线失效。
四个容易混淆的边界
clang++ 与“编译器”
日常交流中,把 clang++
叫作编译器命令没有问题。分析系统时,要知道它是 Driver
入口,会组织多个阶段。
LLVM 与 LLVM IR
LLVM 是一组编译器和工具链基础设施。LLVM IR
是其中一种核心中间表示。两者范围不同。
目标文件与可执行文件
目标文件通常面向链接,仍保留未定义符号和重定位。可执行文件具有可装载布局,但动态链接程序仍可能依赖装载期工作。
构建成功与运行正确
编译、链接、加载、运行和结果正确性是不同层级。任何一层通过,都不能自动推出下一层通过。
回到开头的问题
clang++ 组织构建,前端理解
C++,后端生成目标代码,汇编器和链接器形成可执行文件。运行文件后,操作系统与运行时负责装载和执行。
排查问题时,先看最后成功生成了什么,再确定下一步需要哪个组件和哪些输入。对这个例子,缺少
add.o
应先检查链接命令;它不能靠重写头文件中的声明来解决。
下一期:GCC、Clang、LLVM 到底是什么关系?
补充分析:正确性约束的是行为,不是逐字翻译 · 定位根因,要比较最早出现差异的边界
正确性约束的是行为,不是逐字翻译 若把“翻译”理解成每条源码都对应一条机器指令,就很难解释常量求值、内联和死代码删除。更合适的视角是:在源程序满足语言条件的执行中,目标程序应保留语义要求的可观察行为。中间表示、指令数和控制流可以改变,承担的语义责任不能消失。
构造一个纯整数例子:在32位无符号模运算下,x*2与x+x相等,因为两者都代表2x模2³²;但两个有副作用的函数调用不能仅凭返回值碰巧相等就合并。函数是否读取外部状态、是否修改内存、是否抛异常等信息,会改变合法变换的条件。
对于含未定义行为的C++程序,源语言并未承诺所有直觉中的运行结果。排查时应先问输入是否满足程序前提,再比较实现;也不能反过来,用“可能有未定义行为”免除编译器解释证据的责任。应给出触发路径、相关语言规则或最小输入。
本文手写IR只是认读形式,省略nsw等标志的简写不能作为原C++函数的完整语义证明。IR层的溢出标志、指针与属性是契约的一部分,后续读者可以沿LLVM 18.1的add语义 核对;这份版本化参考不改写本文21.1.8历史产物。
定位根因,要比较最早出现差异的边界 假设最终程序输出错误,至少存在几类候选:输入本身不满足约定,源程序计算错误,前端降低丢失语义,某次IR变换不合法,后端生成错误,链接或运行环境与目标不匹配。最终汇编有异常,并不直接锁定后端。
可先固定源码、输入与预期,保存编译参数与实际调用工具;再比较前端输出、变换前后IR与目标产物。若未优化IR执行正确、加入某条管线后错误,证据把范围缩到该管线及其触发条件,但仍需缩小输入并检查上游是否已经存在非法IR。
对于本篇尚未补齐全命令日志的部分,最诚实的状态仍是教学步骤与已有历史证据并存。本文不把2026-10-18的资料审校登记为新一次21.1.8复跑;它新增的是判断框架。验证命令、输入与输出的缺口继续保留,而非靠更完整文字提高实测等级。
2026-10-18内容修订与勘误:补行为语义、根因定位及完整的错误类型示例,修正链接命令的指代。首篇网页全命令日志待对齐状态保留;本次为文献与语义审校,未新增实验,首次发布日期、原网址及历史运行版本保持。
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-19 :新增阅读路线、实验复现入口和订阅导航。技术结论与命令未改动;完整 Clang 流程仍待实机复核。
2026-09-15:统一环境说明,调整目录和正文排版。编译、链接、装载的职责边界及
LLVM IR 的目标约束已补充说明。
发现错误时,欢迎在评论区指出具体段落;命令相关的问题请附工具版本、运行命令与关键输出。经确认的技术性修正会记录在这里。
本文命令以 Linux 上的 Clang
为例,输出会随工具版本与目标平台变化;完整 Clang 流程尚待实机复核。
收藏0