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

资讯详情

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

llvm-project 从源码构建到自定义 Pass 开发实战指南

llvm-project 从源码构建到自定义 Pass 开发实战指南 llvm-project 这个名字对很多做底层开发的人来说是又爱又恨。爱的是它把整个 LLVM 生态收进了一个 GitHub 仓库里编译器、调试器、标准库、优化器、MLIR 全都有了恨的是第一次git clone就看到几个 G 的体积进去一看几十个子目录根本不知道从哪下手。我当年就是被这个仓库劝退过两次的人。后来因为在公司要做一个基于 LLVM 的代码插桩工具硬着头皮啃了一个多月才摸清楚这里面的门道。这篇文章就围绕 llvm-project 这个仓库把我从构建、开发到写自定义优化 Pass 的整套经验梳理一遍希望能帮你少走点弯路。如果你正好对编译器、工具链、代码优化感兴趣或者想基于 LLVM 做点什么又不想在cmake和头文件里迷路那这篇内容会比较适合你。我会先讲这个仓库的架构全景再讲怎么从源码构建然后带手写一个能跑的自定义 Pass最后把我在实际开发中踩过的坑和排查思路整理成一份速查清单。1. 全景图llvm-project 里到底装了什么1.1 一眼分清 LLVM 和 llvm-project这两个不是一回事先说最容易被绕晕的概念。LLVM 这个名称最早是 Low Level Virtual Machine 的缩写但发展到现在它已经不是一台虚拟机了而是一整套编译器基础设施的统称。llvm-project 则是 GitHub 上那个统一托管的代码仓库把 LLVM 本体和它周边的生态项目全部放在了一起。在 2019 年之前LLVM、Clang、libc、LLD 这些项目是各自独立维护的版本发布经常对不齐。后来项目组下决心做了一次 monorepo 迁移把所有子项目统一到 llvm-project 这个仓库里版本号也统一推进每年发布一个大版本例如 18.x、19.x。所以你可以这样理解llvm-project 是那个全家桶集装箱LLVM core 是集装箱里最核心的库负责表示中间代码、做优化、生成目标机器码。而平时我们挂在嘴边的 Clang、LLD只是这个集装箱里的其他独立组件。弄清楚这一层关系后面看目录和文档就不会犯晕了。1.2 拆开仓库看子项目Clang、LLD、libc 各管哪一块仓库根目录下会看到一堆顶层目录每个目录基本对应一个独立的子项目。我挑几个最常打交道的说目录作用我的使用场景llvm/核心库与工具IR、优化器、后端、opt、llc写 Pass、做后端移植clang/C/C/Objective-C 编译器前端日常编译 C/C、做静态分析clang-tools-extra/clang-tidy、clangd 等工具代码检查、补全、格式化lld/官方链接器支持 ELF/Mach-O/COFF替代系统 ld提速链接libcxx/libcxxabi/C 标准库实现及其 ABI 层定制标准库、交叉编译compiler-rt/运行时库包括 Sanitizer查内存错误、做覆盖率lldb/调试器调试程序、搞调试插件mlir/多层级中间表示框架新硬件加速器编译、DSL 编译polly/循环优化与多面体模型高性能计算场景flang/Fortran 前端科学计算场景openmp/OpenMP 运行时与优化并行计算别被这么多目录吓到平时用到的其实就前面几个。如果你只是想研究编译原理llvm/和clang/是主力如果你要做 C 工具链libcxx和lld也绕不开如果你对 AI 芯片和 DSL 编译感兴趣那重点看mlir/。1.3 三段式架构为什么一套基础设施能支持几十种语言LLVM 能够做到一套后端支持多种语言、多种芯片靠的是经典的三段式编译器架构。传统编译器前端把源代码变成抽象语法树然后生成中间表示优化器对中间表示做各种变换最后后端把中间表示翻译成机器指令。LLVM 的聪明之处在于它把中间表示IR做成了标准化、文档完善的公共语言。前端只需要把代码翻译成 LLVM IR后面优化和代码生成就全部复用同一套设施。用生活化的类比来说LLVM IR 就像国际贸易里的标准集装箱。各个国家的工厂前端把货物装进统一规格的集装箱港口和物流系统优化器 后端不需要关心货物是什么只要按标准流程搬运就行。所以一个新的语言只需要写一个能生成 LLVM IR 的前端一个新的芯片只需要写一个能把 LLVM IR 翻译成该芯片指令的后端就能互相组合。clang -S -emit-llvm看一下 C 代码变成 IR 的样子是最直观的入门方式。比如这段代码int add_zero(int a) { return a 0; }对应生成的 IR 大致长这样define i32 add_zero(i32 %a) { entry: %add add i32 0, %a ret i32 %add }一开始看到%、i32这些符号不要慌IR 本质就是一个强类型的静态单赋值SSA形式理解它的核心概念之后后面读 Pass 代码会轻松很多。2. 环境准备把 llvm-project 从源码编译出来2.1 硬件底线和工具链要求省下半小时看这里如果只是拿别人编好的二进制用apt install或者从 GitHub Releases 下载安装包就够了。但我们要做开发就必须自己编一遍。编译 llvm-project 是个体力活硬件配置直接决定了你的心态。我自己的经验是磁盘剩余空间至少准备 60GB全量构建加测试数据轻松突破 80GB。内存建议 16GB 起步。链接 Clang 和 LLD 这些大目标时单条链接命令就可能吃掉 4~6GB 内存并行链接多个目标时更容易爆。CPU 核心数越多越好但并不是只要-j$(nproc)就万事大吉后面会细说。工具链方面CMake 3.20 以上、Ninja、支持 C17 的编译器GCC 7.1 或者 Clang 5、Python 3.6 以上是基本盘。注意这里有个容易被忽略的点高版本的 LLVM 对系统编译器的版本有要求比如 LLVM 19 需要 GCC 7.5 以上否则会报一些莫名其妙的词法或模板错误其实根因就是编译器版本太老。2.2 CMake 配置这些开关直接影响你的编译体验我见过很多第一次编译的人直接在 llvm-project 根目录执行mkdir build cd build cmake ..然后编了四个小时最后还因为磁盘不足失败。问题出在根目录的 CMakeLists 只是整个项目的外壳真正的 LLVM 构建入口在llvm/目录里。标准的配置命令是这样cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_INSTALL_PREFIX/opt/llvm/19 \ -DLLVM_ENABLE_PROJECTSclang;clang-tools-extra;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON \ ../llvm逐个解释一下为什么这么配-G NinjaNinja 的增量构建和并行调度比 make 强而且输出可读性好。LLVM 官方也推荐 Ninja。-DCMAKE_BUILD_TYPERelease编译出来的工具链跑得快适合日常使用和开发调试。Debug版本调试信息全但性能很差一般只在排查 Pass 崩溃时用。-DLLVM_ENABLE_PROJECTSclang;clang-tools-extra;lld这个变量决定了要构建哪些子项目。注意新版 LLVM 把 libcxx、compiler-rt 这样的运行时组件挪到了LLVM_ENABLE_RUNTIMES里不要全都往PROJECTS里塞。-DLLVM_TARGETS_TO_BUILDX86;AArch64这个开关对构建时间影响巨大。默认会编所有后端每个后端都是一大坨代码。如果只是为了在 x86 上开发只留X86能省掉三分之一到一半的编译时间。-DLLVM_ENABLE_ASSERTIONSON开发调试时强烈建议打开。它会在 IR、Pass 管理器、后端里嵌入大量断言很多 Bug 会在运行时直接暴露而不是变成难以追踪的错误结果。生产发布时可以关掉。配置完成后编译命令很简单ninja -j$(nproc)不过这里也有讲究。如果你内存 16GB-j$(nproc)在链接阶段很容易 OOM。我会用一个折中的办法ninja -j8 # 编译阶段用 8 并发 ninja -j2 # 如果已经进入链接阶段降低并发给链接器留内存一个更省事的方案是让 CMake 直接指定并行度或者在构建时打开 ccachecmake -DCMAKE_CXX_COMPILER_LAUNCHERccache -DCMAKE_C_COMPILER_LAUNCHERccache ../llvmccache 对首次全量构建没有帮助但如果你以后反复切换分支、改代码、重新配置命中缓存后增量编译时间可以从十几分钟降到一两分钟属于早配早享受的选项。2.3 首次构建和自举验证从源码到可用的 clang编译完成以后先别急着写代码做三个快速验证确认这套工具链是能用的# 1. 检查版本 build/bin/clang --version # 2. 用刚编出来的 clang 编一个 hello world echo int main() { return 0; } /tmp/test.c build/bin/clang /tmp/test.c -o /tmp/test # 3. 用自带的 opt 处理一段 IR build/bin/clang -S -emit-llvm /tmp/test.c -o /tmp/test.ll build/bin/opt -passesinstcombine /tmp/test.ll -S -o /tmp/test.opt.ll第二步尤其重要它验证了 clang、系统头文件路径、链接器等整条链路是否正常。如果test.c编译失败先不要怀疑 llvm-project 本身优先检查是不是缺少系统依赖常见的如 zlib。我们可以用下面的方式确认ldd build/bin/clang | grep not found如果哪条动态库显示not found补装对应依赖就行。实测下来16 核 32G 内存的机器只保留 X86 后端、开 Release、带 clang 和 lld首次构建大概在 40 到 60 分钟之间。如果选全目标全项目时间直接奔 3 小时以上去。所以这里必须再次强调按需裁剪别做全量怪。3. 第一个动手实验写一个自定义 Pass 插件3.1 搞清楚 Pass 的本质优化器里的一次手术有了一个能命中的 clang 之后就可以进入最有趣的环节了写自定义优化 Pass。Pass 是 LLVM 优化器里的一次独立手术。它拿到一个函数或者一个模块的 IR可以做分析、做变换然后返回结果。整个-O2其实就是几十上百个 Pass 按顺序执行的过程。LLVM 早期的 Pass 是legacy风格需要继承ModulePass、FunctionPass等基类。但新版本LLVM 14 之后包括 18/19默认使用的是 New Pass Manager写法也变成了基于PassInfoMixin的轻量结构体。打个比方legacy Pass 像一份冗长的合同必须填很多表格New PM 像一张明信片核心内容写上就行。我们直接按新写法来不必学习旧 API除非你要维护很老的项目。3.2 工程搭建用 CMake 组织一个 Pass 插件项目写 Pass 一般有两种方式一种是直接把源码塞进 llvm-project 的llvm/lib/Transforms/目录里和 LLVM 一起编译另一种是单独建一个工程编译成.so插件用opt动态加载。前者适合正式开发后者适合实验和快速验证。推荐先用插件方式不用每次改代码都重编整个编译器的核心库。单独建目录准备两个文件一个是源码一个是 CMakeLists。CMakeLists.txtcmake_minimum_required(VERSION 3.20) project(SimplifyAddZeroPass) 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}) add_library(SimplifyAddZeroPass MODULE SimplifyAddZero.cpp) target_link_libraries(SimplifyAddZeroPass PRIVATE LLVM)这里的关键点是find_package(LLVM REQUIRED CONFIG)它会去找LLVMConfig.cmake。CMake 是怎么找到它的呢通过LLVM_DIR环境变量或者把build/lib/cmake/llvm加到CMAKE_PREFIX_PATH。一个可行的方法cmake -DLLVM_DIR/path/to/llvm-project/build/lib/cmake/llvm ..如果你直接用之前构建 llvm-project 时的 build 目录里面的build/lib/cmake/llvm就是这个文件所在位置。3.3 从打印到变换实现一个 x0 - x 的简化我们的目标很明确实现一个简化规则把add i32 0, %x变成%x也就是常说的消除加零。先把最基础的代码写出来#include llvm/IR/Function.h #include llvm/IR/InstrTypes.h #include llvm/IR/Instructions.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 SimplifyAddZeroPass : public PassInfoMixinSimplifyAddZeroPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { bool Changed false; SmallVectorInstruction *, 8 DeadInsts; for (Instruction I : instructions(F)) { auto *BO dyn_castBinaryOperator(I); if (!BO || BO-getOpcode() ! Instruction::Add) { continue; } Value *LHS BO-getOperand(0); Value *RHS BO-getOperand(1); Value *Other nullptr; if (auto *C dyn_castConstantInt(LHS)) { if (C-isZero()) { Other RHS; } } else if (auto *C dyn_castConstantInt(RHS)) { if (C-isZero()) { Other LHS; } } if (Other) { BO-replaceAllUsesWith(Other); DeadInsts.push_back(BO); Changed true; } } for (Instruction *I : DeadInsts) { I-eraseFromParent(); } return Changed ? PreservedAnalyses::none() : PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, SimplifyAddZeroPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name simplify-add-zero) { FPM.addPass(SimplifyAddZeroPass()); return true; } return false; }); }}; }这里有几点要特别提醒instructions(F)返回的是函数内所有指令的迭代序列dyn_castBinaryOperator用来做类型判断和向下转换这是 LLVM 里最常见的类型识别方式。不要在遍历instructions(F)的同时直接删除指令。迭代器会失效程序直接崩。我习惯把要删的指令先存到一个SmallVector里遍历完再统一 erase。这是新手最容易踩的坑。如果一个 Pass 修改了 IR必须返回PreservedAnalyses::none()告诉 PassManager 一切都可能变了下游分析结果要失效重算。如果确实没改就返回PreservedAnalyses::all()。这关系到正确性和性能不能偷懒。编译生成插件cmake -G Ninja -DLLVM_DIR/path/to/llvm-project/build/lib/cmake/llvm .. ninja成功后目录下会生成SimplifyAddZeroPass.so。3.4 用 opt 和 FileCheck 完成测试闭环插件跑起来很简单。先准备一个测试 IRdefine i32 foo(i32 %a) { entry: %1 add i32 0, %a ret i32 %1 }保存成test.ll执行/path/to/llvm-project/build/bin/opt \ -load-pass-plugin./SimplifyAddZeroPass.so \ -passessimplify-add-zero \ -S test.ll-load-pass-plugin加载插件-passessimplify-add-zero指定跑我们注册的 Pass这里的名字和代码里if (Name simplify-add-zero)那个字符串必须一致。-S表示以文本 IR 形式输出结果。如果不出意外输出会变成define i32 foo(i32 %a) { entry: ret i32 %a }加零运算被消除这就是一个最简优化 Pass 的效果。项目正式一点的话建议引入 FileCheck 做回归测试。思路是在测试注释里直接写上期望输出的模式然后让opt和FileCheck配合比对结果。比如; RUN: opt -load-pass-plugin./SimplifyAddZeroPass.so -passessimplify-add-zero -S %s | FileCheck %s ; CHECK-LABEL: foo ; CHECK-NOT: add ; CHECK: ret i32 %a define i32 foo(i32 %a) { entry: %1 add i32 0, %a ret i32 %1 }CHECK-LABEL用于定位函数CHECK-NOT用于确保没有add指令出现。以后每次改动 Pass只要跑一下测试就知道有没有回归。这个习惯能省下大把手工验证的时间。4. 踩坑记录给后来者的一份速查手册4.1 构建期报错内存、磁盘、链接器三板斧llvm-project 构建期的问题绝大多数逃不过内存、磁盘、链接器这三类。我整理了一张速查表症状常见原因处理办法编译过程中进程被杀 / OOM并行度太高链接阶段内存不足降低-j参数或给链接器指定更大的--mld配置磁盘空间不足build 目录积累太多中间文件删掉 build 重来或者ninja clean确认LLVM_ENABLE_ASSERTIONS和测试选项是否开太多链接时间极长用的是系统默认的 GNU ld指定使用 lld 或者 gold-DLLVM_USE_LINKERlld找不到zlib.h缺少系统依赖安装 zlib 开发包或加-DLLVM_ENABLE_ZLIBOFF不建议长期去掉奇怪的模板编译错误系统 compiler 版本过老升级 GCC/Clang 到满足该 LLVM 版本要求的版本说到链接器值得多讲一句。LLVM 自己维护了 lld就是为了解决链接大型 C 项目时的速度和内存问题。编译 llvm-project 时强烈建议在 CMake 命令里加-DLLVM_USE_LINKERlld。如果用的是旧版本 LLVM 或者系统库比较特殊-DLLVM_USE_LINKERgold也能明显改善但优先 lld。4.2 API 版本变动为什么网上代码经常编译不过网上搜到的 LLVM 教程代码经常编译不过十有八九是版本差异造成的。LLVM 每年一个大版本Pass 的接口、PassBuilder 的回调签名、CMake 的变量名都在变。比如registerPipelineParsingCallback的回调参数不同版本对PipelineElement的支持就不一样导致你从 LLVM 16 的教程复制到 LLVM 19 上直接编译报错。应对办法没有捷径核心就是以头文件为准。遇到编译错误先查/path/to/llvm-project/llvm/include/llvm/Passes/PassBuilder.h之类的头文件看当前的接口签名长什么样。其次是尽量用官方文档和当前版本 release note少依赖非官方博客的过时示例。如果想看历史 API 变迁GitHub 上比较各个 tag 的同一文件也是一种有效的学习方式。另一个常见的兼容性问题是opt和你的.so版本不一致。插件编译时用的 LLVM 版本必须和运行用的opt完全同源连小版本号差异都可能导致加载失败。解决办法就是find_package时指向你自己构建出来的 build 目录而不是系统安装的另一个版本。4.3 调试 IR 和 Pass 的三个实用开关调试 Pass 时我最常用的三个开关能省掉大量脑力劳动第一个是-print-after-all。它会在每个 Pass 执行完以后打印整个模块的 IR。想快速确认某个 Pass 有没有按预期改变 IR用它最直观。不过它的输出量也很大适合在小型测试用例上使用。第二个是-debug-onlypass-name或者-debug。前者按 Pass 名称过滤调试信息会打印出 instcombine、gvn 等 Pass 内部记录的信息。要启用这些调试输出构建时必须带LLVM_ENABLE_ASSERTIONSON否则DEBUG宏可能不生效。这也是我一开始坚持建议开这个开关的原因。第三个是-analyze。某些 Pass 本身就输出分析结果比如-passesprintloops会打印循环结构-passesprintdomtree会打印支配树。想理解后端决策、优化顺序、依赖关系的时候这种分析输出比瞎猜强得多。调试过程中还有一个个人经验先在最小 IR 上复现问题再逐步加指令。LLVM 的优化和错误机制受上下文影响很大但小型化测试用例是定位问题的最可靠手段。一次只改变一个变量比同时开三个调试开关来得有效。5. 学习路线与实战建议5.1 按目标倒推四种入门方向怎么选llvm-project 体量太大不同目标对应完全不同的切入路径。如果你的目标是搞懂编译器在做什么可以从 clang 前端入手用clang -S -emit-llvm反复观察代码到 IR 的映射配合opt -passesdefaultO2看优化前后差异。这个方向不需要写代码更多是阅读 IR 和命令行实验。如果你的目标是给 LLVM 贡献代码建议先修小的测试用例、文档、简单 bug。把仓库的lld/test、clang/test目录里的测试组织方式读懂再动手改代码会顺利很多。提交 PR 前务必跑ninja check-llvm或者对应的子项目测试。如果你的目标是基于 LLVM 做自定义工具比如插桩、混淆、性能分析那就从自定义 Pass 开始也就是我们前面做的实验。熟练掌握 New Pass Manager 的写法之后再研究如何接入 clang 的 pipeline或者作为独立工具链的一部分运行。如果你的目标是研究新硬件后端那重点会落在 TableGen 描述、指令选择、寄存器分配、llc 的流程上。这部分难度高建议先把llvm/lib/Target/里某个精简后端比如X86的某一段读熟再动手。5.2 给 LLVM 社区提交补丁的实用提醒如果你准备向 LLVM 社区提交代码有几件事提前了解能少走弯路代码风格要过clang-formatLLVM 有一套自己的格式配置在仓库根目录有.clang-format。提交之前跑一遍git clang-format是基本礼貌。每个新文件都要带版权和许可证信息。LLVM 采用 Apache License 2.0带 LLVM 例外没有许可证声明的文件是不可能被合入的。测试比代码更重要。任何行为改动都需要配套测试用例因为 LLVM 的 CI 非常严格代码覆盖率检查也会卡人。建议从小改动开始先在llvm-test-suite和对应子项目的 mailing list或 GitHub 上的 issue里混个脸熟再提交复杂补丁。直接上来就甩一个大 patchreviewer 很难有精力给你详细反馈。结尾最后说一点个人体会。llvm-project 这种体量的项目跟以前在学校里写的课程设计完全不同。它的特点是知识密度极高但模块边界很清楚。你不需要也不应该从头读到尾带着一个具体问题进去沿着 IR - Pass - 工具链这条线往深处走是最快进入状态的方式。我自己从被这个仓库的体积吓到到能写 Pass、改后端、给社区提 patch最大的感悟是不要把它当一门学问来学而是把它当一个工具箱来用。每次遇到具体需求从已有的代码里找到最近的参照再动手改效率远高于枯燥地读源码。我现在写代码之前还会习惯性地先翻一翻llvm/include/llvm下的头文件很多设计意图其实都写在接口命名和注释里。如果你也准备开始折腾 llvm-project希望这篇文章的构建配置、Pass 示例和踩坑清单能帮你少走点弯路。后面如果你有更细的问题比如如何移植一个后端、如何接入 MLIR、如何调试 Sanitizer我们再逐个展开聊。
返回列表