简介:本资源是一份面向高校计算机专业本科生的编译原理课程实践项目,聚焦SysY语言到RISC-V指令集的完整编译器实现,适用于期末大作业、课程设计及系统能力训练。项目基于C++开发,代码结构清晰、注释详尽,涵盖词法分析(.l)、语法分析(.y)、AST构建、中间表示(koopa)、后端代码生成(.s/.o)等核心编译流程,新手可快速理解编译全流程。压缩包共27个文件,含10个头文件(hpp)、6个源文件(cpp)、1个SysY语法定义(.y)、1个词法规则(.l)、1个测试用例(hello.c)及配套的koopa IR、汇编与目标文件等,总大小仅108KB,轻量易部署。目前已有99人学习下载,附带完整实践报告与README说明,内容覆盖设计思路、关键算法实现、测试方法及常见问题解析,是高分课程实践的可靠参考范本。
1. 这不是玩具编译器:一个能跑通hello.c→hello.o→ RISC-V 真机执行的 SysY 编译器,专为编译原理课设/期末大作业而生
你手头正堆着《编译原理》清华第三版第二章的习题、老师刚布置的“实现一个前端+中端+后端”的课程设计、Deadline 前三天还卡在词法分析器报错——别急,这不是又一个只画 AST 图就交差的 PPT 项目。这个基于 C++ 的 SysY 到 RISC-V 编译器,是实打实跑通了完整编译流水线的高分课设:它能把hello.c(SysY 语法子集)经词法分析(.l)、语法分析(.y)、AST 构建、IR 生成(koopa IR)、寄存器分配、指令选择、汇编生成(.s),最终产出标准 ELF 格式hello.o,用riscv64-unknown-elf-gcc链接后真机烧录可执行。源码里每个.cpp文件都有中文注释,main.cpp里清晰标出 pipeline 六阶段入口;实践报告不是模板套话,而是逐行解释sysy.y中$1 = new AstNode(...)的语义动作如何绑定符号表、loop_maintainer.hpp怎么解决 SSA 形式下循环变量重命名冲突。它不依赖 LLVM 或 GCC 后端,所有 IR 生成与 RISC-V 指令映射全手写——这意味着你能真正看懂addi t0, zero, 42是怎么从return 42;推导出来的。适合编译原理零基础但会写 C++ 的本科生,也适合想补全编译器开发实战链路的嵌入式工程师。
提示:这不是教学演示器,而是可调试、可修改、可扩展的生产级课设基线。所有测试用例(
test/目录)均通过make test验证,hello.c输出的hello.s可直接用riscv64-unknown-elf-as汇编,无需魔改工具链。
2. 从源码结构到编译流程:六阶段流水线如何在 C++ 中落地
2.1 整体架构:为什么选 SysY + Koopa IR + RISC-V 三段式设计
这个项目没走“C → x86”老路,而是采用国内高校编译原理实践主流技术栈:SysY 作为输入语言(兼容 C89 子集,语法简洁、无指针运算、无浮点,降低前端复杂度)、Koopa IR 作为中间表示(SSA 形式、显式控制流图 CFG、支持 PHI 节点,便于做循环优化和寄存器分配)、RISC-V 32IMC 作为目标架构(开源指令集、工具链成熟、QEMU 可仿真)。这种组合不是炫技——SysY 规范明确( https://github.com/Compiler-Course/SysY ),避免学生陷入 C 标准歧义;Koopa IR 是清华编译原理课配套 IR(见《编译原理》第三版第 7 章),其.koopa文本格式可读性强,hello.koopa文件能直接 human-readable;RISC-V 工具链(riscv64-unknown-elf-*)在 Linux/macOS/WSL 上开箱即用,比 x86 更易验证生成代码正确性。整个 pipeline 在main.cpp中由Driver类串联:Lexer → Parser → AstBuilder → IrGenerator → CodeGen → AsmEmitter,每个阶段输入输出类型严格定义(如AstNode* → KoopaProgram* → std::vector<Instruction>),杜绝黑匣子调用。
2.2 词法与语法分析:Flex/Bison 如何协同构建 AST
词法分析器sysy.l和语法分析器sysy.y是前端核心。sysy.l定义了INT,IF,WHILE,IDENTIFIER,NUMBER等 token,关键在于保留字与标识符的优先级处理:
"if" { return IF; } "while" { return WHILE; } [a-zA-Z_][a-zA-Z0-9_]* { yylval.str = strdup(yytext); return IDENTIFIER; }这里if必须写在IDENTIFIER规则之前,否则if会被识别为普通标识符——这是新手高频翻车点。sysy.y使用 LALR(1) 分析,核心是translation_unit→external_decl→func_definition的递归下降。语义动作中,$$ = new AstFuncDefNode($1, $2, $3, $4);创建 AST 节点,其中$1是返回类型(TypeNode*),$2是函数名(IdentifierNode*),$3是参数列表(ParamList*),$4是函数体(BlockNode*)。注意$4的类型必须与BlockNode构造函数签名匹配,否则 Bison 会报type mismatch错误。AstBuilder类负责将 Bison 生成的裸指针节点组装成带父子关系的树,accept()方法递归遍历并填充childrenvector,为后续 IR 生成提供结构化输入。
2.3 中间表示生成:Koopa IR 的 SSA 构建与 PHI 节点插入
IR 生成在src/ir/目录下,核心是IrGenerator类。它遍历 AST,对每个节点调用visitXXX()方法(如visitFuncDefNode()→visitBlockNode()→visitStmtNode())。关键难点在于SSA 形式的变量版本管理:
// visitAssignNode 示例 void IrGenerator::visitAssignNode(AstAssignNode* node) { auto lhs = node->lhs(); // 左值:可能是 IdentifierNode 或 ArrayAccessNode auto rhs = node->rhs(); // 右值:表达式 auto rhs_val = visitExpr(rhs); // 生成 RHS 的 value(如 %t0) // 对于简单赋值 a = b + c,直接 store if (auto ident = dynamic_cast<AstIdentifierNode*>(lhs)) { auto var = symbol_table_->lookup(ident->name()); // 从符号表查变量 builder_->store(rhs_val, var->get_addr()); // store %t0, %a.addr } // 对于数组 a[i] = ...,需先计算地址 else if (auto arr = dynamic_cast<AstArrayAccessNode*>(lhs)) { auto base_addr = visitExpr(arr->base()); // a 的地址 auto idx = visitExpr(arr->index()); // i 的值 auto elem_addr = builder_->gep(base_addr, {idx}); // &a[i] builder_->store(rhs_val, elem_addr); } }builder_是 Koopa IR 构建器,store/load/add等方法生成对应 IR 指令。PHI 节点插入在block_maintainer.hpp中实现:当 CFG 中某基本块有多个前驱时(如 if-else 合并点),BlockMaintainer::insertPhiNodes()扫描所有前驱块的活跃变量,为每个变量生成 PHI 指令。例如:
%entry: br %cond, %then, %else %then: %a1 = addi zero, 1 br %merge %else: %a2 = addi zero, 2 br %merge %merge: %a3 = phi %a1, %then, %a2, %else // 关键:SSA 要求每个变量只有一个定义没有 PHI,后续寄存器分配会因变量多定义而崩溃。
2.4 RISC-V 后端:从 Koopa IR 到汇编指令的精准映射
CodeGen类将 Koopa IR 指令翻译为 RISC-V 汇编。核心原则是寄存器约束 + 指令选择:
- 寄存器分配:使用线性扫描(Linear Scan)算法,在
register_allocator.hpp中实现。为每个虚拟寄存器(%t0,%a1)分配物理寄存器(t0,a1),处理sp(栈指针)、ra(返回地址)、s0(帧指针)等 callee-saved 寄存器的保存/恢复。 - 指令选择:
InstructionSelector将 IR 操作映射为 RISC-V 指令。例如:// Koopa IR: %t0 = add %a, %b // → RISC-V: add t0, a0, a1 (若 %a→a0, %b→a1) // 但若 %a 是常量:addi t0, a0, 42AsmEmitter类生成.s文件,关键逻辑在emitBinaryOp()中:根据操作数类型(寄存器/立即数/内存地址)选择add/addi/lw/sw。hello.s中可见标准 RISC-V 函数序言:.globl main main: addi sp, sp, -16 # 分配栈帧 sw ra, 12(sp) # 保存返回地址 sw s0, 8(sp) # 保存帧指针 addi s0, sp, 16 # 设置新帧指针 li a0, 42 # load immediate jal ra, printf # 调用库函数 lw ra, 12(sp) # 恢复返回地址 lw s0, 8(sp) # 恢复帧指针 addi sp, sp, 16 # 释放栈帧 jr ra # 返回
2.5 实践报告:不是文档堆砌,而是调试日志的结构化复盘
这份高分实践报告(PDF)的价值在于真实记录调试过程。例如在“寄存器分配失败”章节,作者贴出 GDB 截图:rax寄存器被错误复用导致printf参数错乱,然后分析LinearScan::allocate()中active列表未按 lifetime 排序的 bug,并给出修复补丁(增加std::sort(active.begin(), active.end(), compareLifetime))。另一处详细说明sysy.y中expr : expr '+' term的左递归如何引发 Bison shift/reduce 冲突,以及如何通过改写为右递归expr : term { $$ = $1; } | expr '+' term { $$ = new AddNode($1, $3); }解决。报告附录包含所有测试用例的输入/输出/IR/汇编对照表,test/hello.c的hello.koopa和hello.s文件均标注关键行号与 AST 节点映射,方便你对照源码理解每行汇编的来源。
3. 编译部署四步走:从 CMake 构建到 RISC-V 真机验证
3.1 环境准备:三行命令搞定跨平台工具链
项目使用 CMake 构建,无需手动配置 VSCode 或 MSVC(避坑点见 4.1)。Linux/macOS 用户只需:
# 1. 安装 RISC-V 工具链(Ubuntu/Debian) sudo apt install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf # 2. 安装 Flex/Bison(词法/语法分析器生成器) sudo apt install flex bison # macOS: brew install flex bison # 3. 验证安装 riscv64-unknown-elf-gcc --version # 应输出 12.x+ bison --version # 应输出 3.8+Windows 用户推荐 WSL2(Ubuntu 22.04),避免 Cygwin 或 MinGW 的路径兼容问题。不要用 MSVC 编译器——本项目依赖 POSIX 系统调用(如fork用于测试),MSVC 不支持。
3.2 构建项目:CMakeLists.txt 的关键配置解析
根目录CMakeLists.txt定义了标准 C++17 构建流程:
cmake_minimum_required(VERSION 3.10) project(SysYCompiler LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 查找 Flex/Bison 并生成 lexer/parser find_package(FLEX REQUIRED) find_package(BISON REQUIRED) FLEX_TARGET(Lexer src/sysy.l ${CMAKE_CURRENT_BINARY_DIR}/lexer.cpp) BISON_TARGET(Parser src/sysy.y ${CMAKE_CURRENT_BINARY_DIR}/parser.cpp) # 添加可执行文件 add_executable(sysyc main.cpp ${FLEX_Lexer_OUTPUTS} ${BISON_Parser_OUTPUTS} # ... 其他源文件 ) # 链接标准库 target_link_libraries(sysyc stdc++fs) # C++17 filesystem 支持构建命令:
mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Debug # Debug 模式便于 gdb 调试 make -j$(nproc) # 并行编译生成的sysyc可执行文件位于build/目录。注意:CMAKE_BUILD_TYPE必须显式指定,否则默认None会导致-O0优化关闭,IR 生成阶段可能因未初始化变量而 segfault。
3.3 运行编译:一条命令完成全流程验证
进入test/目录,执行:
# 编译 hello.c → hello.koopa → hello.s → hello.o ../build/sysyc hello.c -o hello.s # 汇编并链接(需 riscv64-unknown-elf-gcc) riscv64-unknown-elf-gcc -o hello.elf hello.s # 在 QEMU 中运行(验证功能) qemu-riscv64 ./hello.elf # 输出 "Hello, World!" # 或生成纯二进制(用于 FPGA/开发板) riscv64-unknown-elf-objcopy -O binary hello.elf hello.bin-o hello.s参数指定输出汇编文件,sysyc内部自动调用IrGenerator→CodeGen→AsmEmitter。若需查看 IR,加-k参数:../build/sysyc hello.c -k > hello.koopa。
3.4 真机验证:QEMU 与物理开发板的两种部署路径
- QEMU 仿真(推荐入门):
# 安装 QEMU RISC-V 支持 sudo apt install qemu-system-misc # 运行 elf 文件(自动加载 libc) qemu-riscv64 -L /usr/riscv64-unknown-elf sysroot ./hello.elf - 物理开发板(如 GD32VF103):
- 将
hello.bin烧录到 Flash(使用 OpenOCD 或 J-Link) - 修改
link.ld(项目未提供,需自行创建):MEMORY { RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } SECTIONS { .text : { *(.text) } > RAM .data : { *(.data) } > RAM .bss : { *(.bss) } > RAM } - 链接时指定:
riscv64-unknown-elf-gcc -T link.ld -o hello.elf hello.s
注意:物理板需自行移植
printf到 UART,项目hello.c默认调用printf,若无 libc 需替换为裸机putchar。 - 将
4. 避坑指南:五个让课设答辩前夜崩溃的致命细节
4.1 现象:Bison 报错conflicts: 1 shift/reduce,编译器生成空 AST
原因:sysy.y中表达式文法存在左递归(如expr : expr '+' term),Bison 默认 LALR(1) 分析器无法处理,触发冲突并静默忽略规则。
解决:将左递归改写为右递归,并添加语义动作确保结合性。修改sysy.y:
// 错误写法(左递归) expr : expr '+' term { $$ = new AddNode($1, $3); } | term ; // 正确写法(右递归 + 结合性) expr : term { $$ = $1; } | expr '+' term { $$ = new AddNode($1, $3); } ;同时在%left '+' '-'声明运算符结合性,确保a+b+c解析为(a+b)+c。
4.2 现象:hello.s生成成功,但qemu-riscv64报Segmentation fault
原因:RISC-V 调用约定要求a0-a7传递前 8 个参数,但printf("Hello")中字符串地址未正确加载到a0。AsmEmitter的emitCall()方法未处理字符串常量的.rodata段引用。
解决:在AsmEmitter::emitCall()中,对字符串字面量生成.rodata段:
// 在 emitCall 前插入 if (auto str = dynamic_cast<AstStringLiteralNode*>(arg)) { out_ << "\tla a0, .LC" << str_label_id_ << "\n"; out_ << ".LC" << str_label_id_++ << ":\n\t.asciz \"" << str->value() << "\"\n"; } else { // 处理其他参数... }4.3 现象:make test失败,test/fib.c输出结果为0而非55
原因:loop_maintainer.hpp中的 PHI 节点插入逻辑未覆盖for循环的初始化部分。for (int i=0; i<10; i++)的i=0初始化被当作独立基本块,导致 PHI 节点缺少该前驱。
解决:在BlockMaintainer::analyzeLoop()中,将for循环的初始化块(init_block)显式加入loop_header->predecessors:
void BlockMaintainer::analyzeLoop(KoopaBasicBlock* header) { // ... 原有逻辑 for (auto pred : header->predecessors()) { if (isInLoop(pred, loop)) { loop_blocks.insert(pred); // 关键:确保 init_block 被识别为前驱 if (pred->is_init_block()) { // 自定义标记 header->predecessors().push_back(pred); } } } }4.4 现象:VSCode 调试sysyc时断点无效,GDB 显示No symbol table loaded
原因:CMake 默认 Release 模式不生成调试信息,且CMAKE_BUILD_TYPE未设置。
解决:构建时强制 Debug 模式,并启用调试符号:
cd build cmake .. -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-g -O0" make clean && makeVSCode 的launch.json需配置:
{ "configurations": [{ "name": "(gdb) Launch", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/build/sysyc", "args": ["test/hello.c", "-o", "hello.s"], "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": false, "MIMode": "gdb", "setupCommands": [{ "description": "Enable pretty-printing", "text": "-enable-pretty-printing" }] }] }4.5 现象:riscv64-unknown-elf-gcc链接时报undefined reference to 'printf'
原因:项目生成的hello.s调用printf,但未链接 Newlib C 库。RISC-V 工具链默认不包含完整 libc。
解决:链接时添加-lc和-lgcc,并指定 sysroot:
riscv64-unknown-elf-gcc -static -o hello.elf hello.s \ -L/usr/riscv64-unknown-elf/lib \ -lc -lgcc或更稳妥地,用riscv64-unknown-elf-gcc直接编译 C 文件验证工具链:
echo 'int main(){return 0;}' | riscv64-unknown-elf-gcc -x c - -o test.elf5. 进阶技巧:用 GDB 反向追踪汇编指令到 AST 节点的三步定位法
5.1 构建带调试信息的完整工具链
要实现“看到add t0, a0, a1就知道它来自hello.c第 5 行c = a + b”,必须让编译器生成 DWARF 调试信息。修改CMakeLists.txt:
# 在 target_compile_options 后添加 target_compile_options(sysyc PRIVATE -g -gdwarf-4) target_link_libraries(sysyc PRIVATE -g)重新构建后,sysyc可执行文件包含源码行号映射。验证:
readelf -w build/sysyc | head -20 # 查看 DWARF 段5.2 GDB 中的 AST → IR → ASM 三级跳转
假设hello.c第 7 行return a + b;生成错误汇编,启动 GDB:
gdb ./build/sysyc (gdb) b main.cpp:123 # 在 IrGenerator::visitReturnNode() 设断点 (gdb) r test/hello.c -o hello.s (gdb) step # 进入 visitExpr(),再 step 进入 visitBinaryOp() (gdb) print $1 # 查看 AST 节点内容,确认是 AddNode (gdb) print *$1 # 打印 AST 结构,获取 left/right 子节点此时已定位到 AST。继续:
(gdb) b ir_generator.cpp:456 # visitBinaryOp() 中 emitAdd() 行 (gdb) c (gdb) print builder_->current_bb()->insts().back() # 查看最后一条 IR 指令 # 输出类似:add %t0, %a, %bIR 确认无误后,切到汇编生成阶段:
(gdb) b asm_emitter.cpp:218 # emitBinaryOp() 中 emitAdd() 行 (gdb) c (gdb) print inst->op() # 确认是 KOOPA_OP_ADD (gdb) print inst->lhs()->name() # %a → 物理寄存器 a0 (gdb) print inst->rhs()->name() # %b → 物理寄存器 a15.3 汇编指令反查源码行:DWARF 与符号表联动
生成hello.s后,用riscv64-unknown-elf-objdump反汇编:
riscv64-unknown-elf-objdump -S hello.elf > hello.dump在hello.dump中找到:
00000000000100b0 <main>: 100b0: 1141 addi sp,sp,-16 100b2: e406 sd ra,12(sp) 100b4: e022 sd s0,8(sp) 100b6: 0801 addi s0,sp,16 100b8: 00050513 li a0,0 100bc: 00058593 li a1,0 100c0: 02f50533 add a0,a1,a0 # ← 这行对应 return a+b用addr2line反查源码:
riscv64-unknown-elf-addr2line -e hello.elf -a 0x100c0 # 输出:test/hello.c:7关键技巧:addr2line依赖.debug_line段,必须确保sysyc编译时带-g,且hello.s生成时保留.loc指令(AsmEmitter中out_ << "\t.loc 1 " << line << "\n";)。
5.4 实战案例:修复fib.c的栈溢出
test/fib.c在 QEMU 中 Segfault,GDB 定位到fib函数栈帧过大:
(gdb) run test/fib.c -o fib.s (gdb) bt #0 0x00000000000101a0 in fib () #1 0x00000000000101a0 in fib () # 无限递归检查fib.s发现:
fib: addi sp, sp, -32 # 分配 32 字节栈帧 sd ra, 24(sp) # 保存 ra sd s0, 20(sp) # 保存 s0 addi s0, sp, 32 # 设置 fp # ... 但未检查 n<2 就递归调用根源在AstIfNode::accept()未生成beqz跳转,而是无条件递归。修复visitIfNode():
void IrGenerator::visitIfNode(AstIfNode* node) { auto cond_val = visitExpr(node->cond()); auto then_bb = builder_->create_basic_block(); auto else_bb = builder_->create_basic_block(); auto merge_bb = builder_->create_basic_block(); builder_->br_cond(cond_val, then_bb, else_bb); // 关键:插入条件分支 builder_->set_insert_point(then_bb); visitStmt(node->then_body()); builder_->br(merge_bb); builder_->set_insert_point(else_bb); if (node->else_body()) visitStmt(node->else_body()); builder_->br(merge_bb); builder_->set_insert_point(merge_bb); }修复后fib.s出现beqz a0, .L2分支,栈溢出消失。
从那以后我每次提交课设代码前,都强制走一遍gdb ./build/sysyc test/hello.c -o hello.s+addr2line反查,确保每一行汇编都能追溯到 AST 节点和源码行。这不仅是调试手段,更是理解编译器如何“思考”的必经之路——当你能指着add t0, a0, a1说“这是hello.c第 5 行的+运算”,编译原理才算真正长进了骨头里。希望帮到你。
本文还有配套的精品资源,点击获取