ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TINY语言词法分析器手写实现:C/C++扫描循环与避坑

TINY语言词法分析器手写实现:C/C++扫描循环与避坑

简介:面向编译原理课程学习与实验的TINY语言词法分析器手工实现工程,适合正在完成扫描器(tokenizer)作业、需要参照 C/C++ 完整实现的中高年级本科生。压缩包共10个文件,约779KB,以 tinyscan.cpp 源程序为核心,配合 t1~t3.tny 三类输入用例、dev 与 layout 工程配置文件和 Windows 平台可执行文件,另有实验报告 docx 记录 DFA 设计、状态转移与结果验证。已有 1333 人学习下载。读者可从中获得一套可编译运行的词法分析流程:从源码读入、标识符与整数常量的识别,到关键字、运算符的 token 划分与结构化输出,并可对照报告理解确定有限自动机的状态设计与常见错误排查思路,便于迁移到自己的实验环境中改写和扩展。

1. 从一段 TINY 源码说起:词法分析器到底替你挡了什么

TINY 语言的词法分析器,是编译原理课设里最容易被低估的一环:看起来只是把字符切成单词,真写起来却会卡在关键字查表、最长匹配、注释跨行和行号维护上。这个压缩包走的是 C/C++ 手工构造路线,不依赖 lex/flex 生成器,适合想看清扫描器内部状态流转的人,也适合需要一份能改、能调试、能交作业的底稿的读者。下面按 token 定义、扫描循环、构建测试、避坑排查、验证接入五步拆开,重点放在参数边界和翻车点,而不是泛泛讲编译流程。

2. 拆开压缩包前先定 token:TINY 词法单元的边界与 C/C++ 数据结构

很多人拿到资源先打开 main.c 从第一行读,结果被状态机绕晕。词法分析器的源码不是按文件顺序读的,而是按 token 表读的。先把 TINY 的词法边界钉死,再回头看 C/C++ 代码,哪些分支该存在、哪些状态该合并就一目了然。这一章先把 token 分类、数据结构和你为什么要手工写讲清楚,后面读扫描循环才不会靠猜。

2.1 TINY 的字符集与 token 分类:关键字、标识符、数字、符号、注释

TINY 语言是编译原理教材里常见的教学子集,词法层通常只认几类东西:关键字、普通标识符、无符号十进制整数、单字符或双字符运算符、分隔符、花括号注释,以及文件结束符。它的关键字表一般包括if、then、else、end、repeat、until、read、write、do,运算符包括+、-、*、/、=、<、>、:=,分隔符包括(、)、;。注释用{ ... }包起来,不支持嵌套。这些边界如果不在纸上写清楚,写扫描器时就会把:=拆成:和=,或者把ifx误认成关键字if加标识符x。

词法单元常见形式要点
关键字if、then、else、end、repeat、until、read、write、do小写敏感,先收满标识符再查表
标识符字母或下划线开头,后接字母、数字、下划线长度要有上限,不能无限拷贝
整数连续十进制数字负数交给语法层,词法层不吞-
赋值号:=双字符,必须最长匹配
比较符=、<、>单字符,别和赋值号混
注释{ ... }不嵌套,必须处理未闭合
空白空格、制表、回车、换行跳过但维护行号列号

这张表里最容易被忽略的是“负数交给语法层”。很多新手看到-3就想在词法分析器里直接返回一个负数 token,结果后面语法分析处理一元负号时又要拆开。常见做法是词法层只返回TOK_MINUS和TOK_NUM,由语法分析决定它是不是负数字面量。TINY 的整数范围也要提前约定,课程设计里常见做法是用int承接,但如果测试用例里出现超过INT_MAX的数字,atoi会悄悄溢出,这类问题不会在正常样例里暴露,属于典型的后悔药场景。

行号和列号从 1 开始,还是从 0 开始,也要在 token 结构里定死。我一般让行号列号都从 1 开始,因为报错信息里写“第 3 行第 7 列”比“第 2 行第 6 列”更符合人的阅读习惯。EOF 的列号取当前游标位置即可,不要写成 0,否则后面做错误定位时会出现负数列号。注释里的换行也要让行号递增,不能因为注释被跳过就把行号吞掉,否则报错位置会整体偏移。

2.2 用 C/C++ 结构体承接扫描结果:TokenType、Token、行号列号

TINY 的词法分析器在 C/C++ 里通常用enum表示 token 类型,用结构体把类型、词素、位置和附加值打包。这里有一个选型细节:lexeme是用const char *指向输入缓冲区,还是用固定数组拷贝一份。前者省内存,但输入缓冲区一旦释放,token 里的指针就悬空;后者多拷贝几十字节,但调试和后续语法分析省心。课程设计场景里我一般选固定数组,长度设成 64 或 128,超长标识符直接报错或截断,别让一个测试用例把栈写穿。

/* token.h:TINY 词法单元定义,固定数组避免悬空指针 */ #ifndef TOKEN_H #define TOKEN_H #define MAX_LEXEME 64 typedef enum { TOK_EOF = 0, /* 0 方便循环判断 */ TOK_ID, TOK_NUM, TOK_IF, TOK_THEN, TOK_ELSE, TOK_END, TOK_REPEAT, TOK_UNTIL, TOK_READ, TOK_WRITE, TOK_DO, TOK_ASSIGN, /* := */ TOK_EQ, /* = */ TOK_LT, /* < */ TOK_GT, /* > */ TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_SEMI, TOK_ERROR } TokenType; typedef struct { TokenType type; char lexeme[MAX_LEXEME]; /* 词素副本,生命周期跟随 Token */ int line; /* 起始行号,从 1 开始 */ int col; /* 起始列号,从 1 开始 */ int ival; /* 仅 TOK_NUM 有效 */ } Token; const char *token_name(TokenType type); #endif

这段定义里,TOK_EOF = 0不是随手写的。后面写while (tok.type != TOK_EOF)或者用if (!tok.type)判断结束时,0 值语义清晰。lexeme用char[MAX_LEXEME]之后,make_token里用snprintf拷贝,长度超过MAX_LEXEME - 1就截断并返回TOK_ERROR,不要静默截断后继续当正常标识符用。line和col记录的是 token 第一个字符的位置,不是扫描结束时的位置,这一点在报错时非常关键。ival只在数字 token 里有效,其他类型置 0,避免调试输出里出现随机值。

token_name是一个小函数,把枚举转成可读字符串,测试脚本比对输出时全靠它。常见做法是用一个switch返回"ID"、"NUM"、"ASSIGN"这类名字,不要直接打印枚举数字。数字 token 的ival用strtol承接比atoi多一个错误检查:errno被设置或者endptr没走到词素末尾,就返回TOK_ERROR,这样超范围数字不会悄悄变成负数。

2.3 为什么手工写扫描器而不是直接上 lex/flex:课程设计与可控调试的取舍

这个资源的关键词里有“手工构造”,这不是为了避开工具而避开工具,而是词法分析器这个环节特别适合看清状态推进。lex/flex 用正则规则生成扫描器,优点是规则改动快,缺点是生成出来的 C 代码层层跳转,调试时很难把某个 token 的识别过程和源码对应上。课程设计里经常要求你说明“标识符状态怎么走到关键字查表”“注释状态怎么处理 EOF”,手工写一遍比看生成代码更有说服力。

维度手工扫描器lex/flex 生成
代码可读性高,状态和分支都在手边低,生成代码跳转多
行号列号维护自己控制,灵活需要额外写YY_USER_ACTION
最长匹配自己写,容易漏规则引擎保证
课程设计适配适合讲清原理容易被问“生成代码怎么工作”
扩展关键字改枚举和关键字表改规则后重新生成

手工写最大的风险是最长匹配和回退。比如:=必须先把:吃掉,再看下一个字符是不是=;如果是,吃掉=返回TOK_ASSIGN;如果不是,就要返回TOK_ERROR,因为 TINY 里单独的:不是合法 token。这里没有“回退一格”的问题,因为第二个字符已经用peek看过了,只有确认不是=才不推进。真正需要回退的场景是数字后面跟字母,比如123abc,常见处理是数字扫描到a前停下,返回TOK_NUM,下一轮再报标识符错误,而不是把整个123abc当一个 token。

手工扫描器还要提前想好错误恢复。遇到非法字符时,返回TOK_ERROR并记录位置,主循环可以选择跳过该字符继续扫描,也可以直接退出。课程设计里我一般让TOK_ERROR带上原始字符和行列号,主循环打印后跳过当前字符继续,这样一次运行能看到多个错误,而不是遇到第一个就停。这个选择不影响词法正确性,但影响调试体验。

3. 手写扫描循环:从字符流到 token 流的六个状态

扫描器的骨架其实很小:一个输入指针、一个行号列号、一个next_token函数。真正麻烦的是边界条件。下面这组代码把peek、advance、空白跳过、标识符、数字、注释和运算符分开写,读的时候先看next_token的主循环,再回头看每个分支。参数上我默认输入是以\0结尾的字符串,文件读取时多留一个字节,别让peek越界。

3.1 输入缓冲与游标推进:peek/advance 两个原语

peek只读当前字符,不推进;advance返回当前字符并推进,遇到\n把行号加一、列号归 1,其他字符列号加一。EOF 时advance不推进,否则pos会一直涨,后面算词素长度时就越界。这个细节看起来小,但注释未闭合时如果advance在\0上继续推进,程序会跑进未知内存,表现为随机崩溃或者死循环,属于血泪经验里最常见的一类。

/* scanner.c:游标原语,输入必须以 '\0' 结尾 */ #include "scanner.h" #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> static const char *src; static size_t pos; static int line_no; static int col_no; void scanner_init(const char *input) { src = input; pos = 0; line_no = 1; col_no = 1; } static char peek(void) { return src[pos]; } static char advance(void) { char c = src[pos]; if (c == '\0') { return c; /* EOF 不推进,避免越界 */ } pos++; if (c == '\n') { line_no++; col_no = 1; } else { col_no++; } return c; }

scanner_init只接收一个以\0结尾的字符串,文件读取函数负责分配和释放。peek和advance都是static,外部只能通过next_token拿 token。line_no和col_no是全局状态,这样做的好处是代码短,坏处是不能同时跑两个扫描器;课程设计里够用,如果后面要接多文件,把这三个变量塞进Scanner结构体即可。advance返回的是推进前的字符,这个语义要统一,别在某个分支里返回推进后的字符,否则词素截取会错位。

3.2 标识符与关键字识别:先收满再查表

标识符识别的关键顺序是“先收满,再查表”。如果一边读一边查关键字,ifx会在读到if时就返回关键字,剩下的x变成下一个标识符,这是典型的翻车点。正确做法是先把字母、数字、下划线全部吃进去,拿到完整词素,再和关键字表逐项比较。关键字表用const数组,顺序无所谓,但大小写要一致,TINY 的关键字通常全小写。

typedef struct { const char *word; TokenType type; } Keyword; static const Keyword 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}, {"do", TOK_DO} }; static Token make_token(TokenType type, size_t start, int line, int col) { Token t; size_t len = pos - start; t.type = type; t.line = line; t.col = col; t.ival = 0; if (len >= MAX_LEXEME) { len = MAX_LEXEME - 1; t.type = TOK_ERROR; /* 超长词素直接报错,不静默截断 */ } memcpy(t.lexeme, src + start, len); t.lexeme[len] = '\0'; return t; } static Token scan_identifier(size_t start, int line, int col) { while (isalnum((unsigned char)peek()) || peek() == '_') { advance(); } Token t = make_token(TOK_ID, start, line, col); for (size_t i = 0; i < sizeof(keywords) / sizeof(keywords[0]); i++) { if (strcmp(t.lexeme, keywords[i].word) == 0) { t.type = keywords[i].type; break; } } return t; }

make_token的start是 token 第一个字符在src里的下标,pos是扫描结束后的下标,两者相减得到词素长度。len >= MAX_LEXEME时返回TOK_ERROR,这样测试用例里的超长标识符不会把后面的 token 流带偏。关键字查表放在收满之后,ifx会先变成TOK_ID,查表不命中,保持标识符;if会命中TOK_IF。isalnum的参数强转unsigned char是防御性写法,避免负值字符传入未定义行为。

3.3 数字与运算符识别:最长匹配和回退一格

数字扫描比标识符简单,只吃连续数字,遇到非数字就停。ival用strtol转换,转换前把errno置 0,转换后检查errno == ERANGE或者endptr是否指向词素末尾。运算符里唯一需要最长匹配的是:=,其他都是单字符。如果你在实验里扩展<=、>=,照:=的写法先advance看下一个字符,再决定返回双字符还是单字符,别先返回<再让=变成下一个 token。

static Token scan_number(size_t start, int line, int col) { while (isdigit((unsigned char)peek())) { advance(); } Token t = make_token(TOK_NUM, start, line, col); errno = 0; char *end = NULL; long v = strtol(t.lexeme, &end, 10); if (errno == ERANGE || end == t.lexeme || *end != '\0') { t.type = TOK_ERROR; t.ival = 0; } else { t.ival = (int)v; } return t; } static Token scan_operator(size_t start, int line, int col) { char c = advance(); switch (c) { case ':': if (peek() == '=') { advance(); return make_token(TOK_ASSIGN, start, line, col); } return make_token(TOK_ERROR, start, line, col); case '=': return make_token(TOK_EQ, start, line, col); case '<': return make_token(TOK_LT, start, line, col); case '>': return make_token(TOK_GT, start, line, col); case '+': return make_token(TOK_PLUS, start, line, col); case '-': return make_token(TOK_MINUS, start, line, col); case '*': return make_token(TOK_STAR, start, line, col); case '/': return make_token(TOK_SLASH, start, line, col); case '(': return make_token(TOK_LPAREN, start, line, col); case ')': return make_token(TOK_RPAREN, start, line, col); case ';': return make_token(TOK_SEMI, start, line, col); default: return make_token(TOK_ERROR, start, line, col); } }

scan_number里strtol的end必须指向\0,否则说明词素里混进了非数字字符,直接报错。ival用int承接,如果实验要求支持更大整数,把Token.ival改成long,同时把打印格式从%d改成%ld。scan_operator的start在调用前已经记录,advance先吃掉第一个字符,再看peek决定要不要吃第二个。:后面不是=时返回TOK_ERROR,因为 TINY 没有单独的冒号 token;这个错误位置就是冒号本身,方便报错。

3.4 注释、空白与行号维护:别让 EOF 变成玄学

注释处理要同时检查}和\0,不能只写while (peek() != '}') advance();,否则未闭合注释会把游标推到字符串末尾之外。跳过注释后不要递归调用next_token,长注释嵌套多时递归会加深调用栈;用外层for (;;)循环继续扫下一个 token 更稳。空白跳过时,\n已经由advance维护行号,不需要额外加。

Token next_token(void) { for (;;) { while (peek() == ' ' || peek() == '\t' || peek() == '\r' || peek() == '\n') { advance(); } int start_line = line_no; int start_col = col_no; size_t start = pos; char c = peek(); if (c == '\0') { return make_token(TOK_EOF, start, start_line, start_col); } if (isalpha((unsigned char)c) || c == '_') { return scan_identifier(start, start_line, start_col); } if (isdigit((unsigned char)c)) { return scan_number(start, start_line, start_col); } if (c == '{') { advance(); while (peek() != '\0' && peek() != '}') { advance(); } if (peek() == '}') { advance(); continue; /* 注释不产生 token */ } return make_token(TOK_ERROR, start, start_line, start_col); } return scan_operator(start, start_line, start_col); } }

next_token的start_line和start_col放在空白跳过之后、token 扫描之前,这样read x;里x的列号是实际列号,不是行首列号。c == '{'分支里先advance吃掉左花括号,再循环到右花括号或 EOF;遇到 EOF 返回TOK_ERROR,错误位置是注释起始位置,报错信息里能直接指出“第几行第几列注释未闭合”。continue回到循环顶部,继续跳空白和扫下一个 token,不会产生注释 token。EOF 的make_token里pos - start为 0,lexeme是空串,主循环判断TOK_EOF后退出即可。

4. 把扫描器跑起来:构建、测试用例与 token 输出对照

代码写完不等于能交。词法分析器最怕“看起来能跑”,实际 token 流里多一个少一个,后面语法分析全乱。下面给一个最小 Makefile、一个 token dump 主程序、一个 TINY 样例和批量回归脚本。构建参数先开-Wall -Wextra,再开-fsanitize=address,undefined跑一轮,很多越界和未初始化问题会在测试阶段直接暴露。

4.1 用 CMake 或 Makefile 组织最小工程:三个文件起步

课程设计里常见做法是把词法分析器拆成token.h、scanner.h、scanner.c、main.c,编译单元不用多。Makefile 里把头文件依赖写进模式规则,改token.h后所有相关.o都会重编,避免“改了枚举但旧目标文件没更新”的玄学问题。CFLAGS里的-std=c11保证strtol、snprintf这些函数声明可见,-g方便 gdb 看游标。

CC = gcc CFLAGS = -std=c11 -Wall -Wextra -g TARGET = tiny_lexer OBJS = main.o scanner.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $@ $^ %.o: %.c token.h scanner.h $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(OBJS) $(TARGET) asan: $(CC) -std=c11 -Wall -Wextra -g -fsanitize=address,undefined \ main.c scanner.c -o $(TARGET)_asan

OBJS只列参与链接的目标文件,%.o规则把token.h和scanner.h作为依赖,头文件改动会触发重编。asan目标不覆盖普通构建,生成tiny_lexer_asan,跑完测试后直接删掉即可。如果你的环境没有gcc,换成clang也一样,-fsanitize参数通用。Windows 下用 MinGW 或 WSL 都行,注意路径分隔符别写死。

4.2 准备 TINY 样例与期望输出:逐行比对 token 序列

样例要覆盖关键字、标识符、数字、赋值号、注释、空白和 EOF。下面这个sample.tiny里故意放了sum := 0;和x = 0,前者是赋值,后者是比较,词法层必须区分TOK_ASSIGN和TOK_EQ。注释放在第一行,用来检查注释跳过后行号有没有偏。

{ sample.tiny } read x; sum := 0; repeat sum := sum + x; x := x - 1; until x = 0; write sum;

期望输出不用手写全部,先跑一遍 token dump,人工确认前几行和关键行,再把输出固化成.expected文件。主程序里token_name打印类型,lexeme打印词素,line:col打印位置,数字额外打印ival。TOK_ERROR和TOK_EOF也打印,EOF 作为结束标记保留在输出里,回归比对时能看出有没有提前退出。

/* main.c:读取文件并逐 token 打印,便于和期望输出 diff */ #include "scanner.h" #include <stdio.h> #include <stdlib.h> static char *read_file(const char *path) { FILE *fp = fopen(path, "rb"); if (!fp) return NULL; fseek(fp, 0, SEEK_END); long n = ftell(fp); fseek(fp, 0, SEEK_SET); char *buf = malloc((size_t)n + 1); if (!buf) { fclose(fp); return NULL; } size_t got = fread(buf, 1, (size_t)n, fp); buf[got] = '\0'; fclose(fp); return buf; } int main(int argc, char **argv) { if (argc != 2) { fprintf(stderr, "usage: %s file.tiny\n", argv[0]); return 2; } char *buf = read_file(argv[1]); if (!buf) { fprintf(stderr, "cannot read %s\n", argv[1]); return 1; } scanner_init(buf); for (;;) { Token t = next_token(); printf("%-10s %-16s %d:%d", token_name(t.type), t.lexeme, t.line, t.col); if (t.type == TOK_NUM) { printf(" ival=%d", t.ival); } printf("\n"); if (t.type == TOK_EOF || t.type == TOK_ERROR) { break; } } free(buf); return 0; }

read_file用rb打开,避免 Windows 下文本模式把\r\n转成\n导致列号对不上。fseek和ftell拿到文件长度后多分配一个字节放\0,fread的返回值got才是实际读到的字节数,不要直接用n当长度。主循环遇到TOK_EOF或TOK_ERROR就退出,如果你希望错误后继续扫,把TOK_ERROR的break去掉即可,但回归脚本要相应调整期望输出。

4.3 用脚本做回归:批量跑样例并 diff 结果

手工比对三五个样例还行,样例一多就会漏。用 Bash 写一个回归脚本,遍历tests/*.tiny,把实际输出写到.actual,再和.expected做diff -u。脚本开头set -euo pipefail,任何一个样例失败就停,diff的非零退出码会直接中断,避免“后面全错但脚本还显示通过”。

#!/usr/bin/env bash set -euo pipefail for f in tests/*.tiny; do base="${f%.tiny}" ./tiny_lexer "$f" > "$base.actual" diff -u "$base.expected" "$base.actual" done echo "all lexer cases passed"

base="${f%.tiny}"去掉扩展名,得到tests/sample,期望文件就是tests/sample.expected。diff -u输出统一格式,失败时能看到哪一行 token 多了或少了。脚本最后一行打印通过信息,不要写成夸张的“完美通过”,只是告诉你所有diff返回 0。新增样例时先跑一次生成.actual,人工确认后复制成.expected,不要直接拿程序输出当标准答案,否则错了也会被固化。

5. 避坑与排查:手工词法分析器最容易翻车的五处

词法分析器的 bug 有个特点:编译能过,运行不崩,token 流却悄悄错位。下面五类问题在课程设计和自测里出现频率最高,每条按“现象、原因、解决”写。调试时别一上来就加printf满屏输出,先把最小复现样例缩到十行以内,再打开 token dump 对照位置。

5.1 五类高频翻车现场:现象、原因、解决

第一类:关键字被拆错。现象是ifx输出成IF加ID(x),或者repeat1被识别成REPEAT加NUM(1)。原因是扫描标识符时边读边查关键字,读到if就提前返回,没有等标识符收满。解决是把关键字查表放到scan_identifier收满全部字母数字下划线之后,先用TOK_ID构造 token,再用strcmp逐项比较关键字表,命中才改类型。改完后用ifx、if、iff、repeat1四个输入各跑一遍,确认只有完整的if和repeat变成关键字。

第二类:注释未闭合导致越界或死循环。现象是输入以{开头但没有}时,程序不报错,直接卡住或者打印一堆乱码。原因是注释循环只写了while (peek() != '}') advance();,没有检查\0,advance在 EOF 处如果继续推进就会跑出缓冲区。解决是把循环条件改成while (peek() != '\0' && peek() != '}'),循环结束后判断peek()是不是},不是就返回TOK_ERROR,错误位置记在注释起始处。测试用例里专门放一个bad_comment.tiny,内容只有{ unclosed,确认程序能正常退出并报错。

第三类::=被拆成两个 token。现象是sum := 0;输出ERROR(:)加EQ加NUM,或者COLON加EQ。原因是运算符分支先返回了单字符:,没有看下一个字符是不是=。解决是在case ':'里先advance()吃掉冒号,再peek()看下一个字符;是=就再advance()并返回TOK_ASSIGN,不是就返回TOK_ERROR。如果你扩展了<=、>=,同样处理,先看第二个字符再决定返回双字符还是单字符。注意不要先返回<再指望下一轮把=吃掉,那样 token 流已经错了。

第四类:行号列号整体偏移。现象是报错信息里的行号比实际少 1,或者列号总是从 0 开始。原因是start_line和start_col记录位置太早,在跳过空白之前就记了,导致 token 位置是行首而不是 token 首字符;或者advance在\n时先加列号再加行号,顺序反了。解决是把start_line、start_col放在空白跳过之后、peek取第一个有效字符之前;advance里遇到\n先把行号加一、列号归 1,其他字符列号加一。用read x;验证:read应该是 2:1,x应该是 2:6,;应该是 2:7。如果对不上,先打印pos和line_no对照源码数一遍。

第五类:词素指针悬空或超长截断。现象是调试时 token 的lexeme前一秒还对,函数返回后变成乱码;或者超长标识符把后面的 token 挤掉。原因是lexeme用了const char *指向局部缓冲区,函数返回后缓冲区失效;或者固定数组拷贝时没有检查长度,memcpy写越界。解决是Token.lexeme用固定数组,make_token里先算len = pos - start,len >= MAX_LEXEME就截断并返回TOK_ERROR,正常长度用memcpy加\0。测试时放一个 100 字符的标识符,确认输出ERROR而不是崩溃,再放一个 63 字符的标识符,确认能完整保留。

5.2 调试手段:token dump、字符码打印、最小复现

遇到 token 错位,第一步不是改代码,是缩小输入。把出问题的 TINY 源码删到只剩触发错误的那一行,比如只留sum := 0;,跑 token dump 看输出。如果这一行没问题,再逐行加回去,直到错误复现。这个办法看着笨,但比盯着几百行源码猜要快。第二步是打印字符码,尤其是不可见字符和中文标点。TINY 源码里混进全角分号或全角括号时,scan_operator会走default返回TOK_ERROR,但肉眼看源码很难发现。用od -c sample.tiny或者在小工具里打印(int)c,能直接看到字节值。

第三步是开 sanitizer。make asan生成带 AddressSanitizer 和 UBSan 的二进制,跑一遍全部样例。注释未闭合、数组越界、strtol溢出这类问题会在 sanitizer 下直接报栈,比事后猜“为什么偶尔崩”省时间。第四步是给next_token加一个调试开关,打印每次进入时的pos、line_no、col_no和peek(),但不要长期留在提交代码里,输出太多反而看不清。调试完把开关关掉,回归脚本重新跑一遍。

6. 进阶与验证:把词法器接进语法分析前的自检清单

词法分析器写完最后一个 token 函数,别急着往语法分析器里塞。先做一轮自检,把 token 流当成接口来验。下面这张表是我每次交课程设计前会过一遍的清单,按“自检项、做法、通过标准”组织,适合直接抄到实验报告里,也适合当回归用例的补充。

自检项做法通过标准
关键字边界输入if、ifx、iff、repeat1只有完整关键字变类型,其余是ID
最长匹配输入:=、:、<、<=(若扩展):=不被拆开,单字符不吞下一个
注释闭合输入{ ok }和{ unclosed前者跳过,后者ERROR且不越界
行号列号输入多行read x;每个 token 位置和源码对得上
数字溢出输入超过INT_MAX的数字返回ERROR,不静默变负数
超长词素输入 100 字符标识符返回ERROR,不写越界
EOF 处理空文件和只有空白文件只输出EOF,位置合理
错误恢复输入@和sum := 0;报错后能继续扫或明确退出

这张表里“错误恢复”最容易被忽略。课程设计里常见要求是“遇到错误继续”,但继续的方式要一致:TOK_ERROR打印后跳过当前字符,还是直接退出?两种都行,但回归脚本的期望输出要对应。我一般选“打印后跳过当前字符继续”,这样一次运行能看到多个错误,调试效率高。跳过时注意advance只跳一个字符,如果错误字符是多字节的,别用pos += 2这种硬编码,让advance按字节走。

进阶用法可以加一个 token 覆盖率统计:跑完样例后统计TOK_IF、TOK_ASSIGN、TOK_ERROR各出现多少次,确认测试用例真的覆盖了关键字和双字符运算符。这个统计不用改扫描器,在main.c里加一个计数数组,按t.type累加,最后打印非零项。它不能证明词法分析器绝对正确,但能暴露“样例里根本没测到:=”这种盲区。

从那以后我每次交词法分析器前,都先跑最小样例、注释未闭合样例和最长匹配样例,再打开 sanitizer 跑一轮,最后对照 token 位置表逐行数一遍。词法分析器不怕写得慢,怕的是 token 流悄悄错位,后面语法分析全成黑匣子。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进