资讯动态

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

发布时间:2026/10/12 22:14:11 来源:尧图企业网站定制
简介这是一份面向计算机专业学生与编译原理初学者的实验配套资源聚焦词法分析器的设计与实现帮助读者从理论走向实践理解编译器前端的工作流程。压缩包共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读进来可能是EOFungetc(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 -stdc99 -o lexer lexer.c ./lexer test.c-Wall -Wextra打开所有警告能帮你发现未使用变量、类型不匹配等问题。-stdc99保证for循环内声明变量等特性可用。测试用例要覆盖纯关键字、标识符含下划线和数字、整数边界值2147483647和2147483648、双字符运算符、单行和多行注释、非法字符、数字开头接字母123abc。我一般会准备一个test.c里面故意混入这些情况跑完对照预期 Token 序列逐个核对。输出格式建议每行一个 Token类型, 值, 行号方便肉眼比对。4. 词法分析器避坑改作业时最常看到的五类翻车4.1 现象被拆成两个原因没做双字符预读解决用ungetc回退这是最高频的错误。学生写完单字符运算符识别就收工遇到输出两个TOKEN_OPERATOR值都是。语法分析阶段直接崩。根因是识别运算符时没有预读下一个字符。解决就是上面scan_operator的做法读第二个字符判断组合是否合法不合法就ungetc回退。注意ungetc只能保证回退一个字符C 标准不保证多次连续回退所以别想着回退两个。4.2 现象文件末尾多输出一个乱码 Token原因EOF被当成普通字符处理解决所有读取后先判EOFfgetc返回int而不是char就是为了能表示EOF通常是 -1。如果把返回值存进charEOF会被截断成某个有效字符循环永远不结束。血泪经验所有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调了一晚上。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑