简介重庆大学编译原理课程实验项目——构建轻量级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, bgeif/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恒为02号寄存器是栈指针(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对照三条龙”确认全绿才算过。希望这套验证思路也能帮你的编译器项目少走几个小时的弯路。本文还有配套的精品资源点击获取