简介:面向计算机专业学生与编译原理初学者的课程实验配套代码包,聚焦北京邮电大学编译原理实验中的词法分析器实现,可用于理解扫描器如何把源程序字符流切分为标识符、关键字、常量与运算符等词素。压缩包共4个文件、约10KB,以2个txt文档、1个h头文件和1个cpp源文件为主,其中文档承担实验说明与测试输入,头文件与源文件承载词法规则定义、输入处理、分词逻辑及基础错误处理。内容围绕词法分析器的规则描述、符号识别与测试验证展开,适合对照课程要求梳理实现思路、检查边界输入和调试分词结果。已有1000人学习,可作为编译原理课程实验的参考实现与排错对照。
1. 词法分析器为什么总在细节上出问题
编译原理课程实验一十有八九是同一个任务:写一个词法分析器,把源程序字符流切成 token 流,按种别码输出。真动手才发现,考察点不在 DFA 那套理论,而在 token 表怎么定、注释要不要输出成 token、1..2到底切几段这些边界约定上。这份资源是某高校的课程实验压缩包,里面最值钱的是种别码表和一组带参考输出的测试输入,等于先把评分标准摆出来。它适合两类人:正在做词法分析实验、不确定边界怎么划的学生,和想拿一份能跑通的扫描器框架去改自己语言方言的工程师。下面按“先立理论、再写代码、最后对拍”的顺序逐步拆解。
2. 正规式到最小 DFA:状态表不是用来交差的
实验报告里那张状态转换表,很多人是写完代码回头补的,补出来的表十有八九和代码对不上。正确的顺序是先从词法规则推正规式,再构造 NFA,再子集构造得到 DFA,最小化,最后才是照着表写代码。表错了,代码只是错得不容易被发现。
2.1 先固定输入输出接口:种别码、属性值和错误码
压缩包里有份 token 定义文档,这一步最容易被跳过。课程实验的评分脚本通常按行读输出,格式差一个制表符就判错。常见约定是每行一个 token,三个字段:行号、种别码、属性值。种别码这里有两种流派,一种是每个关键字单独一个码(if是 1,while是 2),一种是关键字统一归到KW大类、属性值存原文。后者更好写,前者更贴合教材,选哪种看课程给的样例输出。
以一套常见的 C 语言子集为例:
| 类别 | 种别码 | 示例 | 属性值 |
|---|---|---|---|
| 关键字 | 1 ~ 20 | if / else / while / int / return | 关键字原文 |
| 标识符 | 100 | count、_tmp、MAX_N | 标识符名 |
| 整型常量 | 101 | 42、0x1F | 数值 |
| 浮点常量 | 102 | 3.14、1.5e-3 | 数值 |
| 运算符 | 200 起 | + >= <<= | 原文 |
| 界符 | 300 起 | ( ) { } , ; | 原文 |
| 错误 | 999 | @、未闭合注释 | 错误描述 |
接口里还有一条常被忽略:错误处理。非法字符不能让程序直接崩,也不能静默跳过。课程实验一般要求输出一行错误信息,带上行号和列号,然后从下一个字符继续扫描。这条要求决定了主循环必须用while逐字符推进,而不是一上来就按行split。
2.2 Thompson 构造:把标识符和数字手算成 NFA
正规式本身好写:标识符是letter (letter | digit)*,整数是digit+,浮点是digit+ "." digit+ (e (+|-)? digit+)?。难点在把它们变成状态图。Thompson 构造只有三条规则,记住就能手推:
单字符a:两个状态,一条标记为a的边。 连接RS:把 R 的接受态和 S 的起始态合并,或者中间加一条 ε 边。 选择R|S:新建起止两个状态,四条 ε 边分别指向 R、S 的起止。 闭包R*:新建起止状态,起始态 ε 到 R 起始态和接受态,R 接受态 ε 回 R 起始态和最终接受态。
手推一遍标识符:状态 0 读到字母进入状态 1,状态 1 读到字母或数字留在 1,状态 1 是接受态。就三个状态。数字更简单,两个状态自环。真正膨胀的是运算符那一坨,>=、>>、>>=三个规则合在一起,NFA 会到十几个状态,手推容易画晕,这时候先只推主干,运算符部分直接用字符串比较处理,是工程上更划算的做法。
提示:实验报告要求画 NFA 图时,别把注释、空白也画进去,它们不产生 token,属于扫描阶段的“吃掉”逻辑,不在词法规则的主线里。
2.3 子集构造与最小化:状态数从十几个压到八个
子集构造的核心是两个函数:ε-closure(S)求状态集合经过任意条 ε 边能到达的所有状态,move(S, a)求集合 S 中所有状态经过一条a边到达的状态。从ε-closure(起始态)出发,对每个输入符号算一次move再取闭包,得到新集合,直到没有新集合为止。
落到表上大概是这样,只列标识符和数字部分:
| DFA 状态 | 字母 / 下划线 | 数字 | . | 其他 | 备注 |
|---|---|---|---|---|---|
| 0 | 1 | 2 | - | - | 起始 |
| 1 | 1 | 1 | - | - | 接受 ID |
| 2 | - | 2 | 3 | - | 接受 INT |
| 3 | - | 3 | - | - | 接受 FLOAT |
最小化按 Hopcroft 的思路走:初始划分成“接受态集合”和“非接受态集合”两组,然后反复检查同一组内状态对每个输入符号的去向是否落在同一组,不同就拆开,直到划分不再变化。上面这张表里,状态 2 和状态 3 虽然都接受,但对数字的转移不同(2 去 2,3 去 3),所以不能合并。很多实验报告在这里写成“接受态都能合并”,是典型错误,答辩一问就露馅。
状态表的另一个用处是写代码时直接查表,而不是写一长串if-else。表驱动的好处是错误可定位:出现非法转移时,能立刻知道是哪个状态、读了哪个字符。代价是查表比switch慢一点,对课程实验这种输入规模完全无所谓。
3. 手写扫描器:最长匹配、回退和前瞻
理论推完,代码就好写了。手写扫描器的骨架很短,难的是三个细节:最长匹配、回退和前瞻。漏掉任何一个,测试用例里的>>=、1.5e-3、a.b都会切错。
3.1 主循环:一个 while 加三个分派函数
主循环负责跳过空白和注释、维护行列号、把活分派给标识符、数字、运算符三个扫描函数。这里给一个能直接跑的 Python 版本,结构照搬到 C 或 Java 也一样。
# lexer.py —— 手写 DFA 扫描器骨架 KEYWORDS = {"if", "else", "while", "for", "int", "float", "return", "void", "break", "continue"} class Token: def __init__(self, kind, text): self.kind, self.text = kind, text self.line, self.col = 0, 0 class LexError(Exception): def __init__(self, msg, line, col): super().__init__(f"第 {line} 行第 {col} 列: {msg}") def tokenize(src): toks, i, line, col, n = [], 0, 1, 1, len(src) while i < n: c = src[i] if c in " \t\r\f\v": # 空白直接吃,不进 token i += 1; col += 1 elif c == "\n": # 换行只更新位置 i += 1; line += 1; col = 1 elif src.startswith("//", i): # 行注释吃到行尾 while i < n and src[i] != "\n": i += 1 elif src.startswith("/*", i): # 块注释要跨行统计行号 end = src.find("*/", i + 2) if end < 0: raise LexError("未闭合的块注释", line, col) seg = src[i:end + 2] nl = seg.count("\n") if nl: line += nl col = len(seg) - seg.rfind("\n") else: col += len(seg) i = end + 2 elif c.isalpha() or c == "_": tok, j = scan_ident(src, i) tok.line, tok.col = line, col col += j - i; toks.append(tok); i = j elif c.isdigit(): tok, j = scan_number(src, i) tok.line, tok.col = line, col col += j - i; toks.append(tok); i = j else: tok, j = scan_op(src, i) if tok is None and c in "(){}[],;": tok, j = Token("DELIM", c), i + 1 if tok is None: raise LexError(f"非法字符 {c!r}", line, col) tok.line, tok.col = line, col col += j - i; toks.append(tok); i = j return tokscol的维护比想象中重要。课程实验的样例输出往往带列号,块注释跨行后如果只col += len(seg),后面所有 token 的列号都会偏。上面用rfind("\n")定位最后一行起点,就是补这个漏洞。LexError单独成类而不是直接print,是为了让上层能决定是继续扫描还是中断,评分类实验通常要求“报错但继续”。
3.2 标识符与数字:边界判定是主要问题
标识符简单,关键在于关键字优先。先查关键字表,再落到标识符,顺序反了if就永远是 ID。
def scan_ident(src, i): n, j = len(src), i + 1 while j < n and (src[j].isalnum() or src[j] == "_"): j += 1 word = src[i:j] # 关键字优先:这一步的顺序决定 if 是 KW 还是 ID return Token("KW" if word in KEYWORDS else "ID", word), j数字麻烦得多。三个坑:小数点和成员访问符冲突、指数部分可省略、前导零。下面这个版本把三个都处理了。
def scan_number(src, i): n, j, is_float = len(src), i, False while j < n and src[j].isdigit(): j += 1 # 只有 "." 后面紧跟数字才吞小数点,否则 1..2 和 a.b 会被吃坏 if j + 1 < n and src[j] == "." and src[j + 1].isdigit(): is_float = True j += 1 while j < n and src[j].isdigit(): j += 1 # 指数部分:1e10 合法,1e 后面没数字就整体不认,e 留给标识符 if j < n and src[j] in "eE": k = j + 1 if k < n and src[k] in "+-": k += 1 if k < n and src[k].isdigit(): is_float = True j = k while j < n and src[j].isdigit(): j += 1 return Token("FLOAT" if is_float else "INT", src[i:j]), jj + 1 < n and src[j] == "." and src[j+1].isdigit()这个条件就是前瞻,先看一眼再决定吃不吃。不用前瞻的写法是“先无条件吃掉点,发现后面不是数字再回退一个字符”,ungetc那套。两种写法都对,但回退版本在块注释和字符串里容易把位置算错,我更倾向前瞻。
1e这种输入,按上面的逻辑会切成INT(1)和ID(e),而不是报错。这符合大多数教材词法,因为e本身可以是标识符。如果课程要求报“非法数值”,就在指数判断失败时抛LexError,改一行的事。
3.3 运算符最长匹配:按长度分桶查
运算符是最容易偷懒的地方。写一串if c == '>'然后判断下一个字符,代码能跑,但>>=会被切成>>和=。正确做法是按长度从长到短查表,三字符优先,然后两字符,最后一字符。
OPS = ( {"<<=", ">>=", "..."}, # 3 字符 {"==", "!=", "<=", ">=", "&&", "||", "++", "--", "->", "+=", "-=", "*=", "/=", "<<", ">>"}, # 2 字符 {"+", "-", "*", "/", "%", "=", "<", ">", "!", "&", "|", "^", "~", "?", ":", "."}, # 1 字符 ) def scan_op(src, i): for length, bucket in ((3, OPS[0]), (2, OPS[1]), (1, OPS[2])): cand = src[i:i + length] if len(cand) == length and cand in bucket: return Token("OP", cand), i + length return None, ilen(cand) == length这个判断不能省,文件末尾只剩两个字符时src[i:i+3]会返回长度 2 的串,不加判断可能误匹配。分桶顺序就是匹配优先级,>>>如果你语言里有,放第一桶;>>=和>>都在,长的必须先查。
3.4 注释和字符串:吃掉但不出 token
注释在 3.1 的主循环里已经处理了,要点是块注释必须检查find的返回值,返回-1说明到文件尾都没找到*/,这时候不报错就会把后面整个文件当注释吃掉,输出一片空白,排错时很难往这上面想。
字符串常量比注释复杂,因为要处理转义。"a\"b"里的\"不能当结束引号。常见写法是维护一个escape标志:
def scan_string(src, i, line, col): n, j, buf = len(src), i + 1, [] while j < n: ch = src[j] if ch == "\\" and j + 1 < n: # 转义:原样收两个字符 buf.append(src[j:j + 2]); j += 2; continue if ch == '"': # 未转义的引号才是结束 return Token("STR", "".join(buf)), j + 1 if ch == "\n": # 字符串不许跨行,直接报错 raise LexError("未闭合的字符串", line, col) buf.append(ch); j += 1 raise LexError("未闭合的字符串", line, col)字符串要不要进 token 表,取决于课程要求。有的实验只要求识别标识符、数字、运算符,字符串直接跳过;有的要求输出STR类型。压缩包里的种别码表如果列了STR,就不能省。
4. 拿生成器校准手写版:对拍比肉眼靠谱
手写扫描器写到后半段,自己看自己的代码已经没有判断力了。这时候最省时间的做法是拿一个成熟的词法生成器跑同一份输入,把两边的 token 流逐行比对。这不是为了交作业,是为了定位自己到底错在哪一类 token 上。
4.1 规则文件里的书写顺序就是优先级
以 lex/flex 这类工具为例,规则段的顺序决定匹配优先级,同一长度下先写的赢。这正好对应手写版里“关键字优先于标识符”的那条规则。
%option noyywrap %{ #include <stdio.h> %} DIGIT [0-9] LETTER [A-Za-z_] %% "if"|"else"|"while"|"for"|"int"|"float"|"return" { printf("KW\t%s\n", yytext); } {LETTER}({LETTER}|{DIGIT})* { printf("ID\t%s\n", yytext); } {DIGIT}+"."{DIGIT}+([eE][+-]?{DIGIT}+)? { printf("FLOAT\t%s\n", yytext); } {DIGIT}+ { printf("INT\t%s\n", yytext); } ">>="|"<<="|"=="|"!="|"<="|">="|"&&"|"||" { printf("OP\t%s\n", yytext); } [+\-*/%<>=!] { printf("OP\t%s\n", yytext); } [ \t]+ { } \n { } . { printf("ERR\t%s\n", yytext); } %%注意浮点规则排在整数前面。反过来写的话,3.14会先被{DIGIT}+匹配走3,剩下.14再报错,这是生成器路线最常见的翻车点。手写版里对应的是“先判小数点再决定 INT 还是 FLOAT”,逻辑一样,只是表现形式不同。
编译和运行:
# 生成 C 源码并编译,-o 指定产物名,输入从 stdin 或文件均可 flex -o ref_lexer.c lexer.l cc -O2 -o ref_lexer ref_lexer.c ./ref_lexer tests/basic.c-O2对课程实验无所谓,加不加都行。产物ref_lexer只作为参照,不参与最终提交。
4.2 对拍脚本:两份 token 流逐行比
对拍脚本的关键是先把两边输出归一化成同一个格式。手写版直接调函数拿(kind, text),生成器版读子进程输出按制表符切。行号列号先不比,第一轮只比 token 序列,能过滤掉大部分实现错误。
# diff_test.py —— 手写版与生成器版对拍 import subprocess from lexer import tokenize def load_hand(path): return [(t.kind, t.text) for t in tokenize(open(path, encoding="utf-8").read())] def load_ref(path): out = subprocess.run(["./ref_lexer", path], capture_output=True, text=True, check=True).stdout return [tuple(line.split("\t", 1)) for line in out.splitlines() if line.strip()] for case in ["tests/basic.c", "tests/number.c", "tests/comment.c", "tests/op.c"]: hand, ref = load_hand(case), load_ref(case) if hand == ref: print(f"{case}: 一致,{len(hand)} 个 token") continue for idx, (a, b) in enumerate(zip(hand, ref)): if a != b: print(f"{case}: 第 {idx} 个 token 不一致,手写={a} 参考={b}") break else: print(f"{case}: 长度不一致,手写 {len(hand)},参考 {len(ref)}")第一轮跑完,不一致的地方基本集中在三类:数字和点、运算符长度、注释边界。定位到具体是第几个 token,再回头看那一段源码,比通读代码快得多。
4.3 输出格式对齐:行列号、种别码映射和空行
第一轮只比(kind, text),第二轮要把行列号加回来对着样例输出核。这里有个坑:生成器版的yylineno默认从 1 开始,但块注释跨行时它不一定更新,需要开%option yylineno并在规则里手动维护。手写版如果行列号是自己算的,两边对不上很正常,这时候以课程给的样例输出为准,别以生成器为准。
空行处理也容易出问题。样例输出里通常没有空行,但自己实现里如果每个 token 后多打一个\n\n,评分类脚本按行比对就全错。写完先cat -A看一眼行尾,确认没有多余的^M和空行。Windows 下编辑过的文件带 CRLF,\r会被当成未知字符,也会造成“多一个 ERR token”的假象。
5. 避坑与排查:五条踩过的记录
这些问题是实际跑下来最耗时间的,按“现象、原因、解决”记下来,遇到同类情况直接对号入座。
5.1 环境与构建环节
现象:压缩包解压后第一个源文件读进来就是乱码,输出的 token 全是ERR。原因:文件是 GBK 编码,而运行环境默认按 UTF-8 读。中文只出现在注释里时不容易发现,出现在字符串里就整段乱掉。解决:读文件时显式指定编码,Python 用open(path, encoding="gbk", errors="replace");或者先用编辑器批量转成 UTF-8 再跑。转完记得用file -i确认一遍,别凭肉眼判断。
现象:同一份输入,在同学机器上 token 数正好,在自己机器上多出几个ERR。原因:文件用 CRLF 换行,\r没被列入空白字符集合,每个行尾都多一个非法字符。解决:空白判断里加上\r,或者在读入阶段统一做src.replace("\r\n", "\n")。前者更稳,因为有些输入是管道传进来的,换行符不可控。
现象:相对路径打开测试文件报FileNotFoundError,但文件明明就在旁边。原因:IDE 的运行工作目录不是脚本所在目录,open("tests/basic.c")会去 IDE 的工程根下找。解决:用os.path.join(os.path.dirname(os.path.abspath(__file__)), "tests/basic.c")拼绝对路径,或者在命令行里cd到脚本目录再跑。课程实验的评分脚本通常从固定目录调用,路径写死更安全。
5.2 词法边界环节
现象:输入1..2被切成一个浮点数,或者报“非法字符”。原因:数字扫描无条件吞小数点,没检查后面是不是数字;另一种是先吞了点再回退,回退时把位置算错了。解决:用前瞻,src[j] == "." and j + 1 < n and src[j+1].isdigit()三个条件同时成立才当浮点。1..2应该切成INT(1)、OP(.)、OP(.)、INT(2),这是多数教材的约定,具体以课程样例为准。
现象:文件里有一个/*没配对的注释,程序跑完输出空,也不报错。原因:块注释用while src[i:i+2] != "*/"往后推,到文件尾也没跳出,循环结束但没报错,前面已经扫到的 token 也可能没输出。解决:用find并检查返回值,-1时抛LexError带上起始行列号。报错后是继续还是中断,按课程要求定,但绝不能静默吞掉。
现象:关键字被识别成标识符,输出里全是ID,一个KW都没有。原因:先查符号表再查关键字表,或者干脆忘了查关键字表;也可能是关键字集合里大小写写错,If和if混在一起。解决:顺序固定成“先关键字后标识符”,关键字集合用frozenset防止运行中被改。C 语言区分大小写,If就是标识符,别为了“友好”做大小写归一化,那是另一个错误。
注意:报错信息里带行列号这件事,调试阶段价值极高,交作业阶段反而容易因为格式不符被判错。先保证格式和样例一致,再考虑加不加额外信息。
6. 用对拍脚本做回归:三组必测输入
做到最后,真正能保证不返工的,是一份固定的回归用例集。我自己的习惯是每个词法分析器都配三组输入,缺一组都不算写完。
6.1 三组输入覆盖什么
| 用例 | 覆盖点 | 典型内容 |
|---|---|---|
basic.c | 主干正确性 | 声明、赋值、if/while、函数调用 |
number.c | 数字边界 | 0、1.、1..2、1e10、1.5e-3、1e |
edge.c | 异常路径 | 未闭合注释、未闭合字符串、非法字符、CRLF 混用 |
basic.c用来验证主干别写崩,number.c用来验证前瞻逻辑,edge.c用来验证报错不崩、位置准确。三组里最容易被跳过的是第三组,但它恰恰是评分脚本最爱加隐藏用例的地方。
6.2 把对拍脚本挂进日常流程
# regression.py —— 改动后一键回归 import sys from diff_test import load_hand, load_ref CASES = ["tests/basic.c", "tests/number.c", "tests/edge.c"] fail = 0 for case in CASES: try: hand, ref = load_hand(case), load_ref(case) except Exception as e: # 手写版抛 LexError 时也要记录,不算通过 print(f"{case}: 抛出异常 {e}") fail += 1 continue if hand != ref: print(f"{case}: token 流不一致,{len(hand)} vs {len(ref)}") fail += 1 print("回归通过" if fail == 0 else f"{fail}/{len(CASES)} 组失败") sys.exit(1 if fail else 0)脚本返回非零退出码,方便挂到提交前的手动检查里。改一行动手写扫描器的逻辑,跑一遍,看三组是不是都过。有一次我为了修0x1F十六进制常量,顺手改了数字扫描的循环条件,结果1.5e-3挂了,就是对拍脚本在下班前拦住的。
还有一个经验:别在交作业前一天重构扫描器结构。词法分析器的代码量不大,但状态之间的耦合比看上去紧,行列号、回退位置、前瞻窗口这几样东西改一处动全身。从那以后我每次动数字或运算符的匹配逻辑之前,都强制先把regression.py跑一遍留个基线,改完再跑一遍对照。希望帮到你。
本文还有配套的精品资源,点击获取