LLVM编译器基础设施入门:从架构原理到Pass开发实践 1. LLVM到底是什么为什么这十几年绕不开它我入行编译器方向的时候第一件事就是被前辈按着头去读 LLVM 的源码。当时觉得这东西又大又绕光一个llvm-project仓库拉下来就好几个 GB连构建都能折腾一整天。但后来我逐渐意识到所有在编译器、编程语言、静态分析、GPU、甚至 AI 芯片领域工作的人迟早都会和 LLVM 打交道所以这个标题拿出来单独聊确实值得。先做个最简单直白的定位LLVM 不是一个单一的编译器而是一整套编译器基础设施。传统 GCC 那种“前端 优化 后端”焊死在一个程序里的方案LLVM 把它拆成了模块化的三个大块Clang 负责把 C/C/Objective-C 变成中间表示中间表示LLVM IR经过一系列优化 Pass最后由后端生成目标平台的机器码。这个拆法带来的好处是历史级的你在 IR 层做一次优化所有前端语言、所有目标后端都能共享收益你新造了一门语言只要把前端接到 LLVM后端就白拿了几十个 CPU、GPU 架构的支持。这个项目的主体从一开始就是开源的现在在 GitHub 上以llvm-project为名统一维护。它的体量用数据说话光是主要子项目就包括 LLVM 核心库、Clang、Clang-Tools-Extra、LLD 链接器、libc、libcabi、compiler-rt、polly、openmp、mlir 和 flang。如果你只是需要某一个工具链组件可以单独把对应目录 checkout 出来做构建不需要每次都全量编译。这篇内容我会从项目拆解、架构设计、源码构建、二次开发到实际踩过的坑完整梳理一遍尽量让不同基础的人都能找到自己需要的部分。适合看这篇内容的人我觉得至少有这几类一是刚入行想系统理解编译器工作方式的同学二是工作中需要给某个硬件平台做定制工具链的嵌入式工程师三是做编程语言、脚本解释器、DSL 编译的开发者四是研究 AI 编译器、想基于 MLIR 做算子加速的算法工程师。哪怕你只是偶尔用 Clang 交叉编译某个库也会发现自己对 LLVM 运行机制的理解越深遇到链接、优化、sanitizer 报错时越能从根上定位问题。2. 项目仓库的整体架构先把这个地图刻在脑子里2.1 llvm-project 根目录下的主力子项目第一次拉下llvm-project的人面对一长串目录名很容易懵。我先给一张按实际用途划分的“地图”帮你把每个目录和“它到底干什么”对应起来。llvm/核心仓库。包含 LLVM 的中间表示定义、优化 Pass、目标描述、汇编器、反汇编器、llc和opt等命令行工具。这是所有其他子项目的地基。clang/C/C/Objective-C 的前端。负责语法分析、语义分析、生成 AST再生成 LLVM IR。lld/LLVM 官方的链接器。现在 ELF、Mach-O、COFF、wasm 都支持得不错链接速度比传统 GNU ld 快不少。libcxx/和libcxxabi/LLVM 标准库实现和 ABI 层。Clang 默认在非 macOS 环境用的就是这套 libc强调研的 C 工程基本都切换到它。compiler-rt/提供运行时库包括各种 SanitizerAddressSanitizer、ThreadSanitizer、MemorySanitizer、libfuzzer、profile和内置库builtins。做服务端性能分析、崩溃定位的人基本天天和这里的组件打交道。mlir/专门为构建编译器和编译器工具而设计的子项目。它的定位比通用 IR 更灵活可以在多层抽象间逐步下降也是现在 AI 编译器领域的主流底座。polly/基于多面体模型的循环优化和自动并行化框架简单说就是对嵌套循环做重排、分块、向量化充分利用缓存和 SIMD。openmp/OpenMP 运行时和并行任务支持。GPU offload 的很多逻辑也在这里。flang/Fortran 前端。老科学计算代码还大量在用 FortranLLVM 这部分补全了“科学计算领域也要现代化工具链”的缺口。clang-tools-extra/这里面是clang-tidy、clangd、include-what-you-use等实用工具。日常写 C 的开发者可能没直接编过 LLVM但没少用这里面的东西。2.2 那么多子项目为什么网址还叫 llvm-project很多人问过我一个问题既然 Clang、LLD、MLIR 各管一摊为什么不拆成几个独立的 git 仓库单独维护其实早期确实是分开的但后来社区发现这些项目之间的版本联动极其敏感。Clang 生成的 IR 结构往往依赖特定版本的 LLVM 核心库libc 又要跟 Clang 的 builtins 行为对齐拆开维护会导致“我用的 Clang 10 配 LLVM 12 的库”这种错位问题编译到一半各种诡异报错。把它们统一放在llvm-project一个仓库里虽然拉代码和编译压力变大了但保证了所有子项目共享同一个提交基线checkout对齐一次整个工具链就是完全一致的快照。对嵌入式团队来说意味着你在 PC 上编出来的交叉工具链和最终发布版之间不存在任何漂移对贡献者来说也意味着一个 PR 可以跨clang和llvm目录同时修改不需要跨仓库逐个提补丁。2.3 LLVM 原生的目录结构读源码必须懂的逻辑进入llvm/内部同样有一套固定的组织习惯。include/llvm/放公开头文件lib/下按功能模块分目录比如lib/Analysis是各类分析框架lib/Transforms是优化 Passlib/Target是各后端的指令选择、寄存器分配、指令调度。tools/放命令行程序如opt、llc、llvm-as。unittests/和test/是两个层级不同的测试体系前者是 C 单元测试后者是 LIT 驱动的集成测试。这套结构好读在哪就在于职责分离非常严格。你看到一个头文件基本能猜到它属于哪个层次你在Target/X86里加一条指令重排规则几乎不需要碰Transforms/InstCombine的代码。读代码时沿着“前端生成 IR - opt 跑 Pass - llc 选指令 - 汇编成目标文件”这条线走整个流程特别顺这也是我建议初学者先不要碰 MLIR、先顺着这条主路径跑一遍的原因。3. 为什么 LLVM 能“一套中间表示通吃所有平台”3.1 LLVM IR 是整座大厦的核心LLVM 的厉害之处不在某一条指令选得有多聪明而在于它定义了一个优秀、稳定、表达能力强的中间表示——LLVM IR。它有三层形态内存里的LLVMContext/Module/Function对象模型、可读的文本 IR.ll文件、紧凑的二进制位码.bc文件。三者的语义完全等价你可以把.ll文件转成.bc再在另一个环境读回来继续处理全过程无损。IR 是静态单赋值SSA形式的意味着每个变量只能被赋值一次。这个设计看起来平平无奇但它让优化 Pass 在做数据流分析和依赖检查时变得异常可靠。你还是需要 phi 节点来合并控制流的变量版本但每个值“从哪来、用到哪去”都在 IR 层面被显式记录不必靠人工推导别名关系大幅降低了写优化 Pass 的难度。举一个特别直观的优化例子。你有这样一段代码int add_one(int* a) { int t *a; *a t 1; return *a; }Clang 在未开优化时生成的 IR 会忠实地执行两次 load、一次 store、一次 add。开-O2之后优化 Pass 会分析出第二次 load 和第一次 store 没有别名冲突直接复用第一次 load 的结果最后优化成define i32 add_one(i32* nocapture %a) local_unnamed_addr { entry: %0 load i32, i32* %a, align 4 %t add i32 %0, 1 store i32 %t, i32* %a, align 4 ret i32 %t }这个例子看起来简单但它是所有优化工作的缩影。IR 给了优化器一个“既能看到高层语义又已经接近底层机器”的位置接下来它可以安全地在每个层次进行推理。3.2 前端、中端、后端的解耦逻辑很多人把 LLVM 理解成一个编译器但它更准确的定位是“编译器的工具箱”。GCC 那种单体编译器如果你想支持一个新语言必须重写前端并且把它和后端紧密耦合的优化策略全部重新适配。LLVM 把这三个环节彻底分开前端Clang 等解析源码生成使命明确的 AST然后降级成初始 LLVM IR。中端优化 Pass不关心语言是什么、目标机是什么只看到一份中性 IR不断做各种等价变换以改善性能或体积。后端Target 体系把优化后的 IR 按目标机器的寄存器、指令集、调用约定、调度模型逐步翻译成汇编指令。这套解耦带来的真正红利是“一次优化处处受益”。假设 Intel 发布了一个新指令集那么 LLVM 只需要在后端X86目录里增加新指令的选择 pattern并修补调度模型所有前端语言、所有优化 Pass 不需要改动一行就自动获得了生成新指令的能力。反之如果你想给一门新语言做编译器前端接到 LLVM 上之后等于瞬间获得了对几十种 CPU、GPU、DSP 后端的支持这种杠杆效应是没有任何商业编译器能给的。3.3 TableGen 和 Target 描述后端到底怎么“知道”指令长什么样后端的代码并不像你想象的那样直接写每条指令的编码。LLVM 定义了一种领域特定语言 TableGen.td文件用声明式的方式描述寄存器、指令、寻址方式、指令选择 pattern 和调度信息。比如 X86 后端有一套庞大的X86InstrInfo.tdAMDGPU 后端也有自己的一套AMDGPUInstructions.td。构建时 TableGen 会把.td文件生成出 C 代码再参与编译。这个设计有什么好处最大好处是大幅降低了新增 target 的门槛。你不需要手写几百个函数来处理指令的二进制编码只需要在.td文件里清晰地描述“这条指令的助记符、操作数类型、操作码、有哪些约束”TableGen 生成器会帮你补完剩下的样板代码。社区里有人统计过新增一个简单后端超过一半的代码其实都是 TableGen 描述真正手写的 C 逻辑往往集中在指令选择、寄存器分配策略、汇编解析这几块。这也让我想起一个新手常犯的错误试图在后端 C 代码里直接改指令编码却发现改动总是被构建链路上的 TableGen 重新生成的代码覆盖。正确的做法永远是先修改.td再让 TableGen 重新生成然后编译。搞清楚这条链路后端开发才算是入了门。4. 从零构建 LLVM以 x86 Linux 环境为例4.1 环境准备与工具链要求构建 LLVM 不像普通 C 项目它对构建工具、内存、磁盘和耐心都有一定要求。我在 Ubuntu 22.04 上测试过一套比较稳的流程按下面准备一般不会出问题。首先保证系统里装了基础工具cmake、ninja-build、gcc、g、python3。LLVM 构建对 CMake 版本有一定底线要求老 Ubuntu 上现成的 CMake 可能太旧建议直接用pip install cmake装新版。内存方面如果你用 Ninja 并行编译16GB 内存是起步线32GB 会更舒服。磁盘建议留出至少 60GB 可用空间一个 Debug Assert 全量构建下来能吃掉很多。然后是源码获取。直接克隆llvm-project主分支体验最新玩法是可以的但如果你是生产环境使用或者做稳定的二次开发建议拉一个 release 分支或者打 tag比如git clone --depth 1 --branch llvmorg-18.1.8 https://github.com/llvm/llvm-project.git cd llvm-project用--depth 1做浅克隆能省掉大量历史记录拉取速度明显更快。缺点是以后想切换分支会比较麻烦但大多数场景下我们根本不需要 git 历史只需要一个干净快照。4.2 CMake 配置我建议的参数组合LLVM 的构建目录我强烈建议和源码目录分开在源码目录旁边建一个build-release目录。下面是我反复验证过的一套配置cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_ENABLE_RUNTIMESlibcxx;libcxxabi;compiler-rt \ -DCMAKE_INSTALL_PREFIX/opt/llvm-18 \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON \ ../llvm-project/llvm这里几个参数单独说一下。LLVM_ENABLE_PROJECTS指定的是“在 LLVM 构建树内一起构建”的顶层项目Clang 和 LLD 必须放这里。LLVM_ENABLE_RUNTIMES则用于构建运行时库比如 libc 和 compiler-rt它们在构建顺序上晚于核心工具链分开配置可以避免相互依赖的鸡生蛋问题。按我上传到生产环境的经验把它们分开不仅构建更清晰后续单独升级运行时库也更灵活。LLVM_TARGETS_TO_BUILD这里我只选了 X86 和 AArch64如果你不需要交叉编译到其他平台没必要把 AMDGPU、BPF、Mips 这些全部编进来每一项目标都会显著增加构建时间和二进制大小。LLVM_ENABLE_ASSERTIONS我建议开发调试时打开它能让很多非法 IR 问题直接爆出断言而不是静默产生错误代码如果追求极致性能发布版可以关掉但那会覆盖很大一部分易用性。配置完成后直接用 Ninja 开始构建ninja -j$(nproc)构建时间取决于你的机器。现在的 CPU 通常 16 核以上Release 带 Clang 和 LLD 大约需要 20 到 40 分钟。如果是老笔记本或者 8 核机器做好心理准备可以先构建llvm-tblgen这种比较小的目标测试一下工具链能否正常工作。4.3 安装与快速验证构建完要做两件事先跑测试再安装。测试命令是ninja check-llvm check-clang如果只是想验证基本的功能是否正常我更推荐用下面的“小测试”流程比跑几千个 LIT 测试快得多./bin/clang -O2 -o /tmp/hello /tmp/hello.c ./bin/llvm-objdump -d /tmp/hello | head写一个极简的hello.c编译出可执行文件再用 LLVM 的反汇编器看一眼生成的机器码基本就能确认工具链可用。安装则执行ninja install注意一点/opt/llvm-18可能需要 root 权限如果你没权限或者不想写系统目录可以改成安装到源码目录下的build-release/install。我个人在开发机上就是装到用户目录避免污染系统环境也方便随时删掉重来。5. 用 opt 和 clang 跑通一次真实 Pass再试着手写一个新 Pass5.1 先用现成的 Pass 感受一下优化流程LLVM 的优化 Pass 是编译器里最核心、也最耐玩的部分。我们先不写代码用现成的 Pass 感受一下优化效果。我把上一节的add_one函数写成完整 C 文件int add_one(int* a) { int t *a; *a t 1; return *a; }先关掉优化生成可读 IRclang -S -emit-llvm -O0 add_one.c -o add_one_O0.ll cat add_one_O0.ll你会看到 IR 里很多load、store、alloca操作非常直观但离机器码还有距离。接下来开优化再生成一份clang -S -emit-llvm -O2 add_one.c -o add_one_O2.ll对比两个文件你会发现-O2版本里alloca被消除了store/load被简化成了对寄存器的直接操作。LLVM 中端的优化 Pass 干了这么几件事mem2reg把这个局部的栈变量提升为 SSA 虚拟寄存器instcombine合并了多余的操作gvn消除了冗余加载。整个过程不用知道目标 CPU 具体是 x86 还是 ARM只要 IR 语义安全即可。如果你想知道是哪几个 Pass 起了主要作用可以用opt -print-after-all查看 Pass 执行序列。这个开关会把每个 Pass 执行前的 IR 和执行后的 IR 都打印出来是理解优化管线的最佳学习工具。例如opt -O2 -print-after-all add_one_O0.ll -o /dev/null你会看到整条流水线从AlwaysInliner、GlobalOpt到SROA、EarlyCSE再到后面的LoopVectorize。建议新手每看到一个 Pass 名字就去llvm/lib/Transforms里找到对应的文件哪怕只读前 50 行注释收获也很大。5.2 写一个最简单的 Function Pass插桩记录每个函数做二次开发时最常写的 Pass 类型是 FunctionPass。我们写一个极简的 Pass遍历模块里每个函数打印函数名和基本块数量。这个功能虽然简单但足够让你理解 Pass 的注册方式、IR 遍历方式和构建集成方式。首先在目录里建MyPass.cpp#include llvm/IR/Function.h #include llvm/IR/Module.h #include llvm/Pass.h #include llvm/IR/LegacyPassManager.h #include llvm/Transforms/IPO/PassManagerBuilder.h using namespace llvm; namespace { struct MyFunctionInfo : public FunctionPass { static char ID; MyFunctionInfo() : FunctionPass(ID) {} bool runOnFunction(Function F) override { errs() Function: F.getName() , basic blocks: F.size() \n; return false; } }; } char MyFunctionInfo::ID 0; static RegisterPassMyFunctionInfo X( my-function-info, Print function name and basic block count);这段代码做几件事继承FunctionPass重写runOnFunction里面遍历函数的size()也就是基本块数量RegisterPass把 Pass 注册到系统里给它一个命令行名字my-function-info。注意这是 legacy Pass 接口新 PassManager 的写法稍有不同但入门阶段先从旧的入手反而能更快理解整体机制因为大量文献和资料仍然是 legacy 接口的。构建这个 Pass 有两种办法。一种是直接加到 LLVM 源码树里一起编译适合长期维护另一种是做成一个动态共享库用opt -load动态加载适合快速迭代。我演示动态加载的方式把上面代码编译成.soclang -shared -fPIC -o MyPass.so MyPass.cpp \ $(llvm-config --cxxflags --ldflags --libs)然后用opt -load ./MyPass.so -my-function-info add_one_O0.ll -o /dev/null如果一切正常你会看到输出里列出了add_one函数和它的基本块数量。从这个样例出发你完全可以把它扩展成更实用的工具比如统计每个函数里call指令的数量找出没有return的路径或者输出一个调用图的.dot文件。5.3 配一套顺手的新 Pass 开发环境我见过不少同学因为“每次改 Pass 都要重新编译整个 LLVM”直接放弃了二次开发其实没必要。开发 Pass 的最佳实践是核心库和 Clang 编译一次之后开发自己的 Pass 就作为外部模块编译动态加载。这样改动 Pass 只需几十秒就能出一版而不是每次全量构建。一种更现代的方式是使用llvm/CMakeLists.txt里的add_llvm_pass_plugin宏在 LLVM 树外维护一个插件 CMake 工程它会把编译目标绑定成 LLVM 外插件。如果你只想快速验证算法独立.so的方式更直接两三个文件就能跑起来。等你确认 Pass 逻辑稳定了再考虑把它合入主树或者作为公司内部工具链的常驻优化环节也不迟。6. 我在 llvm-project 上踩过的坑以及排查思路6.1 构建阶段最容易出的三类问题第一类是内存不足导致 Ninja 异常退出。LLVM 某些大文件编译峰值内存极高尤其是lib/Target/X86下的 TableGen 生成文件和lib/CodeGen里的指令选择文件能把 16GB 内存吃满。解决思路不是硬上大内存而是调低并行度ninja -j4或-j8通常能显著降峰。另一个更省内存的办法是使用lld替代系统默认的 GNU 链接器因为 lld 内存占用远低于 GNU ld官方仓库构建本身也推荐 lld。第二类是clang编译器自身版本过旧导致的编译错误。LLVM 迭代很快源码经常会用到比较新的 C 标准特性。如果你本机默认的 gcc 版本低于 9建议先升级到 gcc 10 或者 Clang 15 再构建。用CC/usr/bin/clang CXX/usr/bin/clang指定编译器能避免一部分系统头文件兼容性问题。第三类是磁盘空间不足。很多人只给根目录分配了 30GB一构建就满了。建议构建前df -h先看一眼至少确保build目录所在分区有 50GB 可用。一个 Debug Assert 全 Target 的构建甚至可以超过 100GB这对 IPC 有限制的小机器很不友好合理裁剪LLVM_TARGETS_TO_BUILD和LLVM_ENABLE_PROJECTS能省掉一半以上的占用。6.2 链接错误不一定是你代码的问题先分清层次写 Pass 时经常遇到这类报错形如 undefined reference tollvm::Function::getBasicBlockList()。很多新手第一反应是自己的代码写错了但大多数情况下是 ABI 不匹配。你的插件如果是由系统里的 LLVM 版本编译出来的那和opt运行时的 LLVM 根本不是同一套代码符号对不上太正常了。排查思路很简单。确认opt --version的 LLVM 版本然后确认llvm-config --version是否一致如果版本一致再用llvm-config --cxxflags里的 include 路径确保编译插件时头文件来自同一个发布分支。如果实在查不出差异可以用nm -C查看.so里的符号和opt二进制的符号表但一般不会走到这一步。版本不一致在 DEBUG 构建下通常一启动就崩Release 下则可能跑一会儿才崩而且崩得毫无规律。所以我的建议是开发 Pass 阶段一定打开LLVM_ENABLE_ASSERTIONS至少能让问题尽早暴露而不是变成无法复现的内存破坏。6.3 表驱动文件改完没生效最常见的低级失误改.td文件后忘了重新生成代码是每个玩过 TableGen 的人都犯过的错。比如你在X86InstrInfo.td里加了一条新指令定义编译后怎么都找不到对应枚举最后发现 TableGen 生成的X86GenInstrInfo.inc里根本没有你新增的条目。这是构建依赖没写对导致的。正确做法是搞清楚.td文件和生成文件之间的依赖关系在 CMake 里使用tablegen(LLVM ...)宏声明。正常情况下LLVM 的构建系统会在.td变化时自动重新运行 TableGen但如果你把新.td文件放进了自定义目录而没有注册到CMakeLists.txt那什么都不会发生。遇到这类问题先删掉对应的生成文件重新跑构建再从日志里确认 TableGen 命令确实执行了。如果还是没生效就要检查 TableGen 的 include 路径确认你有没有在.td文件里正确include了基础定义。7. LLVM 社区开发流程和我的参与心得7.1 一个 Patch 从提交到合入的完整流程很多人以为给 LLVM 提代码就是在 GitHub 上开 PR 就行因为项目托管在 GitHub。实际流程比普通开源项目严格不少。当前主流方式是在 GitHub 上 fork 仓库推送你的修改再开 Pull Request。但合入之前必须有代码审查Code Review通常至少需要一两位领域维护者 approve并且要经过持续集成测试的验证。提交之前需要跑两个测试你修改的模块对应的 LIT 测试比如改 X86 后端就要跑check-llvm-codegen-x86以及全量单元测试check-llvm-unit。提交信息要遵循“[Target] 简述”或“[InstCombine] 简述”的格式这样维护者扫一眼就知道这个 Patch 影响哪个模块。我做过的比较小的一次改动是在InstCombine里增加一个模式匹配修复了一个简单的sub指令合并遗漏。那个改动本身只有十几行但测试文件我写了将近一百行因为要覆盖正反例子、不同数据类型的组合、以及确保生成的 IR 是最优形态。LLVM 社区对测试覆盖率的要求比绝大多数企业项目都严格。7.2 给新人推荐的源码阅读路径如果你想深入了解 LLVM我的建议是从“自己用 Clang 编译一个小函数逐步看 IR 和汇编”开始不要一上来就啃编译器理论。路径大概是先读clang/lib/CodeGen里CodeGenFunction处理函数体的逻辑然后读llvm/lib/Transforms/InstCombine里几个核心 Pass理解 IR 变换的台式接着去llvm/lib/Target/X86看指令选择的 TableGen 模式最后用llc --debug观察它选择指令的过程能直观看到各个 target 组件如何协同。把这条线走完你基本就能理解“前端怎么把源码变成 IR”“优化怎么改变 IR”“后端怎么把 IR 变成机器码”这三个核心环节。之后再去碰 MLIR 或者写自定义后端你就有足够的地基了。7.3 MLIR 和 LLVM 的关系顺便说一句LLVM 的 IR 是静态单赋值形式这对于做底层优化非常好但不适合描述高层编译流程比如张量计算、算子融合、循环分块这些带丰富语义的中间层次。MLIR 解决的就是这个问题它允许你定义多级抽象先用高层方言描述问题再通过一系列 lowering 逐步下降到 LLVM IR最后交给后端生成机器码。现在 AI 编译器里常用的mlir-hlo、triton底层其实都依赖这套基础设施。如果你对 LLVM 本身已有一定基础学习 MLIR 会顺畅很多因为很多概念能对应上MLIR 的 Operation、Value、Block 几乎就是从 LLVM 的 Instruction、Value、BasicBlock 扩展来的只是多了一层“方言”的概念让抽象粒度更灵活。8. 一些长期有用的建议和最后想说的从业这么多年我对 LLVM 最深的体会是它不是一个“看完文档就会用”的工具而是一个需要你在项目里不断实践、不断 debug 才能掌握的体系。哪怕你只是用它做交叉编译、写 sanitizer、跑 clang-tidy理解中间的 IR 转换流程都会让你比只会敲命令的人更早找到问题根因。如果你是在公司内做私有工具链开发我建议建立一份属于自己的“LLVM 速查表”内容包括每个关键.td文件的位置、常用 CMake 开关、你所在 target 的指令选择入口、哪些 Pass 在-O2默认启用、哪些只在高优化级别启用。这份笔记在你处理客户问题、跨版本升级时能省下大量时间。最后再分享一个实用技巧当你对某个优化行为摸不着头脑时用llc -O2 -debug-onlyisel可以看到后端的指令选择日志用opt -print-after-all可以看优化 Pass 的逐步变换用clang -Rpass-analysisloop-vectorize可以看到为什么某个循环没有被向量化。这三个命令几乎解决了我工作中 80% 与“为什么编译器这么生成”“为什么性能没提升”相关的问题。LLVM 的项目文化里有一句话很打动我大意是说“编译器的价值在于让别的程序员不用思考编译器”。但它真的走入生产环境的那一刻你必须比别的程序员思考得更深。希望这篇从架构、构建、到二次开发和踩坑的记录能帮你减少在那条更深道路上的试错成本。