
简介面向编译原理课程学习者提供一套手工构造TINY语言词法分析器的完整工程与实验报告。TINY语言是一种教学用简化编程语言涵盖变量声明、运算符、控制流等语法适合用来掌握词法分析器的构造过程。压缩包内含10个文件包括C源码、Dev-C工程文件、可执行程序、Word版实验报告及多个测试样例整体大小约779KB便于直接打开工程进行调试和验证已有1330人下载学习。资源核心价值在于源码基于确定有限状态自动机DFA设计将词法规则转化为状态转移逻辑可识别标识符、整数、关键字、运算符等Token配套实验报告完整记录了从DFA设计到数据结构定义、函数实现与测试用例验证的整个流程借助这份资源读者既能深入理解编译前端工作机制也能借鉴其中的C实现技巧动手完成自己的词法分析器实验。1. 手工构造TINY语言的词法分析器不依赖生成器的编译原理实验第一步词法分析器是编译原理实验里绕不开的第一关而TINY语言因为关键字少、文法规整成了课程设计里最常见的教学语言。这份资源是一份不依赖 lex/flex 生成器、直接用 C/C 把 TINY 词法分析器手工写出来的完整实现从 Token 定义、字符识别、状态转移、符号表到错误恢复都有落地的代码。它解决的是那种“状态图看懂了但代码写不出来”的卡壳状态教材上画了识别数字、标识符的自动机但真正进编辑器就不知道从哪一行开始。适合正在做编译原理实验、要交课程设计或者想用一个小项目验证自己对自动机理解的人。这里把实现过程拆开讲顺便把最容易翻车的边界条件一起说清楚。2. 词法单元约定先定 Token 表再写识别代码词法分析器看起来是一堆判断字符的代码但真正设计时第一个动作不是写循环而是把 TINY 语言的词法规范落到一张 Token 表上。规范不固定后面所有代码都要返工。2.1 TINY 语言的词法规范关键字、特殊符号与注释不同教材或实验指导书对 TINY 的定义略有差异但主流实验版本以 Appel《现代编译原理》里的 TINY 为蓝本词法约定通常长这样关键字有 if、then、else、repeat、until、read、write 七个运算符和分隔符有 、-、*、/、、、、(、)、;赋值号是独立的:注释用花括号{ }包裹标识符是字母开头的字母数字串长度一般限制在 64 以内数字只支持非负整数字面量不处理小数和指数。我把这个约定整理成一张表写代码时直接对着看类别单词示例Token 类别备注关键字if、then、else、repeat、until、read、write各自独立词法阶段直接枚举标识符name、temp1、countTOKEN_ID最长匹配后查关键字表数字0、123、9999TOKEN_NUM识别后转成整数值赋值号:TOKEN_ASSIGN双字符需要预读一个字符运算符 - * / 各自独立单字符直接返回括号/分隔( ) ;各自独立单字符直接返回注释{ ... }无 Token扫描阶段直接跳过文件结束EOFTOKEN_EOF语法分析阶段依赖它终止这张表里最值得注意的坑是:和。很多第一次写词法分析器的人会把赋值号当成普通符号处理结果a : 5被拆成a、:、、5语法分析阶段没法做赋值语句的识别。还有一种常见设计是把所有关键字都当成普通标识符放到语法分析阶段再判断这种做法在 TINY 这种小语言里不是不行但实验报告通常要求词法阶段就分类输出两种做法最后打印出来的 Token 流差距很大。如果你用的实验指导书里的 TINY 定义不是这套比如有的版本没有 read/write有的版本加了 end 关键字那只需要调整枚举和关键字查表扫描逻辑本身不用动。TINY 的好处就是词法规模小到可以手工枚举不需要搞登记表里面的复杂结构。另外如果你用的是 Java 版《现代编译原理》Token 结构换成枚举加字符串字段即可后面的识别流程完全不变。2.2 Token 数据结构枚举类型与 Token 结构体设计词法规范定好之后先写 Token 的头文件。这个文件决定了词法分析器对外输出的样子语法分析器也是按这个接口来写。/* token.h */ #ifndef TOKEN_H #define TOKEN_H typedef enum { TOKEN_IF, TOKEN_THEN, TOKEN_ELSE, TOKEN_REPEAT, TOKEN_UNTIL, TOKEN_READ, TOKEN_WRITE, TOKEN_ID, TOKEN_NUM, TOKEN_PLUS, TOKEN_MINUS, TOKEN_TIMES, TOKEN_DIVIDE, TOKEN_EQ, TOKEN_LT, TOKEN_GT, TOKEN_LPAREN, TOKEN_RPAREN, TOKEN_SEMI, TOKEN_ASSIGN, TOKEN_EOF, TOKEN_ERROR } TokenType; typedef struct { TokenType type; char text[64]; int value; int line; } Token; #endif这个结构里四个字段的分工是type表示 Token 的类别语法分析器根据它决定走哪个产生式text保存当前 Token 的原文实验输出时直接打印value只在 TOKEN_NUM 时有意义存储转换后的整数表达式求值阶段直接用line记录所在行号词法错误和语法错误定位都靠它。有人会省掉text只保留type和value但实验验收一般要求输出 Token 原文比如打印NUM(123)而不是只打印123所以保留原文字段是值得的。那为什么要同时保留text和value两个冗余字段因为标识符和关键字的原文在语法分析阶段还要继续用而数字的整数值已经在词法阶段转换好了语法分析阶段不需要再做字符串到整数的转换。这也是教材里把词法分析和语法分析解耦的意义词法分析器输出的 Token 流是语法分析器唯一的数据来源字段冗余一点换来的是接口整洁。枚举顺序本身没有硬性要求但建议把关键字放前面、符号放后面和教材上的 Token 表对齐核对输出时方便。有的实现会在枚举里混入TOKEN_START、TOKEN_END这类哨兵值用来做循环边界在实验代码里没必要反而容易把枚举值弄得很难看。2.3 扫描接口约定getToken 的输入输出与错误码Token 数据结构定完后接下来要定接口。词法分析器的使用方式很固定语法分析器里写一个while (tok.type ! TOKEN_EOF) { tok get_token(); ... }循环。接口设计成三个函数就够了。/* lexer.h */ #ifndef LEXER_H #define LEXER_H #include token.h int lexer_open(const char *path); Token get_token(void); void lexer_close(void); #endiflexer_open负责打开源文件返回 0 表示成功get_token每次调用返回下一个 Tokenlexer_close释放文件资源。这个三函数接口的好处是文件指针、行号这些状态都封装在词法分析器内部语法分析器拿到的只是 Token 流不关心文件是怎么读进来的。很多课程模板喜欢用全局变量保存文件指针和当前字符这个规模下没有大问题但封装成接口之后语法分析器可以很容易改成从一个字符串缓冲区读取比如实验里做交互式输入时就不需要改调用方。我一般建议接口保持最小化别把 get_token 的预读字符、行号计数器暴露出去否则后续写语法制导翻译时语法分析器会不小心改坏词法状态。接口背后的实现里通常需要维护一个预读字符变量。因为识别:时读完:之后必须再看下一个字符是不是如果不是就得把那个字符放回输入流。放回操作如果直接做在文件指针上代码会变得到处都是ungetc所以资源包里比较常见的做法是用一个静态变量保存这个“已读但未消费”的字符。后面第 3 章写主循环时这个变量的位置就决定了识别逻辑是否干净。3. 扫描函数落地从状态图到可编译的 C 代码词法规范定了接口定了这章把状态图换成能编译运行的代码。识别逻辑的核心是把教材上那张状态转移图翻译成“读一个字符 - 判断- 移动 - 再读”的循环。3.1 字符分类字母、数字、符号的辅助函数与空白处理扫描的本质是对每个字符做分类然后根据类别进入不同分支。如果直接在 get_token 里写一堆 ASCII 码判断代码会变成一坨难以维护的if。常见做法是先独立出几个字符分类函数把“什么是字母、什么是数字、什么是符号”定义清楚。#include ctype.h static int is_alpha_c(int c) { return (c A c Z) || (c a c z); } static int is_digit_c(int c) { return c 0 c 9; } static int is_sym_start(int c) { switch (c) { case : case -: case *: case /: case : case : case : case (: case ): case ;: case :: return 1; default: return 0; } }这段代码里的is_alpha_c明确排除了下划线TINY 的标识符规范里通常不需要下划线如果你自己的实验要求支持下划线把这个函数加一行判断就行。is_digit_c没直接调isdigit是因为isdigit要求参数转成 unsigned char而这里读文件拿到的是 int自己写避免了一堆强制转换的麻烦。is_sym_start把单字符符号全部列出来后面主循环的 switch 直接按这个分类走。空白和注释的处理我一般单独写一个函数而不是散落在主循环里否则主循环里到处都是if (c || c \n)这种判断。这个函数要做两件事跳过空格、制表符、换行以及跳过花括号注释。注释里允许出现任意字符包括换行所以行号计数也要放在这里。static void skip_whitespace_and_comment(void) { int c; while (1) { c getc(src_file); if (c || c \t || c \n || c \r) { if (c \n) { cur_line; } continue; } if (c {) { do { c getc(src_file); if (c \n) cur_line; if (c EOF) { fprintf(stderr, line %d: unterminated comment\n, cur_line); return; } } while (c ! }); continue; } ungetc(c, src_file); break; } }这段有一个细节值得注意注释循环里先判断c \n再判断c EOF顺序不能反过来。如果文件在注释中途结束getc返回 EOFcur_line会把行号多加一行虽然不影响最终报错位置太多但多一个无关数字会让排错增加干扰。另外最后那个ungetc(c, src_file)很关键循环跳出时已经读到了一个真正的词法字符必须放回去留给 get_token 主循环处理否则每个 Token 都会丢第一个字符。3.2 核心 get_token状态转移主循环主循环是词法分析器的心脏。它要做的事按顺序是跳过空白和注释、读第一个字符、判断入口状态、循环读取直到满足最长匹配、返回 Token。Token get_token(void) { Token tok {0}; int c; skip_whitespace_and_comment(); c getc(src_file); tok.line cur_line; if (c EOF) { tok.type TOKEN_EOF; return tok; } if (is_alpha_c(c)) { int len 0; while (is_alpha_c(c) || is_digit_c(c)) { if (len 63) { tok.text[len] (char)c; } c getc(src_file); } if (c ! EOF) ungetc(c, src_file); tok.text[len] \0; tok.type keyword_lookup(tok.text); if (tok.type TOKEN_ERROR) { tok.type TOKEN_ID; } return tok; } if (is_digit_c(c)) { int val 0; int len 0; while (is_digit_c(c)) { val val * 10 (c - 0); if (len 63) tok.text[len] (char)c; c getc(src_file); } if (c ! EOF) ungetc(c, src_file); tok.text[len] \0; tok.type TOKEN_NUM; tok.value val; return tok; } switch (c) { case : tok.type TOKEN_PLUS; strcpy(tok.text, ); break; case -: tok.type TOKEN_MINUS; strcpy(tok.text, -); break; case *: tok.type TOKEN_TIMES; strcpy(tok.text, *); break; case /: tok.type TOKEN_DIVIDE; strcpy(tok.text, /); break; case : tok.type TOKEN_EQ; strcpy(tok.text, ); break; case : tok.type TOKEN_LT; strcpy(tok.text, ); break; case : tok.type TOKEN_GT; strcpy(tok.text, ); break; case (: tok.type TOKEN_LPAREN; strcpy(tok.text, (); break; case ): tok.type TOKEN_RPAREN; strcpy(tok.text, )); break; case ;: tok.type TOKEN_SEMI; strcpy(tok.text, ;); break; case :: { int next getc(src_file); if (next ) { tok.type TOKEN_ASSIGN; strcpy(tok.text, :); } else { if (next ! EOF) ungetc(next, src_file); tok.type TOKEN_ERROR; strcpy(tok.text, :); } break; } default: tok.type TOKEN_ERROR; tok.text[0] (char)c; tok.text[1] \0; break; } return tok; }整体结构和状态图是一一对应的字母入口对应“标识符或关键字”状态数字入口对应“无符号整数”状态单字符符号直接对应终态冒号进入双字符判断分支。那个if (len 63)的条件是给标识符长度截断用的TINY 语言本身的符号长度一般远小于 64但万一实验文件里出现一个超长标识符不做截断就会写坏栈上的text数组。ungetc的使用位置也有讲究。在标识符循环和数字循环结束时循环是因为读到了非单词字符才退出这个字符必须放回否则就丢了。但在冒号分支里如果next不是放回去的是“下一个 Token 的第一个字符”而当前冒号本身已经被消费掉了这个位置是很多初学者容易搞混的地方。3.3 边界处理标识符超长、数字超长与未知字符边界处理往往决定实验分。先看标识符超长。上面代码里用len 63限制了回填长度但循环依然会把整个超长标识符读完这样做的目的是保证后续 Token 不会被截断后的残余字符污染。如果实验要求超长报错在这个循环结束后加一条错误输出即可。数字识别也一样val val * 10 (c - 0)在遇到特别大的字面量时会溢出int 溢出后变成负数是沉默的不报错。多数 TINY 实验源程序里的数字都在合理范围但严谨的做法是用 long 类型累加末尾判断val INT_MAX并报错。资源包的实现如果没做溢出检查你交实验前最好自己加上。未知字符的处理是 default 分支。常见误用是“遇到未知字符就跳过并继续读下一个”这会导致一行里出现#$时输出一串错误 Token正确做法是返回一个 TOKEN_ERROR让上层语法分析器去决定是终止还是继续。词法分析器自己只负责把错误标记出来不负责做恢复策略这个边界要在接口层讲清楚。4. 符号表与错误恢复让词法分析器处理真实输入的细节Token 流能正常产生之后还差两块实验报告里经常拿分的地方符号表登记和错误恢复。这两块不直接参与识别字符但决定了工具是否能处理真实代码。4.1 符号表线性查找和局部哈希的一次取舍符号表在这里的作用是判断一个完整单词是关键字还是普通标识符。TINY 只有 7 个关键字最简单的实现是一张静态表加线性查找。有人会质疑线性查找会不会太慢对一个教学语言来说源程序通常只有几百行线性查找最多比较 7 次字符串性能差异完全可以忽略。static const struct { const char *name; TokenType type; } keywords[] { {if, TOKEN_IF}, {then, TOKEN_THEN}, {else, TOKEN_ELSE}, {repeat, TOKEN_REPEAT}, {until, TOKEN_UNTIL}, {read, TOKEN_READ}, {write, TOKEN_WRITE}, {NULL, TOKEN_ERROR} }; TokenType keyword_lookup(const char *s) { int i; for (i 0; keywords[i].name ! NULL; i) { if (strcmp(keywords[i].name, s) 0) { return keywords[i].type; } } return TOKEN_ERROR; }查表时机是“最长匹配完成之后”不要边读字符边查表。比如输入readx词法扫描应该先完整读入readx再查表发现它不是关键字返回 TOKEN_ID如果读r时就查表看到前缀read就把后面那个x落下了。这个坑很隐蔽因为readx这种单词不常见一旦在实验文件里出现Token 流就会错位。有些实验要求标识符登记表记录每个标识符出现的位置或次数这个需求可以在词法层加一个简单数组实现。但注意这里的符号表和编译原理教材里那个用于作用域分析的符号表不是一回事词法层的登记表只维护字符串到 Token 类型的映射不保存变量类型、作用域等属性。把两者混在一起会让接口变得难以维护我一般建议课程设计里把这两层分开词法层做简单登记即可。4.2 错误处理策略报告错误后如何继续扫描词法错误处理最容易出现的问题是“报错后卡死”。TINY 源文件里可能出现的错误包括注释未闭合、非法字符、冒号后不是等于号、标识符超长。通用策略是返回 TOKEN_ERROR同时保证下一次调用 get_token 还能继续从正确位置扫描。错误输出我建议固定一个函数把信息全部打向 stderr。void report_lex_error(Token *tok, const char *msg) { fprintf(stderr, lexical error at line %d: %s (after %s)\n, tok-line, msg, tok-text); }把词法错误输出到 stderr 而不是 stdout是血泪教训。很多课程验收脚本会检查 stdout 里的 Token 流如果错误信息混进去Token 流格式就崩了输出对比直接判定不通过。错误信息和正常输出必须走两个通道这个习惯从词法分析器阶段就要建立。错误恢复的细节在于“只消费出错字符”。比如遇到当前字符就是错误来源返回 TOKEN_ERROR 即可不要顺手把后面的字符也读掉。注释未闭合是例外扫描器必须读完整个文件才能确认没有}这种情况下要输出错误并回到正常流程下一次调用 get_token 会自然返回 TOKEN_EOF。注意未闭合注释的报错行号以注释开始的行为准还是以 EOF 所在行为准实验要求各不相同。资源包里的实现一般以 EOF 行号报错你交作业前对着自己课程的要求改一下就行。5. 避坑排查编译报错、边界 case 与四个高频翻车点这一章把我在拆这类项目时常遇到的四个问题记下来。前两个是运行时表现异常后两个是编译和逻辑层面的坑。5.1 翻车点一换行符被当成非法字符报错风暴现象跑一个几十行的 TINY 源程序报错刷屏几乎每一行都有illegal char而且报错位置集中在每行行尾。原因读字符时没有正确处理行尾。Windows 下编辑器保存的文件带\r\nLinux/macOS 下带\n如果扫描主循环的 switch 里没有处理换行符的分支换行符就会落到 default 分支被当成非法字符。还有一种更隐蔽的用fgets读行再逐字符分析行尾的\n没被删除同样会触发报错。解决在skip_whitespace_and_comment里把\r、\n、\t、空格全部跳过并且\r和\n分别处理。Windows 的\r\n里两个字符都会被跳过不能只跳过\r留着\n。我当时踩这个坑时代码里只判了\n在 Windows 上跑实验文件每行末尾报一个非法字符\r输出结果惨不忍睹。5.2 翻车点二数字后紧跟字母被拆成两个 Token现象输入12ab输出的 Token 流是NUM(12)和ID(ab)但编译原理课程要求这种输入要么报错要么当成一个整体处理拆成两个 Token 属于默认错误结果。原因数字识别循环在遇到非数字字符时直接收尾没有检查后面跟的是什么。ab被留到下一轮循环自然被识别成标识符。解决数字循环结束后检查下一个字符是不是字母如果是字母就说明这个数字字面量不合法。正确做法是把这个错误的词素整体读完然后返回 TOKEN_ERROR而不是返回一个正常的数字 Token。5.3 翻车点三文件末尾死循环现象程序读完源文件后不退出一直卡住CPU 占用拉满。原因EOF 处理有遗漏。最常见的是注释未闭合时skip_whitespace_and_comment里的循环读到 EOF 没有 break外层的 while(1) 继续转getc永远返回 EOF死循环。另一种情况是用了feof判断文件结束feof在读到 EOF 之后才为真恰好会在最后一次读取后多绕一圈。解决把 EOF 当成普通字符在每个循环入口判断。注释循环里先判c EOF再判c }主循环里单独处理c EOF返回 TOKEN_EOF。排查死循环时先搜所有while循环里有没有getc再检查每个循环体里是否都有 EOF 分支。5.4 翻车点四关键字被当成标识符现象if、then、read 这些关键字在输出流里全部变成ID(if)、ID(then)语法分析器拿到的 Token 类别不对后面全乱。原因识别标识符的代码读完整单词后直接返回了 TOKEN_ID没调用关键字查表函数。或者查表函数内部判断顺序写反了把“查不到表”当成“返回 ID”把“查到了”也错写成了 ID。解决把keyword_lookup放在标识符识别完成之后、返回之前。先查表查不到再回落成 TOKEN_ID顺序不能反。我排查这种问题时会在keyword_lookup里临时加一个printf打印每个传入字符串看它是否真的被执行了确认调用链没被条件编译之类的机制裁掉。6. 用驱动程序与边界用例验证词法分析器三个实测习惯代码写完不是结束能稳定验证才是。词法分析器是纯函数式的输入输出组件特别适合用固定用例集回归测试。我自己的做法是写一个最小的驱动程序把 Token 流按固定格式打印出来再准备一组覆盖边界的输入文件。6.1 把 Token 流输出成可核对的格式驱动程序的职责只有两个调用 get_token 直到 EOF把 Token 类别和原文打印出来。格式越简单越好方便和老师给的样例输出做 diff。int main(int argc, char *argv[]) { Token tok; if (argc 2) { fprintf(stderr, usage: lexer source.tiny\n); return 1; } if (lexer_open(argv[1]) ! 0) { fprintf(stderr, cannot open %s\n, argv[1]); return 1; } do { tok get_token(); printf(%-15s %s\n, token_name(tok.type), tok.text); } while (tok.type ! TOKEN_EOF); lexer_close(); return 0; }token_name是一个把枚举值转成字符串的辅助函数可以用 switch 实现也可以写成一个静态表。建议写成表因为你可能在语法分析阶段还要用到同样的转换逻辑到时候直接复制。6.2 一组边界用例清单我给每类边界准备一个测试文件改动识别逻辑之后全部重跑输入片段期望行为容易出错点if x then yIF ID(x) THEN ID(y)关键字查表时机a : 5ID(a) ASSIGN NUM(5):双字符识别{ comment } read注释跳过然后是 READ注释闭合判断12ab词法错误不拆 Token数字后接字母检查x :文件末尾ASSIGN 后紧跟 EOFEOF 分支处理我的习惯是每次改完扫描函数顺序跑一遍这五个用例文件用 diff 和上一次的输出对比。如果某个用例的结果变了先判断是新需求导致的还是回归破坏。这个流程看着笨但确实帮我挡掉过至少三次在验收现场翻车的局面。尤其是第二种异常改一个数字循环时不小心把边界条件调错很容易把12ab从错误恢复成错误拆分。词法分析器写完之后下一个自然动作是把它接进一个简单的递归下降语法分析器。到那一步时你会发现词法阶段的 Token 设计是否合理会直接暴露出来枚举值是否清晰、错误 Token 是否单独处理、行号是否准确都会在语法错误定位时变成放大镜。所以写词法分析器时多花半小时处理边界是在给后面的阶段省时间。希望这个流程安排能帮你少踩几个我当年踩过的坑做实验时一次跑通。本文还有配套的精品资源点击获取