ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C语言词法分析器实验:从状态机设计到Token输出避坑指南

C语言词法分析器实验:从状态机设计到Token输出避坑指南

简介:这是一份面向计算机专业学生与编译原理初学者的实验配套资源,聚焦词法分析器的设计与实现,帮助读者从理论走向实践,理解编译器前端的工作流程。压缩包共3个文件,约86KB,包含一份C语言实现的词法分析器源码、一份记录实验目的、步骤、结果与问题解决方案的实验报告文档,以及一份用于测试扫描器识别效果的源程序文本,覆盖输入处理、标记定义、词法规则与错误处理等关键环节。目前已有2170人学习下载,适合正在完成编译原理课程实验或希望掌握C语言词法分析实现技巧的读者参考。通过阅读源码与实验报告,读者可以对照理解关键字、标识符、常量与运算符的识别逻辑,借鉴状态机或正则思路的落地方式,并借助测试用例验证与排查自身实现中的问题,为后续语法分析等实验打下基础。

1. 词法分析器到底在做什么:从一段 C 代码被“切碎”说起

你写下一行int sum = a + 10;,编译器看到的不是一句话,而是一串待切分的字符流。词法分析器(Lexer / Scanner)干的事,就是把这串字符按规则切成一个个有意义的记号(Token),再交给后面的语法分析器。很多同学做编译原理实验(一)时,第一反应是“这不就是字符串处理吗”,真动手才发现边界条件多到离谱:==和=要分开、123abc要报错、注释和空白要跳过、关键字和标识符要区分。这个实验在 C 语言环境下做,核心就是用stdio.h和limits.h这类标准库,手写一个能识别 C 语言子集 Token 的扫描器。它适合正在上编译原理课、需要交实验报告,或者想真正搞懂“编译器前端第一步”的人。下面我按自己带实验课和改作业的经验,把设计、实现、参数和踩坑一次讲透。

2. 词法分析器的状态机设计与 Token 分类

2.1 为什么用有限状态自动机而不是一堆 if-else

词法分析的本质是正则语言识别,理论上对应确定有限自动机(DFA)。新手最容易写成“读一个字符判断一次”的散装 if-else,代码能跑,但一遇到>=、<=、!=这种双字符运算符就开始乱。正确做法是先画出状态转移图:起始状态读字母进标识符状态,读数字进数字状态,读=进运算符状态再看下一个字符是不是=。我一般会让学生在纸上先画状态图,再翻译成switch嵌套。这样写出来的代码,每个状态职责单一,加新 Token 类型时只改对应分支,不会牵一发动全身。

C 语言实现时,状态可以用一个enum表示,当前字符用getchar()或fgetc()从文件流读取。这里有个关键选择:用getchar()还是先把整个文件读进缓冲区?实验规模小,两种都行,但用fgetc配合ungetc回退一个字符,是处理双字符运算符最干净的方式。ungetc是stdio.h里的标准函数,能把刚读的字符塞回流里,下次再读还是它。很多人不知道这个函数,结果自己维护一个“预读字符”变量,逻辑越写越乱。

2.2 Token 类型定义与符号表初探

Token 至少要包含类型和值两部分。类型用枚举,值用字符串或数字。C 语言子集常见 Token 分类如下:

Token 类型示例说明
KEYWORDint, if, while保留字,查表判定
IDENTIFIERsum, count字母或下划线开头
NUMBER123, 0整数常量
OPERATOR+, ==, >=单双字符运算符
DELIMITER; , ( ) { }分隔符
ERROR@, 123abc非法字符或格式

关键字和标识符的区分靠一张关键字表,常见做法是定义一个字符串数组,识别出标识符后遍历比对。这里就涉及编译原理里符号表的雏形——实验一通常不要求完整符号表,但你可以顺手把标识符存进一个简单数组,为后面实验铺路。limits.h在这个实验里的作用主要是处理整数溢出:当数字串太长时,用INT_MAX判断是否越界,避免atoi悄悄截断。这个细节很多实验指导书没写,但改作业时经常看到有人把999999999999识别成合法整数,后面一算就崩。

2.3 最小可运行框架的搭建步骤

先建一个lexer.c,包含stdio.h、ctype.h、string.h、limits.h。主循环逻辑:跳过空白和注释,读第一个非空白字符,根据字符类型进入不同识别函数。识别完一个 Token 后打印或存入数组。下面是一个可复现的骨架:

#include <stdio.h> #include <ctype.h> #include <string.h> #include <limits.h> typedef enum { TOKEN_KEYWORD, TOKEN_IDENTIFIER, TOKEN_NUMBER, TOKEN_OPERATOR, TOKEN_DELIMITER, TOKEN_ERROR, TOKEN_EOF } TokenType; typedef struct { TokenType type; char value[64]; } Token; const char *keywords[] = {"int", "if", "else", "while", "return", NULL}; int is_keyword(const char *s) { for (int i = 0; keywords[i]; i++) if (strcmp(s, keywords[i]) == 0) return 1; return 0; } Token scan_identifier(FILE *fp, int first) { Token t; t.type = TOKEN_IDENTIFIER; int i = 0; t.value[i++] = (char)first; int c; while ((c = fgetc(fp)) != EOF && (isalnum(c) || c == '_')) { if (i < 63) t.value[i++] = (char)c; } t.value[i] = '\0'; if (c != EOF) ungetc(c, fp); // 多读的字符回退 if (is_keyword(t.value)) t.type = TOKEN_KEYWORD; return t; }

这段代码的关键点在ungetc:识别标识符时读到非字母数字才停,但那个字符属于下一个 Token,必须回退。参数first是已经读掉的第一个字符,避免丢失。value数组固定 64 字节,实验够用,生产环境要动态分配。is_keyword用NULL结尾的字符串数组遍历,简单可靠。数字识别函数结构类似,但要多一步溢出判断:把字符逐个转成数字累加,累加前检查value > (INT_MAX - digit) / 10,超过就报TOKEN_ERROR。

3. 用 C 语言把扫描器跑通:从文件读取到 Token 输出

3.1 主循环与字符回退的配合

主循环是整个扫描器的调度中心。我一般写成这样:先skip_whitespace_and_comments(fp),然后c = fgetc(fp),如果EOF就输出结束 Token 并退出。接着根据c分派:isalpha(c) || c == '_'走标识符,isdigit(c)走数字,strchr("+-*/%=<>!", c)走运算符,strchr(";,(){}", c)走分隔符,其余报错。运算符识别要特别小心,因为=、<、>、!可能是双字符开头。常见做法是读第二个字符,如果是=就组成双字符运算符,否则ungetc回退,当作单字符处理。

Token scan_operator(FILE *fp, int first) { Token t; t.type = TOKEN_OPERATOR; int second = fgetc(fp); if (second == '=' && (first == '=' || first == '<' || first == '>' || first == '!')) { t.value[0] = (char)first; t.value[1] = '='; t.value[2] = '\0'; } else { if (second != EOF) ungetc(second, fp); t.value[0] = (char)first; t.value[1] = '\0'; } return t; }

逻辑说明:只有==、<=、>=、!=四种双字符组合合法,其他情况一律回退。参数first是主循环已经读掉的第一个运算符字符。这里有个易错点:second读进来可能是EOF,ungetc(EOF, fp)行为未定义,所以必须先判断。这个坑我在实验课上见过至少五个人踩,程序在文件末尾直接卡死或输出乱码。

3.2 注释跳过与行号统计

C 语言子集一般要求支持//单行注释和/* */多行注释。跳过逻辑放在主循环开头:读到/时预读下一个字符,如果是/就循环读到\n;如果是*就循环读到*/。多行注释里要统计行号,方便报错时定位。行号用一个全局int line = 1,每读一个\n就自增。报错 Token 带上行号,实验报告里会好看很多。

void skip_comment(FILE *fp, int *line) { int c = fgetc(fp); if (c == '/') { while ((c = fgetc(fp)) != EOF && c != '\n'); if (c == '\n') (*line)++; } else if (c == '*') { int prev = 0; while ((c = fgetc(fp)) != EOF) { if (c == '\n') (*line)++; if (prev == '*' && c == '/') break; prev = c; } } else { if (c != EOF) ungetc(c, fp); // 不是注释,回退 } }

参数line传指针,因为要修改调用方的行号。prev记录前一个字符,用来识别*/。注意/* */不嵌套,这是 C 语言标准行为,别自作聪明支持嵌套。如果文件在注释中间结束,循环自然退出,不会死循环,但最好报一个“注释未闭合”的错误。

3.3 编译、运行与测试用例设计

编译命令很简单:

gcc -Wall -Wextra -std=c99 -o lexer lexer.c ./lexer test.c

-Wall -Wextra打开所有警告,能帮你发现未使用变量、类型不匹配等问题。-std=c99保证for循环内声明变量等特性可用。测试用例要覆盖:纯关键字、标识符含下划线和数字、整数边界值2147483647和2147483648、双字符运算符、单行和多行注释、非法字符@、数字开头接字母123abc。我一般会准备一个test.c,里面故意混入这些情况,跑完对照预期 Token 序列逐个核对。输出格式建议每行一个 Token:<类型, 值, 行号>,方便肉眼比对。

4. 词法分析器避坑:改作业时最常看到的五类翻车

4.1 现象:==被拆成两个=;原因:没做双字符预读;解决:用ungetc回退

这是最高频的错误。学生写完单字符运算符识别就收工,遇到==输出两个TOKEN_OPERATOR,值都是=。语法分析阶段直接崩。根因是识别运算符时没有预读下一个字符。解决就是上面scan_operator的做法:读第二个字符,判断组合是否合法,不合法就ungetc回退。注意ungetc只能保证回退一个字符,C 标准不保证多次连续回退,所以别想着回退两个。

4.2 现象:文件末尾多输出一个乱码 Token;原因:EOF被当成普通字符处理;解决:所有读取后先判EOF

fgetc返回int而不是char,就是为了能表示EOF(通常是 -1)。如果把返回值存进char,EOF会被截断成某个有效字符,循环永远不结束。血泪经验:所有fgetc的返回值都用int接,判断!= EOF之后再转char。ungetc之前也要判EOF,否则行为未定义。

4.3 现象:123abc被识别成数字123加标识符abc;原因:数字识别没检查后续字符;解决:数字后跟字母直接报错

严格来说123abc是非法 Token,应该报错而不是拆成两个。数字识别函数读完数字后,要预读一个字符,如果是字母或下划线,说明这个 Token 非法,返回TOKEN_ERROR。这个边界很多实验指导书不强调,但实际编译器必须处理。类似地,0x1F这种十六进制要看实验要求,不要求就报错。

4.4 现象:大整数被atoi截断成负数;原因:没做溢出检查;解决:用limits.h的INT_MAX提前判断

atoi在溢出时行为未定义,常见结果是回绕成负数。正确做法是手动累加:value = value * 10 + (c - '0'),每次乘之前判断value > (INT_MAX - (c - '0')) / 10。超过就报错。limits.h里的INT_MAX就是干这个的。这个细节能让你的实验报告比别人的多一个亮点。

4.5 现象:多行注释里的行号不增加;原因:跳过注释时没统计\n;解决:在注释循环里同样自增行号

报错行号不准,调试时很痛苦。跳过单行注释读到\n要自增,跳过多行注释每读一个\n也要自增。建议把行号做成全局变量,所有读取字符的地方统一维护。别在多个函数里各管各的,最后对不上。

5. 进阶技巧:把词法分析器改成可配置的 Token 表

实验一交完不是终点。如果你后面还要做语法分析,词法分析器的输出结构直接决定后面好不好写。我一般会做一个改进:把 Token 类型和识别规则从硬编码改成一张表。表里每行是一个规则:类型、正则描述、处理函数指针。主循环遍历规则表,匹配哪个走哪个。这样加关键字、加运算符只改表,不改主逻辑。

具体做法:定义struct Rule { TokenType type; int (*match)(FILE*, int); },把scan_identifier、scan_number、scan_operator等函数指针填进去。主循环里for遍历规则,第一个返回非零的就采用。关键字判定仍然在标识符识别之后做,因为关键字本质是特殊的标识符。这个结构多花二三十行代码,但后面实验二、实验三会感谢自己。

验证方法:准备一组输入,手动写出预期 Token 序列,用脚本 diff 实际输出。我习惯用 Python 写个比对脚本,读lexer的输出和预期文件,逐行对比。这样每次改代码跑一遍,回归测试几秒钟完成。别小看这个习惯,改 bug 时能省大量时间。

最后一个习惯:每识别一个新 Token,先在纸上画状态转移,再写代码。词法分析器的 bug 九成来自状态没想清楚就动手。我早期也翻过车,一个ungetc漏判EOF,调了一晚上。希望帮到你。

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

RELATED READING

延伸阅读

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