简介编译原理课程设计中词法分析器是编译器前端的关键模块负责将源代码字符流转换为有意义的单词序列。该文档以docx格式封装内含完整的C语言词法分析器代码与实现说明面向计算机专业学生、编译原理学习者及需快速完成课程设计或实验报告的开发者。资源包共1个docx文件压缩后仅18KB轻量便于下载。文档围绕预处理、错误处理、单词识别三大环节展开具体涉及注释与空白过滤、GetChar字符读取与分类、关键字表/标识符表/常量表/错误码表的数据组织以及Dualistic结构体保存单词种类与值等细节。文档通过宏定义控制关键字长度、标识符上限和缓冲区大小并清晰展示错误定位函数ProcError的调用方式。目前已吸引143人学习或浏览适合用作课设参考也可直接对照源码理解词法分析流程为后续语法分析及编译器实现打下扎实基础。1. 词法分析器代码一份能直接跑完实验的 C 语言参考实现编译原理课的词法分析实验卡住大多数人的不是“知道要识别关键字、标识符、运算符”而是代码写到一半忽然发现注释怎么去、和怎么区分、符号表和常量表怎么组织、错误行号怎么定位。这份代码把这些问题一次性解决了。它用纯 C 语言实现了一个完整的词法分析器包含预处理去注释、32 个 C 关键字识别、标识符/常量双表管理、错误代码表、二元组输出主函数直接读Test.txt输出Result.txt。适合正在做编译原理课程设计、需要一份可复现参考代码的从业者也适合想搞懂“词法分析器到底怎么组织状态判断”的人直接读代码、改代码。2. 预处理与字符管理先把注释和空白清掉再谈识别2.1 预处理函数的设计思路为什么不能边读边分析词法分析器最容易被忽略的环节是注释处理。如果主流程里遇到/就直接判断运算符那么//和/* */会污染整个 token 流。这份代码的做法是先跑一遍pretreatment()把注释剥掉把结果写到临时缓冲区再拷贝回全局缓冲区proBuffer。常见的做法是先处理注释再做词法分析因为注释里的内容对后续识别没有任何意义而且//注释可能包含不配对的引号、括号直接进分析流程会让状态机乱掉。void pretreatment() { int lines 0; char tmp[PRO_MAX_LEN]; // 处理结果先存临时空间 int tmpp 0; bool flg; char tmpc; point 0; do { flg GetChar(); if (ch /) { flg GetChar(); switch (ch) { case /: do { flg GetChar(); } while (!(ch \n || flg false)); if (ch \n) Retract(); // 归还换行 break; case *: do { flg GetChar(); tmpc ch; if (tmpc \n) tmp[tmpp] tmpc; flg GetChar(); Retract(); } while (flg !(flg tmpc * ch /)); flg GetChar(); if (!flg) ProcError(5); break; default: Retract(); Retract(); GetChar(); tmp[tmpp] ch; flg GetChar(); tmp[tmpp] ch; } } else { tmp[tmpp] ch; } } while (flg); tmp[tmpp] \0; strcpy(proBuffer, tmp); }这段代码的核心逻辑是把//注释一直读到行尾或文件结束把/* */注释一直读到匹配的*/。注释里如果有换行会把换行保存到临时缓冲区这是为了后面errorLine行号定位不出错。default分支处理的是“斜杠后跟的不是注释符号”的情况比如a / b或者/这里连续两次Retract()再重新GetChar()目的是把/和下一个字符都原样保留。注意Retract()只回退一个字符所以这里回退两次再读一次等价于把当前位置恢复到/前再重新读入/和后续字符。2.2 GetChar 与 Retract 的配对规则行号维护的玄机GetChar()每次从proBuffer里取一个字符并且如果取到\n就让errorLine加一。Retract()则反向操作但如果当前proBuffer[point]是\n它会先让errorLine减一再回退指针这是行号维护的关键。bool GetChar() { if (point PRO_MAX_LEN proBuffer[point] ! \0) { ch proBuffer[point]; if (ch \n) errorLine; return true; } ch \0; return false; } void Retract() { if (proBuffer[point] \n errorLine 0) errorLine--; point--; }这里有个很容易踩的坑Retract()判断的是proBuffer[point]也就是当前指针指向的字符。如果之前GetChar()刚读到\n并且指针已经越过了它回退时proBuffer[point]指向的是代码里的\n这时errorLine减一逻辑是对的。但如果连续回退两次第二次回退时proBuffer[point]已经不是\n就不会再减这也是为什么代码里default分支要回退两次然后重新读入保证行号不重复加减。另外GetBC()用来跳过空白字符它内部先GetChar()再循环判断isspace(ch)如果文件读完就返回false并把ch置为\0。2.3 预处理阶段的边界情况注释符号出现在字符串里这份代码的预处理逻辑有一个明确的边界它没有处理“注释符号出现在字符串或字符常量里”的情况。比如char *s /* not comment */;预处理会认为/*是注释开始然后把字符串内容当注释吃掉直到找到*/。如果*/没找到就报ProcError(5)提示“注释丧失 */”。这是实验级代码的正常取舍——真正的编译器预处理需要区分 token 上下文但课程设计里通常不会把这种用例作为硬性要求。我一般会在实验报告里主动写清楚这个限制反而显得对词法分析的状态感知有理解。3. 关键字表与符号表用查表法代替逐字比较3.1 32 个关键字的存储方式与 Reserve 函数C 语言的关键字有 32 个这份代码用二维字符数组kwTab存下来KEYWORD_LEN宏定义为 32。Reserve()函数逐个比较如果匹配返回i1否则返回 0。注意 1 的原因是 0 被用作“非关键字”的标记这样关键字编号从 1 开始不会和错误码 0 冲突。int Reserve(char *str) { int i; for (i 0; i KEYWORD_LEN; i) { if (0 strcmp(kwTab[i], str)) return i 1; } return 0; }查表法的核心好处是简单直接32 个关键字线性扫描的开销对实验来说完全可以接受。如果觉得顺序查找不够“高级”可以改成二分查找前提是kwTab按字母序排列但这会让代码维护成本变高。实验报告里如果你写“本设计采用查表法时间复杂度 O(n)在关键字数量固定为 32 时开销可忽略”评审老师挑不出毛病。关键字编号用i1还有个隐藏好处后续 wordAnalyse 里的pDu-value -1表示“该 token 没有表下标”而关键字 token 的 value 永远为 -1因为它不需要进符号表。3.2 符号表与常量表的插入策略去重是关键InsertId()和InsertConst()做的事情本质上一样先遍历表找到同名就返回已有下标找不到就追加新条目。表的容量是STB_MAX_LEN 1000常量表也是 1000。int InsertId(char *str) { int i; for (i 0; i pointSTB; i) if (0 strcmp(signTab[i], str)) return i; strcpy(signTab[pointSTB], str); return (pointSTB - 1); }这段代码里pointSTB既是已用条目的数量也是下一个空位的下标。strcpy之后pointSTB返回值是pointSTB - 1也就是新插入条目的下标。这个设计的好处是同一个标识符在源代码里出现 10 次符号表里只有 1 条记录后续语法分析阶段查符号表属性时直接复用下标即可。坏处是如果源代码里标识符超过 999 个会越界写坏内存因为这里没有检查pointSTB是否达到上限。实际使用时建议在InsertId开头加一个容量判断返回-1表示表满。不信的话拿一段自动生成的超长代码跑一次大概率会看到玄学崩溃。3.3 标识符识别的状态循环字母开头、后面跟字母数字下划线wordAnalyse()里对标识符的处理用的是judge变量技巧先GetBC()拿到非空白字符然后根据字符类型设置judge的值再进入 switch 分支。GetBC(); judge ch; if (isalpha(ch) || ch _) judge L; if (isdigit(ch)) judge D; switch (judge) { case L: while (isalnum(ch) || ch _) { wordget[i] ch; GetChar(); } wordget[i] \0; Retract(); code Reserve(wordget); if (code 0) { value InsertId(wordget); pDu-kind ID; pDu-value value; } else { pDu-kind code; pDu-value -1; } return true;这段代码的核心思想是只要当前字符是字母、数字或下划线就一直读下去直到遇到非这些字符然后Retract()回退多余读入的字符。这里有个细节容易被忽略循环条件是isalnum(ch) || ch _也就是说数字开头进不了这个分支因为在前面judge判断时isdigit(ch)会把数字字符导向D分支。标识符开头不能是数字的规则在这里是靠分支入口保证的不是靠循环条件。Retract()之后调Reserve判断是不是关键字是关键字就给 kind 赋关键字编号不是关键字就插入符号表。注意循环里最后一次GetChar()读到的字符是要被回退的这就是“超前读一个字符”的标准做法。4. 运算符与界符识别judge 变量 多字符运算符消解4.1 judge 变量与 switch 分支的整体结构这个分析器的运算符识别部分用了judge变量做入口分发judge初始值就是当前字符本身但字母被改成L数字被改成D这样 switch 的 case 就可以同时处理单字符运算符和字母/数字分支。单字符界符如(,),[,],;,{,}直接构造 token 返回。多字符运算符则需要向前多看一个字符来判断到底是还是是还是。case : wordget[i] ch; GetChar(); if (ch || ch ) wordget[i] ch; else Retract(); wordget[i] \0; break;这段代码的逻辑先收下再读下一个字符。如果下一个字符是或,说明是或一起收进 wordget否则说明只有一个需要Retract()把多读的字符还回去。回退之后wordget[i] \0截断字符串然后跳出 switch统一走到底部pDu-kind OPERAT; return true;。这个模式在-、*、/、%、、、、|、^、等分支里反复出现区别只在于第二个字符的候选项不同。-分支多了一个的判断所以-指针访问符也能被识别出来。4.2 字符常量与字符串常量的处理转义字符的关键路径字符常量分支用单引号开头逻辑是先收下再读一个字符。如果这个字符是\说明是转义序列比如\n需要再读一个字符把n收进来然后再读右引号。case \: wordget[i] ch; GetChar(); wordget[i] ch; if (ch \\) { GetChar(); wordget[i] ch; } GetChar(); wordget[i] ch; wordget[i] \0; if (ch ! \) { printf(%s, wordget); ProcError(2); pDu-kind ERROR; pDu-value 0; } else { value InsertConst(wordget); pDu-kind CONST; pDu-value value; } return true;这里有个值得注意的顺序问题如果读到的不是\\那说明只有一个普通字符此时第二次GetChar()直接读右引号。所以整个逻辑覆盖了a和\n两种形态。如果最后读到的不是比如ab这种多字符常量就会报ProcError(2)提示“不正确的字符常量表达”。字符串常量分支case 的处理类似不同的是循环直到读到双引号或文件结束如果ch \0说明字符串没闭合报错误码 3。4.3 运算符识别的遗漏项检查对照 C 语言运算符表我把 C 语言运算符过了一遍这份代码覆盖了 - * / % | ^ !的单字符形式以及 -- - * / % ! || | ^ -的多字符形式。缺失的是? :已经在界符里处理了sizeof走关键字表。三个点...可变参数、#预处理器、.结构体访问都归到界符分支。所以只要不是反复嵌套的复杂表达式这份分析器的运算符识别是够用的。不过要注意*分支判断的是**和***其实不是 C 标准运算符但这不影响分析器正常工作最多是多识别一个不存在的运算符 token。运行时如果遇到a ** b这种代码它会被识别成两个*运算符语法分析阶段会报错这是合理的。5. 避坑与常见问题排查行号错乱、表溢出、getch 依赖5.1 错误行号不准预处理改变了行数但分析阶段的行号是对的现象预处理完成后打印的源代码行数和词法分析报错时的行数对不上。原因pretreatment()会删除//注释原来注释里的换行会被丢掉只有/* */注释里的换行被保留。词法分析时errorLine从 0 重新计数读入的是预处理后的缓冲区所以行号对应的是预处理后的代码行不是原始文件行。解决如果你想对原始文件行号需要在pretreatment()里把删除的换行数量统计出来或者保留原始文件副本。一般实验不要求精确到原始行号预处理后行号够用。5.2 表溢出导致内存写坏插入函数没有容量检查现象源代码里标识符超过 1000 个程序不报错但结果随机出错严重时崩溃。原因InsertId()和InsertConst()只判断同名存在不判断pointSTB是否达到STB_MAX_LEN。解决在插入前加判断表满时返回 0 或 -1主循环收到异常值就停止分析并提示“符号表溢出”。int InsertId(char *str) { if (pointSTB STB_MAX_LEN) return -1; int i; for (i 0; i pointSTB; i) if (0 strcmp(signTab[i], str)) return i; strcpy(signTab[pointSTB], str); return (pointSTB - 1); }对课程设计来说加上这段判断不需要额外引入复杂逻辑但能避免最让人头疼的越界崩溃。要注意-1会和合法的表下标 0 冲突所以主循环里对value -1有特殊判断时要区分“表满”和“关键字 token”两种情况。5.3 getch() 在非 Windows 平台失效移植到 Linux 会报错现象用 GCC 在 Linux 下编译链接阶段报getchundefined reference。原因getch()是 Windows 下conio.h提供的函数Linux 的gcc默认不链接对应库。解决有两个方向一是用getchar()替代但会残留换行符需要额外处理二是用curses库或直接删掉这两个暂停点。从实验结果可读性角度我建议保留 Windows 版本的getch()在报告里注明“本实验在 Windows Visual Studio/Dev-C 环境运行”然后在代码注释里给一个 Linux 替代版本。#ifdef _WIN32 #include conio.h #else #include termios.h #include unistd.h static int getch() { struct termios oldt, newt; tcgetattr(STDIN_FILENO, oldt); newt oldt; newt.c_lflag ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, newt); int c getchar(); tcsetattr(STDIN_FILENO, TCSANOW, oldt); return c; } #endif5.4 数字识别的边界小数点没进数字逻辑现象输入3.14分析结果是一个整数常量3、一个界符.、一个整数常量14。原因case D分支只循环吸收数字字符遇到.就停止。解决如果你要识别浮点数需要在case D分支里加一个ch .的判断同时判断小数点后是否跟数字。注意a[3].x这种结构体取成员的场景如果盲目把.并入数字反而会出错。一般实验只要求整数常量这个行为可以保留并在报告里说明。5.5 注释未闭合时报错但错误码显示位置不准现象/* comment没有闭合预处理阶段报Error:第 n 行,注释丧失 */但行号是文件末尾行不是注释开始行。原因ProcError(5)调用时errorLine已经累加到文件末尾而代码没有记录注释开始行号。解决在进入/*分支前保存当前errorLine到一个局部变量报错时用这个局部变量输出。这个细节在验收演示时容易被问到改一下也就几行代码。6. 从 Test.txt 到 Result.txt主流程跑通与验证技巧主函数做的事很直接读Test.txt到proBuffer显示源程序调用pretreatment()去注释然后循环调用wordAnalyse()生成二元组写入Result.txt。建议在调试时先准备一段覆盖各种情况的测试代码包含关键字、标识符、整数常量、字符常量、字符串常量、各类运算符、//注释、/* */注释一次性验证所有分支。int main() { Dualistic tmp; pDualistic ptmp tmp; FILE *fin, *fout; int i; char c; char filename[20]; printf(源代码读入\n); if ((fin fopen(Test.txt, r)) NULL) { printf(Cannot open infile\n); return 0; } i 0; while ((c fgetc(fin)) ! EOF) { if (i PRO_MAX_LEN - 1) { printf(\n 程序代码太长无法处理\a); return 0; } proBuffer[i] c; } fclose(fin); proBuffer[i] \0; printf(\n***************************\n 源 代 码 读 入 成 功 源 代 码 如 下 :\n%s, proBuffer); printf(\n 按任意键继续\n); getch(); printf(\n 预处理\n); pretreatment(); printf(\n***************************\n 预处理成功去掉注释后的源代码为:\n%s*, proBuffer); printf(\n 按任意键继续\n); getch(); printf(\n 词法分析\n); point 0; if ((fout fopen(Result.txt, wb)) NULL) { printf(建立文件 Result.txt 失败。\n); return 0; } i 0; errorLine 0; do { if (i PRO_MAX_LEN) break; if (!wordAnalyse(ptmp)) break; if (ptmp-value -1) fprintf(fout, %d, -1\n, ptmp-kind); else fprintf(fout, %d, %d\n, ptmp-kind, ptmp-value); } while (1); fclose(fout); return 0; }主循环里i PRO_MAX_LEN是一个保险丝防止wordAnalyse内部出 bug 死循环无限写文件。ptmp-value -1对应的 token 是关键字、界符和运算符它们不进符号表所以二元组第二个分量固定是 -1。标识符和常量的 value 是符号表/常量表下标。验证方法很简单打开Result.txt对照源程序逐个 token 检查。比如int a 3;应该输出int的关键字编码、a的符号表下标、的运算符编码、3的常量表下标、;的界符编码。实际跑的时候我习惯先跑一个 5 行左右的最小用例确认每个 kind 值都在预期范围再跑完整的测试文件。复杂运算符的验证重点看和、和、-和-这三组最容易翻车的情况是否输出了不同的 kind。从那以后我每次拿到新的词法分析器代码都会先用一份刻意包含所有运算符的样例去测一遍边界组合再谈后续的语法分析对接。这招虽然不是万能但确实能提前拦下大半的识别 bug希望帮到你。本文还有配套的精品资源点击获取