用Clang+LLVM打造你的第一个C++编译器:从AST到自定义Pass实战
来聊聊用ClangLLVM打造C编译器这件事。很多人一听到“写编译器”三个字就头皮发麻觉得那是编译器大佬才能碰的东西。但当我第一次用LLVM把一段C源码解析成AST、再翻译成IR、最后跑通自定义Pass的时候我发现这条路其实已经被LLVM铺得相当平了。这篇实战文章会把环境搭建、核心原理、代码编写和实际报错的完整排查过程都整理出来带你从零走一遍C编译器的构建流程特别适合学过C基础、想深入编译原理但一直不知道怎么入手的开发者。1. 一个反直觉的结论写出“第一个C编译器”没你想的那么难1.1 这次动手实践到底要做什么先把目标说清楚我们不是要从零写一个能够编译全部C标准的编译器那是几十年工程积累的事。我们要做的是基于Clang前端加LLVM优化器和后端搭建一个能解析C源码、能输出中间表示、并能注入自定义优化逻辑的编译通道。换个说法你用Clang的库把C源码变成AST再让LLVM把它变成IR然后在IR层面写上你自己的Pass这就是一个真正的、可扩展的C编译器雏形。这样定位有两个好处。第一学习曲线合理不会一上来就被文法分析、词法状态机淹没第二实战价值高因为现实工业界的编译器开发很大一部分工作正是集中在Clang前端API的二次开发和LLVM Pass的编写上。你想给公司实现一门自定义DSL的编译器或者是想给现有C工程做定制化静态分析这条路就是必经之路。1.2 为什么是LLVM而不是gcc做编译器选型很多人的第一反应是gcc但实际动手之后你会发现LLVM是更适合深入改造的框架。gcc虽然开源但它的内部中间表示RTL和GIMPLE没有设计成对外公开的稳定API插件机制也存在但版本兼容性非常差每次gcc大版本升级插件代码基本都要重写。而LLVM从设计上就是一套模块化的编译器基础设施中间表示IR有公开的格式说明和使用文档Pass插件有稳定的注册机制甚至Clang前端也可以通过LibTooling工具库直接嵌入到你的程序里。还有一点很现实生态。Rust、Swift、Julia这些语言的后端都构建在LLVM之上很多大厂的编译器团队也在围绕LLVM做深度开发。从这个角度说掌握LLVM的实战技能比只了解gcc命令行开关要有用得多。1.3 动手前必须建立的3个认知第一编译器和编辑器是两回事。VSCode只是编辑器它负责你打字时的语法高亮和补全clang和gcc才是编译器负责把文本变成机器码。IDE则把两者包在一起再额外提供调试、构建管理等功能。很多人折腾半天“VSCode配置C/C环境”其实本质就是让编辑器找到编译器这个认知建立起来后续配置才不会迷路。第二编译器的内部是一条流水线。词法分析、语法分析、语义分析、中间表示生成、优化、机器码生成每个阶段各司其职。LLVM把这条流水线拆成了前端Clang负责到AST、优化器Pass管理器负责IR变换、后端CodeGen负责机器码生成三段这就是它的核心架构。第三命令是接口库才是灵魂。clang命令行能做的事你几乎都可以通过libclang、LibTooling、LLVM Pass库在代码里复现这才是“打造自己的编译器”的关键。2. 环境准备三种方式搭建ClangLLVM以及最容易踩的坑2.1 方式一安装官方预编译二进制如果你只是想先把流程跑通完全不需要从源码编译LLVM官方和主流包管理器都提供了预编译版本。Ubuntu/Debian上执行sudo apt update sudo apt install clang llvm lldmacOS上用Homebrewbrew install llvm注意这里的细节macOS自带的clang是Apple自己维护的定制版和LLVM官方的clang不完全一致opt、llc、llvm-config这些工具默认并不在/usr/bin里。用Homebrew安装LLVM之后它会被安装到/opt/homebrew/opt/llvm/目录下你需要把它加进PATHexport PATH/opt/homebrew/opt/llvm/bin:$PATH export LDFLAGS-L/opt/homebrew/opt/llvm/lib export CPPFLAGS-I/opt/homebrew/opt/llvm/includeWindows上最简单的方式是winget install LLVM.LLVM或者直接从LLVM官方GitHub Release页面下载Windows安装包。安装完成后在PowerShell里执行clang --version验证一下。如果提示找不到命令检查安装路径是否被加进了系统PATH。2.2 方式二从源码构建LLVM预编译版本虽然省事但如果你打算改动LLVM源码或者需要使用官方Release包没有包含的组件就必须从源码构建。很多初学者会搜“有没有预编译的llvm”其实官方Release页面就有但源码构建本身也是一项必会的技能毕竟工作里你很可能遇到定制需求。克隆官方仓库这里用17.0.6稳定版做示范git clone --depth 1 --branch llvmorg-17.0.6 https://github.com/llvm/llvm-project.git cd llvm-project配置构建重点cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang \ -DLLVM_TARGETS_TO_BUILDX86构建cmake --build build -j$(nproc)这里有几个参数值得说明。-DLLVM_ENABLE_PROJECTSclang告诉CMake我们还需要构建Clang前端-DLLVM_TARGETS_TO_BUILDX86表示只生成X86目标的后端。LLVM默认会为所有架构生成代码但大多数人的机器只需要X86大幅缩短编译时间、减少内存占用。如果想让llvm-config命令可用在配置时还需要把clang工具的安装步骤加进去不过我们这里仅做本地构建使用build目录下的工具即可。2.3 方式三Windows/VSCode环境下的clang配置注意点在Windows上你可能会遇到一道选择题MSVC、MinGW还是Clang简化的答案是如果你主要用VSCode写C用MinGW系的g或者LLVM的clang都可以配置思路一致——在tasks.json里告诉编译器源文件在c_cpp_properties.json里告诉IntelliSense去哪里找头文件。一个常见误区是有人以为装了VSCode就算配好了C/C环境。VSCode是编辑器它不会编译代码你需要的是在系统里安装一个真正的编译器。下载并安装LLVM for Windows之后在VSCode的c_cpp_properties.json里把compilerPath指向clang.exe的实际路径例如{ configurations: [ { name: Win64, compilerPath: C:/Program Files/LLVM/bin/clang.exe, intelliSenseMode: windows-clang-x64 } ], version: 4 }这里的关键体会是配置环境时遇到报错先确认clang --version在终端里能不能正常执行如果能在终端运行但VSCode里不行大概率是VSCode没继承系统的PATH环境变量重启VSCode或者手动设置环境变量文件就好。2.4 第一个验证程序环境装好之后写一个最简单但能验证工具链完整性的程序#include cstdio int main() { for (int i 0; i 3; i) { printf(hello from my compiler\\n); } return 0; }编译运行clang hello.cpp -o hello ./hello如果你的系统里同时装了gcc用clang --version和gcc --version对比一下不需要深入理解但可以感受两者的版本输出风格。接下来才是重头戏——把这行clang编译命令拆开看看编译器内部到底发生了什么。3. 架构先行LLVM三段式设计编译器骨架长什么样3.1 前端、优化器、后端到底各承担什么LLVM的模块化设计把传统编译器拆成三个可独立替换的组件。前端负责把源代码变成中间表示。以C为例Clang解析源码先生成AST抽象语法树再把AST转换成LLVM IR。这个阶段包含了词法分析、语法分析、语义分析和类型检查。Clang都会做而且做得非常严格这也是为什么clang的报错信息通常比gcc更友好。优化器接收IR对IR做各种等价变换目的是让程序跑得更快或者更小。比如死代码消除、循环展开、常量折叠、函数内联。优化器不关心你写的是C还是Rust因为它只看IR。后端把优化后的IR转换成目标机器的汇编或机器码。这里有指令选择、寄存器分配、指令调度等复杂算法。你写一次IRLLVM就能帮你生成X86、ARM、RISC-V等多个平台的目标代码。Rust和Swift编译器之所以敢宣称跨平台很大程度上就是站在了LLVM后端这套成熟架构的肩膀上。3.2 LLVM IR是贯穿流程的“中间货币”IR在LLVM里有三种等价形式内存中的数据结构、二进制bitcode.bc文件、可读的文本形式.ll文件。三种形式承载同样的信息可以在不同阶段互相转换。IR的核心特点是SSA形式也就是静态单赋值。每个变量只能被赋值一次这给优化器提供了极大的便利——不需要反复分析某个变量在程序点上的取值状态简化了数据流分析。第一次看IR的人常常被成千上万的%临时变量搞晕但你只要理解“每个%变量是一次性赋值的快照”这个点读IR就顺畅很多。IR还有一个重要的设计是显式的控制流图。基本块之间用br指令跳转每个基本块内部是一串顺序执行的非终止指令。这种表达方式让优化器的某些变换变得非常直观比如你要做循环优化直接在控制流图上定位循环结构就行。3.3 通过clang -S -emit-llvm直观感知IR理论框架过一遍之后最好用一条命令让IR“显形”clang -S -emit-llvm hello.cpp -o hello.ll打开hello.ll你会看到类似这样的内容define dso_local i32 main() #0 { %1 alloca i32, align 4 store i32 0, i32* %1, align 4 br label %2 2: ; preds %5, %0 %3 phi i32 [ 0, %0 ], [ %6, %5 ] %4 icmp slt i32 %3, 3 br i1 %4, label %6, label %7 6: ; preds %2 %7 call i32 (i8*, ...) printf(i8* noundef getelementptr inbounds ([23 x i8], [23 x i8]* .str, i64 0, i64 0)) br label %8 8: ; preds %6 %9 add nsw i32 %3, 1 br label %2 }注意里面有一个%3 phi i32它表示循环变量i在循环入口处的当前值。第一次看的时候可能会略过但如果你研究循环优化会发现phi节点处处存在。再跑一次带优化管线的高版本IR对比clang -O2 -S -emit-llvm hello.cpp -o hello_o2.ll你会看到-O2下的IR简洁得不像同一个程序循环可能被展开printf的参数直接变成了常量字符串临时变量大幅减少。这就直观展示了“中间表示优化器”这套设计的力量。3.4 顺手回答热词编译器和编辑器的区别说到这我想到热搜里常有“编译器和编辑器的区别”这个问题趁环境刚搭好正好一并说明白。编辑器是帮你输入和修改文本的软件像VSCode、Sublime Text都属于这一类编译器是把源代码文本翻译成可执行程序的软件像clang、g、MSVC都属于这一类。IDE集成开发环境则是编辑器、编译器、调试器、构建工具、版本管理客户端的综合体。VSCode严格说是编辑器但装上插件、配置好tasks.json之后它也能调用外部编译器来扮演半个IDE。理解了这条区分线后面很多配置报错你就能自己判断问题出在哪一层是编辑器没找到编译器配置问题还是编译器本身报错代码或环境问题。4. 手写编译器前端从C源码到AST再到IR4.1 在代码里驱动ClangLibTooling还是libclang配置完环境、看清了IR现在进入正题在我们自己写的程序里驱动Clang。Clang提供了两套主要API一套是C接口libclang稳定但信息密度低适合做索引和补全类工具另一套是C接口LibTooling提供完整的AST访问能力适合做编译器和静态分析工具。我们显然要选LibTooling。版本匹配是第一个坑。用llvm-config获取编译参数而不是自己手动写一堆-I路径llvm-config --cxxflags --ldflags --libs --system-libs如果你是用Homebrew安装的LLVM记得先把/opt/homebrew/opt/llvm/bin放进PATH否则llvm-config可能指向系统自带的版本版本不一致会导致链接时一堆诡异报错。4.2 用RecursiveASTVisitor遍历AST结构AST其实是一棵巨大的树Clang提供了RecursiveASTVisitor模板类你只需要重写它感兴趣的节点处理方法它会自动递归走遍整棵树。下面这个工具会找出源码中所有函数并打印函数名和参数个数#include clang/AST/ASTConsumer.h #include clang/AST/RecursiveASTVisitor.h #include clang/Frontend/CompilerInstance.h #include clang/Tooling/Tooling.h #include llvm/Support/raw_ostream.h using namespace clang; class FuncDeclVisitor : public RecursiveASTVisitorFuncDeclVisitor { public: bool VisitFunctionDecl(FunctionDecl *FD) { if (!FD-isMain()) { llvm::outs() found function: FD-getQualifiedNameAsString() \\n; llvm::outs() parameters: FD-getNumParams() \\n; } return true; } }; class MyASTConsumer : public ASTConsumer { public: bool HandleTopLevelDecl(DeclGroupRef DG) override { for (Decl *D : DG) { if (auto *FD dyn_castFunctionDecl(D)) { FuncDeclVisitor Visitor; Visitor.TraverseDecl(FD); } } return true; } }; class MyFrontendAction : public ASTFrontendAction { public: std::unique_ptrASTConsumer CreateASTConsumer(CompilerInstance CI, StringRef File) override { return std::make_uniqueMyASTConsumer(); } }; int main(int argc, const char **argv) { clang::tooling::runToolOnCode(std::make_uniqueMyFrontendAction(), int add(int a, int b) { return a b; }\\n int main() { return add(1, 2); }); return 0; }编译它需要注意链接顺序和参数clang $(llvm-config --cxxflags) \ ast_dump.cpp \ $(llvm-config --ldflags --libs --system-libs) \ -o ast_dump运行后输出类似found function: add parameters: 2这个简单工具再往前走一步就能扩展成你自己的静态分析器。比如在VisitFunctionDecl里记录所有函数的入参类型、返回类型、调用关系然后生成自定义报告。这类能力在企业级的代码规范检查、接口变更影响分析里非常有用。4.3 从Inline代码到真实文件把前端工具落地runToolOnCode适合快速验证思路但真实场景是处理一个项目目录里的多个文件。这时应该用ClangTool和newFrontendActionFactory#include clang/Tooling/CommonOptionsParser.h #include clang/Tooling/Tooling.h #include llvm/Support/CommandLine.h using namespace clang::tooling; using namespace llvm; static cl::OptionCategory MyToolCategory(my-tool options); int main(int argc, const char **argv) { auto ExpectedParser CommonOptionsParser::create(argc, argv, MyToolCategory); if (!ExpectedParser) { llvm::errs() ExpectedParser.takeError(); return 1; } CommonOptionsParser OptionsParser ExpectedParser.get(); ClangTool Tool(OptionsParser.getCompilations(), OptionsParser.getSourcePathList()); return Tool.run(newFrontendActionFactoryMyFrontendAction().get()); }用这样一个工具去处理一个C工程你相当于拥有了一双能“看见”项目全貌AST的眼睛。Clang会帮你去读头文件、解析宏、处理函数重载。市面上不少代码分析工具的工作方式就是这么来的。到这里你的“编译器”已经有了前端能力接收C源码解析成AST还能自定义遍历逻辑。下一步是让IR层面的定制逻辑参与进来让编译器真正产生自己的优化行为。5. 用Pass打造专属优化器从能跑的Pass开始5.1 什么是Pass新旧PassManager怎么选Pass是LLVM优化框架的核心概念。一个Pass就是一次对IR的遍历和变换比如“死代码消除”是一个Pass“函数内联”是另一个Pass。你可以把Pass想象成流水线上的一个加工工位IR从流水线上流过每个工位负责一种特定的修正。LLVM现在推荐的是New PassManager简称新PM。命令行上的表现就是opt -passes...而不是老的opt -foo。新PM的优点是Pass之间的依赖关系更明确缓存机制更完善并且从LLVM 16开始旧PM已经被移除所以新手上路直接学新PM即可不用在旧机制上浪费时间。5.2 写一个HelloPass完整可编译代码下面这个Pass是最简单的观察型Pass遍历每个函数在终端打印函数名。它不修改IR所以返回PreservedAnalyses::all()告诉优化管理器“我的变换没有动任何东西不需要重跑分析”#include llvm/IR/Function.h #include llvm/IR/PassManager.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 extern C LLVM_ATTRIBUTE_WEAK PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, HelloPass, 0.1, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-pass) { FPM.addPass(HelloPass()); return true; } return false; }); }}; }这个文件的要点在于llvmGetPassPluginInfo这个导出符号。opt加载插件时查找的就是这个入口然后通过registerPipelineParsingCallback把你的Pass和命令里的hello-pass名字绑定起来。编译成动态库clang -fPIC -shared hello_pass.cpp -o hello_pass.so \ $(llvm-config --cxxflags --ldflags --libs --system-libs)在macOS上把-shared换成-dynamiclib输出文件后缀改成.dylib。编译时如果报一堆clang头文件找不到还是版本匹配问题先确认llvm-config的版本和编译器的版本属于同一个发行包。5.3 用opt加载并运行Pass先准备一份测试IR。直接用之前生成的hello.ll就可以opt -load-pass-plugin./hello_pass.so -passeshello-pass hello.ll -o /dev/null注意-passeshello-pass这个名字必须和你在插件注册代码里写的字符串完全一致。运行后终端会打印每个函数的名字。如果你把-o /dev/null换成实际输出文件观察型Pass虽然没改IR但也说明整套插件机制是通的。到这里第一个Pass已经跑了。但观察型Pass毕竟只“看”不“改”。要做真正属于自己的编译器需要让Pass修改IR这正是LLVM最核心的用途。5.4 从“看”到“改”一个真正修改IR的插桩Pass思路来说一个我实际做过的插桩方向给目标工程里所有函数入口插入一行日志输出方便排查线上崩溃时的函数调用序列。核心手段是IRBuilder。思路分成三步第一步在Module里获取或创建printf的函数声明第二步在目标函数的entry基本块开头创建一个CallInst调用printf打印函数名第三步返回PreservedAnalyses::none()因为IR被修改了LLVM需要重新做依赖分析。伪代码骨架大致如此#include llvm/IR/IRBuilder.h PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { Module M *F.getParent(); LLVMContext Ctx M.getContext(); // 1. 查找或创建 printf 的声明 FunctionCallee PrintfFn M.getOrInsertFunction( printf, FunctionType::get(IntegerType::getInt32Ty(Ctx), PointerType::getUnqual(Ctx), true)); // 2. 构造格式字符串 Constant *FormatStr IRBuilder(Ctx).CreateGlobalStringPtr( enter function: %s\\n); // 3. 在函数入口插入调用 IRBuilder Builder(F.getEntryBlock(), F.getEntryBlock().begin()); Value *FuncName Builder.CreateGlobalStringPtr(F.getName()); Builder.CreateCall(PrintfFn, {FormatStr, FuncName}); return PreservedAnalyses::none(); }这个Pass编译出来之后你在opt里跑一遍再用llc生成汇编会看到每个函数入口前多了对printf的调用。把这个Pass注册进opt的流水线你的“编译器”就已经具备了自定义代码生成能力。更进一步如果想让clang在正常编译过程中自动带上这个Pass可以把Pass编译成动态库然后用命令行参数交给clangclang -fpass-plugin./hello_pass.so hello.cpp -o hello_with_pass这样每次用clang编译C工程时你的Pass都会自动跑一遍而编译出来的程序行为也有了你的定制痕迹。到这一步“用ClangLLVM打造你的第一个C编译器”就不只是一句口号了而是一个能感知、能修改、能注入行为的最小编译器实例。6. 实测排查高频报错的修复链路6.1 “clang: error: sdk does not contain libarclite”——macOS上的经典报错如果你在macOS上用过较新的Xcode大概率见过这条clang: error: sdk does not contain libarclite at the path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/archive/1/libarclite_iphoneos.a我第一次遇到时也一头雾水。libarclite是ARC自动引用计数依赖的运行时库文件当Xcode的SDK版本和工具链不完全匹配时编译器去找这个文件就会扑空。常见于Xcode 14.3之后的版本配合某些iOS部署目标或者React Native这类重Objective-C桥接的项目。排查链路建议按顺序来。先确认Xcode命令行工具路径是否正确xcode-select -p如果输出里的Developer目录不对用sudo xcode-select --switch切回正确路径。接着检查libarclite文件是否存在find /Applications/Xcode.app -name libarclite* 2/dev/null如果文件确实缺失优先升级Xcode到最新版本并重新安装Command Line Toolssudo rm -rf /Library/Developer/CommandLineTools xcode-select --install如果报错场景是某个具体工程的构建脚本那多半是脚本里写死了旧版SDK路径。把它改成$(SDKROOT)动态获取当前SDK路径比手动硬编码要稳得多。对于纯C用户通常不会遇到这个报错因为libarclite只和Objective-C的ARC机制相关。如果编译C时莫名报这个错多半是你的构建命令里混进了-fobjc-arc参数去掉即可。6.2 “Microsoft Visual C 14.0 or greater is required”——Windows上的经典报错在Windows上用pip安装dlib、pycocotools或其他含C扩展的Python包时你大概见过这个error: Microsoft Visual C 14.0 or greater is required. Get it with Microsoft C Build Tools原因是pip试图编译C源码来构建Python扩展模块但系统里找不到MSVC的cl.exe编译器。Python的setuptools在Windows上默认要求MSVC而不是MinGW或Clang。解决方法很直接安装Visual Studio Build Tools。到Visual Studio官网下载Build Tools安装时工作负载勾选“使用C的桌面开发”这一项会带上MSVC编译器、Windows SDK和必要的CMake工具。装完后打开“x64 Native Tools Command Prompt for VS 2022”输入cl如果能打印出版本信息说明编译器就位。然后重新执行pip install问题就消失了。这类报错背后的方法学也值得记住凡是看到“xxx compiler is required”的报错先别急着在环境变量里一顿猛加路径而是先确认对应编译器本身是否安装、能否在终端里直接启动。很多日后的“诡异配置问题”在终端里输一遍命令立刻原形毕露。6.3 编译器提示“堆空间不足”和“未包含main”时该查哪里先聊“编译器的堆空间不足”。这在链接大工程时比较常见Windows下会看到LNK1106之类的链接器错误本质是链接工具在解析符号表时内存耗尽。多数情况下把工具链换成64位版本就能解决如果你已经在用64位可以降低链接并行度比如在CMake里cmake --build build -j2同时在CMakeLists里限制一下链接任务set(CMAKE_LINK_CONCURRENCY 2)对于VSCode用户“编译器未包含main类型”这个提示几乎都是入口点的问题。C程序必须有一个main函数如果你写的程序入口叫WinMain这在Windows GUI程序里才合法或者根本没写链接阶段就会报未解析的外部符号。排查时先检查源文件里是否真的存在int main()再有就是确认链接命令是否包含了正确的运行时库。MSVC环境下如果没指定子系统也可能出现入口点不匹配比如你写的是控制台程序却用了/SUBSYSTEM:WINDOWS。这里还有一个经验在VSCode里点击运行按钮报“未包含main类型”最常见的原因其实是tasks.json里的编译命令根本没有成功执行比如编译器路径配错了、源文件参数没传对。先切到终端手动执行那行命令看看真正的编译器输出是什么比在VSCode的报错面板里瞎猜高效得多。6.4 一套省心的VSCode C/C配置示例针对VSCode配置C/C环境的热搜场景给出实际可用的最小配置。tasks.json负责编译{ version: 2.0.0, tasks: [ { label: build with clang, type: shell, command: clang, args: [ -g, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe ], group: build } ] }launch.json负责调试{ version: 0.2.0, configurations: [ { name: C Debug, type: cppdbg, request: launch, program: ${fileDirname}/${fileBasenameNoExtension}.exe, cwd: ${fileDirname}, preLaunchTask: build with clang } ] }配置好之后F5一键编译加调试就都通了。这套配置里值得留意的是preLaunchTask和task的label对应关系如果名字不匹配调试器会一直提示找不到任务。最后再分享几个我实际用下来的小体会版本对齐是最大的坑。llvm-config --version和clang --version如果版本不一致链接时会出现各种莫名其妙的问题比如符号找不到、头文件API不匹配。我踩过最难的一次坑就是macOS上系统自带clang和Homebrew LLVM混用排查了整整一个下午最后发现是两个版本的头文件冲突。建议项目里所有LLVM相关命令都用同一个版本的安装路径最好用绝对路径方式调用。其次是不要一上来就照着老教程敲。网上很多资料还在写opt -hello之类的旧PM命令你在LLVM 16以上的版本里运行会直接报错。判断教程新旧有一个简单办法命令里出现-passes这种写法的是新PM还教opt -foo的十有八九过时了。最后是善用llvm-config。手动指定include路径链接库写错顺序编译报错半小时——这个工具能帮你把编译参数全部生成好少走太多弯路。对于任何刚搭建好LLVM环境的人遇到编译或链接问题第一反应都应该是跑一下llvm-config --cxxflags --ldflags --libs --system-libs看输出是否正常。把这套工具链的编译调试流程跑顺LLVM带来的是一个延伸空间极大的编译平台你可以从DIY用户逐渐过渡到领域作者角色的阶段。