尧图精选

LLVM项目深度解析:从源码结构到编译优化实践

🕒 发布时间:2026/9/20 15:56:32 📁 来源:尧图网络
如果你在编译器、编程语言或者底层系统方向工作过一段时间大概率绕不开一个名字llvm-project。它不是一个单一软件也不是只能做C/C编译的玩具仓库而是一整套模块化编译基础设施。无论是Clang、LLD、libc、LLDB还是近些年活跃的MLIR、Flang全都挂在这棵大树下面。对于想搞懂“编程语言到底怎么变成机器码”的人、想做静态分析工具的人或者是只想更高效使用编译器的人llvm-project都值得花时间仔细拆一遍。这篇文章我就从一个实际使用者的角度把它的结构、构建方式、核心机制、实操脚本以及踩坑经验一次性讲透。1. 项目整体认知LLVM到底是什么解决了什么问题1.1 从“编译器工具链”到“编译基础设施”很多初学者会把LLVM理解成一个编译器这没错但不完整。更准确地说它是一个“编译器工具链的组装车间”。传统GCC是单体的前端解析C/C中端优化后端生成汇编所有逻辑揉在一起维护成本高想支持一个新语言或新芯片往往要动整条链路。LLVM把这条链路拆成了三个独立的层前端、优化器、后端中间用一种叫LLVM IR的中间表示来衔接。这种拆分带来的好处非常直接。后端和优化器一旦稳定新的语言只需要实现一个新的前端把源码翻译成LLVM IR就能复用后续所有优化与代码生成能力。这也解释了为什么Rust、Swift、Julia这些语言都会选择LLVM作为底层支撑。对硬件厂商来说也一样支持一套LLVM后端就等于瞬间拥有了几十种语言的编译器生态。当你说“我要研究llvm-project”的时候本质上不是研究某一个程序而是要理解这套从源码到机器码的总线设计逻辑。它的核心价值在于把“编译”从一个黑盒变成了可以按需替换、定制、插件的流水线。1.2 子项目与生态定位llvm-project仓库不是单个repository里面一个项目而是多个项目并列在同一个monorepo中。具体大家最常用到的包括clangC/C/Objective-C前端。clang-tools-extraclang附带的各种工具比如clang-tidy、clangd、clang-format。lld一个用LLVM理念重新实现的高性能链接器。lldb取代GDB的调试器。libcxx / libcxxabi / libunwindC标准库与底层运行支持。compiler-rt编译器运行时库包含sanitizer、profile等。mlir多层级IR基础设施用来构建更灵活的机器学习、异构计算编译器。flangFortran前端。polly多面体优化器。openmpOpenMP运行时实现。这几个子项目之间是既独立又协作的关系。比如你在编译一个普通C程序时Clang负责前端解析与IR生成opt负责中端优化llc负责后端汇编lld负责链接最终的跑起来的程序中还会链接进libc和compiler-rt的一些运行时函数。每个环节都可以被替换、单独测试、单独研究。官方把所有代码放进一个仓库是为了方便同步版本和统一测试但对使用者来说并不需要全部编译构建时按需选择就好。2. 首次接触必读源码结构、构建方式与日常工具链2.1 源码仓库目录到底该怎么看第一次clone llvm-project下来面对几十个顶层目录很多人会有种无从下手的感觉。其实只需要抓住几个关键目录就行。llvm-project/llvm这是核心仓库包含IR定义、优化pass、代码生成后端、目标描述等。真正研究LLVM逻辑的地方就是这里。llvm-project/llvm/include/llvm头文件目录弄懂了include的结构就基本了解了LLVM的模块划分。llvm-project/llvm/lib/IRLLVM IR的数据结构定义比如Module、Function、BasicBlock、Instruction等。llvm-project/llvm/lib/Passes新Pass管理器的入口逻辑。llvm-project/llvm/lib/CodeGen代码生成相关包含SelectionDAG、GlobalISel等。llvm-project/llvm/lib/Target各种硬件后端的实现X86、ARM、RISCV等都在这里。llvm-project/clang前端代码包含词法分析、语法树构建、语义分析、AST与IR转换。llvm-project/lld链接器源码。llvm-project/libcxxC标准库实现。我个人建议的学习顺序不是从代码量最多的库开始而是先从llvm/lib/IR开始读数据结构然后通过opt工具实验不同pass的行为最后再深入CodeGen。因为IR是整个体系的“公约数”理解了IR前端和后端的代码看着就有方向了。2.2 构建配置实战从CMake到NinjaLLVM的构建系统基于CMake但工程量大直接默认配置构建会非常慢所以一定要按需裁剪。我常用的构建命令大致是这样git clone https://github.com/llvm/llvm-project.git cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_USE_LINKERlld \ -DLLVM_CCACHE_BUILDON cmake --build build --target clang lld几个参数解释一下。LLVM_ENABLE_PROJECTS决定要构建哪些子项目不要一次性全列构建时间会爆炸。LLVM_TARGETS_TO_BUILD指定后端架构初学者如果只在本机实验写个“X86”就够了省下大量编译和链接时间。LLVM_USE_LINKERlld是用lld作为链接器这一步能明显加速多次链接过程前提是系统里已经安装了一个可用的lld。LLVM_CCACHE_BUILDON打开ccache对于反复编译比如改一个pass非常友好命中缓存能省掉一半以上时间。如果你用的LLVM版本比较新还需要注意LLVM_ENABLE_PROJECTS和LLVM_ENABLE_RUNTIMES的划分。简单来说libcxx、compiler-rt这类运行时库更适合放到RUNTIMES构建因为它们要做多配置矩阵编译。第一次实验阶段不建议直接开启libcxx和compiler-rt等主链路跑通了再按官方文档调整。2.3 日常高频工具的作用llvm-project里有一批命令行工具不需要天天编译全部但下面这几个是理解和debug编译器流程的必备clang前端入口负责把源代码转成IR、汇编或目标文件。opt中端优化器实验台可以在纯IR上跑任意pass组合。llc后端代码生成工具把IR变成汇编或目标文件。llvm-as / llvm-dis文本IR与bitcode之间的互转。lli直接解释执行IR适合快速验证IR语义。llvm-mc机器码编解码工具常用来看汇编怎么编码成二进制。llvm-lit测试运行工具用来跑lit测试套件。举一个典型流程你想看一个C文件经过优化后长什么样可以这样操作# 生成文本IR clang -S -emit-llvm test.c -o test.ll # 跑一遍O2管道 opt -S -passesdefaultO2 test.ll -o test.o2.ll # 生成汇编 llc test.o2.ll -o test.s这套“clang-opt-llc”的流程可以拆开使用正是LLVM模块化设计最直接的体现。任何一步都可以单独替换比如换一个优化管道、换一个目标架构这在GCC里几乎是做不到的。3. 核心机制拆解从源码到机器码的流水线3.1 LLVM IR是编译器世界的“通用语”LLVM IR是一种采用SSAStatic Single Assignment静态单赋值形式的低层级中间表示。所谓SSA本质上就是要求每个变量只能被赋值一次变量一旦定义就不再变化。如果后续代码要修改同一个“逻辑变量”就通过phi指令来合并不同分支上的值。这个设计简化了优化器的数据流分析也方便指令重排、常量传播、死代码消除等操作的实现。老看到有人问为什么不直接用汇编做优化原因很简单汇编没有类型信息没有变量作用域指令绑定具体架构优化逻辑会被细节淹没。LLVM IR则保留了类型i32、ptr、struct等和内存访问的层级又比源码抽象层次低很多方便进行机器无关优化。说人话IR是给优化器准备的标准操作模型不是给人直接写业务代码的。一段最简单的LLVM IR长这样define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }这个函数接收两个i32相加后返回。注意%sum只赋值一次符合SSA约束。如果分支里有多个可能值就要用phi合并优化器和后端就能准确追踪每个值的来源。从优化器的视角看IR上的每条指令都对应一个“操作语义”比如add、load、store、br、ret等。而后续的pass可以自由地增删或修改这些指令只要不改变程序的可见行为。要深入理解LLVM第一步就是学会“用IR思考”一个C语言语句会被翻译成哪几条指令一个循环使用的是自然循环还是更底层的基于跳转和cond_br的形式。3.2 Pass管道与优化流程LLVM的优化是全套pass的流水线作业。所谓pass就是一次对IR或分析信息的遍历和处理。它可以做转换transform比如把一条乘法替换为移位加加法也可以做分析analysis比如算出循环不变量供后续pass使用。早期LLVM使用“legacy pass manager”用字符串identifier来注册pass。现在已经全面切换到“new pass manager”核心是显式的PassBuilder和Pipeline字符串。比如我想依次跑函数内联和指令合并可以这样写opt -S -passesfunction(instcombine,inline) test.ll -o out.ll如果你想看看O2的完整管道到底跑了什么可以加转储参数opt -S -passesdefaultO2 -debug-pass-manager test.ll -o out.ll这段命令会打印每一次pass执行的顺序和耗时。我实际看下来即使是简单函数O2下也有几十个pass在跑。它们在功能上分层有的做规范化如mem2reg把栈变量提升到寄存器有的做标量优化如gvn、licm有的做向量化loop vectorizer最后再做一遍清理。理解这些pass并不需要全知全能抓住几个核心代表就够了。mem2reg消除alloca构建真正的SSA形式。instcombine把多种模式匹配规则合并成更强表达式为后续优化铺路。simplifycfg简化控制流合并基本块。gvn全局值编号消除公共子表达式。licm循环不变量外提。loop-vectorize把循环向量化。我自己的经验是拿到一段IR后先无脑跑-passesdefaultO2看整体效果再用-debug-pass-manager看执行顺序最后用-print-before-all或-print-after-all观察每个pass对IR的影响。这套方法论比直接读源码更高效。3.3 代码生成链路SelectionDAG、GlobalISel、MIR优化完的IR不会直接变成汇编还要经过后端代码生成CodeGen。这一层的流程通常包含IR 被转化为SelectionDAG一种有向无环图进行指令选择Instruction Selection。每个IR操作会被匹配到目标机器的具体指令模式。指令调度Schedule和寄存器分配Register Allocation。寄存器分配是决定变量放物理寄存器还是栈上的关键环节会直接影响到性能。最后由MachineFunction逐步展开成MCInst再交给汇编层导出ELF、Mach-O等二进制格式。不同目标架构可以自己选择采用SelectionDAG还是较新的GlobalISel。GlobalISel的优点是把指令选择拆成更大的块便于复用和调试AArch64后端已经默认使用它处理部分优化级别。如果你想亲眼看看这部分可以用llc的选项把中间的各步dump出来llc -O2 -run-passisel -stop-afterisel test.mir看MIR类似于看IR但它已经包含目标机器的寄存器号、栈帧等信息。对于想移植后端或者做性能调优的人来说MIR是比汇编更友好的研究材料。不过新手入门阶段不需要把所有细节背下来先把“IR - SelectionDAG - MIR - MCInst - 汇编”的整体流程记住再针对具体架构去查阅TableGen描述文件就行。4. 实操案例手写IR、自定义Pass与调优4.1 手写IR并跑通优化器很多人在学习阶段会写一段C语言再通过clang生成IR来观察。但有时候手写IR是更快的验证方式因为可以精确控制输入。比如我想验证一个简单的表达式能否被优化成常数可以直接写define i32 mul_add() { entry: %v1 mul i32 7, 6 %v2 add i32 %v1, 3 ret i32 %v2 }然后用opt跑常量折叠opt -S -passesinstcombine mul_add.ll -o out.lloutput的IR中mul i32 7, 6可能会变成i32 42add也会被合并到45。这个实验能让你直观理解每个pass具体怎么改变IR。如果你想一步步观察可以加上-print-after-all把每个pass前后immediately后的IR都打印出来。这种手写IR的练习我强烈建议多做几轮。因为IR就是LLVM世界的“汇编语言”你能看懂IR后面理解优化管道、写pass、看后端日志就都顺了。常见的练习题目包括写一个间接跳转switch、写一个调用外部函数puts的代码、写一个带多个返回路径且需要phi的代码块。4.2 写一个最简单的自定义Pass并集成到opt如果要正式学习LLVM底层能力写自定义pass是绕不开的一步。现在官方建议通过插件方式编写无需修改LLVM源码本身。下面是一个新Pass管理器下最简单的FunctionPass示例功能只是打印每个函数的名称#include llvm/IR/Function.h #include llvm/IR/PassManager.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct HelloLowerPass final : public PassInfoMixinHelloLowerPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() visit function: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, HelloLowerPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-lower) { FPM.addPass(HelloLowerPass{}); return true; } return false; }); }}; }编译成.so插件clang -shared -fPIC -fno-rtti $(llvm-config --cxxflags) hello_pass.cpp -o hello.so然后通过opt加载测试opt -load-pass-plugin./hello.so -passeshello-lower mul_add.ll如果一切正常你会看到每个函数名被打印出来。这个示例虽然简单却包含了插件化pass的完整骨架注册回调、匹配pass名、插入到FunctionPassManager、返回PreservedAnalyses。后面要做的分析或者变换都可以在这个骨架上扩展。说几个对我影响很大的注意点第一插件版本的LLVM_PLUGIN_API_VERSION要和主程序严格匹配不匹配会报版本错误第二在pass中不要修改IR结构后还返回PreservedAnalyses::all()那是虚假声明会让后续分析失效甚至导致崩溃第三不要在分析时保留过期的指针因为IR在优化管道的每个阶段都在变化。4.3 编译时间与产物体积的调优心得LLVM虽然优化能力强但默认配置下编译时间和产物体积都比较“豪放”。我近期做一个嵌入式项目时发现-Oz相比-O2能让固件体积缩小12%左右代价是编译时间增加约30%。在实际使用时要分场景设定优化策略开发调试阶段用-O0和关闭优化器节省编译时间。发布阶段用-O2或-Oz配合LTO进一步跨模块优化。需要极致体积时还要加上-fno-exceptions -fno-rtti -ffunction-sections -fdata-sections以及链接器侧--gc-sections。LTOLink Time Optimization是我比较推荐的一招。默认单个编译单元内只能看到局部的优化机会LTO把整个程序的IR都拿给优化器跨函数、跨模块进行内联和常量传播效果明显。启用也简单clang -fltothin -O2 source_a.c source_b.c -o appThinLTO比full LTO的并行度更高链接速度和内存占用更友好我在多数项目里都用-fltothin。代价是构建系统要配合处理bitcode文件并且会有额外的链接期开销。如果项目里同时用了大量模板LTO编译时间增加会更明显建议用ccache缓存中间结果。5. 生态延伸与工具链联动5.1 Clang、LLD、libc等子项目的定位先说Clang。作为C/C前端它要处理词法、语法、语义生成AST最后落到LLVM IR。和GCC相比Clang的优势是模块化、报错信息更友好、内置静态分析框架。你在日常命令行里敲的clang命令其实是一整套驱动它会调用cc1、opt、llc、lld等多个内部工具完成编译链接。LLD是链接器。它最大的特点是快启用lld后链接速度能达到传统GNU ld的数倍对大型C工程来说改善非常明显。此外它的内部逻辑也更容易配合插件做分析比如检查二进制中是否有未定义的符号、生成map文件都比旧的脚本方式方便。libc则是C标准库的实现。如果你用的标准库是默认的GNU libstdc那么Clang默认也会用GNU头文件。只有显式指定-stdliblibc时才会用到llvm-project里的libc实现。两者在ABI函数签名、内存布局上有差异混合使用会出现莫名其妙的链接错误所以整个项目的标准库选择一定要统一。5.2 在语言实现中的应用从MLIR到Flang近几年LLVM生态最吸引我目光的是MLIR。简单说MLIR允许你定义多种层级的中间表示从接近上层语义的表示一直降到LLVM IR。这让编译器的开发者可以用更细粒度去表达领域特定的优化而不用把一切都压成通用的SSA指令序列。比如做机器学习编译器时可以把“卷积”这种高层算子保留在高层IR里做融合而不是先翻译成底层load/store再依赖通用优化器去恢复结构。Flang使用了一套类似思路Fortran的数组语义、do循环语义可以在高层IR阶段做多重分析和变换最终才降到LLVM IR做标量优化和代码生成。对编译器开发者来说MLIR并不是替代LLVM而是作为LLVM IR上层的“翻译车间”两者协同工作。如果你不想深入MLIR只做普通的C/C编译也可以暂时不关注但如果有意向接触新语言设计或AI编译器MLIR是绕不开的关键模块。5.3 跨平台与交叉编译场景LLVM天然支持交叉编译。你需要为ARM或RISC-V开发程序时只需安装对应的target支持并用--target指定三元组即可clang --targetaarch64-linux-gnu -O2 test.c -o test_aarch64前提是系统中有对应的sysroot和链接器。LLVM的各个后端是独立编译的在构建时通过LLVM_TARGETS_TO_BUILD控制。如果你的llvm-project只编译了X86后端却想生成ARM代码clang会报“assembler not supported”或“cannot find target”之类的错误。解决方法是重新构建把需要的架构加入target列表。交叉编译时最容易被坑的是标准库头文件和库文件缺失。LLVM编译器本身只是把源码转成目标平台代码但调用printf、malloc这些函数时还是需要目标平台的libc和libc。一般嵌入式项目会提供交叉编译的sysroot目录通过--sysroot参数指定即可clang --targetaarch64-linux-gnu --sysroot/path/to/sysroot -O2 test.c -o app经验是先确认LLVM的target是否编译再确认sysroot是否正确最后检查链接阶段是否提供了所需库。不要一上来就在命令行堆参数很多时候问题是链路里哪一层没配齐。6. 常见问题与排查技巧实录6.1 构建期问题速查llvm-project因为体积大、组件多构建时最容易出问题。下面是我实际遇到过的经典情况现象可能原因解决办法make/ninja进程被Killed构建并行度太高内存不足降低-j参数换gold或lld链接器减少target列表编译clang时没有头文件系统没有安装对应的依赖安装zlib、libxml2等基础开发包或检查CMake缓存版本不匹配的ABI错误插件或工具和主程序版本不一致统一llvm-project版本编译插件时用相同llvm-configccache不命中编译参数和路径发生变化检查LLVM_CCACHE_BUILD确认ccache配置正确链接时缺libffi、libedit某些子项目开启了额外依赖关闭对应选项如LLVM_ENABLE_LIBEDITOFF、LLVM_ENABLE_FFIOFF构建是我的建议是不要开全部子项目不要开全部target不要用并行度过高的-j留出至少16GB内存。如果机器配置一般还可以先构建llvm核心再看clang。还有个小技巧用cmake --build build --target install之前先ninja -t targets看看目标名避免明明编了却没安装在预期路径的情况。6.2 运行期与IR问题IR相关的问题也是高频。比如opt运行报错“Invalid bitcode signature”常见原因是.bc文件版本和opt版本不一致。另一个高频报错是“Bitcode file requires a different language ID”多发生在bitcode是在不同语言前端下生成的情况下。最简单的排查路径是先用llvm-dis把bitcode转成文本IR看看内容是否符合预期再决定是不是工具版本不匹配。在写pass时我最常遇到的问题是unsafe iteration。假设你在遍历一个BasicBlock的指令列表同时删除其中一些指令就会导致迭代器失效。正确的做法是先把要删除的指令收集到临时容器里遍历结束后再删除或者使用make_early_inc_range来提前自增。这个问题在源码仓库中也有很多历史提交反复出现过现在官方Review标准里专门有这条要求。再补充一个关于Pass运行顺序的经验如果你写的pass依赖某个analysis的结果比如LoopInfo务必在getAnalysis或通过AnalysisManager.getResult方式明确获取不要自己重新计算。原因很简单PassManager已经为分析和转换提供了缓存机制不按接口走容易拿到的就是过期数据。6.3 我踩过的一些坑与心得我在写自己的编译器玩具时踩过不少坑挑几个有代表性的分享。第一个是关于mem2reg和alloca的。一开始我把所有局部变量都用alloca分配以为这是最“简单”的IR结果优化效果非常差。后来才意识到alloca本质上代表内存地址、别名分析的结果不准确很多优化都白费。正确做法是让前端尽量生成虚拟寄存器形式的SSA值只在取地址时才用alloca然后让mem2reg把这些alloca提升回去。这也是为什么Clang的CodeGen会做两步先创建一个alloca映射再通过store/load访问最后靠优化pass提升成寄存器IR。第二个是关于phi节点的。在做CFG结构修改时如果把一个block合并到另一个block别忘了修复phi指令的操作数否则验证器会直接报错“PHINode should have one entry for each predecessor”。这类错误的定位方法是用opt -verify跑一遍让IR验证器告诉你具体哪里失效。第三个是关于TableGen的。如果你想新增或修改后端的指令格式会发现TableGen描述文件非常容易出错。我建议先用llvm-tblgen -print-records查看展开后的记录再用llvm-mc做小样本测试不要直接编译整个llc。这种“小步快跑”的方式能极大减少定位时间。说到底llvm-project是一个非常庞大的系统但它的奖项就在“模块化”三个字。只要沿着“前端-IR-优化-后端”这条主线配合opt、llc这些实验工具慢慢就能把陌生感消除。如果你急着改后端就去看lib/Target如果想做前端就去看clang如果想理解优化就从写一个最简单的pass开始。每走通一步你会觉得这个项目的设计其实非常通透。最后再分享一个小技巧与其搜别人写好的宏达分析不如在本地把源码clone下来用llvm-config --help和ninja -t targets看看实际可用的命令再配合llvm-undname、llvm-readobj这些小工具去反向验证你的理解。这些不起眼的工具往往才是真正能帮你“确认自己懂了”的入口。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →