尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

从LLVM IR到自定义Pass:一次讲透编译器工具链的核心实践

发布时间:2026/9/20 17:04:59

资讯中心
01
ARTICLE

从LLVM IR到自定义Pass:一次讲透编译器工具链的核心实践

从LLVM IR到自定义Pass:一次讲透编译器工具链的核心实践
前两天翻内核编译的脚本时又想到 llvm-project 这套东西。现在很多人一听到 LLVM 就犯怵觉得那是编译器大牛的领域跟普通开发没什么关系。但现实是Xcode 的 Clang、Android 的 NDK、Rust 的 rustc 后端、Julia 的 JIT、甚至游戏行业用的主流 GPU 编译器背后全是 LLVM。只要你想搞懂代码是怎么从 C 源码变成本地汇编或者想给自己写的语言做一套编译器llvm-project 几乎是一条绕不过去的路。这篇文章我会从“LLVM 到底解决什么问题”开始讲然后拆它的仓库结构、教你亲手跑一遍编译流程、写一个简单的 Pass最后把我在实操里踩过的坑列成清单。适合初次接触 LLVM、想深入了解编译器工具链的开发者。1. 传统编译器卡在哪LLVM 为什么非要推倒重来1.1 老派编译器的“一座独木桥”在 LLVM 出现之前主流的开源编译器是 GCC。GCC 把“人类可读源码—语法分析—中间表示—优化—机器码生成”这一整条链路封装成了一个巨大的单体工程。这种设计对普通用户没什么问题你只要敲gcc hello.c -o hello就能得到可执行文件。可一旦你想在中间层插一脚比如自定义一种优化规则、把代码翻译到某个冷门芯片架构就得面对成千上万行纠缠在一起的 C/C 源码。改一个前端特性可能影响后面所有优化模块改一个后端指令选择可能牵扯整个编译器的数据结构。LLVM 项目最初就是冲着这个痛点去的。它的核心理念是“把编译器拆成可重用的零件”前端只负责把源码变成统一的中间表示IR优化部分只操作这份 IR后端只负责把 IR 翻译成具体硬件的机器码。三部分之间用清晰的接口隔开而不是堆在一口锅里。这个设计听起来平淡无奇但难点在于 IR 的设计要同时兼顾前端表达能力和后端代码生成效率一个抽象层级选错整条生态都会跑偏。1.2 开篇立意的关键三层架构带来的生态裂变LLVM 的三层架构真正厉害的地方在于它让“写一个编译器”的门槛大幅降低。传统写法里你要支持一个新语言等于从零开始实现整个编译链路包括优化器、汇编器、链接器、调试信息处理工作量巨大。而在 LLVM 生态里前端做完语法和语义分析后把结果降到 LLVM IR剩下的优化、寄存器分配、指令调度、目标代码生成全都可以交给 LLVM 后端。如今很多教学项目、创业公司的专用语言、甚至学术界的实验编译器都是“新前端 现成后端”的思路短时间就能产出能跑的原型。这套架构对工业界的影响更深。苹果公司需要一个既能做 C/C/Objective-C 编译又能深度定制优化和代码生成的工具链GCC 的开源协议和架构限制让他们很难下手于是基于 LLVM 开发了 Clang。后来 Android、Windows 等平台也逐步拥抱 LLVM。你不能只说这是某个公司的选择更合理的解释是LLVM 的项目形态天生适合被“拼装”——你只要关心你想改的那一层而不必重写整个编译器。这种理念直接影响了后续几十年编译器工具链的发展。2. 打开 llvm-project 仓库核心模块与 LLVM IR 架构2.1 仓库里到底装了些什么llvm-project是一个多仓库聚合的项目初次git clone看到一大堆目录名会有点懵。我按用途帮你分一下类后面看代码时心里就有地图了llvm整个项目的核心库包括 IR 的数据结构定义、优化 Pass 框架、后端代码生成框架还有llc、opt、llvm-objdump这些命令行工具。这个目录是整个项目的心脏。clangC/C/Objective-C 的前端。它负责解析源码、做类型检查然后把语义信息翻译成 LLVM IR。“Clang”这个名字不是 LLVM 的全部只是 LLVM 生态中最高调的一个前端。lld链接器。你可以把它看作 LD 的高性能替代品Clang 配合-fuse-ldlld可以显著加快链接速度尤其在大型 C 工程里。libc / libcabiC 标准库实现和对应的 ABI 层。很多 LLVM 生态的达人喜欢用它们在嵌入式或新平台上跑现代 C。compiler-rt编译期运行库处理__asan、__ubsan、硬件原子操作等底层支持。lldb基于 LLVM 的调试器可以理解为 GDB 的一种替代选择。polly面向循环嵌套和多面体模型的优化器做循环变换、缓存分块等高级优化。mlir一个专门给“编译器编译器的编译器”设计的多层 IR 框架目前 AI 芯片和深度学习编译器大量基于它。flangFortran 前端libunwind栈回溯库bolt二进制优化和布局工具。第一次接触时不需要每个都懂你要先抓住主线读llvm/include/llvm/IR里的头文件理解 IR 的结构再用opt跑现有 Pass观察优化前后发生了什么变化最后才能谈得上写 Pass。2.2 LLVM IR一剂“把前端和后端解耦”的良药LLVM IR 是整套设计里最关键的东西。它用一种接近底层、但不绑定具体硬件特性的形式描述程序逻辑。经典形式是三地址静态单赋值SSA核心思想是“每个变量只被赋值一次”这样数据流分析会变得非常直观。如果你见过汇编语言里的寄存器再看 LLVM IR 很容易获得一种既视感define i32 add_one(i32 %x) { %result add i32 %x, 1 ret i32 %result }这段代码做了三件事声明一个函数add_one接收一个 32 位整数参数%x把%x加上 1 后返回。%result是虚拟寄存器它的类型、来源、使用方式在 IR 里一目了然。i32表示 32 位整数add是整数加法指令。这种设计让优化器可以像操作数据流图一样操作程序而不是跟一堆字符串化的源码打交道。IR 的另一大特性是可以在内存里、文本里、磁盘上相互转换。你可以把一份.ll文本文件直接喂给llc让它生成汇编也可以用lli引擎直接解释执行。这意味着调试编译器工具链时你几乎能在每一层看到程序的中间产物不像传统编译器那样“黑盒”到底。这种透明性是 LLVM 社区快速迭代的重要基础。2.3 Pass 架构与优化管线有了 IR 还不够真正的编译优化发生在 Pass 里。一个 Pass 就是一段遍历 IR 并改写的代码分析哪些表达式是冗余的、哪些赋值可以合并、哪些循环可以被展开。LLVM 把 Pass 分为两类一类是分析 Pass它们只读取 IR计算结果并缓存供其他 Pass 使用另一类是变换 Pass它们修改 IR。opt工具就是用来依次执行一串 Pass 的opt -passesmem2reg,instcombine,simplifycfg input.ll -o output.ll这里mem2reg把局部变量的内存访问提升为 SSA 寄存器instcombine做指令级化简simplifycfg简化控制流图。你可以把这条命令理解为一个“微缩版优化器”把多条标准优化按顺序串起来看看输出结果的变化。现代的 LLVM Pass 管理器比早期更复杂引入了“新 PM”按 pass 类型自动做分析结果的复用和依赖排序。你写一个自定义 Pass 时不仅要考虑“怎么改 IR”还得声明“我依赖啥分析结果、我改变哪些分析结果”否则优化管线在复杂的工程里很容易跑出错误。这个抽象层次的考虑恰恰是 LLVM 在工业化过程中不断迭代的原因优化需要和工程结构、调试体验、编译速度一起平衡。2.4 从源头改造的代价三层架构虽然清爽但也会让新人疑惑为什么写一个简单 Pass 要引入一大堆库为什么 clang 编译输出和 gcc 那么不一样原因很好理解LLVM 为了通用性做了大量抽象IR 里携带的类型信息、模块信息、调试信息、属性信息最终都会影响目标代码的质量。可以说 LLVM 是用“更高的原始复杂度”换取了“每个环节的可定制性”。一旦你接受这个理念就会发现改一个后端或者加一门语言确实比传统方案舒服得多。3. 跑通一次完整编译流程从 C 源码到可执行文件3.1 环境准备与构建动手实践前我默认你已经装好了 x86-64 Linux 或 macOS并且有基础的cmake、ninja、clang或gcc。其实现在很多系统自带的clang就是 LLVM 的日常前端但如果你想构建完整工具链或者自己写 Pass还是建议从源码编译一份因为这样才能拿到开发版头文件和库。我常用的配置是git clone https://github.com/llvm/llvm-project.git cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON cmake --build build -j$(nproc)这里解释一下关键参数LLVM_ENABLE_PROJECTS用来选择要一并构建的子项目比如clang和lldLLVM_TARGETS_TO_BUILD指定目标后端如果你只做通用实验写X86就够了否则全量编译会非常耗时LLVM_ENABLE_ASSERTIONSON会开启内部断言开发阶段建议打开能帮你抓到很多 IR 结构错误。初次构建大概需要几分钟到几十分钟取决于机器配置我的经验是保持耐心。如果你是 Windows 用户也可以用 Visual Studio 的 CMake 支持来构建但我个人建议先在一个 Linux 虚拟机里跑通流程因为很多 IR 调试脚本、工具链的路径假设在 Unix 类环境里更顺。3.2 用 clang 把一个 C 文件“拍扁”成 IR来一个最直接的实验。新建一个hello.c#include stdio.h int main() { printf(hello llvm\n); return 0; }普通编译很直接clang hello.c -o hello ./hello接下来我们把它拆开来看clang -S -emit-llvm hello.c -o hello.ll这条命令告诉你“把源码翻译成 LLVM IR但不对 IR 做优化”。打开hello.ll你会看到大量模块信息、字符串常量以及main函数。其中ret i32 0对应return 0;而printf会被转成call i32 (ptr, ...) printf。有意思的是整数类型为什么是i32而不是int因为 LLVM IR 的整数类型必须显式声明位宽不管前端是 C、Rust 还是 Swift最终 IR 层都只认i32/i64这种明确位宽。再试一下开优化后的效果clang -S -emit-llvm -O2 hello.c -o hello.opt.ll对比两个文件你会发现-O2会做常量传播、栈缓冲重用、内联分析等操作甚至把printf的格式字符串优化为puts调用因为尾部不需要格式化参数。这个现象就是你在日常编译中感受到的“代码变快了”的根本来源——优化器在 IR 上进行了一次又一次重写。3.3 进阶写一个最简单的自定义 Pass不想把 Pass 写成论文级代码的话先从一个“打印函数名”的 Pass 开始最实际。为了减少样板代码我用新 Pass 管理器new PM的示例。它的核心是继承PassInfoMixin然后实现run方法#include llvm/IR/Function.h #include llvm/IR/Module.h #include llvm/IR/PassManager.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h using namespace llvm; namespace { class MyPrintPass : public PassInfoMixinMyPrintPass { public: PreservedAnalyses run(Module M, ModuleAnalysisManager MAM) { for (auto F : M) { if (!F.isDeclaration()) errs() Find function: F.getName() \n; } return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, MyPrintPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager MPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-print-pass) { MPM.addPass(MyPrintPass()); return true; } return false; }); } }; }在这个代码里我遍历 Module 里的每个函数跳过空声明把非声明函数的名字打出来。PreservedAnalyses::all()表示我没有修改任何分析结果——严格来说我确实没有动 IR所以这里声明“所有分析都保留”不会出错。万一你改了 IR 而忘了更新分析信息后续 Pass 可能拿到过期结果这在大型优化管线里是非常难查的 bug。编译这个插件clang -shared -fPIC MyPrintPass.cpp $(llvm-config --cxxflags --ldflags) -o MyPrintPass.so然后跑一下opt -load-pass-plugin./MyPrintPass.so -passesmy-print-pass hello.ll -o /dev/null如果一切正常你会看到类似Find function: main的输出。这里有一个容易踩坑的点-passes后面必须带插件注册时写的名字my-print-pass而且加载顺序要在使用之前。如果 Pass 名字匹配不上opt会直接报错说找不到回调。4. 关键细节优化等级、目标后端与工具链配合4.1 优化级别到底该怎么选日常编译时-O0、-O1、-O2、-O3甚至-Os、-Oz有什么区别很多人只停留在“O0没优化O3最快”的直觉上。实际上它们对应的是不同的 Pass 管线-O0几乎不跑优化只做内存访问和寄存器分配的必需处理编译速度最快符号调试体验最好-O1会做一些基础优化比如mem2reg、指令合并、死代码删除-O2在-O1基础上加上了内联、循环旋转、向量化等中高级优化-O3则更激进比如函数内联阈值提高、更多向量化尝试。在 LLVM 里你可以用-print-pipeline-passes查看不同优化级别实际执行的 Pass 串。比如clang -O2 -mllvm -print-pipeline-passes hello.c -o /dev/null 21 | head -c 1000输出的 Pass 串很长因为O2会一次性挂载几十个 Pass包括function(instcombine(...))、loop(...)这种嵌套结构。这份输出比面向用户的 README 值钱得多它让你看到编译器到底做了啥而不是只听一句“O2优化了代码”。我个人建议发布生产代码时优先从-O2起步只有当 profile 数据明确显示热点函数需要更激进的开关时才用-O3因为它可能增加二进制体积、影响指令缓存局部性反而导致性能退化。对于嵌入式场景-Os或-Oz控制体积的效果更符合预期。4.2 目标后端如何影响最终代码LLVM 的后端框架支持很多架构比如 X86、AArch64、RISCV、PowerPC、WebAssemblywasm 目标等。同一份 IR 在不同后端的输出差异体现了硬件 ABI 和机器特性的差异ARM 上函数返回值可能放在w0寄存器而 RISC-V 则遵循更规则的整数寄存器分组。如果你在交叉编译时忘了指定--target后果往往是“builtin 函数 undefined”或者链接器不认识目标格式。一个简单的实验方式是把 IR 编译成不同架构的目标代码llc hello.opt.ll -o hello-x86.s --mtriplex86_64-unknown-linux-gnu llc hello.opt.ll -o hello-arm.s --mtripleaarch64-none-linux-gnu对比两个汇编文件你会看到指令名称、寄存器命名完全不同。这种差异不是 LLVM 故意制造而是硬件架构本身的执行模型不同导致。后端框架负责把这些差异尽量隐藏让 IR 层保持统一这是 LLVM 项目最受欢迎的设计之一。4.3 连接器、调试器与编译器的配合工具链是环环相扣的。Clang 生成目标文件后还要经过链接器才能成为可执行文件。使用lld往往能明显缩短链接时间特别是大型 C 工程里GNU ld 可能需要几十秒lld 可能只花几秒。你可以试试clang -fuse-ldlld hello.c -o hello_lld再讲讲调试数据。Clang 默认生成 DWARF 调试信息LLDB 这种调试器依赖这些元数据把机器指令映射回源码行号和变量名。如果你在编译里加了-gIR 中会多出大量dbg.assign、dbg.value这类指令。它们本身不影响程序逻辑但影响优化器如何保留变量映射关系。这也解释了为什么同一套 Pass 在未带调试信息和带调试信息时输出可能不一样。5. 实际踩坑清单构建失败、Pass 不生效与调试技巧5.1 常见问题速查表现象常见原因排查方法CMake Error: ... not found缺少依赖库或路径不对检查llvm-config路径、-DLLVM_DIR设置确认用开发版而不是 release 安装包构建长时间卡在某个 target链接大库时单线程编译用ninja并加大-j必要时只构建llvm和opt别全量构建所有子项目opt: Unknown pass namePass 未注册或插件加载顺序不对确认-load-pass-plugin在-passes之前且-passes里的名字和注册回调中的字符串一致生成的 IR 里变量名全是数字优化后-disable-llvm-passes未生效默认优化会做 SSA 重建和重命名用-O0或不优化 IR 看原始形态链接时报undefined reference to ...缺少运行时库或 ABI 不匹配检查目标平台架构、-target、--sysroot等交叉编译参数Pass 打印函数名但程序行为没变化插件 Pass 没被调度到检查 Pass 类型是否和-passes的层级匹配如ModulePass不能在function(...)里直接作为函数 Pass 执行5.2 一个实际排查案例优化后 printf 变了我调试过一个 C 程序发现-O2后printf(hello\n)变成了puts(hello)。一开始以为是自定义 Pass 改坏了 IR后来单独看clang -O2 -S -emit-llvm的输出才发现这是 LLVM 的标准优化printf如果只有一个常量字符串参数没有格式符会被替换成puts因为puts更高效还会自动加换行符。这个现象说明当你研究编译产物时不要急着怀疑工具链坏了先在干净的 IR 和汇编层看看到底发生了什么。排查这种问题的方法很简单把优化级别调低、逐层保留中间文件、用llvm-diff对比不同 Pass 管线的 IR 差异。LLVM 自带一系列分析调试工具只要你愿意一层层往里挖很少有解不出来的谜题。5.3 构建性能与开发效率的心得用 LLVM 做开发编译速度是绕不开的痛。我踩过最大的坑是一次性全量构建全部 target 和 project结果光编译就花了一小时。后来我总结出一个规律实验阶段只建clang、opt、llc和必要的库其他一律关掉用ninja而不是 make链接速度提升很可观开发 Pass 时尽量复用已有的build目录不要每次cmake都换新路径否则缓存全丢。调试 IR 的流程也值得多说一句不要只盯着clang的 C 源码尝试把 IR 保存下来用opt -passes... -print-after-all查看每个 Pass 执行后的 IR 变化。这样你会逐步形成“编译器在改我代码”的直觉写新 Pass 时才会知道自己的变换顺序是否合理。6. 我的个人体感LLVM 的学习路线与扩展方向如果让我给新人画一条不劝退的路线我会这么排先用clang和llc把 C 代码变到汇编体会 IR 存在感再用opt跑几个标准 Pass观察 IR 的变化然后照着文档写一个打印函数名的插件 Pass最后尝试写一个能修改 IR 的 Pass比如把两个连续加 1 的指令合并成加 2然后用FileCheck写一个测试。这个过程大约需要两三个周末但走完之后你再回头看编译器工具链的文档会有种通了电的爽快感。我的个人体会是LLVM 最迷人的地方不是“优化有多强”而是它把编译器从一块不可捉摸的黑盒变成了可以逐层观察、随时插手的体系。跨语言支持、跨平台后端、以至后来的 MLIR 生态都从一个相对简单的 C 项目里长出来。你不需要成为编译器理论顶级专家只要愿意动手把每一层的输出打印出来看就能很快进入状态。往后不管是学 Rust 的 MIR、看 AI 芯片编译器还是会遇到各种教人做 DSL 编译器的课程你都会发现自己在吃 LLVM 的老本——这个投资回报率不低。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。