LLVM这个项目,我从第一次编译它的懵圈状态,到现在能熟练在它上面做各种二次开发,中间踩过的坑比写过的代码还多。很多人一听到llvm-project,第一反应是"哦,就是那个编译器",但真正进去之后才发现,它根本不是一个简单的编译器项目,而是一整套能让你自定义编译器、静态分析工具、代码优化管线、甚至自己造一门编程语言的基础设施。这篇文章我打算从一个过来人的角度,把这套东西掰开揉碎了讲清楚——它到底在解决什么问题、仓库里那些子项目分别是什么、怎么从零开始把它构建出来、IR到底是什么、以及如何动手写一个自己的Pass扩展它的能力。无论你是想入门编译器开发、做代码分析、还是给AI芯片写编译器后端,读完这篇应该能少走很多弯路。
1. 别被名字骗了:LLVM真正在做的事
1.1 "Low Level Virtual Machine"这个名字的误会
2000年,Chris Lattner在UIUC开始这个项目时,给它起了个名字叫Low Level Virtual Machine,也就是"底层虚拟机"。所以LLVM最早确实是想做一个虚拟机,类似Java虚拟机那样,只不过面向的是底层指令。但后来的发展完全超出了预期——这个项目变成了一个编译器基础设施,名字却一直沿用了下来,成了历史遗留问题。
我在很多技术交流场合发现,仍然有人把这个缩写当作"虚拟机"来理解,然后问出"LLVM能跑什么字节码"这种问题。实际上今天的LLVM已经和虚拟机的概念关系不大,它更像是一个"编译器的工具箱",或者按官方文档的说法,是一个"模块化、可重用的编译器与工具链技术的集合"。
理解这一点很重要,因为它决定了你后续怎么用它。如果你拿它当普通编译器用,那Clang只是它表面的一个前端;如果你拿它当可执行文件去运行,那只是用了不到它全部能力的一小部分。真正的价值在于,它暴露了成体系的C++库API,让你可以像搭积木一样,把"解析源代码""优化中间表示""生成机器码"这些阶段组合成自己的工具。
1.2 把"编译"这件事拆成三段
要理解LLVM的设计哲学,得先看传统编译器的痛点。
在LLVM出现之前,写一个编译器通常是这样的:你拿到一种编程语言,比如C语言,然后要为每一个目标平台分别写一套完整的前端、优化器、后端。GCC就是这么干的。语言有M种、目标平台有N种,你就要做M乘以N份工作,而且每份工作都高度耦合,语言特性跟指令选择逻辑搅在一起,牵一发而动全身。
LLVM把这条链路彻底解耦成了三个阶段:
- 前端(Frontend):把源代码解析成抽象的中间表示IR(Intermediate Representation)
- 中端(Optimizer):对IR做各种与目标平台无关的优化
- 后端(Backend):把优化后的IR翻译成特定目标架构的机器码
这样做的好处非常直观:新增一种语言只需要写一个新的前端,把语法翻译成LLVM IR;新增一种目标平台只需要写一个新的后端,把IR翻译成对应指令集。M加N的工作量,而不是M乘以N。
且慢,这个"中间表示"的概念,恰恰是整个LLVM能走红的关键。它不是某一种语言特有的,也不属于某一种CPU特有的,它是所有前端和后端之间的"普通话"。前端负责各种方言,后端负责各种土话,IR就是中间的翻译层。
1.3 谁在用它:从苹果到英伟达到你自己
你可能已经在不知情的情况下使用了LLVM。苹果的Xcode从Xcode 5开始默认就用Clang而不是GCC;Android NDK的编译器链就是基于Clang和LLD的;游戏主机、路由器固件、各种嵌入式设备的编译器链也大量使用它。
但更值得你留意的是那些"不像编译器"的用法。英伟达的CUDA编译器、AMD的ROCm编译栈、各种AI加速芯片的编译器研发,底子几乎都建在LLVM和它的子项目MLIR上。苹果的Swift语言编译器、Rust的官方编译器rustc,也是把前端接入了LLVM后端。
也就是说,哪怕你不打算做一个传统意义上的编译器,只要你想快速实现一种新的编程语言、想解析分析现有代码、想给特定硬件生成优化代码,LLVM都可能是最合理的起点。这也是我为什么强烈建议每个系统软件方向的工程师,都应该花一段时间把llvm-project摸透的原因。
2. 从仓库布局看清llvm-project的家底
2.1 一个仓库装下十几套独立工具链
llvm-project这个仓库之所以叫"项目集",是因为它把整个LLVM生态的所有子项目都收纳成了一个monorepo。2019年之前,各个子项目分布在不同的Git仓库,版本同步非常痛苦,后来谷歌和苹果牵头把它们合并到了一起。合并后的仓库体积巨大,我第一次做完整clone的时候,光Git历史就下载了很久。
这个仓库的一级目录,每一个都可以看作一个独立的项目,有些甚至有自己的邮件列表、自己的发布节奏、自己的代码评审流程。初学者最容易犯的错误,就是以为只要进了llvm-project仓库,所有东西都跟"编译C语言"有关。其实里面有很多东西完全不在编译主链路上。
下面是仓库里最核心的几个一级目录,我按"和日常工作关系的紧密程度"列一个表:
| 目录 | 角色定位 | 典型使用场景 |
|---|---|---|
| llvm/ | 核心库:IR、优化器、目标后端、基础工具 | 写Pass、做代码分析、自定义后端 |
| clang/ | C/C++/Objective-C编译器前端 | 编译C/C++代码、做AST层面的分析与改写 |
| clang-tools-extra/ | 基于Clang的附加工具 | clang-tidy静态检查、clangd语言服务、clang-format格式化 |
| lld/ | 链接器 | 替代GNU ld/gold,链接速度极快 |
| lldb/ | 调试器 | 替代GDB,调试现代C++和Swift等语言 |
| compiler-rt/ | 运行时库 | AddressSanitizer等内存检查工具、内置函数实现 |
| libc++/ libc++abi/ | C++标准库实现 | 使用现代C++特性的运行时支持 |
| mlir/ | 多级IR编译器基础设施 | AI芯片编译器、领域特定编译器 |
| flang/ | Fortran前端 | 科学计算Fortran编译 |
| openmp/ | OpenMP运行时 | 并行计算 |
| polly/ | 多面体优化 | 针对循环嵌套的自动并行化/向量化优化 |
| bolt/ | 二进制优化工具 | 对编译完的二进制做性能调优 |
| libunwind/ | 栈回溯库 | C++异常的栈展开支持 |
这里面我需要特别强调一下llvm目录和其他目录的区别。你可能会困惑:仓库根目录下就有个llvm文件夹,这个llvm和整个llvm-project是什么关系?实际上,狭义的LLVM核心库就在这个llvm目录里,包括IR的定义、优化Pass、代码生成器、以及opt、llc、llvm-as这些命令行工具。Clang、LLD这些是"外围项目",它们依赖llvm核心库,像插头一样插上去。所以如果你想做和优化、目标代码生成相关的工作,重点精力要放在llvm/这个目录。
2.2 Clang:不仅仅是一个编译器前端
Clang是LLVM生态里最广为人知的前端,它负责C、C++、Objective-C这些语言解析为LLVM IR。但Clang能做的事情远不止"编译生成可执行文件"。
Clang对外暴露了libclang和Clang的C++ API,这意味着你可以不用自己写词法和语法分析器,就能解析任何C/C++代码,拿到完整的AST,做代码重构、静态分析、代码补全。clangd就是这样一个基于Clang的语言服务器,很多现代IDE的后台都是它。
还有clang-tidy,它是一堆静态检查规则的集合,能在编译之前发现代码里的潜在问题。我在实际项目里经常用它做代码评审的自动化关卡,配合clang-format统一代码风格,效果相当好。如果你写过GCC插件就会知道,做这种工具在GCC生态里是很痛苦的,而Clang把这些工具链的能力作为一等公民暴露出来,这是它能在工具链市场快速占领份额的重要原因。
clang-tools-extra目录里的东西,绝对值得你去翻一翻。很多初学者只盯着clang目录看,忽略了旁边还有这样一个宝库。
2.3 运行时阵营:编译器不只是编译期的事
很多人忽略的一个事实是:编译器项目往往还附带一堆运行时库。llvm-project里compiler-rt、libc++、libc++abi、libunwind这些目录,都属于运行时阵营。
compiler-rt里最出名的是Sanitizer系列,这是编译器自动插桩代码所依赖的运行时库。比如AddressSanitizer(ASan)能在程序越界访问内存时立刻报错并打印调用栈,UBSan能检查未定义行为。它们的使用方式往往是编译时加一个-fsanitize=address,翻译出来的代码就会调用compiler-rt里对应的运行时代码。这些工具在C/C++项目调试中几乎是必备的,我在自己的项目里一直默认开ASan,抓出过无数莫名其妙的越界崩溃。
libc++则是C++标准库的另一种实现。Linux上大家默认用的是GCC的libstdc++,但libc++对C++标准的支持更快更干净,特别是C++17之后很多新特性,libc++跟进得都很积极。如果你想给某个平台定制C++运行时,用它比改动libstdc++友好太多。
2.4 面向未来的MLIR和底层倚仗的LLD
MLIR(Multi-Level Intermediate Representation)是llvm-project里相对年轻但势头最猛的一个项目。它出生于2019年,目的是设计一组可自由组合的多级中间表示框架,专门解决编译器在不同抽象层级之间的表示匹配问题。
我看过不少人不理解为什么有了LLVM IR还非要搞MLIR。一个很现实的原因是:LLVM IR的抽象层级过于接近机器码,不适合表示深度学习计算图这类高层结构。TensorFlow对着LLVM IR干瞪眼,无法有效优化矩阵乘法、卷积这些高层算子。MLIR允许你定义自己的方言Dialect,让计算图、张量算子、循环结构、向量化这些层级各自表达,再逐层Lower到LLVM IR,最终生成机器码。今天主流的AI编译器,几乎都在MLIR之上构建。
LLD则是一个链接器,它的核心卖点就一个字:快。同样的项目,GNU ld链接可能要几十秒,LLD往往几秒就跑完了。在大型软件迭代中,这个提速带来的体验提升是质变的。我现在构建任何C++项目,只要条件允许,都会把链接器换成LLD,配合-fsplit-dwarf,链接时的等待感基本消除。
3. 亲手把它编出来:构建llvm-project的完整姿势
3.1 构建前的硬件准备和心态建设
很多人第一次构建llvm-project,被它庞大的编译时间和磁盘占用吓住。我这里先给你一个心理预期:全套默认配置编译下来,大约会消耗40到60GB磁盘空间(取决于是Debug还是Release),用一台8核16线程的现代CPU,Release构建大概需要40到60分钟;Debug构建可能要2到3个小时以上。
这还没算你后面改代码的增量编译时间。所以我的建议是:磁盘至少留80GB,内存至少16GB,能用SSD就用SSD,机械硬盘编译LLVM会让人怀疑人生。另外,编译时-j参数不要无脑拉满,内存不够的话Ninja容易被系统OOM杀死,一般来说并行度设为逻辑核心数的一半到三分之二比较稳妥。
如果你觉得全量构建太慢,完全可以只构建你需要的子项目,或者先用预编译包配合源码做局部开发,这是很多LLVM开发者的日常做法。
3.2 一次标准构建的完整命令拆解
我平时用的构建命令大概是这样的形式,先clone再配置再编译:
# 获取代码,--depth=1只拉最新提交,能省大量时间和流量 git clone --depth 1 https://github.com/llvm/llvm-project.git cd llvm-project # 配置构建系统,-G指定生成Ninja构建脚本 cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld;clang-tools-extra;compiler-rt" \ -DLLVM_TARGETS_TO_BUILD="X86" \ -DLLVM_ENABLE_ASSERTIONS=ON # 开始构建,nproc获取逻辑核心数 cmake --build build -j $(nproc)这里-S llvm -B build的含义要解释一下:-S指定源码根目录,这里填的是llvm,因为要构建的核心项目源码在这个目录下,Clang这些子项目是作为"额外项目"参与的;-B指定构建目录,所有中间文件和生成文件都放在build下面。把构建产物和源码分开是CMake的标准实践,千万不要直接在源码目录里就地构建,否则后面清理和切换配置会非常痛苦。
构建完成后,验证一下是否成功:
build/bin/clang --version echo 'int main(){return 0;}' > /tmp/hello.c build/bin/clang /tmp/hello.c -o /tmp/hello /tmp/hello && echo "OK"如果输出了一行"OK",说明整个工具链已经能正常工作了,可以从零开始编译C代码了。
3.3 这些CMake开关到底在控制什么
上面的命令里用了几个CMake变量,很多新手对这些变量一知半解,随意改动导致各种编译失败。我这里逐个说清楚。
CMAKE_BUILD_TYPE控制优化级别和调试信息。Release会开-O2,编译出来的Clang效率高,适合日常使用;Debug则不做优化,便于用gdb调试LLVM自身的代码,但速度极慢,而且生成的文件巨大。我平时以Release为主,只有在需要打断点调试Pass逻辑时才构建Debug。
LLVM_ENABLE_PROJECTS用来指定除了llvm核心之外,还要一起构建哪些子项目。注意,这里填的每一个项目都会显著拖长编译时间。如果只是打算用Clang编译C代码,-DLLVM_ENABLE_PROJECTS=clang就够了;如果要做链接器的替换,再加lld;compiler-rt这些运行时组件虽然有用,但不是每次都得编。这个变量还区分了"编译期组件"和"运行时组件",运行时组件(libc++、compiler-rt、libunwind)在较新版本里往往需要通过LLVM_ENABLE_RUNTIMES来构建,而不是LLVM_ENABLE_PROJECTS。
LLVM_TARGETS_TO_BUILD控制后端要支持哪些目标架构,这个变量最容易被忽视,但实际上对编译时间的影响极大。LLVM默认会构建它支持的所有目标,包括X86、ARM、AArch64、RISC-V、PowerPC等等,每一个目标后端都有一堆代码生成逻辑。如果确定只需要X86,就把其他目标全部砍掉,能省下大量时间。如果你做的是交叉编译或者嵌入式开发,就按需加上ARM;AArch64等。
LLVM_ENABLE_ASSERTIONS则决定LLVM自身代码里的assert宏是否生效。如果你是开发LLVM功能、写Pass、调试后端,强烈建议打开;如果只是使用Clang做日常编译,可以关闭以获得更好的性能。
3.4 构建必踩的三个坑
构建LLVM有一个特点:报错信息往往比较隐晦,不像普通项目那样一行错误就能定位问题。我总结三个我栽过跟头的地方:
第一个坑是系统GCC版本太旧。LLVM要求编译器支持C++17,如果系统默认GCC旧于7.1,配置阶段会直接报错提示编译器不支持相关特性。解决办法是用Clang本身来编译LLVM(反正Clang也可以编译自己),或者安装一个较新的GCC再指定CC和CXX环境变量。
第二个坑是磁盘空间在编译过程中耗尽。LLVM的编译会产生大量中间文件、静态库和二进制产物,Debug模式尤其严重。我在一次Debug构建时,发现竟然吃掉了80多GB。这个坑最难受的地方在于它不是一开始就爆,而是编到一半才提示"No space left on device",重来一遍又浪费时间。所以开始之前一定用df -h确认磁盘余量。
第三个坑是默认Python版本问题。LLVM的测试框架和部分工具脚本依赖Python3,如果系统里默认Python指向Python2,会有一堆诡异错误。现代Linux发行版一般没问题,但如果你在旧系统上构建,记得确保python3命令可用。
如果你打算长期开发LLVM,建议安装ccache并开启缓存:
cmake -G Ninja -S llvm -B build \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -DCMAKE_C_COMPILER_LAUNCHER=ccacheccache在多轮清理重建时的加速效果非常恐怖,某些场景下能把重复编译时间缩短到十分之一。
4. 中间表示IR:整个项目的命脉
4.1 为什么非要一个IR不可
我一直认为,没有理解IR,就不可能真正理解LLVM。IR是LLVM生态的血液循环系统,前端负责生产它,优化器负责加工它,后端负责消费它。
如果把编译比作一个跨国物流系统:源代码是在A国写的"委托书",机器码是B国能识别的"货物清单",那IR就是两国之间的"标准集装箱"。无论A国的委托书用什么语言写成,都统一装箱成标准集装箱;无论B国怎么处理货物,都从标准集装箱里提取。这样一来,物流公司只需要维护"各国语言到集装箱"的翻译员,以及"集装箱到B国仓库"的拆箱员,而不需要给每一对"国家-到-国家"的组合都配备专门的翻译。
更关键的一点是,IR让"优化"这件事有了落脚点。在没有IR的编译器里,优化逻辑要针对源语言写、针对目标机写,两头都不讨好。有了统一的IR,优化器只需要处理一种结构规范的数据,语言无关的优化(常量传播、死代码消除、函数内联、循环展开)就能把各种语言一视同仁地优化。
4.2 SSA和基本块:IR的语法骨架
LLVM IR的数学基础是静态单赋值形式(Static Single Assignment,SSA)。
SSA的核心规则是:每个变量只能赋值一次。你可能会想,这一条规则有什么用?它让数据之间的依赖关系变得显式了。如果一个变量只被赋值一次,那么到底谁在用它的结果、它依赖于谁的赋值结果,都一目了然,后续的寄存器分配和许多优化都因此简单了很多。
在SSA的形式下,IR程序的基本单位是基本块(Basic Block)。一个基本块是一条线性执行的指令序列,只有一个入口和一个出口,函数则由若干基本块组成控制流图(CFG)。分支、循环、函数调用这些控制流变化,都会表现为基本块之间带条件的跳转边。
我看过不少初学者试图用C语言的直觉去阅读IR,看到一堆%1、%2这种临时虚拟寄存器就觉得头大。其实掌握一个规律就好:虚拟寄存器名只是编号,重点是每条指令的操作和它引用的依赖关系。
4.3 用一段C代码看懂IR和优化前后的变化
耳听为虚,动手操作一遍比看书强一百倍。我们把下面这段C代码转成IR看看。
// test.c int abs_sum(int a, int b) { int x = a - b; if (x < 0) x = -x; return x; }在构建好的LLVM目录里执行:
build/bin/clang -O1 -S -emit-llvm test.c -o test.ll生成的test.ll里,关键部分长这样:
define i32 @abs_sum(i32 %a, i32 %b) { %sub = sub i32 %a, %b %cmp = icmp slt i32 %sub, 0 br i1 %cmp, label %if.then, label %if.end if.then: %neg = sub i32 0, %sub br label %if.end if.end: %x = phi i32 [ %sub, %entry ], [ %neg, %if.then ] ret i32 %x }我们来逐行理解这份IR。%sub用sub指令计算a - b,这里的i32表示32位整数类型。%cmp用icmp slt做有符号小与比较。br i1 %cond, label %A, label %B是条件跳转,如果条件为真跳到if.then基本块,否则跳到if.end。
重点看phi指令,它是SSA的一个特色指令:根据控制流的来源选择某个值。如果从entry基本块来,%x取值%sub;如果从if.then基本块来,%x取值%neg。之所以需要phi,是因为x在C代码里被赋了两次值,第一次是a - b,第二次是-x,这在SSA规则下是两个不同的变量,phi负责把这两条"历史值"合并回"当前值"。
这说明一个非常重要的道理:IR比C代码更接近机器码,很多高级语言里已经消失的底层细节它都保留下来了。
接下来再看优化器的威力。改用-O2重新生成:
build/bin/clang -O2 -S -emit-llvm test.c -o test_o2.ll结果会简洁到只剩几行:
define i32 @abs_sum(i32 %a, i32 %b) { %sub = sub i32 %a, %b %neg = sub i32 0, %sub %cmp = icmp slt i32 %sub, 0 %x = select i1 %cmp, i32 %neg, i32 %sub ret i32 %x }可以看到,优化器把phi指令消掉了,把分支变成了select指令,省掉了基本块的跳转开销。这就是优化器在IR上做的好事——它纯粹在IR层面发现"这段控制流可以等价转换为一条select指令",然后把IR重写得更高效。这种优化和原始语言无关,和最终目标CPU也无关。
4.4 优化管道如何运转
LLVM的优化工作不是一条巨型Pass横扫一切,而是由几十个"各管一段"的Pass按固定顺序组成"管线"(Pass Pipeline)。每个Pass只做一类非常聚焦的变换,比如:
instcombine:各种指令层面的代数化简和模式匹配gvn:全局值编号,消除冗余计算inline:函数内联loop-rotate:把while循环改写成do-while形式以便优化dce:死代码消除
-O2参数的本质,就是告诉Pass管理器"把这条标准管线跑一遍"。不同的优化级别对应不同的管线,O0几乎不做任何优化,O1做轻量优化,O2和O3逐步加入更强(也更耗时)的Pass。
理解这一层后,你会发现写一个Pass本质上就是在"往管线里插入自己的一段处理逻辑"。这也是我下一步要讲的实操内容。
5. 写一个自己的Pass:LLVM扩展的正确打开方式
5.1 为什么要用插件方式,而不是改LLVM源码
几乎每个学过LLVM的人,都会经历一次"我要写个Pass实现XX功能"。但写Pass有两种方式,方式选错会让你后续迭代极其痛苦。
一种方式是直接把Pass源码加进LLVM的源码树里,修改CMakeLists,然后重新编译整个LLVM。这种方式的缺点是显而易见的:每次改动都要等全量或增量编译,动辄几分钟到十几分钟;而且改乱了核心库还影响其他功能的稳定性。
另一种更推荐的方式是把Pass编译成动态库,用opt的-load-pass-plugin参数在运行时加载。你把Pass作为一个独立的.so文件,想加载就加载,想卸载就卸载,主项目一行代码都不用改,编译自己这个插件只需要几秒钟。我的所有Pass开发工作现在都用插件方式,只有确认某个优化值得合入主项目后,才会考虑移植到LLVM源码树里。
LLVM官方也把PassPlugin作为扩展的主要推荐机制,它和LLVM自身的PassManager能够无缝集成。
5.2 一个能跑的FunctionPass:统计函数指令数
下面这个插件是一个完整可运行的示例。它的功能是在编译器的优化阶段遍历每个函数,统计函数体内的指令数量,并把结果打印到标准错误输出。
我先给完整代码,然后逐块解释。
// CountInstruction.cpp #include "llvm/IR/Function.h" #include "llvm/IR/Module.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/Support/raw_ostream.h" using namespace llvm; namespace { class CountInstructionPass : public PassInfoMixin<CountInstructionPass> { public: PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { unsigned InstCount = 0; for (auto &BB : F) { InstCount += BB.size(); } errs() << "[CountInstruction] Function " << F.getName() << " has " << InstCount << " instructions\n"; return PreservedAnalyses::all(); } }; } // namespace extern "C" ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, "CountInstructionPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "count-instruction") { FPM.addPass(CountInstructionPass()); return true; } return false; }); }}; }这个代码的几个关键点,我要详细说说。
PassInfoMixin<CountInstructionPass>是LLVM新Pass管理器(New Pass Manager)从老式继承接口迁移过来的风格。你只需要提供run函数,框架会负责调用它。run的返回类型PreservedAnalyses用来告知Pass管理器"我这个Pass跑了以后,哪些分析结果仍然有效"。因为我们只是打印信息、不修改任何IR,所以返回PreservedAnalyses::all(),表示所有分析都保持原样,这样可以避免后续Pass做无效的重复计算。
extern "C"部分的llvmGetPassPluginInfo是插件的约定入口点。opt加载.so文件时会查找这个符号,拿到一个结构体,里面包含插件版本、名称和注册回调。这个回调里registerPipelineParsingCallback做的事情是:当用户在-passes=命令行参数里写了count-instruction时,就创建一个我们的Pass并加入FunctionPassManager。
新PassManager把Pass按作用域分为ModulePass、FunctionPass、LoopPass等不同级别,运行机制也各自不同。如果你只需要单个函数级别的处理,把Pass放进FunctionPassManager就够了。
5.3 编译、加载、跑通的完整过程
首先是编译成动态库。我用构建好的Clang来编译这个插件:
# 需要先知道LLVM头文件和库的位置,用llvm-config获取 LLVM_CXXFLAGS=$(build/bin/llvm-config --cxxflags) LLVM_LDFLAGS=$(build/bin/llvm-config --ldflags --libs --system-libs) # 编译成动态库 build/bin/clang++ $LLVM_CXXFLAGS -shared -fPIC -fno-rtti \ CountInstruction.cpp -o CountInstruction.so $LLVM_LDFLAGS注意两个细节:-fno-rtti必须加,因为LLVM默认禁用了RTTI,如果编译器开启RTTI,链接时会报一堆typeinfo相关的符号找不到错误;另外这个插件只用了头文件和少量核心库符号,链接参数理论上不必拉全所有库,但在复现阶段图省事直接加上--libs也没问题,就是生成的.so会大一点。
接下来是拿到一份IR作为输入。还是用之前生成的test.ll,先编译成字节码文件:
build/bin/llvm-as test.ll -o test.bc然后运行我们的插件:
build/bin/opt -load-pass-plugin=./CountInstruction.so \ -passes="count-instruction" \ test.bc -o /dev/null如果你看到类似这样的输出,说明插件加载成功并被执行了:
[CountInstruction] Function abs_sum has 7 instructions如果你在-passes=里写了不存在的Pass名,opt会提示注册失败。这通常意味着插件的函数名跟你注册时的字符串不一致,或者插件根本没被加载进去。
5.4 从传统Pass到新PassManager:接口怎么适应
网上的很多LLVM教程还停留在老PassManager的写法,类要继承FunctionPass,使用getAnalysis获取依赖分析,通过llvm-as编译后再用opt -load加载。其实在LLVM 13之后,新PassManager已经成了默认,新代码应该优先写PassInfoMixin风格的Pass。
老接口的问题在于,它依赖全局状态,写起来虽然直接但不利于并行执行和模块化。新接口把配置和依赖关系的管理挪到了PassManager层,Pass本身变得"无状态"——只是接收输入、产生输出,从而允许框架决定何时运行、如何缓存分析结果。
如果你接手的老代码是传统格式写的,也不用太慌张。核心优化逻辑(遍历函数、指令等)可以几乎原样保留,需要改的只是类继承方式、run函数签名、以及注册宏这几层结构。把上面示例的框架套进去,把里面函数体换成你原来的逻辑,通常就能迁移过来。
6. 折腾llvm-project这一年多,最值钱的经验
6.1 三件套调试工具:opt、llc、llvm-dis
很多人在LLVM里调试代码,只会加打印语句和用gdb,效率很低。其实LLVM自带一套处理IR的命令行工具,它们才是日常调试的主武器。
opt负责跑优化Pass,它既可以加载外部插件,也可以执行内置的所有标准Pass。我是用它来快速验证"某个Pass的输出是否符合预期"的,几乎每次改完Pass逻辑都要跑一遍小样例。
llc负责把IR变成汇编或目标文件。当你想看某个IR经过后端代码生成后变成什么样的指令时,用它执行llc test.bc -o test.s,打开汇编文件逐行对照即可。配合--debug参数还能输出指令选择的具体过程,这是排查后端问题时的利器。
llvm-dis则是把二进制bitcode反汇编成文本IR。它和llvm-as正好是一对。这两个工具让IR可以自由地在文本和二进制之间切换——文本适合人看,二进制适合机器快速加载,传递优化结果时通常会转成.bc文件。
我调试代码的标准模式是这样的:写一个很小的测试源文件,用clang -emit-llvm转成IR,再通过opt跑自己要调试的Pass,观察IR变换;发现问题后再缩小到具体指令。整个过程往复迭代,几分钟就能完成一个验证周期,不需要重新编译LLVM主工程。
6.2 库版本和头文件不一致,是整个项目最隐蔽的坑
LLVM的ABI兼容性政策很严格:同一份代码,如果链接的LLVM库版本和头文件版本不一致,几乎百分之百会出问题,而且报错信息往往非常难懂。
我自己就吃过一次大亏。当时系统里同时装了发行版的LLVM(比如Ubuntu的默认包)和从源码编译的LLVM。我在写插件时用llvm-config拿的是源码版路径,但CMake在查找依赖时找到了系统安装版的库,导致一堆"undefined symbol"和"version mismatch"错误。
这个问题的解决思路是搞清楚你的llvm-config到底是哪一个。如果编译插件时用了build/bin/llvm-config,那么编译和链接都要保证它指向同一个构建产物目录。为避免混乱,我建议你构建插件时始终用绝对路径,并且在CMake里显式指定LLVM_DIR为构建目录下的lib/cmake/llvm。优先使用find_package(LLVM)并传入确切的LLVM_DIR路径,比靠环境变量碰运气可靠得多。
6.3 TableGen生成的代码,手改就是给自己挖坑
如果你深入LLVM的目标描述代码,会看到大量.td文件。这些文件不是C++源码,而是用TableGen语言写的目标描述文件。TableGen是整个LLVM里的一个代码生成器,它读取.td文件,自动生成C++代码比如指令匹配表、寄存器信息表、调度模型,输出的.inc文件会在编译时被包含进源码里。
很多人第一次看到X86GenInstrInfo.inc这类文件时,会因为它看起来像"正常代码"而尝试直接修改,这是大忌。所有手工修改生成文件的努力,都会在下一次TableGen重新生成时被覆盖。
正确的姿势是:改.td文件,然后重新编译,让TableGen重新生成.inc。理解了这条规则,就能看懂为什么LLVM的每个后端目录下面都有.td文件、工具链里还有一个叫llvm-tblgen的可执行文件。如果你要添加一条新指令或新寄存器,核心工作是在.td文件里描述它的属性,而不是手写C++代码。
6.4 官方文档和测试就是最好的学习路径
最后分享一个学习效率最高的路径:LLVM文档里有一个"Writing an LLVM Pass"的教程页面,但那个页面我建议不要作为唯一的Pass入门资料,因为它更新的速度有时跟不上代码演进的步伐。
最好的办法其实是直接读代码。在llvm/lib/Transforms/目录下随便挑一个小型Pass,比如Hello、SROA、GVN,用我上面说的方法单步跑一遍,看它处理前后的IR差异,理解它调用了哪些分析接口。LLVM的单元测试目录llvm/test/下面有海量的测试用例,每个用例的IR小文件都附带了期望输出,天然就是一个个"为什么这么写Pass"的答案。
我自己学某一类优化算法时,常用的顺序是:先看该Pass的.cpp源码里注释开头的算法概述,然后在llvm/test/Transforms/下找到对应的测试文件,观察它期望的变换方式,最后用opt手工跑一个小样例验证理解。这个循环比任何教程都扎实。
还有一点,LLVM源码里的错误消息和断言信息是很有价值的"文档"。遇到崩溃,不要急着打补丁,先认真读一下崩溃位置的断言信息,往往会发现它是为了拦截某种你没有预料到的情况而专门写下的。
我在LLVM上从看文章、跑例子、写第一个Pass,到给公司做了一套内部代码插桩工具,前后也就一两个月的事。现在回想起来,最关键的转折就是动手写了自己的Pass并跑通了那个"编译、加载、验证"的最小循环。一旦你跨过这道门槛,LLVM庞大的源码就不再是迷宫,而是一个按模块摆放整齐的工具库,你需要什么就去哪个抽屉里拿。