拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从零理解LLVM项目:架构解析、源码构建与Pass实战

在编译器这个圈子里泡久了你会发现一个很有意思的现象无论是讨论新语言设计、搞性能优化、做二进制分析还是排查图形驱动渲染异常话题最后都会绕到同一个名字上——llvm-project。这项目覆盖面实在太广了以至于很多人一开始面对它的源码仓库都会有点懵这么大一堆代码到底哪个是我要用的怎么编译为什么大家都说它重要这篇文章就是我自己的一个梳理和实操记录从架构理念到源码构建从写第一个Pass到顺带解开 llvmpipeLLVM 15.0.7, 256 bits这个在 glxinfo 里常见的字符串到底是什么意思全程尽量说人话适合刚接触 LLVM、想真正跑起来并理解它的人。1. 先把概念盘清楚llvm-project 到底是个什么东西1.1 名字里有历史包袱但项目本身早就出圈了LLVM 的全称是 Low Level Virtual Machine这个名字其实有很大的历史包袱。早期它确实是想做一个虚拟机的但随着时间推移项目演进成了一个编译器基础设施compiler infrastructure集合。名字里那个 Virtual Machine 到现在已经没有多少实际意义了你完全可以把它理解成一套造编译器的工具包或者更直白一点一套把源代码变成优化过的机器码的工业化流水线。我见过太多人第一次看到 llvm-project 仓库时被吓到。这个仓库用 monorepo 的方式管理也就是所有子项目放在同一个代码仓库里用 Git 的 submodule 或者直接根目录方式管理。你 clone 下来之后会看到一堆并列的目录每个目录都是一个可以独立发展的子项目但它们共享底层这套 LLVM 核心库。这种组织方式的好处是版本同步特别方便——Clang 用什么版本的 LLVM 核心库、LLDB 又依赖什么接口全都在一个仓库里对齐了不会出现子项目之间版本匹配的噩梦。1.2 monorepo 里那些目录分别都是干什么的很多人 clone 完 llvm-project 之后第一反应是打开 llvm 目录开始看源码结果被里面的抽象基类和模板搞得头皮发麻。其实你不必一开始就钻进核心先搞清楚地图比什么都重要。这里有几个你大概率会碰到的目录llvm/整个基础设施的核心包含 IR 的定义、优化 Pass、目标描述、后端代码生成、libLLVM 核心库以及 opt、llc、llvm-as、llvm-dis 这些命令行工具。你写的所有编译器逻辑最终都会落到这层。clang/C/C/Objective-C 的前端。它的工作是把源码解析成 AST再降级成 LLVM IR 交给核心做优化。Clang 在工业界的口碑很好一个重要原因是它的诊断信息写得比很多传统编译器清楚得多报错会直接提示你的代码问题在哪一行、什么原因甚至给出修复建议。lld/一个高性能的原生链接器速度比经典 GNU ld 快很多尤其是链接 C 项目时优势明显。LLD 也自带很多特性比如支持 LTO链接时代优化、压缩调试信息等。lldb/调试器目标是做更好的 Clang/LLVM 生态调试体验脚本化能力很强底子跟 LLVM 的表达式求值和 JIT 能力绑定得很深。libcxx / libcxxabi / libunwindLLVM 自己的 C 标准库实现、ABI 层和栈展开库。想在一个新平台跑 Clang 编译的 C 程序这三位基本就得配上。compiler-rt/运行时库包含 AddressSanitizer、 UndefinedBehaviorSanitizer 这些 sanitizer 的实现还有内建函数buildins和一些 profiling 运行时。mlir/一个构建编译器的元框架用一套可扩展的中间表示来搭建领域特定编译器。它在 AI 芯片编译器、硬件综合领域非常火但它是在 LLVM 之上抽象出来的新一层新手不用一上来就啃。flang/、polly/等Fortran 前端和基于多面体模型的循环优化框架特定场景才需要关心。把它们的关系类比成汽车产业LLVM 核心是底盘和动力总成Clang 是驾驶舱换挡逻辑前端体验LLD 是把各个零件总装下线的那一步而 sanitizer 就是出厂前的各种碰撞测试。每个子项目都有价值但最核心的还是 LLVM 本身。2. LLVM 的核心设计思路为什么它能通吃这么多场景2.1 三段式设计前端、中端、后端的解耦思路理解 LLVM 的钥匙是三段式架构。传统编译器通常把整个编译过程揉成一团——语法分析、语义分析、优化、生成机器码全都耦合在一起像 GCC 早期那样换一个目标平台要动的东西非常多。LLVM 把这条链路拆成了三段前端Frontend把源代码变成中间表示。Clang 就是干这个的它吃进去 C/C吐出来 LLVM IR。中端Middle-end / Optimizer对 IR 做各种与目标平台无关的优化。无论是循环展开、常量传播还是函数内联都在这一层完成因为这一层面对的是统一的 IR所以优化逻辑不需要针对每一个 CPU 架构单独写一遍。后端Backend把优化后的 IR 变成目标机器码。这里会做指令选择、寄存器分配、指令调度以及跟具体 CPU 特性相关的优化。这个解耦带来的价值是巨大的你写一门新语言只需要写一个前端把语言翻译成 LLVM IR立刻就能吃到 LLVM 后端对 X86、ARM、RISC-V、GPU 等几乎所有主流平台的支持。你开发一个新 CPU 架构只需要写一个后端就能让 C、C、Rust、Swift 等所有用 LLVM 做后端的语言跑起来。这种一次中间表示到处生成代码的模式就是 LLVM 生态能滚雪球式壮大的根本原因。2.2 IR 中间表示整套系统的灵魂LLVM IR 是理解这一切的必经之路。它有几个让强迫症很舒服的特点静态单赋值形式SSA、显式类型、三地址码风格。所谓 SSA简单说就是每个变量只能被赋值一次如果想要更新一个值你就得生成一个新的变量。这个设计对优化极其友好因为编译器可以轻松追踪一个值从定义到使用的所有路径做数据流分析时不用去解析复杂的赋值历史。IR 有三种形态你在日常使用中可能会反复碰到内存表示编译器内部的数据结构由 C 对象组成Pass 操作的就是它。.ll 文本格式人可以读的文本表示调试和教学时最常用。.bc 位码格式二进制序列化格式体积小、加载快适合做任何形式的缓存和分发。比如一段简单的 C 代码int add(int a, int b) { return a b; }经过 Clang 翻译成 .ll 文本后大概是这样的define i32 add(i32 %a, i32 %b) { %1 add i32 %a, %b ret i32 %1 }注意这里每个结果都用带编号的临时变量承载这就是 SSA 的体现。IR 本身是一门自成一体的语言类型系统里 i32、i64、ptr、[N x T]、 这些类型需要花点时间熟悉但一旦你掌握了它再看 LLVM 上层的优化和下层的代码生成都会顺畅很多。2.3 Pass 框架与优化管线一层层搭积木LLVM 的优化能力全部来自一个个叫做 Pass 的模块化单元。Pass 的粒度通常很小一个 Pass 只做一件事比如只做死代码消除或者只做指令合并。你可以把它们理解成乐高积木每个积木都是独立造好的通过排列组合拼成完整的优化管线pipeline。传统上我们区分两类 Pass分析 PassAnalysis Pass收集信息比如计算某个函数的循环结构、别名分析结果、支配树。它们自身不修改代码只是把结果缓存起来供后续 Pass 使用。变换 PassTransform Pass真正修改 IR比如删除无用代码、重写循环、内联函数。这些 Pass 通过 PassManager 来编排。值得一提的是LLVM 15 开始新的 PassManagerNew PM已经成为主流默认和老的 Legacy PassManager 在 API 上有很大差别。New PM 最重要的改进是 Pass 之间的依赖关系的显式化不再通过隐式地请求分析结果来传递这大大减少了 Pass 之间互相踩脚的情况也让并行流水线成为可能。我自己一开始用旧的写法写 Pass升级到 LLVM 15 之后发现编译不过花了不少时间迁移所以这里想强调一句新写的代码直接按 New PM 的 API 来做不要走回头路。3. 实操从源码编译一套自己的 llvm-project3.1 环境和准备磁盘、内存、工具链先安排好编译 LLVM 本身是一件比较吃资源的事提前做好准备能少一堆烦恼。首先是磁盘空间官方源码加上构建产物release 模式动辄占用几十 GB我用 Debug 模式构建时甚至见过 100 GB 级别的磁盘占用所以至少留出 50 GB 空间比较从容。其次是内存如果直接全量编译所有 target 和工具16 GB 内存会非常紧张链接时可能直接 OOM。最后是构建工具Ninja 比 make 快得多也是官方推荐的构建器务必装上。下面这些依赖基本是标配一个可用的 C/C 编译器GCC 或者 Clang 都行Clang 构建 Clang 很常见也推荐这么做CMake 3.20 以上Ninja 1.10 以上Python 3TableGen 和部分脚本需要zlib 和 zstd 的开发头文件部分功能可选但建议装上建议配好 ccache因为 LLVM 反复编译同一份代码的场景太常见了ccache 能大幅缩短二次编译时间3.2 CMake 配置关键参数逐个说清楚构建目录建议和源码目录分开不要在源码目录里就地构建。我的习惯是在 llvm-project 根目录外建一个 build 目录mkdir build cd build cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;libcxx;libcxxabi \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON \ ../llvm这几个参数是新手最容易纠结的我逐个说说我的理解。CMAKE_BUILD_TYPERelease 会开优化适合日常使用和性能测试RelWithDebInfo 是带调试信息的优化版本适合你想追一些性能问题的场景Debug 则完全关闭优化编译产物体积巨大但调试 LLVM 自身代码时最友好。如果只是想用优先 Release。LLVM_ENABLE_PROJECTS控制除了 LLVM 核心之外还要构建哪些子项目。你想用 Clang 就写clang要链接器就加lld要 C 标准库就加libcxx;libcxxabi。多个项目之间用分号分隔注意是分号不是逗号。LLVM_TARGETS_TO_BUILD默认会构建几乎所有后端 target包括你可能永远用不到的 PowerPC、SPARC 等。只写你需要的架构能明显减少编译时间和产物体积。如果吃不准就先把 X86 和 AArch64 都放进去覆盖绝大多数开发场景。LLVM_ENABLE_ASSERTIONS打开后 IR 和 Pass 内部会有大量断言检查能帮你尽早发现插件里的问题但性能会下降。我建议在开发 Pass 时开着在生产使用时关掉。到这里先别急接着执行cmake --build . -j$(nproc)这个命令会真的开始编译。第一次全量构建如果你的机器不是特别强等一两个小时甚至更久都很正常。我之前在一台 8 核笔记本上构建 Release 加 Clang大约花了 40 分钟风扇全程狂转。如果你只想用某个工具也可以指定目标cmake --build . --target clang这样只编前端能快不少。3.3 构建之后怎么验证装没装好构建完成后可执行文件在bin目录下。我习惯先跑一个最小测试./bin/clang --version ./bin/opt --version echo int main(){return 0;} | ./bin/clang -x c - -o /tmp/test /tmp/test如果clang --version能正常输出版本和 target 信息说明编译器本体和基础后端没问题如果最后那个小程序能编译并运行说明工具链的链路是通的。这里有个小坑如果你是用系统自带的 GCC 编出来的 Clang运行时可能会因为找不到libstdc报错碰到这种情况一般把LD_LIBRARY_PATH指到构建目录下的lib就行或者干脆用-static-libstdc编 Clang。4. 动手写第一个 LLVM Pass不求惊艳但求跑通4.1 新 Pass Manager 下的最小插件代码很多人把写 Pass想得很神秘其实它就是一段在编译过程中被调用的 C 代码。现在主流写法是基于新 Pass Manager 的插件形式编译出一个 .so然后用 opt 动态加载。下面这个 Pass 干的事很简单遍历函数里所有指令凡是碰到函数调用就在终端上打印被调用的函数名。#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/IR/LegacyPassManager.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct DemoPass : public PassInfoMixinDemoPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { bool Changed false; for (auto BB : F) { for (auto I : BB) { if (auto *Call dyn_castCallInst(I)) { if (Function *Callee Call-getCalledFunction()) { errs() called: Callee-getName() \n; } } } } return Changed ? PreservedAnalyses::none() : PreservedAnalyses::all(); } }; } // namespace extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, demo-pass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name demo-pass) { FPM.addPass(DemoPass()); return true; } return false; }); }}; }这段代码体现了两个关键点。第一PassInfoMixinDemoPass是新 Pass Manager 下 Pass 的标准基类通过run方法干活的模式。第二llvmGetPassPluginInfo是插件被加载时的入口函数作用是把 pass 名称demo-pass注册到 PassBuilder 的解析回调里。这里注册的是 FunctionPassManager所以我们的 Pass 会逐函数被调用。4.2 用 opt 加载插件验证效果把上面的代码存成DemoPass.cpp然后编译成插件clang -fPIC -shared DemoPass.cpp -o libDemoPass.so $(llvm-config --cxxflags)这里用llvm-config --cxxflags是偷懒做法它会展开一堆 include 路径。家用的时候没问题但如果你的构建目录和安装目录不对齐可能会找到错误的头文件。更稳妥的方式是把-I/path/to/llvm-project/llvm/include -I/path/to/build/include手动写上。然后准备一个测试文件cat /tmp/test.ll EOF define i32 add(i32 %a, i32 %b) { %1 call i32 helper(i32 %a) %2 add i32 %1, %b ret i32 %2 } declare i32 helper(i32) EOF opt -pass-plugin./libDemoPass.so -passesdemo-pass -disable-output /tmp/test.ll如果一切正常终端会输出called: helper。-disable-output是不写出 bitcode因为我们只验证 Pass 有没有跑起来。我自己第一次跑这种测试时最常遇到的错误就是opt: unknown pass name demo-pass这通常意味着注册名字和命令行名字没对上或者插件根本没被加载进去。排查方法很简单用opt -load-pass-plugin./libDemoPass.so -print-passes看 Pass 列表里有没有demo-pass。写 Pass 这个能力是 LLVM 生态里非常值钱的一项技能。编译器优化、自定义 CFG 变换、插桩、代码静态分析几乎都能用 Pass 的形式做出来。我一个做性能分析工具的朋友就是把探针插桩逻辑写成一个 FunctionPass 挂在编译工具链里一套代码通吃多个架构。所以即便你目前不写编译器理解 Pass 的工作方式也会让你看很多工具链底层实现时豁然开朗。5. 顺藤摸瓜llvmpipe 和那个LLVM 15.0.7, 256 bits是什么来路5.1 llvmpipe用 LLVM 做 JIT 的软件渲染器在 Linux 图形栈里如果你没有独立显卡驱动或者跑在虚拟机里用glxinfo查看 OpenGL 信息时经常会看到渲染器字符串里面写着llvmpipe。这个 llvmpipe 是 Mesa 项目里的一个软件渲染器Software Renderer它不依赖 GPU纯靠 CPU 来做 3D 渲染。听起来很慢但它有个关键设计在运行时会用 LLVM 的 JIT 能力把 GLSL 着色器实时编译成当前 CPU 的机器码。这样一来即使没有 GPUCPU 也能通过向量化指令SIMD按像素或顶点批量处理实现可用的 3D 效果。这正好是多段式架构在现实世界的经典应用着色器源码先被前端翻译成 IR然后在 LLVM 中间表示上做优化最后后端生成 X86 或 ARM 的机器码整个过程发生在应用程序运行时。也就是说你看视频、跑桌面环境、用一些 3D 导览工具时如果系统用的 llvmpipe背后其实就有一个微型的LLVM 编译器在替你工作。5.2 256 bits到底指什么glxinfo输出里llvmpipe (LLVM 15.0.7, 256 bits)这一段前半部分是你系统的 Mesa 构建编译时绑定的 LLVM 版本号后半部分的 256 bits 通常指的是 llvmpipe 内部处理像素/顶点数据时所用的 SIMD 向量宽度。如果你的 CPU 支持 AVX2编译器就会生成 256 位宽度的向量指令一次处理 8 个 32 位浮点数这个信息就会如实反映在渲染器字符串里。如果 CPU 只支持 SSE2可能会显示128 bits。那这个 LLVM 15.0.7 是什么概念LLVM 15 在 2022 年下半年发布正好是新的 PassManager 普及度已经非常高、默认开启的版本也是 llvmpipe 软件渲染在 Linux 桌面领域中比较常见的版本。看到这个字符串时第一反应不是去升级显卡驱动而是确认一下自己的 Mesa 包是不是和 LLVM 版本匹配。很多系统里 Mesa 是动态链接 LLVM 的一旦两者版本跨度太大可能链接不了于是 llvmpipe 就退化成不带 JIT 的慢速模式图形性能肉眼可见地下降。这个坑我实际踩过升级了一下发行版后3D 性能突然暴跌查了半天发现是 Mesa 链接的 LLVM 库被系统包管理器偷偷换掉了。6. 常见问题与排查技巧实录6.1 构建阶段的高频翻车点我在各种机器上编译过 llvm-project也帮人排查过不少问题翻车点高度集中在下面几个链接内存不足错误信息通常是collect2: error: ld returned 1 exit status或者直接被 OOM Killer 杀掉。解决办法是减少并行任务数比如cmake --build . -j4或者关掉LLVM_LINK_LLVM_DYLIB这种把所有库打成一个大共享库的选项。如果机器内存真的很小还可以把-DCMAKE_BUILD_TYPERelease换成-DCMAKE_BUILD_TYPERelWithDebInfo它在部分阶段的内存占用会低一些。头文件版本不匹配插件编译后一加载就undefined symbol或者接口对不上。这八成是头文件和 libLLVM 库版本不一致导致的。解决办法是确保llvm-config --cxxflags和llvm-config --ldflags指向同一个构建目录。我自己是直接写死路径不用系统里多个 LLVM 版本混着来的环境。Debug 模式构建时间太长Debug 会开很多断言编译产物大链接也慢。如果只是读代码可以先用 Release 构建得到可以运行的工具再用-DLLVM_ENABLE_ASSERTIONSON配 Release这样既有优化也有一定检查能力。Ninja 和 CMake 版本太低LLVM 15 时代对 CMake 版本有要求老版本直接会在 configure 阶段就报错升级 CMake 和 Ninja 是很多人容易忽略的一步。6.2 运行和调试 Pass 时的实战心得先说 opt 加载插件的常见问题。如果你在opt -pass-plugin后面写的路径有问题工具会直接报错无法打开文件如果插件的注册函数没写好那么即使加载成功-passes里也找不到你要的名字。我自己调试时最喜欢用-print-after-all这个选项它会在每跑完一个 Pass 后 dump 一次 IR虽然输出量大但能让你清楚地看到你的 Pass 到底有没有改变 IR以及改变了什么。再有就是 Pass 里PreservedAnalyses的返回。我见过不少人写 Pass 时不管改没改代码都返回PreservedAnalyses::all()如果你的 Pass 实际修改了 IR这会造成非常隐蔽的 bug——后续分析拿到的还是旧信息甚至触发断言。我的经验是只要不确定自己的 Pass 是否修改了 IR就返回PreservedAnalyses::none()让框架重新计算代价只是微小的性能损耗但正确性有保障。至于调试 llvmpipe 的问题如果你的系统在跑软件渲染最简单的验证方式是glxinfo | grep OpenGL renderer LIBGL_ALWAYS_SOFTWAREtrue glxinfo | grep OpenGL renderer这两条命令的输出应该都包含 llvmpipe 相关字样。如果第一条显示的是你的硬件 GPU 而第二条变成 llvmpipe说明硬件加速是正常的。如果两条都退化成 llvmpipe且性能异常那就需要检查 Mesa 和 LLVM 的版本匹配情况以及内核模块是否正常加载了。7. 一些想对后来者说的话踩了这么多坑最后分享几条我从实际经历里总结出来的心得算不上什么大道理但对刚接触 llvm-project 的人应该有点用。第一不要一开始就试图读懂所有源码。LLVM 源码量非常庞大直接从头读容易陷入细节泥潭。更好的路径是先学会用 Clang 和 opt 做一些 IR 层面的小实验再逐步往 Pass 和代码生成方向深挖。当你对 IR 有直觉之后再回头看那些抽象基类会顺畅很多。第二用 LLVM 自己的工具链来逃课。比如你想理解某个优化做了什么直接clang -O2 -S -emit-llvm看生成的 IR或者opt -passesloop-unroll -S单独跑一个 Pass 看效果比翻十篇论文都快。LLVM 几乎所有工具都支持文本形式的 IR 输入输出这意味着你可以非常方便地观察编译器的内部行为。第三写 Pass 时保持小步快跑。先把最小的 Pass 跑通再加复杂逻辑。我见过太多人一上来就写几百行的插桩逻辑出问题时根本不知道是 Pass 的问题还是 IR 的问题。从打印一条消息开始逐步加上你的变换每次都用opt -print-after-all验证中间状态这样 debug 的效率会高很多。回到开头那个问题llvm-project 是什么它是一套编译器基础设施更是一个庞大生态的基石。Clang、LLD、LLDB、sanitizer、llvmpipe 这些工具背后都共享同一套核心设计。搞懂它的架构不只是为了会用这几个命令而是为了理解这个年代里从源代码到机器码这件事究竟是怎么被工程化地解决的。这篇分享里所有命令和代码我都实际跑过希望你照着做也能顺利跑通。如果你后来在构建或者写 Pass 时碰到什么奇怪的坑欢迎回来翻翻这篇文章大概率你走过的路我也走过。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门