☰
C语言词法分析器与语法分析器课设全流程指南
2026/10/12 1:13:13 网站建设 项目流程

简介:这是一份C语言词法分析器和C语言语法分析器的编译原理课程设计报告书,面向计算机类专业学生、编译原理课程学习者以及需要完成类似课设的开发者,既可作为课程设计报告撰写的参考范本,也可作为词法分析与语法分析实验实现的辅助资料。报告从实验目的与意义、C语言词法特点入手,系统梳理保留字、特殊符号、标识符等Token定义,并给出基于正则表达式和确定性有限自动机(DFA)的词法分析器设计方案;其中详细展示了Token类型枚举代码、注释DFA与词法分析DFA的状态转移设计,同时解析语法分析器的工作原理与实现技术,有助于理解词法到语法的完整分析流程。资源为1个doc文档,整包约379KB,结构清晰、内容紧凑,便于直接参考与复用。已有274人学习,适合正在准备编译原理课程设计、需要完整报告思路与代码说明的读者;读者可直接参照其中的正则表达式与DFA设计思路进行编码实现,减少从零搭建的摸索时间。

1. 拿到“C语言词法分析器”和“语法分析器”课设,先别急着敲代码

大二或大三的编译原理课,总有一道绕不开的题目:用C语言写一个词法分析器,再写一个语法分析器,最后交一份《课程设计报告书.doc》。很多人的第一反应是上网找一份源码改个文件名就交差,结果答辩时老师问“为什么你的保留字表用数组不用哈希”“这个状态是怎么跳转的”,当场卡壳。这篇文章我就按自己做过一遍的流程,把词法分析器、语法分析器以及报告书写作拆成能照做的步骤,参数、边界和常见坑都标出来,让你从动手到交报告心里都有底。

2. 词法分析器的设计:从字符流到token序列,三步走

2.1 先定token集合和保留字表:别把int当成标识符

词法分析器的目标是把源代码字符串切成一串有意义的token。这里的token不是“单词”,而是带类别标记的二元组,一般记作(token类别, token值)。做这一步之前,必须先列一张“我要识别哪些东西”的清单,否则写代码时边界特别容易乱。

常见做法是把token分成五类:标识符、关键字、常数(整数、浮点数)、运算符、界符。对于C语言课设,最小集合至少包含:

token类别例子token值的设计
关键字ifelsewhileintreturn用一个枚举或字符串,IR_IF之类
标识符count_tmpx1存到字符数组,区分大小写
整数常数1230直接转为int存储
运算符+-*/==!=单字符或双字符,注意=和==要分开
界符;(){}每个界符对应一个token类别

保留字表我建议一开始就用数组,不要急着上哈希。课设代码量不大,线性查找完全够用;等做完基础版,再把数组换成哈希表写进“改进与展望”里,反而显得你有思考。但有一个原则:关键字必须先查表,识别出标识符后再查关键字表,命中就改成关键字token,否则就是标识符。很多新手把顺序写反,导致输入int的时候被当成普通标识符。

2.2 状态转换图与实现:用逐字符扫描代替正则库

词法分析器最稳妥的实现是“手工状态转换图”,而不是去调正则库。课程设计环境下,老师更希望看到你能画出状态图,并且能用C代码把它描述出来。我用的是一个极简的自动机:一个全局state变量,外加当前字符ch,每个循环根据(state, ch)决定下一步。

下面这段代码是一个能识别标识符、整数和基本运算符的骨架,保留了后续扩展浮点数的位置。

#include <stdio.h> #include <ctype.h> #include <string.h> #define MAX_ID_LEN 128 #define MAX_TOKEN_LEN 128 typedef enum { TK_ID, TK_NUM, TK_OP, TK_EOF } TokenType; typedef struct { TokenType type; char lexeme[MAX_TOKEN_LEN]; // 实际字符串 int intVal; // 整数值,仅 TK_NUM 有效 } Token; // 状态:0 初始,1 标识符,2 整数,3 运算符,4 遇到无法继续字符 int state = 0; Token nextToken(FILE *fp) { Token tok; int ch; char buf[MAX_ID_LEN]; int len = 0; memset(&tok, 0, sizeof(tok)); // 跳过空白字符,这里不把空格和换行当作 token while ((ch = fgetc(fp)) != EOF && isspace(ch)) ; if (ch == EOF) { tok.type = TK_EOF; return tok; } // 标识符:字母或下划线开头 if (isalpha(ch) || ch == '_') { buf[len++] = (char)ch; while ((ch = fgetc(fp)) != EOF && (isalnum(ch) || ch == '_')) { if (len < MAX_ID_LEN - 1) buf[len++] = (char)ch; } if (ch != EOF) ungetc(ch, fp); // 多读一位要吐回去 buf[len] = '\0'; strncpy(tok.lexeme, buf, MAX_TOKEN_LEN - 1); tok.type = TK_ID; return tok; } // 整数常数 if (isdigit(ch)) { int val = 0; while (ch != EOF && isdigit(ch)) { val = val * 10 + (ch - '0'); ch = fgetc(fp); } if (ch != EOF) ungetc(ch, fp); tok.type = TK_NUM; tok.intVal = val; sprintf(tok.lexeme, "%d", val); return tok; } // 运算符:这里先处理 + - * / 和 = ,再处理 == if (ch == '+' || ch == '-' || ch == '*' || ch == '/' || ch == '=') { int first = ch; int second = fgetc(fp); if (first == '=' && second == '=') { strcpy(tok.lexeme, "=="); } else if (first == '=') { strcpy(tok.lexeme, "="); if (second != EOF) ungetc(second, fp); } else { strcpy(tok.lexeme, (char[]){first, '\0'}); if (second != EOF) ungetc(second, fp); } tok.type = TK_OP; return tok; } // 其他字符(界符等)先原样输出 sprintf(tok.lexeme, "%c", ch); tok.type = TK_OP; return tok; }

这段代码的逻辑核心是“读一个字符,判断能不能继续当前状态;不能继续就把这个字符退回流中”。ungetc就是那个后悔药:词法分析器经常会向前多看一位才能判断一个token在哪里结束。比如读123abc,按规则应该识别出整数123和标识符abc,所以读到a时不能直接吞掉,要ungetc塞回去。参数方面,MAX_ID_LEN要留足余量,一般128足够;如果你要处理很长的标识符,可以改成256,但注意溢出检查不能省。

2.3 缓冲区与文件读取:fopen后要处理换行和文件结尾

课设里最容易翻车的不是自动机构造,而是读取源代码的方式。有人习惯用scanf读字符串,再逐字符处理,结果一遇到空格和换行就乱套。更稳的方案是直接fopen一个.c源文件,用fgetc读字符流。这样“文件缓冲区”完全是C标准库帮你管理,不需要自己维护复杂的输入缓冲。

FILE *fp = fopen("test.c", "r"); if (fp == NULL) { perror("无法打开源代码文件"); return 1; } Token t; while (1) { t = nextToken(fp); if (t.type == TK_EOF) break; if (t.type == TK_ID && isKeyword(t.lexeme)) { printf("关键字: %s\n", t.lexeme); } else if (t.type == TK_NUM) { printf("整数: %s (%d)\n", t.lexeme, t.intVal); } else { printf("其他token: %s\n", t.lexeme); } } fclose(fp);

这里最容易忘的有两件事。第一,文件路径写的是相对路径,程序工作目录跟.c源文件不在同一个目录时,fopen会返回NULL,这时候用perror能快速定位。第二,Windows环境下fopen打开文本文件时,\r\n会被翻译成\n,这在读行号时没问题,但如果你想把每行原样输出并记录行号,就得区分平台,或者在报告里说明你只处理\n。另外,fclose一定不能省,不关文件虽然程序结束也会释放,但课设代码会造成一种“文件一直占用”的假象,让人误以为是缓冲区没刷出去。

3. 语法分析器:递归下降和LR(1)怎么选,课设里哪个更稳

3.1 文法改写:消除左递归和提取左因子是第一步

词法分析拿到的是token流,语法分析要做的是判断这些token能不能由文法生成。课程设计一般不会让你真的构造完整的C语言文法,而是给一个子集:表达式、赋值语句、if/else、while。这个子集用递归下降就能做,但前提是文法必须改写成“LL(1)兼容”的形式。

最典型的坑是表达式文法。很多人第一反应写:

E -> E + T | T T -> T * F | F F -> (E) | id | num

这个文法直观,但左递归会让递归下降进入无限下降:parseE一开始就调用parseE,永远没有机会消费token。必须改写成右递归形式:

E -> T E' E' -> + T E' | ε T -> F T' T' -> * F T' | ε F -> ( E ) | id | num

这里ε表示空串。改写后,每个非终结符对应一个函数,函数里按选择分支去匹配。我在报告里画了一张“改写前/改写后”对照表,老师一眼就能看出你懂左递归的危害。

3.2 递归下降子程序:每个非终结符一个函数

递归下降的核心是“用手写代码模拟最右推导”。具体做法:全局维护一个lookahead变量,代表当前看到的token;每个非终结符函数负责判断当前token能否作为该非终结符的开头,然后依次调用下层非终结符函数。

下面是一段支持+和*的表达式子集代码,输入token流已经由词法分析器生成:

#include <stdio.h> extern Token nextToken(FILE *fp); // 在词法模块实现 Token lookahead; void match(TokenType expected) { if (lookahead.type == expected) { lookahead = nextToken(fp); } else { printf("语法错误: 期望 token 类型 %d,实际是 %s\n", expected, lookahead.lexeme); // 简单的 panic-mode 错误恢复 while (lookahead.type != TK_EOF && lookahead.type != TK_SEMI) lookahead = nextToken(fp); } } void parseE(void) { parseT(); while (lookahead.type == TK_PLUS) { match(TK_PLUS); parseT(); } } void parseT(void) { parseF(); while (lookahead.type == TK_MUL) { match(TK_MUL); parseF(); } } void parseF(void) { if (lookahead.type == TK_NUM || lookahead.type == TK_ID) { lookahead = nextToken(fp); } else if (lookahead.type == TK_LPAREN) { match(TK_LPAREN); parseE(); match(TK_RPAREN); } else { printf("语法错误: 意外的 %s\n", lookahead.lexeme); } }

这里有个细节:parseE里我用的是while循环而不是递归调用E'。文法改成右递归后,仍然可以直接用循环处理+的重复出现,效果一样,但不会增加递归深度。很多教材把E'写成递归函数,你递归调用多了,遇上很长的表达式比如1+2+3+...+100,可能栈溢出;用循环更保险。match函数是递归下降的流量阀,它只负责两件事:类型对了就推进token,类型不对就报错并跳到分号附近继续分析。

3.3 用LR(1)分析表做语法分析的备选路线

有的课设题目点名要求“用LR(1)分析表”,这时候递归下降就不算数了。LR(1)分析器需要一个二维表:行是状态,列是终结符和非终结符,表项填“移进/规约/接受/报错”。手算这个表对小文法不算难,但对C语言子集就很痛苦,一般用YACC/Bison自动生成,可课设要求“C语言实现”,这就有点尴尬。

我的建议是,如果你的题目没强制LR,就用递归下降,重在讲清楚“预测分析的思路”。如果强制LR,你可以只做一个小表达式文法,把LR(1)分析表的构建过程写到报告里,并用一个二维数组存储表项。核心代码一般是:

// action[s][a]:状态 s、终结符 a 时的动作。 // 1 表示移进,2 表示规约,0 表示出错,-1 表示接受。 int action[5][6] = { /* 状态0 */ { 1, 0, 0, 2, 0, -1 }, /* 状态1 */ { 0, 1, 0, 0, 3, 0 }, // 具体数值根据你的文法和状态图填 };

这种方式的难点不在写数组,而在于让你能把状态转移图变成表。你可以在报告里附一张“LR分析表示例”表格,不需要覆盖整个C语言,只用E -> E+T | T这样的四条文法即可。因为课程设计的评分点通常是“你理解LR分析过程吗”,而不是“你的表覆盖了完整C语言吗”。

3.4 错误处理:给自己留一条“跳过错”的活路

语法分析器拿到非法输入时必须给出有意义的报错,而不是直接崩溃或死循环。最简单的错误恢复是panic mode:发现错误后,一直跳过token,直到遇到一个同步记号,比如分号或右括号。上面match函数的while循环就是干这个的。这个方案不完美,但它符合课设要求,也让你以后理解编译器“错误恢复”时有一个具象起点。

如果你想提升一点分数,可以做一个“同步token集合”。比如在parseE中,遇到错误后把;}EOF当作同步集合,只跳到下一个语句边界。报告里记录一次错误输入1+*2,让老师看到你的分析器会报“expected number, got*”,而不是整段卡住,这比“能跑通合法输入”更能体现你对项目边界的理解。

4. 课程设计报告书.doc 怎么写才不是“代码粘贴合集”

4.1 报告结构:从需求分析到测试结果的标准六章

你手里那份《课程设计报告书.doc》如果只贴了源码和运行截图,老师翻两页就失去兴趣。一份合格的报告书要有主线:先讲清楚“要解决什么问题”,再讲“设计思路”,最后是“怎么证明它对了”。常见做法是下面六章:

章节内容页数建议
1. 需求分析输入/输出定义、功能范围、运行环境1~2页
2. 总体设计模块划分:词法模块、语法模块、主控模块1~2页
3. 详细设计状态转换图、文法改写、函数接口设计3~4页
4. 编码实现关键代码片段,不是全部代码3~5页
5. 测试与结果测试用例表、运行截图、错误处理表现2~3页
6. 总结与展望遇到的问题、改进方向1页

很多人把“编码实现”写成一整篇源码,这是大忌。老师想看到的是“你为什么这么写”,不是“你复制了什么”。我会把每个函数挑一段最核心的代码放进报告,并用小字号注释解释状态变量和参数含义。其他代码作为附录附在后面,或者说明“完整源码见文件”。

4.2 关键图表:状态转换图、语法树和截图怎么排版

报告里的图不只是装饰,它能直接帮你拿分。词法分析部分必须画一张“标识符识别状态图”:初态到终态的圆圈,边上标触发字符。语法分析部分画表达式1+2*3的语法分析树,把+作为根,*作为右子树。这两张图我建议用Visio或draw.io画,导出成PNG再插入Word,不要用Word自带形状库里歪歪扭扭的线条。

插入图片有个排版细节:图片宽度不要超过页面正文宽度。Word中设置为“居中”,大小控制在12cm左右。如果截图里有代码,字号要放大到能看清,否则老师会认为你故意模糊。图片下方加“图3-1 标识符识别状态图”这样的编号,文中引用时写“见图3-1”,不要让插图孤零零地悬在页面中间,却没有任何正文提到它。

4.3 测试用例设计:覆盖正常、边界和错误输入

测试章节是报告书里性价比最高的部分,却常被忽略。我一般设计三组用例:正常用例、边界用例、错误用例,每一条都写清楚“输入是什么、预期输出是什么、实际输出是什么”。光贴运行截图没有说服力,一张测试用例表立刻让报告专业化。

测试类型输入预期结果实际结果
正常int main(){ return 0; }所有token识别正确,语法分析通过同预期
边界a1 = 123456789;整数能完整读入,不溢出同预期
边界if(a==b) c=1;==识别为单个运算符,而不是两个=同预期
错误1+*2;语法错误,报告位置在*处同预期
错误int 123abc;词法错误,提示非法标识符同预期

边界用例里最容易漏的是“最大标识符长度”和“整数溢出”。我在代码里用MAX_ID_LEN=128限制标识符长度,超过后截断,但报告里要写清楚“这里做了截断而不是报错,因为C语言标识符长度标准允许不同实现”。至于整数,课设只要求int范围,输入99999999999999时用超出int范围的数字测试,说明你的分析器或报错或在读入时截断,不能静默算错。

5. 避坑排查:词法、语法和报告书里的经典翻车点

5.1 现象一:getchar和fgetc混用,换行符残留导致第一个token读空

很多人先写一个交互界面,用scanf让用户输入字符串,又在同一个程序里用fgetc读文件,结果输入完字符串后残留了一个换行符,fgetc第一次读到的就是\n,直接被当成空白跳过,看起来像“文件没读进来”。原因就是scanf和fgetc共用了同一个输入缓冲区,scanf不会消费后面的换行符。

解决办法是统一输入来源。要么全部走文件,要么全部走内存字符串。如果一定要从标准输入读取源代码,就用一个fgets把整段读进char数组,然后为词法分析器提供一个“从缓冲区读字符”的函数,不要混用两套读取API。报告里可以加一句“程序支持从文件或标准输入读取,但统一通过getNextChar()抽象层访问”,这比在main函数里反复调试缓冲区干净得多。

5.2 现象二:关键字全部识别成标识符,int和if被当成普通名字

代码里写成if (strcmp(lexeme, "int") == 0)没什么问题,但有人图省事写成if (lexeme == "int"),在C语言里比较的是指针地址,结果永远不相等。更隐蔽的错误是,有人把关键字表定义成char *keywords[] = {"int", "if"};,却用keywords[i] == lexeme比较两个不同的字符数组,同样不会命中。

解决方法是统一用strcmp,或者给TokenType设计成枚举,在词法分析器识别标识符时直接查表。我的习惯是写一个isKeyword函数,内部先用二分查找或顺序查找,返回枚举值;如果返回-1就是普通标识符。这个函数要放在一个独立的keyword.c文件里,方便报告里截图,也让main函数显得干净。

5.3 现象三:表达式文法没消除左递归,运行时栈溢出

递归下降分析表达式时,如果一开始就用E -> E + T这种左递归文法,函数parseE会无限调用自己。表现是程序在解析第一个token之后没有任何输出,直接Stack Overflow崩溃,或者卡死。原因就是对“递归下降要求LL文法”理解不深。此时不要盲目加大栈空间,那只是掩耳盗铃。正确做法是回到文法改写,把左递归变成右递归,对应到代码上就是用while循环处理+和*的重复项,我在3.2给出的写法就是模板。

一个小技巧:检验一个递归下降函数是否有左递归,看它调用的第一个函数是不是它自己。parseE第一句如果是parseE,基本判定死刑;第一句若是parseT,还需要继续追parseT第一句,直到某个函数消费token为止。

5.4 现象四:==被分析成两个=,或>=只识别出>导致语法错乱

词法分析器预处理运算符时,常见实现是“先读第一个字符,再读第二个字符,如果组合合法就组成双字符运算符”。但有人图简单只读一个字符就返回token,导致a==b被解析成a = = b,语法分析时=后面期望一个表达式,结果来了一个=,直接报错。原因是运算符识别没做“向前看一个字符”的处理。

解决方法是复刻2.2代码里的分支:读入=后,再fgetc一位,如果是=则组成==,否则ungetc把第二位还回去,只返回=。<=、>=、!=同理。报告里最好专门加一个测试用例“边界运算符组合”,把==、!=、<=、>=逐项列出并注明“能区分单字符与双字符运算符”。

5.5 现象五:报告书里代码块字体混乱,导师批注“排版混乱”

这个坑跟程序无关,但直接影响成绩。有人从IDE复制代码到Word时,行号、缩进、空格全变成乱码,中文注释还带有特殊字体,整页看起来参差不齐。原因是Word直接粘贴带有源格式或使用了不支持的字体。解决方法是把代码先粘贴到记事本,再从记事本复制到Word,或者直接用Word的“粘贴为纯文本”选项。

我在写报告时会把所有代码统一成Consolas或Courier New,字号小五,中文注释用宋体,行距固定值18磅。代码截图不要超过半页,关键代码控制在10~20行。更重要的一点:报告中的代码必须是可编译的,不能从网上抄一段中间缺几行。因为老师可能真的会运行,也可能会把你的关键函数挑出来问细节。

6. 把符号表和错误恢复加进去,让课设从及格到优秀

如果基础版已经跑通,想让分数再上一个台阶,我建议优先加一个轻量符号表。符号表不一定要做复杂的作用域栈,只需要在识别到标识符时插入表项,记录该标识符的名字和出现行号,语法分析结束时能统计“标识符总数和不同标识符个数”。实现上就是在词法分析的TK_ID分支里调用一个insertSymbol(lexeme, lineNo)函数,然后给每个表项加一个引用计数。报告里多一张符号表截图,再写一段“符号表为后续语义分析提供基础”,整个课设就从“做了一个玩具”变成了“做了一个编译器雏形”。

错误恢复也可以再往前走一步。我在3.4里用了panic mode,进阶版可以试试“同步符号集合”:在每个语法分析函数的错误处理分支中,定义一组token作为“同步点”,遇到错误后跳过直到遇到这些token,再继续分析。比如在语句开始处同步;和},在表达式开始处同步+和;。这样一次输入1+*2; a=3;可以报两个错误而不是第一个错误就终止。

我自己的教训是:第一次课设只做了词法分析,没写语法分析,结果答辩时老师说“你这叫包含词法分析器的课程设计,不是语法分析器”。后来我把语法分析加进去,又花了一晚上补符号表和错误恢复,代码只多了80行,报告加了四页,成绩从“中”直接跳到“优秀”。课设的意义不在于给老师交差,而是让编译原理那些抽象概念在你手里真正跑起来。希望帮到你。

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

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

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

立即咨询