ARTICLE DETAIL

资讯详情

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

LLVM源码级解析:架构原理、Pass编写与工具链实战

LLVM源码级解析:架构原理、Pass编写与工具链实战 如果只让我推荐一个值得源码级阅读、也值得日常拿来当工具用的开源项目我的答案里一定有 LLVM。这个项目全名就是 llvm-project托管在 GitHub 上是编译器领域绕不开的基础设施。Clang、LLD、libc、compiler-rt 这些在各自领域叫得上名字的工具全都出自这个仓库。更直白一点说你现在用的 Android 底层编译器、苹果生态的 Xcode 工具链、还有越来越多云厂商在用的 GPU 编译方案底层都离不开它。这篇文章我打算把 llvm-project 从头拆一遍不仅讲它是什么、能干什么还会讲它的设计为什么这么牛以及一个完全没有接触过 LLVM 的人该怎么上手、怎么自己写一个 Pass 跑起来。适合的人群很明确写过 C/C、对编译原理和工具链感兴趣、或者想加入编译器方向的开发者。内容会偏实操但原理部分我也会尽量讲透看完你能自己动手。1. 项目概述llvm-project 到底是个什么东西1.1 从一个“虚拟指令集”开始很多人第一次听到“LLVM”第一反应是“又一个编译器”。确实LLVM 最初的名字是 Low Level Virtual Machine也就是“底层虚拟机”但今天它已经远不止是一个虚拟机了而是一个模块化的编译器基础设施。核心思路其实可以用一句话概括LLVM 不直接面向某个具体的 CPU而是定义了一套“中间表示”也就是 LLVM IR。所有的语言前端比如 C/C 的 Clang、Rust 的 rustc、Swift 的 swiftc都先把源代码翻译成这套 IR然后后端再根据不同的硬件平台把 IR 翻译成对应的机器码。中间的所有优化工作都在这套 IR 上完成。这么做的好处非常明显。以前编译器都是“前端 后端”捆绑在一起换一个目标平台基本等于重写一整套编译器。但 LLVM 把前后端彻底拆开前端只需要负责把语言翻译成 IR后端只需要负责把 IR 翻译成机器码中间的优化逻辑大家共用。这就是为什么 Rust 语言出现的时候只需要复用 LLVM 的后端就能在短短几年内支持 Windows、macOS、Linux、WebAssembly 等一大堆平台。很多人问过我同样的问题既然 GCC 也支持这么多种语言和平台为什么 LLVM 还能后来居上我的理解是GCC 的架构虽然也在模块化但它的核心设计是围绕着“树”和“GIMPLE”展开的插件机制和 IR 的稳定性都不如 LLVM。LLVM 的 IR 设计得非常规整有清晰的文本形式、二进制形式还能在内存中被各种 pass 随意读取和修改。这种开放程度让学术界和工业界都愿意在它上面做二次开发。1.2 仓库里都有哪些“成员”llvm-project 本身不是一个单一的程序而是一个大杂烩仓库。我第一次 clone 的时候也被它的体积吓了一跳完整拉下来十几个 GB 都是正常的。仓库里最主要的成员包括LLVM 核心库包含 IR 的定义、优化 pass、目标后端X86、ARM、RISC-V 等、各种代码生成工具。ClangC/C/Objective-C 的前端也是最知名的 LLVM 子项目。LLD一个高性能的链接器号称比 GNU ld 快几倍。libc / libcabiC 标准库的实现也是 LLVM 项目的官方 C 标准库。compiler-rt提供一些运行时支持库比如 AddressSanitizer、UndefinedBehaviorSanitizer 这些动态分析工具的运行时。Polly一个基于多面体模型的循环优化器做 CPU 和 GPU 的自动并行化。OpenMP / libunwind并行编程的支持库和栈回溯库。MLIR一个可扩展的编译器中间表示框架现在也是 LLVM 社区里最火的子项目之一主要应用在机器学习和异构计算领域。flangFortran 前端。这么多东西不是随随便便堆在同一个仓库里的它们都围绕同一个核心LLVM IR。你可以把 IR 理解成一个“公共语言”仓库里所有工具说的都是这一种语言所以它们彼此之间可以无缝协作。1.3 它到底解决了什么痛点在 LLVM 出现之前编译器开发的痛点有两个一是重复造轮子每做一个新语言或新平台基本都要从头写整套工具链二是优化和代码生成的代码复用率太低GCC 的中间表示是为 C 语言设计的换一种语言就显得很别扭。LLVM 的解法是把所有语言都“压平”到同一层 IR。不管你是 Rust 还是 Swift最终在优化器眼里都是一棵棵函数调用图和数据流图。优化器不需要关心你的代码是来自哪个语言它只需要处理 IR 的通用规则就行。举个具体的例子你写了一个 C 函数里面有std::vector的操作经过 Clang 前端之后这些高层语义会被一层一层拆解最终变成一批简单的 load、store、add、call 指令。这些指令没有任何 C 的味道完全就是一份“中间码”。然后优化器在这份中间码上做内联、常量传播、死代码消除、循环展开最后才交给后端生成真正的 x86 或者 ARM 指令。这种分层设计还带来了一个直接收益调试体验好。LLVM IR 有可读的文本格式你可以直接把中间结果 dump 出来看这对编译器开发者来说简直是救命稻草。我在调试自己写的 Pass 时最常用的动作就是llvm-dis看一眼 IR 长什么样问题往往一眼就能看出来。2. 架构解析为什么 LLVM 的设计能打这么多年2.1 三层架构前端、优化器、后端LLVM 的整体架构分三层每一层都有清晰的职责。前端负责把源代码转换成 IR。Clang 是这里面最典型的前端它做的事情包括词法分析、语法分析、语义分析、生成抽象语法树最后生成 LLVM IR。很多人以为 Clang 只是“翻译”代码其实它同时还承担了绝大部分编译报错和警告的生成工作。Clang 的报错信息质量在业界是公认的标杆它会告诉你哪一行哪一列出错还会在终端里用不同颜色的字符标出来甚至给出修复建议这点用过的人应该都有同感。优化器是 LLVM 的核心也叫 middle end。它接收 IR做一系列优化再输出优化后的 IR。这里面跑的就是一个接一个的 pass。每个 pass 负责一种特定优化有的做常数折叠有的做循环展开有的做函数内联。这些 pass 串联成一个流水线每一轮优化都会让 IR 变得更“好”这里的“好”指的是执行效率更高、代码体积更小或者功耗更低。后端也叫 backend负责把优化后的 IR 转换成目标机器码。这一层要处理指令选择、寄存器分配、指令调度这些非常底层的问题。不同 CPU 的指令集差异巨大x86 有各种奇怪的寻址模式ARM 有条件执行和 Thumb 模式RISC-V 是精简的定长指令。LLVM 通过一套 TableGen 描述语言来定义各个平台的指令集规则后端的大部分代码都是通过 TableGen 自动生成的。2.2 IR 为什么是核心中的核心LLVM IR 的设计非常讲究。它既有高层语言的表达力又能被底层代码生成器容易地消化。IR 的三种形式很值得记住内存中的表示、磁盘上的 bitcode、可读的文本格式。你写一个.ll文件看到的是一串类似汇编的指令用llvm-as可以把.ll打包成.bc二进制在程序运行过程中IR 是以 C 对象的形式存在内存里的。三种形式可以互相转换这给调试和分发都提供了极大的方便。IR 最核心的概念是“静态单赋值”简称 SSA。简单说每个变量只能被赋值一次。比如x a b之后你不能在别的地方再给x赋值只能新定义一个新的变量。这种设计看似绕实际好处极大数据流分析变得非常简单编译器想知道某个变量的值是从哪条路径来的直接看它的定义就行不用像老式编译器那样做复杂的到达定值分析。IR 本身也被设计成“类型化”的。每条指令都要明确标签和类型比如add i32 %a, %b就表示两个 32 位整数相加。类型信息帮助优化器做出准确的推断比如知道两个指针指向的内存区域不会重叠就可以大胆做重排序。我还记得第一次用llvm-dis查看一个简单 C 函数对应的 IR 时那种“原来编译器是这么看我的代码”的震撼感。它把所有人类语言的糖衣都剥掉直接露出最赤裸的计算逻辑。如果你没试过我强烈建议找一个简单函数看一眼。2.3 Pass 机制优化器的大脑Pass 是 LLVM 优化器里最主要的工作单元翻译过来就是“一趟遍历”或“一遍处理”。每个 pass 可能会遍历 IR 的全部指令也可能只处理部分模块目的只有一个让 IR 变得更好。Pass 分几种函数 pass 每次处理一个函数模块 pass 一次处理整个模块循环 pass 专门处理循环结构。不同的 pass 有不同的权限范围和控制粒度这种设计让开发者写优化的时候可以专注于自己的那部分逻辑不用关心其他东西。想要新增一个优化只需要写一个新的 pass然后把它插到流水线的某个位置就行完全不需要改动其他 pass 的代码。这里面有一个非常有名的 pass叫-O2优化级别对应的标准 pass 流水线。这个流水线由几十个 pass 组成按固定顺序执行每个 pass 都在前一个 pass 的基础上做进一步优化。比如先做函数内联再做死代码消除再做循环展开再做寄存器分配前的最后清理。Pass 的顺序是有讲究的顺序不对优化效果可能会大打折扣。现在 LLVM 主推的是 New Pass Manager也就是新的 pass 管理器。旧的 Pass Manager 在并发和缓存方面有很多限制新的管理器支持 pass 结果缓存还能在多线程环境下分析多个函数性能提升非常明显。我第一次在自研编译器里切换到新 PM 之后编译时间直接下降了差不多 30%属实是个大版本的质变。3. 子项目全景每个模块各自扮演什么角色3.1 Clang不只是 C/C 编译器Clang 可能是 llvm-project 里最“出圈”的一个项目了。它对 C/C 标准的支持速度非常快对 C17、C20、C23 的新特性往往在标准发布后一年内就能拿出可用的实现。更重要的是Clang 提供了一套极其完善的库接口你可以在自己写的工具里调用 Clang 的词法分析器、语法分析器、AST 生成器做代码分析、代码补全、甚至是代码改写。大名鼎鼎的 clang-format 和 clang-tidy 就是基于 Clang 的库做出来的。clang-format 负责自动格式化代码clang-tidy 负责静态分析找出代码里的隐患。我在公司里推广过 clang-tidy 的 check最常用的几个如bugprone-*、performance-*、modernize-*确实能发现不少普通 review 里不容易看出来的问题。Clang 还有一个很重要的特性它支持“插件式开发”。你可以写一个 Clang plugin在编译的某个阶段插入自己的逻辑。这个能力让 IDE 工具比如 clangd可以做到非常精确的语法高亮、跳转定义、查找引用。我现在写 C 的时候几乎离不开 clangd补全速度和定位准确率都远超传统的 ctags 方案。3.2 LLD链接速度和现代设计的结合LLD 是 LLVM 的链接器也是我比较早用起来顺手得不得了的工具。链接器的主要工作是把编译器生成的多个目标文件合并成一个可执行文件处理符号解析、重定位、段布局这些杂活。传统的 GNU ld 在处理大型 C 项目时经常慢得让人抓狂几十秒到几分钟的链接时间都很正常。LLD 在刚开始推广的时候就主打“快”实测下来比 GNU ld 快 3 到 4 倍很常见。大项目的链接时间从一分钟降到十几秒那种体验提升不是一星半点。LLD 的设计思路是尽量并行化。GNU ld 早期版本是单线程的处理一个符号表就占住了整个链接过程。LLD 把符号解析、重定位、段合并这些阶段都做了并行化还用了高效的数据结构来加速符号查找所以才能做到那么快。我现在用 Clang 编译项目时都会加上-fuse-ldlld链接一下就能体会到差距。3.3 libc、compiler-rt 与日常工具链libc 是 LLVM 官方的 C 标准库实现。和 GCC 的 libstdc 相比libc 的代码结构更清晰对标准的最新演进跟进也快。不过在实际使用中它最大的存在感可能是和 libstdc 的 ABI 兼容问题。如果你用 Clang 编译代码但链接的是 libstdc有时会遇到类型或符号不匹配的诡异问题。所以如果项目能用 libc我会建议保持一致前端和后端都切换到 LLVM 的整套方案。compiler-rt 是一个很容易被忽视但很重要的库。它包含了很多运行时支持代码比如编译器内建函数的实现、sanitizer 的运行时、profile 工具的支持。你写代码时用到的__asan_report_*这类符号其实都是 compiler-rt 提供的。AddressSanitizer简称 ASan、UndefinedBehaviorSanitizer简称 UBSan现在已经是 C/C 开发者排查问题的高频工具了它们在 llvm-project 里都有对应的实现。另外日常工作中我还会高频用到这些工具llvm-objdump看反汇编llvm-nm看符号表llvm-size看各段大小llvm-addr2line做地址到源码行的转换。它们都是 llvm-project 提供的小工具但在逆向、性能分析、崩溃排查场景里非常有价值。4. 实操从零构建 LLVM 工具链4.1 源码获取与目录结构动手之前先拿到源码。一般我建议直接 clone 官方仓库的稳定分支比如 release/18.x。完整的仓库很大如果只想获取某个版本可以加上--depth 1做浅克隆。我第一次是直接 clone 默认分支结果发现切分支要重新拉一大堆对象后来就学乖了。获取源码后第一件事是熟悉目录结构。源码根目录下有一个llvm目录这是核心代码所在。clang目录是 C 家族前端lld目录是链接器libcxx和libcxxabi是标准库实现compiler-rt是运行时mlir是机器学习中间表示框架。目录之间的依赖关系是通过 CMake 的LLVM_ENABLE_PROJECTS或者LLVM_ENABLE_RUNTIMES来配置的。比如你想构建 Clang 和 LLD就在 cmake 的时候传这两个项目的名字。不传的话默认只会构建 LLVM 核心库和工具。构建 LLVM 是我见过最能“教育”人的编译过程它对机器配置的要求极高内存不够直接编译到一半报错磁盘空间不够也会在中途突然红字退出。4.2 CMake 配置的黄金参数LLVM 使用 CMake 构建但它的参数极其多。作为一个有几年 LLVM 构建经验的人我把自己常用的一套模板给出来cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;ARM;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON \ -DBUILD_SHARED_LIBSON \ ../llvm这里解释一下几个关键参数CMAKE_BUILD_TYPERelease如果用 Debug编译出来的二进制体积巨大、速度也慢而且构建时间会翻倍。日常开发验证用 Release 足够。LLVM_ENABLE_PROJECTS指定要额外构建哪些上层项目。多语言之间用分号隔开。LLVM_TARGETS_TO_BUILD指定要生成哪些后端。如果只需要 X86 平台只填 X86 就能省下大量构建时间。LLVM_ENABLE_ASSERTIONSON在 Release 模式下也开启断言这对调试自己的 Pass 非常重要。如果不开很多 IR 合法性检查会被跳过写 Pass 的时候容易翻车。BUILD_SHARED_LIBSON把 LLVM 的库编译成动态库。这样做的好处是链接快、磁盘占用分散缺点是运行速度会比静态库略慢。我第一次没开这个选项链接一个测试程序动辄几分钟换成 shared 之后整个世界都安静了。构建命令就是经典的ninja -j$(nproc)如果机器配置不够或者想更快验证我建议只构建你需要的 target比如ninja clang lld而不是整个ninja一把梭。整个 LLVM 全量编译在性能好的服务器上也要十几分钟笔记本上直接奔着一小时去。4.3 构建中常见的坑构建 LLVM 的过程中我第一次就踩了不少坑这里挑几个最关键的说一下内存不足。LLVM 编译时需要大量内存最典型的是链接clang可执行文件时可能瞬间需要 4GB 以上的内存。如果是虚拟机或者低配机器很容易在链接阶段被杀进程。建议至少准备 8GB 内存物理内存不够可以先开 swap。磁盘空间。Release 模式全量构建很容易超过 30GB 磁盘占用。我当时在只有 40GB 空闲的机器上构建中途频繁报No space left on device最后只能删掉 build 目录重新配参数只保留需要的项目。动态库 vs 静态库。不开BUILD_SHARED_LIBSON的话最终生成的可执行文件会很大而且每次改一行代码重链接一个测试工具都要很久。开了共享库之后构建速度会有质变。这个选项对新手尤其友好。Python 和 zlib 依赖。LLVM 的构建系统强依赖 Python 3还要求系统里有 zlib 开发包。在干净的 Ubuntu 机器上记得先装好python3-dev、zlib1g-dev否则 CMake 配置阶段就会报错。4.4 验证工具链是否可用构建完成之后验证一下工具链是否正常工作。先查看版本信息build/bin/clang --version build/bin/ld.lld --version然后写一个简单的 C 程序测试编译和链接// hello.c #include stdio.h int main(void) { printf(Hello, LLVM!\n); return 0; }用我们刚构建出来的 Clang 编译build/bin/clang -fuse-ldlld -o hello hello.c ./hello能正确输出Hello, LLVM!就说明整套工具链已经可用了。我还建议顺手看一下 IR 长什么样build/bin/clang -S -emit-llvm hello.c -o hello.ll打开 hello.ll 看一下里面全是main、%call这样的符号会有一种第一次看到编译器内部的神秘感。5. 上手指南自己写一个 LLVM Pass5.1 Pass 的骨架长什么样学会了构建下一步自然是动手写 Pass。这是深入理解 LLVM 优化机制最快的方式。LLVM 现在的 Pass 推荐用 New Pass Manager 的写法代码比旧版简洁很多。下面是一个最简单的函数级 Pass 例子它的作用只是打印每个函数的名字#include llvm/IR/Function.h #include llvm/IR/LegacyPassManager.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 HelloPass : public PassInfoMixinHelloPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Hello from: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getHelloPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, HelloPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-pass) { FPM.addPass(HelloPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getHelloPassPluginInfo(); }这段代码看着不长但有几个关键点值得说道说道run方法接收一个Function对象和一个FunctionAnalysisManager引用。每个 pass 的核心逻辑都在run里。返回值为PreservedAnalyses告诉优化器这个 pass 有没有改变 IR。如果你只是打印信息没有修改任何东西就返回PreservedAnalyses::all()。如果你修改了指令就要返回PreservedAnalyses::none()让后续的分析缓存失效。PassPluginLibraryInfo和llvmGetPassPluginInfo是给 Pass 插件的加载机制用的。编译成动态库之后opt工具可以自动加载这个插件并通过命令行参数调用你注册的 pass。5.2 编译插件并在 opt 中运行写好代码之后用 CMake 组织编译。假设插件源码叫HelloPass.cpp对应的CMakeLists.txt如下cmake_minimum_required(VERSION 3.20) project(HelloPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) message(STATUS Using LLVMConfig.cmake in: ${LLVM_DIR}) include_directories(${LLVM_INCLUDE_DIRS}) separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS}) add_definitions(${LLVM_DEFINITIONS_LIST}) add_library(HelloPass MODULE HelloPass.cpp) target_link_libraries(HelloPass PRIVATE LLVM)然后用 cmake 编译这个插件。编译完成后会生成一个libHelloPass.so这就是我们要的插件库。接下来找一个测试文件先生成 IRclang -S -emit-llvm test.c -o test.ll然后运行 opt加载我们的插件并执行 passopt -load-pass-plugin./libHelloPass.so -passeshello-pass test.ll如果一切正常你会看到每个函数的函数名都被打印出来。这意味着你刚才写的代码真的在 LLVM 的优化流水线里跑起来了。第一次成功的感觉是很奇妙的就像是在一个庞大的发动机里装上了你自己设计的一个小齿轮。5.3 从打印到真正“干活”打印函数的 Pass 只是热身。要真正理解 Pass 的能力边界我建议尝试写一个“做实事”的 Pass比如做简单的常量传播。思路是这样遍历函数里的所有指令如果某条指令是add i32、sub i32、mul i32这类算术运算而且它的两个操作数都是ConstantInt那我们就可以在编译期计算出结果然后用新的常量替换掉这个运算指令。这个优化看似简单但它涉及 Instruction 遍历、Value 的使用替换、IR 的合法性更新、分析缓存的标记等核心操作。写一遍之后你对 LLVM IR 的“面向对象”气质会理解得更加到位。还有一个我特别推荐练习的方向是在 Pass 里调用其他分析结果比如LoopAnalysis遍历函数里所有的循环并打印循环层数。这个练习能帮你理解“分析 pass”和“变换 pass”的区别分析 pass 不改 IR变换 pass 改 IR 并让相关分析失效。5.4 调试 Pass 的独门技巧写 Pass 难免出错而且这类错误经常不像普通程序那样直接崩溃而是编译出来的目标程序行为诡异。我排错时有一套自己的方法。首先善用-print-after-all和-print-before-all。这两个 opt 参数会在每个 pass 执行前后打印 IR。如果你的 pass 把 IR 弄坏了这个输出能帮你一眼定位到是哪一个 pass 出的问题精确到哪一条指令被改错了。其次用-debug-onlyxxx可以开启某个 pass 内部的 debug 输出。如果代码里用了LLVM_DEBUG(dbgs() ...)就能按需看到调试信息不用自己在源码里加errs()。再来断点调试。插件是动态库用 LLDB 或者 GDB 都是可以断点跟进的只是在调试.so时需要设置breakpoint set -s libHelloPass.so -n run之类的命令略微麻烦一点。不过一旦断进去Stack Trace 就会非常清晰。最后验证 IR 合法性。LLVM 有一个工具叫verify-uselistorder还有一个方式是在编译器里开启-verify选项它会在每个 pass 之后自动检查 IR 合法性。如果你改了 IR 却没有正确更新用户列表这个检查会直接报错帮你省去很多排查时间。6. 实战应用把 LLVM 用在自己的项目里6.1 日常编译优化组合LLVM 的工具链不只是给编译器开发者的普通 C/C 项目也能直接受益。Clang 自带的优化能力加上 LLD 的链接速度我自己的 C 项目基本默认就是这套组合。举一个实际案例我曾经把一个基于 CMake 的 C 项目从 GCC 切换到了 Clang并且把链接器从默认的/usr/bin/ld换成了lld编译配置大概是这样cmake -DCMAKE_C_COMPILERclang -DCMAKE_CXX_COMPILERclang \ -DCMAKE_EXE_LINKER_FLAGS-fuse-ldlld ..没有改任何代码整体编译时间大约少了 20%原因是 Clang 的前端速度和 LLD 的链接速度都比 GCC GNU ld 快不少。这还不算完如果用-flto开启链接时优化Link Time Optimization还能进一步跨编译单元做优化让不同 .cpp 文件之间也能互相内联。链接时优化的原理是把所有目标文件都编译成 LLVM bitcode然后在链接阶段再做一次全程序的 IR 级优化。这个功能 GCC 也有但 Clang LLD 的 LTO 实现兼容性和稳定性好很多。不过需要提醒的是LTO 会让链接时间变长并且对符号可见性和初始化顺序有要求项目比较复杂的话第一次切可能遇到一些坑。6.2 用 Sanitizer 抓到隐藏 BugLLVM 工具链里我最喜欢的排查工具之一是 Sanitizer它默认集成在 Clang 里开一个编译选项就能用。最常用的是 AddressSanitizerASan和 UndefinedBehaviorSanitizerUBSan。ASan 能检测内存泄漏、堆栈缓冲区溢出、堆缓冲区溢出、use-after-free 这类内存错误。使用方法是在编译时加上-fsanitizeaddressclang -fsanitizeaddress -g -o test test.cpp ./test一旦程序有内存访问越界ASan 会直接打印出详细的错误报告包括出错的地址、前后的堆栈、是读还是写。相比起用 valgrind 跑一遍慢吞吞的动态分析ASan 要快得多而且集成在编译器里开发环境随时可以开。UBSan 更轻量专门检测整数溢出、除零、无效的移位、空指针运算这类未定义行为。用法是clang -fsanitizeundefined -g -o test test.cpp我自己的习惯是本地开发和 CI 同时开 ASan 和 UBSan代码里很多平时测不出来的隐藏问题都会被揪出来。尤其是那些“偶尔崩溃但怎么也复现不了”的疑难问题第一次开 UBSan 就能查出一堆整数溢出和移位越界。6.3 用 clang-tidy 给代码做“体检”clang-tidy 是我们团队代码 review 之前必须过的一道检查。它的强大之处在于它不只是靠正则匹配而是基于 Clang 的 AST 做语义分析能识别出非常精细的问题模式。举个例子performance-unnecessary-copy-initialization这个 check 能找出那些可以被移动构造却用了拷贝构造的变量bugprone-narrowing-conversions能找出隐式窄化转换这对写出合规代码非常有帮助modernize-use-auto建议把冗长的类型名替换成auto。这些 check 用下来代码质量提升得很明显。配置上也不麻烦项目根目录放一个.clang-tidy文件里面写上开启哪些 checkChecks: clang-analyzer-*,bugprone-*,performance-*,modernize-*然后命令行跑clang-tidy test.cpp -checks-*,bugprone-* -- -stdc17clang-tidy 还有一个厉害的应用场景是“自动修复”。对于很多 check它都支持--fix选项能让工具自动改写代码把明显的问题直接修好。这样代码 review 的时间可以省下一大半我们只需要聚焦在逻辑正确性和架构合理性上。6.4 clangd 与 IDE 体验另外一个 LLVM 生态里对我日常影响巨大的工具是 clangd。它基于 Clang 的库实现了一个 Language Server。我的 VSCode 配了 clangd 插件后C 的补全、跳转、改写提示都变得非常灵敏。clangd 能正常工作需要一份编译数据库也就是compile_commands.json。CMake 生成这个文件很方便cmake -DCMAKE_EXPORT_COMPILE_COMMANDSON ..生成之后可以把compile_commands.json软链接到项目根目录。clangd 会自动读取这个文件知道你每个文件用了哪些编译参数、启用了哪个 C 标准版本这样代码分析才会准确。说实话没用 clangd 之前我用 ctags 跳转的时候经常跳错文件查一个符号来回折腾半天。切到 clangd 之后整个代码浏览体验是从“手动挡”直接跳到“自动挡”的级别。哪怕你平时不做编译器开发只要经常写 C这个工具都值得配起来。7. 避坑指南我把常见问题和排查方法整理成了表7.1 构建阶段问题速查实践过程中积累了一批问题和对应的解决方案我整理成表格供参考问题现象可能原因解决办法CMake 配置时报找不到 Python缺少 python3-dev安装 python3-dev并确认 python3 在 PATH 中链接clang可执行文件时 OOMDebug 模式或内存不足切换 Release 模式或加深思。增加 swap 空间Ninja构建时文件写入失败磁盘空间不足清理旧 build 目录或把 build 放到大分区运行clang提示缺少共享库依赖了特定版本的 zlib/libxml2安装对应开发包或者用LD_LIBRARY_PATH指向依赖目录构建时间过长开启了太多 Target 和项目只保留需要的LLVM_TARGETS_TO_BUILD和LLVM_ENABLE_PROJECTS编译错误提示找不到头文件LLVM_INCLUDE_DIRS没有配置用find_package(LLVM)后确认 include 路径正确第一次构建时间很长是非常正常的不是你的机器有问题而是 LLVM 本身的代码量就是那么庞大。我常用的办法是先只跑ninja llvm-tblgen clang来做最小验证确定配置没问题了再全量构建。7.2 编写 Pass 时的典型坑写 Pass 比构建工具链更容易遇到隐蔽的坑这里挑几个高频问题忘记更新PreservedAnalyses。如果你在run里改了 IR却仍然返回PreservedAnalyses::all()后续 pass 的分析结果就是过期的可能导致错误代码生成。建议对做了修改的 pass 返回PreservedAnalyses::none()或者用getLoopUP()...精准标记哪些分析失效。使用旧版 Pass API。网上很多教程还在用老式FunctionPass和INITIALIZE_PASS宏这些在新版本里虽然还能用但推荐路径已经迁到了 New Pass Manager。建议新手直接学新 API省得之后还要迁移。没有处理指令的 use 关系。在 IR 里替换一个 Value 时不能只改定义者还要把用到它的所有用户都改掉。最稳妥的方式是用Value::replaceAllUsesWith()接口手动遍历 users 列表很容易漏掉某个边角情况。修改 IR 后没有做验证。每次写完 Pass记得用opt -verify-each或编译时加-verify跑一遍系统会检查 IR 合法性。这个习惯能帮你把问题扼杀在萌芽阶段。不理解模块和函数作用域。有些分析只能在模块级别做比如全局变量和函数间的依赖关系有些则只能在函数内做。新写 Pass 时最好确认一下自己注册到哪个 pass manager是 Function 的还是 Module 的搞混了会导致编译错误或者运行时报错。7.3 使用 LLVM 工具链的实践建议工具链本身用起来也有一些值得注意的细节第一优化级别不是越高越好。-O3有时会生成更快的代码但二进制体积大、编译时间长有些激进优化甚至可能引入诡异行为。我们在发布项目前会用-O2作为稳妥选择只在性能关键模块上单独开-O3配合 profile 指导做优化。第二Sanitizer 和优化器会互相影响。开-fsanitizeaddress时建议配合-O1或-O2在复现问题与运行速度之间取得平衡。不开优化时 Sanitizer 可能会报出很多原本不会触发的路径噪音较大。第三Clang 和 GCC 的 ABI 兼容问题。C 项目混用两个编译器编译的不同模块时要小心标准库类和异常处理的兼容性。最好统一工具链或者确认 ABI 版本完全匹配。第四不要忽视 TableGen。如果你想给 LLVM 贡献新后端或修改指令集描述TableGen 是绕不开的。它是 LLVM 用来描述指令集、寄存器、调用约定这些信息领域的 DSL写错了会直接导致生成的代码不对。建议先阅读llvm/docs/TableGen/下的文档再动手改文件。8. 写在最后的体会我最初接触 llvm-project 是想搞清楚编译器优化到底是怎么回事结果一头扎进去就出不来了。它是我见过代码组织最规整的大型 C 项目之一从 IR 到 pass 到后端的每一层都有明确的接口和文档非常适合有系统编程基础的人去精读。就算你暂不打算做编译器开发把 Clang 和 LLD 用在日常项目里节省下来的编译时间也足够值回票价。从学习路径上说我强烈建议“先会用、再会写、后会改”。先在命令行里用 clang、opt、llvm-dis 跑通几个例子然后照着官方教程写一个自己的 pass最后再去读lib/Transforms/里的具体实现。读那些 real-world 的优化代码比你翻十篇博客都要管用。最后分享一个小技巧读 LLVM 源码的时候别直接从头开始看。先挑一个你感兴趣的 pass比如LoopUnrollPass从它的入口函数开始一边看代码一边用opt -passesloop-unroll -print-after-all对照实际编译结果。这种“代码 运行行为”对照式阅读理解效率会非常高。我个人有不少优化思路都是从这种阅读习惯里学到的希望你也能体验到那种“原来编译器是这么思考的”的快感。
返回列表