拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写PL/0编译器:从词法分析到解释器的编译原理课程实验指南

手写PL/0编译器:从词法分析到解释器的编译原理课程实验指南

简介:山东大学SDU编译原理课程PL/0编译器实验的完整实现包,面向高校计算机专业学生及编译原理学习者,适合作为课程实验参考、复习与二次开发基础。资源包含完整的C/C++源代码(.cpp/.h/.c)、CMake构建脚本、测试用例(.in/.out)及编译产物(.obj/.exe),并附有实验报告与多张运行截图,系统覆盖词法分析、语法分析(BNF/EBNF文法)、语义分析、符号表管理等编译器前端核心环节,部分源码还涉及中间表示与目标代码生成的实现思路。压缩包共94个文件,总大小约513KB,虽然包含out、txt、png、cmake等多种类型,但目录结构清晰,便于按模块查阅和重新构建。目前已有104人学习下载,适合需要完成类似PL/0实验或想深入掌握编译器前端原理的学生,能提供从文法设计到代码落地的完整参考。

1. PL0-Compiler是什么:一门能被“写出来”而不是“读出来”的编译原理课

很多学校的编译原理课,理论部分能把递归下降、LR分析表、语法制导翻译推到黑板上,但一进实验课,学生面对的是另一道题:写一个能编译、能执行的小型编译器。PL0-Compiler(PL/0编译器)就是这类课程实验最常见的载体,山东大学SDU编译原理课程实验用的也正是这套方案:用一门极小语言PL/0,从零手写词法分析、语法分析、语义分析和目标代码解释执行,最后产出一个可运行的编译器加一份实验报告。它解决的问题很具体:让“编译原理”这四个字从一个黑匣子,变成一条你能单步调试、敢说“每一行都是我写的”的流水线。适合正在上编译原理课、需要交付可运行程序和报告的学生,也适合想补手写编译器基本功的从业者。

2. 先定文法再落代码:PL0的语法骨架与SDU课程实验的工程划分

2.1 PL/0语言到底有多“小”:从产生式看这门语言边界在哪

PL/0是Niklaus Wirth在《Algorithms + Data Structures = Programs》里定义的教学语言,整套语法只有十几条产生式。你别嫌它小,它保留了高级语言最核心的东西:常量定义、变量声明、嵌套过程声明、表达式、赋值、条件分支、循环、过程调用和输入输出。先看核心文法,这是整个实验的“宪法”:

program ::= block "." . block ::= [const decl] [var decl] {procedure decl} statement . const decl ::= "const" ident "=" number {"," ident "=" number} ";" . var decl ::= "var" ident {"," ident} ";" . procedure decl ::= "procedure" ident ";" block ";" . statement ::= [ident ":=" expression | "call" ident | "begin" statement {";" statement} "end" | "if" condition "then" statement | "while" condition "do" statement | "read" "(" ident ")" | "write" "(" expression ")" | ε] . condition ::= "odd" expression | expression relop expression . expression ::= ["+"|"-"] term {("+"|"-") term} . term ::= factor {("*"|"/") factor} . factor ::= ident | number | "(" expression ")" . relop ::= "=" | "#" | "<" | "<=" | ">" | ">=" .

注意两点。第一,PL/0没有负数字面量,负号是作为表达式的单目前缀出现的,这意味着词法分析器里不需要处理“负数”这个token,一切负数在语法层被拆成“0减正数”或“取负操作”。第二,不等于号是#,不是!=。这两点看似不起眼,却是后面最常见的翻车点。

这门语言没有数组、没有自定义类型、没有返回值概念(过程调用靠全局变量通信),所以实验的工作量被控制在一个学期能完成的范围:词法分析约150行,递归下降语法分析约300行,代码生成和解释器约300行,加起来一千行左右,正好是一份课程实验报告该有的体量。

2.2 课程实验的常见交付物:四个模块与一张指令集对照表

SDU这类编译原理课程实验的验收口径一般是:能编译PL/0源码,输出中间代码或直接解释执行,且报告里要有设计说明、流程图、测试用例。常见做法是把编译器拆成四个模块,顺序严格执行,前一个模块的输出是后一个模块的输入。

  1. 词法分析:把源码字符串切成token序列,token类型包括保留字、标识符、数字和特殊符号。
  2. 语法分析:用递归下降法按产生式逐层下降,同时做语法错误检查与恢复。
  3. 语义分析与代码生成:PL/0实验里这两步不分离,语法分析过程中直接查符号表、输出类Pascal虚拟机指令。
  4. 解释执行:用C语言模拟一个栈式虚拟机,执行生成的指令序列。

这四个模块对应到代码上,就是五个文件或五个函数块。最关键的接口是中间代码的格式,PL/0虚拟机指令是经典的三字段结构:

指令作用参数说明
LIT 0,a将常数a压栈a为立即数
LOD l,a将层差l、偏移a的变量值压栈l为层差,a为栈内偏移
STO l,a将栈顶值弹入层差l、偏移a的变量赋值操作
CAL l,a调用层差l处的入口a过程进入过程前要建活动记录
INT 0,a栈顶指针加a为局部变量腾出空间
JMP 0,a无条件跳转到a用于while回跳
JPC 0,a栈顶为假时跳转到a用于while和if
OPR 0,op执行运算或子程序返回op为运算子码

其中OPR的子码是整个虚拟机的“运算大管家”:

OPR子码操作OPR子码操作
0过程返回9奇数判断ODD
1取负10等于
2加11不等于
3减12小于
4乘13小于等于
5除14大于
6~8保留未用15大于等于
16read读入17write输出

这张表就是整个实验的“指令集手册”,代码生成阶段的任务就是把语法分析过程中遇到的每个操作翻译成上面某一条指令。

2.3 工程结构怎么摆:源文件划分、全局变量分配和报告配合

工程结构我建议直接按“单文件主程序+清晰分节”来,不建议一上来就拆多文件。PL/0编译器精华在逻辑不在架构,单文件里用注释把词法、语法、解释器三个区域隔开,调试时能省去头文件同步的麻烦。但全局变量的规划要提前想清楚,这是很多人翻车的地方。

我需要保留的全局状态有这么几个:当前输入的字符ch、当前解析出的token类型sym、符号表tab、符号表当前指针tx、中间代码区code、代码区写入指针cx、当前过程层级level、当前过程中的变量偏移dx。符号表的设计是整个编译器的核心数据结构,我建议结构体如下:

#define SYM_MAX 100 #define ALNX_MAX 10 #define CODE_MAX 500 typedef enum { nul, ident, number, plus, minus, times, slash, oddsym, eql, neq, lss, leq, gtr, geq, lparen, rparen, comma, semicolon, period, becomes, beginsym, endsym, ifsym, thensym, whilesym, dosym, callsym, constsym, varsym, procsym, readsym, writesym } SYMBOL; typedef struct { char name[ALNX_MAX]; // 名字 SYMBOL kind; // const / var / procedure int value; // const的值,或var/procedure的地址 int level; // 层号,过程嵌套深度 int addr; // 相对当前活动记录基址的偏移 int size; // 仅过程使用,记录过程体局部空间大小 } TABLE;

词法分析器往sym里填token类型,语法分析器查符号表时用tx遍历,解释器执行时靠level和addr定位变量。这一套设计是PL/0实验的通行做法,照着这个结构写,后面无论做扩展还是写报告都顺。

报告部分,重点放三张图:整体流程图、递归下降调用关系图、虚拟机执行流程图。别放代码大段截图,验收老师看的是“你有没有想清楚”,不是“你复制了多少行”。

3. 词法分析与语法分析:把PL0源码变成Token流再变成调用树

3.1 词法分析怎么实现:保留字表、标识符与无符号数识别

词法分析器的输入是PL/0源码文件,输出是一个接一个的token。PL/0的token就四类:保留字、标识符、数字、特殊符号。实现思路很简单:先读一个字符,跳过空白,然后按首字符分类——字母开头走标识符/保留字分支,数字开头走数字分支,其余按符号映射。

先看主逻辑代码,这段我一般写成getSym函数:

void getSym(void) { int i, j; // 跳过空白 while (ch == ' ' || ch == '\n' || ch == '\t') nextch(); // 字母开头: 标识符或保留字 if (isalpha(ch)) { j = 0; while (isalnum(ch) && j < ALNX_MAX - 1) { name[j++] = ch; nextch(); } name[j] = '\0'; // 查保留字表 i = 0; while (i < NORW && strcmp(keyword[i], name) != 0) i++; if (i < NORW) sym = wsym[i]; // 是保留字 else { sym = ident; // 是普通标识符 // 同时可在此处查符号表,决定是const/var/procedure } return; } // 数字开头: 无符号整数 if (isdigit(ch)) { num = 0; while (isdigit(ch)) { num = num * 10 + (ch - '0'); nextch(); } sym = number; return; } // 特殊符号 switch (ch) { case '+': sym = plus; nextch(); break; case '-': sym = minus; nextch(); break; case '*': sym = times; nextch(); break; case '/': sym = slash; nextch(); break; case '(': sym = lparen; nextch(); break; case ')': sym = rparen; nextch(); break; case ',': sym = comma; nextch(); break; case ';': sym = semicolon; nextch(); break; case '.': sym = period; nextch(); break; case '#': sym = neq; nextch(); break; case '=': sym = eql; nextch(); break; case '<': nextch(); if (ch == '=') { sym = leq; nextch(); } else sym = lss; break; case '>': nextch(); if (ch == '=') { sym = geq; nextch(); } else sym = gtr; break; case ':': nextch(); if (ch == '=') { sym = becomes; nextch(); } else sym = nul; // 单独冒号非法 break; default: sym = nul; error(0); // 非法字符 nextch(); break; } }

这段代码的逻辑重点有三个。第一,nextch()负责读下一个字符并维护当前行号,出错时报错信息能带行列号。第二,标识符被读进全局name数组,语法分析阶段查符号表时直接比对name即可。第三,保留字表keyword里放的是begin call const do end if odd procedure read then var while write这13个词,对应枚举wsym数组,顺序必须一一对应,否则const会被当成普通标识符。

参数说明:ALNX_MAX是标识符最大长度,我设10够用,超过会截断,截断逻辑在循环条件里——j < ALNX_MAX - 1。NORW是保留字个数13。数字累加时没做溢出保护,PL/0实验不需要,但你可以顺手加一个if (num > 32767) error(5)。

3.2 递归下降分析器的骨架:factor、term、expression、statement

语法分析采用递归下降法,原因是PL/0的每个产生式都能对应一个同名函数,代码结构跟文法一一映射,出错位置也能直接定位到函数栈。整个分析器的入口是block,block里按文法顺序调statement,statement里按语句类型分发到具体处理逻辑。

下面这段是factor和statement的核心写法,也是我认为最需要理解的递归下降代码:

void factor(void) { if (sym == ident) { // 标识符: 变量或常量 int pos = position(name); if (pos < 0) { error(11); // 未声明标识符 getSym(); return; } if (table[pos].kind == constsym) { gen(LIT, 0, table[pos].value); getSym(); } else if (table[pos].kind == varsym) { gen(LOD, level - table[pos].level, table[pos].addr); getSym(); } else { error(13); // 过程名出现在表达式中 getSym(); } } else if (sym == number) { gen(LIT, 0, num); getSym(); } else if (sym == lparen) { getSym(); expression(); if (sym == rparen) getSym(); else error(9); // 缺右括号 } else { error(12); // factor位置出现非法符号 getSym(); } }

statement分发逻辑看起来更直观:

void statement(void) { int pos, savedCx1, savedCx2; if (sym == ident) { // 赋值语句 pos = position(name); if (pos < 0) { error(11); getSym(); return; } if (table[pos].kind != varsym) { error(12); getSym(); return; } getSym(); if (sym != becomes) { error(10); // 缺赋值号 := return; } getSym(); expression(); gen(STO, level - table[pos].level, table[pos].addr); } else if (sym == beginsym) { // begin statement {; statement} end getSym(); do { statement(); } while (sym == semicolon); if (sym != endsym) error(8); // 缺 end getSym(); } else if (sym == ifsym) { getSym(); condition(); if (sym == thensym) getSym(); else error(4); savedCx1 = cx; // 保存JPC位置 gen(JPC, 0, 0); // 条件为假跳过then语句 statement(); code[savedCx1].a = cx; // 回填跳转地址 } else if (sym == whilesym) { getSym(); savedCx1 = cx; // 保存条件判断位置 condition(); savedCx2 = cx; gen(JPC, 0, 0); // 条件为假跳出循环 statement(); gen(JMP, 0, savedCx1); // 跳回条件判断 code[savedCx2].a = cx; // 回填跳出地址 } else if (sym == callsym) { getSym(); if (sym != ident) { error(11); return; } pos = position(name); if (pos < 0) { error(11); return; } if (table[pos].kind != procsym) { error(13); return; } gen(CAL, level - table[pos].level, table[pos].addr); getSym(); } else if (sym == readsym) { getSym(); if (sym != lparen) error(9); getSym(); if (sym == ident) { pos = position(name); if (pos >= 0 && table[pos].kind == varsym) { gen(OPR, 0, 16); // read gen(STO, level - table[pos].level, table[pos].addr); } else error(11); } else error(4); getSym(); if (sym == rparen) getSym(); else error(9); } else if (sym == writesym) { getSym(); if (sym != lparen) error(9); getSym(); expression(); gen(OPR, 0, 17); // write if (sym == rparen) getSym(); else error(9); } }

这段代码看着长,但每个分支都只做三件事:检查token类型、调对应的子程序、生成中间代码指令。savedCx1、savedCx2的用法就是“标号回填”:先留一个0占位,等分支体编译完再把真实地址写回去。这是整个编译器里最经典的技巧,务必吃透。

3.3 语法错误的现场恢复:报错后如何继续编译而不是直接退出

递归下降分析器天然有错误定位能力,但大多数初学者写的版本会在第一处错误就崩掉——直接exit(1)或无限递归。课程实验验收时要的是“能报多个错误并继续分析”,所以错误恢复逻辑得单独写。

我的做法是维护一个全局错误计数errCount,error(int n)只负责打印错误号和行号,不退出。然后在statement的入口做一个“安全网”判断:

if (sym == semicolon || sym == endsym || sym == period) { return; // 不该出现在statement开头,跳过去 }

更通用的恢复策略是“跳过到同步符号”。我一般把begin、end、;、period作为同步符号:如果某个子程序发现token跟预期完全对不上,就循环调getSym()直到遇到同步符号再返回。比如expression里循环解析+/-项时,如果term返回后sym不是预期的plus或minus,就当作表达式结束。

这个恢复逻辑不完美,但够用。它的价值在于:一次运行能输出所有错误清单,而不是让学生对着终端干瞪眼。

4. 目标代码生成与解释器:让PL0程序真正“跑起来”

4.1 中间代码的形态:PL0虚拟机指令与三字段表

很多人以为实验做到语法分析就结束了,其实“能编译”和“能运行”中间还隔着一层:目标代码生成。PL/0实验的对象码不是x86汇编,而是一套为教学设计的虚拟机指令。每条指令三个字段f(功能)、l(层差)、a(地址或数值)。

代码区我建议直接用结构体数组:

#define CODE_MAX 500 typedef struct { int f; // 指令功能: LIT/LOD/STO/CAL/INT/JMP/JPC/OPR int l; // 层差 level差 int a; // 地址、数值或OPR子码 } INSTRUCTION; INSTRUCTION code[CODE_MAX]; int cx = 0; // 代码区写入指针 void gen(int f, int l, int a) { if (cx >= CODE_MAX) { error(20); // 程序太长,代码区溢出 return; } code[cx].f = f; code[cx].l = l; code[cx].a = a; cx++; }

gen函数是整个代码生成的中转站,所有指令都必须经过它写入。层差l的计算方式是level - table[pos].level,含义是“当前过程层级减去变量定义过程层级”。层差为0表示当前过程变量,为1表示外层过程变量。这套层差机制是PL/0栈式分配的核心,理解它就能解释为什么嵌套过程能引用外层变量。

4.2 语义动作怎么嵌入语法分析:emit生成指令与标号回填

语义动作不是单独一遍扫描,而是“语法制导翻译”——在递归下降过程的每个归约点调用gen。我在第三章的代码里已经穿插了gen调用,这里把关键模式抽出来讲透。

第一类模式是表达式求值。expression处理加减,term处理乘除,每个运算符归约时生成对应OPR指令:

void term(void) { factor(); while (sym == times || sym == slash) { getSym(); factor(); if (lastOp == times) gen(OPR, 0, 4); // 乘法 else gen(OPR, 0, 5); // 除法 } }

注意顺序:先算出两个操作数压栈,再生成运算指令。因为虚拟机运算是对栈顶两个值做操作,结果再压回栈顶。这个顺序一旦写反,比如先gen再factor,运行结果必然错乱。

第二类模式是标号回填。以while循环为例,完整流程是:

  1. 记下条件指令开始地址savedCx1 = cx。
  2. 编译条件,条件结束生成JPC 0,0,占位地址记为savedCx2。
  3. 编译循环体。
  4. 生成JMP 0,savedCx1,跳回条件。
  5. 回填:code[savedCx2].a = cx。

这一套在递归下降代码里就是三行赋值,但它是循环和分支能否正确跳转的生命线。如果你做扩展加else分支,同样是先占位再回填,多一个标号而已。

4.3 解释执行:活动记录、display表和OPR指令的分发

解释器是C语言写的栈式虚拟机,数据区是一个大数组stack,栈顶由指针top维护。过程调用时要建立“活动记录”,包含返回地址、动态链、局部变量区。PL/0实验里我建议用display表而不是静态链,因为静态链在过程嵌套深时容易算错层差。

核心执行逻辑是对每条指令做分发:

void interpret(void) { int pc = 0; // 程序计数器 int top = 0; // 栈顶指针 int b = 1; // 基址指针,指向当前活动记录起点 int p, a, op; printf("Begin executing PL/0 program...\n"); while (pc < cx) { p = code[pc].f; a = code[pc].a; switch (p) { case LIT: stack[top++] = a; break; case LOD: // 层差为0直接用当前基址偏移,否则沿display找 stack[top++] = stack[base(b, code[pc].l) + a]; break; case STO: stack[base(b, code[pc].l) + a] = stack[top - 1]; top--; break; case INT: top += a; // a为负时是分配空间,为正时释放 break; case JMP: pc = a; continue; case JPC: if (stack[top - 1] == 0) { pc = a; top--; continue; } top--; break; case CAL: // 调用过程: 建立display条目,保存返回地址 p = base(b, code[pc].l); // 找到被调过程的基址 stack[top] = b; // 保存动态链 b = top; display[code[pc].l] = b; // 更新display stack[top + 1] = pc + 1; // 保存返回地址 pc = a; top = top + 2; continue; case OPR: op = a; if (op == 0) { // 过程返回 top = b - 1; pc = stack[top + 1]; b = stack[top]; continue; } // 双目运算统一从栈顶取两个数 if (op >= 2 && op <= 5 || op >= 10 && op <= 15) { int x = stack[top - 2]; int y = stack[top - 1]; top -= 2; switch (op) { case 2: stack[top++] = x + y; break; case 3: stack[top++] = x - y; break; case 4: stack[top++] = x * y; break; case 5: stack[top++] = x / y; break; case 10: stack[top++] = (x == y); break; case 11: stack[top++] = (x != y); break; case 12: stack[top++] = (x < y); break; case 13: stack[top++] = (x <= y); break; case 14: stack[top++] = (x > y); break; case 15: stack[top++] = (x >= y); break; } } else if (op == 1) { // 取负 stack[top - 1] = -stack[top - 1]; } else if (op == 9) { // 奇数判断 stack[top - 1] = stack[top - 1] % 2; } else if (op == 16) { // read scanf("%d", &stack[top++]); } else if (op == 17) { // write printf("%d\n", stack[top - 1]); top--; } break; } pc++; } }

base函数的作用是做层差寻址:

int base(int basePtr, int levelDiff) { while (levelDiff > 0) { basePtr = display[levelDiff + 0]; // 简化写法 levelDiff--; } return basePtr; }

这段代码的关键是display表。每次进入过程时,b指向新活动记录的起点,display[level]更新为当前的b。当内层过程要访问外层变量时,层差l告诉解释器沿display跳几层。常见坑是:层差算错一位,访问到的是外层过程的活动记录头部而非变量区,表现是运行结果随机、有时直接栈溢出。

5. 避坑清单:PL0-Compiler交实验前最容易翻车的五个地方

5.1 代码区溢出还是死循环:gen函数不做上限检查的翻车

现象:编译稍微复杂一点的PL/0程序(比如嵌套三层的递归求阶乘),程序直接卡死,或者终端反复刷“PROGRAM TOO LONG”。

原因:code数组只有500条,递归下降对每个语句都会生成多条指令,嵌套过程多时很容易逼近上限。更隐蔽的是,gen函数没有上限检查,cx一路涨超越数组边界,写坏内存后执行结果变成玄学。

解决:在gen开头加if (cx >= CODE_MAX) { error(20); return; }。别小看这行代码,它能把“内存写坏”变成“报错后优雅退出”。如果编译大程序不是实验要求,CODE_MAX维持500就好;要做扩展实验,直接改到2000。

5.2 负数写成“负数字面量”:-1不是LIT而是OPR取负

现象:源码里写x := -1;,编译通过但运行结果错,或者你为了实现负数硬在词法分析里加了一个负号token,导致x := - 1这种写法报语法错。

原因:PL/0文法里factor只能接受number,负号只能出现在expression开头的["+"|"-"]部分。标准实现里-1被翻译成LIT 0,1加OPR 0,1(取负)。如果你在词法层把“-1”合成一个数字token,文法就全乱了。

解决:不要在词法层做任何负号合并。在expression函数入口处检查sym == minus,是则先压一个0,再接term,然后生成OPR 0,1取负。这样1-2和1- -2都能正确解析。

5.3 嵌套过程的display更新错位:局部变量引用乱套

现象:一个两层嵌套的程序,内层过程调用外层过程,运行结果完全不对;或者过程结束返回后,外层函数的局部变量被改掉。

原因:CAL指令执行时做了三件事:保存动态链、更新display、保存返回地址。顺序不能乱。更常见的问题是base函数实现错误,比如直接拿basePtr做加减而不是沿display链逐层上跳。

解决:把base和CAL的执行逻辑在纸上画一遍栈布局再写代码。画一次栈帧布局,比调试三小时有效得多。

5.4 while循环的JMP/JPC回填顺序:跳转地址错一个就死循环或跳过循环体

现象:while循环要么一次都不执行,要么无限循环,要么循环体顺序错乱。

原因:JPC的占位地址和JMP的回跳地址搞反了。正确顺序是——条件判断前记startCx,条件结束后JPC占位,循环体结束后JMP回startCx,最后回填JPC地址为循环体后的地址。

解决:写代码时先梳三条标号:循环体入口、条件入口、循环体出口。PL/0没有for循环,但你可以在纸上推演i := 1; while i < 5 do i := i + 1的指令序列,跑一遍虚拟机单步执行,标号对不对一目了然。

5.5 read和write的OPR顺序只看结果:栈顶操作错位

现象:read(x)执行一次,结果x的值是随机的,或者write(x)多打了一个数。

原因:read的语义是“读入一个数压栈,再把栈顶存到变量”,必须先生成OPR 0,16再生成STO。反过来,write是“先算表达式压栈,再OPR 0,17弹出打印”。顺序反了就是存取错位。

解决:在statement的read分支里,先getSym()解析括号内的标识符,再gen(OPR,0,16),最后gen(STO,...)。而write分支是先expression()再gen(OPR,0,17)。这两个分支我各写错过一次,全是运行结果不对才回头查到的。

6. 给PL0-Compiler加三个扩展,顺手用批测脚本给自己验收

扩展是实验报告里最能拿分的地方,但扩展别贪多,选两三个跟文法贴合紧密的即可。我建议优先做这三个,改动量小、测试容易、报告里能写清楚设计思路。

第一个是if-else。在文法里加elsesym保留字,语法分析时把statement的if分支改为:JPC占位后编译then语句,遇到else再生成一个JMP跳过else部分,回填两个标号。改动不到十行,但能说明你理解了标号回填。

第二个是repeat-until循环。文法就一句话:repeat statement {; statement} until condition。语义是“先执行循环体,再判断条件,条件为假继续循环”。这跟while的结构正好相反,只需要一个标号回填,比if-else还简单。

第三个是注释。词法分析里处理{和}之间的内容直接跳过,注意不要跨行吞掉行号计数。这个扩展能展示你对词法分析的边界把握。

扩展写完后,用shell批测:

#!/bin/bash # 批测脚本:逐个运行测试用例,对比实际输出和预期输出 for case in tests/*.pl0; do name=$(basename "$case" .pl0) printf "Running %s: " "$name" ./pl0 "$case" < "tests/$name.in" > "tests/$name.out" 2>&1 if diff -q "tests/$name.out" "tests/$name.expected" > /dev/null; then echo "PASS" else echo "FAIL" diff "tests/$name.out" "tests/$name.expected" fi done

批测脚本的价值在回归:每改一次扩展,跑一遍全部用例,能立刻发现“新功能把老功能改坏了”。这是我在实验最后一天最后悔没早做的事。

这个实验做完,最大的收获不是“我会写递归下降了”,而是“编译器不是黑匣子,是一行行能跑通能调试的代码”。我自己当年做的时候,卡在display表上耗了三个晚上,后来把栈布局画在草稿纸上才想通。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表