简介:东北大学秦皇岛分校计算机与通信工程学院编译原理课程实验报告,面向正在学习词法分析、需要完成 PL/0 语言词法分析程序设计与实现的学生。内容围绕词法分析器的设计与实现展开,涵盖读取源程序字符串、按字母数字运算符和界符分类字符、匹配关键字表、输出(单词,类别)二元组序列,以及识别“2A”等非法单词的错误检测,并附 Windows PC 环境下使用 C++ 编写的实验代码、函数说明与处理流程。压缩包内共 1 个 doc 文件,约 122KB,集中呈现实验目的、实验内容、程序源码和结果展示,便于直接查阅和对照。已有 102 人学习,适合用于课程实验复盘、理解 PL/0 词法分析流程,以及复习单词构建、运算符与界符判断、错误处理等关键环节。
1. 两份程序、两条链路:这份编译原理实验报告到底能跑出什么
很多人找编译原理实验报告,是想拿一份能直接编译通过的模板交差,打开却发现是两段风格完全不同、还带着明显 bug 的 C++ 源码,扭头就走。这份资源恰好相反:它的价值不在排版,而在两段能独立编译运行的程序——一个 PL/0 词法分析器,把源程序切成(单词,类别码)二元组;一个递归子程序语法分析器,用 E、A、B、C 四个函数实现四个非终结符,判断输入串是不是给定文法的句子。前者让你看清最长匹配和关键字表怎么配合,后者让你看清产生式怎么机械地翻译成函数。适合正在上编译原理课、既要交实验又想讲清原理的人,也适合工作几年后回头补编译器前端基本功的开发者。
2. 资源构成与技术选型:两个独立程序加一张 48 项关键字表
这份材料不是一个大工程,而是两次实验的拼装:实验二做词法分析,实验四做语法分析,彼此不共享代码,各自一个main,各自一套输入方式。好处是拆得干净,坏处是如果你想把它串成一条完整的前端流水线,中间那层适配要自己写。先把家底摸清楚,后面改造才知道从哪下手。
2.1 两份实验的分工:一个切词,一个判句
从功能上看,两个程序处理的是编译前端最前面两段。词法分析器的输入是待分析的源程序字符串,输出是单词串;语法分析器的输入是任意符号串,输出是「结构正确 / 分析失败」的结论。
| 维度 | 词法分析程序 | 递归子程序分析程序 |
|---|---|---|
| 输入源 | pl0.txt文件 | 控制台scanf输入 |
| 输入内容 | PL/0 风格源程序 | 由 e/a/b/c/d 组成的符号串 |
| 输出 | 单词 + 类别码二元组 | 句子结构是否正确 |
| 核心数据结构 | ktt[48][20]关键字表 + 全局游标pos | 全局字符数组a[10]+ 全局游标s |
| 分析方法 | 最长匹配 + 查表 | 递归下降,按 FIRST 集分支 |
| 语言/平台 | C++,Windows 控制台 | C++,Windows 控制台 |
两边都用了「全局游标 + 逐字符推进」这套最朴素的写法。词法那边游标叫pos,语法那边叫s,本质是同一个东西:一个指向当前读到哪里了的整数。这种写法在课程实验里够用,但也埋了两个共同的雷——游标只能前进不能回退,出错之后没有任何定位信息。后面避坑那章会专门算这笔账。
从技术选型上说,2012 年前后的课程环境基本就是 Windows + Visual C++ 6.0 或者 Dev-C++,所以代码里能看到#include <iostream>、using namespace std、FILE*和fgetc混着用的痕迹。这在今天用 g++ 或者 clang 一样能编过,只是会冒出一堆「不建议使用」的告警,不影响跑。
2.2 关键字表 ktt 的四段分区
整个词法分析器的骨架就是一张 48 项的二维字符数组ktt。这张表的排列顺序不是随便写的,它严格按类别码从小到大铺开,每一段对应一种 token 类型,后面所有分支的循环边界都从这张表里来:
| 类别码区间 | 内容 | 数量 | 含义 |
|---|---|---|---|
| 0 ~ 20 | and、begin、const … write | 21 | PL/0 保留字 |
| 21 | 标识符 | 1 | 用户自定义名字 |
| 22 | 无符号数 | 1 | 整数或小数常量 |
| 23 ~ 33 | ,;:.()[]..++-- | 11 | 单/双字符界符 |
| 34 ~ 37 | +-*/ | 4 | 算术运算符 |
| 38 ~ 47 | =<><><=>=:={}# | 10 | 关系/赋值/注释符 |
这张表的设计思路值得学:把「类别」和「码值」压成同一个维度,查表时命中的下标直接就是类别码,省掉一次映射。代价是表的顺序不能乱动,插入一个关键字就得整体挪位,所有硬编码的循环边界(j<21、j=23;j<47、j=34;j<38)都要跟着改。我在实际项目里更倾向于拆成enum TokenType加一张unordered_map<string, TokenType>,但作为理解原理的教具,这张平铺的表反而更直观——你能一眼看出编译器是怎么把字符串变成数字的。
有个小细节:打印表的那行循环写的是for(int j=0;j<47;j++),而数组有 48 项,最后那个#永远打不出来。不影响分析逻辑,但对着输出核对类别码时会疑惑一下。
2.3 环境与文件依赖:Windows 控制台加一个 pl0.txt
词法分析器对文件路径是硬依赖的。源码里直接写死fp=fopen("pl0.txt","r"),用的是相对路径,所以pl0.txt必须和可执行文件在同一目录,或者和工程文件在同一目录(取决于你从哪启动)。如果fopen返回NULL,代码没有做任何判空,下一句fgetc(fp)就会直接崩,这在实验课上是高频翻车点。
测试用的源文件内容长这样:
CONST A=10; VAR B,C; 2BPROCEDURE P; VAR D; 2E PROCEDURE Q; VAR X; BEGIN READ(X);D:=X;WHILE X<0 DO CALL P; END; BEGIN WRITE(D); CALL Q; END; BEGIN CALL P; END.里面故意塞了2B和2E两个非法串,就是想看分析器能不能报错。这一点很关键——它决定了这个程序是「能跑」还是「能检查」。遗憾的是,按现在的代码逻辑,2B会被拆成数字2(码 22)和标识符B(码 21)两个合法 token,一个错都不报,跟实验要求里写的「发现 2A 这类不能作为单词的字符串」有差距。具体原因放在第 5 章展开。
语法分析器那边反倒没有文件依赖,纯控制台交互,scanf("%s", a)读一个串就分析一个串。
3. 词法分析器拆解:从字符流到(单词,类别)二元组的完整链路
这段代码一共不到 150 行,但把词法分析的几个核心动作都摆出来了:字符分类、最长匹配、查表定类、输出二元组。想看懂它,别从头顺着读,先抓住主循环那个while(ch != '#'),剩下的都是它内部的分支。
3.1 五个字符判定函数和主循环骨架
开头那五个bool函数是整个程序的判断基础,每个只干一件事:
bool isLetter(char ch) { // 字母:标识符和关键字的首字符 return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); } bool isDigit(char ch) { // 数字:常量的组成字符 return ch >= '0' && ch <= '9'; } bool isP(char ch) { // 算术运算符:+ - * / return ch == '+' || ch == '*' || ch == '-' || ch == '/'; } bool isJ(char ch) { // 界符:逗号分号括号等等 return ch == ',' || ch == ';' || ch == '.' || ch == '(' || ch == ')' || ch == '[' || ch == ']' || ch == '=' || ch == ':' || ch == '<' || ch == '>' || ch == '{' || ch == '}' || ch == '#'; } bool isBlank(char ch) { // 空白:空格和制表符,直接跳过 return ch == ' ' || ch == '\t'; }这五个函数的参数都只有一个char,返回布尔值,作用是给主循环做「这个字符该进哪个分支」的分流。注意isP和isJ有语义重叠的风险:+只在isP里,=只在isJ里,但isJ不收+,所以++这个双字符界符永远走不到界符分支——它会被isP先截成两个单独的+。表里明明留了++(码 32)和--(码 33),实际一条都用不上,这是典型的「表设计超前于分支实现」。
主循环的结构是这样:
FILE *fp = fopen("pl0.txt", "r"); ch0 = fgetc(fp); while (ch0 != EOF) { // 第一步:把整个文件读进字符串 src src += ch0; ch0 = fgetc(fp); } src += '#'; // 末尾补哨兵,作为结束标志 ch = src[pos++]; // 初始化当前字符 while (ch != '#') { // 第二步:逐字符扫描,直到哨兵 // 数字 / 标识符 / 运算符 / 界符 / 空白 五个分支 }这里的参数含义要说清:src是完整源程序字符串,pos是全局读指针,ch是当前待处理字符。每进一个分支,ch会被更新成下一个字符,pos同步前移。用哨兵法#结束是课程实验的惯用做法,好处是不用处理pos < src.length()的边界判断,坏处是源程序里不能合法出现#——巧的是ktt表里#本身就占了一个类别码 47,语义上打架了。
3.2 数字与标识符:最长匹配的落地写法
词法分析的核心思想是「最长匹配」:读到1,不能立刻断定它是常量,得继续往后看2和.,直到遇到第一个不属于常量的字符才收手。数字分支就是这么写的:
if (isDigit(ch)) { // 首字符是数字,进入常量分支 int i = 0; while (isDigit(ch) || ch == '.') { // 贪心地吃掉所有数字和小数点 str[i++] = ch; // 暂存到局部缓冲 ch = src[pos++]; // 游标前移,读下一个 } str[i] = '\0'; // 手动补字符串结束符 ste = ste + "|" + "22"; // 类别码 22 = 无符号数 cout << str; // 输出单词本体 continue; // ch 已经是新字符,直接下一轮 }str是长度 20 的局部数组,i是写入位置,循环结束后手动补\0是 C 风格字符串的标准动作。continue的作用是跳过后面的分支判断,因为此时ch已经指向第一个不属于常量的字符,正好是下一轮的起点。
标识符分支的结构一样,只是判断条件换成isLetter(ch) || isDigit(ch),允许字母开头、后续接数字:
else if (isLetter(ch)) { // 首字符是字母 int i = 0, j; while (isLetter(ch) || isDigit(ch)) { // 字母数字混合,最长匹配 str[i++] = ch; ch = src[pos++]; } str[i] = '\0'; for (j = 0; j < 21; j++) { // 只在保留字区 0~20 里查 if (strcmp(str, ktt[j]) == 0) { ste += "|" + to_string(j); // 命中保留字,用下标当类别码 break; } } if (j == 21) ste += "|21"; // 全没命中,就是标识符 cout << str; continue; }这段的关键是循环变量j在循环外还能用。循环正常走完j == 21,说明 21 个保留字都没匹配上;中途break出来,j就是命中的关键字下标。这个小技巧在 C 里很常见,但可读性一般,二次开发时建议换成函数加返回值。
ste是最终输出的二元组累加器,每识别一个 token 就追加一段|类别码,换行时才整体打印。所以最终看到的输出形如CONST|2 A|21 =|38 10|22,单词和码混在一行,靠|分隔。这套拼接用stringstream也能做,代码里有几处绕了一圈ss << ste; ss >> ste;,其实是多余的,直接to_string更清楚。
3.3 关键字匹配与类别码拼接
保留字识别本质是「字符串相等 + 查表」,没什么算法含量,但有一个坑必须提:strcmp是区分大小写的,而 PL/0 的保留字全小写。所以源程序里写CONST,匹配的是ktt表里第 2 项const吗?不是。strcmp("CONST", "const")返回非零,CONST会被当成标识符,码 21。
测试文件恰好就是大写写的CONST A=10;,所以跑起来你会看到CONST输出成 21 而不是 2。这不是 bug,是 PL/0 语言本身规定关键字小写,测试数据写大写属于「故意制造差异」。但如果你的实验要求里说「关键字不区分大小写」,那就得在strcmp前先做统一大小写转换,常见做法是std::transform(str.begin(), str.end(), str.begin(), ::tolower)。
另外,类别码拼接的方式直接影响输出可读性。现在的输出把单词和码挤在一行,中间只有|,遇到:=这种双字符符号时会出现:=|44,肉眼扫一遍还行,想拿去做后续语法分析的输入就不行了。我在自己项目里会改成每行一个(token, type),方便管道传给下一个阶段。
3.4 运算符与界符分支的边界
运算符分支最短,因为它只处理单字符:
else if (isP(ch)) { // 只可能是 + - * / 之一 int i = 0, j; str[i++] = ch; str[i] = '\0'; // 直接当单字符符号处理 for (j = 34; j < 38; j++) { // 在 34~37 里查表 if (strcmp(str, ktt[j]) == 0) { ste += "|" + to_string(j); break; } } cout << str; ch = src[pos++]; // 手动推进游标 continue; }问题在于它完全不看后一个字符。<=里的<不走这里,走的是界符分支;但++里的第一个+会走这里,于是++被拆成两个单独的+。同理--也被拆成两个-。
界符分支反过来,是「贪心过头」:
else if (isJ(ch)) { int i = 0, j; while (isJ(ch)) { // 问题所在:只要还是界符就一直吃 str[i++] = ch; ch = src[pos++]; } str[i] = '\0'; for (j = 23; j < 47; j++) { // 在 23~46 里查表 if (strcmp(str, ktt[j]) == 0) { ste += "|" + to_string(j); break; } } cout << str; // 无论匹配成功与否都输出 continue; }因为while(isJ(ch))是无条件连续吃,遇到)(这种相邻的两个合法界符,会被拼成)(一个串,然后 23 到 46 里找不到匹配,for循环走完没有else分支,什么码都不追加,这个符号就这么静默消失了。测试源文件里有READ(X);,X)里的)后面跟;,);会被拼成);,一样匹配失败。这是这段代码最隐蔽的一个坑——它不报错,只是少输出,你不逐字核对根本发现不了。
正确的写法应该是「先看两个字符能不能组成双字符界符,不能就退化成单字符」。双字符集合是固定的:..、<>、<=、>=、:=,判一次就行。
4. 递归子程序语法分析:G[E] 文法翻译成 E/A/B/C 四个函数
第二个程序换了个完全不同的思路:不再逐字符切分,而是直接把文法产生式写成函数。给定文法:
G[E]: E → eBaA A → a | bAcB B → dEd | aC C → e | dC四个非终结符对应四个函数,每个函数负责「识别一个非终结符能推导出的串」。这就是递归下降分析法最朴素的形态。
4.1 产生式到函数的机械映射
翻译规则只有三条:遇到终结符就比对并前移游标;遇到非终结符就调用对应函数;遇到|就在函数里用if-else分支。E 的产生式只有一个右部,所以函数里只有一条路:
char a[10]; // 输入缓冲区,只能装 9 个字符加结束符 int s = 0; // 全局游标,指向当前待匹配字符 void E() { if (a[s] == 'e') { // 匹配终结符 e s++; // 游标前移 B(); // 递归进入非终结符 B if (a[s] == 'a') { // 匹配终结符 a s++; A(); // 递归进入非终结符 A } } }参数说明:a是全局输入串,s是全局下标。两个都是全局变量,函数没有参数也没有返回值,靠副作用推进状态。这种写法在实验里省事,但完全没法做错误定位——出错时你只知道「失败了」,不知道失败在第几个字符。
B 有两个右部,所以是if-else if:
void B() { if (a[s] == 'd') { // 第一个右部 B → dEd s++; E(); if (a[s] == 'd') s++; // 尾部终结符 d } else if (a[s] == 'a') { // 第二个右部 B → aC s++; C(); } }分支依据是当前字符:看到d走第一条,看到a走第二条。这就是 FIRST 集在代码里的直接体现——FIRST(dEd) = {d},FIRST(aC) = {a},两个集合不相交,所以看一眼就能决定走哪条,不需要回溯。
C 是右递归形式,写成循环和递归都行,这里用的是递归:
void C() { if (a[s] == 'd') { // C → dC s++; C(); // 递归吃掉后续所有的 d } else if (a[s] == 'e') { // C → e s++; } }A 稍微复杂一点,因为bAcB的第一个符号是b,第二个右部是a:
void A() { if (a[s] == 'b') { // A → bAcB s++; A(); // 递归,可能嵌套多层 b...c if (a[s] == 'c') { s++; B(); } } else if (a[s] == 'a') { // A → a s++; } }四个函数加起来不到 40 行,但完整覆盖了四条产生式。这种「一条产生式一个分支」的写法,就是编译原理课上讲的递归子程序法的标准形态。手写解析器时如果文法不大,这套方法比查表驱动好调试得多。
4.2 全局游标 s 与 FIRST 集判断
s这个全局游标是整段代码的命门。它只增不减,所有函数共享,任何一步匹配失败都不会回退。这意味着这套分析器只对 LL(1) 文法有效——每一步都能靠当前字符唯一确定走哪条产生式。给定的 G[E] 恰好满足这个条件:
| 非终结符 | FIRST 集 | 是否有交集 |
|---|---|---|
| E | {e} | 单元素,无冲突 |
| A | {a, b} | 两元素不相交 |
| B | {d, a} | 两元素不相交 |
| C | {e, d} | 两元素不相交 |
如果文法里出现A → aB | aC这种两个右部 FIRST 集相交的情况,这套「看一眼就分支」的写法立刻失效,必须提取左公因子或者改成回溯式递归下降。所以判断一个文法能不能用这种写法,第一步就是算 FIRST 集。
游标只前进不后退的另一个后果是:一旦在深层递归里匹配失败,整个分析过程就废了,没法像回溯法那样换个分支重试。代码里所有的if都没有配else报错,匹配不上就静默返回,让上层继续往下走,最后靠主函数里的一次判断兜底。这也是课程实验的典型做法——能判对错,但说不清错在哪。
4.3 主程序输入与结果判定
主函数的意图是用一个死循环反复读串、反复分析:
int main() { While(1) { // 注意:大写 W printf("请输入算数表达式(以#键结束):"); scanf("%s", a); // 读串,遇空白停止 E(); // 从开始符号推导 if (a[s] == '#') // 判定结束标志 printf("句子结构正确\n"); else printf("分析失败\n"); } return 0; }这段代码有三个必须点出来的问题。
第一,While首字母大写,C++ 里没有这个关键字,编译直接报错。改小写就能过。
第二,scanf("%s", a)遇到空白字符就停,而且不会把#读进数组。所以提示语里说的「以 # 键结束」在实际运行时根本不会发生——用户敲eaeaa#,a里存的是eaeaa,s走到 5 时a[5]是\0而不是#,判断恒为假,永远输出「分析失败」。这个 bug 让整个程序看起来能跑,实际一次都没判对过。修法有两种:要么用fgets读整行再去掉换行符、手动补#;要么干脆把判断改成a[s] == '\0'。
第三,char a[10]只有 10 字节。scanf不做长度检查,输入超过 9 个字符就越界写,栈上的其他变量会被覆盖。测试串短于 9 个字符时看不出来,一旦输入长串就是玄学崩溃。
拿一个最小合法串验证一下推导过程。取eaeaa:
E ⇒ e B a A ⇒ e a C a A ⇒ e a e a A ⇒ e a e a a对应代码执行路径:E()见e,s到 1,调B();B()见a,s到 2,调C();C()见e,s到 3;回到E(),a[3]是a,s到 4,调A();A()见a,s到 5。此时a[5]应该是结束符,判断通过。把判断条件从'#'换成'\0',这个串立刻就能跑出「句子结构正确」。这也是我建议先做的最小验证用例。
5. 避坑与排查:这段实验代码里最容易翻车的五处
代码短不代表坑少。这两份程序有几个问题属于「能编译、能运行、结果悄悄错」,不逐字核对输出根本发现不了。下面五条是我实际编译调试时踩到的,按现象、原因、解决拆开写,你对着改能省不少时间。
5.1 词法侧:2A 不报错、界符被吞、匹配失败静默丢弃
第一条,非法单词2A被拆成两个合法 token,一个错都不报。现象是输入含2A的源文件,输出里出现2的类别 22 和A的类别 21,没有任何错误提示。原因是数字分支的循环条件只认isDigit(ch) || ch == '.',读到A就停,然后continue把控制权交回主循环,此时ch正好是A,下一轮走标识符分支,顺理成章地把它当成一个新单词。整个过程没有任何「单词边界校验」。解决办法是在数字扫描结束后加一次后继检查:如果ch是字母或下划线,说明这是个非法数值常量,直接报(行号, 列号, "非法数值常量")并跳到下一个分隔符。同理,标识符扫描结束后如果紧跟数字以外的非法字符,也该报错。
第二条,连续的界符被贪心拼成一个串,然后静默消失。现象是源文件里的);、)(、]>这类相邻符号在输出里完全找不到。原因是界符分支写的是while(isJ(ch)),只要当前字符还属于界符集合就继续吃,把两个独立符号拼成一个两字符串,再去 23 到 46 的范围里查表,当然查不到,而for循环没有配else分支,匹配失败就什么都不输出。这个坑最阴,因为它不抛异常、不打印错误,只是少几个 token。修法是改成「先窥探两个字符」:读当前位置和下一个位置,先去双字符表里查..<><=>=:=,命中就吃两个字符,没命中就退化成单字符处理。这样):会被正确拆成)和:。
第三条,关键字匹配失败时没有任何兜底输出。现象是某些符号在最终结果里凭空蒸发。原因和上一条同源:三个查表分支(标识符、运算符、界符)都只在strcmp == 0时追加类别码,匹配失败的分支没有else。标识符分支靠if (j == 21)兜住了,运算符分支因为集合封闭(只可能是那四个)基本不会漏,界符分支则是重灾区。修法很简单,查表循环结束后统一加一句「未匹配则报非法符号」,把str和位置打出来。
5.2 语法侧:数组越界、#永远读不到、无回溯误判
第四条,a[s] == '#'这个判断永远为假,程序从来没判对过一次。现象是无论输入什么串,包括手工推导过的合法串eaeaa,输出一律是「分析失败」。原因是scanf("%s", a)遇到空白就终止,且不会把提示语里说的#存进数组,数组里实际只有有效字符加一个\0。于是s走到末尾时读到的是\0,'#'比较恒为假。更麻烦的是char a[10]容量太小,输入超过 9 个字符直接越界写,可能覆盖s本身,导致游标乱跳。修法是换成fgets(a, sizeof(a), stdin),去掉尾部换行,把判断条件改成a[s] == '\0';数组至少放大到 256,给正常输入留余量。
第五条,While(1)大写加上无break,编译不过或者死循环。现象是编译期报While was not declared in this scope;改小写之后能编过,但程序进去就出不来,只能 Ctrl+C。原因是 C++ 关键字区分大小写,而且这个循环体内没有break、没有return、没有退出条件。修法是把While改成while,并在读入失败(fgets返回NULL)或者读到特定退出符时break。另外这个循环体里没有任何清空s的动作,第二轮分析会接着上一轮的游标往下走,第二次输入必然判错——这是很多人在实验课上「第一次对、第二次错」的真实原因,s必须在每次分析前归零。
6. 二次开发:把课程作业改成能进工程的扫描器与预测分析器
真要把这两段代码用到项目里,第一件事不是加功能,而是把「隐式约定」变成「显式结构」。词法侧最该抽的是 Token 结构体和错误列表,语法侧最该抽的是把全局游标换成带位置的游标对象。我给个最小改造骨架:
struct Token { std::string lexeme; // 单词本体,比如 "CONST"、":="、"42" int type; // 类别码,沿用 ktt 的下标,0~47 int line; // 行号,报错时定位用 int col; // 列号 }; // 双字符界符优先匹配:先看两个字符,再看一个 static const std::set<std::string> kDouble = {"..", "<>", "<=", ">=", ":="}; Token nextToken(const std::string& src, size_t& pos, int& line, int& col) { while (pos < src.size() && isBlank(src[pos])) { // 跳过空白并维护行列 if (src[pos] == '\n') { line++; col = 1; } else { col++; } pos++; } if (pos + 1 < src.size()) { std::string two = src.substr(pos, 2); if (kDouble.count(two)) { // 双字符优先 Token t{two, lookup(two), line, col}; pos += 2; col += 2; return t; } } // 单字符、数字、标识符分支照旧,但每个分支都要填 line/col }这段的关键改动只有两处:一是把line、col塞进 Token,后面语法分析报错才能说清「第几行第几列期望什么」;二是把双字符界符的判定提到最前面,彻底绕开while(isJ(ch))那个贪心吞并的坑。lookup就是查ktt那张表,返回下标。
语法侧建议把四个函数从「无参无返回」改成「返回 bool + 接受游标引用」:
bool parseE(const std::string& s, size_t& p, std::string& err);这样失败时能沿着调用栈把「在哪个非终结符、期望什么字符、实际读到什么」一层层带回来,而不是像现在这样所有函数都静默返回、最后靠主函数一次判断兜底。改完之后,E()里那两个if都应该有else分支,写清err = "E: 期望 e,实际读到 " + s[p]。
再往下走一步,如果文法规模变大,手写递归下降就不划算了。常见做法是先算 FIRST 和 FOLLOW 集,生成一张预测分析表,用显式栈代替函数调用栈。好处是错误恢复好做——栈顶和当前输入不匹配时,可以弹栈跳过、继续分析,而不是整棵树作废。这一步不需要一步到位,先把 Token 和错误定位做好,后面的改造都是顺水推舟。
有个验证习惯值得养成:改完扫描器,先跑三个最小用例——2A必须报非法常量,):必须拆成两个 token,:=必须识别成一个赋值符。这三个过了,再拿完整的pl0.txt跑一遍,对比 token 数量和类别码分布。我当年第一次交这个实验,就是因为没跑):这个用例,答辩时被问「你数过输出里少了几个符号吗」,当场翻车。从那以后我每次写完扫描器,都强制先走一遍这几个边界串,再谈功能。希望帮到你。
本文还有配套的精品资源,点击获取