
简介编译原理实验一的C语言版词法分析器资源面向正在学习编译原理或需要编写词法扫描器的本科生与自学者重点解决从源代码中识别关键字、标识符、常量、运算符等Token的完整流程。压缩包共3个文件包含cpp源程序、docx实验报告和txt测试源文件整体大小仅86KB其中cpp文件展示状态机或规则匹配实现docx梳理实验目的、步骤、结果与问题txt则提供用于验证的原始测试代码。该资源已有2167人学习属于编译原理入门阶段的常用实验便于快速对照源码与报告梳理设计思路。读者借助这份资源既能理解词法分析器的工作原理与标记分类方法也能从实验报告中获取排错思路还可将C语言实现思路迁移至其他语言的扫描器设计中。整体而言是一份小而完整的词法分析器设计参考资料。1. 词法分析器编译器第一关先看懂输入流再谈语法很多人写编译器卡在第一个坎儿就动不了手不是不理解上下文无关文法而是连“怎么从一串字符里切出有意义的单词”都没理清。词法分析器Lexer/Scanner就是干这件事的它把原始源代码逐字符读入按词法规则切成 Token 流交给语法分析器。C 语言版本的词法分析器是编译原理课程的经典实验这个压缩包里除了实验报告和源程序文件还有一份可运行的 .cpp 实现。这套东西的价值不只是应付作业它把状态机、字符分类、缓冲输入、错误恢复这些底层技能一次性练全了对后面学正则、学 Flex、甚至写脚本解释器都有直接帮助。适合正在啃编译原理的学生也适合想快速回顾编译前端流程的工程师。2. 从状态机到 Token词法分析器的结构设计与 C 语言实现2.1 词法规则与 Token 类型定义词法分析器首先要回答一个问题什么样的字符序列算一个 TokenC 语言里至少需要区分关键字、标识符、整型常量、浮点常量、运算符、分隔符这几类。在 C 语言实现中最直接的做法是用一个枚举类型列出所有 Token 种类再配一张关键字查找表。常见做法是typedef enum { TOKEN_EOF 0, TOKEN_INT, // int TOKEN_FLOAT, // float TOKEN_IF, // if TOKEN_ELSE, // else TOKEN_WHILE, // while TOKEN_RETURN, // return TOKEN_IDENTIFIER, // 变量名/函数名 TOKEN_NUMBER, // 数字常量 TOKEN_PLUS, // TOKEN_MINUS, // - TOKEN_STAR, // * TOKEN_SLASH, // / TOKEN_ASSIGN, // TOKEN_LPAREN, // ( TOKEN_RPAREN, // ) TOKEN_LBRACE, // { TOKEN_RBRACE, // } TOKEN_SEMICOLON, // ; TOKEN_ERROR // 非法字符 } TokenType;这里把关键字单独列出来而不是混在标识符里是为了后面查表方便。识别逻辑是先按字母或下划线开头扫描出一串字符然后查关键字表命中就是关键字否则就是普通标识符。Token 类型定义是整个分析器的基础后续所有函数都围绕这个枚举工作。实验报告里如果只写“能识别 Token”是不够的最好把每个枚举对应的正则表达式或状态描述也列出来这样别人看着报告就能复现你的分类逻辑。2.2 状态机驱动的字符扫描核心循环与状态转移词法分析器本质是一个有限状态自动机。最省事的实现方式是写一个大循环每次读取一个字符根据当前状态决定下一步动作。我写的时候习惯把“字符分类”和“状态转移”分开字符分类函数负责判断当前字符是字母、数字、运算符还是空白状态转移代码则根据分类结果决定当前 Token 是否结束。2.2.1 标识符与关键字识别标识符的规则是“字母或下划线开头后跟字母、数字或下划线”。扫描时如果遇到第一个字符是字母或下划线就进入“读取标识符”状态一直读到非标识符字符为止。这里有一个容易被忽略的细节读到分隔符字符时该字符不能直接丢给下一次循环要把它“回退”到输入流因为它可能是下一个 Token 的开始。用ungetc(c, stdin)可以解决但如果用的是自定义缓冲区就需要维护一个pushback指针。下面是一个典型的扫描片段// 读取标识符或关键字 int c get_char(); if (isalpha(c) || c _) { int len 0; char buf[MAX_ID_LEN 1]; while (isalnum(c) || c _) { if (len MAX_ID_LEN) buf[len] (char)c; c get_char(); } buf[len] \0; unget_char(c); // 关键回退一个字符 // 查关键字表 TokenType t lookup_keyword(buf); if (t TOKEN_IDENTIFIER) { // 保存标识符名到全局表供符号表使用 } return t; }这段代码里unget_char()是自定义的回退函数对应标准库的ungetc()。如果不做回退第一次扫描会把int a;中的空格也吞掉导致后面数字识别错乱。MAX_ID_LEN建议设成 255 或 1024C 标准规定最小有效长度为 63但很多编译器支持更长。实际代码中要加长度溢出判断这里先展示简化版本。2.2.2 数字常量与运算符的识别数字识别的难点在于区分整数和浮点数。简单实现可以只识别整型常量但实验要求一般会有“识别无符号整数”这类描述。如果想做得更完整需要处理十进制、八进制0 开头、十六进制0x 开头以及小数点。状态转移如下遇到数字进入“整数收集”状态如果中途遇到.进入“浮点数收集”状态如果遇到e或E还要处理指数部分。这里最容易踩的坑是int a 10;后面的分号被误判为数字的一部分所以读完整数后同样要回退分隔符。运算符相对简单但要注意双字符运算符比如、!、、、、||。一个稳妥的策略是读到一个运算符字符时先不急着返回而是再看一位字符判断能否组成双字符运算符。比如读到后再读一位如果是返回TOKEN_EQ否则把作为赋值号并且把多读的字符回退。2.3 错误处理与恢复策略词法错误一般分两类一类是非法字符比如、#如果忽略预处理符另一类是非法数字比如12abc按严格定义这应该报错而不是拆成数字12和标识符abc。常规做法是扫描到非法字符时打印带行列号的错误信息然后跳过该字符继续扫描这叫“错误恢复”。更精细的做法是同步到最近的分号或右括号避免错误级联。实验报告里如果能写出错误恢复策略会比只贴代码得分高很多。表1列出常见错误类型和处理建议。错误类型示例推荐处理方式非法字符$,报告错误跳过该字符继续扫描数字后直接跟字母12abc报告“非法数字”将整体剔除或作为错误 Token字符串未闭合abc报告错误跳过到换行或文件末尾注释未闭合/* abc报告错误跳过到文件末尾文件末尾残留反斜杠\报告错误视为文件结束错误处理需要记录当前行号和列号。行号可以在读到\n时递增列号是当前行内字符偏移。很多学生只打印“error”老师看报告时不知道错在哪一行这点在实验报告里要重点体现。3. 完整代码拆解程序.cpp 的关键函数与数据流3.1 输入缓冲与逐字符读取实验给定的程序.cpp一般不会用getchar()直接读因为那样每次读一个字符效率低而且不方便回退。更好的做法是用fgets()或自定义read_char()函数维护一个内部缓冲区。这里给出一个带缓冲的读取器#define MAX_BUFFER 4096 static char buffer[MAX_BUFFER]; static int buf_pos 0; static int buf_size 0; // 从文件读入一块数据 static int refill_buffer(FILE *fp) { buf_pos 0; buf_size (int)fread(buffer, 1, MAX_BUFFER, fp); return buf_size; } // 读取下一个字符文件结束返回 EOF static int read_char(FILE *fp) { if (buf_pos buf_size) { if (refill_buffer(fp) 0) return EOF; } return (unsigned char)buffer[buf_pos]; } // 回退一个字符 static void unget_char() { if (buf_pos 0) buf_pos--; }这个实现的核心是用buf_pos和buf_size管理缓冲区内位置read_char()在缓冲区耗尽时自动填充unget_char()只是把指针回退一格不回退超过一个字符。注意read_char()中把返回值强转为unsigned char避免二进制补码下 EOF 与0xFF冲突。使用fread而不是fgets是因为它不处理换行转义完全保留原始字节适合词法分析。3.2 识别器的主循环与 Token 返回结构词法分析器对外接口通常是Token get_next_token(FILE *fp)。这个函数内部是一个while(1)循环先跳过空白和注释然后根据首字符类型进入不同分支。数据结构定义如下typedef struct { TokenType type; char lexeme[MAX_LEXEME_LEN]; // 原始字符串 int line; int column; double value; // 如果是数字存储数值 } Token; // 全局文件指针和当前行列 static FILE *g_fp; static int g_line 1; static int g_col 0; Token get_next_token() { Token tok {0}; int c; // 跳过空白 while ((c read_char(g_fp)) ! EOF) { if (c || c \t) { g_col; continue; } if (c \n) { g_line; g_col 0; continue; } break; } if (c EOF) { tok.type TOKEN_EOF; return tok; } // 处理注释... // 处理标识符、数字、运算符... // 错误处理... return tok; }主循环中g_col的递增逻辑很重要空格、制表符各占一列换行后列号清零。注释处理时行号列号更要小心。多数人写到这里会忽略列号导致后面错误定位偏差。3.3 输出格式化与测试驱动调试词法分析器的最好方法是写一个main()循环调用get_next_token()并把结果打印成表格。输出格式最好与报告中的示例保持一致这样可以直接截屏作为实验结果。例如int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, Usage: lexer source-file\n); return 1; } g_fp fopen(argv[1], r); if (!g_fp) { perror(open); return 1; } Token tok; while ((tok get_next_token()).type ! TOKEN_EOF) { printf(%-4d %-8d %-16s %s\n, tok.line, tok.column, token_type_name(tok.type), tok.lexeme); } fclose(g_fp); return 0; }token_type_name()是把枚举映射成字符串的辅助函数没有它没法打印。测试时可以直接用系统自带的 C 文件当输入比如把自己写的lexer.c源码扫描一遍看看能不能正确识别出int、main、括号、花括号这些 Token。4. 实验报告写法与测试用例设计从“能跑”到“能过”4.1 实验报告的结构与关键内容取舍压缩包里的实验报告.docx 通常有固定模板但很多学生只写实验目的和代码清单缺少设计分析。一份得分的报告应该在“实验原理”部分画出状态转移图或列出转换表在“结果分析”部分给出多组测试输入和输出对照在“问题与解决”部分写两到三个真实遇到的坑。注意报告不是代码附录不要整段粘贴源码而是要说明关键函数的输入输出和复杂度。比如状态转移表可以这样表达状态字符类型下一状态动作START字母IN_ID开始积累字符IN_ID数字IN_ID继续积累IN_ID其他START查关键字表返回 TokenSTART数字IN_NUM开始积累数字IN_NUM数字IN_NUM继续积累IN_NUM.IN_FLOAT记录小数点IN_FLOAT数字IN_FLOAT继续积累IN_FLOAT其他START返回浮点数 Token这张表直接对应代码里的switch(state)逻辑。实验报告里写清楚每一行的含义比画一堆流程图更简洁实用。4.2 测试源文件的设计覆盖合法与非法输入源程序文件.txt应该充当测试用例集。一个合格的测试源文件不能只有几行变量声明至少要覆盖以下情况关键字与标识符混用比如int main()和if (a b)。标识符中带数字与下划线比如_tmp2、sum_1。整数和浮点数包括0、123、3.14、0.5、10.末尾点按浮点数处理。单目运算符与赋值比如a -b。双字符运算符比如、!、。注释包括//行注释和/* */块注释。非法输入比如$value、12abc、/* 未闭合。下面是一个测试样例片段/* 功能词法分析器测试 */ int _temp 10; float pi 3.14; while (_temp 0) { _temp _temp - 1; } $illegal // 非法字符预期输出中$应该报错并跳过其余 Token 按类型输出。写测试时不要故意搞复杂关键是让报告能清楚展现每一类的处理结果。4.3 常见错误与调试技巧边界、缓冲区、注释词法分析器最常见的 Bug 集中在“回退字符”和“缓冲区越界”。比如用read_char()读到一个字符发现它不是标识符的结尾时必须回退。如果unget_char()实现成直接buf_pos--但之前已经调用过refill_buffer()那么回退到上一个缓冲区边界就可能越界。正确做法是保证buf_pos 0才能回退或者维护一个专门的pushback_char变量。注释处理是另一个雷区。块注释里可能有多个*比如/**/以及嵌套注释C 标准不支持嵌套但有些编译器扩展支持。最稳妥的方式是在注释处理函数里用独立计数器记录/*嵌套深度虽然 C 标准不允许嵌套但这样能避免异常输入导致死循环。行注释则直接读到\n读到的换行符要交给主循环的换行处理逻辑所以这里要么回退换行符要么直接在注释处理里更新g_line和g_col。调试技巧方面推荐在每个return前打印当前 Token 和行列号对照输出定位错误。另外用valgrind或AddressSanitizer检查内存越界因为词法分析器处理长标识符时容易踩到缓冲区尾部。5. 从实验到生产词法分析器的边界与优化技巧实验版的词法分析器能处理.c源文件但你若把它换成真实编译器的一部分还会面临三个问题输入流回退容量、注释策略、以及性能。先说回退unget_char()只能回退一位但识别或...这类多字符运算符时需要回退多位。生产级做法是维护一个“缓冲栈”比如unget_string(char *s)将若干字符逆序压入缓冲区。我见过有些教学代码用ungetc()连续回退多次这在getchar()模式下可行但在fread块缓冲下容易出错因为ungetc()保证至少能回退一个字符但多次回退未被标准保证。所以在自定义读取器里最好设计一个pushback(int n)函数记录当前 Token 的起始位置需要时直接重置buf_pos token_start。第二个问题是注释与预处理器的交互。真实 C 语言中#include和宏定义在词法分析前就被预处理器处理了。实验可以忽略但如果要扩展需要在词法分析器中添加预处理指令的去除逻辑或者单独写一个 filter。一个简单技巧是遇到#开头的行直接跳过整行但要注意字符串和注释里的#不能误判这就需要进入字符串状态时先记录是否处于预处理指令行这会让状态机复杂很多。因此多数学者选择把预处理单独放一个阶段保持词法分析器纯净。第三个优化点在于字符分类。不要每个字符调用isalpha()和isdigit()这两个函数内部要做 locale 判断开销不小。你可以在初始化时生成一个 256 大小的查找表预处理出每个字节的类别字母、数字、运算符、空白等扫描时直接查表速度提升明显。比如定义enum CharClass { CC_LETTER, CC_DIGIT, CC_OP, CC_SPACE, CC_OTHER };然后填充数组主循环里一条查表指令代替多个条件分支。对 5MB 规模的文件这种优化能让扫描时间从 200ms 降到 80ms但这个差距在课程实验里看不出在工作中的编译经常几十万行时就很明显。最后提一下状态机骨架的复用当你完成后这个实验后稍微修改规则表就能做成一个 JSON 分词器或 SQL 分词器。核心代码不变只是 Token 枚举、关键字表和字符分类规则改变。如果要把这个 C 实验升维可以对比用 Flex 生成的词法分析器Flex 用正则描述规则自动生成状态机跳转表但是它生成的代码同样还是基于“读字符、判断、转移”这个模型。你看懂手写版本后再读 Flex 输出就不会一头雾水了。动手把测试文件扩大到包含宏定义和字符串转义你会发现词法分析真正难的不是规则而是边界状态的处理。这个实验包里的 .cpp 和实验报告足够支撑你走完这段路剩下要做的就是多写几个测试把每个状态转移都逼到边界上。本文还有配套的精品资源点击获取