ARTICLE DETAIL

资讯详情

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

LLVM编译器基础设施入门:源码构建与第一个Pass开发实战

LLVM编译器基础设施入门:源码构建与第一个Pass开发实战 llm-project这个仓库名在编译器圈子里基本等同于“编译器基础设施全家桶”。如果你只是写过几年代码、用过GCC或者Clang可能对这个名字又熟悉又陌生——熟悉是因为经常在各类技术讨论里看到它陌生是因为打开仓库一看上百万行代码根本不知道从哪里下手。这篇文章我想从实际使用的角度把llvm-project里到底有什么、为什么值得你折腾一遍源码构建、以及怎样基于它快速写出第一个自己的编译器Pass这件事讲清楚。这篇内容适合三类人第一类是C/C程序员想深入了解编译原理却不想只啃龙书第二类是工具链开发者需要在LLVM之上做定制分析或优化第三类是语言爱好者想借助LLVM给自研语言接入一个工业级后端。不管你是哪一类读完这篇文章之后至少能独立完成一次源码构建并且知道下一步该往哪走。1. llvm-project仓库结构与整体设计思路1.1 这个仓库到底装了些什么llvm-project是LLVM官方维护的一个monorepo单仓库多项目它把整个编译器生态的核心部分全部放在了一起。我记得早期LLVM是分仓库管理的后来官方在2020年前后彻底切到单仓库模式目的很直接让LLVM核心库、Clang前端、LLD链接器、libc标准库这些项目在版本迭代上保持完全同步。你只需要一次git clone就能拿到一套互相兼容的工具链源码不用再像拼积木一样去对齐各个子项目的版本号。这个仓库里最主要的组成部分我列个表大家感受一下子项目名称定位说明典型用途LLVM核心库提供IR中间表示、优化器、代码生成器、目标后端写Pass、做静态分析、定制代码生成ClangC/C/Objective-C前端把C/C源码解析成LLVM IRLLD高性能链接器替代系统默认ld链接速度大幅提升libcC标准库实现搭配Clang使用尤其在跨平台场景compiler-rt运行时支持库Sanitizer、Profile等底层支持MLIR多层级IR框架构建领域专用编译器AI框架编译落地常用Polly多面体优化框架循环变换与数据局部性优化lldb调试器基于LLVM生态的调试工具这里面我建议初学者把重点放在LLVM核心库和Clang这两个子项目上因为它们是整个生态的地基。其他项目的存在意义都是在LLVM核心库这个“通用编译基础设施”之上叠加上层能力。比如MLIR它本质上是LLVM IR体系的一次抽象升级用来解决传统IR无法表达的高层语义问题。有个比较反直觉的点是很多人以为llvm-project只是给Clang用的但实际上它服务的对象远不止C/C。Rust的编译器rustc在很早就接入了LLVM作为后端Swift编译器也用了LLVM生态哪怕是NVIDIA的GPU编译器NVVM、Google的XLA等底层都是LLVM。所以这个项目的真实定位是“编译器领域的Linux内核”——你未必直接用它但你用的工具链很可能构建在它之上。1.2 理解llvm-project的定位不只是编译器我经常跟朋友打一个比方llvm-project像是一套“编译器乐高积木”。乐高积木本身不是成品但它提供了各种标准接口的模块你可以用它们拼出汽车、城堡甚至机器人。LLVM的核心设计也是这样——它把编译过程拆成了可以独立复用、任意组合的组件前端只管把源码变成IR优化器只管在IR上做变换后端只管把IR变成目标机器码。三段式结构中间的那层IR就是所有模块兼容的“标准积木接口”。这种设计的威力体现在什么地方假如你想发明一门新的编程语言传统做法是需要为每个目标平台重写一个完整的编译器工作量极其恐怖。但在LLVM的框架下你只需要写一个前端把你的语言翻译成LLVM IR剩下的优化和生成机器码全都可以交给LLVM完成。也就是说一个晚上让语言跑在x86、ARM、RISC-V等多个平台上不是幻想而是LLVM生态里的常规操作。再比如你想给自己的项目做深度的代码静态分析不用去解析各种语言的语法树直接用Clang把源码转成IR然后写一个Pass遍历IR就能拿到调用关系、数据流信息和控制流结构。这就是为什么安全团队、性能优化团队、芯片公司都对llvm-project如此看重——它把编译器的能力开放成了可编程的基础设施而不再是一个只能通过命令行参数间接控制的黑盒。对个人开发者来说接触llvm-project最大的价值在于通过阅读它的源码和源码结构你能真正理解工业级编译器是怎么组织模块的。从Pass管理器的调度方式到指令选择器的匹配逻辑每一块都是几十年的工程经验沉淀。我自己的体会是啃完一遍LLVM核心库的代码结构之后再回头看大学期间学的编译原理课程很多当时觉得抽象的概念都变得非常具体。2. 把llvm-project跑起来源码构建全流程与关键参数2.1 拉取代码与构建环境准备既然要深入使用第一步肯定是把源码拉到本地。llvm-project的仓库非常庞大完整clone下来大约在1GB到2GB之间所以我不建议直接git clone https://github.com/llvm/llvm-project.git把整个历史和全部分支都拿下来。更常用的做法是只拉取深度为1的浅克隆并且指定一个release分支。比如我现在用的构建版本是LLVM 17对应的命令是这样git clone --depth 1 --branch llvmorg-17.0.6 https://github.com/llvm/llvm-project.git这里有个值得注意的点为什么要带release分支标签因为LLVM的主干分支mainline更新极快今天能编译过的代码可能过两周就变了。作为使用者或二开者如果你不想被API的频繁变动折磨请务必锁定一个大版本分支。构建环境方面llvm-project本身是用C写的支持用GCC或Clang编译。Windows上可以用Visual Studio的MSVCmacOS上可以用系统自带的ClangLinux上我一般直接用系统GCC。版本要求的话LLVM 17至少需要GCC 7.1以上或Clang 7以上这个门槛在如今的主流发行版上基本都能满足。硬件配置上内存建议至少16GB磁盘建议预留40GB以上的空余空间。为什么内存要求这么高因为编译LLVM时编译器和链接器需要同时处理大量编译单元链接最终可执行文件时尤其吃内存。如果机器只有8GB内存那我强烈建议配置里把并行链接数降到1或2否则很容易出现OOM问题。2.2 CMake配置中那些影响构建速度的选项llvm-project使用CMake作为构建系统整个源码构建流程最终都汇聚在一个CMake配置命令里。我直接给出一份我实测过很稳的配置然后解释每个关键选项为什么这么设置cmake -S llvm-project/llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_CCACHE_BUILDON \ -DLLVM_PARALLEL_LINK_JOBS2第一个选项CMAKE_BUILD_TYPERelease让编译产物带上优化这个不用多说如果选Debug构建速度和体积都会差很多。LLVM_ENABLE_PROJECTS决定这次要编译哪些上层子项目我只选了clang和lld因为它们是最常用的。注意这个选项不要在第一次构建时就贪心全开子项目越多编译时间越长而且一些实验性项目可能对依赖有额外要求。LLVM_TARGETS_TO_BUILDX86是个非常关键的提速选项。默认情况下LLVM会编译支持几乎所有主流架构的后端这会让编译时间成倍增加。如果你只是学习或开发x86平台的应用只保留X86目标就完全够用了。等你哪天真要交叉编译到ARM或RISC-V再重新加回来也不迟。LLVM_ENABLE_ASSERTIONSON会开启运行时断言检查。很多初学者不理解为什么这个选项在Release模式下还要打开。我的看法是做LLVM二次开发时这个选项最好保持开启因为大量编译错误和IR非法操作都是靠这些断言提前暴露的。不过它也有副作用会稍微增加运行时的性能开销。LLVM_CCACHE_BUILDON是启用CCache加速增量构建。这一步强烈建议加上——如果你后续需要反复修改代码再重新编译CCache能把同一份源码的编译结果缓存下来实际体验是增量构建时间能减少一半以上。最后LLVM_PARALLEL_LINK_JOBS2是限制同时执行的链接任务数量。链接是LLVM构建过程中最吃内存的环节之一我把并行链接数限制在2配合16GB内存基本不会出现OOM同时速度影响也不大。如果你的机器是32GB甚至更高内存可以去掉这个限制或者提高数量。2.3 构建、验证与常见时间预期配置完成后构建命令本身很简单cmake --build build -j16-j16是因为我的机器有16个逻辑核心。如果你不确定自己机器有多少核可以用nproc查一下。首次构建的时间取决于机器性能和target的多少以我的经验16核机器上只构建X86目标加clang和lld大约需要20到30分钟。如果你把所有的target都加上时间可能会飙到两三小时。所以前面那步“只选一个目标架构”是真的划算。构建完成之后怎么验证是否成功第一步看产出的Clang能不能正常使用./build/bin/clang --version如果输出正常显示clang version 17.0.6之类的信息说明整个工具链已经可以用了。接着你可以用一个最简单的C文件测试编译// hello.c #include stdio.h int main(void) { printf(Hello LLVM\n); return 0; }./build/bin/clang hello.c -o hello ./hello能输出Hello LLVM就说明整个流程通了。还有一个小技巧你可以用./build/bin/clang -S -emit-llvm hello.c查看生成的IR文件这一步能让你对“前端到IR”有直观感受后面写Pass时也会经常用到。初次构建LLVM时我建议把整个流程完整走一遍再去做二次开发这能帮你积累对构建系统、目录结构和产出物的整体感知后面所有实验都会顺手很多。3. 基于llvm-project做二次开发写一个属于自己的编译器Pass3.1 二开的第一层理解Pass是什么当你说“基于LLVM做二次开发”时绝大多数情况下你都在写Pass。Pass这个概念新手不好理解我用流水线来类比LLVM的优化过程像一条工厂流水线IR就是流水线上的半成品工件每个Pass就像流水线上的一个工位有的工位负责检查工件质量分析Pass有的工位负责把工件改造得更高效变换Pass。一段源码被前端翻译成IR之后会依次经过流水线上几十上百个Pass最终产出一个被优化过的IR再交给后端生成目标机器码。Pass到底能干什么举几个实际例子。写分析Pass你可以统计一个程序里所有函数被调用的次数为热函数优化提供依据写变换Pass你可以将某种特殊的循环模式改写为另一种更高效的形式写检查Pass你可以扫描代码里不符合团队规范的函数签名并报警。因为IR处于“半抽象半具体”的中间层次它既保留了程序的控制流、变量类型等信息又去掉了具体编程语言的语法包袱所以很多通用型的代码分析工具都喜欢基于IR做。LLVM从历史上发展出了两套Pass框架。早期的是legacy PassManager通过注册宏LLVM_PASS_REGISTRATION等机制工作而现在主推的是新PassManager通过llvm::PassPlugin来动态加载插件。新手入坑建议直接学新PassManager因为从LLVM 14开始legacy框架基本进入“能跑但不推荐新代码使用”的状态新版本甚至已经移除了一部分旧接口。3.2 从零写一个FunctionPass的高频步骤下面我完整演示一个最简单的Pass它遍历模块里的每个函数打印出函数的名字和基本块数量。这个Pass虽然功能不值钱但它覆盖了Pass生命周期、IR遍历和信息输出这三个最核心的要点你掌握之后换任何分析逻辑都是一回事。首先在llvm-project之外单独建一个目录放你的插件项目PM与LLVM源码分开编译官方推荐的插件方式是不用改动LLVM源码本身的。我的项目结构是这样my-pass/ ├── CMakeLists.txt └── MyPass.cppMyPass.cpp完整代码如下#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 { struct MyPass : public PassInfoMixinMyPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Function: F.getName() | BasicBlocks: F.size() \n; return PreservedAnalyses::all(); } }; } // namespace // 注册插件入口 llvm::PassPluginLibraryInfo getMyPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-pass) { FPM.addPass(MyPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getMyPassPluginInfo(); }我简单解释一下几个关键点。run函数是新PM的入口每个Pass都会实现这个函数在这里面对Function进行操作。F.size()返回的是一个函数内基本块的数量因为llvm::Function本身就是一个包含BasicBlock的容器。PreservedAnalyses::all()表示这个Pass没有修改任何IR所以上游分析结果可以全部保留——如果后续你写的Pass真的改了IR就需要按实际情况返回哪些分析被破坏了。registerPipelineParsingCallback这行是动态Pass插件的关键它让opt -passesfunction(my-pass)能通过字符串my-pass找到并执行这个Pass。CMakeLists.txt是这样写的cmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) add_library(MyPass MODULE MyPass.cpp) target_include_directories(MyPass PRIVATE ${LLVM_INCLUDE_DIRS}) target_link_libraries(MyPass PRIVATE LLVMCore LLVMSupport) target_compile_definitions(MyPass PRIVATE ${LLVM_DEFINITIONS})编译时你需要指定LLVM的安装路径也就是之前构建时的build目录用-DLLVM_DIR指向它cmake -S my-pass -B my-pass/build -DLLVM_DIR$(pwd)/llvm-project/build/lib/cmake/llvm cmake --build my-pass/build -j8如果成功在my-pass/build目录下会生成一个libMyPass.so这就是可以动态加载的Pass插件。3.3 用opt加载并验证自己的Pass插件编译好了怎么测试它需要一个待分析的IR文件。最方便的办法是先让Clang生成// 一个测试文件 test.c int add(int x, int y) { return x y; } int main(void) { return add(1, 2); }./build/bin/clang -S -emit-llvm test.c -o test.ll生成的test.ll内容大致长这样; ModuleID test.c source_filename test.c target datalayout e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128 target triple x86_64-unknown-linux-gnu define i32 add(i32 noundef %0, i32 noundef %1) { %3 alloca i32, align 4 %4 alloca i32, align 4 store i32 %0, ptr %3, align 4 store i32 %1, ptr %4, align 4 %5 load i32, ptr %3, align 4 %6 load i32, ptr %4, align 4 %7 add nsw i32 %5, %6 ret i32 %7 } define i32 main() { %1 call i32 add(i32 1, i32 2) ret i32 %1 }然后调用opt加载插件llvm-project/build/bin/opt -load-pass-pluginmy-pass/build/libMyPass.so -passesfunction(my-pass) test.ll -o /dev/null执行后会看到插件打印输出Function: add | BasicBlocks: 1 Function: main | BasicBlocks: 1看到这个输出就说明你的Pass已经成功跑起来了。到这里你已经迈出了LLVM二次开发的第一步不是改LLVM源码而是像搭积木一样在外部写一个插件由LLVM的Pass管理器把它调度起来。这个模式很重要因为它在不污染LLVM主工程的前提下实现了完全的模块化。3.4 常见二开问题写Pass时会遇到几个高频问题我把它们汇总一下。第一个问题是报错“unknown pass name”。这个大概率是插件没有正确注册或者加载失败了。先用opt --load-pass-pluginlibMyPass.so --help-hidden | grep my-pass检查插件是否被识别如果识别不到优先检查llvmGetPassPluginInfo导出符号是否写对了以及CMake里链接的LLVM库版本是不是和运行时的opt版本完全一致。第二个问题是头文件编译失败报一堆symbol not found或者类型不匹配的错误。这通常是因为LLVM版本之间API有差异。比如PassInfoMixin这个名字在老版本里可能写作PassInfoMixin但有细微差别或者某些注册接口签名变了。最直接的解决办法是打开你本地的LLVM头文件照着源码里的示例改。每个版本的llvm-project源码的llvm/examples/Bye目录就有一个可用的插件示例那是官方维护的参考实现我之前踩版本坑时就是靠它救回来的。第三个问题是Pass逻辑改完但每次跑的结果都一样。先检查是不是忘了重新编译插件——这是新手最容易忽视的然后是确认输出结果有没有被系统缓存。还有一点需要注意如果你在Pass里修改了IR但返回了PreservedAnalyses::all()会导致LLVM认为分析结果都没失效从而跳过一些必要的修正带来难以排查的问题。修改IR后最稳妥的方式是根据修改内容返回相应的PreservedAnalyses不确定时直接返回PreservedAnalyses::none()让LLVM重新计算所有分析结果成本通常不高但正确性有保障。提示修改IR时务必确保IR始终处于合法的SSA静态单赋值形式否则后续Pass和代码生成都会崩。LLVM有-verify验证选项建议在测试时加上它来检查IR合法性。4. 调试方法与常见问题排查实录4.1 构建失败的三个经典场景LLVM构建虽然文档齐全但实际跑起来坑真不少。我把自己遇到过的失败场景分成了三类每类都给你排查路径。第一类是内存不足导致构建失败。表现是编译过程中突然出现c: internal compiler error: Killed (program cc1plus)这通常发生在链接或编译大文件的时候。解决办法很直接降低并行度。把cmake --build里的-j16改成-j4同时把LLVM_PARALLEL_LINK_JOBS设为1牺牲一点速度保住构建不崩。曾经我在一台8G内存的笔记本上构建LLVM硬是在-j4加LLVM_PARALLEL_LINK_JOBS1的配置下跑通了虽然耗时一个多小时但结果是好的。第二类是CMake配置阶段失败提示找不到某个依赖。LLVM的依赖其实不算多最常缺的是zlib、libxml2等系统库。一般错误信息会直接告诉你要装什么包。在Ubuntu/Debian系系统上你可以提前把build-essential cmake ninja-build python3 zlib1g-dev libxml2-dev libedit-dev一次性装好能省很多事。第三类是磁盘空间不足。很多人以为源码加build目录有个20G就够实际上如果开了Debug构建并启用所有targetllvm-project很容易吃掉60G以上的磁盘。我建议在cmake --build之前先检查磁盘剩余空间至少保留40G。一个可行的替代方案是只构建clang和LLVM core不启用lld并把Debug信息去掉体积能明显降下来。4.2 调试LLVM项目时我常用的工具和方法有一次我想看自己写的Pass在前面优化之后看到的状态在IR上摸索了很久后来发现LLVM自带的调试工具帮我省了大量时间。第一个是-print-after-all。运行opt时加上它每个Pass执行之后都会打印出IR这样你能看到优化前后发生了什么非常直观。如果只想看某一个具体的Pass执行前后可以用-print-afterpass-name精准指定。第二个是LLVM的debug输出。在Pass代码里可以加LLVM_DEBUG(dbgs() debug message\n);这类语句编译时开启LLVM_ENABLE_DEBUG运行时用-debug-onlymy-pass开启对应模块的调试输出。这样既能保留调试代码在日常运行时不输出干扰又能在需要时随时打开。第三个是gdb/lldb单步调试。Pass本质上是C代码所以完全可以用调试器打断点。比如我想看某个函数的F.getName()返回什么在函数入口处打断点运行到那里时查看变量值即可。但这个方式需要你对Pass的调用栈有大概了解不然会被一堆优化管道内部的调用关系绕晕。还有一个细节值得注意在函数里调用llvm::errs()输出时如果输出内容很多可能会导致性能很慢。调试时适当减少输出或者在不需要时把输出语句注释掉能让你更专注地看关键信息。4.3 关于llvm-project生态的避坑心得最后聊几个整个生态层面的经验教训这些都是踩坑踩出来的。首先跟着release分支走不要长期追mainline。LLVM的mainline开发非常活跃API变更速度极快今天的llvm::make_unique明天就可能被移除。二开场景下锁死一个release版本比如17.0.6等于给自己买了一份“稳定契约”。等新版本发布后再评估是否迁移。其次尽量用CCache。我已经不止一次强调CCache的重要性这里再补充一个细节当你切换到新分支或者修改CMake配置后CCache的命中率可能会下降这很正常不用特别担心。CCache的存在意义主要是支持快速迭代否则每次改动一行代码就得重新链接整个LLVM那种等待是真的考验耐心。再次改动LLVM源码之后不要只手工测试一两个用例。LLVM自带set of regression tests可以通过ninja check-llvm和ninja check-clang跑一遍。我第一次改完忘跑测试结果几天后发现影响了另一个场景排查半天最后用回滚解决。如果你给LLVM提PR官方CI也会强制跑这些测试所以本地提前跑能帮你避免很多返工。最后如果目标只是使用LLVM的功能不要自己造轮子编译整个项目。你可以直接从LLVM官网下载预编译二进制包或者用包管理器安装。源码构建适合学习、定制、二开这三种情况如果你只是想用clang编译C代码完全没必要花几十分钟去编译整个llvm-project。我自己现在也是分两套环境一个预编译的二进制用来日常编译代码一个源码构建的目录专门用来做开发和实验。作为一个从零开始折腾过无数遍LLVM的人我对llvm-project最深的体会是它的学习曲线确实陡峭但信息密度极高。每次构建失败想放弃的时候我都会告诉自己LLVM这些年来的变化虽然大量但它的核心设计并没有变早期版本的Pass框架里很多好思想至今仍在沿用。它真正让人上瘾的地方在于一旦你理解了IR和Pass管理器的配合关系你就能把编译器的能力变成你自己的工具箱。如果你刚打算入手我的建议很简单老老实实完成一次完整源码构建然后写一个最蠢的Pass打印出每个函数的名字——别小看这一步那是一个新的世界在你面前展开的样子。
返回列表