E4 从C代码到二进制程序(一)——编译器的工作流水线
本笔记是一生一芯计划的学习记录,主要参考了”一生一芯”课程E4章节的内容。
写个
hello.c按一下gcc就出可执行文件了,但你知道中间发生了什么吗?🤔
📌 这篇笔记讲了啥
编译器把C代码变成可执行二进制程序,不是一步到位的,而是经过一条五阶段的流水线:
C源文件 → 预处理 → 编译(词法/语法/语义分析) → 中间代码生成 → 编译优化 → 目标代码生成 → 汇编/链接 → 可执行文件这篇(一)先讲前面五个环节:
- 预处理 — 头文件展开、宏替换、条件编译…
- 编译·词法分析 — 把代码拆成一个个”单词”(token)
- 编译·语法分析 — 把token组织成树状结构(AST)
- 编译·语义分析 — 检查类型对不对、逻辑合不合法
- 中间代码生成 — 翻译成编译器内部的语言(LLVM IR)
- 编译优化 — 让生成的代码跑得更快更小
- 目标代码生成 — 翻译成CPU真正能懂的汇编指令
每一个阶段都只依赖前一个阶段的结果,互不干扰,这就是编译器的分层设计思想。
(汇编和链接留在下一篇讲)
1️⃣ 预处理(Preprocessing)
预处理是在正式编译之前,对源代码做的一波文本层面的处理。它不涉及任何语法检查,纯粹是”文本替换+文件拼接”。
预处理具体做了啥
- 头文件包含 —
#include <stdio.h>会把 stdio.h 的内容整个粘贴进来 - 宏替换 —
#define MAX 100会把代码里所有的MAX替换成100 - 去掉注释 —
//和/* */全部删除 - 连接断行符 — 行尾的
\会把下一行拼接上来 - 处理条件编译 —
#ifdef/#else/#endif决定哪些代码留下、哪些删除 - 处理
#字符串化操作符 —#x变成"x" - 处理
##标识符连接操作符 —a##b变成ab
💡 举个例子你就懂了
// 原始代码#include <stdio.h>#define PI 3.14159#define AREA(r) (PI * (r) * (r))
int main() { double s = AREA(5); printf("s = %f\n", s); return 0;}预处理后变成(可以理解成”展开后的代码”):
// stdio.h 的几千行内容被粘贴到这里...int main() { double s = (3.14159 * (5) * (5)); printf("s = %f\n", s); return 0;}怎么查看预处理结果?
gcc -E a.c # 输出到终端gcc -E a.c -o a.i # 保存到 .i 文件
.i 文件就是预处理后的结果,你会看到原来短短几行的代码变成了几百上千行——就是因为头文件被展开了。
🔍 gcc是如何找头文件的?
gcc有一套默认的头文件搜索路径,可以用下面命令查看:
echo | gcc -v -E -x c - 2>&1 | grep "search starts"输出大概长这样:
#include <...> search starts here: /usr/lib/gcc/x86_64-linux-gnu/9/include /usr/local/include /usr/include/x86_64-linux-gnu /usr/includeEnd of search list.头文件搜索顺序
gcc 先找 -I 指定的目录 → 再找系统默认目录尖括号 vs 双引号
| 写法 | 搜索范围 |
|---|---|
#include <stdio.h> | 只搜索 -I 目录 + 系统目录 |
#include "myheader.h" | 优先搜索当前目录,找不到再去系统目录 |
-I的用途给项目添加自定义头文件搜索目录。比如你的项目有个
include/文件夹:gcc -I./include a.c这样gcc就会先去
./include里找头文件,优先级比系统目录还高。
🔍 交叉编译时头文件有啥不同?
一生一芯的目标是生成 RISC-V 架构的代码,所以要用交叉编译器:
riscv64-linux-gnu-gcc -E a.c这时候预处理展开的不再是 x86 的头文件,而是 RISC-V 架构专用的标准库头文件。
而且gcc会自动定义 RISC-V 专属宏:
#define __riscv 1#define __riscv64 1#define __riscv_xlen 64➡️ 预处理阶段就已经标记好目标CPU架构了,后续的编译都会基于这个架构生成对应指令。
.i 文件里完全没有 x86 架构的宏、类型、头文件,是一份纯 RISC-V 架构的预处理代码。
想知道编译器预定义了哪些宏?
echo | gcc -dM -E - | sort会输出一大串预定义宏,比如
__linux__、__x86_64__等等,足足几百个!
2️⃣ 编译(Compilation)—— 核心阶段
编译阶段其实是三个子阶段的组合:词法分析 → 语法分析 → 语义分析。
可以用 clang 来单独观察每个阶段都在做什么。
为什么用clang不用gcc?
因为clang(LLVM的前端)提供了很多调试选项,可以dump出各个阶段的中间结果,特别适合学习!gcc也能做到,但不如clang方便。
2.1 词法分析(Lexical Analysis)
词法分析的工作是把源代码拆成一个个 token(单词),包括:
- 标识符 —
main、printf、x - 关键字 —
int、return、if - 常数 —
10、20、3.14 - 字符串 —
"z = %d\n" - 运算符 —
+、-、*、= - 分隔符 —
{、}、;、(、)
查看词法分析的结果
clang -fsyntax-only -Xclang -dump-tokens a.c输出示例:
int 'int' [StartOfLine] Loc=<a.c:3:1>identifier 'main' [LeadingSpace] Loc=<a.c:3:5>l_paren '(' Loc=<a.c:3:9>r_paren ')' Loc=<a.c:3:10>l_brace '{' Loc=<a.c:3:12>int 'int' [StartOfLine] [LeadingSpace] Loc=<a.c:4:3>identifier 'x' [LeadingSpace] Loc=<a.c:4:7>...注意到 Loc=<a.c:4:7> 这种格式了吗?它记录了每个token的文件名:行号:列号,所以编译器报错时能精准告诉你”第几行第几列出错了”!
一句话总结词法分析:把代码字符串切成一个个有意义的小碎片。
2.2 语法分析(Syntax Analysis)
词法分析拿到了 token 列表,但这些 token 之间是什么关系?语法分析就是按照C语言的语法规则,把这些token组织成一棵树——这棵树就叫抽象语法树(Abstract Syntax Tree, AST)。
查看语法分析的结果
clang -fsyntax-only -Xclang -ast-dump a.c输出示例:
TranslationUnitDecl└─ FunctionDecl <a.c:3:1, line:8:1> main 'int ()' └─ CompoundStmt <line:3:12, line:8:1> ├─ DeclStmt <line:4:3, line:4:18> │ └─ VarDecl <line:4:3, col:7> x 'int' cinit │ └─ IntegerLiteral <col:15> 'int' 10 │ └─ VarDecl <col:10, col:18> y 'int' cinit │ └─ IntegerLiteral <col:18> 'int' 20 ├─ DeclStmt <line:5:3, line:5:16> │ └─ VarDecl <line:5:3, col:7> z 'int' cinit │ └─ BinaryOperator <col:15, col:19> 'int' '+' │ ├─ ImplicitCastExpr <col:15> 'int' <LValueToRValue> │ │ └─ DeclRefExpr <col:15> 'int' lvalue Var 'x' 'int' │ └─ ImplicitCastExpr <col:19> 'int' <LValueToRValue> │ └─ DeclRefExpr <col:19> 'int' lvalue Var 'y' 'int' └─ ReturnStmt <line:6:3, col:10> └─ IntegerLiteral <col:10> 'int' 0可以看到,int z = x + y; 这一行被展开成一棵小树:
VarDecl z └─ BinaryOperator '+' ├─ DeclRefExpr x └─ DeclRefExpr y这棵树清晰地展示了变量声明、赋值、运算的层次关系。后面的编译阶段就是在这棵树上做文章。
为什么叫”抽象”语法树?
因为它省略了那些不影响语义的细节,比如分号、花括号的位置等。只保留理解程序逻辑所必需的结构信息。
2.3 语义分析(Semantic Analysis)
AST建好了,但这里面有些信息还不够——比如每个表达式的类型是什么。
语义分析就是给AST的每个节点打上类型标签,同时检查:
- 类型是否匹配 — 不能把结构体赋值给整数
- 变量是否定义 — 不能使用未定义的变量
- 函数调用参数是否匹配 — 参数个数、类型要对
- 运算符是否合法 — 不能对结构体做加法
语义分析的一个重要应用:静态程序分析
静态分析是指在不运行程序的情况下对源代码进行分析。它可以帮你发现:
- 代码风格和规范问题
- 潜在的软件缺陷(比如野指针、内存泄漏)
- 安全漏洞
- 性能问题
常用静态检查命令
# gcc 基础警告gcc a.c -Wall
# clang 更专业的静态分析clang a.c --analyze -Xanalyzer -analyzer-output=text举个栗子🌰
假设有段有bug的代码:
#include <stdlib.h>int main() { int *p = malloc(sizeof(*p) * 10); free(p); *p = 0; // ❌ 已经释放了还在用! return 0;}运行 clang --analyze 后,输出:
a.c:6:3: warning: Use of memory after it has been freed *p = 0; ^~~~~a.c:4:3: note: Call to 'malloc' returns memory allocated here int *p = malloc(...); ^~~~~~~~~~~~~~~~~~~~~a.c:5:3: note: Memory is released here free(p); ^~~~~~~a.c:6:3: note: Use of released memory happens here *p = 0; ^~~~~Clang 分析器会逐行追踪代码执行流,精准定位bug,还会告诉你完整的”犯罪链”:
- 第4行:
malloc分配了内存 ✅ - 第5行:
free释放了内存 🟡 - 第6行:释放后还在用 ❌
这种bug在大型项目中极其隐蔽——程序可能正常运行好几天才突然崩溃,到那时候再调试就难了!
所以一定要重视lint工具!
一些编程初学者会觉得让编译器报告更多警告是给自己找麻烦。但事实上,使用lint工具的代价几乎是零,却能在代码还在编写阶段就发现大量潜在问题。
这些问题一旦留到运行阶段,你将要付出几十倍甚至上百倍的代价来调试它们。大型项目都会充分利用lint工具来提升代码质量。
📊 语法分析 vs 语义分析 快速对比
| 阶段 | 核心工作 | 检查目标 | 错误例子 |
|---|---|---|---|
| 语法分析 | 检查代码结构格式对不对 | 括号配对、语句格式、关键字拼写 | int a = ; 语法错误 |
| 语义分析 | 检查代码逻辑含义合不合法 | 类型匹配、变量定义、作用域 | struct A a; a + 1 语义错误 |
实际上,Clang/GCC 不会严格区分这两个阶段——语法分析完立刻做语义分析,直接生成带类型标签的AST。
3️⃣ 中间代码生成(Intermediate Representation)
好了,现在AST有了,类型标签也打上了。接下来编译器要做一件很聪明的事:
把C代码翻译成一种”中间语言”——不针对任何特定CPU,而是面向编译器自己定义的一套虚拟指令集。
这就像是先翻译成”世界语”,后面再根据目标国家(x86、ARM、RISC-V)翻译成当地语言。
LLVM IR 长啥样?
clang -S -emit-llvm a.ccat a.ll输出示例:
define i32 @main() { %1 = alloca i32, align 4 %2 = alloca i32, align 4 %3 = alloca i32, align 4 store i32 10, i32* %1, align 4 store i32 20, i32* %2, align 4 %4 = load i32, i32* %1, align 4 %5 = load i32, i32* %2, align 4 %6 = add nsw i32 %4, %5 store i32 %6, i32* %3, align 4 ...}解释一下:
%1、%2、%3是虚拟寄存器(不是真正的CPU寄存器)alloca— 在栈上分配空间store— 存值load— 取值add— 加法call— 调用函数ret— 返回
为什么要有中间代码?——LLVM的三段式架构
LLVM的核心思想:前端统一、后端可扩展
- 多种编程语言 → 同一个LLVM IR
- 同一个LLVM IR → 多种CPU架构
这意味着:你只要写一次编译器后端(从IR到某款CPU),所有支持的语言(C、C++、Rust、Swift…)都能直接生成这款CPU的代码!
编译的本质,就是依据AST的语义,把C语言的高层状态机,等价翻译成中间代码虚拟状态机,最终翻译成CPU硬件ISA的状态机。
4️⃣ 编译优化(Optimization)
中间代码生成完之后,编译器会做一件非常核心的事:优化。
优化的目的是:让你写代码时不用操心性能,编译器帮你搞定。
优化正确性的铁律
编译器可以随意优化代码,但有一条绝对不能触碰的红线:
优化后的程序,外部可观测行为必须和没优化时完全一样
根据C99标准(5.1.2.3节第6点),“可观测行为”包括:
- 对
volatile修饰的变量的访问 — 必须严格执行,不准优化 - 程序结束时写入文件的数据 — 必须和没优化时一致
- 交互式设备的输入输出(
printf/scanf)— 必须和没优化时一致
只要满足这三点,编译器想怎么折腾都行。
常见的优化技术举例
① 常量传播
变量取值是确定的常数,直接代入计算:
// 优化前 // 优化后int a = 1; int a = 1;int b = a + 2; int b = 3;printf("%d\n", b * 3); printf("%d\n", 9);编译器直接在编译期就算出 9 了,运行时根本不用算!
② 死代码消除
对于不可达的代码或不再使用的变量,直接删掉:
// 优化前 // 优化后#define DEBUG 0 #define DEBUG 0int fun(int x) { int fun(int x) { int a = x + 3; return x / 2; if (DEBUG) { } printf("a = %d\n", a); } return x / 2;}DEBUG 是 0,if(0) 里面的代码永远不会执行——直接删掉!连 a 这个变量都不需要了。
③ 消除冗余操作
没被读取就被覆盖的赋值,删掉:
// 优化前 // 优化后int a; int a;a = 3; f();a = f(); a = 10;a = 7;a = 10;a = 3、a = 7 还没来得及用就被覆盖了——纯属浪费CPU时间,删了!
⚠️ 注意:
f()不能被优化掉!编译器不知道
f()内部做了什么——它可能打印了东西、修改了全局变量、写入了文件。删掉它会改变程序行为,违反优化铁律。
④ 代码强度削减
用更简单的运算替代复杂的运算:
// 优化前 // 优化后int x = a[i * 4]; int x = a[i << 2];乘法 *4 和左移 <<2 结果一样,但移位比乘法快几十倍!
⑤ 提取公共子表达式
多次计算的相同表达式,只算一次:
// 优化前 // 优化后int x = a * b - 1; int temp = a * b;int y = a * b * 2; int x = temp - 1; int y = temp * 2;a * b 算了两次 → 改成都用 temp,只算一次。
⑥ 循环不变代码外提
每次循环结果都一样的东西,提到循环外面:
// 优化前 // 优化后int a = f1(); int x = f1() + 2;for (i = 0; i < 10; i++) { for (i = 0; i < 10; i++) { int x = a + 2; int y = f2(x); int y = f2(x); sum += y + i; sum += y + i; }}a + 2 在循环10次里结果完全一样 → 提到外面算一次就够了!
⑦ 函数内联
小函数直接展开在调用处,省去函数调用的开销:
// 优化前 // 优化后int f1(int x, int y) { int f1(int x, int y) { return x + y; return x + y;} }int f2(int x) { int f2(int x) { return f1(x, 3); return x + 3; // 直接展开!} }函数调用需要压栈、跳转、返回——对于只有一行代码的小函数来说,调用开销比函数本身还大,内联展开完美解决这个问题。
优化等级怎么选?
编译器提供了不同的优化等级,让开发者在性能、代码大小、编译时间之间做选择。
按性能排序(从快到慢)
-Ofast > -O3 > -O2 > -O1 > -Og > -O0| 等级 | 特点 | 适用场景 |
|---|---|---|
-O0 | 无任何优化,默认 | 调试代码、开发阶段 |
-O1 | 基础优化,速度/体积平衡 | 简单程序 |
-O2 | 标准优化【工业界主流】 | 90%的项目用这个 |
-O3 | 激进优化,用更大体积换性能 | 追求极致性能(音视频、游戏) |
-Ofast | 最激进,违反部分C标准 | 极致性能,不在乎精度 |
-Og | 调试友好的优化 | 既要性能又要调试 |
按代码大小排序(嵌入式专用)
-Oz > -Os > -O1 > -O0| 等级 | 特点 | 适用场景 |
|---|---|---|
-Os | 优化大小,不牺牲太多性能 | 一般嵌入式 |
-Oz | 极致压缩代码体积 | 单片机、小内存设备 |
查看某个等级启用了哪些优化
# gccgcc -Q --help=optimizers -O1
# clangclang -S -emit-llvm -O1 a.c -ftime-report💡 关于 volatile 的一个小补充
笔记里提到了 volatile 关键字,它告诉编译器:
这个变量的值可能会被外部因素改变(硬件、中断、其他线程),不准对它做任何优化!不准删、不准重排、不准缓存!
常见使用场景:
- 内存映射的硬件寄存器(比如单片机里读取传感器数据)
- 多线程共享变量(防止编译器优化导致读取陈旧值)
- 信号处理函数中修改的变量
volatile int *sensor = (int *)0x40001000;int value = *sensor; // 每次读取都要真的去内存拿,不能用缓存的值5️⃣ 目标代码生成(Code Generation)
优化完的LLVM IR,最后一步是把它翻译成目标CPU真正能执行的汇编指令。
两个关键命令
# 1️⃣ 生成【本机CPU】的汇编代码(比如x86电脑 → x86汇编)clang -S a.c
# 2️⃣ 交叉编译:生成【RISC-V64】的汇编代码clang -S a.c --target=riscv64-linux-gnu
# GCC 对应交叉编译命令riscv64-linux-gnu-gcc -S a.c
-S选项的意思是:只编译到汇编代码(.s文件),停在目标代码生成阶段。
目标代码生成的三大核心优化
1️⃣ 寄存器分配 🏆(性能关键!)
寄存器是CPU内部的高速存储,访问速度比内存快100倍以上!
| 寄存器 | 内存 | |
|---|---|---|
| 速度 | 纳秒级(1个CPU周期) | 微秒级(几十~几百个周期) |
| 容量 | 极小(几十~几百个) | 很大(GB级别) |
编译器策略:
- 常用变量 → 分配寄存器(性能暴增)
- 不常用变量 → 放内存
无优化时,所有变量都放内存;优化后,常用变量全放寄存器,性能差距可以达到几十倍!
2️⃣ 指令精简
把多条虚拟指令合并成最少的硬件指令:
LLVM IR: load → add → store硬件指令: add (一条搞定)3️⃣ 适配目标ISA
不同的CPU指令完全不同,编译器自动适配:
- x86 → 生成
mov、add、sub、call… - RISC-V → 生成
addi、lw、sw、ret…
📊 加 -O1 后汇编有啥变化?
拿最开始的示例代码:
int main() { int x = 10, y = 20; int z = x + y; printf("z = %d\n", z); return 0;}| 维度 | 无优化(-O0) | 优化后(-O1) |
|---|---|---|
| 指令数量 | 多,冗余 | 极少,极致精简 |
| 变量存储 | 全部放内存 | 全部放寄存器(高速!) |
| 计算方式 | 逐行翻译,运行时计算 | 编译时直接算出结果(常量折叠) |
| 性能 | 低 | 高 |
| 可读性 | 能对应每一句C代码 | 高度精简,难以逐行对应 |
优化后的汇编还能跟C代码对应上吗?
不能逐行对应了,但行为完全等价:
- C代码:
a=1, b=2, c=a+b, return c- 汇编:直接返回
3- 最终结果完全一样!
这就是编译器的等价优化——不管中间怎么折腾,对外表现必须一致。
📝 小结
从C代码到二进制程序,编译器做了一场精密的”翻译+优化”流水作业:
| 阶段 | 输入 | 输出 | 做了什么 |
|---|---|---|---|
| 预处理 | .c 源文件 | .i 预处理文件 | 展开头文件、替换宏、去注释 |
| 词法分析 | 预处理后的代码 | token序列 | 拆成”单词” |
| 语法分析 | token序列 | AST(抽象语法树) | 组织成树状结构 |
| 语义分析 | AST | 带类型标签的AST | 检查类型、打标签 |
| 中间代码生成 | AST | LLVM IR | 翻译成中间语言 |
| 编译优化 | LLVM IR | 优化后的LLVM IR | 常量传播、死代码消除等 |
| 目标代码生成 | LLVM IR | 汇编代码(.s) | 翻译成CPU指令 |
⚡ 各阶段常用指令速查
# ── 1. 预处理 ──gcc -E a.c # 查看预处理结果(输出到终端)gcc -E a.c -o a.i # 保存预处理结果到 .i 文件echo | gcc -dM -E - | sort # 查看所有预定义宏riscv64-linux-gnu-gcc -E a.c # RISC-V 交叉编译的预处理
# ── 2. 词法分析 ──clang -fsyntax-only -Xclang -dump-tokens a.c # 查看token序列
# ── 3. 语法分析 ──clang -fsyntax-only -Xclang -ast-dump a.c # 查看抽象语法树(AST)
# ── 4. 语义分析 / 静态检查 ──gcc a.c -Wall # gcc基础警告clang a.c --analyze -Xanalyzer -analyzer-output=text # clang深度静态分析
# ── 5. 中间代码生成 ──clang -S -emit-llvm a.c # 生成 LLVM IR(.ll文件)
# ── 6. 编译优化 ──clang -S -emit-llvm -O1 a.c # 带优化的LLVM IRclang -S -emit-llvm -O1 a.c -ftime-report # 查看优化子步骤(pass)gcc -Q --help=optimizers -O1 # 查看-O1启用了哪些优化
# ── 7. 目标代码生成 ──clang -S a.c # 生成当前CPU架构的汇编(.s)clang -S a.c --target=riscv64-linux-gnu # 交叉编译 → RISC-V汇编riscv64-linux-gnu-gcc -S a.c # GCC版交叉编译下一篇(二)会讲剩下的两个阶段:汇编(Assembly) 和 链接(Linking),最终生成可执行文件!
本笔记整理自”一生一芯”E4章节学习内容,加入了一些自己的理解和补充说明。如有错误欢迎指正~😤
下次更新:E4 从C代码到二进制程序(二)——汇编与链接
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时








