ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入解析llvm-project:从IR到Pass的编译器基础设施实战指南

深入解析llvm-project:从IR到Pass的编译器基础设施实战指南 从第一次在 GitHub 上看到llvm-project这个仓库到真正把它拉下来跑通一次完整构建我花了不少时间踩坑。这个项目说白了就是一套编译器基础设施但它的影响力早就超出了“编译器”三个字。现在主流语言里C/C、Rust、Swift、Julia 的后端都基于它GPU 生态里的 CUDA、ROCm 也绕不开它甚至很多人每天在用的 Android 和 iOS 工具链底层都有它的影子。如果你对编译原理、语言实现、性能优化感兴趣或者就是单纯想搞明白“一段高级语言代码到底是怎么变成机器指令的”那llvm-project是最值得啃的一个开源项目。这篇文章我会从一个实际参与过构建、改过 Pass、写过前端接入层的开发者视角把llvm-project的核心结构、工程思路、构建实操和排坑经验全部摊开讲。不会只停留在“LLVM 很牛”这种层面而是会告诉你它内部到底怎么组织、哪些概念是必须理解的、实际动手时会遇到哪些问题以及怎么从零开始参与这个项目。1. 项目全貌llvm-project 到底是一套什么体系1.1 从一次“编译太慢”的现场说起先讲个真实的场景。几年前我在做一门解释型语言的性能优化改了语法和运行时之后需要把生成的中间表示交给本地代码后端来提速。当时团队里有人提议直接生成 C 代码再丢给 gcc 编译结果遇到两个非常头疼的问题一是生成 C 代码这个过程本身有信息损耗很多高层优化机会直接没了二是每轮改动都要经历“生成 C - 调 gcc - 看汇编”调试循环长得让人崩溃。后来换成基于llvm-project来做思路就完全不一样了。我不需要把中间表示降级成 C而是直接把自研语言的 AST 翻译成 LLVM IR然后调用 LLVM 的优化管线最后由它的后端生成目标平台的机器码。整个过程里我可以随时把 IR dump 出来看也可以在任意一个优化 Pass 之后停下来检查中间结果。这种“可插拔、可观察”的编译架构正是llvm-project最核心的价值。1.2 仓库里都有什么llvm-project是一个超大 monorepo里面其实包含了好几个相对独立的子项目。最开始很多人以为它就是一个叫 LLVM 的库实际拉下来会发现目录比想象中多得多。按我的习惯通常会先关注这几个核心目录llvm/核心基础设施包括 IR 定义、优化 Pass、目标描述、代码生成、汇编器、链接器等。clang/C/C/Objective-C 前端负责把源码解析成 AST再转成 LLVM IR。lld/高性能链接器现在很多 Linux 发行版和 macOS 工具链默认用它。libcxx/、libcxxabi/、libunwind/C 标准库和运行时相关实现。compiler-rt/运行时支持库比如 Sanitizer 系列的 AddressSanitizer、UndefinedBehaviorSanitizer。mlir/面向机器学习和其他专用领域的编译器基础框架。flang/Fortran 前端polly/基于多面体模型的循环优化clang-tools-extra/额外工具比如 clang-tidy、clangd。2. 核心设计为什么它敢叫“编译器基础设施”2.1 三段式编译模型llvm-project的设计核心可以概括成一句话把编译过程拆成前端、中端、后端三个阶段三个部分之间用统一的中间表示IR来通信。传统编译器比如早期版本的 gcc经常是“前端和后端强耦合”的每一种语言都由一套独立的前端驱动整个编译流程导致前端逻辑没法复用也很难支持新的目标平台。LLVM 的做法刚好反过来前端只负责把高级语言翻译成 LLVM IR中端只负责优化 IR后端只负责把优化后的 IR 变成机器码。这样一来新增一门语言只需要写前端复用了所有的优化和后端能力新增一个 CPU 架构只需要写后端所有语言的前端都能立刻受益。2.2 LLVM IR 到底是什么LLVM IR 是一种静态单赋值形式SSA的中间表示它看起来介于高级语言和汇编之间。它的一个优秀设计是同时具备可读文本形式.ll、二进制位码形式.bc和内存中的表示三类形态而且这三者可以无损转换。这意味着我写 Pass 的时候可以直接用 C API 操作内存里的 IR也可以随时llvm-dis把 bitcode 变成人可读的文本。一个简单的加法函数IR 大概长这样define i32 add(i32 %a, i32 %b) { %sum add i32 %a, %b ret i32 %sum }不带 SSA 概念的人第一次看这堆%开头的东西会觉得有点乱但一旦理解了“每个变量只能被赋值一次”很多优化就变得非常自然。比如公共子表达式消除在传统编译器里要费劲做数据流分析在 SSA 形式下直接看值的唯一来源就行Pass 的复杂度一下子降下来了。2.3 Pass 架构和“插槽式”设计LLVM 的优化管线是由一个个 Pass 组成的而 Pass 本身可以是一个函数级别的小变换也可以是整个模块级别的分析。设计上近乎“插槽式”我想做循环展开就注册一个 LoopUnroll Pass我想做内联就调 Inliner Pass我想 debug 某个模块的 IR加一个printloops或者printdomtree就行。这种架构给开发者带来的直接好处是你可以通过opt -passes...在命令行上自由组合优化步骤而不需要重新编译整个编译器。比如我想看看 Dead Code Elimination 在某个 IR 上做了什么可以这样opt -passesdce -S input.ll -o output.ll输出的output.ll就是跑完 DCE 之后的样子换一个 Pass 组合跑一遍对比 diff基本就能定位很多性能或者正确性问题。这也是我在实际开发里用得最多的工作模式之一。3. 构建实操从源码拉取到跑起第一版 Clang3.1 拉代码和准备工作llvm-project用 git 管理仓库非常大直接git clone可能会等很久。除非你需要完整历史否则建议用--depth做浅克隆或者直接下载 release 版源码包。我个人更推荐浅克隆加切换 tag 的组合。git clone --depth1 -b llvmorg-17.0.6 https://github.com/llvm/llvm-project.git构建机器建议配置高一点。我自己的经验是16G 内存起步32G 会更舒服磁盘预留 80G 以上因为编译中间文件非常多。操作系统上Linux 是最顺的macOS 也可以Windows 的话著作者喜欢用 Visual Studio 的生成器但坑明显更多新手建议先用 Linux 环境。3.2 CMake 配置的关键参数因为我需要的是 Clang、LLD 和一些常用工具所以通常这样配置cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON这里面有几个参数值得展开讲-DLLVM_ENABLE_PROJECTS是决定你构建哪些子项目的开关不用全开开太多编译时间成倍上涨。-DLLVM_TARGETS_TO_BUILD指定目标后端。默认会生成所有支持的后端X86、ARM、AArch64、RISC-V、PowerPC……但这会让编译时间大幅变长。只留X86和AArch64足够覆盖绝大多数桌面和移动场景。-DLLVM_ENABLE_ASSERTIONSON对于开发 LLVM 本身几乎是必须的。很多 IR 合法性检查只在断言开启时生效不开的话出了内存错误都不知道在哪。如果只是为了发布用工具链可以考虑 OFF 以提升运行时性能。3.3 开始编译配置完成之后直接 Ninja 拉起来ninja -C build如果机器核数多可以加-j参数比如 16 核机器用ninja -C build -j16。首次全量构建大概耗时从十几分钟到几个小时不等取决于机器性能和开了多少项目。我第一次在公司服务器上全量构建带clang;lld;clang-tools-extra大概花了不到 30 分钟笔记本上则跑了两小时。这种初构建速度其实是可以接受的因为增量构建通常非常快。构建完之后验证一下./build/bin/clang --version能看到 clang 版本号和目标平台信息就说明这套工具链已经可以用了。顺手写一个 hello world 编译试试printf int main() { return 0; }\n | ./build/bin/clang -x c - -o /tmp/hello /tmp/hello4. 看懂源码和写第一个 Pass4.1 目录导航技巧拿到llvm-project之后想要读懂它得先学会在巨大的代码库里找路。我的建议是不要从头到尾硬读而是按“看一个功能 - 定位到对应 Pass/目录 - 读相关代码”的方式去学。拿llvm/lib/Transforms/目录来说它按功能分了很多子目录比如InstCombine、Scalar、Vectorize、IPO等。想看某一个 Pass比如LoopUnrollPass就直接在Transforms/下面搜名字通常能在Scalar/或Utils/里找到对应文件。Clang 的代码结构也类似。前端主要分clang/lib/AST、clang/lib/Sema、clang/lib/CodeGen等几个大块。其中CodeGen负责把 AST 降级成 IR。如果想搞清楚int类型在某个目标上占多少字节去看clang/lib/AST/ASTContext.cpp里的getTypeInfo就是很典型的路径。4.2 中间表示的打印与阅读很多时候问题不是“写不出 Pass”而是“看不清 IR”。LLVM 内置了很多打印工具最常用的就是opt -passes... -S和llvm-dis。此外在 pass 里直接加errs() IR: *F \n;这种调试语句也是常规操作——LLVM 内部定义好了operator可以方便地打印Function、BasicBlock、Instruction等对象。看 IR 的时候我建议大家养成一个习惯先用opt -passesprintloops这种方式看循环结构再用printdomtree看支配关系。这些分析信息在做优化时特别有用。4.3 写一个简单的 FunctionPass写一个自定义 Pass 是理解 LLVM 工程哲学最快捷的方式。这里举一个最简单的例子注册一个 Function Pass打印每个函数的名字和基本块数量。#include llvm/IR/Function.h #include llvm/IR/LegacyPassManager.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct CountBasicBlocksPass : public PassInfoMixinCountBasicBlocksPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Function: F.getName() , BasicBlocks: F.size() \n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, CountBasicBlocksPass, 0.1, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-bb) { FPM.addPass(CountBasicBlocksPass()); return true; } return false; }); }}; }把这段代码编译成动态库然后通过opt -load-pass-plugin加载就能在一个自定义 pipeline 中调用clang -shared -fPIC -Ipath-to-llvm/llvm/include countbb.cpp -o countbb.so opt -load-pass-plugincountbb.so -passescount-bb -S input.ll -o /dev/null这里有个重点新版 LLVM 推荐用 New Pass Manager也就是PassInfoMixinPassPlugin这套接口。网上不少旧教程还在教legacy::FunctionPass能跑但已经逐渐边缘化了。写新代码的时候尽量直接做 New Pass Manager 适配。5. 生态延展llvm-project 已经不只是在编译 C5.1 语言前端的百花齐放最直接的使用方式是给新语言做前端。Rust 官方编译器rustc的后端最初就使用的 LLVMSwift 编译器基于 LLVM 深度改造Julia 的 JIT 也构建在 LLVM 之上。这意味着如果你懂 LLVM IR 的生成规范你就可以给一门新语言快速获得全球大部分 CPU 平台的支持包括 x86、ARM、RISC-V 等。个人感受是LLVM 把“做一门语言”的门槛从“重新发明整个工具链”降到了“只写前端 对接 IR”。这中间还有学校、研究机构在做的各种方言和实验语言几乎都是基于 LLVM 在迭代。5.2 MLIR 与 GPU 编译器近年来mlir成了非常火的方向。它从 LLVM IR 的土壤里长出一层更接近“领域专用抽象”的中间表示很多深度学习编译器比如 TensorFlow 相关的部分工具链、Intel 的 oneAPI都开始基于 MLIR 构建。MLIR 允许定义自己的 dialect比如先在高阶的 tensor 级别做图优化再逐步 lower 到循环、再到 LLVM IR。这种层次化降级的思想在传统编译器里很难实现得这么优雅。GPU 方面英伟达的 CUDA 工具链底层大量使用了 LLVM 技术栈AMD 的 ROCm 编译器也基于 LLVM。写 GPU kernel 的人可能感觉不到但整个编译管线背后都在依赖 LLVM 的架构。5.3 静态分析与质量保障工具clang-tidy和clangd这类工具都基于 Clang 的 AST 和前端基础设施。用clang-tidy做代码风格检查和潜在 bug 扫描是很多团队的日常操作。compiler-rt里的 AddressSanitizer、ThreadSanitizer 更是 C/C 项目定位内存问题的一把好手。只要链接了对应 sanitizer runtime不用改太多代码就能揪出越界访问、释放后再使用等难缠问题。我在实际项目中几乎把-fsanitizeaddress,undefined当成 CI 里的默认配置。它确实会增加运行时间但对内存安全问题的定位效率提升是惊人的。这也是llvm-project这类基础设施项目“润物细无声”的地方——你或许没有直接用 LLVM 写代码但你的日常工具链已经深陷其中。6. 常见问题与排坑实录6.1 构建阶段最容易踩的坑构建llvm-project时的典型问题集中在 CMake 配置和资源不足上。比如你把-DLLVM_ENABLE_PROJECTS写成了空格分隔而不是分号分隔或者忘了安装zlib、libxml2等依赖导致编译到某个模块时报找不到头文件。这里给一个检查建议配置完后立刻看CMakeCache.txt里对应的LLVM_ENABLE_PROJECTS变量确保是类似clang;lld;clang-tools-extra这样的字符串。另一个常见坑是内存不足导致编译被杀。LLVM 后端在生成指令选择器相关表格时非常吃内存链接clang主程序时也需要大内存。如果你的机器内存小于 8G建议减少并行度或者用lld链接器替代系统的ld因为它更快也更省内存。6.2 调试 LLVM 自身的一些经验调试 LLVM 时llvm::errs()是零成本上手的方式。但如果你需要更系统化的调试可以考虑用-print-after-all看每一个 Pass 后的 IR 变化用-debug-onlyloop-unroll看特定 Pass 的调试信息前提是LLVM_ENABLE_ASSERTIONS开启且编译了 debug 信息。为了定位某个优化在哪个 Pass 引入问题我曾经大量使用opt逐步二分 pipeline 集合配合-S输出 diff效率很高。需要注意的是Release 无断言模式下很多内部校验会被编译器优化掉出问题的时候很难定位。所以我一直坚持在开发阶段开LLVM_ENABLE_ASSERTIONSON发布时再关掉。6.3 我的三条经验教训第一不要一开始就去看 TableGen 文件。很多人打开 LLVM 源码后看到.td文件一头雾水然后放弃。其实 TableGen 是 LLVM 用来描述指令集、寄存器等等领域特定语言的工具理解它需要先懂得目标后端那部分概念。入门阶段完全可以跳过只看 Pass 和 IR 处理逻辑。第二遇到奇怪问题先升级到 release tag 试一次。LLVM main 分支演进极快某个最新 commit 可能引入回归。我在 18 版本期间踩到过一次构建失败切回llvmorg-18.1.8就完全正常。开发新功能时用 release tag研究新特性时再换 main是比较稳妥的策略。第三参与社区之前先跑一遍完整的ninja check-llvm和ninja check-clang。本地测试全绿再提交 PR既是对维护者负责也是对自己的时间负责。LLVM 的 code review 非常严格新代码如果没有测试覆盖基本上是被打回的。7. 进一步方向如何系统化学习与实践想深入学习llvm-project有几个路径可以参考。第一做一个小工具链项目选一门简单的小语言写一个前端把 AST 降级成 LLVM IR再用lli解释执行或llc编译成可执行文件。这个过程会逼迫你把 IR、Pass、后端接口全部跑一遍。第二研究已有的 Pass比如尝试给LoopUnrollPass加一个启发式参数观察对实际程序的性能影响。第三为某个开源库写一个 Clang 插件或 clang-tidy 检查规则这是比较贴近实际业务需求的入门方式。我个人踩过的坑是一开始太贪心想一次把所有模块都看懂结果几个月过去进展缓慢。后来我换成“用哪里学哪里带着问题看源码”的策略每周只聚焦一个子模块反而很快把 LLVM 的代码架构摸清了。举个例子为了搞懂opt是怎么加载 Pass 插件的我先后读了llvm/tools/opt/opt.cpp和PassPlugin.cpp顺带就理解了 New Pass Manager 的注册机制。这样每一次阅读都有明确的产出不会迷失在代码海里。如果想把 LLVM 知识用在工程上建议从一门你已经很熟悉的语言开始手动写一个翻译器或解释器然后尝试对接 LLVM C API 或 C API。很多嵌入场景里的 JIT 编译器也是用 MCJIT 或 ORC 实现的把 ORC 的示例代码跑通一次你就能理解它和静态编译的差别在哪里。最后分享一个真实感受我在做 LLVM 相关开发那段时间最大的收获其实不是“会用某个工具”而是理解了现代编译器的设计哲学——模块化、可复用、可观察。这些思想放在任何大型软件系统里都是适用且值得借鉴的。llvm-project不只是编译技术的集大成者更是一本可以反复翻阅的工程教科书。
返回列表