ARTICLE DETAIL

资讯详情

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

llvm-project入门:从源码构建到编写第一个LLVM Pass插件

llvm-project入门:从源码构建到编写第一个LLVM Pass插件 点进 llvm-project 这个仓库的人十个里有九个是冲着编译器或者工具链来的。我自己是从“想给 clang 加一个自定义编译警告”开始的结果第一次 clone 就吃了大亏。那台机器有 32GB 内存我心想配置够宽裕了于是开开心心用默认参数加 Ninja 全量编译跑到链接阶段内存直接被拉满整台机器卡死最后只能强制重启。后来每次有人问我 llvm-project 应该怎么入门我都会先让他想清楚四件事版本、磁盘、内存、构建系统。这篇文章就是我交过的学费换来的经验覆盖从拉取仓库、读懂目录、完成构建到写出第一个能被 opt 加载的 IR Pass 的全过程。想玩转编译器底层、做自定义优化或静态分析的人应该都能从中带走点能立刻落地的东西。1. 动手之前先把版本、磁盘、内存和构建系统这四个变量定下来很多人 clone llvm-project 之后的第一反应是“先 build 一下看看”玩别的项目也许可以这样玩 LLVM 不行。这个仓库的体量决定了一旦前置决策做错代价不是等几分钟而是等一两个小时之后收获一个 OOM 或者磁盘满。我建议把下面四个问题先想明白再动命令。1.1 为什么我坚持用 release tag而不是 main 分支llvm-project 的 main 分支是一个 7x24 小时都在滚动的开发分支。它今天的 API 大概率和你上个月在网上抄的示例代码对不上等你编译完一个 pass 插件可能又变了一轮。对普通学习和二次开发来说main 分支的“最新”没有任何意义只会带来不稳定。我的习惯是先看 release taggit clone --depth 1 -b llvmorg-18.1.8 https://github.com/llvm/llvm-project.git如果你不确定当前有哪些稳定版本可以先完整 clone 或者用 GitHub 的 tags 页面看一眼再挑一个最新的 release tag。用--depth 1只拉一个 tag 的快照能省掉大量历史提交数据这个仓库完整 clone 下来有几 GB 的 .git 数据浅克隆会舒服很多。有人会担心浅克隆之后没法切分支。对于固定版本研究来说完全够用真想切换到别的 tag再单独拉一份也不亏几十 GB 源码磁盘占用和踩 main 分支 API 漂移的坑比起来前者便宜太多了。1.2 磁盘和内存编译前就要按最高水位准备LLVM 构建最反直觉的一点是源码看起来不大但构建产物大得吓人。它不只是一个 C 项目而是几十个工具链组件在同一套构建系统里联动每个二进制都极其臃肿。我按自己的实际构建经验给出一组参考值给准备入坑的人一个心理预期构建范围磁盘占用参考链接阶段内存峰值只编 LLVM 核心 opt8-12GB5-8GB再加 clang lld20-30GB10GB 以上再加 compiler-rt / mlir / flang50GB 起步15GB 以上Debug 模式加断言通常会再翻倍更夸张注意两个关键词最高水位、链接阶段。config 出来的构建目录里链接 clang 那个二进制的时候内存会突然冲高。很多人平时看 CPU 占用没问题一到最后一步就 OOM。所以我一直建议第一次构建之前至少保证 16GB 内存和 40GB 空闲磁盘。如果你的机器是 8GB 内存也不是不能编但要严格控制并行 job 数后面第 5 章我会专门讲怎么避坑。1.3 Ninja 几乎是唯一推荐项LLVM 官方文档里同时支持 Makefile 和 Ninja但我个人强烈建议用 Ninja。原因不复杂Ninja 对增量构建的调度比 Make 聪明改动一个头文件之后它知道哪些编译单元需要重编而 Make 在这个项目规模下经常会出现“不知道该重编谁”的保守行为导致无辜地重新编译大片代码。另外 Ninja 默认会占满所有 CPU 核心这个特性在别的项目上是优点在 LLVM 上是灾难。后面我会提到必须主动限制并发那是因为 Ninja 太“勤快”了。安装 Ninja 之后配置阶段你需要显式告诉 CMake 使用它cmake -G Ninja -S llvm-project/llvm -B build ...如果以后你想清空构建重新来直接删 build 目录即可不需要碰源码。这一步一定要把源码目录和构建目录分开别在源码目录里就地构建否则后续换构建系统或者清缓存都很痛苦。1.4 第一次全量编译是最大的陷阱我看到太多新手第一次就直接开-DLLVM_ENABLE_PROJECTSclang;clang-tools-extra;lld;lldb;compiler-rt;mlir;flang;polly;openmp然后对着屏幕看三小时编译日志。说实话第一次就这么干基本等于给自己挖坑。compiler-rt、mlir、flang 这些项目相互之间还有隐含依赖任何一个环节出问题你都得在一堆组件的报错里翻找真正的原因。第一次老老实实只加clang和lld。这两个组件已经把编译器前端、后端、优化器和链接器串成了一条完整的工具链闭环。后续按需再加其他 project增量构建会快很多排查问题也更容易。2. llvm-project 的目录其实是一张依赖地图这个仓库名字叫 llvm-project很多人以为它就是一个叫 LLVM 的编译器项目。实际上它是把 LLVM、clang、lld、lldb、compiler-rt、mlir 等一整个编译器生态塞进了一个 monorepo。不理解这块目录结构你会经常遇到“我改了代码为什么没编进去”“这个组件到底是干嘛的”之类的困惑。2.1 仓库里的每一个目录都有自己的位置我简单列一份模块地图不需要背但至少知道每个顶层目录大概负责什么目录作用llvm/核心基础设施IR、优化 pass、后端代码生成、目标架构支持clang/C/C/Objective-C 前端把源码变成 AST 和 LLVM IRclang-tools-extra/clang-tidy、clangd 等附加工具lld/链接器lldb/调试器compiler-rt/运行时支持库比如 ASan、UBSan 的运行时libcxx / libcxxabi / libunwind/C 标准库和底层 ABI 支持mlir/多层级中间表示框架flang/Fortran 前端polly/多面体循环优化openmp/OpenMP 运行时bolt/面向二进制的优化布局工具我遇到过一个很典型的误解有人想在 LLVM 上加“编译错误提示”直接去 llvm/ 目录里找。这其实是 clang 的职责范围前端负责诊断LLVM core 负责优化和代码生成选错目录等于找错了工具。2.2 模块之间的依赖不像想象中那么扁平clang 依赖 LLVM core这是显然的但 lld 也会依赖 LLVM core 里的 object 文件解析和 target 支持compiler-rt 虽然是运行时库但构建它在很多配置下又需要 clangmlir 不仅依赖 LLVM还和 clang 有大量互操作。所以拿到一个编译错误时先看是哪个 project 报的错再顺着依赖关系去定位。一次典型的情况是你在LLVM_ENABLE_PROJECTS里同时开了mlir和clang然后发现编译 mlir 时它去 clang 里找某个头文件如果 clang 组件的构建顺序不对就会报一些看起来毫不相关的“头文件找不到”。这时候恐惧地重装完全没用先理解依赖关系才是正解。2.3 想给自己的代码找位置先回答一个问题每次有人问我“我想给 LLVM 加一个功能代码应该放哪”我一般会反问你要改的是什么层面的东西如果你想写一个 IR 优化 pass那放llvm/lib/Transforms/下的某个子目录如果你想加一个数据流分析框架放llvm/lib/Analysis/如果你想给 clang 增加一个编译告警去clang/lib/Sema/或者clang-tools-extra/clang-tidy/如果你想新支持一种处理器架构那就去llvm/lib/Target/下建一个新目录。“放对位置”不是目录洁癖而是因为 LLVM 的 CMake 结构是按目录组织的。每个子目录有自己的CMakeLists.txt你新增了一个.cpp文件但是没修改对应的CMakeLists.txt构建系统根本不会编译它。这是初学者最容易踩的坑比语法错误隐蔽得多。3. 我反复验证过的一套构建配置以及怎么确认产物可用下面这套配置是我在各种机器上重复过很多次的组合适合从零开始拿下一个能写代码、能编译 C、能加载自定义 pass 的开发环境。它不追求编译出地球上所有子项目但足以覆盖绝大多数学习场景。3.1 一条可复制的 cmake 配置cmake -S llvm-project/llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_PROJECTSclang;lld重点解释几个参数-DCMAKE_BUILD_TYPEReleaseRelease 模式编译出来的工具性能好速度也快。如果你是要调试 LLVM 本身或者跟踪某个 pass 的运行细节可以改用 Debug但体积和编译时间都会增加。-DLLVM_ENABLE_ASSERTIONSON这个选项在 Release 模式下依然开启断言。LLVM 内部有大量assert在检查 IR 合法性关掉它们能提升运行性能但排查问题时会失去很多有用信息。我的经验是除非你要做性能压测否则把断言打开。-DLLVM_TARGETS_TO_BUILDX86只生成 X86 后端的代码。默认情况下 LLVM 会为所有支持的目标架构生成后端代码那会让编译时间显著变长。如果你只是学习只保留 X86 就够。如果以后做交叉编译再按目标架构加。-DLLVM_ENABLE_PROJECTSclang;lld只把 clang 和 lld 纳入这次构建。LLVM core 不需要放在这个变量里它本来就是主工程。如果你的机器装了 ccache建议再加上-DLLVM_CCACHE_BUILDON。LLVM 的 C 代码量非常大反复 clean build 的滋味谁试谁知道。ccache 对增量构建的加速非常可观可能把二次构建时间缩短到原来的三分之一。3.2 从最小构建到全量构建的分阶段策略配置完之后不要直接一个ninja把所有 target 全部编完因为 LLVM 有太多可执行文件。第一个阶段我建议只拿最关键的三个ninja -C build opt clang lldopt是 LLVM 优化器写 pass 插件、跑 IR 变换实验都靠它clang是前端用来把 C/C 源码编成 IR 或目标文件lld是链接器。这三个工具已经足够组成一个可玩性很高的开发环境。如果第一阶段顺利再考虑跑全量cmake --build build -- -j4这里的关键是-j4。Ninja 默认会使用所有逻辑核心但 LLVM 的链接阶段对内存极其敏感。宁可编译时间多等一会儿也不要让链接 job 同时开十几个。内存小的机器甚至可以先用-j2把全量构建跑通再决定要不要放开并行。3.3 验证产物不要只盯着“没报错”构建结束后我一般会做三件事验证产物确实可用第一检查版本信息build/bin/clang --version build/bin/opt --version第二写一个最简单的 C 文件编译并运行cat EOF /tmp/hello.c #include stdio.h int main() { printf(ok\n); return 0; } EOF build/bin/clang /tmp/hello.c -o /tmp/hello /tmp/hello第三用 clang 把源码变成 LLVM IR再用 opt 处理build/bin/clang -S -emit-llvm /tmp/hello.c -o /tmp/hello.ll build/bin/opt -S -passesdefaultO2 /tmp/hello.ll -o /tmp/hello.o2.ll这三步走完才说明你的工具链是从头到尾通的。很多人只看到clang --version能输出就觉得很稳结果真到了opt加载插件阶段才发现 IR 版本不一致或者其他隐藏问题。4. 第一次动手改 llvm-project写一个会被 opt 加载的 Pass 插件到了这一步才算是真正“动手”了。我会展示一个最精简的 LLVM 新 Pass Manager 插件它不修改任何 IR只是遍历每个函数并打印函数名。跑通它你就算迈过了 LLVM 二次开发最基础的一道门槛。4.1 在源码里加 Pass 和做独立插件的区别在 llvm-project 里加自定义 pass 有两种主流路线。第一种是直接往源码树里加文件改llvm/lib/Transforms/下面的某个子目录以及相应的CMakeLists.txt重新编译整个 LLVM。这种路线的好处是 pass 和工具链完全捆绑适合正式集成坏处是每次改代码都要重新编译 LLVM调试迭代很慢。第二种是写一个独立的 pass 插件编译成.so在运行时用opt -load-pass-plugin加载。它不污染原始源码树编译一个小的插件只需几秒非常适合学习阶段反复试探。我下面的例子采用第二种路线。4.2 一个 New PM 插件的最小实现新建一个目录比如hello-pass/里面放两个文件。HelloPass.cpp#include llvm/ADT/StringRef.h #include llvm/IR/Function.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: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace extern C LLVM_ATTRIBUTE_WEAK ::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) { FPM.addPass(HelloPass()); return true; } return false; }); }}; }代码不多但每一块都有意义。run是 New Pass Manager 的入口FunctionAnalysisManager AM参数是后半程做更复杂分析的基础。返回值用PreservedAnalyses::all()意思是这个 pass 没有改动任何 IR所以分析结果全部保留。如果你的 pass 真的修改了 IR这里就不能直接 all否则后续 pass 可能基于过期的分析结果做出错误决策。llvmGetPassPluginInfo是插件被加载时最先调用的入口它告诉 opt 这个插件叫什么、版本是多少、如何解析管道中的 pass 名字。这里的hello就是之后在命令行里用的 pass 名。4.3 用 cmake 编译插件并让 opt 加载在同一个目录下放一个CMakeLists.txtcmake_minimum_required(VERSION 3.20) project(HelloPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) message(STATUS Using LLVMConfig.cmake in: ${LLVM_DIR}) include_directories(${LLVM_INCLUDE_DIRS}) separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS}) add_definitions(${LLVM_DEFINITIONS_LIST}) if(NOT LLVM_ENABLE_RTTI) set(CMAKE_CXX_FLAGS ${CMAKE_CXX_FLAGS} -fno-rtti) endif() add_library(HelloPass MODULE HelloPass.cpp) set_target_properties(HelloPass PROPERTIES PREFIX )然后配置并构建cmake -S . -B build -DLLVM_DIR/path/to/llvm-project/build/lib/cmake/llvm cmake --build build这里最关键的是LLVM_DIR要指到你构建 LLVM 时生成的LLVMConfig.cmake所在目录。它必须和你刚才构建的 opt 来自同一个构建目录否则后面加载插件大概率会报版本不匹配。构建完会得到一个libHelloPass.so。准备一个测试用的 IR 文件build/bin/clang -S -emit-llvm /tmp/hello.c -o /tmp/hello.ll build/bin/opt -load-pass-plugin./build/libHelloPass.so -passeshello -disable-output /tmp/hello.ll如果一切正常你会看到每个函数名都被打印出来。-disable-output避免把处理后的 bitcode 写进文件因为我们这个 pass 没有“处理”任何内容只是观察。4.4 插件加载失败的常见原因加载插件时最常见的报错是版本不匹配插件是用 LLVM 17 的头文件编译的但你的 opt 是 LLVM 18 构建出来的。LLVM 内部 ABI 变化很快跨版本加载基本都会挂。解决办法是永远用和 opt 相同的源码树、相同的构建目录来编译插件。另一个常见问题是 RTTI 不匹配。LLVM 默认关闭 RTTI插件如果开了 RTTI运行时会因为 typeid 相关逻辑崩掉。所以我上面在CMakeLists.txt里做了判断当LLVM_ENABLE_RTTI没开时给插件编译参数加-fno-rtti。还有一点插件里的 pipe 名字找不到时opt 会提示无法解析-passeshello。这种时候先确认registerPipelineParsingCallback里判断的名字和命令行一致以及HelloPass已经被FPM.addPass加进去。5. 构建与开发 llvm-project 时最容易翻车的五个场景最后这部分是我在多次重建 llvm-project 后总结的避坑清单。它不能代替你踩坑但能帮你少走几个大弯路。5.1 链接期 OOM不是 CPU 不够是内存不够现象编译过程 CPU 占用很高到某个二进制链接时突然卡住然后系统开始疯狂 swap或者直接被 OOM killer 杀掉。原因LLVM 的 clang 二进制是在链接阶段一次性把所有对象文件链接起来的单个链接进程可能吃掉 10GB 以上内存。如果你用默认 Ninja它会同时开十几个链接 job内存直接爆炸。解决办法有三个层次全局限制并行度比如ninja -j4。单独限制链接 job配置时加-DLLVM_PARALLEL_LINK_JOBS2。用它自己的 lld 作为 host linker配置时加-DLLVM_USE_LINKERlld。前提是你的系统里已经有一个可用的 lld否则需要先装一个或者先编一个 lld。lld的内存占用比 GNU ld 低很多对 LLVM 这种巨型 C 项目特别友好。5.2 磁盘被 build 目录吃掉之后构建目录增长速度远超想象我自己有一次在 home 分区上直接 build结果跑到一半提示磁盘满了。当时构建目录已经超过 40GB而 home 分区总共 50GB。从那之后我固定规则LLVM 一定放到空间最大的分区build 目录和源码目录分开放。如果构建中途磁盘满了不要尝试“清一部分”继续直接把 build 目录删掉检查磁盘空间换一个更精简的配置重新开始。LLVM 的 build 目录不像其他项目那样支持“随便删几个文件抢救”它的 CMake 缓存和各种中间产物盘根错节手动清理往往不靠谱。5.3 main 分支的 API 漂移在 main 分支上你今天写的 pass 插件可能下个月就编译不过原因往往是某个 API 改了签名或者头文件路径变了。这个问题在 release tag 上基本不存在。所以我再次强调固定版本。选llvmorg-18.1.8或者当时最新的稳定 tag然后在文档和代码注释里都记下这个版本号。网上搜到的大部分教程也会写清楚基于哪个版本对照起来容易得多。5.4 改了源码但“没生效”的假象开发 LLVM 时最烦的一件事就是我明明在HelloPass.cpp里加了打印重新运行 opt结果还是旧行为。常见原因有三个第一新增了.cpp文件但没有改CMakeLists.txt构建系统根本没把你新文件编进去。第二改的是头文件但某些 CMake 配置不是全局的需要重新触发配置阶段。第三你运行的是build/bin/opt但插件是从另一个 build 目录编译的两个产物彼此不同步。排查思路是从构建日志入手。在 build 目录里执行ninja -t clean然后重新编译如果日志里根本没有出现你改过的文件那就不是“没生效”而是“没参与构建”。这个区分很重要能省下大量排查时间。5.5 调试 IR 时最顺手的三板斧跑 pass 的时候想看中间状态先用最直观的三招不要一开始就上 gdb。第一招是在run里直接打印 IRerrs() F \n;第二招是用 opt 自带的打印参数opt -S -passeshello -print-after-all /tmp/hello.ll -o /dev/null第三招是如果你对 IR 合法性有怀疑用-verify-each开启 pass 前后的 IR 校验。它会在每个 pass 之后检查一遍 IR 合法性很多隐藏的“改坏 IR”问题都是这么抓出来的。至于 gdb通常是定位 segfault 或者断言失败时才需要。启动方式记一个就行gdb --args build/bin/opt -load-pass-plugin./build/libHelloPass.so -passeshello -disable-output /tmp/hello.ll加载插件之后在HelloPass::run里打断点比靠errs()一行行猜要高效得多。最后说一个个人习惯每次拿到新的 LLVM 版本我都会先跑通“clang 编 C 文件 opt 加载官方示例插件”这一条最小链路再开始做自己的改动。这个习惯帮我筛掉了大量环境问题也让后续所有调试都建立在一个可靠的基线上。你如果正准备入坑 llvm-project也可以从这条最小链路开始。
返回列表