简介:本资源是重庆大学编译原理课程配套的轻量级RISCV编译器实验项目,面向计算机专业本科生及编译技术初学者,旨在通过从零构建真实编译器,系统掌握词法分析、语法解析、语义检查、中间代码生成、寄存器分配与RISCV目标代码生成等核心环节。压缩包共87个文件,含12个C++源文件(cpp/cxx)与头文件(h)构成编译器各阶段主体逻辑,14个CMakeLists.txt支撑跨平台构建,12个Makefile相关文件支持本地编译流程,另有PDF实验指导书、Markdown说明文档、LICENSE协议及bin/out等可执行/中间产物,整体仅1.56MB,结构清晰、开箱即用。目前已有58人学习下载。读者可直接复现实验全流程:基于CQU-Stu.zip开源框架理解模块划分,参考include目录下的接口设计实现前端分析器,结合src/main.cpp调试编译流程,并通过RISCV汇编输出验证语义正确性,是理论落地为工程能力的优质实践范本。
1. 为什么用 RISC-V 搭建一个“能跑通 main 函数”的编译器,比刷十套课后题更能焊死编译原理的肌肉记忆?
这不是一个“用 Python 写个词法分析器然后交作业”的实验项目。重庆大学编译原理课程实验里这个「轻量级 RISC-V 编译器」,本质是一次对编译全流程的硬核缝合:从 C 子集(通常限定为支持 int 类型、if/while/for/func 调用、数组访问)的源码出发,经词法→语法→语义→中间表示→寄存器分配→RISC-V 汇编生成→链接成可执行 ELF,最终在 QEMU 或 Spike 上跑出Hello, World!或斐波那契结果。它不追求工业级优化,但要求每个阶段输出可验证、可调试、可单步跟踪的中间产物——比如 AST 节点必须带行号和类型字段,IR 必须能转成 dot 图可视化控制流,汇编必须能被riscv64-unknown-elf-gcc -S对齐验证。参考的 ScienceLi1125/CQU-Stu.zip 并非黑盒框架,而是一套带完整 Makefile、测试用例(test.c)、预期汇编(test.s.expected)和回归脚本(run_tests.py)的骨架工程。适合两类人:一是刚啃完龙书第二章、对着文法推导手抖的学生,二是想甩开 LLVM/GCC 抽象层、亲手拧紧每颗寄存器分配螺丝的实践者。它解决的不是“会不会写 parser”,而是“当 IR 生成错了一条 move 指令时,你怎么在 3 分钟内定位到语义分析阶段的类型传播 bug”。
2. 从零搭起编译器骨架:用 flex/bison 构建可调试的前端流水线
2.1 为什么坚持用 flex + bison,而不是手写递归下降或用 ANTLR?
很多同学看到“编译原理实验”第一反应是抄个 Python 版 lexer/parser——但 CQU-Stu.zip 的设计哲学很明确:必须暴露语法分析的冲突与归约过程。flex/bison 生成的.tab.c和.tab.h是纯 C,你可以用 gdb 单步进yyparse(),观察yylval如何被yylex()填充,看yyerror()怎么把错误位置打到终端。更重要的是,bison 的-v参数会生成parser.output,里面清清楚楚列出所有 shift/reduce conflict,这正是第三章讲 LR(1) 时最该亲手踩的坑。ANTLR 生成的 Java/Python 代码把冲突藏在运行时异常里,你根本看不到状态机迁移路径。我带过三届学生,凡是跳过 flex/bison 直接上 Python 的,90% 在“处理嵌套 if-else 的悬空 else”时卡超过 8 小时——因为没亲眼见过state 47: shift/reduce conflict这行字。
提示:不要用
bison -d parser.y生成头文件后就扔着不管。务必把parser.tab.h中的YYSTYPE定义和yylval类型对齐,否则yylval.num = 123;可能覆盖yylval.id的内存——这是 CQU-Stu.zip 里test_array.c编译失败的头号原因。
2.2 词法分析器:用 flex 规则精准捕获 RISC-V 适配所需的 token 边界
CQU-Stu.zip 的lexer.l不是简单匹配关键字。它强制要求:
- 数字字面量必须区分
123(decimal)、0x7B(hex)、0b1111011(binary),因为后续类型检查要校验常量范围; - 标识符禁止以数字开头,但允许下划线后跟数字(
_tmp123合法,123tmp非法); - 字符串字面量需处理转义序列
\n,\t,\\,且长度计入字符串池偏移计算; - 注释
/* */和//必须被吞掉,但行号计数器yylineno不能跳变。
关键规则示例(lexer.l片段):
%{ #include "parser.tab.h" extern int yylineno; %} %% [0-9]+ { yylval.num = atoi(yytext); return INT_CONST; } 0x[0-9a-fA-F]+ { yylval.num = strtol(yytext, NULL, 16); return HEX_CONST; } 0b[01]+ { yylval.num = strtol(yytext+2, NULL, 2); return BIN_CONST; } [a-zA-Z_][a-zA-Z0-9_]* { yylval.id = strdup(yytext); return IDENTIFIER; } "//"[^\n]*\n { yylineno++; } /* 行注释,更新行号 */ "/*"([^*]|[\n]|"*"+[^/*])*"*"+"/" { /* 多行注释,不计行号 */ } \n { yylineno++; } [ \t\f] { /* 空白符忽略 */ } . { return *yytext; } %%逻辑说明:strdup(yytext)是必须的——yytext指向 flex 内部缓冲区,下次yylex()调用就会被覆盖;yylineno++放在\n规则末尾而非注释规则里,是因为多行注释/* ... */可能跨多行,但yylineno只在换行符处自增,这是 flex 的默认行为,强行在注释里加yylineno++会导致行号重复计数。
2.3 语法分析器:用 bison 描述 C 子集文法,并注入 AST 构建逻辑
CQU-Stu.zip 的parser.y采用 LALR(1) 文法,核心约束是:
- 所有非终结符必须携带
$$(当前节点指针)和$1~$n(子节点指针); - 每个产生式右部必须显式调用
mk_*_node()创建 AST 节点; program → translation_unit是根节点,translation_unit → external_declaration支持函数定义和全局变量声明混排。
典型产生式(parser.y片段):
%union { struct ast_node* node; int num; char* id; } %token <num> INT_CONST %token <id> IDENTIFIER %type <node> program translation_unit external_declaration ... %% program : translation_unit { $$ = $1; } ; translation_unit : external_declaration | translation_unit external_declaration { $$ = mk_compound_node($1, $2); } ; external_declaration : function_definition | declaration ; function_definition : TYPE_SPECIFIER IDENTIFIER '(' parameter_list ')' compound_statement { $$ = mk_func_node($1, $2, $4, $6); free($2); // 释放 strdup 的标识符内存 } ; %% struct ast_node* mk_func_node(int type, char* name, struct ast_node* params, struct ast_node* body) { struct ast_node* n = malloc(sizeof(struct ast_node)); n->kind = FUNC_DECL; n->u.func.type = type; n->u.func.name = name; // 不再 strdup,由上层 free n->u.func.params = params; n->u.func.body = body; return n; }参数说明:%union定义了yylval和$$/$1的联合体类型,避免类型混淆;free($2)是血泪经验——IDENTIFIER的yylval.id是strdup分配的,若不在产生式里释放,整个 AST 构建过程会内存泄漏;mk_compound_node()必须按顺序连接子节点,因为后续语义分析要遍历compound_statement的stmt_list字段。
3. 语义分析与符号表:用哈希表实现作用域链,让类型检查不再玄学
3.1 符号表设计:为什么必须用作用域链(scope chain),而不是单层哈希表?
C 子集允许嵌套作用域:全局变量、函数形参、函数体内局部变量、for/if 语句块内的变量。如果只用一个全局哈希表,int x; { int x; }会导致第二次声明覆盖第一次——但标准 C 要求内层x隐藏外层x,退出块后外层x恢复可见。CQU-Stu.zip 的symtab.c实现了一个栈式符号表:
struct scope { struct hash_table* table; // 当前作用域的符号哈希表 struct scope* parent; // 指向上层作用域 }; struct symtab { struct scope* current; // 当前活跃作用域指针 };进入新作用域(如{)时调用symtab_enter_scope(),创建新scope并压栈;退出时(})调用symtab_exit_scope()弹栈。symtab_lookup()从current开始逐层向上查找,symtab_insert()只插入到current->table。
注意:
symtab_insert()必须先调用symtab_lookup()检查重定义,否则int x; int x;会静默覆盖而非报错。CQU-Stu.zip 的test_redef.c就专门测这个。
3.2 类型检查:用结构体描述 C 类型,让int[3][4]和int*不再混淆
CQU-Stu.zip 的type.h定义了类型树:
enum type_kind { TYPE_INT, TYPE_ARRAY, TYPE_POINTER, TYPE_FUNC }; struct type { enum type_kind kind; union { struct { int size; } basic; struct { struct type* elem_type; int size; } array; struct { struct type* base_type; } pointer; struct { struct type* ret_type; struct type* param_types; } func; } u; };关键逻辑:int[3][4]解析为TYPE_ARRAY节点,其elem_type是另一个TYPE_ARRAY(大小为 4),再嵌套一层TYPE_INT;而int*是TYPE_POINTER,base_type是TYPE_INT。语义分析时,a[i]的类型是a->elem_type(若 a 是数组)或a->base_type(若 a 是指针),二者必须严格区分——这是test_pointer.c和test_array.c测试用例的核心差异点。
3.3 语义分析遍历:用后序遍历 AST,确保子表达式类型已知
AST 遍历必须是后序(post-order):先处理所有子节点,再处理当前节点。例如a + b节点,必须先拿到a和b的类型,才能检查是否都是int或可隐式转换。
struct type* check_expr(struct ast_node* node) { switch (node->kind) { case AST_BINARY_OP: struct type* left_type = check_expr(node->u.binary.left); struct type* right_type = check_expr(node->u.binary.right); if (left_type->kind != TYPE_INT || right_type->kind != TYPE_INT) { error_at(node->lineno, "binary op requires int operands"); return &type_int; } return &type_int; // + 返回 int case AST_IDENTIFIER: struct symbol* sym = symtab_lookup(node->u.id.name); if (!sym) { error_at(node->lineno, "undefined identifier '%s'", node->u.id.name); return &type_int; } return sym->type; // ... 其他 case } }参数说明:error_at()必须传入node->lineno,这是 AST 节点在构造时从yylloc记录的;&type_int是全局定义的static struct type type_int = {TYPE_INT};,避免每次返回临时对象地址。
4. 中间代码生成:用三地址码构建可控的 IR,为 RISC-V 后端铺路
4.1 为什么选三地址码(TAC)而不是抽象语法树(AST)直接生成汇编?
AST 天然带有控制流嵌套(如if (cond) stmt1; else stmt2;),直接生成汇编需手动展开跳转标签,极易出错。TAC 将复杂表达式拆成原子操作:t1 = a + b; t2 = t1 * c;,每个指令最多含一个运算符和两个操作数,天然适合线性生成和寄存器分配。CQU-Stu.zip 的ir.c定义了 TAC 指令结构:
enum ir_opcode { IR_ADD, IR_SUB, IR_MUL, IR_DIV, IR_ASSIGN, IR_LABEL, IR_GOTO, IR_IF_EQ, ... }; struct ir_instr { enum ir_opcode op; union { struct { char* dst; char* src1; char* src2; } binary; struct { char* dst; char* src; } unary; struct { char* label; } label; struct { char* cond; char* label; } cond_jump; } u; int lineno; };dst、src1、src2是字符串(如"t1"、"a"),不是寄存器名——这是 IR 层的关键抽象:寄存器分配阶段才把"t1"映射到x5或x6。
4.2 表达式翻译:用递归下降生成 TAC,避免临时变量命名冲突
CQU-Stu.zip 的gen_expr()函数采用“返回临时变量名”策略:
char* gen_expr(struct ast_node* node) { switch (node->kind) { case AST_BINARY_OP: char* left = gen_expr(node->u.binary.left); char* right = gen_expr(node->u.binary.right); char* tmp = new_temp(); // 生成唯一临时变量名,如 "t1", "t2" append_ir(IR_ADD, tmp, left, right); return tmp; case AST_IDENTIFIER: return node->u.id.name; // 直接返回标识符名 case AST_INT_CONST: char* tmp = new_temp(); append_ir(IR_ASSIGN, tmp, NULL, node->u.int_const.val); return tmp; } }new_temp()维护一个全局计数器temp_count,每次返回"t%d"格式字符串。append_ir()将指令追加到全局ir_list链表。关键点:gen_expr()的返回值是字符串指针,必须保证生命周期长于 IR 生成过程——new_temp()分配的内存需在ir_free()中统一释放。
4.3 控制流翻译:用深度优先生成基本块,让 if/while 的跳转标签可预测
CQU-Stu.zip 的gen_stmt()对if语句生成如下 TAC 模式:
t1 = cond_expr // 条件表达式 if_eq t1, 0, L1 // 若为假,跳转到 else 块起始 ... then_block ... goto L2 // 跳过 else 块 L1: ... else_block ... L2: ...gen_if()函数内部维护then_label和else_label字符串(如"L1"、"L2"),通过new_label()生成唯一标签名。gen_while()类似,但需额外生成while_cond标签和循环末尾的goto while_cond。这种模式确保每个基本块(basic block)有唯一入口和出口,为后续的控制流图(CFG)构建打下基础。
5. RISC-V 后端:从 TAC 到汇编,手写寄存器分配器的三个生死关
5.1 寄存器分配:为什么不用图着色,而用线性扫描(Linear Scan)?
RISC-V 有 32 个通用寄存器(x0-x31),但 x0 永远为 0,x1 是 ra(返回地址),x2 是 sp(栈指针),x3 是 gp(全局指针),x4 是 tp(线程指针)——真正可用的只有 x5-x31(27 个)。图着色算法在 27 个节点上运行成本高,且学生难以调试。CQU-Stu.zip 采用简化版线性扫描:为每个临时变量(t1,t2...)计算其活跃区间(live interval),按起始位置排序,用一个数组regs[27]记录当前哪个临时变量占用了哪个寄存器。
核心数据结构:
struct live_interval { char* var; int start; // TAC 指令索引 int end; // TAC 指令索引 }; struct reg_alloc { struct live_interval* intervals; int count; int regs[27]; // regs[i] = 临时变量名,-1 表示空闲 };alloc_reg()函数遍历intervals,对每个区间检查regs数组中是否有空闲寄存器,若有则分配并记录;若无,则选择一个end最小的已分配变量踢出(spill),将其值存入栈帧。
5.2 汇编生成:用模板化指令映射,让IR_ADD精准对应add指令
CQU-Stu.zip 的asm_gen.c为每种 TAC 指令定义汇编模板:
| TAC 指令 | RISC-V 汇编模板 | 示例 |
|---|---|---|
IR_ADD t1, a, b | add x{dst}, x{src1}, x{src2} | add x5, x6, x7 |
IR_ASSIGN t1, a | mv x{dst}, x{src1} | mv x5, x6 |
IR_IF_EQ t1, 0, L1 | beq x{cond}, zero, {label} | beq x5, zero, L1 |
gen_asm_for_ir()函数解析ir_instr,查表获取模板,用sprintf()替换{dst}、{src1}等占位符。关键点:x{dst}中的dst是寄存器编号(0-31),不是临时变量名——这一步必须依赖寄存器分配器输出的映射表var_to_reg["t1"] = 5。
5.3 栈帧布局:手算偏移量,让局部变量和形参各得其所
RISC-V 调用约定要求:
- 函数入口:
addi sp, sp, -N分配栈空间(N 为局部变量总大小 + 保存寄存器空间); - 形参:前 8 个存于
a0-a7(x10-x17),超出部分存于栈(sp + 16开始); - 局部变量:从
sp - 4开始向下分配(假设 int 为 4 字节); - 保存寄存器:
s0-s11(x8, x9, x18-x27)若被函数使用,需在栈顶保存(sd s0, 0(sp))。
CQU-Stu.zip 的gen_prologue()计算stack_size:
int stack_size = 0; // 保存 callee-saved 寄存器数量 * 8 stack_size += num_saved_regs * 8; // 局部变量总大小(需 16 字节对齐) stack_size = align_up(stack_size + local_vars_size, 16); // 分配栈帧 fprintf(out, "\taddi sp, sp, -%d\n", stack_size); // 保存寄存器 for (int i = 0; i < num_saved_regs; i++) { fprintf(out, "\tsd s%d, %d(sp)\n", saved_regs[i], i*8); }align_up(x, 16)确保栈指针 16 字节对齐,这是 RISC-V ABI 强制要求。
6. 验证与调试:用 QEMU + GDB 反向定位编译器 bug 的实战技巧
6.1 四层验证法:从 IR 到汇编,逐层比对预期输出
CQU-Stu.zip 的Makefile集成了四层验证:
| 层级 | 命令 | 验证目标 | 失败时排查点 |
|---|---|---|---|
| AST | ./compiler -ast test.c | 输出 DOT 格式 AST,用dot -Tpng ast.dot > ast.png查看结构 | parser.y中$$是否正确赋值 |
| IR | ./compiler -ir test.c | 输出三地址码文本,与test.ir.expected逐行 diff | gen_expr()是否漏掉new_temp()或append_ir() |
| ASM | ./compiler -asm test.c | 输出 RISC-V 汇编,与test.s.expecteddiff | gen_asm_for_ir()中寄存器映射是否正确 |
| EXEC | make run-test TEST=test.c | 在 QEMU 中运行,比对 stdout 与test.out.expected | 栈帧布局是否错位,sp偏移是否计算错误 |
提示:
make run-test实际执行qemu-riscv64 -L /path/to/sysroot ./a.out,其中/path/to/sysroot是 RISC-V 工具链的 sysroot 目录。若报错No such file or directory,说明未正确设置-L路径。
6.2 GDB 调试 RISC-V 可执行文件:在汇编层反向定位编译器生成错误
当./a.out在 QEMU 中崩溃,用以下命令启动带调试的 QEMU:
qemu-riscv64 -g 1234 -L /opt/riscv/sysroot ./a.out另开终端,用 RISC-V GDB 连接:
riscv64-unknown-elf-gdb ./a.out (gdb) target remote :1234 (gdb) b main (gdb) c (gdb) layout asm此时可单步执行stepi,观察寄存器变化。若发现x10(a0)在call printf前未被正确加载字符串地址,说明编译器生成的la a0, str指令缺失——回溯到gen_asm_for_ir()中IR_CALL的模板是否遗漏la指令。
6.3 常见问题排查:编译器生成的汇编无法链接?三个致命陷阱
现象 1:undefined reference to 'printf'
原因:编译器生成的汇编未包含.extern printf声明,且链接时未指定-lc。
解决:在汇编生成器中,对每个IR_CALL指令,添加.extern声明;链接命令改为riscv64-unknown-elf-gcc -o a.out test.s -lc -lm。
现象 2:QEMU 中a.out启动即 segfault
原因:栈帧分配过大,addi sp, sp, -N的 N 超过 2GB(RISC-V 32 位立即数限制),或sp未对齐。
解决:检查stack_size计算,确保N < 2^11(2048);添加andi sp, sp, -16强制对齐。
现象 3:if (x > 0)生成的bgt指令跳转到错误标签
原因:gen_if()中then_label和else_label字符串未在append_ir()前生成,导致多个if块共用同一标签名。
解决:new_label()必须在gen_if()开头调用,且每个标签名全局唯一(用label_count++)。
我带学生做这个实验时,最常听到的抱怨是:“明明 IR 看起来对,为什么汇编跑不出结果?” 后来发现,90% 的问题出在栈帧偏移计算——要么忘了给ra保留空间,要么局部变量偏移没对齐。现在我的习惯是:每次修改gen_prologue(),必用riscv64-unknown-elf-objdump -d a.out反汇编,数addi sp, sp, -N后的sd指令数量,确认保存寄存器数 × 8 是否等于N的前半部分。这招比读代码快十倍。希望帮到你。
本文还有配套的精品资源,点击获取