尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

LLVM编译器基础设施详解:构建、Pass开发与调试技巧

LLVM编译器基础设施详解:构建、Pass开发与调试技巧 聊到编译器基础设施和现代语言实现llvm-project大概是绕不开的名字。它不是一个单独的程序而是一套围绕 LLVM 核心展开的完整工具链生态编译器、链接器、调试器、标准库、代码分析工具甚至新语言的编译器后端都住在这个仓库里。简单来说它能帮你把 C/C 顺利变成高效可执行文件也能帮你为自己的领域语言、硬件指令集搭建一条从源码到机器码的流水线同时也被大量安全检测、性能分析和教学研究项目当作基础设施来用。如果你刚开始接触这套东西可能会有点懵——克隆下来几个 GB目录多到看不完构建一次要喝好几杯咖啡。但换个角度想这正是 LLVM 生态丰富性的体现。这篇文章我会从“为什么项目这么设计”讲起再带你把源码构建、日常编译、自定义 Pass 这些环节全部走一遍最后把常见坑和排查思路整理成一份可以直接抄的笔记。不管你是在读编译器相关课程、为自研语言找后端还是工作中想用 Clang/LLD/Sanitizer 替代旧工具链这篇文章都适合当一份过渡手册来读。1. 认识 llvm-project不只是“一个编译器”1.1 一个仓库一整条工具链先说一个容易混淆的点llvm-project虽然是 monorepo单仓库多项目但它并不是“一个叫 llvm 的编译器”。准确地说LLVM 是一个编译器基础设施库而 Clang、LLD、LLDB、libc 这些工具和库是在这个基础设施上长出来的应用层。这个仓库里目前能看到的主要子项目包括llvm核心库包含 IR 表示与优化 Pass、目标描述、CodeGen 后端。clangC/C/Objective-C 前端把源码变成 LLVM IR。clang-tools-extraclang-tidy、clang-format、clangd 等工具日常开发很依赖它们。lld一个非常快的链接器可以直接替代部分场景下的 GNU ld。lldb调试器和 LLVM 生态深度绑定。libc / libc / libcabiC 标准库与 C 标准库的实现。compiler-rt提供 Sanitizer、Profile 等运行时支持。mlir多级 IR 基础设施常用于 AI 编译器。flangFortran 前端。polly基于多面体模型的循环优化框架。所以当你听到“LLVM 能编译 C/C”说的其实是 Clang LLVM 的组合当你听到“新语言选择 LLVM 做后端”说的是借用 LLVM 核心来生成目标代码而不是真的让新语言前端去“调用 llvm 命令”。我经常跟同事打比方LLVM 核心很像一个通用“工厂车间”Clang 负责把原料源码拆成半成品IR优化 Pass 负责在半成品上做各种加工后端 CodeGen 负责把半成品组装成具体产品目标汇编。其他工具则是围绕这个车间搭配的物流、质检、维修系统。1.2 三段式流水线为什么 IR 是核心任何现代编译器几乎都逃不开“三段式”结构前端、中端、后端。前端负责词法、语法、语义分析生成中间表示中端在中间表示上做优化后端负责指令选择、寄存器分配、指令调度最终生成机器码。LLVM 的特别之处在于它的中间表示LLVM IR被设计得足够通用、足够稳定于是可以做到“任意语言前端 同一套优化管线 任意目标后端”。这个组合非常像“万能插座”前端只负责把语言差异消化掉后端只关心目标硬件差异中间的所有优化用同一套。为什么要拆成这样最直接的理由是成本。假设有 n 种语言、m 种目标架构如果每个编译器都从头写到尾工作量是 O(n×m)但有了一套通用 IR 作为桥梁只需要写 n 个前端和 m 个后端工作量降到 O(nm)。Rust、Swift、Zig 这些语言愿意把 LLVM 当后端就是这个原因。用 Clang 做一次编译底层真正执行的是这样一串步骤# 预处理展开宏、处理 #include clang -E hello.c -o hello.i # 编译从预处理文件生成 LLVM IR文本形式 clang -S -emit-llvm hello.i -o hello.ll # 优化在 IR 上跑一系列 pass opt -passesdefaultO2 hello.ll -o hello.opt.ll # 目标代码生成从 IR 生成汇编 llc hello.opt.ll -o hello.s # 汇编 链接生成可执行文件 clang hello.s -o hello当然平时你只需要执行clang hello.c -o helloClang 驱动会帮你把所有环节串起来。但理解这条流水线很重要因为后面你写自定义 Pass、做性能分析、定位编译器问题全部都要和中间层打交道。注意opt的-passes语法在不同 LLVM 版本里变化较多本文示例基于较新的 LLVM 18/19。老版本里可能写作-O2或者-pass-name遇到版本差异时先看opt --help。2. LLVM 的技术价值为什么大家愿意围绕它建生态2.1 库化与模块化它不是“黑盒”而是“积木”传统编译器往往是一整块可执行程序你给它参数它给你产物内部逻辑很难复用。LLVM 从一开始设计就是库化的所有核心能力都以 C 库的形式暴露官方工具只是这些库的薄封装。这意味着你可以写一段 C 代码链接 LLVM 库直接把 IR 加载进自己的程序里分析、改写、再输出也可以给opt写一个插件把自己定义的优化 Pass 动态加载进去。对开发者来说这不是在使用“黑盒”而是在堆“乐高积木”。举个例子假设你想做代码混淆、插桩、或者给 IR 里的函数改名不需要去改 Clang 本体只需要写一个 Pass 并注册进 Pass Pipeline。如今 Rust、JVM 等领域的一些静态分析工具也会先把自己源代码编译成 LLVM IR再基于 IR 做分析——因为 IR 比源码更规范、比机器码更易读。2.2 配套生态LLD、libc、compiler-rt、LLDB 的合围LLVM 能成为基础设施不只是靠 IR 设计得好还靠一整套配套组件形成了闭环。LLD闪电般快的链接器。大型项目里链接阶段经常是瓶颈LLD 用并行化把链接速度提升好几倍。Chromium、Android 等大型构建系统都在逐步切换。libc / libcabi现代 C 标准库。相比老牌 libstdclibc 的代码可读性和模块化更好在 Clang 生态里配合默契。compiler-rt提供 AddressSanitizerASan、UndefinedBehaviorSanitizerUBSan、ThreadSanitizerTSan、libFuzzer 等运行时支持。做 C/C 项目调试和线上问题彻查时ASan 几乎是“救命级”工具。LLDB调试器。它与 LLVM 共享大量组件调试体验和现代 IDE 集成度很好。这套生态最舒服的地方在于你从“编译”到“链接”到“调试”到“运行时检测”用的都是同一套代码库彼此之间信息可以共享。比如调试器能理解 Clang 生成的调试信息Sanitizer 能拿到编译期插入的 instrumentation 信息。2.3 面向新硬件和新语言的开放接口除了常规的 x86、ARM、RISC-VLLVM 的后端还支持 GPU、DSP、FPGA 等异构架构。NVIDIA 的 NVPTX、AMD 的 AMDGPU 后端都在 LLVM 里维护。这也是为什么 AI 编译器、HPC 编译器很愿意围绕 LLVM 做文章。而 MLIR 的出现进一步把 LLVM 的“基础设施”定位往前推了一步。MLIR 允许编译器开发者自定义多层 IR按需逐级 lower 到 LLVM IR最终生成机器码。现在很多深度学习加速器编译器走的就是“前端框架 - MLIR 方言 - LLVM IR - 目标后端”的路径。所以从这个角度看LLVM 已经不单单是 C/C 编译器了它是整个编译器技术圈的公共底盘。3. 实操从源码构建到自定义 Pass3.1 本地构建 llvm-project一次能少踩的坑都列出官方推荐用 CMake Ninja 构建。先克隆仓库注意仓库体积较大建议只拉最近一层提交--depth1或者签出稳定发布分支。git clone --depth1 -b llvmorg-18.1.8 https://github.com/llvm/llvm-project.git cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;libcxx;libcxxabi \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_PARALLEL_LINK_JOBS4 ninja -C build clang lld几个关键点LLVM_ENABLE_PROJECTS只列你需要的子项目不要默认全选。全选意味着要编 Flang、MLIR 等多个大件时间和磁盘都翻倍。LLVM_TARGETS_TO_BUILD只保留你需要的目标后端。只做 X86 实验的话就写X86别默认编全 targets。LLVM_PARALLEL_LINK_JOBS4控制链接阶段的并行度。链接是最吃内存的环节16 核机器上如果开默认并行度很容易直接 OOM。我建议从 2 或 4 开始根据内存再调。构建目标尽量精确。ninja clang lld比ninja快非常多因为全量 build 会把你并不需要的 libc、compiler-rt、clang-tools-extra 全编一遍。如果你不想从源码构建官方也提供预编译二进制包。但自己构建一次的价值在于后面写自定义 Pass、调试 Clang 行为时你至少清楚整条链路是怎么长出来的。3.2 用 Clang 把一次编译拆开看IR 长什么样写一个简单的测试文件test.cint add_one(int x) { return x 1; }执行clang -S -emit-llvm test.c -o test.ll cat test.ll你会看到类似这样的 IRdefine i32 add_one(i32 %x) { entry: %add add nsw i32 %x, 1 ret i32 %add }这里i32是 32 位整数类型%x是虚拟寄存器SSA 形式nsw是“no signed wrap”的标记表示这个加法不会有有符号溢出方便后面做更多优化。如果执行opt -passesmem2reg,instcombine test.ll -S再看输出会发现 IR 已经被规范化。写 Pass 之前建议先手工生成几个 IR 文件用opt、llvm-dis、llc反复观察输入输出这比看文档理解得快。3.3 写一个函数统计 Pass从代码到加载运行假设你想统计一个模块里有多少个有定义的函数非声明。新版 LLVM 推荐使用 New Pass Manager代码很简洁。新建CountFunctions.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 CountFunctionsPass : public PassInfoMixinCountFunctionsPass { public: PreservedAnalyses run(Module M, ModuleAnalysisManager AM) { unsigned Count 0; for (Function F : M) { if (!F.isDeclaration()) Count; } errs() [CountFunctions] module M.getName() has Count defined functions.\n; // 这个 pass 没有修改任何 IR所以标记所有分析为 preserved return PreservedAnalyses::all(); } }; } // namespace // 注册插件入口这样 opt 才能动态加载 extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, CountFunctions, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager MPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-functions) { MPM.addPass(CountFunctionsPass()); return true; } return false; }); }}; }编译插件时需要告诉编译器 LLVM 的头文件和库文件在哪里。如果你的 LLVM 在系统 PATH 里可以直接用llvm-configclang -fPIC -shared CountFunctions.cpp \ $(llvm-config --cxxflags --ldflags --libs) \ -o CountFunctions.so然后跑一次clang -S -emit-llvm test.c -o test.ll opt -load-pass-plugin./CountFunctions.so \ -passescount-functions test.ll -S -o /dev/null看到输出[CountFunctions] module test.ll has 1 defined functions.就说明你的 Pass 已经跑通了。这里要注意几个点插件必须编译成.soLinux或.dylibmacOS且要链接同一个 LLVM 版本否则会报 ABI 不匹配或找不到llvmGetPassPluginInfo符号。PassInfoMixin里的run返回PreservedAnalyses。如果你的 Pass 会自动修改 IR就不能返回all()而是返回修改过的分析集合否则后续 Pass 可能使用了过期的分析结果。新版 Pass 注册是registerPipelineParsingCallback对应的是opt -passesxxx。老版本里写的是registerPassBuilderCallbacks如果你参考的博客比较老代码会不一样。3.4 日常效率工具clangd、clang-format、clang-tidy除了编译器和 Pass同一套工具链里还有几个日常干活离不开的队友。clangd基于 Clang 的语言服务器。在 VS Code / Neovim 里提供补全、跳转、重构。对比 Ctagsclangd 能准确理解#ifdef分支和模板实例化。clang-format代码格式化工具。把.clang-format文件放进仓库团队格式之争基本就消失了。clang-tidy静态分析工具。能帮你抓bugprone-*、performance-*、modernize-*等一堆问题。我的建议是即使你现在不做编译器开发把这些工具接进日常工作流也值了。它们和 LLVM 核心共享同样的词法、语法、AST 基础设施所以分析结果比纯正则规则要可靠得多。4. 常见坑与排查思路绕过前人踩过的雷4.1 构建阶段内存炸了、目标不对、缓存坑第一次构建 LLVM 最常见的问题就是“编译到一半机器卡死”。除了前面说的控制LLVM_PARALLEL_LINK_JOBS还有几个经验不要在一个目录里反复切换构建配置。我试过build/里先配 Release再配 Debug最后链接各种诡异报错。项目再大也建议一个构建目录对应一套 CMake 配置。内存小的机器可以把-DLLVM_USE_LINKERlld加上LLD 链接内存占用通常比 GNU ld 低一些速度还快。如果只想写 Pass不需要完整 Clang/LLD可以只构建opt和llvm-dis这两个 target节省大量时间。有条件的话用ccache。LLVM 这种重编译项目开 ccache 之后二次构建会快非常多。如果你已经构建过完整 LLVM再写 Pass 时也可以直接用 CMake 的方式官方提供了add_llvm_pass_plugin编进 LLVM 自身构建里。不过最轻量的方式还是上面的llvm-config命令行尤其是只做实验时少一层构建系统心智负担。4.2 写 Pass老框架和新框架傻傻分不清网上很多教程还在讲 Legacy Pass Manager比如继承FunctionPass、实现runOnFunction、用static RegisterPass注册。这些 API 在 LLVM 14 之后逐步被废弃新工程建议直接上 New Pass Manager。常见报错有这么几类unknown pass name xxx多半是插件没加载成功或者-passes里的名字和registerPipelineParsingCallback里注册的名字不一致。unable to load plugin ... undefined symbol通常是编译插件时链接的 LLVM 版本和opt自身版本不一致。建议用同一个构建目录里的opt来测插件。has different ABI version同理版本不匹配。别混用系统自带 LLVM 和源码构建 LLVM。遇到这类问题先执行opt --version和llvm-config --version确认版本一致再检查llvm-config --libs输出里的库路径是否是同一个安装位置。4.3 调试优化后的诡异程序行为不要第一反应是编译器 bug很多 C/C 开发者第一次用 Clang 加-O2后发现程序行为变了第一反应是“编译器有毛病”。实际上绝大多数情况是代码本身有未定义行为UB只是优化把 UB 以一种更难理解的方式暴露出来了。排查思路可以按顺序来用-O0编译如果行为正常优先怀疑优化暴露的 UB。开 UBSanclang -fsanitizeundefined -g test.c先把越界、溢出、非法转换这类问题兜出来。开 ASanclang -fsanitizeaddress -g test.c主抓内存越界、释放后再使用。怀疑优化器 Pass 有极端 bug 时可以逐层降低优化等级对比或者用-Rpass-analysis查看优化决策日志。绝大多数情况下问题都在代码而不是编译器。但如果你真的确认是 LLVM 后端的 bug去 LLVM 的 GitHub issue 搜一搜贴出最小复现例子和--version信息社区响应还是很积极的。4.4 版本差异master 好激进release 才稳定llvm-project的主分支main每天都在变。今天能编过的 CMake 参数下个月可能就不支持了今天的 Pass API 写法三个月后可能就要求改签名。如果不是想追新功能或贡献代码建议直接固定在某个 release 分支上比如llvmorg-18.1.8。判断项目用哪个版本最简单就是看项目根目录下的llvm/CMakeLists.txt开头的LLVM_VERSION_MAJOR会告诉你版本号。写 Pass 时LLVM_VERSION_STRING宏或llvm-config --version也能用来做版本判断避免用了不兼容 API。5. 应用场景与影响范围它到底改变了什么5.1 现代工具链从移动端到桌面端都在用你手机上跑的应用很多字节码里都带着 LLVM 或 Clang 的痕迹Android NDK 默认编译器就是 ClangiOS/macOS 的 Xcode 底层用 Clang 编译 C/C/Objective-C大量 Linux 发行版开始把默认工具链切向 Clang/LLD 的组合。Chromium、Firefox 这些大型项目的构建系统都深度优化过 LLVM 工具链的适配。LLVM 早就不是“学术界玩具”而是实打实的工业级基础设施。我自己在给几个大型 C 项目做链接优化时切换到 LLD 后链接时间从分钟级降到秒级效果非常明显。5.2 AI 编译器与新硬件MLIR 成了新战场AI 芯片层出不穷每颗芯片都需要自己的编译器栈。如果从零写一套不仅周期长而且生态工具调试器、优化器都接不上。MLIR 出现后芯片厂商可以先定义自己的高级方言再逐步 lower 到 LLVM IR 或直接生成硬件代码这套路径把新硬件编译器的时间成本压缩了数倍。所以你会看到很多 AI 编译器岗位要求里写着“熟悉 LLVM/MLIR”“了解 TVM”本质上是同一个技术栈的延伸。5.3 教育领域学编译器的最好素材编译器课程如果只讲龙书理论学生很难理解概念怎么落地。LLVM 让“手写一个 Pass 实现循环不变量外提”这类实验变得可操作、可验证。而且代码质量高、模块边界清晰读它比读很多业务代码舒服得多。我自己带新人入门编译器时通常先让他们在opt上写一周 Pass再去读后端代码学习曲线会平缓很多。最后聊点实在的一些推荐的学习路径和小技巧我个人刚开始接触 LLVM 时走了一些弯路最明显的一点是把它当成 gcc 的克隆来用遇到问题只会找命令行参数忽略了它“库”的属性。直到写第一个 Pass 时才意识到原来程序里可以直接include/llvm/IR/Function.h、调用 API 操作 IR那一刻的思路才真正打开。如果你也想系统上手我的建议路径是先用 Clang 编译日常 C/C 项目然后尝试在opt上跑现成 pass接着复制一个最简单的 Pass 插件改成自己的分析逻辑最后再去翻后端 CodeGen或者看 MLIR 文档。不要一开始就盯着几千行的官方文档那只会劝退自己。最后分享一个实际调试 Pass 时很好用的小技巧在opt命令行里加-print-after-all配合-filter-print-funcs你需要看的函数名能看到指定函数在每轮 pass 之后的 IR 变化。这个组合我用了无数次排查“某个 pass 把代码改坏了”非常有效。
返回列表