news 2026/9/20 3:56:31

深入LLVM:从项目构建到自定义Pass的完整实践指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入LLVM:从项目构建到自定义Pass的完整实践指南

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再指定CCCXX环境变量。

第二个坑是磁盘空间在编译过程中耗尽。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=ccache

ccache在多轮清理重建时的加速效果非常恐怖,某些场景下能把重复编译时间缩短到十分之一。

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。%subsub指令计算a - b,这里的i32表示32位整数类型。%cmpicmp 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,比如HelloSROAGVN,用我上面说的方法单步跑一遍,看它处理前后的IR差异,理解它调用了哪些分析接口。LLVM的单元测试目录llvm/test/下面有海量的测试用例,每个用例的IR小文件都附带了期望输出,天然就是一个个"为什么这么写Pass"的答案。

我自己学某一类优化算法时,常用的顺序是:先看该Pass的.cpp源码里注释开头的算法概述,然后在llvm/test/Transforms/下找到对应的测试文件,观察它期望的变换方式,最后用opt手工跑一个小样例验证理解。这个循环比任何教程都扎实。

还有一点,LLVM源码里的错误消息和断言信息是很有价值的"文档"。遇到崩溃,不要急着打补丁,先认真读一下崩溃位置的断言信息,往往会发现它是为了拦截某种你没有预料到的情况而专门写下的。

我在LLVM上从看文章、跑例子、写第一个Pass,到给公司做了一套内部代码插桩工具,前后也就一两个月的事。现在回想起来,最关键的转折就是动手写了自己的Pass并跑通了那个"编译、加载、验证"的最小循环。一旦你跨过这道门槛,LLVM庞大的源码就不再是迷宫,而是一个按模块摆放整齐的工具库,你需要什么就去哪个抽屉里拿。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 3:55:00

Ubuntu 终端光标消失,让 Codex 改走 TaoToken 查开机回显脚本行不行?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 3:54:03

高校校园信息整合小程序:基于微信云开发的服务聚合方案

简介&#xff1a;本资源是一份面向专科与本科毕业生的原创毕业论文&#xff0c;聚焦微信小程序在高校智慧校园建设中的落地实践&#xff0c;解决校园信息分散、获取低效等实际问题。全文逾万字&#xff0c;结构完整&#xff0c;含摘要、六章正文及参考文献&#xff0c;覆盖研究…

作者头像 李华
网站建设 2026/9/20 3:53:54

MMPose关键点标注半自动化指南:8000张图像两周交付的实操路径

MMPose关键点标注半自动化指南&#xff1a;8000张图像两周交付的实操路径 【免费下载链接】mmpose OpenMMLab Pose Estimation Toolbox and Benchmark. 项目地址: https://gitcode.com/GitHub_Trending/mm/mmpose 手头有8000张待标注的人体图像&#xff0c;排期只有两周…

作者头像 李华
网站建设 2026/9/20 3:48:19

Dreamweaver全流程实战:从安装到发布的核心操作指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 3:47:33

VBA批量统一Excel课表字体:Range.Font实战指南

每次拿到一份从系统里导出的课表&#xff0c;打开Excel的一瞬间我都要深吸一口气&#xff1a;标题可能还是宋体加粗&#xff0c;表头却变成了等线&#xff0c;这周几列里的英文课程名又自动套用了Calibri&#xff0c;字号有大有小&#xff0c;颜色有黑有蓝&#xff0c;加粗更是…

作者头像 李华