简介:面向编译原理课程实验的C++词法分析器实现,适合正在学习词法分析、需要完成类似实验任务的高校学生。程序启动后输入测试程序名即可自动分析,结果以二元式序列输出单词类别与属性,覆盖标识符、关键字、运算符等常见词法单元;同时能够检测非法字符、字符常数缺少右单引号、注释缺少结束界符三类典型错误,并指出错误性质与位置,便于对照调试。RAR压缩包内共1个文件,核心代码为cpp源文件,整体仅2KB,结构精简,便于直接查看或移植到自己的实验框架中,可结合课程要求快速理解词法分析核心流程。目前已有4264人学习下载,对希望快速掌握词法分析器实现思路、错误定位方法以及二元式输出格式的同学有参考价值。
1. 词法分析器在编译原理实验里的真实位置:先动手再补理论
把词法分析器当成第一个 C 语言大作业来写,十个人里有七个卡在最开始的状态设计上:不是不会写 C,而是不知道状态机该往哪儿走。词法分析器是编译原理实验里最靠前的一环,它把源代码字符串流切成 token——关键字 if、整数 42、运算符 <=,每类 token 都有自己的模式。说穿了,它就是一个正则表达式的运行时实现,难点不在概念,而在把正则转换成能查表的状态机,还得处理最长匹配、字符回退这些细节。这篇笔记按我实际做这个实验的顺序来:先用三张表把状态机拆明白,再给出一份能跑的最小 C 实现,最后列出调试中容易翻车的五个场景。适合正在做编译原理实验的学生,也适合想复习词法分析工程细节的开发者。
2. 把词法规则拆成状态机:三张表与手动构造DFA的套路
2.1 为什么词法规则长成“正则表达式”而不是“上下文无关文法”
词法单元的本质是一个字符串集合,比如“标识符”就是“以字母或下划线开头,后面跟任意个字母、数字或下划线”的所有字符串的集合。正则表达式恰好能用三种基本运算描述这类集合:连接(ab)、选择(a|b)、闭包(a*)。标识符的经典写法[a-zA-Z_][a-zA-Z0-9_]*就是“一个字符类连接一个闭包”,数字[0-9]+是闭包的简写。词法规则整体落在正则语言这个层级里,这一条先立住,后面所有表驱动设计才有根据。
为什么词法阶段不直接用上下文无关文法?因为词法单元不需要处理嵌套和计数。注释里的/* ... */看起来像嵌套结构,但主流语言都不允许注释嵌套;字符串字面量内部虽然有转义,但词法阶段只需要识别边界,不需要在字符粒度上做推导。如果把这些规则写进 CFG,语法分析器就要在字符粒度上做推导,既慢又容易出现歧义,属于把简单问题复杂化。
正则语言与 DFA 等价这一点,是词法分析器能用“查表”实现的根基。教科书标准路径是“正则 → NFA → DFA → 最小化”,但手写一个小语言的词法分析器时,直接枚举 DFA 状态往往更快。token 种类一多,NFA 转 DFA 的子集构造法很容易造出几十个状态的中间产物;而手写时从“我要支持几种 token”出发,状态数通常控制在十几个以内,转移表也能手填。
2.2 三张核心表:字符类表、状态转移表、动作表
手写词法分析器最常见的翻车方式,是把所有判断写成 if-else,每来一个字符就套一层 if,最后代码变成一团乱麻。我一般用三张表把状态机固化下来,三张表缺一张,后面的调试都会变成玄学。
第一张是字符类表。ASCII 字符有 128 个,如果状态转移表按字符做列,光一个初态行就要填 128 个格子,完全没有可读性。先把字符映射到几个类别:
| 字符范围 | 字符类 | 说明 |
|---|---|---|
| a-z A-Z _ | C_LETTER | 标识符起始和中间字符 |
| 0-9 | C_DIGIT | 数字 |
| 空格、\t、\n、\r | C_WS | 空白,跳过 |
| < > | C_LTGT | 可能组成复合运算符 |
| ! | C_EXCL | 只有 != 合法 |
| = | C_EQ | 可能是赋值或 == |
| + - * / % | C_OP | 单字符运算符 |
| ( ) { } ; , | C_PAREN | 分隔符 |
| 其他可打印字符 | C_OTHER | 错误或字符串内容 |
映射逻辑就是 classify 函数里的一串分支,代码不复杂,但它决定了转移表的列数。列数少了,两个语义不同的字符会被合并成同一列,表里的状态就分不开;列数多了,表又回到 128 列的噩梦。控制在 8 到 10 列比较合适。
第二张是状态转移表。行是当前状态,列是字符类,值是下一状态。终态用 ST_DONE 或专门的终态编号标记,非法转移用 ST_ERROR 标记。物理形态就是一个二维数组,初始化完不再变,后面所有“当前字符该往哪走”都只查这一张表,不再写分支判断。
第三张是动作表。状态到达终态后,需要决定产出哪个 token,以及是否要回退最后一个字符。动作表的输入是“终态状态 + 已读字符序列”,输出是 token 类型和回退标记。关键字识别也挂在动作表上:标识符类 token 到达终态后,先查关键字表,命中就变成对应关键字。
三张表分开后,“加一种 token”从改代码变成改数据。比如给语言加一个取模运算符 %,只需要在 classify 里把 % 归到 C_OP(如果之前漏了),转移表不用动,动作表里加一条单字符映射;如果要加复合运算符 &&,才需要加一个状态和一列。
2.3 手动构造DFA的步骤与状态编号规则
小语言的 DFA 不需要严格走机械化转换,按四步枚举就行。第一步,把要支持的 token 正则全部列出来。第二步,给每个正则从初态出发走一遍,路过的地方按顺序编号,重复出现的位置用同一个编号。第三步,合并功能相同的状态——比如“已经进入标识符”和“已经进入关键字”本质是同一个状态,区别只在动作表。第四步,把每个状态的转移关系填成表。
状态编号我遵循一个顺序:先编字符都还没被消费的初态,再编“读了多少字符、具备什么含义”的中间态,最后编终态和错误态。以 2.2 节的目标语言为例,状态分配如下:
| 状态 | 含义 | 进入条件 | 离开条件 |
|---|---|---|---|
| ST_START | 初态,尚未读入任何词素字符 | 每次 token 识别开始时 | 读到任何非空白字符 |
| ST_ID | 正在读标识符 | 初态读到 C_LETTER | 读到非字母非数字 |
| ST_NUM | 正在读整数 | 初态读到 C_DIGIT | 读到非数字 |
| ST_CMP | 已读 < 或 >,等待判断是否复合 | 初态读到 C_LTGT | 读 = 则成复合,读其他则回退 |
| ST_NOT | 已读 !,只接受 != | 初态读到 C_EXCL | 读 = 成 !=,读其他报错 |
| ST_EQ | 已读 =,等待判断是否 == | 初态读到 C_EQ | 读 = 成 ==,读其他回退为赋值 |
编号规则只有一条硬性要求:每个状态在表里至少有一行,终态和错误态也要编号,即使它们的转移行全填 ST_ERROR。原因很实际:动作表要靠状态编号索引,编号断了,查表就漏。
手填表最容易漏的是“回退转移”。比如标识符后跟一个括号,读到(时已经处于 ST_ID,此时(不属于标识符,必须回退给下一轮,这个转移要显式写成“所有非字母数字列都指向 ST_DONE”。漏了这一步,括号会被吞进标识符里,后面语法分析阶段怎么查都查不对。
3. 用C语言写一个能跑的最小词法分析器:环境、代码与参数说明
3.1 目标语言与token设计:先定一个最小但完整的子集
这里我给一个最小但完整的子集:标识符、4 个关键字(if、else、while、return)、十进制整数、单字符运算符(+ - * / %)、复合运算符(<= >= == !=)、赋值 =、比较 < >、分隔符(( ) { } ; ,)。选这个集合有两个理由。第一,它逼着你必须处理最长匹配:<= 由两个单字符运算符拼成,不处理它,逻辑运算符会被拆成两个,语法分析阶段直接崩。第二,它包含关键字回查和非法字符报错两条动作路径,做完它,再往语言里加注释、字符串、浮点数都只是加状态,不动框架。
| token 类别 | 举例 | 正则(简写) |
|---|---|---|
| TOK_ID | count, _tmp | [a-zA-Z_][a-zA-Z0-9_]* |
| TOK_IF / TOK_ELSE / TOK_WHILE / TOK_RETURN | if else while return | 关键字表 |
| TOK_NUM | 42 0 007 | [0-9]+ |
| TOK_PLUS / TOK_MINUS / TOK_STAR / 等 | + - * / % | 单字符 |
| TOK_LE / TOK_GE / TOK_EQ / TOK_NE | <= >= == != | 双字符 |
| TOK_LT / TOK_GT / TOK_ASSIGN | < > = | 单字符 |
| TOK_LPAREN / TOK_RPAREN / 等 | ( ) { } ; , | 单字符 |
| TOK_ERROR | @ # | 非法字符 |
3.2 字符分类与token类型定义
#include <stdio.h> #include <string.h> #include <ctype.h> #define MAX_LEXEME_LEN 256 typedef enum { TOK_EOF = 0, TOK_ID, TOK_NUM, TOK_IF, TOK_ELSE, TOK_WHILE, TOK_RETURN, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_PERCENT, TOK_LT, TOK_LE, TOK_GT, TOK_GE, TOK_EQ, TOK_NE, TOK_ASSIGN, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_SEMI, TOK_COMMA, TOK_ERROR } TokenType; typedef enum { C_LETTER, C_DIGIT, C_WS, C_LTGT, C_EXCL, C_EQ, C_OP, C_PAREN, C_OTHER, CHAR_CLASS_COUNT } CharClass; static CharClass classify(int ch) { if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_') return C_LETTER; if (ch >= '0' && ch <= '9') return C_DIGIT; if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') return C_WS; if (ch == '<' || ch == '>') return C_LTGT; if (ch == '!') return C_EXCL; if (ch == '=') return C_EQ; if (ch == '+' || ch == '-' || ch == '*' || ch == '/' || ch == '%') return C_OP; if (ch == '(' || ch == ')' || ch == '{' || ch == '}' || ch == ';' || ch == ',') return C_PAREN; return C_OTHER; } static struct { const char *name; TokenType type; } keywords[] = { {"if", TOK_IF}, {"else", TOK_ELSE}, {"while", TOK_WHILE}, {"return", TOK_RETURN} };classify 就是把字符类表落在代码里。判断顺序里有两点要注意:下划线_并进 C_LETTER,这样标识符正则不用单独处理下划线;/暂时归到 C_OP 里当普通运算符,后面加注释支持时要把它单独拆出来,第 5 章会讲。关键字表是动作表的一部分,在识别完整个词素后再查,而不是在读字符时逐个匹配关键字——后者会让状态数爆炸,而且关键字和标识符本来就共享同一个正则。
3.3 状态转移表初始化
typedef enum { ST_START = 0, ST_ID, ST_NUM, ST_CMP, ST_NOT, ST_EQ, ST_LE_GE, ST_NE, ST_EQ2, ST_DONE, ST_ERROR, ST_COUNT } LexState; static int trans[ST_COUNT][CHAR_CLASS_COUNT]; static void init_transition(void) { int s, c; for (s = 0; s < ST_COUNT; s++) for (c = 0; c < CHAR_CLASS_COUNT; c++) trans[s][c] = ST_ERROR; /* 初态 */ trans[ST_START][C_LETTER] = ST_ID; trans[ST_START][C_DIGIT] = ST_NUM; trans[ST_START][C_WS] = ST_START; /* 空白直接忽略 */ trans[ST_START][C_LTGT] = ST_CMP; trans[ST_START][C_EXCL] = ST_NOT; trans[ST_START][C_EQ] = ST_EQ; trans[ST_START][C_OP] = ST_DONE; trans[ST_START][C_PAREN] = ST_DONE; trans[ST_START][C_OTHER] = ST_ERROR; /* 标识符和数字:读到其他类别即终结 */ trans[ST_ID][C_LETTER] = ST_ID; trans[ST_ID][C_DIGIT] = ST_ID; trans[ST_NUM][C_DIGIT] = ST_NUM; /* 复合运算符 */ trans[ST_CMP][C_EQ] = ST_LE_GE; trans[ST_NOT][C_EQ] = ST_NE; trans[ST_EQ][C_EQ] = ST_EQ2; /* 其余位置保持 ST_ERROR,驱动循环里按非法转移报错 */ }初始化统一填 ST_ERROR,再覆盖有效路径,这样表里空着的格子就是天然的报错路径。注意 ST_START 的 C_WS 指向自身,这是“空白跳过”在表里的表达;ST_ID 和 ST_NUM 行里其余列留 ST_ERROR,但驱动循环会把它们当作“词素结束”处理,不会真报错。这个设计靠驱动循环区分,也是初学者最容易看不懂的地方。
3.4 驱动循环get_next_token:最长匹配与字符回退
static void append(int *lexeme, int *len, int ch) { if (*len < MAX_LEXEME_LEN - 1) lexeme[(*len)++] = ch; } static TokenType lookup_keyword(const int *lexeme, int len) { char buf[MAX_LEXEME_LEN]; if (len >= MAX_LEXEME_LEN) return TOK_ID; memcpy(buf, lexeme, len); buf[len] = '\0'; for (size_t i = 0; i < sizeof(keywords) / sizeof(keywords[0]); i++) if (strcmp(buf, keywords[i].name) == 0) return keywords[i].type; return TOK_ID; } static TokenType single_char_token(int ch) { switch (ch) { case '+': return TOK_PLUS; case '-': return TOK_MINUS; case '*': return TOK_STAR; case '/': return TOK_SLASH; case '%': return TOK_PERCENT; case '<': return TOK_LT; case '>': return TOK_GT; case '=': return TOK_ASSIGN; case '(': return TOK_LPAREN; case ')': return TOK_RPAREN; case '{': return TOK_LBRACE; case '}': return TOK_RBRACE; case ';': return TOK_SEMI; case ',': return TOK_COMMA; default: return TOK_ERROR; } } static TokenType make_token(LexState state, const int *lexeme, int len) { switch (state) { case ST_ID: return lookup_keyword(lexeme, len); case ST_NUM: return TOK_NUM; case ST_CMP: return lexeme[0] == '<' ? TOK_LT : TOK_GT; case ST_EQ: return TOK_ASSIGN; case ST_LE_GE: return lexeme[0] == '<' ? TOK_LE : TOK_GE; case ST_NE: return TOK_NE; case ST_EQ2: return TOK_EQ; case ST_START: return single_char_token(lexeme[0]); default: return TOK_ERROR; } } TokenType get_next_token(FILE *in, int *lexeme, int *len) { int state = ST_START; *len = 0; int ch; while ((ch = fgetc(in)) != EOF) { CharClass cl = classify(ch); int next = trans[state][cl]; if (next == ST_ERROR) { if (state == ST_START) return TOK_ERROR; /* 非法字符 */ ungetc(ch, in); return make_token(state, lexeme, *len); } if (next == ST_DONE) { if (state == ST_START) { append(lexeme, len, ch); /* 单字符 token */ return make_token(ST_START, lexeme, *len); } ungetc(ch, in); return make_token(state, lexeme, *len); } if (next == ST_LE_GE || next == ST_NE || next == ST_EQ2) { append(lexeme, len, ch); /* 双字符运算符的第二个字符 */ return make_token(next, lexeme, *len); } if (next == ST_START) { continue; /* 空白,不追加 */ } append(lexeme, len, ch); state = next; } if (*len > 0) /* EOF 时缓冲区还有半截 token */ return make_token(state, lexeme, *len); return TOK_EOF; }这段有三处必须讲透。ungetc 是单级缓冲,连续回退两个字符会丢数据,所以驱动循环只在“最后一个字符不属于当前 token”时回退一次;双字符运算符的第二个字符是在 next 为终态时直接追加进词素的,不能走 ST_DONE 分支,否则这个字符被回退,下次再读永远凑不齐<=;空白跳过放在 next == ST_START 分支,而不是追加后再判断。lexeme 和 len 由调用方持有,避免 token 字符串跨函数拷贝,也方便在 main 里直接打印。
3.5 编译运行与最小测试
int main(void) { init_transition(); int lexeme[MAX_LEXEME_LEN], len; TokenType t; while ((t = get_next_token(stdin, lexeme, &len)) != TOK_EOF) { if (t == TOK_ERROR) { fprintf(stderr, "非法字符或非法状态: %.*s\n", len, lexeme); return 1; } printf("%d\t%.*s\n", t, len, lexeme); } return 0; }token 用数字打印,方便后面用脚本对比输出。测试输入准备一个 test.c:
if (a <= 10) { b = a + 1; } else { return -1; }gcc -Wall -Wextra -std=c99 -o lexer lexer.c ./lexer < test.cCC = gcc CFLAGS = -Wall -Wextra -std=c99 lexer: lexer.c $(CC) $(CFLAGS) -o $@ $<-std=c99 是因为代码里用了声明混用和//注释;-Wall -Wextra 会把未初始化表之类的问题提前暴露。main 从 stdin 读输入而不是打开文件,方便在脚本里反复喂不同用例,这是调试阶段最省事的姿势。
4. 手写识别与自动生成二选一:选型、改法和常见误用
4.1 手写识别适合什么场景
手写状态机的优势不在性能,在诊断信息和行为控制。错误提示可以精确到“第几行第几列,!后面只能接=”,这在教学实验和嵌入式前端里特别值钱。另一个原因是构建简单:不依赖额外的生成工具,一个 .c 文件就能编译。
但手写也有明显的边界。token 种类超过 30 种、运算符存在大量长复合(<<=、->、::)时,手工状态数会涨到 40 个以上,转移表错误率陡增。这时候再用“枚举状态 + 手填表”就是在给自己挖坑,每一行表都要手工核对,加一个运算符要改五六处。
4.2 自动生成(flex)适合什么场景
flex 的核心价值是把“正则 → 表”这种机械劳动自动化。写一个 .l 文件声明 token 规则,flex 生成 C 文件,里面包含 yylex 和一张内部状态表。规则多、改得勤的场景适合 flex:每加一个关键字就加一行规则,而不是在状态表里找半天该往哪个状态插转移。生成的表经过长期打磨,对最长匹配和 DFA 最小化都处理得干净。
代价是要接受一个黑匣子:错误定位信息来自生成代码,遇到 flex 版本的边界行为还得回去读生成文件。另外 flex 生成的代码体积比手写大不少,在资源受限的嵌入式环境里不一定划算。
4.3 flex 的最小配置与 C 接入方式
flex 的输入文件分三段:定义段、规则段、用户代码段。一个最小例子长这样:
%{ #include "tokens.h" int line = 1; %} %% [ \t\n]+ { /* 跳过空白 */ } "//"[^\n]* { /* 行注释 */ } "/*"([^*]|\*+[^*/])*"*/" { /* 块注释 */ } if { return TOK_IF; } else { return TOK_ELSE; } while { return TOK_WHILE; } return { return TOK_RETURN; } [0-9]+ { yylval.ival = atoi(yytext); return TOK_NUM; } [a-zA-Z_][a-zA-Z0-9_]* { return TOK_ID; } "<=" { return TOK_LE; } ">=" { return TOK_GE; } "==" { return TOK_EQ; } "!=" { return TOK_NE; } "<" { return TOK_LT; } ">" { return TOK_GT; } "=" { return TOK_ASSIGN; } "+" { return TOK_PLUS; } "-" { return TOK_MINUS; } "*" { return TOK_STAR; } "/" { return TOK_SLASH; } "%" { return TOK_PERCENT; } . { return yytext[0]; } %% int yywrap(void) { return 1; }flex 规则的核心语义是“最长匹配优先”,多个规则匹配同一长度时,先出现的规则胜出。所以<=的规则必须放在<前面,但即使放反了,最长匹配也会选<=;真正会翻车的是长度相同的规则,比如关键字和标识符谁先写。上面例子把关键字放在标识符前面,保证if匹配到 TOK_IF 而不是 TOK_ID。yytext 指向当前匹配的原始文本,yylval 用来传语义值,token 类型用 return 返回给语法分析器。
flex 生成时执行flex lexer.l,产出 lex.yy.c,再和 main 一起编译。main 里调用 yylex 循环拿 token 即可,和手写版的动作表职责完全对应。
4.4 常见误用:正则写得像字符串匹配
第一种误用是把正则当成字符串匹配来写。新手在 flex 里写"if|else",期望匹配 if 或 else,结果匹配的是字符串if|else。正则里的|是选择运算符,但引号包住的都是字面量;正确写法是if|else,如果想匹配字面量竖线才写"|"。
第二种误用是字符类边界没想清楚。[a-zA-Z_][a-zA-Z0-9_]*里,第一个字符类包含下划线,第二个也包含,但很多人在第二个字符类里漏掉下划线,导致a_b被拆成a和_b。词法分析器不像人眼那样“看到下划线就自动连上”,字符类里没写就真的不在里面。
第三种误用是把标识符写成[a-zA-Z_]*。这个表达式能匹配空字符串,flex 会对空串报错,手写 DFA 里则会出现“在初态直接终结”的诡异现象。正则是闭包但不是空串闭包,标识符至少要有一个字母或下划线开头,写成[a-zA-Z_][a-zA-Z0-9_]*才能保证词素非空。
5. 词法分析器调试避坑:五个高频翻车现场与排查方法
5.1 标识符和关键字打架:ifx 被拆成 if 和 x
现象:输入ifx,输出 TOK_IF 和 TOK_ID(x);输入iffy,同样被拆成关键字加尾巴。原因:驱动循环在识别过程中逐个字符比对关键字表,读到if时匹配成功就直接返回,没有把整个词素读完。解决:统一走 ST_ID 路径,把整个词素收进缓冲区,最后在 make_token 里通过 lookup_keyword 一次性判定。关键字和标识符共享同一个正则,区别只发生在 token 产出的那一刻。
5.2 最长匹配失效:<= 被拆成 < 和 =
现象:输入a <= b,输出 TOK_LT、TOK_ASSIGN,语法分析器在=处报错。原因:驱动里对 C_LTGT 在 ST_START 状态直接调用了 single_char_token 返回,没有先进 ST_CMP 状态等第二个字符。这是初学者最容易写出的路径,因为它“看起来很直接”。解决:<和>必须先进 ST_CMP,延迟到看到下一个字符再决定。表里 ST_CMP 对 C_EQ 指向 ST_LE_GE,对其他列指向 ST_DONE 并回退,这样单字符和双字符两种情况都覆盖。
5.3 数字后面跟字母:123abc 被拆成 123 和 abc
现象:输入123abc,lexer 一声不吭输出 TOK_NUM(123) 和 TOK_ID(abc),编译在上游就给了个不明不白的错误。原因:ST_NUM 遇到 C_LETTER 走了终结回退,把 abc 留给下一轮当标识符。这在大多数语言里都不是合法输入,应该在词法阶段就报错。解决:把 ST_NUM 行的 C_LETTER 显式指向 ST_ERROR,报“数字后面不能跟字母”。注意别把 C_DIGIT 也指向 ST_ERROR,否则多位数都识别不了。
5.4 EOF 丢 token:最后一行没有换行
现象:输入文件最后一行是return 0且没有换行,最后一个数字 0 丢失,语法分析器报“非预期的文件结束”。原因:get_next_token 的 while 循环以 EOF 退出,而 token 只会在循环内部返回,缓冲区里的半截 token 永远没有机会被处理。解决:循环结束后判断缓冲区长度,*len > 0时调用 make_token 把最后一个 token 吐出来。这个分支必须在接手任何项目时先检查,它出现在所有用“读一个字符就判断一次”实现的 lexer 里。
5.5 加了注释支持却回退错字符:注释尾巴被吞
现象:给 lexer 加了//和/* */支持后,a /* comment */ b被识别成a和b,但a /**/ b会得到一个奇怪的 TOK_ERROR 或者注释结束符*/被吞。原因:新增注释状态时,把 C_SLASH 从 C_OP 里拆出来单列,但转移表里块注释的“读到*进入待退出状态,待退出状态读到非/要回到块注释内部”这条转移没有处理对,把本该在注释内部消费的字符回退给了词法循环。解决:块注释内部需要两个状态——ST_BLOCK_COMMENT 和 ST_BLOCK_EXIT。ST_BLOCK_EXIT 遇到非/字符时,不要 ungetc,而是把这个字符留在注释内部继续按块注释处理,只有遇到/才真正回到 ST_START。
/* 在 3.3 表的基础上扩展:把 '/' 从 C_OP 拆成 C_SLASH */ trans[ST_START][C_SLASH] = ST_SLASH; trans[ST_SLASH][C_SLASH] = ST_LINE_COMMENT; trans[ST_SLASH][C_STAR] = ST_BLOCK_COMMENT; trans[ST_BLOCK_COMMENT][C_SLASH] = ST_BLOCK_COMMENT; trans[ST_BLOCK_COMMENT][C_STAR] = ST_BLOCK_EXIT; trans[ST_BLOCK_EXIT][C_SLASH] = ST_START; /* 关键:ST_BLOCK_EXIT 的其余列回到 ST_BLOCK_COMMENT,且不 ungetc */这条踩坑记录的重点不是那几行代码,而是“注释状态是词法分析器里唯一需要连续吞字符的区域”,它对回退的语义和普通 token 不一样。加这类状态之前先把驱动循环的每个 ungetc 分支标出来,否则改一处漏三处。
6. 用最小化DFA验证状态表正确性:一个可复现的检查技巧
状态表手填完之后,与其用一堆 printf 肉眼看输出,不如写一个检查脚本,把 lexer 的输出和期望 token 序列逐项对比。我的做法是让 lexer 输出“token 编号 + 词素”,然后写个小脚本喂固定输入,比对两件事:token 序列完全一致,且输入里每个字符恰好被消费一次。
#!/usr/bin/env python3 import subprocess, sys CASES = [ ("if (a <= 10) { b = a + 1; }", [ ("TOK_IF", "if"), ("TOK_LPAREN", "("), ("TOK_ID", "a"), ("TOK_LE", "<="), ("TOK_NUM", "10"), ("TOK_RPAREN", ")"), ("TOK_LBRACE", "{"), ("TOK_ID", "b"), ("TOK_ASSIGN", "="), ("TOK_ID", "a"), ("TOK_PLUS", "+"), ("TOK_NUM", "1"), ("TOK_SEMI", ";"), ("TOK_RBRACE", "}"), ]), ] # token 编号到名称的映射,必须和 C 里的枚举顺序一致 TOK_NAME = [ "TOK_EOF", "TOK_ID", "TOK_NUM", "TOK_IF", "TOK_ELSE", "TOK_WHILE", "TOK_RETURN", "TOK_PLUS", "TOK_MINUS", "TOK_STAR", "TOK_SLASH", "TOK_PERCENT", "TOK_LT", "TOK_LE", "TOK_GT", "TOK_GE", "TOK_EQ", "TOK_NE", "TOK_ASSIGN", "TOK_LPAREN", "TOK_RPAREN", "TOK_LBRACE", "TOK_RBRACE", "TOK_SEMI", "TOK_COMMA", "TOK_ERROR", ] for src, expected in CASES: p = subprocess.run(["./lexer"], input=src.encode(), capture_output=True) if p.returncode != 0: print("lexer 报错:", p.stderr.decode()) sys.exit(1) actual = [] for line in p.stdout.decode().splitlines(): tok_id, lexeme = line.split("\t", 1) actual.append((TOK_NAME[int(tok_id)], lexeme)) if actual != expected: print("MISMATCH") print("期望:", expected) print("实际:", actual) sys.exit(1) print("全部用例通过")检查脚本本身也有参数要定:token 编号顺序要和 C 枚举完全一致,否则对比全是假的;用例要覆盖“单字符运算符”“双字符复合运算符”“关键字回查”“非法字符报错”四类路径,每类至少一条;跑完后把 lexer 返回码和 stderr 也纳入检查,因为 TOK_ERROR 本身就是一条合法输出。这几个用例一过,状态表正确性才有底气。这个验证习惯帮我省了不少事,希望帮到你。
本文还有配套的精品资源,点击获取