简介:面向编译原理课程实验的词法分析与语法分析报告,系统讲解单词识别原理、状态图设计以及LL(1)语法分析表构造。资源围绕标识符、关键字、十进制整数、运算符和分隔符的识别展开,给出使用C++语言实现的扫描函数完整代码,并以表达式文法为例,演示首字符集合、后继字符集合与预测分析表的生成步骤,最后通过简单算术表达式测试实例验证分析结果。报告完整包含实验目的、实验环境、实验步骤、实验原理、程序源代码和结论,涵盖课程实验所有环节,适合计算机专业学生在课程设计、实验报告写作或编译原理复习时直接参考。压缩包共一个文档,容量约220KB,内容集中紧凑,便于下载阅读。目前已有2570人学习过该文档,可作为了解词法分析和语法分析程序实现思路的实用资料,帮助读者快速掌握从状态图到LL(1)分析表的完整设计方法。
1. 编译原理词法分析与语法分析实验报告:一次打通两个阶段的完整路径
很多第一次写编译原理实验的同学,都会把词法分析和语法分析当成两个孤立任务:先写个状态机把单词切出来,再按文法搭个递归下降,最后拼在一起跑通几个用例就算完事。但真正到答辩环节你会发现,导师最常追问的不是「这个 while 怎么写」,而是「你的 Token 分类为什么这么定」「文法左递归怎么消的」「出错时报错位置准不准」。这篇文章要解决的,正是词法分析器与语法分析器的接口设计、文法改写和错误恢复——这三个点决定了你的编译器前端是能跑的作业,还是经得起改的骨架。
适合正在做编译原理课程实验、准备提交实验报告,或者想把手写词法状态机与递归下降分析器这套基本功练扎实的读者。下面从 Token 设计一路写到测试用例,代码可直接抄,参数和边界会逐条说清楚。
2. 词法分析器从零写起:Token 定义、状态转移与工具取舍
2.1 Token 分类怎么定:关键字、标识符、常量、运算符与界符
实验报告的第一步不是写代码,而是把 Token 类型定下来。常见做法是按语言结构分成五类:关键字(if、else、while、return)、标识符(变量名和函数名)、整数常量、运算符(+、-、*、/、>、<、= 等)、界符(分号、左右括号、花括号)。这五类不是随便分的,每一类都对应着语法分析里不同的文法符号:关键字和界符在产生式里是终结符,标识符和数字是综合符号,运算符则决定了表达式的层级。
一个容易被忽略的边界是「关键字和标识符的关系」。关键字本质上是语言预留的标识符,所以处理顺序有讲究:必须先按标识符的规则读完整个单词,再查关键字表决定类型。反过来先查表会出问题——比如ifx这个合法标识符,如果逐字符外匹配就会被误判成关键字。实验报告里这一点值得单独写一段,因为它直接体现了你对「最长匹配」和「保留字」两个概念的理解。许多教材第三章的课后习题都在练这套判断,题做顺了,代码基本不会写歪。
2.2 手工状态机扫描:核心代码与三个必须调对的参数
我一般用 C 写词法分析器,因为指针操作和字符读写最直观,也最容易在报告里画出状态转移图。下面是核心扫描函数,注释里标了三个容易翻车的点:
/* 词法分析器核心函数:从输入流读取一个 Token */ Token getNextToken(Lexer* lex) { Token tok; int ch; /* 跳过空白字符,同时维护行列号,供后续报错定位 */ do { ch = lex->nextChar(lex); if (ch == '\n') { lex->line++; lex->col = 1; } else { lex->col++; } } while (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'); if (ch == EOF) { tok.type = TOKEN_EOF; return tok; } /* 分支一:字母或下划线开头,读完整标识符后查关键字表 */ if (isalpha(ch) || ch == '_') { int len = 0; while (isalnum(ch) || ch == '_') { tok.text[len++] = ch; ch = lex->nextChar(lex); } lex->unreadChar(lex, ch); /* 注意:多读的字符必须放回 */ tok.text[len] = '\0'; tok.type = isKeyword(tok.text) ? TOKEN_KEYWORD : TOKEN_IDENT; return tok; } /* 分支二:数字开头,读整数常量 */ if (isdigit(ch)) { int len = 0; long value = 0; while (isdigit(ch)) { if (value > (INT_MAX - (ch - '0')) / 10) { reportError(lex, "integer literal too large"); } value = value * 10 + (ch - '0'); tok.text[len++] = ch; ch = lex->nextChar(lex); } lex->unreadChar(lex, ch); tok.text[len] = '\0'; tok.type = TOKEN_NUMBER; return tok; } /* 分支三:运算符与界符,做最长匹配 */ tok.text[0] = ch; tok.text[1] = '\0'; tok.type = TOKEN_OPERATOR; int next = lex->nextChar(lex); if (isTwoCharOp(ch, next)) { /* >= <= == != 这类双字符运算符 */ tok.text[1] = next; tok.text[2] = '\0'; } else { lex->unreadChar(lex, next); } return tok; }三个参数值得展开说。第一个是unreadChar:扫描标识符时,循环结束条件会把下一个 Token 的首字符也读出来,如果不放回流,那个字符就永久丢失了。这是词法分析器最常见的错位 bug,后面 5.2 会专门展开。第二个是数字溢出检查value > (INT_MAX - digit) / 10,先除后乘是为了避免乘法先溢出,属于教科书标准写法,报告里提一句「整数常量采用 32 位有符号溢出检查」就行。第三个是运算符最长匹配:>=必须整体读成一个 Token,不能拆成>和=,否则a >= 1会被分析成两个独立运算符,语法分析器直接懵。
提示:实验报告里画一张状态转移图,把标识符、数字、运算符三个分支画成三个子图,这张图比一大段代码更值分。
2.3 手写状态机与 flex 生成怎么选:实验报告场景的取舍
很多学校的编译原理实验允许用 flex(或 Java 生态里的 JFlex)生成词法分析器。我的建议是:如果题目没强制要求工具,就手写。理由有三条。第一,手写状态机能逼你把正规式到 DFA 的转化过程真正过一遍,报告里的状态转移表就是这部分的主要得分点。第二,flex 生成的代码可读性差,答辩时老师让你现场解释 .l 文件里某个规则的行为,答不上来反而扣分。第三,这个实验的 Token 种类一般不超过 20 个,手写和生成的工程量差距很小。
但如果你是用 Java 完成实验的选手,或者时间实在紧张,用 flex 也完全可行。报告里需要补上三样东西:.l 源文件的规则清单、规则之间的优先级说明(比如「id 的规则要排在关键字之后」这类坑)、以及对生成代码里跳转逻辑的讲解。老师看到你能说清楚「为什么规则顺序会影响匹配优先级」,比纠结你用没用工具更重要。编译原理实验评分看的是理解深度,不是工具崇拜。
3. 语法分析器落地:消除左递归、递归下降与预测分析表
3.1 文法改写:左递归产生式为什么必须消,消完长什么样
词法分析器吐出的 Token 流是线性的,语法分析器要把它变成树形的结构。递归下降分析器的工作方式是:每个非终结符写一个函数,函数体按产生式右侧的顺序逐个匹配。这就要求分析器只能靠「当前 Token」决定走哪个分支,不能回头试探。如果文法里存在左递归,比如E -> E + T,那么parseExpression的第一件事就是再调parseExpression,无限循环直接栈溢出。
所以动手写代码之前,必须对原文法做消除左递归的改写。以最经典的表达式文法为例:
| 原始文法 | 消除左递归后 |
|---|---|
| E -> E + T | E - T | T | E -> T E' |
| T -> T * F | F | E' -> + T E' | - T E' | ε |
| F -> ( E ) | id | num | T -> F T' |
| T' -> * F T' | ε | |
| F -> ( E ) | id | num |
改写规则只有一条:A -> A α | β变成A -> β A',A' -> α A' | ε。α 是原先跟在左递归后面的部分,β 是不含左递归的开头分支。这里E -> E + T | T中 β 就是T,α 是+ T,所以得到E -> T E'和E' -> + T E' | ε。减法同理,合并进E'即可。
除了左递归,还要检查公共左因子。比如S -> if E then S | if E then S else S两条产生式都以if E then S开头,递归下降没法靠当前 Token 区分,需要提取因子变成S -> if E then S S',S' -> else S | ε。实验报告里把这步改写过程写清楚,比贴十行代码更有说服力。
3.2 递归下降分析器:每个非终结符一个函数
文法改写好之后,代码就是文法的直接翻译。下面这个实现对应 3.1 改写完的表达式文法:
/* 递归下降分析器核心:lookahead 是词法分析器刚吐出的当前 Token */ int parseExpression() { /* E -> T E' */ if (!parseTerm()) return 0; return parseExpressionPrime(); } int parseExpressionPrime() { /* E' -> + T E' | - T E' | ε */ if (lookahead.type == TOKEN_PLUS || lookahead.type == TOKEN_MINUS) { advance(); /* 消费运算符 */ if (!parseTerm()) { error("运算符后面缺少操作数"); return 0; } return parseExpressionPrime(); /* 递归处理剩余部分 */ } return 1; /* ε 产生式:什么都不做 */ } int parseTerm() { /* T -> F T' */ if (!parseFactor()) return 0; return parseTermPrime(); } int parseTermPrime() { /* T' -> * F T' | ε */ if (lookahead.type == TOKEN_STAR) { advance(); if (!parseFactor()) { error("乘号后面缺少因子"); return 0; } return parseTermPrime(); } return 1; } int parseFactor() { /* F -> ( E ) | id | num */ if (lookahead.type == TOKEN_LPAREN) { advance(); if (!parseExpression()) return 0; if (lookahead.type != TOKEN_RPAREN) { error("缺少右括号 ')'"); return 0; } advance(); return 1; } if (lookahead.type == TOKEN_IDENT || lookahead.type == TOKEN_NUMBER) { advance(); return 1; } error("语法错误:期望标识符、数字或左括号"); return 0; }这个代码有四个地方要在报告里解释。第一,每个函数和文法产生式是一一对应的,函数名就是非终结符名,这是递归下降最直观的地方。第二,ε产生式对应函数里的return 1——不消费 Token 直接返回成功,把决策留给上层调用者。第三,+和*的递归调用放在操作数之后,天然实现了左结合,这个细节在答辩时经常被问到。第四,advance()从词法分析器取下一个 Token,同时更新行列号,错误报告全靠这个信息。语法分析器本体不直接读源文件,它只消费 Token 流,这个分层设计让两个阶段的耦合降到最低。
3.3 FIRST、FOLLOW 与预测分析表:报告里最值分的三张表
递归下降分析器能跑还不够,实验报告里必须证明「这个文法可以用一个 Token 前瞻决定分支」——也就是 LL(1)。证明过程就是构造 FIRST 集、FOLLOW 集和预测分析表。
| 非终结符 | FIRST | FOLLOW |
|---|---|---|
| E | { (, id, num } | { $, ) } |
| E' | { +, -, ε } | { $, ) } |
| T | { (, id, num } | { +, -, $, ) } |
| T' | { *, ε } | { +, -, $, ) } |
| F | { (, id, num } | { *, +, -, $, ) } |
计算规律:FIRST 集看产生式右侧最左边能推出的终结符;FOLLOW 集看非终结符在哪些文法位置出现,后面跟着什么。$表示输入结束标记。关键点是 FOLLOW 集合不出现一个终结符的普通字符,只关心)和$这类「结构边界」,这个思路和词法分析的空白字符处理正好形成对照。
| 非终结符 | id | num | + | - | * | ( | ) | $ |
|---|---|---|---|---|---|---|---|---|
| E | E→TE' | E→TE' | E→TE' | |||||
| E' | E'→+TE' | E'→-TE' | E'→ε | E'→ε | ||||
| T | T→FT' | T→FT' | T→FT' | |||||
| T' | T'→ε | T'→ε | T'→*FT' | T'→ε | T'→ε | |||
| F | F→id | F→num | F→(E) |
填表规则:对每个产生式A -> α,把 α 的 FIRST 集里的终结符对应格子填上这个产生式;如果 α 能推出 ε,再把 FOLLOW(A) 里的终结符对应格子填 ε。检查一遍可以发现每个格子最多只有一个产生式,这就是 LL(1) 的判据。报告里这三张表一放,再配一句「所有单元格候选产生式唯一,因此可构造递归下降分析器」,比你写十行注释都管用。
注意:如果某个格子出现两个产生式,说明文法不是 LL(1),需要回头提取公共左因子或改写文法。
4. 错误处理与测试用例:让实验报告经得起答辩追问
4.1 词法错误与语法错误的边界:报错信息怎么带行号列号
很多实验报告到这里就结束了,但一份完整的前端实验还差最后一块:错误处理。词法错误发生在「字符不能组成合法 Token」时,比如int 1a = 5;里的1a、源码里的@符号、未闭合的注释。语法错误发生在「Token 序列不符合文法」时,比如if (a > 5少了右括号、a = b + ;少了操作数。两者的边界很清晰:词法分析器只能报「我不认识这个字符」,语法分析器能报「这个 Token 不该出现在这里」。
最容易被轻视的是错误信息的可读性。只输出syntax error的程序在调试时就是个黑匣子,你根本不知道哪里写错了。业内通用的做法是在每个 Token 上记录行列号,报错时统一输出:
/* 统一错误报告:带文件名(如果有)、行号和列号 */ void reportError(Lexer* lex, const char* fmt, ...) { fprintf(stderr, "error at line %d, column %d: ", lex->line, lex->col); /* 后面用 va_list 处理格式化参数,输出具体错误描述 */ /* 例如:integer literal too large / unexpected token ')' */ }另一个常见需求是错误恢复:语法分析器遇到错误后,是直接退出还是跳过一段继续分析?我一般用恐慌模式(panic mode):先报错,然后把 Token 流跳到同步集合(如;、}、EOF)再继续。这样一次运行能报出多个错误,测试时效率高很多。报告里把这个策略写清楚——同步集合选哪些 Token、为什么选它们——会让老师看到「这个学生不只会写 happy path」。
4.2 测试用例设计:合法输入、边界输入与非法输入
实验报告的测试部分,建议按三个类别组织用例,每类至少三条:
| 类别 | 输入片段 | 期望结果 |
|---|---|---|
| 合法 | int a = 10; | Token 类型正确,语法通过 |
| 合法 | a = (b + 2) * 3; | 嵌套括号处理正确 |
| 边界 | 多行缩进 + 空行 | 行号列号准确,空白不产生 Token |
| 边界 | 超长标识符(超过缓冲区) | 不越界,截断或报错 |
| 非法 | int 1a = 5; | 词法错误:标识符不能以数字开头 |
| 非法 | if (a > 5 | 语法错误:缺少右括号 |
| 非法 | a = (b + 2)) * 3; | 语法错误:多余的右括号 |
合法用例证明「能跑」,边界用例证明「考虑过极端情况」,非法用例证明「错误处理真的生效」。写完测试用例后,把它们落成一个回归脚本,每次改代码跑一遍:
# 批量跑测试用例,和预期输出逐行对比 for f in tests/case*.in; do ./minic "$f" > "$f.out" 2>&1 if diff -q "$f.expected" "$f.out" > /dev/null; then echo "PASS: $f" else echo "FAIL: $f" fi done这个脚本本身也可以在报告里放一行说明:测试是可持续验证的,不是手工点两下就截图走人。
4.3 实验报告结构:从需求分析到结果验证的七个段落
很多同学的实验报告是从网上抄个框架,把代码一贴就交。但按答辩老师的视角,一份合格的词法语法分析实验报告通常需要这七个段落:
- 实验目的与要求:把题目要求转写成可验证的验收标准,比如「能识别 5 类 Token」「能拒绝 3 类非法输入」。
- 需求分析:列出 Token 种类、文法产生式、错误类型清单。
- 设计思路:放正规式到 DFA 的状态转移表、消除左递归前后的文法对比、FIRST/FOLLOW 表。
- 核心代码实现:只放关键函数并加注释,不要全贴源码。
- 测试方法与结果:4.2 的三类用例 + 运行输出截图。
- 遇到的问题与解决:写 2~3 个真实 bug,描述现象、推测原因、修复方式。
- 实验收获与不足:明确写「错误恢复只做了恐慌模式,还没做短语级恢复」这类诚实的短板。
每个段落的篇幅不用平均,设计思路和测试结果各占三成,代码三两页即可。报告的价值在于「你为什么这么设计」而非「代码有多少行」,把 2.2 的 Token 边界处理、3.3 的预测分析表这些决策点写透,答辩基本不会卡。
5. 词法语法分析实验避坑指南:4 个高频问题的现象、原因与修复
5.1 关键字和标识符的匹配顺序写反,保留字被当成普通标识符
现象:输入if (a > 0),词法分析输出的第一个 Token 是 IDENT 而不是 KEYWORD。更隐蔽的是反过来:ifx被识别成关键字,因为代码是先逐字符匹配关键字表再决定走标识符分支。
原因:处理保留字的顺序错了。关键字本质是标识符的一个子集,必须先完成「按标识符规则读完整单词」这一步,再回头查表确认是否命中保留字。
解决:把判断放在分支一的末尾:tok.type = isKeyword(tok.text) ? TOKEN_KEYWORD : TOKEN_IDENT;。关键字表用strcmp逐个比较即可,Token 种类少,二分查找属于锦上添花。报告里写一句「保留字采用读毕查表策略」就能拿到这部分的思考分。
5.2 多读一个字符没放回,Token 错位牵一发动全身
现象:int a = 10;分析结果里第二个 Token 变成了a=而不是a,或者某个 Token 的首字符凭空消失。
原因:这是词法分析器最经典的 bug。扫描标识符、数字这类变长 Token 时,循环条件会多读一个字符来判断结束,这个字符通常是下一个 Token 的首字符。如果没调用unreadChar(C 里是ungetc)把它放回输入流,输入指针就永久前进了。
解决:在每个「读到了不属于当前 Token 的字符」的分支出口统一放回。写完立刻加一条调试断言:把每个 Token 的 text 打印出来和源文件逐字符核对。血泪经验是:这个 bug 在代码量小的时候完全看不出来,一旦文法复杂起来,所有错位都会伪装成「语法错误」,排查起来极其折磨。
5.3 左递归没消除,递归下降一运行就栈溢出
现象:程序编译通过,一运行就Segmentation fault,或直接爆栈退出。用调试器看调用栈,发现parseExpression在反复调用自己。
原因:递归下降要求文法不能有左递归,但很多同学把教材上的原始文法直接搬进代码。E -> E + T翻译成parseExpression第一行就调parseExpression,无限递归直到栈耗尽。
解决:写递归下降代码之前,把文法改写这一步做成强制动作。3.1 的表格就是标准答案;写完对照检查,确保每个函数第一行调用的都是「右侧第一个符号」对应的函数,而不是自己。顺带一提,E' -> ε这种空产生式对应的函数体是空返回,不要画蛇添足去消费 Token。
5.4 报告只有代码没有测试数据,答辩时被追问就慌
现象:代码能跑通一两个用例,但报告里只有函数截图。老师追问「你处理a = (b + 2)) * 3这种多余右括号会怎样」的时候,答不上来。
原因:把实验报告当成代码作业来写,只提交了「实现」而缺失了「验证」。编译原理实验的重点恰恰是验证过程——词法分析器的合法/非法输入对比、语法分析器的错误恢复路径,这些都是评分点。
解决:按 4.2 的三类用例补测试,每类附上输入、输出、预期、通过与否四列。最重要的非法用例一定要能说明「分析器没有崩溃,而是输出了带行列号的错误信息」。这一步的性价比极高:代码可能有 80 分的水平,补上测试表和错误恢复说明,报告观感直接上一个档次。
6. 进阶:把词法语法分析器的输出接到符号表与语义检查上
词法分析和语法分析跑通之后,最值得做的一个进阶动作是把「接受/拒绝」升级成「产出结构」,再把标识符收进符号表——这一步正好衔接后边的语义分析实验。具体做法是让 parseFactor 不返回 1/0,而是返回一棵 AST 节点:
/* 抽象语法树节点:把「匹配是否成功」升级成「结构可复用」 */ typedef struct ASTNode { NodeKind kind; /* NODE_BINOP / NODE_NUM / NODE_IDENT */ struct ASTNode* left; struct ASTNode* right; char* text; /* 叶子节点保存值或名字 */ int line; /* 来源位置,语义报错要用 */ } ASTNode;语法分析器每匹配一个产生式就分配一个节点,E' -> + T E'的递归调用结束后,把左右子树挂到+节点上。这一步完成后,AST 还能接一个简单的符号表:遍历树,每遇到声明就把名字登记进表,遇到引用就查表,能查出「变量未声明」「重复声明」这类经典的语义错误。这些正是后续实验的得分点,现在把接口留好就不用来回返工。
验证这一层的方法也很具体:拿一个语义有问题的输入(比如a = b + 1;但 b 从未声明)跑一遍,确认符号表报了 undefined identifier。这个测试能把词法错误、语法错误、语义错误的输出区分开,整个前端的分层一目了然。我自己写这类实验的习惯是:先跑一批故意写错的输入,再跑合法用例——因为合法路径只能证明理想条件下可用,非法路径才能暴露状态机和文法的边界处理。越早把 AST 和符号表接上,后面做语义分析时越不用吃后悔药。
正确性和鲁棒性都验证完之后,这份实验报告就从一个「能跑的作业」变成了「能接着往里加内容的骨架」,后面做类型检查、中间代码生成都有落点。希望帮到你。
本文还有配套的精品资源,点击获取