
LLVM 这个项目我在不同阶段反复接触过好几次。最早是拿它当 C 编译器用后来做性能分析时开始看它生成的中间码再后来自己动手写过几个分析 pass才算真正摸到门道。如果你也准备啃 LLVM或者正在为“到底该怎么入手”发愁这篇内容应该能帮你省掉不少弯路。先说清楚 LLVM 到底是什么。它不只是一个编译器而是一整套编译器基础设施。我们平时说的“ Clang ”只是 LLVM 项目里的一个 C/C 前端真正核心的是那套中间表示IR、优化器、后端代码生成以及围绕这些构建的庞大工具链。llvm-project 这个仓库就是把所有子项目放在一起的统一代码库包括 LLVM 核心库、Clang、LLD 链接器、libc、compiler-rt、OpenMP 运行时等等。换句话说你想研究的编译器技术几乎都能在这个仓库里找到落地的样本。这套东西能做什么解决什么问题对不同的人有不同的答案。对于做编译器的工程师它是目前最活跃的开源编译器平台对于做编程语言设计的人它提供了一个完整的“前端-优化-后端”框架你只需要写好前端就能吃到 LLVM 的优化和后端生成能力对于做性能工程的人它能帮你精确控制代码生成甚至直接查看优化前后的 IR 变化对于纯粹想学习编译原理的人llvm-project 是一本巨大的、可以运行的教科书。所以无论你是学生、研究员、工具链开发者还是系统软件工程师这个项目都值得花时间去研究。不过llvm-project 的体量也决定了它的门槛。第一次 clone 仓库、第一次 CMake 构建、第一次看到那几十个 target 名字时很容易一头雾水。我自己第一次构建是在一台配置一般的笔记本上等了一个多小时当时心里想的不是“好厉害”而是“我到底干了什么”。所以这篇内容我不想写那种“ LLVM 简介”的官样文章而是从一个实际使用者、二次开发者的角度把这套项目从大到小、从原理到实操一点一点拆开。1. llvm-project 的整体面貌它不是“一个编译器”而是一条完整的编译器流水线如果你去看 llvm-project 的仓库结构会看到顶层有一堆目录——llvm、clang、lld、libcxx、libcxxabi、compiler-rt、polly、flang、mlir、openmp、parallel-libs第一次打开的人多半会懵。这些目录看起来是平级关系但在编译流水线里各自承担完全不同的角色。最容易理解的方式是把编译器想象成一条工厂流水线原料是源代码产品是机器码。流水线第一站是“前端”负责把源代码解析成编译器能理解的中间表达第二站是“优化器”在中间表达上做各种等价变换让它跑得更快或更小第三站是“后端”把优化后的中间表达翻译成具体 CPU 的机器指令。LLVM 项目最了不起的地方就是把这三个环节彻底解耦了。具体到仓库里Clang 是 C/C/Objective-C 的前端Flang 是 Fortran 前端而 llvm 目录本身包含了优化器和后端。你写一个 C 文件用 clang 编译时实际发生的是Clang 把 C 代码翻译成 LLVM IRLLVM 优化器对 IR 做处理然后 LLVM 后端按照你指定的目标架构生成汇编和机器码。这三步是清清楚楚分开的这也是 LLVM 能支持那么多语言、那么多 CPU 架构的根本原因——新的语言只需要写前端新的芯片只需要写后端中间的优化器可以完全复用。别忘了还有一堆“配套工厂”。LLD 是链接器负责把编译出来的目标文件链接成可执行文件compiler-rt 提供运行时支持包括很多内建函数和 sanitizerlibc 是 C 标准库实现polly 是一个基于多面体模型的循环优化器MLIR 是后来加入的它是一套构建编译器的框架说白了就是“用来构建前端的框架”在机器学习、异构计算领域非常活跃。我看到很多初学者上来就钻进 llvm 目录里看代码结果被各种 pass 和数据结构搞得头大。我的建议是先用“流水线思维”建立整体框架一个源文件经过哪几个阶段每个阶段由哪个子项目负责产物是什么。框架清楚了再往里填细节就容易得多。1.1 核心子项目之间的依赖关系也就是“谁依赖谁”clang / flang 依赖 llvm 与 clang 自身的头文件 lld 依赖 llvm 核心库 libc 与 compiler-rt 相互独立但 libcabi 依赖 libc polly 依赖 llvm且需要特定的 pass 注册机制 mlir 依赖 llvm但与 clang 没有直接依赖这套依赖关系直接影响了你构建时的 CMake 配置。如果你只需要 clang那就可以关掉 lld、polly、mlir 等组件显著减少构建时间。如果你要开发一个基于 MLIR 的方言那就需要把 mlir 打开但可以关掉 flang、openmp 之类的组件。另一个需要理解的概念是“target”。LLVM 支持几十种 CPU 架构但你在构建时不需要全部编译进去。常见的 target 包括 X86、AArch64、ARM、RISCV、PowerPC、SystemZ 等配置时用LLVM_TARGETS_TO_BUILD指定。这个选项直接影响构建时间和生成的编译器体积。对于大多数在 x86 机器上做实验的人来说只保留 X86 就够了等真正需要交叉编译时再添加其他 target。仓库的版本管理也值得一提。LLVM 的发布节奏是半年一个大版本版本号通常是偶数。master 分支永远处于开发状态API 变化频繁所以如果不是要贡献代码而是要做稳定开发建议直接使用某个 release 分支或 tag。我看到不少人在 master 上写 pass写完之后发现 LLVM API 变了代码编译不过这种体验真的非常打击人。1.2 tablegenLLVM 项目里最容易被忽略的“代码生成器”看 LLVM 源码时你会频繁看到 .tdTableGen文件比如 X86.td、RISCV.td。TableGen 是 LLVM 自己的一套 DSL专门用来描述指令集、寄存器、调用约定等高度规律化的信息。它不会直接出现在编译产物里而是在构建时由 llvm-tblgen 工具处理生成 C 代码。刚开始我很不理解为什么指令集描述不用普通的 C 类后来写了一点 .td 代码才明白指令集描述里面的规律性太强了——每个指令有助记符、操作数类型、编码格式、指令选择模式、汇编打印方式……如果全部手写 C不仅量大而且极容易出错。用 TableGen 描述后一个指令只需写一行自动生成对应的解析、打印、匹配代码出错的概率小很多。而且你新增一条指令时只需要改 .td 文件不用满世界找各个地方的 switch-case。TableGen 是 LLVM 二次开发绕不过去的一道坎。如果你打算给某个后端加指令或者自定义目标架构至少要能读懂 .td 文件的结构。学习路径可以是先看 include/llvm/Target/Target.td 里定义的基础类再看某个具体后端的 .td 文件理解 Instruction、Register、Pattern 这些核心概念最后尝试修改一条指令观察生成代码的变化。这个过程能帮你快速理解 LLVM 后端的工作方式。2. LLVM IR整个项目的灵魂也是理解优化的关键入口如果说 LLVM 项目有一个东西是必须理解的那就是 LLVM IR。它对编译过程的意义有点类似于 Java 字节码之于 JVM——但 LLVM IR 的设计目标更强调可分析性和可优化性。IR 是一种静态单赋值SSA形式每个变量只被赋值一次这种约束让数据流分析变得非常直观。看一下实际的 IR 长什么样。写一个简单的 C 函数int add(int a, int b) { return a b; }用clang -S -emit-llvm add.c -o add.ll编译得到类似这样的 IRdefine i32 add(i32 noundef %a, i32 noundef %b) { entry: %add add nsw i32 %a, %b ret i32 %add }理解这一段有几个关键点。define i32 add定义了一个返回 i32、名为 add 的函数。i32是 32 位整数类型。%a和%b是虚拟寄存器也就是 SSA 变量它们只在这一处被赋值。add nsw是带 no-signed-wrap 标志的加法告诉优化器这里不会发生有符号溢出从而允许更多优化。ret是返回指令。IR 有三种形式内存中的表示Memory IR、位码Bitcode.bc 文件和文本形式.ll 文件。三者完全等价可以在任意时刻互相转换。文本形式方便人阅读和调试位码形式适合存储和传输内存中的表示是优化器实际操作的对象。调试时最常用的思路是用-S -emit-llvm生成文本 IR用opt工具跑某个 pass再对比优化前后的 IR 变化。我自己的习惯是每写一个 C 函数都会顺手编译成 IR 看一眼。这样能直观感受到 clang 在前端做了哪些处理——比如struct的布局、static函数的内联、常量折叠等。这些在源码层面是看不到的但 IR 会把它们暴露无遗。理解了 IR你就真正理解了“编译器的视角”。2.1 pass 机制优化和分析都是一个个“插件”LLVM 的优化器核心就是一个 pass 框架。所谓 pass就是对 IR 做一次遍历和变换。有的 pass 只分析不修改比如统计函数调用次数有的 pass 会改写 IR比如死代码消除、循环展开、函数内联。每个 pass 是一个独立模块可以单独运行、组合运行这也是 LLVM 能持续演进且保持稳定架构的原因。举个例子死代码消除DCE这个 pass做的事情是如果一个指令的计算结果没有任何后续使用那就把它删掉。对于一个上了优化等级的编译器来说这属于非常基础的清理。但它的实现却涉及数据流分析——编译器需要精确知道一条指令的定义是否被使用而 SSA 形式让这个判断变得极其简单。这也是 LLVM IR 设计的精妙之处。我看过很多初学者自己实现分析 pass 时第一件事就是遍历函数的所有指令这当然没错但 LLVM 提供了一整套遍历和回调机制能让你用更简洁、更安全的方式写 pass。比如在新版 pass 框架New PM里你只需要实现一个run函数框架会帮你处理模块、函数、循环的遍历顺序。这个设计把编译器的“流程控制”和“具体分析逻辑”解耦了。从学习角度我强烈建议你动手写一个最简单的 pass统计每个函数的基本块数量并打印出来。这个过程会逼着你理解 Module、Function、BasicBlock、Instruction 这四层结构以及它们之间的遍历关系。写完之后再用opt -passesyour-pass跑一下看到输出的一瞬间你对 LLVM 的理解会提升一个台阶。2.2 通过 opt 和 clang 的参数观察优化器的实际行为LLVM 提供了一些命令行工具来单独跑优化器最常用的就是opt。比如我们想对一个 .ll 文件做 mem2reg 优化这个 pass 会把allocaloadstore这种栈上变量访问提升为 SSA 寄存器是理解 SSA 与内存访问关系的最佳入门 passopt -passesmem2reg add.ll -S -o add.mem2reg.ll运行前你会看到函数体里有alloca指令运行后这些指令就消失了取而代之的是直接在寄存器间的操作。这个过程能让你直观感受到“优化”到底在做什么——它不是魔法而是可预测、可理解、甚至可手动模拟的代码变换。如果你想看整个优化流程的全貌可以这样操作clang -O2 -S -emit-llvm add.c -o add.o2.ll用 -O2 编译后的 IR 与默认 IR 对比你会发现函数可能被内联了、循环被展开了、某些计算被常量折叠了。这时候可以用llvm-dis或者文本编辑器直接打开 .ll 文件仔细看每个 pass 留下的痕迹。理解优化器对于写出高性能代码非常有帮助——你会开始意识到C 代码里的某些写法会在哪个阶段被转换成什么哪些优化该由编译器负责哪些必须手工完成。3. 从零开始构建 llvm-project一次成功的本地构建实践这个部分我打算从自己的实际操作经验出发完整走一遍构建流程。LLVM 的构建系统用的是 CMake而 CMake 的配置选项非常多不用全部掌握但有几个关键选项因为你必须理解否则构建体验会非常痛苦。我这次是在 Ubuntu 22.04 上构建 LLVM 17 的 release 分支。先说硬件构建 LLVM 非常吃内存和 CPU。官方推荐最少 8GB 内存、4 核以上实际体验下来如果你只有 4GB 内存编译到链接阶段很容易把内存耗光机器直接卡死。我的建议是至少 16GB 内存如果条件允许用 32GB 也不嫌多。磁盘空间也要留够release 版本全量构建可能需要 40GB 以上master 分支会更多。如果你空间紧张构建完后可以执行ninja clean清理中间文件只保留安装产物。首先获取源码。这里要特别提醒不要直接 clone master 分支。因为 master 是开发分支API 一直在变今天编译过明天可能就编译不过了。正确做法是获取某个 release taggit clone --depth 1 --branch llvmorg-17.0.6 https://github.com/llvm/llvm-project.git cd llvm-project--depth 1是浅克隆只拉取这一个 tag 的代码速度会快很多。后续如果想切换到其他版本可以去掉--depth 1做完整克隆或者直接重新浅克隆一个新 tag。然后是配置构建目录。LLVM 官方推荐“out-of-source”构建方式也就是在源码树外面建一个 build 目录避免污染源码。我用的是 Ninja 构建系统配合 ccache 做编译缓存第二次构建会快很多。配置命令如下cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_ENABLE_ASSERTIONSON \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang逐项解释一下这些选项的含义LLVM_TARGETS_TO_BUILD指定要生成哪些后端的 target这里只选了 X86 和 AArch64如果全部默认值会把几十种架构都编译进去构建时间会变得非常夸张。LLVM_ENABLE_PROJECTS是需要一起构建的其它子项目我选了 clang 和 lld如果你还需要 compiler-rt 或者 mlir可以在这里加。LLVM_ENABLE_ASSERTIONSON会开启断言对调试和开发有帮助代价是生成的编译器运行时会慢一些。如果只是要一个高性能的发布版编译器可以关掉它。编译器选择 clang 是 LLVM 社区的习惯做法官方对 clang 构建 clang 的支持最好但 gcc 也可以。配置完成后再执行构建cmake --build build -j $(nproc)并行编译的核数根据你的 CPU 决定。我实测在一台 8 核 16 线程的机器上构建 clanglld 大约需要 30 到 45 分钟如果只构建 LLVM 核心库大概 20 分钟。如果把几十个 target 和所有项目都编进去几个小时也不是没可能。构建成功之后下一步就是安装。这一步用到的路径要仔细规划因为 LLVM 推荐把整个工具链安装到同一个前缀目录比如/opt/llvm或$HOME/llvm-project-installcmake --install build --prefix /opt/llvm安装完成后你会看到/opt/llvm/bin目录下有 clang、lld、llvm-objdump、opt、llvm-dis 等一大堆工具。把/opt/llvm/bin加到 PATH 里就可以像使用系统编译器一样使用 LLVM 工具链了。3.1 构建选项的取舍如何定制出适合自己需求的 LLVM很多人第一次构建 LLVM 都是默认配置一把梭然后被漫长的编译时间劝退。我建议在配置前先想清楚自己的目标再决定选项如果你只是想用 clang 这个编译器那只需要构建 clang 和 lldtarget 选一个就够了甚至可以关掉 LLVM_ENABLE_PROJECTS只构建 LLVM 核心然后用系统自带的 clang。如果你要做 IR 优化相关实验比如写 pass 或做分析那么 clang 不是必须的LLVM 核心加了 opt 工具就够了构建时间会大幅下降。如果你要开发新后端那么需要对应的 target可能需要用 TableGen 生成代码还需要确保 enable-timeline 之类的调试工具打开。如果你是做性能分析建议打开LLVM_ENABLE_PERF或使用-ftime-report查看编译时间这会给你很多有用信息。另外一个常见争议是 build type。Release模式因为关闭了 debug 信息构建速度快运行时的 llvm 工具也快Debug模式适合调试 LLVM 自身代码但因为断言和 debug 符号速度和大小都受影响。做二次开发时我的经验是用RelWithDebInfo构建一次既能得到较高的运行性能又保留了基本调试信息。如果遇到难以定位的问题再单独编一个 Debug 版本。3.2 利用 ccache 加速二次构建LLVM 的代码量很大哪怕改了很小一行重新编译某几个目标文件可能就要几分钟全部重编更是灾难。这里我非常推荐 ccache。安装好之后在 CMake 配置时启用它cmake -G Ninja -S llvm -B build \ -DCMAKE_C_COMPILER_LAUNCHERccache \ -DCMAKE_CXX_COMPILER_LAUNCHERccache \ ...配置好之后第一次构建会照常编译但会把每个目标文件缓存起来。第二次构建时如果你只是改了一个头文件缓存命中率会很高整体编译时间大幅缩短。我试过在改了某个 TableGen 文件后重新构建从原来的半小时缩短到一分钟以内这在调试 pass 和修改 IR 相关功能时非常受用。4. 动手实践在 LLVM 中新增一个自定义 pass 并运行验证理论说再多不如动手写一个 pass。这里我选一个入门级的任务——写一个 function pass统计每个函数的基本块数量和指令数量并在每个函数处理完后打印输出。这个任务覆盖了 LLVM 二次开发最核心的流程编写 pass、注册 pass、构建、运行。先建立一个独立目录方便以后扩展。LLVM 的 pass 既可以放在 LLVM 源码树里一起构建也可以作为外部插件单独构建。为了减少对主树的依赖这里我选择外部插件方式更贴近实际开发中“给 LLVM 加自定义功能”的使用场景。在项目目录下创建源文件FunctionInfo.cpp#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/IR/Module.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class FunctionInfoPass : public PassInfoMixinFunctionInfoPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned numBlocks 0; unsigned numInstructions 0; for (auto BB : F) { numBlocks; for (auto I : BB) { (void)I; numInstructions; } } errs() Function: F.getName() , Blocks: numBlocks , Instructions: numInstructions \n; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getFunctionInfoPluginInfo() { return {LLVM_PLUGIN_API_VERSION, FunctionInfo, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name function-info) { FPM.addPass(FunctionInfoPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getFunctionInfoPluginInfo(); }这段代码有几个关键点需要解释。PassInfoMixin是新版 pass 框架New PM的标准基类它要求你实现run方法。FunctionAnalysisManager是一个分析结果的缓存容器虽然本例没用它但在复杂 pass 中你会频繁需要它来获取分析信息比如 TargetLibraryInfo、LoopInfo 等。PreservedAnalyses::all()表示该 pass 不修改任何 IR优化器可以放心地复用之前的分析结果这对性能很关键——如果你改了 IR 但没正确报告哪些分析失效可能触发难以察觉的错误。llvmGetPassPluginInfo是插件入口LLVM_ATTRIBUTE_WEAK允许多个插件共存而不冲突这个符号会被opt或clang在动态加载时调用。注册回调时我用的是registerPipelineParsingCallback这意味着你只能在opt的 pass 管道命令行中通过-passesfunction-info来调用它而不是默认跑在所有优化级别里。如果想默认启用需要改用registerPipelineStartEPCallback。接下来写构建文件CMakeLists.txtcmake_minimum_required(VERSION 3.20) project(FunctionInfo) 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(FunctionInfo MODULE FunctionInfo.cpp) target_link_libraries(FunctionInfo PRIVATE LLVMCore LLVMSupport)这里最关键的一点是find_package(LLVM REQUIRED CONFIG)。要让这段 CMake 找到 LLVM需要先执行 LLVM 构建时的安装步骤或者在配置 LLVM 时开启LLVM_BUILD_TOOLS然后通过-DLLVM_DIR指定 LLVMConfig.cmake 所在路径。实际构建时我用如下命令mkdir build cd build cmake -DLLVM_DIR/opt/llvm/lib/cmake/llvm .. make编译成功后会生成libFunctionInfo.so这就是可加载的 pass 插件。接下来验证我们的 pass 是否有效。写一个简单的测试函数test.cint func_a(int x) { int sum 0; for (int i 0; i x; i) { sum i; } return sum; } static int helper(int a, int b) { return a b; } int func_b(int a, int b) { return helper(a, b) 1; }先用 clang 生成 IR再通过 opt 加载插件并指定 pass 名称clang -S -emit-llvm test.c -o test.ll opt -load-pass-plugin./build/libFunctionInfo.so -passesfunction-info test.ll -o /dev/null运行后我得到的输出如下Function: func_a, Blocks: 3, Instructions: 11 Function: helper, Blocks: 1, Instructions: 3 Function: func_b, Blocks: 1, Instructions: 4从结果可以看到func_a因为含循环基本块数量是 3分别是入口块、循环体块和退出块指令数为 11说明了它内部有很多加载、计算和跳转指令。helper只有 1 个基本块、3 条指令对应ab的加法和返回。这说明我们的 pass 已经能正确遍历和统计函数了。4.1 从 FunctionPass 到 New Pass Manager新旧两套框架的差异我一开始接触 LLVM 时网上的教程大多基于旧版 Pass Manager也就是写FunctionPass子类、实现runOnFunction方法。但 LLVM 17 之后New Pass Manager 已经成为默认且唯一支持的框架所以学习时不要再看旧教程了。新框架的几个优势是按需计算分析结果、更好的缓存机制、支持 pass 管道的细粒度控制。编写新的 pass 时只需要像上面那样继承PassInfoMixin并实现run方法。新旧框架的另一个重要区别是分析结果的获取方式。旧框架靠getAnalysisT()动态获取分析数据新框架统一通过FunctionAnalysisManager的getResultT(F)获取。这意味着你在run方法里需要显式传入分析管理器这种设计避免了很多隐式依赖和跨模块耦合问题。如果你是从旧教程入门这里一定要切换思路。注册层面新框架用PassBuilder代替了原来的RegisterPass宏。上面代码中registerPipelineParsingCallback与-passes命令行对应PassBuilder::registerAnalysisRegistrationCallback用来注册自定义分析PassBuilder::registerPipelineStartEPCallback用来在所有 pass 管道的开头插入你的 pass。这些回调机制让 LLVM 可以非常灵活地组装 pass 管道但也增加了初次上手的理解成本建议动手敲一遍代码多实验几次。4.2 调试 pass 的几种实用思路写 pass 不可能一遍过常见问题包括IR 遍历范围不对、分析结果没保存、修改 IR 后 PreservedAnalyses 报告错误等。调试时我常用的方法有三招。第一招大量使用errs()打印。LLVM 自己的打印对象很方便比如打印某个指令errs() I \n;它会直接调用 Instruction 的 print 方法输出成文本 IR 的样子。对于简单逻辑这比用 gdb 快得多。第二招借助-print-after-all或-print-before-all查看 pass 执行前后的 IR。在 opt 命令里加上这些参数它会在每个 pass 执行后输出当前 IR能帮你确认 pass 是否真的改变了 IR以及改变发生在哪一步。这个功能在排查“优化结果不符合预期”时非常有用。第三招使用llvm::verifyFunction和llvm::verifyModule验证 IR 合法性。如果你在 pass 里修改了 IR修改后接着调用这两个函数可以在开发阶段就发现 IR 结构损坏避免问题被推迟到下游 pass 才暴露。检查功能在 Debug 构建下通常会自动开启但显式调用一下更稳妥。5. 探索 LLVM 的应用场景从静态分析到异构编译和编程语言设计掌握了构建和 pass 开发的基础接下来可以从项目各子系统的角度看看 LLVM 这套基础设施在真实世界里到底能做什么。它的影响范围远比“编译器”三个字大这也解释了为什么 llvm-project 有着极高的活跃度和庞大的社区。第一个很成熟的场景是静态分析和代码质量保障。Clang 自带一组静态分析器比如clang --analyze能检查出空指针解引用、内存泄漏、逻辑错误等问题。更强大的是你可以基于 clang 的 AST 编写自定义检查这就是 clang-tidy 里大量 check 的实现原理。实际做代码评审时我经常在 CI 里集成 clang-tidy把很多问题从“运行期崩溃”提前到“编译期报警”。compiler-rt 里的 AddressSanitizerASan、UndefinedBehaviorSanitizerUBSan、ThreadSanitizerTSan则是动态分析利器它们通过插桩方式在运行时检测内存错误、未定义行为和数据竞争。用 ASan 抓内存越界几乎一抓一个准代价是运行速度会慢一些但和节省的调试时间相比完全值得。第二个场景是异构计算。LLVM 对 GPU 等异构设备的支持已经相当成熟。NVPTX 后端可以把代码编译成 PTX 指令AMDGPU 后端支持 AMD 显卡。在 AI 框架里很多算子编译走的就是 LLVM 路线输入是特定 DSL 或 IR比如 MLIR输出是 GPU 机器码。MLIR 项目让 LLVM 具备了构建更上层编译基础设施的能力——因为 AI 芯片、FPGA、专用加速器的编译器层级非常多直接落到 LLVM IR 太粗糙中间需要一个分层的抽象框架MLIR 正好补上了这个空缺。你可以把 MLIR 理解为“编译器中间表示的乐高积木”想搭什么方言都行最后再通过转换下降到 LLVM IR 到机器码。第三个场景是编程语言设计。很多现代语言都选择 LLVM 作为后端比如 Rust 默认就使用 LLVMSwift 也以 LLVM 为编译基础设施。原因很简单语言设计者只需要写好前端词法、语法、语义分析生成 LLVM IR剩下的优化、目标代码生成、调试信息等都可以直接复用。这大大降低了开发一门新语言的成本。即使你不做语言设计理解这条路径也能帮你在阅读语言运行时或 JIT 引擎时更快上手。其他值得留意的应用还包括生成调试信息DWARF、CodeView 等LLVM 能生成丰富的调试信息这也是众多调试器的基础二进制工具集llvm-objdump、llvm-readelf、llc 等它们实现了对目标文件的分析和操作是逆向工程、链接器开发的重要基础设施以及安全领域的符号执行、模糊测试工具它们大量利用 LLVM IR 或 clang 的插桩能力。你不需要立刻掌握所有这些但了解 LLVM 的应用边界有助于判断“某个工具链问题是否可以用 LLVM 生态解决”。5.1 选型参考什么时候用 clang、什么时候用 opt、什么时候自己写 pass这里我把自己实际做工具链选型时的判断标准整理成一张表方便你对照决策。需求场景推荐工具原因日常编译 C/C 代码clang兼容性好报错信息友好支持静态分析和 sanitizer观察优化前后的 IR 变化opt llvm-dis可以直接在字符串或 bitcode 上运行 pass直观可控分析源码语义、做重构clang-tidy / clang-query基于 AST 和 matcher能拿到丰富的源码级信息完全自定义的 IR 变换自己写 pass 插件能精确控制要修改的指令适合深度学习编译器内部机制做链接期优化LLD LTO跨编译单元的优化在链接阶段进行LLD 是天然配合为某种特定硬件生成代码llc能直接将 LLVM IR 编译成指定后端的汇编代码这条选型路径的核心思路是尽量复用官方工具只有当现有工具不能满足你的分析或转换需求时才考虑写自定义 pass。因为自定义 pass 增加了维护成本还要与 LLVM 接口版本保持同步能不写就不写但一旦写了收益往往也很显著。6. 构建和使用 llvm-project 的常见问题排查LLVM 项目体量大、依赖复杂构建和使用过程中遇到的问题也很多。这里把我踩过的一些坑和排查思路整理出来都是高频问题。6.1 构建阶段的高频问题与解决办法内存不足导致编译中断。表现是 ninja 或 make 进程被 kill报 Killed 字样。解决办法降低并行度用-j 2或-j 4换用 Release 模式减少内存占用关闭不必要的 target 和项目考虑用多台机器做分布式编译。实际测试同样的配置在 8GB 内存机器上很容易挂在 16GB 内存机器上用-j8就没事。ld: cannot find -lz或-ltinfo之类的链接错误。这是缺少系统库导致的。在 Ubuntu 上安装zlib1g-dev和libtinfo-dev就能解决。这类问题本质是 LLVM 依赖某些基础库而系统没有安装对应开发包。TableGen 文件修改后编译报错。如果你改了.td文件构建系统会重新生成相关 C 代码报错信息往往指向生成文件而不是 .td 文件本身。建议先定位到build目录下的生成文件检查一下再对照.td修改点排查绝大多数情况是语法或类型不匹配。6.2 运行自定义 pass 时遇到的问题插件加载失败提示libLLVM版本不匹配。这是因为 pass 插件是用一套 LLVM 头文件编译的而运行opt时用的却是另一套版本。解决方法是保证插件编译时的LLVM_DIR与运行opt的 LLVM 安装一致最好使用同一个 build 目录。我一个朋友因为系统里有两个 LLVM 版本插件加载时报错看了半天最后发现就是路径指错了。pass 没有注册成功opt提示找不到-passes名字。检查插件是否成功加载可以用opt -load-pass-plugin./libFunctionInfo.so -print-pipeline-passes -passesfunction-info看是否能识别也要检查registerPipelineParsingCallback里匹配的名字是否和命令行一致。大小写和拼写都要严格一致。编译通过但实际运行结果为空。通常是你 pass 中的分析逻辑没找到目标结构比如函数被优化器内联了、循环被 unroll 了或者遍历方式少了。这时先不优化用-O0生成原始 IR 来测试或者增加打印信息观察遍历过程。6.3 使用 clang 编译时的一些提示当我想快速验证某个写法以及对应的 IR 时常用命令是clang -O0 -S -emit-llvm test.c -o test.ll-O0 会保留比较直接的代码逻辑适合分析前端行为而 -O2 则能反映优化后的真实形态。如果遇到 clang 编译时报错但 gcc 通过的情况不要急着认定 clang 有问题。clang 的诊断虽然清晰但它在 C/C 标准遵循上比较严格很多 gcc 默认忽略的未定义行为或警告在 clang 下会显式报出。这时候仔细读诊断信息往往能帮你发现源码里隐藏的问题。7. 如何持续跟进 LLVM 社区与项目动态llvm-project 是开源社区里少数始终维持高频开发和严谨治理的项目代码审查、设计讨论、版本发布都有一套成熟流程。如果你想长期参与或借鉴它的技术演进有必要了解它的动态获取渠道。首先是官方的 Discourse 论坛和邮件列表这是 LLVM 讨论的主阵地RFCRequest for Comments机制非常活跃。任何一个重大设计改动比如新 pass 框架的引入、新 target 的提案都会在这里先发 RFC接受社区评议。如果你想深入了解某项设计的来龙去脉翻 RFC 历史是绝佳路径。其次是 Phabricator 或 GitHub Pull Request。LLVM 的代码审查传统上使用 Phabricator最近逐步迁移到 GitHub PR。每次 commit 都能在 GitHub 上看到完整的 diff 和讨论。阅读一些与你关注模块相关的 commit比看教科书更能学到“为什么这样实现”的深层原因。再次是 LLVM 的年会——LLVM Developers Meeting 和 EuroLLVM。这些会议通常免费演讲视频会在 YouTube 发布涵盖很多前端、中端、后端的实践和优化案例。看这几个演讲的收获远大于零散地刷代码。我自己的经验是每次听完某个主题的演讲回来再读对应模块的源码理解深度会明显不同。如果你想动手贡献代码可以从简单的文档修正、测试用例补充开始再逐步深入到某一个小功能的实现。LLVM 社区对新手的耐心和指导一般都不错只要你的改动能通过测试和代码评审。通过这种方式你能学到非常系统的工程实践。8. 从 llvm-project 里能带走的工程思维最后想聊一点专业技术之外的东西。LLVM 项目有很多工程上的决策值得借鉴即使你将来不做编译器开发这些思维方式也能在其他软件项目中发挥作用。最明显的一点是分层与解耦。LLVM 把前端、优化器、后端拆成绝对独立的模块接口通过 IR 这个中间产物来定义。谁都可以替换其中一层而不影响其它层。实际做大型软件系统时不同模块之间定义清晰稳定的“中间语言”往往比强耦合的接口设计长命得多。写库的时候如果能找到一个“像 IR 一样稳定”的内部表达整个系统的演进空间就会大很多。其次是数据和代码分离的 DSL 设计。TableGen 用声明式描述代替手写 C让大量规律性强的代码可以被自动生成。这背后是一种“元编程”思维——与其写一万行重复代码不如写一个能生成一万行代码的小工具。在工程实践中识别出“规律性强、重复性高”的代码然后用代码生成去解决是一种很高效的策略。还有就是对兼容性和演进节奏的重视。LLVM 有明确的版本发布节奏和弃用流程即使内部 API 翻天覆地release 分支也会在相当长时间内保持兼容。这套治理规范保证了它能在规模庞大的前提下保持高质量。做开源项目或团队内部基础设施时提前设计好兼容和演进策略能省掉大量后期维护成本。和 LLVM 打交道这么久我的感觉是它是一个既庞大又精密的存在。刚接触时觉得到处都是源码不知从何看起一旦理解了 IR 和 pass 框架这条主线再去看任何模块都会有“原来如此”的感觉。希望这篇从整体到细节、从原理到实战的内容能帮你迈过最初那道坎。如果你在构建或写 pass 时遇到什么奇怪的问题很多时候去读一读 IR 输出答案就自己跑出来了。