简介基于精简C语言的C-MIPS编译器实验资源包面向编译原理课程设计与实践适合需要完整实现词法分析、语法分析、语义分析、中间代码生成与目标代码生成的学生或开发者。项目以C语言子集作为输入语言采用Flex与Bison自动生成词法、语法分析程序并在语法树基础上构造符号表、检查上下文错误中间代码生成后利用DAG图进行代码重构与优化最终生成可在MARS汇编器和自制CPU上运行验证的MIPS汇编目标代码。压缩包共26个文件约1.26MB包含项目源文件C、头文件、Flex定义文件、Bison定义文件、编译原理实验报告PDF、运行过程截图以及说明文档目录结构清晰有助于理解编译器各阶段实现细节并快速复现实验。目前已有533人浏览学习适合参考其整体流程设计、关键算法编码与报告撰写思路作为编译原理课程实验或系统软件综合实训的优质模板。1. 基于精简 C 语言的 C-MIPS 编译器这门编译原理实验到底卡在哪C-MIPS 编译器说白了就是给你一套「读 C 语言子集 → 输出 MIPS 汇编」的完整工具链而这份资源把华中科技大学编译原理实验的四次任务全部串在了一起词法分析用 Flex语法分析用 Bison语义分析、中间代码生成、DAG 优化和目标代码生成各自对应一份 C 程序最后生成的汇编交给 MARS 模拟器验证。很多同学做到第二步就卡住因为实验讲义只给了要求和思路没给工程骨架这份 zip 里恰好有 lex.l、parser.y、Analysis.c、ast.c、TargetCode.c、def.h外加 README 和一份编译原理实验报告 PDF等于把「从零开始写编译器」变成了「读懂并改一个能跑的参考实现」。适合正在做编译原理实验、需要对着完整流程写自己版本的人也适合想搞清楚 Flex/Bison 和 AST 遍历如何衔接的开发者。2. 工程文件与工具链先把六个文件的分工和实验四次的对应关系理清2.1 解压后的文件清单与模块职责拿到压缩包后先别急着编译把每个文件对应到编译流程的哪个阶段后面改代码才会顺。以这份资源常见的工程组织方式来看核心文件是这七个外加一份实验报告和若干过程截图文件定位对应实验阶段lex.lFlex 词法规则文件生成词法分析器实验一词法分析parser.yBison 文法文件生成语法分析器和 AST 构建代码实验一语法分析ast.c / def.hAST 节点定义、创建与打印函数实验一/二共用Analysis.c语义分析 中间代码生成的主程序实验二/三前半段TargetCode.c读中间代码序列生成 MIPS 目标代码实验四README.md编译命令、运行示例、常见问题全程编译原理实验报告.pdf完整实验报告含设计思路与测试结果参考这里要特别说一句Analysis.c 在大部分参考实现里承担了两件事——遍历 AST 做语义检查同时在同一遍遍历里产出中间代码这也是实验指导书里明确要求的「在同一遍遍历语法树的基础上利用符号表生成中间代码」。所以不要指望中间代码是单独一个 pass它和语义分析共用一次 AST 遍历符号表建好的那一刻三地址码也顺手出来了。2.2 环境准备Flex、Bison、GCC 的安装与版本坑在 Linux 下用系统包管理器装是最省事的Ubuntu/Debian 系列执行sudo apt update sudo apt install flex bison gcc make flex --version bison --version gcc --version版本检查一定要做因为 Flex 2.6.x 和 Bison 3.x 生成的接口与老版本不完全兼容。常见翻车现场是机器上自带 Bison 3.8但参考实现是拿 Bison 2.7 写的parser.y 里用到的%lex-param、%parse-param写法在 3.x 下行为不一样轻则警告重则编译不过。遇到版本问题我的习惯是看一眼报错是不是在 yylex 或 yacc 的宏定义上如果是优先改 parser.y 的声明而不是降级系统工具。README 里如果写了作者当时的环境版本先按那个版本对齐能少踩一半坑。2.3 编译命令与整体流程从 C 子集到 MARS 汇编整个构建流程分三步Flex 生成词法分析器 C 文件Bison 生成语法分析器 C 文件再用 GCC 把它们和手写的 AST、语义分析、目标代码生成模块一起链接成可执行文件。命令大致是# 1. 由 lex.l 生成 lex.yy.c flex lex.l # 2. 由 parser.y 生成 parser.tab.c 和 parser.tab.h bison -d parser.y # 3. 链接所有模块生成编译器可执行文件 gcc -o cmips lex.yy.c parser.tab.c ast.c Analysis.c TargetCode.c -lfl # 4. 编译一个测试 C 文件输出 MIPS 汇编 ./cmips test.c test.asm参数说明bison -d的-d表示同时输出头文件 parser.tab.hlex.yy.c 里要引用它才能拿到 token 定义-lfl链接 Flex 库提供yywrap等默认实现如果你的 lex.l 里写了%option noyywrap这个库可以不链但链上保险。执行./cmips test.c时编译器内部依次走完词法、语法、语义、中间代码生成、DAG 优化、目标代码生成最终把汇编写到 stdout所以重定向到 .asm 文件是标准操作。这一步跑通之后整条流水线对你就不是黑匣子了任何一步出错报错信息会指明是第几行 C 代码、哪个 token、哪个语法规则不匹配。后续改词法规则、加关键字、调寄存器分配都是在同一个骨架里做局部手术。3. 词法与语法分析lex.l 和 parser.y 是怎么配合工作的3.1 lex.l 的词法规则关键字、标识符、数字与注释状态机精简 C 语言的词法规则不需要覆盖全部 C99 标准核心是抓住关键字集合、标识符、整数常量、运算符和注释。典型的 lex.l 规则段长这样%{ #include def.h #include parser.tab.h int line_no 1; %} %option noyywrap %x COMMENT %% int { return INT; } char { return CHAR; } if { return IF; } else { return ELSE; } while { return WHILE; } return { return RETURN; } [0-9] { yylval.ival atoi(yytext); return NUMBER; } [a-zA-Z_][a-zA-Z0-9_]* { strcpy(yylval.sval, yytext); return IDENT; } |-|*|/ { yylval.opch yytext[0]; return ARITH_OP; } |||! { strcpy(yylval.ostr, yytext); return RELOP; } { return ASSIGN; } ;|(|)|{|} { return yytext[0]; } [ \t] ; \n { line_no; } /* { BEGIN(COMMENT); } COMMENT*/ { BEGIN(INITIAL); } COMMENT\n { line_no; } COMMENT. ; . { fprintf(stderr, line %d: 非法字符 %s\n, line_no, yytext); } %%逻辑说明规则按最长匹配优先必须写在前面否则会被单独匹配成左括号注释用%x COMMENT排他状态处理进入注释后除了*/和换行其余字符全部吞掉这样多行注释不会污染 token 流。yylval是 Bison 的语义值联合体NUMBER 把整数值放进ivalIDENT 把字符串拷进sval语法分析器后面从这两个字段取内容。这里有个新手容易忽略的细节标识符规则里的strcpy如果标识符超过sval数组长度会缓冲区溢出。def.h 里一般会把sval定义成 128 字节但保不准你测试文件里写了个 200 字符的变量名。我习惯在规则里加长度判断超过就报错跳过别让一个变量名拖垮整个编译器。3.2 parser.y 的文法设计优先级、左递归与悬空 else语法分析器的核心是两件事声明运算符优先级来消除表达式文法二义性用左递归处理语句序列避免右递归造成栈溢出。parser.y 的关键片段%{ #include def.h #include parser.tab.h extern int line_no; static Node* new_node(NodeKind kind, Node* l, Node* r); %} %token INT CHAR IF ELSE WHILE RETURN %token ival NUMBER %token sval IDENT %token ASSIGN ARITH_OP RELOP %left - %left * / %nonassoc RELOP %nonassoc LOWER_THAN_ELSE %nonassoc ELSE %% program: program func_def | func_def ; func_def: type IDENT ( ) { stmt_list } { $$ new_node(NODE_FUNC, $6, NULL); strcpy($$-attr.sval, $2); } ; type: INT { $$ TYPE_INT; } | CHAR { $$ TYPE_CHAR; } ; stmt_list: stmt_list stmt | /* empty */ ; stmt: IDENT ASSIGN expr ; { $$ new_node(NODE_ASSIGN, $3, NULL); strcpy($$-attr.sval, $1); } | IF ( expr ) stmt %prec LOWER_THAN_ELSE { $$ new_node(NODE_IF, $3, $5); } | IF ( expr ) stmt ELSE stmt { $$ new_node(NODE_IF_ELSE, $3, $5); $5-next $7; } | WHILE ( expr ) stmt { $$ new_node(NODE_WHILE, $3, $5); } | RETURN expr ; { $$ new_node(NODE_RETURN, $2, NULL); } ; expr: expr expr { $$ new_node(NODE_BINARY, $1, $3); strcpy($$-attr.bin.op, ); } | expr - expr { $$ new_node(NODE_BINARY, $1, $3); strcpy($$-attr.bin.op, -); } | expr * expr { $$ new_node(NODE_BINARY, $1, $3); strcpy($$-attr.bin.op, *); } | expr / expr { $$ new_node(NODE_BINARY, $1, $3); strcpy($$-attr.bin.op, /); } | expr RELOP expr { $$ new_node(NODE_RELOP, $1, $3); strcpy($$-attr.bin.op, $2); } | ( expr ) { $$ $2; } | NUMBER { $$ new_node(NODE_NUMBER, NULL, NULL); $$-attr.ival $1; } | IDENT { $$ new_node(NODE_IDENT, NULL, NULL); strcpy($$-attr.sval, $1); } ; %%参数说明%left和%nonassoc声明了优先级* /在 -之后声明所以乘法优先级更高%prec LOWER_THAN_ELSE是悬空 else 的标准解法——让没有 else 的 if 语句优先级低于 else 分支if(e1) if(e2) s1 else s2会被归约为内层 if 带 else而不是外层。Bison 默认移进优先这里用优先级声明显式告诉它 else 应该匹配最近的未闭合 if。AST 节点通过new_node函数统一创建$$是归约后传给上一层的内容。注意NODE_IF_ELSE的处理有的参考实现把 else 分支挂在节点结构里的单独字段有的像我上面这样挂在$5-next这两种设计会影响 Analysis.c 遍历时的取值方式改代码前先确认自己的 AST 布局免得语义分析时-right取到空指针。3.3 实验一验证打印 AST 的两个方法跑通生成器之后建议先用一个最小测试文件验证 AST 构建对不对./cmips -t test.c-t这个选项如果参考实现里没加就自己看 ast.c 里有没有print_ast之类的函数直接用 gdb 设断点看树结构也行。AST 打印是实验一的重要产出报告里一般会放一张带缩进格式的语法树截图zip 里的 .png/.jpeg 截图可以对着看自己的输出格式差在哪里。我个人建议把打印函数做成「每层缩进两个空格 节点类型名」比如NODE_IF下挂NODE_RELOP一眼能看出条件和分支是谁。后面语义分析检查错误时这个打印也会成为你肉眼排查结构问题的利器。4. 语义分析AST 遍历、符号表设计与同一遍生成中间代码4.1 def.h 中的 AST 节点与符号表结构语义分析要依赖两个基础设施AST 节点的定义以及符号表的数据结构。def.h 里常见的设计是把节点类型枚举、符号表条目、四元组结构全堆在一起方便所有 C 文件共享typedef enum { NODE_FUNC, NODE_STMT_LIST, NODE_ASSIGN, NODE_IF, NODE_IF_ELSE, NODE_WHILE, NODE_RETURN, NODE_BINARY, NODE_RELOP, NODE_NUMBER, NODE_IDENT } NodeKind; typedef struct Node { NodeKind kind; struct Node *left, *right, *next; union { int ival; char sval[128]; struct { char op[8]; } op; } attr; } Node; typedef struct SymEntry { char name[128]; int type; /* TYPE_INT 或 TYPE_CHAR */ int offset; /* 局部变量在栈帧中的偏移 */ struct SymEntry* next; } SymEntry; typedef struct SymTable { SymEntry* head; struct SymTable* parent; } SymTable;参数说明offset字段在目标代码生成时才派上用场它记录变量在栈帧里的位置MIPS 汇编里访问局部变量就是lw $t0, offset($fp)。符号表用parent指针串成作用域链函数体是一个子作用域全局是一个父作用域查找变量时从当前层逐级往上找到即返回这与 C 语言「内层覆盖外层」的语义一致。4.2 作用域链查找与语义检查的典型实现语义分析器的骨架是一次深度优先遍历每个节点类型对应一个 check 函数。变量查找和赋值类型检查是最容易出问题的两个点SymEntry* lookup(SymTable* t, const char* name) { for (; t ! NULL; t t-parent) { for (SymEntry* e t-head; e ! NULL; e e-next) { if (strcmp(e-name, name) 0) return e; } } return NULL; } void check_assign(Node* n, SymTable* t, int lineno) { SymEntry* e lookup(t, n-left-attr.sval); if (e NULL) { fprintf(stderr, line %d: 变量 %s 未声明\n, lineno, n-left-attr.sval); error_cnt; return; } if (infer_type(n-right, t) ! e-type) { fprintf(stderr, line %d: 类型不匹配期望 %d 实际 %d\n, lineno, e-type, infer_type(n-right, t)); error_cnt; } }逻辑说明lookup是典型的符号表查询从当前作用域往父作用域逐级找找到就返回找不到返回 NULL调用方报「未声明」错误。infer_type递归推导表达式类型NODE_NUMBER 是 intNODE_IDENT 查符号表NODE_BINARY 看两个子节点类型是否一致。这里有个细节char 在表达式里可以升级为 int所以不要要求严格相等而是「char 可赋给 int」的单向兼容。4.3 同一遍遍历里输出中间代码实验指导书要求中间代码在语义分析同一遍遍历中顺手生成所以参考实现里常见做法是 check 函数里同时调用 emit 函数。这里给出赋值语句生成四元组的模式static int temp_cnt 0; static Quad quads[MAX_QUADS]; static int qc 0; void gen_expr(Node* n, SymTable* t, char* dst) { if (n-kind NODE_NUMBER) { sprintf(dst, t%d, temp_cnt); add_quad(, n-attr.ival, , dst); } else if (n-kind NODE_IDENT) { strcpy(dst, n-attr.sval); } else if (n-kind NODE_BINARY) { char a1[32], a2[32]; gen_expr(n-left, t, a1); gen_expr(n-right, t, a2); sprintf(dst, t%d, temp_cnt); add_quad(n-attr.op.op, a1, a2, dst); } } void gen_assign(Node* n, SymTable* t) { char dst[32]; gen_expr(n-right, t, dst); add_quad(:, dst, , n-left-attr.sval); }参数说明临时变量编号temp_cnt从 0 开始递增保证每个临时变量名字唯一这是后续寄存器分配能正确工作的前提。四元组结构Quad的五个字段分别是 op、arg1、arg2、resulta b c翻译成 b c aa b翻译成: b _ a。我在实际调试中吃过一次亏gen_expr里对 IDENT 直接把变量名拷进 dst后面做优化时如果把同一变量多处引用合并成一个节点输出的汇编会引用同一个寄存器反而引爆 MARS 里的数据竞争。处理办法是优化器里对每个四元组独立做活跃变量分析再决定是否合并。语义分析做完错误计数error_cnt为 0 才允许进入优化阶段。很多实验要求里写得明白语义错误的存在只影响后续阶段是否执行报告里需要列出你发现的所有错误类型和示例。5. 中间代码生成与 DAG 优化三地址码的结构、优化与输出5.1 四元组的定义与基本块划分中间代码的载体是四元组目标代码生成器 TargetCode.c 只认这一种格式所以 Analysis.c 输出的序列就是两个模块之间的接口。四元组定义typedef struct Quad { char op[8]; /* : - * / JZ JMP LABEL */ char arg1[32]; /* 第一个操作数 */ char arg2[32]; /* 第二个操作数单目运算为空 */ char result[32]; /* 运算结果 */ } Quad;为了 DAG 优化方便优化器通常先把连续的四元组切成基本块基本块的入口是首条四元组、跳转目标、跳转指令的下一条出口是跳转指令或序列末尾。每个基本块内部没有控制流分支DAG 建图只做块内优化跨块的公共子表达式不变换。5.2 DAG 构建节点合并与公共子表达式消除DAG 优化的核心思路是把基本块内的四元组逐个构造成有向无环图遇到相同运算且操作数相同的两个四元组只保留一个节点把第二个四元组的结果变量挂到第一个节点的标签列表里。构建代码的骨架如下typedef struct DAGNode { char op[8]; char name[32]; struct DAGNode *left, *right; int id; char labels[8][32]; int label_cnt; int emitted; } DAGNode; DAGNode* find_or_create(DAGNode* roots[], int n, const char* op, DAGNode* l, DAGNode* r) { for (int i 0; i n; i) { if (roots[i]-left l roots[i]-right r strcmp(roots[i]-op, op) 0) { return roots[i]; } } DAGNode* nn malloc(sizeof(DAGNode)); strcpy(nn-op, op); nn-left l; nn-right r; nn-label_cnt 0; nn-emitted 0; return nn; }参数说明find_or_create是公共子表达式消除的入口判定条件是「op 相同 左右子节点指针相同」。注意这里是按指针相等找不是按字符串值相等找也就是说必须先递归地处理子表达式让相同子表达式在图中已经对应同一个节点上层合并判断才成立。labels数组记录所有「被赋值为该节点结果」的变量名字比如t1 a b和x a b合并后labels 里同时挂 t1 和 x。构建完成后按「先输出叶子再输出根」的后序遍历顺序重新生成四元组公共子表达式只输出一次。输出函数要带emitted标志防止重复输出这就是优化后代码量变小的原因之一。实际跑分时一个循环体内的a[i]*2出现三次DAG 一合并就省掉两次乘法MARS 里运行周期明显下降。5.3 死代码删除与优化后序列的输出顺序DAG 优化不仅能合并重复计算还能顺手做死代码删除如果一个叶子节点的变量在块内从未被后续使用也没有挂在任何标签上说明它在当前基本块内是死的可以直接不输出。判断方法是回溯每个变量的活跃区间或者简单粗暴一点——优化器输出时只保留 labels 非空且结果被引用的节点。输出阶段有个顺序坑假设基本块内三条四元组是t1 a * b、t2 t1 c、x t1DAG 里 t2 依赖 t1 的结果x 直接引用 t1 的标签。后序遍历输出时必须先输出 t1 的节点再输出 t2 的节点最后单独把 x 的赋值补上因为 x 只是 t1 结果的别名不能把 x 当成一个新计算。漏掉这一步优化后的四元组顺序就会从「先算 t1 再用 t1」变成「先算 t2 再补 t1」在 MARS 里直接得到错误结果而且这种错误编译阶段根本不会报只有运行结果对不上才发现。优化器输出的四元组序列继续作为 TargetCode.c 的输入。到这里整个编译器的前五段流水线全部打通剩下的就是把四元组翻译成 MIPS 汇编。6. 目标代码生成与 MARS 验证寄存器分配与五条血泪避坑记录6.1 MARS 里跑通全链路的验证流程与寄存器分配策略目标代码生成是最后一段核心矛盾是「四元组里的临时变量无限MIPS 寄存器只有 32 个」。参考实现一般按实体类型分桶使用寄存器不会做完整图着色实验阶段完全够用。我用这张表来说明分配原则这也是修改 TargetCode.c 时最需要对齐的约定C 语言实体MIPS 寄存器说明临时变量 t0~t9$t0-$t9生命周期短跨函数调用不保存局部变量 s0~s7$s0-$s7跨函数调用需要保存函数参数$a0-$a3 栈超过 4 个参数按顺序压栈函数返回值$v0函数入口/出口约定栈帧指针$fp / $sp每个函数入口addiu $sp, $sp, -framesize对应到二进制表达式四元组目标代码生成的典型输出是「load 两个操作数 → 运算 → store 结果」void emit_binary(const char* op, const char* r1, const char* r2, const char* res) { fprintf(out, lw $t0, %s\n, r1); fprintf(out, lw $t1, %s\n, r2); if (strcmp(op, ) 0) fprintf(out, add $t2, $t0, $t1\n); else if (strcmp(op, -) 0) fprintf(out, sub $t2, $t0, $t1\n); else if (strcmp(op, *) 0) fprintf(out, mul $t2, $t0, $t1\n); else if (strcmp(op, /) 0) fprintf(out, div $t0, $t1\n mflo $t2\n); fprintf(out, sw $t2, %s\n, res); }参数说明lw/sw的操作数是符号地址MARS 的数据段标签会解析成具体地址div在 MIPS 里是除法指令结果放在 LO 寄存器必须用mflo取出到通用寄存器。如果四元组的操作数是带偏移的数组元素比如temp[i]常见做法是先用sll算偏移量再加基地址这块代码在不同参考实现里差异很大改之前先跑通一遍最简单的求和程序。验证流程我一般固定走四步先用./cmips test.c test.asm生成汇编再用 MARS 打开 .asm 文件确认汇编通过、数据段布局正常接着在 MARS 里单步执行看变量在数据段里的值变化最后对比 C 程序的预期输出。实验报告里通常要求贴 MARS 的运行截图zip 里的截图就是参照样板。6.2 五个典型坑的记录现象、原因与解决办法坑一MARS 报 unaligned address程序在第一条 lw 就崩。现象汇编能通过运行时报错点击错误行发现是访问数据段标签的lw指令地址没对齐。原因MIPS 的lw要求地址按 4 字节对齐数据段里如果先声明了一个 1 字节变量或字符串后面的 int 变量就偏到非对齐地址了。解决在变量声明段加.align 2强制后续变量对齐到 4 字节边界。这个坑几乎每个手写目标代码生成的人都会踩一次。坑二分支跳转指令的偏移超范围。现象MARS 汇编时报branch out of range。原因beq的偏移是 16 位有符号立即数表示相对于下一条指令的半字偏移代码段一大跨在beq和目标之间的指令数超过 ±32768 半字就溢出了。解决超出范围的分支改成beqjr组合或者把大代码块按函数拆开让跳转集中在函数内部。这个坑在测试程序里塞了长 if-else 链时特别容易出现。坑三DAG 优化把变量赋值优化没了。现象优化前后的汇编在 MARS 里跑出来结果不同某些变量永远是 0。原因DAG 输出时把某个变量当成了公共子表达式的别名直接跳过但那个变量后续在另一个基本块里被当作输入使用跨块的可见性没有处理好。解决优化器在建块边界时对每个基本块的入口变量重新定义节点块内见到的变量都来自入口状态我后来强制在输出函数里对所有 labels 非空的节点补一条赋值四元组翻车率立刻降为零。坑四C 变量名和 MIPS 指令助记符撞名。现象汇编时报错或者产生的指令被 MARS 当成伪指令展开成多条指令运行时栈布局全乱。原因C 语言里写int add 3;目标代码生成直接把add当数据段标签MARS 里 add 是指令助记符标签冲突。解决目标代码生成时给所有用户变量加前缀下划线比如_add同理函数名main在 MARS 里没有特殊含义但printf这类名称要避开。这是血的教训我上一版代码里所有变量名都没处理跑一个 normal 命名的变量直接翻车。坑五printf 没实现测试输出全看 return 值。现象C 程序里写了printf(result%d, x)编译生成的汇编在 MARS 里没有任何输出。原因精简 C 语言子集不包含 printf 和标准库调用实验要求本来就是说测试程序最终在自设计 CPU 里执行输出靠 MARS 的 syscall。解决写测试程序时用 return 返回值承载结果MARS 里看寄存器 $v0或者自己加一个print_int的库函数内部用li $v0, 1syscall实现。实验报告里最好说明你支持了哪些内置函数不然答辩时容易问倒。这五个坑对应三个层面对齐问题出在目标代码生成的地址分配分支超范围出在控制流翻译优化丢赋值出在优化器的块边界处理撞名和输出问题出在与 MARS 的约定不一致。排查时按「先单步看寄存器 → 再看数据段 → 最后回溯四元组序列」的顺序通常不用半小时就能定位。我现在拿到任何一份新的编译实验代码都会先写一个冒烟测试一个含 if-else、while 循环和加法乘法的求和程序逼自己在三个小时内跑通「C 子集 → MIPS 汇编 → MARS 运行」全链路再开始改业务逻辑。这套流程帮我挡住了至少一半的玄学问题也让我在交实验报告时心里有底。希望这份 C-MIPS 编译器资源和踩坑记录能让你少走几段弯路。本文还有配套的精品资源点击获取