资讯详情

词法分析器实验一:token边界、DFA状态表与最长匹配

📅 2026/10/23 19:58:50 | 华诺云谱 👁 阅读
词法分析器实验一:token边界、DFA状态表与最长匹配
简介面向计算机专业学生与编译原理初学者北京邮电大学编译原理课程实验一词法分析器资料以 zip 包提供聚焦扫描器设计与实现可用于课程实验复现、词法规则梳理及编译器前端入门练习。包内共 4 个文件包含 2 个 txt 说明与测试文本、1 个 h 头文件、1 个 cpp 源文件整体约 10KB体积轻巧便于查看词法规则定义、输入处理、分词逻辑、符号管理与错误处理等实验要点。已有约 1000 人学习下载适合需要对照课程要求完成词法分析器编写、调试与测试的读者。源码与说明相互配合可帮助理解标识符、关键字、常量、运算符等词素的识别流程也能从中借鉴实验组织方式、测试用例设计及常见排错思路对后续语法分析、语义分析等环节的学习亦有参考价值。1. 拿到词法分析器 zip 先别改代码实验一真正卡人的是 token 边界如果你正在做编译原理课程实验一手里拿到一个词法分析器 zip 资源包第一反应多半是解压、找 main、编译跑通。真做起来容易翻车的点不在 while 循环而在 token 边界1.0该切成 FLOAT 还是 INT DOT INT注释里的换行算不算行号关键字表命中了还要不要继续读下一个字符。这个资源包适合刚接触编译原理、需要一份可对照实现和测试样例的人也适合已经写完扫描器但输出格式对不上实验要求的人。它把词法分析器拆成可运行的小步骤字符输入、状态迁移、token 输出、错误恢复。先别急着改代码按下面的顺序把状态表和测试输出对齐后面能省很多返工。2. 词法分析器的理论底座正则、NFA 到 DFA 怎么落到一张状态表编译原理课程实验一通常不会一上来就要求写完整编译器而是先让你把源程序切成 token 流。词法分析器的理论部分看起来是正则表达式、NFA、DFA 三件套落到代码里其实就三件事每个 token 的识别规则、状态怎么迁移、匹配失败时退回到哪个位置。资源包里的实现不管是手写扫描器还是表驱动核心都绕不开这三件事。先把 token 分类定清楚再谈正则和状态表否则后面加一个关键字或一个注释规则就会牵一发动全身。2.1 先定 token 分类再谈正则表达式很多实验一的要求里会给一张 token 表但真正写代码时容易把“词法类别”和“具体单词”混在一起。比如int是关键字intx是标识符int1也是标识符是赋值运算符是关系运算符。词法分析器输出的一般是二元组或三元组类别、原文、行号有的实验还要求列号。类别用枚举表示原文用字符串切片保存行号在扫描过程中维护。常见做法是先把类别枚举定死再给每个类别写一条正则或一组字符判断最后用最长匹配原则收口。下面这段 C 代码是常见的 token 枚举和结构定义实验报告里也能直接对应上。枚举值本身不重要重要的是输出格式要和实验要求一致有的要求输出类别名有的要求输出整数编码还有的要求把关键字单独标出来。// token.h typedef enum { TOK_EOF 0, TOK_IDENT, // 标识符 TOK_KEYWORD, // 关键字 TOK_INT_LIT, // 整数字面量 TOK_FLOAT_LIT, // 浮点字面量 TOK_STRING_LIT, // 字符串字面量 TOK_OP, // 运算符 TOK_DELIM, // 分隔符 TOK_COMMENT, // 注释实验要求保留时才输出 TOK_ERROR // 词法错误 } TokenType; typedef struct { TokenType type; char *lexeme; // 指向输入缓冲区中的切片或单独拷贝 int line; // 起始行号 int column; // 起始列号 } Token;这段代码的逻辑是先固定类别再让扫描函数只负责填Token。lexeme如果指向输入缓冲区生命周期要和缓冲区一致如果实验要求输出后继续处理建议拷贝一份避免缓冲区复用后 token 内容被覆盖。line和column记录的是 token 起始位置不是结束位置报错时更好定位。常见坑是把关键字也归到TOK_IDENT里后面语法分析阶段再查表这样做不是不行但实验一如果明确要求区分关键字输出就会对不上。token 分类定完后再给每类写规则。标识符一般是字母或下划线开头后接字母、数字、下划线整数是数字序列浮点数要处理小数点、指数部分和前后边界注释要区分单行和多行运算符要注意双字符运算符优先于单字符。这里不需要把正则表达式写进代码但脑子里要有一张优先级表最长匹配优先长度相同则按实验规定的规则优先。比如不能先匹配成再匹配1..2里的..要看实验是否定义为范围运算符如果没有定义就应该在第二个点号处报错或切成两个点号。类别常见规则容易混淆的边界标识符字母或下划线开头后接字母、数字、下划线intx不是关键字关键字固定单词表必须先按标识符读完整再查表整数连续数字123abc要报错或切开浮点数数字、小数点、数字可带指数1.和.5看实验要求字符串双引号包围处理转义未闭合字符串要报错注释//到行尾/* */跨行多行注释要维护行号2.2 NFA 转 DFA 不是画图作业要落成转移表实验报告里经常要求画 NFA、转 DFA、最小化但代码里不一定真的用表驱动。这里要区分两件事理论作业要你证明自己懂子集构造工程实现可以用手写switch也可以用二维转移表。资源包如果带状态表实现读代码时先找“当前状态”和“输入字符”两个维度再看接受态列表和回退逻辑。DFA 的好处是每个状态对每个输入字符最多有一条出边扫描时不需要回溯坏处是表可能很大字符集要映射到列索引。常见做法是把字符集分成几类字母、数字、下划线、空白、点号、引号、斜杠、运算符字符、其他。这样转移表的列数可控不用为 256 个字符每个都建一列。下面是一个简化后的状态表写法重点看结构不要照搬状态编号。state是当前状态cls是字符类表项是下一个状态-1表示没有合法转移需要回退或报错。// 简化状态表行是状态列是字符类 // 字符类0 字母/下划线1 数字2 空白3 点号4 引号5 斜杠6 其他 static int trans[][7] { /* S_START */ { S_IDENT, S_INT, S_START, S_DOT, S_STR, S_SLASH, S_OP }, /* S_IDENT */ { S_IDENT, S_IDENT, S_ACCEPT, S_ACCEPT, S_ACCEPT, S_ACCEPT, S_ACCEPT }, /* S_INT */ { S_ACCEPT, S_INT, S_ACCEPT, S_FLOAT_POINT, S_ACCEPT, S_ACCEPT, S_ACCEPT }, /* S_FLOAT_POINT */ { S_ERROR, S_FLOAT_FRAC, S_ERROR, S_ERROR, S_ERROR, S_ERROR, S_ERROR }, /* S_FLOAT_FRAC */ { S_ACCEPT, S_FLOAT_FRAC, S_ACCEPT, S_ACCEPT, S_ACCEPT, S_ACCEPT, S_ACCEPT }, /* S_SLASH */ { S_ACCEPT, S_ACCEPT, S_ACCEPT, S_ACCEPT, S_ACCEPT, S_LINE_COMMENT, S_ACCEPT }, /* S_LINE_COMMENT */ { S_LINE_COMMENT, S_LINE_COMMENT, S_ACCEPT, S_LINE_COMMENT, S_LINE_COMMENT, S_LINE_COMMENT, S_LINE_COMMENT } };这张表的逻辑是“读一个字符、查一次表、迁到下一个状态”遇到接受态时先不急着输出继续看下一个字符能不能让匹配更长不能就回退一个字符并输出当前 token。参数上要注意S_ACCEPT不是真实状态而是“当前状态可接受”的标记真正实现时通常用两个数组一个存下一状态一个存当前状态是否接受。S_ERROR表示进入非法状态比如1.a里的点号后不是数字这时要根据实验要求决定报错还是回退成INT加点号。字符类映射函数要单独写别在扫描循环里堆一长串if否则加一个运算符就要改很多地方。提示实验报告里的 DFA 状态编号和代码里的状态编号可以不同但建议在注释里做一张对照表后面调试时不用来回猜。2.3 手写扫描器与生成器方案怎么选课程实验一一般要求手写词法分析器目的是让你理解字符级扫描和状态迁移。资源包里如果同时出现手写实现和生成器配置不要把生成器输出直接当实验答案除非实验要求明确允许。手写扫描器的优点是逻辑透明、好加行号、好做错误恢复缺点是关键字表、注释、字符串转义这些边界要自己维护。生成器方案的优点是正则规则集中、改规则快缺点是对实验要求的输出格式控制没那么直接而且很多课程实验不接受“只交一个生成器配置文件”。我一般会这样选实验一以手写为主生成器只用来做对照验证。比如同一组输入手写扫描器输出 token 流生成器方案也输出 token 流两边做 diff差异就是边界没对齐的地方。手写实现里最关键的是“最长匹配 回退”只要这两个点稳住后面加规则就是体力活。生成器方案里最关键的是规则顺序和锚点规则写得太宽会把关键字吞成标识符规则写得太窄会在注释和字符串上翻车。如果资源包里的代码是表驱动读的时候先找三个东西字符类映射、转移表、接受态表。如果代码是switch手写读的时候先找主循环里的switch (ch)和每个分支的“回退”语句。两种写法没有绝对优劣实验一更看重你能不能把 token 边界说清楚。选型理由写进实验报告时不要只写“手写更简单”要写“手写便于维护行号和列号、便于在非法字符处恢复、便于按实验要求输出错误 token”这样才像做过。3. 把资源包跑起来目录识别、构建命令与第一组回归测试拿到 zip 后不要直接双击 main 文件开始改。先看目录结构判断哪部分是源码、哪部分是测试输入、哪部分是实验报告或说明。常见目录会包含src、include、test、doc这类名字入口通常是main.c、main.cpp或某个带scan字样的文件。构建方式可能是 Makefile、CMake也可能只有一条编译命令写在说明里。先按原样构建一次保留原始输出再动代码这样后面输出对不上时你能判断是自己改坏了还是本来就没对齐实验要求。3.1 解压后先看入口和测试数据解压后先列目录不要急着打开所有文件。重点看三类文件入口文件、词法规则实现、测试输入和期望输出。入口文件负责读文件或读标准输入词法规则实现负责扫描测试数据负责验证。如果资源包里带期望输出先拿一组最短的输入跑通比如只包含一个关键字和一个标识符不要一上来就跑完整源程序错误信息会混在一起。# 解压到独立目录避免污染下载目录 mkdir -p lexical-lab unzip lexical-analyzer.zip -d lexical-lab cd lexical-lab # 先看两层目录不要递归展开所有文件 find . -maxdepth 2 -type f | sort # 找入口和构建脚本 find . -maxdepth 3 -iname main.* -o -iname Makefile -o -iname CMakeLists.txt这段命令的逻辑是先建立独立工作目录再解压避免覆盖已有文件。find -maxdepth 2只看两层防止一上来被大量测试文件淹没第二条find用-iname忽略大小写找入口和构建脚本。参数上-maxdepth的数字按资源包目录深度调整如果解压后多了一层同名目录先cd进去再找。注意不要用rm -rf清理下载目录实验资源包经常和报告放在一起误删很麻烦。找到入口后先读main的前几十行看它是从argv[1]读文件还是从标准输入读。很多实验一资源包默认从文件读但说明里写的是标准输入直接跑会卡住。测试数据一般放在test或cases目录文件名可能带.c、.txt或.in。先选一个最小的输入文件不要选带复杂注释和字符串的否则第一轮排错会很难受。3.2 用一条固定编译命令锁住构建如果资源包带 Makefile先直接make如果没有就用一条固定的编译命令把警告打开。实验一代码量不大但指针和缓冲区操作多-Wall -Wextra能提前暴露不少问题。C 代码用gccC 代码用g不要混用。编译产物统一放到build目录后面清理方便。# 有 Makefile 时先原样构建 make # 没有 Makefile 时用固定命令编译 C 源码 mkdir -p build gcc -stdc11 -Wall -Wextra -g \ src/main.c src/lexer.c src/token.c \ -Iinclude -o build/lexer # 先跑一个最小输入确认程序能启动 printf int main;\n build/smoke.c ./build/lexer build/smoke.c这段命令的逻辑是先尝试项目自带构建方式失败再手动编译。-stdc11固定语言标准避免不同环境下的隐式声明差异-Wall -Wextra打开常用警告-g保留调试信息-Iinclude指定头文件目录。源文件列表要按实际目录替换不要照抄。printf生成一个最小输入目的是确认程序能读文件、能输出 token不是验证完整功能。如果程序要求从标准输入读把最后一行改成./build/lexer build/smoke.c。注意编译警告不要一开始就全部忽略。像“隐式声明函数”“有符号与无符号比较”这类警告在词法分析器里经常对应真实的越界或字符判断错误。3.3 写一个最小回归脚本锁住输出格式实验一最耗时间的不是写扫描逻辑而是输出格式对不上。类别名大小写、token 之间用空格还是换行、是否输出行号、注释要不要保留这些细节每个实验要求都可能不同。建议先写一个很小的回归脚本把当前输出保存下来后面每次改代码都跑一遍。脚本不需要复杂框架Python 标准库就够。下面这个脚本读取输入文件调用可执行文件把输出和期望文件做简单对比期望文件先手工确认一次后面就是回归基线。# test_regression.py import subprocess import sys from pathlib import Path LEXER Path(build/lexer) CASES Path(test/cases) def run_case(infile: Path) - str: # 统一从标准输入喂给词法分析器避免不同实现的参数差异 data infile.read_text(encodingutf-8) proc subprocess.run( [str(LEXER)], inputdata, textTrue, capture_outputTrue, timeout5, ) if proc.returncode ! 0: raise RuntimeError(f{infile.name} 退出码 {proc.returncode}: {proc.stderr}) return proc.stdout.strip() def main() - int: failed 0 for infile in sorted(CASES.glob(*.c)): expected infile.with_suffix(.expected) if not expected.exists(): print(f跳过 {infile.name}没有期望输出) continue actual run_case(infile) want expected.read_text(encodingutf-8).strip() if actual ! want: failed 1 print(f失败: {infile.name}) print(实际输出前 300 字符:) print(actual[:300]) else: print(f通过: {infile.name}) return 1 if failed else 0 if __name__ __main__: sys.exit(main())这段脚本的逻辑是把每个.c输入喂给词法分析器和同名.expected文件比较。subprocess.run用inputdata从标准输入传数据textTrue按文本处理timeout5防止程序卡死。capture_outputTrue同时收 stdout 和 stderr退出码非零时直接报错。参数上CASES.glob(*.c)只匹配 C 文件如果你的测试输入是.txt就改后缀.expected文件要和输入文件同名。第一次运行时没有期望文件会跳过先手工跑一次确认输出合理再把它保存成.expected后面就有了后悔药。4. 核心扫描循环拆解缓冲区、关键字表与最长匹配词法分析器的主循环看起来就是一个while加一个switch但真正决定稳定性的是缓冲区怎么读、什么时候回退、关键字表什么时候查。资源包里的实现如果用了“预读一个字符”的写法读代码时先找peek和advance两个函数如果用了“先读完整单词再分类”的写法先找单词缓冲区。两种写法都能做对关键是别在一个分支里既改指针又改行号最后回退时行号对不上。4.1 字符读取与回退两个指针比一个指针稳常见的手写扫描器用一个字符变量ch保存当前字符读下一个字符时覆盖它。这种写法简单但遇到“读到分隔符才知道上一个 token 结束”的情况需要把分隔符退回去。如果只用一个指针回退逻辑容易和行号维护纠缠在一起。我一般用两个位置pos指向下一个待读字符start指向当前 token 的起始位置扫描时先推进pos匹配失败就把pos回退到start之后的一个位置。这样 token 原文就是input[start:pos]不需要额外拼接。typedef struct { const char *src; // 输入缓冲区以 \0 结尾 int pos; // 下一个待读字符下标 int start; // 当前 token 起始下标 int line; int column; } Lexer; static char peek(Lexer *lx) { return lx-src[lx-pos]; } static char advance(Lexer *lx) { char c lx-src[lx-pos]; if (c \n) { lx-line; lx-column 1; } else { lx-column; } return c; } static void rewind_to_start(Lexer *lx) { // 只回退位置不重新计算行号行号在 token 起始处已经记录 lx-pos lx-start; }这段代码的逻辑是用pos做预读用start标记 token 起点。peek不推进位置advance推进并维护行号列号rewind_to_start把位置退回 token 起点。参数上src必须以\0结尾否则peek会越界line和column的初始值按实验要求设成 0 或 1输出时保持一致。注意回退后不要重新扫描一遍行号否则多行注释或字符串里的换行会被重复计数。更稳的做法是在 token 开始时保存line和column回退只改pos报错时用保存的行列号。提示如果输入文件很大不要一次性读入内存再扫描按块读也可以但回退逻辑会更复杂。课程实验一一般输入不大一次性读入更省事。4.2 关键字表与符号表别混在一起关键字表和符号表长得像作用完全不同。关键字表是固定集合用来判断一个标识符原文是不是语言保留字符号表是编译过程中记录变量、函数、类型的信息实验一通常还不需要。很多资源包在词法阶段就建了一个symtab把标识符都塞进去这不是错但容易让初学者误以为词法分析器要做语义检查。实验一如果只要求输出 token关键字表用静态数组或哈希表都行符号表可以留到后面实验。typedef struct { const char *word; TokenType type; } Keyword; static const Keyword keywords[] { {int, TOK_KEYWORD}, {float, TOK_KEYWORD}, {if, TOK_KEYWORD}, {else, TOK_KEYWORD}, {while, TOK_KEYWORD}, {return, TOK_KEYWORD}, // 按实验要求补全不要凭印象加语言特性 }; static TokenType lookup_keyword(const char *s, int len) { for (size_t i 0; i sizeof(keywords) / sizeof(keywords[0]); i) { if ((int)strlen(keywords[i].word) len strncmp(keywords[i].word, s, len) 0) { return keywords[i].type; } } return TOK_IDENT; }这段代码的逻辑是先按标识符规则读完整单词再用lookup_keyword查固定表。len参数很重要因为单词缓冲区可能不是以\0结尾直接strcmp会读到旧数据。strncmp只比较len个字符长度不等直接跳过。参数上关键字表要按实验要求补全不要加实验没定义的词如果关键字很多可以换成哈希表但实验一规模不大线性查找足够。常见坑是边读边查关键字读到i就以为命中了if结果后面还有字符必须先读完整标识符再查表。4.3 数字、标识符、注释的状态迁移怎么写数字和标识符的扫描相对直接注释和字符串更容易出边界问题。数字要处理小数点、指数、后缀具体看实验要求标识符要处理下划线和数字单行注释读到换行结束多行注释要跨行并维护行号字符串要处理转义和未闭合。下面这个scan_token是简化版主循环重点看状态迁移和最长匹配不追求覆盖所有语言特性。Token scan_token(Lexer *lx) { // 跳过空白但保留换行用于行号维护 while (peek(lx) || peek(lx) \t || peek(lx) \r || peek(lx) \n) { advance(lx); } Token tok {0}; tok.line lx-line; tok.column lx-column; lx-start lx-pos; char c peek(lx); if (c \0) { tok.type TOK_EOF; return tok; } if (isalpha((unsigned char)c) || c _) { advance(lx); while (isalnum((unsigned char)peek(lx)) || peek(lx) _) { advance(lx); } int len lx-pos - lx-start; tok.type lookup_keyword(lx-src lx-start, len); tok.lexeme strndup(lx-src lx-start, len); return tok; } if (isdigit((unsigned char)c)) { advance(lx); while (isdigit((unsigned char)peek(lx))) { advance(lx); } // 只有点号后还是数字才吞掉避免把 1..2 的第一个点吞进浮点数 if (peek(lx) . isdigit((unsigned char)lx-src[lx-pos 1])) { advance(lx); while (isdigit((unsigned char)peek(lx))) { advance(lx); } tok.type TOK_FLOAT_LIT; } else { tok.type TOK_INT_LIT; } tok.lexeme strndup(lx-src lx-start, lx-pos - lx-start); return tok; } if (c / lx-src[lx-pos 1] /) { advance(lx); advance(lx); while (peek(lx) ! \0 peek(lx) ! \n) { advance(lx); } tok.type TOK_COMMENT; tok.lexeme strndup(lx-src lx-start, lx-pos - lx-start); return tok; } // 单字符运算符双字符运算符按实验要求补 advance(lx); tok.type TOK_OP; tok.lexeme strndup(lx-src lx-start, 1); return tok; }这段代码的逻辑是先跳过空白记录 token 起始行列再按首字符分流。标识符分支读完整单词后查关键字数字分支用“点号后还是数字”做浮点数判断注释分支读到换行前结束。参数上strndup不是 C 标准函数某些环境要自己写malloc memcpy \0isalpha、isdigit的参数要转成unsigned char否则遇到负值字符可能越界。lx-src[lx-pos 1]这种预读要确保缓冲区以\0结尾否则会读越界。多行注释、字符串转义、双字符运算符没有展开按实验要求补状态分支即可补的时候记住一条先保证最长匹配再保证错误可恢复。5. 常见翻车现场排查五类词法分析器错误从现象到修复词法分析器的 bug 有个特点输出看起来“差不多”但一到边界输入就崩。下面这五类问题是我在课程实验一里见得最多的每一条都按“现象→原因→解决”写。排查时不要一上来改主循环先拿最小输入复现再看是输入层、状态机层还是输出层的问题。5.1 输入编码与行号类问题现象同样的测试文件在 Windows 上跑行号从 1 开始在另一个环境跑每个 token 行号都偏 1或者文件开头带一个看不见的字符第一行第一个 token 识别成错误。原因换行符有\r\n和\n两种代码只把\n当换行\r被当成普通字符文件可能带 BOM前三个字节不是源码内容。行号初始值有的实现从 0 开始有的从 1 开始测试期望文件没对齐。解决读文件后先判断并跳过 UTF-8 BOM扫描时把\r\n统一按一个换行处理或者读入时做换行归一化行号初始值按实验要求固定测试脚本里显式检查第一行。下面这段处理放在读文件之后即可。// 跳过 UTF-8 BOM避免第一个 token 变成非法字符 if (len 3 (unsigned char)buf[0] 0xEF (unsigned char)buf[1] 0xBB (unsigned char)buf[2] 0xBF) { memmove(buf, buf 3, len - 3 1); }这段代码的逻辑是检测 BOM 并把后面的内容前移。memmove比memcpy安全源和目标重叠时不会出问题。参数上len是读入字节数1是为了把结尾的\0一起移动。注意不要对二进制文件做这个处理课程实验一一般只处理文本源码。现象多行注释跨行后注释后面第一个 token 的行号偏大或偏小。原因回退时重新计算了行号或者注释扫描分支里手动改了line主循环又改了一次。解决行号只在advance里维护回退只改postoken 起始行列在扫描前保存输出时用保存值。多行注释分支不要自己写line统一走advance。5.2 状态机与 token 边界类问题现象intx被识别成关键字int加标识符x或者if1被切成if和1。原因边读边查关键字没有先读完整标识符或者关键字匹配用了前缀比较。解决先按标识符规则读到非标识符字符再拿完整单词查表查表时比较长度不用strncmp只比前缀。上面lookup_keyword已经带了len参数照这个思路改即可。现象1.0输出成INT(1)、OP(.)、INT(0)或者1..2在第二个点号处报错。原因数字分支看到点号就无条件吞掉没有判断点号后面是不是数字或者实验把..定义成范围运算符代码没处理双字符运算符。解决浮点数判断加点号后预读只有下一个字符是数字才进入小数部分双字符运算符按最长匹配先查两个字符的表再退回单字符。边界规则以实验要求为准不要凭直觉加语言特性。现象字符串里的//被当成注释字符串提前结束或者未闭合字符串把后面整个文件吞掉。原因字符串状态和注释状态没有分开扫描字符串时没有处理转义未闭合字符串没有错误恢复。解决字符串分支单独维护状态遇到\跳过转义遇到换行或文件结束仍未闭合就输出TOK_ERROR并回到起始状态注释分支只在非字符串状态下进入。错误恢复的目标是让后面的 token 还能继续扫描不是立刻退出程序。5.3 构建与测试脚本类问题现象本地make通过换一个目录就找不到头文件测试脚本跑出来全是空输出。原因Makefile 里用了绝对路径或依赖当前工作目录测试脚本调用可执行文件时没传输入文件程序从标准输入读却收到空。解决构建命令统一用相对路径加-I指定头文件目录测试脚本显式传文件或显式用input喂标准输入跑之前先ls确认可执行文件存在。下面这条检查放在脚本开头能省很多猜。from pathlib import Path lexer Path(build/lexer) if not lexer.exists(): raise SystemExit(先构建 build/lexer再跑回归脚本)这段代码只做一件事确认可执行文件存在。参数上路径按实际构建产物改如果 Windows 下是.exe记得加后缀。别在脚本里自动构建构建失败和测试失败混在一起很难排查。现象期望输出文件里 token 之间是空格实际输出是换行diff 全红。原因输出格式没按实验要求统一或者最后一行多了空格、少了换行。解决把输出格式集中到一个print_token函数里别在扫描分支里到处printf比较前对行尾空白做归一化但不要掩盖真正的 token 差异。格式问题看着小实验一扣分往往就扣在这里。6. 进阶验证用对拍和覆盖率把词法分析器压到边界把基本用例跑通后别急着交。词法分析器最怕“看起来对”我一般会做两件事对拍和边界集。对拍是拿两份实现跑同一组输入比较 token 流边界集是手工写一批专门打边界的输入比如intx、1..2、未闭合字符串、跨行注释、文件末尾无换行。对拍脚本不用复杂下面这个思路就够用一份是资源包自带实现一份是你改过的版本两边都输出“类别:原文:行号”逐行 diff。import subprocess def tokens(exe, src): p subprocess.run([exe], inputsrc, textTrue, capture_outputTrue, timeout5) if p.returncode ! 0: return fERROR {p.returncode} return p.stdout.strip() cases [ intx if1 1.0 1..2\n, /* a\n b */ int x;\n, \unclosed\n, ] for i, src in enumerate(cases, 1): a tokens(./build/lexer_old, src) b tokens(./build/lexer_new, src) if a ! b: print(fcase {i} 不一致) print(old:, a[:300]) print(new:, b[:300])这段脚本的逻辑是同一个输入分别喂给新旧两个可执行文件比较标准输出。timeout5防止某个版本在未闭合字符串上死循环returncode非零时直接返回错误标记不比较半截输出。参数上可执行文件名按实际改输入用例别只写合法代码错误输入更能暴露恢复逻辑的差异。对拍发现不一致时先看行号再看 token 原文最后看类别大多数差异都是回退位置或关键字查表时机不同。覆盖率可以用编译器的插桩选项跑一遍但课程实验一不必追求数字重点看分支有没有走到空白跳过、标识符、关键字、整数、浮点、单行注释、多行注释、字符串、非法字符、文件结束。每个分支至少一条输入文件末尾不要留空行专门测“最后一个 token 后面没有分隔符”的情况。血泪经验是很多实现最后一个 token 丢失就是因为主循环条件写成了“还有下一个字符”而不是“当前位置不是结尾”。从那以后我每次做词法分析器实验都先把最小输入、边界输入、错误输入三组用例写进脚本再动主循环改完先跑对拍再看行号最后才看输出格式。这份课程实验一资源包的价值不在于替你交作业而在于给你一个能对照的状态机和测试骨架。如果你手头正好缺一份能跑起来的词法分析器实现建议先按第 3 章的构建命令和第 6 章的对拍脚本走一遍确认 token 边界和输出格式都对得上再决定要不要把它放进自己的实验目录。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑