一张图看懂LLVM编译流程:从源码到机器码的完整流水线
开头
如果你接触编译原理或者看过几篇编译器相关的文章,大概率已经被“LLVM”这个词刷屏过。从 Xcode 的默认工具链,到 Android 的 ART 编译器,再到 Rust 的 rustc 后端,甚至你用 Chrome 打开网页时的 JavaScript 引擎 V8,底层都有 LLVM 的影子。但一打开源码或者官方文档,你会瞬间被目录结构、Pass 机制、IR 规范这些概念淹没。作为一个在编译方向摸爬滚打多年的从业者,我第一次完整看懂 LLVM 编译流程,靠的不是啃文档,而是一张画满方框和箭头的流程图。
这篇文章我就用“一图胜千言”的思路,把 LLVM 的完整编译流程拆开揉碎给你看。从源代码到最终可执行文件,中间每一层产物长什么样、每个环节在干什么、为什么这么设计,我都用最直白的话讲清楚。文章后面还会附上我实际跑通全流程的操作步骤,以及大家特别关心的一个问题:有没有预编译的 LLVM 可以直接用,还是非得从源码编译?
不管你是刚入门编译原理的学生,还是工作中被迫接触 LLVM 的普通后端工程师,这篇文章都能帮你补上这块容易被绕晕的基础知识,把那条流水线牢固地装进脑子里。
1. 整条流水线在干什么:LLVM 的设计哲学与主干流程
在看具体节点之前,得先理解一个核心问题:LLVM 为什么要把编译过程拆成好几个独立阶段?要搞懂这个,你得先知道传统编译器是怎么做的。
1.1 传统编译器的一条龙老路子
早期的 GCC 走的是“一条龙”路线:拿到 C 源码,先词法分析、语法分析,生成一棵抽象语法树,然后直接开始生成目标平台的汇编代码。从上到下一次性走完,每个阶段紧密耦合。好处是编译效率高,坏处是——如果你想给这门语言新增一个后端,比如让它支持某个新出的 CPU 架构,就得把编译器的后半截几乎全部重写。反过来,如果你想支持一门新语言,也得把它的语义分析跟现有后端强行绑定在一起。
这就好比一个全能餐厅,从种菜、炒菜到上菜全在自家厨房里完成。你想加一道新菜,后厨得跟着大改;你想开一家新分店,整个团队全套搬过去。这套模式在早期能跑,但放到今天的环境里,语言这么多(C、C++、Rust、Swift)、硬件平台更多(x86、ARM、RISC-V、GPU),完全撑不住。
1.2 LLVM 的“万能中间层”方案
LLVM 的设计天才之处,在于它引入了“中间表示”这个概念,也就是常说的 IR。整个编译过程被硬生生切成了三段:前端负责把源代码翻译成 IR,中端拿到 IR 以后只做优化不关心源代码是什么样,后端拿到优化后的 IR 再翻译成具体平台的机器码。
这句话多说几遍,因为它是理解整个 LLVM 的钥匙。每一段都只跟自己的上游和下游打交道,接口干净利落。增加一门新语言,只需要写一个前端,把源码翻译成 IR,后面所有优化和后端直接白嫖;增加一个新硬件平台,只需要写一个后端,前面所有前端和优化也直接白嫖。
这就是为什么 LLVM 生态能飞速膨胀,同样是编译器的老大哥 GCC 虽然也在演进,但它那种“语言专属前端 + 通用优化 + 多后端”的架构,开始时设计时并没有把跨语言扩展当作首要目标,是后期慢慢演化出来的。而 LLVM 从一开始就把这套三层架构刻在了骨子里。
1.3 这张主干图应该怎么读
把 LLVM 编译全流程画成图,主干其实就是一条直线:源代码 → 词法分析 → 语法分析 → 语义分析 → AST → 中间代码生成 → IR → Pass 优化 → 优化后 IR → 指令选择 → 寄存器分配 → 指令调度 → 机器码 → 汇编 → 目标文件 → 链接 → 可执行文件。
如果你有过一些编译原理基础,会觉得前面这些名字眼熟,因为它们本来就是经典编译器的基础流程。但 LLVM 的特别之处在于:这条流水线的中段,也就是 IR 和 Pass 优化那部分,被设计成了整个体系的核心枢纽。后面你看到的所有高级功能,比如跨语言优化、LTO(链接时优化)、JIT 动态编译,全都是围绕这个核心枢纽做文章。
2. 图解编译全流程:每一个节点到底在干什么
把整条流水线按三段式切成前端、中端、后端三个大块来看,每一块内部还能继续细分,这就是那张“千言万语”的图里最重要的层次关系。
2.1 前端:从源码到 AST
前端的工作是把一段文本变成程序能理解的结构化数据。以你在 C 源文件里写下的一行 int a = 10 + 20; 为例,前端编译器拿到这行文本之后,先做词法分析,也就是把连续的字符流切成一个个单词,术语叫 token。int 是一个关键字,a 是一个标识符,= 是赋值运算符,10 和 20 是数字字面量,+ 是加号运算符。这一堆 token 组成的列表,就是词法分析的产物。
接下来是语法分析,它要把 token 列表按照 C 语言的语法规则拼成一棵结构树——抽象语法树,也就是 AST。上面那行代码在 AST 里大致长这样:根节点是“声明语句”,下面挂着类型节点、名称节点,以及一个“加法表达式”节点,加法表达式节点下面再挂两个数字字面量节点。到了这一层,程序已经不再是文本,而是一棵可以被遍历和操作的内存中的树。
接着是语义分析,编译器要检查这棵树在语义上是否正确。比如 a 有没有被声明过,10 + 20 的类型是否匹配,有没有隐式转换的需要。这一步会往 AST 上补充类型信息,标注各个表达式的结果类型,同时把作用域解析清楚。做完这些检查以后,一棵“语义完备”的 AST 就可以继续往前端流水线的下一站走了。
对 LLVM 生态里的 C 语言家族来说,这个前端就是 Clang。Clang 的语法分析速度很快,错误提示又友好,这也是为什么很多开发者即使不用 LLVM 的后端,也愿意拿 Clang 当编译器前端来用。
2.2 中端:从 AST 到 IR,优化的大本营
AST 不是 LLVM 体系内部的核心数据结构,所以前端拿到语义完备的 AST 后,还要继续做一步翻译,把它转成 LLVM 的核心中间表示 IR。这一步通常叫“中间代码生成”,有时候也叫“IR 生成”。
LLVM IR 是一套非常有意思的设计。它采用三地址码的形式,每条指令最多带三个操作数,比如 %sum = add i32 %a, %b,就是把 %a 和 %b 相加,结果存到 %sum。它同时被设计成静态单赋值形式,也就是每个变量只能被赋值一次,而且必须先定义、后使用。这两点保证了 IR 非常规整、易于做各种分析和变换。
IR 还有一个特点:不区分具体平台。它不使用 x86 的寄存器,也不使用 ARM 的寄存器,而是假设有“无限个临时寄存器”,也就是 %1、%2、%3 这种虚拟寄存器。真正映射到物理寄存器是后端的事,中端的优化完全不用考虑硬件约束。
优化 Pass 干的事情就是从各个维度把 IR 改得更高效。比如常量折叠会把 10 + 20 直接算成 30;死代码消除会把那些结果没人用的指令删掉;循环不变量外提会把循环体里每次计算都一样的表达式搬到循环外面。每个 Pass 只干一件事,但几十个 Pass 按固定顺序串起来,就能把一份朴素 IR 打磨得相当高效。
2.3 后端:从 IR 到机器码
优化结束后的 IR 被交给后端,后端里才是真正的硬功夫。第一步是指令选择:IR 里每条指令都是目标无关的抽象操作,但具体芯片只认识自己的指令集。这一步要把 IR 指令映射到目标平台的指令,比如把 add i32 映射成 x86 的 addl 指令,同时要考虑寻址模式、指令本身能否一条搞定,还是需要拆成多条。
接着是寄存器分配。这一步要把优化过程中使用的无限虚拟寄存器,映射到物理寄存器上。物理寄存器数量有限,x86-64 只有 16 个通用寄存器,ARM64 多一些也就 31 个。那些分配不到寄存器的值会被“溢出”到内存栈上,这个过程叫寄存器溢出,会影响性能,所以寄存器分配器会想尽办法做图着色算法,尽可能让热路径上的变量都留在寄存器里。
然后是指令调度,它负责调整指令的执行顺序。现代 CPU 是流水线执行、乱序执行的,指令间如果存在数据依赖就得等待,如果不存在依赖就可以并行。调度器会把没有依赖关系的指令穿插排列,让 CPU 的多个执行单元尽量别闲着。
最后还会做一层窥孔优化,针对已经生成的目标指令做局部的、模式匹配式的优化,比如把两条连续的加载指令合并成一条带偏移的加载。做完这些,机器码就诞生了。后续的汇编和链接虽然也在这条流水线末端,但它们的思路相对直白:汇编把汇编文本转换成目标文件,链接把多个目标文件合并成最终可执行文件,其中的符号解析和重定位环节,又是另一套值得专门写一篇文章的话题。
2.4 一张表看懂每层产物
如果你对 LLVM 流程还觉得模糊,我建议你把“每一层的输入和输出分别是什么”记牢。下表是我自己常用的备忘表,在实践中非常管用。
| 阶段 | 输入 | 输出 | 数据形态 |
|---|---|---|---|
| 词法分析 | 源代码文本 | token 流 | 文本序列 |
| 语法分析 | token 流 | AST | 内存中的树 |
| 语义分析 | AST(无类型) | AST(带类型标注) | 内存中的树 |
| IR 生成 | 带类型 AST | LLVM IR | 文本或内存中的指令序列 |
| 优化 Pass | LLVM IR(朴素) | LLVM IR(优化后) | 指令序列的变换 |
| 指令选择 | 优化后 LLVM IR | 目标平台指令序列 | 机器指令 |
| 寄存器分配 | 虚拟寄存器指令序列 | 物理寄存器指令序列 | 机器指令 |
| 汇编 | 汇编文本 | 目标文件(.o) | 二进制 |
| 链接 | 多个目标文件 | 可执行文件 | 二进制 |
这条表从顶到底,就是一张图里最关键的主干线。你把它横着放在脑子里,再来理解 LLVM 的任何组件,都有了坐标感。
3. 比编译流程本身更重要的三条“暗线”
很多介绍 LLVM 的文章都停留在“前端—中端—后端”这种大而化之的层面,但如果你想真正上手操作,必须理解流程图下方藏着的三条暗线:Pass 管线的调度方式、IR 的设计动机、以及目标无关性与目标相关性的分界。不懂这三条,你跑出来的实验代码可能糊里糊涂。
3.1 Pass 管线:优化顺序本身就是一门学问
中端的 Pass 不是一个个独立运行的,它们组合成一条管线,按顺序执行。这个顺序非常有讲究。比如 -O2 这个优化档位,在 LLVM 里会被展开成一条由几十个 Pass 组成的序列,每个 Pass 在 IR 上做一次遍历和变换。
为什么顺序有讲究?因为有些 Pass 做分析时要依赖之前 Pass 留下的信息。如果先做内联,再做死代码消除,能顺手删掉内联后产生的冗余指令;反过来先做死代码消除再做内联,前面白做一遍。再比如循环相关的优化,通常要等循环结构信息被提取出来之后才能做。Pass 之间嵌套的分析结果可以复用,有专门的 Pass 管理器负责缓存和调度这些信息。
不同优化档位的区别就在于管线的“密度”。-O0 几乎不做优化,IR 怎么生成就怎么交给后端;-O1 只做最基本的清理工作;-O2 是大多数项目默认的选择,它在编译速度和生成代码质量之间做了平衡;-O3 会开启更激进的内联和向量化,让代码体积变大,但运行速度可能更快;-Os 和 -Oz 则偏向减小体积。你在实践中看到的意义相反的现象——比如某个项目编译时从不使用 -O3——往往就是因为在特定场景下这种激进的优化会带来问题,比如栈占用过大或者编译时间暴涨。
3.2 IR 的设计理念:SSA 与无限寄存器
LLVM IR 选择静态单赋值形式和无限虚拟寄存器这两个设计,不是偶然,是深思熟虑的结果。SSA 让每个变量只被赋值一次,这让数据流分析变得非常简单直观。如果想把一个表达式替换成另一个值,只需要改它的定义处,不需要在若干个赋值点里逐一排查所有引用。
举个例子,在非 SSA 形式里,一个变量 x 可能被赋值三次,后面有五处使用它。你想做常量传播,把某个位置的 x 值替换成常量,得先搞清到底哪个赋值流到了哪个使用点,这是复杂的数据流分析问题。而在 SSA 形式里,每个 x 只有一次定义,使用处如果引用它,一定就是引用那一次的定义。这种简化对优化器极其友好。
无限虚拟寄存器则是把寄存器分配问题完全推迟到了后端。中端优化时完全不用考虑“物理寄存器不够用怎么办”,所有变量都是虚拟寄存器,想用多少用多少。真正映射物理寄存器时再统一处理,这样优化逻辑就跟具体硬件解耦了。这也是为什么同一份 IR 可以跨平台移动到 x86、ARM、RISC-V 上做后端编译。
3.3 目标无关与目标相关的分界线
理解 LLVM 的另一个关键,是搞清楚哪些部分跟硬件无关,哪些跟硬件强相关。前端、IR 生成、绝大部分优化 Pass 都是目标无关的。它们处理的是抽象的指令层级,不关心最终机器码长什么样。
但从后端开始,目标相关性就来了。指令选择要知道目标平台的指令集有哪些可用指令,寄存器分配要知道目标平台到底有多少个物理寄存器,指令调度要知道目标 CPU 的流水线结构。这些后端代码在 LLVM 源码里分成一类列架构,每种架构都有独立目录。
这条分界线意味着什么呢?意味着如果有人告诉你“LLVM 优化后代码性能更好”,你得先问清楚是哪一层做的优化。很多优化在中端做,对任何平台都有效;但真正的杀招,比如针对特定 CPU 微架构的指令重排,是后端干的活,必须针对具体型号调优。工程师如果混淆这两层,排查性能问题时很容易走弯路。
4. 实操:把“图”跑一遍,亲手摸到每一层产物
概念说完了,咱们现在动手,把这条流水线从头到尾跑一遍。我以最常见的 C 语言为例,环境是 Ubuntu 或 macOS 上装了 Clang 的普通机器。为了理解方便,我们用一个简单到不能再简单的求和函数。
4.1 环境准备:别自己编译 LLVM
我见过太多人卡在“先编译 LLVM”这一步,耗时几个小时甚至一整天,最后还没开始学就放弃了。这里必须强调:日常学习和实验,完全不需要自己从源码编译 LLVM。你自己编译一个 Release 版的 LLVM 需要的时间,足够你读完这篇文章并且跑完后面所有实验了。
在 Ubuntu 上直接 sudo apt install clang llvm lld,在 macOS 上 Xcode 自带 Clang,如果想用更新版本可以 brew install llvm,Windows 上可以用 LLVM 官方提供的预编译安装包。这些方式得到的都是完整可用的 LLVM 工具链。后文我会专门把“有没有预编译的 LLVM”这个问题展开讲一遍。
装好之后,确认工具能用:
能看到版本号就说明环境没问题。
4.2 从源码摸到 IR
新建一个源文件:
用 Clang 把它编译成 LLVM IR,但先不要继续往下编译:
生成的 sum.ll 是文本形式的 IR,可以直接打开看。你会看到类似下面的内容:
这里你能很直观地看到 SSA 是什么样:%i.0 虽然在循环里值会变化,但在 SSA 里它用 phi 指令来表示“来自不同前驱块的不同值”。IR 的语法比你想象得更有规律,每条指令都是一个操作符加操作数,一眼望过去,整个循环结构、数据流都清晰可见。这就是 LLVM IR 非常值得学习的原因之一:它比汇编抽象,又比 C 语言直观地暴露了底层机制。
4.3 在 IR 上跑一轮优化,看看变化
现在我们把优化档位打开,让中端优化管线跑一遍:
打开 sum_opt.ll 一看,你会大吃一惊。之前的循环消失了,整个函数变成了一条指令:
虽然这个简单求和函数的循环没有被完全消除(它消除不了,复杂度是 O(n)),但你注意到 -O2 版本里的指令明显精简了不少:常数比较方式变了、循环结构也调整过。如果你换一个更“傻瓜”的写法,比如函数里写 return 10 + 20;,-O2 会直接把它折叠成 ret i32 30。这就是常量折叠和死代码消除在背后干活的结果。
为了更明显地体验优化管线的威力,你可以跑一下 LLVM 自带的优化器 opt:
mem2reg 这个 Pass 专门把原本在栈上分配的变量提升到虚拟寄存器中,它是很多后续优化的前置步骤。看看 sum.ll 转成 sum_mem2reg.ll 之后的变化,能帮助你理解 IR 为什么要在早期就做这一步。
4.4 后端 CodeGen 与汇编
IR 看过了,优化也体验过了,现在看后端流水线。用 Clang 直接生成目标平台的汇编:
打开 sum.s,如果用的是 x86-64 平台,你会看到一堆 movl、addl、cmpl、jle 之类的汇编指令。这些就是后端指令选择和寄存器分配的产物。你可以把 sum.s 跟之前的 sum.ll 对照着看:IR 里的 add nsw i32 到了目标平台就变成 addl,%i.0 这样的虚拟寄存器变成了 -8(%rbp) 或 %eax 这样的真实寄存器和内存位置,寄存器分配就在这一步真正落到了实处。
再往后就是汇编和链接了:
这一步 Clang 内部会调用汇编器把 .s 转成 .o 目标文件,再调用链接器把目标文件连同 C 运行时库链接成可执行文件。在 LLVM 生态里,汇编器是 llvm-mc,链接器是 lld,如果你好奇,可以手动拆开来看:
虽然 lld 直接调用需要一堆参数,日常也没人这么写,但看一眼这一层能让你对整个链路的理解更完整。
5. 大家最常问的:有没有预编译的 LLVM?
我自己在社区里不知道被问了多少次这个问题,也是很多入门者卡壳的根源。其实答案很明确:有,而且途径有很多。
5.1 系统包管理器:最快的路
绝大多数主流 Linux 发行版都打包好了 LLVM 全家桶。Debian/Ubuntu 上直接:
Fedora 上对应的是:
Arch 系列则更简单:
macOS 上如果装了 Homebrew,brew install llvm 会把整套 LLVM 装好。但注意,macOS 系统自带的 Clang 是 Xcode 工具链的一部分,相对独立;Homebrew 的 LLVM 则比较新,两者可能版本不一致,用的时候看下路径,别用混了。
这些预编译包的好处是省心,安装即用。代价是版本可能不是最新的,但对学习和绝大多数生产需求,足够了。
5.2 官方二进制与用户侧工具链
除了系统包管理器,LLVM 官方在 GitHub Releases 页面也提供预编译的二进制文件,支持 Windows、Linux、macOS 等常见平台。下载解压后,把 bin 目录加进 PATH 就能用。这一途径对 Windows 用户尤其友好,省去了自己配置 MSYS2 或 WSL 的麻烦。
如果你的开发目标是 Android NDK 或 iOS 开发,对应的 LLVM 也已经随着 NDK/Xcode 一起配好了,不用你自己折腾预编译问题。这个生态的好处是:你几乎总是能找到某个包管理器已经把 LLVM 打包好了,它不像某些小众库那样必须从源码编译才能用上。
5.3 什么情况下才需要源码编译
既然有预编译,为什么网上还有很多教程让人从源码编译?原因大概有三类。
第一,你需要定制化的 LLVM,比如自己写了新的 Pass、修改了 IR 规范,或者要研究 LLVM 源码本身。这种情况下你当然得拿源码编译一套自定义版本。
第二,目标平台没有预编译包。比如你要为一个新的嵌入式架构交叉编译一套 LLVM 工具链,或者你的操作系统版本太老、包管理器里没有对应的 binary,那就得自己编译。
第三,教学场景。很多编译原理课程要求学员从源码构建 LLVM,目的不是造轮子,而是让你亲手经历一遍配置、构建、安装的完整流程,理解 LLVM 的工程结构。对学习者来说,这是一堂必修课,但对普通用户,没必要。
从源码编译 LLVM 的经典姿势是这样的:
在配置里,LLVM_ENABLE_PROJECTS 决定除了核心 LLVM 之外还要构建哪些子项目,LLVM_TARGETS_TO_BUILD 决定支持哪些后端架构。如果全都默认构建,时间和磁盘空间的消耗会很可观;只保留需要的部分,能省掉一大截时间。这套 CMake 命令本身也值得专门写一篇博客来介绍,这里不展开。我的建议是:先装预编译版本跑通流程,有需要再回头源码编译,两不耽误。
6. 踩坑实录与排查技巧
流程跑通了,但光看流程永远不如动手踩坑学得快。这里分享几个我在实践中反复遇到的问题,以及排查思路,希望能帮你少走弯路。
6.1 命令行总是找不到 clang
这种情况最常见于 macOS 上同时装了 Xcode 和 Homebrew LLVM。Xcode 自带的是老版本的 Clang,路径在 /usr/bin/clang;Homebrew 的则在 /opt/homebrew/opt/llvm/bin 下面。如果你用 brew install llvm 之后直接敲 clang --version,很可能敲出来的是 Xcode 自带的版本,版本号比你预期旧很多。
解决方法是:把 Homebrew LLVM 的路径加到 PATH 前面,或者直接用全路径调用。你可以在 shell 配置文件里加这么一行:
但要注意,这种改法可能会影响其他工具链的优先级,尤其是 macOS 上一些依赖系统 Clang 的脚本。更稳妥的方式是只在你确认需要新版本 LLVM 的终端会话里临时设置,或者通过 CMake 里的 CMAKE_C_COMPILER 指向你想要的 Clang。
6.2 LLVM 版本与 Clang 版本不一致
LLVM 是一个整体,但实际环境中,clang 后端依赖的 LLVM 版本必须和 llvm-* 系列工具版本匹配。如果系统上同时存在不同版本的库,可能在链接时报各种找不到符号的错误。比如你用 apt install clang-15,又手动装了 llvm-14,那 llvm-config --version 和 clang --version 会各报各的,工具之间互相不认账。
排查方法是统一版本号。clang-15 --version 应该对应 llvm-config-15 --version,也对应 /usr/lib/llvm-15 目录下的库文件。使用包管理器安装时,尽量选择同一套版本的 meta 包,比如同时装 clang-15 和 llvm-15,而不是混着装。如果确实需要多版本共存,可以用 update-alternatives 机制管理符号链接,让命令默认指向目标版本。
6.3 链接时符号找不到
很多初学者在编译自己写的小程序时,会遇到 undefined reference to XXX 这种错误,第一反应以为是自己代码写错了。在 LLVM 语境里,这个问题经常出在两个方面:一是你没有链接对应的库,需要手动加 -l 参数;二是编译和链接用的编译器和库不一致,比如用 Clang 编译,但链接阶段依赖的某个静态库是 GCC 版本。
我自己踩得最频繁的坑是在 macOS 上交叉编译到其他平台,经常会忘了指定对应的 sysroot 和链接器。LLVM 生态里 LLD 支持多种链接风格,但它的驱动参数和系统自带链接器不太一样,默认情况下你可能根本没用到它。如果你需要做交叉编译或者特殊链接,建议手动显式指定 linker 为 -fuse-ld=lld,并确认目标平台的运行时库路径都正确。
6.4 优化等级不要一上来就 O3
我知道很多新手有个惯性:追求性能,编译选项直接拉满 -O3。这在大多数场景下问题不大,但如果你在开发自己的 Pass,或者研究某个优化行为,-O3 会让问题变得扑朔迷离。因为 -O3 的管线里包含大量激进的变换,你看到的最终 IR 可能已经被改得面目全非,很难定位某个优化是否正确执行。
从这里延伸出一个经验:调试编译器本身时,先别开优化。-O0 产出的 IR 最接近源代码的结构,变量名也保留得最好,最适合人眼阅读。等基础逻辑验证通过,再逐个增加优化档位,观察每一步的 IR 变化。我之前写过不少 Pass,所有的 debug 流程都是 -O0 起步,然后单独用 opt -passes=xxx 来跑自己写的那个 Pass,配合 -print-after-all 看每一个 Pass 之后 IR 变成什么样。这条方法比盯着 -O3 的最终产物盲目猜测要高效得多。
7. 对图的新理解与一点个人经验
回过头再来看“一图胜千言”这件事:所谓的图,不只是把方框和箭头画出来,而是把每一条边界都标清楚。LLVM 这条流水线真正优雅的地方,在于它把一项极其复杂的任务,用非常干净、规则的方式切成了几大块,每个模块之间只通过定义良好的接口通信。当你真正理解了这一点以后,再去看 LLVM 源码、写 Pass、甚至设计一门新语言,都会顺畅很多。
如果你刚开始接触编译方向,我的建议是不要急着去读源码,先按照我这篇文章里的步骤,亲手把那段 sum.c 从头到尾跑一遍,每跑一步都打开中间产物看一眼。花一两个小时把这条流水线的每个节点摸一遍,比你看十篇理论文章都管用。后面如果你对这个方向有兴趣,可以继续探索寄存器分配的具体算法、Pass 管理器的设计思路,又或者自己动手用 LLVM 的后端接一门 DSL 试试。这条路的入口,就是这张流程图。