简介:这份资源是面向计算机专业学生的编译原理课程实验报告,聚焦词法分析器的设计与实现,适合正在学习编译原理、需要完成课程实验或巩固词法分析原理的读者参考。报告围绕C语言词法分析子程序展开,涵盖实验目的、内容要求、程序清单、调试过程与运行结果,并附有思考题与实验收获体会,完整呈现了从理论到编码的实践路径。压缩包内共1个doc文档,约74KB,内容包含SYMBOL.H、BASEDATA.H与Symbol.c等核心代码片段,涉及种别码枚举、保留字表、单词字符串表及字符处理逻辑,可直接对照理解词法分析器的数据结构与扫描算法。目前已有489人学习下载,读者可借此掌握标识符、数字、运算符等标记的识别方法,理解有限状态自动机与正则表达式在词法规则中的应用,并参考调试思路与思考题拓展实现下划线开头标识符等改进方案,为后续语法分析学习打下基础。
1. 词法分析器设计实验:从 C 源码到 Token 流的完整拆解
很多人第一次拿到「词法分析器设计」这个实验题目时,第一反应是去翻编译原理教材里那套正则表达式到 NFA 再到 DFA 的推导流程,然后发现推导归推导,真让你写一个能跑起来、能把一段 C 代码切成 Token 的程序,还是不知道从哪下手。这份湖北汽车工业学院的实验报告给了一个非常务实的切入方式:不搞自动机生成器,直接手写一个字符扫描循环,用switch分支把每个字符映射到种别码,配合保留字表和标识符缓冲区完成识别。它的核心价值在于——让你真正理解编译器前端第一步在干什么,而不是停留在纸面推导。适合正在上编译原理课、需要交实验报告、或者想自己动手写一个简易词法分析器的同学。整份材料包含SYMBOL.H、BASEDATA.H、Symbol.c三个文件,代码量不大,但麻雀虽小五脏俱全,保留字识别、标识符提取、数字常量解析、运算符和界符处理都有涉及。接下来我会按「这份代码怎么跑起来 → 每个模块在干什么 → 参数怎么调 → 哪里容易翻车」的顺序,把它拆开讲透。
2. 把三个文件跑起来:环境搭建与首次编译
2.1 文件结构与依赖关系
这份实验代码由三个文件组成,职责划分很清晰:
| 文件名 | 职责 | 关键内容 |
|---|---|---|
SYMBOL.H | 数据结构与宏定义 | 种别码枚举SYMBOL、保留字个数WORDLEN、标识符最大长度MAXIDLEN、种别码总数SYMBOLNUM |
BASEDATA.H | 基础类型与常量 | FALSE/TRUE宏、空格和退格字符的十六进制定义 |
Symbol.c | 词法分析主逻辑 | 保留字表、种别码名称表、Getsym()核心扫描函数、main()驱动循环 |
三个文件的依赖方向是Symbol.c包含basedata.h和symbol.h,后者又依赖标准库的stdio.h、stdlib.h。这种分层方式在课程实验里算比较规范的,头文件管声明,源文件管实现,改种别码只需要动SYMBOL.H里的枚举。
2.2 编译命令与常见报错处理
代码里用了conio.h和ctype.h,前者在 Windows 下的 VC/Dev-C++ 里可以直接用,后者是标准库。如果你在 Linux 或 macOS 下编译,conio.h会直接报fatal error: conio.h: No such file or directory。解决办法是把#include <conio.h>去掉,同时把代码里用到getch()的地方替换掉——不过这份代码的main()里其实没直接调getch(),所以删掉这行 include 通常就能过。
在 Windows 下用 Dev-C++ 或 Visual Studio 建一个控制台项目,把三个文件加进去,编译命令大致是:
gcc -o lexer Symbol.c -I.如果用的是 Visual Studio,直接新建空项目,添加三个文件,注意SYMBOL.H和BASEDATA.H要放在与Symbol.c同级目录,或者手动在项目属性里加包含路径。
编译通过后运行,程序会打开一个输出文件(代码里用fout写入),把识别结果按TOKEN / SYM / NUM三列格式输出。如果你发现运行后没有任何输出,大概率是fout没有正确初始化或者文件路径没有写权限,检查Init()函数里fopen的返回值。
2.3 输入源程序的准备
实验要求分析的源程序是一段简单的 C 代码:
main(){ int x,a,b; float y,c,d; x = a + b; y = c / d; if(x>y) x = 10; else y = 100; }这段代码覆盖了关键字(int、float、if、else)、标识符(main、x、a、b、y、c、d)、整数常量(10、100)、运算符(+、/、>、=)和界符((、)、{、}、;、,)。作为词法分析器的测试输入,覆盖面是够的。
你需要把这段代码保存成一个文本文件,比如test.c,然后在Init()函数里把输入文件重定向到这个文件。常见做法是用freopen把stdin重定向:
freopen("test.c", "r", stdin);这行放在main()开头、Init()调用之前。这样Getchar()里的getchar()就会从文件读字符,而不是等你手动输入。
提示:如果你不想改代码,也可以在运行程序后手动粘贴源程序,最后按 Ctrl+Z(Windows)或 Ctrl+D(Linux)发送 EOF 结束输入。
3. Getsym 核心扫描逻辑:字符分类与种别码映射
3.1 扫描循环的整体骨架
Getsym()是整个词法分析器的心脏。它的工作流程可以概括为:跳过空白 → 读第一个非空白字符 → 判断字符类别 → 进入对应分支 → 拼出完整 Token → 返回种别码。
代码里main()的驱动逻辑是:
do { flag = Getsym(); if (flag) { fprintf(fout, "\n%10s %10s %d", token, SNAME[sym], num); } else if (ch != EOF) { fprintf(fout, "\n%10s", token); Errorsym(); } } while (ch != EOF);Getsym()返回非零表示识别成功,返回零表示遇到非法字符。token是全局缓冲区,存当前识别出的单词字符串;sym是种别码;num是数值(如果是数字常量的话)。
3.2 标识符与保留字的区分
当首字符是字母或下划线时,进入标识符识别分支。逻辑是:不断读字符,只要还是字母或数字就追加到token里,直到遇到非字母数字字符为止。拼完之后拿token去保留字表WORD[]里逐个比对:
for (i = 0; i < WORDLEN; i++) { if (strcmp(token, WORD[i]) == 0) { sym = WSYM[i]; return 1; } } sym = IDENT; return 1;这里有个关键设计:保留字表和种别码表是平行数组,WORD[i]对应的种别码就是WSYM[i]。这样查表的时候一次遍历就能同时拿到字符串和种别码,不需要额外维护一个映射结构。
WORDLEN定义为 13,对应 13 个保留字:BEGIN、CALL、CONST、DO、END、IF、ODD、PROCEDURE、READ、THEN、VAR、WHILE、WRITE。注意这套保留字其实是 Pascal 风格的,和实验要求里那段 C 代码的关键字(int、float、else)并不完全对应。这是这份代码的一个「历史遗留」问题——它原本可能是为 Pascal 子集设计的,后来被拿来当 C 词法分析的实验模板。实际使用时,你需要根据待分析的语言调整WORD[]和WSYM[]的内容。
3.3 数字常量的识别与转换
数字分支的逻辑是:读入连续的数字字符,拼成字符串后用atoi()转成整数,种别码设为NUMBER,同时把数值存到全局变量num里。这部分代码在实验报告里没有完整贴出,但根据上下文可以推断出大致结构:
if (isdigit(ch)) { int i = 0; while (isdigit(ch)) { token[i++] = ch; Getchar(); } token[i] = '\0'; num = atoi(token); sym = NUMBER; return 1; }这里有个细节:读完最后一个数字后,ch已经指向下一个字符了,但这个字符还没有被处理。所以Getsym()的循环设计里,每次识别完一个 Token 后不能无条件Getchar(),否则会跳过一个字符。常见做法是在Getsym()开头判断ch是否已经被预读,如果是则直接使用,否则调Getchar()。
3.4 运算符与界符的分支处理
运算符和界符的处理用switch分支最直观:
switch (ch) { case '+': Concat(); sym = PLUS; break; case '-': Concat(); sym = MINUS; break; case '*': Concat(); sym = TIMES; break; case '/': Concat(); sym = SLASH; break; case '=': Concat(); sym = EQL; break; case '<': Concat(); sym = LSS; break; case '>': Concat(); sym = GTR; break; case '(': Concat(); sym = LPAREN; break; case ')': Concat(); sym = RPAREN; break; case ',': Concat(); sym = COMMA; break; case ';': Concat(); sym = SEMICOLON; break; case '{': Concat(); sym = BEGINSYM; break; case '}': Concat(); sym = ENDSYM; break; default: return 0; } return 1;Concat()的作用是把当前字符追加到token缓冲区,然后调Getchar()读下一个字符。这样每个单字符运算符都能正确生成一个 Token。
但这里有个问题:<=、>=、!=、==这类双字符运算符没有处理。如果待分析代码里出现了<=,当前逻辑会把它拆成<和=两个 Token,这显然不对。要支持双字符运算符,需要在case '<'分支里再预读一个字符判断是不是=,如果是则种别码设为LEQ,否则回退。回退操作在文件流里比较麻烦,常见做法是用一个全局的char变量做预读缓冲,而不是直接操作文件指针。
3.5 种别码名称表的输出映射
SNAME[]数组的作用是把枚举值转成可读字符串,方便输出到报告文件里。比如sym = IDENT时,SNAME[sym]就是"IDENT"。这个数组的顺序必须和SYMBOL枚举严格一致,否则输出会张冠李戴。枚举里NOL是 0,IDENT是 1,NUMBER是 2,依此类推,SNAME[]的第 0、1、2 个元素也必须是"NOL"、"IDENT"、"NUMBER"。
如果你新增了种别码,比如加一个STRING类型,需要同时改三处:枚举里加、SNAME[]里加对应字符串、SYMBOLNUM宏加一。漏掉任何一处都会导致数组越界或者输出错位。
4. 避坑与排查:实验里最容易翻车的五个点
4.1 现象:编译报错conio.h找不到
原因:conio.h是 Windows 下特有的控制台输入输出头文件,Linux 和 macOS 的标准 C 库不提供。
解决:直接删掉#include <conio.h>。这份代码里conio.h主要提供getch(),但main()里并没有直接调用,删掉不影响核心逻辑。如果后续代码里确实用到了getch(),可以用getchar()替代,或者用termios库自己实现无回显读取。
4.2 现象:识别结果里标识符和保留字混在一起
原因:保留字表WORD[]里放的是 Pascal 关键字(BEGIN、END、IF等),但待分析的 C 代码里用的是int、float、else。这些词不在保留字表里,所以全被当成IDENT输出。
解决:根据待分析语言修改WORD[]和WSYM[]。如果要分析 C 代码,把WORD[]改成{"int", "float", "if", "else", "while", "return", ...},WSYM[]对应改成{INTSYM, FLOATSYM, IFSYM, ELSESYM, WHILESYM, RETURNSYM, ...},同时更新WORDLEN和SYMBOLNUM。注意枚举里新增的种别码要放在合适位置,SNAME[]也要同步加。
4.3 现象:数字后面紧跟字母时识别错误
原因:比如10abc,数字分支读到0后遇到a停止,返回NUMBER,但a没有被消费。下一轮Getsym()从a开始读,又识别出一个IDENT。这其实是正确的词法行为——10abc在 C 里本来就是非法 Token,词法分析器把它拆成10和abc两个 Token 是合理的。但如果你期望它报错,就需要在数字分支结束后检查下一个字符是否是字母,如果是则报错。
解决:在数字识别循环结束后加一个判断:
if (isalpha(ch) || ch == '_') { // 数字后面紧跟字母,非法 return 0; }4.4 现象:程序运行后输出文件为空
原因:fout文件指针没有正确初始化,或者Init()函数里fopen失败但没有检查返回值。
解决:在Init()里加错误检查:
fout = fopen("output.txt", "w"); if (fout == NULL) { printf("无法打开输出文件\n"); exit(1); }另外确认当前工作目录有写权限。在 Visual Studio 里,默认工作目录是项目目录,不是exe所在目录,这一点容易搞混。
4.5 现象:最后一个 Token 丢失
原因:main()的循环条件是while (ch != EOF),但Getsym()在识别完最后一个 Token 后,ch可能已经是EOF了,循环直接退出,最后一个 Token 没有被输出。
解决:把循环改成先执行后判断,或者用do-while结构。更稳妥的做法是在Getsym()返回后立即输出,然后再判断ch是否EOF。代码里用的是do-while,理论上不会丢,但如果你改成了while循环,就要注意这个边界。
5. 进阶改造:支持下划线标识符与双字符运算符
5.1 支持下划线开头的标识符
实验的思考题问的就是这个。当前代码里标识符的首字符判断大概率是isalpha(ch),只认字母。要支持下划线,改成:
if (isalpha(ch) || ch == '_') { int i = 0; while (isalnum(ch) || ch == '_') { token[i++] = ch; Getchar(); } token[i] = '\0'; // 查保留字表... }关键改动有两处:首字符判断加|| ch == '_',循环条件里把isdigit换成isalnum并加|| ch == '_'。这样_count、__init__、my_var这类标识符就能正确识别了。
5.2 支持双字符运算符
<=、>=、!=、==这四个双字符运算符在 C 代码里很常见。改造思路是在单字符分支里预读下一个字符:
case '<': Concat(); if (ch == '=') { Concat(); sym = LEQ; } else { sym = LSS; } break; case '>': Concat(); if (ch == '=') { Concat(); sym = GEQ; } else { sym = GTR; } break; case '!': Concat(); if (ch == '=') { Concat(); sym = NEQ; } else { sym = NOL; // 单独的 ! 在 C 里是逻辑非,需要新增种别码 } break; case '=': Concat(); if (ch == '=') { Concat(); sym = EQL; // 注意这里 EQL 原本表示赋值,需要区分 } else { sym = BECOMES; } break;这里有个坑:原来的枚举里EQL和BECOMES的语义可能和 C 语言不一致。在 C 里=是赋值,==是相等判断。你需要根据实际语义调整枚举命名和SNAME[]里的字符串。如果不想动枚举,也可以新增ASSIGN和EQ两个种别码,把原来的EQL和BECOMES保留给其他用途。
5.3 用状态机思路重构 Getsym
当前Getsym()用的是switch-case加if-else的混合结构,代码能跑但扩展性一般。如果你想让它更规整,可以改成显式状态机:定义几个状态(START、IN_ID、IN_NUM、IN_OP),用一个while循环驱动状态转移,每个状态里根据当前字符决定下一个状态和动作。这种写法在增加新 Token 类型时更不容易出错,也更容易和教材里的 DFA 理论对应上。
我自己的习惯是:每次改完词法分析器,都会拿一段包含所有 Token 类型的测试代码跑一遍,逐行核对输出。特别是边界情况——文件末尾没有换行、连续多个空格、Tab 和空格混用、注释符号——这些地方最容易出玄学 bug。从那以后我每次交实验报告前都强制走一遍「全 Token 覆盖测试」,确认每个种别码都至少出现一次,输出格式对齐无误,才敢点提交。希望帮到你。
本文还有配套的精品资源,点击获取