ARTICLE DETAIL

资讯详情

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

LLVM项目核心解析:从架构原理到工程实践指南

LLVM项目核心解析:从架构原理到工程实践指南 最近在翻代码的时候又跟 llvm-project 打了好一阵子交道这个项目我之前断断续续研究过但每次深入都还是能挖出点新东西来。正好后台有朋友留言问这个仓库到底该怎么看、怎么用今天就以我自己的经验为线索把 llvm-project 这个庞然大物从头到尾梳理一遍。先说清楚它是什么。llvm-project 是 LLVM 官方的统一代码仓库包含的不仅是那个叫 LLVM 的编译器后端框架还有大家熟知的 ClangC/C/Objective-C 编译器前端、LLD链接器、libcC 标准库实现、compiler-rt运行时库、OpenMP 运行时等等。可以说整个现代编译工具链的基石基本都在这里面。如果你对编译器、编程语言设计、静态分析、代码优化、甚至芯片后端感兴趣这个仓库都绕不开。它不只是给编译器工程师准备的做 IDE 插件、做代码检查工具、做脚本语言 JIT 加速的人也都在这个项目的地盘上干活。我自己最初接触 llvm-project是想搞明白一个很实际的问题为什么我在别的编译器上能跑得好好的 C 代码拿到 Clang 下编译行为会不一样。后来渐渐发现搞懂这个项目的架构比抠某一条语法的差异重要得多。这篇就把我看源码、跑实验、踩坑的过程都写出来重点说这个项目里最值得下功夫的几个核心模块以及怎么从零把它跑起来、做你自己的第一个实验。1. 整体架构一个编译器是怎么被拆成三段的先说一个很多人刚接触 LLVM 时的困惑明明很多教程说 LLVM 是一个编译器框架但在 Linux 上安装它之后你敲 clang 命令却真的能把 C 语言编译成可执行文件。这就要理解它的三段式设计——前段、中端、后端。前端负责把源代码变成一种统一的中间表示这个中间表示叫 LLVM IR。中端在这个 IR 上做优化比如循环展开、内联、常量传播它不关心你的源码原来是 C、Rust 还是 Swift。后端把优化后的 IR 转换成目标平台的机器码比如 x86、ARM、RISC-V。llvm-project 仓库里对应关系非常清楚。clang 目录是 C 家族的前端。llvm/lib/Transforms 里是一堆优化 Pass。llvm/lib/Target 下面是各种后端的实现。这种拆法的聪明之处在于如果你想发明一门新语言只需要写一个能生成 LLVM IR 的前端后面所有优化和代码生成直接白嫖如果你想做一个新芯片只需要实现一个后端所有能生成 LLVM IR 的语言都能跑在你的芯片上。这个设计一直被人夸是有道理的。在没有 LLVM 的年代一个编译器想支持多种语言和多种架构基本上要为每种组合都维护一份完整的实现工作量是语言数量乘架构数量。有了 LLVM这个成本变成加法写一个前端加一个后端就搞定一种新组合。而且因为 IR 是统一的标准格式前端工程师不需要懂后端细节后端工程师也不用管前端解析源码那一堆语法糖团队协作边界清晰得很。1.1 为什么 LLVM 的 IR 是关键设计LLVM IR 是这个项目的灵魂。它是一种静态单赋值形式也就是每个变量只被赋值一次配合无限数量的临时变量。这个特性的意义在于很多优化算法在 SSA 形式下变得特别简单比如需要分析某个变量的使用范围时不用去追踪那些反复赋值的岔路直接顺着数据流图找就行。我最早听说 SSA 时觉得这是个很高深的学术概念但后来动手写了一个简单的分析 Pass 才发现它更像是一种让编译器工程师偷懒的设计——把复杂问题变成一个有向图上的问题图论里一堆现成算法直接搬过来用。LLVM IR 还分了三种形态内存中的表示C 对象、字节码格式.bc 文件、以及人可以读的文本格式.ll 文件。调试的时候多用文本格式性能敏感的场景用字节码。1.2 一个 IR 在三种形态下长什么样举个例子一段非常简单的 C 代码int add(int a, int b) { return a b; }用clang -S -emit-llvm add.c -o add.ll生成的 LLVM IR 大概是这样的define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }这个文本格式非常直观。define i32 add声明了一个返回 32 位整数、名字叫 add 的函数。nsw表示这个加法不会发生有符号溢出允许编译器做更多激进优化。ret就是返回指令。我第一次看到这个文件时最大的感受是它比汇编好读太多了。汇编里你可能要关注寄存器怎么分配但 IR 里变量名都保留着原始语义类型也清清楚楚。这也是 LLVM 强大生态的起点——所有工具都基于这样一份统一的、可读性强的中间表达。2. 源码结构速览仓库里各目录到底在干嘛llvm-project 仓库体量巨大第一次 clone 下来很容易迷失。我当时就是看到十几个顶层目录直接懵了不知道该从哪看起。这里列一份基于我个人经验的地图帮你少走弯路。顶层目录里最核心的是llvm和clang这两个。llvm是框架本体优化器、汇编器、反汇编器、后端都在这clang是 C/C 编译器前端。如果你只对诊断信息或者 C 语义分析感兴趣主要看clang目录下的lib/Sema、lib/AST这些子目录就行。其他几个也值得了解。lld是我个人很喜欢的部分它是 LLVM 的链接器速度比 GNU ld 快很多用了都说好。libc和libcabi是 C 标准库和 ABI 层的实现compiler-rt提供一些运行时支持比如 AddressSanitizer 内存检测工具的实际实现就在这polly是做循环优化的多面体模型框架flang是 Fortran 前端mlir是 LLVM 之上的一个多级 IR 框架AI 编译器领域这几年非常火。2.1 llvm 目录内部lib 和 tools 的职责划分llvm目录内部最重要的两个子目录是lib和tools。lib里是各种库文件真正的核心逻辑都在这里。比如lib/IR是 IR 的核心数据结构在这里你能看到Module、Function、BasicBlock、Instruction这些类是怎么组织的。lib/Transforms是优化 Pass 的家分Scalar处理标量优化、Vectorize做向量化、IPO做跨函数优化等。tools目录则是一些独立的可执行程序。llc负责把 IR 转成目标汇编opt负责对 IR 跑优化llvm-dis把字节码转成可读文本llvm-as做反向操作。调试时这几个命令行工具是每天都要打交道的。我第一次看lib/IR源码时有点绝望因为类继承层级比较深一个Instruction派生出一大堆指令类。但后来找到一个方法不要试图从上到下全看而是从一条具体的指令入手比如BinaryOperator看它怎么创建、怎么被处理慢慢就类比到其他指令了。2.2 从一次实际编译流程理解各工具的分工为了把这些工具的职责串起来我当时做了一个实验。写一个简单 C 文件 test.c# 前端C 源码 - LLVM IR clang -S -emit-llvm test.c -o test.ll # 优化对 IR 做 Pass 优化 opt -passesmem2reg,instcombine test.ll -S -o test_opt.ll # 后端IR - 目标汇编 llc test_opt.ll -o test.s # 汇编和链接 clang test.s -o test完整跑一遍链路之后我头脑中那些抽象概念全部落到了具体命令上。clang会完成词法分析、语法分析、语义分析生成 IRopt可以手动选择跑哪些优化 Pass适合教学和研究想看哪一步效果精确控制llc负责指令选择、寄存器分配这些后端工作。这在日常开发中有什么实际用途呢比如你想排查编译器在某个优化环节出了错就可以用手动方式一步步复现而不是让编译器一把梭。这也是开源项目调试的常规操作——把复杂链路拆成一个一个可控的步骤逐步定位问题。3. Clang 前端不只有语法分析还有一整套工程工具如果把 LLVM 比作汽车底盘Clang 就是那个做得特别漂亮的驾驶舱。它不只是把 C/C 翻译成 IR还提供了极其丰富的 API让外部工具能直接复用它的词法和语法分析能力。最典型的就是代码补全、跳转定义、语法高亮这些 IDE 功能很多都基于clang库而不是靠正则匹配去猜。我自己用得最多的是 Clang 的静态分析能力。传统编译器一般只在编译时报错但 Clang 的-Wall、-Wextra配合analyzer能查出一类 bug比如空指针解引用、内存泄漏、在释放后使用变量等。这个能力在接入 CI 流水线时极其有用很多问题不用等运行期爆出来编译阶段就直接扼杀了。3.1 用 clang 生成并阅读 AST理解代码结构的第一课有个命令我强烈建议新手试试clang -Xclang -ast-dump -fsyntax-only test.c。它会输出一份完整的抽象语法树展示编译器眼中你的源码是什么结构。我那时候第一次看到这个输出还是挺震撼的——原来编译器比我更懂我的代码结构。比如一个简单的函数AST 会展示它有一个FunctionDecl节点下面挂着参数列表、返回类型、函数体函数体里可能是一个CompoundStmt再往下是ReturnStmt里面有一个BinaryOperator。这棵树就是我们写代码时的语法结构的忠实表达。读懂 AST 对理解 C 的一些复杂特性特别有帮助。比如模板实例化、重载决议、隐式转换这些在 AST 里都会有对应的节点或标记。你对照源码和 AST 看一遍很多“为什么编译器报这个错”的疑问都会豁然开朗。我自己后来写代码分析工具时最常干的事就是先ast-dump一份文件看看 Clang 是怎么理解这段代码的再决定我的工具在哪个节点上做文章。3.2 用 LibTooling 写一个简单的代码重构工具Clang 最有吸引力的地方是提供了 LibTooling 库你可以用它写自己的 C 代码处理工具比如批量改函数签名、统计某类 API 的调用频率或者做代码风格检查。它跟正则表达式替换完全不是一个层次——LibTooling 懂得语法结构不会出现把注释里的字符串也替换了的低级错误。一个最简流程是这样。首先在 llvm-project 外的独立目录建一个工程用 CMake 链接 clang 的库然后实现一个继承自clang::ASTConsumer的类其中的HandleTranslationUnit方法会在整个文件被解析完后回调。你可以在这个回调里遍历 AST想做啥都行。#include clang/AST/ASTConsumer.h #include clang/AST/ASTContext.h #include clang/AST/RecursiveASTVisitor.h #include clang/Frontend/CompilerInstance.h #include clang/Tooling/CommonOptionsParser.h #include clang/Tooling/Tooling.h #include llvm/Support/CommandLine.h using namespace clang; using namespace clang::tooling; class FunctionVisitor : public RecursiveASTVisitorFunctionVisitor { public: bool VisitFunctionDecl(FunctionDecl *FD) { if (FD-isThisDeclarationADefinition()) { llvm::outs() Found function: FD-getNameAsString() \n; } return true; } }; class MyASTConsumer : public ASTConsumer { public: void HandleTranslationUnit(ASTContext Context) override { FunctionVisitor Visitor; Visitor.TraverseDecl(Context.getTranslationUnitDecl()); } }; class MyAction : public ASTFrontendAction { public: std::unique_ptrASTConsumer CreateASTConsumer(CompilerInstance CI, StringRef File) override { return std::make_uniqueMyASTConsumer(); } }; static llvm::cl::OptionCategory ToolCategory(my-tool options); static llvm::cl::extrahelp CommonHelp(CommonOptionsParser::HelpMessage); int main(int argc, const char **argv) { auto ExpectedParser CommonOptionsParser::create(argc, argv, ToolCategory); if (!ExpectedParser) { llvm::errs() ExpectedParser.takeError(); return 1; } CommonOptionsParser OptionsParser ExpectedParser.get(); ClangTool Tool(OptionsParser.getCompilations(), OptionsParser.getSourcePathList()); return Tool.run(newFrontendActionFactoryMyAction().get()); }代码本身看着不长但信息密度很高。RecursiveASTVisitor是深度优先遍历 AST 的利器你只需要重写Visit*方法命名对了就能自动被调用。CommonOptionsParser负责解析命令行参数同时读入编译数据库。编译完成后执行方式跟 clang 很类似得在末尾加上要分析的文件名比如./my-tool test.cpp --。为什么会有两个短横线因为test.cpp是要分析的目标后面的内容是要传给编译器驱动器的参数。这个例子跑通之后你就能打开一扇大门代码迁移辅助、API 使用统计、规范化重构脚本全都可以用 LibTooling 来实现。这属于使用编译器的语法树做元编程比单纯改文本强大太多。4. 中间端优化Pass 框架是 LLVM 的武功心法理解 LLVM IR 只是第一步真正让 LLVM 从“能编译”进化到“编译得好”的是它的优化 Pass 框架。Pass 本质上是一个在 IR 上做分析和变换的独立模块每次处理一个函数、一个循环或者整个模块。你可以把它想象成流水线上的一道道工序——有的负责打磨细节有的负责调整结构终点是让程序跑得更快或者体积更小。这里的典型 Pass 包括mem2reg把内存访问提升为 SSA 寄存器值instcombine做各种指令级化简gvn做全局值编号从而消除冗余计算loop-unroll展开循环以减少分支开销inline把函数体直接嵌入调用点。LLVM 默认优化级别-O2就是按照一个精心排序的 Pass 管线去执行的。4.1 亲手写一个分析 Pass统计函数个数写 Pass 是很多想深入研究编译器的人的第一道坎。我这里分享一个最最基本的示例——统计一个模块里有多少个函数。它的价值不在功能本身而在于让你完整走一遍“写 Pass、编进 LLVM、运行 Pass、看结果”的流程。在 llvm-project 的源码树里有一个专门放示例的目录叫llvm/lib/Transforms/Utils周围都是现成 Pass 可以参考。新版 LLVM 的 Pass 用llvm::PassInfoMixin这个模板核心是重写一个run方法#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 { class CountFunctionPass : public PassInfoMixinCountFunctionPass { public: PreservedAnalyses run(Module M, ModuleAnalysisManager AM) { unsigned Count 0; for (Function F : M) { if (!F.isDeclaration()) { Count; } } errs() Number of function definitions: Count \n; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getCountFunctionPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, CountFunctionPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager MPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-func) { MPM.addPass(CountFunctionPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getCountFunctionPassPluginInfo(); }重点看三个地方。isDeclaration()判断函数是否是只有声明没有定义。registerPipelineParsingCallback把count-func这个字符串跟我们写的 Pass 关联起来这样在命令行里写-passescount-func就能调用。llvmGetPassPluginInfo是插件系统要求的导出符号没有它 LLVM 认不出这个动态库。编译这个 Pass 需要用 CMake 写一个独立工程链接LLVM的库并引用 LLVM 的头文件。在 LLVM 17 之后官方推荐的方式是把它做成一个插件用-fpass-plugin路径动态加载这样不用每次都重新编译整个 LLVM开发迭代快很多。4.2 从 IR 到机器码后端到底做了几件大事优化完的 IR 还要过最后一关就是后端。这里要干的事包括指令选择把 IR 指令映射到目标处理器的具体指令、指令调度排列指令顺序以利用 CPU 流水线、寄存器分配决定哪些变量放在寄存器哪些放内存、以及最后的机器码输出。我在看后端代码之前以为它跟前面部分差不多结果发现复杂度是另一个量级的。因为每个 CPU 架构的指令集都不一样LLVM 用一套 TableGen 描述语言来自动生成不少代码。TableGen 文件描述指令格式、寄存器种类、操作数约束然后用 llvm-tblgen 这个工具生成 C 代码。对于新接触的人我建议先通过llc -marchx86-64 -mcpuhelp看看目标机器支持哪些特性和指令集再对比llc输出的汇编代码理解指令选择结果。等你需要在嵌入式芯片上做交叉编译时那些--targetarm-none-eabi之类的参数背后其实都是这套后端框架在起作用。5. 从源码构建 llvm-project一份完整避坑指南网上关于 LLVM 构建的教程非常多但版本差异导致很多教程已经失效了。这里给出我当前环境Ubuntu 24.04LLVM 18 版本下实测可行的步骤同时把容易踩的坑集中说一下。5.1 环境准备和依赖安装首先是装依赖sudo apt update sudo apt install -y build-essential cmake ninja-build python3 \ git zlib1g-dev libedit-dev libncurses-dev libxml2-devlibedit和libncurses这两个容易漏漏掉后 LLVM 的命令行交互工具可能会有问题。ninja-build比make快不少LLVM 这种大项目用 Ninja 能省很多时间。然后 clone 代码。llvm-project 官方仓库体积很大完整 clone 超过 1GB网络差的话建议加--depth1只取最近一次提交git clone --depth1 https://github.com/llvm/llvm-project.git cd llvm-project5.2 CMake 配置中几个关键参数的选择逻辑构建 LLVM 的 CMake 参数直接决定你之后的工作效率。我先列一份实测可用的配置cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSOn \ -DCMAKE_INSTALL_PREFIX$HOME/llvm-installCMAKE_BUILD_TYPE设成 Release 能获得最好的运行性能但如果你想调试编译器本身需要设成 Debug 或 RelWithDebInfo。LLVM_ENABLE_PROJECTS是你想额外构建的子项目用分号分隔至少要带上 clang 才能编译 C 语言。LLVM_TARGETS_TO_BUILD建议只选自己需要的架构全选的话编译时间会翻很多倍。LLVM_ENABLE_ASSERTIONS开启一堆内部检查代价是性能下降但开发调试阶段这泡错信息能救命。配置完成后直接编译cmake --build build -j$(nproc)这个阶段耗时取决于机器配置8 核 16 线程的机器编译一份 Release clang lld大概需要 15 到 30 分钟。如果中间报内存不足可以把-j调小或者加-DLLVM_PARALLEL_LINK_JOBS2限制并行链接任务数。5.3 推荐阅读顺序与调试小技巧代码构建好只是第一步真正上手才刚开始。我建议的阅读顺序是先看 IR 核心数据结构的头文件比如llvm/include/llvm/IR/Function.h、BasicBlock.h、Instruction.h再跑clang生成.ll文件对照看然后挑一个简单的 Pass 源码看最后才接触后端。日常调试有个非常实用的技巧就是打印调试信息。在 LLVM 源码里经常能看到LLVM_DEBUG(dbgs() something\n)默认不输出需要通过-debug-onlypass名打开。比如想看 instcombine 的调试信息opt -passesinstcombine -debug-onlyinstcombine test.ll -S -o /dev/null这个输出对我来说价值巨大相当于编译器在逐步解释它在干什么。写自己的 Pass 时也建议用LLVM_DEBUG而非裸的errs()这样别人用的时候可以根据调试通道开关自由控制日志输出量。6. 常见问题与异常排查实录llvm-project 是个超大项目踩坑是常态。这里把我平时遇到频率最高的问题分类整理一下附带排查思路。6.1 编译时间长与内存不足怎么破很多人第一次 clone 完直接cmake --build build -j$(nproc)结果链接阶段内存爆了。原因是链接几个巨大的 C 可执行文件非常吃内存。解法有几个一是降低并行度二是在 CMake 时设置LLVM_PARALLEL_LINK_JOBS2三是用lld作为链接器来链接 LLVM 本身lld 内存占用比 GNU ld 低不少速度还快。另一个容易踩的坑是DLLVM_ENABLE_PROJECTS和LLVM_ENABLE_RUNTIMES混淆。早期版本很多组件写在LLVM_ENABLE_PROJECTS里后来官方逐渐迁移到LLVM_ENABLE_RUNTIMES。如果你发现libc编译出来却找不到头文件大概率是这类版本迁移问题。建议以官方文档为准不要盲信网上老教程。6.2LLVM_ENABLE_ASSERTIONS开启与关闭的坑有两次我改了一点后端代码Release 版编译出来的二进制行为诡异怎么排查都找不到问题。后来开了LLVM_ENABLE_ASSERTIONS重新编译后立刻提示我某个指令的寄存器约束没满足。这就是 LLVM 开发者为什么要默认开启断言的原因——它能在问题最小的时候拦住你。代价是开启断言后编译器性能明显下降构建出的 clang 编译代码比 Release 慢不少。所以我的习惯是调代码阶段用 Debug 断言跑大规模 benchmark 时再编一个纯 Release 版本。两个 build 目录并行存在互不干扰反正磁盘便宜。6.3 写的 Pass 不生效或报错怎么办最常见的现象是插件加载成功了但-passesmy-pass提示无法识别。这通常是因为注册回调里的名字跟命令行传的名字对不上或者registerPipelineParsingCallback没在正确的 PassBuilder 时机去注册。另一个容易被忽略的点是 Pass 的返回值PreservedAnalyses写错如果返回all()但实际改了 IR后续 Pass 拿到的可能是旧的分析结果导致诡异问题。调试这种问题我的套路是先用opt -print-passes查看当前 LLVM 能识别的所有 Pass 名确认自己的插件有没有被加载再用-debug-onlypass-manager看 Pass 管线的执行日志最后用llvm-diff或者肉眼比较优化前后的 IR 文件确认 Pass 到底执行了没有。这套组合拳能覆盖绝大多数“Pass 没生效”的场景。6.4 交叉编译时头文件路径的坑用 clang 做交叉编译比 gcc 方便不少但有个经典坑clang 不会像 gcc 那样自动找到 sysroot 里的 C 标准库头文件。你需要在命令行里手动指定--sysroot或者通过-target加-resource-dir组合告诉 clang 去哪找内置头文件和库。我第一次用 clang 编译 ARM 目标时一直报找不到stddef.h后来才知道 clang 搜索内置头文件的路径是以自身二进制位置推算的如果安装到了/usr/local/bin而库文件在别处路径匹配就挂了。解法是显式传入-resource-dir/path/to/llvm/lib/clang/18。这个坑跟工具链安装方式强相关不同发行版差异很大遇到fatal error: stddef.h file not found优先往resource-dir方向排查都会对。7. 进阶方向mlir 与编译器的未来聊完核心的 LLVM IR、Clang 和 Pass再说一个 llvm-project 里我个人觉得很有意思的方向MLIR。这个子项目位于mlir目录它的想法是传统的 LLVM IR 是给通用处理器代码优化设计的层级固定且语义绑定在通用计算模型上。但今天大家手里的硬件五花八门GPU、NPU、FPGA 各有各的计算模型这时候一套 IR 打天下就不够用了。MLIR 引入了“多级 IR”的概念你可以定义一批适合特定领域的抽象层次。比如做深度学习编译器时先用一个高层 IR 描述张量运算和算子融合然后逐步 lower 到循环级 IR再 lower 到 LLVM IR最终生成目标代码。每一级 IR 都有自己的类型系统、操作集、优化 Pass因此灵活性远超传统的单一 IR。其实这背后是协同设计的思想——前端语言和后端硬件不必强行适配中间多插几层“翻译站”即可。对于刚接触 LLVM 的人直接学 MLIR 可能有点门槛但理解它的思路有助于看清 LLVM 生态为什么这么有生命力它不是一个固化的工具链而是一个不断生长的编译基础设施。我个人在实际定位和阅读 llvm-project 时的体会是永远从具体问题切入比从头到尾读源码效率高得多。想搞懂优化就找一个 Pass 改一改想搞懂前端就写一个 LibTooling 工具想搞懂后端就挑一个小指令集做交叉编译。这个项目太大把它当“字典”随用随查而不是当“小说”从头读到尾才是更现实的学习策略。如果你已经在 llvm-project 里折腾出了什么有趣的小工具或者卡在某个环节欢迎在评论区聊聊大家一起把坑填平。
返回列表