☰
C语言词法分析器完整实现:从预处理到符号表的编译原理参考
2026/10/13 21:59:33 网站建设 项目流程

简介:编译原理课程设计中,词法分析器是编译器前端的关键模块,负责将源代码字符流转换为有意义的单词序列。该文档以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()函数逐个比较,如果匹配返回i+1,否则返回 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 时开销可忽略”,评审老师挑不出毛病。关键字编号用i+1还有个隐藏好处:后续 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; } #endif

5.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,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询