ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C-MIPS编译器实现:从精简C到MIPS汇编的完整编译流水线

C-MIPS编译器实现:从精简C到MIPS汇编的完整编译流水线 简介基于精简C语言的C-MIPS编译器完整实现面向编译原理课程实验与相关系统开发者演示如何将C语言子集经词法、语法、语义分析、中间代码生成与优化、目标代码生成转换为MIPS汇编覆盖C语言子集的基本语法与特有关键字并可在MARS及自研CPU上验证。压缩包共26个文件约1.26MB内含3个C源文件、1个Flex词法文件、1个Bison语法文件、1个头文件、PDF实验报告、README与LICENSE并附13张PNG和4张JPEG截图便于对照运行结果与中间过程。已有533人学习下载。内容覆盖实验要求的四个阶段利用Flex与Bison生成分析器并打印抽象语法树遍历AST构建符号表并检查语义错误基于DAG完成中间代码优化最后为临时变量和存储变量分配寄存器并生成目标代码。报告与代码配合清晰适合编译原理课程设计、实验报告撰写和MIPS工具链上手参考。1. C-MIPS 编译器是什么把精简 C 搬进 MIPS值不值得自己写编译原理实验做到 C-MIPS 这一档意味着前面只考词法、只考语法树的作业已经不够看了你要把一门精简过的 C 语言完整地编译成能在 MIPS 模拟器上运行起来的汇编程序。标题里“精简 C 语言”不是偷懒是实验设计上的边界管理——把 int、数组、函数调用、if/else、while 留下把 struct、多维数组、函数指针这些复杂特性挡在门外你才有精力把词法、语法、语义分析和代码生成整条链路做通透。这个方向适合正在做编译原理课程设计的学生也适合想给自己补一遍完整编译流水线的工程师。它回答一个很实际的问题一段 C 源码从字符流变成 MIPS 指令中间每一步到底做了什么卡在哪一步最容易翻车。2. 从 C 子集到 MIPS开工前先定死的三个设计决策写代码之前定方向这部分省掉的返工时间最多。C-MIPS 编译器最怕的不是某个语法写不出来而是写到一半发现“这个特性当初不该要”“那个 ABI 约定没定导致前后端对不上”。编译器的各个模块是分头写的接口却必须提前锁死。2.1 精简 C 子集保留哪些语法砍掉哪些语法最划算我一般会先拿一张纸列出保留与砍掉清单。典型实验子集的保留项是int 与 char 标量、一维数组、全局变量简单版、函数定义与调用、递归、if/else/while/for、算术关系逻辑表达式和赋值。int 是目标机器上对齐最简单的类型1 个 int 占 4 字节地址计算只有一步左移两位char 保留下来是为了让“类型宽度会修改偏移计算”这个知识点在数组下标处反复出现。保留一维数组而不是多维数组是因为多维下标的编译要从一层的 slladdu 变成多层地址累加工作量翻倍但知识点没有新增。砍掉 struct/union 的理由更实际成员偏移表和整体赋值语义会让符号表与栈帧同时复杂化而课程核心的“函数调用、栈帧、目标代码生成”并不会因此多学到什么。函数指针涉及间接调用和类型系统里多一层“指向函数的类型”建议同样砍掉。switch 如果实验说明没明确要求用 if/else 链替代省掉 fall-through 的标签管理。多维数组、指针数组、可变参数这类属于“做了不加分、不做不扣分”从工期角度看全部划掉。语法特性是否保留理由int / char 标量保留类型宽度讲解成本低、价值高一维数组保留下标地址计算是 MIPS 代码生成重点多维数组砍多层地址计算对核心知识增量有限struct / union砍成员偏移与拷贝语义让栈帧复杂化函数与递归保留验证栈帧和调用约定的最佳用例switch砍或简化标签管理与 fall-through 易错全局变量保留便于跨函数共享与测试输出这个表定下来之后语法分析的边界就清楚了AST 节点类型按这个表设计代码生成只处理这些节点语义分析也只在这些范围内做类型检查。课程设计小组里如果有分工这张表就是“哪些功能算在范围内、哪些 bug 算不会修”的对齐依据。2.2 中间表示AST 直译还是先造三地址码子集定好后第二个决策是语法树和汇编之间要不要再插一层。AST 直译是我在两三周实验工期内最常用的做法语法分析建出来的 AST 本身就是树形结构代码生成递归遍历节点、按节点类型输出几条 MIPS 指令少写一层三地址码的翻译和临时变量管理。缺点也明显表达式嵌套深到五六层时临时寄存器容易互相覆盖需要按树深度分配临时寄存器兜底另外如果课程后半段要求做活跃变量分析或寄存器分配AST 直译没有现成的线性中间代码可喂给分析器。三地址码 TAC 的好处是把源码变成 t1 a b 这类线性指令序列之后做基本块划分、活跃性分析都顺手也更贴近教材“中间代码生成→优化→目标代码生成”的模块划分方便一块块验收。代价是相当于把一个编译器拆成两个先写一个 AST 到 TAC 的翻译器再写一个 TAC 到 MIPS 的低层翻译器每一层都有各自的指令设计要调试。选型判断我一般这样给课程只要求编译结果能运行工期又紧选 AST 直译课程明确要做数据流、寄存器分配或指令调度选 TAC。如果时间够又不打算做优化可以折中AST 直译为主但把临时寄存器分配和 MIPS 指令输出写成独立模块。将来想升级到 TAC 时只需要替换“AST 到中间指令”这一段后面的寄存器分配代码不用全推倒。这个模块设计在第 4 章会具体展开。2.3 目标 ABIMIPS 寄存器约定和栈帧规则先在纸上定死MIPS 32 的通用寄存器很多但编译器只能按约定使用否则生成代码和手写汇编对不上时极难排查。最少要知道这些角色$zero 恒为 0$v0/$v1 放返回值$a0-$a3 传前四个参数$t0-$t9 是调用者保存的临时寄存器$s0-$s7 是被调用者保存的变量寄存器$sp 是栈指针$ra 是返回地址。在函数调用边界上$t0-$t9 里的旧值谁都不保证$s0-$s7 则必须由被调用函数负责保存和恢复。实验级编译器其实只需要定五条规则参数按顺序放 $a0-$a3超过四个的按从左到右压入调用者栈帧标量返回值一律放 $v0所有局部变量统一放到栈上不碰 $s0-$s7 的保存恢复逻辑这样能省掉编译器最复杂的 callee-saved 流程函数入口先保存 $ra出口恢复后 jr $ra栈指针 $sp 保持 8 字节对齐这是 MIPS ABI 里几乎所有人都会在递归调用上踩到的一条约束。为什么要提前把这些“在纸上定死”因为代码生成各阶段是分头写的表达式翻译不知道栈帧大小就不知道该用多大偏移访问局部变量函数调用不知道寄存器约定就不清楚调用前后哪些数据还能信。把这五条写成一张表放在项目说明第一页后面每一处代码生成都去查约定一致性问题大多能提前拦住。这一环节做得潦草后面就会出现那种“编译器有 bug 但查完发现是前后端约定不一致”的玄学现场。3. 词法分析与递归下降把精简 C 变成语法树词法和语法是整条编译流水线的前两站。这两段代码量大、逻辑直白但坑也最多Token 结构设计不到位后面报错信息全废递归下降函数写错一个方向栈就直接溢出。这章给出可以直接抄作业的 Token 设计、表达式解析和 AST 结构。3.1 Token 定义与保留字处理一个结构体解决后续所有报错词法分析的输出是 Token 流。定义 Token 结构时把行号、列号带上这是排查语法错误的第一手信息。常见做法是枚举类型和结构体分开定义枚举给出所有 Token 类别结构体承载实例数据typedef enum { TK_INT, TK_CHAR, TK_VOID, TK_IF, TK_ELSE, TK_WHILE, TK_FOR, TK_RETURN, TK_IDENT, TK_NUMBER, TK_ADD, TK_SUB, TK_MUL, TK_DIV, TK_ASSIGN, TK_EQ, TK_NE, TK_LT, TK_LE, TK_GT, TK_GE, TK_LP, TK_RP, TK_LB, TK_RB, TK_LBRACE, TK_RBRACE, TK_SEMI, TK_COMMA, TK_EOF, TK_ERROR } TokenKind; typedef struct { TokenKind kind; int line, col; int ival; char text[64]; } Token;保留字不需要上哈希表几十个关键字用一张线性表足够。扫描时先收集完整标识符到 text再用顺序查表把 if、while、return 这类词从标识符升级成对应关键字 Token。这里顺序要写对先判断是不是关键字再决定交给标识符处理否则“intx”这种拼写会被误吞成两个 Token。非法字符的处理是词法分析最容易粗糙的点。遇到 、# 这类不在字符表里的符号时返回 TK_ERROR 并跳过一个字符继续扫描而不是直接终止整个编译所有词法错误先收集到一个列表最后统一打印。这样一次能给出完整错误清单而不是反复编译十几次才把错找完。注意Token 的 text 字段不要省。报错信息“第 12 行出现非法字符”永远不如“第 12 行 near main 出现非法字符”有用。后面语法排查会大量依赖这句上下文文本。3.2 递归下降表达式解析优先级靠函数嵌套锁死C 表达式的优先级在语法层面就是函数嵌套深度。解析加减、乘除、括号三个层级正好对应 arith、term、factor 三层函数。每个函数先调用下一优先级再用 while 循环吃掉同一优先级的运算符static ASTNode *parse_arith_expr(void) { ASTNode *left parse_term(); while (tok.kind TK_ADD || tok.kind TK_SUB) { Token op tok; next_token(); ASTNode *right parse_term(); left make_binary(op.kind, left, right); } return left; } static ASTNode *parse_term(void) { ASTNode *left parse_factor(); while (tok.kind TK_MUL || tok.kind TK_DIV) { Token op tok; next_token(); ASTNode *right parse_factor(); left make_binary(op.kind, left, right); } return left; } static ASTNode *parse_factor(void) { if (tok.kind TK_LP) { next_token(); ASTNode *e parse_arith_expr(); expect(TK_RP); return e; } if (tok.kind TK_NUMBER) { ASTNode *n make_int(tok.ival); next_token(); return n; } if (tok.kind TK_IDENT) { ASTNode *n make_var_ref(tok.text, tok.line); next_token(); return n; } error_here(expected an expression, got token kind %d, tok.kind); return NULL; }这里每层 while 循环对应左结合a - b - c 被解析成 (a - b) - c符合 C 语义。如果写成 parse_term 直接调用自身会因左递归无限套娃直到 C 函数调用栈溢出这是递归下降最经典的翻车点必须用 while 改写。右结合的赋值则反着来赋值号右边递归解析整个表达式不放在循环里。factor 的圆括号分支调用上一层 parse_arith_expr加上 expect(TK_RP) 吃掉右括号括号嵌套就自动闭合了。语法分析到这里加减乘除和括号的优先级就能和 C 编译器对上不需要查任何优先级表。3.3 AST 节点与符号表为语义分析和代码生成铺路语法分析的产物是一棵 AST。节点设计不要覆盖全部 C 语法覆盖子集清单就够整数、变量、数组、赋值、二元运算、if、while、函数调用、return、代码块、函数定义大概十个节点类型。typedef enum { N_INT, N_VAR, N_ARRAY, N_ASSIGN, N_BINOP, N_IF, N_WHILE, N_CALL, N_RETURN, N_BLOCK, N_FUNC } NodeKind; typedef struct ASTNode { NodeKind kind; int line; union { int ival; char name[64]; struct { struct ASTNode *left, *right; } kids; } u; struct ASTNode *next; TypeInfo *type; } ASTNode;kind 决定代码生成时走哪条模板line 配合词法报错能给出错误源于源码哪一行u 联合体按节点类型存放字面量、变量名或左右子树避免每个节点都背一个用不上的大结构next 把同一代码块内的多条语句串成链表type 在语义分析阶段填写代码生成时据此决定一次 lw/sw 取 4 字节还是 1 字节。符号表我常用带作用域链的实现每进入一个函数体或代码块新建一个符号表节点挂到链首声明变量时只在链首插入查找从链首向下逐层找找到即返回离开代码块摘下链首。这个小设计能挡住“内层 int a 把外层 a 全带偏”这类隐蔽错误。符号表数据结构本身不需要复杂关键是作用域链的顺序别写反。4. 生成 MIPS 汇编栈帧布局、临时寄存器和调用约定落地代码生成是 C-MIPS 编译器最核心的部分也是工作量最大的部分。它的本质是把 AST 递归翻译成 MIPS 指令序列。这里的三个关键点分别是函数栈帧怎么安排、表达式的临时寄存器怎么分配、函数调用怎么遵守约定。每一点都直接决定生成的汇编能不能在 MIPS 模拟器上正确跑起来。4.1 栈帧与函数出入口FRAME_SIZE 不是拍脑袋算出来的每个函数在栈上有一块独立空间MIPS 里 sp 指向栈顶且向低地址增长。函数入口要做三件事分配栈帧、保存返回地址、按需保存被调用者寄存器。出口则反向恢复。# prologue addiu $sp, $sp, -FRAME_SIZE sw $ra, RA_OFF($sp) sw $s0, S0_OFF($sp) # 用到几个 s 寄存器就存几个 # epilogue lw $ra, RA_OFF($sp) lw $s0, S0_OFF($sp) addiu $sp, $sp, FRAME_SIZE jr $raFRAME_SIZE 怎么算局部变量字节数加保存寄存器字节数最后向上对齐到 8。局部变量字节数来自符号表每个 int 局部变量算 4 字节char 数组按声明长度算参数如果要挪到栈上再额外预留空间。RA_OFF 和 S0_OFF 在布局时从高地址往低地址安排偏移量在生成 prologue 时一次性写死。局部变量的访问统一用 sp 加偏移第一个局部变量在 -4(sp)第 n 个在 -4*n(sp)。这个偏移在声明变量时就算好并记进符号表代码生成查询变量时取出偏移量不需要在运行期动态计算。8 字节对齐是翻车率最高的一条MIPS 要求 sp 在调用点保持 8 字节对齐函数栈帧如果不是 8 的倍数保存 ra 的偏移就可能错位后续在模拟器或 FPGA 板上表现为莫名其妙的跳飞。计算 FRAME_SIZE 的最后一句话永远是向上取整到 8。4.2 表达式翻译每个 AST 节点对应一条 MIPS 模板表达式翻译需要一张“AST 节点到 MIPS 指令模板”的对照表再按序遍历 AST。最小表如下AST 节点MIPS 模板说明N_INTli $t0, 值整数字面量进临时寄存器N_VAR 读lw $t0, 偏移($sp)局部变量从栈读入N_ARRAY 读sll addu lw下标乘宽度再加基地址后读N_ASSIGN左地址、右值、sw左右子树分开生成后合并N_BINOP两操作数各进一个临时寄存器再做算术指令操作数至少占两个临时寄存器临时寄存器分配是这里的核心难点。每个子表达式都要一个临时寄存器放结果嵌套深了容易互相覆盖。常见做法是维护一个深度计数器每进入一个表达式节点加一返回后减一以深度作寄存器下标。static int temp_depth 0; static const char *temp_regs[] { $t0, $t1, $t2, $t3, $t4 }; static const char *alloc_temp(void) { return temp_regs[temp_depth]; } static void free_temp(void) { temp_depth--; }深度 0 的表达式用 $t0深度 1 用 $t1依此类推。递归先深入右子树再回溯兄弟节点和父节点不会同时占用同一深度所以这套按深度分配在小表达式上足够可靠。表达式超过四五层嵌套时深度会超出 t0-t4 范围超出部分要么把结果先压栈要么报“表达式过深”。这是 AST 直译方案最明确的边界实验阶段接受它就好。数组下标翻译是另一个常考点。a[i] 的地址等于 a 的基地址加 i 乘元素宽度。int 数组宽度是 4所以先取 i 到临时寄存器再 sll 左移两位再 addu 加基地址最后 lw 取数据。如果忘了乘宽度a[2] 就会取到首地址后 2 字节处的数据等于把 a[0] 的高半部分和 a[1] 混合在一起。赋值节点最需要注意左值右值区别左边是地址右边是值。翻译 N_ASSIGN 时先递归生成左边的地址不取值再生成右边值两条结果分别落在临时寄存器最后 sw 写入。如果两边都走同一套取值逻辑a b 会被编译成把 b 的值当成地址去写运行期表现为写坏栈内存。4.3 控制流与函数调用标签自增、jal 覆盖 $raif、while 的翻译本质是标签加跳转。条件表达式翻译完结果只可能是 0 或 1放在临时寄存器里再与 $zero 比较后条件跳转。# if (cond) { A } else { B } cond 翻译到 $t0 beq $t0, $zero, .L_else A 的语句序列 j .L_end .L_else: B 的语句序列 .L_end: # while (cond) { A } .L_loop: cond 翻译到 $t0 beq $t0, $zero, .L_end A 的语句序列 j .L_loop .L_end:标签名不能用源码里的变量名因为变量可能重名或带下划线会造成汇编标签冲突。常见做法是维护一个全局计数器每次需要新标签自增一次生成类似 .L_3 的标识。这样不管嵌套多少层 if/while标签永远不重。函数调用按第 2 章定的约定来先把实参从左到右求值放进 $a0-$a3超过四个的在 caller 栈帧预留的参数区里压入然后 jal 目标返回后立刻把 $v0 读到临时寄存器因为下一条语句的计算随时可能覆盖 $v0。被调用函数可以任意使用 $t0-$t9调用者在 jal 前必须确保还需要的临时数据已经落地这就是 caller-saved 的含义。函数返回的 epilogue 里不要忘了恢复 $ra。递归调用时 jal 会把当前 $ra 覆盖成下一次调用的返回地址所以入口保存、出口恢复是递归能正确返回的生命线。很多递归测试翻车最后查出来都是 $ra 被第二次调用覆盖而不是逻辑写错。5. C-MIPS 实现最常见的五个坑现象、原因、解决这一章把课程设计里最容易翻车的五个点单独拿出来写。每条都按“现象 → 原因 → 解决”的顺序可以直接对着自己的代码排查。5.1 数组下标偏移差一倍a[i] 永远取不到期望值现象int a[10] 定义后a[1] 读出来和 a[0] 一样a[2] 读出来是乱值而且下标越大错得越离谱。原因下标翻译没有乘元素宽度。int 占 4 字节a[i] 的地址是基地址加 i 乘 4代码里只做了加法漏了左移两位。这是把 C 源码思维直接搬进代码生成的结果数组每个元素占几个字节这件事在 C 里由编译器隐式处理在 MIPS 汇编里必须自己算。解决生成下标代码时判断元素类型宽度int 用 sll 左移两位char 不偏移。把宽度计算收敛到“元素偏移”这一个函数里全局只改这一处。修完这个坑后我建议把 array.c 测试用例单独归档后续任何一次大改动都先重跑。5.2 赋值左右不分a b 把 b 的值当成了地址现象赋值语句编译后目标变量的值变成奇怪的大地址运行时栈被写坏有时程序跑着跑着就跳飞。原因赋值左侧走了取值逻辑。变量节点在表达式里默认被翻译成 lw 读值但赋值左侧需要的是地址不是值。两侧共用一套翻译逻辑时左侧也先 lw 了一次从变量 a 的地址里读出旧值再把这个旧值当成地址去写。解决代码生成里显式区分 gen_value(node) 和 gen_address(node) 两个入口。变量节点在 gen_address 下只计算栈偏移并返回地址不做 lw在 gen_value 下才读值。赋值节点固定走“左地址、右值、sw”三步。写完后用 a b 这类最小用例跑一遍确认生成的汇编里赋值左侧没有 lw 指令。5.3 条件分支判反if(a b) 该进的不进现象条件表达式生成的汇编看起来对但 if 分支总是相反或随机错误。原因关系表达式翻译成 slt 后条件结果是一个 0/1 值放在临时寄存器里但 if 的分支代码是在翻译条件之前或之后、用错比较方向写的。比如该用 beq 跳向 else 时用了 bne或者拿 slt 结果又和 $zero 做了一次多余的比较后反了。解决把条件分支封装成一个统一入口 emit_if_false(cond_node, label)内部先翻译条件表达式再输出 beq $t0, $zero, label表示条件为假时跳到目标标签。所有 if、while 的翻译都走这个函数不直接散写跳转指令。这样条件翻译和分支语义只在一处定义不会再各写各的。5.4 作用域遮蔽内层 int a 把外层 a 全部带偏现象函数开头声明 int a在 while 块里又声明 int a块结束后外层 a 的引用全部错乱赋值赋不到外层变量上。原因符号表只有一张平铺结构插入时没有分层后声明的内层同名变量覆盖了外层。内层作用域结束时也没有回滚导致后续所有同名引用都找到内层声明。解决符号表改成链式作用域进入函数体或代码块时新建一层挂到链首声明只插入当前层查找从当前层向上逐层找出块时弹出当前层。改造后内层块里的同名变量只影响块内引用块结束立即恢复外层的可见性。这个坑修一次再没复发但它排查起来很耗时间因为错误不在报错信息里而在运行结果上。5.5 栈帧没对齐递归第二次调用就跳飞现象非递归函数一切正常递归函数第一次调用正常第二次或第三次递归返回时报错跳飞sp 指向的地址不对。原因FRAME_SIZE 没有对齐到 8 的倍数。MIPS ABI 要求 sp 在函数调用边界保持 8 字节对齐栈帧大小不对时保存 $ra 的偏移计算也连带错误。递归层级越深sp 的错位越明显最终在第二三层层崩溃。解决栈帧大小做成一个统一计算函数局部变量字节数加保存寄存器字节数后用 (size 7) ~7 对齐到 8。生成 prologue 和计算局部变量偏移时都引用同一份结果避免入口和访问偏移各算一遍导致不一致。验收前用一个递归求阶乘的小程序连续跑 20 层深度能过基本就稳了。6. 验证与调试用最小测试集把每个语法分支逼出问题编译器不能靠“最后跑一两个大程序”验证。正确的做法是从小到大、从单语法节点到组合特性分阶段回归。6.1 最小测试集的设计与一键回归我会建一个 tests 目录每个用例只覆盖一个或两个语法点expr.c 覆盖加减乘除与括号优先级ifelse.c 覆盖嵌套分支while.c 覆盖循环与条件退出array.c 覆盖下标读写和元素宽度func.c 覆盖非递归调用与返回值recur.c 覆盖递归与栈帧保存恢复。每个用例配一个 .exp 期望输出文件。回归脚本用 shell 就能搞定for c in tests/*.c; do ./c-mips-compiler $c -o ${c%.c}.s || echo COMPILE FAIL: $c spim -f ${c%.c}.s ${c%.c}.out diff -u ${c%.c}.exp ${c%.c}.out || echo DIFF FAIL: $c done脚本里 c-mips-compiler 是编译器的输出产物-o 指定汇编输出文件spim -f 以批处理模式运行汇编并打印程序输出diff 对比期望结果。任何一个用例失败echo 会直接点名不用人肉盯终端滚动。6.2 AST dump先判断问题在哪一半排查编译器 bug 最忌讳直接看生成的汇编。先做一次 AST dump把语法树原样打印出来。void dump_node(ASTNode *n, int depth) { for (int i 0; i depth; i) printf( ); if (n-kind N_VAR) printf(var:%s\n, n-u.name); else if (n-kind N_INT) printf(int:%d\n, n-u.ival); else if (n-kind N_BINOP) { printf(op:%d\n, n-u.kids.left-kind); dump_node(n-u.kids.left, depth 1); dump_node(n-u.kids.right, depth 1); } }这个函数只需要覆盖代码生成用到的节点种类不需要很完整。如果 AST dump 和源码语义对不上问题在词法或语法阶段如果 AST 正确但汇编错了问题在代码生成阶段。两层隔离之后排查范围少一半。6.3 一个习惯希望你用得上我自己的血泪经验是一口气写完整个编译器再测等于把词法、语法、代码生成的错误全部混在一起爆炸。后来强制自己“一个功能一个提交”先让 expr.c 通过再提交 ifelse.c 和 while.c再提交 array.c最后提交函数和递归。每过一关就把测试案例连工程编号一起归档比如这次实验对应的【100012256】工程目录里tests 子目录永远保持着“当前已知哪几个用例通过、哪几个还在修”的状态。这个习惯让我最后验收前的心跳次数降了一大半。调试编译器是一个先做简单、再做复杂的过程别指望一次全绿但每一步都能确定退路在哪。希望帮到你。本文还有配套的精品资源点击获取
返回列表