简介:这份面向计算机专业学生的编译原理课程设计资料包,来自南京航空航天大学课程实践,围绕词法分析、语法分析、符号表管理与Token处理等核心环节提供一套可运行的课设实现,适合正在做课程设计或需要对照理解编译前端流程的学习者参考。压缩包共32个文件、约961KB,包含txt源码与说明、c/cpp/h实现文件、exe可执行程序、doc课设报告及ppt答辩材料,另有工程配置与调试文件,目录按词法、语法等模块组织,便于按环节查阅。已有751人学习下载,说明其在同类课设资料中具备一定参考价值。读者可从中获得完整的模块划分思路、可运行程序与报告撰写样例,结合属性字、符号表、扫描器等内容理解编译程序的组织方式,也可用于调试排错和答辩准备。
1. 打开骨架代码那一刻:这份编译原理课程设计到底让你造什么
第一次翻开这份课程设计的源码,很多人会愣一下:main.cpp只有二十来行,真正执行的是按阶段串联起来的几个Stage对象,输入一个.c源文件路径,终端输出一列四元式,没有界面,也没有花哨日志。它做的事很朴素——把一门 C 语言子集翻译成能直接解释执行的四元式序列,覆盖词法分析、语法分析、语义检查、中间代码生成四个阶段,外加多层符号表与行列号级错误定位。适合两类人:正被编译原理课程设计卡住、需要一份能跑通的参照实现的同学;以及想把编译器前端拆开融进自己工具链的开发者,比如做 DSL、做配置语言解析、做表达式引擎的那批人。它不追求工业级优化,但阶段边界划得干净,改一处不会牵动全局。
2. 项目骨架与技术选型:五个阶段如何落进目录
课程设计最容易卡住的地方,是“知道要分词、要建树、要生成四元式,但不知道这些代码该放在哪个文件里”。这份源码的目录划分几乎是照着编译流程流水线切的,每个阶段一个目录,阶段之间只通过数据结构通信,不互相#include对方的内部实现。这种分层方式看着朴素,却是后面能改得动的前提。
2.1 目录结构与各文件职责
整体目录大概长这样,不同版本可能多一两个辅助文件,主干不会变:
| 路径 | 职责 | 对外暴露的核心类型 |
|---|---|---|
src/lexer/ | 字符流切成 Token,跳过空白与注释 | Lexer::next()→Token |
src/parser/ | 递归下降建 AST,处理语法错误恢复 | Parser::parseProgram()→Program* |
src/ast/ | AST 节点定义与访问者接口 | Expr/Stmt/Decl基类 |
src/sema/ | 符号表、类型检查、作用域管理 | SemanticAnalyzer::check() |
src/ir/ | 四元式结构、临时变量分配、标号回填 | Quad、IRBuilder |
src/runtime/ | 四元式解释执行器(部分版本提供) | Vm::run(quads) |
tests/ | 按阶段划分的输入输出用例 | .c输入 +.expected期望输出 |
Makefile | 一键构建与make test | — |
关键在于ast/是唯一的公共依赖:lexer 只产出 Token,parser 消费 Token 产出 AST,sema 和 ir 都只读 AST。这意味着你换掉词法分析器,后面的代码一行不用动;想加一个for循环,只需要在 parser 里加一条产生式、在 sema 里加一个visit、在 ir 里加一段生成逻辑,三处独立改动。
2.2 技术选型:手写递归下降还是 Flex/Bison
课程设计在这个点上有两条常见路线,这份项目选的是纯手写,不依赖任何第三方生成器:
| 维度 | 手写递归下降(本项目) | Flex/Bison 生成 |
|---|---|---|
| 依赖 | 只需 C++ 标准库 | 需要额外安装工具链 |
| 调试体验 | 断点能直接打在parseExpr里 | 生成代码难读,报错定位抽象 |
| 错误恢复 | 自己写同步集,可控 | 需要理解error产生式 |
| 改语法成本 | 加一个函数 | 改.y文件再重新生成 |
| 左递归 | 必须改写成循环或 EBNF | 天然支持 |
大部分课程设计报告里要求“说明所选文法”,LL(1) 或递归下降是最省事的选择:文法写成 EBNF,每个非终结符对应一个函数,函数返回Expr*或Stmt*。代价是左递归必须手工消除,E -> E + T要改写成E -> T {+ T}的循环形式——这也是后面第 5 章要说的一个高频翻车点。
2.3 一次编译的数据流:从源码字符串到四元式序列
把阶段串起来的那段代码,是理解整个项目最快的入口。真实实现里通常是这样的:
// main.cpp —— 编译器驱动,四个阶段顺序执行,任一阶段失败立即中止 int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: cc <source.c>\n"; return 1; } std::ifstream in(argv[1]); std::string src((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); // 阶段 1:词法。Lexer 内部持有源码和游标,next() 每次吐一个 Token Lexer lexer(src); // 阶段 2:语法。Parser 持有 Lexer,按需拉取 Token,边读边建树 Parser parser(lexer); Program* prog = parser.parseProgram(); if (parser.hasError()) { // 错误已带行列号打印 std::cerr << "parse failed with " << parser.errorCount() << " error(s)\n"; return 2; } // 阶段 3:语义。遍历 AST,建符号表,标出类型错误 SemanticAnalyzer sema; if (!sema.check(prog)) { std::cerr << "semantic error\n"; return 3; } // 阶段 4:中间代码。遍历 AST 生成四元式,临时变量和标号在此分配 IRBuilder builder; std::vector<Quad> quads = builder.build(prog); for (size_t i = 0; i < quads.size(); ++i) std::cout << i << ": " << quads[i].str() << "\n"; // 可选阶段 5:解释执行四元式,直接看到程序输出 if (argc > 2 && std::string(argv[2]) == "--run") { Vm vm(quads); vm.run(); } return 0; }这段驱动的价值在于它规定了阶段的失败语义:词法错误和语法错误在 parser 内累积、一次性打印,语义错误在 sema 阶段统一报告,只有前三个阶段都通过,才进入生成。很多同学的实现是在 parser 里遇到错误直接exit(1),结果一个文件里十个错只报一个,改完一个再编译一次又是下一个,效率极低。
参数上需要留意--run这个可选开关:不加时只打印四元式,方便和tests/*.expected做文本比对;加上时走栈式虚拟机执行,用来看程序的实际输出对不对。两套验证手段互补——四元式比对能发现“结果对但过程错”的问题,比如你应该先算乘法却先算了加法。
3. 核心模块拆解:词法分析器与递归下降语法分析
前端两个阶段是课程设计的评分重点,也是改起来最频繁的地方。这里把 Token 设计、状态机扫描、优先级链解析和错误恢复摊开讲,代码都是可以直接替换进骨架的类型。
3.1 词法分析:状态机、Token 结构与最长匹配
Token 的设计决定了后面所有阶段的顺手程度。核心原则只有一条:报错需要的信息,全部塞进 Token。行列号不给出来,parser 报错就只能说“语法错误”,写报告时没法交代。
// lexer.h —— Token 结构与词法单元类型 enum class Tag { ID, NUM, REAL, STR, IF, ELSE, WHILE, RETURN, INT, FLOAT, VOID, PLUS, MINUS, STAR, SLASH, ASSIGN, EQ, NE, LT, LE, GT, GE, LPAREN, RPAREN, LBRACE, RBRACE, SEMI, COMMA, END, ERR }; struct Token { Tag tag; std::string lexeme; // 原始文本,报错时原样打印给用户看 int line = 1, col = 1;// 起始行列,parser 靠它定位 long ival = 0; // NUM 的字面值 double rval = 0.0; // REAL 的字面值 bool is(Tag t) const { return tag == t; } };扫描主体是一个典型的状态机,核心难点是最长匹配:读到>不能立刻返回GT,要再看一个字符,是=就返回GE。同理=和==、!和!=。这里用“前瞻一个字符”的写法最省事:
// lexer.cpp —— 核心扫描循环,处理最长匹配与注释跳过 Token Lexer::next() { skipSpaceAndComment(); // 吃掉空白、// 行注释、/* */ 块注释 int line = curLine_, col = curCol_; if (eof()) return Token{Tag::END, "", line, col}; char c = advance(); if (std::isalpha(c) || c == '_') { // 标识符或关键字 std::string s(1, c); while (std::isalnum(peek()) || peek() == '_') s += advance(); static const std::unordered_map<std::string, Tag> kw = { {"if", Tag::IF}, {"else", Tag::ELSE}, {"while", Tag::WHILE}, {"return", Tag::RETURN}, {"int", Tag::INT}, {"float", Tag::FLOAT}, {"void", Tag::VOID} }; auto it = kw.find(s); return Token{it != kw.end() ? it->second : Tag::ID, s, line, col}; } if (std::isdigit(c)) { // 数字,区分整数和浮点 std::string s(1, c); bool isReal = false; while (std::isdigit(peek())) s += advance(); if (peek() == '.' && std::isdigit(peekAt(1))) { isReal = true; s += advance(); while (std::isdigit(peek())) s += advance(); } Token t{isReal ? Tag::REAL : Tag::NUM, s, line, col}; isReal ? t.rval = std::stod(s) : t.ival = std::stol(s); return t; } switch (c) { // 运算符:先看两字符,再看单字符 case '>': if (peek() == '=') { advance(); return mk(Tag::GE, ">=", line, col); } return mk(Tag::GT, ">", line, col); case '<': /* 同构,处理 <= 和 < */ break; case '=': if (peek() == '=') { advance(); return mk(Tag::EQ, "==", line, col); } return mk(Tag::ASSIGN, "=", line, col); case '/': /* 注释在上一步已处理,这里只剩除号 */ break; default: break; } return Token{Tag::ERR, std::string(1, c), line, col}; }lexeme字段看起来冗余,实际是排错时的救命稻草:一旦 parser 报“期望),实际得到Tag::ID”,你可以直接把lexeme打出来,一眼看出是哪个标识符。我见过有人为了省内存只存 tag,结果调试时全靠猜。
注释跳过要单独说明://一直吃到\n,/* */要跨行并且维护行号计数。块注释未闭合是必须报错的——这里的做法是返回Tag::ERR并带上起始行号,而不是静默吞到文件末尾,否则用户会看到一个莫名其妙的“文件意外结束”。
3.2 语法分析:递归下降的表达式优先级链
表达式是语法分析里最容易写乱的部分。手写递归下降的经典做法是一个优先级一层函数,从低到高排成一条链:
// parser.cpp —— 表达式优先级链,从低到高,每层一个函数 Expr* Parser::parseExpr() { return parseAssign(); } // 赋值右结合:a = b = c 解析成 a = (b = c) Expr* Parser::parseAssign() { if (check(Tag::ID) && checkNext(Tag::ASSIGN)) { Token name = advance(); advance(); // 吃掉 '=' Expr* rhs = parseAssign(); // 递归自己,右结合 return new AssignExpr(name, rhs); } return parseOr(); } Expr* Parser::parseOr() { // || 左结合,写成循环 Expr* left = parseAnd(); while (match(Tag::OR)) { Expr* right = parseAnd(); left = new BinaryExpr(Tag::OR, left, right); } return left; } Expr* Parser::parseAdd() { // 加减同优先级,一个循环搞定 Expr* left = parseMul(); while (check(Tag::PLUS) || check(Tag::MINUS)) { Token op = advance(); Expr* right = parseMul(); left = new BinaryExpr(op.tag, left, right); } return left; }这套写法有三条硬性规则,违反任何一条都会导致行为错误:
第一,每层只处理自己那一级优先级,向下调用更高优先级。parseAdd里调的是parseMul,不是parseAdd,否则1 + 2 * 3会被算成(1+2)*3。
第二,左结合用while循环,右结合用递归。赋值是右结合所以parseAssign递归调用自己,加减是左结合所以用while。把加减写成递归也对,但遇到a+b+c+d+...长表达式会栈溢出,课程设计的测试用例里往往就有上百项的连加。
第三,每层函数入口不加“跳过空白”的逻辑,空白统一由 Lexer 处理。曾经见过 parser 里也写了一份skipWhitespace,结果两套逻辑不同步,只在一个特定输入上翻车,查了大半天。
3.3 语法错误恢复:同步集与 panic mode
课程设计报告里“错误处理”是必写章节,但很多实现只做了“报错退出”。可用的错误恢复至少要做到一条语句出错不影响下一条,否则测试用例里故意埋的五个错误只能一个个改。
基本做法是 panic mode + 同步集:在语句级别捕获异常,跳到下一个分号或右花括号再继续。
// parser.cpp —— 语句序列的错误恢复 void Parser::parseBlock() { expect(Tag::LBRACE); while (!check(Tag::RBRACE) && !check(Tag::END)) { size_t before = pos_; // 记录位置,用于判断是否卡死 try { stmts_.push_back(parseStmt()); } catch (const ParseError& e) { report(e); // 带行列号记录,不中断 synchronize(); // 跳到同步集 } if (pos_ == before) { // 一拍没前进,强制推进避免死循环 advance(); } } expect(Tag::RBRACE); } void Parser::synchronize() { // 同步集:分号、右花括号、以及语句起始关键字 while (!check(Tag::END)) { if (check(Tag::SEMI)) { advance(); return; } if (check(Tag::RBRACE)) return; if (check(Tag::IF) || check(Tag::WHILE) || check(Tag::RETURN) || check(Tag::INT)) return; advance(); } }pos_ == before这个判断是血泪经验:如果synchronize()因为当前 Token 恰好在同步集里而立刻返回,而parseStmt()又没能消费任何 Token,外层while就变成死循环,程序挂住不动,日志一片空白——这种黑匣子状态最难查。加一行强制推进,代价是多报一条错,但至少能跑完。
同步集的选择也有讲究:}一定要在里面,因为它标志着块结束;语句起始关键字要在里面,因为它们大概率是下一条语句的开头;逗号和右括号不放,否则表达式内部的错误恢复会把一个函数调用拆得七零八落。
4. 语义分析与中间代码生成:符号表、类型检查与四元式
语法树建起来只是字符串结构的重组,真正的“编译”从这一章开始:符号表决定名字能不能找到,类型检查决定运算合不合法,四元式决定最终执行什么。
4.1 符号表:作用域链与哈希桶
课程设计里的符号表有两种写法:全局一张大表加scopeLevel字段,或者一层作用域一个Scope对象串成链。后者在退出作用域时直接丢弃整个Scope,不用担心清理,更不容易出错。
// sema/symbol.h —— 作用域链式符号表 struct Symbol { std::string name; Type type; // INT / FLOAT / VOID / FUNC bool isFunc = false; std::vector<Type> paramTypes; // 函数签名,调用时逐个比对 int definedLine = 0; }; class Scope { public: explicit Scope(Scope* parent = nullptr) : parent_(parent) {} bool declare(const Symbol& s) { // 同层重定义要报错 if (table_.count(s.name)) return false; table_[s.name] = s; return true; } Symbol* lookup(const std::string& n) { // 沿链向上找 for (Scope* s = this; s; s = s->parent_) { auto it = s->table_.find(n); if (it != s->table_.end()) return &it->second; } return nullptr; } private: std::unordered_map<std::string, Symbol> table_; Scope* parent_; };declare和lookup的语义必须严格分开:declare只查当前层,用来判断“同一作用域内重复定义”;lookup查整条链,用来解析引用。把两者混成一个函数是高频错误——那样内层定义同名变量会被误判成重复定义,而 C 语言子集里{ int x; { int x; } }是完全合法的。
进入块时Scope* child = new Scope(current),退出时current = current->parent_,一棵块的子树对应一条作用域链。这个结构顺便解决了“变量在声明前使用”的检查:lookup找不到就是未声明,不需要额外的两遍扫描。
4.2 类型检查:赋值兼容与实参匹配
类型检查不需要做得像真实编译器那样精细,课程设计抓到四个点就能让报告有内容:赋值两侧类型、算术运算两侧类型、条件表达式必须是标量、函数调用的实参个数与类型。
// sema/check.cpp —— 二元运算的类型推导 Type SemanticAnalyzer::binaryType(Tag op, Type lhs, Type rhs, int line) { bool arith = (op == Tag::PLUS || op == Tag::MINUS || op == Tag::STAR || op == Tag::SLASH); if (arith) { if (lhs == Type::VOID || rhs == Type::VOID) { error(line, "void value in arithmetic expression"); return Type::VOID; } // 隐式提升:int 与 float 混合运算结果为 float return (lhs == Type::FLOAT || rhs == Type::FLOAT) ? Type::FLOAT : Type::INT; } if (op == Tag::LT || op == Tag::LE || op == Tag::GT || op == Tag::GE || op == Tag::EQ || op == Tag::NE) { if (lhs == Type::VOID || rhs == Type::VOID) { error(line, "void value in comparison"); } return Type::INT; // 比较结果当 0/1 用,简化 IR } return Type::INT; } // 赋值检查:允许 int -> float 的提升,反过来报精度损失 bool SemanticAnalyzer::assignable(Type dst, Type src, int line) { if (dst == src) return true; if (dst == Type::FLOAT && src == Type::INT) return true; error(line, "cannot assign " + typeName(src) + " to " + typeName(dst)); return false; }有一个坑要提前说:error函数只记录不抛出,让语义分析把所有错误攒完一起报。但类型不匹配之后继续推导会产生连锁错误,比如void参与运算后,外层又会报一次“赋值类型错误”,一次输入刷出五条错,用户反而看不清根因。这里的处理是把错误的表达式结果类型标成Type::VOID,并在后续检查里对VOID静默跳过——报一次根因,不报连带。
4.3 四元式生成:表达式、控制流与回填
四元式的结构简单到二十行代码就能定义清楚,真正的难点在控制流的标号回填:生成条件跳转时,目标地址还不知道,得先把位置记下来,等结构结束了再填回去。
// ir/quad.h —— 四元式结构与构造辅助 struct Quad { std::string op; // "+","=","j","jf","call","ret","param" std::string arg1, arg2, result; std::string str() const { return op + " " + arg1 + " " + arg2 + " " + result; } }; class IRBuilder { public: std::string newTemp() { return "t" + std::to_string(++tempCnt_); } std::string newLabel() { return "L" + std::to_string(++labelCnt_); } 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 quads_.size() - 1; // 返回下标,供回填使用 } void patch(int idx, const std::string& target) { quads_[idx].result = target; // 把占位符换成真实标号 } private: std::vector<Quad> quads_; int tempCnt_ = 0, labelCnt_ = 0; };有了emit返回下标和patch,while的生成就变成三步走:
// ir/gen_stmt.cpp —— while 语句的四元式生成 void IRBuilder::genWhile(WhileStmt* s) { std::string start = newLabel(); std::string end = newLabel(); emit("label", start, "_", "_"); // 循环条件入口 std::string cond = genExpr(s->cond); // 条件表达式的值 int jfIdx = emit("jf", cond, "_", "?"); // 条件假则跳出,目标待填 genStmt(s->body); emit("j", start, "_", "_"); // 回到条件判断 emit("label", end, "_", "_"); patch(jfIdx, end); // 出口标号此时才确定 }jf那条指令在生成时出口还不知道,先用"?"占位并记下下标,循环体生成完之后把end补进去。临时变量用t1, t2...递增命名,标号用L1, L2...递增命名,两套计数器分开——混用一个计数器会导致标号和临时变量撞名,虚拟机执行时跳到一条算术指令上,程序行为彻底不可预测。
表达式生成递归很简单,重点是每个子表达式都产出一个结果名(临时变量或变量名),父节点只负责把两个结果名拼成一条指令:
// ir/gen_expr.cpp —— 二元表达式,先算左右再出指令 std::string IRBuilder::genExpr(Expr* e) { if (auto v = dynamic_cast<VarExpr*>(e)) return v->name; if (auto n = dynamic_cast<NumExpr*>(e)) return std::to_string(n->value); if (auto b = dynamic_cast<BinaryExpr*>(e)) { std::string l = genExpr(b->lhs); // 先左 std::string r = genExpr(b->rhs); // 后右,保证求值顺序 std::string t = newTemp(); emit(opName(b->op), l, r, t); return t; } // ... 赋值、调用、数组下标等同理 }求值顺序在这里是显式保证的:先递归左子树,再递归右子树,因此f() + g()一定先调用f。如果为了“优化”把两侧放进同一个表达式里交给 C++ 求值,顺序就不确定了——这在课程设计里不算错,但在报告里被问“你的求值顺序是什么”时答不上来。
5. 跑起来与避坑排查:构建、用例与五条翻车记录
代码能编译不等于能跑通,能跑通不等于结果对。这一章按“先跑起来、再验证、最后排错”的顺序走。
5.1 构建与运行
项目用 Makefile 组织,没有外部依赖,理论上一条命令就能出二进制:
# Makefile —— 零依赖构建,C++17 CXX := g++ CXXFLAGS := -std=c++17 -Wall -Wextra -g -O0 SRCS := $(wildcard src/*/*.cpp) src/main.cpp OBJS := $(SRCS:.cpp=.o) TARGET := build/cc $(TARGET): $(OBJS) @mkdir -p build $(CXX) $(OBJS) -o $@ %.o: %.cpp $(CXX) $(CXXFLAGS) -c $< -o $@ test: $(TARGET) @for f in tests/*.c; do \ exp=$${f%.c}.expected; \ $(TARGET) $$f > /tmp/out.txt 2>&1; \ diff -q /tmp/out.txt $$exp > /dev/null \ && echo "PASS $$f" || echo "FAIL $$f"; \ done clean: rm -rf build $(OBJS) .PHONY: test clean-O0和-g是刻意留的:课程设计阶段调试比性能重要,优化一开,断点跳行、变量被优化没了的情况很常见。-Wall -Wextra一定要开,未初始化变量、有符号无符号比较这类警告,在递归下降和符号表代码里出现的频率极高,很多“玄学”结果就是未初始化内存导致的。
跑单个文件直接./build/cc tests/arith.c,加--run看执行输出。跑全量用make test,逐条diff出 PASS/FAIL。
5.2 测试用例怎么组织
用例按阶段分目录,每个用例是“一个.c输入 + 一个.expected文本输出”,输出就是四元式打印结果。这种做法的好处是回归成本极低:改完 parser 跑一遍make test,哪些用例崩了一眼看到。
用例的覆盖面建议按这个顺序补:单条赋值 → 算术优先级 → 括号 → 关系运算 →if/else嵌套 →while循环 → 函数定义与调用 → 作用域遮蔽 → 各类错误输入。错误输入也要有用例,期望输出是错误信息的行号和条数,这样才能验证错误恢复真的没死循环。
提示:用例文件名带序号前缀(
01_assign.c、02_precedence.c),make test的输出顺序稳定,比对失败时能快速定位到是哪一类退化。
5.3 五条翻车记录
下面这几条都是实际调试时撞出来的,按“现象 → 原因 → 解决”记,遇到了能省几个小时。
记录一:长表达式直接段错误
现象:十几个数连加,程序在parseAdd里崩掉,短表达式完全正常。
原因:把左结合的加法写成了递归调用parseAdd(),每读一个+就压一层栈,深链表达式把栈撑爆。
解决:左结合一律改写成while循环,只在右结合(赋值、三目)处用递归。改完顺手在测试里加一条 200 项连加的用例防回归。
记录二:四元式看着对,执行结果总是差一
现象:while循环跑出来少执行一轮,或者多执行一轮,打印的四元式肉眼看着没问题。
原因:jf的回填下标记错了,patch填到了label那条指令上,导致条件判断跳到循环体内部。
解决:emit返回下标后立刻存进局部变量并加注释标明“这里是 jf 的位置”,patch只接受这个下标。更稳妥的做法是让patch校验quads_[idx].op == "jf" || "j",不匹配就断言失败,早崩早发现。
记录三:内层变量把外层变量“吃掉”了
现象:块内声明同名变量后,退出块再引用外层变量,报“未声明”。
原因:退出作用域时忘了把current指针移回父作用域,或者用了全局一张表、退出时把整层符号全删了,连外层一起删。
解决:作用域链式实现,块结束只把current = current->parent_,不碰任何delete;全局大表方案则要在每个符号上记scopeLevel,退出时只删等于当前层级的项。
记录四:注释里的中文让行号全错
现象:报错行号整体偏了几行,且只在含中文注释的文件上出现。
原因:按字节遍历std::string,一个 UTF-8 汉字占三字节,每个字节都被当成字符计入列号,行号统计在跨行注释里也错位。
解决:列号只对 ASCII 有意义,统计时把0x80以上的字节跳过不计入列;行号只认\n。更省事的做法是报错只报行号不报列号,课程设计够用。
记录五:make test全绿,换个环境全红
现象:本机全 PASS,拷到另一台机器上大批 FAIL,四元式文本对不上。
原因:临时变量名依赖std::unordered_map的遍历顺序,或者标号从随机初值开始;另一种情况是double打印精度不一致,1.0在一台机器上打成1,另一台打成1.000000。
解决:临时变量和标号严格用递增计数器,命名只由生成顺序决定;浮点打印统一printf("%.6f")或固定std::setprecision(6)。凡是进.expected的文本,都不允许依赖容器遍历顺序。
6. 二次开发:把课设改成能跑真实语法的编译器
这份骨架最大的价值不是“能过课设”,而是它的阶段边界足够干净,可以按需往上加东西。想把它从玩具推到能用的程度,我建议按这个顺序改,每步都能独立验证,不会一次改崩。
第一步是扩语法。加for循环最省事,先在 parser 里把它降级成while的 AST 结构而不是新增 IR 逻辑——for (init; cond; step) body等价于init; while (cond) { body; step; },解析完直接复用genWhile,IR 层一行不用改。同理+=、-=这类复合赋值也可以在 parser 里降级成a = a + b,代价是左值被求值两次,对课程设计场景完全可接受。
| 想加的特性 | 改哪一层 | 是否要动 IR |
|---|---|---|
for循环 | parser 降级为 while | 否 |
复合赋值+= | parser 降级为二元表达式 | 否 |
break/continue | parser + IR(维护循环标号栈) | 是 |
| 数组 | lexer[]+ parser + sema + IR(load/store) | 是 |
| 函数返回值检查 | 仅 sema | 否 |
第二步是把错误信息做成结构化输出。现在错误是直接cerr打印,改造成struct Diagnostic { int line, col; std::string msg; Severity level; }收集进std::vector,最后统一渲染。这不只是好看——有了结构化诊断,你才能在make test里断言“这个输入应该产生 3 条错误,且第一条在第 7 行”,测试强度立刻上一个台阶。
第三步,如果你想验证自己真的理解了中间代码,加一个最简单的常量折叠:在IRBuilder::genExpr的BinaryExpr分支里判断左右是否都是NumExpr,是就直接算出来返回常量,不再emit指令。这一步只需要十行代码,但能让你清楚看到“优化”发生在 AST 到 IR 的边界上,而不是在 parser 里。
// 常量折叠:只在 IR 生成阶段做,parser 保持纯粹 if (auto b = dynamic_cast<BinaryExpr*>(e)) { auto ln = dynamic_cast<NumExpr*>(b->lhs); auto rn = dynamic_cast<NumExpr*>(b->rhs); if (ln && rn && isArith(b->op)) { // 两侧都是字面量 long v = evalConst(b->op, ln->value, rn->value); return std::to_string(v); // 直接返回常量名,不生成指令 } std::string l = genExpr(b->lhs), r = genExpr(b->rhs); std::string t = newTemp(); emit(opName(b->op), l, r, t); return t; }改完之后一定要跑全量回归。我吃过一次亏:加了常量折叠,结果while (i < 10)里的10被折叠成常量名返回,而jf判断的逻辑只认临时变量和变量名,遇到纯数字直接当成“恒真”处理,循环变成死循环。从那以后我每次动 IR 层的任何一行,都强制先跑一遍make test再单步看四元式,绝不凭“改动很小”就跳过。这份骨架值得花时间从第 2 章的驱动代码往下读一遍,跑通、改一处、再跑一遍,编译原理里那些抽象概念才会真正落到你能改的代码上。希望帮到你。
本文还有配套的精品资源,点击获取