Clang+LLVM实战:手写一个C++子集编译器
2026/9/18 6:50:32 网站建设 项目流程

我当年第一份编译器相关的活儿,是在别人留下的三万行C++代码里找bug。那个项目用的就是LLVM,但彼时我对它的理解仅限于“clang是个编译器”。后来踩了无数坑,才慢慢把Clang前端、LLVM IR、后端CodeGen这一整套东西串起来。所以今天这篇不想写成教科书,而是想实打实告诉你:用Clang+LLVM打造一个能处理C++子集的“编译器”,到底怎么设计、怎么配置环境、怎么写关键代码、遇到报错怎么排查。

这里要先把一个概念说清楚:标题里的“C++编译器”,并不是让你从零重写一遍Clang——那工程量不是个人项目能扛的。真正的做法是:借助Clang做词法/语法分析,借助LLVM做IR优化和机器码生成,我们自己只实现“前端到IR的翻译逻辑”和“编译驱动流程”。换句话说,你在亲手搭一条迷你版编译流水线,而Clang+LLVM是这条流水线上最可靠的标准件。

这篇文章适合三类人:一是刚学完C++想进阶看编译原理的学生,二是工作中要写DSL(领域特定语言)、做代码插桩或性能工具的工程师,三是纯粹好奇“编译器到底怎么把源码变成可执行文件”的爱好者。下面内容不玩虚的,按我实际跑通的路径来。

1. 项目全景:这个“编译器”到底在做什么

1.1 核心需求拆解

接到类似“用LLVM做编译器”的需求时,第一步不是写代码,而是把目标切成可执行的三块:

  • 前端:读入源代码,做词法分析、语法分析,输出抽象语法树(AST)。
  • 中端:把AST翻译成LLVM IR,并做必要的优化。
  • 后端:交给LLVM自带的Backend,把IR编译成目标平台机器码。

我这次的目标语言命名为MiniC++,语法是C++的一个极小子集,支持int型变量声明、赋值、四则运算、括号表达式,以及一条print语句。别小看这个范围,它能覆盖编译过程里最核心的概念:Token流、递归下降解析、AST节点、类型、IR指令、基本块。把这条链路跑通,之后加if、for、函数调用都只是重复劳动。

1.2 为什么选Clang+LLVM而不是手写全栈

很多人会问,动态语言里随便用正则就能“分析”源码,为什么要上LLVM这么重的东西?因为正则匹配只能做模式提取,做不了嵌套结构和类型检查。真正的编译需要把源码转成树,再转成中间表示,最后落到寄存器分配和指令选择这些硬核环节。

Clang负责把C++源码吃进去,LLVM负责CodeGen,我们的MiniC++就搭在两者组成的骨架上。而且LLVM的IR是静态单赋值(SSA)形式,每条指令都是“计算结果赋值给一个虚拟寄存器”,非常规范,适合学习也适合实际做工具链开发。

1.3 这个项目的学习价值

做完这个小编译器,你会顺手搞懂一堆看似零散的问题:为什么编译器能报出“缺少分号”而不是“运行出错”?为什么C++的编译比解释型语言慢?为什么Debug版本的二进制那么大?这些全都在编译流水线的不同阶段里写着答案。

2. 环境准备:LLVM/Clang完整安装与工程初始化

2.1 三个主流平台的安装方案

先说结论:不要自己从源码编LLVM,除非你要改LLVM本身的代码。标准做法是直接用官方预编译包或包管理器。

  • macOS:用Homebrew安装,命令是brew install llvm。装完注意,Homebrew的LLVM是keg-only,会提示你把它加入PATH:
export PATH="/opt/homebrew/opt/llvm/bin:$PATH" export LDFLAGS="-L/opt/homebrew/opt/llvm/lib" export CPPFLAGS="-I/opt/homebrew/opt/llvm/include"
  • Ubuntu/Debian:apt库里就有Clang和LLVM,版本可能偏旧但对学习完全够用:
sudo apt update sudo apt install clang llvm lld cmake ninja-build
  • Windows:推荐两条路。一是装官方LLVM Windows安装包,安装程序会把clang和llvm工具链放进PATH;二是用MSYS2,在MSYS2终端里执行pacman -S mingw-w64-x86_64-clang mingw-w64-x86_64-llvm mingw-w64-x86_64-cmake。我自己在Windows上更喜欢MSYS2这条,因为后续用CMake生成工程更顺。

2.2 版本选择的关键细节

LLVM的C++ API在不同大版本之间有过几次破坏性变化,尤其是PassManager和IRBuilder相关接口。比如LLVM 15之前用的legacy PassManager,在15之后被新的PassBuilder机制替代。所以我强烈建议:

  • 教程和API文档以llvm-config --version输出的版本为准。
  • 把LLVM版本写死在CMakeLists.txt里,不要“兼容所有版本”,那只会让代码充满#if分支。

检查环境是否就绪,分别执行:

clang --version llvm-config --version

我这里实测输出的是clang version 17.0.617.0.6,后面代码全部基于这个版本,如果你用的是14或15版本,遇到编译报错先去看文档。

2.3 用CMake搭建项目骨架

在项目根目录写一个CMakeLists.txt,用llvm-config自动拼接头文件和库路径:

cmake_minimum_required(VERSION 3.20) project(MiniCompiler) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) execute_process( COMMAND llvm-config --cxxflags OUTPUT_VARIABLE LLVM_CXX_FLAGS OUTPUT_STRIP_TRAILING_WHITESPACE ) execute_process( COMMAND llvm-config --ldflags --libs core irreader OUTPUT_VARIABLE LLVM_LIBS OUTPUT_STRIP_TRAILING_WHITESPACE ) add_executable(minicpp src/lexer.cpp src/parser.cpp src/codegen.cpp src/main.cpp ) target_include_directories(minicpp PRIVATE ${LLVM_INCLUDE_DIRS} ${CMAKE_CURRENT_SOURCE_DIR}/include ) target_compile_options(minicpp PRIVATE ${LLVM_CXX_FLAGS}) target_link_libraries(minicpp PRIVATE ${LLVM_LIBS})

这里有个非常容易踩的坑:llvm-config --libs默认输出的库列表是按顺序排列的,如果出现大量“undefined reference”错误,多半是库顺序不对。可以直接在命令行里跑llvm-config --ldflags --libs core irreader看输出,再复制到CMake里,别自作聪明改顺序。

2.4 VSCode里的C++开发环境配置

很多人在VSCode里写C++,是因为它的IntelliSense引擎本身也是个编译器前端,这个类比非常有意思。配置C/C++环境时,如果遇到“network unavailable”这种网络诊断提示,通常是把C_Cpp.default.compilerPath指到了不存在或者没权限的路径上。

我的推荐做法:

  • 安装C/C++扩展和CMake Tools扩展。
  • .vscode/c_cpp_properties.json里明确指定编译器路径。
{ "configurations": [ { "name": "LLVM", "compilerPath": "/opt/homebrew/opt/llvm/bin/clang++", "intelliSenseMode": "macos-clang-arm64", "cppStandard": "c++17" } ], "version": 4 }

编译器路径别写clang++这种裸命令,因为VSCode进程的环境变量可能和你终端不一样。写完整绝对路径,能省掉一半环境相关的玄学报错。

3. 核心原理:Clang前端与LLVM后端如何协同

3.1 从源码到可执行文件的完整旅程

一行C++代码要经过这些阶段才能变成机器指令:

  1. 预处理:处理#include、宏展开。
  2. 词法分析:把字符流切成token集合。
  3. 语法分析:根据文法规则,把token流变成AST。
  4. 语义分析:做类型检查、作用域检查。
  5. 中间代码生成:把AST翻译成LLVM IR。
  6. 优化:IR层面做常量折叠、死代码删除等。
  7. 代码生成:IR变成汇编,汇编变成目标文件。
  8. 链接:多个目标文件和库合并为可执行文件。

Clang做的是2到4步,LLVM做的是5到7步的后半程和大部分第6步。我们的MiniC++目前自己写2、3、5的一部分,把4简化掉,6和7交给LLVM。

3.2 LLVM IR到底是什么

LLVM IR看起来像一种介于汇编和C之间的伪汇编。把下面这段C代码:

int add(int a, int b) { return a + b; }

clang -S -emit-llvm编译,会得到类似这样的IR:

define i32 @add(i32 %a, i32 %b) { entry: %addtmp = add i32 %a, %b ret i32 %addtmp }

理解IR的几个关键点:

  • i32是32位整数类型。
  • 寄存器名字前带%,全局符号前带@
  • 每条指令都“产生”一个新值,并且每个值只能被赋值一次,这就是SSA形式。

你可以在终端里随便写个.c文件,运行clang -S -emit-llvm file.c -o file.ll,亲眼看到C代码变成IR。这个动作本身就很有教学价值,我建议看这篇的你先做一次,三分钟就能建立起“IR在C和汇编之间”的直观感受。

3.3 编译器的正确切分方式

很多业余项目失败,是因为把“编译器”当成一个黑盒,疯狂往一个compileAll()函数里塞逻辑。正确切分有两条铁律:

  • 数据流单向:源文件 -> Token数组 -> AST节点 -> IR模块,每一层只依赖上一层产物,绝不反向引用。
  • 错误处理前置:词法、语法阶段就该报“第几行第几列出错”,代码生成阶段不再做源码级别的检查。

遵循这两条,就算代码只有几百行,维护起来也像模像样。它们就是真实编译器里FrontendBackend解耦的微型体现。

4. 动手实现:MiniC++编译器的完整开发过程

4.1 词法分析器:把源码切成Token

词法分析器负责把原始字符串变成Token。Token的关键属性是类型、文本、行号、列号。MiniC++的Token类型不需要很多:

  • 标识符:int、变量名。
  • 数字字面量:一串数字字符。
  • 运算符与符号:=+-*/();{}
  • 结束符:EOF

我习惯用一个简单的枚举加结构体来表达:

enum class TokenType { Identifier, Number, Assign, Plus, Minus, Star, Slash, LParen, RParen, LBrace, RBrace, Semi, Print, Int, End }; struct Token { TokenType type; std::string text; int line; int col; };

扫描循环的逻辑就是“跳过空白和换行,再看当前字符属于哪一类”。这里容易出现的问题是:把两个字符的运算符(比如==&&)拆成两个单字符Token。MiniC++里暂时没有这些运算符,所以不用处理;如果以后要扩展,建议在词法阶段一次读完整,否则后面parser里要做二次归并,很别扭。

4.2 递归下降语法分析:生成AST

语法分析是整条链路里最容易写崩的部分。MiniC++的语法我设计成最简的语句列表:

program := statement* statement := declaration | assignment | print declaration := 'int' identifier ('=' expression)? ';' assignment := identifier '=' expression ';' print := 'print' expression ';' expression := term (('+' | '-') term)* term := factor (('*' | '/') factor)* factor := number | identifier | '(' expression ')'

对应的AST节点我定义成类似这样:

struct Expr { virtual ~Expr() = default; }; struct IntExpr : Expr { int value; }; struct VarExpr : Expr { std::string name; }; struct BinaryExpr : Expr { char op; std::unique_ptr<Expr> lhs; std::unique_ptr<Expr> rhs; }; struct Statement { virtual ~Statement() = default; }; struct DeclStmt : Statement { std::string name; std::unique_ptr<Expr> init; }; struct AssignStmt : Statement { std::string name; std::unique_ptr<Expr> value; }; struct PrintStmt : Statement { std::unique_ptr<Expr> value; }; struct Program { std::vector<std::unique_ptr<Statement>> stmts; };

递归下降解析的核心套路是一个函数解析一种语法规则,照着我上面写的文法逐条翻译即可。比如expression函数长这样:

std::unique_ptr<Expr> parseExpression() { auto lhs = parseTerm(); while (peek().type == TokenType::Plus || peek().type == TokenType::Minus) { char op = (peek().type == TokenType::Plus) ? '+' : '-'; nextToken(); auto rhs = parseTerm(); auto bin = std::make_unique<BinaryExpr>(); bin->op = op; bin->lhs = std::move(lhs); bin->rhs = std::move(rhs); lhs = std::move(bin); } return lhs; }

这里有个容易懵的点:为什么加减要循环,乘除要单独函数?因为运算符优先级。减法、除法不满足结合律,但把这层处理好最优雅的办法就是分层:expression层只处理加减,term层处理乘除,factor层处理括号和原子项。这就是为什么1+2*3会正确解析成1+(2*3)

4.3 用LLVM API生成IR

AST解析完成后,进入真正的LLVM环节。我维护一个llvm::LLVMContext、一个llvm::Module、一个llvm::IRBuilder<>,三者几乎是每个LLVM代码生成器的标准配置。

生成函数入口的代码逻辑是:在Module里创建一个名为main的函数,类型是int(),然后创建入口基本块,在这个基本块里逐条翻译语句。

std::unique_ptr<llvm::Module> codegen(Program& prog) { auto int32Ty = llvm::Type::getInt32Ty(context); auto mainFnType = llvm::FunctionType::get(int32Ty, false); auto mainFn = llvm::Function::Create(mainFnType, llvm::Function::ExternalLinkage, "main", module.get()); auto entryBB = llvm::BasicBlock::Create(context, "entry", mainFn); builder.SetInsertPoint(entryBB); for (auto& stmt : prog.stmts) { codegenStmt(*stmt); } // 默认返回0 builder.CreateRet(llvm::ConstantInt::get(int32Ty, 0)); return std::move(module); }

表达式生成的通用做法是根据AST节点类型分支。以加法为例,IRBuilder提供的CreateAdd会生成一条add指令:

Value* codegenExpr(Expr& expr) { if (auto* num = dynamic_cast<IntExpr*>(&expr)) { return llvm::ConstantInt::get(llvm::Type::getInt32Ty(context), num->value); } if (auto* var = dynamic_cast<VarExpr*>(&expr)) { auto* ptr = namedValues[var->name]; return builder.CreateLoad(llvm::Type::getInt32Ty(context), ptr, var->name); } if (auto* bin = dynamic_cast<BinaryExpr*>(&expr)) { auto* lhs = codegenExpr(*bin->lhs); auto* rhs = codegenExpr(*bin->rhs); switch (bin->op) { case '+': return builder.CreateAdd(lhs, rhs, "addtmp"); case '-': return builder.CreateSub(lhs, rhs, "subtmp"); case '*': return builder.CreateMul(lhs, rhs, "multmp"); case '/': return builder.CreateSDiv(lhs, rhs, "divtmp"); } } return nullptr; }

print语句稍微麻烦一点,需要在Module里声明C库的printf函数,然后创建字符串常量作为格式串:

auto* printfFn = module->getOrInsertFunction("printf", llvm::FunctionType::get(llvm::Type::getInt32Ty(context), llvm::Type::getInt8PtrTy(context), true)); llvm::Value* formatStr = builder.CreateGlobalStringPtr("%d\n", "fmt"); builder.CreateCall(printfFn, {formatStr, value});

这里一个常见的LLVM新手坑:CreateGlobalStringPtr创建的变量在IR里的类型是[4 x i8]*,而printf接受的是i8*,IRBuilder会自动做退化转换,但如果你在调试时直接打印formatStr的type,别被那一堆数组类型搞晕。

4.4 组装完整流程与运行验证

main.cpp承担编译驱动工作:读文件 -> 词法分析 -> 语法分析 -> LLVM代码生成 -> 输出IR文件。核心调用链如下:

int main(int argc, char** argv) { std::string source = readFile(argv[1]); Lexer lexer(source); auto tokens = lexer.tokenize(); Parser parser(tokens); auto program = parser.parseProgram(); auto module = codegen(*program); // 校验IR if (verifyModule(*module, &llvm::errs())) { llvm::errs() << "IR verification failed\n"; return 1; } std::error_code EC; llvm::raw_fd_ostream out("output.ll", EC); module->print(out, nullptr); return 0; }

生成的output.ll还不是可执行文件。要真正运行它,有两种方式:

  • 用LLVM自带的解释器:lli output.ll
  • 继续走编译后端:clang output.ll -o mini_prog && ./mini_prog

我用一个测试文件验证:

int a = 3 + 4 * 2; print a; print (a + 1) * 5;

编译并运行后,终端输出是1160,结果正确。看到这两个数字从自己写的编译器里产生,那种成就感不是用现成编译器能比的。

5. 完整配置步骤与调试技巧

5.1 一步步跑通示例工程

我整理一份“从头到尾五分钟跑通”的完整操作序列,这里假设你已经按第2章装好LLVM:

  1. 创建项目目录,把CMakeLists.txt和src下的源文件按结构放好。
  2. 新建build目录,在终端执行:
mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Debug make -j4
  1. 用mini工具编译测试文件:
./minicpp ../test/mini.txt
  1. 把生成的output.ll编译成可执行文件并运行:
clang output.ll -o test_prog ./test_prog

如果卡在cmake阶段找不到LLVM,执行llvm-config --cxxflags看是否输出正常。如果提示找不到命令,把/opt/homebrew/opt/llvm/bin(macOS)或/usr/lib/llvm-17/bin(Ubuntu)加到PATH里。

5.2 IR可视化调试三板斧

写编译器最痛苦的不是写,而是“咦,好像结果不对,但不知道哪里不对”。我总结了三招,基本能覆盖九成问题:

第一,打印IR。在codegen完成后把IR文本输出到文件,肉眼检查每条指令是否符合预期。比如上面那个print (a+1)*5,IR里应该出现一次mul和一次add,指令顺序能对应上AST的运算顺序。

第二,verifyModule。在输出前加这个校验,它会检查类型是否匹配、基本块是否有终止指令、SSA约束是否被破坏。很多崩溃其实是IR不合法,运行前就能发现。

第三,插桩调试。在codegen函数里临时打印当前处理的AST节点名,比如进入codegenStmt时打印printassign。一旦IR生成异常,你能马上定位是哪个语句先出了错。

5.3 实战中遇到的经典编译错误

要区分“你代码写错”和“环境不匹配”两类报错:

  • clang: error: sdk does not contain 'libarclite' at the path ...:这是典型的Xcode CommandLineTools与Homebrew Clang版本不匹配导致的。解决办法是重新安装CommandLineTools,或者干脆用系统自带的/usr/bin/clang,不做混用。
  • undefined reference tomain``:排查是不是IR里没有创建main函数,或者创建时函数名写错。
  • Expected Instruction as operand:说明你CreateLoad时传了一个非指针类型,通常是namedValues里存的类型不对。

这类问题Google一搜一大把,但搜之前先弄清楚错误发生在哪一层,是词法、语法还是代码生成。层面对了,排查速度快十倍。

6. 常见问题与避坑实录速查表

6.1 近期高频报错与处理方案

我从社区和实际答疑里整理了几条最容易卡住初学者的报错,做成表格,遇到直接对着看:

报错/场景原因分析处理办法
sdk does not contain 'libarclite'Xcode工具链与独立Clang版本冲突重装CommandLineTools,或统一用系统clang
Microsoft Visual C++ 14.0 or greater is requiredWindows下缺少MSVC构建工具安装Visual Studio Build Tools,勾选C++桌面开发组件
compiler path network unavailable(VSCode)IDE环境变量与终端不一致在配置里写死编译器绝对路径,关掉自动检测
LLVM ERROR: Cannot select...生成了目标机不支持的IR指令检查是否把CreateSDiv等指令用在错误类型上
链接阶段一堆undefined referencellvm-config --libs库顺序不对不要手动重排库列表,原样粘贴到链接行

6.2 编译器和编辑器之别,别再混为一谈

这个话题几乎每周都有人问。编辑器是你写代码的工具,它做语法高亮、自动补全、括号匹配;编译器是你写完之后把代码“翻译”成机器语言,让操作系统能直接运行的工具。VSCode是编辑器,clang是编译器,两者配合是因为编辑器内部调用了编译器的词法分析能力来做智能提示,但VSCode本身不会把你的源码变成可执行文件。

理解了这层关系,你就明白为什么“在VSCode里配好C++就能跑”是个错觉:真正干活的是VS Code调用的clang/gcc/MSVC。这也是为什么我把VSCode配置单独放进一节的缘由——它经常被当成“编译器”让人误解。

6.3 我给初学者的三条实战建议

  • 先别急着写代码生成,把词法、语法分析器用一周时间磨扎实。寄存器分配、指令选择这些高级话题,等手头这个MiniC++跑通后再碰。
  • 用LLVM至少一个月后再考虑自己从零写后端。搞懂IR、优化pass、CodeGen这三板斧,已经能覆盖很多真实项目需求。
  • 每改一次代码就重新跑一遍verifyModule和IR打印。宁可慢一点,也要让错误暴露在最早阶段。

我在实际开发中最深的体会是:编译器的每个模块都相当单纯,词法器只是扫描,语法分析器只是按规则递归,codegen只是发指令;难点全在模块之间数据结构的衔接,以及调试时是否能快速定位错误发生在哪一层。MiniC++这个项目不复杂,但它把这条链路的骨架完整立起来了,之后无论是加语法糖、做静态分析插件还是接一个新后端,你都知道该往哪一层下手。

最后再分享一个我一直在用的习惯:把clang -S -emit-llvm生成的IR,和你自己编译器生成的IR放在一起对比。遇到语义差异,逐指令diff,很快就能看出是自己漏了类型转换,还是AST结构设计走了弯路。这个小技巧,比翻十篇文档都顶用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询