手写词法分析器:从状态机到回退机制的完整实验指南 简介这是西南科技大学编译原理课程的词法分析程序设计实验报告面向计算机相关专业本科生适用于完成编译器前端实验、考试复习或课程设计参考。报告完整覆盖正则表达式描述词法规则、将正则表达式转化为NFA再确定化并化简为最小DFA的核心流程结合TEST语言给出标识符、保留字、无符号整数、分界符、运算符、注释符等各类单词的识别规则与输出方案。资源为1个doc文档体积444KB内容包含实验目的、实验设计、详细实验过程、Python词法分析程序框架及运行效果结构清晰可直接作为撰写实验报告或复习编译原理的参考资料。该资源已有477人学习下载对正在学习词法分析原理或准备相关实验的学生具有实用价值。1. 编译原理实验报告1为什么第一关是词法分析而且真的会翻车编译原理实验报告1在许多高校的课程设计里对应着同一个任务设计并实现一个词法分析器。看起来这是整个编译原理课设中最没悬念的一关——无非是把源代码切成一个个单词再打印出来。但我的经验恰好相反这个实验通过率高高分率极低。大部分人把它当成“正则表达式调用题”用现成工具很快做出一个能跑的demo验收时被老师问一句“你的最长匹配是怎么回退的”当场哑火。这个实验真正在考的是把词法规则从文字描述翻译成状态转移图再把状态转移图翻译成可运行代码。三个环节任何一环是黑匣子答辩都会很难看。这篇笔记按我最常用的手写状态机方案完整走一遍这个过程先拆需求再写代码再设计测试集最后讲那些不跑一遍根本遇不到的坑。适合正在做这门课设的学生也适合想补编译器前端基础的从业者照着复现一遍。2. 实验报告1的需求拆解验收标准、实现路线与语言选型2.1 从实验文档里读出五项硬验收点你们学校的实验题目原文通常只有一段话大意是“设计一个词法分析程序能识别标识符、关键字、常数、运算符和界符并输出二元式”。如果照字面意思做很容易做成一个能跑但拿不到分的程序。我拿到题目后会拆成下面五项硬验收点逐一对齐文档识别类别要全标识符、保留字、整数、浮点数、运算符、界符一类都不能少。保留字表要明确一般覆盖 int、float、if、else、while、return 就够具体以文档为准。输出格式要严格按文档多数要求二元式也就是 (种别码, 单词本身)。输出格式不对程序逻辑再对也被判结果错误。错误处理识别到非法字符时要报告行号和出错字符而不是悄悄跳过。可演示性老师会现场输入一段源码让你运行还会对着状态转移图提问。第五项最容易被忽略。实验报告1表面上是写代码其实有一半分数在报告和答辩上。状态转移图不是可选项是必选项。不同学校的实验1在细节上还有差异比如是否要求处理注释、是否要求浮点数、是否要求多字符运算符。建议在动手前先确认这三个点它们直接决定状态图的复杂度和代码量。如果文档里写了“能处理 // 与 /* */ 注释”那状态图就必须有注释分支代码里也要有对应实现。2.2 三条实现路线手写状态机、生成器与正则库实现词法分析器有三条常见路线手写状态机、用 LEX/FLEX 生成器、用正则库直接匹配。我把它们的差异整理成一张对比表路线上手难度对原理的体现答辩风险适用场景手写状态机中高低想真正搞懂词法分析并拿高分LEX/FLEX 生成器低低高只想快速出程序、不打算讲内部原理正则库如 Python re极低极低极高不建议用于课程实验1我一般选择手写状态机原因不是因为它更“硬核”而是实验1的需求规模实在太小几个状态就能覆盖全部要求完全没必要交给生成器。用 FLEX 生成的代码动辄上千行一旦老师追问“你的报告分支是怎么进入和退出的”你可能连自己的程序都讲不清楚。用正则库的问题更隐蔽正则库自动做最长匹配把词法分析中最关键的回退机制给隐藏了而回退恰恰是答辩时最常被追问的点。2.3 语言选型C 与 Python 的取舍我建议用 C 或 C 来完成实验报告1理由和习惯无关词法分析要处理的字符流读入、指针回退、缓冲区、行号累计用 C 的 FILE* 接口写出来每一步都很直观老师看代码也容易看懂。C 可以用 vector 和 string 简化符号表与缓冲管理比纯 C 省力。Python 不是不能做它的字典和列表能让符号表部分代码量大减但引入两个新问题一是按行读取后需要自己维护字符位置和回退处理不好会漏字符或重复字符二是很多同学因为 Python 写起来快直接跳过了状态转移图最后报告里只剩一段正则表达式分数自然不高。一句话语言不重要核心的状态转移逻辑不要依赖语言的高级特性。下面所有实现均基于 C换成 C 时把 vector 换成链表即可。3. 手写一个能过验收的词法分析器Token定义、主循环与符号表3.1 先定义Token类型与保留字表词法分析的第一步是定义“单词长什么样”这一步直接对应报告里的种别码定义表。先放一份可以直接抄进代码的头文件// token_type.h #ifndef TOKEN_TYPE_H #define TOKEN_TYPE_H #include string // 种别码和实验文档里的要求保持一致 enum TokenType { TK_IDENT 0, // 标识符 TK_INT, // 整常数 TK_FLOAT, // 浮点常数 TK_KEYWORD, // 保留字 TK_OP, // 运算符 TK_DELIM, // 界符 TK_EOF, // 文件结束 TK_ERROR // 非法字符 }; // 保留字表识别完一个完整标识符后再查表 const char* keywords[] { int, float, if, else, while, return }; const int keyword_count sizeof(keywords) / sizeof(keywords[0]); // Token 结构词法分析器每次识别的结果 struct Token { TokenType type; std::string lexeme; // 单词本身 int line; // 所在行号 int value; // 常数记录数值标识符记录符号表索引 }; #endif逻辑说明种别码是整个程序最重要的约定。枚举值的具体数字没有统一标准但必须和实验文档给出的种别码表一一对应否则输出二元式时对不上号。保留字单独抽成数组不要写在状态机里逐字符去凑“if”“int”这是新手最容易写反的地方。正确流程是先按标识符规则扫出一个完整单词再查保留字表命中就是 TK_KEYWORD没命中才是 TK_IDENT。3.2 主扫描循环逐字符读入、回退与最长匹配核心扫描循环的逻辑可以浓缩到一份代码里。我会在报告里把这份代码单独放一节因为回退和最长匹配这两个点几乎必定被老师问到// lexer.cpp 核心片段 #include cstdio #include cctype #include cstring #include token_type.h #define MAX_LEXEME 256 static int current_line 1; // 从文件流中读取一个Token // 返回值1 成功0 遇到 EOF-1 非法字符 int next_token(FILE* fp, Token* tok) { int ch fgetc(fp); // 跳过空白累计行号 while (ch || ch \t || ch \n) { if (ch \n) current_line; ch fgetc(fp); } if (ch EOF) { tok-type TK_EOF; tok-line current_line; return 0; } // 分支1标识符或关键字 if (isalpha(ch) || ch _) { char buf[MAX_LEXEME]; int len 0; while (isalnum(ch) || ch _) { if (len MAX_LEXEME - 1) buf[len] (char)ch; ch fgetc(fp); } buf[len] 0; ungetc(ch, fp); // 关键把多读的一个字符放回流中 tok-lexeme buf; tok-line current_line; for (int i 0; i keyword_count; i) { if (strcmp(buf, keywords[i]) 0) { tok-type TK_KEYWORD; return 1; } } tok-type TK_IDENT; return 1; } // 分支2数字含整数与浮点 if (isdigit(ch)) { char buf[MAX_LEXEME]; int len 0; bool is_float false; while (isdigit(ch) || ch .) { // 第二个小数点直接终止避免把 1.2.3 扫成一个数 if (ch .) { if (is_float) break; is_float true; } if (len MAX_LEXEME - 1) buf[len] (char)ch; ch fgetc(fp); } // 数字后直接跟字母非法标识符如 123abc if (isalpha(ch)) { tok-lexeme std::string(buf) (char)ch; tok-type TK_ERROR; tok-line current_line; ch fgetc(fp); while (isalnum(ch) || ch _) ch fgetc(fp); ungetc(ch, fp); return 1; } ungetc(ch, fp); buf[len] 0; tok-lexeme buf; tok-line current_line; tok-type is_float ? TK_FLOAT : TK_INT; return 1; } // 分支3运算符与界符先试双字符再回退到单字符 char two[3] { (char)ch, 0, 0 }; int next fgetc(fp); two[1] (char)next; if ((ch next ) || (ch next ) || (ch ! next ) || (ch next )) { tok-lexeme two; tok-type TK_OP; tok-line current_line; return 1; } ungetc(next, fp); if (strchr(-*/%;,(){}[], ch)) { tok-lexeme (char)ch; tok-type TK_OP; tok-line current_line; return 1; } // 分支4无法识别的字符 tok-lexeme (char)ch; tok-type TK_ERROR; tok-line current_line; return 1; }逻辑说明主循环的骨架就是四个分支跳过空白、标识符、数字、运算符最后 EOF 收尾。第一次写时容易漏掉三处。第一处是ungetc(ch, fp)回退。每次多读的字符必须放回流中否则下一个 Token 会丢字符。第二处是数字后跟字母的处理我把123abc这类整体合并成一个 TK_ERROR而不是拆成“123”和“abc”这样更符合单词的整体定义报告里也更好解释。第三处是运算符双字符匹配失败后的回退、、、!都要先预读下一个字符尝试拼成双字符拼不成就回退。参数说明MAX_LEXEME 256是单词缓冲区上限。实验源码里标识符一般不超过 32 个字符256 很安全。如果把缓冲改小要注意处理截断问题如果改大内存占用可以忽略但要注意边界检查的代码不能丢。另外如果你们的实验文档要求把.和!也当作界符或运算符要在strchr的字符串里补上否则它们会被归为 TK_ERROR。3.3 符号表登记标识符而不是登记关键字实验1里的符号表可以简化成“标识符登记表”。理解它的关键是区分两类信息Token 流里的单词和需要登记进符号表的单词。保留字、运算符、界符都不进符号表只有标识符需要登记。代码如下// symbol_table.h #include vector #include string struct Symbol { std::string name; int line; // 首次出现行 }; class SymbolTable { public: // 查找并登记返回符号表索引 int register_ident(const std::string name, int line) { for (size_t i 0; i symbols_.size(); i) { if (symbols_[i].name name) { if (symbols_[i].line 0) symbols_[i].line line; return (int)i; } } Symbol s; s.name name; s.line line; symbols_.push_back(s); return (int)symbols_.size() - 1; } private: std::vectorSymbol symbols_; };逻辑说明register_ident返回的是数组下标这个下标就是 Token 结构里value字段的值。输出二元式时标识符可以输出(TK_IDENT, 索引)也可以输出(TK_IDENT, 名字)具体看文档要求。如果实验文档还要求识别常数并建常数表再单独开一张表不要和标识符合在一起。答辩时被问“符号表里应该存什么”答案就是只存需要跨 Token 复用的信息也就是标识符的名字和属性。3.4 错误处理与行号记录错误处理最常见的需求是报告出错位置和出错字符程序不中断继续向后扫描。我用一个简单的错误收集器等整个文件扫描完再统一输出// error_log.h #include vector #include string struct ErrorRecord { int line; std::string message; std::string content; // 出错的字符或单词 }; std::vectorErrorRecord errors; void report_error(int line, const std::string msg, const std::string content) { ErrorRecord r; r.line line; r.message msg; r.content content; errors.push_back(r); }逻辑说明行号必须在扫描过程中实时累计。不要用“统计这个 Token 前面有几个换行符”的事后算法因为遇到跨行注释和非法字符时定位会整体偏移一行以上。错误收集器的输出建议在程序结束前统一打印并且按行号排序方便老师对照源码检查。提示种别码、输出格式、是否处理注释这三项务必以你们学校的实验文档为准。代码宁可多写几个类别判断也不要让输出格式和文档对不上。4. 测试集设计与报告写法让实验报告1从“能跑”到“挑不出毛病”4.1 覆盖矩阵照着这张表准备测试样例很多同学测试只拿一段“int a 1;”跑一下能出结果就结束了。但实验1这种程序边界输入才是扣分重灾区。我习惯准备一张覆盖矩阵把每个类别的典型用例和期望行为列出来类别测试输入期望行为标识符abc _tmp var2 intx5 个 TK_IDENT保留字int if else while return5 个 TK_KEYWORD整数0 007 1233 个 TK_INT浮点数3.14 0.5 10.3 个 TK_FLOAT双字符运算符 !各 1 个 TK_OP不能拆开单字符运算符 - * / 各 1 个 TK_OP界符, ; ( ) { } [ ]对应 TK_DELIM非法字符 # $3 条 TK_ERROR带行号非法标识符123abc 1x2 条 TK_ERROR空程序空文件只有 1 个 TK_EOF行号准确性第 3 行放一个 Token输出的 line 字段为 3这张表可以直接搬进实验报告每个用例附一小段输入和对应输出截图。老师看到这张表基本不会再在测试环节刁难。最后一行尤其重要行号准确性是最容易翻车的验收点很多实现能识别单词但行号永远是 1。4.2 三个边界输入最长匹配、回退与冲突边界一是a1。如果代码只匹配单字符输出会变成两个TK_OP()语义完全错误。正确结果应该是一个TK_OP()。这个用例直接检验你有没有做最长匹配。边界二是123abc。严格按词法规则它不是合法的整数也不是合法的标识符。我见过一半以上的实现把它切成了TK_INT(123)加TK_IDENT(abc)理由是“数字合法字母也合法各认各的”。但课程文档对单词的定义通常是“由字母开头的字母数字串”所以123abc整体应判为错误。边界三是1.2.3。我在扫描代码里用is_float标志拦截了第二个小数点所以输出会是TK_FLOAT(1.2)、一个TK_ERROR(.)、再一个TK_INT(3)。这个行为是否被接受取决于文档对“非法浮点”的定义但无论接受与否你都要在报告里把策略写清楚而不是等老师自己发现。4.3 用回归脚本兜住后续改动实验报告1的程序往往要反复修改比如验收前发现运算符漏了一个改完又担心破坏之前的逻辑。我一般会写一个十行左右的批处理脚本把每个测试用例存成.in文件期望输出存成.out文件跑完统一对比#!/bin/bash # 批量回归测试testcase/ 目录下每个 .in 对应一个 .out for f in testcase/*.in; do name$(basename $f .in) ./lexer $f testcase/$name.output if diff -q testcase/$name.out testcase/$name.output /dev/null; then echo $name: PASS else echo $name: FAIL fi done逻辑说明脚本本身不复杂价值在于把测试从“手工点几遍”变成可重复的回归流程。每次改完词法分析器跑一遍脚本几十秒就能知道有没有改坏别的地方。这个脚本也可以放进实验报告的附录作为“自动化测试”的说明材料属于成本极低的加分项。参数说明testcase/目录下的文件名会成为输出的一部分建议用有意义的名字比如keyword.in、float.in、illegal_ident.in。.out文件需要用一次人工确认过的正确输出生成否则脚本对比的就是错误的“标准答案”。4.4 报告结构代码之外的三样东西实验报告1的正文我建议按下面这个顺序组织无论学校模板长什么样需求描述与种别码定义表把文档中的识别要求原样列出再给出枚举映射。状态转移图。这是答辩时老师看得最多的部分自己画一遍半小时足够。如果实验要求处理注释状态图里必须有注释分支。典型输入输出与问题记录。放覆盖矩阵的测试结果再写两三条“遇到的问题与解决”比如“最初没有处理数字后跟字母的情况导致 123abc 被拆成两个 Token后来在数字分支中加入非法标识符吞并逻辑”。状态转移图和代码实现必须严格对应。答辩时老师会指着一个状态问“这个状态出错后跳到哪”你答不上来前面的代码分就白拿了。用流程图工具画或者手画后贴图都可以重要的是图里每个分支都要能在代码里找到落点。5. 词法分析实验5个常见问题排查实录现象、原因与解决5.1 现象a1被拆成两个后续表达式全乱这是运算符分支没做最长匹配的典型症状。原因扫描到时只匹配了单字符没有预读下一个字符。下一个又被当成一个新的运算符Token 流变成a、、、1语法阶段根本没法判断赋值还是等值比较。解决在运算符分支先读取下一个字符尝试拼成双字符运算符。核心代码就一句ungetc(next, fp)但位置不能错——必须是双字符匹配失败后才能回退。把a1放进测试用例回归脚本就能一眼发现这类问题。5.2 现象输入123abc程序不报错也不停输出两个“合法”Token原因数字分支在遇到字母时直接终止并把字母回退了导致数字和字母被拆成两个独立 Token。从状态图的角度看是数字状态遇到字母时选择了“结束”而不是“进入错误状态”。解决在数字分支里遇到isalpha(ch)时不要回退把整个后续字母数字串吞掉合并成一个 TK_ERROR。这里注意和最长匹配区分开最长匹配是指同一类别内部多字符取值比如而123abc是不同类别交界处的粘连两者处理逻辑不同报告里可以分开写。5.3 现象注释里的非法字符触发大量错误或者注释内容被当成代码原因实验文档要求处理//与/* */注释但状态机里没有注释分支。遇到/后只按除法运算符处理注释里的任何字符都会进入正常识别流程。解决在跳过空白之后、识别 Token 之前增加注释判断。遇到/时预读下一个字符如果是/一路读到行尾行号不变如果是*进入块注释状态直到遇到*/才退出。块注释跨行时注释状态内部也要累计行号否则注释之后的所有 Token 行号都会偏小。5.4 现象Windows 下测试源码含中文注释输出行号整体错乱原因UTF-8 编码下中文字符占 3 个字节用fgetc逐字节读时一个中文字符会被当成 3 个非 ASCII 字节。如果代码把非 ASCII 字节判为 TK_ERROR会产生大量错误记录同时行号统计也可能被带偏。解决最省事的做法是测试源码不写中文注释全部用英文。如果实验文档明确要求支持中文注释注释分支需要按 UTF-8 的字节规律连续跳过多个字节而不是逐字节判断。这一条属于“验收时才暴露”的问题建议提前问清楚老师测试用例里有没有中文。5.5 现象报告贴了上千行代码但没有一张状态转移图答辩被追问后无法解释原因把实验当成“写程序”而不是“讲原理”。代码可能是拼接或参考来的状态图画不出来等于承认没有理解。解决先画状态图再写代码。把状态图贴进报告后代码里的每个分支对应到图上的一个状态或一条边。这个习惯还能反过来帮你发现代码漏洞——图上画了但代码没实现的分支写代码时就会露出来。我也见过有人从代码反推状态图的但那种图通常和实际代码对不齐答辩时更危险。6. 给词法分析器接一个微型语法骨架验证Token流质量词法分析器写完常规验证方式是打印二元式。但二元式只能告诉你“每个 Token 是什么”不能告诉你“Token 的顺序对不对”。这里有一个实用技巧写一个十行左右的递归下降骨架只识别最简单的声明语句int a 1;用它来消费词法分析器的输出。6.1 用一个十行的递归下降骨架消费Token流// syntax_demo.cpp微型语法骨架只验证Token流顺序 // 语法规则 int IDENT NUMBER ; bool parse_declaration(Lexer* lexer) { Token t lexer-next(); if (t.type ! TK_KEYWORD || t.lexeme ! int) return false; t lexer-next(); if (t.type ! TK_IDENT) return false; t lexer-next(); if (t.type ! TK_OP || t.lexeme ! ) return false; t lexer-next(); if (t.type ! TK_INT t.type ! TK_FLOAT) return false; t lexer-next(); return (t.type TK_DELIM t.lexeme ;); }逻辑说明这段代码体现了词法与语法的边界。词法分析器只负责吐 Token不判断顺序语法骨架只判断顺序不关心单词内部怎么拼。如果这个骨架在你的词法输出上跑通说明 Token 的类型和内容都是干净的。跑不通时优先怀疑运算符是不是把拆开了——这类问题在打印二元式时很难发现因为每行输出看起来都“合理”。6.2 用失败用例反向验证行号记录骨架还能反过来验证行号准确性构造a1少了关键字和int 1;标识符位置出现数字两个错误输入让骨架返回 false 并打印当前 Token 的行号。如果行号持续错误问题不在语法骨架而在词法分析器的行号累计逻辑。我当年做实验报告1时就是栽在状态转移图上——代码来自拼接自己讲不清楚被老师指着一个状态问了三遍最后只能承认没理解。后来重写了一遍手写状态机把所有分支在纸上画一遍才真正明白回退和最长匹配为什么是词法分析的核心。这个实验值得认真做它不仅是一门课的起点也是你第一次把“规则”翻译成“程序”的完整训练。希望这篇能让你少走这个弯路。希望帮到你。本文还有配套的精品资源点击获取