mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5
4336 字
12 分钟
E4 从C代码到二进制程序(一)——编译器的工作流水线
2026-05-21

E4 从C代码到二进制程序(一)——编译器的工作流水线#

本笔记是一生一芯计划的学习记录,主要参考了”一生一芯”课程E4章节的内容。

写个 hello.c 按一下 gcc 就出可执行文件了,但你知道中间发生了什么吗?🤔


📌 这篇笔记讲了啥#

编译器把C代码变成可执行二进制程序,不是一步到位的,而是经过一条五阶段的流水线

C源文件 → 预处理 → 编译(词法/语法/语义分析) → 中间代码生成 → 编译优化 → 目标代码生成 → 汇编/链接 → 可执行文件

这篇(一)先讲前面五个环节:

  1. 预处理 — 头文件展开、宏替换、条件编译…
  2. 编译·词法分析 — 把代码拆成一个个”单词”(token)
  3. 编译·语法分析 — 把token组织成树状结构(AST)
  4. 编译·语义分析 — 检查类型对不对、逻辑合不合法
  5. 中间代码生成 — 翻译成编译器内部的语言(LLVM IR)
  6. 编译优化 — 让生成的代码跑得更快更小
  7. 目标代码生成 — 翻译成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/include
End 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(单词),包括:

  • 标识符 — mainprintfx
  • 关键字 — intreturnif
  • 常数 — 10203.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,还会告诉你完整的”犯罪链”:

  1. 第4行:malloc 分配了内存 ✅
  2. 第5行:free 释放了内存 🟡
  3. 第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.c
cat 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的三段式架构#

graph LR %% 定义样式分组:前端、中间层、后端 subgraph Frontend[前端 frontend] A1[C] --> B1[Clang] A2[Fortran] --> B2[llvm-gcc] A3[Haskell] --> B3[GHC] end %% 中间层:LLVM IR 与 优化器 B1 --> IR[LLVM IR] B2 --> IR B3 --> IR IR --> Opt[llvm-opt 优化器] Opt --> IR2[LLVM IR] subgraph Backend[后端 backend] C1[llvm-x86] --> D1[x86] C2[llvm-arm] --> D2[ARM] C3[llvm-riscv] --> D3[RISC-V] end %% 连接中间层与后端 IR2 --> C1 IR2 --> C2 IR2 --> C3

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点),“可观测行为”包括:

  1. volatile 修饰的变量的访问 — 必须严格执行,不准优化
  2. 程序结束时写入文件的数据 — 必须和没优化时一致
  3. 交互式设备的输入输出(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 0
int fun(int x) { int fun(int x) {
int a = x + 3; return x / 2;
if (DEBUG) { }
printf("a = %d\n", a);
}
return x / 2;
}

DEBUG0if(0) 里面的代码永远不会执行——直接删掉!连 a 这个变量都不需要了。

③ 消除冗余操作#

没被读取就被覆盖的赋值,删掉:

// 优化前 // 优化后
int a; int a;
a = 3; f();
a = f(); a = 10;
a = 7;
a = 10;

a = 3a = 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极致压缩代码体积单片机、小内存设备

查看某个等级启用了哪些优化#

# gcc
gcc -Q --help=optimizers -O1
# clang
clang -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 → 生成 movaddsubcall
  • RISC-V → 生成 addilwswret

📊 加 -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检查类型、打标签
中间代码生成ASTLLVM 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 IR
clang -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代码到二进制程序(二)——汇编与链接

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

E4 从C代码到二进制程序(一)——编译器的工作流水线
https://emilia520.icu/posts/yi-sheng-yi-xin-e4-compiler-p1/
作者
火花花
发布于
2026-05-21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录