编译原理课程设计实战:词法分析、LL(1)/LR(1)与逆波兰式实现

发布时间:2026/9/13 18:04:45
编译原理课程设计实战:词法分析、LL(1)/LR(1)与逆波兰式实现 简介编译原理实验代码与配套文档覆盖词法分析器设计、LL(1)分析法、逆波兰式生成与计算、LR(1)分析法四大核心模块具体包括源程序字符扫描与token输出、基于FIRST/FOLLOW集的预测分析表、利用栈计算后缀表达式、以及自底向上的LR状态机构造等面向需要完成编译原理课程实验、课设或深入理解编译过程的学生。压缩包共35个文件、789KB包含11个txt文本、9个docx文档、5个md说明、4个cpp源码以及少量xls和png辅助文件目录按experiment_1至experiment_4组织每个实验均含参考资料、readme和demo。通过源码与文档学习者可直观看到每个语法分析算法的完整实现流程并借助实验报告理清设计思路遇到运行问题时也可按README或代码注释排错。目前已有149人浏览/学习。项目仅供学习参考请勿用于商业用途。1. 从词法到语法分析一个实验怎么串起四个模块第一次拿到题目很容易当成四个孤立的小作业词法分析器、LL(1) 分析法、逆波兰式、LR(1) 分析法。做过一遍就会发现它们是同一条流水线上的四道工序——词法分析器把源码切成 token 流LL(1) 和 LR(1) 用两套策略验证文法逆波兰式在语法分析过程中顺手生成。写 LR(1) 项目集时你会撞见 FIRST/FOLLOW 的影子写逆波兰式求值器时你会把栈再次用出花来。这篇文章面向正在做编译原理课程设计的学生也面向想搭建可运行语法分析骨架的工程师按“词法→预测分析→移进归约→后缀式生成”的顺序把每个模块的最小实现、参数含义和常见坑位讲透。2. 词法分析器设计用状态转移表把正则变成可运行代码2.1 词法分析器要解决的核心问题词法分析器scanner处于编译前端的入口输入是源文件输出是一串形如token 类型, 词素, 行列号的 token 序列。教材里从“正则表达式 → NFA → DFA → 最小化”这条线讲课程设计不一定每个阶段都实现但有一个结论必须落到代码里每个 token 类型背后是一条正则正则转成 DFA 之后真正执行匹配的只是一张“状态 × 字符类别 → 下一状态”的二维表。我一般先把 token 定义成独立头文件因为后面 LL(1)、LR(1) 和逆波兰式模块都要引用它typedef enum { TOK_IDENT, TOK_KEYWORD, TOK_NUMBER, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_SEMI, TOK_COMMA, TOK_EOF, TOK_ERROR } TokenType; typedef struct { TokenType type; // token 类型 char lexeme[128]; // 词素匹配到的原始字符串 int line; // 起始行号 int col; // 起始列号 } Token;行列号是给语法分析和报错用的。LL(1) 分析器报“第 3 行语法错误”时行号就是在词法阶段记录的LR(1) 做错误恢复时要跳过到同步点也得靠位置信息决定从哪里继续。另一个关键设计每个运算符单独占一个枚举值不要笼统塞进 TOK_OP。终结符的数量直接对应后续分析表里“列”的数量如果、-、*、/全是 TOK_OPLL(1) 分析表就没法区分它们了。下面的实现用 C 写选 Java 或 C 的同学要点完全相同区别只在字符串处理和数组越界行为。2.2 状态转移表与扫描主循环假设实验语言只需要五类 token标识符letter(letter|digit)*、整数digit digit*、运算符 - * /、分隔符( ) ; ,、空白。把输入字符分成六类letter、digit、op、delim、ws空白、other。表 2-1 是 DFA 的状态转移表格子里ACC表示“当前 token 已经完整立即返回”-1表示“当前字符无法继续延长 token”。状态 \ 字符类别letterdigitopdelimwsother0 初始12ACCACC0-11 标识符中11-1-1-1-12 数字中-12-1-1-1-1这里有个容易写错的细节状态 1标识符中遇到空白返回 -1表示 token 到此为止空白字符要被退回输入流等下一次调用get_token时再跳过。如果直接在状态 1 里“看见空白就结束”并顺手消费掉空白逻辑上也能跑但和“最长匹配 回退”的模型就不一致了后面扩展、这类多字符运算符时容易出 bug。#define ERR -1 #define ACC -2 static int trans[3][6] { /* 状态 0: 初始 */ { 1, 2, ACC, ACC, 0, ERR }, /* 状态 1: 标识符中 */ { 1, 1, ERR, ERR, ERR, ERR }, /* 状态 2: 数字中 */ { ERR, 2, ERR, ERR, ERR, ERR } }; Token get_token(FILE *src) { Token tok; int c skip_ws(src); // 跳过前导空白 if (c EOF) { tok.type TOK_EOF; return tok; } int state 0, cls class_of(c), len 0; while (1) { int next trans[state][cls]; if (next ERR) { // 当前字符不能延长 token if (len 0) { // 初始状态就非法非法字符 tok.type TOK_ERROR; tok.lexeme[0] (char)c; tok.lexeme[1] \0; return tok; } ungetc(c, src); // 回退多读的字符最长匹配收尾 break; } if (next ACC) { // 单字符运算符 / 分隔符 tok.lexeme[0] (char)c; tok.lexeme[1] \0; tok.type single_char_token(c); return tok; } tok.lexeme[len] (char)c; // 正常状态迁移 tok.lexeme[len] \0; state next; c fgetc(src); if (c EOF) break; cls class_of(c); } if (state 1) tok.type is_keyword(tok.lexeme) ? TOK_KEYWORD : TOK_IDENT; else tok.type TOK_NUMBER; return tok; }逻辑说明ungetc是“最长匹配”的直接实现。程序每读一个字符就试着往前走一步走不动就把最后的字符退回输入流这正是 DFA 识别 token 的标准做法。ERR分支里len 0表示在初始状态就遇到无法归类的字符此时不能回退否则下一次调用会死循环必须消费掉这个非法字符并返回 TOK_ERROR由调用方决定是报错终止还是跳过继续。ACC分支只会在状态 0 触发因为表里另外两个状态没有 ACC 格子single_char_token做→ TOK_PLUS、(→ TOK_LPAREN 这类映射。注意 C 里数组下标必须落在[0, 6)内class_of对未知字符要返回 other 的类别索引否则越界。2.3 关键字识别与最长匹配的边界关键字处理我采用“先按标识符匹配再查表”的两段式词法规则只有letter(letter|digit)*一条关键字是标识符集合的真子集匹配结束后单独查关键字表。这个顺序必须在报告里写明因为设计上等价于“关键字优先于标识符”。int is_keyword(const char *s) { static const char *kw[] {if, else, while, return, int, void, NULL}; for (int i 0; kw[i]; i) if (strcmp(s, kw[i]) 0) return 1; return 0; }最长匹配有个经典坑输入ab如果扫描器读到一个就急着返回之后跟着的会被当成独立 token整个语义就崩了。处理方式有两种把和设计成两个状态或者像上面代码一样靠ungetc回退、能走多远走多远。课设里最容易丢分的就是这里——回退逻辑写错被拆成两个 token。验证方法很简单拿ab跑一遍token 序列应该是标识符 标识符三个而不是四个。数值方面建议在报告里明确声明支持范围只支持无符号十进制整数不支持012八进制和0x1F十六进制。声明边界比偷偷支持一半的语法更稳妥老师追问时你也能讲清楚 DFA 里为什么只有 digit 一类字符。注意错误恢复在词法阶段的策略是“报错但不终止”。遇到 TOK_ERROR合法的做法是记下行列号和非法字符然后跳过该字符继续扫描这样一次能报出多个词法错误如果每个错误都直接退出测试用例里含有两个非法字符时就只能看到第一个。3. LL(1) 分析法FIRST/FOLLOW 集合与预测分析表3.1 先消除左递归再提取左公因子LL(1) 的含义是从左到右扫描、产生最左推导、向前看 1 个 token。它靠“当前栈顶符号 一个 lookahead token”唯一确定用哪条产生式展开。要保证这一点文法必须先满足两个条件无左递归。E → E T | T这类产生式会让预测分析器在展开 E 时无限循环。必须改写成右递归E → T EE → T E | - T E | ε。无左公因子。S → if E then S | if E then S else S都以if开头一个 lookahead 无法区分。提取公因子后变成S → if E then S SS → else S | ε。课程设计最常给的表达式文法加减乘除、括号改写后如下终结符id、num的数量要和第 2 章词法分析器的 token 类型一一对应E → T E E → T E | - T E | ε T → F T T → * F T | / F T | ε F → ( E ) | id | num这里有个容易和 LR 混淆的点LL(1) 用的是改写后的文法LR 分析器可以直接用带左递归的原文法。同一门语言可以让两套分析器各用各的文法报告里必须写清楚当前分析器使用的是哪一套。3.2 FIRST、FOLLOW 集合的迭代计算用布尔矩阵存集合first[A][a] 1表示终结符 a 属于 FIRST(A)ε 单独用eps[A]标记。算法对所有产生式反复扫描直到一轮下来没有任何集合发生变化即不动点迭代int add_first(Grammar *g, int A, int t) { if (g-first[A][t]) return 0; g-first[A][t] 1; return 1; } int compute_first(Grammar *g) { int changed; do { changed 0; for (int i 0; i g-pcnt; i) { Prod *p g-prods[i]; // A → body[0..blen-1] int j 0; for (; j p-blen; j) { int s p-body[j]; if (s g-term_cnt) { // 终结符直接并入 FIRST(A) changed | add_first(g, p-lhs, s); break; } for (int t 0; t g-term_cnt; t) if (g-first[s][t]) changed | add_first(g, p-lhs, t); if (!g-eps[s]) break; // 非终结符不可空后面不再看 } if (j p-blen) { // 右部所有符号都可空 changed | (g-eps[p-lhs] 0); g-eps[p-lhs] 1; } } } while (changed); return 1; }逻辑说明add_first只在集合真正变大时返回 1这个返回值驱动外层 do-while 判断是否收敛。内层 for 循环的顺序体现的是 FIRST 的定义——只有X1 X2 … Xi-1都能推出 ε才有资格看Xi对 FIRST(A) 的贡献eps[s]为 0 时立刻 break因为后面的符号被“ε 挡板”挡住了。term_cnt是终结符数量body里的符号统一用整数编号非终结符和终结符合用一个编号空间s term_cnt就能区分两者。FOLLOW 集合依赖 FIRST 的最终结果必须放在后面单独跑一轮不动点迭代。规则是对每个产生式A → α B β把 FIRST(β) 中除 ε 外的所有符号并入 FOLLOW(B)如果 β 能推出 ε或 β 为空则把 FOLLOW(A) 并入 FOLLOW(B)。起始符号预先放入#表示输入结束。表 3-1 是本章表达式文法的计算结果程序跑完后建议逐项核对非终结符FIRSTFOLLOWE( id num# )E - ε# )T( id num - # )T* / ε - # )F( id num - * / # )最容易算错的是 FOLLOW(E)它只出现在E → T E和E → T E的产生式右部末尾后面要么是)要么是#所以 FOLLOW 集合里没有*和/。如果程序输出多了*多半是把“β 可空时并入 FOLLOW(A)”这个条件写得太宽把别的集合整个复制过来了。3.3 预测分析表与驱动栈的 C 实现预测分析表 M 是二维数组行是非终结符列是终结符含#。填表规则就两条对产生式A → α把 FIRST(α) 中每个终结符 a 对应的 M[A][a] 填上这条产生式如果 α 能推出 ε则把 FOLLOW(A) 中每个终结符 b 对应的 M[A][b] 填上A → ε。某个格子被填了两次说明文法不是 LL(1)程序要能自动检测并报冲突而不是静默覆盖。void ll1_parse(Table *M, Grammar *g, TokenStream *ts) { Token *cur next_token(ts); Stack st; init_stack(st); push(st, g-start); push(st, END_SYM); // 栈底放 #保证栈永不空 while (!is_empty(st)) { int X pop(st); if (X END_SYM) { if (cur-type TOK_EOF) break; error(第%d行: 输入未结束但栈已空, cur-line); break; } if (is_terminal(X)) { if (X cur-type) cur next_token(ts); // 终结符匹配成功读下一个 else error(第%d行: 期望 %s, 实际 %s, cur-line, term_name(X), token_name(cur)); } else { Prod *p M[X][cur-type]; if (p-error) { // 表项为错误标记 panic_recover(st, cur); continue; } // 逆序压栈保证栈顶是产生式右部最左符号 for (int i p-blen - 1; i 0; i--) push(st, p-body[i]); } } }参数说明M 表的每个格子初始化为一个error标记比用 NULL 指针安全因为 NULL 无法区分“无产生式”和“产生式编号 0”。panic_recover是我写的错误恢复函数典型实现是 panic mode不断弹出栈顶符号直到栈顶符号能与当前 token 匹配或栈顶符号属于同步集合通常取各非终结符的 FOLLOW 集合并上;、}等语句边界符。cur-line直接来自词法模块的 Token 结构这正是第 2 章记录行列号的原因。提示表驱动的分析器代码量小、容易调试但报告里最好同时给出递归下降版本的对照说明。老师常问“为什么表驱动不用递归调用”答案在于分析栈显式保存了推导上下文递归下降的隐式调用栈在这里被变成了显式数据。4. LR(1) 分析法从项目集闭包到移进-归约决策4.1 从 LR(0) 到 LR(1)lookahead 解决了什么LR 分析是自底向上的读入的 token 先压栈直到栈顶符号串能归约成某个非终结符。表达式文法保持左递归的“自然形态”即可E → E T | E - T | T T → T * F | T / F | F F → ( E ) | id | numLR(1) 的分析状态是“带 lookahead 的项目”的集合。项目形如[A → α·β, a]圆点左边是已读入栈的部分右边是期望继续读取的部分a 是归约时的前瞻符号。LR(0) 不看 aSLR(1) 用 FOLLOW 集合粗略近似 aLR(1) 则精确到每个项目各自的前瞻集合因此能处理更多文法代价是状态数量明显膨胀——同一个文法SLR(1) 可能只有十几个状态LR(1) 会翻倍。题目明确写了 LR(1)就按完整的 lookahead 传播实现不要偷懒降级成 SLR。4.2 闭包计算与 goto 表生成核心算法是两个函数closure 和 goto。closure 的规则是项目集中若有[A → α·Bβ, a]则对每条产生式B → γ把所有b ∈ FIRST(βa)对应的项目[B → ·γ, b]加入闭包。注意 FIRST 的参数是“β 后接 a”的符号串当 β 可空时b 就是 a 本身——这就是 LR(1) 与 SLR 的关键差异。void closure(ItemSet *I, Grammar *g) { int changed; do { changed 0; for (int i 0; i I-cnt; i) { Item *it I-items[i]; // [A → α·Bβ, a] if (it-dot it-prod-blen) continue; // 圆点已在末尾 int B it-prod-body[it-dot]; if (is_terminal(B)) continue; for (int p 0; p g-pcnt; p) { Prod *pp g-prods[p]; if (pp-lhs ! B) continue; for (int b 0; b g-term_cnt; b) { if (in_first_seq(g, it-prod-body it-dot 1, it-lookahead, b)) changed | add_item(I, pp, 0, b); } } } } while (changed); }逻辑说明in_first_seq计算的是 FIRST(β a)也就是把第 3 章的 FIRST 集合算法复用在“符号串 一个终结符”上。所以 LR(1) 模块直接依赖第 3 章的集合计算代码这也是我建议把 FIRST/FOLLOW 提取成公共工具的原因两个分析器共用一份实现而不是各写各的。add_item在项目集中查重重复时返回 0保证闭包收敛。goto(I, X) 则把 I 中所有形如[A → α·Xβ, a]的项目圆点右移一位变成[A → αX·β, a]对结果集合再求一次闭包。课设规模下闭包用朴素的双重循环加线性查重就够了不要过早引入哈希。一个几十条产生式的文法项目集规范族撑死几百个项目性能瓶颈根本不在查重。把时间留给后面调试分析表。4.3 action/goto 双表驱动分析过程项目集规范族构造完后对每个状态 i 填两张表。ACTION 表的规则若[A → α·aβ, b]在 Ii 中且 a 是终结符则 ACTION[i][a] shift(j)j 是 goto(Ii, a) 的目标状态号若[A → α·, a]在 Ii 中则 ACTION[i][a] reduce(A → α)若项目是[S → S·, #]则 ACTION[i][#] acc。GOTO 表负责归约后按非终结符跳转。表 4-1 是id id * id用上述文法分析的前几步步骤状态栈输入串动作00id id * id #shift 510 5 id * id #reduce F → id20 3 id * id #reduce T → F30 2 id * id #reduce E → T40 1 id * id #shift 6............驱动循环用状态栈即可符号栈可以根据产生式隐式恢复因为归约时blen和lhs都是已知信息void lr_parse(LRTable *tp, Grammar *g, TokenStream *ts) { int stk[512], top 0; stk[0] 0; // 初始状态 Token *tok next_token(ts); for (;;) { Action *a tp-action[stk[top]][tok-type]; if (a-kind SHIFT) { stk[top] a-state; // 状态入栈 tok next_token(ts); } else if (a-kind REDUCE) { Prod *p g-prods[a-prod_no]; // A → α top - p-blen; // 弹出 |α| 个状态 stk[top] tp-goto_[stk[top]][p-lhs]; // 归约后跳转 semantic_reduce(a-prod_no); // 语义动作生成逆波兰式 } else if (a-kind ACCEPT) { break; } else { lr_panic_recover(stk, top, tok); } } }参数说明C 里goto是保留字表字段命名成goto_是常见规避方式。归约时先弹出blen个状态再用“弹出后暴露出来的栈顶状态”查 GOTO 表——注意查表用的是归约前的栈顶状态这个顺序错了表格全乱。semantic_reduce留到第 5 章讲它是逆向生成逆波兰式的挂载点。冲突处理是报告里必须单独写的一节。上面的表达式文法在 LR(1) 下无冲突但如果你加了一元负号F → - F会在-上出现 shift/reduce 冲突。常用解法是声明优先级结合性*高于-右结合则冲突时优先 shift或者改写文法引入新的非终结符。两种方案都要在报告里给出对照表说明选哪一种、为什么。注意action 表里凡是没填的表项都置为 ERROR 动作。调试时发现分析器在这个动作上“卡死”几乎都是 closure 里 lookahead 算错——把FIRST(βa)写成了FIRST(β)漏掉了 β 可空时把 a 并入的那一步。5. 逆波兰式语法指导翻译与栈式求值5.1 中缀转后缀规则与优先级表逆波兰式RPN就是后缀表达式运算符跟在两个操作数之后a b写作a b 。它不需要括号也不产生歧义正好适合栈式求值也适合在语法分析过程中由语义动作直接生成。手工转换的规则是操作数直接输出运算符入栈前先把栈顶优先级不低于它的运算符全部弹出(直接入栈)弹出直到(。优先级表按数字大小排数字越大优先级越高运算符 -* /( 栈内( 栈外优先级1203(在栈外优先级最高、栈内最低这样既能保证“看见(就压栈”又能保证下一个运算符来临时不会把它弹出。void infix_to_postfix(const char *src, char *post) { char opstk[128]; int otop 0, p 0; for (int i 0; src[i] ! \0; i) { if (isdigit(src[i]) || isalpha(src[i])) { post[p] src[i]; // 操作数直接输出 } else if (src[i] () { opstk[otop] (; } else if (src[i] )) { while (otop 0 opstk[otop-1] ! () post[p] opstk[--otop]; otop--; // 丢弃 ( } else { while (otop 0 in_prior(opstk[otop-1]) out_prior(src[i])) post[p] opstk[--otop]; opstk[otop] src[i]; } } while (otop 0) post[p] opstk[--otop]; post[p] \0; }逻辑说明是左结合的保障。a - b - c读到第二个-时栈顶第一个-优先级相同按规则弹出得到a b - c -求值时等价于(a-b)-c。如果误写成会得到a b c - -变成a - (b-c)语义就错了。这行代码是整个转换器的灵魂报告里建议用括号标注“同优先级先出栈左结合”。5.2 在 LL(1) 或 LR(1) 分析过程中同步生成单独写一个中缀转后缀的函数是最朴素的方案但实验的得分点在“在语法分析过程中生成”。以第 4 章的 LR 分析器为例shift 到id或num时把词素输出到后缀缓冲归约E → E T时输出归约F → ( E )时不输出任何东西因为括号只体现在语法结构上不产生运算。static char post[512], *pp post; void semantic_shift(Token *tok) { sprintf(pp, %s , tok-lexeme); // 操作数直接输出 pp strlen(pp); } void semantic_reduce(int prod_no) { switch (prod_no) { case 1: case 2: /* E → E T | E - T */ *pp (prod_no 1) ? : -; *pp ; break; case 3: case 4: /* T → T * F | T / F */ *pp (prod_no 3) ? * : /; *pp ; break; default: break; /* F → ( E ) 归约时不输出 */ } }semantic_shift挂在第 4 章lr_parse的 SHIFT 分支里semantic_reduce挂在 REDUCE 分支里。输出顺序恰好构成后缀式的原因很直接LR 是自底向上的归约发生在两个操作数都已入栈之后运算符必然晚于两个操作数输出正好满足“左操作数、右操作数、运算符”的顺序。这里有个有意思的结论用右递归的 LL(1) 文法第 3 章的E → T E也能生成正确的后缀式。输入a-b-c时第一次归约输出a b -第二次输出a b - c -栈式求值得到(a-b)-c仍然是左结合。后缀式本身没有结合性问题求值顺序由栈操作天然决定这就是为什么逆波兰式适合当中间表示。用 LL(1) 的同学注意把语义动作放在“读到右操作数之后、产生式返回之前”别放在匹配运算符那一刻。5.3 栈式求值器与边界条件求值器是四个模块里最短的但边界条件最容易翻车int eval_postfix(const char *post, VarTab *vars) { int stk[128], top 0; for (int i 0; post[i] ! \0; i) { char c post[i]; if (c ) continue; if (isdigit(c)) { stk[top] c - 0; // 单字符数字多位数见下文 } else if (isalpha(c)) { int v; if (!var_lookup(vars, c, v)) { error(未声明变量 %c, c); return -1; } stk[top] v; } else if (strchr(-*/, c)) { if (top 2) { error(后缀式非法: 操作数不足); return -1; } int b stk[--top], a stk[--top]; switch (c) { case : stk[top] a b; break; case -: stk[top] a - b; break; case *: stk[top] a * b; break; case /: if (b 0) { error(除零错误); return -1; } stk[top] a / b; break; } } else { error(非法字符 %c, c); return -1; } } if (top ! 1) { error(后缀式非法: 栈内还剩 %d 个操作数, top); return -1; } return stk[0]; }两个边界值得写进报告一是取b、a之前必须检查栈深top 2否则对非法后缀式会越界读内存二是除零要在除法分支显式检查INT_MIN / -1 这类溢出问题课设范围可以不管但除零是测试用例必考的。多位数支持有两种方案词法分析器输出后缀式时用空格分隔操作数求值器用strtol按段读取或者在操作数后附长度标记。课设输入通常是单字符变量加一位数字按上面的代码能跑但报告里要说明扩展到多位数的完整方案这是一个很自然的加分点。6. 把四个模块串起来联调方式、测试用例与答辩加分细节6.1 模块划分与命令行入口四个模块的依赖关系是单向的token 定义 ← 词法分析器 ← 集合计算工具 ← 两个语法分析器 ← 逆波兰式求值器。常见的项目划分如下src/ token.h # Token 枚举与结构体全项目共用 lexer.c # 词法分析器对外提供 next_token() symset.c # FIRST/FOLLOW 集合计算公共工具 ll1.c # LL(1) 分析表构造与分析驱动 lr1.c # LR(1) 项目集、action/goto 表与分析驱动 postfix.c # 后缀式生成与栈式求值 main.c # 入口-mode ll1|lr1 选择分析器-d 打开 trace doc/ 实验报告.md命令行参数我建议做成./compiler -mode ll1 input.c和./compiler -mode lr1 input.c加-d开关逐行打印当前动作、状态栈和已生成的后缀式。这个-d是调试阶段最重要的武器没有它LR(1) 状态下你根本不知道分析器在哪个移进/归约上走偏了。6.2 一张覆盖三档场景的测试表测试用例按“正常功能、边界条件、错误恢复”三档设计并固化成 golden 文件测试输入覆盖模块预期结果(35)*2词法 LL(1)/LR(1) RPN后缀式3 5 2 *求值 16ab*c-d优先级与结合性后缀式a b c * d -a-b-c左结合后缀式a b - c -而非a b c - -a3词法最长匹配合成一个 token共 3 个 tokenif(1){}关键字与分隔符if为关键字1为数字1*2语法错误恢复报错后恢复继续分析到输入结束(ab括号不匹配报出具体行号不崩溃5/0求值边界显式报除零错误6.3 答辩与期末复习时的三个加分细节第一报告里的状态转移表和 DFA 图必须一对一。答辩时老师会指着某个状态问“这个状态为什么是终态”答不上来很减分。第二把 FIRST/FOLLOW 表和 LR(1) 项目集状态数比如 15 个状态打印成文本附在报告附录和手算结果对照。第三把错误恢复当独立小节写。期末季答辩时间紧凑老师最爱把课本上的选择题改造成随口问题比如“LL(1) 的第一个 L 代表什么”“SLR 和 LR(1) 的区别在哪”这些都能在实验数据里找到对应提前标好页码比临时翻书强得多。调试时有个对拍技巧同一份输入分别用 LL(1) 和 LR(1) 跑两个分析器输出的 token 序列必须完全一致后缀式和求值结果也必须一致。不一致时先怀疑文法改写第 3 章是否改变了运算优先级再怀疑语义动作挂载位置。把 token 序列、后缀式、求值结果各存一份 golden 文件每次改代码后跑一次 diff改动的影响范围一目了然。这个习惯能让你在课程设计周少熬一个通宵。本文还有配套的精品资源点击获取