ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

LLVM 项目实战:从零构建 Clang 到编写 Pass 的完整记录

LLVM 项目实战:从零构建 Clang 到编写 Pass 的完整记录 说实话我第一次面对 llvm-project 这个仓库的时候完全是一种守着金山不知道怎么挖的状态。仓库只有一个名字没有任何说明文档也没有人告诉我该从哪里下手但它在编译器领域的分量稍微有点经验的人都心知肚明Clang、LLD、MLIR、libc、compiler-rt几乎所有撑起现代工具链生态的基础设施都堆在里面。这篇博文不是官方文档的复读而是我从只知道用 gcc 编译 C 语言到能在 LLVM 里写 Pass 调 IR这段时间的完整记录。包括顶层目录到底在讲什么、怎么从零构建出一个能跑的 Clang、构建过程中那些让人想砸键盘的报错怎么排查以及最重要的——普通人到底怎么快速跨进 LLVM 这个深水区。我的核心结论先说在前面llvm-project 的学习曲线陡但它的设计意图非常清晰理解了它的分层逻辑与迭代方式它其实就是一套可以随意拆装的编译器积木。1. 为什么 Linux、macOS、Android 都在用同一套编译器内核说到 llvm-project很多人第一反应是Clang 的源代码仓库。这个理解不完全错但严重低估了它的覆盖面。本质上llvm-project 是一整套围绕中间表示IR构建的编译基础设施Clang 只是它的 C/C 前端入口之一。真正让它在业界通吃的是它打破了传统编译器「前端直接翻译成后端机器码」的黑盒模式改成了三段式设计前端负责把源代码变成语言无关的 IR中端对 IR 做优化后端把 IR 翻译成目标平台机器码。这三段式结构带来的直接好处是组合爆炸式的。今天大家耳熟能详的工具链几乎都能在 llvm-project 里找到对应角色项目名称在 LLVM 生态中的角色典型适用场景clangC/C/Objective-C 前端替代 gcc 编译 iOS、Android、Linux 内核lld链接器替代 GNU ld速度快且内存占用低lldb调试器替代 gdb与 LLVM 工具链原生配合libc / libcabiC 标准库实现适配新平台时免去 libstdc 的依赖难题compiler-rt运行时支持库提供 sanitizer、profile、builtinsmlir多级 IR 框架AI 芯片编译器、硬件加速器代码生成flangFortran 前端科学计算、高性能计算场景openmp并行编程运行时共享内存并行计算的实现层polly循环优化框架基于多面体模型的自动并行化与优化从这个表能看出来llvm-project 不止是另一款编译器它是围绕 IR 这个核心设计出来的一整套工具链家族。举一个最贴近日常的例子你在 macOS 上敲 clang 编译程序底层用的是 clang 前端 LLVM 优化 lld 链接你在 Android Studio 里编译原生代码NDK 带的也是 LLVM 工具链甚至新款 Mac 上跑 x86 程序的 Rosetta 2内部用的也是基于 LLVM 的二进制翻译和指令集模拟技术。理解了这套内核的复用逻辑你就明白了为什么各大厂都在向 LLVM 靠拢——不是因为情怀是因为同一套基础设施可以横向适配无数场景。我自己的切入路径也很典型先用 Clang 做日常编译然后发现 -O2 的优化行为跟 gcc 不太一样出于好奇去看了 LLVM IR最后一步步滑向了写自定义 Pass 的深坑。如果你也处于知道 LLVM 很牛但不知道牛在哪的阶段这篇博文正好适合你。2. 第一次面对 llvm-project 时的地图顶层目录到底在说什么llvm-project 这个仓库刚 clone 下来的时候顶层目录至少有二三十个每个看起来都像独立项目。初学者最容易犯的错误是直接钻进 llvm/ 目录里开始读源码结果被海量文件淹没。我建议先花一个小时把这些目录按前端 / 中端 / 后端 / 运行时 / 工具 / 辅助归类后续查代码时定位速度快非常多。2.1 前端与语言相关组件clang、flang、mlir 的分工clang 是 c/frontend 的核心负责把 C、C、Objective-C 源码解析成抽象语法树AST再逐步降级到 LLVM IR。这里有一个非常关键的设计点clang 并不仅仅是把 C 翻译成 IR这么简单它还承载了语法检查、语义分析、模板实例化、静态分析等大量语言层面的工作。如果你在写编译报错插件clang 的 libclang 和 ASTMatcher 就是你的主要工具。flang 则是 Fortran 前端它的历史比 clang 复杂现在基于 MLIR 做了一个全新的实现flang-new有兴趣做科学计算工具链的人可以关注这一块。mlir 在顶层目录里的地位有点特殊它本身不是像 clang 那样的前端而是一个可以自定义多级 IR 的框架。AI 芯片厂商做编译器时通常用 MLIR 搭一层从框架计算图到硬件指令的桥接这也是为什么 llvm-project 是所有 AI 芯片编译器绕不开的底座。2.2 中端与优化核心LLVM 库、opt、llvm-as/llvm-disllvm/ 目录是真正的发动机包含了 IR 的定义、Pass 优化管线、指令选择与寄存器分配等核心逻辑。如果你要写在 LLVM 上做的优化这个目录基本就是你的主战场。值得注意的是llvm/ 下既有 lib/ 这样的库代码也有 tools/ 这样的可执行文件。opt 工具专门用来对单个 .ll 或 .bc 文件跑指定 Pass调试自己的 Pass 时几乎天天都要用到它llvm-as 和 llvm-dis 则是 IR 文本格式和 bitcode 格式之间的转换器。中端优化的逻辑可以用一个比喻理解前端做的是把一篇文言文翻译成白话文中端做的是把白话文浓缩成电报后端则是把电报转译成收件人母语。LLVM IR 就是那个白话文文本它保持了人类可读性同时足够底层让机器可以做数据流分析、死代码消除、循环展开等操作。2.3 后端与目标平台X86、AArch64、RISCV 等子目标llvm/lib/Target/ 下几乎每一个子目录都代表一种硬件架构的后端比如 X86、AArch64、RISCV、PowerPC、Mips、SystemZ 等。后端的核心职责是把优化后的 IR 先选择成目标指令再安排寄存器分配、指令调度、汇编输出。如果你对某种新 CPU 怎么在 LLVM 里被支持这件事感兴趣答案基本就在对应架构子目录里。这里有个很多人忽略的常识LLVM 的后端包含从 SelectionDAG / GlobalISel 到 MCMachine Code层的完整链路。MC 层负责汇编、对象文件生成与反汇编后面接的才是 lld 链接器。看懂这条链路非常重要因为新指令集要支持一个新特性往往需要从前端内置函数到中端指令模式再到后端指令选择和 MC 层编码一起改牵一发动全身。2.4 工具链配套lld、lldb、bolt、pollylld 作为链接器的特点是快实测链接一个大型 C 项目通常比 GNU ld 快两三倍甚至更多在 CI 场景下省下的时间非常可观。lldb 的设计则类似于用 LLVM 库重写的调试器它不需要 GDB 那种通过 ptrace 神操作的方式而是大量复用 LLVM 的底层抽象因此在新平台上的适配速度往往更快。bolt 是后来加入的二进制优化工具核心思路是在最终链接后的二进制上做 profile-guided optimizationPGO对大型应用能带来几个百分点的性能提升。polly 则是基于多面体模型的循环优化框架对嵌套循环密集的数值计算代码特别有用。这些子项目大多数时候不需要你主动去构建但当你真正遇到编译 IDL、链接太慢、调试不方便、二进制性能差这些具体痛点时它们各自就是解药。3. 从零构建 llvm-project 的完整参数拆解与实测数据真正动手构建 llvm-project 的体验用一句话概括就是只要参数给得对一次成功并不难参数给得不对报错能让你怀疑人生。这一节我直接给出我踩过坑之后验证过的步骤和参数设计并解释每个关键参数背后的理由。3.1 环境准备与磁盘空间评估首先明确一个硬指标磁盘空间至少准备 100GB内存建议 16GB 以上8GB 内存构建全量项目大概率会 OOM 或交换到怀疑人生。我最初就是用一台 8GB 内存的笔记本尝试到链接 clang 那一步直接卡死后来切换到 32GB 的服务器才顺畅。依赖方面不同系统略有差异。Ubuntu/Debian 需要安装 build-essential、cmake、ninja-build、python3 等基础包。macOS 上则需要 Xcode Command Line Tools。这里我强烈建议用 Ninja 而不是默认的 Unix Makefiles因为 Ninja 的并行调度更优秀增量构建速度更快而且失败了会告诉你具体是哪个命令失败排错体验好很多。此外系统 cmake 版本如果低于 3.20LLVM 新版本很可能直接报错所以先 cmake --version 看一眼版本不够就用 pip 或者官方脚本升级。3.2 CMake 配置参数详解与我的推荐组合llvm-project 的 CMake 参数体系非常庞大但真正影响构建成败和速度的就那几个核心选项。以下是我最近一次构建 LLVM 19.x 用的关键配置cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang \ -DLLVM_ENABLE_PROJECTSclang;lld;lldb \ -DLLVM_ENABLE_RUNTIMESlibcxx;libcxxabi;compiler-rt \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_USE_LINKERlld \ -DLLVM_CCACHE_BUILDON \ -DCMAKE_INSTALL_PREFIX$HOME/llvm-install \ ../llvm-project/llvm参数逐个解释CMAKE_BUILD_TYPERelease和-DLLVM_USE_LINKERlldRelease 保证产物有实际性能价值用 lld 链接 LLVM 本身链接速度比系统的 GNU ld 快非常多这也是吃自己狗粮的典型实践。实测同一个项目用 GNU ld 链接 clang 可能需要二十多分钟换 lld 后降到十分钟以内。LLVM_ENABLE_PROJECTS与LLVM_ENABLE_RUNTIMES的区别这是很多人的困惑点。早期版本只有一个 LLVM_ENABLE_PROJECTS后来 LLVM 把 libcxx、libcxxabi、compiler-rt 这类运行时库拆到了 RUNTIMES 里因为它们在交叉编译和 bootstrapping 时有独立的构建周期。简单说需要一起编译、一起发布的第一方工具放 PROJECTS跟随目标平台变化、需要重新构建的库放 RUNTIMES。LLVM_TARGETS_TO_BUILD这里只填了X86;AArch64;RISCV不是越多越好。LLVM 默认会构建所有目标后端这会让构建时间成倍增加。我在配置里只保留自己需要的架构编译时间和内存占用都显著下降。如果要跑日常开发只留X86都够用。LLVM_CCACHE_BUILDON强烈建议打开尽量安装 ccache。LLVM 每次改源码重新编译时配置阶段和大量头文件的重复解析都极其耗时ccache 的命中率可以让迭代速度提升数倍。我第一次没有配 ccache每次改一行代码都要等四五分钟后来装了 ccache小改动经常十几秒就出二进制。3.3 构建命令与预期耗时配置完成后直接ninja -j$(nproc)开始构建。在我的 32 核服务器上首次全量构建以上配置大约耗时 30~40 分钟在 8 核的 MacBook Pro 上同样的配置大约需要 2~3 个小时。这里有几个真实数据点供参考LLVM 单核心编译时间在所有大型 C 项目里都是数一数二的所以千万不要开着默认并行度硬等一定要用-j参数合理分配。构建完成后ninja install会把可执行文件和库安装到CMAKE_INSTALL_PREFIX指定的目录。之后就可以直接使用$HOME/llvm-install/bin/clang验证$HOME/llvm-install/bin/clang --version看到输出说明你的 LLVM 工具链已经真正跑起来了。4. 构建过程中那些真实踩过的坑排查链路与对策llvm-project 的构建错误比一般项目高级得多因为错误的根源往往不是代码逻辑而是环境、配置和库版本之间的微妙关系。我把自己踩过且在网上反复被人问起的四类坑整理在这里每一类都附上完整的排查链路而不是直接给结论。4.1 磁盘空间与内存导致的伪报错这类坑最阴险。常见画面是构建到某个大文件时突然报internal compiler error或者ninja: build stopped: subcommand failed。很多人第一反应是代码写错了但实际上可能是/tmp空间满了或者内存不足导致后台进程被 OOM killer 杀掉。我的排查经验是看到构建中断先看最后十几行日志里有没有Killed、No space left on device、cc1plus: out of memory这类字样。用df -h检查磁盘、free -h检查内存再用dmesg | tail确认 OOM 记录。如果确认是资源问题优先减小-j并行度同时把TMPDIR指向大分区必要时添加 swap。盲目加大-j往往适得其反。4.2 编译器版本不匹配的隐藏依赖LLVM 对构建自己的编译器版本有硬性要求。官方文档里一般会写GCC 5.1之类的最低标准但实际体验是版本越老越容易深处踩雷。比如 GCC 7 的某些版本在编译含最新 C17 特性的 LLVM 源码时会出现莫名其妙的模板报错错误位置却在 LLVM 源码内部很难与编译器版本太老联系起来。排查方法很直接用gcc --version或clang --version确认版本如果明显偏低切换到新版编译器后再试。另外如果用系统 GCC 构建时碰到internal compiler error也可以试试换 clang 构建clang 构建 LLVM 时对代码的容忍度和诊断信息反而更好。4.3 CMake 缓存与配置残留的玄学问题这是项目管理中最容易忽视、也最容易浪费大量时间的环节当你修改了LLVM_ENABLE_PROJECTS之类的重要配置后旧缓存不会自动全部更新。表现在行为上就是——你明明加了 lld构建时却始终找不到 lld 目标或者你明明删掉了一个子项目它还在执行编译。我推荐的排查链路是改了 CMake 变量之后不要指望增量配置能完全搞定直接rm -rf build然后重新执行 cmake。虽然耗时但可以保证配置与代码一致。如果不想全删至少删掉 build 目录下的CMakeCache.txt和CMakeFiles再重跑配置。这里我见过太多人在社区里反复提问为什么我加了 lld 还是没生成最后十有八九是缓存没有清干净。4.4 链接阶段报错的定位方法全量构建中链接阶段报错通常是最吓人的因为错误信息长涉及符号多。而我发现大多数链接错误都集中在两处一是libLLVM.so或libclang-cpp.so这类大库的符号未定义二是多个子项目之间版本头文件不一致导致 ABI 不匹配。处理这类问题我的方法分三步第一步全文搜索报错里的undefined symbol看它属于哪个库第二步确认该库是否真的在这次配置中被启用了比如你开启了 compiler-rt但对应的 runtime 库其实还在 RUNTIMES 里没有编出来第三步检查是否有多个 LLVM 版本共存比如系统自带的/usr/lib/llvm和你自己$HOME/llvm-install下的库冲突导致链接器 pick 了错误的版本。用ldd查看生成后的二进制链接了哪些库可以快速锁定是不是串版本了。5. 真正读懂 LLVM 的最小闭环从写 IR 到跑 Opt 再回 C 语言构建完 llvm-project 之后最大的困惑通常是那我接下来干嘛。我个人的经验是不要先啃一遍 LLVM 源码再动手而是直接走一遍最小闭环写一段 C 代码生成 IR看优化前后差异再尝试自己写一个微不足道的 Pass。把这条链路走通你对 LLVM 的认知会有一个质变。5.1 用 clang 生成可读的 IR 文本随便写一个 hello.c#include stdio.h int add_one(int x) { return x 1; } int main(void) { int y add_one(41); printf(%d\n, y); return 0; }用clang -S -emit-llvm hello.c -o hello.ll生成 IR 文本。打开 hello.ll能看到define i32 add_one(i32 %x)这类函数定义i32是 32 位整数类型%x是虚拟寄存器基本块用标签分割。这个文本格式就是 LLVM 界的通用语言几乎所有优化讨论都是基于它展开的。5.2 用 opt 跑单个 Pass 对比优化差异接下来是理解中端优化的关键一步。分别运行opt -S -O2 hello.ll -o hello_O2.ll opt -S -passesinstcombine hello.ll -o hello_instcombine.ll-O2跑的是完整优化管线-passesinstcombine只跑一个指令合并 Pass。对比 hello.ll 和 hello_O2.ll你会发现add_one在 O2 下可能直接变成add i32 %x, 1的嵌入形式甚至在调用点被 inline 掉之后整个函数调用都被替换成了常量计算。这个可视化对比让 IR 的优化效果一目了然比你读十篇理论文章都有用。另一个常用工具是llvm-as和llvm-dis比如把 hello.ll 转成 bitcodellvm-as hello.ll -o hello.bc再用llvm-dis hello.bc -o hello_from_bc.ll转回来。bitcode 是 LLVM 的二进制 IR 格式很多编译器内部环节都吃这个格式。5.3 写一个空 Pass 并接到现有管线里这一步是很多人卡住的地方。新版本 LLVM 推荐使用新 Pass Manager写 Pass 的方式是继承llvm::PassInfoMixin并实现run方法。我这里给出一个最小可用的例子作用是在每个函数开头打印函数名#include llvm/IR/Function.h #include llvm/IR/PassManager.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class HelloPass : public PassInfoMixinHelloPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Hello from function: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, HelloPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-pass) { FPM.addPass(HelloPass()); return true; } return false; }); }}; }把这段代码编译成动态库clang -shared -fPIC -fno-rtti hello_pass.cpp \ -o hello_pass.so \ $(llvm-config --cxxflags --ldflags --libs)然后运行opt -load-pass-plugin./hello_pass.so -passeshello-pass -S hello.ll -o /dev/null如果能看到Hello from function: add_one和Hello from function: main输出恭喜你你已经跨过了写 LLVM Pass这个公认的门槛。之后的进阶方向无非是读取指令、修改 IR、查询分析结果、与其它 Pass 交互本质上都是在这个框架里填充逻辑。这里有一个极其重要的概念需要强调新 Pass Manager 下Pass 返回的PreservedAnalyses决定了它的分析结果是否还对后续 Pass 有效。如果你修改了 IR 却返回PreservedAnalyses::all()等于告诉上层我的分析结果不用更新这在某些场景下会导致后续优化拿到错误的分析结果属于隐藏的 bug 根源。初学者写的 Pass 如果只做打印不修改 IR返回all()是安全的但一旦开始修改 IR就要按需返回PreservedAnalyses::none()或精确保留某些分析。5.4 链接进动态库与真实工程集成的注意点上面用opt -load-pass-plugin的方式适合本地调试但不适合放进真正的大型编译器流程。如果你想把 Pass 集成到 clang 自带优化管线里有两种主流做法一种是把 Pass 源码直接放进 llvm/lib/Passes/ 里注册然后重新编译整个 llvm-project另一种是通过-mllvm -load-pass-plugin...传给 clang 后端让它在你编译时自动跑你的 Pass。实测下来方式二对日常开发和 CI 更友好因为它不需要重编 LLVM。我会用它来对真实项目做代码分析或定制优化方式一更正统但迭代周期太长通常只有发版或确需内嵌时才用。这个选择背后其实是改 LLVM 源码和作为独立插件维护两种开发模式的权衡理解了它你就不再是 LLVM 的旁观者而是有能力参与改造的开发者了。6. 从使用到参与进入 llvm-project 贡献者行列的务实建议很多人觉得给 LLVM 提 patch 是高不可攀的事情总觉得我水平不够。其实 LLVM 社区对新人贡献者的友好程度远超想象关键在于找到合适的切入路径。如果你已经走通了上面的构建和 Pass 闭环就已经具备了入门的最基本能力。6.1 先从小任务和代码评审中学习llvm-project 的 GitHub 仓库带有good first issue标签的 issue 很适合入门。但我想推荐一个更有价值的路径去 LLVM 的 Phabricator 或者 GitHub Pull Request 列表里看别人提交的 patch尤其是那些被社区老手大量评论的 patch。Code Review 评论里往往藏着项目长期积累的编码规范、设计哲学和容易踩坑的点比读十篇博客都有用。如果你找到一个小 bug 想修我的建议是先只做最小修复不改变行为不引入大规模重构。LLVM 社区对 review 非常严格一次 PR 改动太多会让 reviewer 失去耐心。等你熟悉了他们的讨论节奏再逐步扩大改动范围。6.2 代码规范与格式工具的使用LLVM 的代码风格与一般 C 项目有明显差异不使用异常exceptions大量使用LLVM_开头的宏命名习惯偏向下划线风格类名用 PascalCase函数名用 camelCase变量名用 snake_case。好在社区提供了自动格式化工具clang-format和代码检查工具clang-tidy提交前跑一遍能省掉 reviewer 一半的抱怨。具体操作上在 llvm-project 根目录运行git clang-format可以只格式化你改动的代码段而不是整个文件。clang-tidy则能检查一些常见的逻辑问题比如未使用的变量、可疑的指针操作等。养成提交前自查习惯比反复被打回重改要体面得多。6.3 如何构建针对性的测试验证LLVM 的测试体系有两套。一套是lit测试用来测命令行工具的输入输出行为另一套是llvm-lit驱动的单元测试用来测库函数与 Pass 逻辑。对于 Pass 类改动我在实践中最常用的是直接在llvm/test/Transforms/下添加一个.ll测试文件里面写好输入 IR 和期望输出然后通过更新CHECK行来验证。测试文件里大量使用RUN:指令来指定测试命令这个格式是 LLVM 特有的花点时间掌握之后写起来非常顺手。跑测试的命令很简单ninja check-llvm ninja check-clang在提交 PR 之前至少保证你改动的模块对应的check-llvm或check-clang是通过的。社区 CI 会在 PR 里自动跑全量测试如果某个测试挂了reviewer 一般会给点提示但你自己提前跑过一遍肯定能省很多来回沟通的时间。6.4 保持耐心从 reviewer 的反馈里迭代最后分享一个心态层面的经验。我第一次提交 LLVM patch 的时候被 reviewer 连续问了十几个问题从代码格式到 Pass 边界情况的处理来来回回改了几轮。说实话过程挺磨人的但也是收获最大的时候。LLVM 社区的资深开发者对编译原理的理解深度远超普通项目他们的每一个提问几乎都指向真实编译场景中的某个边界情况不像某些项目 review 只是在走过场。后来我逐渐明白了核心竞争力不是我一次性写对了而是我能在 review 过程中快速吸收意见、找到问题根源、修好并重新提交。LLVM 是一个极其庞大的项目但正因为庞大它才愿意给认真做事的贡献者足够的成长空间。跨过最开始那道坎之后你触碰到的就不仅仅是编译器而是所有现代编程语言和芯片之间那一层精密运转的底层架构。
返回列表