简介:这份编译原理实验报告以递归下降分析法为主题,面向正在学习编译原理、需要完成语法分析实验或课程设计的学生,完整呈现根据给定文法编写递归下降分析程序、判断输入符号串是否合法的全过程。资源为单个doc文档(Word格式),压缩包约1.1MB,内含实验目的、实验要求、模块划分、全局变量与函数接口设计、程序流程图、C语言完整源码以及运行结果示例,覆盖E→TG、T→FS等典型文法规则。程序按E、T、G、S、F等非终结符拆分为独立函数,并以#作为输入结束符,能对i+i*i#等符号串进行合法性判断,同时输出推导过程与错误提示,便于理解自顶向下语法分析的工作原理与排错思路。文档结构完整,可直接参考其模块设计方案、流程图画法和代码组织方式,也可作为实验报告撰写模板或备考复习资料。已有1253人浏览/学习,是编译原理递归下降实验的实用参考。
1. 递归下降分析没有传说中那么难:一份能交差的实验报告到底该写什么
如果你正在为编译原理实验报告发愁,这门课的“实验二:递归下降分析法”几乎是所有高校的必修选题。不管是山东科技大学、燕山大学还是普通二本,编译原理实验里总有一份要求你“手动构造递归下降分析程序”的作业。它说难不难,说简单也容易卡住——卡在左递归、卡在回溯、卡在FIRST集合算不明白。这个实验的本质是:用程序模拟自顶向下的语法分析过程,把一个输入串按文法规则逐步展开成一棵语法树。它对后续学LR、LALR甚至编译器后端都没啥影响,但对理解“文法怎么变成程序”这件事帮助非常大。这篇文章直接给你一条从零跑通递归下降分析器的完整路径,并附上能直接抄的代码骨架和实验报告要点,新手能照做,熟手能对照参数和边界。
2. 为什么实验选递归下降而不选LR:从文法到代码的映射逻辑
2.1 递归下降与LL(1)文法的对应关系:函数就是产生式
递归下降分析的核心思想,是把文法中的每个非终结符对应到一个C或Java函数。比如文法的产生式 E → E '+' T,就对应一个名为E()的C函数。函数内部根据当前输入符号决定走哪个分支——如果当前符号是数字或左括号,就调用 T(),然后再期望看到 '+';如果当前符号不对,就抛语法错误。
这种写法的前提是文法属于LL(1)——从左向右扫描、产生最左推导、每次只用向前看一个符号。LL(1)意味着对于每个非终结符A的若干产生式 A → α1 | α2 | ... | αn,任意两个产生式的FIRST集合不相交。如果不满足这个条件,分析器就没法在“该走哪条路”上做决定,要么回溯,要么直接报错。
递归下降的代码形状通常是一组互相调用的函数,函数调用栈本身就充当了语法树的隐式骨架。与Yacc/Bison这类LR工具不同,递归下降不需要建表,不需要冲突消解,调试时直接看函数调用栈就能定位到出错的产生式。这对一门课设来说非常友好,也正因如此,几乎所有编译原理实验的入门题目都选它。
2.2 FIRST集合和FOLLOW集合:手算还是程序算
递归下降分析器的每个分支判断都要用到FIRST集合:某个非终结符能展开出的所有终结符的集合。FOLLOW集合则用于处理空产生式——如果某个非终结符可以推导出空串,那么在输入符号不属于它的FIRST集时,要检查是否属于FOLLOW集,决定是否按空串归约。
做实验时,多数学校要求手算FIRST和FOLLOW集合并写进报告。我的建议是:先手算一遍题目给出的文法,用来理解定义;再在实现时额外写一个小工具函数做自动计算,用来对照你的手算结果。自动计算的算法不复杂:反复扫描所有产生式,对每个产生式从左到右把终结符加入FIRST集,遇到非终结符则合并它的FIRST集,直到某轮集合不再变化为止。FOLLOW集类似,只是起点是开始符号的EOF标记,并且要处理产生式末尾的非终结符。
手算和程序算的结果如果不一致,九成是手算错了,而不是程序错了。递归下降实验的报告里,把FIRST、FOLLOW两张表贴进去,老师会觉得你把原理吃透了。
2.3 左递归是第一个拦路虎:改写规则与示例
左递归在递归下降分析里是致命的。如果一个非终结符的产生式以它自身开头,比如 E → E '+' T,那么对应函数E()的第一行就会调用自身,进入无限循环,直到栈溢出。很多同学第一次跑实验,程序直接崩溃,就是这个原因。
消除左递归的标准做法是把左递归改成右递归。最常见的算术表达式文法 E → E '+' T | T 是左递归的,要拆成 E → T E' 和 E' → '+' T E' | ε。注意ε(空串)的出现,它意味着E'可以匹配空串,此时要借助FOLLOW集合判断何时结束。
改写后的文法对应的递归下降程序,在E'函数里要先判断当前输入符号是否属于 '+' 的FIRST集,如果是就读取运算符,再调用T和E';否则直接返回,让上层函数处理后续输入。这个“何时结束”的判断用术语说就是预测分析表的驱动逻辑,但在递归下降里它体现为普通的if判断。
3. 把四则运算表达式用递归下降跑通:C语言实现与参数说明
3.1 文法设计与token定义:先定边界再写代码
实验题目最常见的样本是带括号的四则运算表达式,比如3 + 5 * ( 2 - 8 )。我一般先按优先级把文法分三层:表达式→项→因子。这样可以保证2 + 3 * 4解析出来的结果是14而不是20,也就是乘法先算。
第一版文法设计如下:
expr → term { ('+' | '-') term } term → factor { ('*' | '/') factor } factor → NUMBER | '(' expr ')'其中花括号表示“零个或多个”,这是对左递归文法做等价改写后的写法。这种文法已经是LL(1)了,每个分支的FIRST集合都互不重叠。token定义只需要三类:数字、运算符(加减乘除)、左右括号。如果输入包含变量名,就需要换成标识符token;但课设不需要,先不引入。
对应实现里的每个非终结符都写成函数。下面这段C代码就是可直接运行的核心骨架,它包含了一个简单的词法读取函数(每次取一个token)和三个语法分析函数。
#include <stdio.h> #include <stdlib.h> #include <ctype.h> char lookahead; // 当前读取的token int pos = 0; // 输入串的读取位置 char input[100]; // 存放输入表达式 void next_token() { // 跳过空白字符,然后读下一个非空字符作为token while (input[pos] == ' ' || input[pos] == '\t') pos++; lookahead = input[pos++]; } void error(const char* msg) { printf("语法错误: %s,位置在 %d\n", msg, pos); exit(1); } double expr(); double term(); double factor(); double expr() { double result = term(); // 循环处理零个或多个 +/- 项 while (lookahead == '+' || lookahead == '-') { char op = lookahead; next_token(); double rhs = term(); if (op == '+') result += rhs; else result -= rhs; } return result; } double term() { double result = factor(); // 循环处理零个或多个 * / 因子 while (lookahead == '*' || lookahead == '/') { char op = lookahead; next_token(); double rhs = factor(); if (op == '*') result *= rhs; else result /= rhs; } return result; } double factor() { double result = 0; if (isdigit(lookahead)) { // 读数字字符串并转换成数值 char numbuf[32] = {0}; int i = 0; while (isdigit(lookahead)) { numbuf[i++] = lookahead; next_token(); } numbuf[i] = '\0'; result = atof(numbuf); } else if (lookahead == '(') { next_token(); result = expr(); // 括号内完整解析一个表达式 if (lookahead != ')') { error("缺少右括号"); } next_token(); } else { error("数字或左括号"); } return result; } int main() { printf("请输入一个算术表达式: "); gets(input); pos = 0; next_token(); double val = expr(); if (lookahead != '\0') { error("表达式结束后出现多余字符"); } printf("计算结果 = %g\n", val); return 0; }这段代码的逻辑说明如下:next_token()每次跳过空白并把下一个字符存入全局变量lookahead,也就是说这个分析器是单字符向前看的。expr()先调用term()拿回一个数值,然后循环看当前token是不是加号或减号,是就再调用term()继续读取右边的操作数。term()和factor()的结构完全同理,只是优先级不同。
三个参数需要注意。第一,lookahead是全局变量,这保证所有函数共享同一个向前看符号,不会出现“下一个token”不一致的问题。第二,pos在输入结束后指向字符串末尾的\0,所以主函数在expr()返回后要检查lookahead是否等于\0,这样才能判断整个输入是否消耗完毕。第三,数字转换用了atof,它只处理整数形态的字符串,如果表达式里有小数,需要在数字读取循环里补上小数点的处理逻辑,否则3.14会被解析成3和.14两个token,触发语法错误。
3.2 核心函数的实现顺序:先factor,再term,最后expr
写代码的顺序和调用顺序相反。先写factor(),因为它不依赖其他非终结符,只处理数字和括号两种token。然后写term(),它调用factor()并处理乘除。最后写expr(),它调用term()并处理加减。这样自底向上实现,每层都能独立测试——写完factor()就能测单个数字和括号表达式,写完term()就能测2*3这类乘除式子。
我在实验里会用一组固定用例边写边测,按优先级从低到高排列:
1 1 + 2 1 + 2 * 3 (1 + 2) * 3 2 * (3 + 4 * (5 - 1))每通过一个用例,才往下一个优先级推进。这种增量测试法能让你快速定位到底是哪层函数的逻辑有问题,而不是全部写完再从头调试。
3.3 实验报告里最重要的三张表:FIRST表、FOLLOW表和测试用例表
写实验报告时,老师最看重三样东西:FIRST集合表、FOLLOW集合表、以及带输入过程和结果对照的测试用例表。FIRST表对每个非终结符列出一行,写出它能推导出的首个终结符集合。FOLLOW表同样按非终结符列出集合,其中开始符号expr的FOLLOW集里必须包含\0(输入结束标记)和),因为在factor → '(' expr ')'这个产生式里,expr后面紧跟右括号。
测试用例表要这样设计:每行一条输入表达式,列出“输入串 → 预期结果 → 程序实际输出 → 语法树对应路径”。例如:
| 输入串 | 预期结果 | 实际输出 |
|---|---|---|
3 + 5 * 2 | 13 | 13 |
(3 + 5) * 2 | 16 | 16 |
2 * (3 + 4 * (5 - 1)) | 38 | 38 |
3 + * 5 | 语法错误 | 语法错误:数字或左括号 |
表格里最后一行展示错误检测能力,这在实验报告里能额外加分。要注意,程序必须能明确报告出错的位置和期望的token,所以error()函数带位置参数很重要。
4. 一份能直接改的完整实现:从主循环到错误恢复
4.1 主循环与词法读取的扩展:支持负数和变量名
上一章的代码能跑通基础四则运算,但实验报告通常要求处理更复杂的输入,比如负数、小数甚至变量名。主循环本身不需要改动,真正要扩展的是next_token()和factor()。负数在文法层面有两种处理:把负号当作单目运算符,在factor()里额外判断;或者在词法层直接把负号和数字合并成一个token。我建议在factor()里加一个单目减判断,因为改文法不如改代码直观——在factor()开头判断当前token是否是-,如果是就读取下一个token,递归调用factor()再取反。
小数支持更简单,只需在数字读取循环里加一个标志位,遇到小数点'.'时允许它出现一次并继续读取数字。变量名的支持则要引入标识符token,在词法层维护一张符号表,把xyz这样的标识符映射为对应的数值。课设题如果允许变量,通常在输入里会给类似a = 3这样的赋值语句,这时候需要增加赋值语句的分析函数,逻辑和expr()并列。
4.2 完整代码骨架:包含错误恢复的表达式分析器
下面这段代码在上一章的骨架上去掉了无限报错的问题,并且支持小数和单目负号。它用panic mode做错误恢复——发现错误时跳过当前token,回到一个安全的同步位置继续分析。
#include <stdio.h> #include <stdlib.h> #include <ctype.h> #include <string.h> char lookahead; int pos = 0; char input[256]; void next_token() { while (input[pos] == ' ' || input[pos] == '\t') pos++; lookahead = input[pos++]; } void error(const char* msg) { printf("第 %d 个字符附近出错: %s\n", pos, msg); } double expr(); double term(); double factor(); double expr() { double result = term(); while (lookahead == '+' || lookahead == '-') { char op = lookahead; next_token(); double rhs = term(); if (op == '+') result += rhs; else result -= rhs; } return result; } double term() { double result = factor(); while (lookahead == '*' || lookahead == '/') { char op = lookahead; next_token(); double rhs = factor(); if (op == '*') result *= rhs; else if (rhs == 0) { error("除数为零"); result = 0; } else result /= rhs; } return result; } double factor() { double result = 0; if (lookahead == '-') { next_token(); result = -factor(); } else if (isdigit(lookahead)) { char numbuf[64] = {0}; int i = 0; int has_dot = 0; while (isdigit(lookahead) || (lookahead == '.' && !has_dot)) { if (lookahead == '.') has_dot = 1; numbuf[i++] = lookahead; next_token(); } numbuf[i] = '\0'; result = atof(numbuf); } else if (lookahead == '(') { next_token(); result = expr(); if (lookahead != ')') { error("缺少右括号"); // 尝试跳过若干字符直到右括号出现,做同步恢复 while (lookahead != ')' && lookahead != '\0') next_token(); } if (lookahead == ')') next_token(); } else if (isalpha(lookahead)) { // 标识符当作变量处理,这里简化为读入后忽略值 while (isalpha(lookahead)) next_token(); result = 0; error("未定义的变量"); } else if (lookahead == '\0') { error("表达式意外结束"); } else { error("非法的token"); next_token(); } return result; } int main() { printf("请输入表达式: "); fgets(input, sizeof(input), stdin); pos = 0; next_token(); double val = expr(); if (lookahead != '\0' && lookahead != '\n') { error("多余字符"); while (lookahead != '\0' && lookahead != '\n') next_token(); } printf("结果: %g\n", val); return 0; }这个版本的主要改动有三处。第一,factor()里对右括号缺失做了同步恢复——发现缺括号后,不断读取token直到找到右括号或输入结束,这样主程序还能继续分析后面的内容。第二,变量名被读入后给了一个警告并返回0,这符合课设场景——不支持的token给出明确提示而非直接崩溃。第三,除零检查放在term()里,运行时错误和语法错误分开了。
实际做实验时,建议把错误恢复收敛一点。课设报告里写“本程序采用恐慌模式进行错误恢复,遇到错误时跳过当前token、同步到右括号或输入结束符”,这句话足以让老师知道你不是随便写的。
4.3 实验报告的“实验步骤”怎么写:从分析文法到测试用例
实验步骤在报告中占一整节,需要用流程化语言描述:构造文法的过程、消除左递归的理由、FIRST/FOLLOW的计算过程、程序结构图、每个函数的功能说明。不要贴大段代码,而是贴关键函数的伪代码或流程图。老师反感的是“贴代码 + 一句话说明”,加分的是“代码 + 输入输出截图 + 和预期对比”。
程序结构图可以用文字描述而不是图形:入口main →next_token()初始化lookahead →expr()→term()→factor(),再加上错误处理函数和数字转换函数的边界。这个流程用一段自然语言写清楚就是一张结构图。
5. 避坑:递归下降实验最常见的5个翻车现场
5.1 卡死在循环里:左递归没消除就运行
现象:程序一执行就栈溢出崩溃,或者表现为死循环一直不返回。原因:文法直接写成expr → expr '+' term,对应函数expr()第一行就调用自己,永远不消费token。解决:必须把左递归文法等价改写为右递归或循环结构。最简单的自查方法:看每个函数的第一行,如果它调用了同名函数,立刻改文法。我用“输入串会被消耗吗”作为自查标准——每次递归调用前必须读取一个新token,否则递归就无意义。
5.2 多一个右括号就崩:回溯逻辑没写
现象:输入(1 + 2))时,程序在结尾报错,但报错信息指向的是“表达式结束后出现多余字符”,而且前面已经完成了expr()的计算。原因:递归下降分析器默认不接受回溯,它要求每个分支走到底都必须是正确的,如果输入合法前缀后面跟着多余字符,只能在主循环里发现。解决:主函数里必须检查整个输入是否被完全消耗。next_token()读取到\0后,expr()返回,此时若lookahead不是字符串结束符,就要报错并提示多余字符的位置。
这个坑在课设里相当经典,因为报错延迟到了最后,容易让学生以为是词法问题而不是语法问题。调试时在关键函数入口打印lookahead和当前函数名,一眼就能看到调用链是否合理。
5.3 FIRST集和FOLLOW集算错:报告里全是红叉
现象:手写的报告中FIRST和FOLLOW表有错,但程序运行正确——因为程序依据的是改写后的文法,而报告写的是原始文法。原因:消除左递归后文法的非终结符集合变了,但报告里还按原始文法计算。解决:报告里的每张表都要标注它对应的是哪个版本的文法。先写出消除左递归后的文法,再算FIRST/FOLLOW。另外,ε空串的处理容易漏——如果某个非终结符能推导出空串,它的FOLLOW集在判断时优先级更高,报告里要单列一行说明。
5.4 除数为零的崩溃:运行时错误混进了语法分析
现象:输入8 / 0,程序在term()里浮点除以零,输出inf而不是报错。原因:除零是运行时语义错误,不在语法分析阶段,但如果不显式检查,结果会一直传到最后,让报告里的“错误检测”部分显得很弱。解决:在term()里调用factor()得到右操作数后,先判断是否为0,再做除法。课设没必要实现完整的语义检查,但显式判断一个除零能显著提升报告质量。
5.5 递归深度爆炸:括号嵌套过深
现象:输入((((((1))))))时,程序栈溢出,但理论上文法支持任意层括号嵌套。原因:函数调用栈的深度受系统限制,递归下降分析的调用深度和括号层数成正比。解决:实验报告里要说明这个限制——你的分析器能处理的嵌套深度取决于系统栈大小,建议测试时给出最大深度的实验数据。可以在测试用例表里加一行“括号嵌套30层,程序正常;100层,栈溢出”,这展示了你对实现边界的了解。
6. 进阶验证:给分析器加一个可视化调用栈,顺便验证明白错误恢复
递归下降分析器最大的优点是“看得到”分析过程。我的习惯是在每个函数入口和出口打印一层缩进信息,这样输入一个表达式就能在终端里看到完整的语法树展开过程。修改方法很简单,在expr()、term()、factor()函数开头加一个打印语句,输出当前函数名和lookahead,在函数返回前再打印一次返回值。
void indent(int level) { for (int i = 0; i < level; i++) printf(" "); } int depth = 0; double expr() { indent(depth++); printf("expr() 开始, lookahead='%c'\n", lookahead); double result = term(); printf("expr() 结束, 当前值=%g\n", result); depth--; return result; }这还能帮你判断错误恢复是否正确生效。输入错误表达式3 + * 5,调用栈打印会显示:expr()读入3,term()读入+退出,factor()遇到*报错,然后返回0,上层继续尝试解析5。从打印结果看得出来,分析器没有因为第一个错误就放弃整个输入,而是跳过错误token后继续。这个现象在实验报告的“错误恢复演示”一节里写清楚,比空谈理论效果好得多。
我给学生的验收标准是三个必须做到:能解析带嵌套括号的四则运算表达式、能准确报告错误的位置、报告里FIRST/FOLLOW表和程序行为一致。这三点做到了,递归下降实验的分数基本不会低。课设做完了,这套分析器也能留着,后面学语义分析、中间代码生成还能拿它当语法分析的前端用,不算白写。希望帮到你。
本文还有配套的精品资源,点击获取