news 2026/10/21 2:46:26

MiniC编译器课设拆解:从词法分析到三地址码生成

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MiniC编译器课设拆解:从词法分析到三地址码生成

简介:面向编译原理课程设计的全套参考资源,围绕词法分析、语法分析、符号表管理和Token处理等核心模块展开,适合计算机专业本科生用于课程设计、实验代码调试以及答辩材料准备,尤其适合南京航空航天大学该课程选题参考。压缩包内共32个文件,整体约961KB,包含可直接运行的exe程序、C/C++源文件与头文件、txt格式代码清单、课程设计报告doc、答辩PPT,以及obj、pdb等工程构建产物,源码、可执行程序与文档配套齐全。资源说明称程序经检验无BUG;词法部分和语法部分分别提供独立工程,从词法识别到语法解析、符号表构造的完整实现均可直接运行验证,便于对照学习或二次修改,同时课设报告和答辩PPT可为文档撰写与PPT制作提供参考。目前已有749人学习下载,对于需要快速搭建编译原理项目框架、理解前端实现细节或完善课程设计材料的同学,是一份实用且完整的参考资料。

1. 编译原理课程设计到底难在哪:MiniC 编译器从零到可运行

编译原理这门课最劝退的往往不是文法推导,而是期末课程设计——一个不小心就得从词法分析一路写到代码生成。这个项目是某高校计算机专业编译原理课的完整课设:MiniC,一个用 C++ 手写的类 C 语言编译器,输入.mini源文件,输出三地址码,再放到自带虚拟机里解释执行。整个工程没有外部依赖,构建后只有一个可执行文件,很适合拿来做「从零跑通编译器」的起点。

反直觉的是,这个项目里最折腾人的不是语法分析里的递归下降,而是符号表作用域、临时变量编号这些看起来不起眼的模块。很多同学把第一版写完后,卡在同一个错误上:一个变量在 if 块里定义,出了块还能访问;关键字int被当成标识符识别。这些问题不翻一次车,很难意识到编译器的每个环节是互相咬死的。下面我按整体架构、核心实现、运行测试、避坑和二次开发的顺序,把这份课设拆开讲。

2. 整体架构:MiniC 如何用六个模块拼出编译流水线

把一份课设从「能跑通样例」变成「能讲清楚」的关键,是先看它怎么组织模块。MiniC 遵循了经典的编译前端加后端结构:词法分析、语法分析、语义分析、中间代码生成、虚拟机执行,另外还有一个错误收集模块贯穿其中。下面分三部分拆。

2.1 技术选型:为什么用手写递归下降而不是 Flex/Bison

做编译原理课设,第一道选择就是:用手写词法扫描器和递归下降,还是用 Flex/Bison 自动生成。我拆这个项目时发现作者刻意避开了自动生成工具,所有代码都在自己的src/目录下。这样选有几个实际原因:一方面课程设计要求里通常会列明「必须实现词法分析的状态转换图」,用工具会直接被判定不合格;另一方面,手写递归下降的代码量其实不大,表达式优先级靠函数嵌套就能解决,比维护一堆.y文件更直观。

常见做法是用 C++17 编译,不引第三方库。词法部分是一个Lexer类,内部维护start和current两个游标,逐字符扫描。语法部分是一个Parser类,按照program -> statement*的规则递归下降。语义和中间代码生成放在Sema和IRGen两个类里,最后VM逐条执行四元式。

我一般会建议把「中间表示」想成一种内部协议,前端和后端只依赖这份协议,不直接互相调用。MiniC 的做法是定义了一个Quad结构体,每种运算对应一种Op枚举,所有变量和临时值都用字符串名字表示。

2.2 目录结构:每个文件对应到编译阶段的职责

整个工程目录结构如下:

minic/ ├── CMakeLists.txt ├── include/ │ ├── token.h │ ├── lexer.h │ ├── ast.h │ ├── parser.h │ ├── symbol.h │ ├── ir.h │ ├── vm.h │ └── error.h ├── src/ │ ├── lexer.cpp │ ├── parser.cpp │ ├── sema.cpp │ ├── ir_gen.cpp │ └── vm.cpp ├── tests/ │ ├── test_lexer.cpp │ ├── test_parser.cpp │ └── cases/ │ ├── factorial.mini │ └── expr.mini └── README.md

token.h定义TokenType和Token结构,是所有模块共享的第一份数据。ast.h定义语法树节点,语法分析阶段产出 AST。symbol.h是符号表,记录变量类型、作用域层级和运行时内存位置。ir.h定义Quad和IRProgram,是前端到后端的桥梁。

src/sema.cpp做语义检查,比如变量是否声明、类型是否匹配。src/ir_gen.cpp遍历 AST 生成三地址码。src/vm.cpp是解释执行器,维护一个栈帧数组。error.h处理错误,统一输出「文件名:行号:列号 错误信息」,避免每个模块各写一套报错逻辑。

2.3 中间表示设计:三地址码和符号表的接口约定

三地址码也叫四元式,MiniC 的ir.h核心是Quad结构:

enum class Op { ADD, SUB, MUL, DIV, NEG, ASSIGN, JMP, JE, JNE, LABEL, PARAM, CALL, RET, PRINT }; struct Quad { Op op; std::string arg1; std::string arg2; std::string result; int line; };

四个字段分别是操作码、两个操作数和一个结果。没有操作数的位置就填空字符串。比如a = b + c * 2会被翻译成两条:

t1 = c * 2 t2 = b + t1 a = t2

这里t1、t2是临时变量名,统一以t开头加自增编号。ASSIGN操作把arg1的值写入result变量。JE和JNE是条件跳转,LABEL标记跳转目标。PRINT对应输出语句。

符号表的设计在symbol.h里:

struct Symbol { std::string name; std::string type; int offset; int scope_level; }; class ScopeChain { public: void push(); void pop(); bool insert(Symbol sym); Symbol* find(const std::string& name); private: std::vector<std::vector<Symbol>> scopes; };

push和pop分别对应进入和退出一个花括号块。insert只在当前作用域插入,如果找到同名变量则返回false。find从最内层逐层向外查,保证内层变量能遮蔽外层变量。这个接口贯穿语义分析和代码生成,是后面避坑章节的关键。

3. 核心实现拆解:从一行赋值语句到三地址码的完整链路

这一章是重点。我们跟着一个最简单的输入int a; a = 1 + 2;走一遍整个编译链路,看每个阶段代码长什么样。

3.1 词法分析:用 DFA 手工切出 token

词法分析器的核心是一个状态循环。Lexer::nextToken跳过空白后,根据首字符决定进入哪个分支。数字、标识符关键字、运算符分别处理。下面是关键代码:

Token Lexer::nextToken() { skipWhitespaceAndComments(); start = current; if (isEnd()) return Token(TokenType::END, ""); char c = advance(); if (isDigit(c)) return numberToken(); if (isAlpha(c)) return identifierOrKeyword(); switch (c) { case '+': return Token(TokenType::PLUS, "+"); case '=': if (peek() == '=') { advance(); return Token(TokenType::EQ, "=="); } return Token(TokenType::ASSIGN, "="); case '{': return Token(TokenType::LBRACE, "{"); // ... 省略其他单字符 default: return Token(TokenType::ERROR, std::string(1, c)); } }

这段代码的输入输出很直观。advance消费一个字符并返回它,peek不消费地查看下一个字符。numberToken内部用循环收集连续数字,再在末尾检查是不是整数合法范围。identifierOrKeyword收集字母和下划线开头的字符串,然后去查关键字表。

这里要特别注意等号的处理:单个=是赋值,两个=是等于。如果不做peek二次确认,第一个==会被误切成两个赋值号,语法分析阶段就会报错。这种「两个字符的运算符」在课设里最容易漏。

3.2 语法分析:递归下降处理声明和表达式

语法分析按产生式写递归函数,每个非终结符对应一个函数。MiniC 的解析从parseProgram开始,循环处理每一条顶层语句。int a;是一条声明语句,a = 1 + 2;是一条赋值语句。两者的处理路径不同:

std::unique_ptr<Stmt> Parser::parseDeclStmt() { consume(TokenType::INT); Token name = consume(TokenType::IDENT); consume(TokenType::SEMICOLON); return std::make_unique<DeclStmt>(name.lexeme); } std::unique_ptr<Stmt> Parser::parseAssignStmt() { Token name = consume(TokenType::IDENT); consume(TokenType::ASSIGN); auto expr = parseExpr(); consume(TokenType::SEMICOLON); return std::make_unique<AssignStmt>(name.lexeme, std::move(expr)); }

parseExpr是表达式优先级处理的经典写法。它通过函数嵌套体现优先级:加法表达式调用乘法表达式,乘法表达式调用原子表达式。这样1 + 2 * 3解析出来的 AST 会自动把乘法挂在加法下面。关键片段:

std::unique_ptr<Expr> Parser::parseExpr() { auto left = parseTerm(); while (match(TokenType::PLUS) || match(TokenType::MINUS)) { Token op = previous(); auto right = parseTerm(); left = std::make_unique<BinaryExpr>(op.type, std::move(left), std::move(right)); } return left; }

这个 while 循环处理多个同级运算符,比如a + b + c。match如果匹配到当前 token 就消费并返回true,不匹配就跳过。每一轮把当前结果和右边项合并成一个新的BinaryExpr,最终构建出左结合语法树。如果换成递归parseExpr -> parseExpr -> parseTerm就会产生左递归,直接栈溢出,后面避坑章会专门讲。

3.3 中间代码生成:表达式节点的翻译和临时变量

语法分析产出的 AST 不能直接运行,要翻译成三地址码。IRGen 的做法是每个 AST 节点实现一个generate方法,返回「保存该表达式结果的变量名」。看下面这段处理BinaryExpr的代码:

std::string IRGen::generate(const BinaryExpr* expr) { std::string left = generate(expr->left.get()); std::string right = generate(expr->right.get()); std::string result = newTemp(); Op op = opFor(expr->op); emit(Quad{op, left, right, result, expr->line}); return result; }

先递归生成左右子表达式,得到两个操作数变量,再申请一个新临时变量作为结果。newTemp实现是维护一个计数器,每次返回t加数字:

std::string IRGen::newTemp() { return "t" + std::to_string(tempCount++); }

生成a = 1 + 2时,词法得到数字1和2。数字节点会生成类似t1 = 1的先加载到临时变量吗?MiniC 的简化做法是:常量直接作为操作数使用,不单独生成加载指令。所以上面表达式的翻译结果是:

t1 = 1 + 2 a = t1

临时变量编号从t0开始,遇到常量会直接填在arg1/arg2里。这种设计让中间代码短很多,虚拟机执行时判断操作数是否是数字即可。缺点是常量不能直接做赋值目标,但语言的文法不会产生这种非法代码。

3.4 虚拟机:把三地址码逐条执行

虚拟机是整个课设里最接地气的部分。它维护一个存储变量值的std::unordered_map<std::string, int> memory,外加一个程序计数器pc和运行栈。执行MUL时这样处理:

void VM::run() { while (pc < program.size()) { const Quad& q = program[pc]; switch (q.op) { case Op::MUL: { int v1 = getValue(q.arg1); int v2 = getValue(q.arg2); memory[q.result] = v1 * v2; pc++; break; } case Op::JNE: { int v1 = getValue(q.arg1); int v2 = getValue(q.arg2); pc = (v1 != v2) ? labelTable[q.result] : pc + 1; break; } default: pc++; } } }

getValue先查memory,查不到就尝试用std::stoi把字符串转成常量数字。这一步把「三地址码里的操作数到底取值还是取变量名」的问题一次解决。labelTable在程序载入时提前构建,把LABEL指令的目标字符串映射到指令下标,保证跳转 O(1)。

理解这段代码,会发现整个编译器的输出就是一份「指令列表」,虚拟机只是机械地按程序计数器推进。课程设计做到这个程度,已经完全能够演示从源码到执行的闭环。

4. 运行与测试:构建命令、测试用例和输出解释

一份课设光有代码不够,还要让评测老师能一键跑起来。MiniC 的构建和测试都做了精简,下面按我的实际使用顺序来讲。

4.1 环境依赖与构建方式

依赖只有一个:支持 C++17 的编译器,GCC、Clang 或 MSVC 都行。CMake 版本要求 3.10 以上。构建命令:

cmake -B build -S . cmake --build build -j4

执行完会在build/下生成minic可执行文件。CMakeLists.txt里需要明确指定 C++17:

set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON)

如果不用 CMake,也可以手动编译,常见做法是把src/*.cpp一起编译:

g++ -std=c++17 -Iinclude src/*.cpp -o minic

我的习惯是先跑一下./minic --help。MiniC 支持三个参数:输入文件、-tokens打印词法结果、-ir打印三地址码。这样调试的时候不用反复改代码。

4.2 测试用例:从阶乘小程序跑通全流程

在tests/cases/factorial.mini里放一个递归阶乘程序:

int fact(int n) { if (n <= 1) return 1; return n * fact(n - 1); } int main() { int x; x = 5; print fact(x); }

注意 MiniC 支持函数定义和调用。运行命令:

./minic tests/cases/factorial.mini -ir

输出中间代码会看到fact函数体被翻译成一组带参数传递标签的三地址码。虚拟机执行后最后一行是120。这里最能验证递归调用。需要注意的是,MiniC 的参数传递是用临时变量和PARAM指令模拟的,函数嵌套调用时需要保存返回地址,这个栈帧逻辑在vm.cpp里是最复杂的部分。

如果是简单的算术表达式用例,运行./minic tests/cases/expr.mini会直接输出结果。expr.mini 内容:

int main() { int a; int b; a = 1 + 2 * 3; b = a * (4 - 2); print a; print b; }

预期第一行7,第二行14。如果输出9,说明表达式优先级解析错了,乘法没绑定到2上。

4.3 输出分析与调试开关

-ir参数打印的三地址码每行对应一条Quad,格式是行号: 操作 arg1 arg2 result。我用这个输出做过一次对照:把源码里每个表达式的手写翻译抄在纸上,再对比程序输出,能快速锁定是词法、语法还是代码生成阶段的问题。

-tokens参数把所有 token 打出来,适合排查多字符运算符切分错误。比如输入a==b,输出应当是IDENT(a)、EQ、IDENT(b)。如果看到两个ASSIGN,说明词法没有做peek()次字符检查。

另外,MiniC 在error.h里定义了一个全局错误收集器,编译阶段出错不会立刻退进程,而是收集成列表,最后一次性输出。这样遇到多个语法错误时不用一遍遍重启程序。这一点在写课设时非常省事。

5. 避坑与排查:词法、作用域和临时变量五类经典翻车现场

这份课设我前前后后踩过一些坑,有些是查了两天才定位到。下面五条按「现象、原因、解决」的顺序写,都是能直接对照排查的经验。

5.1 关键字被识别成普通标识符

现象:输入int a;报语法错误,提示int附近缺分号。调试-tokens时发现int被切成了IDENT。

原因:词法分析器先收集完整字符串,再查关键字表。有的实现是先判断第一个字母,然后误以为i开头一定是标识符,跳过了对int的匹配。还有一种情况是关键字表没包含int,只包含if和else。

解决:在identifierOrKeyword里,收集完完整序列后统一查表。我一般把关键字表做成std::unordered_map<std::string, TokenType>,先查表,命中就返回对应 token 类型,没命中才返回IDENT。这一步必须放在所有分支之后,不能提前返回。

5.2 表达式左递归导致栈溢出

现象:解析a = 1 + 2;时程序崩溃,报栈溢出或段错误。把-tokens开出来发现 token 正常,问题出在语法分析。

原因:递归下降里写了parseExpr直接调用自身作为左操作数,没有先消费任何 token,产生无限递归。也就是产生式E -> E + T被翻译成了parseExpr() { parseExpr(); ... },永远不前进。

解决:改成循环。每轮先解析一个项parseTerm(),然后while循环检查+和-,有就再解析一个项并合并成左结合节点。这样每一个parseExpr至少消费一个项,保证 depth 有限。牢记:直接左递归不能用递归下降,要改写为E -> T E'或等价循环。

5.3 作用域链没弹出,变量定义混乱

现象:在函数内部定义一个变量,函数结束后,在外部还能访问。更隐蔽的是:if块里定义了int temp;,块结束后再定义同名变量,代码生成阶段报重复定义。

原因:ScopeChain::pop没有在任何块结束的地方被调用。AST 的BlockStmt节点生成了子块的符号表,但父级generate返回后忘了pop,导致符号表一直停留在子块作用域,find能查到不该查到的变量。

解决:规范符号表的生命周期。MiniC 的做法是在IRGen::generate(const BlockStmt* block)里调用scopes.push(),在函数末尾调用scope.pop(),并且用 RAII 类包裹:

class ScopeGuard { public: ScopeGuard(ScopeChain& scopes) : scopes(scopes) { scopes.push(); } ~ScopeGuard() { scopes.pop(); } };

这样即使中间有异常或提前返回,作用域也一定被正确弹出。我在排查时,一般会在Symbol* find里打印当前scope_level,一眼就能看出是哪个块没出栈。

5.4 临时变量编号冲突:两组表达式生成相同的 t

现象:循环里多次调用表达式,生成的三地址码出现两个不同的变量共用同一个t3,导致虚拟机里第一个值被覆盖,运行结果错误。

原因:newTemp里的计数器被定义成全局静态变量,或者每次调用generate时没有从 0 开始。更典型的翻车是:在一个表达式中同时递归生成左右子节点,左右子节点内部各有一个临时变量计数器,结果两边都生成t0。

解决:把临时变量计数器放进IRGen实例里,作为成员变量,每次从当前实例取号。每个函数的代码生成任务都用同一个IRGen实例,计数器递增不重置。如果是多线程或多次解析,每个IRGen对象独立计数。

5.5 虚拟机除零没有运行时检查

现象:源码里出现x = 10 / (y - 10),当y等于 10 时程序直接崩溃,被评测系统判定为运行时错误。

原因:VM::run的Op::DIV分支直接调用v1 / v2,没有检查v2是否为 0。C++ 整数除零在 x86 下会触发 SIGFPE,程序直接终止。

解决:在除法分支加检查:

case Op::DIV: if (v2 == 0) { vmError("division by zero at line " + std::to_string(q.line)); pc = program.size(); break; } memory[q.result] = v1 / v2; pc++; break;

不要只加这一项,JE、JNE里的比较如果也依赖除法结果,照样有风险。我的习惯是getValue函数统一做一次「除零陷阱检查」,但除法表达式也必须在生成阶段把line号带进Quad,这样运行时错误才能定位到源码行。

6. 二次开发:给 MiniC 加一个取模运算符并改成 MIPS 汇编

这套代码的扩展点很清晰。如果课设要求更高,最简单的改造是增加取模运算符%。你需要改四个地方:token.h加MOD枚举,lexer.cpp的运算符分支加%,parser.cpp的parseTerm循环里加对MOD的match,vm.cpp的Op::MOD分支实现v1 % v2。取模与乘除同级,因此放在parseTerm里最合适。改完后用15 % 4验证输出3。

如果是更硬核的课设,可以把三地址码输出成 MIPS 汇编,而不走虚拟机。这时只需要替换vm.cpp为mips_gen.cpp,把每个Quad映射到 MIPS 指令。比如ADD对应add $t0, $t1, $t2,临时变量编号直接映射为寄存器号。这个过程能逼你做寄存器分配,但不用改变前端。

我做完这个改动后最大的教训是:每次加新功能前,先把-tokens和-ir两个调试输出重新跑一遍,确认新 token 没有污染旧用例。从那以后我每次改动完都会强制走一遍「构建、跑全部 tests/cases、比对输出」的流程,省了太多回头差 bug 的时间。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 2:46:03

开启labview启用私有属性

请编辑 LabVIEW 配置 文件 以 包含 SuperSecretPrivateSpecialStuffTrue 设置。 "C:\Program Files (x86)\National Instruments\LabVIEW 2020\LabVIEW.ini" 启用SuperSecretPrivateSpecialStuffTrue 禁用SuperSecretPrivateSpecialStuffFalse VI Server - LabVIEW …

作者头像 李华
网站建设 2026/10/21 2:45:28

SpringBoot+jpa适配国产达梦数据库

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 2:45:04

基于深度学习的人脸识别签到系统:从模型训练到部署的完整实践

简介&#xff1a;一套完整的基于深度学习的人脸识别签到系统毕业设计项目&#xff0c;面向计算机相关专业学生、毕业设计开发者&#xff0c;可有效解决传统点名签到效率低、易代签、记录易丢失等问题。项目涵盖人脸注册、特征提取、活体检测、识别签到等核心流程&#xff0c;并…

作者头像 李华
网站建设 2026/10/21 2:43:57

arping 命令详解:局域网 MAC 地址探测与 IP 冲突排查实战

简介&#xff1a;这是一份面向网络管理员、运维工程师及网络编程学习者的 arping 工具源码资源包&#xff0c;聚焦以太网环境下基于 ARP 协议探测主机在线状态并获取其 MAC 地址这一典型场景。资源共 19 个文件&#xff0c;以 cpp 与 h 源文件为核心&#xff0c;涵盖 ARP 报文构…

作者头像 李华
网站建设 2026/10/21 2:43:04

Notepad++核心原理与高效文本处理实战指南

简介&#xff1a;本资源为Windows平台下轻量级代码编辑器Notepad的完整安装与配置包&#xff0c;面向编程初学者、Web前端开发者及系统运维人员&#xff0c;解决基础文本编辑、多语言语法高亮、远程文件协作与轻量化IDE替代等核心需求。压缩包共392个文件&#xff0c;涵盖128个…

作者头像 李华
网站建设 2026/10/21 2:41:37

XLSReadWriteII 6.00.16 for Tokyo 10.2:无Office环境下的Excel读写与报表导出

简介&#xff1a;XLSReadWriteII 6.00.16是面向Delphi 10.2 Tokyo环境的Excel文件读写组件包&#xff0c;专注解决开发者在Windows平台上快速生成、修改.xls/.xlsx文件的需求&#xff0c;无需安装Office即可实现单元格数据、公式、样式、图表及透视表等操作&#xff0c;适合报表…

作者头像 李华