LLVM项目实战指南:源码结构、IR分层与构建调试全解析
2026/9/19 5:49:13 网站建设 项目流程

第一次git clone llvm-project的时候,我盯着终端里跳动的进度条,心里其实是有点发怵的。这个仓库不是一般意义上的"一个项目",它是一整套编译器基础设施的集合,体积有好几个 GB,代码量以百万行计,而且里面的每个子项目单独拎出来都够一个人研究好几年。但我后来发现,真正拦住大多数人的并不是代码量,而是打开仓库之后那种"不知道从哪看起"的迷失感。这篇文章我想做的,就是把我自己从"对着 llvm-project 发懵"到"能改代码、能跑测试、能排查问题"这个过程里积累下来的地图、方法和坑,一次性讲清楚。无论你是想基于 LLVM 做课程设计,还是要在公司里用 Clang 工具链做二次开发,或者单纯好奇这个项目到底是怎么组织起来的,这篇内容应该都能帮你省下大量试错的时间。

1. 拿到 llvm-project 之后,我建议你先建一张"地图"

很多人的第一个误区,是把 llvm-project 当成"一个编译器"。它不是。llvm-project 是一个 monorepo,也就是把一堆彼此独立但又深度关联的子项目放在同一个仓库里统一管理。你在根目录下执行ls,看到的第一梯队目录大致是:llvmclanglldlldbmlirflanglibclibc++compiler-rtpolly,还有一个cmake目录用来存放统一的构建辅助模块。我第一次看到这堆目录的时候完全不知道它们之间什么关系,直到我把一次完整的编译过程在脑子里过了一遍。

1.1 各子项目在实际工具链中扮演的角色

如果你要写一个 C/C++ 程序,日常执行的是gcc或者clang这样的命令。这个命令背后实际上是三段式结构:前端(frontend)、中端(middleend)、后端(backend)。

拿 llvm-project 来对照:clang目录就是 C/C++/Objective-C 的前端,它负责把源代码解析成抽象语法树,然后做语义分析,最后生成一种叫 LLVM IR 的中间表示;llvm目录是整个仓库的核心库,它拥有对 LLVM IR 做优化的中端(各种 Analysis 和 Transform Pass),以及生成目标机器码的后端(X86、AArch64、RISCV、ARM 等目标的后端实现都在llvm/lib/Target下面)。lld是链接器,负责把编译生成的.o目标文件合并成最终的可执行文件;libc++libc++abi是 C++ 标准库实现;compiler-rt提供运行时支持,比如-fsanitize=address时的 ASan 运行时库就来自这里。lldb是调试器,mlir是给 AI 编译器用的多级中间表示框架,flang是 Fortran 前端,polly则是基于多面体模型的循环优化器。

所以你发现没有,llvm-project 其实覆盖了从源码到可执行文件的完整链路:clang负责把高级语言变成 IR,llvm负责优化和生成汇编,lld负责链接,compiler-rt负责运行时插桩支持,lldb负责事后调试。这条线理清楚之后,整个仓库在你眼里就不再是一堆随机目录,而是一条有清晰流向的流水线。

1.2 一次编译过程会按什么顺序触发这些组件

具体走一遍:你执行clang -O2 -g hello.c -o hello。Clang 前端读取hello.c,逐步做预处理、词法分析、语法分析、语义分析,最终生成 LLVM IR。随后 LLVM 中端的优化 Pass 开始对 IR 做一轮又一轮的变换,比如内联、循环展开、常量化传播,这些 Pass 都实现在llvm/lib/Passesllvm/lib/Transforms里。等 IR 被优化到满意程度,后端接手,在llvm/lib/Target/X86这类目录里完成指令选择、寄存器分配、指令调度,最终输出汇编。clang接着调用系统的汇编器把汇编变成目标文件,最后调用lld完成链接,动态链接时还可能拉进libc++compiler-rt里的一些运行时对象。

这张"地图"的价值在于:以后你改代码的时候能立刻定位自己到底在动流水线的哪一环。比如你在clang里加了一个 warning,你影响的是前端;你想优化某个循环的生成代码质量,你应该去看llvm/lib/Transforms;你想让链接更快,那要看lld。如果连地图都没有,很多人会一头扎进llvm/lib/Target,结果发现自己根本不知道要改的是后端还是中端。

2. 源码结构、IR 分层和 TableGen:理解 LLVM 的三把钥匙

地图有了,接着要理解 LLVM 设计的几个底层逻辑。我说"三把钥匙",是因为我见过太多人在这三个概念上卡住,一旦想明白,后面读代码的阻力会小很多。

2.1 LLVM IR 的三层表示与"为什么中间表示决定生态"

LLVM IR 有三种表示形式,理解它们的区别极其关键。第一种是内存中的表示(In-Memory IR),以 C++ 对象的形式存在于编译器进程里,核心类包括ModuleFunctionBasicBlockInstruction,这是 Pass 操作的主要对象;第二种是文本表示(LLVM Assembly,也就是.ll文件),给人读和手写测试用的;第三种是二进制表示(Bitcode,也就是.bc文件),给机器高效读写用的。三者之间可以互相转换:llvm-as.ll变成.bcllvm-dis.bc变回.ll

为什么我说"中间表示决定生态"?因为 LLVM 最成功的决策,是把前端和后端用 IR 切开。任何一门语言,只要前端能产出合法的 LLVM IR,就能自动享受到 LLVM 定义的所有优化和后端支持。Rust 早期用的是自研前端,但后端的代码生成直接借用了 LLVM;Julia、Swift 也走了类似路线。这个"切开"的设计让 LLVM 从"一个编译器"变成了"编译器的工具集",这是它生态繁荣的根本原因。你在读代码时,一旦看到ModuleFunctionValueInstruction这些类,就得意识到你处于 IR 层,而不是语法树层,也不是机器指令层。

2.2 TableGen 是干嘛的,以及 .td 文件怎么读

第二个钥匙是 TableGen。很多新手第一次在源码里看到.td后缀的文件,都会以为那是某种配置文档。实际上 TableGen 是 LLVM 自己搞的代码生成器,输入是.td描述文件,输出是 C++ 代码。它的核心思想是:把"用自然方式描述的信息"转换成"重复性极高的 C++ 代码",从而避免手写大量样板代码。

最典型的就是后端指令定义。以llvm/lib/Target/RISCV/RISCVInstrInfo.td为例,里面每一行def ADD : RVInstR<..., "add", ...>的逻辑是这样:def是定义一个记录,ADD是该记录的名字,RVInstR是父类,后面跟着编码格式、汇编输出字符串、操作数约束等信息。TableGen 会把这些信息展开成指令枚举定义、汇编解析器表格、反汇编器表格、指令选择匹配表等一大堆 C++,你根本不需要手写这些内容。读.td文件的时候,不要试图把每个细节都啃下来,先抓住一个模式:def 名字、继承自哪个模板类、每个参数在真实指令里对应什么字段。如果你要加一条新指令,通常只需要模仿同目录下相近指令的写法,加一行 def,然后让 TableGen 重新生成代码。

顺带说一句,理解 TableGen 还能帮你解决一个常见困惑:为什么 LLVM 后端代码的生成逻辑总是"改一个 .td 文件,好多个 .cpp 文件的行为都变了"。因为那些 .cpp 引用的头文件里有不少是 TableGen 生成出来的表格,它们和 .td 文件里的描述是同步的。

2.3 源码目录的通用布局:include、lib、tools、utils 四层结构

还有一个"钥匙"是目录布局。不管看llvmclang还是mlir,子目录结构基本都是稳定的:include放公共头文件,lib放核心库实现,tools放独立的可执行工具(如llvm目录的tools里有optllcllvm-as等),utils放辅助脚本和工具(如clangutils里有clang-format相关脚本)。在llvm/lib下面,按功能再分AnalysisTransformsCodeGenIRTarget等子目录;在clang/lib下面,按编译阶段分Lex(词法)、Parse(语法)、Sema(语义分析)、CodeGen(生成 IR)等。掌握这个规律之后,你找东西不用搜索全仓库,直接靠路径就能猜个七八分。

3. 从零构建 LLVM:CMake 配置、Ninja 与几个能保命的开关

地图和方法论讲完,现在聊实操。自己从源码构建 LLVM 是绕不开的一步,就算你装的是发行版里的预编译包,只要你想改源码、跑测试,就必须掌握构建流程。我用的是 CMake + Ninja 的组合,这也是目前社区最主流的方案。Makefile 也能用,但 Ninja 的增量构建速度和并行度控制真的不是同一个量级,强烈建议直接用 Ninja。

3.1 一个经过验证的构建配置

下面这个是我现在常用的构建命令,先在 llvm-project 根目录旁边建一个build目录,然后在 build 目录里执行:

cmake -G Ninja ../llvm-project/llvm \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;RISCV" \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DLLVM_USE_LINKER=lld \ -DCMAKE_C_COMPILER=clang \ -DCMAKE_CXX_COMPILER=clang++

逐项解释一下我为什么这么配。LLVM_ENABLE_PROJECTS控制在同一个构建树里编哪些子项目,如果你后面要跑 clang 的测试或改 clang 的代码,就必须把clang加进来。不要一口气把lldbmlirflang全加上,编译时间和磁盘占用会爆炸,按需添加,等用到的时候再回来补。LLVM_TARGETS_TO_BUILD指定要生成哪些后端的代码,默认是编所有后端,如果你只做 X86 的实验,构建时间会多出好几倍。LLVM_ENABLE_ASSERTIONS=ON大概是这几个开关里最重要的一个,它让代码里的assert宏生效,很多内存管理和数据结构不变式问题只有在断言开启时才能暴露出来。官方发布的预编译包为了性能默认关掉断言,但你开发调试验证时一定要打开。LLVM_USE_LINKER=lld是用 lld 来做链接,因为 LLVM 项目在 RelWithDebInfo 模式下链接时的内存占用非常大,系统自带的 ld 很容易耗尽内存或者慢得离谱,用 lld 能明显加速链接。CMAKE_C_COMPILERCMAKE_CXX_COMPILER我指定成 clang/clang++,一是为了生成更快的代码,二是因为 LLVM 项目本身对 Clang 的支持最完善。如果你机器上没装 clang,可以先用 gcc 编第一遍,跑出来的 clang 再用来编第二遍,这叫 stage2 构建,也是很常见的。

配置完成之后,执行ninja开始编译。第一次构建,建议只跑ninja clang或者ninja llc,只编你要用的工具链,别直接ninja全编。全编会把 clang、lld、lldb、各种 utils 全部编出来,耗时长不说,经常你只需要其中一个小工具,但等它把所有东西编完才意识到可以省掉。编完以后所有产物都在build/bin下面,clangoptllcllvm-asllvm-dis都在那里。

3.2 构建失败的高频原因和内存问题的处理

构建 LLVM 常见的失败原因我总结几个:内存不足、磁盘不足、编译器版本过旧、Python 版本不对。

内存不足最典型的表现是链接阶段报 "collect2: error: ld returned 1 exit status" 或者直接被 OOM Killer 杀掉。解决思路有三个:一是用 lld 替换系统 ld(前面已经提到);二是减少并行度,ninja -j2甚至-j1,代价是时间变长;三是用CMAKE_BUILD_TYPE=Release代替 RelWithDebInfo,少带调试信息能明显降低内存。我见过不少人在 8GB 内存的机器上编 LLVM,一直 OOM,最后发现是并行度拉满加系统 ld,换 lld 加-j4之后顺利编完。

磁盘不足也很常见,一次 RelWithDebInfo 带 clang 和 lld 的构建,轻松吃 30GB 以上。解决办法是构建前检查磁盘空间,另外可以开-DLLVM_APPEND_VC_REV=OFF这类开关减少一些额外信息生成,但省的空间有限。真缺磁盘的话,直接选择只编llvm核心不编clang,或者用-DLLVM_TARGETS_TO_BUILD=X86只留一个后端,立省 10GB+。

编译器版本过旧的问题,通常报错说你用的 gcc 版本不支持某个 C++ 特性。LLVM 对编译器版本要求很激进,官方在文档里写明了最低版本要求,比如 GCC 最低一般要求 7.x 以上,Clang 最低要求 14 以上。升级编译器是唯一出路,不要在旧编译器上死磕。Python 版本不对常见于 lit 测试框架的报错,一般装上 python3 就能解决。

3.3 用 ccache 和裁剪目标大幅缩短迭代时间

如果你打算长期在这个项目里改代码,强烈建议装 ccache —— 一个编译器缓存工具。它的原理是缓存每次编译的预处理结果和对象文件,只要源码和编译参数没变,第二次直接命中缓存,不真实编译。配置方式是-DLLVM_CCACHE_BUILD=ON,LLVM 的 CMake 脚本可以直接识别这个选项。我实测在改动频繁的开发周期里,ccache 能省掉 70% 以上的重编时间,尤其是只改了一个头文件导致几千个文件需要重编的场景,区别是非常巨大的。

另外一个缩短迭代周期的方式是:尽量只改你正在研究的子项目,然后用 CMake 的LLVM_ENABLE_PROJECTS精确控制。比如你要写一个新的中端 Pass,其实只需要一个opt工具,根本不需要编 clang。那么配置时LLVM_ENABLE_PROJECTS留空,直接ninja opt就行。这个工具依赖的库少,构建时间短,改完 Pass 立刻就能测。

4. 在 llvm-project 里做第一次改动:选点、改码、测试、提交

构建通过只是开始,真正让你"学会"这个项目的,是亲手改一处代码,把测试跑起来,然后看结果。我建议第一次动手不要选太复杂的功能,从"给现有 Pass 增加一条统计输出"或者"修改 enable 条件"这种小改动入手比较合适。

4.1 一个适合入门的改动示例

举个非常具体的例子:llvm/lib/Transforms/Utils/LoopUnroll.cpp是循环展开的实现,里面有个UnrollCount参数控制展开次数。你可以尝试在函数里加一个errs() << "unrolling loop with count: " << UnrollCount << "\n";,然后用opt -passes='loop-unroll'跑一段.ll 测试文件。这看起来很小,但涉及完整链路:改 C++、编opt、写测试 IR、跑 Pass、看输出。这个"改一行就验证"的正反馈循环,是建立信心的最佳方式。

如果你想要更"正式"一点的改动练手,可以给某个 Pass 加一个命令行选项,或者在llvm/include/llvm/IR/Intrinsics.td里新增一个 Intrinsic 的定义。新增 Intrinsic 时会自动生成对应的Intrinsic::ID,你只要在 Pass 里调用它。由于 Intrinsics.td 也是 TableGen 驱动的,改完之后需要重新编llvm库让它重新生成代码,这个过程会顺便让你体会到 TableGen 的威力。

4.2 lit 与 FileCheck 测试的编写逻辑

LLVM 的测试体系主要是lit驱动、FileCheck校验,这套东西其实不复杂,但头一次接触会觉得很玄。

先看一个最简单的测试文件示例:

; RUN: opt -passes='loop-unroll' -S < %s | FileCheck %s ; CHECK-LABEL: define void @test ; CHECK: br i1 %cond

RUN:声明要执行的 shell 命令,%s会被替换成当前测试文件的路径,-S表示输出 IR 文本。然后命令的输出会管道给FileCheckCHECK开头的行是校验模式:FileCheck 会逐行在输出里查找匹配字符串,CHECK-LABEL专门用来定位函数边界,后面的CHECK则在第一个匹配之后继续向后找。注意有个经典坑:FileCheck 默认的校验是"从上到下依次查找",而不是"整个文件里存在即可",所以你要保证 CHECK 行的顺序和输出顺序一致,否则即使字符串存在也会报错。跑测试的方法是ninja check-llvm,或者单独跑llvm-lit -v <测试文件路径>

写测试的FileCheck匹配时有一个我们开发时经常用的技巧:先不用精确字符串,而是用{{.*}}这种正则去匹配数字和标识符,因为很多输出内容会带寄存器编号、基本块标签等不稳定信息。等确认功能正确后,再逐步收紧匹配内容。还有一个经验是尽量在你改动的 Pass 对应的测试目录下加测试,比如llvm/test/Transforms/LoopUnroll/,这样ninja check-llvm-transforms-loopunroll就只跑这个目录下的测试,定位问题非常快。

4.3 本地验证与社区合入路径

如果一切都是本地实验,不存在提交问题;如果目标是向社区提交 patch,那就需要了解 LLVM 的代码规范和流程。最简单的第一步是把改动用git clang-format HEAD~1格式化,LLVM 的 clang-format 配置在根目录.clang-format里,不遵循格式要求是很大概率被 reviewer 打回来的。然后你需要跑和改动相关的测试集,比如改的是 LoopUnroll,就至少跑ninja check-llvm-transforms-loopunroll,如果改动到了公共 IR 数据结构,那check-llvm全量测试也该跑一遍。社区提交走 GitHub Pull Request 或 Phabricator,具体看项目当前维护状态,但不管走哪个渠道,包含完整测试用例、通过相关测试、格式正确,这三样是硬性门槛。

这里讲讲我踩过的一个真实教训:某次我改了一个 Pass 的命令行参数类型,本地只跑了该 Pass 的目录测试,全都过了。结果提交后 CI 挂了,因为别的 Pass 的测试里用到了这个参数的旧形式,是字符串匹配失败的格式问题。从那以后,但凡改动公共接口,我一定会多跑几级测试,至少check-llvm,有时候甚至需要check-clang。测试覆盖面这个东西,永远比你想象的更需要扩大。

5. 调试 LLVM 的常用武器和几个我踩过的坑

当你开始写稍复杂的 Pass 或者后端代码,调试就成了日常。LLVM 里的调试方式和普通程序略有不同,用对工具能节省大量时间。

5.1 opt 管道单步调试、dwarfdump 等工具的使用

最简单的调试方式,是"打印 IR"。LLVM 的 Pass 里,如果你想知道当前 IR 长什么样,直接用llvm::errs() << *F;把函数打印出来。这比打断点都快,因为 IR 打印出来可读性很好,你能直接看到每条指令和操作数。不过不要把这个打印留在最终提交里,你需要加LLVM_DEBUG宏或者STATISTIC机制来替代。真正的生产代码里做调试输出,标准做法是加DEBUG_TYPE,然后在代码里用LLVM_DEBUG(dbgs() << ...),编译或运行工具时传-debug-only=你的调试类型开启。这个机制好用在哪?它能让你针对性地开某个 Pass 的日志,而不是全量刷屏。

opt是整个中端优化的调试中枢。你可以用opt -passes='loop-unroll,instcombine,gvn' -S < input.ll > output.ll这种形式,把一个 IR 文件逐级跑过若干 Pass,观察变化。这相当于把整个编译流程的子步骤拆开,一块一块地看。而且opt支持-print-after-all,会在每个 Pass 执行完打印 IR,这种打印日志到文件再逐段对照的方式,是我定位 Pass 有没有生效的首选手段。

后端的调试工具另外提一下llcllc -march=riscv32 test.ll -o test.s会把 IR 直接生成目标汇编,不看 IR 的优化过程,只看最终汇编。调试指令选择问题时,llc -debug-only=isel能打印指令选择器的每步决策,信息量很大,但很有用。如果你要调试 DWARF 调试信息相关问题,llvm-dwarfdump可以检查.o文件里的调试节内容。总而言之,opt调中端、llc调后端、llvm-dwarfdump调调试信息,这组工具链基本能覆盖大多数场景。

5.2 两个具体踩坑案例

坑一,是关于Assertions版本差异的。这个问题经常出现在"本地测试没问题,一跑发行版就崩"的场景。有一回我发现某个 Pass 在一个 assert 开启的构建下一切正常,换成-DLLVM_ENABLE_ASSERTIONS=OFF编译出来的opt跑同样输入就段错误。原因其实不复杂:代码里某个Value *被当作Instruction *使用,正常代码逻辑依赖断言来拦截错误类型转换,关掉断言后未定义行为就爆发了。这让我养成了一个习惯:遇到诡异崩溃,先用带断言和 ASan 的构建去复现,通常能直接指到问题行。构建时打开-DLLVM_USE_SANITIZER=Address可以启用 AddressSanitizer,代价是性能下降,但定位内存错误非常有效。

坑二,是 FileCheck 的正则匹配顺序问题。有一次我写测试时,把两个CHECK行里的匹配字符串顺序写反了:先匹配输出靠后的内容,再匹配输出靠前的内容。FileCheck 立刻报错,哪怕这两行字符串在输出里都存在。原因是 FileCheck 要求每个 CHECK 的匹配位置必须在上一个 CHECK 之后。这个例子很典型,因为它说明的不是"正则写错",而是"对校验顺序的理解不够"。以后遇到 FileCheck 报错,先从上到下理一遍 CHECK 行的顺序是否符合输出顺序,然后检查有没有CHECK-NOT这种反向调度把匹配指针搞乱。

5.3 简化大型 case 的 delta 方法

最后一个调试经验:面对一个无法在最小例子上复现的 bug,不要直接拿着几百 MB 的 IR 文件硬啃。正确做法是用llvm-reduce工具做用例化简。llvm-reduce是 LLVM 自带的测试用例最小化工具,它接收一个 IR 文件和一个"能复现 bug 的命令",反复删减 IR 中的函数、指令、基本块,直到得到一个尽量小的复现用例。用法大致是:

llvm-reduce --test=repro.sh bug.ll

其中repro.sh是一段能返回 0(复现成功)的脚本,比如跑opt -passes=xxx bug.ll -o /dev/null。这一步在实际排查里太有用了,我处理过好几个看起来极其复杂的崩溃,最后用llvm-reduce得到只有十几个基本块的用例,一眼就能看出是某个 Pass 对undef值的不安全假设。

深入到项目这个程度之后,你会发现 llvm-project 其实并不神秘。它的规模大,但内在的组织规律非常清晰:monorepo 结构给每个子项目划定了边界,IR 作为统一中间语言把前后端串联起来,TableGen 把描述性信息变成高效代码,optllc是日常调试的显微镜,lit 和 FileCheck 是安全的防护网。我自己带新人时的体会是,真正让人退缩的往往不是某个具体技术难点,而是"几十万文件我该打开哪个"的心理压力。所以我一直把这份"打开顺序"当成最重要的入门建议:先跑通构建,找到一次编译触发了哪些组件;再顺着 IR 从.ll文件出发,用opt逐个 Pass 观察 IR 变化;然后去看对应 Pass 的测试文件,反推它要保证的行为;最后再回到代码里,带着具体问题去寻找答案。

llvm-project 的一个迷人之处在于,你永远能找到比自己更聪明的人写下的代码,也永远有还没被解决的问题等着新面孔去尝试。别怕在 README 里迷路,也别怕第一次看到 Pass 的类继承树时觉得头晕,把这些调试手段和测试习惯内化成肌肉记忆之后,剩下的事情就只是耐心。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询