
简介编译原理课程中词法分析器设计实验的完整实验报告面向计算机专业本科生或正在学习编译原理的开发者。报告以一段简单C语言程序为分析对象围绕实验目的、实验内容、程序清单、调试运行、思考题与收获体会六个部分展开完整展示词法分析器的设计与实现思路。报告包含定义种别码、保留字表以及基于状态判断的字符识别逻辑可清晰看到加、减、乘、除等运算符如何被转换为对应Token帮助读者直观理解将源码分解为标记的具体过程。资源共1个doc文件压缩包大小74KB轻巧实用。目前已有489人学习适合用于课程设计参考、实验报告写作借鉴或编译原理复习巩固。思考题关于下划线开头标识符的识别也能触发对词法规则的深入探究而调试过程的分析则为排查词法分析程序中的常见问题提供了参考。1. 词法分析器没你想的那么难但也不只是 switch调试编译原理课的第一个实验很多人把 C 代码敲进编辑器编译一跑要么报错要么输出乱码。实际上词法分析器是编译器里最机械的部分它不需要复杂的算法核心就是逐字符扫描按规则把字符流切成 Token。难点在于数据结构怎么组织、边界条件怎么处理。下面以湖北汽车工业学院那个经典实验为骨架把我自己补全和调试的过程拆开讲。适合刚做完 C 语言课设、准备啃编译原理的学生也适合想复习词法分析实现细节的工程师。看完你能直接复现一个能跑通简单 C 程序子集的词法分析器并且知道下划线标识符、浮点数这些扩展怎么改。先说结论这个实验的代码量不大但坑不少。原实验给了 SYMBOL.H、BASEDATA.H 和 Symbol.c 的片段真正动手时会发现少了大量实现细节比如花括号怎么处理、EOF 怎么判断、token 怎么清空。这些恰恰是词法分析器能不能稳定工作的关键。所以这篇文章不打算一句一句念代码而是按数据结构 → 识别逻辑 → 联调 → 扩展的顺序把每个决策背后的理由讲清楚。2. 数据结构先行种别码、保留字表与字符接口词法分析器的输出是 Token 流Token 的最小载体就是种别码。实验里用枚举 SYMBOL 定义了 32 种符号这一步做好了后面的识别逻辑才能清晰。2.1 种别码枚举为什么用枚举而不是整数typedef enum SYMBOL { NOL, IDENT, NUMBER, PLUS, MINUS, TIMES, SLASH, ODDSYM, EQL, NEQ, LSS, LEQ, GTR, GEQ, LPAREN, RPAREN, COMMA, SEMICOLON, PERIOD, BECOMES, BEGINSYM, ENDSYM, IFSYM, THENSYM, WHILESYM, WRITESYM, READSYM, DOSYM, CALLSYM, CONSTSYM, VARSYM, PROCSYM } SYMBOL;直接用枚举而不是#define 或者字符串好处是编译器能帮你检查类型比如在 switch(sym) 里漏了 case 会有警告。另一个好处是枚举值就是数组下标SNAME[sym] 可以直接拿到种别码的名字字符串调试时 print 出来可读性高。注意枚举的顺序不能乱。NOL 是空 TokenIDENT 是标识符NUMBER 是数字常量然后是一批运算符和分隔符最后是保留字。SYMBOLNUM 定为 32正好对应枚举项数量。我见过有人往中间插一个枚举值导致后面所有种别码错位SNAME 输出全部对不上。所以改枚举时一定要同步检查 SNAME 表后面讲花括号扩展时就会碰到这个问题。2.2 保留字表与 SNAME 表的对应关系#define WORDLEN 13 char *WORD[WORDLEN] {BEGIN,CALL,CONST,DO,END,IF,ODD, PROCEDURE,READ,THEN,VAR,WHILE,WRITE}; SYMBOL WSYM[WORDLEN] {BEGINSYM,CALLSYM,CONSTSYM,DOSYM,ENDSYM,IFSYM, ODDSYM,PROCSYM,READSYM,THENSYM,VARSYM,WHILESYM,WRITESYM};WORD 表是保留字的字符串形式WSYM 表是对应的种别码。为什么保留字不直接用 strcmp 比较因为当识别出一个字母串后最直接的做法是先在 WORD 表里查查到就返回对应种别码查不到就是用户自定义标识符。这样做的好处是识别逻辑只有一份不需要为每个保留字单独写条件。WORDWSYMBEGINBEGINSYMCALLCALLSYMCONSTCONSTSYMIFIFSYMVARVARSYMWHILEWHILESYM这里有个关键点保留字是大写的。实验里的源程序都是小写 main、int、if。如果输入是IF那么识别逻辑里需要先 toupper 转换再查表或者约定源程序统一大写。我一般在 Getbc 之后直接对读入的字母做大小写归一化否则int会被当成普通标识符而不是保留字。这个坑后面调试时会碰到。SNAME 表是反向映射用的。输出种别码时直接 SNAME[sym] 就能得到 PLUS、IFSYM 这样的字符串省去写一长串 switch 的麻烦。注意 SNAME 数组的长度必须和 SYMBOL 枚举一致否则枚举值越界时会读到垃圾字符串。2.3 字符读取接口Getchar 与 Getbc 的分工void Getchar(void); // 从源文件读下一个字符到全局变量 ch void Getbc(void); // 如果当前 ch 是白字符则反复读直到非白字符Getchar 负责从 fin 文件指针读取一个字符读完后 ch 可能是 EOF、字母、数字、空格、换行等。Getbc 在 Getchar 的基础上跳过空格、制表符、回车这样词法分析器在识别单词时不用每次判断空白。两个函数分开设计后面调试时如果要临时看当前字符直接打印 ch 即可。我在补齐代码时把 Getbc 实现为循环调用 Getchar并用 isspace(ch) 判断白字符。注意 EOF 也可能被 isspace 处理所以要先判断 ch ! EOF。另外ch 的类型建议用 int 而不是 char因为 fgetc 返回的是 int如果用 char 接收 EOF-1在部分编译器上会变成 0xFF导致 ch ! EOF 永远成立程序死循环。这个细节实验报告里不会写但实际跑起来立刻就能遇到。2.4 token 数组与边界条件token 是保存单词字符串的全局数组实验里定义为char token[MAXIDLEN]MAXIDLEN 是 50。这个长度对于课堂实验足够但有一个边界条件必须处理当标识符长度逼近 50 时Concat 函数不能越界写。void Concat(void) { int len strlen(token); if (len MAXIDLEN - 1) { token[len] toupper(ch); token[len 1] \0; } }这段代码在实验原片段里没有给出但它决定了程序稳定性。如果 token 已经满了直接忽略新字符避免数组越界。虽然正常源程序不会写 50 个字符的标识符但作为工程习惯这种保护必须加。每次 Getsym 开始前还要执行token[0] \0否则上次的单词会残留在数组里输出和比较都会出错。3. 单词识别核心状态转移与逐字符扫描这一节是整个实验的硬核。Getsym 函数每调用一次就从源文件中识别出一个 Token把单词内容放到 token 数组种别码放到 sym数字值放到 num。3.1 标识符与保留字的识别流程标识符的识别规则是以字母开头后跟字母或数字。对应代码if (isalpha(ch) || ch _) { Concat(); // 把当前 ch 加入 token 数组 Getchar(); // 读下一个字符 while (isalnum(ch) || ch _) { Concat(); Getchar(); } int i; for (i 0; i WORDLEN; i) if (strcmp(token, WORD[i]) 0) { sym WSYM[i]; break; } if (i WORDLEN) sym IDENT; }Concat 把 ch 追加到 token 的当前末尾。这里有个细节在 while 循环退出后ch 已经读到了不属于标识符的下一个字符这个字符不能丢需要作为下一个 Token 的起点。所以 Getsym 采用继续读而不是回退。实验里用 Getchar() 前进而不是用 ungetc因此每次识别完一个单词ch 停留在下一个待识别字符上下次调用 Getsym 时直接用。查保留字表用 strcmp注意大小写。前面提到 Concat 里用 toupper 统一转大写所以 token 里存的是大写字符串。代价是输出 token 时看不到原来的大小写但实验只要求种别码正确不影响。如果你想保留原始大小写可以另开一个原始文本数组这里不展开。3.2 数字、运算符和分隔符的种别码映射数字识别的规则是连续数字字符转换为数值存到 numif (isdigit(ch)) { num 0; while (isdigit(ch)) { num num * 10 (ch - 0); Concat(); Getchar(); } sym NUMBER; }注意 Concat 会把数字字符也存进 token所以输出时 token 显示 10num 显示 10两边对得上。运算符部分用 switch 处理。实验原码只写了三个 case完整版大致这样switch (ch) { case : Concat(); sym PLUS; Getchar(); break; case -: Concat(); sym MINUS; Getchar(); break; case *: Concat(); sym TIMES; Getchar(); break; case /: Concat(); sym SLASH; Getchar(); break; case (: Concat(); sym LPAREN; Getchar(); break; case ): Concat(); sym RPAREN; Getchar(); break; case ,: Concat(); sym COMMA; Getchar(); break; case ;: Concat(); sym SEMICOLON; Getchar(); break; case .: Concat(); sym PERIOD; Getchar(); break; case : Concat(); sym EQL; Getchar(); break; case :: Concat(); Getchar(); if (ch ) { Concat(); sym BECOMES; Getchar(); } break; case : Concat(); Getchar(); if (ch ) { Concat(); sym LEQ; Getchar(); } else if (ch ) { Concat(); sym NEQ; Getchar(); } else sym LSS; break; case : Concat(); Getchar(); if (ch ) { Concat(); sym GEQ; Getchar(); } else sym GTR; break; default: Errorsym(); break; }运算符种别码说明 - * /PLUS MINUS TIMES SLASH单字符运算符EQL赋值或相等语法层再区分 LSS LEQ GTR GEQ可能需要预读下一个字符: BECOMESPL/0 风格的赋值号( ) , ; .LPAREN RPAREN COMMA SEMICOLON PERIOD分隔符这里有一个容易错的地方单字符运算符在 Concat 之后必须调用 Getchar()把 ch 推进到下一个字符。我就因为忘了这一句导致下一个 Token 的首字符是当前运算符死循环。对于和这类可能组成复合运算符的符号需要多看一个字符。比如流程是先读然后马上 Getchar() 读下一个如果是则 Concat 并设置 LEQ否则保持 LSS并且下一个字符已经在 ch 里了。这种预读一个字符是词法分析器的基本功代价是代码里每个分支都要小心处理 ch 的状态。3.3 错误处理机制Errorsym 与未识别字符当 ch 既不是字母、数字也不是已知运算符时调用 Errorsym 输出错误信息。实验里 Errorsym 的具体实现没给我一般这样写void Errorsym(void) { fprintf(stderr, Error: unrecognized character %c (0x%02x)\n, ch, ch); Concat(); Getchar(); }注意把错误字符也 Concat 进 token这样主程序输出时能看到到底错在哪。然后继续 Getchar避免死循环。这里一个常见的坑是直接 Concat 后忘了 Getchar那么 ch 还是那个非法字符下次循环还会报错形成无限输出。所以错误处理里最重要的原则是——每个分支都必须明确推进 ch 或结束循环。4. 完整实现与联调把那个简单 C 程序跑起来光看片段不跑起来等于没学。这一节我把补齐后的完整代码结构和主流程讲清楚并给出针对实验源程序的测试输出。4.1 补齐 Symbol.c 中的关键函数实验报告里只有函数声明和零散片段。补全时我遵循原框架定义全局变量并实现辅助函数#include basedata.h #include symbol.h #include ctype.h #include string.h FILE *fin, *fout; int ch; // 用 int 接收 fgetc 返回值避免 EOF 截断 char token[MAXIDLEN]; int num; SYMBOL sym; void Getchar(void) { ch fgetc(fin); } void Getbc(void) { while (ch ! EOF isspace(ch)) Getchar(); }Init 和 Quit 负责打开/关闭文件。这里我直接写死了文件名 source.c实际使用时可以通过命令行参数传入void Init(void) { fin fopen(source.c, r); if (fin NULL) { perror(source.c); exit(1); } fout fopen(output.txt, w); Getchar(); } void Quit(void) { fclose(fin); fclose(fout); }Getsym 内部按之前的逻辑写入口处先清空 token再调用 Getbc 跳过空白。4.2 花括号问题与枚举扩展实验源程序是 C 语言里面有{和}但原 SYMBOL 枚举里没有对应项。这是一个很实际的问题直接跑的话遇到{会进入 Errorsym输出一堆错误。为了跑通实验源程序需要在枚举里增加左花括号和右花括号typedef enum SYMBOL { NOL, IDENT, NUMBER, PLUS, MINUS, TIMES, SLASH, ODDSYM, EQL, NEQ, LSS, LEQ, GTR, GEQ, LPAREN, RPAREN, COMMA, SEMICOLON, PERIOD, BECOMES, LBRACE, RBRACE, // 新增的 C 语言花括号 BEGINSYM, ENDSYM, IFSYM, THENSYM, WHILESYM, WRITESYM, READSYM, DOSYM, CALLSYM, CONSTSYM, VARSYM, PROCSYM } SYMBOL;同时更新 SYMBOLNUM 为 34SNAME 表里补上 LBRACE 和 RBRACEswitch 里补上对应 casecase {: Concat(); sym LBRACE; Getchar(); break; case }: Concat(); sym RBRACE; Getchar(); break;这个改动很典型原实验的种别码是按 PL/0 风格设计的但待分析源程序是 C 语言。做实验时不能死板照抄要能根据输入调整数据结构。另外else、float这些词不在保留字表里会被识别为 IDENT这在词法层面是允许的语义是否正确交给语法分析阶段。4.3 主循环与输出格式主函数反复调用 Getsym直到 EOFint main() { int flag; Init(); fprintf(fout, \n TOKEN SYM NUM); 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); Quit(); return 0; }Getsym 的返回值定义为 int正常识别返回 1遇到无法识别的字符返回 0。主循环里ch ! EOF作为终止条件。因为 Getsym 内部可能已经把 ch 读成 EOF所以最后一次循环会结束在ch EOF上。针对实验给出的源程序跑出来的输出应该是类似这样的我这里省略了完整输出TOKEN SYM NUM MAIN IDENT 0 LPAREN LPAREN 0 RPAREN RPAREN 0 LBRACE LBRACE 0 INT IDENT 0 X IDENT 0 COMMA COMMA 0 ... IF IFSYM 0 ...注意main因为不在保留字表里所以是 IDENTint、float同样。花括号能正确输出说明枚举扩展生效。4.4 调试过程中的典型问题EOF 错误判断ch 定义为 char 时fgetc 返回的 EOF 可能被截断成 0xFF导致ch ! EOF永远为真死循环。改成 int 即可。token 残留如果不每次清空 token上一次的单词内容会拼在本次后面。在 Getsym 入口处必须执行token[0] \0。保留字大小写源程序小写保留字表大写不归一化就匹配不上。用 toupper 处理。花括号未定义正如上面说的直接报错或陷入 default 分支。需要在枚举和 switch 里补充。文件路径fopen 依赖当前工作目录。运行时如果找不到文件先检查路径。我一般用相对路径配合命令行参数传入文件名。5. 扩展让词法分析器识别下划线标识符和浮点数实验思考题问的是下划线开头标识符怎么改。其实很简单把标识符的第一个字符判定从 isalpha(ch) 改成 isalpha(ch) || ch _循环条件同理。if (isalpha(ch) || ch _) { Concat(); Getchar(); while (isalnum(ch) || ch _) { Concat(); Getchar(); } // 查保留字表... }注意 Concat 里的 toupper() 会返回 本身所以下划线能原样保留。测试用例可以写_tmp 1;输出应该是_TMP对应 IDENT。浮点数的识别比下划线稍微复杂一点。常见做法是在数字识别分支里加入小数点的处理if (isdigit(ch)) { num 0; while (isdigit(ch)) { num num * 10 (ch - 0); Concat(); Getchar(); } if (ch .) { Concat(); Getchar(); double decimal 0.0; double scale 0.1; while (isdigit(ch)) { decimal (ch - 0) * scale; scale * 0.1; Concat(); Getchar(); } // 如果 num 保留整数部分小数部分存到全局变量或者直接转换整个 token sym NUMBER; } }但要注意原实验里的 num 是 int 类型存不了小数。如果只要求识别 Token可以把 token 字符串保留完整然后输出时用 atof 转换。如果实验要求数值准确就得把 num 类型改成 double。我只建议做3.14这类完整浮点的识别不建议把1.2.3也放行那属于非法输入。验证方法准备一个测试文件包含_main 10; _float 3.14; _a_1 _float / _main;然后跑。观察输出里_MAIN、_FLOAT、_A_1都能识别为 IDENT3.14如果没加浮点支持会被拆成3、.、14三个 Token加了之后就是一个 NUMBER。通过对比输出就能确认扩展是否生效。调试词法分析器时不要直接拿完整 C 程序跑先准备一个只包含单个 Token 的文件比如只写if、123、分别验证。然后再组装成完整程序。这样排查问题会快很多。本文还有配套的精品资源点击获取