资讯详情

llvm-project完全指南:构建、调试与Pass开发实战

📅 2026/9/18 18:18:08 | 华诺云谱 👁 阅读
llvm-project完全指南:构建、调试与Pass开发实战
如果你和我一样最开始是在网上的零散文章里见识到 LLVM 的各种名词——Clang、LLVM IR、opt、llc、GlobalISel——那多半会以为 llvm-project 是个“一个叫 LLVM 的编译器仓库”。但真正把这个仓库 clone 下来打开目录列表之后大多数人会愣一下里面的东西远不止一个核心编译器。llvm-project 不是一个单一工具而是一整套围绕“编译基础设施”展开的大型项目集主要由 LLVM 核心库、Clang 前端、LLD 链接器、LLDB 调试器、compiler-rt 运行时、MLIR、libc 等子项目组成。日常我们口中说的“LLVM”往往只是仓库根目录下那个llvm/文件夹但你实际构建时CMake 又允许你把 Clang、LLD 等一起编译出来。这个仓库可以支撑你干很多事情写一个新的编程语言前端、做自定义代码优化、调 CPU 后端的指令选择、给链接器加特性甚至在编译期运行你的解释器。这篇文章我以一个在这棵大树下摸爬滚打过的开发者视角把 llvm-project 从“目录长什么样”到“怎么改、怎么排错”讲清楚。文章会涉及 monorepo 分割、CMake 构建参数、LLVM IR 调试、新版 Pass Manager 写法以及我自己实际踩过的一些坑。不管你是准备入门编译器开发还是想在现有项目里接入 LLVM 做点什么下面这些内容应该都能让你少走不少弯路。1. llvm-project 的目录全景别把目光只放在 llvm/ 这个目录上很多人的第一节课是“LLVM 是个编译器框架”第二节课就是“打开 https://github.com/llvm/llvm-project”。结果一打开里面一堆目录clang/、lld/、lldb/、compiler-rt/、mlir/、flang/……反应通常是我到底要看哪个1.1 各子项目的定位与依赖关系先说结论llvm-project 是 LLVM、Clang 以及相关工具链的 monorepo最早这些项目都是独立的 SVN 仓库后来在 LLVM 9 时代陆续整合成了统一的 Git 仓库。被分成这么多目录不是为了好看而是因为它们各自承担不同阶段的任务。我列了一个简化版的功能对照表方便新手先建立整体概念目录主要作用典型用途llvm/LLVM 核心IR 定义、优化器、目标后端、公共工具链写优化 Pass、加指令选择、学习 IRclang/C/C/Objective-C 编译器前端把源码转成 LLVM IR提供clang命令lld/高性能链接器替代系统ld也用于 LTO 场景lldb/调试器调试编译产物支持表达式求值compiler-rt/运行时库sanitizer、builtin、profileASan/UBSan构建 instrumented runtimemlir/多层级 IR 框架DSL 编译、AI 编译器、硬件后端flang/Fortran 前端Fortran 语言支持libcxx/、libcxxabi/、libunwindC 标准库及配套独立 toolchain 的标准库实现polly/基于多面体模型的循环优化高层次循环变换openmp/OpenMP 运行时并行编程支持如果你只是做“语言前端”你的大部分时间会花在clang/或者自己新建的前端项目上输出 LLVM IR 之后交给llvm/。如果你是做“优化器”核心工作都在llvm/lib/Transforms/及其头文件里。如果你做的是“后端移植”重点在llvm/lib/Target/我经常说这一层才是真正的“编译器黑魔法”指令选择、寄存器分配、指令调度都在这里。1.2 代码目录的常见组织方式include、lib、tools 与 utils真正进入llvm/之后又是一堆子目录。我建议先认识这么几类llvm/include/llvm/公共头文件安装了 LLVM 开发库之后暴露给外部用户的 API 基本都在这。写 Pass 需要关注的llvm/IR/,llvm/Passes/,llvm/Analysis/,llvm/Transforms/都在这里。llvm/lib/核心库的实现。比如llvm/lib/IR/放着 IR 类的实现llvm/lib/Transforms/放着各个优化 Passllvm/lib/Target/按 CPU 架构分目录。llvm/tools/面向用户的命令行工具比如opt、llc、llvm-as、llvm-dis、llvm-link。llvm/unittests/单元测试我改完 API 后一定会跑对应模块的单测防止无意破坏。llvm/utils/辅助脚本、TableGen 文件、setup 脚本等。比如llvm/utils/TableGen是生成后端代码的重要工具链。你会发现一个特点LLVM 的很多代码不是手写的而是用 TableGen.td文件描述再生成 C 代码。尤其是后端的指令集描述、寄存器描述直接手写容易出错用.td可以一处修改多处生成。1.3 首次 clone 和目录选择建议如果只是学习或做一个和工具链相关的实验没必要把lldb、polly、openmp全部编译出来构建时间和磁盘占用都会非常感人。我的建议是最开始只构建clang和lld也就是下面会讲 CMake 时的-DLLVM_ENABLE_PROJECTSclang;lld。等后来真的需要调试调试器或者想玩 MLIR再回来单独加到列表里重新 configureLLVM 的增量构建通常能处理这种情况。这里也有个小经验clone 仓库时建议加--depth1或直接拉取 release 分支。llvm-project 主分支开发速度极快API 可能在几周内就发生破坏性变化。如果你没有追踪上游的强烈需求选择某个稳定的 release tag比如llvmorg-18.1.8会省掉很多无谓的兼容性痛苦。2. CMake 构建配置真正决定你开发体验的几个参数llvm-project 使用 CMake 做构建系统。第一次跑 CMake 时很多人的反应是“为什么这么多参数” 因为 LLVM 这栋楼太大CMake 要让使用者能自由裁剪、切换 build type、控制第三方依赖。而恰恰是几个参数的选择决定你接下来几个月的编译体验是丝滑还是痛苦。2.1 我常用的构建命令与磁盘规划假设你只构建clang和lld面向 X86 和 AArch64 两个目标命令大概是这样的git clone --depth1 -b llvmorg-18.1.8 https://github.com/llvm/llvm-project.git mkdir build cd build cmake -G Ninja ../llvm-project/llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_PARALLEL_LINK_JOBS2 ninja这里有三个点我要着重解释第一-DLLVM_ENABLE_ASSERTIONSON。默认 Release 构建只是关掉了assert()但 LLVM 内部有大量基于assert的 IR 合法性检查。如果你的目的是写 Pass、调后端强烈建议开 Assertions否则很多错误会直接变成段错误或者静默的坏代码你根本不知道问题出在 IR 还是代码生成。代价是编译产物体积和运行性能会稍微变差但发现问题时的定位速度完全值得。第二-DLLVM_PARALLEL_LINK_JOBS2。LLVM 的链接阶段比编译还要吃内存尤其是链接clang或libLLVM.so时一个链接任务就可能吃掉好几个 GB。如果不限制并行链接任务Ninja 默认会像编译一样开一堆并行链接很容易让内存占满、机器卡死甚至 OOM 被内核杀掉。我见过很多新手第一次全量构建卡在 90% 的地方突然报Killed就是这原因。第三-DLLVM_TARGETS_TO_BUILDX86;AArch64。LLVM 默认会构建所有支持的后端目标这会让编译时间和构建目录体积大很多。如果你只在 x86 上做实验只留X86就好甚至可以在后面加一个;host来自动选择当前主机架构。注意要填对 target 名称比如 AArch64 而不是 ARM64。2.2 Debug、Release 还是 RelWithDebInfo很多人在 CMake 里纠结CMAKE_BUILD_TYPE我自己的实践是只是想快速验证能不能编译通过、跑工具链任务用Release快、稳。要改 LLVM 内部逻辑需要看行号和变量值但不想接受完整 Debug 的极慢用RelWithDebInfo。做比较深入的分析型开发比如调后端算法、查 Pass 里状态变化用Debug但同时建议把链接任务限制到 1 个。Debug 构建的最大问题不是编译慢而是链接和运行时性能。libLLVM在 Debug 下会比 Release 慢好几倍跑大型测试会非常煎熬。所以我通常不会同时开 Debug 和 Assertions只在需要具体调某一段代码时开 Debug。另一个容易踩的坑是编译工具链不一致。LLVM 的 C 标准库依赖较高如果你在 Ubuntu 上使用 GCC/G一定确认 GCC 版本足够新如果使用 Clang 自举注意别让系统默认cc/c指向老版本。初学者最容易遇到的“CMake 检查通过但编译期报fatal error: experimental/optional”之类的问题大多就是编译器版本太老。2.3 构建产物里你最常用的几个二进制构建完成之后默认会生成在build/bin/下。这里有你后续会天天用到的几个工具工具功能clangC/C 前端可编译成目标文件或 LLVM IRllvm-as把可读的.ll文本汇编成二进制.bcllvm-dis把.bc反汇编成可读的.llopt加载优化 Pass 并运行是写 Pass 的主要试验台llc将 LLVM IR 编译成汇编或目标文件可指定 CPU 后端llvm-link链接多个 LLVM IR 模块lli直接解释或 JIT 执行 LLVM IRlld链接器常以ld.lld名字被调用我的日常工作流最常用的组合是clang生成 IRopt跑 Passllc生成汇编lli做快速验证。这四个工具的配合构成了后面要说的“IR 调试闭环”。3. 从 clang 到 IR 到汇编最常用的调试闭环做编译器开发永远离不开“把一个 C/C 文件变成可读 IR再逐步看到汇编输出”。这一节我直接讲一遍最顺手的操作流程以及中间的常见疑问。3.1 用 clang 生成可读 LLVM IR假设有一个test.cint add(int a, int b) { return a b; }用下面的命令生成可读文本形式 IRclang -emit-llvm -S -O0 test.c -o test.ll生成的test.ll大概是这样; ModuleID test.c source_filename test.c target datalayout e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128 target triple x86_64-pc-linux-gnu ; Function Attrs: noinline nounwind optnone uwtable define dso_local i32 add(i32 noundef %a, i32 noundef %b) #0 { entry: %a.addr alloca i32, align 4 %b.addr alloca i32, align 4 store i32 %a, i32* %a.addr, align 4 store i32 %b, i32* %b.addr, align 4 %0 load i32, i32* %a.addr, align 4 %1 load i32, i32* %b.addr, align 4 %add add nsw i32 %0, %1 ret i32 %add }如果你是第一次看 IR可能会被alloca和load/store绕晕明明一句加法为什么要存到栈上再读出来因为-O0的 IR 特意不加优化保持了非常机械的“每句高级语言对应一组 IR”的形态。alloca就是在栈上分配变量store把参数放进栈变量load再取出来做加法。用-O2再生成一次你就能看到“优化后简洁得吓人的 IR”clang -emit-llvm -S -O2 test.c -o test.opt.ll生成的函数体通常只剩一条add指令。理解-O0和-O2的差异是理解 LLVM 优化器价值最好的入门实验。很多时候新手会疑惑“为什么有人要专门看 IR”因为 IR 是“经过了前端语义分析、但还没做最终指令选择”的中间层它既保留了类型和变量信息又去掉了源代码里的语法糖。排查“某段代码到底被优化成了什么”时看 IR 比直接看汇编直观得多。3.2 opt 与 llc在 IR 和汇编之间反复横跳拿到了test.ll之后你可以用opt单独跑某个优化比如opt -S -O2 test.ll -o test.opt.ll llc test.opt.ll -o test.sllc会把 IR 变成汇编。如果只想看某个特定后端的输出可以加-marchaarch64或者指定-mcpu。我在做后端改动时最常做的是llc -marchx86-64 -O3 test.opt.ll -o test.s这样我可以快速观察同一个 IR 在不同后端下的指令选择结果。这里要重点提一下-S参数。很多人直接写opt -O2 test.ll结果输出是一个二进制.bc文件用文本编辑器打开是乱码。加上-S会让 opt 输出可读文本 IR配合-o指定输出文件。类似地llvm-as负责把文本变成二进制.bcllvm-dis把二进制变回文本。这套文本/二进制互转工具是日常调试里最不起眼又最常用的基础设施。3.3 用lli快速验证执行结果如果只是想确认 IR 逻辑对不对不必编译成可执行文件直接lli test.ll arg1 arg2lli可以用解释器执行 LLVM IR也可以 JIT 编译执行。它支持大部分 IR 指令用来做单元级的验证非常方便。我写 Pass 的时候经常会这么干先用clang生成一个小的 IR 模块再用opt跑自定义 Pass最后用lli执行对比前后结果确认优化没有改变程序语义。这个“clang 转 IR → opt 跑 Pass → lli 验证”的闭环其实就对应了编译器前中后端的核心链路。只要能把每一步的输出都看清楚理解和修改 llvm-project 的门槛就降低了一大半。后面写 Pass 的部分全靠这个闭环来检验。4. 从零写一个 Pass新 Pass Manager 的套路与接口学会了看 IR下一步自然是改 IR。LLVM 里这种“修改 IR 的代码单元”叫 Pass我经常把它们类比成“手术刀”。你写一个 Pass遍历函数或模块找到你想改的指令然后替换成新的指令。这里最大的知识更新点是旧 Pass Manager 已经快被淘汰了但网上大量教程还在用旧接口。如果你直接 COPY 一段legacy::FunctionPass会发现各种运行时报“Pass is not registered”或者行为诡异。所以这一节我按新 Pass Manager 的写法来讲。4.1 新 Pass Manager 的最小例子最简单的函数级 Pass头文件一般长这样#include llvm/IR/Function.h #include llvm/IR/Module.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 MyFirstPass : public PassInfoMixinMyFirstPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() I see function: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace // 注册插件入口 extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyFirstPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-first-pass) { FPM.addPass(MyFirstPass()); return true; } return false; }); }}; }这里我用了PassInfoMixinMyFirstPass这是新 Pass Manager 的标准写法。关键点有三个run()方法接收Function F和一个FunctionAnalysisManager AM。你要获取分析结果比如LoopAnalysis、DominatorTreeAnalysis都需要通过AM拿。返回值是PreservedAnalyses。如果你没改 IR应该返回PreservedAnalyses::all()告诉优化器“所有分析结果都没变”如果你改了 IR要返回PreservedAnalyses::none()或者精确地保留某些分析否则后续 Pass 可能拿到过期的分析结果。插件入口通过llvmGetPassPluginInfo暴露用registerPipelineParsingCallback注册一个名字这样opt才能通过--passesmy-first-pass调用它。第一次接触可能觉得“怎么这么啰嗦”。但啰嗦是有原因的新 Pass Manager 希望能明确管理 Pass 之间的依赖和合法化时机所以把插件的“注册”和“运行”拆得很清楚。你越早习惯这套写法越不容易被后面复杂的 Pass 管线绕晕。4.2 编译插件并在 opt 中加载写完之后把代码编译成一个动态库。我通常直接在 llvm-project 的 build 环境里做也可以单独写一个 CMakeListscmake_minimum_required(VERSION 3.20) project(MyFirstPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) include_directories(${LLVM_INCLUDE_DIRS}) separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS}) add_definitions(${LLVM_DEFINITIONS_LIST}) add_library(MyFirstPass MODULE MyFirstPass.cpp) target_link_libraries(MyFirstPass PRIVATE LLVM)然后用opt加载opt -load-pass-plugin./MyFirstPass.so -passesmy-first-pass -S test.ll -o test.out.ll容易踩的坑插件使用的头文件版本必须和运行opt的版本完全一致。如果你系统的opt是 LLVM 17而你的插件是拿 LLVM 18 头文件编的经常会在加载时报library does not match或者直接崩溃。这就是为什么我最开始就建议你 clone release 分支、在统一构建树下做实验而不是“随便装一个系统里的 llvm-dev”。4.3 调试 Pass 的常用武器-print-after-all、-debug-only与-stop-afterPass 写多了之后你会发现“它跑是跑了但是不是我想要的效果”才是常态。这时候不能靠 printf 满屏输出要用 LLVM 提供的调试选项。如果想看每跑完一个 PassIR 变成什么样用opt -S -O2 -print-after-all test.ll -o /dev/null 2 log.txt日志会非常长所以建议重定向到文件再 grep。如果只想看某个 Pass 前后的变化可以用opt -S -debug-onlyloop-vectorize -O2 test.ll前提是你的 Pass 里真的用了LLVM_DEBUG(dbgs() ...)来打输出。另一个非常实用的参数是-stop-after比如opt -S -stop-afterloop-vectorize -O2 test.ll这样 IR 会被 dump 到你指定 Pass 执行后的状态。-print-before-all则是反过来看执行前的状态。这三个参数组合起来几乎可以回答所有“我的 Pass 到底对 IR 干了什么”的问题。5. 这半年我在 llvm-project 上踩过的坑和排查心得理论知识讲得再多真正让你长记性的还是实战里那些奇葩问题。下面这些坑有些是我一个人默默调了两三天才解决的有些是每次带新人都会见到的。列出来希望你能直接绕过。5.1 Assertion 消失带来的“薛定谔的 bug”有一次我写了个自定义 Pass本机跑得好好的发给同事之后他一跑就段错误。我远程一看他用的是 Release 模式构建而我的环境带-DLLVM_ENABLE_ASSERTIONSON。问题根源是我的 Pass 产生了一个非法的 IR某个指令的 operand 类型不匹配。但 IR verifier 和 LLVM 内部的 assert 在 Release 下被NDEBUG整个屏蔽了于是非法 IR 一路传到后端最后在某处发生了难以理解的崩溃。从那以后我给自己立了一条规矩写 Pass 默认必须开 Assertions至少 CI 里必须有一档带 Assertions 的构建。LLVM 的 assert 不只是“程序员的自我检查”它是一道防止非法 IR 污染后端的防火墙。很多所谓“诡异崩溃”开启 Assertions 后第一秒就会告诉你哪行 IR 不合法。另外如果你用opt加载插件时发现 IR 没有被验证可以主动跑一次-verifyopt -S -verify -passesmy-pass test.ll -o test.out.ll这会把 verifier 当作一个独立 Pass 执行提前暴露问题。不要等到llc崩溃了才开始怀疑 IR。5.2 链接 LTO 时的常见误区LLVM 的一个卖点是 LTO也就是把多个编译单元的 IR 链接在一起做跨模块优化。但很多第一次用的人会踩到同一个坑忘记给ar工具指定llvm-ar或者编译时没有使用clang自带的插件导致静态库里装的是目标文件.o而不是 LLVM IR 或 bitcode。如果你用 LTO 流程常见命令是clang -flto -c a.c -o a.o clang -flto -c b.c -o b.o clang -flto a.o b.o -o app这时a.o、b.o实际上是 LLVM bitcode而不是普通 ELF 目标文件。如果你用系统默认的ar把这些文件打包再用clang -flto链接很可能报could not get object file或类似错误。正确做法是把ar指向llvm-ar或者直接用ar --plugin指定 LLVM 插件。LTO 排查的时候还有一个技巧加上-Wl,-plugin-optsave-temps或者--save-temps让链接器把中间 IR 留下来。这样你可以直接看链接器到底拿到的 IR 是什么样的。5.3 TableGen 改动引发的“全量重编恐慌”前面提过llvm-project 大量使用 TableGen 生成 C 代码。这意味着如果你改了.td文件比如后端llvm/lib/Target/X86/X86InstrInfo.td那么依赖这个 TableGen 输出的大量源文件都要重新编译。在我的机器上某次只是加了一条指令定义触发重编的时间几乎等于全量构建的一半。这不能算是 bug而是 TableGen 架构的固有权衡通过把指令描述集中在.td里换来的是后端代码的可维护性。遇到这种“编译风暴”时我的建议是不要取消编译耐心等它跑完同时用ninja -t graph或ninja -t commands看看哪些目标被触发理解依赖方向。尽量把.td改动集中在本地分支不要频繁切换分支避免每次都重新生成。如果只是做实验不一定要改核心.td可以先把指令模式写在.inc文件里或者直接在 C 里临时构造 MachineInstr 来验证。5.4 版本差异与向上迁移的麻烦llvm-project 的 API 变动速度在开源项目里属于激进派。哪怕只是从 LLVM 16 升到 LLVM 17你可能都会遇到FunctionPass被移除、某个分析管理器换名字、某些dbgs()输出格式变化等问题。我在一个内部工具上体验过跨了三个大版本后编译错误几百个最后干脆重写了。如果你要长期维护一个外围工具我建议做这些事情fork 一份 llvm-project在固定的 release tag 上打自己的补丁不要轻易跟随主分支。业务代码通过find_package(LLVM)依赖系统安装的库时明确记录 LLVM 版本号CI 里固定镜像防止环境漂移。遇到 API 变化先在 LLVM 官方 release notes 里搜breaking changes通常能省大量猜谜时间。我在实际项目里甚至见过因为“开发机升级了 Clang导致编译好的插件 ABI 不兼容”的问题。LLVM 的 C ABI 并不稳定到可以跨版本复用所以插件必须以“和主程序同一版本源码构建”为铁律。5.5 多线程环境下的 LLVM 使用注意点llvm-project 提供了很丰富的基础设施但并不是所有 API 都默认线程安全。比如LLVMContext是对线程敏感的在不同线程里共享同一个LLVMContext却没有同步保护会导致非常隐晦的崩溃或数据竞争。我的经验是多线程任务尽量让每个线程持有独立 context除非你明确在做进程级共享的 JIT。另一个常见问题是在信号处理函数或极低延迟路径里调用 LLVM 的 JIT 编译。JIT 编译本身可能触发内存分配、锁竞争、甚至 GC 权衡放到实时要求高的线程里绝对是个大坑。如果你只是想在运行时快速执行一段 IR用预先编译好的机器码或把 JIT 放进一个独立 worker 线程会更稳妥。写在最后我的一次个人体会与后续建议回顾这么多年的经历llvm-project 给我的感觉不只是一个仓库而是一个“编译器领域的操作系统”你既可以直接用它提供的 Clang/LD/LDB 组成完整工具链也可以像使用一组库那样只把 IR 定义、优化器、后端拿出来嵌进你自己的产品。它的学习曲线确实陡峭但一旦你能熟练地“clang → opt → llc → lli”循环再上手任何编译优化任务都会觉得理所当然。最后再分享一个我后来养成的习惯每次在 llvm-project 里做一个稍大的功能我都会提前用git diff写一份“变更说明”不光是给自己看也方便在社区提问或提交 review 时把上下文压缩到最小。LLVM 的社区对“带着清晰复现步骤和最小用例的提问”响应速度通常很快但对“直接说这不行、那不行又没有任何例子”的提问基本不会理睬。你给出的例子越接近上面这种“IR 片段 opt 命令”就越容易获得有价值的技术反馈。如果你正打算深入学习 llvm-project我的建议顺序是先把构建搞定用-O0/-O2的 IR 对比建立直觉再写第一个只打印函数名的 Pass然后尝试修改 IR。等这三步都走通了再往后端或前端拓展。到时候你会发现LLVM 这棵大树虽然庞大但通往核心的那条路其实很清晰。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。