简介:这份东北大学秦皇岛分校编译原理课程实验报告,面向计算机相关专业学生及需要理解编译器前端实现的学习者,围绕 PL/0 语言词法分析程序的设计与实现展开,可用于课程实验复现、代码研读和词法分析原理梳理。压缩包内共 1 个 doc 文件,大小约 122KB,内容以实验报告正文和 C++ 源码为主,涵盖实验目的、环境配置、PL/0 词法分析流程、关键词表匹配、单词与类别二元组输出及 2A 等非法单词检测思路。报告还给出 isLetter、isDigit、isP、isJ、isBlank 等字符判断函数与 main 主程序实现,便于读者对照理解文件读取、逐字符扫描、标识符与常数识别、错误检查等环节。目前已有 98 人学习下载,适合用作编译原理实验参考和词法分析器实现练手材料。
1. 从一段 PL/0 源程序说起:两份实验报告真正要交的东西
把一段 PL/0 源程序输入程序,输出一串由(单词、类别码)组成的二元组,遇到2A这种数字后面跟字母的拼接串要能报错——这是编译原理实验报告里词法分析那一半的全部需求;另一半是把文法 G[E] 的四个产生式原样翻译成四个互相调用的函数,判断任意输入串是不是该文法的句子。两份实验看着独立,其实是同一条流水线上前后相接的两道工序:前一道把字符流压成记号流,后一道在记号流上做推导。这套方案适合正在写编译原理实验报告、课程设计的人,也适合想手写一遍 PL/0 词法分析器和递归子程序语法分析器、把 LL(1) 那套纸面判断落进代码里的人。下面按需求、设计、实现、验收四段走完,代码骨架可以直接改写复用。
2. 总体设计:两道工序的职责边界与 47 个类别码的分段
2.1 谁负责切分,谁负责判合法性
词法分析只回答一件事:这一段字符构成什么记号,它不关心这些记号排在一起合不合法。2A在词法层就该报错,因为2起头的无符号数后面不允许紧跟字母;但BEGIN END在词法层完全合法,那是语法层该拒绝的东西。很多第一版代码把这两件事混在一起,在扫描函数里塞if判断语句结构长什么样,结果词法部分讲不清、语法部分又依赖字符下标,改一个地方两处崩。
我一般会在两者之间放一个明确的接口:词法输出vector<Token>,Token里带text、code、line三个字段,语法分析只认code,连原始字符都不再看。这么切有三个直接好处——两个实验可以拿同一份记号流做联调;语法分析的输入长度不再受固定字符数组限制;出错时能直接报出行号,而不是只打印一句"分析失败"让人猜。原始提交里语法分析用scanf("%s", a)读进一个char a[10],长度一超就是内存踩踏,这类问题在接口切成记号流之后自然消失。
还有一层边界要说清楚:实验四的文法字母表是a b c d e五个单字符终结符,和实验二的 PL/0 关键字表根本对不上。硬把两套塞进同一张表,查表范围就会打架。稳妥的做法是给实验四单独建一张五项小表,词法骨架复用,表换掉,两个实验各跑各的,互不污染。
2.2 类别码表的分段设计:21 + 2 + 24
类别码的排法决定了后面所有查表代码长什么样。原料里给的那张表总共 47 项,顺序是:21 个关键字、标识符、无符号数、24 个界符与运算符。这个顺序本身就是编码,索引即类别码,所以顺序一旦定下就不能随便调整,加一个关键字只能往段尾追加,不能插在中间。
| 区段 | 类别码范围 | 内容 | 数量 |
|---|---|---|---|
| 关键字 | 0 – 20 | and begin const div do else end function if integer not or procedure program read real then type var while write | 21 |
| 标识符 | 21 | 用户自定义名字 | 1 |
| 无符号数 | 22 | 整数与小数 | 1 |
| 界符与运算符 | 23 – 46 | ,;:.()[]..++--+-*/=<><><=>=:={}# | 24 |
这张表最容易被写坏的地方,是把"标识符""无符号数"这两个中文说明也塞进字符串数组,然后靠for(j=0;j<21;j++)这种数格子的方式跳过它们。加一个关键字,就要同步改三处循环上界,漏一处就是越界或者错位,而且编译期一声不响。改成命名常量分段之后,表长交给编译器算,循环边界只写常量名:
// 类别码分段:名字有意义,边界不再靠数格子 namespace sym { constexpr int KW_BEGIN = 0; // 关键字区起点 and constexpr int KW_END = 20; // 关键字区终点 write constexpr int IDENT = 21; // 标识符 constexpr int NUMBER = 22; // 无符号数 constexpr int DELIM_BEGIN = 23; // 界符区起点 "," constexpr int DELIM_END = 46; // 界符区终点 "#" } // 表的下标就是类别码,顺序不可随意调整,新增项一律追加到段尾 static const char* kSymbolTable[] = { "and","begin","const","div","do","else","end","function","if","integer", "not","or","procedure","program","read","real","then","type","var","while", "write", "标识符","无符号数", ",",";",":",".","(",")","[","]","..","++","--","+","-","*","/", "=","<",">","<>","<=",">=",":=","{","}","#" }; static const int kSymbolCount = sizeof(kSymbolTable) / sizeof(kSymbolTable[0]);kSymbolCount这个写法值得保留,它让"表里到底有几项"变成编译期事实。后面所有遍历都写i < kSymbolCount,再不会出现表有 47 项、循环写到 46 的错位。查表函数只做区间内的线性比对,命中返回下标,未命中返回 -1,一个函数同时服务关键字、运算符、界符三种查询。
提示:
kSymbolTable里那两格中文只是给人看的占位符,永远不要拿去和输入串做strcmp。标识符和无符号数走的是常量sym::IDENT、sym::NUMBER,根本不进查表路径。
还有一个隐蔽的坑:PL/0 的源程序习惯全大写(CONST、VAR、BEGIN),而表里的关键字是小写。直接比对的结果是所有关键字都被识别成标识符,输出看起来"没报错",其实全错。我的做法是查表前先把待查单词统一转成小写副本再比,输出的text保留原文不变。这样CONST和const都能命中类别码 2,结果打印出来还是源程序里的原样大小写。
2.3 工程文件与运行方式
整个方案落在三个文件上:pl0.txt存待分析的源程序,lexer.cpp是词法与记号输出,parser.cpp是四个递归子程序。两个实验分开编译,谁出问题就单独调谁,别为了"看起来完整"硬合成一个巨型main。
# 编译词法分析器,C++17 足够,不需要额外库 g++ -std=c++17 -O2 -o lexer lexer.cpp ./lexer pl0.txt在 Windows 上跑要注意一件事:fopen("pl0.txt","r")里的相对路径是相对当前工作目录,不是相对 exe 所在目录。从 IDE 里点运行能读到,双击 exe 就读不到,fp为NULL之后fgetc直接崩溃。这是血泪经验里最典型的一个——凡是从文件读输入,fp判空必须写在第一行:
FILE* fp = fopen("pl0.txt", "r"); if (!fp) { // 读不到就明确报出来,不要让它崩在后面 fprintf(stderr, "打不开 pl0.txt,检查工作目录\n"); return 1; }文件读完之后统一在末尾补一个#当结束哨兵。哨兵的好处是扫描循环的终止条件变成看字符而不是看EOF,语法分析那边的输入串也能用同一套约定,两处逻辑对齐,少一类边界 bug。
3. 词法分析器实现:字符分类、最长匹配与二元组输出
3.1 五个字符判定函数的职责与边界
判定函数只回答"是不是",绝不推进任何状态。这是扫描器最基本的一条纪律,破坏了它,代码很快就会变成"读到哪儿算哪儿"的黑匣子。原料里的isLetter、isDigit、isP、isJ、isBlank五个函数思路是对的,但isP和isJ的划分方式会让后面很难受:isP只认+ - * /四个,isJ把剩下的界符和两字符运算符全包了,于是"两字符优先匹配"这件事在函数层面就无处安放。
我更习惯按用途拆成下面这一组,边界清楚,后面加符号不用动结构:
// 字符分类:只回答"是不是",不碰下标 inline bool isLetter(char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); } inline bool isDigit(char c) { return c >= '0' && c <= '9'; } inline bool isIdentStart(char c) { return isLetter(c) || c == '_'; } inline bool isIdentPart(char c) { return isLetter(c) || isDigit(c) || c == '_'; } inline bool isBlank(char c) { return c == ' ' || c == '\t' || c == '\r'; }isIdentStart和isIdentPart分开的意义在于:标识符首字符不能是数字,这条规则如果用isLetter || isDigit混在一个函数里,就只能靠在循环外面再补一个判断,容易漏。分开之后"首字符"和"后续字符"各判各的,规则和文法描述一一对应,写报告时也容易讲。
isBlank里把\r加进去是个小改动,作用不小。Windows 下的文本文件行尾是\r\n,如果只把 和\t当空白,\r会掉进"非法字符"分支,输出里冒出一堆莫名其妙的报错。这类问题不涉及任何算法,纯粹是平台差异,但足够让人在调试时怀疑人生。
3.2 主扫描循环:从字符串到 (单词, 类别) 二元组
主循环的结构是三段式:跳空白、按首字符分流、把一段连续字符收成一个记号。分流只有四条路——数字开头走无符号数、标识符首字符走标识符或关键字、其余走运算符与界符、都不匹配就是非法字符。关键在于每条分支结束都continue回到循环顶,绝不在分支内部再往下读第二个记号。
struct Token { std::string text; int code; int line; }; struct LexError { int line; std::string text; std::string msg; }; // 在表的指定区间内查类别码,未命中返回 -1 int lookup(const std::string& w, int from, int to) { for (int i = from; i <= to; ++i) if (w == kSymbolTable[i]) return i; return -1; } std::vector<Token> scan(const std::string& src, std::vector<LexError>& errs) { std::vector<Token> out; size_t i = 0, n = src.size(); // src 末尾已补哨兵 '#' int line = 1; while (i < n && src[i] != '#') { char ch = src[i]; if (ch == '\n') { ++line; ++i; continue; } if (isBlank(ch)) { ++i; continue; } if (isDigit(ch)) { // 无符号数(含小数) size_t s = i; bool dot = false; while (i < n && (isDigit(src[i]) || (src[i] == '.' && !dot))) { if (src[i] == '.') dot = true; ++i; } // 收完数字立刻看一眼后继:2A、12x 这类拼接串在这里判死 if (i < n && isIdentStart(src[i])) { size_t s2 = i; while (i < n && isIdentPart(src[i])) ++i; errs.push_back({line, src.substr(s, i - s), "数字后紧跟字母,不是合法单词"}); continue; } out.push_back({src.substr(s, i - s), sym::NUMBER, line}); continue; } if (isIdentStart(ch)) { // 标识符或关键字 size_t s = i; while (i < n && isIdentPart(src[i])) ++i; std::string raw = src.substr(s, i - s); std::string low = raw; for (char& c : low) c = (char)tolower((unsigned char)c); int code = lookup(low, sym::KW_BEGIN, sym::KW_END); out.push_back({raw, code < 0 ? sym::IDENT : code, line}); continue; } // ………运算符与界符分支见 3.3……… } return out; }数字分支里那个"收完之后回头看一眼前继"的动作,是整个词法分析器里最关键的一段检查。如果只是把连续数字收成一串就continue,2A会被切成2和A两个合法记号,程序照常往下跑,一个字都不报——这正是答辩时最容易被追问翻车的地方。判断时机必须放在"已经收完一段"之后,因为要判的是"数字串后面紧跟着什么",中间隔一个空白就不再是非法串,10 A是两个记号,2A是一个错误。
标识符分支里的low就是 2.2 提到的大小写归一。注意转小写只发生在查表用的副本上,Token::text存的是raw,输出保持源程序原样。这个细节看着小,但报告里的结果截图如果全是小写,和源程序对不上,验收时又要解释半天。
3.3 运算符与界符:双字符优先,最左最长匹配
24 个界符里有 7 个是两字符的:..++--<><=>=:=。它们和单字符符号存在前缀关系,:是:=的前缀,.是..的前缀,<是<>和<=的前缀。所以匹配顺序只能是先试两字符、再退一字符,反过来就会把:=拆成:和=,把<>拆成<和>。
// 两字符运算符必须先于单字符尝试,否则前缀关系会导致误拆 static const char* kTwoCharOp[] = { "..","++","--","<>","<=",">=",":=" }; int matchOp(const std::string& src, size_t& i) { if (i + 1 < src.size()) { std::string two = src.substr(i, 2); for (const char* op : kTwoCharOp) { if (two == op) { i += 2; // 命中两字符,游标一次走两格 return lookup(two, sym::DELIM_BEGIN, sym::DELIM_END); } } } int code = lookup(std::string(1, src[i]), sym::DELIM_BEGIN, sym::DELIM_END); if (code >= 0) ++i; // 单字符命中走一格 return code; // -1 交给上层报非法字符 }matchOp把"试两字符—退一字符"这个顺序封装进函数,主循环里只剩一句int code = matchOp(src, i);,干净且不容易写错。返回值 -1 表示既不是两字符也不是单字符,主循环拿到 -1 就构造一条非法字符错误、游标前进一格继续扫。游标一定要前进,否则遇到非法字符就死循环——这是扫描器里第二种典型翻车方式。
原料代码里界符分支写的是while(isJ(ch)),只要还是界符就继续收。这条循环的后果是连续的界符被一次性吞成一个长串:);会被收成);,然后拿整串去表里比对,全不命中。表面上看只是输出少了几项,实际上是扫描粒度错了,后面的语法分析拿到的东西完全对不上。这类问题不要靠"再加个 if"去补,正确做法就是回到"一次只认一个记号"的模型,最长匹配的长度上限由表决定,而不是由循环决定。
3.4 错误检测与输出格式
错误不应该中断扫描。一份源程序里可能同时有2B和2E两处拼接错误,只报第一个,剩下那处要再跑一遍才能发现,来回折腾。所以我把错误收进一个vector<LexError>,扫描跑完统一打印,一次把问题全列出来。
输出格式建议两段式:先逐行打印二元组,再单独打印错误清单。二元组直接写成单词|类别码,一行一个记号,行号按源程序换行递增,方便对照。
CONST|2 A|21 =|38 10|22 ;|24 VAR|18 B|21 ,|23 C|21 ;|24 2B 数字后紧跟字母,不是合法单词(第 3 行)拿原料里那份测试源程序跑一遍,PROCEDURE、BEGIN、WHILE、CALL这些都会正常落到关键字区,2B和2E会在错误清单里各占一行。这里要留意CALL:它不在 21 个关键字表里,会被识别成标识符CALL|21。这不是程序错了,是 PL/0 的语法描述里本来就没有CALL这个关键字,报告里写清楚这一点,比硬把它塞进关键字表要负责得多。
注意:错误清单里的字符串要带上原始拼接内容(
2B而不是2),否则读者无法判断是"数字后接字母"还是别的错误类型。
4. 递归子程序分析器:把文法 G[E] 的四个产生式翻译成四个函数
4.1 先判 LL(1):FIRST 集不交叉就不用回溯
动手写函数之前先把文法过一遍。G[E] 的四条产生式是E → eBaA、A → a | bAcB、B → dEd | aC、C → e | dC。要判两件事:有没有左递归,同一非终结符的多个候选 FIRST 集有没有交集。
| 非终结符 | 产生式 | FIRST 集 | 结论 |
|---|---|---|---|
| E | eBaA | { e } | 单候选,直接匹配 |
| A | a/bAcB | { a } / { b } | 不相交,看首符分支 |
| B | dEd/aC | { d } / { a } | 不相交,看首符分支 |
| C | e/dC | { e } / { d } | 不相交,看首符分支 |
四条产生式都没有直接或间接左递归(没有哪个非终结符能推出以自己开头的串),三组候选的 FIRST 集两两不交,最左推导的每一步都能靠当前符号唯一确定用哪条产生式。也就是说这是一份 LL(1) 文法,不需要消除左递归、不需要提取公共左因子、不需要回溯机制。这一点写进报告比贴代码更有说服力——它解释了"为什么可以一个候选配一个 if 分支",而不是"老师给的模板就这么写"。
反过来说,如果哪个候选的 FIRST 集撞上了(比如A → a和A → ab),那就得先做左因子提取再写函数,否则代码里必然出现"试一下不行再退回来"的回溯逻辑,游标一退错位置,整个分析就废了。判断顺序永远是:先去左递归、再提左因子、最后才是写递归子程序。
4.2 E / A / B / C 四个函数的骨架与全局游标
递归子程序的写法几乎是文法描述的直译:每个非终结符对应一个函数,产生式右部的终结符用match吃掉,非终结符用函数调用展开。全局状态只需要两样——记号数组和一个指向当前待匹配位置的游标。
static std::vector<Token> toks; // 由词法部分产出的记号流,末尾带哨兵 static size_t pos = 0; // 当前待匹配位置 static bool ok = true; // 一旦置 false 就不再往下推进 static std::string why; // 第一条错误信息,保留现场 const Token& cur() { return toks[pos]; } void match(int code, const char* what) { if (ok && pos < toks.size() && toks[pos].code == code) { ++pos; return; } if (ok) { ok = false; why = "第" + std::to_string(cur().line) + "行期望 " + what; } } void fail(const char* expect) { if (ok) { ok = false; why = "第" + std::to_string(cur().line) + "行期望 " + expect; } }match里那个if (ok)是关键。出错之后所有后续的match立刻变成空操作,游标停在出错位置不动,第一个错误信息也不会被后面的连锁错误覆盖。没有这个短路,一个语法错误会引发十几个"期望某某"的报错,真正原因被埋在中间,读的人只能干瞪眼。
四个函数本体按产生式逐条对:
void E() { // E -> e B a A if (!ok) return; match(T_E, "e"); B(); match(T_A, "a"); A(); } void A() { // A -> a | b A c B if (!ok) return; if (cur().code == T_A) { match(T_A, "a"); return; } if (cur().code == T_B) { match(T_B, "b"); A(); match(T_C, "c"); B(); return; } fail("a 或 b"); } void B() { // B -> d E d | a C if (!ok) return; if (cur().code == T_D) { match(T_D, "d"); E(); match(T_D, "d"); return; } if (cur().code == T_A) { match(T_A, "a"); C(); return; } fail("a 或 d"); } void C() { // C -> e | d C if (!ok) return; if (cur().code == T_E) { match(T_E, "e"); return; } if (cur().code == T_D) { match(T_D, "d"); C(); return; } fail("d 或 e"); }对照看A和B的顺序差异,这是最容易被写反的地方。A → bAcB展开是b、A、c、B四个符号,收c的位置在两次递归之间;B → dEd展开是d、E、d,两个d分居递归两侧。原料里那一版把E写成"吃e、调B、吃a、调A",但吃完e之后如果当前不是a就直接return了,没有任何错误标记——只有末尾检查a[s]=='#'这一道防线在兜着。这种"靠终点判对错"的写法在正例上跑得通,一旦输入是eb...这种前半截对、后半截缺的串,报出来的永远是笼统的"分析失败",说不出缺在哪个符号。
4.3 从字符流升级到 token 流
原版的语法分析直接在字符数组上跑:scanf("%s", a)读到char a[10],然后在a[s]上比'e'、'a'。这条路能跑通正例,但有三处硬伤:缓冲区定长,长串直接溢出;字符和记号混为一个概念,e既是终结符又是可能出现在标识符里的字符;结束位置靠检查a[s]=='#',而#又必须由用户在输入里手动敲进去,稍微漏一个就永远等不到结束。
切成记号流之后这三处一起消失。输入串先过一遍 3.2 的scan,把每个字符转成Token,末尾补一个END哨兵(类别码取 99,不与任何真实类别冲突),游标越界时cur()返回的就是这个哨兵,match自然失败并报"期望 X",不会越界读内存。
// 把输入串转成记号流:实验四只认 a~e 五个终结符 enum { T_A = 0, T_B, T_C, T_D, T_E, T_END = 99 }; static int termCode(char c) { return (c >= 'a' && c <= 'e') ? c - 'a' : -1; // 不在字母表里就返回 -1 } bool analyze(const std::string& input, std::string& msg) { toks.clear(); pos = 0; ok = true; why.clear(); for (size_t i = 0; i < input.size(); ++i) { int c = termCode(input[i]); if (c < 0 && input[i] != '#') { // 字母表外的字符当场拒绝 msg = "第" + std::to_string(i + 1) + "个字符不在字母表内"; return false; } if (input[i] == '#') break; // 手动敲的 '#' 直接截断 toks.push_back({std::string(1, input[i]), c, 1}); } toks.push_back({"#", T_END, 1}); // 哨兵,保证 cur() 永不越界 E(); if (!ok) { msg = why; return false; } if (pos + 1 != toks.size()) { msg = "输入串未被完全识别,剩余部分从第 " + std::to_string(pos + 1) + " 个记号开始"; return false; } return true; }最后那句pos + 1 != toks.size()是整个分析器判"对"的唯一出口。E()只是把能匹配的部分推完,剩下没吃掉的记号说明输入长了——比如eaeaaa,E推完eaeaa就返回了,末尾那个a还在缓冲区里,只有靠这句才能判出来。少了它,所有"合法前缀 + 垃圾后缀"的输入都会被判成正确,这是递归下降实现里最常见的漏判。
analyze返回布尔值加一条消息,主程序套一层while反复读输入即可,退出条件交给用户输入空串或者quit,不要再写while(1)那种一口气跑到底的结构。原料里的主函数写成While(1)(大写 W)编译不过,改的时候顺手把退出条件补上,别只改大小写就交。
4.4 测试用例设计:正例、边界例与必错例
只跑通一个正例就交报告,验收时随便换一个串就原形毕露。我一般会准备三组用例,每组都有明确的考察点,写进报告里也显得思路清楚。
| 编号 | 输入串 | 期望结果 | 考察点 |
|---|---|---|---|
| 1 | eaeaa# | 正确 | 最短合法串:E→eBaA,B→aC,C→e,A→a |
| 2 | eadeaa# | 正确 | C 走dC再走e,验证 C 的递归分支 |
| 3 | edeaeadaa# | 正确 | B 走dEd,嵌套一层 E,验证跨层递归 |
| 4 | e# | 失败 | B 缺失,报"期望 a 或 d" |
| 5 | eaeaaa# | 失败 | 前缀合法、后缀多余,验证完全识别检查 |
| 6 | abc# | 失败 | 首符就错,验证 E 的首终结符匹配 |
| 7 | eaeaX# | 失败 | 字母表外字符,验证输入过滤 |
第 3 条那个串是手推出来的:B → dEd,其中内层E → eBaA取B → aC、C → e、A → a,得到eaeaa,套进B得deaeaad,再套最外层E → eBaA(A取a)得e+deaeaad+a+a,一共 10 个字符。手推一遍再对着程序跑,比盲目试串快得多,也是报告里"测试设计"那一节最实在的素材。
5. 避坑与排查:五个让实验报告返工的错误
5.1 词法侧:类别码与匹配顺序相关的问题
现象一:源程序里明明写的是CONST,输出却是CONST|21,所有关键字都变成了标识符。原因在于表里存的是小写,输入是大写,strcmp一次都没命中,全部落进"未命中就是标识符"的兜底分支。这种情况最坑的地方是程序不报错、不崩溃,输出看起来还挺整齐,只有对着类别码表一个个核才发现全错。解决办法就是在查表前把单词转成统一大小写再比,原始文本单独保留用于输出,两件事分开做。
现象二:2A输进去,程序输出2|22 A|21,一句错误都没报。原因在数字分支的收尾动作——收完连续数字就直接continue,根本没有回头看后继字符。这一类错误的本质是把"扫描"和"校验"当成了两件分开的事,而词法层的错误恰恰只能在扫描的瞬间判。解决办法是把检查点放在数字串收完之后、构造记号之前:如果下一个字符是字母或下划线,就把整段2A当作一个非法单词报出去,游标跳过整段,不产生任何记号。
现象三:);这种连续界符被合并成一项,后面所有类别码整体错位。原因是用while(isJ(ch))之类的循环去吞界符,把"一个记号"和"一段连续同类字符"混为一谈了。数字和标识符确实该连续收,因为它们本来就是多字符记号;界符和运算符每一项都是定长的(一字符或两字符),收多了就是错。解决办法是删掉这类循环,改成一次只匹配一项,两字符优先、单字符兜底,匹配完游标推进固定格数。
5.2 语法侧:缓冲区、退出条件与错误定位
现象四:输入稍长一点程序就崩,或者跑出乱码结果。根子在定长字符数组。char a[10]配scanf("%s", a),输入 12 个字符就直接踩到栈上别的变量,表现可能是崩溃,也可能是"结果莫名其妙但程序没死",后者更难查。这类问题没有后悔药可吃,只能从根上换掉:改用std::string承接输入,用vector<Token>承接记号流,长度由容器自己管,同时给记号流末尾补哨兵,让越界读变成一次可控的匹配失败。
现象五:报错只报"分析失败",说不清错在哪。原因是递归函数里没有任何错误记录,全靠末尾检查结束符位置来反推,位置信息早就丢了。解决办法是把"当前游标位置"作为唯一的现场信息保留下来:match失败时立刻记下toks[pos].line和期望的记号名,置ok = false,之后所有递归调用入口第一句都是if (!ok) return;,把错误冻结在第一个出错点。改完之后报错会变成"第 1 个记号处期望 a 或 d",答辩时直接把这条打印出来,比任何口头解释都管用。
注意:短路标记
ok一定要在每个递归函数的入口检查,漏一个函数,错误之后它还会继续往下推游标,第一个错误位置就被覆盖了。
6. 验收与答辩:十分钟里把两份实验讲清楚的顺序
答辩不是把代码从头念一遍,评委看的顺序和你写代码的顺序不一样。他们先看需求立没立住,再看设计有没有依据,最后才看代码跑不跑。所以讲述顺序我建议按"输出格式 → 类别码表 → 最短匹配 → 文法判断 → 测试用例"走,每一步都拿屏幕上能看见的东西说话。
第一步,直接把一段源程序的输出贴出来,指着CONST|2说清二元组里两个分量各是什么,再指一行错误清单说2B为什么非法。这一步两分钟,作用是让评委知道你的程序输入端、输出端都通了,后面才愿意听细节。第二步,翻到类别码表那一页,说明 47 项为什么分成 21 + 2 + 24 三段,边界用常量而不是数字硬编码。这一步是在答"你的表为什么这么排",很多人被问到这里答不上来,只能说"老师给的"。第三步,讲那个"数字收完看一眼后继"的检查点,这是词法部分唯一有设计感的地方,值得单独讲三十秒。
语法部分的开场不要贴代码,先讲 FIRST 集那张表。把四条产生式、三组候选、两两不交这个结论摆出来,然后一句话收口:"所以不需要消除左递归、不需要回溯,一个候选一个 if 分支。"评委听到这句就知道你是先分析再动手,而不是照着模板抄。之后再展示A和B两个函数,重点讲bAcB和dEd里终结符与递归调用的相对位置为什么不同——这个对比最能体现你是"按产生式直译"而不是"凭感觉写"。
常见追问和对应的回答要点,提前准备好:
| 追问 | 回答要点 |
|---|---|
| 你的词法分析器能处理浮点数吗 | 数字分支里用dot标记只允许一个小数点,第二个点会被切走当作..或报错 |
为什么CALL是标识符不是关键字 | PL/0 的 21 个关键字里没有CALL,它 |
本文还有配套的精品资源,点击获取