ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

编译原理实验:手写词法与LL(1)语法分析器联合实现

编译原理实验:手写词法与LL(1)语法分析器联合实现

简介:本资源是华东理工大学《编译原理》课程2022年核心实验的完整交付包,面向计算机专业本科生及编译技术初学者,聚焦词法与语法分析两大关键能力训练。压缩包共5个文件(274KB),含2份详实的Word实验报告(分别对应词法与语法分析)、2个可运行的C++源码文件(PL1Compiler.cpp与yufa2.cpp)以及1个PL/0测试程序Test1.pl;其中报告涵盖实验目标、步骤详解、断点调试记录与变量变化分析,源码支持PL/0及扩展后的PL/1语言词法识别,并输出带序号、字符串、类型与值的完整单词流。已有722人学习下载,资源结构紧凑、理论与实践高度耦合,特别适合理解词法分析器状态机设计、标识符/数字/符号的分类处理逻辑,以及从PL/0到PL/1的词法规则演进思路,是掌握编译前端基础实现的优质参考范例。

1. 为什么词法分析器写出来能跑通,但一接语法分析就报错无数行?

这是某高校编译原理实验课上最常听到的叹息——学生花三天调通了正则匹配、状态转换、关键字识别,自以为拿下词法分析;结果把输出喂给语法分析模块,SyntaxError: unexpected token 'ID' at line 3这类报错像雪片一样飞来。问题往往不在语法树构建本身,而卡在词法单元(token)的边界定义与语法分析器的预期不一致:比如==被拆成两个=,/* comment */没被完整跳过却吐出非法字符,或数字字面量123.45f中的f被当成独立标识符。本报告不是复刻教材伪代码,而是基于华东理工大学2022年编译原理实验真实交付要求,用 C++ 实现一个可调试、可验证、可对接后续语义分析的词法+语法联合分析系统。它覆盖实验全部评分点:支持 C 风格子集(含注释、浮点、十六进制)、生成带行号的 token 流、输出 LL(1) 分析过程表、可视化推导步骤。适合正在赶实验 deadline 的同学直接复现,也适合想搞懂「为什么课本上的 FIRST/FOLLOW 集算出来和实际分析对不上」的进阶学习者。


2. 从正则到状态机:手写词法分析器的三步落地法

词法分析不是把字符串扔给 regex 库就完事。实验要求必须手写状态转换逻辑,核心在于:每个状态只关心当前字符能带它去哪,且所有转移必须覆盖全字符集(包括非法输入)。我们采用「单字符驱动 + 显式状态跳转」方式,避免递归下降带来的栈溢出风险,也比 flex 生成器更利于调试。

2.1 状态定义与字符分类:先画清楚这张表再动笔

不能靠直觉写if (c == 'a') state = ID_START;—— 必须预先将 ASCII 0–127 映射为 7 类逻辑字符,再让状态机按类跳转。这是避免漏处理空格、制表符、不可见控制符的关键。

字符类型ASCII 范围示例字符在状态机中的作用
LETTERa–z, A–Zx,IF启动标识符/关键字识别
DIGIT0–91,0xFF启动整数/浮点数识别
WS' ','\t','\n','\r'(空格)触发跳过,不生成 token
DELIM{,},(,),;,,{,;直接生成对应 token
OP+,-,*,/,=,<,>,!,&,|==,!=,+=需要回溯判断是否为双字符运算符
QUOTE',"'a',"hello"启动字面量识别(本实验暂不实现字符串,但状态需预留)
OTHER其余所有(含\0)@,#,^触发错误状态,记录位置

提示:这个分类表不是可选优化项,是实验报告中「设计思路」章节的硬性得分点。很多同学跳过此步,直接写switch(c),导致0x0C(换页符)等未处理字符引发段错误。

2.2 核心状态机代码:用 switch-case 实现确定性有限自动机(DFA)

以下为scan_token()函数主干,它每次调用返回一个Token对象(含type,lexeme,line,col)。关键点:所有case必须有break,default必须处理OTHER,且每个状态退出前必须ungetch()回退一个字符(因为读到非法字符才知当前 token 结束)。

// Token.h 定义 enum TokenType { TOK_ID, TOK_NUM, TOK_FLOAT, TOK_EQ, TOK_NE, TOK_LE, TOK_GE, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_SEMI, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_ASSIGN, TOK_EOF, TOK_ERROR }; struct Token { TokenType type; std::string lexeme; int line, col; }; // Scanner.cpp 主逻辑 Token Scanner::scan_token() { skip_whitespace(); // 先跳过空白,定位到第一个有效字符 char c = getch(); switch (c) { case 'a'...'z': case 'A'...'Z': return scan_identifier(); case '0'...'9': return scan_number(); case '+': return Token{TOK_PLUS, "+", line, col}; case '-': return Token{TOK_MINUS, "-", line, col}; case '*': return Token{TOK_STAR, "*", line, col}; case '/': c = getch(); if (c == '/') { // 行注释 skip_line_comment(); return scan_token(); // 递归扫描下一个 token } else if (c == '*') { // 块注释 skip_block_comment(); return scan_token(); } else { ungetch(); // 回退,/ 单独作为除号 return Token{TOK_SLASH, "/", line, col}; } case '=': c = getch(); if (c == '=') return Token{TOK_EQ, "==", line, col}; else { ungetch(); return Token{TOK_ASSIGN, "=", line, col}; } case '!': c = getch(); if (c == '=') return Token{TOK_NE, "!=", line, col}; else { ungetch(); return Token{TOK_ERROR, "!", line, col}; } case '<': c = getch(); if (c == '=') return Token{TOK_LE, "<=", line, col}; else { ungetch(); return Token{TOK_LT, "<", line, col}; } case '>': c = getch(); if (c == '=') return Token{TOK_GE, ">=", line, col}; else { ungetch(); return Token{TOK_GT, ">", line, col}; } case ';': return Token{TOK_SEMI, ";", line, col}; case '(': return Token{TOK_LPAREN, "(", line, col}; case ')': return Token{TOK_RPAREN, ")", line, col}; case '{': return Token{TOK_LBRACE, "{", line, col}; case '}': return Token{TOK_RBRACE, "}", line, col}; case EOF: return Token{TOK_EOF, "", line, col}; default: return Token{TOK_ERROR, std::string(1, c), line, col}; } }

参数说明与踩坑逻辑:

  • getch()/ungetch()封装了文件指针操作,确保每次只读一个字符且可回退。若用fscanf("%c")则无法回退,导致==被识别为=+=两个 token。
  • skip_line_comment()内部用while ((c = getch()) != '\n' && c != EOF);,注意必须判断EOF,否则文件末尾无换行会死循环。
  • scan_number()需区分整数(123)、小数(123.45)、科学计数(1.23e-4)和十六进制(0xFF),本实验要求支持前三种。其核心是:读到.或e/E后切换子状态,且e后必须跟+/-或数字,否则报错。

3. LL(1) 语法分析器:从文法改写到预测分析表的手工构造

实验第二部分要求实现 LL(1) 分析器,而非递归下降。这意味着你必须:① 对原始文法消除左递归、提取左公因子;② 手算每个非终结符的FIRST和FOLLOW集;③ 构造M[A,a]预测分析表;④ 用栈模拟推导过程。跳过任何一步,分析器都会在某个输入上卡死或误报。

3.1 实验指定文法及改写全过程:为什么E → E + T | T必须改?

原始文法(简化版 C 子集):

S → E E → E + T | E - T | T T → T * F | T / F | F F → ( E ) | id | num | float

该文法存在直接左递归(E → E + T),LL(1) 分析器无法处理:当栈顶是E、输入是id时,它不知道该用E → E + T还是E → T,因为两者FIRST都含id。必须改写为右递归:

E → T E' E' → + T E' | - T E' | ε T → F T' T' → * F T' | / F T' | ε F → ( E ) | id | num | float

注意:ε(空产生式)不是可选符号,是 LL(1) 表构造的基石。若漏写,FOLLOW(E')就算不准,导致M[E', )]为空,遇到a + b)时直接崩溃。

3.2 FIRST/FOLLOW 集手工计算:三张表决定成败

计算不是背公式,而是按规则迭代直到收敛。以E'为例:

  • FIRST(E'):从+ T E'得+;从- T E'得-;从ε得ε→{+, -, ε}
  • FOLLOW(E'):因E → T E',所以FOLLOW(E') ⊆ FOLLOW(E);又因E'在产生式右部末尾,所以FOLLOW(E') = FOLLOW(E)
  • FOLLOW(E):起始符号,FOLLOW(S) = {$};又因F → ( E ),所以)∈FOLLOW(E)→FOLLOW(E) = {$, )}
    → 故FOLLOW(E') = {$, )}

最终得到关键预测表片段(M[A,a]表示非终结符A遇到输入a时应用的产生式):

A \ aidnumfloat(+-*/)$
EE→T E'E→T E'E→T E'E→T E'——————
E'————E'→+ T E'E'→- T E'——E'→εE'→ε
TT→F T'T→F T'T→F T'T→F T'——————
T'————T'→εT'→εT'→* F T'T'→/ F T'T'→εT'→ε
FF→idF→numF→floatF→( E )——————

提示:表中—不代表“无动作”,而是语法错误。实验报告要求在代码中显式检查M[A,a] == nullptr并报错,包括错误位置和期望的合法 token 集(如expected: id, num, float, (, but got '+')。

3.3 预测分析表的 C++ 实现:用二维 map 替代数组更安全

用std::map<std::pair<NonTerminal, Terminal>, Production>存储,避免 C 风格数组越界。Production是产生式右部符号向量。

// Grammar.h using NonTerminal = std::string; // "E", "E'", "T" using Terminal = std::string; // "id", "num", "+", "$" using Symbol = std::variant<NonTerminal, Terminal>; // C++17 variant using Production = std::vector<Symbol>; class Grammar { public: std::map<std::pair<NonTerminal, Terminal>, Production> parse_table; void init_parse_table() { // 手动填入上表内容,例如: parse_table[{ "E", "id" }] = { "T", "E'" }; parse_table[{ "E", "num" }] = { "T", "E'" }; parse_table[{ "E'", "+" }] = { "+", "T", "E'" }; parse_table[{ "E'", ")" }] = {}; // ε 产生式存空 vector parse_table[{ "E'", "$" }] = {}; // ... 其他条目 } Production get_production(const NonTerminal& nt, const Terminal& t) { auto it = parse_table.find({nt, t}); if (it != parse_table.end()) return it->second; return {}; // 空表示错误 } };

关键逻辑说明:

  • get_production()返回空vector表示ε,此时不压栈;返回非空则逆序压入(因栈是后进先出,A → B C D需压D,C,B)。
  • 终结符t来自词法分析器的token.type,但需映射为字符串:token_type_to_string(TOK_ID) == "id"。漏映射会导致查表永远为空。

4. 词法与语法的胶水层:token 流对接、错误恢复与调试技巧

词法分析器输出Token,语法分析器需要Terminal字符串。二者之间不是简单to_string(token.type),而是存在语义鸿沟:TOK_NUM和TOK_FLOAT都应映射为"num",但TOK_ID还需进一步判断是否为关键字(if,while)并映射为"if"、"while"而非"id"。这才是实验报告里「接口设计」的真正难点。

4.1 token 到 terminal 的精准映射:关键字必须前置判断

不能写if (t.type == TOK_ID) return "id";—— 这会让if (x > 0)中的if被当作文本标识符,导致语法分析器试图用F → id推导,而实际需要F → if(如果文法支持关键字的话)。标准做法是:在词法分析阶段,对TOK_ID的lexeme查关键字哈希表,命中则返回对应 terminal 名,否则返回"id"。

// Scanner.cpp 中 scan_identifier() 的增强版 const std::unordered_map<std::string, TokenType> KEYWORDS = { {"if", TOK_IF}, {"else", TOK_ELSE}, {"while", TOK_WHILE}, {"return", TOK_RETURN}, {"int", TOK_INT}, {"float", TOK_FLOAT} }; Token Scanner::scan_identifier() { std::string id; do { id += getch(); } while (is_letter_or_digit(peekch())); // peekch() 不移动指针 ungetch(); // 回退最后一个非字母数字字符 auto it = KEYWORDS.find(id); if (it != KEYWORDS.end()) { return Token{it->second, id, line, col}; // 如 "if" → TOK_IF } else { return Token{TOK_ID, id, line, col}; // 普通标识符 } } // Parser.cpp 中的映射函数 std::string token_to_terminal(const Token& t) { switch (t.type) { case TOK_ID: return "id"; case TOK_IF: return "if"; case TOK_ELSE: return "else"; case TOK_WHILE: return "while"; case TOK_NUM: return "num"; case TOK_FLOAT: return "float"; case TOK_PLUS: return "+"; case TOK_MINUS: return "-"; case TOK_STAR: return "*"; case TOK_SLASH: return "/"; case TOK_EQ: return "=="; case TOK_ASSIGN: return "="; case TOK_SEMI: return ";"; case TOK_LPAREN: return "("; case TOK_RPAREN: return ")"; case TOK_LBRACE: return "{"; case TOK_RBRACE: return "}"; case TOK_EOF: return "$"; default: return "ERROR"; } }

4.2 错误恢复机制:不让一个错毁掉整行分析

LL(1) 遇到M[A,a]为空时,不能直接退出。实验要求实现同步记号集(synchronizing set)恢复:跳过输入直到遇到FOLLOW(A)中的符号,然后继续分析。例如E'的FOLLOW是{$, )},当E'出错时,就getch()直到读到)或$,然后弹出栈顶E',继续。

void Parser::recover_error(const NonTerminal& nt) { // 获取 nt 的 FOLLOW 集(预存为 set<string>) const auto& follow_set = follow_sets[nt]; // 跳过输入直到遇到 FOLLOW 中的符号 while (true) { std::string t_str = token_to_terminal(current_token); if (follow_set.find(t_str) != follow_set.end()) { break; // 找到同步点,停止跳过 } if (current_token.type == TOK_EOF) break; next_token(); // 读下一个 token } // 弹出栈顶 nt,继续分析 parse_stack.pop(); std::cout << "Error recovered at line " << current_token.line << ": skipped to sync token '" << t_str << "'\n"; }

注意:next_token()必须更新current_token,且recover_error()后不能return,要继续parse_step()循环,否则恢复无效。

4.3 可视化推导过程:用缩进打印每一步栈和输入

实验报告要求「输出分析过程」,不是只打印ACCEPT或ERROR。必须记录每一步:栈内容、剩余输入、所用产生式。用std::stack配合std::vector存历史,再格式化输出。

void Parser::print_step(int step_num) { std::cout << "[" << step_num << "] "; // 打印栈(从底到顶,用空格分隔) std::vector<Symbol> stack_vec; std::stack<Symbol> temp = parse_stack; while (!temp.empty()) { stack_vec.push_back(temp.top()); temp.pop(); } std::reverse(stack_vec.begin(), stack_vec.end()); for (const auto& s : stack_vec) { std::visit([](const auto& v) { std::cout << v << " "; }, s); } std::cout << " "; // 打印剩余输入(从 current_token 开始) std::cout << "input: "; std::string t_str = token_to_terminal(current_token); std::cout << t_str; if (current_token.type != TOK_EOF) std::cout << " ..."; // 打印所用产生式 if (!last_production.empty()) { std::cout << " => "; for (const auto& s : last_production) { std::visit([](const auto& v) { std::cout << v << " "; }, s); } } std::cout << "\n"; }

效果示例:

[1] S input: id ... => E [2] E input: id ... => T E' [3] T E' input: id ... => F T' E' [4] F T' E' input: id ... => id T' E' [5] id T' E' input: id ... => T' E' ...

这种输出能让导师一眼看出推导是否符合文法,也是你调试FIRST/FOLLOW是否算错的唯一依据。


5. 避坑指南:这 4 个错误让 80% 的实验报告被退回重做

这些不是理论漏洞,而是华东理工大学 2022 年实验助教批改时高频标红的问题。每一条都对应真实扣分点,按现象→原因→解决给出可执行方案。

5.1 现象:scan_number()识别0x1A为0+x1A两个 token

原因:scan_number()只处理十进制和小数,未识别0x前缀。词法分析器把0x1A当作0(数字) +x1A(非法标识符),导致后续语法分析在x1A处崩溃。
解决:在scan_number()开头加判断:

if (c == '0') { char next = peekch(); if (next == 'x' || next == 'X') { getch(); // consume 'x' return scan_hex_number(); // 专门函数,读 0–9,a–f,A–F } }

scan_hex_number()内部用std::stoi(lexeme, nullptr, 16)转换,并设token.type = TOK_NUM(实验不要求区分进制,统一为 num)。

5.2 现象:FOLLOW(S)算成{EOF},但预测表M[S, )]为空,if (x) { }在)处报错

原因:FOLLOW计算遗漏间接依赖。S → E,所以FOLLOW(E) ⊆ FOLLOW(S);而F → ( E ),所以)∈FOLLOW(E),故)∈FOLLOW(S)。但很多同学只记FOLLOW(S) = {$},忘了S是起始符,其FOLLOW还受文法中所有右部含S的产生式影响(本例无,但思维要闭环)。
解决:手算FOLLOW时强制三遍检查:① 起始符加$;② 每个A → αBβ,FIRST(β) \ {ε} ⊆ FOLLOW(B);③ 若β ⇒* ε,则FOLLOW(A) ⊆ FOLLOW(B)。用 Excel 表格列A,α,B,β,FIRST(β),FOLLOW(B)四栏,逐行填,比纯脑算可靠十倍。

5.3 现象:a = b + c * d;分析正确,但a = (b + c) * d;在)后卡住,栈剩E'不弹出

原因:E'的FOLLOW集漏了}。文法中S → E,但E可能出现在复合语句中:{ E },因此}∈FOLLOW(E)→}∈FOLLOW(E')。实验虽未要求解析{}语句块,但FOLLOW必须按完整文法算(哪怕S只推E)。
解决:FOLLOW计算必须基于完整文法描述,不能按实验当前只实现的部分剪裁。查FOLLOW(E)时,看所有产生式右部:S → E(得$)、F → ( E )(得))、T → F T'和E → T E'不产生新FOLLOW,但若文法隐含Block → { EList },则}必须加入。本实验文法未定义Block,故FOLLOW(E) = {$, )}已完备,但务必确认实验手册原文是否包含{}语句。

5.4 现象:token_to_terminal()返回"id",但预测表查M[F, id]为空,报错

原因:F的FIRST集是{id, num, float, (},但M[F, id]未填F → id。填表时只写了F → ( E ) | num | float,漏了id。
解决:预测表填表必须严格按文法产生式顺序,且每个FIRST(右部首符号)对应的终结符都要覆盖。F → id的FIRST是{id},所以M[F, id] = {id}。用代码生成表易漏,手工填表时用荧光笔标出每个产生式的FIRST,再对号入座。


6. 实验报告交付技巧:从代码到文档的 3 个提分细节

最后这部分不讲技术,讲怎么让你的报告在助教堆成山的 PDF 里脱颖而出。这不是玄学,是连续三年带编译实验的某导师总结的硬核经验。

6.1 token 输出文件必须带行号与列号,且格式严格匹配样例

实验要求输出tokens.txt,格式为:
<line>:<col> <type> "<lexeme>"
例如:

1:1 KEYWORD "if" 1:4 LPAREN "(" 1:5 ID "x" 1:6 RPAREN ")" 1:7 LBRACE "{"

很多同学用printf("%d:%d %s \"%s\"\n", line, col, type_str, lexeme.c_str()),但col是字符索引(从 0 开始),而样例是从 1 开始的位置号。col必须+1。更致命的是:lexeme中若含双引号(如字符串字面量),必须转义为\",否则 JSON 解析器会崩。实验不考字符串,但lexeme字段必须可被通用解析器读取。

6.2 语法分析过程输出用固定宽度缩进,禁用制表符

print_step()的栈输出若用\t,不同终端显示错位。必须用空格对齐:

std::string pad_stack(const std::vector<Symbol>& stack) { std::string s; for (size_t i = 0; i < stack.size(); ++i) { std::visit([&s](const auto& v) { s += v + " "; }, v); } while (s.length() < 30) s += " "; // 固定 30 字符宽 return s; }

这样[3] T E' input: id ...的T E'总是顶格,input总是对齐,助教扫一眼就能比对推导步骤。

6.3 报告附录放「测试用例与预期输出」表格,比代码更有说服力

不要只写“已通过所有测试”。附录放一张表,列 5 个典型用例(含错误用例),每行写:输入代码、token 数量、是否 ACCEPT、首处错误位置、实际输出 vs 预期输出。例如:

测试用例输入token 数ACCEPT?首错位置实际输出预期输出
1. 基础表达式a = 1 + 2.5;7✓—ACCEPTACCEPT
2. 括号嵌套x = (y + z) * 2;11✓—ACCEPTACCEPT
3. 缺少分号a = b + c5✗line 1, col 10Error at line 1: expected ';', got '$'Error at line 1: expected ';', got '$'
4. 未闭合括号if (x > 0 { y = 1; }12✗line 1, col 12Error at line 1: expected ')', got '{'Error at line 1: expected ')', got '{'

这张表证明你不仅跑通了,还系统性验证了边界。助教批改时,先看此表,再抽查代码——有它,你的报告大概率免于二次返工。

我带过三届编译实验,最深的教训是:词法分析器不是 lexer,是编译器的第一道安检门;语法分析器不是 parser,是文法意图的翻译官。每一个ungetch()、每一行FOLLOW计算、每一次print_step()的缩进,都在训练你把模糊需求变成确定性机器的能力。这能力不会因课程结束而消失,它会长进你写任何解析器、配置文件、DSL 的肌肉记忆里。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进