ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LLVM与Clang深度解析:从源码构建到自定义Pass实战

LLVM与Clang深度解析:从源码构建到自定义Pass实战 作为一个常年跟编译器和底层工具链打交道的人我身边经常有朋友问“LLVM 到底是个什么东西怎么大家一说编译器就是它” 这种问题多了之后我发现国内开发者对 llvm-project 的认知普遍处在“听过名字但不知道里面到底装了什么”的状态。有些人以为 LLVM 就是苹果搞的一个 C 编译器有些人以为它跟 Java 虚拟机是一类东西还有些人想学但一看到 GitHub 那个巨大的仓库就放弃了。所以今天我想以 llvm-project 这个仓库为切入点把它的核心组成、工作原理、实际玩法以及我在构建和二次开发过程中踩过的一些坑一次性讲清楚。这篇文章适合几类人看你想弄明白 Clang 和 LLVM 的关系你想为自己的架构或 DSL 做一套编译器你想给现有语言加点自定义优化或者你只是想在简历上写一句“熟悉 LLVM 基础设施”这篇内容都能给你一个相对完整的参考。我不会把文档念一遍而是以实际使用者的视角带你把 llvm-project 拆开看看。1. 先搞清楚LLVM 到底是一坨什么代码1.1 从“虚拟机”的误会说起十多年前我刚接触 LLVM 的时候第一反应也是LLVMLow Level Virtual Machine这个名字听起来像虚拟机是不是跟 JVM 差不多后来翻了源码和官方文档才知道这里面存在一个历史遗留命名问题早期 LLVM 确实是想做一套跨平台的底层虚拟机但项目在发展过程中逐渐演变成一个“编译器基础组件库”也就是一套用来构建编译器、工具链、静态分析工具的框架和实现。今天你从 GitHub 克隆下来的 llvm-project其实是一个包含多条产品级工具链的大仓库。如果你想对它有直观理解可以把 llvm-project 想成一个“乐高工厂”它不是直接给用户提供一辆成品小汽车而是提供各种标准零件IR、Pass、指令选择器、代码生成器以及几辆官方拼好的示范车型Clang、LLD、libc 等。用户既可以直接开走示范车也可以拿零件拼一辆自己需要的车。比如你想要一个能把自定义脚本语言编译成 WebAssembly 的编译器你不需要从头写指令选择、寄存器分配、优化框架你只需要写好“前端”剩下的交给 LLVM 的处理流程就能得到可运行的目标代码。这个定位非常重要因为它解释了为什么 LLVM 项目在工业界和学术界都这么吃香你不用重复造轮子只需要把精力集中在最核心、最能体现差异化的部分。1.2 一个仓库半条工具链llvm-project 里都有啥从 GitHub 上拉下来的 llvm-project顶层目录非常直观每个目录对应一个子项目。这里我挑几个实际开发中高频接触的llvm/: 这是核心目录里面是 LLVM 的中端和后端实现包括 IR中间表示、Pass 优化框架、向量化、指令选择、寄存器分配、目标代码生成以及opt、llc、llvm-as、llvm-dis这些命令行工具。你写的优化 Pass 基本都在这个层次上工作。clang/: C/C/Objective-C 的前端。它负责把源码解析成 AST然后生成 LLVM IR 交给 llvm 核心去优化和生成目标码。Clang 还附带了clang-tidy、clang-format、静态分析器等实用工具。lld/: 链接器。以前用 GNU ld 的时候链接速度能慢到让人怀疑人生换成 lld 之后体验是质的飞跃尤其在大型 C 项目里链接时间能压到原来的几分之一甚至十几分之一。libc / libcabi: C 标准库实现。苹果生态和一些追求性能的项目会直接选这套实现。compiler-rt: 运行时库提供 sanitiizerAddressSanitizer、UndefinedBehaviorSanitizer 等等底层支持专门用来在运行时捕获内存错误、未定义行为。做服务端开发的人应该多关注这块。polly: 多面体优化框架主要做循环变换优化研究性能优化的人会用到。mlir/: 这是 LLVM 社区后来扩展出来的“多层 IR”框架专门用来做机器学习编译器和更灵活的编译器基础设施。很多 AI 编译栈比如 TensorFlow、PyTorch 的底层编译方案都围绕它来构建。很多人分不清目录之间的关系我建议你用一条流水线来理解Clang 把源代码翻译成 LLVM IRopt 对 IR 做各种 Pass 优化llc 把优化后的 IR 生成汇编lld 把汇编产物链接成可执行文件。这四步就是一个最简单的现代编译流程llvm-project 一个仓库全给你包圆了。2. 为什么 LLVM 能“通吃”那么多语言和芯片2.1 三大分层前端、中端、后端要理解 LLVM 的通用性必提“三层架构”。这个设计思路简单到反直觉把编译器拆成三部分中间用统一表示来衔接。前端Frontend负责把具体语言的源码解析成 AST再生成语义明确的 LLVM IR。每门语言都需要自己独立的前端比如 C/C 是 Clang、Swift 是 Swift 编译器、Rust 是 rustc 前端。中端Middle-end / Optimizer对 IR 做与目标机器无关的优化。比如死代码消除、函数内联、循环展开、常量传播。这里跑的是 LLVM 核心的 Pass 框架。后端Backend把 IR 转换成目标平台相关的汇编或机器码。同一个 IR 可以喂给 X86 后端、ARM 后端、RISC-V 后端、WebAssembly 后端。每个新架构只需要写一个后端模块就能接入整套 LLVM 生态。把这三层分开之后你就相当于拿到了一个“编译器搭桥板”新语言过来把语言侧的活干好前端的产出是 IR新芯片过来把指令选择等后端逻辑写好消费的也是 IR。双方只要跟 IR 对接即可彼此不用关心对方的内部实现。我印象特别深的是 LLVM 的 IR 设计它采用静态单赋值SSA形式也就是每个变量只能被赋值一次每次赋值都生成一个新的“版本”。这个设计让数据流分析变得极其直白优化器可以轻松追踪值的产生和消费链路。类比一下这就像财务账本里每笔钱都有唯一编号审计人员可以很容易判断一笔钱从哪来、到哪去如果允许篡改旧账本对账就麻烦了。2.2 不同角色的开发者能从里面拿走什么很多工程师觉得自己既不是编译器专家也不做芯片跟 LLVM 没什么交集。但实际情况是你在日常开发里已经有意无意地在使用 LLVM 生态了。移动端/桌面端开发者Xcode 的默认编译器是 ClangAndroid NDK 的编译器也是 Clang很多高性能 Linux 服务端项目也会切到 Clang 构建。用 Clang 编译意味着你的代码可以拿到更清晰的报错信息、更严格的编译告警以及 AddressSanitizer 这类能在测试阶段就抓住内存问题的利器。嵌入式开发者交叉编译工具链里LLVM/Clang 早就取代了传统 GCC 工具链中的一部分RISC-V 生态更是全面拥抱 LLVM新指令集的指令选择、汇编支持基本都在 LLVM 社区优先落地。编程语言爱好者如果你正在写一门 DSL 或解释器想把它做成 JIT 编译配合LLVM-C API或者llvmlitePython 绑定你可以比较快地实现“边运行边编译”的动态能力比逐条解释执行有明显性能提升。基础架构团队/性能工程师你可以借助llvm-mca做指令吞吐量分析借助perf配合 Clang 的调试信息做更精准的性能剖析还可以跑优化 Pass 来测试不同优化策略对性能指标的影响。所以不要把 LLVM 理解为“编译大佬的专属玩具”它更像一组基础设施。基础设施的价值在于你可能不直接感知它但你的构建流程、代码质量检查、运行时诊断都在依赖它。3. 上手实操自己从源码构建一套 LLVM3.1 构建前的准备与参数选择想真正“懂” llvm-project最直接的方式是把它拉到本地构建一遍。虽然下载发行版二进制也能用但自己构建能让你对组件之间的依赖、构建系统的能力边界有身体记忆。第一步先确认机器条件内存建议 16GB 以上。如果你只构建核心和 Clang8GB 也能跑但要降低并行度要是把 libc、compiler-rt、MLIR 全加上内存不够会非常痛苦。磁盘完整构建会比较激进保守估计留 80GB。如果只构建核心 Clang30~40GB 基本够用。操作系统Linux、macOS、WindowsVisual Studio 生成器或者 ninja都可。我个人最顺手的组合是 Ubuntu Ninja。依赖方面其实很少主要需要 cmake、ninja、python3 和常见的 C/C 开发环境。注意不要用系统默认的 gcc 版本太老的环境去构建新版本 LLVM因为 LLVM 新版本对编译器版本有最低要求。比如 LLVM 17/18 通常要求 GCC 7.1 以上但你不可能用太老的 GCC 编译成功所以先gcc --version看一下。3.2 CMake 配置与构建命令详解我用的是经典的 out-of-source 构建。先从 GitHub 拉代码git clone https://github.com/llvm/llvm-project.git cd llvm-project克隆完后在 llvm-project 顶层创建一个构建目录然后配置 CMake。这里有一个高频困惑点cmake 的源目录应该指向 llvm 目录而不是仓库根目录。因为 CMakeLists.txt 的入口在 llvm/ 下面所以命令是这样mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ -DCMAKE_INSTALL_PREFIX$HOME/llvm-install逐个解释一下这些参数CMAKE_BUILD_TYPERelease: 编译 LLVM 自身时启用优化这会让构建产物本身跑得更快也显著减少构建时间。如果用 Debug某些 Pass 会因为各种断言检查慢得让你怀疑人生。对于日常学习和二次开发Release Assertions 是常见组合。LLVM_ENABLE_PROJECTS: 控制你要一起构建哪些子项目。大多数场景 clang;lld 足够如果你还想研究 MLIR就加;mlir想要完整工具链再加;clang-tools-extra。注意用分号不是逗号这是个常见翻车点。LLVM_TARGETS_TO_BUILDX86: 只生成 X86 后端的代码。如果全部都构建默认是LLVM_ALL_TARGETS构建时间和磁盘占用会明显增加。你跑在什么架构上就至少留一个对应 Target。比如做 ARM 交叉编译就写X86;ARM;AArch64。LLVM_ENABLE_ASSERTIONSON: 开发和调试 LLVM 本身时必须开很多 IR 非法操作、Pass 使用错误都是靠这些断言暴露的。生产环境的发布工具链一般关掉但研究学习期间开着更友好。CMAKE_INSTALL_PREFIX: 后续ninja install的安装路径。建议单独指定一个用户目录省得跟系统工具链冲突。配置完成之后直接启动构建ninja clang lld注意这里我用的是ninja clang lld而不是裸ninja。裸ninja会把所有能编的都编一遍包括一堆你可能用不上的测试和工具。只指定目标可以显著缩短时间。构建过程中你可以观察到 Ninja 自动检测 CPU 核心数并发编译默认并行数等于核心数。想限制并行度可以加-j 4之类的参数。构建完成后在 build/bin 目录下能看到 clang、clang、ld.lld、opt、llc 等一批工具。顺手验证一下./bin/clang --version看到类似clang version 18.0.0git的输出就说明第一步成功了。3.3 用构建出来的 Clang 跑通第一个例子工具链构建出来不是拿来看的我习惯用一段极简代码验证全流程。在临时目录里写下#include stdio.h int add(int a, int b) { return a b; } int main() { int x add(3, 4); printf(%d\n, x); return 0; }先编译出可执行文件$(pwd)/bin/clang -O2 hello.c -o hello ./hello输出7的话编译、链接、运行都没问题。但编译器光能出可执行文件太没意思我建议你立刻切到 LLVM 的中间环节去看 IR。因为理解和操作 IR 才是 LLVM 的核心价值所在$(pwd)/bin/clang -O0 -S -emit-llvm hello.c -o hello.ll打开 hello.ll你会看到类似这样的内容define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }这个%add就是 SSA 形式里的临时值add nsw里的nsw表示 No Signed Wrap告诉优化器这里如果发生有符号整数溢出属于未定义行为可以放心做各种数学变换。很多优化能跑得激进都是因为这些语义信息给了编译器充分授权。如果你用的是clang -O2生成 IR大概率会看到函数直接被内联进 main 里了。这就是优化 Pass 在起作用它发现 add 函数体积小、调用频繁直接把它内联展开省掉了函数调用开销。4. 写一个属于自己的 LLVM Pass进阶玩法4.1 Pass 是什么以及 Pass 基础设施读到这里你应该已经注意到 Pass 这个高频词了。所谓 Pass就是“一次遍历 IR 并做某种操作的代码”。优化本质上就是一连串 Pass 排队执行比如“内联 Pass”负责内联“死代码消除 Pass”负责清掉无用指令“常量传播 Pass”负责把常量计算前移。LLVM 的 Pass 基础设施是插件化的意味着你可以不修改 LLVM 主仓库单独写一个.so让opt在运行时加载。这非常适合实验性研究和自定义优化。新版的 Pass 基础设施是 New Pass Manager简称 NPM接口风格更现代化写在llvm/include/llvm/Passes/附近。老 Pass Manager 正在逐步淘汰新项目建议直接照着 NPM 写。我举个最小例子。假设你想写一个 Pass把所有函数名字从foo改成bar虽然实际应用场景很弱但足够串通整个开发链路。4.2 动手写一个简单的函数级 Pass在 llvm-project 之外建一个目录比如my-pass/里面放CMakeLists.txt内容如下cmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) message(STATUS Using LLVMConfig.cmake in: ${LLVM_DIR}) include_directories(${LLVM_INCLUDE_DIRS}) add_definitions(${LLVM_DEFINITIONS}) add_library(MyPass MODULE MyPass.cpp) set_target_properties(MyPass PROPERTIES PREFIX OUTPUT_NAME MyPass )然后再放一个MyPass.cpp#include llvm/IR/Function.h #include llvm/IR/Module.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct MyPass : public PassInfoMixinMyPass { PreservedAnalyses run(Module M, ModuleAnalysisManager AM) { for (Function F : M) { if (F.getName() foo) { F.setName(bar); errs() Renamed to bar\n; } } return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getMyPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, MyPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager MPM, ArrayRefPassBuilder::PipelineElement) - bool { if (Name my-pass) { MPM.addPass(MyPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getMyPassPluginInfo(); }这个文件看起来有点长但核心就三件事定义 Pass 类和运行逻辑注册 Pass 到 PassBuilder导出动态库入口。这里的registerPipelineParsingCallback是让opt能在命令行上用-passesmy-pass找到我们注入的 Pass。编译这个插件时关键是让 CMake 能定位到我们刚构建的 LLVM。在 my-pass 目录下执行cmake -B build -G Ninja \ -DLLVM_DIR$HOME/llvm-install/lib/cmake/llvm \ -DLLVM_EXTERNAL_LIT$HOME/llvm-project/build/bin cmake --build build如果你没有执行ninja installLLVM_DIR 需要指向 build 目录下生成的 cmake 文件所在位置比如build/lib/cmake/llvm。如果你执行过 install就用安装目录的路径。这一步其实也是很多新手不理解的地方——既然是独立模块编译它也需要 LLVM 的头文件和 cmake 配置所以上面的 find_package 是必需的。4.3 用 opt 加载并验证效果为了验证 Pass准备一个简单的模块文件test.lldefine i32 foo(i32 %x) { %y add i32 %x, 1 ret i32 %y }然后执行$HOME/llvm-install/bin/opt -load-pass-pluginbuild/MyPass.so -passesmy-pass test.ll如果一切正常Stderr 会打印Renamed to bar输出的 IR 里函数名foo会变成bar。这个流程虽然简单但它完整演示了“如何绕过巨无霸源码修改用插件机制扩展 LLVM”。你再想加点复杂的自定义优化比如根据循环 trip count 做循环拆分往 run 函数里填代码就行。5. 我踩过的那些坑编译慢、磁盘爆炸、版本漂移5.1 构建时间与磁盘空间控制我最早构建 llvm-project 时因为不知道LLVM_ENABLE_PROJECTS和LLVM_TARGETS_TO_BUILD这些开关直接把全量项目都编了。结果第一是等了两三个小时还没编完第二是磁盘从 60GB 可用变成不足 10GB差点磁盘满导致构建产物损坏。现在我的经验是先想清楚要做什么。只研究 Pass 的话一个 Release 的纯 LLVM opt 足够连 Clang 都可以不编想跑 C 代码再加 Clang想验证链接速度再加 lld。只用 Ninja不用 Makefile。Ninja 在增量构建上的速度比 Make 快很多而且在并发任务调度上更稳。用ccache给编译过程加缓存。LLVM 自身文件很大改一个头文件可能导致几十个文件重编有 ccache 之后二次构建能快非常多。配置时加-DLLVM_CCACHE_BUILDON。如果只是临时跑工具别用ninja install。build/bin 目录下的工具副本已经能直接运行不必多占一份磁盘空间。等真正需要系统集成时再安装。5.2 版本漂移问题及解决方案llvm-project 的主干分支更新非常快API 说变就变尤其是新 Pass Manager 普及后旧教程里的legacy::FunctionPass写法经常在新版本编译期直接报错。我就遇到过这种情况照着三年前的文章写 Pass直接提示registerPass不存在。这个不是你的问题是 LLVM API 迭代太频繁。应对方案有这么几条明确告诉别人你用的 LLVM 版本写文章、写工具时都标注LLVM 17或LLVM 18。否则读者拿着新版代码复制粘贴大概率编译不过。查文档时按 tag 去看对应分支。GitHub 上切到与本地一致的 tag比如git checkout llvmorg-17.0.1避免看到 main 分支上最新但不兼容的写法。使用 pass 插件时注意插件构建时用的 MLIR/LLVM 版本和目标 opt 的版本必须一致。版本号对不上opt加载时大概率报Plugin and opt have different LLVM versions或者直接崩溃。遇到报错先别慌多数是头文件路径变化、接口改名、类移到别的命名空间。搜索一下新版本中对应类的位置改改 include 和函数签名就通了。5.3 对普通开发者最实用的几个子项目有时候你不想做编译器二次开发但天然想从 llvm-project 里获取价值。那我推荐几个可以直接“抄作业”的方向clang-tidy: 可定制规则的 C 静态检查工具。团队可以约定自己的命名规范和代码风格在 CI 里跑 clang-tidy检查不通过直接拦截合并请求。它的检查项比普通 linter 更深能在 AST 级别发现逻辑问题。AddressSanitizer (ASan): 编译器自动插桩的运行时内存错误检测器。在测试环境用-fsanitizeaddress编译和运行能抓出 use-after-free、堆缓冲区溢出这类隐藏炸弹。这条对我日常工作影响极大很多线上疑难 bug 都是靠 ASan 在回归测试阶段暴露的。libc: 如果你有极致的性能和 ABI 洁癖或者你在做一个要求可移植性的 C 项目可以认真考察 libc。它的实现代码比 libstdc 更容易追踪和维护对标准的新特性跟进也快。lld: 老项目如果想降低链接耗时换 lld 是投入产出比最高的动作。在大型 C 项目里甚至可以把链接时间从几分钟压到几十秒。副作用是跟某些老版 GNU ld 特性和符号版本脚本可能有兼容性差异需要测试。我个人在实际操作中的体会是llvm-project 最值得学习的不是“某一个工具怎么用”而是整个项目如何用“统一 IR 模块化 Pass 插件机制”把复杂编译器拆成了一个高度可定制的框架。无论你是想优化自己的 DSL还是想深入理解现代编译器的工业级实现从自己动手构建、写一个最简单的 Pass 开始都是一条回报率极高的学习路径。下次有人再问我“LLVM 能做什么”我大概会回答它不只是编译器它是一整套让你掌控代码生成链路的基础设施值得每个做底层方向的开发者认真花点时间进去转一转。
返回列表