从LLVM IR到自定义Pass:深入理解编译器基础设施的模块化设计与实践 📅 发布时间:2026/9/18 9:36:24 👁 浏览次数: 开篇一个编译器项目凭什么火了二十年我第一次真正意识到 LLVM 的价值不是在读论文的时候而是在一个深夜调优化 bug 的时候。当时为了给一套自研脚本语言做即时编译JIT我在 GCC 和 LLVM 之间来回折腾最后被 LLVM 的模块化设计彻底折服——它不像是一个传统编译器更像是一套“编译器积木”。你可以把语法分析、中间表示、优化、指令选择、寄存器分配、目标代码生成这些环节全部拆开按需取用。这种设计让它不只是 C/C 的编译器更是无数编程语言、GPU 驱动、动态分析工具、甚至软件渲染器的底层引擎。llvm-project这个项目名听起来好像只是“一个编译器项目”但实际上它是一整套编译器基础设施的官方源代码仓库涵盖 Clang、LLVM 核心库、libc、 compiler-rt、lld、lldb、OpenMP、MLIR 等一系列子项目。你看到的llvmpipeLLVM 15.0.7256 bits 这类构建信息就是建立在 LLVM 之上的一个典型产物——它利用 LLVM 的 JIT 能力把光栅化、图元处理等图形计算的代码在运行时动态生成出针对当前 CPU 的最优 SIMD 指令比如 256 位的 AVX2 指令从而在纯 CPU 环境下也能跑出可用的图形加速效果。这篇文章不打算讲太多玄乎的理论我会从“为什么 LLVM 能长成这样”讲起把它的三段式架构、核心组件、构建实操、Pass 开发入门再到常见的坑全部展开来说。适合正在学习编译原理的学生、想做工具链的嵌入式开发者以及刚接到 LLVM 相关任务、急需理清头绪的人。1. 内容整体设计与思路拆解1.1 传统编译器为什么“硬伤”明显在 LLVM 出现之前主流的编译器模型是“前端 优化器 后端”绑死在一个代码库里。GCC 就是典型代表前端解析 C/C生成 GCC 自家的 GIMPLE 中间表示然后经过一系列统一优化再针对不同 CPU 架构生成汇编。这套模型本身是能用的但是有个很尴尬的问题你没法轻易换前端。假设你想给 Rust、Swift 或者某种自研语言做一个高性能编译器你必须从头写一遍前端、优化器和后端或者去 hack GCC 的内部接口而有能力动 GCC 内部的人少之又少因为它的中间表示GIMPLE和优化流水线并不是为“复用”而生的。另一个痛点是后端代码生成被藏在编译器堆栈深处它和优化器之间没有清晰的边界。这意味着如果你只想复用 GCC 的优化逻辑但替换掉它的寄存器分配算法或者接入一个新的目标架构你得对整个工程有极其深入的了解否则一改就崩。LLVM 对这个问题的解法非常优雅它把“编译器”这件事切成三个明确的部分——前端负责解析源代码生成LLVM IR中间表示中间层负责对 IR 做平台无关的优化后端负责把优化后的 IR 翻译成目标机器的汇编或机器码。每一层之间靠一个通用的、良定义的接口衔接这个接口就是 LLVM IR。1.2 三段式架构的工程价值这个三段式设计带来的直接好处是只要你的语言前端能生成 LLVM IR就能免费获得后端的所有能力——X86、ARM、RISC-V、PowerPC 等几十个目标架构的代码生成以及数千个经过验证的优化 Pass。Clang 的前端把 C/C/ObjC 翻译成 IR而 Rust通过 rustc 的 LLVM 后端、Swift、Julia、Zig 等语言也都能把各自的抽象语法树AST降级到 LLVM IR然后共享同一套优化与代码生成流水线。我举个例子假设你写了一个小小的教学语言只需要把语法分析出来的结果翻译成 LLVM IR。那你等于直接接入了一个工业级的编译器后端。这对一个小团队的投入产出比来说是无法拒绝的。LLVM 本质上是在说“我们帮你把最难、最不性感的那部分指令选择、调度、寄存器分配做到极致把接口留给你剩下的前端工作你按自己的语言特性去折腾。”这种模块化也直接影响了llvmpipe这类运行时系统的设计。llvmpipe 需要做的事情是把图形 API 的每一个 draw call 转换成实际的像素填充逻辑。它不能像传统编译器那样只做 AOT预先编译因为要服务的图形状态shader 组合、深度缓冲状态、混合模式等实在太多穷举不现实。于是 llvmpipe 借助 LLVM 的 ORC JIT 引擎在运行时把当前帧需要的渲染管线编译成高度优化的机器码再执行。512 位 AVX-512 或者 256 位 AVX2 指令的选择完全由 LLVM 后端根据本机 CPU 特性在编译时自动生成开发渲染器的人根本不关心这些细节。这就是 LLVM 抽象边界带来的红利。2. 核心组件拆解前端、IR、优化、后端、工具链2.1 Clang 前端与 LLVM IR 的生成llvm-project仓库里最核心的组成就是 Clang 前端。Clang 负责做词法分析、语法分析、语义分析把平日写的 C/C 代码变成 AST再将 AST 逐步“下降”成 LLVM IR。对使用者来说一条普通的命令行clang -S -emit-llvm test.c -o test.ll就能看到某个源文件对应的 IR 文本形态。LLVM IR 的设计很有意思它有三层表示形态内存表示编译过程中真正参与分析和优化的是内存中的Module、Function、BasicBlock、Instruction等 C 对象。文本表示.ll文件便于人阅读、调试、写测试用例。字节码表示.bc文件适合保存缓存效率和体积更友好。IR 一个非常鲜明的特性是 SSA 形式静态单赋值形式。它要求每个变量只能被赋值一次这种约束让数据流分析变成了一件极其自然的事情。你可以把 SSA 想象成一张依赖关系网状图——每个值都由它的定义者唯一决定优化器在图上做传播、消除、简化都有非常清晰的数学结构可以依赖。很多第一次接触 LLVM 的人会问IR 看起来像一个“不那么高级的汇编”跟 C 语言生成的汇编到底有什么本质区别区别在于 IR 是与目标无关的。它不知道什么是 x86 的 RAX 寄存器也不知道 ARM 的条件执行更不关心调用约定。IR 只描述“这是什么操作、操作数是什么”而把“如何映射到具体指令”完全交给后端。例如define i32 add(i32 %a, i32 %b) { %result add i32 %a, %b ret i32 %result }这段代码在 x86-64 上会被翻译成lea或者add指令在 ARM 上会被翻译成 ADD 指令在 RISC-V 上同样有对应的 ADDW。优化器只需要优化这段 IR不同的后端就能各自受益。2.2 优化器与 Pass 体系LLVM 中间层的灵魂是 Pass优化与分析的通行证。一个 Pass 对 IR 做一次遍历或者变换比如消除死代码死代码消除、常量传播、内联、循环展开、向量化等。所有 Pass 组合起来形成一整套优化管道。Pass 分为两种基本类型分析 Pass只读取 IR收集信息比如哪个变量没有被使用、哪个循环是热点写入分析结果不修改 IR。变换 Pass读取并改写 IR比如把常量表达式折叠成一个常量或者把多个加载合并成向量加载。在实际工程中写一个自定义 Pass 是接触 LLVM 最好的方式之一。你不需要理解后端的指令选择只需要学会怎么遍历函数、识别模式、修改 IR。很多公司内部的编译器定制都从这个层面入手比如针对某种芯片增加一种特殊的“内存访问合并”优化或者在上游阶段插入自定义的插桩代码这些都能通过 Pass 实现。优化器的设计还非常注重 Pass 的依赖管理。你写一个分析 Pass可能需要另一个分析 Pass 的结果LLVM 的 PassManager 会负责安排执行顺序并缓存分析结果。这就避免了很多传统编译器“优化各干各的叠加起来效果很差”的问题。2.3 后端差异与指令选择LLVM 的后端负责把优化后的 IR 变成目标机器的汇编。这个过程大致可以分为指令选择把 IR 指令映射到目标机器的指令比如把add映射到 x86 的ADD。指令调度调整指令顺序更好地利用 CPU 流水线。寄存器分配把无限数量的虚拟寄存器映射到有限的物理寄存器必要时溢出spill到栈上。指令输出生成汇编或直接编码成机器码。和传统后端相比LLVM 后端最大的优势在于TableGen这门 DSL领域特定语言。它用描述性代码来定义指令集、寄存器、调用约定等然后自动生成部分代码。比如你在一个.td文件里描述某条指令的存在、格式、操作数类型TableGen 就会生成匹配器和反汇编器等代码。这种做法大幅减少了移植一个新 CPU 架构到 LLVM 的工作量。正是因为后端的模块化llvmpipe 这样的项目才能在 JIT 场景下如鱼得水。llvmpipe 把渲染管线的各个阶段顶点变换、光栅化、片段着色用 LLVM IR 表达然后针对运行主机的 CPU 特性即时编译。构建日志里的LLVM 15.0.7, 256 bits就说明了它在这个版本上构建出的代码最多采用 256 位宽的 SIMD 指令。这套机制让纯软件渲染也能获得不错的并行效率尤其在没有显卡的虚拟机或云服务器环境中它几乎是唯一的图形输出路径。2.4 工具链与生态组件llvm-project里远不止编译器和优化器它还是一个完整的工具链集合组件作用实际场景ClangC/C/ObjC 编译器前端日常编译、静态分析lld链接器比 GNU ld 快很多构建大型项目优势明显lldb调试器符号化、表达式求值深度集成 LLVM 生态libcC 标准库实现服务于 Clang 的更一致的标准库compiler-rt运行时支持库处理 ASan、UBSan、sanitizer 覆盖等OpenMP并行编程运行时与编译支持多核并行代码的编译与执行MLIR多层级 IR 编译器框架机器学习模型编译、领域专用编译器llvmpipe (Mesa)基于 LLVM JIT 的软件渲染器无 GPU 环境下运行图形程序这种全家桶式的生态意味着你装好一套llvm-project实际上就获得了从代码变成可执行程序的完整闭环。更重要的是这些组件之间都使用 LLVM 生态的统一抽象调试信息、优化记录、符号表等格式都能互相配合这在遇到跨工具链问题时能省掉非常多排查时间。3. 从源码构建 LLVM 的完整实操3.1 环境准备与版本选择LLVM 的源码构建本身就是一个值得认真对待的工程任务。先说结论不建议直接 clone 最新 main 分支来当生产工具链除非你有特殊需求。一般来说选择一个发布版比如 15.x、16.x、17.x会更稳因为部分 API 在 main 分支上可能随时变化配合第三方项目时往往需要锁定版本号。构建 LLVM 前需要的依赖较新的 C 编译器GCC 7.1 或 ClangCMake 3.20Ninja推荐构建速度比 Make 快很多Python 3部分表生成和测试工具需要zlib、libffi部分组件需要的库这里我推荐用 Ninja 而不是 Unix Makefiles巨大型 C 项目的并行编译效率差别很明显Ninja 在处理增量构建时的调度更聪明CPU 多核利用率高很多。3.2 CMake 配置关键参数克隆仓库的最新稳定分支以 llvm-project 为例git clone --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build然后是 CMake 配置。这里我给出一个我日常实际在用的参数组合cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;libcxx;libcxxabi;compiler-rt \ -DLLVM_TARGETS_TO_BUILDhost \ -DCMAKE_INSTALL_PREFIX/opt/llvm-15 \ ../llvm逐个参数解释一下CMAKE_BUILD_TYPERelease编译优化后的 LLVM性能最好。想调试 LLVM 自身可以用Debug或RelWithDebInfo但构建时间会成倍增加。LLVM_ENABLE_PROJECTS选择要构建哪些子项目。Clang 和 lld 是必备的libcxx 和 libcxxabi 是标准库实现compiler-rt 提供 sanitizer 等运行时支持。如果你只需要基础工具链只留clang;lld就够了能省下不少构建时间。LLVM_TARGETS_TO_BUILDhost只构建当前机器的目标架构。默认会构建所有支持的架构耗时极长。只构建 host 架构构建时间能降一半以上。CMAKE_INSTALL_PREFIX指定安装目录方便多版本共存。还有个参数LLVM_BUILD_LLVM_DYLIB也值得注意它会把 LLVM 编译成动态库 libLLVM.so。这样生成的二进制体积更小但很多项目需要静态链接 LLVM这时候就建议不要开启。配置完成后直接ninja开始构建。这里我必须提醒一句如果你是第一次构建并且选择了一堆子项目比如 clang、lld 全开那么即使机器好也可能要等二十到四十分钟。要有心理准备别以为是死机了。3.3 验证构建结果与安装构建完成后先不要急着ninja install可以先在 build 目录里做一轮基本验证。./bin/clang --version ./bin/llvm-config --version ./bin/opt --version这三个命令分别验证了 Clang 前端、LLVM 配置查询工具、优化工具是否正常。如果都能输出版本信息说明核心链路没有问题。此时可以考虑跑一下测试。LLVM 的测试体系非常庞大全量测试可能需要很久我一般只跑与本次改动相关的测试或者做冒烟测试ninja check-llvm-unitcheck-llvm-unit是 LLVM 单元测试的快捷入口通常几分钟内能跑完。如果你构建了 Clang也可以跑ninja check-clang但相对耗时更长。一切正常就可以安装了ninja install安装完成后/opt/llvm-15/bin下就有了完整的工具链。注意把安装目录加到 PATH 时尽量排在系统自带编译器之前避免干扰系统其他软件的默认编译方式。最好用-DCMAKE_INSTALL_PREFIX指到独立目录这样以后想删直接删目录完全不污染系统。3.4 构建过程中的性能与磁盘规划LLVM 是一个体积很大的项目源码加构建产物轻松超过 30GB。务必提前检查磁盘空间建议至少留出 50GB。我的方案是源码放 SSDbuild 目录也放 SSD最好是另一块盘因为大量头文件和对象文件的随机读写对机械硬盘非常不友好。同时内存也很重要。如果你机器内存只有 16GB用 Ninja 并行编译时建议限制并发任务数ninja -j4否则内存吃满会导致编译进程被 OOM Killer 杀掉而且这种死法往往让整个 build 目录状态变得很尴尬重跑时还需要清理部分中间产物。至于 CPU 核心数-j参数一般设为物理核心数即可超线程对 C 编译的提升没那么线性有时候并发太高反而因为内存带宽瓶颈导致等待。4. 实践亲手写一个 LLVM 优化 Pass4.1 明确目标让 IR 更简洁的常量折叠聊理论不实践等于白聊。这里我带你写一个最简单的自定义 Pass它的作用是把形如mul i32 %x, 1这样的指令优化成%x本身也就是消掉乘 1 的冗余运算。虽然这个优化本身 LLVM 早就内置了但它的写法、注册方式、运行方式和工作原理是所有更复杂 Pass 的通用模板。先建一个目录比如MyPass在目录里放两个文件CMakeLists.txt和MyPass.cpp。4.2 编写 Pass 的代码与构建脚本MyPass.cpp的核心逻辑如下#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Pass.h #include llvm/IR/PassManager.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h using namespace llvm; namespace { struct MySimplifyPass : public PassInfoMixinMySimplifyPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { bool changed false; for (BasicBlock BB : F) { for (Instruction I : make_early_inc_range(BB)) { if (auto *BinOp dyn_castBinaryOperator(I)) { if (BinOp-getOpcode() Instruction::Mul) { Value *Op0 BinOp-getOperand(0); Value *Op1 BinOp-getOperand(1); if (auto *CI dyn_castConstantInt(Op1)) { if (CI-isOne()) { BinOp-replaceAllUsesWith(Op0); BinOp-eraseFromParent(); changed true; } } } } } } return changed ? PreservedAnalyses::none() : PreservedAnalyses::all(); } }; } // namespace extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MySimplifyPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-simplify) { FPM.addPass(MySimplifyPass()); return true; } return false; }); }}; }这段代码最核心的点我拆开解释一下run方法是 Pass 的入口我们遍历函数的每一个基本块、每一条指令。dyn_castBinaryOperator是 RTTI 风格的动态类型检查如果不是二元运算就会返回空指针。ConstantInt::isOne()判断第二个操作数是否为常量 1。replaceAllUsesWith是修改 IR 最关键的 API把所有使用乘运算结果的指令改用第一个操作数。eraseFromParent把自己从基本块中移除。最后返回PreservedAnalyses::none()告诉 PassManager 我们的变换修改了 IR可能影响其他分析结果。CMakeLists.txt里需要借助 LLVM 的构建系统来编译插件cmake_minimum_required(VERSION 3.13) project(MySimplifyPass) 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_library(MySimplifyPass MODULE MyPass.cpp) target_compile_definitions(MySimplifyPass PRIVATE ${LLVM_DEFINITIONS_LIST}) target_include_directories(MySimplifyPass PRIVATE ${LLVM_INCLUDE_DIRS}) target_link_libraries(MySimplifyPass PRIVATE LLVMCore LLVMSupport LLVMPasses)4.3 构建插件并在 opt 中运行编译插件需要先能找到 LLVM 的 CMake 配置所以用-DLLVM_DIR指向安装目录下的lib/cmake/llvmcmake -S . -B build -DLLVM_DIR/opt/llvm-15/lib/cmake/llvm cmake --build build构建成功后生成的文件就是一个动态插件Linux 下是libMySimplifyPass.so。现在用opt来测试。先准备一个简单测试源文件// test.c int mul_by_one(int a) { return a * 1; }先用 clang 生成 IRclang -S -emit-llvm test.c -o test.ll这时候test.ll里有类似这样的代码define i32 mul_by_one(i32 %a) { %1 mul i32 %a, 1 ret i32 %1 }现在加载我们的插件并运行 Passopt -load-pass-pluginbuild/libMySimplifyPass.so -passesmy-simplify -S test.ll -o test_opt.ll执行后打开test_opt.ll你会发现mul i32 %a, 1已经被消除函数直接返回%a了。整个流程下来你会对一件事有非常直观的感受编译器优化其实就是在 Manipulate 一棵巨大的指令图而 LLVM 把所有修改操作都封装得非常顺手。一旦突破“不敢改 IR”的心理门槛之后写更复杂的 Pass比如循环优化、内联决策、向量化调优思路都是同一个套路。4.4 从 Pass 走向真实业务的延伸上面这个小 Pass 看着简单但延伸出去就是无限的可能。我见过不少真实业务场景在 GPU 驱动栈中用自定义 Pass 把某些特殊的内存拷贝范式替换成更高效的架构特定指令序列。在安全领域用 Pass 在函数入口插入内存检测代码实现细粒度的插桩。在 DSL 编译器里用 Pass 把高层的运算模式匹配成特定的低层库调用。在性能分析场景写 Pass 记录每个函数的调用次数做轻量级 profiling。基于 LLVM 做定制开发最大的收益就是你不需要关心底层机器码怎么生成只需要聚焦在“你想对中间代码做什么有意义的变化”上。5. 常见问题与排查技巧实录5.1 构建太慢、OOM 崩溃其实这是对 LLVM 初印象影响最大的一件事。每次有人跟我说“LLVM 编译也太慢了”我第一反应就是问构建参数。太多人默认源码构建但不加LLVM_TARGETS_TO_BUILDhost于是 LLVM 把所有后端的 TableGen 都跑了一遍光生成指令描述就够喝一壶的。而且默认还会构建所有工具的文档和一堆测试依赖。我整理了一张速查表帮助快速定位构建问题症状大概率原因建议操作编到一半被 OOM 杀掉并行任务太多或 Debug 构建内存占用巨大ninja -j4或构建 Release切换编译目录磁盘空间不足build 目录 30GB换大盘或用软链接把 build 指到其他盘编译速度极慢开了全部 targetLLVM_TARGETS_TO_BUILDhostcmake 找不到依赖系统缺少 zlib 等按报错逐个apt install或yum install链接期栈溢出ld 处理大二进制资源紧张换 lld 或提高-Wl,-z,stack-size这里补充一个实用技巧如果想在 Debug 模式下保留优化能力可以选RelWithDebInfo而不是纯Debug。纯 Debug 构建的优化器跑起来慢得让人抓狂但RelWithDebInfo同时带有调试信息和 Release 的优化水平折中方案非常实用。5.2 插件 Pass 没有被加载或报符号错误我在写自定义 Pass 时踩过最深的一个坑就是插件加载失败。通常是这个错误opt: unknown pass name my-simplify原因几乎都是插件没有编译成与当前 opt 相同的 LLVM 版本/API 版本。LLVM 的插件 ABI 在版本之间是可能变化的你用 LLVM 16 构建的.so放到 LLVM 15 的 opt 里去加载大概率就是这种下场。所以一定要保证插件编译时使用的LLVM_DIR指向你要运行的opt所属的那份 LLVM 安装。另一个坑是忘记导出llvmGetPassPluginInfo符号或者符号因为可见性设置被隐藏了。CMake 的默认设置下LLVM_ATTRIBUTE_WEAK能保证导出但如果你自定义了-fvisibilityhidden就一定要显式导出这个函数。还有个细节很多人会忽略opt 文本形式的 Pass 名称必须和你在回调函数里注册的名称完全一致大小写敏感。多一个空格都可能导致找不到。5.3 对 IR 修改后用断言报错刚写 Pass 的人最容易遇到的一个问题是在使用replaceAllUsesWith之后忘记考虑指令迭代器失效的问题。LLVM 的迭代器设计得非常精细如果你在遍历BasicBlock的同时删除指令很可能会造成未定义行为或者崩溃。我在模板代码里特意用了make_early_inc_range它的作用是在删除当前指令之前先拿到下一跳的迭代器安全地支持一边遍历一边删除。这是一个非常重要的习惯尤其是写复杂 Pass 时涉及多指令删除和重建时宁可多拷贝一轮指令列表再遍历也不要拿迭代器瞎搞。如果运行的时候触发了 LLVM 内部的断言比如Use still stuck around after the instruction is deleted这说明你删除了某条指令但还有其他指令在引用它的结果也就是 use 链没有完全替换干净。正确的做法是确保replaceAllUsesWith调用后才执行删除而且所有被替换的 use 都必须指向合法的新值。遇到这种报错仔细检查替换的 Value 和删除的顺序往往就能找到逻辑缺陷。5.4 llvmpipe 与 256 bits 背后的编译选择回到文章开头的llvmpipe。很多人在无 GPU 的服务器或者虚拟机里跑图形程序时会看到llvmpipe (LLVM 15.0.7, 256 bits)这样的渲染器信息。这里的 “256 bits” 代表的是 llvmpipe 通过 LLVM 生成的最大 SIMD 指令宽度也就是 AVX2。如果你的 CPU 支持 AVX-512这个数字可能会变成 512 bits前提是构建时 llvmpipe 检测到并启用了相关支持。这个信息对我们排查性能问题很有用。比如在 QEMU 虚拟机里跑 3D 应用很慢第一眼先看渲染器字符串如果是 llvmpipe说明没有任何 GPU 硬件加速所有 OpenGL 调用都在靠 CPU 模拟。把256 bits调到 512 bits 能带来一部分性能提升但终归是软件渲染和真 GPU 没法比。这时候合理的做法是给虚拟机透传 GPU或者加强 CPU 单核性能毕竟 llvmpipe 的 JIT 生成代码虽然高效但片段着色器的执行开销仍然全部由 CPU 承担。结尾这条路走下去你会看见一个更大的世界我在最初接触 LLVM 的那一个月里反复被它的抽象能力震撼。一个编译器项目通过定义清楚 IR 的形态和 Pass 的边界几乎重构了“编程语言如何落地到机器”这件事的方法论。无论你是想把一门新语言快速带到多个硬件平台还是想在既有工具链中插入一层定制逻辑甚至是像 llvmpipe 那样在纯 CPU 环境里模拟完整的图形管线LLVM 都能给你提供一条相对清晰的路。我个人实际写 Pass 的时候最大的体会其实是不要一上来就想着写很复杂的变换先从小而准的模式匹配开始比如消掉冗余的乘法、合并相邻的加载。把 IR 的文本形式打开反复观察你改之前和改之后的差异这种感觉和 debug 普通程序完全不同——你是在直接操纵程序的“语义抽象层”是编译器内部的微观手术。有了这种手感之后再去啃那些复杂的 loop 优化、向量化模型就会从容很多。最后再分享一个小技巧遇到 LLVM 的 API 不熟直接在源码仓库里搜用法。LLVM 的项目注释和测试代码写得非常详细很多官方单元测试就是最好的示例代码。这比你到处找博客教程快得多而且永远保证版本匹配。编译器的世界很大从 LLVM 这个入口进去你会越走越觉得自己低估了这个领域。