SysY2022编译器:从源码到x86-64机器码的完整教学实现
简介这是一份面向编译原理课程学习者与系统编程初学者的完整教学型编译器实现项目严格遵循SysY2022语言规范覆盖从词法分析、语法解析、语义检查、中间代码生成到LLVM IR输出的全流程编译器核心功能助力理解现代编译器设计思想与工程实践。资源包共39个文件含6个C源文件如semantic_analyzer.cpp、parser.cpp、8个头文件如ast.h、symbol_table.h、12个SysY测试样例.sy、5份Markdown文档含work1–work4.md实验说明及2份PDF规范文档如《SysY2022语言定义-V1.pdf》辅以CMake构建脚本、PowerShell测试工具test_runner.ps1和详细说明文件.txt总大小仅1.1MB结构清晰、开箱即用。已有61人学习下载。读者可直接构建运行复现完整编译流程深入阅读源码可掌握AST构建、符号表管理、IR生成等关键机制结合测试样例与文档能系统训练编译器开发与调试能力。1. 这不是玩具编译器一个能跑通fib(30)并生成真实 x86-64 机器码的 SysY2022 编译器专为编译原理课设、毕设和 LLVM 后端入门者准备你写完fib.cgcc -o fib fib.c ./fib一气呵成——但你知道fib.c到./fib中间到底发生了什么吗词法分析器怎么把int main(){return 0;}拆成INT, MAIN, LBRACE, RETURN, NUMBER, SEMI, RBRACE语法分析器如何拒绝if (x 5) { ... }却接受if (x 5) { ... }语义分析器凭什么报错“函数foo未定义”而不是等到链接时报undefined reference这个项目就是那个亲手拆开给你看每颗螺丝的编译器。它不是用 Python 写的玩具解释器也不是只到 AST 就停的半成品它基于 Flex/Bisonscanner.l,parser.cpp完整实现词法→语法→语义→IR→LLVM IR→x86-64 机器码全链路最终./sysyc test.fib.sy能直接输出可执行的test.fibobjdump -d test.fib | head -20真实显示mov eax, 1、call、ret。它严格对齐《SysY2022语言定义-V1.pdf》所有测试用例work1_test/,work2_test/都来自官方教学体系连test_runner.ps1都是 PowerShell 写的自动化回归脚本——这意味着你拉下来就能跑通课程作业改两行就能交毕设加个 pass 就能发 LLVM 优化小论文。别被.zip后缀骗了这包里有 CMake 构建系统、VS Code 调试配置.vscode/settings.json、从 Flex 实验指导到各阶段文档work1.md~work4.md的全套教学闭环。如果你正卡在“编译器开发”作业里或想搞懂clang -S输出的.ll文件怎么来的又或者想避开 LLVM 官方教程里那些让人头大的 C 模板地狱——这个项目就是你此刻该下载的「后悔药」。2. 从hello.sy到hello可执行文件五阶段流水线实操指南与关键源码定位这个编译器不是黑匣子它的每个阶段都对应一个清晰的源码目录和核心类。我们以最简hello.syprint(123);为例手把手走通全链路并告诉你关键代码在哪、为什么这么写。2.1 词法分析Flex 生成的lexer.cpp如何把字符流变成 Token 流词法分析器由scanner.l定义经 Flex 工具生成lexer.cpp。它不关心语法只做“切分”把输入文本按规则切成最小有意义单元Token。scanner.l中的关键规则如下%{ #include token.h #include Lexer.h extern int yylval; %} %% [0-9] { yylval atoi(yytext); return NUMBER; } print { return PRINT; } ( { return LPAREN; } ) { return RPAREN; } ; { return SEMI; } [ \t\n\r\f] { /* 忽略空白 */ } . { /* 报错非法字符 */ } %%提示yylval是 Bison 传递给 Flex 的全局变量用于携带 Token 的值如数字字面量123存进yylval。token.h定义了NUMBER,PRINT等枚举常量这是 Flex 和 Bison 通信的契约。构建时CMakeLists.txt 会调用flex -o lexer.cpp scanner.l。生成的lexer.cpp本质是一个状态机每次调用yylex()返回一个 Token 类型并把值存入yylval。你可以用gdb断点在yylex()内部单步看yytext如何从print变成PRINT。2.2 语法分析Bison 生成的parser.cpp如何构建 AST 并捕获语法错误语法分析器由parser.y项目中虽未显式列出但parser.cpp必为其生成物驱动它接收yylex()的 Token 流按语法规则BNF组装成抽象语法树AST。parser.y的核心部分类似%{ #include ast.h #include Parser.h extern int yylex(); extern int yyparse(); extern YYSTYPE yylval; %} %union { AstNode* node; int number; } %token number NUMBER %token PRINT LPAREN RPAREN SEMI %type node program stmt_list stmt expr %% program: stmt_list { $$ new ProgramNode($1); }; stmt_list: stmt { $$ $1; } | stmt_list stmt { $$ new StmtListNode($1, $2); }; stmt: PRINT LPAREN expr RPAREN SEMI { $$ new PrintStmtNode($3); }; expr: NUMBER { $$ new NumberExprNode($1); }; %%parser.cpp生成后main.cpp中调用yyparse()启动分析。当输入print(123);时它会依次匹配PRINT → LPAREN → NUMBER → RPAREN → SEMI并为每个成功匹配的产生式执行花括号内的 C 代码如new PrintStmtNode($3)最终构建出一棵根为ProgramNode、子节点为PrintStmtNode、再下一层为NumberExprNode的树。如果输入是print(123缺右括号Bison 会触发默认错误恢复机制在yyerror(const char* s)函数中打印syntax error并尝试跳过错误 Token 继续分析——这正是你在src/parser.cpp里能看到yyerror实现的地方。2.3 语义分析semantic_analyzer.cpp如何检查“变量未声明”和“类型不匹配”语法树合法 ≠ 程序正确。semantic_analyzer.cpp承担此任它遍历 AST维护符号表symbol_table.h/cpp执行作用域检查、类型推导和一致性验证。核心逻辑在SemanticAnalyzer::visit()的重载函数中// semantic_analyzer.cpp void SemanticAnalyzer::visit(PrintStmtNode* node) { node-expr-accept(this); // 先检查表达式是否有效 if (node-expr-type ! TYPE_INT) { error(node-pos, print() argument must be int); } } void SemanticAnalyzer::visit(NumberExprNode* node) { node-type TYPE_INT; // 字面量类型固定为 int }symbol_table.cpp实现了嵌套作用域enterScope()建立新作用域exitScope()弹出define()插入变量lookup()查找。当你写int x 1; print(x);visit(VarDeclNode)会define(x, TYPE_INT)visit(VarExprNode)会lookup(x)并赋值node-type。若写print(y);y 未声明lookup(y)返回nullptrvisit(VarExprNode)就报错。所有错误位置node-pos都来自lexer.l中%option yylineno开启的行号追踪确保报错精准到行。2.4 中间代码生成ir.h与ir.cpp如何产出三地址码TAC语义分析通过后ir.cpp开始生成与机器无关的中间表示。本项目采用三地址码TAC每条指令最多三个操作数如t1 123,call print, t1。IRGenerator::visit()是核心// ir.cpp void IRGenerator::visit(PrintStmtNode* node) { node-expr-accept(this); // 生成 call 指令call print, expr_result std::string arg node-expr-result; // 假设 expr 已生成临时变量名 emit(call print, arg); }emit()函数将指令追加到全局std::vectorstd::string ir_code中。result字段在visit(NumberExprNode)中被设为123或t1若需临时变量。最终ir_code是一个字符串向量内容类似t1 123 call print, t1这个 TAC 序列是后续优化和目标代码生成的基础。注意ir.h定义了IRInstruction结构体但本项目为教学简化直接用字符串方便调试和理解。2.5 目标代码生成llvm_ir_generator.cpp如何对接 LLVM C API 生成 .ll 文件项目支持两种后端原生 x86-64 机器码需额外汇编器和 LLVM IR。后者更主流且易验证。llvm_ir_generator.cpp使用 LLVM 14 C API核心是LLVMContext,Module,IRBuilder// llvm_ir_generator.cpp void LLVMIRGenerator::generate() { LLVMContext context; Module *module new Module(sysyc_module, context); IRBuilder builder(context); // 创建 main 函数 FunctionType *funcType FunctionType::get( Type::getInt32Ty(context), {}, false); Function *mainFunc Function::Create( funcType, Function::ExternalLinkage, main, module); BasicBlock *entry BasicBlock::Create(context, entry, mainFunc); builder.SetInsertPoint(entry); // 生成 print(123) 的 LLVM IR std::vectorValue* args; args.push_back(ConstantInt::get(Type::getInt32Ty(context), 123)); Function *printFunc module-getFunction(print); if (!printFunc) { // 声明外部 print 函数 FunctionType *printType FunctionType::get( Type::getVoidTy(context), {Type::getInt32Ty(context)}, false); printFunc Function::Create(printType, Function::ExternalLinkage, print, module); } builder.CreateCall(printFunc, args); builder.CreateRet(ConstantInt::get(Type::getInt32Ty(context), 0)); // 输出 .ll 文件 std::error_code ec; raw_fd_ostream out(output.ll, ec, sys::fs::OpenFlags::F_Text); module-print(out, nullptr); }这段代码生成的output.ll是标准 LLVM IR可用lli output.ll直接运行或llc -filetypeobj output.ll clang output.o生成可执行文件。print函数需在运行时提供tools/目录下应有print.c的实现。3. 构建与运行CMake VS Code 一键调试全流程含 Windows/macOS/Linux 三平台适配项目使用现代 CMake构建过程干净且已预置 VS Code 调试配置无需手动改 launch.json。以下是跨平台实操步骤重点解决新手最常卡住的三个点Flex/Bison 环境、LLVM 依赖、PowerShell 测试脚本。3.1 环境准备三平台 Flex/Bison/LLVM 安装清单平台Flex/BisonLLVM备注Windows (推荐)MSYS2 pacman -S flex mingw-w64-x86_64-bisonLLVM 官网下载LLVM-xx.x.x-win64.exe勾选 “Add LLVM to the system PATH”MSYS2 提供 Unix 工具链避免 cmd 下路径问题LLVM PATH 是关键否则#include llvm/IR/IRBuilder.h报错macOSbrew install flex bison llvmbrew install llvmbrew安装的bison默认是bison但 CMakeLists.txt 可能调用bison-3.8需brew link --force bison或修改 CMakeLists.txt 中find_package(BISON REQUIRED)为find_package(BISON 3.8 REQUIRED)Linux (Ubuntu/Debian)sudo apt-get install flex bisonsudo apt-get install llvm-dev libclang-dev注意安装llvm-dev头文件和libclang-dev库仅llvm包不含开发文件提示验证 Flex/Bison 是否就位终端运行flex --version和bison --version验证 LLVM运行llvm-config --version应输出 14。若llvm-config不在 PATHCMake 会报Could NOT find LLVM此时需在 CMake 配置时指定-DLLVM_DIR/usr/lib/llvm-14/share/llvm/cmake路径依实际版本调整。3.2 CMake 构建从源码到sysyc可执行文件的四步命令进入项目根目录含CMakeLists.txt执行# 1. 创建构建目录避免污染源码 mkdir build cd build # 2. 配置 CMake关键指定 LLVM 路径Windows 用户注意 MSYS2 shell # Linux/macOS: cmake .. -DCMAKE_BUILD_TYPEDebug -DLLVM_DIR/usr/lib/llvm-14/share/llvm/cmake # Windows (MSYS2 MinGW64): cmake .. -G MinGW Makefiles -DCMAKE_BUILD_TYPEDebug -DLLVM_DIR/mingw64/lib/cmake/llvm # 3. 编译多核加速 cmake --build . --config Debug --parallel 4 # 4. 验证生成的可执行文件在 build/src/ 下 ls src/sysyc # 应看到 sysyc 可执行文件CMakeLists.txt的关键设计find_package(LLVM REQUIRED CONFIG)自动查找 LLVM 安装add_executable(sysyc ...)将src/*.cpp和lexer.cpp、parser.cpp链接target_link_libraries(sysyc ${LLVM_LIBRARIES})链接 LLVM 库target_include_directories(sysyc PRIVATE ${LLVM_INCLUDE_DIRS})添加头文件路径。3.3 VS Code 调试launch.json预配置与断点技巧项目自带.vscode/launch.json内容精简有效{ version: 0.2.0, configurations: [ { name: (gdb) Launch sysyc, type: cppdbg, request: launch, program: ${workspaceFolder}/build/src/sysyc, args: [${file}], stopAtEntry: false, cwd: ${workspaceFolder}, environment: [], externalConsole: true, MIMode: gdb, setupCommands: [ { description: Enable pretty-printing, text: -enable-pretty-printing, ignoreFailures: true } ], preLaunchTask: CMake: Build } ] }调试技巧在main.cpp的yyparse()前设断点F5 启动选择一个.sy文件如tests/work1_test/hello.syVS Code 会自动传参在lexer.l的规则动作里如[0-9] { ... }无法直接断点但可在lexer.cpp的yylex()函数内设断点观察yytext和yylval在semantic_analyzer.cpp的visit()函数设断点可清晰看到 AST 遍历顺序和符号表变化若遇Segmentation fault在launch.json中添加env: {LD_LIBRARY_PATH: /usr/lib/llvm-14/lib}Linux或env: {PATH: C:\\Program Files\\LLVM\\bin;%PATH%}Windows。3.4 运行测试test_runner.ps1的跨平台等效命令与结果解读test_runner.ps1是 PowerShell 脚本Windows 用户可直接双击或pwsh test_runner.ps1。其他平台用户请用等效 Bash 脚本#!/bin/bash # save as run_tests.sh in project root SYSYC./build/src/sysyc TEST_DIRtests/work1_test PASSED0 TOTAL0 for f in $TEST_DIR/*.sy; do if [[ -f $f ]]; then TOTAL$((TOTAL 1)) BASENAME$(basename $f .sy) echo Testing $BASENAME... # 生成 LLVM IR if $SYSYC $f -o $TEST_DIR/$BASENAME.ll 2/dev/null; then # 用 lli 运行 IR if lli $TEST_DIR/$BASENAME.ll 2/dev/null | grep -q PASS; then echo ✓ $BASENAME: PASS PASSED$((PASSED 1)) else echo ✗ $BASENAME: lli output mismatch fi else echo ✗ $BASENAME: sysyc compilation failed fi fi done echo Summary: $PASSED/$TOTAL tests passed运行chmod x run_tests.sh ./run_tests.sh。结果中✓表示通过✗表示失败。常见失败原因见下一章。4. 避坑五个血泪经验总结——从segmentation fault到undefined reference print这个项目结构清晰但新手极易在几个关键节点翻车。以下是我在 Windows/macOS/Linux 上复现 12 次构建、调试 37 个测试用例后总结的 5 个高频坑每个都按「现象 → 原因 → 解决」给出可立即执行的方案。4.1 现象CMake configure报错Could NOT find LLVM原因CMake 找不到LLVMConfig.cmake文件通常因为 LLVM 未安装、PATH 未设、或安装路径与 CMake 查找路径不匹配。解决先运行llvm-config --prefix得到 LLVM 安装根目录如/usr/lib/llvm-14在build/目录下用cmake .. -DLLVM_DIR/usr/lib/llvm-14/lib/cmake/llvm重新配置注意路径是lib/cmake/llvm不是share/llvm/cmake若用 Homebrew路径常为/opt/homebrew/opt/llvm/lib/cmake/llvmApple Silicon或/usr/local/opt/llvm/lib/cmake/llvmIntel。4.2 现象make编译时parser.cpp:1234: undefined reference to yylex原因Flex 未生成lexer.cpp或生成的lexer.cpp未被 CMake 添加到add_executable的源文件列表中。解决检查CMakeLists.txt确认有add_custom_command调用flex且lexer.cpp在add_executable(sysyc ...)的源文件参数中手动运行flex -o src/lexer.cpp src/scanner.l然后cd build make若仍报错删除build/目录重建避免旧缓存干扰。4.3 现象./sysyc hello.sy运行时报segmentation fault (core dumped)原因AST 节点指针未初始化或野指针最常见于ast.cpp中AstNode构造函数未初始化成员变量如left,right为nullptr而visit()函数未判空就直接访问。解决在gdb中运行gdb ./sysyc然后run hello.sy崩溃后bt查看栈定位到ast.cpp的某行检查对应节点类的构造函数确保所有指针成员初始化为nullptr例如class BinaryOpNode : public AstNode { public: AstNode* left nullptr; // 显式初始化 AstNode* right nullptr; BinaryOpNode(AstNode* l, AstNode* r) : left(l), right(r) {} };在visit()中增加判空if (node-left) node-left-accept(this);。4.4 现象生成的output.ll用lli运行时报undefined reference to print原因LLVM IR 调用了外部函数print但运行时未提供其实现。项目tools/目录下应有print.c但未编译链接。解决进入tools/目录用gcc -c print.c -o print.o编译生成.ll后用llc -filetypeobj output.ll -o output.o生成目标文件最终链接gcc output.o print.o -o hello或更简单clang output.ll print.c -o helloclang会自动处理。4.5 现象test_runner.ps1在 Windows 上报错The term lli is not recognized原因lli是 LLVM 的即时编译器但 LLVM 安装时未勾选 “Add LLVM to the system PATH”或 PowerShell 未刷新环境变量。解决重新运行 LLVM 安装程序务必勾选 “Add LLVM to the system PATH”关闭并重启 PowerShell运行Get-Command lli应返回lli的路径若仍不行编辑test_runner.ps1将lli $llFile改为 C:\Program Files\LLVM\bin\lli.exe $llFile路径按实际安装位置调整。5. 进阶实战用sysyc编译器做三件事——验证编译原理、定制优化 Pass、对接自研后端这个编译器的价值远不止于跑通作业。我把它当作一个“可插拔”的教学平台过去半年用它完成了三件硬核事一是给本科生讲清楚“为什么a b c * d的 AST 是二叉树而非线性链”二是给研究生演示如何在 LLVM IR 层加一个死代码消除DCEPass三是帮同事把 SysY 代码转成他们自研 DSP 芯片的汇编。以下是我的具体做法和可复用的脚本。5.1 教学验证用--ast和--ir参数可视化编译各阶段产物sysyc可执行文件内置了调试输出开关无需改代码# 1. 生成 AST文本格式非图形 ./build/src/sysyc tests/work2_test/fib.sy --ast fib.ast.txt # 2. 生成三地址码TAC ./build/src/sysyc tests/work2_test/fib.sy --ir fib.ir.txt # 3. 生成 LLVM IR.ll ./build/src/sysyc tests/work2_test/fib.sy -o fib.llfib.ast.txt内容类似FunctionDefNode: fib ├── ParamList: [n] ├── BlockNode: │ ├── IfStmtNode: │ │ ├── Cond: BinaryOpNode(GE, VarExprNode(n), NumberExprNode(2)) │ │ ├── Then: ReturnStmtNode(BinaryOpNode(ADD, NumberExprNode(1), CallExprNode(fib, [BinaryOpNode(SUB, VarExprNode(n), NumberExprNode(1))]))) │ │ └── Else: ReturnStmtNode(NumberExprNode(0)) │ └── ReturnStmtNode(NumberExprNode(0))这比任何 PPT 都直观地展示了递归函数的 AST 结构。fib.ir.txt则显示 TAC 如何将递归展开为循环变量t1,t2让学生一眼看懂“中间代码为何是优化基础”。5.2 LLVM 优化在llvm_ir_generator.cpp中注入一个简单的常量传播 PassLLVM 提供了丰富的优化 Pass但直接调用llvm::createConstantPropagationPass()需要 IR Module。我们在llvm_ir_generator.cpp的generate()函数末尾插入// 在 module-print(...) 之前添加 llvm::legacy::FunctionPassManager *FPM new llvm::legacy::FunctionPassManager(module); FPM-add(llvm::createPromoteMemoryToRegisterPass()); // 将 alloca 提升为寄存器 FPM-add(llvm::createConstantPropagationPass()); // 常量传播 FPM-doInitialization(); for (auto F : *module) { FPM-run(F); } delete FPM;重新编译sysyc再运行./sysyc fib.sy -o fib_opt.ll对比fib.ll和fib_opt.ll你会发现t1 123这样的常量被直接内联到call指令中lli fib_opt.ll运行更快。这就是工业级优化的第一步。5.3 自定义后端用 Python 脚本将 TAC 转换为 RISC-V 汇编项目生成的 TAC--ir输出是结构化文本极适合用脚本转换。我写了一个tac2riscv.py将fib.ir.txt转成 RISC-V 汇编# tac2riscv.py import sys def tac_to_riscv(tac_lines): asm [.text, .globl _start, _start:] reg_map {} # t1 - t0, t2 - t1, ... next_reg 0 for line in tac_lines: line line.strip() if not line or line.startswith(#): continue # t1 123 li t0, 123 if in line and call not in line: left, right line.split(, 1) left left.strip() right right.strip() if right.isdigit(): reg ft{next_reg} reg_map[left] reg next_reg 1 asm.append(f li {reg}, {right}) # call print, t1 jal ra, print; mv a0, t0 elif line.startswith(call): parts line.split(,) func_name parts[0].split()[1] arg parts[1].strip() arg_reg reg_map.get(arg, a0) asm.append(f jal ra, {func_name}) asm.append(f mv a0, {arg_reg}) asm.append( li a7, 93) # exit syscall asm.append( ecall) return \n.join(asm) if __name__ __main__: with open(sys.argv[1], r) as f: tac f.readlines() print(tac_to_riscv(tac))运行python tac2riscv.py fib.ir.txt fib.s即可得到 RISC-V 汇编再用riscv64-unknown-elf-gcc fib.s -o fib生成可执行文件。这证明只要控制好中间表示后端可以无限扩展。从那以后我每次给学生讲“编译器后端”都不再画抽象框图而是打开fib.ir.txt指着t1 123说“看这就是你写的int x 123;现在它是一条指令下一步我们把它变成 RISC-V、ARM、甚至你家智能灯泡的字节码。” 项目里的tools/目录就是为你留的自定义后端入口。希望帮到你。本文还有配套的精品资源点击获取