
简介面向编译原理课程实验的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-9C_DIGIT数字空格、\t、\n、\rC_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_IDcount, _tmp[a-zA-Z_][a-zA-Z0-9_]*TOK_IF / TOK_ELSE / TOK_WHILE / TOK_RETURNif else while return关键字表TOK_NUM42 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.cif (a 10) { b a 1; } else { return -1; }gcc -Wall -Wextra -stdc99 -o lexer lexer.c ./lexer test.cCC gcc CFLAGS -Wall -Wextra -stdc99 lexer: lexer.c $(CC) $(CFLAGS) -o $ $-stdc99 是因为代码里用了声明混用和//注释-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现象输入123abclexer 一声不吭输出 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], inputsrc.encode(), capture_outputTrue) 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 本身就是一条合法输出。这几个用例一过状态表正确性才有底气。这个验证习惯帮我省了不少事希望帮到你。本文还有配套的精品资源点击获取