简介:中国海洋大学2020年春季学期编译原理课程的8个实验代码与要求文档合集,面向正在学习编译器构造、需要动手实践词法分析到目标代码生成全流程的高校学生与自学者。内容围绕词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成、错误处理与诊断、编译器综合八个阶段展开,并附实验目标、预期输出与评估说明,便于对照课程进度复现实验并理解阶段衔接。压缩包共74个文件、约774KB,以18个C源文件、8个Flex词法文件(.l)、4个Bison语法文件(.y)、8个头文件、4个Makefile及若干测试用例和说明文本为主,覆盖从源码到可执行程序的构建环节。目前已有4419人学习。读者可据此搭建实验环境、查阅参考实现并比对输出结果,借助Makefile与测试样例排查编译流程中的常见问题,适合课程实验复盘与编译器入门实践。
1. 编译原理这门课,最难的不是理论,是第一次把六个阶段真正接起来
有个读者跟我说,课上学 Follow 集、LR(1) 项目集闭包推导,笔记记了一整本,一到上机就懵:词法分析器怎么把字符串切成 token?语法树节点定义几层?语义分析查表查到空指针该报哪一行?我特别理解这种感受——编译原理实验真正的门槛从来不是算法本身,而是把词法、语法、语义、中间代码这几段孤立的代码拼成一条能跑通的链路。
这套某高校编译原理课程的完整实验源码,价值就在这。它不是一段孤立的 Demo,而是把词法分析、语法分析、语义分析、中间代码生成几层完整串起来的一套可运行代码,配好测试用例和文档。适合两类人:一是正卡在实验报告和上机验收之间的同学,需要一份能对照着调试的参照实现;二是想搞懂"一个表达式到底怎么变成四元式"的开发者。下面按目录结构、各阶段核心实现、踩坑排查、二次开发四块拆开讲。
2. 工程目录与实验链路:五个实验是怎么串成一条编译流水线的
拿到一份编译原理实验源码,最容易犯的错是直接从main.cpp读起。这类代码的组织方式往往按「实验周次」分目录,而不是按编译阶段分层,直接顺着文件读会读到大量重复的、每个实验各自复制一份的工具函数。所以第一步永远是先看目录,搞清楚哪些是共用框架、哪些是各实验独立的实现。
2.1 顶层目录:共用框架与实验目录的边界
这类实验包常见的组织形态有两种:一种是每个实验一个独立可执行程序,各带main;另一种是共用一套 AST 与诊断模块,只是不同阶段有各自的驱动入口。这套源码走的是后者,目录大致长这样:
compiler-lab/ ├── include/ # 公共头文件,各实验共享 │ ├── token.h # Token 定义与种类枚举 │ ├── lexer.h # 词法分析器接口 │ ├── ast.h # 语法树节点基类与派生 │ ├── parser.h # 语法分析器接口 │ ├── symbol.h # 符号表与作用域栈 │ ├── type.h # 类型系统 │ ├── tac.h # 四元式与临时变量管理 │ └── diag.h # 统一错误诊断输出 ├── src/ │ ├── lexer.cpp │ ├── parser.cpp │ ├── sema.cpp # 语义分析(符号表建立 + 类型检查) │ ├── tac.cpp # 中间代码生成 │ └── main.cpp # 分阶段驱动,按命令行参数切换执行到哪一步 ├── tests/ │ ├── lex/ # 词法用例:.src 输入 + .tok 期望输出 │ ├── parse/ # 语法用例:.src 输入 + .ast 期望输出 │ ├── sema/ # 语义用例:含错误用例,期望特定错误码 │ └── tac/ # 中间代码用例:按行比对四元式 ├── docs/ │ ├── grammar.md # 文法定义(EBNF) │ └── report/ # 各次实验的报告模板与说明 ├── Makefile └── README.md关键点在于include/与src/是横向分层的,tests/是按阶段纵向切的。这样组织的好处是:做第三个实验(语义分析)时,不需要把前两个实验的代码复制一遍,只需要在sema.cpp里把前面产出的 AST 消费掉。实际动手改的时候,我需要动哪几个文件,边界非常清楚。
main.cpp用参数控制执行深度,常见做法是:
# 只跑到词法,输出 token 序列 ./coolc --stage=lex tests/lex/basic.src # 跑到语法,输出带缩进的语法树 ./coolc --stage=parse tests/parse/expr.src # 跑到语义分析,输出符号表与错误列表 ./coolc --stage=sema tests/sema/scope_err.src # 跑到目标码,输出四元式序列 ./coolc --stage=tac tests/tac/if_while.src这种「一个驱动、多个 stage」的设计不是炫技。做实验时最痛苦的是改了词法分析器,结果语法分析的行为也跟着变了,而你根本不知道是词法改错了还是语法本来就埋着 bug。分阶段输出让你能一段一段地比对期望文件,出问题的地方一目了然。
提示:
--stage这种参数形式各家实现不一样,有的用-l/-p/-s单字母开关,有的干脆编译出多个二进制。拿到源码先 grep 一下argc、argv的处理位置,别急着按自己的想象敲命令。
2.2 各阶段的输入输出契约:为什么必须先把它固定下来
流水线能串起来,靠的是每个阶段之间的数据契约。这套代码的契约大致是:
| 阶段 | 输入 | 输出 | 输出形态 |
|---|---|---|---|
| 词法分析 | 源文件字符流 | std::vector<Token> | 每个 Token 带种类、原始串、行号列号 |
| 语法分析 | Token 序列 | Program*(AST 根节点) | 树形结构,节点带源码位置 |
| 语义分析 | AST 根节点 | 符号表 + 错误列表 | 原地给 AST 节点打上类型与符号引用 |
| 中间代码生成 | 已通过语义检查的 AST | std::vector<Quad> | 线性四元式序列,带 label 与跳转目标 |
这个契约里最容易忽视的是「节点带源码位置」这一条。很多同学写出来的 Token 只存lexeme,不存行列号,等到语义分析阶段报「变量未定义」的时候,只能打印一个变量名,用户拿着一张长源码根本找不到在哪。行号列号从词法阶段就要一路带下来,AST 节点构造时直接从 Token 拷贝过去,这个成本很低,收益极大。
另一条容易被忽视的是「语义分析原地给 AST 打标记」。有的实现选择让语义分析返回一份新的、带类型标注的树,那样内存管理和节点对应关系都变得很麻烦。原地标注——也就是在Expr节点上加一个Type* type字段,语义分析阶段填进去——代码量小得多,而且后续中间代码生成直接读node->type就行。
2.3 构建与回归:Makefile 里那几条真正有用的目标
这类实验包一般不会上 CMake,一个 Makefile 足够了。但 Makefile 里值得看的不只是all,更值得看的是测试目标:
CXX := g++ CXXFLAGS := -std=c++17 -Wall -Wextra -g -Iinclude SRCS := $(wildcard src/*.cpp) OBJS := $(SRCS:.cpp=.o) TARGET := coolc all: $(TARGET) $(TARGET): $(OBJS) $(CXX) $(CXXFLAGS) -o $@ $^ # 单阶段回归:遍历 tests/<stage>/ 下所有 .src,与同名期望文件比对 test-lex: @for f in tests/lex/*.src; do \ ./$(TARGET) --stage=lex $$f > /tmp/out.tok 2>&1; \ diff -u $${f%.src}.tok /tmp/out.tok || echo "FAIL: $$f"; \ done test-parse: @for f in tests/parse/*.src; do \ ./$(TARGET) --stage=parse $$f > /tmp/out.ast 2>&1; \ diff -u $${f%.src}.ast /tmp/out.ast || echo "FAIL: $$f"; \ done clean: rm -f $(OBJS) $(TARGET)-Wall -Wextra这两个编译选项在编译原理实验里尤其值钱。递归下降的语法分析器里,一个switch漏掉某个TokKind分支,不加-Wswitch就静默走 default 返回空指针,最后表现为段错误,排查起来要命。开上警告,编译器直接把漏掉的分支列给你。
回归测试用diff -u比对期望输出,是这一套源码里最实用的部分。它把「我觉得应该对了」变成「字节级完全一致」。我一般会先跑一遍全部用例看基线,改完一个模块再跑一遍,diff 为空才继续往下走。靠肉眼盯着终端滚动的输出判断对错,翻车概率太高了。
3. 词法与语法分析核心拆解:从字符流到一棵能遍历的树
前两个实验的代码量通常占了整个包的一半以上,也是最容易写得又臭又长的地方。这一节挑出真正值得细看的三块:Token 的数据结构设计、扫描器的主循环写法、以及语法分析里递归下降与分析表两条路线的取舍。
3.1 Token 结构与 DFA 扫描器主循环
Token 的定义决定了后面所有阶段能拿到什么信息。这套代码的token.h大致是这样:
// include/token.h #pragma once #include <string> enum class TokKind { Ident, // 标识符 IntLit, // 整型字面量 RealLit, // 实型字面量 Keyword, // 关键字(if/while/int/...) Punct, // 运算符与分隔符 Eof, // 输入结束 Error // 词法错误,如非法字符、未闭合注释 }; struct Token { TokKind kind; std::string lexeme; // 原始词素,报错和调试时直接给人看 int line = 0; // 起始行号,从 1 开始 int col = 0; // 起始列号,从 1 开始 long long intVal = 0; // kind == IntLit 时有效 double realVal = 0; // kind == RealLit 时有效 };lexeme和数值字段同时保留是有意为之。lexeme面向人和调试输出,数值字段面向后续阶段的直接使用。如果只存lexeme,语义分析每次要用整型常量都得重新std::stoll一遍,错误处理还得再来一轮;如果只存数值,报错信息里就没法还原用户原本写的0x1F还是31。
扫描器主循环的核心是一个手写 DFA,典型形态:
// src/lexer.cpp(节选) Token Lexer::next() { skipWhitespaceAndComments(); // 跳过空白与 // 与 /* */ 注释 if (pos_ >= src_.size()) return makeToken(TokKind::Eof); const int startLine = line_, startCol = col_; const char c = src_[pos_]; // 标识符或关键字:字母或下划线开头,后接字母数字下划线 if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') { size_t begin = pos_; while (pos_ < src_.size() && (std::isalnum(static_cast<unsigned char>(src_[pos_])) || src_[pos_] == '_')) { advance(); } std::string word = src_.substr(begin, pos_ - begin); // 查关键字表:命中就是 Keyword,否则是 Ident TokKind k = isKeyword(word) ? TokKind::Keyword : TokKind::Ident; return Token{k, word, startLine, startCol}; } // 数值字面量:先吞掉整数部分,遇到 '.' 再切到实数分支 if (std::isdigit(static_cast<unsigned char>(c))) { size_t begin = pos_; while (pos_ < src_.size() && std::isdigit(static_cast<unsigned char>(src_[pos_]))) advance(); bool isReal = false; if (pos_ < src_.size() && src_[pos_] == '.') { isReal = true; advance(); while (pos_ < src_.size() && std::isdigit(static_cast<unsigned char>(src_[pos_]))) advance(); } std::string num = src_.substr(begin, pos_ - begin); Token t = isReal ? Token{TokKind::RealLit, num, startLine, startCol} : Token{TokKind::IntLit, num, startLine, startCol}; if (isReal) t.realVal = std::stod(num); else t.intVal = std::stoll(num); return t; } // 运算符:必须先试双字符,再退回单字符,否则 "<=" 会被切成 "<" 和 "=" if (pos_ + 1 < src_.size()) { std::string two = src_.substr(pos_, 2); if (two == "<=" || two == ">=" || two == "==" || two == "!=" || two == "&&" || two == "||") { advance(); advance(); return Token{TokKind::Punct, two, startLine, startCol}; } } advance(); return Token{TokKind::Punct, std::string(1, c), startLine, startCol}; }这段代码里三个细节值得单独说。advance()负责推进pos_并维护line_/col_,遇到'\n'时把行号加一并把列号归零——这一步如果漏了,后面所有报错的行号都是 1。运算符匹配必须「先试长的再试短的」,这是最长匹配原则在词法里的具体体现,顺序反了<会把<=吃掉。最后,数字的.处理要小心1..5这种非法输入,严格实现还需要回看前一个 token 来判断.是小数点还是数组成员访问符。
关键字表通常用一个静态std::unordered_set<std::string>或排序好的std::vector加binary_search。前者 O(1) 但要算哈希,后者 O(log n) 且常数极小,关键字就几十个,实际差别可以忽略,选哪个看个人习惯。
3.2 递归下降与分析表:两条路线怎么选
语法分析部分的实现路线,直接决定了这份源码读起来有多舒服。用 LL(1) 分析表的话,代码里会有一大块二维表和一个通用的栈驱动循环,看起来「很编译原理课」,但调试体验相当糟糕——出问题你只能看到当前栈顶符号和输入符号,然后去查表,一行行对下标。递归下降则是每个非终结符一个函数,报错时调用栈本身就是文法路径,定位快得多。
这套代码选的是递归下降,语句部分的骨架:
// src/parser.cpp(节选) // Stmt -> IfStmt | WhileStmt | Block | ExprStmt Stmt* Parser::parseStmt() { switch (peek().kind) { case TokKind::Keyword: { const std::string& kw = peek().lexeme; if (kw == "if") return parseIf(); if (kw == "while") return parseWhile(); if (kw == "return")return parseReturn(); break; } case TokKind::Punct: if (peek().lexeme == "{") return parseBlock(); break; default: break; } return parseExprStmt(); // 兜底:表达式语句 } // IfStmt -> 'if' '(' Expr ')' Stmt ('else' Stmt)? Stmt* Parser::parseIf() { Token kw = expect(TokKind::Keyword, "if"); expect(TokKind::Punct, "("); Expr* cond = parseExpr(); expect(TokKind::Punct, ")"); Stmt* thenBranch = parseStmt(); Stmt* elseBranch = nullptr; if (peek().kind == TokKind::Keyword && peek().lexeme == "else") { advance(); elseBranch = parseStmt(); } // line 取自关键字位置,语义分析报错时直接指向 if 那一行 return new IfStmt(kw.line, cond, thenBranch, elseBranch); }expect是错误恢复的关键入口,它做两件事:匹配成功就前进一个 token,失败就记录一条带行号的诊断信息,然后不前进,让上层决定怎么办。常见的错误恢复策略是「同步集合」——在语句边界(;、}、if、while)处丢 token 直到能继续解析。不前进这一个细节很重要,如果expect失败还强行advance(),往往会把真正的错误点冲掉,最后报出来一堆莫名其妙的后续错误,原始问题被淹没。
递归下降有一个必须先处理干净的前提:文法不能有左递归。Expr -> Expr '+' Term这种写法直接翻译成函数就是parseExpr()第一句调parseExpr(),栈溢出。标准做法是消左递归改写成Expr -> Term Expr'、Expr' -> '+' Term Expr' | ε,或者更工程化的写法:用一个循环处理二元运算符的优先级,而不是真的写Expr'这种非终结符:
// Expr -> Term (('+'|'-') Term)* —— 用循环代替右递归 Expr* Parser::parseAdditive() { Expr* left = parseMultiplicative(); while (peek().kind == TokKind::Punct && (peek().lexeme == "+" || peek().lexeme == "-")) { Token op = advance(); Expr* right = parseMultiplicative(); left = new BinaryExpr(op.line, op.lexeme, left, right); } return left; }这种「一层优先级一个函数」的写法,比机械翻译分析表可读性高太多,也是手写解析器的主流做法。优先级从低到高依次是:赋值 → 逻辑或 → 逻辑与 → 相等比较 → 关系比较 → 加减 → 乘除 → 一元 → 后缀 → 主表达式,每一层一个函数,层层向下调用。
3.3 AST 节点设计与打印格式
AST 节点的设计有一个反复被讨论的问题:用继承体系还是用带标签的联合体。继承体系的写法:
// include/ast.h(节选) struct Node { int line = 0; virtual ~Node() = default; }; struct Expr : Node { Type* type = nullptr; // 语义分析阶段原地填上,初始为空 }; struct BinaryExpr : Expr { std::string op; // "+" "-" "*" "/" "<" "==" ... Expr* lhs; Expr* rhs; BinaryExpr(int ln, std::string o, Expr* l, Expr* r) : op(std::move(o)), lhs(l), rhs(r) { line = ln; } }; struct IfStmt : Node { Expr* cond; Node* thenBranch; Node* elseBranch; // 可能为空 IfStmt(int ln, Expr* c, Node* t, Node* e) : cond(c), thenBranch(t), elseBranch(e) { line = ln; } };Expr里预留一个Type* type字段,是语义分析能「原地标注」的前提,前面 2.2 提到的契约在这里落地。继承体系配合dynamic_cast或者给 Node 加一个enum class NodeKind标签,遍历时按标签分派。纯dynamic_cast在节点量大时开销不小,实用做法是标签 +static_cast,标签正确性由构造函数的唯一入口保证。
打印格式(--stage=parse的输出)通常是带缩进的前缀表达式或括号表达式,用于diff比对:
If Binary(<) Var(x) IntLit(10) Block Assign Var(y) IntLit(1) Else Block Assign ...这种格式的好处是人类能读、机器能 diff。如果打印成一行紧凑的if(< x 10){...},diff 一次只能看到一个巨大的差异块,定位到具体哪个子树变了很费劲。缩进多行加固定前缀,改动会精确落在某几行上。
4. 语义分析与中间代码生成:符号表、类型检查与四元式回填
前两个实验只要输出对了就完事,后两个实验开始有「正确性」的语义在里面——变量必须先用后声明、类型必须匹配、跳转目标必须落在合法位置。这一节讲三块:符号表的作用域栈怎么组织、类型检查规则挂在哪里、以及四元式生成里最容易写错的回填。
4.1 符号表:作用域栈而非一张大表
符号表最常见的错误实现是一张全局std::map<std::string, Symbol>,遇到{不新建作用域,遇到}不清理。这样写第一个用例能过,第二个用例里两个函数各有一个同名局部变量就直接报「重复定义」。正确做法是作用域栈:
// include/symbol.h(节选) struct Symbol { std::string name; Type* type; enum class Kind { Var, Func, Param } kind; int offset = -1; // 栈帧内偏移,目标代码生成阶段才用 }; class Scope { public: explicit Scope(Scope* parent = nullptr) : parent_(parent) {} // 返回 false 表示本作用域内已存在同名符号(重定义) bool declare(const Symbol& sym) { if (table_.count(sym.name)) return false; table_.emplace(sym.name, sym); return true; } // 沿 parent 链向上查找,找到最近的一层 Symbol* lookup(const std::string& name) { for (Scope* s = this; s; s = s->parent_) { auto it = s->table_.find(name); if (it != s->table_.end()) return &it->second; } return nullptr; } private: std::unordered_map<std::string, Symbol> table_; Scope* parent_; }; class SymbolTable { public: SymbolTable() { push(); } // 全局作用域 void push() { stack_.push_back(new Scope(stack_.empty() ? nullptr : stack_.back())); } void pop() { delete stack_.back(); stack_.pop_back(); } bool declare(const Symbol& s) { return stack_.back()->declare(s); } Symbol* lookup(const std::string& n) { return stack_.back()->lookup(n); } private: std::vector<Scope*> stack_; };declare只在当前作用域检查重名,lookup才沿父链向上走,这个区分是作用域语义的核心。函数入口push()、出口pop(),块语句{ }进出也各来一次。要注意pop()时如果符号的Type*是堆分配的,需要在这里释放,否则长期运行会漏,虽然实验里跑一次就退出,但按正确内存管理的习惯写没坏处。
4.2 类型检查:规则挂在哪一层
类型检查有两种挂法:一是单独写一个typeCheck(Expr*)递归函数,二是让每个 AST 节点自己实现check(SymbolTable&)。前者集中、好读,后者分散、好扩展。这套代码用的是第一种,一个大的checkExpr按节点标签分派:
// src/sema.cpp(节选) Type* Sema::checkExpr(Expr* e, SymbolTable& st) { if (!e) return nullptr; // 每种节点先递归检查子节点,再自底向上算类型 if (auto* b = dynamic_cast<BinaryExpr*>(e)) { Type* lt = checkExpr(b->lhs, st); Type* rt = checkExpr(b->rhs, st); const std::string& op = b->op; if (op == "+" || op == "-" || op == "*" || op == "/") { // 算术运算:两侧必须都是数值类型 if (!lt->isNumeric() || !rt->isNumeric()) { diag_.error(b->line, "arithmetic operand must be numeric"); return Type::Error; } // 任一为 real 则结果为 real,否则 int return (lt->isReal() || rt->isReal()) ? Type::Real : Type::Int; } if (op == "<" || op == ">" || op == "==" || op == "&&") { if (op == "&&" && (!lt->isBool() || !rt->isBool())) { diag_.error(b->line, "'&&' requires boolean operands"); return Type::Error; } return Type::Bool; } diag_.error(b->line, "unknown operator: " + op); return Type::Error; } if (auto* v = dynamic_cast<VarExpr*>(e)) { Symbol* sym = st.lookup(v->name); if (!sym) { diag_.error(v->line, "undeclared identifier: " + v->name); return Type::Error; } v->sym = sym; // 回填符号引用,中间代码生成直接用 return sym->type; } // ... IntLit / RealLit / CallExpr / AssignExpr 等分支 return Type::Error; }这里有一个决定后续阶段好不好写的选择:遇到错误类型后,是继续传播还是立刻中断。Type::Error这个哨兵类型的作用就是让错误可以向前传播而不引发二次报错。lt->isNumeric()对Error返回 false 的话,一个未定义变量会连带报出一条「算术操作数必须是数值」,用户看到两条错误,实际只有一个问题。正确做法是让Error在几乎所有的isXxx()判定里都返回 true(或专门在检查前短路),把错误抑制住,只报根源那一条。
v->sym = sym这一句也是契约的一部分:中间代码生成阶段要取变量的栈帧偏移,没必要再查一遍符号表,直接读回填的指针。
4.3 四元式生成与回填:短路求值为什么必须两次遍历
四元式的结构很简单,麻烦的是控制流的跳转目标在生成时还不确定。比如:
if (a < b && c > 0) { x = 1; } else { x = 2; }生成a < b的判断后,如果为假要跳到 else 分支,但 else 分支的代码还没生成,跳转目标地址未知。这就是回填(backpatching)要解决的问题。
// include/tac.h(节选) struct Quad { std::string op; // "+" "-" "<" "=" "j" "jf" "label" "param" "call" "ret" std::string arg1; std::string arg2; std::string result; }; class TacGen { public: // 生成一条四元式,返回它在 vector 中的下标,供回填使用 int emit(const std::string& op, const std::string& a1 = "", const std::string& a2 = "", const std::string& res = "") { quads_.push_back({op, a1, a2, res}); return static_cast<int>(quads_.size()) - 1; } // 生成新标签,形如 L1 / L2 std::string newLabel() { return "L" + std::to_string(++labelSeq_); } // 回填:把第 idx 条四元式的 result 字段改成 target void backpatch(int idx, const std::string& target) { quads_[idx].result = target; } void backpatch(const std::vector<int>& list, const std::string& target) { for (int i : list) quads_[i].result = target; } // 短路与:先算左,为假直接跳到整个表达式的假出口 // 返回 {真出口列表, 假出口列表} std::pair<std::vector<int>, std::vector<int>> genAnd(Expr* lhs, Expr* rhs) { auto [lt, lf] = genCond(lhs); std::string labelMid = newLabel(); backpatch(lt, labelMid); // 左为真 → 继续算右 emit("label", "", "", labelMid); auto [rt, rf] = genCond(rhs); std::vector<int> trueList = rt; // 右为真 → 整体为真 std::vector<int> falseList = lf; falseList.insert(falseList.end(), lf.begin(), lf.end()); // 左为假的情况也要并入整体假出口 falseList.insert(falseList.end(), lf.begin(), lf.end()); return {trueList, falseList}; } private: std::vector<Quad> quads_; int labelSeq_ = 0; };上面genAnd里最后几行有重复插入的笔误——这正是这类代码最容易翻车的地方,假出口列表被并了两次,回填后虽然结果不错但列表长度翻倍,多出来的下标指向错误的四元式。正确写法是把「左为假」的列表和「右为假」的列表合并一次:
std::vector<int> falseList = lf; falseList.insert(falseList.end(), rf.begin(), rf.end()); return {rt, falseList};也就是说,短路与的假出口 = 左操作数的假出口 ∪ 右操作数的假出口;真出口 = 右操作数的真出口(左为真时才会去算右)。短路或则反过来:真出口 = 左真 ∪ 右真,假出口 = 右假。这个对偶关系记住了,写起来不容易错。
回填这套机制的价值在于:整个生成过程只需要一遍遍历,不需要在内存里反复搬移指令,也不需要在大数组里预留空洞然后二次扫描。这也是真实编译器(从早期的 Pascal 编译器到后来的各种实现)采用回填而不是「先生成再调整」的原因。
注意:标签名用
L1、L2这种自增序列时,函数内联或多次调用生成器会全局递增,导致不同函数里的标签编号不连续。这不是 bug,比对期望输出时不要按「每个函数从 L1 开始」去写期望文件。
5. 避坑与排查:这份实验代码里最容易翻车的五处
下面五条是我拆这类代码时反复撞上的,每条按现象、原因、解决写,遇到了能省下大半天。
5.1 现象:<=被切成<和=,语法分析报「意外的 =」
原因:词法分析里运算符匹配是单字符优先,或者双字符判断写在了单字符advance()之后。最长匹配原则在代码里的落地顺序必须是「先看两个字符能不能组成合法运算符,不能再看一个」。
解决:把双字符匹配提到最前面,用src_.substr(pos_, 2)或直接两个字符比较;同时给tests/lex/加一条专门的用例,覆盖<=、>=、==、!=、&&、||六种组合。
5.2 现象:解析一个简单的a + b + c就栈溢出或卡死
原因:文法写成左递归Expr -> Expr '+' Term,递归下降直接照抄,parseExpr()第一句又调parseExpr(),无限递归。
解决:消左递归。要么机械改写为Expr -> Term Expr',要么更实用地用while循环吞掉同级运算符(见 3.2 的parseAdditive)。改完拿一条长链表达式做压测,a+b+c+d+...+z跑通基本就没问题。
5.3 现象:两个不同函数里的同名局部变量,第二个报「重复定义」
原因:符号表只有一张全局表,进块或进函数时没有push(),或者pop()写在了错误的return路径上,导致作用域栈没有正确收放。
解决:在parseBlock、函数体、for循环体的入口统一st.push(),出口统一st.pop(),用 RAII 包一层ScopeGuard最稳:
struct ScopeGuard { SymbolTable& st; explicit ScopeGuard(SymbolTable& s) : st(s) { st.push(); } ~ScopeGuard() { st.pop(); } }; // 使用:进入块时 ScopeGuard g(st); 无论从哪个分支 return,析构都会 pop5.4 现象:if/while生成的四元式里有一条jf的目标是空的,运行到那里就乱跳
原因:条件表达式的假出口列表没回填,或者backpatch用了错误的四元式下标。典型是emit返回值被丢弃,回填时凭记忆写了quads_.size() - 1,在中间插入了 label 之后就错位。
解决:emit的返回值必须接住,作为回填句柄存进列表。写完之后加一条自检:遍历所有j/jf四元式,如果result为空或指向不存在的标签,直接打日志报出来。这条自检在调试阶段能救命。
5.5 现象:报错的行号永远比真实位置大 1 或大好几行
原因:advance()里处理'\n'时只加行号没归零列号,或者 Windows 换行\r\n里的\r被当成普通字符,导致列号偏移;还有一种情况是注释跨多行,跳过注释的函数没有累计行号。
解决:advance()里if (c == '\n') { ++line_; col_ = 1; } else { ++col_; },同时在skipWhitespaceAndComments里也用同一个advance()前进,别自己手写pos_++。测试用例里放一个多行注释加 CRLF 换行的样本,比对着看行号对不对。
6. 二次开发:给它加一个for循环,顺便验证整条流水线
读到这里,最后一步是动手改。最合适的第一个改动不是加复杂特性,而是加一个for循环——它需要动到文法、AST、解析、语义、中间代码生成五个位置,改完相当于把整条流水线又走了一遍,验证效果最好。
改动点按依赖顺序排列:
| 文件 | 改什么 | 关键点 |
|---|---|---|
docs/grammar.md | 增加ForStmt -> 'for' '(' init ';' cond ';' step ')' Stmt | 先把文法写对,再写代码,顺序别反 |
include/ast.h | 新增ForStmt节点,含init/cond/step/body四个字段 | 继承Node,构造时带上行号 |
src/parser.cpp | parseStmt里加if (kw == "for") return parseFor();,并实现parseFor | 三个分号必须用expect严格匹配 |
src/sema.cpp | checkStmt里加ForStmt分支,为 init 里声明的变量开一个新作用域 | 用 5.3 的ScopeGuard |
src/tac.cpp | 按「init → label → cond → jf exit → body → step → j end → label exit」生成 | 顺序错了循环语义就错 |
中间代码生成这一段最容易写反,给出正确骨架:
// src/tac.cpp(节选) void TacGen::genFor(ForStmt* s) { genStmt(s->init); // 1. 初始化,只执行一次 std::string labelCond = newLabel(); // 2. 条件判断入口 emit("label", "", "", labelCond); auto [trueList, falseList] = genCond(s->cond); // 3. 生成条件四元式 backpatch(trueList, ""); // 真出口先占位 genStmt(s->body); // 4. 循环体 genStmt(s->step); // 5. 步进,必须在回跳之前 emit("j", "", "", labelCond); // 6. 跳回条件 std::string labelExit = newLabel(); emit("label", "", "", labelExit); backpatch(falseList, labelExit); // 7. 条件为假,跳出口 }第 5 步和第 6 步的顺序是这类改动的经典翻车点。如果先j再genStmt(step),循环体会变成死循环,因为跳回去的时候计数器从没变过。写完拿for (i = 0; i < 3; i = i + 1) x = x + i;跑一遍,看四元式里是不是i的自增出现在回跳之前。
验证方法我一般用「三遍对照」:第一遍跑tests/tac/原有全部用例,diff 必须全绿,确认改动没有破坏原有语义;第二遍写三个新用例覆盖for的零次循环、单次循环、嵌套for,期望输出手算一遍再落盘;第三遍故意在每个用例里塞一个语法错误,确认报错行号准确、没有崩。
这套流程走下来,我对这份源码的信任度会从「跑通就行」变成「知道它哪块能改、改了影响哪里」。做编译原理实验最怕的就是改一处崩一片,而一份带分阶段回归测试的代码,恰好把这个风险压到了最低。从那以后我每次动解析器之前,都强制先跑一遍make test-parse拿到绿色基线,再开始改——没有基线的情况下改文法,等于闭着眼睛调参。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取