递归下降分析法详解:从文法消除左递归到C语言实现

发布时间:2026/10/14 10:42:23
递归下降分析法详解:从文法消除左递归到C语言实现 简介这份编译原理实验报告以递归下降分析法为主题面向正在学习编译原理、需要完成语法分析实验或课程设计的学生完整呈现根据给定文法编写递归下降分析程序、判断输入符号串是否合法的全过程。资源为单个doc文档Word格式压缩包约1.1MB内含实验目的、实验要求、模块划分、全局变量与函数接口设计、程序流程图、C语言完整源码以及运行结果示例覆盖E→TG、T→FS等典型文法规则。程序按E、T、G、S、F等非终结符拆分为独立函数并以#作为输入结束符能对ii*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 * 21313(3 5) * 216162 * (3 4 * (5 - 1))38383 * 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()读入3term()读入退出factor()遇到*报错然后返回0上层继续尝试解析5。从打印结果看得出来分析器没有因为第一个错误就放弃整个输入而是跳过错误token后继续。这个现象在实验报告的“错误恢复演示”一节里写清楚比空谈理论效果好得多。我给学生的验收标准是三个必须做到能解析带嵌套括号的四则运算表达式、能准确报告错误的位置、报告里FIRST/FOLLOW表和程序行为一致。这三点做到了递归下降实验的分数基本不会低。课设做完了这套分析器也能留着后面学语义分析、中间代码生成还能拿它当语法分析的前端用不算白写。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询