简介:重庆大学编译原理课程实验项目——构建轻量级RISCV编译器,是一份面向计算机专业学生的课程实践资源。该项目参考ScienceLi1125的开源项目CQU-Stu.zip,覆盖词法分析、语法分析、语义分析、中间代码生成、代码优化及RISCV目标代码生成等完整编译流程,适合用于配套实验教学或自学进阶。包体共87个文件,主要包括C/C++源文件(cpp、h、c)、CMake/Make构建脚本、编译生成的中间产物(o、a、bin、out)以及实验指导书和说明文档(pdf、txt、md),压缩包整体仅1.56MB,便于快速部署与调试。资源内含《编译原理实验指导书》、README及完整工程目录,可帮助学习者对照真实项目掌握编译器前中后端设计思路,理解符号表、类型检查、寄存器分配等关键环节。目前已有59人学习,对于正在完成同类课程设计或准备系统实践编译原理的读者具有直接参考价值。
1. 编译原理实验做成RISCV编译器:这不是玩具,是真能跑汇编的
很多学校的编译原理课设还停留在“把表达式算个值”的阶段,而重庆大学这个课程实验直接把目标定成了构建一个轻量级RISCV编译器。这意味着你写的不是某种中间解释器,而是一条完整的工具链:从C语言子集源码,经过词法分析、语法分析、语义检查、中间代码生成、指令选择,最终吐出RISC-V汇编,再交给交叉工具链汇编链接成可执行文件,跑在QEMU或者真实芯片上。它对“轻量级”的定义很明确:只支持一个够用的C子集,但编译出来的程序必须能正确运行。适合的人群是正在做编译原理课设、需要从零搭建编译器的本科生,以及想把手写前端和指令生成串起来的嵌入式开发者。参考了ScienceLi1125的CQU-Stu.zip这类优秀开源仓库,意味着你有现成的架构可以抄作业,但前提是你能看懂它为什么这么设计。
2. 先搭前端:词法分析与语法分析的工程化实现
2.1 词法分析器:别用正则硬扛,手写状态机更可控
词法分析是整个项目里最“看似简单、实则容易埋雷”的部分。课程实验要求支持的关键字、标识符、整数常量、运算符和分隔符加起来大概四十多种token类型。很多初学者在这一步会直接上正则表达式库,但实际做下来你会发现两个问题:一是正则库在匹配长标识符和数字时的性能开销不低,二是报错信息非常难定位——你不知道是哪个分支漏了匹配。
我一般会建议手写一个基于状态机的词法分析器,代码量不大,但可控性极高。核心逻辑就是逐字符扫描,根据当前字符决定状态迁移:
typedef enum { START, IN_IDENT, IN_NUM, IN_OP, IN_STRING, DONE } LexState; Token next_token(FILE *src) { LexState state = START; char buf[128]; int buf_len = 0; int c; while ((c = fgetc(src)) != EOF) { switch (state) { case START: if (isspace(c)) continue; // 跳过空白 if (isalpha(c) || c == '_') { state = IN_IDENT; buf[buf_len++] = c; } else if (isdigit(c)) { state = IN_NUM; buf[buf_len++] = c; } else { return make_op_token(c); // 单字符运算符 } break; case IN_IDENT: if (isalnum(c) || c == '_') { buf[buf_len++] = c; } else { ungetc(c, src); return make_ident_token(buf, buf_len); } break; case IN_NUM: if (isdigit(c)) { buf[buf_len++] = c; } else { ungetc(c, src); return make_num_token(buf, buf_len); } break; default: break; } } return make_eof_token(); }这段代码里最关键的设计是ungetc的使用:当读取到不属于当前token的字符时,把它退回输入流,交给下一轮处理。这样能保证“int32a”不会被错误拆成“int”和“32a”两个token。另一个细节是标识符和关键字的关系——不要在这个阶段区分关键字,一律按标识符处理,等到语法分析时再查表判断。这样词法分析器保持简单,关键字表只在需要时报错或转换token类型。
参数上,buf[128]的缓冲长度是拍脑袋定的,实际要考虑C语言的标识符长度限制。标准C保证前63个字符有效,所以缓冲区至少要64字节,留到128是安全的。
2.2 语法分析:手写递归下降比引入yacc更符合课设目标
词法分析拿到token流之后,下一步是语法分析。这个课程实验里我强烈建议手写递归下降分析器,而不是用yacc/bison。原因有两个:一是课设的C子集文法并不复杂,递归下降的代码结构清晰,出错时栈信息就是调用链,定位极其直观;二是引入yacc之后,你需要同时维护文法文件和语义动作代码,两个文件的同步本身就是新的心智负担。
递归下降的核心是为每个非终结符写一个解析函数,比如表达式解析的经典层叠结构:
ASTNode *parse_expr(Parser *parser) { return parse_additive(parser); } ASTNode *parse_additive(Parser *parser) { ASTNode *left = parse_multiplicative(parser); while (parser->current.type == TOKEN_PLUS || parser->current.type == TOKEN_MINUS) { Token op = parser->current; advance(parser); ASTNode *right = parse_multiplicative(parser); left = make_binary_expr(op, left, right); } return left; } ASTNode *parse_primary(Parser *parser) { if (parser->current.type == TOKEN_NUM) { ASTNode *node = make_const_node(parser->current.value); advance(parser); return node; } if (parser->current.type == TOKEN_IDENT) { ASTNode *node = make_var_node(parser->current.name); advance(parser); return node; } if (parser->current.type == TOKEN_LPAREN) { advance(parser); ASTNode *node = parse_expr(parser); if (parser->current.type != TOKEN_RPAREN) { error("missing closing parenthesis"); } advance(parser); return node; } error("unexpected token in expression"); return NULL; }这里有一个容易翻车的细节:优先级处理。上面的代码把表达式拆成 additive(加减)和 multiplicative(乘除)两层,parse_additive内部调用parse_multiplicative,从而天然实现了“乘除优先于加减”。如果你把括号表达式和一元负号的优先级也做进来,那就再加一层parse_unary。层数越多,优先级越明确,但代码也越嵌套。课设规模下四层足够:赋值 → 或与非 → 加减 → 乘除 → 一元 → 基本。
2.3 AST设计:每种节点一个结构体,别搞大杂烩
语法分析的结果是一棵抽象语法树(AST)。AST节点类型的划分直接影响后面的语义分析和代码生成。最差的实践是只用一个结构体干所有事,里面放一堆用不到的字段。好一点的做法是分门别类:表达式节点、语句节点、声明节点,每种节点单独一个结构体,通过NodeType区分。
typedef enum { NODE_CONST, NODE_VAR, NODE_BINARY, NODE_ASSIGN, NODE_IF, NODE_WHILE, NODE_RETURN, NODE_BLOCK } NodeType; typedef struct ASTNode { NodeType type; union { struct { int value; } as_const; struct { char *name; } as_var; struct { struct ASTNode *left; struct ASTNode *right; TokenType op; } as_binary; struct { char *var; struct ASTNode *value; } as_assign; struct { struct ASTNode *cond; struct ASTNode *then_branch; struct ASTNode *else_branch; // 可能为NULL } as_if; struct { struct ASTNode *cond; struct ASTNode *body; } as_while; struct { struct ASTNode *value; } as_return; struct { struct ASTNode **stmts; int count; } as_block; } data; } ASTNode;注意这个union的设计:每个节点只用自己真正需要的字段,内存紧凑且访问语义清晰。as_if里的else_branch允许为NULL,这对应没有else的if语句。编译器的很多bug来自对“可选字段”的错误假设,所以建议在创建节点时为每个字段显式初始化(哪怕填NULL),不要依赖malloc的未定义初值。这一点在调试时能省掉大量玄学问题。
3. 语义分析与符号表:把类型错误拦在生成汇编之前
3.1 符号表的作用域:局部变量必须支持嵌套遮蔽
词法和语法分析只解决了“语法是否正确”的问题,而int a = "hello";这样的代码语法上完全合法,却不应该通过编译。语义分析做的事情就是把这类错误拽出来。这个环节的核心数据结构是符号表(symbol table),它记录每个变量的名字、类型、作用域和对应的存储位置。
课设里最容易出问题的是作用域的嵌套。C语言允许局部变量遮蔽(shadow)外层变量,比如:
int x = 1; void func() { int x = 2; // 这里的x应该是2,不是1 }如果符号表只有一张全局哈希表,这个遮蔽关系就没法表达。我一般用作用域链(scope chain)的方式实现:每个作用域是一个哈希表,作用域之间用链表串起来,查找时从当前作用域往上逐层找。
typedef struct Symbol { char *name; Type type; int stack_offset; struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; // 当前作用域的符号链表 struct Scope *parent; // 指向外层作用域 } Scope; Symbol *lookup(Scope *scope, const char *name) { while (scope != NULL) { Symbol *sym = scope->head; while (sym != NULL) { if (strcmp(sym->name, name) == 0) return sym; sym = sym->next; } scope = scope->parent; } return NULL; }这个查找逻辑是“从内往外找”,所以内层声明的同名变量会先被命中,外层同名变量被天然遮蔽。实现时要注意一个坑:lookup找不到变量时返回NULL,但调用方往往忘了检查NULL,导致后续对NULL指针的成员访问直接段错误。我习惯在lookup内部做一层包装,找不到就直接抛出带变量名的错误,虽然粗暴但是排查效率极高。
3.2 类型检查:表达式求值时就该把类型算出来
类型检查的粒度要做到每棵表达式节点都携带类型信息。最简单的方式是在AST节点里加一个Type expr_type字段,语义分析时后序遍历整棵树,自底向上推导每个表达式的类型。比如加法要求左操作数和右操作数都是int(课设只搞int,别给自己找麻烦搞float),一元负号要求操作数是int,赋值要求左右类型一致。
除了类型一致性的检查,还要检查return语句的位置。课设要求main函数必须有返回值,其他函数如果声明为int但没有return语句,至少要给警告。我在做语义分析时会在函数入口创建一个新的作用域压栈,函数所有局部变量声明都进这个作用域,函数退出时整层弹出。这样既能捕捉局部变量重复声明的错误,也能在函数体内正确解析变量引用。
3.3 中间代码:三地址码是代码生成之前的稳定跳板
直接从AST生成RISC-V汇编也可以,但跳跃太大,很多指令选择逻辑混在一起,调试时无从下手。更稳的做法是把AST翻译成三地址码(Three Address Code),每条指令最多有一个操作符和三个操作数,后续再基于三地址码做指令选择。
typedef enum { TAC_ASSIGN, TAC_BINARY, TAC_LABEL, TAC_GOTO, TAC_IF_FALSE_GOTO, TAC_CALL, TAC_RETURN, TAC_PARAM } TacOp; typedef struct Tac { TacOp op; char *result; // 目标操作数 char *arg1; // 第一源操作数 char *arg2; // 第二源操作数,有的指令不需要 struct Tac *next; } Tac;三地址码的一个关键设计是临时变量命名。我一般给每个新的临时变量叫t0、t1、t2…… 不区分它是来自表达式中间值还是来自函数返回值。这样后续做寄存器分配的时候,直接把临时变量映射到物理寄存器,规则单一。另一个诀窍是三地址码里标签(label)也是一个指令,而不是单独一张表,这能保证跳转目标一定有对应的指令地址,规避前向跳转的悬空引用问题。
三地址码的好处是它天然贴近汇编的形态:条件跳转被拆成“比较 + 条件跳转”两步,比如if (x < 3)会变成t0 = x < 3; if_false t0 goto L_label。这样到指令选择阶段,每条三地址码差不多对应一到两条RISC-V指令,映射关系干净利落。
4. 指令选择与寄存器分配:从三地址码到RISC-V汇编的最后一公里
4.1 RISC-V指令子集的选取:RV32I就够用,别贪多
课设不需要支持完整的RISC-V指令集,RV32I基础整数指令已经是站在巨人肩膀上了。实际要用的指令类型也就这些:
| 指令类型 | 代表指令 | 用途 |
|---|---|---|
| 加载/存储 | lw, sw | 访问内存中的局部变量 |
| 算术运算 | add, sub, mul, div | 加减乘除 |
| 比较 | slt, sltu | 大小比较,结果写入寄存器 |
| 跳转 | jal, jalr | 函数调用与返回 |
| 分支 | beq, bne, blt, bge | if/while的控制流 |
| 立即数 | addi, li | 加载常量 |
| 移位 | sll, srl | 左移右移 |
指令选择就是把每条三地址码映射到上述指令序列。大多数映射是直白的:a = b + c在RISC-V里就是把b和c分别加载到寄存器,执行add,再把结果存回a对应的内存地址。但这里有个选择要做:局部变量究竟放寄存器还是放内存?答案在课设场景下是确定的——全部放内存,用栈帧的偏移量访问,函数调用时保存和恢复寄存器的工作量就是0,代价是每次变量访问都要lw/sw各一次。对课设性能要求来说完全够用,而且逻辑简单到不可能出错。
void emit_binary(Tac *tac, FILE *out) { char *reg_left = alloc_reg(); char *reg_right = alloc_reg(); char *reg_res = alloc_reg(); // 左操作数加载 fprintf(out, " lw %s, %s\n", reg_left, get_operand_addr(tac->arg1)); // 右操作数加载 fprintf(out, " lw %s, %s\n", reg_right, get_operand_addr(tac->arg2)); // 算术运算 switch (tac->op) { case TAC_BINARY_ADD: fprintf(out, " add %s, %s, %s\n", reg_res, reg_left, reg_right); break; case TAC_BINARY_SUB: fprintf(out, " sub %s, %s, %s\n", reg_res, reg_left, reg_right); break; case TAC_BINARY_MUL: fprintf(out, " mul %s, %s, %s\n", reg_res, reg_left, reg_right); break; case TAC_BINARY_DIV: fprintf(out, " div %s, %s, %s\n", reg_res, reg_left, reg_right); break; default: error("unsupported binary op"); } // 结果写回内存 fprintf(out, " sw %s, %s\n", reg_res, get_operand_addr(tac->result)); free_reg(reg_left); free_reg(reg_right); free_reg(reg_res); }4.2 寄存器分配:临时寄存器用完就释放,比任何优化都省心
寄存器分配是编译器设计里最容易被过度设计的部分。课设场景下,一个临时变量从加载到使用往往隔不了几条指令,采用即时分配即时释放的策略最为稳妥:每条三地址码生成时,为它申请所需的临时寄存器,指令发射完毕立刻释放。这种做法的好处是不会出现寄存器长期占用导致的不足问题,代价是生成的汇编代码在寄存器使用上不够紧凑,但这不破坏正确性。
RISC-V规定寄存器数量为32个(x0-x31),其中x0恒为0,2号寄存器是栈指针(sp),1号是返回值寄存器(ra)。可用的通用寄存器大约28个,课设的表达式深度根本不可能把这个数量用完。分配器实现起来就是用一张空闲链表:
static const char *reg_pool[] = {"t0", "t1", "t2", "t3", "t4", "t5", "t6", "a0", "a1", "a2", "a3", "a4", "a5"}; static int reg_occupied[13]; char *alloc_reg(void) { for (int i = 0; i < 13; i++) { if (!reg_occupied[i]) { reg_occupied[i] = 1; return (char *)reg_pool[i]; } } error("register exhausted"); return NULL; } void free_reg(char *reg) { for (int i = 0; i < 13; i++) { if (strcmp(reg_pool[i], reg) == 0) { reg_occupied[i] = 0; return; } } }注意分配器里的a0-a5和ra的关系。RISC-V的函数调用约定要求参数通过 a0-a7 传递,返回值放在 a0。课设里函数调用实现如果用栈传递参数,就可以完全不碰调用约定,反正汇编是自产自销,自己约定怎么传都行。我一般会走栈传递,省去很多麻烦。
4.3 函数调用与栈帧:sp的管理是整个代码生成最容易被坑的地方
函数调用的代码生成有一个常见翻车点:不保存调用者保存寄存器,或者不知道哪些寄存器是被调用者保存的。课设里最省心的做法是在每个函数入口统一压栈保存用到的所有寄存器,出口统一恢复。虽然粗糙,但正确性有保障。
栈帧布局上,我选择这样一个结构:进入函数后先addi sp, sp, -frame_size,把需要的局部变量空间一次性分配好,然后按固定偏移访问局部变量。frame_size可以在语义分析阶段预先算好:局部变量数量乘以4字节,加上保存ra的4字节。这里有一个细节:因为局部变量在AST和符号表中都已经记录了名字,但没有记录栈偏移,所以需要在生成代码前遍历一遍符号表,为每个变量分配唯一偏移,并把偏移写回符号表结构体里。这一步不做,后面访问变量时无从下手。
5. 避坑指南:编译原理实验最常见的五个翻车点
5.1 编译器报“未包含main类型” —— 入口函数检查
现象:汇编器或链接器提示找不到main函数,或者编译器直接报错说main未定义。
原因:词法分析阶段把main当作普通标识符处理,语法分析时没有特殊登记。如果main函数没有被识别为入口,链接时自然找不到启动代码。更多时候是符号表里main函数的记录被丢弃了,比如作用域弹栈时没有检查是否有main。
解决:在语义分析阶段,解析完所有函数声明后,强制查一次符号表看main是否存在。不存在就报“undefined reference to main”。同时建议把main函数的符号在符号表里加一个特殊标记,后面汇编生成时确保入口处先于其他函数输出。
5.2 空语句导致while循环变成死循环
现象:一个while (x < 10);的分号被解析为循环体,程序跑飞。
原因:递归下降解析while语句时,分号被解析成一个空语句节点,导致循环体为零条指令。跳转目标落错了位置,回边永远指向循环条件判断的前一条指令,或者回边根本不存在。
解决:在AST层面禁止空语句节点,解析到分号时生成一个NODE_NOP节点,代码生成阶段遇到它输出0条指令即可,但控制流的跳转标签要确保跳过这个空语句。另外,循环语句的条件跳转和回边跳转的目标标签必须紧贴条件判断的起始位置,严禁指向条件判断的中间指令。
5.3 局部变量偏移计算不一致 —— 访问到别人的变量
现象:明明给变量a赋值,读取变量b时却拿到了a的值。
原因:符号表保存的stack_offset在语义分析阶段就分配并固定了,但代码生成阶段整了另一个变量布局,前后布局不一致,导致lw/sw指令中的偏移量对不上。这种错通常发生在边改代码边调试的过程中。
解决:强制统一分配逻辑——所有栈偏移只在语义分析结束时分配一次,存回符号表字段,代码生成直接读。不要在代码生成阶段重新遍历AST来推算偏移。这个原则没有商量余地,我自己栽过一次,排查了一整个下午,最后发现就是两类偏移计算差了4字节。
5.4 立即数超出RISC-V的12位有符号范围
现象:给一个变量赋5000以上的数值,生成的汇编在汇编器阶段报错“immediate out of range”。
原因:RISC-V的addi指令只能携带12位有符号立即数,范围是 -2048 到 2047。直接把立即数写进addi指令就会溢出。大常量必须先用lui加载高位,再用addi填充低位。
解决:常量加载走独立函数,判定立即数范围,超过范围则拆分:
void emit_load_imm(int value, char *reg, FILE *out) { if (value >= -2048 && value <= 2047) { fprintf(out, " addi %s, x0, %d\n", reg, value); } else { int upper = (value >> 12) & 0xFFFFF; int lower = value & 0xFFF; if (lower & 0x800) lower -= 0x1000; // 符号扩展处理 fprintf(out, " lui %s, %d\n", reg, upper); if (lower != 0) { fprintf(out, " addi %s, %s, %d\n", reg, reg, lower); } } }这个lower的符号扩展处理是精髓——实际情况里如果低位部分超过12位能表示的正数范围,LUI会带上一个隐含的1,这时addi必须表现为负数才能把结果校正回来。不处理这一位,大常量的边界值整组出错。
5.5 除数为零不检查,运行时硬件异常
现象:QEMU直接报Illegal instruction或运行挂起。
原因:RISC-V的除法指令在除数为0时产生一个运行时异常,不像x86那样返回一个确定值。编译器完全可以提前检查并在编译期报错(如果除数是常量0),但动态除数为0时,更合适的是在生成的汇编里插入一个运行时检查。
解决:在二元除法/取模的三地址码翻译阶段,除数是变量时生成如下序列:
lw t0, 除数地址 beq t0, x0, .Ldiv_zero_panic并在程序末尾放置一个调用exit的陷阱。课设只要完成到汇编输出层面,可以退一步:在语义分析阶段如果检测到立即数0作为除数,直接报错。运行时的除以零检查留作扩展,但至少别让生成的代码在QEMU里默默异常退出,否则你会浪费大量时间认为是编译器代码生成错了。
6. 验证一条龙:和GCC对比输出,能把调试时间砍一半
课程实验做到这个阶段,最需要的是一个能自动验证正确性的流水线。我的习惯做法是:为每个测试用例同时用自己写的编译器和系统GCC编译,然后对比两者在QEMU里的运行输出。这个对比不是为了看性能,而是为了看行为一致性。
具体验证流程分三步。第一步,写一个极简的测试框架脚本,把多个.c测试文件批量跑一遍。每个测试文件最后用printf输出一个确定性的结果,比如printf("%d\n", compute(5));然后比较输出是否与GCC交叉编译的版本完全一致。第二步,用objdump -d查看GCC生成的汇编,对照自己的指令序列,重点看函数序言、局部变量访问和控制流跳转布局。这一步能快速发现自己栈帧计算的错误。第三步,跑几个边界测试:嵌套if、while加上break、多层函数调用、递归函数。递归是检验栈帧布局是否正确的金标准——凡是栈帧偏移算错,递归跑到第三层就会炸掉。
如果输出不一致,我的定位路径很固定:先在QEMU里单步运行,抓崩坏的PC地址,然后对比自己生成的汇编,看崩坏的PC落在哪条指令,再反推这条指令对应的三地址码,再找对应的AST节点。有了三地址码这一层中间表示,定位速度比直接看汇编快一个量级。
我还养成一个习惯:每次改完寄存器分配或栈帧布局的代码后,强制跑一遍全量测试而不是只跑刚才涉及的用例。因为寄存器分配的一个改动会波及所有指令序列,影响面完全不可预期。这套测试流水线救了我很多次,有一次就是修了一个临时变量释放的bug,结果某个深层递归测试的输出从一开始就错了。从那以后我每次调整代码生成逻辑,都会先跑一遍“GCC对照三条龙”,确认全绿才算过。希望这套验证思路也能帮你的编译器项目少走几个小时的弯路。
本文还有配套的精品资源,点击获取