简介:围绕一个简单文法编译器前端展开的课程设计报告,面向计算机专业编译原理课程设计与初学者,聚焦词法分析、语法语义分析及中间代码生成的完整实现路径。内容从扫描器读取字符并生成Token序列讲起,状态转换关系、关键字和界符以文件形式保存,便于扩展;语法部分采用递归下降子程序法,语法与语义分析同步进行,并生成四元式。报告还覆盖常量、数组、if-else、while等文法扩展,以及递归子程序栈的调用观察和后端目标代码生成,配有任务要求、算法与数据结构、程序设计与实现、实验结果等章节。压缩包内仅1个docx文档,约381KB,结构完整,适合对照课程设计流程、梳理前端模块划分与四元式生成思路。已有694人学习,可作为编译原理课程实践与报告撰写的参考材料。
1. 从一个能跑通的"迷你编译器"说起:前端三个模块加后端一条链路
考试里能把 NFA 转 DFA 画得满满一页,真给一段var a: integer; begin a := b + 1 * 2 end.的源码,从字符串一路走到能执行的指令,中间那段链路还是空的。这份课设补的正是这一段:一个简单文法编译器前端,词法分析、语法语义分析、四元式中间代码生成三块齐全,语法分析和语义分析同一遍走完,末尾还接了一个目标代码生成的后端,把四元式翻译成汇编风格的目标代码。它适合两类人:正在做编译原理课程设计、需要一个能跑、能改、结构清楚的原型的同学;以及想给自研 DSL 配一个前端解析器、但不想一上来就上 LLVM 那套重装备的开发者。整份实现不追求工业级健壮性,追求的是每个模块边界清楚、换文法只改一两个文件。
2. 整体架构与目录结构:把词法、语法语义、四元式、目标代码拆成四个可替换的盒子
2.1 模块划分与数据流:源程序到目标代码的五步走
整条链路是五步:读源文件 → 词法分析得到 Token 序列 → 递归下降解析,边解析边做语义动作 → 四元式表落盘 → 后端按四元式逐条选指令。中间没有独立的语法树对象,这是这份实现最值得先讲清楚的一个设计选择。
很多人默认编译器前端必须有 AST,写完 parse 再写一遍 visit 做语义。这里是语法制导翻译的路子:递归下降的每个子程序既负责"认文法",也顺手把该做的语义动作做掉,比如parseExpression()在吃掉一个+之后立刻就emit一条加法四元式。好处是内存省、代码量小、课设规模一周能写完;代价是拿不到全局结构,做常量折叠以外的优化基本没戏,错误恢复也只能靠"跳过到分号"这种土办法。常见做法是:课设、脚本解释器、配置语言解析用这条路足够;要接优化器,老老实实建 AST。
数据流上有一个细节容易被忽略:Token 序列是一次性全扫完再交给语法分析的,不是边扫边取。这么做的好处是词法阶段可以把所有非法字符、未闭合字符串的错一次性报全,而不是解析到一半才发现第 3 行有个怪字符——这个体验差别在调试期特别明显。
四元式表和符号表都是全局单例式的容器,由 Parser 持有,后端只读。这条"前端写、后端读、谁也不改谁"的契约,是后面讲二次开发时能不能少动代码的关键。
2.2 目录结构与文件职责
compiler/ ├── src/ │ ├── main.cpp # 串流程:扫描 → 解析 → 落四元式 → 生成目标代码 │ ├── lexer/ │ │ ├── Lexer.h / Lexer.cpp # 扫描器主循环、最长匹配、Token 输出 │ │ └── StateMachine.h # 状态转换表加载与查询 │ ├── parser/ │ │ ├── Parser.h / Parser.cpp # 递归下降子程序 + 语义动作 │ │ ├── SymbolTable.h/.cpp # 符号表:声明、查找、作用域 │ │ └── CallStack.h/.cpp # 递归子程序调用栈,供界面单步展示 │ ├── ir/ │ │ ├── Quad.h # 四元式结构与回填接口 │ │ └── Emitter.cpp # emit / newTemp / backpatch │ ├── backend/ │ │ └── CodeGen.cpp # 四元式 → 目标指令 │ └── ui/ │ └── MainWindow.cpp # 桌面窗口:打开文件、单步、查看表格 ├── data/ │ ├── keywords.txt # 关键字表,一行一个:program 0 │ ├── delimiters.txt # 界符表,一行一个::= 4 │ └── dfa.txt # 状态转换表 ├── tests/ │ ├── lex_ok.src │ ├── expr.src │ └── nested_if.src └── README.md核心逻辑全部与界面解耦:main.cpp里用命令行跑通整条链路,界面层只是多提供一个"单步执行"按钮和几个表格视图。这个拆分方式对课设答辩很友好——万一界面库环境配不起来,命令行版本照样能演示。
2.3 关键数据结构:Token、符号表、四元式
三个结构体是整个项目的骨架,先把它们定死,后面所有模块都围着转。
// Token:词法分析的最小输出单位 struct Token { int type; // 单词类别:0~13 关键字,100 标识符,200 常量,300 界符 std::string lexeme; // 原始词素,报错和调试时要打印出来 int line; // 行号,错误定位靠它 int col; // 列号 double numVal; // 常量值,只有 type==200 时有效 }; // Symbol:符号表条目 struct Symbol { std::string name; std::string type; // integer / real / char / bool int kind; // 0 变量 1 常量 2 数组 int elemSize; // 数组元素宽度,标量填 0 int low, high;// 数组下标下界和上界,标量填 0 std::string baseLabel;// 数组基址标号,目标代码里当符号地址用 std::string initVal; // 常量初值 }; // Quad:四元式 struct Quad { std::string op; // 运算符:+ - * / := jmp jf > < = 等 std::string arg1; std::string arg2; std::string result; int next; // 跳转类四元式的目标下标,-1 表示待回填 };Token 的 type 用整数区间分段而不是枚举散值,好处是判断"这是不是常量"可以直接type >= 200 && type < 300,不用一堆||。符号表里特意保留了low/high和baseLabel,数组扩展能不能做干净,全看这两个字段在词法之后有没有一路传下来。
把这些结构串起来的主流程很短:
int main(int argc, char** argv) { Lexer lexer("data/keywords.txt", "data/delimiters.txt", "data/dfa.txt"); std::vector<Token> tokens = lexer.scanFile(argv[1]); // 一次扫完 if (lexer.hasError()) { lexer.dumpErrors(std::cerr); return 1; } Parser parser(tokens); parser.parseProgram(); // 边解析边 emit if (parser.hasError()) { parser.dumpErrors(std::cerr); return 2; } parser.dumpQuads("out/quads.txt"); // 四元式落盘,方便肉眼核对 CodeGen gen(parser.quads(), parser.symbols()); gen.emitTo("out/target.asm"); return 0; }参数说明:scanFile返回完整 Token 序列,末尾补一个 EOF Token 让解析器不用到处判pos < size;parseProgram无返回值,错误全部记在 Parser 内部;CodeGen构造时传的是const引用,保证后端不会偷偷改前端的数据。返回值约定 1 是词法错、2 是语法语义错,脚本里做批量测试时靠它区分阶段。
3. 词法分析模块拆解:状态转换表放文件里,关键字和界符靠读表生成
3.1 自动机与状态转换文件的设计
扫描器的骨架是一张表驱动的有限自动机。表不是硬编码在 switch 里,而是放在data/dfa.txt中,每行五个字段:当前状态、输入字符类别、目标状态、是否接受态、单词种类。
# curState inputClass nextState accept tokenKind 0 digit 1 0 - 0 letter 8 0 - 1 digit 1 0 - 1 dot 2 0 - 1 E 4 0 - 1 other 0 1 CONST_NUM 2 digit 3 0 - 4 sign 5 0 - 5 digit 6 0 - 6 digit 6 0 - 6 other 0 1 CONST_NUM 8 letter 8 0 - 8 digit 8 0 - 8 other 0 1 IDENT这里的关键不是表长什么样,而是"输入类别"这一层抽象。字符不是直接喂给自动机的,先经过一个classify()映射成类别:digit、letter、dot、E、sign、delim、quote、other。
int Lexer::classify(char c) { if (std::isdigit((unsigned char)c)) return CLS_DIGIT; if (std::isalpha((unsigned char)c) || c == '_') return CLS_LETTER; if (c == '.') return CLS_DOT; if (c == 'E' || c == 'e') return CLS_E; if (c == '+' || c == '-') return CLS_SIGN; if (std::strchr("+-*/:=,[]{};\"'()<>", c)) return CLS_DELIM; return CLS_OTHER; }注意E被单独拎成一个类别,而不是归到 letter 里。原因很实在:E既是数字指数符号(1.5E-3),又可能是标识符的首字母或中间字母(ELSE1、aE)。单看字符区分不了,必须靠"当前处于什么状态"来消歧——状态 1(已经读到数字)遇到E走指数分支,状态 0 遇到E走标识符分支。这就是为什么表用(状态, 类别)二元组做键,而不是单看字符做 switch。我见过不少课设在这里翻车,把E写死在数字处理函数里,结果变量名叫E的源程序直接扫不出来。
classify里sign也单独分出来,同理:+-在指数位置是正负号,在其他位置是二元运算符,词法阶段不区分,靠状态决定下一步走哪。
3.2 扫描器主循环:最长匹配与回退
主循环要处理的是一件事:一直往前走,直到走不动了,再回头看上一个终态能不能吐一个词出来。
std::vector<Token> Lexer::scanAll() { std::vector<Token> out; int state = 0; std::string buf; while (pos < src.size()) { char c = src[pos]; int cls = classify(c); auto it = trans.find({state, cls}); if (it == trans.end()) { if (accept[state]) { // 走不动了,但当前是终态:吐词 emitToken(out, state, buf); state = 0; buf.clear(); continue; // 注意:pos 不回退,当前字符留给下一轮 } // 非终态且无转移:死亡状态,报错并跳到下一个分隔符,做错误恢复 reportError(pos, "非法字符序列"); while (pos < src.size() && classify(src[pos]) != CLS_DELIM) ++pos; state = 0; buf.clear(); continue; } buf.push_back(c); ++pos; state = it->second; } if (!buf.empty() && accept[state]) emitToken(out, state, buf); return out; }逻辑说明:trans是std::map<std::pair<int,int>, int>,表加载完就只读;accept是std::vector<bool>,下标即状态号。真正体现"最长匹配"的是if (it == trans.end())这个分支——只要还能转移就继续吃字符,哪怕中途经过了好几个接受态。比如扫123.45E-6,状态会依次经过 1→3→4→5→6,中途 1、3、6 都是接受态,但只有走到 6 之后再也走不动了才吐一个常量。如果一进接受态就吐,123.45会被拆成123、.、45三个词。
参数说明:pos是全局读入游标,只在"确认吃下一个字符"时才自增,出错回退分支里不动它,这样死亡状态恢复后不会丢字符。buf只在下一次从头开始匹配时清空,保证emitToken拿到的永远是完整的词素。
错误恢复那段也值得说一句:遇到非法字符不直接退出,而是跳到下一个界符,把这一行剩下的部分吞掉,然后继续扫。一次跑完能报出所有词法错误,比扫一个报一个、改一个再跑一次要省太多时间。
3.3 关键字表与界符表的动态生成
自动机只识别出"这一串字母是个标识符",不负责告诉你是while还是变量名。这一步交给关键字表。
void Lexer::loadKeywords(const std::string& path) { std::ifstream in(path); std::string word; int code; while (in >> word >> code) keywords[word] = code; // 一行:program 0 } void Lexer::loadDelimiters(const std::string& path) { std::ifstream in(path); std::string sym; int code; while (in >> sym >> code) delimiters[sym] = code; }两个表都是std::unordered_map<std::string,int>,用文件驱动而不是写死数组。要加一个新关键字,改 txt 加一行,代码一行不动;要换一门小语言,换一个 data 目录就完事。这是整个项目里性价比最高的一处设计。
真正做分类判断的是emitToken:
void Lexer::emitToken(std::vector<Token>& out, int state, const std::string& buf) { int kind = acceptKind[state]; // 由终态决定粗分类:IDENT / CONST_NUM / CONST_CHAR / DELIM Token t{kind, buf, line, col, 0.0}; if (kind == TK_IDENT) { auto it = keywords.find(buf); if (it != keywords.end()) t.type = it->second; // 是关键字就覆盖类别 } else if (kind == TK_CONST_NUM) { t.numVal = std::stod(buf); } else if (kind == TK_DELIM) { auto it = delimiters.find(buf); if (it != delimiters.end()) t.type = it->second; // 界符查表拿编码 } out.push_back(t); }参数说明:acceptKind是每个接受态预设的粗类别,keywords和delimiters只做"细分类",不参与识别过程。这样职责很干净:状态机管"这串字符合法吗、边界在哪",查表管"它到底叫什么"。
提示:
:=、>=、<=这类两字符界符不要靠扫描完做字符串匹配,而是在状态机里先读:进一个中间态,遇到=再转成两字符终态,遇到别的就回吐单字符:。否则a: = b和a := b在某些写法下会被判成同一个东西,这个坑很隐蔽。
4. 语法语义分析与四元式生成:递归下降里把语义动作挂上去
4.1 文法设计:从基本文法到常量、数组、if-else、while的扩展
原始文法里EXPRESSION -> EXPRESSION + TERM | TERM是左递归,而递归下降遇到左递归会直接栈溢出。这一步必须做等价改写,把左递归换成循环,也就是 EBNF 里的{ }重复。
| 原始产生式 | 问题 | 改写后的写法 |
|---|---|---|
EXPRESSION -> EXPRESSION + TERM | TERM | 左递归 | TERM { (+ | -) TERM } |
TERM -> TERM * FACTOR | FACTOR | 左递归 | FACTOR { (* | /) FACTOR } |
SEN_SEQUENCE -> EVA { ; EVA } | 已适合递归下降 | 循环读,遇;继续 |
VARIABLE -> var ID_SEQ : TYPE ; VARIABLE | 尾递归,深度随变量组数增长 | { var ID_SEQ : TYPE ; }循环 |
VARIABLE -> con ID_SEQ : cons ; VARIABLE | 同上 | 循环 |
VARIABLE -> arr id cons { of cons } ; | 数组声明 | 单独一个parseArrayDecl() |
扩展部分一共四块:常量定义(con段)、数组结构(arr id [low] of [high])、if-else语句、while语句。前两块改的是声明段和符号表,后两块改的是语句集合并引入跳转指令和回填。这四块扩展彼此独立,做二次开发时可以按这个顺序一个个加,每加一个就跑一遍回归用例。
4.2 递归下降子程序的骨架与前瞻处理
顶层三个子程序:程序头、声明段、语句块。声明段用循环而不是递归,避免几十个变量声明把栈压深——虽然课设量级不至于溢出,但循环写法的调试体验好得多。
void Parser::parseProgram() { expect(TK_KW_PROGRAM); std::string name = expect(TK_IDENT); // 程序名不进符号表 parseVariablePart(); // 声明段,允许 0 次 parseCompound(); // begin ... end expect(TK_EOF); emit("end", "-", "-", name); // 收尾四元式,后端据此收栈 } void Parser::parseVariablePart() { while (cur().type == TK_KW_VAR || cur().type == TK_KW_CON || cur().type == TK_KW_ARR) { if (cur().type == TK_KW_VAR) { advance(); std::vector<std::string> names = parseIdSequence(); // id {, id} expect(TK_DELIM_COLON); std::string ty = parseType(); expect(TK_DELIM_SEMI); for (const auto& n : names) { if (!symtab.declare(n, ty, KIND_VAR)) // 重复声明在这里报 error(cur().line, "重复声明 " + n); } } else if (cur().type == TK_KW_CON) { advance(); std::vector<std::string> names = parseIdSequence(); expect(TK_DELIM_COLON); expect(TK_KW_CONS); std::string v = expect(TK_CONST_NUM); // 常量必须有初值 expect(TK_DELIM_SEMI); for (const auto& n : names) symtab.declareConst(n, v); } else { parseArrayDecl(); // arr id [low] of [high]; } } }逻辑说明:cur()返回当前 Token,advance()前移一位,expect()在类型不符时报错并前移一位做恢复(不抛异常,避免一个错把整棵树打断)。前瞻只需要 1 个 Token:语句块里以id开头必是赋值,以if、while、begin开头各自唯一;声明段里三个关键字互斥。1 个前瞻够用是这个文法能写成纯递归下降的前提,如果以后加do-while,注意do和while的组合不会破坏这一点,但加for时要小心id后面跟:=还是(,那时候可能得看两个 Token。
参数说明:symtab.declare返回bool,内部按当前作用域查重;error()只记录不中断,最后hasError()统一判断。
4.3 语义动作与四元式生成:表达式、赋值、控制流的落地
表达式子程序是语义动作最密集的地方,因为每个运算符都要落一条四元式。左递归改循环之后,中间的临时变量靠newTemp()递增生成。
// EBNF: TERM { (+|-) TERM } std::string Parser::parseExpression() { std::string left = parseTerm(); while (cur().type == TK_DELIM_PLUS || cur().type == TK_DELIM_MINUS) { std::string op = cur().lexeme; advance(); std::string right = parseTerm(); std::string tmp = newTemp(); // 生成 t1、t2…… emit(op, left, right, tmp); // 加法四元式 left = tmp; // 结果作为下一轮的左操作数 } return left; // 返回的是"值的表示",可能是变量名也可能是临时名 }参数说明:emit(op, arg1, arg2, result)往四元式表尾追加一条;newTemp()返回t1、t2这种名字,全局计数,不复用。返回值是字符串形式的"值引用",赋值语句直接拿它当右值写进:=四元式。这样写下来a := b + c * d会产出一条标准的:
( * , c , d , t1 ) ( + , b , t1, t2 ) ( :=, t2, - , a )数组下标要算地址,语义动作比标量多一步:
std::string Parser::parseFactor() { if (cur().type == TK_IDENT) { std::string name = cur().lexeme; advance(); Symbol* s = symtab.lookup(name); if (!s) { error(cur().line, "未声明的标识符 " + name); return "_err"; } if (cur().type == TK_DELIM_LBRACK) { // 数组元素 a[i] advance(); std::string idx = parseExpression(); expect(TK_DELIM_RBRACK); std::string off = newTemp(); emit("*", idx, std::to_string(s->elemSize), off); // 下标 × 元素宽度 std::string addr = newTemp(); emit("+", s->baseLabel, off, addr); // 基址 + 偏移 return "[" + addr + "]"; // 方括号标记"间接引用" } return name; } if (cur().type == TK_CONST_NUM) { std::string v = cur().lexeme; advance(); return v; } if (cur().type == TK_DELIM_LPAREN) { advance(); std::string v = parseExpression(); expect(TK_DELIM_RPAREN); return v; } error(cur().line, "因子不能以 " + cur().lexeme + " 开头"); advance(); return "_err"; }参数说明:返回"[t3]"这种带方括号的字符串,是给后端看的约定——表示这个值是一个地址,取数时要多一次间接寻址。不这么做的话,a[i] := b和a := b在后端没法区分,生成的目标代码会把偏移量当地址用。
控制流的重点是回填。if-else生成条件跳转时还不知道跳转目标在哪,先占位写-1,等分支生成完再backpatch回去。
// if C then S1 else S2 void Parser::parseIf() { expect(TK_KW_IF); std::string cond = parseCondition(); int jf = emit("jf", cond, "-", "_"); // 条件为假 → 跳 else,目标待填 enterScope(); parseSentenceSequence(); leaveScope(); if (cur().type == TK_KW_ELSE) { int jmp = emit("jmp", "-", "-", "_"); // 真分支末尾跳过 else backpatch(jf, nextQuad()); // 假出口落到 else 起点 advance(); enterScope(); parseSentenceSequence(); leaveScope(); backpatch(jmp, nextQuad()); // 真分支跳出整个 if } else { backpatch(jf, nextQuad()); } }while更简单,记一个循环头下标,末尾无条件跳回去:
// while C do S void Parser::parseWhile() { expect(TK_KW_WHILE); int loopStart = nextQuad(); // 循环条件的第一条四元式下标 std::string cond = parseCondition(); int jf = emit("jf", cond, "-", "_"); // 条件为假跳出循环 enterScope(); parseSentenceSequence(); leaveScope(); emit("jmp", "-", "-", std::to_string(loopStart)); backpatch(jf, nextQuad()); }参数说明:nextQuad()返回当前四元式条目数,也就是"下一条将写入的位置";backpatch(下标, 目标)把第下标条四元式的next字段填成目标。注意别把真出口链和假出口链混在一张表里,嵌套if时这是最容易出错的地方——每个jf和jmp都要单独记下标,一条一条回填。
4.4 递归子程序调用栈的可视化
答辩时被问"你说递归下降,那某一时刻栈里到底有几个子程序在跑",光讲是讲不清的。项目里做了一个轻量的调用栈记录器,每个子程序进出各打一次点。
class CallStack { public: struct Frame { std::string proc; int line; int tokenPos; }; void enter(const std::string& proc, int line, int tokenPos) { frames.push_back({proc, line, tokenPos}); if (frames.size() > maxDepth) maxDepth = frames.size(); // 记录峰值深度 } void leave() { if (!frames.empty()) frames.pop_back(); } void snapshot(std::ostream& os) const { // 单步时打印一份快照 os << "深度 " << frames.size() << " / 峰值 " << maxDepth << "\n"; for (const auto& f : frames) os << " -> " << f.proc << " @line " << f.line << "\n"; } private: std::vector<Frame> frames; size_t maxDepth = 0; }; struct FrameGuard { // RAII,异常和提前 return 都能保证出栈 CallStack& cs; FrameGuard(CallStack& c, const char* name, int line, int pos) : cs(c) { cs.enter(name, line, pos); } ~FrameGuard() { cs.leave(); } }; #define ENTER_FRAME(name) FrameGuard _fg(callStack, name, cur().line, pos)在每个parseXxx()开头挂一行ENTER_FRAME("parseExpression")即可。界面上点"单步"时做的三件事就是:advance()一个 Token、打印当前 Token、调snapshot()。深度峰值能到多少,直接反映文法嵌套的深浅,比画流程图有说服力。
注意:
FrameGuard用宏而不是手写 enter/leave,是因为递归下降里错误恢复分支的return点很多,手写漏一个leave(),栈就只增不减,单步走两下深度飙到几十,后面所有快照全废。这类"看着只是观测用"的代码,其实最容易埋雷。
5. 避坑与排查:跑不通的时候先看这几处
5.1 词法阶段的三个高发问题
现象一:1.5E-3被切成1.5、E、-、3四个 Token。原因:状态转换表里状态 4(读过E)没有允许+-的转移边,遇到负号直接走不动,回吐了1.5E前面的部分。解决:把表里的4 sign 5这条补上,同时检查状态 5 之后必须接 digit,否则整串退化成1.5E加一个非法字符报错。改完之后跑一遍tests/下所有用例,别只测正数。
现象二:变量名e1、E2扫不出来或者被当成数字。原因:classify()里把E归到了 letter 之类,状态 1(数字中)遇到E就走了标识符分支,把1E当成一个标识符的开头。解决:E必须是独立字符类别,能不能当指数符号由"当前状态"决定,跟字符本身无关。这条本质上就是状态机的意义所在。
现象三::=只认出了:,后面的=变成新的界符,赋值语句直接语法错。原因:状态机没为两字符界符留中间态,靠扫描完做字符串比较去补救。解决:单字符终态不立刻emit,先看下一个字符是否构成合法两字符组合,是就合并,不是再回吐。这正是emitToken只在"确定走不动"时才调用的原因。
5.2 语法与语义阶段的坑
现象四:稍微长一点的表达式就段错误,栈直接爆掉。原因:把原始文法EXPRESSION -> EXPRESSION + TERM原样翻译成了递归函数parseExpression()里再调parseExpression()。这是血泪经验级别的坑,编译器不报错,运行时才崩,而且崩得莫名其妙。解决:所有左递归产生式统一改写成while循环,对照 4.1 那张表逐个检查,一个都别漏。
现象五:a := b + c里b拼错成bb,程序照样生成四元式,到后端才崩。原因:parseFactor()里拿到标识符名就直接返回,没查符号表。解决:标识符一律先symtab.lookup(),查不到立刻error()并返回_err占位符;后续所有用到这个值的四元式都带着_err,后端起手第一件事就是扫一遍看有没有_err,有就拒绝生成。
5.3 回填与后端的坑
现象六:嵌套if里内层else的跳转落到了外层分支的中间。原因:用一个std::vector<int>存所有待回填下标,真假出口混在一起,backpatch的时候按顺序取,多层嵌套下顺序就乱了。解决:每个jf、每个jmp单独用局部变量记下标,在它对应的位置立刻回填;要有链式结构就分开建两张链。宁可多写十行代码,也别用一个全局 vector 图省事。
现象七:数组a[5]声明的是arr a [1] of [10],运行到a[0]时不报错但读到了垃圾值。原因:符号表只存了元素个数,没存上下界,边界检查无从做起。解决:Symbol里保留low/high两个字段,在生成下标四元式之前插入一条比较:下标小于low或大于high就跳到统一的错误处理块。课设里这个检查可以直接打印"下标越界"并退出,不必做异常机制。
6. 二次开发:加一条 for 循环文法,把这份课设改成自己的东西
拿到这套前端最想做的大概是"加一条自己的语句"。以for为例,走的是一条固定流水线,照着做基本不会返工。
第一步改词法:data/keywords.txt加一行for 14,data/dfa.txt一行都不用动,因为for就是一个普通标识符形态,靠关键字表覆盖类别。这一步验证起来最快:拿一段只含for的源码跑扫描,看 Token 类别是不是 14。
第二步改语法:在语句集的 FIRST 集里加一个TK_KW_FOR分支,写parseFor()。语义动作就是while的加强版,把初始化、条件、增量三段拼起来:
// for i := 1 to N do S —— 转成 i:=1; while i<=N do (S; i:=i+1) void Parser::parseFor() { expect(TK_KW_FOR); std::string var = expect(TK_IDENT); expect(TK_DELIM_ASSIGN); std::string init = parseExpression(); emit(":=", init, "-", var); // 初始化 int loopStart = nextQuad(); expect(TK_KW_TO); std::string bound = parseExpression(); std::string cond = newTemp(); emit("<=", var, bound, cond); // 循环条件 int jf = emit("jf", cond, "-", "_"); enterScope(); parseSentenceSequence(); leaveScope(); std::string one = newTemp(); emit("+", var, "1", one); // 增量 emit(":=", one, "-", var); emit("jmp", "-", "-", std::to_string(loopStart)); backpatch(jf, nextQuad()); }这里有个参数上的取舍要提前想清楚:to的边界表达式是每次循环重算,还是一次算完存起来。上面这版是每次重算,写起来短,但如果bound里带函数调用(这套文法没有函数调用,暂时不存在这个风险)就会有语义差异。要做严谨一点,就在loopStart之前把bound求值存进一个临时变量,循环里只读那个临时变量。
第三步改后端:for展开之后只用到<=、+、:=、jmp、jf这五种四元式,全是后端已有的指令选择分支,CodeGen一行不用改。这正是把语句降级成基础四元式的好处——前端扩展不往后端漏。
验证方法我一般强制走三步。一是每加一条产生式,先补一个最小用例,比如tests/for_min.src只放一个三行循环,跑通再加嵌套。二是拿out/quads.txt逐条肉眼核对手算结果,特别是回填目标的下标,for嵌套时最容易出现"跳回外层循环头"的情况。三是对同一份源程序比对两次运行的输出文件字节是否一致,防止临时变量命名带随机数或者地址导致结果不可复现。
从那以后我每次动文法,都先改data/下的表文件、再改parseXxx()、最后才碰四元式,三者的顺序反了就得回头重调。希望帮到你。
本文还有配套的精品资源,点击获取