简介:电子科技大学编译原理实验代码面向计算机专业学生和编译原理课程学习者,围绕词法分析、语法分析、抽象语法树构建等实验任务,提供可对照研读的实现方案。压缩包共21个文件、约203KB,以C++源文件和头文件为主,并含Pascal示例、Word运行说明、Visual Studio工程文件与可执行程序,覆盖输入处理、扫描器、解析器及工程配置等环节。已有2114人学习,说明其在课程实验中具有较高参考价值。代码清晰呈现词法单元识别、语法规则解析与语法树生成的组织方式,并保留语义分析、代码生成等扩展空间;结合运行说明和可执行程序,便于完成环境配置、结果验证与排错,适合作为课程实验对照、满分实现参考和编译器构造入门材料。
1. 一份编译原理实验代码能省下多少通宵:从词法到目标代码的完整链路
编译原理实验代码最折磨人的地方,从来不是理论难,而是理论都懂、一动手就卡在某个状态转移或者 FIRST 集上。这套资源把词法分析、语法分析、语义分析到中间代码生成的链路串成一条能直接跑的通路,适合正在上编译原理课、要做课程设计,或者想自己写个小语言解释器的开发者。它的价值在于先给你一个能编译通过的骨架,你在这个骨架上改产生式、加符号表、补四元式,而不是从空目录开始搭工程。下面按工程结构、核心模块、运行排查、二次开发四步,把它拆开讲清楚。
2. 工程结构与技术选型:先看清这套实验代码的骨架再动手
拿到一份编译原理实验代码,很多人第一反应是打开 main 文件往下读,读到最后发现函数调用绕成一团,只好从头再来。血泪经验是:先看目录分了几层、每层负责哪个阶段,再决定从哪个文件下手。这套代码的组织方式遵循经典的「前端—中端—后端」切分,每个阶段有独立的输入输出边界,阶段之间靠数据结构传递,而不是靠全局变量乱串,这一点对后续改造非常关键。
2.1 目录怎么分层:前端、中端、后端与公共设施
常见做法是按编译阶段分目录,词法、语法、语义、代码生成各自一个文件夹,公共的头文件、工具函数、错误处理单独放。我一般会先确认三件事:源文件入口在哪、AST 定义在哪个头文件、目标代码输出到哪里。这三点定了,整个工程的脉络就清楚了。
# 典型的实验工程目录结构(不同版本命名略有差异,按实际文件对照) compiler-lab/ ├── src/ │ ├── main.cpp # 驱动入口:读源文件 -> 逐阶段调用 -> 输出 │ ├── lexer/ │ │ ├── lexer.h # Token 类型、词法分析器接口 │ │ └── lexer.cpp # DFA 扫描逻辑、关键字表 │ ├── parser/ │ │ ├── parser.h # 递归下降 / 预测分析接口 │ │ ├── parser.cpp # 产生式对应的解析函数 │ │ └── ast.h # AST 节点定义 │ ├── sema/ │ │ ├── symbol_table.h # 符号表、作用域栈 │ │ └── type_check.cpp # 类型检查、语义动作 │ ├── codegen/ │ │ ├── quad.h # 四元式结构 │ │ └── codegen.cpp # 中间代码 / 目标代码生成 │ └── common/ │ ├── error.h # 统一错误上报 │ └── utils.h # 字符串、文件读写小工具 ├── tests/ │ ├── case01.src # 合法程序用例 │ └── case02_err.src # 故意写错的用例,验证错误恢复 ├── Makefile # 或 CMakeLists.txt └── README.md这份结构里,src/common是最容易被忽略但最该先看的部分。错误上报的格式决定了你后面调试时能不能一眼定位到行列号;字符串和文件读取工具决定了输入源程序的编码、换行处理是否稳。很多人上来就改 parser,结果报错信息只有一句「语法错误」,连第几行都不知道,这就是典型的黑匣子式调试。先花十分钟把 error.h 的接口看明白,后面能省几个小时。
技术选型上,这类实验代码主流是 C++(配合 Flex/Bison 或者纯手写),也有用 Java 或 Python 做的教学版本。纯手写的版本依赖少、编译快、调试直观,适合边改边学;用 Flex/Bison 的版本生成器代码量大,但产生式写起来更接近文法本身。这套资源如果是手写递归下降,你改文法时只需要同步改解析函数;如果是 Bison,改完.y文件要重新生成,还要注意移进—归约冲突的告警,两种路线的踩坑点不一样,先确认清楚再动手。
2.2 构建方式与输入输出约定:Makefile/CMake 和测试用例怎么配
构建这块,实验代码一般给的是 Makefile,几条规则就能编完。看 Makefile 时重点不是编译命令本身,而是它有没有把-g、-Wall打开。-g决定你后面能不能用 gdb 单步跟语法分析栈,-Wall决定你能不能提前发现未初始化变量这类隐蔽问题。我见过不少同学直接把编译选项删掉求「编译通过」,结果运行时行为玄学,最后发现是某个 Token 的 line 字段没赋值。
# 简化版 Makefile:重点看编译选项和输出目录 CXX := g++ CXXFLAGS := -std=c++17 -g -Wall -Wextra -O0 SRCS := $(wildcard src/**/*.cpp) src/main.cpp OBJS := $(SRCS:.cpp=.o) TARGET := compiler $(TARGET): $(OBJS) $(CXX) $(CXXFLAGS) -o $@ $^ %.o: %.cpp $(CXX) $(CXXFLAGS) -c $< -o $@ run: $(TARGET) ./$(TARGET) tests/case01.src clean: rm -f $(OBJS) $(TARGET)这段 Makefile 里,-O0是刻意保留的:优化打开后变量被寄存器化,gdb 里print一个局部变量经常显示<optimized out>,调试语法分析递归栈时非常难受。-g配合-O0是调试期最省心的组合,等逻辑稳定了再考虑开优化。wildcard那行把子目录里的 cpp 都收进来,新增模块时不用改构建脚本,这一点在二次开发阶段很实用。
输入输出约定同样要先摸清。多数实验代码约定:命令行第一个参数是源程序路径,标准输出打印 Token 序列、语法树或四元式,错误走标准错误。有的版本还会额外输出符号表快照和汇编文件。你在跑之前先确认main.cpp里的参数解析逻辑,别把源文件路径和输出路径搞反了。
// main.cpp 中的典型驱动流程(节选) int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: compiler <source-file>\n"; return 1; } std::string src = readFile(argv[1]); // 一次性读入,注意处理 BOM 和 CRLF Lexer lexer(src); auto tokens = lexer.tokenize(); // 阶段一:字符流 -> Token 流 Parser parser(tokens); auto ast = parser.parse(); // 阶段二:Token 流 -> AST SemanticAnalyzer sema; sema.analyze(ast); // 阶段三:建符号表、类型检查 CodeGen gen; gen.emit(ast); // 阶段四:生成四元式或目标代码 return 0; }这段驱动逻辑值得单独看:每个阶段都是「上一步的输出作为下一步的输入」,没有跨阶段偷偷读全局状态。参数上,argv[1]是源文件路径,readFile负责把整个文件读成字符串;如果源文件带 UTF-8 BOM,词法分析器第一个字符就会识别失败,这是很隐蔽的一个坑。阶段划分清楚的好处是:你可以在任意两个阶段之间插桩,比如把 Token 流打印出来核对,或者把 AST 用括号形式序列化出来比对,定位问题能精确到阶段,而不是在一整条链路上瞎猜。
3. 词法与语法分析拆解:从字符流到抽象语法树的关键实现
前端是整个实验里代码量最大、也最容易被低估的部分。词法分析看着简单,但关键字与标识符的最长匹配、注释和空白跳过、行号列号维护,每一项都能让你调半天。语法分析则是把 Token 流变成结构,递归下降写起来直观,但对左递归和优先级处理有要求,写错了就是死循环或者结合性错误。
3.1 词法扫描器:Token 结构、关键字表与最长匹配
词法分析器的核心是一个带状态的循环:读字符、根据当前状态和字符决定下一状态、到达接受状态就吐出一个 Token。手写版本一般不用显式状态表,而是用 switch/if 模拟 DFA,代码更短也更好改。
// lexer.h:Token 类型与结构定义 enum class TokenType { ID, NUM, // 标识符、整数常量 KW_INT, KW_IF, KW_ELSE, KW_WHILE, KW_RETURN, // 关键字 PLUS, MINUS, STAR, SLASH, // 算术运算符 ASSIGN, EQ, NE, LT, LE, GT, GE,// 赋值与关系运算符 LPAREN, RPAREN, LBRACE, RBRACE, SEMI, COMMA, END // 输入结束 }; struct Token { TokenType type; std::string lexeme; // 原始文本,报错和调试都用得上 int line; // 行号,从 1 开始 int col; // 列号,从 1 开始 };Token 里保留lexeme是个好习惯。语义分析阶段报「变量未定义」时,你能直接把名字打出来;如果只存枚举类型,报错信息就只能写「标识符错误」,定位全靠猜。line和col两个字段在递归下降里也要一路透传到 AST 节点上,否则语法错误只能报文件级别,用户得自己数行。
扫描主体逻辑大致是这样:跳过空白和注释,遇到字母开头就贪心读完整标识符再查关键字表,遇到数字就读完整数字串,遇到运算符先读一个字符再看下一个字符决定是不是双字符运算符(比如=和==)。
// lexer.cpp:标识符 / 关键字识别(节选) Token Lexer::nextToken() { skipWhitespaceAndComments(); // 维护 line/col,遇到 EOF 返回 END int startLine = line_, startCol = col_; if (isalpha(peek()) || peek() == '_') { std::string s; while (isalnum(peek()) || peek() == '_') s += advance(); auto it = keywords.find(s); // 关键字表:unordered_map<string, TokenType> TokenType t = (it != keywords.end()) ? it->second : TokenType::ID; return {t, s, startLine, startCol}; } if (isdigit(peek())) { std::string s; while (isdigit(peek())) s += advance(); return {TokenType::NUM, s, startLine, startCol}; } // 运算符:先读一个字符,再看下一个字符决定是否组成双字符运算符 char c = advance(); switch (c) { case '=': return {match('=') ? TokenType::EQ : TokenType::ASSIGN, ..., startLine, startCol}; case '<': return {match('=') ? TokenType::LE : TokenType::LT, ..., startLine, startCol}; default: error("unexpected character", startLine, startCol); } }逻辑说明:advance()负责取当前字符并前移指针,同时更新列号;match(ch)是「看一眼下一个字符,是就消费掉」,专门处理==、<=、>=、!=这类双字符运算符。关键字表用哈希表存,查一次 O(1),比一串 if-else 比较可读性好得多。参数上,startLine/startCol在跳过空白之前就记录,保证 Token 位置指向它的第一个字符,而不是指向它后面那个空格。
这里最容易翻车的是注释处理。单行注释//要一直跳到行尾,块注释/* */要跨行并且维护行号,如果块注释没闭合,得报一个明确的错误而不是静默吞到文件末尾。我一般会在skipWhitespaceAndComments里加一个计数器,检测嵌套层数(如果语言支持嵌套注释的话),不支持嵌套就简单标记「遇到*/结束」,两种语义选一种写死,别含糊。
3.2 递归下降与 LL(1):表达式优先级怎么落到代码里
语法分析这块,实验代码常见两条路线:递归下降(手写)和 LL(1) 预测分析表(表驱动)。递归下降可读性好,改文法时直接改函数;表驱动更贴近课堂讲的 FIRST/FOLLOW 集,但改文法要重新算表,容易在表里填错项。如果你的实验要求交预测分析表,那表驱动是必须的;如果只是要一个能跑的 parser,递归下降更省事。
表达式解析的关键是把优先级「编码进函数调用层次」。加减一层、乘除一层、原子一层,高优先级的部分被更深层的函数处理,天然形成正确的结合性。
// parser.cpp:表达式解析的三层结构(节选) Expr* Parser::parseExpr() { // 处理 + - Expr* left = parseTerm(); while (check(TokenType::PLUS) || check(TokenType::MINUS)) { Token op = advance(); Expr* right = parseTerm(); left = new BinaryExpr(op.type, left, right, op.line, op.col); } return left; } Expr* Parser::parseTerm() { // 处理 * / Expr* left = parseFactor(); while (check(TokenType::STAR) || check(TokenType::SLASH)) { Token op = advance(); Expr* right = parseFactor(); left = new BinaryExpr(op.type, left, right, op.line, op.col); } return left; } Expr* Parser::parseFactor() { // 处理括号、数字、标识符 if (match(TokenType::LPAREN)) { Expr* e = parseExpr(); expect(TokenType::RPAREN, "expect ')'"); return e; } if (check(TokenType::NUM)) { Token t = advance(); return new NumExpr(t.lexeme, t.line, t.col); } if (check(TokenType::ID)) { Token t = advance(); return new VarExpr(t.lexeme, t.line, t.col); } error("unexpected token in expression"); return nullptr; }逻辑说明:parseExpr里的while循环负责左结合,a - b - c会被解析成(a - b) - c,符合算术语义;如果把循环写成递归调用parseExpr,就会变成右结合,减法结果就错了。parseFactor遇到左括号时递归回到parseExpr,这是括号提升优先级的标准做法。每个函数在失败时返回nullptr并上报错误,调用方通过nullptr判断是否需要继续,避免错误扩散成一连串无意义的报错。
参数上,check只看当前 Token 不消费,match看并且消费,expect看、消费、不符合就报错。这三个辅助函数是递归下降的骨架,写清楚它们,后面的语句解析(if、while、赋值、块)就是照着产生式一条条翻译。语句解析的函数签名一般是Stmt* parseStmt(),内部按当前 Token 分派到parseIf、parseWhile、parseBlock等,块里的语句列表用while (!check(RBRACE))循环收集。
如果走 LL(1) 表驱动路线,核心是一张二维表M[非终结符][终结符] = 产生式,加一个显式栈。改文法后要重算 FIRST 和 FOLLOW,表里冲突的格子就是文法不是 LL(1) 的证据。常见做法是在表驱动外面包一层递归下降处理表达式,因为表达式文法的左递归消掉之后可读性下降明显,混合使用在实践中很普遍。
3.3 AST 节点设计:让后续阶段少写一半判断
AST 节点设计得好不好,直接决定语义分析和代码生成写起来顺不顺。我一般会定义一个基类,带line、col和虚析构,然后按表达式、语句两大类派生。表达式节点存子节点指针,语句节点存语句列表或条件/循环体。
// ast.h:节点基类与两种典型节点 struct ASTNode { int line, col; virtual ~ASTNode() = default; }; struct BinaryExpr : ASTNode { TokenType op; Expr* lhs; Expr* rhs; BinaryExpr(TokenType o, Expr* l, Expr* r, int ln, int c) : op(o), lhs(l), rhs(r) { line = ln; col = c; } }; struct IfStmt : ASTNode { Expr* cond; Stmt* thenBranch; Stmt* elseBranch; // 没有 else 时为 nullptr }; struct Block : ASTNode { std::vector<Stmt*> stmts; };关键点在elseBranch允许为nullptr,这样语义分析和代码生成里用一句if (node->elseBranch)就能区分两种情况,不用额外加标志位。另一个点是所有节点都带行列号,报错时能精确定位;如果只在 Token 里存位置、AST 不存,到了语义分析阶段位置信息就丢了,报错只能报「某处类型不匹配」,非常难查。
节点内存管理在实验代码里通常不讲究,直接new不delete,进程结束由操作系统回收。这在几百行的实验里完全够用,但如果你要把它改成长期运行的服务,就得引入智能指针或者 arena 分配,否则每编译一次漏一批内存。我的建议是实验阶段别过早引入智能指针,unique_ptr会污染所有函数签名,先跑通再说。
4. 语义分析与中间代码生成:符号表、类型检查和四元式
前端产出 AST 之后,中端要做两件事:搞清楚每个名字指代什么(符号表),搞清楚每个表达式类型对不对(类型检查),最后把树形结构压平成四元式这种线性中间表示。这一段是实验里最容易「看起来能跑、其实没检查」的部分,很多代码只做了符号表插入,类型检查形同虚设。
4.1 符号表与作用域链:栈式结构最省心
符号表的核心需求是支持作用域嵌套:进入一个块就开一层,离开就关一层,查找时从内往外逐层找。最直接的实现是vector<unordered_map<string, Symbol>>,下标就是作用域层级,栈顶是当前作用域。
// symbol_table.h:栈式作用域符号表 struct Symbol { std::string name; Type type; // INT / VOID / ERROR 等 int scopeLevel; // 声明所在层,调试用 }; class SymbolTable { public: void enterScope() { scopes_.emplace_back(); } void exitScope() { scopes_.pop_back(); } bool declare(const Symbol& s) { auto& cur = scopes_.back(); if (cur.count(s.name)) return false; // 同层重复声明 cur[s.name] = s; return true; } Symbol* lookup(const std::string& name) { for (auto it = scopes_.rbegin(); it != scopes_.rend(); ++it) { auto f = it->find(name); if (f != it->end()) return &f->second; } return nullptr; // 未声明 } private: std::vector<std::unordered_map<std::string, Symbol>> scopes_; };逻辑说明:declare只查当前层,因为内层可以遮蔽外层同名变量,这是多数语言的语义;lookup从rbegin往外找,保证内层优先。返回指针而不是拷贝,是为了让调用方能拿到符号后继续更新类型信息(比如函数调用后回填返回类型)。参数scopeLevel主要用于调试输出,打印符号表快照时能看出层级关系。
一个常见错误是exitScope之后还拿着之前lookup返回的指针继续用,指针指向的 map 已经被 pop 掉了,行为未定义。规避办法是语义分析里不要跨作用域缓存符号指针,用完即查;如果确实需要跨层引用,就存名字和层级两个值,用的时候重新查。这个坑在嵌套函数或者块内块的结构里特别容易触发。
4.2 类型检查与错误恢复:别在第一处错误就退出
类型检查遍历 AST,对每个表达式节点推导类型,不匹配就报错。难点不在检查本身,而在错误恢复:如果第一处类型错误就让整个编译中止,用户改完一个错误再编译又冒出下一个,来回很多次,体验很差。合格的实验代码应该做到「报错但继续」,尽量把同一个函数里的错误一次性报完。
// type_check.cpp:二元表达式的类型推导(节选) Type SemanticAnalyzer::checkBinary(BinaryExpr* e) { Type lt = checkExpr(e->lhs); Type rt = checkExpr(e->rhs); if (lt == Type::ERROR || rt == Type::ERROR) return Type::ERROR; switch (e->op) { case TokenType::PLUS: case TokenType::MINUS: case TokenType::STAR: case TokenType::SLASH: if (lt != Type::INT || rt != Type::INT) { reportError(e->line, e->col, "arithmetic operands must be int"); return Type::ERROR; } return Type::INT; case TokenType::EQ: case TokenType::LT: case TokenType::GT: if (lt != rt) { reportError(e->line, e->col, "comparison type mismatch"); return Type::ERROR; } return Type::INT; // 用 0/1 表示布尔 default: return Type::ERROR; } }逻辑说明:先递归拿到左右子表达式类型,任意一边是ERROR就直接返回ERROR,这叫错误抑制,避免一个错误引发连锁报错;算术运算要求两边都是INT,比较运算要求两边类型一致。用INT的 0/1 表示布尔是实验代码里的常见简化,省去单独的 bool 类型,代价是打印时数字和布尔看起来一样,调试时要注意区分。
错误恢复的策略上,我一般会在语句级别做同步:解析一条语句出错时,跳过 Token 直到遇到分号或右花括号,然后继续解析下一条语句。这样一份源文件里的多个独立错误能一次报出来,而不是改一个跑一次。代价是实现复杂度上升,但换来的是调试效率,值得。
4.3 四元式生成与回填:把树压平的关键一步
四元式是(op, arg1, arg2, result)四元组,树形表达式转四元式就是后序遍历:先算子表达式,再把结果作为父表达式的参数。控制流(if、while)麻烦一点,因为跳转指令的目标地址在生成时还不知道,需要回填(backpatch)。
// codegen.cpp:表达式四元式生成 + 布尔短路回填(节选) std::string CodeGen::genExpr(Expr* e) { if (auto* n = dynamic_cast<NumExpr*>(e)) return n->value; if (auto* n = dynamic_cast<VarExpr*>(e)) return n->name; auto* b = dynamic_cast<BinaryExpr*>(e); std::string l = genExpr(b->lhs); std::string r = genExpr(b->rhs); std::string t = newTemp(); // 生成临时变量 t1, t2... emit(opToString(b->op), l, r, t); // 追加一条四元式 return t; } void CodeGen::genIf(IfStmt* s) { std::string cond = genExpr(s->cond); int jumpIfFalse = emit("jf", cond, "_", "_"); // 目标暂空 genStmt(s->thenBranch); if (s->elseBranch) { int jumpEnd = emit("jmp", "_", "_", "_"); backpatch(jumpIfFalse, nextQuad()); // then 结束处 genStmt(s->elseBranch); backpatch(jumpEnd, nextQuad()); } else { backpatch(jumpIfFalse, nextQuad()); } }逻辑说明:genExpr递归生成子表达式四元式,返回结果存放的位置(临时变量名或变量名),父表达式直接引用;newTemp每次返回一个新的tN,保证临时变量不冲突。emit返回该四元式的下标,backpatch(下标, 目标)把那条指令的目标字段补上。if 语句里,jf在条件为假时跳到 else 分支或整个 if 之后,生成时目标未知,先占位,等分支代码生成完再回填,这就是「后悔药」机制——地址晚点填,结构先搭好。
参数上要特别注意临时变量编号的全局唯一性,如果每个函数都从t1开始编号,多个函数合到一个文件里就重名了。常见做法是用一个全局计数器,或者按函数名前缀加编号。另一个细节是_占位符在打印四元式时要统一处理成空白,否则输出看起来会很乱。
回填做对了,while 循环就是同一套逻辑的重复:条件开头记一个标号,循环体结束处回填到条件开头,条件为假时跳出。把这几个控制流结构写熟,中间代码生成这一块基本就通了。
5. 运行与排查避坑:文法、环境和输出对不上的常见问题
代码能编译通过只是第一步,跑测试用例时才会暴露真正的问题。这一章按「先跑通、再排错」的顺序来:先给一套最小可复现的跑通步骤,再把我在实验代码里反复遇到的五个坑列出来,每条按现象、原因、解决写清楚,照着对号入座基本能省掉大半调试时间。
5.1 从零跑通的最小步骤
拿到代码后不要急着改任何东西,先按原样跑通一个用例,确认工具链和输入输出都正常。
# 第一步:编译,确认没有告警级别的错误 make clean && make # 第二步:跑官方/自带用例,看输出是否完整 ./compiler tests/case01.src # 第三步:故意喂一个错误用例,确认错误信息带行列号 ./compiler tests/case02_err.src # 第四步:用 gdb 跟一次递归下降,确认调用栈符合预期 gdb --args ./compiler tests/case01.src这四步里,第三步最容易被跳过。很多人只跑合法用例,输出看着对就以为完事了,结果错误处理路径根本没测过,一遇到真实输入就崩。第四步用 gdb 在parseExpr上打个断点,bt看一下调用栈,能直观确认优先级层次是不是你设计的那三层结构,比盯着代码看快得多。
跑通之后再做对照验证:把 Token 序列单独打印出来,和源程序逐行比对,确认关键字、标识符、数字的分类都正确;把 AST 用括号形式序列化,人工核对嵌套关系;把四元式打印出来,检查临时变量编号和跳转目标是否合理。每一步都有独立的输出,出问题时能立刻定位到是哪个阶段。
5.2 五条踩坑记录:现象、原因与解决
下面这几条是我在实验代码里踩得最狠的,按出现频率排序。
现象:词法分析把
if识别成了普通标识符ID,导致语法分析一路报「unexpected ID」。原因:关键字表的初始化在词法分析器对象构造之后,第一次扫描时表还是空的;或者查表用了大小写敏感的比较,源码里写的是大写。解决:把关键字表做成静态常量或者构造函数里初始化,查表前后各打一条日志确认表内容,顺便统一约定关键字全小写。现象:表达式
a - b - c算出来的结果和数学预期不一致,程序不报错但结果偏了。原因:递归下降里把while循环写成了递归调用parseExpr,导致右结合,实际解析成a - (b - c)。解决:加减层和乘除层都用while循环处理同级运算符,只有进入更高优先级或括号时才递归下降一层,改完用1-2-3这种用例验证。现象:源文件第一行是中文注释或者带 BOM,词法分析第一个字符就报错。原因:文件读取没有剥掉 UTF-8 BOM,扫描器把 BOM 的三个字节当成非法字符;中文注释如果编码不匹配,按字节扫描也会错位。解决:读文件后检查开头是否为
EF BB BF,是就跳过;内部统一按字节处理,注释内容只做跳过不做解析,避免编码问题扩散。现象:符号表在嵌套块里查不到外层变量,报「未声明」。原因:
enterScope和exitScope的配对写错了,比如if分支里多调了一次exitScope,或者lookup只查了栈顶那一层。解决:enterScope/exitScope成对出现在块解析函数的入口和出口,lookup从栈顶往下遍历到底;在exitScope里加断言检查栈不为空,提前暴露配对错误。现象:四元式里跳转目标全是
-1或者空,生成的中间代码跑不通。原因:回填只写了backpatch调用但没更新指令数组里的目标字段,或者nextQuad()在回填时返回的位置不对。解决:backpatch里加一条日志打印「第 N 条指令目标回填为 M」,逐条核对;把四元式数组打印出来人工检查跳转链是否闭合,尤其是 if-else 和 while 嵌套的情况。
这几条的共同点是:现象看起来像「玄学」,根因都在某个不起眼的初始化或配对逻辑上。养成在关键阶段打日志的习惯,比事后猜要快得多。
6. 二次开发改造:给这门小语言加一个 for 循环并验证到底
改造思路建议从「加一个语法特性」入手,因为它会逼你走完词法、语法、语义、代码生成四个阶段,是最完整的练手。以加for循环为例,第一步在词法里注册关键字for,加到关键字表里就行,Token 类型新增KW_FOR;第二步在语法里加产生式forStmt -> FOR LPAREN assign SEMI expr SEMI assign RPAREN stmt,对应写一个parseFor函数,内部复用已有的赋值、表达式和语句解析函数;第三步在语义分析里为循环变量建作用域,检查条件表达式是INT;第四步在代码生成里把它展开成等价的三段式四元式,或者直接生成for相关的跳转序列。
// parser.cpp:新增 for 语句解析,复用已有函数 Stmt* Parser::parseFor() { expect(TokenType::KW_FOR, "expect 'for'"); expect(TokenType::LPAREN, "expect '('"); Stmt* init = parseAssign(); // 初始化,如 i = 0 expect(TokenType::SEMI, "expect ';'"); Expr* cond = parseExpr(); // 条件,如 i < 10 expect(TokenType::SEMI, "expect ';'"); Stmt* step = parseAssign(); // 步进,如 i = i + 1 expect(TokenType::RPAREN, "expect ')'"); Stmt* body = parseStmt(); return new ForStmt(init, cond, step, body, init->line, init->col); }这里的关键是复用:parseAssign、parseExpr、parseStmt都已经存在,for只是把它们按特定顺序组合起来,新增代码不到二十行。语义分析里给ForStmt开一层作用域,把初始化语句里的变量声明放进去,循环体和步进都在这一层里查;代码生成阶段可以偷懒,直接把它降级成「初始化 + while(条件) { 体; 步进; }」的四元式序列,复用已有的 while 生成逻辑,改造成本最低。
验证方法上,我一般准备三组用例:正常循环求和、循环变量在体内被修改、循环嵌套。每组都对照手算结果,同时检查四元式里跳转目标是否闭合。改完跑一遍原有的全部用例,确认没把老功能改坏,这一步比加新功能本身更重要。从那以后我每次动语法或语义的代码,都强制先跑一遍回归用例再往下写,吃过一次「加了个特性把原来的 if-else 跳转搞乱」的亏。希望这套拆解能帮你少走点弯路。
本文还有配套的精品资源,点击获取