ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

词法分析器实验一: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 代码用gcc,C++ 代码用g++,不要混用。编译产物统一放到build目录,后面清理方便。

# 有 Makefile 时先原样构建 make # 没有 Makefile 时,用固定命令编译 C 源码 mkdir -p build gcc -std=c11 -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

这段命令的逻辑是先尝试项目自带构建方式,失败再手动编译。-std=c11固定语言标准,避免不同环境下的隐式声明差异;-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(encoding="utf-8") proc = subprocess.run( [str(LEXER)], input=data, text=True, capture_output=True, timeout=5, ) 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(encoding="utf-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用input=data从标准输入传数据,text=True按文本处理,timeout=5防止程序卡死。capture_output=True同时收 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 + '\0';isalpha、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里维护,回退只改pos;token 起始行列在扫描前保存,输出时用保存值。多行注释分支不要自己写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], input=src, text=True, capture_output=True, timeout=5) if p.returncode != 0: return f"ERROR {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(f"case {i} 不一致") print("old:", a[:300]) print("new:", b[:300])

这段脚本的逻辑是同一个输入分别喂给新旧两个可执行文件,比较标准输出。timeout=5防止某个版本在未闭合字符串上死循环;returncode非零时直接返回错误标记,不比较半截输出。参数上,可执行文件名按实际改,输入用例别只写合法代码,错误输入更能暴露恢复逻辑的差异。对拍发现不一致时,先看行号,再看 token 原文,最后看类别;大多数差异都是回退位置或关键字查表时机不同。

覆盖率可以用编译器的插桩选项跑一遍,但课程实验一不必追求数字,重点看分支有没有走到:空白跳过、标识符、关键字、整数、浮点、单行注释、多行注释、字符串、非法字符、文件结束。每个分支至少一条输入,文件末尾不要留空行,专门测“最后一个 token 后面没有分隔符”的情况。血泪经验是:很多实现最后一个 token 丢失,就是因为主循环条件写成了“还有下一个字符”而不是“当前位置不是结尾”。

从那以后我每次做词法分析器实验,都先把最小输入、边界输入、错误输入三组用例写进脚本,再动主循环;改完先跑对拍,再看行号,最后才看输出格式。这份课程实验一资源包的价值不在于替你交作业,而在于给你一个能对照的状态机和测试骨架。如果你手头正好缺一份能跑起来的词法分析器实现,建议先按第 3 章的构建命令和第 6 章的对拍脚本走一遍,确认 token 边界和输出格式都对得上,再决定要不要把它放进自己的实验目录。希望帮到你。

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

RELATED READING

延伸阅读

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