ARTICLE DETAIL

资讯详情

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

LLVM实战指南:掌握llvm-project核心组件与构建方法

LLVM实战指南:掌握llvm-project核心组件与构建方法 拿到 llvm-project 这个项目标题很多朋友第一反应是“编译器源码离我太远”或者“这是大佬才碰的东西我看看就好”。说实话我第一次面对这个仓库的时候也是这心态后来因为工作需要被迫啃了一周才发现这东西的价值被严重低估了。llvm-project 是 LLVM 官方的统一仓库里面装的不只是那个叫 LLVM 的编译器基础设施还有 Clang、MLIR、LLD、libc 等一系列子项目。你把它们拆开看是若干独立工具合起来看就是一套能支撑你搞语言前端、写静态分析工具、优化后端乃至尝试自研编译器整套流程的完整工具链。这篇文章不打算整那些教科书式的长篇大论就以我自己的实际使用过程为线索聊一聊 llvm-project 这个仓库的结构、核心组件、构建方法以及最常见的坑。对于想入门编译器工具链的开发者或者正在犹豫要不要往这个方向投入时间的工程师希望能提供一个相对务实的参考。内容会比较细偏实际操作适合一边看一边动手。1. llvm-project 到底是个什么项目1.1 一个仓库装下一整套工具链LLVM 项目的代码仓库经历过一次比较关键的组织方式调整。以前是多个仓库分开管理LLVM 主仓库一个Clang 一个libc 又是一个维护和版本对应都很麻烦。后来官方采用了 monorepo 模式把各子项目统一收进 llvm-project 这一个仓库里。这种做法的好处很明显你只需一次 git clone就能拿到所有核心项目而且切换分支、打 tag、做版本校验都方便得多。编译器工具链内部其实有天然的依赖关系Clang 依赖 LLVM 核心库LLD 又依赖后端的某些接口如果不放在同一套仓库里管理版本错位会直接让你怀疑人生。从实际使用角度看monorepo 也降低了环境一致性问题的概率。你不需要手工去拼凑“这个 LLVM 版本要配哪个 Clang 版本”拉到一份代码后用对应的构建脚本和 CMake 配置就能得到一套内部版本号严格匹配的工具链。这一点对做二次开发或者想跟踪上游能力的人来说尤其重要。llvm-project 的仓库体量不小。完整克隆的话算上历史和 LFS 内容下载数据量可以达到几个 GB甚至更多。如果网络条件一般建议只做浅克隆--depth1只把最新代码拉下来能省掉大量等下载的时间。我第一次没经验直接完整克隆结果在办公室网络下硬是等到了第二天。后来学乖了都是浅克隆加按需展开。1.2 核心子项目的版图llvm-project 仓库里一眼望去目录很多但真正决定生态版图的核心项目就是那么几个。LLVM 本体是基础底座它提供的是优化器、代码生成、指令选择、寄存器分配等一整套中间层能力。你可以把它看作一个“编译器后端功能库”本身不直接产出可执行文件但所有工具链里和机器码打交道的事情都有它的份。LLVM 的设计核心是那个静态单赋值形式的中间表示也就是 LLVM IR。绝大多数优化在 IR 上进行和具体 CPU 架构解耦这也是它可以同时支持 X86、ARM、RISC-V 这么多后端的底气所在。Clang 是 LLVM 官方的 C/C/Objective-C 前端。它的职责是把高级语言翻译成 LLVM IR顺带负责语法检查、语义分析、警告信息等。Clang 的架构清晰错误提示质量高比某些传统编译器友好得多所以这些年越来越多的项目开始把默认编译器切到 Clang。MLIR 是相对年轻但关注度极高的子项目。它的定位是“多层级中间表示框架”专门用来处理复杂编译场景下的多层 IR 设计问题。深度学习编译器、芯片厂商的自研编译器、特定领域语言编译器都有用它做骨架的案例。MLIR 的设计思想是把层级化的 IR 建模能力开放出来让开发者能定义自己需要的方言和优化 pass。LLD 是个链接器设计目标是快非常快。相同规模的项目LLD 在链接阶段通常比传统 GNU ld 要快几倍甚至更多特别是项目较大时链接速度的提升非常明显。libc 是 C 标准库实现配合 Clang 用能获得比较一致的 ABI 体验不过在 Linux 上用 GCC 的那些老工程要想切换到 libc 会有一定额外成本。这几个子项目构成了 llvm-project 的地基后面所有具体工作比如写个静态分析工具、给某种语言做编译前端、调优某个目标平台的指令输出都是围绕它们展开的。2. 为什么这套工具链值得你花时间研究2.1 走出编译器只能 GCC 的惯性思维很长一段时间里很多人提到 C/C 编译器脑子里就只有 GCC。这一点可以理解传统 Linux 发行版默认工具链就是 GCC大家也习惯了。但如果你认真接触过 Clang尤其是关注过它的编译报错信息、模块支持能力和插件化设计就会发现它设计上的现代感确实更足。我之前帮同事分析一个大型 C 工程编译变慢的问题GCC 给出的 warning 信息很泛定位起来费劲切到 Clang 之后不仅警告信息给出了更精确的源码位置还根据代码上下文提示了可疑的未定义行为整个排查过程舒服很多。更重要的是llvm-project 给了你“自己动手写编译器组件”的可能。它不像某个闭源工具链那样所有机制都是黑盒。LLVM 的所有 pass 都是开源的中间表示是文档化的API 是公开且稳定的在可接受范围。这就意味着你可以基于它做大量二次开发。很多商业公司做芯片编译器就是基于 LLVM 后端做靶机描述加上自定义优化 pass。你如果掌握了这套框架等于握住了行业内编译器定制化方向的一把钥匙。2.2 不只给编译器工程师普通开发者的切入点没有准备专门搞编译器的人可能觉得这项目和自己无关。实际上关系不小。比如你处理大型 C 项目时用 clang-tidy 做代码静态检查可以方便地发现问题代码模式这个工具就是 llvm-project 工具链体系里的一环。又比如你想学习 RISC-V 汇编asm parser、disassembler 相关代码可以直接参考Clang 还支持按目标架构生成对应汇编单条指令的实验成本很低。再比如你有模糊测试需求Clang 的 sanitizer 系列工具也就是 AddressSanitizer、UndefinedBehaviorSanitizer能帮你很快找到内存错误和未定义行为的问题这在实际工程里是救命级别的能力。所以哪怕你现在不是编译器方向的人多了解 llvm-project 的架构也能在未来遇到工具链相关问题时多一个解决思路。这套框架的价值不在于它本身多炫而在于它的模块化设计让很多底层问题可以被优雅解决。2.3 与前沿技术方向的契合这几年芯片设计领域 RISC-V 火起来以后背后最活跃的软件栈就是 LLVM。原因不复杂RISC-V 架构相对年轻传统工具链支持成熟度不够而 LLVM 作为一个模块化、易于添加新后端的框架天然适合新指令集的快速适配。学术界搞编程语言设计很多新语言选择基于 LLVM 做代码生成。工业界的很多领域特定加速器编译栈底层也是 LLVM。包括 AI 编译器生态里相当火热的 MLIR 项目干脆就是从 LLVM 社区长出来的。这些都说明llvm-project 在未来的软件开发底层版图里位置只会越来越重。3. 实操之前必须明白的核心概念3.1 LLVM IR所有优化发生的舞台想要看懂 llvm-project 里的代码尤其是 LLVM 的 pass 机制必须先理解 LLVM IR。IR 是什么简单讲就是一种“高级语言和机器码之间的中间表示”它具备静态单赋值特性也就是每个变量只能被赋值一次这给优化算法带来了巨大便利。编译器前端把 C/C 源码转成 IR优化器在 IR 上做变换后端再根据最终 IR 生成目标代码。给没接触过编译原理的朋友打个比方前端负责理解你要做什么但用比较标准、抽象的语言描述出来优化器像是一个很严格的秘书帮你理清步骤、剔除废话、合并重复动作后端则是把整理后的步骤真正翻译成机器能执行的指令列表。IR 就是这份标准化的步骤描述它既要保证语义准确又要足够抽象不能被某一类芯片的指令集束缚住。实际操作中你可以在 Clang 中通过 -emit-llvm 参数把 C/C 源码转成可读的 .ll 文件观察前端产物。再通过 -O2 等优化选项观察同一个函数在优化前后的 IR 差异。这一步对理解优化的本质很有帮助我经常建议想入门 LLVM 的朋友先花一个下午做这个小实验比看十篇博客都管用。3.2 PassLLVM 优化流水线的最小单元Pass 是 LLVM 优化框架里最关键的概念可以理解为一个独立的“代码变换器”。它拿到一份 IR按自己的逻辑分析一遍产出修改后的 IR。比如某个 pass 专门负责删除永远不会执行到的代码死代码消除另一个 pass 专门把循环内不变的表达式提到循环外循环不变代码外提。一个完整的编译过程就是把几十个甚至上百个 pass 串成流水线依次执行。在 llvm-project 仓库里优化 pass 的实现大多在 llvm/lib/Transforms 目录下。每个 pass 类重写一个 run 方法在这个方法里实现具体的 IR 操作。你如果想自己扩展一个优化逻辑比如针对某种自定义指令做指令合并思路就是在合适的位置新增一个 pass然后把它接入 pass 管理器中。Pass 框架本身也演化过好几轮从最早的 FunctionPass 到现在推荐的 new pass managerAPI 和注册方式有变化但基本思想没变。实际写 pass 之前最好先确认你基于的是哪个版本的 LLVM对应的 pass 注册风格是旧的还是新的否则照着网上老教程写很可能编译都不通过。我在某次升级到 LLVM 17 之后写 pass 用 legacy pass manager 编译直接报错查半天才发现是新老框架接口不兼容。3.3 TableGen用描述性语言减少重复代码第一次在 llvm-project 里看到 .td 文件的时候很多人的表情是“这是什么鬼”。它不是 C也不是汇编而是一种领域描述语言名字叫 TableGen。LLVM 用它描述目标指令集、寄存器、调用约定等信息然后自动生成对应 C 代码。这么做的好处是描述集中一条指令定义只需要写一份用到的各种查询表、解码器、汇编器里的条目都由工具生成避免手写大量样板代码造成的规则漂移。举例来说你在某个后端目录里看 .td 文件里的指令定义写着“操作数类型”“编码格式”“寻址模式”等字段。TableGen 解析后会为这部分生成指令选择时需要的匹配表。如果你要做一种新处理器架构的 LLVM 后端TableGen 差不多就是你的第一站也是最花费心思的一站。3.4 文件格式全家桶BC、Object、Bitcodellvm-project 涉及的文件格式也不少。Bitcode 是 LLVM IR 的二进制编码形式后缀一般为 .bc多用于跨环节传递 IR。目标文件格式则和普通编译器产物一致常见的有 ELF、Mach-O、COFF 等。另外像 LTO链接时优化这种场景编译器会先把源码编译成 bitcode到链接时才把所有模块的 IR 合并起来做全局优化。这些格式虽然不用全记但了解它们能帮助你在实际使用中避免一些莫名其妙的问题。比如某个库提供的是 .bc 文件你想直接链接到普通 C 程序里可能就要先确认你们的编译流程是否支持 LTO 模式否则会报无法识别的文件格式。踩过一次这个坑你就明白 bitcode 和普通的 .o 文件绝对不能混为一谈。4. 构建环境准备与完整编译流程4.1 系统依赖与硬件资源要求构建 llvm-project 不是在任意机器上都能顺顺利利的。先说硬件LLVM 本体加 Clang 全量构建普通配置的机器也能编就是时间较长。官方推荐构建时预留至少 30 GB 左右的磁盘空间内存建议 16 GB 以上。内存不够的情况下编译器编译过程中的某些大型中间文件会触发 OOM尤其是当你开启多线程并行构建比如 make -j8 或 ninja -j8内存占用会成倍上升。我之前在一台 4C8G 的云服务器上编过一次开着 8 并发直接卡死把并发降到 2 才勉强跑完花了大概两个多小时。操作系统层面Linux、macOS 和 Windows 都支持但开发体验最好是 Linux。Windows 上构建需要配合 Visual Studio 的工具链且某些组件支持不完整容易踩坑。如果只是为了研究代码并二次开发VirtualBox 里装个 Ubuntu 也会比 Windows 原生环境省心。4.2 获取源码的两种方式获取源码主要有两种方式。一种是浅克隆适合只想基于最新代码做开发的人命令是git clone --depth1 https://github.com/llvm/llvm-project.git这种方式拿到的代码不包含历史提交仓库体积小很多。另一种是完整克隆适合需要追溯历史实现、查看不同版本差异、或者想切到特定 release tag 的人命令是普通的git clone https://github.com/llvm/llvm-project.git建议明确自己要做什么再选。如果只是跑通一个 demo浅克隆足矣。如果打算长期跟踪上游、做版本兼容性研究那就完整克隆后续还能方便地查看 git log。4.3 CMake 配置与构建命令实战构建 llvm-project 官方推荐用 CMake Ninja。Ninja 的增量构建速度比 make 快不少尤其是改动一点点源码后重新编译的场景Ninja 能省下大把时间。下面是一份我常用的构建配置以 llvm-project 根目录为起点cd llvm-project mkdir build cd build cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;mlir \ -DLLVM_TARGETS_TO_BUILDhost;RISCV \ ../llvm ninja拆开解释几个关键参数。CMAKE_BUILD_TYPE 设置为 Release是因为 Debug 版 LLVM 在构建时不仅编译速度慢生成的编译器执行性能也差很多跑大型工程时效率极低。日常做编译实验、学习源码Release 足够。LLVM_ENABLE_PROJECTS 这个参数用来决定构建哪些子项目。这个值用分号隔开多个项目名称。注意LLVM 本身是默认构建的不需要写进去。第一次构建不用贪多建议带 clang 和 lld 就够用了MLIR 看个人需求再打开因为 MLIR 会引入额外的依赖和编译负担。LLVM_TARGETS_TO_BUILD 指定要生成为哪些目标架构生成代码。默认是构建所有目标编译时间会成倍增加。如果你用不到那些架构只保留 host也就是你当前机器架构通常是 X86和你想研究的 RISC-V 就非常省时间。实测中这个配置调整对构建速度的影响相当明显。Ninja 构建命令本身没什么悬念直接 ninja它会自动根据机器核数并行但如果你的内存不大建议加 -j 限制并发数比如ninja -j4这样能让系统稳定很多。构建完成后二进制文件通常位于 build/bin 目录下比如 clang、clang、llc、opt、ld.lld 等。4.4 进阶参数和常用工具入口如果你还想进一步压减构建时间还可以设置 CMAKE_BUILD_TYPE 为 RelWithDebInfo这种模式支持调试符号带在编译后的产物体积上并使用 Release 级别的优化对于调试优化器代码比较合适。如果只是学习研究用 Release 也够了真需要调试的时候再改成 RelWithDebInfo 重新编一遍。构建完成后的常用工具入口主要在 build/bin。重点说几个clangC/C 编译器前端入口可以直接替代 gcc 使用。clangC 编译器前端入口。llc把 LLVM IR 编译成目标汇编代码或目标文件。opt对 LLVM IR 执行优化 pass是做 pass 调试时的主角。llvm-dis把 bitcode 反汇编成人可读的 .ll 文件。lld链接器入口可以通过 ld.lld 调用。mlir-optMLIR 相关优化工具如果你启用了 MLIR 的话。比如我用 opt 测试某个自定义 pass命令大概长这样build/bin/opt -load-pass-pluginlibMyPass.so -passesmy-pass input.ll -o output.ll这里的 input.ll 是人读的 IR 文件output.ll 是经过 pass 处理后的 IR。调试 pass 时这种交互方式非常高效比每次都走完整编译链路舒服得多。5. 常见问题与排查技巧实录5.1 构建过程 OOM怎么处理OOM 是新手在低配服务器上最常遇到的问题。典型表现是 ninja 构建过程中系统无响应或者直接被内核杀掉编译进程。查日志通常能看到类似于 “Killed signal terminated program cc1plus” 的字眼。解决思路分两层。一是在构建参数上控并发。ninja -j2 能显著降低峰值内存占用。二是利用 CMake 的 LLVM_PARALLEL_LINK_JOBS 参数单独限制链接阶段的并发数。链接是最吃内存的阶段调小这个参数往往立竿见影。更彻底的做法是配置 swap 文件但效果远不如真正限制并发来得直接。我个人的经验是 8 GB 内存的机器上-j2 加 link 并发为 1基本能稳定编完。5.2 版本不对应导致的上游代码编译错误llvm-project 主分支属于滚动更新状态它的依赖关系变化很快。如果你的机器上已经安装了系统自带的 LLVM 开发包在编译新拉取的 llvm-project 时偶尔会出现头文件不匹配或符号冲突。解决办法有两个方向一是启用 LLVM_EXTERNAL_* 相关选项让新项目完全使用仓库内的源码二是保证外部依赖包版本足够新不要混搭老版本库。最稳妥的方案是在构建时优先使用仓库内部自带的 bazel 或 CMake 配置避免和系统库之间发生链接期符号版本冲突。5.3 自定义 Pass 不被加载很多人在照着教程写新 pass 的时候会遇到编译时正常但运行时提示 pass 不存在的错误。这多半是因为插件注册方式写错了或者 pass 名和注册名不一致。LLVM 的新 pass 管理器中插件注册需要调用相应宏并且通过 -passes 传入的名称要与注册名一致。想要确认模块加载状态可以在 opt 命令后面加 -debug-pass-manager 参数观察 pass 是否被真正执行。排查思路上先看插件路径是否对了再看 pass 名是否完全匹配最后确认代码里是否写了正确的静态注册逻辑。这三步能排除绝大多数问题。5.4 后缀名和文件格式识别异常如果你的编译流程里混用了 .bc、.ll、.o 文件偶尔会遇到 “invalid signature” 或 “file format not recognized” 之类的报错。这通常是你把 bitcode 当成普通目标文件去链接或者把文本 IR 直接塞给了不支持的阶段。解决问题的方式是明确工具链各阶段输入。源文件先由 clang 编译成 IRIR 经过 opt 优化后再用 llc 生成目标文件最终由 lld 链接。无论如何bitcode 和机器码文件不能混着用。我建议在 Makefile 或 CMake 里给不同中间产物设置不同的输出目录避免把文件类型搞混。5.5 链接器版本不匹配导致无法识别调试信息还有一个容易踩的坑是用新版 Clang 生成的目标文件拿去给旧版 lld 链接可能在调试信息段上出问题报错信息类似 “unknown attribute group” 或者 “malformed debug info”。这类问题的根子在于不同版本间的 DWARF 调试信息格式存在演进工具链版本不一致就会翻车。解决方式很简单整个 llvm-project 里的工具尽量保持同一版本构建产物。不要手动从系统包管理器装一个老版本 lld然后和刚编译出来的新 clang 混用否则调试体验会非常差。5.6 磁盘空间不足导致构建中断构建大量静态库和工具时临时文件占用的磁盘空间增长很快。如果没提前预留足够空间构建可能在任意阶段报 “No space left on device”而且出错位置看起来毫无规律。预防措施是在构建前用 df -h 检查磁盘剩余空间至少保证 30 GB 以上。如果已经中招了清理方式包括删除 build 目录下不需要的中间依赖比如示例和测试程序可以适当裁剪把 CMake 的 LLVM_BUILD_EXAMPLES 和 LLVM_INCLUDE_TESTS 关闭能省不少空间。对于学习用途跑通构建后不用的 build 目录也可以整体删除源码目录的干净程度其实不影响下一次全新构建。6. 学习路径与二次开发的起点建议6.1 从好奇到上手的推荐路线如果你是从零开始接触 llvm-project不建议一上来就啃源码。最舒服的路径是先当用户。具体来说先下载官方发布的二进制版本或者自己构建一套 Release 版然后用 clang 编译几个 C/C 小例子对比 GCC 的产物差异。用 opt 对简单 IR 执行不同优化级别观察 IR 的变化。这一步是为了让你熟悉“LLVM 生态”的操作手感。接着可以研究 Clang 的 -emit-llvm 产物把一段 C 代码生成 .ll 文件仔细读读里面的函数定义、基本块、指令格式和元数据。这个过程能帮你把抽象概念具象化。再然后尝试自己写一个极其简单的 pass比如把某个特定指令替换成另一个指令通过 opt 跑起来。这个阶段你才开始真正接触 LLVM 作为开发框架的实质。6.2 如何在 llvm-project 里定位你要改的代码当你想改某段逻辑时一个比较实用的方法先用 git grep 搜索与你关注点相关的关键词。比如你想查“指令选择”相关代码可以搜 SelectionDAG想查某个优化 pass可以搜它的注册名。因为 LLVM 代码结构整体很规范目录命名和组织方式具有较强的一致性搜索定位会比你想象中顺利得多。定位到具体文件后优先看头文件和注释把接口理解清楚再碰实现。LLVM 源码里注释质量不错尤其是新代码很多关键逻辑会有较多解释。不要怕 .td 文件和 .cpp 文件间来回跳那是日常工作流程里的一部分。6.3 关于贡献代码与社区协作如果想向 llvm-project 回馈代码建议先读官方开发者文档了解代码风格和提交流程。LLVM 社区对代码质量要求较高改一个 bug 需要附带回归测试。刚接触的话可以先从修文档、修注释、或者修复低难度 bug 开始慢慢积累对软件构建流程的熟悉度。直接贸然提交大功能很容易被 review 驳回打击自信心。社区讨论主要在邮件列表和 Phabricator/GitHub 上。每次 review 都会有人直接提意见哪怕写得不够好也不用觉得挫败那是快速成长的一部分。我自己第一次提交的小改动就是因为少加了一个测试用例被退回来后面根据反馈补上来回两轮才合进去。这个过程虽然磨人但确实学到了不少东西。6.4 用 llvm-project 能做的一些有意思的事最后聊点轻松的。掌握这套工具链后你可以在很多方向玩出花来。给公司内部自研的某种领域语言写一个编译后端落地到现有的 JIT 引擎上这个事情完全可行做性能分析时用 Sanitizer 快速定位内存越界比人肉看代码高效太多甚至可以用 MLIR 做图优化调度把深度学习模型的计算图翻译成底层指令序列然后对接 LLVM 生成可执行代码。我更想说的是多了解一点底层工具链不一定马上能转化成手头项目的产出但会在潜移默化中提升你对程序执行的理解。比如写 C 时你对内联、循环展开、常量传播这些优化方式的认识不再停留在字面上因为你在 LLVM IR 里真正见过它们发生的过程。这种理解层面的提升带来的长期收益很值得期待。
返回列表