☰
编译原理实验:手写词法分析器与Flex实现全攻略
2026/10/13 0:25:41 网站建设 项目流程

简介:编译原理是计算机科学的核心课程,词法分析器作为编译器前端的重要组成,负责将字符流识别为有意义的词素。资源来自北京邮电大学编译原理课程实验,提供了完整的词法分析器实现方案,适合正在学习编译原理、需完成课程实验或希望掌握词法分析具体编码实现的学生参考。压缩包内共4个文件,包含C++源文件与头文件,以及两个txt文档,分别用于说明实验设计与提供测试用例;整体仅10KB,结构紧凑,便于快速查阅。目前已有1000人学习下载。通过分析源码,读者可以了解词法规则的定义方式、标识符与关键字的识别逻辑、符号栈管理以及错误处理机制,还可借助自带测试文本验证程序行为。对于希望从理论走向实践、熟悉Lex/Flex之外的手写词法分析器思路的同学,这份资源具有不错的借鉴价值。

1. 北邮编译原理课程实验一词法分析器.zip:先把入口关守好,后面四场比赛才好打

北邮编译原理课程实验一词法分析器.zip 这个压缩包,拿到的瞬间很容易被当成一个普通作业:读几行源码、切出单词、填个表,提交就算完事。但真正打开实验文档就会发现,它是整门课第一个必须用工程态度对待的黑匣子——后面语法分析、语义分析、中间代码生成全靠一条没有错误的 Token 流,而这一层正是词法分析器要守住的入口。这个实验的核心任务是把源程序拆成有类型的 Token:标识符、关键字、整数、浮点、运算符、分隔符,同时把非法字符和行号位置错误挡在门外。适合正在交这个实验的你,也适合那些想把手写词法分析器升级成 Flex/JFlex 方案的从业者。

2. 先把语言子集定死:Token 规格、关键字表与符号表登记

2.1 为什么实验一最容易翻车在一个“没定义清楚”的词表上

词法分析器本质是一个分类器:输入是字符流,输出是带类型和位置的 Token 流。如果分类标准没定死,后面所有规则都是空中楼阁。“if” 算关键字吗?下划线开头的标识符合不合法?十六进制整数要不要支持?注释里出现的字符串引号怎么处理?这些不是实现问题,是规格问题。

我一般会先写一份 Token 规格表,再动手写代码。以北邮这类课设最常见的 C 语言子集为例,最小集合是:

类别内容说明
关键字if else while return int float char void按实验文档裁剪
标识符字母或下划线开头,后接字母、数字、下划线通常区分大小写
整数十进制、十六进制(0x 开头)可选
浮点小数点形式的实数,如 3.14陷阱在“.”后必须跟数字
字符串双引号括起,支持 \n \t " \ 等转义状态机最易错的地方
运算符+ - * / % = == != < <= > >= && || !单字符与双字符要分开匹配
分隔符; , ( ) { } [ ]通常单独一类
注释// 单行,/* */ 多行词法阶段跳过,但行号要维护

这张表就是词法分析器与外部的“合同”。关键词表用数组还是哈希集合?规模小时无所谓,但不要写成每次判断都遍历一遍的线性扫描,样例多了以后会被放大。用 Java 的 HashSet 或 C 的枚举值映射就够,十几二十个关键字,哈希查找既快又直观。

最容易被忽视的是“非法后缀”规则。教科书里的正则表达式通常写成[A-Za-z_][A-Za-z0-9_]*描述标识符,但实际输入里可能出现123abc这种形态。如果词法阶段简单把123和abc拆成两个合法 Token,语法分析阶段就会收到一种本不该存在的 Token 序列,错误报告位置还会莫名其妙地偏差。这个坑放在后面避坑清单里细说,规格阶段要做的决定是:数字后紧跟字母或下划线,直接报词法错误,而不是拆分。

2.2 符号表与字面量登记:可运行的 Java 骨架(C 同样适用)

很多实验要求词法分析器把识别到的标识符和常量登记到符号表。北邮这类课设在词法阶段一般不做作用域分析,符号表只是“登记”,数据结构选哈希表加链表即可。用 Java 演示最直观,换成 C 就是把类拆成结构体和一组函数。

// TokenType.java public enum TokenType { KEYWORD, IDENTIFIER, INTEGER, FLOAT, STRING, OPERATOR, SEPARATOR, EOF, ERROR }
// Token.java public class Token { public TokenType type; public String lexeme; // 原始词素,如 "3.14" public Object value; // 解析后的值,Integer / Double / String public int line, col; // 起始行列,排错用 }
// SymbolTable.java import java.util.HashMap; public class SymbolTable { private static final int INIT_CAPACITY = 128; private final HashMap<String, Symbol> table = new HashMap<>(INIT_CAPACITY); public void insert(String name, TokenType kind) { Symbol symbol = table.get(name); if (symbol == null) { table.put(name, new Symbol(name, kind)); } } public boolean contains(String name) { return table.containsKey(name); } static class Symbol { String name; TokenType kind; Symbol(String name, TokenType kind) { this.name = name; this.kind = kind; } } }

这几个类的逻辑说明其实集中在两点。第一,TokenType.ERROR必须存在,词法错误也是 Token,只是语义是“非法输入”,这样主循环可以统一按 Token 流处理,不需要单独的错误返回通道。第二,SymbolTable.INIT_CAPACITY设成 128 是经验值,课设测试样例通常几百个标识符以内,128 的初始容量配 0.75 负载因子足够,太大反而浪费内存。

参数上值得留意的是“区分大小写”。C/C++ 标识符区分大小写,if和IF是两个东西;但有的课设文档会把语言子集定义成不区分。如果拿不准,按区分大小写实现更通用,交实验前在配置里留一个开关即可,不要写死在代码里。

提示:词法阶段的符号表只做登记,不做重复声明判断。同名学生第二次insert时覆盖或忽略都行,作用域和类型检查留给后续实验,别提前给自己加戏。

3. 用手写状态机实现词法分析器:状态转移表与最小可运行主循环

3.1 从正则描述到确定性状态机:状态转移表先于代码

正则表达式是词法规则的描述语言,但课设几乎不会真的实现一个正则引擎。常见做法是把每个正则转成 NFA,再转成 DFA,最后拿一张状态转移表驱动识别。实验一规模小,手工画状态图写转移表,比上工具更快。

为什么要先画状态图而不是直接写一长串 if-else?因为 if-else 判断字符容易漏掉状态组合。比如识别字符串时会经过“普通字符、转义、转义后、结束”好几个状态,注释有“读到 *、读到 /、结束”等状态,纯 if-else 写出来逻辑分散,调试时全靠打印,非常痛苦。状态图把每个状态和转移条件列清楚,代码只是翻译表格。

以下是一个最小语言子集的状态转移表,覆盖标识符、整数、浮点和运算符:

当前状态字母/_数字.+ - = ! 等运算符"其它
起始标识符状态整数状态错误运算符状态字符串状态逐个判断
标识符状态保持保持回退并结束回退并结束回退并结束回退并结束
整数状态数字后紧跟字母 → 错误保持浮点状态回退并结束回退并结束回退并结束
浮点状态错误保持错误回退并结束回退并结束回退并结束

这张表的含义是:状态机每读一个字符就查一次表,决定“保持当前状态”“跳到新状态”还是“回退结束”。注意这里“回退并结束”意味着当前字符不属于正在识别的 Token,要把它退还给输入流,留到下一个 Token 再读。这就是最长匹配的工程实现。

实际课设还会遇到十六进制、单行注释、多行注释等,只需在表格里加状态列。原则是:一个状态只表达“我正在处理哪一种词素”,不要用布尔变量互相组合代表状态,那样状态一多必然乱。

3.2 最小主循环与 pushback 缓冲:一份能跑的核心骨架

用 Java 实现时,我一般用PushbackReader做单字符回退,配合一个保存当前行列的计数器。下面这段代码是手写词法分析器的核心骨架,能跑通标识符、关键字、整数、运算符合分隔符,也演示了最长匹配和回退。

import java.io.*; public class HandLexer { private final PushbackReader reader; private int line = 1, col = 0; public HandLexer(Reader in) { this.reader = new PushbackReader(in, 4); } // 读取一个字符,并递增列号。-1 表示文件结束 private int readRaw() throws IOException { int c = reader.read(); if (c != -1) col++; return c; } // 回退一个字符,并递减列号 private void unreadRaw(int c) throws IOException { if (c != -1) { reader.unread(c); col--; } } public Token nextToken() throws IOException { int c = readRaw(); // 跳过空白,换行时行号加一,列号归零 while (c == ' ' || c == '\t' || c == '\n' || c == '\r') { if (c == '\n') { line++; col = 0; } c = readRaw(); } if (c == -1) return new Token(TokenType.EOF, "", line, col); // 标识符或关键字 if (Character.isLetter(c) || c == '_') { StringBuilder sb = new StringBuilder(); while (c != -1 && (Character.isLetterOrDigit(c) || c == '_')) { sb.append((char) c); c = readRaw(); } unreadRaw(c); String word = sb.toString(); TokenType type = KeywordTable.contains(word) ? TokenType.KEYWORD : TokenType.IDENTIFIER; return new Token(type, word, line, col - word.length() + 1); } // 整数 if (Character.isDigit(c)) { StringBuilder sb = new StringBuilder(); while (c != -1 && Character.isDigit(c)) { sb.append((char) c); c = readRaw(); } // 数字后紧跟字母或下划线:非法,不能拆开 if (c != -1 && (Character.isLetter(c) || c == '_')) { return new Token(TokenType.ERROR, sb.toString() + (char) c, line, col); } unreadRaw(c); return new Token(TokenType.INTEGER, sb.toString(), line, col - sb.length() + 1); } // 单字符运算符与分隔符,这里只列出两个,完整版补全即可 switch (c) { case '+': return new Token(TokenType.OPERATOR, "+", line, col - 1); case ';': return new Token(TokenType.SEPARATOR, ";", line, col - 1); default: return new Token(TokenType.ERROR, String.valueOf((char) c), line, col - 1); } } }

这段代码的逻辑说明要先讲回退。读标识符时,循环会多读一个字符才停下,这个字符属于下一个 Token,不unread就会被吞掉,这是手写词法分析器最常见的丢字 bug。unreadRaw在回退的同时把列号减一,保证下一个 Token 的起始列正确。

参数上有三个值得注意的点。第一,PushbackReader的缓冲容量设成了 4,因为目前最长的回退距离是 1 个字符;将来如果要支持>>这类双字符运算符,按“读到第二个字符后发现该结束”的情形最多回退 1,4 已经够用。第二,标识符长度没有上限,实际课设可以加一个MAX_IDENTIFIER_LENGTH截断并报错,防止超大标识符拖垮性能。第三,列号从 0 还是 1 开始,不同实验文档要求不同;上面的实现里col - word.length() + 1是按列号从 1 开始算的,交实验前要对齐文档约定。

注意:错误 Token 的列号指向“错误发生的位置”。数字后紧跟字母时,我直接返回了 ERROR Token,但没有把字母回退,这是刻意为之——这个字符必须出现在错误信息里,否则用户不知道错在哪。

4. 用 Flex/JFlex 生成词法分析器:从手写切到生成器怎么对齐实验要求

4.1 Flex 文件三段式结构:一个能过的 .l 样例与编译步骤

有的班允许用 Flex,有的实验明确要求手写,我个人的建议是:如果允许,先把 Flex 版本跑通,再对照着改手写版本,两边用同一份测试样例回归。Flex 的学习成本很低,核心是一个三段式文件:定义区、规则区、用户代码区。

%{ #include <stdio.h> %} %option noyywrap nodefault %option yylineno DIGIT [0-9] ID [a-zA-Z_][a-zA-Z0-9_]* %% "if"|"else"|"while"|"return" { printf("%d KEYWORD %s\n", yylineno, yytext); } {ID} { printf("%d IDENTIFIER %s\n", yylineno, yytext); } {DIGIT}+ { printf("%d INTEGER %s\n", yylineno, yytext); } "+"|"-"|"*"|"/" { printf("%d OPERATOR %s\n", yylineno, yytext); } ";"|","|"("|")" { printf("%d SEPARATOR %s\n", yylineno, yytext); } [ \t\r\n]+ { /* 空白跳过 */ } . { printf("%d ERROR %s\n", yylineno, yytext); } %% int main() { yylex(); return 0; }

编译命令在 Linux 或 WSL 下是这样:

flex lexer.l gcc lex.yy.c -lfl -o lexer ./lexer < test.c

这个 .l 文件的逻辑说明集中在规则顺序。Flex 匹配遵循“最长匹配优先,相同长度取最前面的规则”。{ID}能匹配if,但"if"规则字面量长度也是 2,两者长度相同,谁在前面谁生效。所以关键字规则必须写在标识符规则之前,这也是我代码里把关键字规则放在{ID}上方的直接原因。

参数说明:%option yylineno让 Flex 自动维护全局变量yylineno,省去手写换行计数;noyywrap告诉生成的扫描器不要调用yywrap,因为只有一个文件要扫;nodefault则让 Flex 在规则没覆盖到某字符时报错而不是静默匹配,调试时能立刻发现漏规则。如果实验环境是 Windows 且没有 flex,可以用 JFlex 替代,规则语法几乎一样,只是生成的是 Java 类。

4.2 输出格式对齐与手写/生成器取舍:怎么让实验评分认你的结果

Flex 版本默认输出由我们自己在规则里控制,这带来一个关键优势:输出格式可以和手写版本完全一致。我习惯把两种实现统一成行号 Token类型 词素的格式,这样后面用 diff 脚本做回归验证才可行。

Java 侧用 JFlex 的写法略有不同。%class、%public、%type Token控制生成类名和yylex()返回类型,%line %column让 Yylex 自动记录行列号:

%% %public %class JFlexLexer %type Token %line %column DIGIT=[0-9] ID=[a-zA-Z_][a-zA-Z0-9_]* %% "if"|"else"|"while"|"return" { return new Token(TokenType.KEYWORD, yytext(), yyline + 1, yycolumn + 1); } {ID} { return new Token(TokenType.IDENTIFIER, yytext(), yyline + 1, yycolumn + 1); } {DIGIT}+ { return new Token(TokenType.INTEGER, yytext(), yyline + 1, yycolumn + 1); } [ \t\r\n]+ { /* 跳过 */ } . { return new Token(TokenType.ERROR, yytext(), yyline + 1, yycolumn + 1); }

手写与生成器不是二选一的对抗关系,取舍取决于你的实验约束和调试场景。手写版调试直观,出错时能加打印、能下断言,缺点是状态多了以后代码膨胀。Flex 版规则表达力强、代码量小,但生成的lex.yy.c几乎无法单步调试,遇到诡异行为只能靠反复改规则试探,非常玄学。我的建议是:主交手写版,同时留一份 Flex 版当参照物,两者互相对拍,能少踩一大半坑。

对比项手写状态机Flex/JFlex
调试可加打印,逻辑可见黑匣子,只能改规则
规则修改改状态表改正则,改动小
代码量大,易膨胀小,规则集中
最长匹配自己实现回退自动处理
适用场景课设要求手写、面试手撕允许工具、快速原型

5. 避坑清单:5 个让词法分析器翻车的边界输入与排查方法

5.1 数字后紧跟字母被拆成两个 Token

现象:输入123abc,手写版输出INTEGER 123和IDENTIFIER abc,语法分析阶段在123abc处解析出错,错误位置却指到abc上,看起来莫名其妙。

原因:数字状态机只判断“下一个字符是不是数字”,读到a就认为数字结束并回退,没有检查数字的后继字符是否合法。按多数语言词法规则,数字后紧跟字母或下划线不是两个 Token 的合法边界。

解决:在识别完数字后peek一下后继字符,如果是字母或下划线,直接返回 ERROR Token,并把这几个字符原样放进错误信息。前面 3.2 的代码已经做了这个检查,手写时最容易漏的就是这个分支。Flex 侧可以在数字规则后追加一条{DIGIT}+[a-zA-Z_][a-zA-Z0-9_]*的错误规则,专门捕获这种形态。

5.2 多行注释里的字符串引号让状态错乱

现象:输入/* " */,注释在第二个双引号前就提前结束,后面裸奔的*/被识别成运算符;更糟的是未闭合的块注释会一路吃干净整个文件。

原因:状态设计没把“注释状态”和“字符串状态”设为互斥。注释里出现引号时,状态机误以为字符串开始,等真正遇到*/时已经在字符串状态里,注释结束标志完全不生效。

解决:把注释和字符串当作两个独立状态,注释状态里除了*/不看任何字符,引号只是普通字符;字符串状态里除了转义和结束引号,其他都原样吞掉。实现上可以加一个inBlockComment布尔量,但更稳的做法是状态机里把“注释内”和“字符串内”设计成两个互斥状态,不要在外部再用布尔变量组合。

5.3 行号不涨,所有报错都在第一行

现象:错误提示里行号恒为 1,列号却还在变化,排查半天找不到哪一行出的问题。

原因:代码在读字符时对\n没有单独处理,或者把换行符当成普通字符,跳空白时直接continue忘记line++。Flex 若用了%option yylineno但规则里又手动写line++,还会出现行号翻倍。

解决:跳空白循环里先判断c == '\n',再加一行line++并把col归零。手写版本要注意readRaw()已经对每个字符col++,所以归零要放在line++之后。Flex 版本要么完全交给 yylineno,要么完全自己维护,不要混用,混用是双倍行号的经典来源。

5.4 手写回退不彻底,丢掉关键字符

现象:识别<=时先读到=作为完整运算符返回,把<也当作运算符返回,结果两个 Token。或者识别==时只回退了一个字符,得到错误结果。

原因:ungetc在标准 C 里只保证回退一个字符,如果我不小心在同一个位置连续ungetc两次,第三个字符就可能丢失。单字符运算符用单字符回退没问题,但双字符运算符的识别可能要回退的长度不固定。

解决:手写版用一个显式的pushbackBuffer,自己实现peek和unread,或者直接维护一个“当前已读未消费”的字符数组,容量按最长运算符长度加一设计。Java 的PushbackReader我在构造时传入 4 就是为了留足余地。Flex 内部自带大缓冲,这类问题不会出现,所以遇到回退玄学问题时,优先怀疑手写代码自己的缓冲逻辑。

5.5 关键字判断用字符串比较导致类型错乱

现象:识别return时,手写版把标识符和关键字都输出成IDENTIFIER,或者把ifx也当成关键字做特殊处理。

原因:关键字表判断写成了word.startsWith("if"),或者直接把字符串和关键字做了contains判断,模糊匹配把ifx也吞进去了。

解决:关键字判断必须是全字匹配。用哈希集合HashSet<String>做contains,或维护一个静态枚举映射,一定不要用前缀匹配。Flex 侧规则顺序正确的场景下,最长匹配加规则顺序已经保证ifx走{ID}规则,但手写版没有这个兜底,必须自己在代码里保证整词判断。

6. 用 token 级 diff 回归脚本验证两种实现

6.1 统一输出格式 + 一个最简单的 diff 脚本

实验一交付的核心产出是 Token 序列,验证 Token 序列等价,比单测词法分析器内部函数更贴近真实需求。我习惯把手写版和 Flex 版都跑在同样的测试样例上,输出统一成行号 Token类型 词素三列,然后用一个几十行的 Python 脚本逐行比对。

#!/usr/bin/env python3 import sys def read_tokens(path): tokens = [] with open(path, encoding="utf-8") as f: for i, line in enumerate(f, 1): line = line.rstrip("\n") if line: tokens.append((i, line)) return tokens def main(): a = read_tokens(sys.argv[1]) b = read_tokens(sys.argv[2]) if a == b: print("TOKEN STREAMS MATCH") return max_lines = max(len(a), len(b)) for i in range(max_lines): left = a[i] if i < len(a) else ("", "<EOF>") right = b[i] if i < len(b) else ("", "<EOF>") if left != right: print(f"file line {left[0]}: {left[1]}") print(f"your line {right[0]}: {right[1]}") if __name__ == "__main__": main()

这个脚本的逻辑很简单:两个文件按行比较,第一次出现差异的行会同时打印两边内容,没有diff format依赖,在任何环境都能跑。参数上唯一要注意的是两边的输出列顺序必须一致,否则 diff 永远报错。

测试样例集建议覆盖四种:一个正常小程序、一个含多行注释的样例、一个故意写123abc的错误样例、一个/* 未闭合的边界样例。四条全过,实验一的词法部分就稳了。

6.2 从踩坑到固定习惯

我在做这一套之前也犯过低级错误。当时用 Java 写手写 Lexer,注释规则里没有把/*和字符串状态分开,测试样例里有个表达式a /* b,后面正好跟着"*/"字符串,我的分析器把代码注释和字符串搅在一起,输出错得一塌糊涂。那次之后我把“手写版和 Flex 版对拍”固定成了一个习惯:每次改一处规则,就重新跑一遍 diff 脚本,确保旧样例全部回归通过。

词法分析器是编译原理课程里少有的、能完全白盒验证的组件:它输入输出都直观,错误可复现,最适合用来建立对编译流程的整体感觉。把这一层写干净,后面语法分析拿到的是可信的 Token 流,调试复杂度会降一个量级。希望这个方案和踩坑清单能帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询