简介面向编译原理课程实验的词法分析与语法分析报告系统讲解单词识别原理、状态图设计以及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 | TE - T ET - T * F | FE - T E | - T E | εF - ( E ) | id | numT - F TT - * F T | εF - ( E ) | id | num改写规则只有一条A - A α | β变成A - β AA - α 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 SS - 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 集和预测分析表。非终结符FIRSTFOLLOWE{ (, id, num }{ $, ) }E{ , -, ε }{ $, ) }T{ (, id, num }{ , -, $, ) }T{ *, ε }{ , -, $, ) }F{ (, id, num }{ *, , -, $, ) }计算规律FIRST 集看产生式右侧最左边能推出的终结符FOLLOW 集看非终结符在哪些文法位置出现后面跟着什么。$表示输入结束标记。关键点是 FOLLOW 集合不出现一个终结符的普通字符只关心)和$这类「结构边界」这个思路和词法分析的空白字符处理正好形成对照。非终结符idnum-*()$EE→TEE→TEE→TEEE→TEE→-TEE→εE→εTT→FTT→FTT→FTTT→εT→εT→*FTT→εT→εFF→idF→numF→(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 21 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 的三类用例 运行输出截图。遇到的问题与解决写 23 个真实 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 的首字符。如果没调用unreadCharC 里是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 和符号表接上后面做语义分析时越不用吃后悔药。正确性和鲁棒性都验证完之后这份实验报告就从一个「能跑的作业」变成了「能接着往里加内容的骨架」后面做类型检查、中间代码生成都有落点。希望帮到你。本文还有配套的精品资源点击获取