咱们直接从LLVM这个让人又爱又恨的编译器基础设施聊起。说到llvm-project,很多刚接触的人第一反应是"哦,就是那个Clang编译器吧",其实这个理解只对了一小半。LLVM项目远不止是一个C/C++编译器这么简单,它更像是一整套用来构建编译器、代码分析工具、图形渲染加速器甚至编程语言运行时的模块化工具箱。如果你写过Rust、Swift、Julia,或者用Android Studio开发过应用,你其实已经在和LLVM打交道了,只是没人告诉你而已。
这篇文章写给三类人:一是刚入门编译原理、想找个开源项目练手的学生,二是工作中被C++工程构建折磨、想搞清楚clang到底在干什么的后端开发,三是听说过llvmpipe这个名字、好奇软件渲染是怎么用CPU硬画出画面的技术爱好者。我会从项目架构讲到实际编译操作,再深入到如何写一个自定义优化Pass,最后聊聊llvmpipe这个"用CPU跑图形"的奇葩,全程用我实际踩过的坑说话。
1. 项目全景:LLVM到底解决了什么问题
1.1 从"编译器"到"编译器基础设施"的进化
传统编译器的经典模型是:前端把源代码变成中间表示,优化器对中间表示做各种变换,后端把优化后的中间表示变成目标机器码。这个模型在GCC里发展到了一个高度,但它有个致命问题——前端、优化器、后端三个部分强耦合。你想支持一种新语言,得从头写一遍优化器和多个目标平台的后端;你想支持一个新CPU架构,所有语言的前端都得跟着重新生成一遍后端代码。
LLVM的设计从一开始就打算打破这个死局。它的核心思路是把优化器做成一个以LLVM IR(Intermediate Representation,中间表示)为中心的、松耦合的平台。前端只需要负责把源语言翻译成IR,后端只需要负责把IR翻译成目标机器码,优化器作为一个独立环节,输入的是一份IR,输出的还是一份IR,只不过更优化。这样C/C++/Rust/Objective-C共用同一套优化器,X86/ARM/RISC-V/GPU共用同一套前端和优化器,谁都不需要为别人买单。
这就是"编译器基础设施"这个定位的含义:LLVM不只是一个能产出可执行文件的工具,而是提供了分层的能力封装,你可以在任意一层插入自己的代码,利用它暴露的接口和中间结构完成自己的目标。业界比较典型的案例就是Apple在自研GPU上做图形编译器、各个芯片厂商做AI编译栈,都是这层能力的直接受益者。
1.2 llvm-project仓库到底包含哪些东西
很多人在GitHub上看到llvm-project这个仓库,第一反应是"这代码怎么这么多",克隆下来占用好几个GB,还经常断。确实,这个仓库是一个monorepo(单仓库多项目),把LLVM的基础库、Clang编译器、调试器、标准库、并行库、libc++等等全部塞在了一起。做这种布局的原因很简单:LLVM各子项目之间的依赖关系非常紧密,频繁跨项目修改API,拆成多个仓库会出现版本同步灾难,monorepo能在同一套提交历史里保证构建一致性。
仓库内的核心子项目包括:
- LLVM核心库:提供IR、优化Pass、目标描述、代码生成、汇编器、链接器等一系列基础能力
- Clang:C/C++/Objective-C的前端,目前GCC在实测编译速度和内存占用上已经没有优势
- lld:一个用LLVM库实现的高性能链接器,链接速度比系统默认的GNU ld快好几倍
- LLDB:基于LLVM和Clang表达式解析器的调试器
- libc++和libc++abi:C++标准库的实现
- compiler-rt:提供编译器运行时支持库,比如Sanitizer(内存/地址/未定义行为检测工具)
- MLIR:一套多级IR框架,近年AI编译器领域的主角
- Flang:Fortran前端
- llvmpipe:软件光栅化渲染器,后面我会专门聊
理解这个仓库的划分方式对于构建和阅读代码非常关键。比如你要调试一个C++编译优化问题,你需要操作的是Clang加LLVM核心;你要做的是图形渲染的软实现,那目光就要放在llvmpipe目录;你想研究AI编译和算子优化,重点就是MLIR。不同的目标决定了你应该怎么配置、构建和阅读这个庞大的代码库。
2. 核心技术拆解:让LLVM转起来的三个关键环节
2.1 LLVM IR:整个体系的通用语言
如果说LLVM是编译器界的"全球化贸易体系",那LLVM IR就是这个世界里的通用货币。它被设计成一种介于高级语言和汇编之间的中间表示,保留了类型信息、变量名、控制流结构,但已经抹去了具体高级语言的大部分语法特征。它的形态有三种:可读的文本格式(.ll文件)、不可读的二进制位码格式(.bc文件)、以及内存中的C++数据结构。
我举个例子,下面这段简单的C代码:
int add(int a, int b) { return a + b; }用clang的-S -emit-llvm参数生成IR,会得到类似这样的东西:
define i32 @add(i32 noundef %a, i32 noundef %b) { entry: %add = add nsw i32 %a, %b ret i32 %add }看到没有,i32是32位整数类型,@add是一个全局函数符号,nsw表示"no signed wrap",即这里做加法时认为不会发生有符号溢出,所以优化器可以更大胆地做变换(比如假设a+b > a不成立时可以进行一些代数化简)。LLVM IR里的每条指令都对应一个SSA形式的值,SSA(静态单赋值)意味着每个变量只被赋值一次,这让数据流分析变得极其简单。
IR的设计原则里有几个点值得深入理解。第一是类型系统显式化,所有运算都带类型,不像汇编语言那样只是字节操作。第二是显式的控制流图,每个函数体都由基本块组成,基本块之间用跳转指令和条件分支连接,这为分析优化提供了清晰的结构。第三是平台无关性,IR层面没有寄存器分配、没有具体的指令选择,这些是后续后端的工作。
2.2 Pass框架与优化管道
LLVM强大能力的另一个载体是它的Pass框架。Pass就是"一遍遍历",输入IR,输出IR(或者分析信息),每次干一件具体的事。LLVM把几十上百个Pass组织成管道(Pipeline),按顺序对IR施加变换。从源码到目标代码的大致流程是:前端生成未优化的IR → 经过O0/O1/O2/O3等不同等级的优化管道 → 得到优化后的IR → 指令选择 → 寄存器分配 → 指令调度 → 生成汇编/机器码。
有人问O2和O3到底差在哪,本质上就是管道里包含的Pass组合不同。O1做基础局部优化,O2加入内联、循环展开、全局优化,O3再进一步激进(有时会导致代码体积膨胀和编译时间上升)。你可以用-mllvm -print-after-all让编译器在每个Pass跑完后打印一份IR,完整观察管道的各个阶段。Debug模式下我经常用这招排查哪个Pass把我的代码给改坏了。
Pass按作用范围又分为FunctionPass(每个函数跑一遍)、ModulePass(整个编译单元跑一遍)、CallGraphSCCPass(按调用图分析)等。新版Pass管理器还引入了AnalysisManager机制,分析结果可以跨Pass缓存复用,避免了重复计算。这套设计让LLVM优化器的性能在大型工程上依然可控。
2.3 目标后端与代码生成
IR经过优化后交给Target后端处理,这部分的架构也很讲究。LLVM用TableGen来维护目标平台描述,比如寄存器集合、指令格式、指令选择模式等等,都写在.td文件里。TableGen会把这些描述展开成C++代码,参与编译。所以看后端代码时,你会看到大量由.td生成的自动化代码,真正的手写逻辑主要聚焦在ISel(指令选择)、RegAlloc(寄存器分配)、调度这几块。
指令选择是后端最核心也最难的部分。LLVM采用SelectionDAG的方式,先把IR转成一种有向无环图(DAG),每个节点是一个虚拟指令,然后通过模式匹配把虚拟指令逐渐下降成目标平台的真实指令。这一步的复杂程度在于,同一个IR操作在不同架构上的实现方式完全不同,比如x86的add指令可以直接操作内存操作数,而ARM需要先load到寄存器再加。这些都在SelectionDAG的匹配表里体现。
后端设计对新增架构非常友好,社区里甚至有"新增一个后端需要三个月到一年"的说法,主要指的就是要写核心的指令选择和寄存器分配部分,但相比GCC,LLVM的后端编写门槛和调试体验已经好了一个量级。
3. 实操:从源码构建一个可用的LLVM环境
3.1 系统依赖与磁盘规划
我用了很多年的Linux服务器和macOS做开发,构建LLVM是件吃配置的事。先说硬性要求:磁盘预留至少50GB,内存至少8GB(16GB更舒服),CPU核心越多越好。因为LLVM的构建极度并行化,你用ninja -j$(nproc)跑全量构建,半小时到几个小时都是正常的,取决于机器档次。
在Ubuntu/Debian系统上,先装基础工具:
sudo apt update sudo apt install build-essential cmake ninja-build python3 gitmacOS上我用Homebrew装依赖:
brew install cmake ninja python3 git构建前要决定用哪个编译器和哪个构建类型。官方目前推荐用Clang构建Clang,但你第一次接触时系统里很可能只有GCC,那就先GCC构建一份基础版本的clang,之后再用clang去构建clang(俗称bootstrap)。这种自举构建更干净,因为Clang对C++标准的支持、优化性能都有优势。
3.2 Release和Debug构建的选择
LLVM的标准构建流程是:
git clone --depth 1 https://github.com/llvm/llvm-project.git cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld;libcxx" \ -DLLVM_TARGETS_TO_BUILD="X86;ARM;AArch64;RISCV" \ -DCMAKE_INSTALL_PREFIX=/opt/llvm cmake --build build几个配置选项我说下我的理解。LLVM_TARGETS_TO_BUILD决定了后端支持哪些CPU架构,如果你只做x86开发,可以把范围缩小,构建时间和内存消耗都会明显下降。LLVM_ENABLE_PROJECTS决定启用哪些子项目,必须用分号分隔,并且注意有些项目之间有依赖,比如你想用lld就必须先构建LLVM核心。CMAKE_INSTALL_PREFIX是安装路径,建议单独放一个目录,因为LLVM版本之间二进制不一定兼容,避免污染系统路径。
Debug构建适合做开发调试,能拿到完整的符号信息,但构建出来的二进制巨大且运行极慢。我的做法是开一个专门的Debug Build目录用于调试Pass,另开一个Release Build目录用于日常编译和跑测试,两个目录互不干扰。
3.3 几个build目录的管理技巧
同时维护多个构建目录是很常见的操作。我本地的习惯是建build-release和build-debug两个目录,每个目录下再建install子目录作为安装前缀。这样切换版本、切换配置都不需要重新下载代码。
还有个坑是CMake缓存。当你改了选项想重新配置,CMake会复用之前的缓存,有时某些旧选项僵在那里导致新配置不生效。遇到这种情况,不要急着删整个目录,可以先删掉CMakeCache.txt,或者直接在全新的目录里重新配置,因为全量构建的成本要高得多,但配置本身很快。另外,Ninja对多构建目录并行编译的内存压力比较大,我试过同时ninja两个大型目录,结果直接OOM把系统干崩了,后来就老老实实串行跑,或者一个Release一个Debug分开错峰构建。
4. 深入实战:写一个自己的LLVM Pass
4.1 新建一个Pass项目
写Pass是理解LLVM内部机制的最佳方式,也能直接体会这套框架的扩展能力。下面我用最经典的"New Pass Manager"方式演示一个简单的FunctionPass,它扫描每个函数,统计里面的基本块数量并打印出来。
首先写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 LLVM_CONFIG_PATH=${LLVM_CONFIG_PATH}") include_directories(${LLVM_INCLUDE_DIRS}) add_definitions(${LLVM_DEFINITIONS}) add_library(MyPass MODULE MyPass.cpp ) set_target_properties(MyPass PROPERTIES PREFIX "" OUTPUT_NAME "MyPass" ) target_link_libraries(MyPass PRIVATE LLVMCore LLVMSupport LLVMPasses)注意关键点是PREFIX "",这会让生成的插件文件不带lib前缀,这样LLVM的工具才能按插件名直接加载。模块类型的动态库也要求采用这种命名约定。
然后是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 { class CountBlocksPass : public PassInfoMixin<CountBlocksPass> { public: PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { int count = 0; for (auto &BB : F) { ++count; } errs() << "Function " << F.getName() << " has " << count << " basic blocks\n"; return PreservedAnalyses::all(); } }; } // namespace // 注册Pass插件 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 == "count-blocks") { FPM.addPass(CountBlocksPass()); return true; } return false; }); }}; } extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getMyPassPluginInfo(); }4.2 编译并加载Pass插件
我把这个项目放在llvm-project外面的一个独立目录里,然后编译:
mkdir build && cd build cmake .. -DCMAKE_PREFIX_PATH=/opt/llvm make如果顺利,会生成MyPass.so文件。然后在待测试的代码上跑:
opt -load-pass-plugin=./MyPass.so -passes="count-blocks" -disable-output /tmp/test.lltest.ll可以用clang先生成:
clang -S -emit-llvm /tmp/test.c -o /tmp/test.ll如果你在终端看到类似
Function main has 2 basic blocks的信息,说明Pass已经正常工作了。这套写法与老式registerPass方式最大的区别在于:新Pass Manager的Pass通过PassBuilder管道的管线解析回调注册,而不是用静态注册器,这让Pass可以在运行时动态组装、灵活排序。
4.3 踩坑记录:动态库加载失败的常见原因
我初学写Pass的时候遇到最频繁的问题是opt加载插件时报错Library not found或者undefined symbol。排查优先级应该是这样的:
先确认编译时的LLVM版本和运行opt的LLVM版本一致。LLVM每年大版本会调整API,插件的C++符号含有版本信息,版本不匹配基本必然加载失败。我在本机上就有过LLVM 16的opt去加载LLVM 15编译的Pass,结果直接崩溃,排查了特别久才发现是环境变量里PATH指向了另一个旧版本。
其次是确认LLVM_PLUGIN_EXT后缀。Linux上插件就是.so,macOS上是.dylib,Windows上比较麻烦需要导出函数符号表。llvmGetPassPluginInfo这个导出符号必须用extern "C"包裹,否则C++ name mangling机制会改变符号名,插件运行时找不到入口。
另外别忘了-disable-output参数。如果待分析IR文件较大,opt默认会把变换后的IR打印到标准输出,你会看到一大堆文本刷屏,不一定是Pass出错了。真要保留输出到文件,用-o output.bc。
5. llvmpipe专题:用CPU硬画高清画面的软件渲染器
5.1 llvmpipe的原理与定位
搜索热词里出现的llvmpipe,值得单独拿出来讲。一句话概括,llvmpipe是LLVM生态里用CPU实现OpenGL/Vulkan光栅化渲染的软件渲染器。它的定位是"没有GPU的时候,也要让图形程序跑起来",比如在虚拟机、无头服务器、嵌入式环境或者是做图形调试兜底的时候。
llvmpipe的核心玩法是把图形管线的Shader(顶点着色器、片元着色器等)编译成LLVM IR,然后通过LLVM的JIT(即时编译)在运行时生成针对当前CPU指令集优化的机器码。简而言之,每个Shader函数都会被编译成真正的CPU指令,渲染循环再用这些指令处理像素或顶点数据。这种方式比传统的解释执行Shader快几个数量级,虽然不是硬件GPU的对手,但在软件渲染领域已经是天花板级别的存在了。
5.2 为什么llvmpipe要借助LLVM的JIT能力
软件渲染最大的瓶颈是Shader执行的效率。传统的CPU软渲染器每条指令都要解释执行,在分支多、循环多的现代图形Shader中极度低效。LLVM JIT的出现让"针对特定输入现场动态生成代码"成为可能,比如它在生成片元着色器机器码时可能做常量折叠、循环展开、SIMD向量化,这些优化在解释器里是不可能实现的。
我见过一个有趣的实测:老牌的Mesa软件渲染器在未启用llvmpipe时跑开源游戏平均只有几帧,切到llvmpipe后帧率能提升一个数量级。虽然帧数依然不能跟独立显卡比,但已经能让开发者正常调试渲染流程和验证逻辑正确性了。另外llvmpipe对Shader所用指令集的适配也很有意思,它会检测-march=native级别的CPU特性,尽可能把标量运算向量化成SSE/AVX指令,LLVM在向量化这块的能力正好派上用场。
5.3 软件渲染的适用场景与调试价值
在实际开发中,llvmpipe有两个很有价值的场景。第一个是CI(持续集成)环境里没有GPU,但你需要保证渲染测试能够在无头机器上跑通,设置环境变量让Mesa加载llvmpipe就能得到确定性的软件渲染结果。第二个是图形调试,当你怀疑驱动有Bug、不知道问题出在GPU还是应用侧时,切换到llvmpipe重跑一遍同一个渲染流程,如果llvmpipe下一切正常,基本能锁定问题在驱动或者GPU硬件本身;如果llvmpipe也重现问题,那就要往应用代码里查。
使用llvmpipe很简单,在Linux上装好Mesa后,运行程序前加上:
export LIBGL_ALWAYS_SOFTWARE=true或者在Vulkan场景下用:
export VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json就能强制应用使用软件渲染路径。我在调试一个跨平台图形框架的兼容性问题时就靠这招快速复现了硬件渲染下的诡异颜色输出,定位到是纹理坐标计算的精度问题,而不是驱动问题。软件渲染虽然慢,但它的一致性、可预测性就是调试时最稀缺的东西。
6. LLVM的调试与排查技巧实录
6.1 用opt和bugpoint缩小编译器Bug
编译器本身就是极度复杂的系统,llvm-project的开发者们早就为"编译器出了Bug"准备好了工具链。当你发现某个源码经过Clang编译后的运行结果不对,怀疑是优化器的问题时,第一步就是用opt复现问题。
流程是:先用clang -S -emit-llvm拿到未优化的IR,然后用opt单独跑某一个Pass或某几个Pass,看是否出错。如果复现了,就轮到bugpoint登场。bugpoint是一个自动二分工具,它可以不断删除IR中的无关函数、指令、基本块,直到找到最小化的问题样本。我印象特别深的一次:它把一段有几千行代码的项目文件精简成一个只有几十行的IR片段,问题定位在LTO的全局优化Pass上,最终修复只改了几行代码。
6.2 常见LLVM编译问题排查速查表
我整理了平时最常遇到的几类问题,供快速参考:
| 现象 | 可能原因 | 排查/解决方向 |
|---|---|---|
| clang编译时提示无法找到头文件 | 未指定系统rootfs或目标环境 | 检查--sysroot和-I参数,交叉编译场景尤其容易踩 |
链接时undefined reference to clang_rt.* | 缺少compiler-rt运行时库 | 安装或构建compiler-rt,检查-print-resource-dir |
| 运行编译出的程序闪退且无报错 | 可能是UB触发优化路径的未定义行为 | 用-fsanitize=address,undefined重新编译 |
| opt加载插件崩溃 | 插件与opt版本不一致 | 检查LLVM_版本一致性,用llvm-config --version对比 |
| lld链接时内存暴涨 | 调试信息太多或开启过高的链接优化 | 使用--threads限制线程,或拆分调试信息 |
| llvmpipe渲染画面出现花屏 | 材质格式不兼容或是软渲染精度问题 | 确认纹理格式是否支持,切换LIBGL_ALWAYS_SOFTWARE对比 |
排查编译器问题时要有一个基本心态:编译器也是程序,也会犯错,但在99%的情况下,问题出在你的代码触发未定义行为、ABI不匹配、或者构建选项冲突上。先用Sanitizer和-Wall跑一轮,再怀疑编译器本身,这个顺序能省很多时间。
6.3 手工构造IR做单元调试的素材
工作中我还经常直接手工写IR来做一个最小功能测试。比如想验证某个优化Pass对循环旋转的处理是否正确,不需要写一个完整的C文件然后看生成IR,直接写一个循环IR片段,用opt -passes=loop-rotate跑一遍观察变换效果。这种方式的调试迭代速度极快。
手工写IR要把握几个语法细节:注释用;开头;每个基本块需要有label;函数的定义要有define关键字和返回类型;分支指令br、条件分支br i1 %cond, label %then, label %else。下面是一个最简单的循环IR:
define i32 @sum(i32 %n) { entry: br label %loop loop: %i = phi i32 [0, %entry], [%next, %loop] %acc = phi i32 [0, %entry], [%newacc, %loop] %cmp = icmp slt i32 %i, %n br i1 %cmp, label %body, label %exit body: %newacc = add i32 %acc, %i %next = add i32 %i, 1 br label %loop exit: ret i32 %acc }循环里的phi节点对刚接触IR的人是最难的,它的含义是"根据我从哪个基本块而来,选择不同的值"。在这个例子中,%i从entry块进入循环时是0,从loop内部迭代时是%next。理解phi节点之后就基本能读懂80%的优化Pass在做什么了,因为大部分优化都是在操作SSA数据流关系。
7. LLVM生态的行业影响与未来方向
7.1 从Rust到AI编译器,LLVM无处不在
LLVM的行业影响力已经远超编译器圈子。Rust的官方编译器rustc选择LLVM作为默认后端,看重的是多平台支持和先进的优化能力;Swift语言从诞生起就深度集成LLVM;苹果的Xcode工具链、Android的NDK编译工具链、还有PlayStation/Xbox游戏开发SDK底层都有LLVM的身影。在C/C++生态里,Clang的-fanalyzer静态分析器、-fsanitize运行时检查工具、clangd语言服务器,都是现代开发流程中不可替代的基础设施。
AI编译器领域更是最近几年LLVM系技术的爆发点。MLIR在LLVM之上构建了一套多级IR体系,允许AI框架(TensorFlow、PyTorch、IREE等)在不同抽象层级上做算子融合和优化,最终仍然通过LLVM后端下降到底层指令集。可以说,当下端侧AI推理和高性能计算的基础设施,很多都长在LLVM这棵树上。
7.2 新生代方向:MLIR、ORC JIT与更多语言的接入
MLIR代表的是编译框架向"多语言、多级抽象、可组合"方向演进的大趋势。以前编译器只有"源语言IR→目标机器码"一条路,MLIR则允许你在不同抽象层级之间灵活升降,比如先在张量层面做算子替换,再下降到循环层面做循环变换,最后进入LLVM IR合成机器码。这种灵活性让它成为AI加速器、可重构硬件、异构计算等领域的热门底座。
ORC JIT也是我一直关注的方向。传统上JIT主要用在动态语言和即时编译里,LLVM自带的ORC可以把IR/JIT编译能力嵌入到任何C++应用中,比如在运行时根据数据特征动态生成特化函数、实现数据库表达式编译、脚本语言的即时编译器等等。跨语言互操和动态代码生成的需求越来越多,ORC提供了一个稳定高效的底层支撑。
从个人经验看,LLVM的学习曲线确实陡峭,但它的代码质量、文档完备度、社区活跃度在开源项目里都属于顶级。如果你能沉下心来读一段SelectionDAG的指令选择代码,或者自己写一个小Pass跑通一遍优化流程,你对"编译器是怎么工作"的认知会上一个大台阶,这对从事编程语言设计、高性能计算、底层系统开发的人来说都是一笔无可替代的积累。