简介:这份资源是北京邮电大学编译原理课程实验一的词法分析器实现包,面向正在学习编译原理、需要动手完成扫描器设计的高校学生与自学者,帮助解决从词法规则定义到分词逻辑落地的实践问题。压缩包共4个文件,约10KB,以cpp源代码与h头文件为核心,辅以txt说明与测试用例,分别承担词法分析算法实现、词素类型声明、实验说明与输入样例验证等用途,体量轻便、结构紧凑。目前已有1002人学习下载,具备一定的参考热度。读者可从中获得一套可编译运行的词法分析器参考实现,理解正则表达式或EBNF描述词法规则、字符流读取与特殊情况处理、词素识别与符号栈管理、非法输入的错误报告与恢复等关键环节,并借助测试样例对照调试,为后续语法分析与语义分析打下基础。
1. 从一份课程实验压缩包说起:词法分析器到底在做什么
打开这份压缩包,第一眼看到的是几个源文件和一份实验说明,名字很朴素,叫“词法分析器”。很多同学第一次接触编译原理,会觉得词法分析只是把字符流切成一个个单词,没什么技术含量,真正难的是语法分析和代码生成。但真动手写一遍就会发现,词法分析器是整个编译前端的第一道关卡,它决定了后续语法分析器能不能拿到干净、准确、带位置信息的 token 序列。这份课程实验资源来自某高校编译原理课程的实验一,目标很明确:给定一段类 C 语言的源程序,输出每个单词的类别、内容和行号,遇到非法字符要报错。它适合正在上编译原理课、需要交实验报告的同学,也适合想从零理解编译器前端链路的开发者拿来练手。整个项目不依赖大型框架,核心就是一个手写的扫描器,配合状态机思路,代码量不大,但把正则表达式、有限自动机、符号表这些概念落到了实处。下面我按“先跑起来、再看结构、最后改得动”的顺序,把这份实验拆开讲清楚。
2. 项目结构与技术选型:一个手写扫描器的骨架
2.1 目录里有什么,每个文件管什么
解压后典型的目录结构大致如下,不同年份的实验模板可能略有差异,但核心文件跑不出这几类:
lexer-lab/ ├── src/ │ ├── main.c # 程序入口,读文件、调扫描器、打印结果 │ ├── scanner.c # 词法扫描核心实现 │ ├── scanner.h # token 类型定义与函数声明 │ └── symtab.c # 关键字表 / 符号表管理 ├── test/ │ ├── sample1.c # 正常源程序样例 │ └── sample2.c # 含非法字符的测试样例 ├── Makefile # 编译脚本 └── README.md # 实验说明与运行方式这个结构是课程实验里最常见的 C 语言工程组织方式:入口、核心逻辑、头文件、辅助模块分离。scanner.h里一般会定义 token 的枚举类型,比如ID、NUM、KEYWORD、OPERATOR、DELIMITER、ERROR,以及一个结构体用来承载每次扫描的结果。symtab.c负责关键字识别,常见做法是用一个字符串数组存if、else、while、int这些保留字,扫描到标识符后先查表,命中就归为关键字,否则归为普通标识符。这种“先按标识符规则扫,再查关键字表”的顺序是词法分析器的标准套路,避免了为每个关键字单独写一条匹配规则。
技术选型上,这份实验用的是纯 C 语言,没有引入 lex/flex 这类自动生成工具。这一点很关键:用 flex 生成词法分析器当然快,但课程实验的目的就是让你手写状态机,理解“正则表达式到有限自动机”的转换过程。所以代码里能看到大量switch、if-else和字符判断,而不是一张规则表。对于想二次开发的人来说,手写扫描器的好处是逻辑透明,改一个 token 规则只需要动对应的分支,不用重新生成代码。
2.2 核心数据结构:token 长什么样
在动手看扫描逻辑之前,先搞清楚 token 的数据结构。scanner.h里通常会有类似这样的定义:
typedef enum { TOKEN_ID, // 标识符 TOKEN_NUM, // 数字常量 TOKEN_KEYWORD, // 关键字 TOKEN_OP, // 运算符 TOKEN_DELIM, // 分隔符 TOKEN_ERROR, // 非法字符 TOKEN_EOF // 文件结束 } TokenType; typedef struct { TokenType type; // 单词类别 char lexeme[64]; // 单词原文 int line; // 所在行号 int col; // 所在列号 } Token;这个结构体是后续所有模块的契约。type决定语法分析器怎么处理,lexeme保留原始文本用于报错和语义分析,line和col是错误定位的关键。很多同学写实验时只存类别不存位置,结果报错信息只能写“某处有错”,实验验收时被追问就说不清了。我一般建议一开始就把行列号带上,扫描器每读一个字符就维护当前行列,换行时行号加一、列号归零。这个习惯在后续做语法分析、语义分析时都会受益,因为错误恢复和调试都依赖精确位置。
2.3 扫描主循环:一个字符一个字符地推进
词法分析器的核心是一个循环:不断从输入流取字符,根据当前字符决定进入哪个分支,匹配完一个 token 后回退多余字符,然后继续。scanner.c里的主函数通常长这样:
Token next_token(FILE *fp) { Token tok; int ch; skip_whitespace(fp); // 跳过空格、制表、换行 ch = fgetc(fp); if (ch == EOF) { tok.type = TOKEN_EOF; return tok; } if (isalpha(ch) || ch == '_') { // 标识符或关键字 return scan_identifier(fp, ch); } if (isdigit(ch)) { // 数字常量 return scan_number(fp, ch); } if (is_operator(ch)) { // 运算符,可能双字符 return scan_operator(fp, ch); } if (is_delimiter(ch)) { // 分隔符 tok.type = TOKEN_DELIM; tok.lexeme[0] = ch; tok.lexeme[1] = '\0'; return tok; } tok.type = TOKEN_ERROR; // 其他一律报错 tok.lexeme[0] = ch; tok.lexeme[1] = '\0'; return tok; }这段代码的逻辑很直白:先跳过空白,再看首字符属于哪一类。scan_identifier会继续读后续的字母、数字、下划线,直到遇到非标识符字符,然后用ungetc把多读的那个字符退回输入流。scan_number类似,但要注意处理小数点、科学计数法这些边界情况,课程实验里一般只要求整数,但如果你要扩展,这里就是入口。scan_operator需要处理==、!=、<=、>=、&&、||这类双字符运算符,常见做法是读到第一个运算符后再预读一个字符,如果组合合法就吞掉,否则退回。这个“预读加回退”的模式是手写扫描器的经典手法,理解它之后,后面加新运算符就是照葫芦画瓢。
参数说明:fp是已打开的源文件指针,函数每次调用返回一个 token,调用方循环调用直到TOKEN_EOF。这种“拉模式”比一次性扫描全部 token 存进数组更省内存,也方便和语法分析器做流式对接。如果你的实验要求先输出完整 token 表,那就在main.c里循环调用next_token并打印即可。
3. 跑起来:编译、测试与结果验证
3.1 编译命令与 Makefile 怎么用
这份实验的编译方式很标准,进入项目根目录执行make即可。如果目录里没有 Makefile,手动编译也不复杂:
gcc -Wall -g -o lexer src/main.c src/scanner.c src/symtab.c-Wall打开所有警告,-g保留调试符号。我强烈建议加上-Wall,因为手写扫描器里常见的“忘记处理某个分支”“变量未初始化”这类问题,编译器警告能提前拦住一大半。编译成功后生成lexer可执行文件,运行方式一般是:
./lexer test/sample1.c程序会读取指定源文件,逐行输出 token 的类别、原文和行号。有些实验模板要求从标准输入读取,那就用./lexer < test/sample1.c。具体看main.c里是fopen(argv[1])还是stdin,改起来也就一行的事。
3.2 用样例文件验证输出
拿一个最小样例来验证,比如test/sample1.c里写:
int main() { int a = 10; if (a > 5) return a; return 0; }期望输出大致是:int识别为关键字,main识别为标识符,(){}识别为分隔符,=>识别为运算符,1050识别为数字常量。跑完之后逐行对照,重点看三件事:关键字有没有被误判成标识符、双字符运算符有没有被拆成两个单字符、行号有没有在换行后正确递增。这三项是实验验收的高频检查点,也是手写扫描器最容易翻车的地方。
如果输出里出现TOKEN_ERROR,先别急着改代码,把出错字符和行号打出来,对照源文件看是不是样例里本来就有非法字符。test/sample2.c通常就是故意放了一些@、$之类的符号来测试错误处理,能正确报错反而是加分项。
3.3 常见运行失败与排查顺序
跑不起来的时候,按这个顺序排查效率最高:先看编译有没有报错,链接错误多半是scanner.c或symtab.c没加进编译命令;再看运行时有没有段错误,常见原因是lexeme数组越界,标识符太长或者没加字符串结束符;最后看输出是否符合预期,如果所有 token 都是ERROR,检查fgetc的返回值有没有被存成char导致 EOF 判断失效。这个排查顺序是我踩过几次坑之后固定下来的,从编译到运行再到逻辑,逐层收窄,比一上来就盯着扫描函数看要快得多。
4. 核心模块拆解:标识符、数字与运算符的识别细节
4.1 标识符与关键字:先扫再查表
标识符的识别规则是“字母或下划线开头,后跟零个或多个字母、数字、下划线”。代码实现上,scan_identifier通常这样写:
Token scan_identifier(FILE *fp, int first) { Token tok; int i = 0; int ch = first; while (isalnum(ch) || ch == '_') { if (i < 63) tok.lexeme[i++] = (char)ch; ch = fgetc(fp); } tok.lexeme[i] = '\0'; ungetc(ch, fp); // 多读的字符退回 if (is_keyword(tok.lexeme)) { // 查关键字表 tok.type = TOKEN_KEYWORD; } else { tok.type = TOKEN_ID; } return tok; }这里有两个细节值得说。第一,i < 63的边界判断不能省,虽然课程样例里标识符不会太长,但一旦输入超长标识符,没有边界检查就是缓冲区溢出,运行时直接崩溃,调试起来很费时间。第二,ungetc只能保证退回一个字符,所以循环里每次只多读一个,这个约束决定了扫描器的写法,不能一次性读一大段再处理。is_keyword的实现就是遍历关键字数组做字符串比较,关键字数量少的时候线性查找完全够用,不需要上哈希表。
4.2 数字常量:整数、小数与边界
数字识别比标识符多一层考虑:要不要支持小数和科学计数法。课程实验一一般只要求无符号整数,但代码里留好扩展点没坏处:
Token scan_number(FILE *fp, int first) { Token tok; int i = 0; int ch = first; while (isdigit(ch)) { tok.lexeme[i++] = (char)ch; ch = fgetc(fp); } if (ch == '.') { // 小数部分,按需启用 tok.lexeme[i++] = (char)ch; ch = fgetc(fp); while (isdigit(ch)) { tok.lexeme[i++] = (char)ch; ch = fgetc(fp); } } tok.lexeme[i] = '\0'; ungetc(ch, fp); tok.type = TOKEN_NUM; return tok; }如果实验只要求整数,把if (ch == '.')那段删掉即可。要注意的是,数字后面紧跟字母的情况,比如123abc,严格来说应该报错,因为这不是合法的 token。常见做法是在数字扫描结束后检查下一个字符,如果是字母或下划线就标记为TOKEN_ERROR。这个边界处理很多同学会漏,验收时拿一个123abc一测就露馅了。
4.3 运算符与分隔符:双字符运算符的预读回退
运算符识别是手写扫描器里最需要小心的地方,因为>和>=、=和==、&和&&都是前缀关系。处理方式统一为“读一个,预读一个,能组合就组合,不能就退回”:
Token scan_operator(FILE *fp, int first) { Token tok; int next = fgetc(fp); tok.lexeme[0] = (char)first; tok.lexeme[1] = '\0'; tok.type = TOKEN_OP; if ((first == '=' && next == '=') || (first == '!' && next == '=') || (first == '<' && next == '=') || (first == '>' && next == '=') || (first == '&' && next == '&') || (first == '|' && next == '|')) { tok.lexeme[1] = (char)next; tok.lexeme[2] = '\0'; } else { ungetc(next, fp); // 不是双字符,退回 } return tok; }这段代码把双字符运算符的组合规则集中在一个if里,加新运算符就是往条件里加一行,维护成本低。分隔符如(、)、{、}、;、,都是单字符,直接返回即可,不需要预读。这里有个容易忽略的点:&和|单独出现时在 C 语言里也是合法运算符(按位与、按位或),所以不能因为没匹配到&&就报错,退回后按单字符运算符处理才是对的。
4.4 错误处理与行号维护:别让报错信息变成黑匣子
错误处理的质量直接决定实验报告好不好写。扫描器遇到非法字符时,除了返回TOKEN_ERROR,还应该把行号、列号和字符本身带出去,让main.c能打印出“第 5 行第 12 列出现非法字符 @”这样的信息。行号维护的常见做法是在skip_whitespace里检测换行:
void skip_whitespace(FILE *fp) { int ch; while ((ch = fgetc(fp)) != EOF) { if (ch == '\n') { current_line++; current_col = 0; } else if (ch == ' ' || ch == '\t' || ch == '\r') { current_col++; } else { ungetc(ch, fp); break; } } }current_line和current_col用全局变量或扫描器上下文结构体维护都行,课程实验规模小,全局变量更省事。但要注意,ungetc退回的字符不会影响行列计数,因为计数是在读取时递增的,退回后下次重新读到会再计一次,逻辑上保持一致。如果发现行号总是差一,检查一下是不是在next_token开头就把行号加了,或者在换行判断上用了\r\n只处理了\n。
5. 避坑与排查:手写扫描器最容易翻车的五个地方
5.1 现象:所有 token 都变成 ERROR,程序像没读文件
原因通常是fgetc的返回值被存进了char类型。fgetc返回int,EOF 是-1,如果存成char,在某些平台上-1会变成255,导致 EOF 判断永远不成立,或者字符比较全部错位。解决方法是所有接收fgetc返回值的变量都声明为int,包括next_token里的ch和各个scan_*函数里的临时变量。这个坑很隐蔽,因为编译不一定报错,运行时表现却是“读不到文件”或者“全是乱码”。
5.2 现象:双字符运算符被拆成两个单字符
比如==输出成两个=,>=输出成>和=。原因多半是scan_operator里预读逻辑写反了,或者ungetc调用位置不对。检查两点:预读的字符在组合成功时有没有写进lexeme,组合失败时有没有退回。另外,如果next_token在调用scan_operator之前已经把第二个字符读走了,也会导致组合失败,确保运算符分支里只传首字符进去。
5.3 现象:关键字被识别成普通标识符
比如int输出为TOKEN_ID。先检查is_keyword的关键字表里有没有这个词,再检查查表时机是不是在lexeme拼接完成之后。常见错误是在循环里边读边查,此时lexeme还不完整,自然查不到。正确顺序是先把整个标识符扫完、补上\0,再查表。如果关键字表用的是二维数组,注意字符串比较用strcmp而不是==。
5.4 现象:超长标识符导致程序崩溃或输出截断
lexeme数组开得太小,或者循环里没做边界判断。课程样例一般不会触发,但验收时老师随手敲一长串字符就可能崩。解决方法是给lexeme一个明确上限(比如 64 或 128),循环里加i < MAX_LEN - 1的判断,超出部分可以选择截断并报错,或者继续消费字符但不存储。我一般选择后者,保证 token 流不被打断,同时在报告里说明超长标识符的处理策略。
5.5 现象:行号在跨行注释或字符串里错乱
如果实验要求支持注释和字符串常量,行号维护就不能只在skip_whitespace里做。注释和字符串内部也可能有换行,扫描这些结构时要同步更新current_line。常见做法是把行号更新封装成一个advance_char函数,所有读字符的地方都走它,避免漏更新。这个坑在只做基础词法的实验里不一定遇到,但一旦扩展功能就会冒出来,提前把结构留好能省不少返工。
6. 二次开发:把课程实验改成能用的词法分析器
6.1 扩展方向一:支持注释与字符串常量
课程实验一通常不要求注释和字符串,但这两项是真实编译器前端的必备能力。改造入口在next_token的分支判断里:遇到/时预读下一个字符,如果是/就进入单行注释扫描,一直读到换行;如果是*就进入多行注释扫描,直到遇到*/。字符串常量则以"开头,循环读取直到遇到未转义的",中间处理\"和\\这类转义序列。改造时注意注释和字符串都不产生 token(或者字符串产生一个TOKEN_STRING),但行号必须照常维护。我一般会先写一个skip_comment函数,在skip_whitespace之后、正式取 token 之前调用,这样主循环结构不用大改。
6.2 扩展方向二:把硬编码规则抽成配置表
手写扫描器的规则散落在各个if分支里,加一种 token 就要改多处。如果想让这份实验代码具备一点工程性,可以把运算符和关键字抽成配置表:
static const char *keywords[] = {"if", "else", "while", "for", "int", "return", NULL}; static const char *two_char_ops[] = {"==", "!=", "<=", ">=", "&&", "||", NULL};然后在is_keyword和scan_operator里遍历这些表。好处是新增关键字或运算符只改数组,不动逻辑。更进一步,可以把单字符运算符和分隔符也做成表,用查表代替switch。这个改造不复杂,但能让代码的可读性和可维护性上一个台阶,写进实验报告里也是加分项。
6.3 验证方法:用对拍和边界用例兜底
改完之后怎么确认没改坏?我的习惯是准备三组用例:正常程序、边界用例、错误用例。正常程序用课程样例;边界用例包括最长标识符、连续运算符a+++b、数字后跟字母123abc、空文件、只有注释的文件;错误用例放非法字符和未闭合字符串。每组跑完对照预期输出,重点看 token 序列和行号。如果条件允许,可以写一个简单的对拍脚本,把改造前后的输出 diff 一下,确认只有预期内的变化。这个习惯看起来麻烦,但比改完直接交要稳得多,毕竟词法分析器的 bug 会一路传导到语法分析,越晚发现越难查。
6.4 一个具体技巧:用 token 流做单元测试
最后分享一个我在课程实验里常用的小技巧:把next_token的输出重定向成一行一个 token 的文本格式,比如KEYWORD int 1:1,然后用 shell 脚本或 Python 做断言。这样每次改扫描器,跑一遍测试脚本就知道有没有回归。相比人工看输出,脚本能覆盖更多用例,也方便在报告里展示测试覆盖率。具体做法是在main.c里加一个--dump参数,走不同的打印分支,不影响正常输出。这个技巧不改变核心逻辑,但能让二次开发的过程可控很多。
从那以后我每次改扫描器规则,都强制先跑一遍边界用例再动主逻辑,血泪经验是曾经为了加一个+=运算符把+的识别改崩了,排查了半天才发现是预读回退的顺序写反了。希望这份拆解能帮你少走同样的弯路,把实验跑通、改顺,真正理解词法分析这一层的设计取舍。
本文还有配套的精品资源,点击获取