资讯动态

LLVM 编译器框架实战:从环境搭建到自定义 Pass 开发

发布时间:2026/9/20 2:01:19 来源:尧图企业网站定制
老实说第一次把llvm-project这个仓库 clone 下来的时候大多数人都会被它的体积和复杂度吓一跳。这不是一个普通的开源项目它是一整套编译器基础设施涵盖了从底层 IR中间表示到目标代码生成、从 C/C 前端到链接器、从调试器到标准库实现的完整链路。很多人一开始只想搞懂“LLVM 到底是什么”结果一头扎进去几个月都出不来。这篇内容我就以自己的实战经验为主线聊聊 llvm-project 的核心结构、源码怎么读、环境怎么搭、以及如何在这个项目上动手写点真正有用的东西。这篇内容适合以下几类读者刚接触编译原理、想在 LLVM 基础上做二次开发的研究生或工程师工作中需要定制编译器工具链的底层开发者以及想通过阅读 LLVM 源码提升自身内功、但苦于没有系统路线图的自学者。我会尽量把那些文档里不会细讲的“坑”和“捷径”都说清楚希望能帮你少走弯路。1. 项目整体认知LLVM 不是一个编译器而是一整套编译器工厂在深入代码之前有一件事必须先把观念掰过来llvm-project并不是一个“编译器”而是一套用来构建编译器的工具箱。你可以把它理解成一个“编译器工厂”——里面存放着各种标准化零件IR 优化框架、目标描述语言、代码生成器、链接器、标准库模块等你自己选择搭配最终拼装出一款面向特定语言、特定硬件平台的编译器。1.1 核心模块到底有哪些目前官方仓库里主要包含这么几大块LLVM 核心库这是整个项目的心脏包含 IR 的定义与操作、优化 Pass 框架、目标无关代码生成器、后端支持X86、ARM、RISC-V、AArch64 等、MC 层机器码相关、JIT 相关组件。ClangC/C/Objective-C 前端。它负责把源码解析成 AST再降级成 LLVM IR。实际工作中我们经常把“Clang”和“LLVM”混着叫——严格说Clang 只是 LLVM 生态里最知名的一个前端。LLD一个高性能的原生链接器在构建 LLVM 自身时经常用来替代系统默认的ld速度提升非常明显。libc / libcabiC 标准库和 ABI 层的实现。如果你在 macOS 或某些嵌入式环境上用 Clang 作为默认编译器这两个模块就是幕后功臣。MLIR一个用于构建可复用、可扩展编译器基础设施的多层 IR 框架。最近几年 AI 编译器领域特别火很多芯片厂商的编译器工具链都是基于 MLIR 做的。LLDB基于 LLVM 技术栈的调试器。compiler-rt提供编译器运行时库比如asanAddressSanitizer、ubsanUndefinedBehaviorSanitizer等。OpenMP、polly、flang、libclc分别对应 OpenMP 运行时、多面体优化、Fortran 前端和 OpenCL 库。有一点要明确git 仓库里的代码是“全家桶”形态但实际使用时完全可以按需挑选。通过 CMake 的LLVM_ENABLE_PROJECTS选项你可以只构建 Clang 和 LLD或用LLVM_ENABLE_RUNTIMES只编译 libc。1.2 为什么 LLVM 能成为行业事实标准原因归根到底在于它的 IR 设计LLVM IR 是一种有静态单赋值SSA形式的、与具体语言和具体硬件解耦的中间表示。正因为解耦前端和后端都可以独立演进——你写好一个基于 LLVM 的后端就能自动支持所有接入了 LLVM 的前端语言。反过来你开发一个新的前端语言只要输出合法 LLVM IR就能免费获得大量成熟的优化和数十个硬件后端支持。这就解释了为什么 GPU 厂商、AI 芯片公司、甚至 FPGA 工具链都在基于 LLVM 做定制开发。它直接把“造编译器”的门槛从“从零到一”降到了“做加减法”让你只需要专注自己最擅长的部分其余的交给生态。理解了这一层你再看这个项目庞大的代码量就不会觉得劝退反而会心生敬畏——毕竟你是在和过去二十年世界顶级编译器工程师的集体智慧打交道。2. 环境准备与源码构建从 Clone 到跑通 Clang 的完整流程获取和构建 LLVM 项目本身是接触这个项目的必备功课。很多新手在这里就卡住了源码体量大、CMake 参数复杂、链接阶段的资源占用能直接把内存吃满。下面我按实际操作的顺序和你过一遍并把我踩过的坑重点标出来。2.1 拉取源码与版本选择从 GitHub 上拉取源码的方式非常简单但这里有一个关键决策选择哪个版本。git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project以 LLVM 15.0.7 为例。为什么建议用 release 版本而不是直接拉主分支如果你是第一次接触这个项目主分支每天都在变API 今天能编译明天就废弃而你查到的技术博客和论坛回答大概率基于某个 release 版本代码动不动就不是最新状态排查成本会非常大。Release 版本稳定、资料多、生态成熟适合作为切入点。如果你只是学习而非对外发布工具链浅克隆--depth 1就够了可以省下大量下载时间。注意如果你之后想跑git log查看历史提交信息浅克隆会受限可以后续按需git fetch --unshallow。2.2 CMake 配置解析这些参数到底在干什么LLVM 用的是 CMake 构建系统但直接cmake ..十有八九会失败或编译出残废版本。我经历过无数次失败后总结出一套稳妥的配置mkdir build cd build cmake ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_CCACHE_BUILDON \ -DLLVM_OPTIMIZED_TABLEGENON \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang \ -G Ninja逐个解释这些参数背后的逻辑CMAKE_BUILD_TYPERelease使用-O2优化编译自身的代码。如果你的机器内存足够强烈建议 ReleaseDebug 版本的 LLVM 编译慢、链接内存占用高但调试自己写的 Pass 会更友好。折中方案是RelWithDebInfo带调试信息且优化的构建日常开发我一般用这个。LLVM_ENABLE_PROJECTSclang;lld因为我要做编译器栈相关的开发Clang 和 LLD 是刚需。如果你只研究优化 Passclang都可以不启用构建时间能大幅缩短。LLVM_TARGETS_TO_BUILDX86;AArch64限制只生成 X86 和 ARM 的目标后端避免把几百个后端都编译一遍。这个参数直接决定你只需要一种架构就可以大幅缩减编译时间。LLVM_CCACHE_BUILDON启用 ccache 编译缓存反复修改源码重编时的提速效果极其明显。第一次构建会慢一点缓存未命中第二次起就是质的飞跃。LLVM_OPTIMIZED_TABLEGENON用优化后的 TableGen 工具生成代码能显著缓解 TableGen 执行慢的问题对调试 TableGen 定义文件尤其重要。如果你是在 Linux 上构建且内存低于 16GB我个人还建议加入-DLLVM_PARALLEL_LINK_JOBS2来限制并行链接任务数。否则 LLVM 链接时会一下子起很多进程内存瞬间爆掉直接 OOM。2.3 编译过程中的三个大坑第一个坑链接内存不足。这是最经典的问题。LLVM 自带的几个核心库非常庞大链接阶段非常吃内存。我曾在 8GB 内存的机器上试过链接libLLVM.so时直接触发 OOM 被内核杀死。除了上面提到的限制链接并发数还可以切换链接器。用 lld 替代系统默认的 BFD ld 或者 GNU gold内存和速度双双优化-DLLVM_ENABLE_LLDON如果你是用 Clang 编译加这个参数配合 LLD整个构建过程会顺畅很多。第二个坑磁盘空间不够。Release 构建 Clang LLD 完整编下来磁盘占用轻松突破 30GB加上源码和缓存建议至少留出 50GB。如果空间紧张可以不用 ccache并且只构建必要的目标后端。第三个坑系统 GCC 环境不兼容。明明是 Ubuntu 20.04 之前的系统用的 GCC 版本过老编译 LLVM 新版时经常报一些 C 标准库相关的晦涩错误。解决办法很简单装一个版本合适的 Clang 当编译器来编 LLVM或者升级 GCC。我一般喜欢用clang lld ccache这个组合实测下来稳。构建完成后验证一下./bin/clang --version ./bin/llvm-as --version能正常输出版本信息说明你的基础环境已经跑通了。3. 核心源码结构与源码导航读懂 LLVM 项目的高效路线很多人拿到仓库后第一反应是想通读源码。坦白讲通读是不可能的也没必要。更高效的方式是按图索骥搞清楚每个目录负责什么然后针对你的问题定点深入。3.1 顶层目录到底谁是谁在llvm-project/llvm里按目录做一次快速地图梳理include/llvm和lib/核心代码区里面按功能又分了IRIR 的定义与基本操作、PassesPass 管理器与新 Pass 的注册、Transforms各类优化 Pass、CodeGen目标代码生成、Target各硬件平台的后端描述、MC机器码层、Analysis分析框架等。tools/各可执行工具比如opt优化 Pass 的试验场、llc把 IR 降级为汇编、llvm-as文本 IR 转 bitcode、llvm-disbitcode 转文本 IR等。unittests和test单测与端到端测试采用litFileCheck框架。你写的 Pass 如果要做完整测试这两个目录就是你的归宿。utils/辅助脚本比如在git commit时自动运行clang-format的 hook。cmake/构建相关的 CMake 模块。在llvm-project/clang下include/clang和lib/同理包含AST抽象语法树、Sema语义分析、CodeGen从前端 IR 生成 LLVM IR、Driver命令行驱动等模块。首次接触不需要全部看完抓住主干即可。3.2 LLVM IR整个项目的中枢神经LLVM IR 的文本表示很容易上手尝试用下面命令看看一个简单 C 函数到底变成了什么// test.c int add(int a, int b) { return a b; }clang -S -emit-llvm test.c -o test.ll cat test.ll你会发现原来带类型的变量经过 IR 中间表示后变成了无类型的虚拟寄存器%0、%1操作变成了类似add i32 %0, %1这样的三地址码形式。指令上能看到nsw这种 no-signed-wrap 的标志位这是给优化 Pass 的提示——不要产生带符号整数溢出的行为。理解和熟悉 IR 是后续写 Pass 的前提相当于你要学会一门“中间语言”。如果一时觉得抽象可以想象一台虚拟机LLVM IR 就是这台虚拟机的汇编语言但这种汇编语言是为优化和移植而设计的。3.3 TableGen写编译器也能生成代码TableGen是 LLVM 里一个非常独特的子系统。如果你想给 LLVM 添加一个新的 CPU 指令或者定义一个新的目标特性你不应该手写 C 代码而是定义.td文件。然后用llvm-tblgen生成对应的 C 头文件、枚举、匹配器等。一个最小的.td片段下面这样def HasAVX2 : SubtargetFeatureavx2, HasAVX2, true, Enable AVX2 instructions;这段代码看似简单但它生成的不仅仅是字符串常量还会联动指令选择表、代码生成匹配器、汇编器/反汇编器等大量 C 代码。TableGen 的核心理念就是“声明式描述自动生成实现”让你不用维护几千行重复代码。这是 LLVM 可以轻松支持几十个后端架构的秘密武器之一。3.4 一个值得关注的实际案例llvmpipe 与 256 位向量很多人知道llvmpipe它属于 Mesa 3D 项目里的软件渲染器。但你没意识到的是它内部大量使用 LLVM 作为 JIT 引擎把图形着色器编译成当前 CPU 能够执行的高性能机器码。你过来时带的热搜里出现llvmpipe (llvm 15.0.7, 256 bits)就是在说它用 LLVM 后端把着色器转成了支持 AVX2 的 256 位 SIMD 指令。这很能说明 LLVM 的价值它不仅存在于离线编译工具链还可以作为运行时 JIT 编译引擎嵌在图形栈、数据库比如一些现代 OLAP 引擎做表达式 JIT和 AI 推理引擎里。LLVM 的MCJIT、ORC和ORCv2模块就是专门为这类场景设计的。你在llvm-project里逛久了你就会发现它绝不是一个“上古时代的编译代码库”而是一个永不过时的编译器基础设施。4. 动手实践用 LLVM Pass 框架定制自己的编译优化纸上得来终觉浅。我的建议是第一次动手不要想那些宏大的目标就写一个最简单的 Function Pass然后通过opt在 IR 上运行它。这个过程能让你对 LLVM 的编译流程、IR 结构与 Pass 生命周期有一个立体认知。4.1 环境准备与工程骨架最简单的实验可以不新建独立项目直接在 LLVM 源码树里加一个 Pass但那样污染代码树不利于版本管理。更推荐的方式是做一个独立的 out-of-tree Pass 插件。这里用新 Pass 管理器的PassPlugin方式演示。创建如下目录结构demo-pass/ ├── CMakeLists.txt └── DemoPass.cppCMakeLists.txt内容如下cmake_minimum_required(VERSION 3.20) project(DemoPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) include_directories(${LLVM_INCLUDE_DIRS}) separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS}) add_library(DemoPass MODULE DemoPass.cpp) target_link_libraries(DemoPass PRIVATE LLVM) target_compile_definitions(DemoPass PRIVATE ${LLVM_DEFINITIONS_LIST})这文件里有几个关键点find_package(LLVM REQUIRED CONFIG)需要你的 LLVM 安装路径能被 CMake 找到MODULE表示生成动态库也就是插件形态target_link_libraries(DemoPass PRIVATE LLVM)会链接整个 LLVM 库。你可以先用上面那个 build 目录里的 LLVM 作为依赖我这里把构建好的 LLVM 路径赋值给环境变量后面编译插件直接用它。4.2 一个统计函数指令数的 Pass写一个最简单的 Pass作用是在每次函数 visit 时统计该函数基本块和指令数量并用errs()打印出来。#include llvm/IR/Function.h #include llvm/IR/Instructions.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 DemoPass : public PassInfoMixinDemoPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned instCount 0; for (BasicBlock BB : F) { for (Instruction I : BB) { instCount; } } errs() Function F.getName() has instCount instructions\n; return PreservedAnalyses::all(); } }; } // namespace几处容易产生疑问的点说明一下PassInfoMixinDemoPass是新 Pass 管理器NewPM的基类它让 Pass 能被自动注册、序列化并复合进 Pass 管线。run返回PreservedAnalyses表示这个 Pass 哪些分析结果被保持了。如果你没有修改任何 IR就返回PreservedAnalyses::all()告诉框架所有分析缓存都有效——这是优化性能的重要细节如果修改了 IR你就要想清楚哪些分析被破坏了。F.getName()拿到的是函数名包括重载修饰后的形式如果函数没有名字比如匿名函数getName()返回空字符串此时可以用F.hasName()来判断。4.3 新旧 Pass 管理器的注册差异很多老教程里用的是旧的legacy::FunctionPass和RegisterPassDemoPass X(demo-pass, ...)。新 Pass 管理器的注册方式完全不同我们需要导出插件入口函数extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, DemoPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name demo-pass) { FPM.addPass(DemoPass()); return true; } return false; }); }}; }这里有几点需要特别注意LLVM_ATTRIBUTE_WEAK是为了防止插件被同时静态链接进主程序时出现重复符号定义。注册回调registerPipelineParsingCallback让opt可以通过-passesdemo-pass识别你的 Pass。如果注册失败或者 Pass 名字对不上opt 就会报错“unknown pass name”。如果你还想让这个 Pass 默认加进-O2的标准管线也可以注册registerPipelineStartEPCallback或registerOptimizerLastEPCallback但第一次先把基本跑通就行。在CMakeLists.txt旁边写好源文件后用如下方式编译插件export LLVM_DIR/path/to/llvm-project/build/lib/cmake/llvm cmake -S . -B build -DLLVM_DIR$LLVM_DIR cmake --build build编译成功会生成build/DemoPass.so或build/libDemoPass.so。4.4 用 opt 和 clang 实际验证效果先准备一个 C 文件// sample.c int add(int a, int b) { return a b; } int main() { return add(1, 2); }然后用 clang 生成 IRclang -S -emit-llvm sample.c -o sample.ll用你编译好的插件跑一下opt -load-pass-plugin./build/DemoPass.so -passesdemo-pass sample.ll -S你会看到输出里面穿插着类似Function add has 3 instructions Function main has 5 instructions如果你没有用-load-pass-plugin加载插件而直接用-passesdemo-pass就会报错。这个问题我遇到过很多次所以建议先在命令行上确认opt -load-pass-plugin./build/DemoPass.so -passesdemo-pass sample.ll能正常通过再往下做。5. 常见问题与排查技巧我踩过的那些坑和解决办法写 Pass 和构建 LLVM 的过程绝非一帆风顺。我把高频问题整理成一份速查表下面每一条都是实际踩过的。症状可能原因解决办法opt加载插件后报unknown pass name demo-pass插件注册 PipelineParsingCallback 名字不匹配或插件没有正确导出llvmGetPassPluginInfo检查回调里比较的字符串用opt -load-pass-plugin... -passeshelp查看已注册 Pass用nm -D查看导出符号插件编译报一堆undefined reference to ...没有链接 LLVM 库或 LLVM 库版本和头文件版本不一致检查target_link_libraries(DemoPass PRIVATE LLVM)确认LLVM_DIR指向的构建树和你用的 clang/opt 相同llvm-config找不到环境变量没配好构建完 LLVM 后将build/bin加入PATHbuild/lib/cmake/llvm写入LLVM_DIRLLVM_ENABLE_PROJECTS里加clang后构建失败项目间的依赖顺序或内存不足建议先只构建clang;lld控制并行任务数使用-DLLVM_PARALLEL_LINK_JOBS2Pass 运行后没有任何输出Pass 没执行或函数被优化掉了确保-passesdemo-pass在正确优化层级之前运行给函数加__attribute__((noinline))或降低优化级别修改 IR 后返回PreservedAnalyses::all()导致后续优化误判没有正确报告分析被破坏如果修改了 CFG 或删除了指令应该返回PreservedAnalyses::none()或具体说明哪些分析被破坏5.1 链接内存不足的终极解法链接阶段是资源杀手。在构建所有库的过程中最“凶残”的场景是生成libLLVM-15.so这个共享库。如果你内存不足可以放弃共享库构建改为静态链接一切-DLLVM_BUILD_LLVM_DYLIBOFF -DLLVM_LINK_LLVM_DYLIBOFF这个配置会减少链接时峰值内存但会增加每个二进制的大小。如果你只是做开发而不是发布一个完整的工具链这样反而更省事。5.2 调试 LLVM 问题的几个姿势翻看 LLVM 源码自带的 Log 输出非常有价值。很多 Pass 本身支持 debug 输出编译 LLVM 时加上-DLLVM_ENABLE_ASSERTIONSON -DCMAKE_BUILD_TYPEDebug构建 Debug 版本后使用opt -debug-onlypass-name ...可以输出极详细的内部调试信息。注意它要求-debug-only...这个参数在opt之后且插件也需要是 Debug 版本否则字符串匹配可能失效。如果你要跟踪一个 Pass 对 IR 的影响最朴素但有效的手段是在 Pass 里打印 IR 前后快照errs() Before: F \n; // do something errs() After: F \n;5.3 区分“你的 Pass 的问题”和“LLVM 的问题”遇到异常行为首先要确认 LLVM 本身的正确性不受影响。最简单的办法是先用不带插件的opt对同样的 IR 跑一遍标准-O2确认输出正常再叠加自己的 Pass。如果加了 Pass 才出问题那大概率就是你 Pass 的 IR 修改逻辑有误这时要仔细检查 basic block 和 instruction 的迭代器是否在遍历中失效了。C 中向Instruction列表插入/删除指令会导致迭代器失效这在 LLVM 中同样是高频地雷。6. 学习路线与后续扩展从能跑通到真正玩明白写到这里你已经在 LLVM 上跑通了自己的第一个 Pass这就算正式入门了。接下来的路怎么走我结合自己的经验给出几条建议。6.1 从使用者到贡献者LLVM 社区的代码评审文化非常强调“small patch”。第一次贡献不建议一上来就提交一个能进入标准优化管线的巨型 Pass哪怕功能完整也可能因为缺少测试、缺少文档或格式问题被打回。可以从test/目录下的lit测试用例写起或者修一个文档注释、处理一个简单 TODO。你会在评审过程中学到大量编码风格和设计取舍这远比你自己闷头写代码收获大。同时善用llvm-dev邮件列表和 Discourse 论坛。这里提问时记住一个原则同时附上尽量小的复现用例和你的 LLVM 版本信息这个问题基本就成功了一半。技术圈很少有人愿意回答一句“我的 Pass 不工作”的提问但几乎所有人都愿意讨论一段可复现的 IR 为什么会变坏。6.2 我个人最推荐的学习路径如果你的目标是成为编译器领域的专业开发者我推荐按这个顺序进阶吃透 LLVM IR 的语义读官方文档LLVM Language Reference Manual把add、load、store、br、phi等指令搞明白。可以做练习手写 IR然后用lli解释执行。理解编译流程一条 C 代码是如何一步步变成汇编的深入研究 Clang 的前端到 IR 的降级过程、LLVM IR 的各种优化 Pass 分别做了什么。选一个目标架构做后端挑一个简单的后端比如旧版 X86 或 RISC-V跟一遍SelectionDAG的指令选择流程。这块是最难啃的但啃下来之后你对整个编译器生态的理解会完全不同。研究 MLIR如果你对 AI 编译器、DSL 编译器感兴趣MLIR 几乎是绕不开的核心。它把 IR 分成多层抽象极大简化了领域专用优化的开发流程。关注 JIT 与运行时如果你对数据库向量化执行、动态语言 JIT 感兴趣那么ORC相关接口是最好的学习材料包括llvm-jitlink工具和Kaleidoscope教程。我遇到过一个很有意思的项目用 LLVM 的ORC在查询执行引擎里做表达式 JIT把原本逐行解释执行的 SQL 表达式改用 JIT 编译成机器码性能提升好几个量级。这类玩法恰恰是 llvm-project 能支撑的典型现代应用场景。6.3 一个可以继续扩展的方向自定义 IR 分析与优化写 Pass 不局限于某个简单的打印统计。你可以尝试写一个真正的分析 Pass比如统计循环层级、分析函数调用关系并输出调用图或者做一个“把某个函数内所有add转换为sub”的 IR 改写 Pass。再进阶一点尝试做跨函数分析使用ModuleAnalysisManager并在多个函数之间共享分析结果。这会迫使你去了解AnalysisManager的缓存机制和分析依赖模型。再往后你可以研究一下 Polly 或 MLIR 的 Affine 框架在多面体模型上做循环变换。这条路虽然在工业上落地成本较高但学术价值和应用前景都可观很多编译器优化论文里的实验就是在这个框架下做的。7. 最后再说几句心里话我在 llvm-project 上投入的时间越多越觉得它是一个被严重低估的“宝藏仓库”。很多人一看到 C 模板和巨型 CMake 配置就畏难而退但实际上只要找到正确的抓手它的学习曲线完全可以被拉缓。从构建环境开始到读懂 IR再到写自己的第一个 Pass每一步都会带来实实在在的成就感。这个过程也会让你对编译器、对程序语言背后发生的事情有完全不同的理解而这种理解在工程实践中是很大的加分项。如果你想开始建议今天就下决心把源码拉下来、把构建跑通哪怕只编译llc并用它生成一句汇编也算迈出了最难的一步。后面的事情会越来越顺。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价