简介:编译原理实验代码与配套文档,覆盖词法分析器设计、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_OP,LL(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”。
| 状态 \ 字符类别 | letter | digit | op | delim | ws | other |
|---|---|---|---|---|---|---|
| 0 初始 | 1 | 2 | ACC | ACC | 0 | -1 |
| 1 标识符中 | 1 | 1 | -1 | -1 | -1 | -1 |
| 2 数字中 | -1 | 2 | -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; }最长匹配有个经典坑:输入a>=b,如果扫描器读到一个>就急着返回,之后跟着的=会被当成独立 token,整个语义就崩了。处理方式有两种:把>和>=设计成两个状态,或者像上面代码一样靠ungetc回退、能走多远走多远。课设里最容易丢分的就是这里——回退逻辑写错,>=被拆成两个 token。验证方法很简单,拿a>=b跑一遍,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 E',E' → + T E' | - T E' | ε。 - 无左公因子。
S → if E then S | if E then S else S都以if开头,一个 lookahead 无法区分。提取公因子后变成S → if E then S S',S' → 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 是本章表达式文法的计算结果,程序跑完后建议逐项核对:
| 非终结符 | FIRST | FOLLOW |
|---|---|---|
| E | ( 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) 不看 a,SLR(1) 用 FOLLOW 集合粗略近似 a,LR(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用上述文法分析的前几步:
| 步骤 | 状态栈 | 输入串 | 动作 |
|---|---|---|---|
| 0 | 0 | id + id * id # | shift 5 |
| 1 | 0 5 | + id * id # | reduce F → id |
| 2 | 0 3 | + id * id # | reduce T → F |
| 3 | 0 2 | + id * id # | reduce E → T |
| 4 | 0 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 +。它不需要括号也不产生歧义,正好适合栈式求值,也适合在语法分析过程中由语义动作直接生成。手工转换的规则是:操作数直接输出;运算符入栈前,先把栈顶优先级不低于它的运算符全部弹出;(直接入栈;)弹出直到(。
优先级表按数字大小排(数字越大优先级越高):
| 运算符 | + - | * / | ( 栈内 | ( 栈外 |
|---|---|---|---|---|
| 优先级 | 1 | 2 | 0 | 3 |
(在栈外优先级最高、栈内最低,这样既能保证“看见(就压栈”,又能保证下一个运算符来临时不会把它弹出。
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 文件:
| 测试输入 | 覆盖模块 | 预期结果 |
|---|---|---|
(3+5)*2 | 词法 + LL(1)/LR(1) + RPN | 后缀式3 5 + 2 *,求值 16 |
a+b*c-d | 优先级与结合性 | 后缀式a b c * + d - |
a-b-c | 左结合 | 后缀式a b - c -,而非a b c - - |
a>=3 | 词法最长匹配 | >=合成一个 token,共 3 个 token |
if(1){} | 关键字与分隔符 | if为关键字,1为数字 |
1+*2 | 语法错误恢复 | 报错后恢复,继续分析到输入结束 |
(a+b | 括号不匹配 | 报出具体行号,不崩溃 |
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,改动的影响范围一目了然。这个习惯能让你在课程设计周少熬一个通宵。
本文还有配套的精品资源,点击获取