多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

LLVM项目实战指南:源码结构、IR分层与构建调试全解析

LLVM项目实战指南:源码结构、IR分层与构建调试全解析 第一次git clone llvm-project的时候我盯着终端里跳动的进度条心里其实是有点发怵的。这个仓库不是一般意义上的一个项目它是一整套编译器基础设施的集合体积有好几个 GB代码量以百万行计而且里面的每个子项目单独拎出来都够一个人研究好几年。但我后来发现真正拦住大多数人的并不是代码量而是打开仓库之后那种不知道从哪看起的迷失感。这篇文章我想做的就是把我自己从对着 llvm-project 发懵到能改代码、能跑测试、能排查问题这个过程里积累下来的地图、方法和坑一次性讲清楚。无论你是想基于 LLVM 做课程设计还是要在公司里用 Clang 工具链做二次开发或者单纯好奇这个项目到底是怎么组织起来的这篇内容应该都能帮你省下大量试错的时间。1. 拿到 llvm-project 之后我建议你先建一张地图很多人的第一个误区是把 llvm-project 当成一个编译器。它不是。llvm-project 是一个 monorepo也就是把一堆彼此独立但又深度关联的子项目放在同一个仓库里统一管理。你在根目录下执行ls看到的第一梯队目录大致是llvm、clang、lld、lldb、mlir、flang、libc、libc、compiler-rt、polly还有一个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和libcabi是 C 标准库实现compiler-rt提供运行时支持比如-fsanitizeaddress时的 ASan 运行时库就来自这里。lldb是调试器mlir是给 AI 编译器用的多级中间表示框架flang是 Fortran 前端polly则是基于多面体模型的循环优化器。所以你发现没有llvm-project 其实覆盖了从源码到可执行文件的完整链路clang负责把高级语言变成 IRllvm负责优化和生成汇编lld负责链接compiler-rt负责运行时插桩支持lldb负责事后调试。这条线理清楚之后整个仓库在你眼里就不再是一堆随机目录而是一条有清晰流向的流水线。1.2 一次编译过程会按什么顺序触发这些组件具体走一遍你执行clang -O2 -g hello.c -o hello。Clang 前端读取hello.c逐步做预处理、词法分析、语法分析、语义分析最终生成 LLVM IR。随后 LLVM 中端的优化 Pass 开始对 IR 做一轮又一轮的变换比如内联、循环展开、常量化传播这些 Pass 都实现在llvm/lib/Passes和llvm/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 对象的形式存在于编译器进程里核心类包括Module、Function、BasicBlock、Instruction这是 Pass 操作的主要对象第二种是文本表示LLVM Assembly也就是.ll文件给人读和手写测试用的第三种是二进制表示Bitcode也就是.bc文件给机器高效读写用的。三者之间可以互相转换llvm-as把.ll变成.bcllvm-dis把.bc变回.ll。为什么我说中间表示决定生态因为 LLVM 最成功的决策是把前端和后端用 IR 切开。任何一门语言只要前端能产出合法的 LLVM IR就能自动享受到 LLVM 定义的所有优化和后端支持。Rust 早期用的是自研前端但后端的代码生成直接借用了 LLVMJulia、Swift 也走了类似路线。这个切开的设计让 LLVM 从一个编译器变成了编译器的工具集这是它生态繁荣的根本原因。你在读代码时一旦看到Module、Function、Value、Instruction这些类就得意识到你处于 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 四层结构还有一个钥匙是目录布局。不管看llvm、clang还是mlir子目录结构基本都是稳定的include放公共头文件lib放核心库实现tools放独立的可执行工具如llvm目录的tools里有opt、llc、llvm-as等utils放辅助脚本和工具如clang的utils里有clang-format相关脚本。在llvm/lib下面按功能再分Analysis、Transforms、CodeGen、IR、Target等子目录在clang/lib下面按编译阶段分Lex词法、Parse语法、Sema语义分析、CodeGen生成 IR等。掌握这个规律之后你找东西不用搜索全仓库直接靠路径就能猜个七八分。3. 从零构建 LLVMCMake 配置、Ninja 与几个能保命的开关地图和方法论讲完现在聊实操。自己从源码构建 LLVM 是绕不开的一步就算你装的是发行版里的预编译包只要你想改源码、跑测试就必须掌握构建流程。我用的是 CMake Ninja 的组合这也是目前社区最主流的方案。Makefile 也能用但 Ninja 的增量构建速度和并行度控制真的不是同一个量级强烈建议直接用 Ninja。3.1 一个经过验证的构建配置下面这个是我现在常用的构建命令先在 llvm-project 根目录旁边建一个build目录然后在 build 目录里执行cmake -G Ninja ../llvm-project/llvm \ -DCMAKE_BUILD_TYPERelWithDebInfo \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;RISCV \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_USE_LINKERlld \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang逐项解释一下我为什么这么配。LLVM_ENABLE_PROJECTS控制在同一个构建树里编哪些子项目如果你后面要跑 clang 的测试或改 clang 的代码就必须把clang加进来。不要一口气把lldb、mlir、flang全加上编译时间和磁盘占用会爆炸按需添加等用到的时候再回来补。LLVM_TARGETS_TO_BUILD指定要生成哪些后端的代码默认是编所有后端如果你只做 X86 的实验构建时间会多出好几倍。LLVM_ENABLE_ASSERTIONSON大概是这几个开关里最重要的一个它让代码里的assert宏生效很多内存管理和数据结构不变式问题只有在断言开启时才能暴露出来。官方发布的预编译包为了性能默认关掉断言但你开发调试验证时一定要打开。LLVM_USE_LINKERlld是用 lld 来做链接因为 LLVM 项目在 RelWithDebInfo 模式下链接时的内存占用非常大系统自带的 ld 很容易耗尽内存或者慢得离谱用 lld 能明显加速链接。CMAKE_C_COMPILER和CMAKE_CXX_COMPILER我指定成 clang/clang一是为了生成更快的代码二是因为 LLVM 项目本身对 Clang 的支持最完善。如果你机器上没装 clang可以先用 gcc 编第一遍跑出来的 clang 再用来编第二遍这叫 stage2 构建也是很常见的。配置完成之后执行ninja开始编译。第一次构建建议只跑ninja clang或者ninja llc只编你要用的工具链别直接ninja全编。全编会把 clang、lld、lldb、各种 utils 全部编出来耗时长不说经常你只需要其中一个小工具但等它把所有东西编完才意识到可以省掉。编完以后所有产物都在build/bin下面clang、opt、llc、llvm-as、llvm-dis都在那里。3.2 构建失败的高频原因和内存问题的处理构建 LLVM 常见的失败原因我总结几个内存不足、磁盘不足、编译器版本过旧、Python 版本不对。内存不足最典型的表现是链接阶段报 collect2: error: ld returned 1 exit status 或者直接被 OOM Killer 杀掉。解决思路有三个一是用 lld 替换系统 ld前面已经提到二是减少并行度ninja -j2甚至-j1代价是时间变长三是用CMAKE_BUILD_TYPERelease代替 RelWithDebInfo少带调试信息能明显降低内存。我见过不少人在 8GB 内存的机器上编 LLVM一直 OOM最后发现是并行度拉满加系统 ld换 lld 加-j4之后顺利编完。磁盘不足也很常见一次 RelWithDebInfo 带 clang 和 lld 的构建轻松吃 30GB 以上。解决办法是构建前检查磁盘空间另外可以开-DLLVM_APPEND_VC_REVOFF这类开关减少一些额外信息生成但省的空间有限。真缺磁盘的话直接选择只编llvm核心不编clang或者用-DLLVM_TARGETS_TO_BUILDX86只留一个后端立省 10GB。编译器版本过旧的问题通常报错说你用的 gcc 版本不支持某个 C 特性。LLVM 对编译器版本要求很激进官方在文档里写明了最低版本要求比如 GCC 最低一般要求 7.x 以上Clang 最低要求 14 以上。升级编译器是唯一出路不要在旧编译器上死磕。Python 版本不对常见于 lit 测试框架的报错一般装上 python3 就能解决。3.3 用 ccache 和裁剪目标大幅缩短迭代时间如果你打算长期在这个项目里改代码强烈建议装 ccache —— 一个编译器缓存工具。它的原理是缓存每次编译的预处理结果和对象文件只要源码和编译参数没变第二次直接命中缓存不真实编译。配置方式是-DLLVM_CCACHE_BUILDONLLVM 的 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 -passesloop-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 -passesloop-unroll -S %s | FileCheck %s ; CHECK-LABEL: define void test ; CHECK: br i1 %condRUN:声明要执行的 shell 命令%s会被替换成当前测试文件的路径-S表示输出 IR 文本。然后命令的输出会管道给FileCheck。CHECK开头的行是校验模式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 -passesloop-unroll,instcombine,gvn -S input.ll output.ll这种形式把一个 IR 文件逐级跑过若干 Pass观察变化。这相当于把整个编译流程的子步骤拆开一块一块地看。而且opt支持-print-after-all会在每个 Pass 执行完打印 IR这种打印日志到文件再逐段对照的方式是我定位 Pass 有没有生效的首选手段。后端的调试工具另外提一下llc。llc -marchriscv32 test.ll -o test.s会把 IR 直接生成目标汇编不看 IR 的优化过程只看最终汇编。调试指令选择问题时llc -debug-onlyisel能打印指令选择器的每步决策信息量很大但很有用。如果你要调试 DWARF 调试信息相关问题llvm-dwarfdump可以检查.o文件里的调试节内容。总而言之opt调中端、llc调后端、llvm-dwarfdump调调试信息这组工具链基本能覆盖大多数场景。5.2 两个具体踩坑案例坑一是关于Assertions版本差异的。这个问题经常出现在本地测试没问题一跑发行版就崩的场景。有一回我发现某个 Pass 在一个 assert 开启的构建下一切正常换成-DLLVM_ENABLE_ASSERTIONSOFF编译出来的opt跑同样输入就段错误。原因其实不复杂代码里某个Value *被当作Instruction *使用正常代码逻辑依赖断言来拦截错误类型转换关掉断言后未定义行为就爆发了。这让我养成了一个习惯遇到诡异崩溃先用带断言和 ASan 的构建去复现通常能直接指到问题行。构建时打开-DLLVM_USE_SANITIZERAddress可以启用 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 --testrepro.sh bug.ll其中repro.sh是一段能返回 0复现成功的脚本比如跑opt -passesxxx bug.ll -o /dev/null。这一步在实际排查里太有用了我处理过好几个看起来极其复杂的崩溃最后用llvm-reduce得到只有十几个基本块的用例一眼就能看出是某个 Pass 对undef值的不安全假设。深入到项目这个程度之后你会发现 llvm-project 其实并不神秘。它的规模大但内在的组织规律非常清晰monorepo 结构给每个子项目划定了边界IR 作为统一中间语言把前后端串联起来TableGen 把描述性信息变成高效代码opt和llc是日常调试的显微镜lit 和 FileCheck 是安全的防护网。我自己带新人时的体会是真正让人退缩的往往不是某个具体技术难点而是几十万文件我该打开哪个的心理压力。所以我一直把这份打开顺序当成最重要的入门建议先跑通构建找到一次编译触发了哪些组件再顺着 IR 从.ll文件出发用opt逐个 Pass 观察 IR 变化然后去看对应 Pass 的测试文件反推它要保证的行为最后再回到代码里带着具体问题去寻找答案。llvm-project 的一个迷人之处在于你永远能找到比自己更聪明的人写下的代码也永远有还没被解决的问题等着新面孔去尝试。别怕在 README 里迷路也别怕第一次看到 Pass 的类继承树时觉得头晕把这些调试手段和测试习惯内化成肌肉记忆之后剩下的事情就只是耐心。
返回列表