不知道你有没有过这种经历:明明只是想搞清楚一个编译报错,结果翻着翻着就撞进了“llvm-project”这个仓库里;再一看,光顶层目录就有几十个,整个人瞬间有点懵。
我刚入行的时候也犯过同样的晕,一直以为 LLVM 就是那个把 C/C++ 变成汇编的编译器,后来才搞清楚:严格说,LLVM 只是这个大仓库里的一个核心子项目,而 llvm-project 是一个把编译器前端、优化器、后端、链接器、调试器、运行时库全部打包在一起的 monorepo。
这篇文章我打算从实际工程视角出发,把 llvm-project 的组成、构建方式、LLVM IR 基础、以及它和 llvmpipe 软件渲染之间的关系讲清楚,再分享一个从零写 LLVM pass 的完整过程。内容偏实操,适合刚接触编译原理、准备用 LLVM 做课程设计或工具链二次开发的读者。
1. 别再只把 LLVM 当作“编译器”,它是一个工具链全家桶
1.1 项目仓库的真实构成:不止 llvm 目录
llvm-project是官方 monorepo 的仓库名,你 clone 下来之后,顶层并不是只有一个llvm目录,而是密密麻麻一堆并列的子项目:
llvm:核心基础设施,包括 LLVM IR、优化器、指令选择、代码生成以及各种后端。clang:C/C++/Objective-C 编译器前端。clang-tools-extra:clang-tidy、clangd 等基于 Clang 的辅助工具。lld:链接器。lldb:调试器。compiler-rt:运行时库,包括 sanitizer、builtins 等。libcxx、libcxxabi、libunwind:C++ 标准库、ABI 兼容层、栈展开库。mlir:MLIR 子项目,用于构建可复用、可扩展的编译器基础设施。polly:多面体优化器。flang、openmp、bolt、libclc等等。
这个结构对新手最直接的误导在于:很多人想“下载 LLVM 编译器”,结果直接 clone 了llvm-project,然后发现不知道编哪个。其实真正编译 C/C++ 时,你用的是clang前端,而clang是作为llvm-project的一员参与整体构建的。如果你从源码构建 LLVM 时只启用clang,其他子项目不编译,也完全没问题。
1.2 为什么 monorepo 的构建方式决定了你的使用体验
LLVM 各子项目之间的耦合度非常高。clang用 LLVM 的库,lldb也用,mlir同样用。如果你把它们拆成多个仓库,每次 API 变更都要同步改版本号,这对一个每年两次大版本发布的编译器项目来说成本太高。所以 LLVM 从 8.0 开始采用 monorepo,所有子项目共用同一个llvm子目录下的 CMake 配置。
这也意味着,你在构建 clang 工具链时,实际上是在把整个 LLVM 核心库一起编译出来。很多人第一次跑cmake时,会觉得“我只是想编译个 clang,为什么配置了这么多选项?”——这是正常的,因为 clang 本身就是挂在 LLVM 库上的一棵大树。
1.3 版本差异:LLVM 15 在你上手时需要注意的变化
本文假定你使用的是 LLVM 15.0.7,这也是 Mesa 的 llvmpipe 软件渲染器里很常见的一个 LLVM 版本号。为什么单提 15?因为这个版本对普通使用者有几个影响:
- LLVM 15 中,不透明指针(opaque pointer)默认开启,IR 里不再区分
i32*、i8*,统一写成ptr。 - 优化管线已经全面转向新的 Pass Manager,老式
opt命令行参数很多地方不再兼容。 - 后端对 RISC-V、AArch64 的成熟度已经很高,做交叉编译体验比以前好很多。
如果你从最新源码开始学,版本差异会更多;但既然你大概率会碰到带llvm 15.0.7字样的环境,从这里入手最稳妥。
2. llvm-project 首选构建方式:从 CMake 配置到三件套产物
2.1 Ninja + clang 的黄金组合与构建参数设定
我个人的建议是:不要用默认的 Unix Makefiles,直接用 Ninja。Ninja 并行度更好,增量构建也快很多。如果你机器上已经有 clang/gcc,先就别折腾用系统编译器去 bootstrap,直接用系统 clang 或 GCC 来构建即可。
先说一个最常用的配置模板:
git clone --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project mkdir build && cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=$HOME/llvm-15 \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64;RISCV" \ -DLLVM_ENABLE_ASSERTIONS=OFF注意这里的../llvm不是笔误。llvm-project仓库里的llvm目录才是 CMake 的源根目录。LLVM_ENABLE_PROJECTS指定要一起编译的子项目,我用的是clang和lld,这样后续做实验够用了。
如果想把 libc++ 也编进去,更推荐用LLVM_ENABLE_RUNTIMES:
cmake -G Ninja ../llvm \ -DLLVM_ENABLE_RUNTIMES="libcxx;libcxxabi;libunwind;compiler-rt" \ ...这里面的差异解释起来有点长:projects 和 runtimes 的构建阶段不同,runtimes 是在工具链本身可用之后再用刚编出来的 clang 去构建的。LLVM 15 仍兼容两种写法,但如果你照抄网上老教程把 libcxx 放进LLVM_ENABLE_PROJECTS,新版 CMake 会提示你应该移到 runtimes。
2.2 按需裁剪:LLVM_TARGETS_TO_BUILD 等关键变量
新手最容易忽略LLVM_TARGETS_TO_BUILD。它控制要生成的各个 CPU 后端。LLVM 默认会生成所有后端,包括像 BPF、Hexagon 这类你可能一辈子都用不到的 target,构建时间会明显变长。只保留你需要的那几个后端,构建速度会有可感知的提升:
| 变量 | 作用 | 建议值 |
|---|---|---|
LLVM_TARGETS_TO_BUILD | 选择生成哪些后端 | 本机架构;做实验时再加 AArch64/RISCV |
LLVM_ENABLE_PROJECTS | 启用同仓库编译的前端/工具 | clang;lld比较常用 |
CMAKE_BUILD_TYPE | 优化/调试配置 | Release或RelWithDebInfo |
LLVM_PARALLEL_LINK_JOBS | 限制并行链接任务数 | 2,避免链接 OOM |
还有一个容易被忽略的选项:LLVM_ENABLE_ASSERTIONS=ON。建议如果你要写 pass、调试优化器本身,就打开;如果只是想拿 clang 当普通编译器用,建议关闭,性能更好。一个折中是CMAKE_BUILD_TYPE=RelWithDebInfo加上LLVM_ENABLE_ASSERTIONS=ON,可调试性和性能都还能接受。
2.3 构建时长和资源占用:我的实测数据与建议
在很多教程里,构建 LLVM 被描述成一件极其耗时的事,有人会说“一编就是一下午”。以我个人经验,只要配置合理,它其实没那么可怕。比如我用一台 8 核 16 线程、32GB 内存的机器,构建:
- 目标后端:X86、AArch64、RISCV
- 子项目:clang、lld
- 构建类型:Release
首次全量构建大约需要 40 到 60 分钟,主要卡在最终链接阶段。如果你不限制并行链接任务数,lld或clang这类大二进制在链接时可能同时拉起四五个进程,每个吃好几 GB 内存,32GB 机器也会卡死。
所以我在构建命令里加了:
-DLLVM_PARALLEL_LINK_JOBS=2编译阶段你可以让 Ninja 尽量并行,链接阶段保守一点,这样整体反而更稳。
构建完成后,真正的产物在build/bin下。你可能会关心“三件套”,我一般会重点确认这几个文件:
./bin/clang --version ./bin/llc --version ./bin/opt --versionclang负责前端和总驱动,llc把 IR 变成汇编,opt负责跑优化 pass。做编译原理相关开发时,opt和llc几乎每天都要用。
提示:如果你只是想临时用一下 LLVM,不想花时间全量编译,可以直接安装发行版提供的
llvm-15-dev、clang-15、lld-15这类二进制包。但如果你准备自己改 IR、写 pass,还是建议从源码构建,否则头文件和库版本对不上,后面有你哭的。
3. LLVM IR 快速上手:理解 llvm 15 的中间表示设计
3.1 IR 的基本模块:Module、Function、BasicBlock 与 Instruction
LLVM 的核心资产是它的中间表示,也就是 IR。它既不是源码,也不是机器码,而是一种带类型、基于静态单赋值(SSA)形式的指令集。好多人第一次看.ll文件会发怵,其实拆开来看非常规律。
先看一个最简单的 C 函数:
int add(int a, int b) { return a + b; }把它变成 LLVM IR:
clang -S -emit-llvm add.c -o add.ll生成的 IR 核心部分长这样:
define i32 @add(i32 %a, i32 %b) { entry: %add = add nsw i32 %a, %b ret i32 %add }你可以把这里面对应到 LLVM 的几个基本概念:
Module:一个 IR 文件就是一个 Module,里面可以放若干个函数、全局变量、元数据。Function:也就是@add这个函数。函数名前面的i32是返回类型。BasicBlock:entry:这样的标签,一个函数被划分为一个或多个基本块。基本块内没有跳转,只有一个入口一个出口。Instruction:add、ret等指令,是构成 IR 的最小操作单位。
SSA 形式意味着每个变量只赋值一次。上面%add是加法指令的结果,后面直接用就行,不能重复给它赋值。这种形式让很多优化变得很简单,因为数据流分析本质上就是沿着变量名追踪即可。
3.2 内存中的 IR 与文本 IR 的关系
.ll文件是给人看的文本格式。但在内存里,LLVM 的 IR 是一堆 C++ 对象:Module对象持有Function对象的列表,Function持有BasicBlock,BasicBlock持有Instruction。它们是严谨的 C++ 类层次结构。
除了文本格式和内存格式,还有一种紧凑的二进制格式叫 bitcode(.bc)。三者的关系可以这样理解:
.ll:可读文本,方便调试和 diff。.bc:序列化后的二进制,方便大量存储和快速加载。- 内存对象:优化器和后端真正操作的数据结构。
你用clang -c add.c -emit-llvm -o add.bc拿到的就是 bitcode。用llvm-dis add.bc可以把它还原成.ll。很多 pass 的测试都直接写.ll,因为可以精确控制 IR 的形态,不依赖 C 源码的编译结果。
3.3 新版 LLVM 对 IR 的一些调整
LLVM 15 有个特别显眼的变化:默认开启不透明指针。以前你写 IR 时会看到:
define i32 @add(i32* %p, i32* %q)现在指针类型统一为ptr,不再区分整型指针还是单精度浮点指针。比如:
define void @test(ptr %p) { %v = load i32, ptr %p, align 4 ... }这样做的原因是历史上有太多针对“指针类型是否参与别名分析”的 bug。去掉指针元素类型之后,load指令必须显式写清楚加载的类型,getelementptr也需要带元素类型。举个例子:
%gep = getelementptr i32, ptr %p, i64 4这里i32是元素类型,%p是基址,i64 4表示偏移 4 个元素。它不直接翻译成“在%p上加 16 字节”,而是按i32大小来计算地址,这样能避免在i8*和i32*之间来回转换带来的混乱。
对于写过老 LLVM pass 的人而言,这个改动会让你很多涉及getPointerElementType()的代码编译不过。不过如果你是新手,直接按新风格学,反而没有这些历史包袱。
4. llvmpipe 到底在做什么:软件渲染里的 LLVM 后端
4.1 llvmpipe 如何让 CPU 也能跑起 OpenGL
llvmpipe是 Mesa 里的软件光栅化器。简单说,它是一个完全用 CPU 模拟 GPU 渲染管线的实现。正常情况下图形程序会把 GLSL 着色器编译成 GPU 专用的机器指令,然后把三角形数据丢给 GPU。但如果机器没有独立显卡,或者驱动坏了,Mesa 就能拉起 llvmpipe,让一切在 CPU 完成。
这里的关键点是:llvmpipe 内部用 LLVM 作为 JIT 引擎。它会把自己的中间表示转换成 LLVM IR,再用 LLVM 的即时编译功能生成当前机器能跑的本地代码。这就是为什么 llvmpipe 的版本号里直接带着 LLVM 的版本号。
用类似下面的命令可以查看当前环境的渲染器信息:
glxinfo -B LIBGL_ALWAYS_SOFTWARE=1 glxinfo -B如果输出里出现llvmpipe,就说明你其实是在用 CPU 跑 OpenGL 渲染。
4.2 “llvmpipe (LLVM 15.0.7, 256 bits)” 这行渲染器信息意味着什么
在不少 Linux 环境里,你会看到这样一行:
OpenGL renderer string: llvmpipe (LLVM 15.0.7, 256 bits)这句话可以拆成三部分:
llvmpipe:渲染器是 Mesa 的软件光栅化实现。LLVM 15.0.7:当前使用的 LLVM JIT 版本。256 bits:llvmpipe 在构建或运行时检测到的向量宽度上限是 256 位,也就是能使用 AVX2 的 YMM 寄存器来跑 SIMD 指令。
这个256 bits直接影响渲染性能。llvmpipe 在编译像素着色器时,会把一组像素打包成向量一起运算。如果 CPU 支持 256 位向量,一次能处理的像素分量就更多;如果只支持 128 位 SSE,那就是 “128 bits”。这行信息对图形调试很有用,因为它直接告诉你软件渲染器是否发挥了当前 CPU 的全部 SIMD 能力。
如果你在虚拟机里看到它,基本可以确认显卡没有被直通给虚拟机,图形走的是 CPU 模拟。
4.3 把 llvmpipe 当作调试工具:一次图形问题排查经历
有一回我给客户写一个 OpenGL 渲染工具,客户的机器是 ARM 小主机,外接了简单的 GPU,但驱动一直不稳定。程序一跑起来,画面偶尔花屏。我让客户跑了glxinfo,发现渲染器即便是正常路径也偶尔回退到llvmpipe,而我的 CI 机器没有 GPU,测试时强制走LIBGL_ALWAYS_SOFTWARE=1,反而跑出来的结果像素与客户报错时的像素对不上。
后来定位下来,问题出在客户机器回退 llvmpipe 时,GLSL 着色器用的mediump精度被 llvmpipe 以较高精度计算,而真实 GPU 驱动按低精度处理,导致两者颜色细节出现肉眼可见的差异。
这个案例想说明的是:llvmpipe 不只是“没显卡时的备胎”,它还是排查驱动 bug 的利器。它和 GPU 的真正行为存在一定偏差,但这个偏差恰恰可以用来区分“算法错了”还是“驱动实现错了”。
另外,llvmpipe 也支持 Vulkan,对应的驱动叫lavapipe。通过设置VK_ICD_FILENAMES指向对应的 JSON 文件,就能用 SwiftShader 之外的方案做 Vulkan 软渲染验证。
5. 动手写一个 LLVM pass:从零到接入 pipeline
5.1 pass 的基础骨架与注册方式
讲完项目结构和 IR,就该做点实际的事了。好多人学 LLVM 卡在 pass 这一步,因为不知道从哪里下手。这里我给出一个最精简的 function pass,作用是在每个函数里打印所有被调用的函数名。
#include "llvm/IR/Function.h" #include "llvm/IR/Instructions.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/Support/raw_ostream.h" using namespace llvm; namespace { struct DemoPass : public PassInfoMixin<DemoPass> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { for (BasicBlock &BB : F) { for (Instruction &I : BB) { if (auto *Call = dyn_cast<CallInst>(&I)) { if (Function *Callee = Call->getCalledFunction()) { errs() << "call: " << Callee->getName() << "\n"; } } } } return PreservedAnalyses::all(); } }; } // namespace extern "C" ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, "DemoPass", "0.1", [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "demo-pass") { FPM.addPass(DemoPass()); return true; } return false; }); }}; }这段代码里最核心的是两点。第一,pass 必须继承PassInfoMixin<DemoPass>,并实现run方法,返回PreservedAnalyses。第二,入口函数必须是llvmGetPassPluginInfo,它告诉 LLVM 这个插件的 API 版本号、名称、注册回调。
把它编译成动态库:
clang++ -std=c++17 -fPIC -shared demo.cpp \ $(llvm-config --cxxflags --ldflags --libs) \ -o demo.so然后对任一.ll文件执行:
opt -load-pass-plugin=./demo.so -passes=demo-pass -S input.ll你会发现input.ll里的每个函数内部的调用信息都被打印出来了。
5.2 遇到的实际坑:New Pass Manager 与旧接口的差异
网上搜 LLVM pass 教程,很容易搜到老代码。老代码一般是这样的写法:
struct DemoPass : public FunctionPass { static char ID; DemoPass() : FunctionPass(ID) {} bool runOnFunction(Function &F) override { ... } }; char DemoPass::ID = 0; static RegisterPass<DemoPass> X("demo-pass", "...");这是 legacy pass manager 的写法。LLVM 15 里,新 pass manager 已经是默认,opt命令行的-passes=语法只认新 PM 的插件注册方式。你在网上看到的老代码直接用opt -demo-pass跑,大概率会提示找不到 pass。
新 PM 的关键差异可以总结为三点:
- pass 是函数对象,不需要静态 ID。
run方法返回PreservedAnalyses,告诉后续 pass“哪些分析还可以继续用”,没法明确判断时直接返回PreservedAnalyses::none()。- 通过
PassBuilder的 callback 注册进 pipeline。
这里特别提醒:如果你的 pass 真的修改了 IR,就不要返回PreservedAnalyses::all()。这个返回值的意思是“我什么都没改,所有分析结果都能复用”。如果你改了 IR 却说没改,后面 pass 可能会基于过期数据做错误的优化。做个保守的 pass,直接返回:
return PreservedAnalyses::none();5.3 用 lit 和 FileCheck 做回归测试
LLVM 官方测试框架是 lit,配合 FileCheck 工具来校验 pass 输出。拿上面的 DemoPass 举例,你可以写一个测试文件:
; RUN: opt -load-pass-plugin=%t/demo.so -passes=demo-pass -S %s | FileCheck %s ; CHECK: call: bar define void @foo() { call void @bar() ret void }RUN行就是测试命令。%s是当前文件路径,FileCheck会检查后续输出里是否存在与CHECK匹配的行。如果你把 pass 加入到 LLVM 源码树,还可以放到llvm/test/Transforms/下并用llvm-lit执行。
我自己更常用的方式是单独建一个目录,不编进 LLVM 代码树,直接用opt加-load-pass-plugin跑,配合一小段 shell 脚本做断言。这样迭代快,也不污染主工程。
6. llvm-project 工程实践中的隐性成本与建议
6.1 版本回溯与依赖匹配:不要把 clang 和 LLVM 混着用
最常见的错误是:系统里装了clang-15,但你自己从源码编译 LLVM 时用的却是另一套配置。接着你用源码里的opt去加载某个插件,结果插件依赖的 LLVM 库版本跟opt编译时不一致,一运行就报“unknown symbol”或“version mismatch”。
我踩过最深的坑是把发行版自带的libLLVM-15.so和源码构建的clang混在一起用。那时候我天真地以为“反正都是 LLVM 15,应该兼容”。结果就是无休止的undefined symbol。
如果要在同一台机器上维护多个 LLVM 版本,我的建议是:
- 所有工具链组件都尽量从同一个
llvm-projecttag 编译。 - 使用
CMAKE_INSTALL_PREFIX区分安装目录,比如~/llvm-15、~/llvm-16。 - 使用
llvm-config时,把它所在的bin目录放在 PATH 最前面,避免找到系统内置版本。 opt -load-pass-plugin引用的动态库,编译时用哪个llvm-config,运行时就用哪个opt。
6.2 常见编译错误和排查思路
LLVM 代码库庞大,编译错误有时让人很绝望。根据我这些年积累的经验,大部分错误逃不出下面几类:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
找不到llvm-config.h | 没有先构建llvm-config或没有安装 dev 包 | 先执行ninja llvm-config,或安装对应版本 dev 包 |
undefined reference tollvm::... | 链接库顺序不对或缺少某个组件 | 尽量用llvm-config --libs,动态库放后面 |
uses undefined class 'llvm::Function' | 缺少对应头文件 | 检查 include,一般同名.h文件即可 |
| 编译 pass 时 RTTI 报错 | LLVM 构建时关闭了 RTTI,而你的插件开了 | 编译插件时加上-fno-rtti,并保持异常选项一致 |
expected top-level entity | IR 文件格式不对,或 bitcode 和文本混淆 | 确认文件是.ll文本,或先llvm-dis |
另一个隐蔽问题是 new pass manager 下,pass 插件运行时崩溃但错误信息不明显。这种情况优先用llvm-symbolizer配合地址回溯,或者在编译插件时保留调试符号:
clang++ -g -O0 -fno-rtti -std=c++17 ...6.3 适合个人的工作流参考
如果你只是一个人维护一个小的 LLVM 实验项目,不必追求编全量的clang和lld。有两个更轻量的开发流:
- 只构建
opt和llc:cmake时-DLLVM_ENABLE_PROJECTS="",然后ninja opt llc。这个流程足够跑 pass 和看汇编。 - 需要 clang 前端时再补:先
ninja clang,通常增量编译几十秒就能出来。
我自己的习惯是建两个构建目录:
build-release/ # Release + ASSERTIONS=ON,用于日常跑测试 build-debug/ # Debug,用于调试 pass 崩溃日常修改一个 pass,我会在build-release里跑ninja demo.so,然后快速跑一遍 lit 测试。只有在定位到具体崩溃点时,才去build-debug里用 debugger 跑。
对于调试优化问题,我还会在命令行上加:
opt -passes='print<loops>,demo-pass' ...或者在 clang 编译时加:
clang -mllvm -print-after-all ...这些参数能帮你看到每个 pass 前后 IR 的变化,非常利于理解优化器在干什么。总之,llvm-project 的深度可以让你从编译器前端一路钻研到后端机器码,但它真正难的地方不是某一处的算法,而是整套工程之间的衔接。把这些工程细节先理顺,后续看源码、写 pass、调后端,都会顺畅很多。