ARTICLE DETAIL

资讯详情

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

LLVM 编译器框架入门:llvm-project 构建、IR 分析与自定义 Pass 实战

LLVM 编译器框架入门:llvm-project 构建、IR 分析与自定义 Pass 实战 第一次见到llvm-project这个名字大部分人心里都咯噔一下一个仓库动辄好几个 GB 的源码文档多得像百科全书光是 clone 回来再编一次就要耗掉大半天。但只要你在这个行业里待过一阵就会明白llvm-project几乎定义了现代编译器的天花板。Rust、Swift、Clang、各种 GPU 工具链、底层性能分析工具底层全都是这套东西。这篇文章不聊教科书式的概念我直接以一个普通开发者的视角讲讲llvm-project到底是什么、为什么值得啃、怎么构建、怎么写第一个自定义 Pass以及这一路我踩过的坑。如果你是第一次接触这篇文章也完全能跟得上。llvm-project是 LLVM 社区维护的单一代码仓库里面包括编译器前端 Clang、优化器和后端、链接器 LLD、调试器 LLDB、C 标准库实现 libc/libcabi还有 MLIR、Flang 在内的一整套工具链源码。适合谁看想深入理解编译原理的学生做工具链二次开发的工程师需要给新硬件做编译器后端支持的团队或者只是想在简历上增加一项硬技能的开发者。1. llvm-project 到底是什么为什么值得啃1.1 名字看着唬人拆开就懂了LLVM 最初是 Low Level Virtual Machine 的缩写后来项目范围越扩越大这个缩写本身的含义已经不重要了官方也更愿意直接叫它 LLVM。今天你在 GitHub 上看到的llvm-project是 2019 年各个独立仓库合并后的“全家桶仓库”把核心编译器基础设施、语言前端、工具链、运行时全部放到一个地方管理。这种单体仓库模式带来的好处非常直接。以前你想搭一个 LLVM 相关环境得分别拉 llvm、clang、lld 好几个仓库版本还得对齐经常出现对不上的情况。现在一个 git clone 就能拿到版本完全一致的源码构建脚本、组件之间的依赖关系由社区统一维护省心很多。社区也能方便地跑跨组件回归测试改一个后端处理逻辑前端的所有测试都能跟着跑一遍不容易引入隐藏问题。那它到底解决了什么问题用一个不严谨但很好理解的生活类比喻编译器好比一家翻译公司。前端负责听懂各种“外语”C、C、Rust、Swift中端负责把句子重新组织成逻辑清晰、便于传达的“中间语言”后端负责用目标听众熟悉的“口音”把话说出来x86、ARM、RISC-V。llvm-project最大的价值就是这三层被彻底拆开了。你想支持一门新语言只需要贡献一个前端你想支持一款新芯片只需要写一个后端中间的优化逻辑大家共用不用重复造轮子。1.2 前后端分离最核心的设计理念这里有必要展开讲讲中间层的设计。LLVM IR 是前后端之间的桥梁它有三种存在形式内存中的对象、二进制 bitcode 文件.bc、可读文本.ll。文本形式对学习最友好你随时可以用 clang 把一个 C 函数翻译成 IR 看一眼。比如这个最简单的加法函数int add(int a, int b) { return a b; }执行clang -S -emit-llvm add.c -o add.ll生成的add.ll核心内容大概是define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }这里define i32 add(i32 %a, i32 %b)是定义一个返回值 i32、两个参数都是 i32 的函数函数名叫 add。entry是基本块的名字基本块里的指令按顺序执行。%add add nsw i32 %a, %b是一条加法指令nsw表示 no signed wrap允许优化器对溢出行为做更激进的假设。最后ret返回结果。这个过程里你看不到任何 x86 寄存器、ARM 指令所有优化都在这种平台无关的 IR 上做。前后端分离的优势在工程上特别明显。以我自己给一个自定义指令集写后端的经历来说只需要关注指令选择、寄存器分配、指令调度这几个环节完全不用考虑前端怎么解析 C 的复杂语法。反过来很多想了解编译器的人也发现从 IR 入手比直接看汇编门槛低得多。1.3 全家桶盘点除了 Clang 还有谁llvm-project里的组件非常多我第一次打开目录时也有点懵。我把常用的整理成一张表大家各取所需目录/组件作用适合场景llvm/核心库包含 IR、优化、目标描述、opt/llc 等工具编译器后端、优化器开发clang/C/C/Objective-C 编译器前端日常编译、前端问题分析clang-tools-extra/clangd、clang-tidy 等工具IDE 支持、代码检查和重构lld/链接器加快链接速度、自定义链接逻辑lldb/调试器C/C 调试尤其是远程调试场景libc/libcabi/C 标准库实现新工具链自举、C 工程开发compiler-rt/sanitizer、profile runtime 等运行时插桩、内存检测、覆盖率mlir/多级 IR 基础设施AI 芯片编译器、硬件综合flang/Fortran 前端科学计算语言支持bolt/二进制优化和布局工具大型程序性能调优openmp/OpenMP 运行时并行计算我想提醒一点llvm-project真正难啃的不是某一个组件而是组件之间的依赖关系。比如你要完整验证一条工具链需要 clang、lld、compiler-rt、libc 一起配合但如果你只想学习优化器构建llvm/核心部分就够了。先想清楚自己的目标再决定启用哪些组件能节省大量时间。2. 源码仓库这样看事半功倍2.1 顶层目录怎么读拿到代码先不急着构建花十几分钟看目录结构能帮你少走很多弯路。llvm-project的顶层目录基本都是组件名核心内容在llvm/底下。llvm/目录内部有几个关键子目录llvm/include/llvm/公共头文件IR、CodeGen、Analysis 的定义都在这里。llvm/lib/IR/IR 的核心实现Value、Function、BasicBlock、Instruction这些基础类型都在这里。llvm/lib/Transforms/优化的实现Scalar、Vectorize、IPO等子目录分工明确。llvm/lib/Target/各架构后端X86、ARM、AArch64、RISCV 的指令选择、寄存器分配都在这里。llvm/tools/opt、llc、llvm-as、llvm-dis等命令行工具的入口。llvm/test/测试套件经典模式是.ll文件加RUN:指令。新手最容易犯的错误是一上来就扑到某个具体架构的.td文件里抠指令格式。我建议先把llvm/lib/IR和llvm/lib/Transforms过一遍因为整个优化器的核心抽象都在这一块。等你能看懂基本块怎么组织、指令怎么定义、Pass Manager 怎么调度再去看后端会轻松得多。2.2 Projects 和 Runtimes 的区别如果你看过别人的构建命令会经常遇到LLVM_ENABLE_PROJECTS和LLVM_ENABLE_RUNTIMES两个参数二者区别值得单独说。简单来说LLVM_ENABLE_PROJECTS主要用于那些“需要和 clang 一起构建、通常作为工具链一部分”的组件比如clang、lld、lldb、clang-tools-extra、mlir、flang。LLVM_ENABLE_RUNTIMES则是面向运行时库比如libc、libcabi、libunwind、compiler-rt、openmp。为什么会有这个区分因为运行时库的构建往往需要一套能用的编译器如果在 project 阶段就和 clang 一起构建容易产生鸡生蛋的问题。新版本 LLVM 官方推荐把运行时库统一放到LLVM_ENABLE_RUNTIMES里构建过程会先编译出一个可用的 clang再用它去编译运行时库。如果你想快速把 clang 和 lld 跑起来其实什么都不用额外配默认只构建核心就够了。模块开多了构建时间会指数级上升。一个人学习或做开发验证时我的建议是保持最小集合碰到需要时再启用对应组件别一开始就搞个大满贯配置。3. 从零构建一套可用的 llvm-project3.1 构建前的硬件和依赖准备先说硬性条件。llvm-project是出了名的“体力活”虽然现在用 Ninja 加 lld 已经快很多但一个完整 Release 构建依然很吃资源。我建议的底线是至少 16GB 内存剩余磁盘空间 30GB 以上CPU 核心数越多越好。如果你在 8GB 的小笔记本上跑完整构建大概率会在链接阶段因为内存不足挂掉。这不是代码的问题是资源跟不上。依赖方面以 Ubuntu 这种主流 Linux 发行版为例先保证下面的包都装好sudo apt update sudo apt install -y git cmake ninja-build gcc g python3 zlib1g-dev libxml2-dev如果以后要折腾调试器或其他工具链可能还需要libedit-dev、libncurses-dev、swig等但上面这一套已经足够完成本文的流程了。Windows 上可以用 Visual Studio 的工具链macOS 上可以直接拿系统自带的 Clang 来编译新版本思路一样。版本选择也是很多人会忽略的问题。我非常不建议直接 clone main 分支因为它可能处于不稳定的开发状态新特性随时变社区甚至不保证它能一次构建成功。老老实实选一个 release tag比如llvmorg-17.0.6或llvmorg-18.1.8。这些 tag 都是经过大量回归测试的踩坑概率低很多。3.2 CMake 配置的几个关键参数构建llvm-project全部通过 CMake 配置参数很多但真正重要的就那几个。我习惯的配置如下cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_INSTALL_PREFIX$HOME/llvm-install \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_PARALLEL_LINK_JOBS2逐个解释-G Ninja生成 Ninja 构建系统比 make 快不少还能精细控制并行度。-DCMAKE_BUILD_TYPERelease关闭 debug 符号、开启优化。如果你想断点调试编译器本身可以用RelWithDebInfo但体积和构建时间都会明显增加。-DCMAKE_INSTALL_PREFIX最终安装路径。不设置也行那就在build/bin下面直接运行。-DLLVM_ENABLE_PROJECTSclang;lld决定构建哪些工具组件。注意分号在 shell 里要加引号少了很容易解析出错。-DLLVM_TARGETS_TO_BUILDX86只生成宿主机的 X86 后端构建时间能省下一大半。如果你还要交叉编译 ARM 或 RISC-V再自行添加。-DLLVM_ENABLE_ASSERTIONSON开启断言。开发 Pass 或调试问题时强烈建议打开很多潜在崩溃能提前暴露。-DLLVM_PARALLEL_LINK_JOBS2限制链接并行任务数。这是避免 OOM 的保命参数尤其在 16GB 内存机器上。3.3 构建、验证和第一次 IR 观察配置完成后直接构建cmake --build build -j$(nproc)内存小的话把-j降下来比如-j8或-j4。这一步可能要十几分钟到一小时取决于机器。构建完后先做个快速验证build/bin/clang --version build/bin/llc --version看到版本信息正常输出说明核心工具链已经可用。接下来写个 hello 程序试试#include stdio.h int main() { int s 0; for (int i 0; i 100; i) { s i; } printf(sum%d\n, s); return 0; }build/bin/clang test.c -o test ./test我这里特意选了个带循环和加法的程序因为你可以顺手对比优化效果build/bin/clang -S -emit-llvm test.c -O0 -o test-O0.ll build/bin/clang -S -emit-llvm test.c -O2 -o test-O2.ll打开两个文件对比你会看到 O2 版本里循环很可能被优化成了等差数列求和公式这比任何教科书都直观。到这一步你亲手构建的 LLVM 工具链已经真正跑通了。4. 在 llvm-project 里写第一个 Pass4.1 Pass 是什么为什么值得学说完了构建咱们进入大多数人最感兴趣的部分写 Pass。Pass 是 LLVM 中做分析和变换的模块比如死代码消除、常量传播、循环展开本质上都是一个一个 Pass。理解 Pass 怎么写等于掌握了深入优化器内部的黑魔法。LLVM 有两套 Pass 基础设施旧的 legacy Pass Manager 和新的 new Pass Manager。旧接口仍然能运行但新代码建议直接使用 new PM。从 LLVM 14 开始opt 默认跑在 new PM 上所以本文只讲 new PM 的写法免得你学了过时的东西。Pass 的执行单位有 module、function、loop、basic block 等。我们最常用的是 function pass它遍历每个函数可以读取或修改函数的指令。下面我写一个非常简单的函数级 Pass它不修改任何东西只是把看到的所有函数和指令打印出来。这个例子虽然小但包含了插件注册、pass 回调、结果返回的完整套路。4.2 插件代码和编译配置我习惯用一个独立目录放插件代码比如hello_pass/hello_pass.cpp#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct HelloPass : public PassInfoMixinHelloPass { PreservedAnalyses run(Function F, FunctionAnalysisManager FAM) { errs() Function: F.getName() \n; for (auto BB : F) { for (auto I : BB) { errs() I \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-pass) { FPM.addPass(HelloPass{}); return true; } return false; }); }}; }解释下几个关键点。struct HelloPass : public PassInfoMixinHelloPass是 new PM 的标准写法run函数是入口。PreservedAnalyses::all()表示我没有改动 IR分析结果可以全部保留如果改了 IR要返回具体保留的分析集这是写优化 pass 时最容易踩的坑之一。插件注册这一段比较固定。LLVM_PLUGIN_API_VERSION是当前 LLVM 版本对应的插件 API 版本LLVM_VERSION_STRING是版本号字符串。registerPipelineParsingCallback里注册了 pass 的名字hello-pass这样 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}) add_definitions(${LLVM_DEFINITIONS}) add_library(HelloPass MODULE hello_pass.cpp) llvm_map_components_to_libnames(llvm_libs core support) target_link_libraries(HelloPass PRIVATE ${llvm_libs}) if(MSVC) set_target_properties(HelloPass PROPERTIES PREFIX ) endif()注意这里的find_package(LLVM REQUIRED CONFIG)会去找LLVMConfig.cmake前提是你已经用前面那个-DCMAKE_INSTALL_PREFIX安装了 LLVM或者构建目录里有可用的 CMake 导出文件。最简单的方式是让 CMake 找到安装目录cmake -S . -B build \ -DLLVM_DIR$HOME/llvm-install/lib/cmake/llvm cmake --build build -j$(nproc)编译完成后你会在build目录里得到一个HelloPass.so。4.3 用 opt 加载插件并观察输出先用 clang 生成本章一开始那个加法函数的 IRbuild/bin/clang -S -emit-llvm add.c -o add.ll然后使用 opt 加载插件并指定运行hello-passbuild/bin/opt -pass-plugin./build/HelloPass.so -passeshello-pass add.ll -o /dev/null正常情况下你会看到类似下面的输出把函数里每条指令都打出来Function: add %add add nsw i32 %a, %b ret i32 %add如果你希望 pass 在 clang 编译时自动运行也可以用build/bin/clang -fpass-plugin./build/HelloPass.so add.c -o add这里的原理是 clang 内部会调用插件注册的 PassBuilder 回调把 pass 加进优化管线。实际产品中这种机制常用于公司内部的代码审计插桩、性能埋点、自定义安全检查能侵入编译流程而不需要反复维护补丁。所以别小看这个 hello world你已经掌握了扩展官方编译器的基本手段。5. 常见问题与排查技巧实录5.1 构建期间最让人崩溃的几个报错构建llvm-project的过程里我遇到过不少恼人的问题这里挑几个代表性的。第一个是链接阶段内存不足典型表现是编译到某个大文件时突然报collect2: error: ld returned 1 exit status或者直接被操作系统杀掉进程。这几乎是低内存机器上最常见的问题。解决办法是使用 lld 替代系统默认链接器并限制并行链接任务数。如果系统里已经安装了 lld可以在 cmake 时加-DLLVM_USE_LINKERlld再加上-DLLVM_PARALLEL_LINK_JOBS1基本能稳住。另外建议给 swap 留点余量但根本还是内存要够。第二个是缺少开发依赖报错五花八门。常见的有找不到zlib.h、libxml2、python3-dev等。这类问题不用背错误码看到Could NOT find ZLIB之类的提示直接检查对应 apt 包是否安装即可。我用得最多的是sudo apt install -y zlib1g-dev libxml2-dev python3-dev libedit-dev libncurses-dev第三个是分支代码自身不稳定。如果你用了 main 分支某天早上发现构建出错不一定是你的操作问题可能是仓库里的代码刚被改动。解决办法很朴实换到最近的 release tag或者回退上一个版本。5.2 编译产物和插件运行的问题插件跑不起来是写 Pass 时最常见的拦路虎。最常见的原因有两个插件编译用的 LLVM 头文件、库文件和运行时 opt/clang 的版本不一致。LLVM 的 C ABI 并没有完全稳定到可以跨版本混用哪怕小版本差异也可能导致undefined symbol。所以插件必须和你构建出来的那个 LLVM 配套编译别拿系统的头文件去编插件再塞给自编的 opt。忘记extern C和LLVM_ATTRIBUTE_WEAK。这个符号是 LLVM 运行时加载插件时查找的入口如果符号不对插件会被静默忽略或者直接报找不到 Pass。新手经常在这里卡住其实套用上面的模板就不会出问题。如果你加载插件时报could not load library先确认路径写对没有再用ldd查看插件的动态依赖是否完整。很多时候是插件编译时链接的 LLVM 库路径和运行时的 LLVM 库路径不一致。5.3 一张速查表我把构建和使用llvm-project过程中比较典型的坑整理成一张表现象原因解决方向链接阶段 OOM并行链接太多内存不够限制LLVM_PARALLEL_LINK_JOBS使用 lldclang 编译找不到 stdio.h新构建的 clang 没有正确识别 GCC 头文件路径安装 libc6-dev、gcc用--gcc-toolchain或-idirafter临时补充opt 加载插件报 undefined symbolLLVM 头文件版本不匹配或未加 extern C统一版本重新编译插件检查注册入口符号cmake 找不到 LLVMConfig.cmakeLLVM_DIR 没有指向安装目录设置-DLLVM_DIR$HOME/llvm-install/lib/cmake/llvm换 main 分支后构建失败开发分支不稳定切换到 release tag除表格外我还有个个人习惯任何自定义 Pass 在正式跑之前都会写一个最小 IR 用例只跑这一个 pass然后看 dump 结果。这样能把问题圈定在插件代码里而不是整个工具链。6. 写在最后我的上手路线和一点心得6.1 我自己总结的上手路线总有人问我llvm-project这么大到底从哪开始。我的建议是走一条“能跑起来就先跑起来”的路线先按本文的方式构建一套可用的工具链然后用 clang 生成 IR反复看-O0和-O2的差异。等到你对 IR 有一定感觉再写一个什么都不改、只会打印的 Pass把它加载进 opt。最后如果你想深入后端可以挑一个简单指令比如加法在 X86 后端里跟踪它是怎么做指令选择的。这条路线最大的好处是用反馈驱动学习。每一步都能看到代码和结果之间的关系而不是在几千个文件里漫无目的地逛。我见过很多人第一天就去看 SelectionDAG 的代码结果被一堆术语劝退。你完全可以先用工具观察现象再反向去源码里找答案。6.2 后续可以扩展的方向llvm-project值得探索的方向实在太多。如果你对工具链综合应用感兴趣可以研究 lld 的链接脚本和 LTO如果你对性能工程感兴趣可以试试 BOLT它甚至能在编译完成后对二进制重新做布局优化如果你关注 AI 芯片和新的硬件抽象MLIR 几乎已经是这个领域的事实标准。最后分享一个我常对新人说的话把llvm-project当成一片森林不要试图一次看完整片森林。先选一条你想走的路比如“我想给 clang 加一个自定义警告”然后沿着这条路把相关目录翻开看多踩几次坑森林的地图自然就在你脑子里了。这个仓库值得反复来每次换一个角度收获都完全不同。
返回列表