十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

LLVM项目仓库实战解析:从源码构建到自定义编译器开发

LLVM项目仓库实战解析:从源码构建到自定义编译器开发 如果你只是听说过LLVM这个名字可能很难想象第一次打开llvm-project仓库时的感受一整个屏幕的子目录加在一起超过千万行C代码光一个clang子项目就能吃下几个GB的工作区副本。我至今还记得自己刚接触编译技术时面对这个仓库的那种发怵——它不只是一个编译器而是一整套编译器基础设施是无数编程语言、工具链和芯片厂商背后的公共底座。这篇内容不打算讲LLVM的历史故事也不做重复文档的翻译我想从一个实际使用者的角度把llvm-project仓库的结构、构建方法、常见开发场景以及踩坑经验串起来。无论你是想给新语言写前端还是打算给特定硬件做后端或者只是好奇这个仓库到底怎么组织都能从中找到可直接落地的参考。1. llvm-project的仓库解剖学从libc到MLIR的完整版图1.1 为什么说它是一整套编译器基础设施而不是一个编译器很多人以为llvm-project只是编译器的代码但实际上它是一组高度耦合又相对独立的子项目集合。我第一次完整浏览仓库目录时最大的感受是这里的每个目录几乎都能单独发展成一个项目但它们共享同一套基础设施——统一的IR表示、统一的pass管理框架、统一的代码生成后端、统一的测试工具链。这种设计带来的直接好处是你写一门新语言的编译器时不需要从头实现机器码生成也不需要自己搞寄存器分配、指令调度、目标代码优化。LLVM已经把从中间表示到可执行文件这条链路全部打通了你要做的只是把源码翻译成LLVM IR或者更间接地调用Clang等现成前端的能力。1.2 子项目分工与依赖关系打开仓库根目录映入眼帘的子目录很多但它们之间是有清晰分层的。我整理了一张常用表方便你快速定位每个子项目的用途子项目目录核心功能典型使用场景llvm核心库、优化器、后端、汇编器、链接器层所有子项目的公共底座clangC/C/Objective-C前端把C/C代码编译成可执行文件clang-tools-extra基于Clang的辅助工具代码检查、重构、格式化lld链接器替代系统默认ld速度更快lldb调试器源码级调试C/C/Rust等compiler-rt运行时库覆盖率、sanitizer、内建函数支持libcC标准库实现嵌入式、无系统依赖环境libcxx/libcxxabiC标准库与ABI层配合Clang使用libunwind栈回溯与异常处理底层C异常、崩溃栈打印mlir多层级IR框架AI编译器、领域专用编译器flangFortran前端科学计算领域polly基于多面体的循环优化高性能计算优化openmpOpenMP运行时并行计算bolt二进制优化工具性能关键应用的后链接优化这些子项目并不是平级的存在明显的依赖关系。比如clang依赖llvm的核心库compiler-rt依赖llvm的builtinsmlir在IR层面借用LLVM的基础设施但又能独立构建。如果你只想做语言前端通常只需要llvm加上自己的代码就够如果你是做全栈工具链那才需要把clang、lld、compiler-rt一堆项目一起拉进来。1.3 一个常见误解LLVM IR就是唯一的IR吗早期LLVM生态里LLVM IR确实是所有前端统一的中间表示。但在mlir出现之后整个格局已经变了。MLIR的定位是多层级IR框架它允许你在接近源码的抽象层和接近机器的抽象层之间建立多个中间表示最后再lowering到LLVM IR。实际开发中这种分层非常有用。比如做AI编译器时上层的张量运算如果用LLVM IR直接表达会非常别扭但用MLIR的tensor和linalg方言就可以保持高层语义做各种算子融合和内存布局优化最后通过convert-to-llvm把IR降到后端可处理的形式。我开始接触mlir时花了不少时间理解它的方言机制但掌握之后我写自定义后端的效率比以前直接用LLVM IR硬怼高了不少。如果你只是做传统语言编译直接面向LLVM IR仍然是最直接的选择。判断标准很简单你的语言在语义上离C/C有多远不远就选LLVM IR若引入了大量独有概念和优化问题可以认真考虑MLIR。2. 源码构建与日常开发我的CMake参数与工作流2.1 为什么需要自己编译而不是直接装系统包llvm-project的现代开发几乎都围绕源码构建展开因为很多使用场景依赖的是它提供的库而不是独立的编译命令。单纯为了跑命令安装发行版自带的llvm、clang就够了但如果你想开发一个新的pass、编写自定义后端或者给Clang做插件就必须拿到带开发头文件的源码构建产物。另一个原因在于版本控制。系统包版本又老又固定llvm-project的master分支几乎每天都在变很多新API只在最近的提交里存在。做编译器开发的人通常需要锁定某个commit然后基于它搭建自己的工具链这种灵活度只有源码构建能给。2.2 我常用的构建配置与参数解读用CMake和Ninja构建llvm-project是最普遍的方案。我自己的习惯是先把仓库克隆到本地然后单独建一个build目录尽量不动源码目录git clone https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON \ -DCMAKE_INSTALL_PREFIX$HOME/opt/llvm-dev这里几个参数的含义值得展开说一下CMAKE_BUILD_TYPERelease让生成的LLVM库和工具带优化跑起来更快。但编译器开发经常需要调试所以很多人也会单独建一个Debug或RelWithDebInfo的build目录。LLVM_ENABLE_PROJECTS指定要进入同一个构建流程的其他子项目。这里列举的clang、lld、clang-tools-extra是工具链开发最常用的组合。LLVM_TARGETS_TO_BUILD指定需要生成哪些后端。默认会构建所有支持的后端耗时和磁盘占用会大很多只留自己需要的能明显加速构建。LLVM_ENABLE_ASSERTIONSON开启断言。生产工具链可以关但做开发调试时必须开它会拦截掉大量内存越界和状态不一致问题虽然会拖慢性能但能救命。配置完成后运行ninja开始构建。如果是第一次全量构建时间会比较长通常十几分钟到一小时不等取决于机器性能和所选组件。之后每次改动源码增量构建往往几十秒就能完成开发体验相当不错。2.3 增量构建、多配置切换与测试实际开发中我通常维持两个构建目录一个build-release用于跑性能和给下游测试一个build-assert专门开断言和调试符号用于日常开发。这两套目录互不干扰切换成本就是重新运行一次cmake命令磁盘空间多花一点但省去来回改参数的时间。测试方面LLVM提供了一套基于lit的测试框架。构建完成之后可以运行ninja check-llvm ninja check-clang测试量非常大完整跑完可能要几十分钟适合提交前做回归检查。日常调试时我一般只跑某个测试目录ninja check-llvm-unit llvm-lit ../llvm/test/Transforms/InstCombinellvm-lit是更精确的测试入口它可以直接指向某个测试文件比如llvm-lit ../llvm/test/Transforms/InstCombine/add.ll只跑单文件。这个习惯能大幅缩短循环时间我强烈推荐。3. 用LLVM把一门新语言编译到机器码两条落地路线3.1 选择你的接入点完整前端 vs Clang插件想基于llvm-project做语言编译最常见的是两条路线。第一条是写一个独立前端把源码解析成AST后再生成LLVM IR然后交给后端生成机器码。这条路适合新增语言语义比较强、语法完全不同于C/C的项目工作量大但自由度极高。第二条是借助Clang已有的解析和语义分析能力只做AST层面的变换或代码生成。适合的目标语言多数语法和C/C高度相似或是作为C/C的超集扩展。这样你不用处理头文件搜索、预处理器、C模板这些极其沉重的工程问题Clang已经帮你扛住了。代价是语言设计范围受限AST变换要跟着Clang的版本走。我的个人判断是如果只是想快速验证一个领域专用语言或DSL的可行性走Clang插件路线最快如果目标是一门独立语言那第一条路线虽然难天花板更高。3.2 从AST到IR的最小骨架示例假设我们要实现一门简易语言只支持整数加法和返回语句。前端代码怎么组织最典型的结构是词法分析、语法分析、AST构建、语义检查、IR生成五层。LLVM负责的是最后一层和之后的所有事情。IR生成部分核心思路是把AST节点映射成LLVM IR指令。比如一个函数定义节点会创建一个llvm::Function一个整数加法表达式节点会使用builder.CreateAdd返回语句则调用builder.CreateRet。这里有一个最小示例展示用LLVM的C API生成一段IR#include llvm/IR/IRBuilder.h #include llvm/IR/LLVMContext.h #include llvm/IR/Module.h llvm::LLVMContext Context; llvm::Module *Mod new llvm::Module(my_module, Context); llvm::IRBuilder Builder(Context); // 生成 i32 add(i32 %a, i32 %b) llvm::FunctionType *FT llvm::FunctionType::get(Builder.getInt32Ty(), {Builder.getInt32Ty(), Builder.getInt32Ty()}, false); llvm::Function *F llvm::Function::Create(FT, llvm::Function::ExternalLinkage, add, Mod); llvm::BasicBlock *Entry llvm::BasicBlock::Create(Context, entry, F); Builder.SetInsertPoint(Entry); auto *A F-arg_begin(); auto *B A 1; llvm::Value *Sum Builder.CreateAdd(A, B, sum); Builder.CreateRet(Sum);这段代码生成的IR和你在文本文件里看到的这个等价define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }生成IR只是第一步。后续要把IR交给opt做优化再交给llc生成汇编或目标文件最后用lld链接成可执行程序。手动做这些步骤很繁琐通常会在编译器驱动里组织这些流程。3.3 为什么IR生成要时刻想着类型一致性初学者最容易踩的坑是IR类型不一致。LLVM IR是强类型系统i32和i64之间的加法指令必须先插入转换指令否则CreateAdd直接报断言失败。指针类型之间也一样如果你构造了一个函数返回i8*但实际返回了i32*IR验证器会直接拒绝。我一般在生成的每个函数后面调用F-verify()或者对整个模块调用verifyModule()尽早暴露类型问题。调试IR比调试机器码容易得多IR验证器给出的错误信息通常直接说明是类型不匹配还是基本块没有以终结指令结束省去了一大半排查时间。3.4 优化管线该怎么搭生成IR之后优化管线的选择直接决定最终代码质量。最简单的做法是把模块塞进PassBuilder按默认的O2/O3管线跑一轮#include llvm/Passes/PassBuilder.h llvm::LoopAnalysisManager LAM; llvm::FunctionAnalysisManager FAM; llvm::CGSCCAnalysisManager CGAM; llvm::ModuleAnalysisManager MAM; llvm::PassBuilder PB; PB.registerModuleAnalyses(MAM); PB.registerCGSCCAnalyses(CGAM); PB.registerFunctionAnalyses(FAM); PB.registerLoopAnalyses(LAM); PB.crossRegisterProxies(LAM, FAM, CGAM, MAM); llvm::ModulePassManager MPM PB.buildPerModuleDefaultPipeline(llvm::OptimizationLevel::O2); MPM.run(*Mod, MAM);如果你接的是MLIR流程类似但会多一步在MLIR层做领域优化最后lowering到LLVM IR。这套优化管线也体现了llvm-project的核心价值同一套IR和优化代码服务所有语言而不是每个编译器都从零写一遍。4. 真正让我花掉最多时间的坑调试LLVM工具链4.1 TableGen改完不生效生成文件与重新构建的玄学LLVM的很多后端描述文件基于TableGen编写比如指令集定义、寄存器文件、pass参数等。初学者改完一个.td文件发现重新编译后行为没变于是开始怀疑人生。原因在于TableGen在构建时会生成大量C头文件和源文件这些生成文件通常位于build目录中而不是源码目录。如果你只改了.td文件但没有触发生成规则编译用的还是旧的生成头文件。Ninja通常会正确跟踪依赖关系但如果你手动拷贝或修改了生成文件就会遇到缓存不一致的奇葩问题。我的经验是改TableGen文件后先ninja一下看看有没有对应目标的重新生成日志。或者直接删除build目录里相关的生成文件包再重建比如rm -rf build/tools/clang/include/clang/Driver这种。更保险的做法是在源码树中搜索生成的.inc文件确认修改已经反映进去grep -r YourNewInstruction build/tools/clang/include/最可靠的避免办法是不要手动编辑生成文件所有修改都落在.td源文件上。4.2 Pass Manager新旧接口差异同一个功能两套写法LLVM历史上经历过从legacy pass manager到new pass manager的大迁移。开发社区已经在逐步淘汰旧接口但很多教程和博客仍在使用旧写法。这就导致了一个尴尬情况你在网上找到一段registerPass的代码拿到当前的llvm-project里编译直接报No member named registerPass。新PM中写一个函数pass的标准做法是定义llvm::PassInfoMixinMyPass结构体并实现run方法#include llvm/IR/PassManager.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h struct MyPass : public llvm::PassInfoMixinMyPass { llvm::PreservedAnalyses run(llvm::Function F, llvm::FunctionAnalysisManager AM) { // 改造这个函数的IR return llvm::PreservedAnalyses::all(); } };如果你是给opt写动态插件还需要提供插件注册入口。llvm/Passes/PassPlugin.h中有专门的宏比如llvm::PassPluginLibraryInfo和getPassPluginInfo。这块接口在持续演进最稳妥的办法是直接看当前源码里的例子源码中的llvm/examples/Bye是一个非常经典的插件样例从老PM兼容到新PM都能在社区找到不同版本。4.3 断言、段错误与Debug构建的意义我在跑自写pass的时候遇到最多的问题就是LLVM断言失败和段错误。断言失败其实还好它会在出错位置打印源码文件和行号甚至带上IR相关的内容照着调试就行。真正难缠的是段错误它意味着你访问了非法的内存。这时候唯一可靠的工具就是用断言开启、调试符号完整的构建。LLVM_ENABLE_ASSERTIONSON加上CMAKE_BUILD_TYPERelWithDebInfo是最常见的开发组合。接着用自己的pass跑一个很小的测试IR文件在gdb或lldb下面定位崩溃点gdb --args opt -load-pass-plugin./MyPass.so -passesmy-pass test.ll在gdb里执行bt看到调用栈。如果你用的版本匹配栈顶会直接指到出错的C代码行。如果没有调试信息你只能面对一串十六进制地址那体验可以说是噩梦级的。4.4 FileCheck测试为什么我的测试用例这么难写LLVM项目里大部分测试是.ll文件配合RUN:指令和CHECK:断言组成的。FileCheck的行为逻辑是从头到尾扫描工具输出依次寻找每个CHECK模式。这个机制理解起来不难但写起来有很多隐性约束。例如CHECK-NEXT对空行极其敏感输出里多一个空行就会失败。CHECK-DAG则允许多个模式顺序不一致适合匹配不关心顺序的输出。最常用的小技巧是给测试加--check-prefixes配合CHECK-COMMON复用公共断言从而用较少的文本覆盖多种后端或多种优化层级。如果你在写pass给每个优化场景建一个小型.ll测试是成本最低的回归保护。我习惯用opt -passes... -S先把输出转成IR文本看一眼结果再把关键差异点固化成CHECK断言。这样后续改代码时任何一个行为变化都能被立刻捕捉到。5. 给想参与llvm-project贡献的开发者一条现实可走的路5.1 什么才是合适的第一个patchllvm-project的代码量非常巨大它对贡献者的技术栈要求也不低但并不是只有编译器专家才能贡献。我的建议是从小处切入修注释、补充测试、完善文档或者解决一个标记了good first issue的小bug。很多人会低估测试和文档的价值。LLVM的测试覆盖度极其惊人一个改动如果测试不全审查者几乎不会合入。反过来如果你能补齐某个函数或pass的边界测试这个贡献的价值并不亚于几个新功能。具体操作上先在issues列表里找带good-first-issue标签的任务看清标注的组件归属比如clang目录还是mlir目录。然后跑一遍相关测试确认能复现问题再动手修改。改动越小进入维护者视野的成本越低合入概率也越高。5.2 代码风格与审查标准先过clang-format和clang-tidyLLVM的代码风格是社区多年沉淀下来的和很多公司内部的C风格不太一样。比如命名上类名用PascalCase、函数用camelCase、变量用小写加下划线。缩进是两空格不是四空格。贡献前先安装clang-format用仓库根目录的.clang-format文件格式化一遍这是基本礼仪。此外还要自己跑一遍相关组件的lint与测试例如clang-tidy常见检查。在PR描述中写清楚改动内容、动机和测试结果能极大缩短review来回时间。LLVM的审查比一般开源项目严格得多维护者非常在意接口的长期兼容性和测试的完备性。可能一个很小的pass改动review意见就有十好几条。我第一次提交时被要求补充三种不同案例的测试当时觉得繁琐但事后回看正是这些要求把代码质量稳住了。5.3 用lit和FileCheck搭建自己的回归测试习惯不管你是自己写编译器还是要向llvm-project提交patch掌握lit和FileCheck都是必须的。最基础的一个测试文件长这样; RUN: opt -passesinstcombine -S %s | FileCheck %s ; CHECK-LABEL: define i32 test ; CHECK: %add add i32 %a, 1 define i32 test(i32 %a) { %b add i32 %a, 1 %c add i32 %b, 0 ret i32 %c }这个测试模拟了把加0优化掉的预期行为。RUN:定义命令行CHECK-LABEL定位函数边界CHECK匹配期望输出。写测试时我推荐每次只对应一个优化行为用例小而精确将来debug时容易定位是哪一步改变了行为。5.4 从使用者到贡献者的身份转变我的一点体会最初我从llvm-project里拿现成工具用后来开始在个人项目里调用它的API再后来尝试提交patch。这个过程最大的门槛不是代码量而是思维方式转变从怎么让我的程序跑起来变成怎么让这个基础设施对所有使用者更健壮。我自己的第一个merged patch并不是什么惊艳功能只是给Clang的一个警告信息补充了更精确的说明外加两个测试用例。但那次经历让我完整走了一遍提交、审查、修改、合并的流程之后我再处理LLVM内部的问题时心态就完全不一样了。你不再觉得这个仓库是一个不可动的巨大黑盒而是一套可参与演进的基础设施。写在最后llvm-project最迷人的地方在于它同时承载了工业级工具链的稳定性和研究级项目的实验性。你既可以用它编译每天运行的线上服务也可以在里面尝试一个还非常小众的编译优化idea。对任何想深入编译器、语言运行时和代码生成的人来说这个仓库就是最好的试验场。如果你正打算入门建议先不要急着读源码而是亲手编译一版写几个IR文件跑几遍opt和llc感受一下LLVM IR的流动过程。等动手的体感建立了再回头读那些最经典的文档和代码你会发现自己很快就能找到切入点。真正阻碍人的从来不是LLVM的复杂性而是缺乏一个明确的小目标。找一个最简单的功能改改看这个仓库很快就会从陌生变成熟悉。
返回列表