ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LLVM实战指南:从源码构建到IR与Pass调试的全链路解析

LLVM实战指南:从源码构建到IR与Pass调试的全链路解析 如果你搜索过llvm-project这个词大概率不是出于好奇而是真的准备和它认真打打交道了。我第一次打开这个 GitHub 仓库的时候第一反应是这哪里是一个项目这分明是一整片大陆。llvm-project是 LLVM 官方维护的 monorepo里面包含了 LLVM 核心库、Clang 前端、lld 链接器、lldb 调试器、compiler-rt 运行时库、MLIR 等多个子项目几乎构成了现代编译器工具链的半壁江山。这个项目的内容量非常大如果按照“从 README 开始读”的路径走很容易迷失在几十万个文件里。这篇文章就是我实际折腾下来的经验总结打算从仓库结构、源码构建、IR 与 Pass 调试、工具链组合使用这几个角度给你画一张真正能落地的地图。无论你是编译器初学者还是已经在做语言实现、性能优化、调试器开发的人这篇文章都会比你自己盲着翻官网文档要省时间得多。1. 先搞清楚你面对的是什么llvm-project 的真实边界1.1 它和“一个编译器”之间差了多少东西很多人对 LLVM 的认知停留在“编译器”三个字上但实际上 LLVM 严格来说是“编译基础设施”。区别在哪里GCC 是一个传统的编译器前端解析 C/C中端优化后端生成汇编整个流程是耦合在一起交付的。而 LLVM 把这套流程拆成了三块独立的东西——前端负责把源代码变成中间表示IR中端只做与机器无关的优化后端负责把 IR 变成目标机器代码。这带来的直接效果就是如果你想实现一门新语言你只需要写一个新的前端然后直接复用 LLVM 的中端优化器和后端代码生成器。Rust 的 rustc 这么干Swift 的编译器前端也是这么干Apple 的 GPU 编译器、NVIDIA 的 CUDA 编译器、各种芯片厂商的自研编译器工具链底层几乎都挂着 LLVM 这套框架。所以你会发现一个现象现在做编译器的人很少会从零开始写后端基本都是围绕llvm-project在开展工作。llvm-project这个 monorepo 把这一整个生态系统放在同一个仓库里版本同步发布、统一构建。你拉下来的时候会觉得大但这是它在过去十几年演进出来的最佳组织形态。如果从单个 GitHub 仓库的角度看它的 commit 数量、参与人数、代码行数基本都排在所有项目的最前面。1.2 解开 llvm-project 仓库的目录结构这些子项目各管一摊刚开始接触llvm-project的人最容易被顶层目录吓到。我拆开给你看其实每一块的分工非常明确。下面列的是你在仓库根目录里会看到的主要子项目目录作用典型使用场景llvm/LLVM 核心库和相关工具包括 IR、优化器、代码生成所有编译流程的中间环节clang/C/C/Objective-C 前端最常见的用户入口clang 命令clang-tools-extra/基于 Clang 的附加工具clang-tidy、clangd、clang-format代码静态检查、IDE 语言服务lld/高性能链接器支持 ELF、Mach-O、COFF代替系统默认 ld链接速度快得多lldb/调试器对标 GDB调试程序尤其适合源码级调试compiler-rt/各种运行时库ASan、UBSan、TSan 等动态分析、内存/未定义行为检测mlir/多级中间表示框架机器学习编译器、专用硬件编译polly/基于多面体模型的循环优化高性能计算、自动并行化flang/Fortran 前端Fortran 代码编译libc/C 标准库实现嵌入式、专用系统 C 库libc/C 标准库实现使用 C STL配合 clanglibunwind/栈回溯库异常处理、调试信息展开openmp/OpenMP 运行时和插件多线程并行编程真正开发时会长期待的地方就是llvm/lib和clang/lib。llvm/lib/Transforms里放着大量优化 passllvm/lib/CodeGen里是后端代码生成的逻辑llvm/lib/Target是各架构的后端描述。如果你想改优化逻辑去 Transforms如果你想让编译器支持一种新的指令集去 Target如果你想做 C 代码分析去 clang/AST 和 clang/Analysis。1.3 为什么它能以开源项目的身份占据整个编译链这个问题其实揭示了llvm-project的另一个核心优势它不只是“能跑”而是从设计之初就把模块化做到极其彻底。每个功能几乎都以库的形式提供工具只是一层薄薄的封装。这意味着你可以不启动 clang 可执行文件而是直接在你的程序里链接 LLVM 的库调 API 完成编译任务。IDE 之所以能实现语法高亮、自动补全、重构底层就是像 Clang 的 libTooling 这种库在提供支持。还有一个很现实的因素是授权模式。LLVM 采用的许可协议对商业集成非常友好厂商可以在不公开自己修改代码的前提下直接使用 LLVM 构建产品。这一点让它在工业界的采用率远远超过了其他方案。再加上社区生态的滚雪球效应——越来越多的语言、芯片公司、学术实验室贡献代码它就越来越难被绕开。2. 从源码构建 LLVM真实环境下的配置清单与踩坑记录2.1 构建之前先算一笔资源账我看到太多人第一次构建就翻车根本原因是低估了这个项目的资源需求。LLVM 是重型 C 项目不是随便cmake make就能轻松跑完的。我的建议是内存至少 16GB最好 32GB磁盘空间预留 50GB 到 80GBCPU 核心尽量多因为并行编译能大幅缩短时间。如果条件比较紧张也有办法但你要知道自己在做什么取舍。操作系统方面Linux 和 macOS 是体验最顺畅的Windows 建议优先使用 Visual Studio 的生成器或 Ninja 配合 LLVM 自己的工具链但会遇到一些额外的路径和库依赖问题。我的经验是除非你就是在开发 Windows 上运行的工具链否则研究阶段直接用 Linux 或者 WSL 更省心。2.2 CMake 配置项背后都有它的理由构建 LLVM 现在的主流姿势是 Ninja CMake。从一个标准 Release 构建开始cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_CCACHE_BUILDON \ -DLLVM_USE_LINKERlld \ -DLLVM_PARALLEL_LINK_JOBS2 \ ../llvm-project/llvm ninja这几个参数没有一个是多余的。-DLLVM_ENABLE_PROJECTS指定除了 LLVM 核心之外要额外构建哪些子项目clang 和 lld 是绝大多数场景都需要的clang-tools-extra 里带了 clangd 和 clang-tidy做开发很实用。-DLLVM_TARGETS_TO_BUILD只编你要用的目标后端默认会编译所有支持的后端那会极大拉长构建时间并吃掉大量磁盘空间。很多人默认配置直接跑结果编译了几个小时其实大部分目标架构根本用不上。-DLLVM_USE_LINKERlld用的是 LLVM 自带的 lld 做链接器它的链接速度比系统默认的 GNU ld 快很多。这里要求你已经能构建出 lld或者系统里有 lld 可执行文件因为链接那一步需要它。-DLLVM_PARALLEL_LINK_JOBS2非常关键LLVM 链接阶段有多个大型共享库链接器吃内存很凶如果不限制并行链接任务数16GB 内存很容易直接被撑爆。2.3 三个高频构建失败的根因与排查链路我把自己和身边人踩过的坑集中说一下。第一个是链接期间被系统杀掉典型表现是ninja跑到链接阶段时报clang: error: unable to execute command: Killed或者系统日志里显示 OOM。根因就是链接时的并行任务太多内存扛不住。排查链路是这样的先用free -h看剩余内存再看构建输出是不是稳定卡在链接某个.so的步骤上。解决方法是加低-DLLVM_PARALLEL_LINK_JOBS我会用 2如果机器实在老就设为 1。编译阶段的并行度-DLLVM_PARALLEL_COMPILE_JOBS可以保持和 CPU 核心数一致编译比链接省内存。第二个是 configure 阶段就抱怨编译器不支持。LLVM 对宿主编译器版本有明确要求比如 GCC 最低版本可能已经提高到 7 以上Clang 也有版本门槛。如果错误信息形如Compiler does not support required C features不要先怀疑代码有问题先查自己当前系统默认的gcc --version、clang --version是不是太老。这个问题的坑点在于很多 Linux 发行版自带的老版本编译器根本不在 LLVM 的支持列表里解决办法是用新版 clang 或者升级系统编译器。第三个是磁盘空间不足但错误提示并不直接。Ninja 在写中间文件时可能报ninja: error: write error: No space left on device。LLVM 的 Release 构建加上 clang 和 lld所有中间文件和产物加起来四五十 GB 是很正常的。这里我建议用du -sh *看一下构建目录膨胀情况并且在配置的时候用单独的构建目录不要和源码目录混在一起。因为 LLVM 支持 out-of-source 构建强烈建议把 build 目录放到剩余容量足够的盘上。2.4 面向日常开发的高效构建组合如果你不是在研究阶段而是在往llvm-project里提交代码那就不能每次都全量构建了。我的做法是准备两个构建目录一个 Release 基础构建用来得到可用的 clang 和工具一个 Debug 构建专门用来调试和开发某个 pass 或者某个模块。全量构建确实很贵但 LLVM 的模块化体现在这里当你改了某个 pass 之后只需要重编包含它的那个库和链接它的工具。比如你改的是llvm/lib/Transforms/Scalar下的代码那么执行ninja LLVMScalarOpts opt它会只编 scalar 优化库和 opt 工具整个时间会控制在几十秒到几分钟内远不需要全量 rebuild。而如果你改的是 clang 前端就编ninja clang。ccache 在这类项目上价值极大。它在第一次全量构建时可能帮不上太多忙但后续小幅修改、切换分支、重新配置时能命中大部分历史编译缓存。我配置完-DLLVM_CCACHE_BUILDON之后用ccache -s可以查看命中率。从实际体验看如果是在同一个分支上持续开发命中率可以到 90% 以上这基本等于把重新编译的等待时间消灭掉了。3. 攻克 LLVM IR 与 Pass理解优化体系的关键钥匙3.1 为什么编译器要建“中间那座桥”编译器为什么要费劲把源代码转成一种中间表示而不是直接翻译成汇编核心原因是“解耦”。回到最直观的比喻有 10 种前端语言每种想支持 10 种 CPU 架构如果直接翻译你需要写 10×10100 个前端到后端的组合但如果你定义一套 IR每个前端只需要生成 IR每个后端只需要消费 IR那么你只需要 10 个前端 10 个后端一共 20 个模块就搞定了。这就是 LLVM IR 存在的商业级理由。LLVM IR 从外表看像是一种带类型系统的汇编语言但它有几个关键特性静态单赋值形式SSA、显式的控制流图、丰富的元数据。一个变量一旦被赋值就不能再赋值每次使用都能直接找到唯一的定义点这让数据流分析变得非常容易。所有优化 pass 都在这套表示之上工作你要写新的编译器优化本质上就是在写一个 IR 到 IR 的变换。3.2 动手生成与读懂一份 LLVM IR上一节讲了不少概念现在动手看真实代码。假设有一个最简单的 C 文件int add(int a, int b) { return a b; }在构建好的环境里运行clang -S -emit-llvm -O1 -o add.ll add.c得到的add.ll核心内容大概是这样define i32 add(i32 %a, i32 %b) { %add add nsw i32 %a, %b ret i32 %add }逐行拆开define i32 add(i32 %a, i32 %b)定义了一个函数返回类型是i32函数名是add参数是两个 32 位整数。LLVM 里全局符号用局部变量用%。%add add nsw i32 %a, %b做一次有符号整数加法nsw是 “no signed wrap” 的缩写告诉优化器这里如果出现有符号溢出属于未定义行为于是优化器可以做更多激进的变换。ret i32 %add返回这个计算结果。你可以对比不优化的情况也就是-O0生成的结果。不开优化时clang 会在栈上分配局部变量做store/load会啰嗦很多。从-O0和-O1的差异里你一眼就能看出优化器到底在做什么。读 IR 的时候我建议养成配合工具的习惯。llvm-dis可以把比特码.bc转回文本格式opt -S是在文本和优化之间频繁切换的关键命令lli可以直接解释执行一份 IR 文件非常适合验证逻辑lli add.ll 3 5它能直接输出 8说明这份 IR 的语义是正确的。3.3 动手写一个最简单的 FunctionPass写 pass 是接触 LLVM 内部机制最直接的路径。传统教程里会写旧版 Pass 类但现在的 opt 工具默认已经切换到新 Pass Manager。以打印函数名为例可以这么写#include llvm/IR/Function.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 HelloPass : public PassInfoMixinHelloPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() hello from: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, HelloPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-pass) { FPM.addPass(HelloPass()); return true; } return false; }); }}; }编译成插件再跑clang -stdc17 -fPIC -shared \ -I/path/to/llvm-project/llvm/include \ -I/path/to/build/include \ -o libHelloPass.so hello_pass.cpp opt -load-pass-plugin./libHelloPass.so \ -passeshello-pass \ -disable-output add.ll运行后你会看到hello from: add。这个例子虽然简单但它把插件注册、Pass 管理器回调、run 函数签名这几件事全走了一遍。之后你想写一个真正改代码的 pass只需要在run函数里操作F系列的 IR 数据结构做完变换返回PreservedAnalyses::none()表示 IR 被修改了如果没改就返回PreservedAnalyses::all()。3.4 在调试 Pass 过程中积累的教训写 Pass 最容易犯的错误是返回错误的PreservedAnalyses。新 Pass Manager 里有一个 analysis 缓存系统你声明all()表示所有分析结果都没变之后其他 pass 依赖这些分析时会直接使用旧缓存。如果你改了 IR 却仍然返回all()后面的 pass 会基于过期的 analysis 做出错误的优化。我自己的排查办法是如果不确定就直接返回none()。损失一点编译速度换来的是正确性。另一个高频问题是打印信息时用了std::cout。LLVM 整个工具链约定使用llvm::errs()而不是std::cout因为 errs 连接到 llvm 的 raw_ostream 体系在处理 IO 缓冲、线程并发和 debug 输出时都更可靠。看起来是小事但在多线程 pass 场景下可能导致输出顺序混乱甚至崩溃。还有一点很现实网上大量教程是基于旧版 Pass Manager写法是class Foo : public FunctionPass。这种代码在现在的 opt 上跑不起来因为新 PM 和旧 PM 之间不是 API 兼容的关系。看到 Legacy pass 教程你可以用来理解思路但真要使用请对照新 PM 的写法。4. 把 opt、llc、FileCheck 串成一条调试流水线4.1 用 opt 做单 Pass 运行的定位技巧opt是 LLVM 优化器的主入口。它的能力不只是执行 LTO 或者 O3 级优化更强大的是可以指定跑某一个 pass非常适合做问题定位。比如你想看看instcombine这个 pass 单独对 IR 做了什么opt -S -passesinstcombine add.ll -o add_ic.ll diff add.ll add_ic.ll-S表示输出文本 IR不加的话默认输出比特码。加-debug-pass-manager可以看到整个 PassPipeline 的执行过程哪个 pass 分析了什么、跑完花了多少毫秒都清清楚楚。这在排查“到底哪个优化把程序改坏了”的时候非常有用做法很简单先跑完整 O3 验证是不是有问题再用二分法在这个 pipeline 里做子集裁剪逐步缩小涉事 pass 的范围。我在实际项目里经常做的事是先用opt -S -O3 -debug-pass-manager保存完整 pass 序列然后一点点去掉可疑的 pass对比不同序列下生成的 IR 差异。依靠这种思路可以快速定位很多代码生成层面的怪异问题。4.2 llc 与 llvm-objdump从 IR 到汇编再到机器码llc是 LLVM 的静态编译器后端工具作用是把 IR 变成目标平台汇编或目标文件。它的连接层逻辑很清晰llc add.ll -o add.s # 生成汇编 llc add.ll -filetypeobj -o add.o # 直接生成目标文件 llc -marchaarch64 add.ll -o add_arm.s # 生成 AArch64 汇编看到对应架构的汇编你才能真正理解后端在做什么。比如 x86-64 上那条 add 指令长这样addl %esi, %edi leal (%rdi,%rsi), %eaxllc还支持在流水线的中间阶段停下来。如果你怀疑指令选择的某个环节出了问题可以这样llc -stop-afterisel add.ll -o add_isel.mir它会输出 SelectionDAG 之后的机器 IR。这种分阶段 dump 的能力是 LLVM 调试的精髓因为你能逐层看到前端 IR 是怎么一步步变成机器码的。配合llvm-objdump -d检查目标文件的反汇编整条链路就闭环了。4.3 FileCheck 断言设计避免误匹配的结构化写法LLVM 的回归测试体系依赖 FileCheck它的职责不是执行程序而是检查命令输出是否符合预期。你在llvm/test/下面会看到大量.ll文件里面带RUN:和CHECK指令。一个标准的测试长这样; RUN: opt -S -passesinstcombine %s | FileCheck %s define i32 test(i32 %a, i32 %b) { %add add i32 %a, %b ret i32 %add } ; CHECK-LABEL: test ; CHECK: add i32 %a, %bCHECK-LABEL用于确认函数边界避免前面的指令被错误匹配到后面。这里面有很多反直觉的坑CHECK默认是逐行扫描输出不会限制两条匹配之间的内容。如果你担心前面的add干扰匹配请用CHECK-NEXT确保下一行紧邻。CHECK-NOT的作用域是从上一条匹配到下一次匹配之间的区域不能全局断言某个模式始终不存在除非你把它的匹配位置放在整个文件的起点和终点之间。变量用[[REG:...]]定义[[REG]]引用但它的生命周期只到当前匹配块结束跨块使用可能拿到上一个不相关的值。写 FileCheck 测试的原则是“宁可多写几行也不要做宽松匹配”。模糊的 CHECK 会让一个真正回归的 bug 被悄悄放过去这种测试形同虚设。4.4 结合 lld 与 fuzzer 做端到端验证把 LLVM IR 变成可执行程序以后怎么能确认优化没有改变语义我常用的办法是差分测试用不同优化级别编译同一份源文件生成两个可执行程序再用同一组输入去跑它们的输出。如果-O0的输出和-O3的输出不一致那说明有个 pass 做坏了。对于 Compiler-RT 和 Sanitizer还可以组合起来做更强验证。比如用 ASan/UBSan 构建的 clang 去编译被测代码再用它跑一遍测试集能快速暴露内存错误和未定义行为。有些问题只会在优化后的代码里暴露所以我会同时在-O0和-O2下跑 Sanitizer 构建两个维度交叉定位。如果你的目标不止是“用编译器”而是“改编译器”或者“开发新的 pass”那么建议把 fuzzing 纳入测试体系。LLVM 仓库里本身有 libFuzzer 集成在开启-fsanitizefuzzer的情况下可以把编译器前端的输入做成 fuzz target不断生成随机输入喂给 IR parser。我见过不少崩溃就是在这种自动化检查下被揪出来的它们通常不是算法逻辑错误而是解析器对边界输入处理不当。5. 一套读源码、选方向、持续进阶的实操方法论5.1 从 Pass 反推整体架构一条阅读路径面对一个几十万文件的巨型代码库最忌讳的就是从仓库根目录开始顺序读。我的建议是反着来从一个具体的优化 pass 入手。比如你想了解全局值编号GVN是怎么实现的就打开llvm/lib/Transforms/Scalar/GVN.cpp。看完实现后再回头查这个 pass 是被谁调用的。在llvm/lib/Passes/PassBuilder.cpp里搜索GVN会看到它在默认 Pipeline 里被挂载的位置以及开启它的优化级别。然后再从llvm/lib/IR和llvm/include/llvm/IR里查看它操作的核心数据结构比如Instruction、BasicBlock、DominatorTree这一层。这个路径走通之后你就不再是看“一个孤立文件”了而是建立了一张“pass 依赖链和 IR 数据结构”的认知网络。读 Clang 前端的路径也类似从clang/lib/AST的数据结构开始到clang/lib/Sema的语义分析再到clang/lib/CodeGen的 IR 生成。你会发现每个层级其实都只依赖下层接口上层只是不断变换表示形式。5.2 值得借鉴的 LLVM 设计模式做编译器的人总说 LLVM 代码里有很多值得学的设计模式这话真不虚。挑几个对我影响最大的说。第一是 TableGen。LLVM 用 TableGen 描述目标架构的指令集、寄存器、调用约定自动生成大量 C 代码。这个方法让“指令选择”可以从一套描述信息里自动推导出匹配器不用手写大量 boilerplate。任何做 DSL 或者元编程的人都应该看看llvm/lib/Target/X86/X86.td是怎么用描述式编程替代手写代码的。第二是llvm::Error和llvm::ExpectedT的错误处理体系。它和传统异常模型不同要求调用者显式处理错误。ExpectedT同时携带“结果值”和“错误信息”在类型系统层面强制约束错误检查这比靠约定“读返回值”要可靠得多。第三是分层库设计。LLVM 的层是从Support基础工具库到IR核心表示到Analysis分析库再到Transforms变换库最后是CodeGen。越底层的库越独立几乎不依赖上层上层可以调用底层但底层不得包含上层逻辑。这种严格的依赖方向让这个巨型项目可以并行开发、增量编译也让它能同时被 Link Time Optimization 和高性能 JIT 等衍生产物复用。5.3 不同背景的开发者各自该从哪里切入llvm-project覆盖的面实在太广不同的个人背景最佳的切入路径完全不同。我根据自己的观察和经验给你一个比较务实的建议如果你是编译器初学者对编译器理论上已经有一些基础建议从 LLVM IR 开始。先学会看 clang 生成的.ll文件然后用 opt 跑各种优化再尝试写一个简单的 FunctionPass。先把 MiddleEnd 这一层玩熟不着急碰后端。如果你是做语言实现的那就从clang前端出发重点研究 AST、Sema 和 CodeGen。你想知道 C 的某个语法是怎么被解析和翻译成 IR 的完全可以顺着 parse 到 codegen 的路径一步步跟下去。如果你本身是做性能优化的可以直接进入llvm/lib/Transforms看各种优化算法同时掌握llvm-mca这些性能分析工具对自研工具链的调优会非常有用。如果你对调试器和工具链感兴趣那就值得花时间研究 lldb 和 lld它们虽然属于 LLVM 帝国的外围但内部设计思路和核心编译器路线是一致的。另外要提醒的是官方文档的 《LLVM Programmer’s Manual》 和 《Getting Started with LLVM Core Libraries》 是绕不开的参考资料。但我不建议一开始就硬啃边实操边查是最高效的。遇到一个 IR 指令不懂就查 Instruction Reference遇到 Pass 写法不对就去看llvm/include/llvm/Passes里的注释。LLVM 的头文件注释质量很高很多时候它们比 Stack Overflow 上的答案更准确、更新。还有个很实用的习惯看到不确定的 API可以直接在llvm-project里全局搜索它的实际用法。仓库里的测试用例就是最好的活文档比如你想知道DominatorTree怎么用搜一下llvm/test和llvm/lib里被调用的地方比猜接口要靠谱得多。回头说回我自己这几年的体会LLVM 这个项目最不稀缺的是源代码最稀缺的是看懂源代码的路径。我第一次构建它的时候花了差不多一整天期间遇到的错误一个接一个但熬过那个阶段后后面的路就顺畅非常多。找到一条属于自己的切入路径比试图全面理解整个仓库要现实得多也有效得多。
返回列表