从零实现小型C++编译器:编译原理核心流程实践
2026/9/8 17:01:48 网站建设 项目流程

简介:一份完整的编译原理课程设计项目,使用C++在VS2019中实现了一个小型编译程序,可将类高级语言源程序先翻译为四元式,再生成基于8086的汇编代码。资源适合计算机专业学生、编译器入门开发者作为课设参考与代码学习模板。压缩包共47个文件,涵盖源代码(.cpp/.h)、Visual Studio工程文件(.sln/.vcxproj)、调试生成物(.obj/.pdb)、可执行文件,以及两阶段生成的.asm、.med、.dat等中间与结果文件,整体大小约42.74MB。目前已有2752人学习下载。项目按词法分析、语法语义分析、汇编生成等模块划分,代码结构清晰,并附带一个用于验证的res.txt示例;作者课设成绩为优秀,可帮助读者快速理解四元式生成与8086汇编输出的完整流程,也可在此基础上扩展自己的编译器功能。如需课程设计报告,可联系作者进一步获取。 先交代一个背景:我当时拿到“编译原理课程设计,实现一个小型编译程序(C++实现)”这个题目时,第一反应跟大多数人一样——去网上找现成的源码。结果翻了一圈,要么是几百行糊在一起的“能跑就行”代码,要么是Flex/Bison生成的框架代码,看起来高大上,但你问它为什么这么写,讲不清楚。纠结了一周之后,我决定自己从零开始写,用C++一行一行把词法分析、语法分析、语义分析和代码生成拼起来。这段时间我最大的感受是:编译原理那些看起来抽象的概念,比如Token流、递归下降、符号表、三地址码,在上机实现一遍之后,会变成特别自然的东西。这篇博文就记录一下整个小型编译程序的设计思路、核心实现和踩坑经验,给正在做类似课程设计的人一个参考。

这个项目面向的读者有两类:一类是正在做编译原理课程设计、需要从零开始写一个小型编译器的同学,另一类是理论学得还行、但一直没搞清楚“编译器到底是怎么把代码跑起来的”的人。项目完全用C++实现,不依赖任何工具生成代码,所有模块手写,整体的完整流程是:源代码字符串 -> Token流 -> 语法树 -> 带符号表的中级表示 -> 栈式虚拟机指令 -> 执行输出。

1. 开题前想清楚的三件事:语言规模、模块划分、数据流

1.1 定义一个小而完整的语言

选题后第一个要决策的问题,不是用什么算法,而是“这个编译器要编译什么语言”。我见过不少课设项目一上来就照着C语言的语法抄,试图支持指针、结构体、函数、数组,结果写了不到一半就烂尾。说实话,课程设计的时间有限,能体现编译原理的核心流程才是关键,语言特性应该做减法。

我最终定义了一个mini语言,支持以下几种语句:

  • 变量声明:int a;double b;
  • 赋值语句:a = 1 + 2 * 3;
  • 输出语句:print a;print "some text";
  • 输入语句:input a;
  • 条件语句:if (a > 0) { ... } else { ... }
  • 循环语句:while (a < 10) { a = a + 1; }
  • 表达式:支持加、减、乘、除、括号、一元负号、以及 > < >= <= == != 这类比较运算

这个语言能写出来的程序已经相当丰富了,比如求斐波那契数列、判断素数、找最大值这些典型的算法题目都能在里面写。但语法元素只有30种左右,控制在一个月内能完成的体量。说实话,这个规模对课程设计来说刚好:太小的语言体现不出编译的完整流程,太大的语言容易卡在某个细节上出不来。

1.2 分层架构:每个模块的输入和输出必须清晰

编译器的经典结构是“前端+后端”,前端负责把源代码变成中间表示,后端负责把中间表示变成目标代码。对于课设来说,我建议把这一步再细分,让每个模块都能单独验证。

我采用的模块划分方式:

模块输入输出
Lexer 词法分析器源代码字符串Token流
Parser 语法分析器Token流AST语法树
SemanticAnalyzer 语义分析器AST带符号表和类型信息的AST
CodeGenerator 代码生成器AST栈式虚拟机指令队列
VirtualMachine 虚拟机指令队列运行结果
ErrorReporter 错误报告器各阶段收集的错误格式化报错信息

这个分层的好处非常实际:调试的时候,词法出错不会牵扯语法,语法出错不会牵扯语义。编译器的每一层都能独立验证,可以把出错范围缩小到一层之内。另外我建议每一层之间通过明确的接口连接,比如Lexer产出一个vector<Token>,Parser拿到这个vector<Token>之后才开始工作,不要在Parser里再嵌一个词法函数。

1.3 为什么选C++

很多同学会纠结用Java还是Python还是C++,我的建议是如果题目没有硬性要求,就看你最熟的语言。但C++做这个事情有一个天然优势:它跟你写的编译器之间有一种“同构感”——C++程序最终也要经过词法分析、语法分析、中间代码生成,你在C++里写一个编译器,相当于在用“机器能听懂的语言”描述“机器如何听懂语言”这个过程。而且C++的指针和引用非常适合构建语法树这样的层次结构,std::vectorstd::unordered_map这些容器管理Token流和符号表也很顺手。性能上,解释执行一个小型程序,C++的速度基本可以忽略不计。

2. 词法分析器:从字符串到Token流

2.1 Token结构的设计

词法分析做的事情,本质上是把原始字符串“切碎”,切出来的每一块叫Token,同时给每个Token打上类别标签。这个过程很像把一篇文章按词拆分,并标注这个词是名词、动词还是形容词。

我定义的Token类型如下:

enum class TokenType { ID, // 标识符:变量名 INT, // int关键字 DOUBLE, // double关键字 NUMBER, // 数值常量 STRING, // 字符串常量 PLUS, MINUS, STAR, SLASH, // + - * / ASSIGN, // = EQ, NEQ, LT, GT, LEQ, GEQ, // == != < > <= >= LPAREN, RPAREN, LBRACE, RBRACE, // ( ) { } SEMICOLON, // ; IF, ELSE, WHILE, PRINT, INPUT, // 关键字 END // 文件结束标记 }; struct Token { TokenType type; std::string lexeme; // 原始文本 double value; // 当type为NUMBER时,存放数值 int line; // 行号 int column; // 列号 };

注意,我把行号和列号直接放进了Token结构里。这个看起来不起眼的设计,实际却在后面的语法错误报告里帮了大忙。报错信息能精确到第几行第几列,调试效率比只说“第2行有错”高了一个档次。

2.2 手动扫描的循环结构

词法分析器的核心就是一个大循环,逐个字符扫描。我的实现是这样的:

Token Lexer::nextToken() { skipWhitespaceAndComments(); if (isAtEnd()) return makeToken(TokenType::END, ""); char c = peek(); if (std::isalpha(c) || c == '_') { return lexIdentifier(); } if (std::isdigit(c)) { return lexNumber(); } if (c == '"') { return lexString(); } return lexOperator(); }

skipWhitespaceAndComments负责跳过空格、换行、以及//注释和/* */注释,同时维护line_column_两个计数器。这个函数被很多人当成“无脑代码”,但恰恰是这里最容易出问题。比如注释没有正确闭合时,词法分析器会一直扫描到文件结尾,如果不做检查,最后会索引越界。

2.3 识别数字:区分int和double

识别数字时要注意,小数点和整数部分都要正确处理。我用的办法是:先扫描整数部分,如果遇到小数点,再把小数点后面的数字也吃掉,并把Token类型标为NUMBER(后面靠value字段区分类型,语法分析阶段根据变量声明的类型做匹配)。

Token Lexer::lexNumber() { size_t start = pos_; bool isDouble = false; while (std::isdigit(peek())) advance(); if (peek() == '.') { isDouble = true; advance(); while (std::isdigit(peek())) advance(); } // 关键检查:数字后面紧跟字母,说明用户写错了变量名 if (std::isalpha(peek()) || peek() == '_') { std::string bad = peekRemainingIdentifier(); error("非法数字字面量: " + bad); } std::string text = source_.substr(start, pos_ - start); Token t = makeToken(TokenType::NUMBER, text); t.value = std::stod(text); return t; }

这里有一处我踩过的坑:如果数字后面直接跟字母,比如123abc,词法分析器如果只把123当作NUMBER返回,那么后面的abc会被识别成一个标识符,语法分析阶段会报“缺分号”之类的错,问题会被掩盖得很深。最佳做法是在词法阶段直接报“非法数字字面量”,把错误定位到源头。

2.4 运算符的识别:等号与双等号的边界

运算符识别的细节在于两个字符的运算符和单字符运算符之间的区分。比如===<<=。我采用“当前字符 + 下一个字符”联合判断的方式:

Token Lexer::lexOperator() { char c = advance(); switch (c) { case '+': return makeToken(TokenType::PLUS, "+"); case '-': return makeToken(TokenType::MINUS, "-"); case '*': return makeToken(TokenType::STAR, "*"); case '/': return makeToken(TokenType::SLASH, "/"); case '=': if (peek() == '=') { advance(); return makeToken(TokenType::EQ, "=="); } return makeToken(TokenType::ASSIGN, "="); case '!': if (peek() == '=') { advance(); return makeToken(TokenType::NEQ, "!="); } return makeToken(TokenType::UNKNOWN, "!"); case '<': if (peek() == '=') { advance(); return makeToken(TokenType::LEQ, "<="); } return makeToken(TokenType::LT, "<"); // ... } }

这里有个值得注意的设计取舍:!=是完整运算符,但如果用户输入了一个单独的!,编译器应该报错,而不是把这个错误字符当成垃圾丢掉。我在UNKNOWN类型里保留它的原始文本,方便ErrorReporter输出“无法识别的字符: !”。

3. 递归下降语法分析:从Token流到语法树

3.1 为什么不用Yacc,而是手写递归下降

这个选择可能是我在整个项目里最有主见的决定。Flex和Bison是工业级工具,生成代码的效率和准确性远高于手写,但课程设计的价值恰恰在于“亲手实现一遍”。如果只是配置一下Bison文件然后自动生成,做完之后你对语法分析的理解仍然停留在“知道有这么个工具”的层面。手写递归下降,你会真正理解左递归、回溯、预测、FIRST集合这些概念在代码里是怎么体现的。

递归下降的核心思路:每个非终结符对应一个函数,函数内部按照产生式右侧的内容依次匹配Token,或者调用其他非终结符的函数。如果产生式有多个候选分支,就通过“前瞻一个Token”来决定走哪个分支。

3.2 表达式优先级的文法设计

表达式的优先级处理,教科书上的做法是引入多个层次的非终结符。我用的文法如下:

expr := term ((+|-) term)* term := factor ((*|/) factor)* factor := NUMBER | ID | (expr) | -factor

这个文法的含义很直接:expr由若干个term用加减连接,term由若干个factor用乘除连接,优先级通过层层包装自然体现。+-的优先级最低,*/高一层,括号和一元负号最高。左结合性则通过while循环实现,解析1 - 2 - 3时会得到((1 - 2) - 3),而不是(1 - (2 - 3))

对应的C++代码:

ASTNode* Parser::parseExpr() { ASTNode* node = parseTerm(); while (match(TokenType::PLUS) || match(TokenType::MINUS)) { TokenType op = previous().type; ASTNode* right = parseTerm(); node = new BinaryOpNode(op, node, right); } return node; } ASTNode* Parser::parseTerm() { ASTNode* node = parseFactor(); while (match(TokenType::STAR) || match(TokenType::SLASH)) { TokenType op = previous().type; ASTNode* right = parseFactor(); node = new BinaryOpNode(op, node, right); } return node; } ASTNode* Parser::parseFactor() { if (match(TokenType::MINUS)) { return new UnaryOpNode(TokenType::MINUS, parseFactor()); } if (match(TokenType::NUMBER)) { return new NumberNode(previous().value); } if (match(TokenType::ID)) { return new VarNode(previous().lexeme); } if (match(TokenType::LPAREN)) { ASTNode* inner = parseExpr(); expect(TokenType::RPAREN, "缺少右括号 )"); return inner; } error("无法解析的因子"); return nullptr; }

这种写法的妙处在于:不需要引入任何符号优先级表,也不需要在运行时做运算符栈的优先级比较,程序的结构就是优先级的结构。难怪《编译原理》第三版里反复强调文法设计的重要性,真正上手之后才理解这句话的分量。

3.3 错误恢复:自带“跳过”能力的match函数

递归下降分析器最容易卡死的问题是:当Token不匹配时,如果只是简单报错停止,那么一个源代码里如果同时有3个语法错误,你得修改、编译、运行、再看错误,循环3次才能把错误清干净。这个问题靠panic mode错误恢复来解决。

我的做法是封装了一个expect函数,当期望的Token类型不匹配时,记录错误并执行一个跳过策略,把所有Token一直跳到下一条语句的分号或右大括号为止。这样一次编译就能收集到尽可能多的错误:

bool Parser::expect(TokenType type, const std::string& msg) { if (peek().type == type) { advance(); return true; } error(msg + ",第 " + std::to_string(peek().line) + " 行"); synchronize(); // 跳到下一条语句 return false; } void Parser::synchronize() { while (!isAtEnd()) { if (previous().type == TokenType::SEMICOLON) return; switch (peek().type) { case TokenType::IF: case TokenType::WHILE: case TokenType::PRINT: case TokenType::RBRACE: return; default: advance(); } } }

关于synchronize这个函数,我当时调试时踩过一个特别隐蔽的坑:如果跳过Token的逻辑设置得不好,会导致位置不推进,然后Parser在同一个位置反复报错,陷入死循环。后来总结出一个经验:无论走哪个分支,每轮循环至少要向advance()一次,才能保证进度前移。这一点看起来微不足道,但能卡掉80%的新手。

3.4 语句解析:把控制流翻译成AST节点

表达式处理清楚之后,语句的解析就比较容易了。我用一个parseStatement函数来分派不同的语句类型,每种语句对应一个解析函数:

  • parseIfStmt:解析if (cond) stmt else stmt,用IfNode保存条件和两个分支
  • parseWhileStmt:解析while (cond) stmt,用WhileNode保存条件和循环体
  • parsePrintStmt:解析print expr;print string;
  • parseAssignStmt:解析ID = expr;

解析赋值语句时要注意一个问题:第一Token是ID时,可能是赋值语句,也可能是一个表达式语句。我个人倾向于在mini语言里不支持裸表达式语句,也就是说1 + 2;这种写法直接报语法错误,所有以ID开头的语句都当作赋值语句处理。这样Grammar简单很多,也更适合初学。

4. 语义分析:让语法树变得“有意义”

4.1 为什么不能把符号表掉在Parser里

很多同学图省事,会在Parser阶段直接维护一个符号表,边解析边填表。我一开始也这么干,但很快就发现代码越来越乱:语法分析的职责是检查“结构对不对”,语义分析的职责是检查“逻辑合不合理”。这两个问题的关注点完全不同,混在一起会让Parser代码膨胀,而且一旦后面要扩展作用域或类型系统,改动会非常痛苦。

所以我选择:先把AST完整构建出来,再单独用一次遍历做语义检查。这个设计的好处非常明显,Parser的代码保持纯粹,语义分析的逻辑集中在一个文件里,几十年积累的工程经验确实是有道理的。

4.2 符号表的实现方式

符号表的核心功能是记录“变量在哪个作用域、什么类型、对应哪个运行时槽位”。在mini语言里,作用域主要有全局作用域和{}块作用域两种。我的实现采用了作用域链的方式:

struct Symbol { std::string name; Type type; // Type::INT 或 Type::DOUBLE int slot; // 变量在运行时的存储槽位 bool initialized; // 是否初始化过 int declLine; // 声明所在行号,用于报错 }; class Scope { public: Scope* parent; std::unordered_map<std::string, Symbol> symbols; bool contains(const std::string& name) const; bool add(const Symbol& sym); bool lookup(const std::string& name, Symbol& out) const; };

关于作用域链的实现,要注意一点:lookup查找变量时,必须先查当前作用域,再逐层向上查父作用域,直到查不到为止。在同一个作用域内重复声明同名变量属于错误,但子作用域可以声明与父作用域同名的变量,这是合法的遮蔽(shadowing)。这个设计决定了语义分析的规则。

4.3 语义检查的类型和时机

我实现的语义检查包括四类:

  • 未声明变量:在遍历AST时,如果遇到VarNode且符号表里查不到,就报“变量未声明”
  • 重复声明:在声明语句中,如果当前作用域已存在同名变量,就报“变量重复声明”
  • 类型不匹配:赋值语句中,如果右侧表达式求值类型与左侧变量类型不一致,就报“类型不匹配”,但这里我用的是一个比较宽松的规则:double变量可以接收int表达式的值,int变量如果接收到double类型的值则报错
  • 未初始化变量使用:检查VarNodeinitialized标记,在使用变量时如果发现尚未赋值,就报“变量未初始化”的警告。

其中类型推导的逻辑集中在BinaryOpNode的访问函数里:

Type SemanticAnalyzer::inferBinaryType(const BinaryOpNode* node) { Type lt = getType(node->left); Type rt = getType(node->right); if (lt == Type::DOUBLE || rt == Type::DOUBLE) { return Type::DOUBLE; } return Type::INT; }

这种类型提升规则跟C语言一致:只要有一个操作数是double,结果就是double,否则是int。这样做的好处是生成中间代码时,所有数值都能统一转换成double存储,虚拟机不需要维护复杂的类型信息。

4.4 程序结构信息如何传给后端

语义分析完成后,AST节点上要附带两类关键信息:类型信息和变量的slot编号。slot编号的作用是告诉虚拟机这个变量应该放在“运行时的哪个格子”里。我在遍历作用域时,给每个变量分配一个递增的int编号,比如第一个变量slot是0,第二个是1,以此类推。这样中间代码生成阶段,一个变量名就映射成一个整数索引,后面的虚拟机只需要面对slot,不再需要处理变量名。

5. 中间代码与虚拟机:让程序真正跑起来

5.1 栈式虚拟机的指令集设计

课程设计里生成中间代码,最直观的选择就是栈式虚拟机指令。栈式指令的特点是:操作数都放在一个栈上,指令从栈顶弹出操作数,计算结果再压回栈里。它跟真实CPU的寄存器架构相比效率低一些,但结构极其简单,非常适合展示“代码是如何被一步步解释执行的”。

我设计的指令集非常精简:

指令含义
PUSH_NUM把一个常量压入操作数栈
PUSH_VAR把一个变量的值压入操作数栈
STORE_VAR弹出栈顶值,写入指定变量
GET_INPUT读取用户输入,存储到指定变量
PRINT_VAL弹出栈顶值并打印
PRINT_STR打印一个字符串常量
ADD/SUB/MUL/DIV弹出两个操作数,计算后压回
GT/GE/LT/LE/EQ/NEQ比较两个操作数,压入0或1
JMP无条件跳转到指定指令序号
JMP_FALSE弹出栈顶值,为0则跳转
LABEL跳转目标标记
HALT程序结束

给一个小例子,赋值语句a = 1 + 2 * 3;生成的指令序列是这样:

PUSH_NUM 1 PUSH_NUM 2 PUSH_NUM 3 MUL ADD STORE_VAR a // a 的 slot 假设为 0,实际指令里存的是 STORE_VAR 0

可以看到,这个执行过程跟我们在编译原理课上讲的逆波兰表达式几乎一模一样,栈式虚拟机本质上就是一个“带变量的计算器”。

5.2 从AST生成指令的方式

代码生成阶段其实就是对AST做一次后序遍历。比如遇到BinaryOpNode,先递归生成左子节点的指令,再生成右子节点的指令,最后输出该运算符对应的指令。这里有一个细节:左子节点的指令必须先生成,因为栈式执行时,后压入栈的值会先被弹出。以减法为例:

std::vector<Instruction> CodeGenerator::genBinaryOp(const BinaryOpNode* node) { auto left = gen(node->left); auto right = gen(node->right); auto vec = left; vec.insert(vec.end(), right.begin(), right.end()); vec.push_back(makeInstruction(opFor(node->op), node->op)); return vec; }

5.3 控制流指令:if和while的跳转处理

生成if语句的指令时用到JMP和JMP_FALSE两种跳转指令。关键点在于label的编号管理。我用一个labelCounter_,每次遇到一个if或while就生成一个唯一的编号:

std::vector<Instruction> CodeGenerator::genIfStmt(const IfNode* node) { auto condCode = gen(node->condition); int elseLabel = newLabel(); int endLabel = newLabel(); auto result = condCode; result.push_back(makeInstruction(OpCode::JMP_FALSE, elseLabel)); auto thenCode = gen(node->thenBranch); result.insert(result.end(), thenCode.begin(), thenCode.end()); result.push_back(makeInstruction(OpCode::JMP, endLabel)); result.push_back(makeInstruction(OpCode::LABEL, elseLabel)); auto elseCode = gen(node->elseBranch); result.insert(result.end(), elseCode.begin(), elseCode.end()); result.push_back(makeInstruction(OpCode::LABEL, endLabel)); return result; }

没有else分支时,直接把elseLabel和endLabel合并成一个,减少跳转次数。

while循环的处理逻辑类似,要额外注意一点:条件判断放在循环体前面,条件为假时跳到循环结束,条件为真时执行循环体之后跳回条件判断处。

5.4 虚拟机的执行循环

虚拟机的运行逻辑非常直白,一个for循环不停取指令、执行指令。执行时维护一个操作数栈std::vector<double> operands_和一组局部变量std::vector<double> locals_STORE_VAR slot执行时,从栈顶弹出值并写入locals_[slot],而PUSH_VAR slot执行时读取locals_[slot]并压栈。

void VirtualMachine::run() { for (size_t pc = 0; pc < instructions_.size(); pc++) { const Instruction& ins = instructions_[pc]; switch (ins.op) { case OpCode::PUSH_NUM: operands_.push_back(ins.numValue); break; case OpCode::PUSH_VAR: operands_.push_back(locals_[ins.varIndex]); break; case OpCode::STORE_VAR: locals_[ins.varIndex] = pop(); break; case OpCode::ADD: { double rhs = pop(); double lhs = pop(); operands_.push_back(lhs + rhs); break; } case OpCode::JMP: pc = ins.target - 1; break; case OpCode::JMP_FALSE: if (pop() == 0.0) { pc = ins.target - 1; } break; case OpCode::HALT: return; default: break; } } }

特别注意JMP的实现:因为外层循环执行完pc++,所以实际跳转时需要把pc设置为target - 1。这个“减1”的细节坑了我不少时间,因为指令计数编号是从0开始的,而跳转目标在生成指令时往往会先标记好位置。如果你也遇到跳转乱跳、循环不结束的问题,优先检查这里。

5.5 一个mini程序的完整实例

整个流程跑通之后,我可以直接用mini语言写一个计算0到10之和的程序:

int sum; int i; sum = 0; i = 1; while (i <= 10) { sum = sum + i; i = i + 1; } print sum;

经过Lexer、Parser、SemanticAnalyzer、CodeGenerator之后,生成的中间代码会以极快速度执行完毕,输出55。这一个小程序验证了词法、语法、语义、变量存储、控制流、运算和输出整个链路是否通畅。看到控制台打印出正确的55时,我长出了一口气,那种持续了一个多月的“心里悬着一块石头”的感觉彻底落地了。

6. 测试与调试:课程设计里那些隐藏最深的坑

6.1 坑一:错误恢复造成的死循环

前面说了panic mode的synchronize实现,如果写得不好,会在错误位置原地踏步。我遇到的实际场景是这样的:一个if语句少了左括号,Parser报错后跳过Token,但synchronize里恰好把左括号跳过了,右括号也跳过了,结果跳到了条件表达式内部,把整个结构弄乱了,后面的解析函数继续报错,最终在同一个循环里产生了一百多条错误信息。

解决办法就是我在3.3里写的那条原则:synchronize每调用一轮,必须保证至少消费一个Token,否则就强制advance()一次。我把这个判断写成了一个断言放进synchronize开头,调试时很快就能定位问题。

6.2 坑二:比较运算优先级与JMP_FALSE的交互

另一个让我纠结了两天的Bug发生在if条件的中间代码分析环节。如果条件比较运算生成的指令优先级有误,JMP_FALSE就会弹出一个错误的值。举个例子:if (a > 0) { ... },正确的指令是:

PUSH_VAR a PUSH_NUM 0 GT JMP_FALSE endLabel

但我一开始把GT的比较结果压栈顺序搞反了,导致a > 0被计算成了0 > a。问题难查就难在,它不影响编译流程,只是运行结果反了。最后我用一个简单的测试程序输出了多个比较表达式的结果,逐一跟手算值对比,才锁定了操作数弹出顺序的问题。

这里我悟出一个很重要的调试技巧:栈式虚拟机的每条指令执行前,栈里的元素个数其实是可预测的。比如ADD执行前栈里至少要有2个元素,执行后减少1个。我在虚拟机的run()里加了一个#ifdef DEBUG模式的检查函数,在每条指令执行后比对栈深度变化是否符合预期,一旦发现栈元素残留就立刻打印指令序号和当前指令内容。这个机制帮我快速梳理出了一大批代码生成阶段的数据流Bug。

6.3 坑三:变量声明与赋值的初始化状态丢失

早期版本里我并没有给Symbol添加initialized字段,导致下面的代码能在编译期顺利通过:

int a; print a;

运行时,a的slot里是一个未初始化的double值(0.0),输出0,看起来好像没问题,但这其实掩盖了一个语义层面应该被捕获的错误。后来我在语义分析阶段加了initialized标记,并要求在使用变量时必须先赋值,成功补救了一处容易留下隐患的Bug。顺便说一下,很多真实编译器对这种“未定义行为”的处理方式都不一样,但课程设计阶段,宁可严格一点,也不要放过明显不合理的代码。

6.4 值得专门设计的测试用例

课程设计报告里,“测试用例设计”是必不可少的一部分。我的经验是分层设计测试用例,层次越分明,报告越有说服力:

测试层次测试输入示例期望结果
词法错误int a = 123abc;报“非法数字字面量”,精确定位行列
语法错误if (a > 0 { print a; }报“缺少右括号”并跳过整条语句
语义错误print undefined_var;报“变量未声明”
语义错误int a; int a;报“变量重复声明”
类型错误int a; double b; a = b;报“类型不匹配”
运行行为含while循环的程序循环次数正确,输出完全正确

把这6类用例做全、做扎实,课程设计答辩时拿着测试表和报错截图,比单纯说“我的编译器能编”更有说服力。

7. 如果让我重做这个课设,我会在这些地方花更多时间

写完整套编译程序再复盘,有一些经验心得值得分享。如果时间倒流,我会把重心放到AST节点类设计上,先花一晚上设计好所有节点类的接口和字段,把节点类型、源位置信息、类型信息全部规划完整,再动手写Parser。实际上我一开始是边写Parser边改节点类,导致好多处重复修改。过早跳进编码确实容易让自己陷入细节里出不来,先想清楚数据结构真的能省掉大量返工。

另外,编译原理这门课的目的不完全是“学会写编译器”,而是通过编译器这条线,把字符串处理、树形结构、栈、哈希表、指令执行这些零散的知识全部串起来。写完这个课设之后,再看平时用的IDE、解释器、脚本语言,我会有一种“原来它们内部大概就是这样的”直觉感。如果你正在做这个课设,我建议你一定不要只满足于“跑通Demo”,而要把每个模块单独测试一遍,记录每个Bug的根因。期末答辩时,你把自己排错的过程讲清楚,老师通常不会问得太深,因为你已经用行动证明了你是真的理解了。

最后分享一个小技巧:给编译器加一个--dump-tokens--dump-ast的命令行参数,把Token流和AST结构打印出来。调试词法问题时看Token流,调试语法问题时看AST,这是最快缩小问题范围的办法。这个习惯一直延续到现在,我写别的解释器、模板引擎,也都会先加一个类似的中间表示导出功能,越早能看到中间产物,找Bug的速度就越快。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询