1. 项目概述
1.1 核心需求解析:LLVM到底是什么,为什么值得折腾
先说个身边常见的事。很多新手第一次听说LLVM,是在编译原理课上,老师提了一句“LLVM是个编译器框架”。然后自己上网一搜,好家伙,GitHub上的llvm-project仓库star数超高,光是clone下来就有好几个GB,编译一次能让人等到怀疑人生。于是很多人就这么被劝退了。
但这个项目真的值得你花时间。LLVM(Low Level Virtual Machine)虽然名字里有“虚拟机”,但本质上它是一整套可用于构建编译器、代码生成工具、静态分析工具、IDE后端的基础设施。你平时用的Xcode里的Clang编译器,Android NDK默认的工具链,Swift和Rust的编译器,甚至PlayStation、Switch这类游戏主机的官方SDK编译器,底层都和LLVM有关系。可以说,只要你在写代码,你就在间接和LLVM打交道。
这篇文章我想从一个实际折腾过llvm-project的人的角度,把这个项目完整拆开来讲。不只是讲它是干什么的,更重要的是讲清楚它的几个核心组件怎么协同工作,自己动手编译一次要怎么做,遇到问题怎么排查,以及最后怎么用LLVM的API写一个能跑的编译器小工具。不管你是计算机专业学生、编译器爱好者、还是想给公司做定制工具链的工程师,这篇内容应该都能让你少走不少弯路。
1.2 使用场景澄清:llvm-project里到底装了什么
很多人把llvm-project当成“一个叫LLVM的东西”,实际上一clone下来就懵了:里面有clang、lld、libcxx、compiler-rt、mlir、flang、polly等等一大堆子项目。其实这个仓库本质上是LLVM生态的统一仓库,官方叫monorepo,就是把这个生态里几乎所有核心工具的源码都放到了一起。
理解这一点很重要,因为你在编译的时候并不是所有组件都要编。你要是只写C语言并想用Clang,那只编clang就够了;你要是想搞Rust的代码生成,那只需要LLVM核心库;你要是想折腾编译器优化,那就要看LLVM的middle-end和back-end;你要是想写一个自定义的语言前端,那你最需要研究的是Clang和LLVM IR的接口。搞清楚了目录结构,后面的使用才不至于绕圈子。
llvm-project的核心价值就三件事:第一,提供了一整套模块化的编译器基础设施,你不用从零搭建也能搞出一个实用的编译器;第二,有一个极其丰富的中间表示(IR)和优化框架,让代码优化变成一个相对独立的环节;第三,围绕它形成了一个庞大的工具生态,从语法高亮、自动补全、静态检查到性能剖析,全都能在LLVM的底座上长出来。
有了这个整体认知,下面我就结合自己动手编译的完整过程,把llvm-project从源码到能用的全过程掰开来细说。
2. LLVM的核心设计架构与技术选型
2.1 经典三段式架构:为什么LLVM能把跨平台和跨语言兼顾
要理解LLVM,先得忘掉“编译器就是读一个文件输出一个可执行文件”这个简单模型。传统编译器如早期的GCC,前端、优化、后端是紧密耦合在一起,你接到一个新语言或者新CPU架构的活,往往要从头改到尾。LLVM从诞生那天起就做了一个关键决定:把编译过程强行拆成三段,也就是:
源代码 -> 前端(Frontend) -> LLVM IR -> 优化器(Optimizer) -> LLVM IR(优化后) -> 后端(Backend) -> 机器码我动手写编译器之前,觉得这个拆分没什么了不起,代码不就是左边进右边出吗。但当你真的去实现过一遍才发现,这个拆分的魄力极大。前端只负责把源代码变成IR,后端只负责把IR变成机器码,中间的优化器则完全建立在IR之上。这样一来,你要支持一门新语言,只需要写一个新的前端,把IR生成对就行,优化器和后端直接复用;你要支持一种新CPU,只需要写一个后端,所有语言都能跟着受益。这就是为什么Rust、Swift这些新语言选LLVM做底座——不是它们的编译器团队懒,而是这个架构本身就划算。
作为使用者,这种架构带来的直接好处是:你学一次LLVM IR的知识,几乎可以用于分析C、C++、Rust、Swift、Objective-C等所有基于LLVM的语言。你要写代码混淆工具、加密保护工具、性能分析工具,都可以统一在IR层面做,完全不需要关心前端语言语法,更不用关心目标机器码。这是我在真实做工具链开发时体会最深的一点。
2.2 LLVM IR的核心地位:为什么它是整个生态的“通用语”
LLVM IR(Intermediate Representation)是LLVM的灵魂。它被设计成一种类似精简指令集(RISC)的中间语言,但不是某种真实CPU的指令集,而是为编译优化“量身定做”的。它有三个特点:
第一,静态单赋值形式(SSA)。每个变量只能被赋值一次,这看起来像是个限制,但实际上让数据流分析变得异常简单。要做常量传播、死代码删除这类优化,直接对着SSA分析就很方便。
第二,强类型且足够底层。IR里每条指令都带类型信息,比如add i32 %a, %b,表示两个32位整数相加,结果类型也是i32。这种底层但又保留类型的设定,让编译器既做得了底层优化,又能做不少类型相关的分析。
第三,人类可读。IR有三种形态:内存中的数据结构、字节码格式(bitcode)、可读的文本格式(.ll文件)。文本格式这点对学习者极其友好。我第一次用clang -S -emit-llvm把C代码变成IR文件,打开看的时候,瞬间就理解了“原来我写的for循环在编译器眼里长这样”。这种可读性让LLVM不仅仅是一个工具,更是一种学习编译器知识的绝佳教材。
很多人觉得写编译器是天才才能做的事情,但LLVM IR把这个门槛拉低了很多。你不需要一开始就去处理词法、语法、语义分析这些复杂的前端理论,只需要掌握IR的结构,就能开始写优化pass和分析工具。这也是我建议所有想入门编译器的人,从LLVM IR入手而不是从写前端入手的原因。
2.3 构建系统与组件选型考量:为什么官方推荐CMake + Ninja
llvm-project的构建方式和普通Linux软件不太一样,它非常依赖一套比较现代的构建工具链。官方默认推荐用CMake配合Ninja。我第一次编译LLVM时用的是Makefile,结果那个等待时间、那个单线程编译的酸爽,至今记忆犹新。后来换成Ninja,又加了并行参数,速度提升了不止一个量级。
为什么用CMake?因为LLVM本身的高度模块化,需要构建系统能够灵活配置要编译哪些组件、哪些目标架构。CMake提供大量开关,比如LLVM_ENABLE_PROJECTS决定编译哪些子项目,LLVM_TARGETS_TO_BUILD决定要生成哪些后端的机器码,这种按需选择的能力是LLVM这种庞大项目的刚需。
为什么用Ninja?因为它比Make更快、更好地处理并行依赖关系。LLVM源码文件数以万计,如果构建系统的任务调度不够聪明,很容易出现大量编译单元等待的情况。Ninja就是为大型C++项目设计的,它生成的构建文件把依赖关系全部显式列好,编译调度做得比Makefile精细得多。所以后面我给出的编译命令,默认都是CMake + Ninja的组合,这也是几乎所有LLVM相关工具链(包括Android NDK、Rust)实际在用的构建方案。
3. llvm-project核心组件拆解与实操要点
3.1 核心组件地图:Clang、LLD、libc++、compiler-rt分别管什么
llvm-project仓库里的子项目非常多,但实际干活的核心组件其实就那么几个。我整理了一个对照表,方便大家按需选用:
| 组件 | 全名/定位 | 解决什么问题 | 典型使用场景 |
|---|---|---|---|
| LLVM core | 优化器与代码生成器 | IR生成、优化pass、目标指令选择与寄存器分配 | 所有基于LLVM语言的共同底座 |
| Clang | C/C++/Objective-C前端 | 把C/C++源代码解析并生成LLVM IR | 日常C/C++编译、静态分析、IDE索引 |
| clang-tidy | C++静态分析工具 | 基于Clang AST的规则检查 | CI流程代码规范检查、自定义规则 |
| LLD | 链接器 | 快速链接可执行文件和动态库 | 替代系统自带链接器,显著加快增量链接 |
| libc++ / libc++abi | C++标准库实现 | 提供C++运行库和异常处理支持 | 跨平台C++开发,特别是macOS/iOS |
| compiler-rt | 编译器运行时库 | 提供内存检测、sanitizer、内建函数等支持 | 调试未定义行为、内存泄漏、性能剖析 |
| MLIR | 多层IR框架 | 构建可复用的编译基础设施 | 机器学习模型编译、领域特定语言 |
我第一次接触这些组件的时候有点懵,因为它们之间界限比较模糊。我后来用一个比较粗浅的类比才彻底理解:你可以把llvm-project看成一个“编译器工厂”。Clang是前台接待,负责听你讲需求(读源码);LLVM core是中央厨房,负责把需求转化成具体的菜(优化和生成代码);LLD是传菜员,负责把各个菜配成完整的一桌(链接成可执行文件);libc++是餐具和调味料,负责把使用体验补齐(标准库);compiler-rt则是消防安全员,负责检测厨房里有没有出问题(运行时检查和sanitizer)。这么一想,整个项目之间的关系就清楚多了。
3.2 正确拉取源码与版本选择的经验之谈
开始编译之前,第一步是把llvm-project源码弄到本地。这一步看似简单,但版本选择这里其实藏了很多坑。llvm-project的git仓库非常活跃,开发分支上的代码可能每天都有大量改动,今天能编过明天就挂了是常有的事。所以我的经验是,除非你想体验最新特性并愿意一起修bug,否则永远不要去clone开发分支。
正确做法是选择最新发布的release版本。LLVM的release策略通常是每个大版本发布若干次修订版,比如17.0.1、17.0.6、18.1.0、18.1.8等。这里我推荐选择你所在时间节点的“最新release版”,或者隔一个版本的成熟release版。选好版本后在GitHub的Release页面找到对应的源码tarball,用下载而不是git clone的方式获取。一方面tarball体积小很多,另一方面也避开了git仓库巨大的历史记录。
如果你一定要用git clone全量仓库,有个建议是使用--depth=1做浅克隆,只拉最新一次提交,能省掉大量历史数据。如果是用来学习而不是参与开发,没必要保留完整历史。我自己后来就是直接在本地建一个目录专门放各种版本的源码包,需要用哪个版本就解压哪个。这套工作流比反复切git分支要舒服得多。
不过需要注意一点,源码包的体积本身也很可观,完整仓库甚至超过2GB。建议在磁盘空间充足的情况下进行,并留出至少30GB的编译临时空间。
# 以LLVM 18.1.8为例(实际下载URL以官方release页面为准) wget https://github.com/llvm/llvm-project/releases/download/llvmorg-18.1.8/llvm-project-18.1.8.src.tar.xz tar -xf llvm-project-18.1.8.src.tar.xz cd llvm-project-18.1.8.src提示:如果你在国内网络环境,从GitHub下载经常会非常慢。可以考虑从清华、中科大等开源镜像站下载相同版本号的源码包,只是镜像站更新可能滞后一点。镜像下载不受影响,release包在镜像站上通常都能找到。
3.3 按需裁剪编译范围:只编译你真正需要的组件
很多第一次编译llvm-project的人,看到README里的默认配置就直接上了,结果一编就是三四个小时,还差点把硬盘塞满。其实这完全可以避免。LLVM的CMake配置非常灵活,你可以只编译需要的部分。
最核心的参数是LLVM_ENABLE_PROJECTS,它决定你同时编译哪些上层子项目(clang、lld、libcxx等)。如果你只需要Clang和LLD,就只指定这两个:
cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86" \ -DLLVM_ENABLE_ASSERTIONS=ON还有LLVM_TARGETS_TO_BUILD,这个参数也很关键。它决定编译哪些目标架构的后端代码生成器。默认情况下LLVM会编一大堆架构的back-end,包括X86、ARM、AArch64、RISC-V、PowerPC、Mips等等。如果你只是在自己电脑上开发调试,那绝大多数后端都用不上。只留一个X86(或者你在用的架构),编译时间和磁盘占用能减少一半以上。
再补充一点,LLVM_ENABLE_ASSERTIONS这个开关平时建议开着,尤其是你自己在开发LLVM pass或者修改LLVM源码的时候。它会在运行时多做大量内部一致性检查,一旦发现IR或数据结构有问题就会立即报错。性能上确实会受影响,但开发期这种debug能力比性能重要得多。真正要发布到生产环境的时候再关掉也不迟。
3.4 预处理与依赖检查:编译前必须做好的准备工作
编译llvm-project之前,还得先检查一下系统环境。LLVM是用现代C++写的,对编译器的版本要求比较高。我见过不少编译失败的情况,最后排查半天发现是系统自带的gcc版本太老,连C++17支持都不完整。这里给出一个粗略的版本建议:
- Linux:GCC 7.1以上,或者Clang 5.0以上;推荐GCC 11+或者Clang 14+
- macOS:Xcode自带的Clang即可,注意要用Command Line Tools完整版
- Windows:Visual Studio 2019以上,并勾选“使用C++的桌面开发”工作负载
另外还要确认内存和swap空间。LLVM编译的峰值内存占用可以到好几个GB,如果你的机器只有4GB内存,加上4GB swap就会很吃力。我自己的经验是,8GB内存的机器编起来还算顺畅,16GB基本无压力。云端编译机器的话,建议选内存优先的实例类型。
磁盘空间也要提前规划。默认的构建目录通常会有10GB以上的产物,如果用CMAKE_BUILD_TYPE=Debug,体积还要翻几倍,最高能到30GB以上。所以在开始编译前,先df -h看一眼磁盘剩余空间。这个动作看着很啰嗦,但能省掉编到一半磁盘爆掉、前功尽弃的惨剧。
3.5 编译流程全记录:从cmake到ninja install的完整过程
环境准备好后,就可以正式开始编译了。下面是一套适用于绝大多数Linux发行版和macOS的完整流程,我在多台机器上实测过,没有出过问题。
首先建立构建目录。官方推荐在源码根目录外构建,也就是out-of-source构建。这是为了让源码目录保持干净,方便后续重新配置。具体操作是在llvm-project源码目录的平行位置建一个build目录:
mkdir build cd build然后执行cmake配置,把源码根目录下的llvm子目录作为源目录传入。注意cmake的路径正正是源码里的llvm文件夹,不是llvm-project根目录:
cmake -S ../llvm-project/llvm -B . -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=/opt/llvm-18 \ -DLLVM_ENABLE_PROJECTS="clang;lld;clang-tools-extra" \ -DLLVM_TARGETS_TO_BUILD="X86" \ -DLLVM_ENABLE_ASSERTIONS=ON配置完成后,终端会输出一大堆配置信息,最后显示Configuring done和Generating done。这个时候不要急着编译,我建议先看一眼生成的build.ninja文件是否包含了我们想要的目标。简单办法是执行ninja -t targets | grep clang看看有没有clang相关构建目标。确认无误后,再开编:
ninja clang lld clang-tidy这里说说为什么不是编译全部的ninja。LLVM的默认all目标会编译所有组件,不仅是clang和lld,还包括llvm-ar、llvm-nm、llvm-objdump、llvm-size等一系列工具,甚至包括各种test工具。这些工具虽然也有用,但初次编译时可以先不出,等需要时再单独构建。直接挑自己需要的目标编,效率最高。
如果机器核数多,可以加上并行参数。Ninja默认就会根据CPU核心数自适应并发,但我个人经验是在内存不太充裕的机器上,可以手动限制一下并发任务数,比如ninja -j4,防止内存耗尽导致OOM。要知道,LLVM每个编译任务都是吃内存的大户,16核机器开着默认并发,峰值内存轻松超过20GB。
编译完成并验证可执行文件正常后,执行ninja install。这一步会把编译产物和头文件按预设的CMAKE_INSTALL_PREFIX安装到指定目录。之后使用的时候,把对应目录的bin加到PATH即可,比如:
export PATH=/opt/llvm-18/bin:$PATH export LD_LIBRARY_PATH=/opt/llvm-18/lib:$LD_LIBRARY_PATH3.6 不同平台的差异化构建:macOS和Windows需要注意什么
上面的流程在Linux上基本上不会出什么问题,但到了macOS和Windows,有一些差异化的坑要提前说清楚。
macOS上,首先建议先执行xcode-select --install确保Command Line Tools安装完成。由于macOS默认的SDK路径和Linux不一样,有些第三方依赖查找会失败。最简单的办法是,让编译器使用系统自带的clang,并设置好SDK路径。另外macOS上如果遇到ld: library not found for -lcurses之类的链接错误,多半是缺少ncurses库,用Homebrew装一个brew install ncurses就能解决。还有一点,Apple Silicon(M1/M2/M3)机器上,如果要用原生编译,把LLVM_TARGETS_TO_BUILD里加上AArch64;如果想跑Intel的x86_64工具链,则需要加-DCMAKE_OSX_ARCHITECTURES=x86_64。
Windows上的流程差异更大。官方推荐用Visual Studio的Developer PowerShell,因为编译LLVM生命周期中大量用到MSVC的特定工具链。使用cmake -G Ninja时,要确保在Developer环境中执行,否则cmake找不到cl.exe。一个比较容易踩的坑是Windows路径里的反斜杠问题,建议所有路径参数都使用正斜杠。还有,Windows上的并行编译内存消耗比Linux更凶,建议-j不要开满,不然很容易出现系统卡死。
我个人的建议是,如果只是学习LLVM而不涉及Windows特有功能,尽量在WSL的Linux环境里编译。WSL2下表现接近原生Linux,一揽子解决掉很多Windows特有的依赖问题,省下的时间能用来多写好几个pass。
4. 第一个LLVM实践:写一个自定义优化pass
4.1 为什么建议从优化pass入手学LLVM
说实话,研究LLVM最容易获得成就感的方式,不是读源码,而是写一个自己的优化pass跑起来。优化pass是LLVM优化器的核心扩展单元。它做的事情非常简单:遍历IR中的函数、基本块、指令,然后根据你的逻辑对IR做变换。你写一个pass,注册到LLVM的pass管理器里,然后在编译C代码的时候,你的pass就能在优化阶段自动对IR进行操作了。
我给自己学生上课时,第一节课就会让他们写一个最简单的Hello World pass。具体来说,这个pass什么都不优化,只是遍历所有函数,打印每个函数的名字和它包含的基本块数量。就这么一个简单的pass,跑起来的那一刻,你会发现你以前对LLVM“似乎很高深”的印象瞬间被打破——原来编译器内部真的是可以被我们随手操控的。
从这个实践里,你能学到好几样东西:pass的编写方法、LLVM IR的遍历API、LLVM的new pass manager注册方式。这三样东西基本涵盖了LLVM二次开发中最常用到的能力。后面你要写代码混淆、性能分析、插桩,都是在这些基础上扩展而已。
4.2 搭建你的第一个pass工程(基于LLVM 18)
现在LLVM官方推荐使用new pass manager,写一个pass的工程结构一般是这样:一个CMakeLists.txt、一个pass源文件,然后通过opt工具加载运行。我的示例代码基于LLVM 18,因为这一版本对new pass manager的支持已经非常成熟。
先写pass源文件MyPass.cpp:
#include "llvm/IR/Function.h" #include "llvm/IR/Instructions.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 PassInfoMixin<MyPass> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { errs() << "函数名: " << F.getName() << "\n"; errs() << "基本块数量: " << F.size() << "\n"; for (auto &BB : F) { errs() << " 基本块 " << BB.getName() << " 指令数: " << BB.size() << "\n"; } return PreservedAnalyses::all(); } }; } // namespace // 注册pass插件,让opt工具能够加载 llvm::PassPluginLibraryInfo getMyPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "MyPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "my-pass") { FPM.addPass(MyPass()); return true; } return false; }); }}; } extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getMyPassPluginInfo(); }这个pass在run函数里遍历函数的每个基本块,打印函数名和块的信息,然后返回PreservedAnalyses::all(),表示我没有改变任何IR,所以所有分析结果都保持有效。
配套的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}) separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS}) add_definitions(${LLVM_DEFINITIONS_LIST}) add_library(MyPass MODULE MyPass.cpp) target_link_libraries(MyPass PRIVATE LLVM)把这两个文件放在同一个目录下,然后用cmake配置编译。为了让find_package(LLVM)能找到我们的LLVM,需要设置LLVM_DIR环境变量指向LLVM安装目录下的lib/cmake/llvm:
cd mypass_build cmake -S ../mypass_src -B . -G Ninja -DLLVM_DIR=/opt/llvm-18/lib/cmake/llvm ninja编译完成后会生成一个MyPass.so(Linux)或MyPass.dylib(macOS),这个就是opt工具可以直接加载的插件。
4.3 运行并验证自定义pass的效果
先写一小段测试用C代码,命名为test.c:
int add(int a, int b) { return a + b; } int main() { int x = add(1, 2); if (x > 2) { return x; } return 0; }然后分两步走。第一步,把C代码编译成LLVM IR的文本形式;第二步,用opt加载我们的pass插件运行在IR上:
clang -S -emit-llvm test.c -o test.ll opt -load-pass-plugin ./MyPass.so -passes=my-pass test.ll -o /dev/null运行后你应该能看到类似下面的输出:
函数名: add 基本块数量: 1 基本块 entry 指令数: 3 函数名: main 基本块数量: 3 基本块 entry 指令数: 7 基本块 if.then 指令数: 2 基本块 if.else 指令数: 1到这里,你的第一个LLVM pass就跑通了。这一步跨过去以后,后面你对“怎么修改编译器行为”这个概念就会有非常具体的感知。以后看到市面上各种基于LLVM的加固工具、插桩工具、代码覆盖率工具,你都不会觉得它们神奇了,因为它们本质上都是在做同一件事:写一个pass,然后塞进编译流程里。
4.4 实战扩展:从一个打印pass到真正修改IR
打印信息只是热身,真正能干活的是修改IR。这里分享一个我写的典型教学例子:写一个pass,把代码里的整数加法指令(add i32)替换成函数调用。
这个需求的现实背景是,有些场景下我们要对加法操作做额外处理,比如防溢出、追加日志、做运行时检查。编译期把这些add指令替换成对自定义运行时函数的调用,是最常见、最灵活的插桩手段。
关键代码逻辑大概是这样的:
// 在run函数里,遍历所有基本块,遍历所有指令 for (auto &BB : F) { for (auto &I : BB) { auto *BinOp = dyn_cast<BinaryOperator>(&I); if (!BinOp) continue; if (BinOp->getOpcode() != Instruction::Add) continue; // 关键验证:仅有i32类型的add才处理 if (BinOp->getType() != Type::getInt32Ty(F.getContext())) continue; // 构造对外部函数的调用,替换原指令 FunctionCallee callee = F.getParent()->getOrInsertFunction( "my_runtime_add", BinOp->getType(), BinOp->getType(), BinOp->getType()); CallInst *call = CallInst::Create(callee, {BinOp->getOperand(0), BinOp->getOperand(1)}); call->insertAfter(BinOp); BinOp->replaceAllUsesWith(call); BinOp->eraseFromParent(); } }这个代码里有个非常容易出错的地方是函数原型匹配。getOrInsertFunction时参数顺序要和调用一致,如果有不匹配,LLVM会直接报类型错误甚至崩溃。我当初第一次写这个pass时,因为把参数类型顺序写反了,花了一整个晚上排查。后来养成了一个习惯,每写一个这类pass,都要用opt -verify验证IR合法性,防止生成无效IR。
运行的时候注意,这个pass返回的PreservedAnalyses不能再是all()了,因为你确实修改了IR。正确写法是返回PreservedAnalyses::none(),表示所有分析结果都可能失效,需要重新计算。这也是一个经常被忽略的细节。如果返回了错误的PreservedAnalyses,后续优化可能基于过时的分析结果做出错误决策,最终生成的代码会出问题。
5. Clang与LLD的实用技巧
5.1 用Clang查看和分析C代码的IR生成过程
实际开发中,写优化pass只是LLVM生态的一小部分。绝大多数情况下,你是在“使用”Clang和LLVM,而不是在“修改”它们。这里分享几个我日常高频使用的Clang命令,无论是排查编译器行为还是学习编译原理,都特别实用。
第一个是用Clang生成IR文本。对代码test.c执行:
clang -S -emit-llvm test.c -o test.ll生成的test.ll就是对应代码的LLVM IR。很多初学者看着这个文件觉得一堆%变量名看不懂。其实读IR是有技巧的,关键是先看指令类型。比如:
%2 = load i32, i32* %1, align 4这行代码表示从指针%1指向的内存中读一个32位整数,存到%2。IR里的指令名不是重点,重点是指令操作码和类型描述。掌握了这两点,IR基本就能当汇编一样读了。
第二个是查看优化前后IR的区别,这一步是理解编译器优化最重要的途径:
clang -S -emit-llvm -O0 test.c -o test_O0.ll clang -S -emit-llvm -O2 test.c -o test_O2.ll diff test_O0.ll test_O2.ll打开diff结果,你会清楚地看到-O2下编译器帮你做了哪些事:死代码被去掉、常量被直接计算、循环被发现可以向量化等。这种直观对比比对着优化算法文档啃效率高得多。
第三个是查看ast,这对写静态分析工具或有语法理解需求的人很有帮助:
clang -Xclang -ast-dump -fsyntax-only test.c它会输出抽象语法树(AST),以缩进层级的方式展示出代码的语法结构。比如main函数下有一个CompoundStmt,里面包含DeclStmt、ReturnStmt节点等。在写clang-tidy自定义检查的时候,这个命令是必不可少的调试工具。
5.2 LLD链接器:为什么说它是C/C++工程师的隐形加速器
LLVM生态里有一个经常被忽视但性价比极高的组件——LLD,LLVM的链接器。它最大的优势就是快。在大型C++项目里,传统GNU ld链接一个上亿行代码的二进制可能要好几分钟,LLD往往几十秒就能干完。
为什么LLD这么快?核心原因是它的内部设计从一开始就考虑了并行化和高效的内存访问。链接的过程本质上是解析符号、重定位、写入输出文件,传统链接器通常是单线程串行处理这些步骤,而LLD把符号表解析和数据布局等步骤并行化了,而且在很多环节直接利用内存映射文件减少拷贝,速度自然就上来了。
使用LLD也非常简单。在编译参数里加一个-fuse-ld=lld(需要在PATH里有ld.lld):
clang -fuse-ld=lld test.c -o test如果使用的是CMake项目,可以在CMakeLists里设置:
set(CMAKE_EXE_LINKER_FLAGS "-fuse-ld=lld") set(CMAKE_SHARED_LINKER_FLAGS "-fuse-ld=lld")我实际测过,一个中等规模的C++项目,链接时间从原来的3分50秒降到55秒左右,提升相当可观。增量构建场景下,这个优势会更加明显。
不过使用LLD要注意一个兼容性问题。个别老旧的第三方库可能依赖GNU ld的某些特有行为,换到LLD后会链接失败。遇到这种情况,建议先单独把那个库拿出来测试,一般都是某个并行选项或脚本引起的。真搞不定再用回系统链接器就行,这不是面子问题,能干活最重要。
5.3 自定义工具链:libc++和compiler-rt何时用得上
libc++和compiler-rt是两个相对小众但对特定场景非常重要的组件。
libc++是LLVM官方的C++标准库实现。系统自带的libstdc++(GCC的C++标准库)在大多数情况下够用,但如果你需要在非Linux平台上使用统一的标准库行为,或者需要快速迭代标准库代码,libc++就是更好的选择。比如在macOS上,Xcode自带的工具链已经默认使用libc++。在Linux上如果你交叉编译到macOS目标,那libc++几乎是唯一可行的C++标准库选择。
使用时通过-stdlib=libc++指定:
clang++ -stdlib=libc++ test.cpp -o test注意这同时要求系统安装了libc++的头文件和运行库,所以如果你的LLVM安装目录里没有libc++,需要先编译安装它。
compiler-rt则更像一个“幕后英雄”。它的functions包括Sanitizer(AddressSanitizer、ThreadSanitizer、UndefinedBehaviorSanitizer)、profile运行时库、一些特定的内建函数。最经典的用法是内存检测:
clang -fsanitize=address test.c -o test_asan ./test_asan如果你怀疑代码有use-after-free、缓冲区溢出这类内存问题,ASan能直接给出出错位置和调用栈。这个能力几乎是生产级C/C++项目排查内存问题的标配手段,我经手过的很多线上崩溃,最后都是靠ASan在本地复现后定位的。
5.4 实战经验:加速你的日常编译流程
不管你是用llvm-project做二次开发,还是单纯把它当编译器用,优化编译体验总是一件值当的事情。这里分享几个我自己做工具链开发时积累的经验。
第一,善用ccache。ccache是一个编译缓存工具,它根据输入文件、编译参数、头文件等计算哈希,命中缓存就直接返回之前的编译结果。在反复调整单个源文件并重编的场景里,ccache能带来接近数量级的加速。我通常在配置cmake时设置:
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache对于LLVM这样的大型项目,第一次完整编译后,后续增量重编的速度提升非常明显。
第二,使用ld.lld配合增量链接。大型C++项目的仿真链接往往是整个构建过程的瓶颈,换成LLD后,链接耗时会大幅缩短。如果你已经开始用CMake,给编译和链接参数里加一行命令即可。
第三,合理使用-j参数。编译任务并发数和CPU核数对应起来是理想情况,但内存往往才是瓶颈。如果你不确定内存能否撑住最大并发,可以先ninja -j4试跑一分钟,观察内存占用曲线再往上加。很多人在这一步把机器搞到OOM崩溃,前功尽弃。
6. 常见问题与排查技巧实录
6.1 编译过程中“莫名其妙”的失败,十有八九是这些问题
llvm-project编译失败这件事,几乎没有人能完全避免。这里整理一份我这几年来遇到最多的几个问题,按出现频率排序,附带排查经验和解决思路,应该能帮你省下很多时间。
第一个问题:源码下载不完整或解压损坏。LLVM的源码包很大,网络不稳定时下载很容易出问题。装完后cmake配置时,经常会出现某个头文件找不到的情况。排查办法很简单,对源码目录执行一次校验或者重新解压,确认文件时间戳和数据完整性。可以下载对应的.sig签名文件,或查看官方提供的SHA256校验值进行比对。
第二个问题:磁盘空间不足。前面提到过,编译LLVM需要足够的临时空间和安装空间。但实际中很多人不注意$TMPDIR。CMake在配置阶段会在/tmp下生成大量临时文件,如果/tmp挂载的分区空间不足,会在配置中途报各种奇怪的错误。解决办法:把TMPDIR指向一个空间充足的位置,或者给系统盘扩容。
第三个问题:内存不足导致编译过程被系统“杀”掉。如果你看到Killed、cc1plus: fatal error: Killed signal terminated program cc1plus这类错误,十有八九是OOM。这种情况下可以减少并行编译任务数,比如ninja -j2,也可以增大swap空间。我在4GB内存的云服务器上编译过LLVM,通过设置8GB的swap也勉强能编完,但时间非常长,不推荐。
第四个问题:源码和系统库版本冲突。LLVM对部分依赖库版本有要求,比如zlib、libxml2。如果在配置阶段提示找不到特定版本的库,可以用系统包管理器安装,但不建议升级系统默认库,因为可能导致其他程序出问题。更安全的方式是将依赖库安装到独立路径,然后通过CMAKE_PREFIX_PATH或-DLLVM_DEPENDENCY_DIR指定给CMake。
6.2 运行时“找不到库”、module not found如何处理
编译好LLVM后,运行时经常出现找不到共享库的问题。比如:
error while loading shared libraries: libLLVM-18.so: cannot open shared object file: No such file or directory这个原因很简单,LLVM安装到了非系统标准目录,动态链接器找不到。解决办法是把LLVM的lib目录加入LD_LIBRARY_PATH:
export LD_LIBRARY_PATH=/opt/llvm-18/lib:$LD_LIBRARY_PATH更一劳永逸的办法是配置ldconfig,在/etc/ld.so.conf.d/下新建一个llvm.conf,写入/opt/llvm-18/lib,然后执行ldconfig。
如果你是开发自己的pass插件,opt加载时还会遇到Could not load library: MyPass.so。这个时候分几类排查:插件路径是否正确、插件是否是当前LLVM版本编译的、是否缺少LLVM运行时库的依赖。在Linux上可以用ldd MyPass.so查看动态库依赖,排查缺哪个库。
6.3 自定义pass没有生效?排查顺序和方法建议
写pass最痛苦的事情就是费了半天劲,结果编译出来发现pass根本没跑、或者效果不对。这种问题要根据具体现象分层排查。
先看pass是否被加载。如果opt -load-pass-plugin ./MyPass.so -passes=my-pass执行时报错Unknown pass name 'my-pass',说明pass注册的name和命令行传入的不一致。检查源码里回调函数中的if (Name == "my-pass"),确保两边字符串完全一致。
再看pass是否被正确运行。如果命令行能接受my-pass,但在run函数里设置断点或打印信息没有输出,很可能是pass确实被调度了但传递的对象和你预期不一样。比如写的是ModulePass却以FunctionPassManager方式注册。此时要在run函数第一行加一个errs() << "pass is running\n"强制输出来确认。
第三种情况是pass确实运行了,但生成的IR就是没变化。这个时候需要逐条检查你的变换逻辑。一个常见问题是遍历IR时使用了迭代器,但你修改或删除了指令,导致迭代器失效而静默返回。解决方案是在修改前先收集待操作指令到std::vector,遍历这个向量做变换,而不是在遍历IR的同时直接修改IR。
最后一步,永远别忘用opt -verify!在pass后面接一个验证步骤,让LLVM帮你检查IR是否合法。
opt -load-pass-plugin ./MyPass.so -passes=my-pass -verify test.ll -o /dev/null如果IR有问题,验证器会直接告诉你出错的位置和类型信息,这比你自己一行行对IR要高效太多。我几乎每次改完pass都会跑一遍,已经是肌肉记忆了。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| cmake提示找不到LLVMConfig.cmake | LLVM_DIR未设置或路径错误 | 确认LLVM_DIR指向含有LLVMConfig.cmake的目录 |
| ninja编译中途“Killed” | 内存不足触发OOM | 减少-j并发数,增加swap |
| 找不到libLLVM-18.so | 动态库路径未配置 | export LD_LIBRARY_PATH或配置ldconfig |
| opt报Unknown pass name | pass注册名不匹配 | 检查registerPipelineParsingCallback里的Name字符串 |
| pass加载了但无输出 | pass未在pass pipeline中调度 | 设置断点或打印进入run函数的信息 |
| pass执行完IR没变化 | 修改的是临时拷贝,或指令收集不全 | 确保操作的是真实Instruction对象,先收集再修改 |
| LLD链接报错,旧库不兼容 | 个别库依赖GNU ld行为 | 单独处理或回退到系统链接器 |
| clang无法找到标准库头文件 | 使用了非标准安装路径 | 设置CPATH或C_INCLUDE_PATH指向正确目录 |
7. 学习路径与资源推荐
7.1 初学者的LLVM路线图:从会用工具到能写工具的四个阶段
我发现越来越多的人开始对LLVM感兴趣,但不少人因为一开始就陷进“读源码”的无底洞而放弃。其实学LLVM有一条比较顺畅的路径,我把它总结成四个阶段,每个阶段都有明确的目标和产出。
第一阶段是“工具使用者”。你不需要理解LLVM内部细节,只要会装好Clang/LLD,知道怎么编译代码、怎么生成IR、怎么看优化前后差异。这个阶段最快的上手方式是用Clang把各种测试代码变成IR,开着-O0和-O2比较。目标是能读懂LLVM IR的基本结构。
第二阶段是“pass编写者”。你已经能写一个能跑的Hello World pass,会遍历函数和指令,会做简单的IR变换。这个阶段重点掌握new pass manager的API风格,以及常用IR类的继承体系:Module、Function、BasicBlock、Instruction、Value、Type。不必追求写复杂的优化算法,先把LLVM IR的类型系统搞清楚。
第三阶段是“优化器理解者”。到这个阶段,你要开始系统学习经典优化算法在LLVM中的实现,比如死代码消除、循环不变量外提、内联、常量传播等。理解这些算法是怎么在SSA和IR上实现的,是彻底掌握编译优化的分水岭。可以找几个你日常开发中最关心的优化pass,单步调试看它的数据流和分析过程。
第四阶段是“工具链设计者”。到这一步,前面几个阶段的知识已经能够融会贯通,你可以开始设计自己的完整编译工具链或者语言前端。比如实现一门简单语言,用Lex和Yacc做词法和语法分析,直接输出LLVM IR,然后交给后端生成可执行文件。这个项目做完,你对“编译器”这个概念的理解会产生质变。
7.2 官方文档的正确打开方式,别在入门阶段就背源码
LLVM官网上的文档非常全,但也不是所有文档都适合新手直接啃。如果让我按优先级推荐,我会推荐这几个:
llvm/docs/LangRef.rst:LLVM IR语言参考。这是最重要的一份文档,没有之一。它详细列出了IR的语法、类型、指令语义。学IR碰到疑问,直接查这份文档比搜网页高效得多。llvm/docs/Passes.rst:所有内置优化pass的简介。好多优化的名称和用途,查一下这个列表就有了。llvm/docs/CMake.rst:构建LLVM时CMake参数的官方说明。很多自定义选项的含义和组合方式都能在里面找到。clang/docs/ClangCommandLineReference.rst:Clang命令行参数的参考手册。
另一个容易被忽视但非常重要的资源是源码自带的示例代码。在llvm/examples/目录下有好多可直接编译运行的小demo,包括BrainF(一个BrainFuck语言前端)、Fibonacci、Kaleidoscope(一个完整的教程式语言)。特别是Kaleidoscope教程,它会带着你一步步实现一门真正的语言,从词法分析到代码生成全过程,这门语言虽然小,但该有的编译原理骨架全都有。我第一次完整跑完Kaleidoscope的时候,真的有种“原来如此”的通透感。
7.3 最适合拿来练手的小项目:从具象到抽象
读再多的文档,都不如亲手做几个小项目。推荐三个能帮你把LLVM知识“焊死”在脑子里的练手项目,难度递增。
第一个项目是“打印函数字节码大小”。写一个模块级别的pass,统计模块里每个函数最终生成的机器码大小。这个项目需要你熟悉Module和Function API,同时能读懂生成的汇编。做完后,你会对“一条高级语言语句对应后端多少条机器指令”有直观感受。
第二个项目是“无副作用函数检测”。写一个函数级别的pass,分析一个函数是否修改了全局状态或参数指向的内存,也就是判断它是不是纯函数。如果检测到纯函数,就在函数名后加上一个特殊属性。这个项目需要你掌握指令分类、调用图分析和内存访问判断,有一定的分析逻辑在里面。
第三个项目是“用LLVM实现一个极小语言”。参考Kaleidoscope,但自定义一门简单的脚本语言,只支持整数运算、if/else、for循环和函数定义,然后输出IR并用LLVM JIT执行。这个项目做完,你对LLVM前端、IR生成、JIT执行的理解会非常扎实。我认识的不少编译器工程师,入行契机都是从类似的小项目开始的。
8. 从LLVM到整个编译生态:扩展与思考
8.1 MLIR:多层IR带来的新可能
了解LLVM核心之后,时不时会在社区里看到MLIR这个名词。MLIR是LLVM项目里一个相当重要的子项目,虽然它现在还挂着“experimental”的牌子,但实际已经在机器学习、硬件编译等领域大规模使用了。
MLIR的核心思想是“多级IR”。传统LLVM只有一层IR,所有前端都要经过抽象语法树直接生成LLVM IR。而MLIR允许你在IR里定义不同抽象级别的dialect,比如有表示张量计算的Tensor dialect、表示循环结构的SCF dialect、表示底层内存操作的MemRef dialect。一个编译器可以先用高层dialect描述问题,然后逐级lowering到低层dialect,最后降到LLVM IR。这种设计非常适合处理异构硬件和机器学习模型编译,也让我看到IR这个概念的延展性远比我之前想象的要大。
如果你已经对LLVM IR很熟悉,可以花点时间研究MLIR,它会进一步拓宽你对编译器架构的理解。传统编译器是“两段式”(前端+后端),MLIR把中间变成了一个可以自定义层数的“千层饼”,这种灵活性正在吸引越来越多的硬件厂商和AI框架团队。
8.2 静态分析工具的基石:从clang-tidy到自定义检查
还有一个非常实用的方向,是基于Clang的AST写自定义静态分析工具。这一点在日常开发中尤其有用。
clang-tidy是官方提供的静态分析框架,它的每个检查项都以AST visitor的形式组织。你写一个新的检查,本质上就是遍历Clang AST的某些节点,判定是否符合你设定的规则,然后输出diagnostic信息。整个过程不需要关心编译优化的细节,只需要理解代码“长什么样”。
举个例子,假设你想检测项目里是否有人把auto用得过多(有些团队风格确实不喜欢过度auto),你可以写一个匹配AutoType节点的检查,统计每个函数里的auto出现次数,超过阈值就输出warning。这种能力用于团队代码规范管理,比纯粹的Review要高效得多。
clang-tidy自定义检查的工程结构和写LLVM pass类似,但不需要处理IR,而是在AST层面工作。它对于不懂代码生成的纯应用开发工程师来说,也完全有机会做出有用的工具。
8.3 llvm-project对当代编程语言和工具链的深远影响
站在一个长期从业者的角度看,llvm-project已经从一个大学实验室项目,演变成了整个编译基础设施领域的“水电煤”。它不只是编译器爱好者的玩具,而是实实在在影响整个软件行业的基础工程。
在语言生态层面,Rust编译器选择了LLVM作为默认后端,Swift更是深度绑定LLVM。这就意味着,只要LLVM支持的架构,这些语言天然就能支持;LLVM每优化一轮,这些语言自动获得性能提升。这种“一次基础设施、多方共享收益”的模式,正是LLVM架构前瞻性的体现。
在硬件层面,几乎每一家芯片公司都会基于LLVM定制自己的编译器。很多新CPU一发布,配套工具链就是LLVM的一个自定义后端。你只要去看各大芯片厂商的官方文档里那个“LLVM”文件夹,就知道它在产业界的分量有多重。
你可以不从事编译器开发,但了解LLVM生态的运作方式,会极大提升你阅读编译错误信息、分析性能瓶颈、设计工具链方案的能力。它是那种“一次学会,长期受益”的知识投资。
9. 最后说一点我的实战心得
写到这里,llvm-project的核心内容基本都过了一遍。从项目架构、核心组件、源码编译,到自定义pass、Clang/LLD技巧、问题排查,再到学习路线和生态展望,这条线是完整的,也是我过去这些年一步步踩出来的。
如果让我给后来者一句最实在的建议,我想说:千万不要被LLVM庞大的代码量和“编译器高不可攀”的刻板印象吓住。你不需要先读完所有文档、理解所有算法,再开始动手。最快的学习路径就是先把它编译出来,然后照着Kaleidoscope教程写一个能跑的IR生成器,最后动笔改一个pass。每一步的产出都是可见的,成就感会推着你继续往前走。
我至今还记得自己第一次成功加载自写pass时,看到终端里打印出函数名的那一刻,那种“原来编译器真的是可以被普通人改造的”震撼感。这种体验,可能也正是LLVM这个项目能持续吸引那么多开发者投入其中的根本原因。希望这篇文章能帮你少踩一些坑,更快走到那个让你兴奋的时刻。