ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

LLVM与Clang实战:从IR到Pass的编译优化与代码分析

2026/9/18 8:51:46 拓冰建站 浏览量
LLVM与Clang实战:从IR到Pass的编译优化与代码分析 很多 C 程序员每天都在用 Clang 编译代码也听说 LLVM 是编译器领域的“工业级基础设施”但真正打开 llvm-project 源码仓库的那一刻大多数人第一反应是这是什么庞然大物十几个一级子项目、数百万行 C 代码、名字听起来就劝退的 TableGen、Pass 基础设施、SelectionDAG……别说读懂了光是把代码拉下来编译一遍就够折腾一整天。但我想说的是llvm-project 并没有想象中那么遥不可及。它的价值也不只是“能编出 Clang”这么简单——你写的每个编译优化、每次 LTO 链接、每个 sanitizer 报错背后都是这套代码在支撑。我这些年在这套代码里摸爬滚打踩过不少坑也尝到了不少甜头。这篇文章不打算复述官方文档而是从一名普通使用者和二次开发者的视角聊一聊 llvm-project 到底给你带来了什么、怎么入门、怎么避坑。无论你是想搞懂编译原理的学生还是想基于 LLVM 做代码分析、自定义语言、甚至只是想把项目构建速度提上去的工程开发者这篇文章都值得你花点时间读一读。1. LLVM 项目的核心价值不只是“一个编译器”很多人对 LLVM 的第一印象是“Clang 的底层”。这个理解没错但太窄了。llvm-project 最大的贡献是把“编译器”这件事拆成了清晰的阶段让前端、优化器、后端各自独立。这种设计直接造就了它的生态价值。1.1 三层架构给了你什么自由传统的 GCC 是一个整体式编译器前端解析 C/C 语法中端做 GIMPLE 优化后端生成目标机器码三者绑定得很紧。如果你想拿 GCC 做一门新语言基本等于把整个 GCC 重写一遍。LLVM 不一样。它的核心是一个被称为 LLVM IR 的中间表示层。前端Clang负责把源码转成 IR优化器只认 IR后端把 IR 翻译成 x86、ARM、RISC-V 等目标平台指令。这意味着你发明了一门新语言只需要写一个能产出 LLVM IR 的前端后续的优化和代码生成全部白嫖。你在优化器里加一个新的 Pass所有语言、所有平台都能享受。你想做代码静态分析不需要真正生成机器码把 IR 读出来分析就行。我第一次真正意识到这个抽象的巨大价值是在做一门 DSL领域专用语言的编译器时。当时只需要用 LLVM 的 C API 生成 IR就拿到了全套优化和 JIT 执行能力整个项目的代码量比预想中少了一个数量级。这就是“编译器的编译器”这句话的真实含义——它把编译这件事本身做成了可复用的平台。1.2 为什么说 llvm-project 是一个“项目群”llvm-project 仓库之所以叫“project”而不是“llvm”是因为它包含了非常多的协同子项目。简单列一下你日常大概率会碰到的子项目作用你会在什么场景用到LLVM 核心库IR 定义、优化 Pass、后端代码生成、目标描述写 Pass、做代码分析、自定义后端ClangC/C/Objective-C 前端也提供大量工具日常编译、静态分析、重构工具clang-tools-extraclang-tidy、clang-format、clangd 等周边工具代码格式化、静态检查、IDE 补全LLD高性能链接器大幅提升链接速度libc / libcabiC 标准库实现用 Clang 配 libc 编译compiler-rtsanitizer、profile、builtin 等运行时库ASan/UBSan、覆盖率统计LLDB调试器基于 LLVM 生态的调试MLIR多级 IR 基础设施机器学习编译器、高级别编译优化FlangFortran 前端科学计算场景polly多面体优化框架循环嵌套优化openmpOpenMP 运行时并行编程这个列表长得吓人但结构上是清楚的核心是 LLVM 库和 Clang周边工具都是围绕它们长出来的。你不一定需要全部掌握重点搞清楚哪些是你当前项目需要的就能省下大量时间。2. 从源码结构看清 LLVM 的家底如果你已经决定深入 llvm-project第一步永远是搞懂目录布局。这个仓库看起来文件无数但组织逻辑非常清晰每个目录的职责划分是稳定的。2.1 一级目录的职责边界打开仓库根目录你会看到 llvm/、clang/、lld/、libcxx/ 这些一级目录。有一个很反直觉的点顶层的 CMakeLists.txt 不是用来构建整个大仓库的真正的构建入口在 llvm/ 目录里。第一次用 LLVM 的人经常会在这里懵掉——为什么我照着文档在根目录建 build 目录编译结果报错说找不到目标实际上 llvm-project 的构建逻辑是llvm/ 是主工程其他子项目通过 LLVM_ENABLE_PROJECTS 这个 CMake 选项被拉进来。所以正确的构建姿势是git clone https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake -G Ninja ../llvm \ -DLLVM_ENABLE_PROJECTSclang;lld;libcxx;clang-tools-extra \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 ninja注意 -DLLVM_TARGETS_TO_BUILD 这个参数。如果你只是在 x86 机器上做常规开发默认构建所有目标架构会白白多编译很多后端代码把构建时间拉长一倍不止。指定目标架构是官方反复强调、但很多人不当回事的优化项。2.2 llvm/include/llvm/IR 是理解 IR 的最佳入口让我给初学者一条我非常推荐的源码阅读路径。先不要直接扎进 Pass 目录那样很容易迷失。第一步要看的是 include/llvm/IR/ 这个目录下的头文件它定义了 LLVM IR 本身的类型体系。你会看到 Instruction、BasicBlock、Function、Module 这几个核心类。它们之间的关系可以打个比方Module 就是一个源文件包含全局变量和函数Function 就是函数BasicBlock 是函数里的基本块顺序执行的语句序列没有分支Instruction 是基本块里的每一条指令。整个 IR 就是一棵树Module 包含若干 FunctionFunction 包含若干 BasicBlockBasicBlock 包含若干 Instruction。我看过不少人想学 LLVM 时直接从“写一个 Pass”开始结果遇到一堆 API 不知道在操作什么。其实只要先把这几个 IR 类的关系搞清楚后面读 Pass 代码时会顺畅非常多。2.3 TableGenLLVM 里的“元编程”模式llvm-project 里有一个让新手困惑的名词叫 TableGen。它的本质是一个代码生成工具你用声明式的语言描述目标机器的指令集、寄存器、调用约定等TableGen 会帮你生成 C 代码。我在接触它之前觉得写目标后端是件特别痛苦的事。但是用 TableGen 描述指令之后你会发现大量样板代码都不用手写了。比如描述一条加法指令只需要写它是二元操作、操作数是 GPR、助记符是 ADDTableGen 会根据这些描述生成解码器、编码器、指令选择器甚至汇编打印器的骨架代码。这个设计思路其实可以迁移到正常工作里对于重复性高、模式固定的代码用声明式描述加代码生成代替手写既减少错误又方便调整。LLVM 本身就是一台巨大的“代码生成器生成器”。3. 亲手跑通一条 Clang 的优化流水线很多人学 LLVM 的终极目标就是看 Clang 到底是怎么把 C 源码变成高效的机器码的。这一节我带着你实际操作一遍用工具把每层变换“扒开”来看这比读一百篇文章都管用。3.1 从源码到 IR前端做了什么先准备一段最简单的 C 代码// test.c int add(int a, int b) { return a b; }用 Clang 生成 IR注意 -S 和 -emit-llvm 两个参数是配合使用的只生成 IR 不进入后续阶段clang -S -emit-llvm test.c -o test.ll打开 test.ll你会看到 define i32 add(i32 %a, i32 %b) 这样的定义。这行看着很简单但 Clang 在这个过程中做了大量你不知道的工作语法分析、语义分析、类型检查、表达式展开、常量折叠……你写的 C 代码在语法树层已经被解析成一系列节点然后逐一降级成 IR 指令。用 opt 工具对 IR 做优化时你可以加 -print-after-all 参数它会打印每个优化 Pass 执行后的 IRopt -O2 -print-after-all test.ll -o /dev/null这条命令会输出海量信息建议把输出重定向到文件里找几个关键 Pass 前后的 IR 对比。比如 gvn全局值编号Pass 之后重复的子表达式会被消除licm Pass 之后循环不变计算会被移出循环体。亲眼看到这个变化比任何文档解释都直观。3.2 优化流水线的编排逻辑你可能会问这么多 Pass谁先跑谁后跑LLVM 的优化流水线是有讲究的。最基本的分类是分析 Pass 和变换 Pass。分析 Pass 只计算信息比如循环结构、依赖关系不修改 IR变换 Pass 真正修改 IR。流水线的编排逻辑大体遵循几个原则尽早做规范化Canonicalization。让不同写法产生的 IR 尽量统一后续 Pass 才能匹配到更多模式。比如 instcombine 会把数学等价的表达式归一到同一种形式。循环优化有专门的阶段。内联inline通常比较早做因为它改变了函数边界会影响后面的分析循环变换unroll、vectorize通常在函数内联和基本标量优化之后。后端相关的准备如目标特定的指令组合放到流水线后段。手动跑一条完整流水线其实并不常见日常工作里直接用 opt -O2 就能得到和 Clang -O2 等价的优化序列。但如果你在做自定义 Pass理解它在流水线里跟谁配合、跟谁冲突就非常重要了。3.3 从 IR 到汇编后端做了什么优化后的 IR 要继续走后端。这一步里最核心的算法是指令选择Instruction Selection、寄存器分配Register Allocation和指令调度Instruction Scheduling。你可以用下面命令看后端生成的汇编llc test.ll -o test.s后端从 IR 到汇编经过了多个阶段。IR 里的虚拟寄存器是无限多个的但物理寄存器只有那么几个寄存器分配算法要把虚拟寄存器映射到物理寄存器上映射不过来的时候就得溢出到栈上。这个过程在 llc 里跑一次就能看到 -print-after-all 的又一次大爆发。这里我必须提醒一句如果你想看后端的 IR 打印记得给 llc 加 --print-before-all 或 --print-after-all 时把标准输出重定向到文件终端直接看会刷屏刷到怀疑人生。我当初第一次跑这个命令的时候输出量把我终端直接干崩了几万行日志在屏幕上飞速滚动。4. 用 LLVM 做代码分析的完整路径llvm-project 不只是编译器还是全世界最强大的程序分析平台之一。这一节我想分享的是怎么用这套基础设施做静态代码扫描、做重构工具、做自定义检查规则。4.1 clang-tidy最容易上手的分析框架clang-tidy 是 llvm-project 里一个基于 Clang 前端、专门做 lint 检查的工具。你以为它只是一个普通的代码检查器它的框架价值在于你可以写一个 Check挂载到 AST抽象语法树遍历的某个节点上实现自己的检查逻辑。举个最简单的例子你想检查代码里所有对 malloc 的调用并在函数没有对应 free 时给出警告。传统做法是写正则表达式匹配源码文本但那样既容易误报又拿不到类型信息和变量作用域。用 clang-tidy 的框架你在 AST 的 CallExpr 节点被访问时检查被调函数名是不是 malloc然后沿着 AST 往上找当前函数有没有对应的 free 调用——这个过程能获得完整的语义信息准确率完全不在一个量级。我自己写过一个团队内部的不安全 API 检查插件落地成本比想象中低很多只需要实现一个继承自 ClangTidyCheck 的类重写 VisitCallExpr 方法然后注册进检查器。整个过程不到一天。4.2 libTooling标准的 AST 操作库如果你要做更复杂的重构比如自动替换 API、重命名符号、修改函数签名clang-tidy 的框架就不够用了。这个时候要上 libTooling。libTooling 是 Clang 提供的一组 C 库让你写一个独立程序来解析源码、遍历 AST、甚至修改源码再写回。Clang 自带的 clang-rename、clang-refactor 本质上都是基于 libTooling 构建的。基于 libTooling 写分析工具的基本套路是先声明一个 FrontendAction然后通过 ASTConsumer 接收 AST 节点再配合 RecursiveASTVisitor 遍历感兴趣的节点类型。这套接口初看有点绕但好处是完整保留了源码位置、类型信息、宏展开等细节改代码的时候能做到精确定位。我经常给团队分享的一个经验是不要一上来就在 libTooling 上写整套框架先用 clang-queryClang 自带的交互式 AST 调试工具摸清目标代码的 AST 结构确认节点类型和层级关系再动手写 C 代码。clang-query 可以让你像调试数据库一样输入 AST 节点的匹配规则看到实际匹配结果效率会高很多。4.3 自定义分析插件如何在真实工程里落地在真实工程里跑自定义分析插件最棘手的问题是编译数据库的获取。Clang 工具需要一个叫 compile_commands.json 的文件里面告诉你每个源文件用什么参数编译。CMake 项目可以通过 CMAKE_EXPORT_COMPILE_COMMANDSON 生成它这算是最常见的方案。拿到 compile_commands.json 后你就可以跑自己的分析工具扫描整个项目了。但这里还有个大坑大型项目的源码用的是同一套编译参数不代表每个文件都能独立解析。比如有些文件依赖 PCH预编译头有些文件有特殊宏定义分析工具可能在这些文件上报错。我在真实项目里就遇到过很多次这种问题处理方案通常是给 clang-tidy 加 --header-filter 过滤掉不关心的系统头文件把精力集中在自己的业务代码上。5. LLVM 带来的工程红利与隐形成本讲了这么多架构和分析回到工程落地。LLVM 生态不是空中楼阁它在真实开发里最直接的三个红利是 LLD 的高效链接、sanitizer 的精准调试、以及 fuzzing 生态的带动作用。但红利背后也有代价这一节说点实在的。5.1 LLD链接慢的终极解药用过一段时间 Clang 的人几乎都会被 LLD 的链接速度震撼。对于同样的大型 C 项目用系统默认的 GNU ld 链接可能要几分钟换成 lld 链接往往几秒到十几秒就完成了。这个加速的原理并不神秘lld 采用并行处理和高效的符号表算法把链接过程拆分成多个可以并行的阶段。对长期迭代的大型项目来说链接耗时直接影响开发节奏。链接从三分钟变成三秒意味着每天的集成、测试、重构效率完全不一样。体验 LLD 很简单编译参数里加一句clang -fuse-ldlld main.cpp -o main但如果你的系统里没有 lld先装一个 llvm 工具链包就行。链接器出错时的报错信息风格和 GNU ld 也不太一样第一次用的时候会有点不适应不过适应之后上下文信息其实更丰富。5.2 sanitizer把内存错误变成立刻可见的报告compiler-rt 提供了 AddressSanitizerASan和 UndefinedBehaviorSanitizerUBSan这两样东西简直是 C/C 开发者的救星。编译时加上 -fsanitizeaddress,undefined程序里的越界访问、释放后使用、整数溢出等问题会第一时间被拦截并打印详细的调用栈。ASan 的原理是在每次内存访问前后插入检查代码同时维护一份影子内存记录每个地址的分配状态。因此它会导致程序体积和运行时间显著上升一般在测试阶段使用不可能上生产。我用 ASan 抓到过很多线上 bug 的根源比如一个看起来无害的 memcpy 把数据写到了缓冲区边界之外导致后续某个变量被悄悄篡改。这类 bug 不用 ASan 之前可能要排查几天用了之后一个调用栈就解决了。5.3 隐形成本编译时间和磁盘占用说了这么多好处也得给 llvm-project 泼点冷水。它最大的短板是编译自己太慢了。即便用 Release 模式、指定目标架构、用 Ninja 并行构建在一台主流配置的电脑上第一次构建 llvm-project 也差不多要半小时到一小时debug 模式下更慢。磁盘占用方面构建产物轻松超过 10GB 甚至更多。这带来一个现实问题很多人想学习和研究 LLVM结果把时间全耗在等编译上了。我的建议是日常使用直接用发行版自带的 clang没必要从源码构建。真正需要源码构建的场景是你要修改 LLVM 本身或者需要最新特性或者要精确控制目标架构和组件。学习 IR 和 Pass 时可以用 llvm 的 api 写小工具拉源码看头文件即可不一定要完整构建。6. 实践中的几个典型坑点每个深入 LLVM 的人都有一段被坑的经历我也不例外。挑四个最常见的坑提前写在前面帮你省掉几周的排查时间。6.1 版本混乱带来的“迷之报错”llvm-project 的演进速度很快API 变化也频繁。你用系统自带的 clang 17 写了一个 Pass拿到另一台机器上用 clang 16 编译报了一堆莫名其妙的错误大概率就是 API 不兼容。不同版本的 LLVM 对 Pass 的接口要求非常不同。以前写一个 Pass 需要继承 FunctionPass 类后来新 Pass 管理器推广后需要继承 PassInfoMixin 并实现 run 方法。如果你在网上搜教程发现代码和手头版本对不上不要怀疑是自己能力问题八成是版本差异。应对方法只有一个弄清楚自己用的 LLVM 版本去对应版本的源码目录下找官方示例。llvm-project 里有 llvm/examples/ 这个目录里面就是当前版本的范例。看官方示例永远是最贴近版本实际情况的。6.2 构建时没有启用断言很多人在学习阶段会在 CMake 配置里用 Release 模式构建 LLVM因为全职构建更快。但其实 Release 模式默认会关闭 LLVM_ENABLE_ASSERTIONS很多 IR 不被验证导致你在写 Pass 时出现的错误被悄悄掩盖或者以极其难懂的信号——比如段错误和非法指令——暴露出来。在研究阶段我强烈建议你使用-DCMAKE_BUILD_TYPEDebug -DLLVM_ENABLE_ASSERTIONSON这样当你生成的 IR 不符合规范时LLVM 会直接掷出很有诊断信息的 assert 失败而不是在一个奇怪的角落里崩溃。等你真正进入性能优化阶段再切回 Release。6.3 修改了 TableGen 描述但没重新构建TableGen 生成代码有个特点你改了 .td 文件如果构建系统没有正确识别依赖关系可能不会触发重新生成。特别是当你手动把生成的 .inc 文件拷到别处或者构建目录比较混乱时很容易出现“改了指令描述但汇编器毫无反应”的诡异状况。处理办法是改完 .td 文件后先确认构建系统是否把对应头文件纳入了依赖。不确定的时候可以直接删除生成的 .inc 文件再重新构建让 TableGen 从干净状态重新生成。6.4 优化级别不同导致的行为差异同一段代码O0 和 O2 的行为可能完全不一样。O0 基本不做优化变量都在栈上代码通俗易懂O2 会引入大量优化比如未定义行为UB相关的代码可能在 O2 下被完全删除。最有名的就是 C 里越界访问或整数溢出的代码开启优化后编译器可能利用 UB 做出“合理”但出人意料的行为。排查 bug 时如果问题只在特定优化级别下出现先追问一句是不是有 UB用 UBSan 跑一遍常常能快速锁定。编程时也要有意识地避免写依赖编译顺序的别扭代码。7. 给不同基础读者的实践路线最后聊一聊学习路径。llvm-project 的源码量极其庞大随便乱逛很快就会迷路。根据你的基础和目标不同路径差异非常大我这里给几条清晰的路线。7.1 如果你是 C/C 工程师想打磨编译知识建议不要一开始就读源码先用工具。用 clang -S -emit-llvm 看自己的代码生成了什么样的 IR。用 opt -passes... 尝试单独跑某个 Pass观察 IR 前后的变化。用 clang-tidy 写一个最简单的检查器感受一下 AST 分析的门槛。这套流程走下来你对“编译器到底对我的代码做了什么”会有全新的认识。等工具用得顺手了再回头读 IR 类定义和 Pipeline 相关的源码就容易得多。7.2 如果你想做一门新语言这是 LLVM 最闪光的应用场景之一。最快捷的路径是学习 LLVM 的 C API 或 C 的 IRBuilder直接手写代码生成 LLVM IR。官方教程中有一篇“Kaleidoscope”教程从零开始实现一门包含变量、函数、控制流的语言并生成可执行代码。这本书虽然语言风格偏老但核心知识完全不过时。学完 Kaleidoscope 之后你至少会明白怎么把 AST 翻译成 IR、怎么调用优化和 JIT。再往后可以看看官方给的 llvm/examples/ 里的其他示例。7.3 如果你想深入 LLVM 本身的源码方向就明确了先读 Pass 的接口定义再用 llvm/examples/ 写独立 Pass 练手接着用 lldb 在调试器里跟踪一个简单函数从 IR 到机器码的完整编译过程。这个过程最能建立对编译器后端的空间感。调试 LLVM 自身时记得给 llc 或 opt 下断点用单步调试观察一个 IR 指令是怎么被 SelectionDAG 逐步降低成机器指令的。说实话这一步不看调试器很难理解清楚。纸上谈兵和实际看到指令选择表怎么匹配模式体验差距是天壤之别。8. 写在最后的一点实际体会跟 llvm-project 打了这么多年交道我最想对初学者说的一句话是别被它的代码规模吓退。庞大的仓库里绝大多数代码你不需要读你只需要找到那条属于你目标的路径然后沿着它往前走。我个人最大的收获不是背会了几个 Pass 的名字而是通过这套源码建立起了一种思维方式把一个复杂系统拆成清晰的阶段每阶段之间用稳定的接口通信。这种分层思维、抽象思维影响了我后来设计所有大型软件工程的方式。如果你正在学习或研究 LLVM给你一个我最直接的实用建议把 clang 的 -S -emit-llvm、opt 的 -print-after-all、llc 的 --print-after-all 这几个工具的日志输出用好它们就是编译器内部的可视化窗口。遇到看不懂的概念就找一段小代码跑一下、对比一下。实际看一下 IR 如何随 Pass 变化比任何文档都管用。愿你在 llvm-project 的世界里找到属于你自己的那条路。