简介:这是一份面向编译原理学习者与课程实践者的词法分析项目源码,围绕C语言文法实现,最终目标是将高级语言转换为MIPS汇编代码,适合正在做编译器课程设计或希望打通前端到后端流程的中高级学习者。压缩包共19个文件,以10个h头文件与7个cpp源文件为主,另有2个txt测试用例,整体约30KB,体量轻便但结构完整。源码按编译器阶段拆分模块,涵盖词法分析、语法解析、中间代码生成、MIPS代码生成、优化、错误处理与寄存器管理等环节,头文件则集中声明各类接口与数据结构,便于读者对照理解符号表、递归下降解析等核心概念。目前已有120人学习下载。通过阅读与调试这套代码,读者可以掌握从源代码扫描、标记识别到目标汇编输出的完整链路,并借鉴寄存器分配、中间表示与错误报告的实现思路,为自行构建小型编译器提供可复用的参考框架。
1. 从一份“词法分析(final)”压缩包说起:它到底能不能跑通
如果你手头正好有一个词法分析(final).zip,解压后看到word.cpp、syntax.cpp、midCode.cpp、mips.cpp、optimizer.cpp、registerManager.cpp这一串文件,第一反应大概率是:这是一个把 C 语言子集编译到 MIPS 汇编的课程设计级编译器。它不是一个“词法分析器”那么简单,而是覆盖了从词法、语法、中间代码、优化到目标代码生成的完整链路。很多人搜“词法分析”是想找一个能直接跑的小程序,但这个包更像一个教学用的小型编译器工程,适合想理解编译器全流程、或者正在做编译原理课设的人。它能不能用,取决于你是否有 MIPS 模拟器(如 Mars、QtSpim)和对应的 C 语言测试输入。下面我从文件结构、编译流程、参数配置到踩坑,一步步拆开。
2. 文件结构与编译链路:每个 cpp 到底在干什么
2.1 从word.cpp到mips.cpp的流水线
拿到这个包,先别急着编译。把文件按功能分组,能省掉大量“找不到入口”的时间。根据文件名和常见编译器课程设计惯例,可以梳理出这样一条链路:
- 词法分析层:
word.cpp/word.h负责扫描源程序,把字符流切分成 token。常见做法是用状态转换图或简单的switch驱动,识别关键字(int、if、while)、标识符、数字常量、运算符和界符。 - 语法分析层:
syntax.cpp/syntax.h接收 token 流,按 C 文法做递归下降或 LL(1) 分析,构建语法树或直接产生中间代码。error.cpp/error.h配合报错,比如缺少分号、括号不匹配。 - 中间代码层:
midCode.cpp/midCode.h生成四元式或类似三地址码的中间表示。这一步是为了把前端和目标机解耦,方便后续优化。 - 优化层:
optimizer.cpp/optimizer.h对中间代码做局部优化,常见的有常量折叠、公共子表达式删除、死代码消除。 - 目标代码层:
mips.cpp/mips.h把中间代码映射到 MIPS 汇编,registerManager.cpp/registerManager.h负责寄存器分配。MIPS 有 32 个通用寄存器,但实际可自由使用的有限,寄存器不够时就要溢出到栈。 - 符号表与辅助:
table.h、normal.h、quotation.h可能存放符号表结构、常量定义和字符串处理。 - 测试输入:
test1.txt、test2.txt是样例源程序,通常包含几个简单的 C 函数,用来验证词法、语法和代码生成是否正确。
提示:不同课设的命名习惯不同,但
word对应词法、syntax对应语法、midCode对应中间代码、mips对应目标代码,这个映射在多数高校编译原理课设中是一致的。
2.2 编译与运行的最小操作步骤
假设你用的是 g++ 或 clang++,在 Linux/macOS 下可以直接用一条命令把所有 cpp 编译成一个可执行文件。注意不要漏掉任何一个.cpp,否则会出现“未定义引用”的链接错误。
# 把所有源文件编译成可执行文件 compiler g++ -std=c++11 -O2 -o compiler \ word.cpp syntax.cpp midCode.cpp optimizer.cpp \ mips.cpp registerManager.cpp error.cpp编译成功后,用测试文件跑一遍:
# 假设程序从标准输入读取源程序,输出 MIPS 汇编 ./compiler < test1.txt > output.s如果程序需要指定输入输出文件,常见做法是:
./compiler test1.txt output.s具体是哪种,要看main函数里怎么解析参数。很多课设代码会把main写在syntax.cpp或单独一个文件里,但文件列表里没有main.cpp,所以入口很可能在syntax.cpp或word.cpp末尾。用grep -n "int main" *.cpp快速定位。
grep -n "int main" *.cpp找到入口后,再看它读的是argv[1]还是std::cin。这一步花两分钟,能避免后面“为什么没输出”的玄学问题。
2.3 关键参数与寄存器分配策略
registerManager.cpp是这个包里比较有技术含量的部分。MIPS 的寄存器分配通常有两种做法:一是简单地把所有变量都放栈上,用lw/sw访问,寄存器只做临时计算;二是做图着色或线性扫描分配,尽量把热点变量留在寄存器里。课设级别常见的是第一种,但会用一个空闲寄存器池来减少访存。
如果你要改寄存器分配策略,重点关注这几个参数:
| 参数/结构 | 常见位置 | 作用 |
|---|---|---|
| 可用寄存器列表 | registerManager.h | 定义$t0-$t9、$s0-$s7哪些可用 |
| 栈帧大小 | mips.cpp或registerManager.cpp | 决定局部变量和溢出槽的偏移 |
| 临时寄存器回收 | registerManager.cpp | 表达式计算完后释放寄存器 |
| 溢出策略 | registerManager.cpp | 寄存器不够时写回栈 |
常见做法是:先分配$t0-$t7给临时值,$s0-$s7给跨基本块的变量,$sp和$fp管理栈帧。如果你发现生成的汇编里大量出现lw/sw,说明寄存器分配比较保守,但功能通常没问题。
3. 词法分析的状态转换与语法分析衔接:怎么改、怎么调
3.1 词法分析的状态转换图落地
词法分析的核心是状态转换。以识别标识符和关键字为例,常见状态转换是:初始状态读到字母或下划线,进入标识符状态;继续读字母、数字、下划线,直到遇到非标识符字符,回退一个字符,把当前字符串拿去查关键字表。如果是int、if、while就返回对应关键字 token,否则返回标识符 token。
在word.cpp里,你可能会看到类似这样的结构:
// 简化后的词法扫描片段 Token Word::nextToken() { skipWhitespace(); if (isalpha(ch) || ch == '_') { string name; while (isalnum(ch) || ch == '_') { name += ch; ch = getChar(); } ungetChar(ch); // 回退一个字符 if (keywordTable.count(name)) { return Token(KEYWORD, name); } return Token(ID, name); } // 数字、运算符、界符处理... }逻辑说明:skipWhitespace跳过空格、制表符和换行;ungetChar是回退,因为多读了一个字符;keywordTable是关键字表,通常用map<string, TokenType>实现。参数上,ch是当前字符,getChar从输入流取下一个字符。如果你要支持注释//和/* */,需要在skipWhitespace或单独的状态里处理,注意/* */不能嵌套,但//要读到行尾。
注意:回退字符时如果用了
ungetc,要确保只回退一个字符,否则会打乱后续 token 的起始位置。很多“词法分析结果错位”的问题都出在这里。
3.2 语法分析如何消费 token 流
syntax.cpp通常是一个递归下降分析器。每个非终结符对应一个函数,比如parseProgram、parseDeclaration、parseStatement、parseExpression。它从word.cpp拿到 token 后,用lookahead向前看一个 token,决定走哪条产生式。
常见做法是:
// 递归下降中匹配期望的 token void Syntax::expect(TokenType type) { if (lookahead.type == type) { lookahead = lexer.nextToken(); } else { error("expected token type " + toString(type)); } }逻辑说明:expect检查当前 lookahead 是否匹配,匹配就前进,否则报错。error函数在error.cpp里,通常会输出行号和错误类型。参数上,type是期望的 token 类型,lookahead是当前预读的 token。如果你发现语法分析一上来就报错,先检查词法分析输出的 token 序列是否符合预期,可以在nextToken里加一行打印,把 token 类型和值输出到stderr。
3.3 中间代码与 MIPS 生成的衔接
midCode.cpp生成的四元式通常形如(op, arg1, arg2, result)。比如a = b + c会变成(+, b, c, t1)和(=, t1, -, a)。mips.cpp遍历这些四元式,为每个临时变量分配寄存器或栈槽,然后翻译成 MIPS 指令。
一个常见的翻译模式是:
// 四元式到 MIPS 的简单映射 void Mips::genAdd(Quad q) { string r1 = regManager.load(q.arg1); // 把 arg1 加载到寄存器 string r2 = regManager.load(q.arg2); string rd = regManager.alloc(); // 分配结果寄存器 emit("add " + rd + ", " + r1 + ", " + r2); regManager.store(q.result, rd); // 结果写回变量 regManager.free(r1); regManager.free(r2); }逻辑说明:load负责把变量从栈或全局区加载到寄存器,如果变量已经在寄存器里就直接返回;alloc从空闲池取一个寄存器;emit输出汇编文本;store把结果写回变量对应的位置。参数上,q.arg1、q.arg2是源操作数,q.result是目标。如果你发现生成的汇编里寄存器冲突,重点检查alloc和free是否配对,以及load是否在变量已被修改后还用了旧寄存器。
4. 避坑与排查:课设编译器最容易翻车的五个地方
4.1 现象:编译通过但运行时报“段错误”
原因:registerManager或mips.cpp里数组越界,常见于寄存器编号超出范围,或者栈偏移计算错误导致访问了非法内存。课设代码里经常用vector或固定数组存寄存器状态,如果alloc返回了-1还继续用,就会越界。
解决:在alloc里加断言或打印,确保返回的寄存器编号在合法范围内。栈偏移用int计算时注意对齐,MIPS 要求栈指针 8 字节对齐。
4.2 现象:词法分析把关键字识别成标识符
原因:关键字表初始化不完整,或者查表时大小写敏感但输入是大写。C 语言关键字区分大小写,Int不是关键字,但如果你误把Int当关键字,就会出错。
解决:检查keywordTable的初始化,确保只包含小写关键字。如果输入里有大写,按标识符处理。
4.3 现象:语法分析报错位置总是不对
原因:词法分析回退字符时多退或少退,导致 token 的起始位置偏移。另一个常见原因是换行符处理不当,行号没有递增。
解决:在nextToken里维护line变量,遇到\n就递增。回退只回退一个字符,并且确保回退的字符不会被再次消费。
4.4 现象:生成的 MIPS 汇编在 Mars 里跑不出正确结果
原因:MIPS 汇编的伪指令和 Mars 的默认设置不匹配,比如用了li加载大立即数但 Mars 不支持,或者没有正确设置$gp、$sp。另一个常见原因是系统调用号写错,比如print_int是1,exit是10。
解决:在 Mars 里勾选“允许伪指令”或手动展开伪指令。检查syscall前后的寄存器设置,确保$v0和$a0正确。
4.5 现象:优化后程序行为改变
原因:optimizer.cpp做了常量折叠或死代码消除,但没考虑副作用。比如把a = f()删掉,如果f有副作用就会出错。课设级别的优化通常只做很保守的局部优化,但如果你自己加了公共子表达式删除,要确保表达式没有函数调用。
解决:优化前先做副作用分析,或者只对纯算术表达式做优化。测试时用-O0和-O2对比输出,确认行为一致。
5. 进阶验证:用差分测试和 MIPS 模拟器确认编译器正确性
5.1 差分测试:同一份 C 代码,对比 gcc 和你的编译器
最可靠的验证方法是差分测试。写一个简单的 C 程序,用 gcc 编译到 MIPS(如果有交叉编译器)或者直接在主机上跑,记录输出;再用你的编译器生成 MIPS,在 Mars 里跑,对比结果。如果没有交叉编译器,可以手工把 C 代码和预期输出写死,只验证你的编译器生成的汇编是否算出相同结果。
# 用 gcc 编译并运行,得到预期输出 gcc -o test test.c ./test > expected.txt # 用你的编译器生成 MIPS,在 Mars 里运行后得到 actual.txt ./compiler < test.c > test.s # 然后在 Mars 里加载 test.s,运行,把输出保存为 actual.txt diff expected.txt actual.txt逻辑说明:diff没有输出就说明结果一致。参数上,test.c要覆盖算术运算、条件分支、循环和函数调用。如果diff有差异,先看是哪个变量算错了,再回到对应的四元式或 MIPS 指令排查。
5.2 用 Mars 的调试功能单步跟踪
Mars 支持单步执行和寄存器查看。加载output.s后,按 F7 单步,观察$t0-$t9、$s0-$s7和栈内存的变化。常见做法是:在关键的四元式对应位置插入nop或注释,方便在 Mars 里定位。如果你发现某个寄存器值不对,往回找是哪条指令写进去的。
提示:Mars 的“Execute”选项卡里可以设置断点,按行号断下来,比盲猜快得多。
5.3 我自己的习惯:每次改完寄存器分配都跑一遍回归
从那以后我每次改registerManager.cpp或mips.cpp的寄存器分配逻辑,都强制走一遍回归:先用test1.txt和test2.txt跑通,再用三个自己写的边界用例(嵌套循环、多函数调用、大量局部变量)验证。因为寄存器分配是编译器里最容易“改一处、崩一片”的地方,没有回归测试,你根本不知道是优化生效了还是引入了新 bug。希望帮到你。
本文还有配套的精品资源,点击获取