简介:面向编译原理课程学习者,提供一套手工构造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、count | TOKEN_ID | 最长匹配后查关键字表 |
| 数字 | 0、123、9999 | TOKEN_NUM | 识别后转成整数值 |
| 赋值号 | := | TOKEN_ASSIGN | 双字符,需要预读一个字符 |
| 运算符 | + - * / = < > | 各自独立 | 单字符直接返回 |
| 括号/分隔 | ( ) ; | 各自独立 | 单字符直接返回 |
| 注释 | { ... } | 无 Token | 扫描阶段直接跳过 |
| 文件结束 | EOF | TOKEN_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每次调用返回下一个 Token;lexer_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返回 EOF,cur_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\n,Linux/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 y | IF ID(x) THEN ID(y) | 关键字查表时机 |
a := 5 | ID(a) ASSIGN NUM(5) | :=双字符识别 |
{ comment } read | 注释跳过,然后是 READ | 注释闭合判断 |
12ab | 词法错误,不拆 Token | 数字后接字母检查 |
x :=文件末尾 | ASSIGN 后紧跟 EOF | EOF 分支处理 |
我的习惯是每次改完扫描函数,顺序跑一遍这五个用例文件,用 diff 和上一次的输出对比。如果某个用例的结果变了,先判断是新需求导致的还是回归破坏。这个流程看着笨,但确实帮我挡掉过至少三次在验收现场翻车的局面。尤其是第二种异常,改一个数字循环时不小心把边界条件调错,很容易把12ab从错误恢复成错误拆分。
词法分析器写完之后,下一个自然动作是把它接进一个简单的递归下降语法分析器。到那一步时你会发现,词法阶段的 Token 设计是否合理会直接暴露出来:枚举值是否清晰、错误 Token 是否单独处理、行号是否准确,都会在语法错误定位时变成放大镜。所以,写词法分析器时多花半小时处理边界,是在给后面的阶段省时间。希望这个流程安排能帮你少踩几个我当年踩过的坑,做实验时一次跑通。
本文还有配套的精品资源,点击获取