尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

LLVM项目实战:仓库结构、构建配置与自定义Pass开发指南

LLVM项目实战:仓库结构、构建配置与自定义Pass开发指南 很多编译方向的朋友第一次接触llvm-project这个仓库时都会被它的体量惊到——代码量几百万行子项目七八个光是git clone就要等半天。但真正想用 LLVM 做点事情的人迟早都要直面这个仓库。这篇博文我想从一个实际开发者的视角把llvm-project的仓库结构、编译方式、二次开发流程和常见坑一次讲清楚希望能帮你在面对这个庞然大物时少走弯路。这不止是给编译器初学者看的也适合要做静态分析、语言前端、性能优化工具的同学。不论你是想基于 Clang 做代码诊断还是用 MLIR 做领域编译器或者只是想把 LLVM 作为依赖集成到自己的工程里搞清楚llvm-project的基本玩法和架构思路是所有后续工作的地基。1. 内容整体设计与思路拆解1.1 为什么需要一个“全家桶”仓库很多第一次接触llvm-project的人会有一个疑问LLVM 不是一个编译器吗为什么这个仓库里既有 Clang 编译器前端又有 LLVM 核心优化器还有调试器、标准库、链接器甚至还有一堆看起来和编译毫无关系的工具这个设计思路实际上很符合编译器生态的现实需求。LLVM 的核心价值在中间表示IR和优化基础设施但它本身不能独立完成“把源代码变成可执行文件”这件事。你需要一个前端如 Clang把 C/C/Objective-C 翻译成 IR需要一个后端把 IR 变成目标平台机器码还需要链接器、汇编器、运行时库配合。llvm-project把这些组件放在同一个仓库里统一版本、统一构建能保证各组件之间接口兼容这是它和零散维护各个独立项目最本质的区别。从开发者的角度来看这种“全家桶”式的组织方式还有一个实际好处做实验时不用自己拼装一堆版本不匹配的组件。比如你想验证一个新的优化 pass 对最终生成代码的影响只需要改 IR 层代码重新构建 clang然后拿 clang 去编译测试用例。如果 LLVM 和 Clang 是分开独立管理的这种改动的成本会高很多。1.2 仓库全景从一个编译器到一个工具链生态llvm-project的核心子项目包括LLVM 本体核心库和优化器、ClangC/C/Objective-C 前端、LLD链接器、LLDB调试器、libcC 标准库实现、compiler-rt运行时库、mlir多层 IR 基础设施、polly循环优化、flangFortran 前端、libunwind栈展开库等。这个版图要理解起来其实不难。你可以把编译器比作一条“翻译生产线”源代码进来要经过词法分析、语法分析、语义分析、优化、代码生成、汇编、链接、加载调试最后才变成能跑的程序。llvm-project的每个子项目对应的正是这条生产线上的一环。在实际开发中我发现很多刚接触的人对llvm-project还有一个误解以为它只是一堆二进制工具的集合。实际上LLVM 更重要的身份是一组可嵌入的库。你在自己的工具里可以直接#include llvm/IR/Module.h调用 API 来做 IR 分析或修改而不必非得走命令行。这个特性让它成为实现静态分析、代码插桩、自动修复等定制化需求的理想底座。1.3 这套架构的核心设计逻辑理解llvm-project的架构核心要把握三点三段式设计、库化内核、全新 Pass 管理机制。三段式设计是指“前端-优化-后端”分离。C/C 代码先进 Clang 生成 LLVM IRLLVM 优化器对 IR 执行一系列分析变换后端再将 IR 转为目标机器码。这样做的好处非常直观增加一门新语言只需写新的前端增加一种新 CPU 架构只需写新的后端中间的优化资源对所有前端和后端完全共享。这就是为什么你能看到 Rust、Swift、Julia 这些语言都选择 LLVM 作为后端——它们省去了开发高质量代码生成器的巨大工程。库化内核更好理解。LLVM 的设计哲学是“一切皆库”命令行工具只是库上的一层薄壳。优化 pass 是库目标描述是库IR 操作 API 是库。所以你可以只链接自己需要的部分而不是被迫接受一个几十 MB 的全家桶库。这个特性对嵌入式环境或大型项目的集成非常重要。Pass 管理机制则是 LLVM 内部模块化的体现。每个优化遍历被称为一个 pass它有明确的依赖关系。在写自定义 pass 时你需要声明依赖哪些分析结果pass 管理器负责调度和缓存。新版 Pass 管理器New PM的引入优化了加载策略让流水线更高效、可扩展性更强。2. 核心细节解析与实操要点2.1 源码获取从 git clone 到 submodule 少踩坑获取llvm-project源码是第一步。官方仓库在 GitHub 上有镜像通常做法是git clone https://github.com/llvm/llvm-project.git cd llvm-project这个仓库本身很大完整克隆下来体积在 2GB 以上。做第一件事之前你要明确自己需要哪些子项目。LLVM 采用 monorepo 模式所有子项目都在一起但你完全可以在构建时只选择需要的组件。这里有个重要提醒llvm-project的仓库经过多年演化代码量非常可观首次克隆时建议用--depth 1参数只拉取最新版本减少下载时间git clone --depth 1 https://github.com/llvm/llvm-project.git但如果你后续需要切换到历史版本或参与代码审查--depth 1可能就不够用了会无法查看历史记录。根据实际场景决定是否要全量克隆即可。2.2 构建配置28 个常用 CMake 参数逐个说进入llvm-project目录后官方推荐的构建方式是使用 CMake Ninja。首次构建前你需要根据自己的需求设置一组 CMake 参数。我整理了一份经过实际验证的配置cmake -G Ninja -S llvm -B build \ -DLLVM_ENABLE_PROJECTSclang;lld;compiler-rt \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON \ -DCMAKE_INSTALL_PREFIX/opt/llvm逐个解释一下这几个关键选项-DLLVM_ENABLE_PROJECTS指定要同时构建哪些子项目多个项目用分号分隔。-DCMAKE_BUILD_TYPERelease表示优化构建适合日常编译使用。如果是调试 LLVM 源码本身的行为改成Debug会更友好但构建时间和二进制体积都会大幅增加。-DLLVM_TARGETS_TO_BUILD指定目标后端。默认会构建所有目标架构但实际上 99% 的场景只要 X86 就够了可以明显减少构建时间。-DLLVM_ENABLE_ASSERTIONSON开启断言能帮你尽早发现 IR 或 pass 的非法操作强烈建议在开发调试阶段开启。-DCMAKE_INSTALL_PREFIX设定安装路径方便把构建产物统一装到一个目录里。在第一次构建时还有一个非常影响体验的参数-DLLVM_PARALLEL_LINK_JOBS。默认情况下 Ninja 会并发执行链接任务但每链接一个动态库就会吃掉好几个 GB 内存。如果你的机器内存不够充足设置-DLLVM_PARALLEL_LINK_JOBS1可以限制同时只链接一个目标防止内存爆掉。别问我为什么知道连续两次 OOM 之后你就会把这个参数刻在脑子里。2.3 构建过程中的资源管理和加速技巧第一次构建llvm-project是一个非常考验耐心的过程。以我手头一台 16 核 32GB 内存的机器为例只构建 Clang 和 LLVM 核心库Release 模式大约需要 30 到 60 分钟。如果你的配置为 Debug 全部目标架构 全部子项目等待时间可以轻松超过两个小时这是很正常的不是电脑坏了。有几个加速技巧值得分享第一用 ccache。ccache是一个编译器缓存工具第二次构建同一份代码时它能直接复用上次的编译产物速度提升非常明显。在 CMake 配置中加上-DLLVM_CCACHE_BUILDON即可开启。我自己实际测试过反复迭代修改小文件后重新构建incremental build从原来的几分钟缩短到几十秒这个投入产出比极高。第二控制并行任务数。-j参数和 CPU 核数不是永远成正比LLVM 的编译各任务之间竞争内存和 CPU 资源都很激烈。如果内存只有 16GB建议-j832GB 内存可以-j16再往上加的边际收益很小。第三只构建当前需要的目标。如果你只是改了某个 pass不要傻等全量构建完成。LLVM 的增量构建已经做得很好Ninja 会自动检测变化只重编受影响的部分。你要做的就是别到处乱删 build 目录。2.4 快速验证构建产物是否可用构建完成后用一个简单的 hello world 验证一下产物#include stdio.h int main() { printf(hello llvm\n); return 0; }build/bin/clang -O2 hello.c -o hello build/bin/llvm-dis hello.bc -o - | head -20在接触 LLVM 的早期阶段建议先学会用llvm-dis把.bc字节码文件“反汇编”成文本形式的 IR。这有助于你理解 clang 在编译过程中发生了什么也为后面写分析和优化 pass 打下基础。llvm-as是反向的汇编器opt是跑优化 pass 的命令行工具所有新写的 pass 最终都要通过opt来加载验证。3. 实操过程与核心环节实现3.1 最小 CMake 工程调用 LLVM 库如果你想在项目中使用 LLVM而不是直接修改 LLVM 源码推荐的方式是“外部工程 find_package”。具体来说在llvm-project的 build 目录下执行 install或者在使用cmake --install后再把它作为外部依赖来引用。一个最简的CMakeLists.txt是这样的cmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) add_executable(my-tool main.cpp) target_link_libraries(my-tool LLVMCore LLVMSupport LLVMPasses)注意find_package(LLVM REQUIRED CONFIG)会读取 LLVM 安装目录下的LLVMConfig.cmake把相关的宏和路径一次性配置好。这里需要确保 CMake 能通过-DLLVM_DIR找到配置文件的路径比如cmake -G Ninja .. -DLLVM_DIR/opt/llvm/lib/cmake/llvm3.2 编写一个自定义 LLVM Pass 的完整流程写 pass 是llvm-project二次开发里最经典也最常用的技能。按当下主流的 New Pass Manager 接口一个最简单的 FunctionPass 代码如下#include llvm/IR/Function.h #include llvm/IR/LegacyPassManager.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 { class MyHelloPass : public PassInfoMixinMyHelloPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Hello from: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace // 注册插件否则 opt 认不出这个 pass extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyHelloPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-hello) { FPM.addPass(MyHelloPass()); return true; } return false; }); }}; }这段代码的核心逻辑非常直白run函数会在每个函数上被调用打印函数名然后返回PreservedAnalyses::all()意思是我这个 pass 什么都没改之前的分析结果可以全部保留。这个返回值很重要搞错会导致后续 pass 使用过期分析数据产生非常难查的 bug。要用opt测试这个 pass先编译成动态库再指定-load-pass-pluginclang -stdc17 -fPIC -shared mypass.cpp $(llvm-config --cxxflags --ldflags) -o libMyPass.so build/bin/opt -load-pass-plugin ./libMyPass.so -passesmy-hello hello.bc -o /dev/null在opt里新增 pass 时-passes参数新写法的基础已经是你看到的my-hello不再是老式-my-hello风格。如果你要接的是旧的LegacyPassManager语法会不太一样。我建议直接用新版接口不要在新代码里留旧接口。3.3 使用llvm-config辅助开发编译有很多人在写 LLVM 外部工具时会被一堆编译参数搞疯。一个趁手工具llvm-config能帮上大忙llvm-config --cxxflags --ldflags --libs core support passes它会输出需要的编译器和链接器参数。你可以拿它在自己的构建系统里替换或者在简单的测试中直接用clang mytool.cpp $(llvm-config --cxxflags --ldflags --libs core support) -o mytool这里有一点要留意llvm-config的默认输出和你安装的 LLVM 构建配置有关。如果你的 LLVM 是用Release构建的那--cxxflags里会带-O2如果你是在 Debug 模式下开发的工具可能想手动清掉这些优化确保你的调试信息不会被混淆掉。3.4 用 lit 跑测试和写测试用例llvm-project的测试体系是围绕lit搭建的。lit是一个通用测试框架但为 LLVM 做了大量定制。写一个新 pass 时你需要为它添加测试用例测试文件通常放在llvm/test/Transforms/下文件内容是 IR 源码加上 RUN 指令; RUN: opt -passesmy-hello %s -S -o /dev/null | FileCheck %s ; CHECK: Hello from: testFunc define void testFunc() { ret void }%s会被替换为当前文件名-S表示输出 IR 文本而不是字节码/dev/null 丢弃结果而把 pass 打印的Hello from信息送到 stderr打印用的是 errs()。要跑这个测试build/bin/llvm-lit llvm/test/Transforms/MyHelloPass/hello.ll写测试也有几个容易踩的坑FileCheck 的CHECK指令很严格多一个空格都会导致匹配失败如果输出内容是可选的用CHECK-EMPTY或CHECK-NOT验证不存在的行另外测试尽量做成无需外部依赖的“单文件测试”能在任何环境里重复跑通是基础要求。4. 常见问题与排查技巧实录4.1 构建阶段OOM、版本不匹配、符号找不到先说说最普遍的“编译中内存不够”。LLVM 的 C 模板和优化非常吃内存链接时因为要生成动态符号表内存占比尤其高。遇到internal compiler error: Killed或cc1plus: out of memory马上加-DLLVM_PARALLEL_LINK_JOBS1必要时候把-j调低。其次是 CMake 版本和 GCC 版本不匹配。LLVM 主干的更新速度极快有时候你本地的编译器太旧构建时会报一些奇怪的模板错误而且这些错误往往在 LLVM 自己的源码里。如果排除了自己的代码问题优先查编译器版本。LLVM 官方文档里有最低编译器版本要求务必先对照一下。还有一个高发问题动态库路径不对。build/bin/clang能编译但运行时报libLLVM-18.so: cannot open shared object file。这是因为你没有设置LD_LIBRARY_PATH或者安装时没有执行ldconfig。临时方案是用LD_LIBRARY_PATH/opt/llvm/lib ./clang永久方案是把/opt/llvm/lib写入 ld.so.conf。4.2 调试阶段断言崩溃、IR 非法在开启了LLVM_ENABLE_ASSERTIONSON的开发版里如果你的 pass 生成了非法 IR跑opt时大概率会直接触发 assertion并带一堆调用栈。保存好这个调用栈多数情况下问题都在你最近改动的那几行代码附近。遇到Verifier报错也不必慌。OpenMP 或者某些特殊语义的代码在优化后会触发一些看似“误报”的规则实际上你的 pass 可能改坏了一个phi节点的 incoming 块或者漏更新了dominator信息。遇到这种问题先用-print-after-all找出是哪个 pass 引入坏 IR再定位到自己的代码逻辑。4.3 测试阶段lit 超时、FileCheck 匹配意外写新测试后跑不起来最常见的几个原因路径写死测试里不能带机器相关路径。用%t临时文件代替。依赖未声明如果测试依赖llc必须在 RUN 里显式指定llc不然 lit 默认只随机启用部分工具。超时默认 timeout 是 10 秒一般测试足够但如果跑大型 benchmark可能要显式调大超时参数。FileCheck 还有一个特别容易被忽视的事CHECK会在全文件中搜索匹配不是按顺序逐行严格匹配。如果要验证“某段代码中必须存在 A 且 A 后面紧跟 B”要用CHECK: A然后CHECK-NEXT: B或者CHECK-SAME:。这个细节会让你省下非常多调试时间。4.4 将 LLVM 集成到现有工程时的隔离策略最后聊一个更“现实”的话题把llvm-project嵌进自己的项目时如何管理版本和依赖关系。LLVM 的 API 变动非常频繁每两三个版本就有不少 breaking change直接把 LLVM 的源码add_subdirectory进工程会让你的工程体量瞬间膨胀构建时间也拉长到不可接受。更常见的选择是用系统包管理器安装预编译版本如 Ubuntu 的llvm-18-dev并在接口层做隔离。我的建议是把“LLVM 相关操作”集中封装在一个独立的抽象层比如只在自己的IRUtil模块里直接 include LLVM 头文件上层业务代码只操作IRUtil的接口。这样哪天 LLVM 大版本升级导致接口不兼容你只需要改IRUtil这一个文件而不是全工程几百处调用点。这个经验是我在维护静态分析工具时踩过坑才总结出来的——有一回从 LLVM 15 升到 16光改适配代码就花了一整天。实际接触llvm-project这几年我的体会是这个项目的复杂度是真实的但它的学习曲线同时也是“线性”的。你不需要第一天就理解所有子项目的所有代码先用最小的路径完成一个能跑的 demo再慢慢延伸反而比一上来就想读懂整个优化流程要高效得多。最后再分享一个小技巧编译llvm-project时编译日志一定要留存。真遇到莫名的构建失败build目录下的CMakeCache.txt和各个日志文件就是你的第一手排查材料。很多问题在社区提问时别人第一句话就是“贴一下你的 CMake 配置和完整报错”这不是敷衍是真的只有这些信息能定位问题。
返回列表