简介:手工构造TINY语言词法分析器的完整工程包,面向学习编译原理、需要动手完成词法分析实验的高校学生与C/C++初学者,围绕简化版TINY语言用C++实现扫描器,解决从词法规则到token输出的落地问题。压缩包共10个文件、约779KB,以1个cpp源文件为核心,配合3个tny源码样例、1份docx实验报告,以及dev/layout工程配置与exe可执行文件,覆盖编码、编译、测试到结果验证的环节,已有1333人学习下载。读者可从中获得一套可运行的词法分析实现:用枚举类型定义TK_ID、TK_INT、TK_PLUS等标记,以结构体保存标记值与位置,通过DFA状态转移函数逐字符识别关键字、标识符、常量与运算符;实验报告还记录了DFA设计过程、关键代码解释、测试用例输出及问题处理思路,便于对照复现与查漏补缺。
1. 从一段 TINY 源码说起:C/C++ 手写词法分析器要处理的到底是什么
很多人下载「编译原理:手工构造 TINY 语言的词法分析器」这类资源包,翻两页就卡住:正则表达式和 NFA 那套理论看得懂,真到 C/C++ 里一个字符一个字符往前挪,循环反而不知道从哪儿下笔。TINY 这门为教学造出来的小语言,关键字只有 if、then、else、end、repeat、until、read、write 八个,运算符两只手数得过来,正好够把扫描器的骨架完整走一遍:读入字符流、切成 Token、带上行列号、出错能定位。它适合正在啃编译原理课设的人,也适合工作几年、想把「手写扫描器」这件事真正写清楚的工程师。下面按我拆这份资源的顺序讲:先定 Token 结构,再写扫描主循环,最后补错误处理和回归测试。
2. Token 建模:类型、词素与行号的三层设计
动手写代码之前,先把数据结构定下来。词法分析器最后要交给语法分析器的,是一个个 Token。Token 里装什么、怎么装,会直接决定后面写起来是否顺手,也决定出错时能不能说清楚「哪一行哪一列出问题」。我见过不少课设代码,nextToken()直接返回一个char*字符串,让调用方自己去strcmp判断是关键字还是标识符。这样写能跑,但后面接语法分析时会立刻付出代价。
2.1 把 Token 结构固定下来:类型、词素、行列号
一个够用的 Token 至少要有三样东西:枚举类型、词素原文、位置信息。类型让上层用switch做分支,不用字符串比较;词素原文用于报错和后续的符号表插入;行号列号用于精准定位。三样缺一样,后面都要返工。
/* token.h —— 词法单元定义,只放类型和接口 */ #ifndef TOKEN_H #define TOKEN_H #define MAX_TOKEN_LEN 64 /* 词素最长长度,超出按错误处理 */ typedef enum { TOK_EOF = 0, TOK_NUM, TOK_ID, TOK_IF, TOK_THEN, TOK_ELSE, TOK_END, TOK_REPEAT, TOK_UNTIL, TOK_READ, TOK_WRITE, TOK_ASSIGN, /* := */ TOK_EQ, TOK_LT, /* = < */ TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_SEMI, TOK_COMMA, TOK_ERROR } TokenType; typedef struct { TokenType type; char lexeme[MAX_TOKEN_LEN + 1]; /* 值拷贝,不指向源缓冲 */ int line; /* 1 起 */ int col; /* 1 起 */ } Token; #endif这里有两个刻意的选择。第一,TOK_EOF放在枚举第 0 位,后面写数组下标取名字符串时能自然对齐。第二,lexeme用的是定长数组而不是char *。原因是源文件缓冲是整个读进来的,如果 Token 只存指针,等源缓冲释放、或者缓冲被复用,打印出来的词素就是一堆脏数据,而且这种错误在小样本上很难复现,属于典型的静默失效。写成值拷贝,每个 Token 自带一份词素,复制开销在 TINY 这个量级可以完全忽略。
MAX_TOKEN_LEN定成 64 是我一般会用的数字。TINY 的标识符在教材语法里只有单个字母,但实际作业里几乎没人守这条,学号、变量名、下划线全往里塞,留 64 个字符的余量足够。真要更长,那就该按错误处理,因为超长标识符在真实编译器里也要有明确上限,否则一个恶意输入就能把栈撑爆。
位置信息从第一行第一列开始计数,而不是从 0 开始,这一点和常见编辑器的显示保持一致。后面报错时能直接对着编辑器找到位置,省掉一次心算。
2.2 关键字和标识符走同一条识别路径
初学者最容易写出的结构是:先写一堆if判断当前字符是不是'i',再判断下一个是不是'f',把关键字单独走一遍。这样写的问题不在于慢,而在于一旦加了新关键字就要再补一条分支,代码越写越像一团乱麻。
常见做法是先把整段字母序列扫成一个完整的词,扫完之后再去查关键字表。查到了就是关键字,查不到就当普通标识符。实现上就是一张静态表加一次线性比较:
/* 关键字表:顺序按出现频率排,if/then 命中率最高放前面 */ static const struct { const char *word; TokenType type; } KEYWORDS[] = { {"if", TOK_IF}, {"then", TOK_THEN}, {"else", TOK_ELSE}, {"end", TOK_END}, {"repeat", TOK_REPEAT}, {"until", TOK_UNTIL}, {"read", TOK_READ}, {"write", TOK_WRITE} }; #define KEYWORD_COUNT (sizeof(KEYWORDS) / sizeof(KEYWORDS[0])) static TokenType lookup_keyword(const char *word) { for (size_t i = 0; i < KEYWORD_COUNT; ++i) if (strcmp(word, KEYWORDS[i].word) == 0) return KEYWORDS[i].type; return TOK_ID; /* 表里没有,就是普通标识符 */ }这段代码的关键在于「整词扫描」。扫描循环必须一次性吃掉所有isalnum和下划线,中间不能提前比较。如果边扫边比,就会出现ifx被识别成if加x的经典事故。八个关键字线性查找最多八次字符串比较,性能上完全不用考虑优化;真要做大语言的关键字表,那就上哈希,但这属于另一件事了。
符号表在词法阶段一般不做真正的插入,只做「同一个标识符的词素保持一致」。TINY 的语义分析用不到符号表,所以作业包里通常不给,这不算缺失,属于分工问题。
2.3 用状态转移表校验你的分支是否漏了情况
手写扫描器最大的风险是分支覆盖不全。写完能跑的代码,往往漏掉了「数字后面紧跟字母」「冒号后面不是等号」这类边角。我的习惯是先画一张状态转移表,把每个状态在每类输入字符下的动作列清楚,再照着表写switch。
| 当前状态 | 读到的字符类 | 动作 | 下一状态 |
|---|---|---|---|
| START | 空格 / 制表 / 回车 / 换行 | 跳过 | START |
| START | { | 进入注释 | COMMENT |
| COMMENT | } | 结束注释 | START |
| COMMENT | 换行 / 其他 | 跳过 | COMMENT |
| COMMENT | 文件结束 | 报「未闭合注释」 | 终止 |
| START | 字母 / 下划线 | 累积 | IN_ID |
| IN_ID | 字母 / 数字 / 下划线 | 累积 | IN_ID |
| IN_ID | 其他 | 查关键字表,产出 Token | START |
| START | 数字 | 累积 | IN_NUM |
| IN_NUM | 数字 | 累积 | IN_NUM |
| IN_NUM | 其他 | 产出 NUM | START |
| START | : | 看下一个字符 | PENDING_COLON |
| PENDING_COLON | = | 产出 ASSIGN | START |
| PENDING_COLON | 其他 | 报错「孤立的冒号」 | START |
| START | 其他可识别符号 | 产出对应 Token | START |
| START | 其他字符 | 报错并丢弃该字符 | START |
表里出现了「回退一个字符」这个概念,但用表驱动实现时需要ungetc或者手动pos--。我在实际代码里一般用「只看不取」的方式绕开回退:写一个peek()只读不动指针,判断完再advance()真正消费。这样既不用回退,也不会在 CRLF 和文件末尾上出错,算是这类扫描器最省心的写法。表驱动写法扩展性好,但调试时状态跳跃不直观;手工switch分支写起来啰嗦,单步调试却一目了然。TINY 这个规模,我建议手工写,但表一定要先画。
3. 手工构造扫描器:从字符指针到 nextToken 的完整实现
结构定好之后,主体代码其实不长,两三百行能全部写完。这一章按「原语 → 主循环 → 驱动」三层往下落,代码可以直接抄进工程编译。
3.1 peek/advance 两个原语与位置记录
扫描器本质是一个带位置的游标。我把游标封装成结构体,对外只暴露两个函数:peek()看当前字符但不移动,advance()消费当前字符并推进位置。位置记录放在advance()里做,是因为只有真正消费字符时行号和列号才应该变化;放在peek()里会导致列号虚增,报错位置全部偏一格。
/* scanner.c —— 扫描器状态与两个基础原语 */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #include "token.h" typedef struct { const char *src; /* 整文件读入后的缓冲 */ size_t len; size_t pos; /* 下一个待读字符的下标 */ int line; int col; } Scanner; void scanner_init(Scanner *s, const char *src, size_t len) { s->src = src; s->len = len; s->pos = 0; s->line = 1; s->col = 1; } /* 返回值为 int:必须能表达 EOF(-1),用 char 接会翻车,后文详述 */ static int peek(Scanner *s) { return s->pos < s->len ? (unsigned char)s->src[s->pos] : EOF; } static int advance(Scanner *s) { if (s->pos >= s->len) return EOF; int c = (unsigned char)s->src[s->pos++]; if (c == '\n') { s->line++; s->col = 1; } else { s->col++; } return c; }peek的返回类型必须是int,并且取值时强制转成unsigned char。这两个细节合起来解决两件事:一是能返回-1表示文件结束;二是当源文件里出现 0x80 以上的字节时,不会被符号扩展成负数,从而被误判成 EOF。advance里在换行时把列号重置为 1,行号加一,这是整份代码里唯一维护位置的地方,集中在一起后面才好排查。
源缓冲我用的是「整文件一次读入」而不是逐行fgets。原因是词法分析器天然是流式的,Token 可以跨行,如果按行读,就必须自己处理「这一行读完了但标识符还没结束」的拼接逻辑,纯属给自己找麻烦。整文件读入之后,peek的实现就退化成一次数组下标访问,简单且快。
3.2 主循环:跳过空白与注释,再分发到各识别分支
next_token()是整份代码的核心。它的结构固定:先循环跳过空白和注释,再记录当前 Token 的起始位置,然后按首字符分四类走——字母开头、数字开头、运算符、非法字符。
/* 主扫描函数:每次调用产出一个 Token */ Token next_token(Scanner *s) { Token tk; tk.type = TOK_ERROR; tk.lexeme[0] = '\0'; tk.line = s->line; tk.col = s->col; /* 第一步:跳过空白与 { } 注释 */ for (;;) { int c = peek(s); if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { advance(s); continue; } if (c == '{') { int open_line = s->line; advance(s); /* 吃掉 '{' */ while (peek(s) != '}' && peek(s) != EOF) advance(s); if (peek(s) == EOF) { tk.line = open_line; snprintf(tk.lexeme, sizeof tk.lexeme, "未闭合的注释"); return tk; /* 仍是 TOK_ERROR */ } advance(s); /* 吃掉 '}' */ continue; } break; } tk.line = s->line; tk.col = s->col; int c = peek(s); if (c == EOF) { tk.type = TOK_EOF; strcpy(tk.lexeme, "EOF"); return tk; } /* 第二步:标识符与关键字共用一条路径 */ if (isalpha(c) || c == '_') { size_t n = 0; while (isalnum(peek(s)) || peek(s) == '_') { int ch = advance(s); if (n < MAX_TOKEN_LEN) { tk.lexeme[n++] = (char)ch; } else { while (isalnum(peek(s)) || peek(s) == '_') advance(s); snprintf(tk.lexeme, sizeof tk.lexeme, "标识符过长"); return tk; } } tk.lexeme[n] = '\0'; tk.type = lookup_keyword(tk.lexeme); /* 查不到返回 TOK_ID */ return tk; } /* 第三步:数字,TINY 只有十进制整数 */ if (isdigit(c)) { size_t n = 0; while (isdigit(peek(s))) { int ch = advance(s); if (n < MAX_TOKEN_LEN) tk.lexeme[n++] = (char)ch; } tk.lexeme[n] = '\0'; if (isalpha(peek(s)) || peek(s) == '_') { /* 12abc 这类 */ while (isalnum(peek(s)) || peek(s) == '_') advance(s); snprintf(tk.lexeme, sizeof tk.lexeme, "数字后紧跟字母"); return tk; } tk.type = TOK_NUM; return tk; } /* 第四步:运算符与分隔符 */ advance(s); /* 消费首字符 */ switch (c) { case '+': tk.type = TOK_PLUS; strcpy(tk.lexeme, "+"); break; case '-': tk.type = TOK_MINUS; strcpy(tk.lexeme, "-"); break; case '*': tk.type = TOK_STAR; strcpy(tk.lexeme, "*"); break; case '/': tk.type = TOK_SLASH; strcpy(tk.lexeme, "/"); break; case '<': tk.type = TOK_LT; strcpy(tk.lexeme, "<"); break; case '=': tk.type = TOK_EQ; strcpy(tk.lexeme, "="); break; case '(': tk.type = TOK_LPAREN; strcpy(tk.lexeme, "("); break; case ')': tk.type = TOK_RPAREN; strcpy(tk.lexeme, ")"); break; case ';': tk.type = TOK_SEMI; strcpy(tk.lexeme, ";"); break; case ',': tk.type = TOK_COMMA; strcpy(tk.lexeme, ","); break; case ':': if (peek(s) == '=') { /* 双字符运算符 */ advance(s); tk.type = TOK_ASSIGN; strcpy(tk.lexeme, ":="); } else { strcpy(tk.lexeme, ":"); /* 孤立的冒号 */ } break; default: snprintf(tk.lexeme, sizeof tk.lexeme, "%c", c); /* 非法字符 */ break; } return tk; }几个地方值得单独说。跳过空白的那段for(;;)里同时处理注释,是因为注释在语义上等价于空白,两者必须一起循环,否则{...}{...}连写的时候只能吃掉一个。注释遇到 EOF 时直接把tk.line改回注释开始的那一行,报错位置才指向用户真正写错的地方,指向文件末尾是没有意义的。
:=的处理在advance消费掉:之后再peek一次,看到=就再消费一个。注意=单独使用时是TOK_EQ(等于比较),不是赋值,这是 TINY 的语法规定,跟 C 语言正好相反。作业里最常见的错误就是照着 C 的习惯把=当赋值、又给==留了个位置,结果整个语法对不上。
数字识别之后要多做一步「紧跟字母」的检查。如果不做,12abc会被切成12和abc两个合法 Token,语法分析阶段报的错误离真正的原因已经很远了,追起来相当费劲。在词法阶段拦住,报错信息直接说「数字后紧跟字母」,定位成本立刻降下来。
3.3 编译与驱动:把 .tny 文件跑通
扫描器本身不产生输出,需要一个驱动把文件读进来、循环调next_token、把结果打出来。驱动里我顺手加了 Token 名字表和错误计数,方便批量跑测试。
/* 驱动:整文件读入 + 循环调用 + 结果打印 */ static char *read_whole_file(const char *path, size_t *out_len) { FILE *fp = fopen(path, "rb"); if (!fp) return NULL; if (fseek(fp, 0, SEEK_END) != 0) { fclose(fp); return NULL; } long size = ftell(fp); if (size < 0) { fclose(fp); return NULL; } rewind(fp); char *buf = (char *)malloc((size_t)size + 1); if (!buf) { fclose(fp); return NULL; } size_t got = fread(buf, 1, (size_t)size, fp); fclose(fp); buf[got] = '\0'; *out_len = got; return buf; } static const char *type_name(TokenType t) { static const char *names[] = { "EOF", "NUM", "ID", "IF", "THEN", "ELSE", "END", "REPEAT", "UNTIL", "READ", "WRITE", "ASSIGN", "EQ", "LT", "PLUS", "MINUS", "STAR", "SLASH", "LPAREN", "RPAREN", "SEMI", "COMMA", "ERROR" }; return names[t]; /* 枚举顺序与数组下标严格一一对应 */ } int main(int argc, char **argv) { if (argc != 2) { fprintf(stderr, "用法: %s <源文件.tny>\n", argv[0]); return 2; } size_t len = 0; char *src = read_whole_file(argv[1], &len); if (!src) { fprintf(stderr, "打不开文件: %s\n", argv[1]); return 2; } Scanner sc; scanner_init(&sc, src, len); int errors = 0, total = 0; for (;;) { Token tk = next_token(&sc); if (tk.type == TOK_EOF) break; total++; if (tk.type == TOK_ERROR) { errors++; fprintf(stderr, "%d:%d: 词法错误: %s\n", tk.line, tk.col, tk.lexeme); continue; /* 跳过坏字符,继续扫后面的内容 */ } printf("%d:%d\t%-8s\t%s\n", tk.line, tk.col, type_name(tk.type), tk.lexeme); } fprintf(stderr, "共 %d 个 Token, %d 个错误\n", total, errors); free(src); return errors ? 1 : 0; }编译命令用-std=c11 -Wall -Wextra,把警告全打开。手写扫描器最容易犯的错就是隐式截断和有符号比较,这类问题编译器基本都能提示出来,白白关掉太可惜。
gcc -std=c11 -Wall -Wextra -O2 -o tinylex scanner.c准备一个最小样例文件sample.tny,覆盖赋值、算术、循环、比较、注释五种结构:
{ 计算阶乘的 TINY 片段 } n := 5; fact := 1; repeat fact := fact * n; n := n - 1 until n < 1; write fact运行./tinylex sample.tny,正常输出每行是「行号:列号、Token 类型、词素」三列,最后一行统计 Token 总数和错误数。返回值设计成「有错误返回 1」,是为了能直接接进make test或 CI,&&串起来就能跑回归。参数说明只有一条:命令行只接受一个源文件路径,多参数直接退 2,避免误把参数当文件名读。
4. 边界与错误处理:非法字符、未闭合注释、超长标识符怎么兜
能跑通正常样例只是及格线,词法分析器真正的价值在于异常输入下的表现。真实场景里,源文件经常是残缺的、编码混杂的、复制粘贴带乱码的。这一章把三类高频边界拆开讲,每一类都给出具体处理策略。
4.1 三类错误的处理策略:跳过还是终止
错误处理首先要做的是一个决定:遇到坏输入是立刻停下,还是吞掉继续。我的取舍标准是看这个错误会不会破坏后续的扫描边界。
| 错误类型 | 典型输入 | 是否破坏边界 | 处理策略 |
|---|---|---|---|
| 非法字符 | @、#、$、中文标点 | 否,单字符 | 报错、丢弃该字符、继续 |
| 孤立的冒号 | a : b(缺=) | 否,单字符 | 报错、丢弃、继续 |
| 数字后紧跟字母 | 12abc | 否,已吃掉整个词 | 报错、继续 |
| 未闭合的注释 | { 没有右花括号 | 是,后面全是注释 | 报错、直接返回 EOF |
| 超长标识符 | 100 个字符的变量名 | 否,已吃掉整个词 | 报错、继续 |
判断依据在于「扫描位置是否还能可靠地继续」。单字符错误和「整个词已经吃完」的错误,游标位置是确定的,继续扫不会引发连锁误报,跳过是对的。未闭合注释不一样,它意味着从{到文件末尾全被当成注释吞掉了,这时候继续扫只会把剩余内容全部误判,不如直接结束,让用户先去修那个漏掉的右花括号。
这里有个细节值得单独提:注释里的错误要不要报。我的做法是不报,注释内容对语法没有意义,里面写错别字、写中文、写乱码都不该影响编译。但如果业务上要求检查注释里的编码,那就得在注释循环里额外加校验,这属于另一个需求,不要混进主流程。
4.2 行列号与错误信息:让报错指向对的地方
错误信息里最有价值的部分不是「哪里错了」,而是「在哪一行哪一列错了」。前面代码里tk.line和tk.col在跳过空白之前就先记录了一次,跳过之后再记录一次,最终采用的是「Token 起始位置」而不是「扫描结束位置」。这个选择很关键:如果记录的是结束位置,那么12abc报出来的列号指向c,用户还要往前数三个字符才知道问题从哪开始。
列号在遇到制表符时会失真。制表符在编辑器里显示成 4 格或 8 格,代码里只算 1 列。两种做法都行:一是把\t按 1 列算,报错时注明「列号按字符计」,简单但用户要自己换算;二是按 4 列膨胀,跟主流编辑器对齐,代价是列号计算逻辑要改。我一般选第一种,因为源文件里混用空格和 Tab 的情况太普遍,怎么算都不准,不如说清楚规则。
还有一点:报错时打印的应该是tk.lexeme里的原始词素,而不是「内部错误码」。像「标识符过长」这种信息,如果只打印一个TOK_ERROR,用户根本不知道是长度问题还是字符问题。把具体原因塞进lexeme字段虽然语义上不太规范,但在这种小工具里非常实用,等于免费获得了一个错误描述字段。
4.3 长度与整数边界:定长缓冲的两个上限
MAX_TOKEN_LEN定了 64,就意味着超过 64 个字符的标识符一律报错。这个上限要在文档里写清楚,不能默默截断。截断是最坏的处理方式:verylongname_for_something和verylongname_for_something_else截断后可能变成同一个词,语义直接错乱,而且这种 bug 只在特定输入下出现,极难定位。
数字的处理同理。当前实现只做识别不做求值,但如果后面要接常量折叠或者语义分析,就必须把词素转成整数,这时候要用strtol并检查errno:
/* 词素转整数时的边界检查,防溢出、防非法尾字符 */ #include <errno.h> #include <limits.h> int lexeme_to_int(const char *lexeme, long *out) { char *end = NULL; errno = 0; long v = strtol(lexeme, &end, 10); if (errno == ERANGE) return -1; /* 超出 long 范围 */ if (end == lexeme) return -1; /* 一个数字都没读到 */ if (*end != '\0') return -1; /* 尾部还有残留字符 */ if (v > INT_MAX || v < INT_MIN) return -1; *out = v; return 0; }三个检查缺一不可。ERANGE管溢出,end == lexeme管空串,*end != '\0'管「数字后面还有东西」。第三项和前文词法阶段做的「数字后紧跟字母」检查是两道防线,词法阶段拦住大部分,转换阶段再兜一次底,因为 Token 的lexeme有可能被上层代码改过,不能完全信任。
5. 避坑与排查:五个让扫描器静默出错的细节
下面这五条,全部来自实际拆代码时踩过的坑,症状都很像「程序能跑但结果不对」,排查起来最花时间。每条按「现象 → 原因 → 解决」写,对着看基本能省掉大半天。
5.1 识别层面的三个隐形坑
现象一:某些源文件扫到一半就停了,明明后面还有几百行。
原因是用char接收getchar()或peek()的返回值。char在多数平台是有符号的,源文件里只要出现一个 0x80 以上的字节(比如中文注释、复制粘贴带进来的全角空格),就会被符号扩展成负数,跟EOF(-1)撞上,扫描循环直接退出。这个坑的隐蔽之处在于,纯 ASCII 的测试文件永远复现不了,一换成带中文注释的真实文件就翻车。
解决办法是把所有「读一个字符」的函数返回值都定成int,取值时强制(unsigned char)转换,前文的peek和advance就是这么写的。另外源文件用"rb"二进制模式打开,避免 Windows 上的文本模式偷偷做字符转换。
现象二:a := 1;被扫成了a、:、=、1、;五个 Token。
原因是双字符运算符的识别逻辑写漏了。只消费了:就直接返回,没有往后peek一次看是不是=。后面那个=就被单独识别成TOK_EQ,语法分析阶段报「赋值语句缺左值」,错误提示离真相隔了一层。
解决办法是把所有双字符(这里是:=)的处理都写成「先消费首字符,再peek下一个,匹配就再消费」。TINY 只有:=一个双字符组合,加一段if就够;如果语言有<=、>=、<>,那就该用最长匹配原则,从长度 2 到长度 1 逐个试。写完之后一定要拿a := 1;、a : 1;、a = 1;三行分别测一遍,三种组合正好覆盖:=、孤立冒号、等于比较三条路径。
现象三:注释后面的第一行代码总是被吃掉。
原因多半是注释结束时右花括号没被消费。while (peek(s) != '}') advance(s);这个循环停下来时,peek指向的正是},如果后面忘了再advance(s)一次,下次进入主循环第一个看到的就是},被当成非法字符报错;如果错误处理又是「跳过一个字符继续」,那看起来就像紧跟注释的那行代码被吞了。
解决办法是注释处理严格按「进、吃、出」三步走:进来先advance吃掉{,循环吃到},最后再advance吃掉}。三段代码写在一起,中间不要插别的东西。更稳妥的做法是给注释这段单独抽个函数skip_comment(Scanner *s),返回 0 或 -1 表示是否闭合,调用处只判断返回值,减少状态串扰。
5.2 工程层面的两个坑
现象四:Windows 下编辑的源文件,每一行末尾都报一个词法错误。
原因是行尾是\r\n两个字符,\r没被当作空白跳过。跳到空白的那段for循环里如果只判了' '、'\t'、'\n',\r就会落到后面的分支,变成非法字符。报错列号还正好在行末,看上去莫名其妙。
解决办法是在空白判断里加上'\r',一行代码的事。但别止步于此,同一段代码在不同平台、不同编辑器、不同 Git 配置下都可能带进来各种不可见字符,所以我的习惯是在跳过空白之后再加一层兜底:遇到非打印字符(iscntrl为真且不是已处理的空白)一律按非法字符报错并跳过,不参与任何 Token 的构造。这样即使有脏字符,也只会产生一条明确的错误,不会污染语法树。
现象五:把 Token 输出到文件再读回来,词素全是乱码或者空串。
原因是 Token 里存的是指向源缓冲的指针,而源缓冲在扫描结束后被free掉了。指针还在,内容已经还给堆管理器,后面谁再申请内存就把它覆盖了。这类问题在小样本上可能因为内存没被复用而「看起来正常」,一旦样本变大或者加了别的内存分配就立刻失控。
解决办法只有一条:Token 里的词素必须是值拷贝。前面token.h里用定长数组就是为此。如果嫌 64 字节浪费,那就用malloc一份精确长度的字符串,但记住必须配套free_token(),否则就是拿内存泄漏换内存浪费。我一般选定长数组,TINY 这个规模下 64 字节的内存开销完全可以忽略,换来的是零泄漏风险和零生命周期管理负担。
6. 用回归集反向验证:一套挡住九成低级错误的测试清单
写完扫描器,最忌讳的就是只拿一个「漂亮」的样例跑通就收工。我一般会准备一组小而刁钻的测试用例,每条针对一个具体分支,跑一遍就知道自己的代码到底覆盖了多少。下面这张表是我常用的骨架,可以直接抄成.tny文件逐条验证:
| 用例 | 输入片段 | 预期结果 |
|---|---|---|
| 空文件 | 零字节 | 只输出 EOF,零错误 |
| 纯注释 | { only comment } | 只输出 EOF,零错误 |
| 未闭合注释 | { never closed | 一条「未闭合注释」,指向{所在行 |
| 关键字边界 | ifx if x | ID(ifx)、IF(if)、ID(x) |
| 双字符 | a := 1; | ID、ASSIGN、NUM、SEMI |
| 孤立冒号 | a : 1; | 一条「孤立的冒号」,其余正常 |
| 数字粘连 | 12abc | 一条「数字后紧跟字母」 |
| 非法字符 | a @ b | 一条非法字符错误,a、b正常产出 |
| 行尾与末尾 | 无结尾换行、CRLF 混用 | 零错误,行号正确 |
| 超长标识符 | 100 个字符的变量名 | 一条「标识符过长」 |
跑这套用例的时候,别只看「有没有报错」,要看四件事:Token 数量对不对、每个 Token 的类型对不对、报错的行列号对不对、退出码对不对。四个维度里最容易漏的是列号,我的做法是把输出重定向到文件,用diff跟一份人工核对过的期望输出比对,一旦列号偏了立刻能看出来。
最后说一个我自己的习惯。早些年我图省事,改完扫描器只跑那个阶乘样例,结果有一次把注释循环里的advance挪了个位置,阶乘样例里没有注释,一路绿灯,直到三天后导进来一个带注释的测试文件才炸。从那以后我每次动next_token的任何一行,哪怕只是改个变量名,都会强制走一遍上面这套回归集,先在make test里过一遍再提交。词法分析器这种东西,改起来感觉像是改了个无关紧要的细节,实际上每一个advance的位置都在影响游标,血泪经验就是别相信自己的记性,相信那十条用例。希望帮到你。
本文还有配套的精品资源,点击获取