简介:北邮编译原理课程词法分析器实验的完整源码包,面向正在学习编译原理的本科生,以编译器前端的关键环节为切入点,帮助理解扫描器如何将源代码字符流拆分为记号,并识别关键字、标识符、常量、运算符等词素。包内共4个文件,包含C++实现的主程序与头文件,以及配套说明文档和测试用例文本;压缩包整体仅10KB,结构紧凑,适合直接阅读核心代码。源码覆盖词法规则定义、输入流预处理、分词算法、符号栈管理与错误恢复等关键模块,采用手写分析器的方式呈现,可直观对照基于正则表达式或EBNF描述的词法规则,以及从规则到状态转换图的实现路径。目前已有1000人学习下载,学生在完成课程实验时可将其作为独立实现的参考,既能借鉴词素分类、符号表组织与错误定位思路,也能据此排查自身代码在边界字符与特殊输入上的处理漏洞,为后续语法分析与语义分析打下基础。
1. 编译原理实验一:词法分析器到底在考什么
编译原理课的第一关,永远是词法分析器。北邮这门课的实验一要求把一份源程序拆成一条条Token流,很多人的编译原理实验分数就折在这个看似不起眼的环节上。这份在学生圈子里传了很多届的“词法分析器.zip”,装的是实验一整套可用的实现,从源码到测试用例再到实验报告都有。我把它拆过一遍,第一感觉是:代码确实能跑,但直接照抄去验收,大概率被助教问住。真正的价值在于看懂它的识别主循环怎么设计、符号表怎么查、错误怎么恢复——这些才是编译原理实验和普通编程作业的分水岭。适合正在赶实验一的在校生,也适合想用手写词法分析器入门编译器的自学者。
2. 设计与选型:状态转移图先于代码
2.1 手工识别还是正则引擎:实验约束下的唯一选择
词法分析器有两条技术路线:一是用Lex/Flex这类生成器写规则,让工具自动生成DFA;二是纯手写状态机。北邮实验一通常明确要求手写识别过程,不允许用生成器,目的是让你亲手实现一遍DFA的状态转移过程。所以这份资源的核心不是正则表达式,而是一张状态转移图。
状态转移图长什么样?以标识符为例:起始状态S0,遇到字母进入S1;在S1里继续遇到字母或数字,保持在S1;遇到其他字符,从S1退出,识别出一个标识符。数字、运算符、界符各有一组子图。把整张图并起来,就是以状态为行、以输入字符类别为列的二维表。我一般会在草稿纸上先把这张表画全,再动键盘;画不全就写代码,后期一定改到吐。
| 状态 / 字符类别 | letter | digit | + - * / | 空白 | 换行 | " | 其他 |
|---|---|---|---|---|---|---|---|
| S0 起始 | S1 | S2 | S3 | S0 | S0 | S5 | ERR |
| S1 标识符 | S1 | S1 | END | END | END | END | END |
| S2 整数 | ERR | S2 | END | END | END | END | END |
| S3 运算符 | END | END | END | END | END | END | END |
| S5 字符串 | S5 | S5 | S5 | S5 | ERR | END | S5 |
这张表就是后面所有代码的依据。如果你在资源里看到大段switch-case或if-else,那只是表的另一种写法;如果能直接改成二维数组驱动,说明作者的表画得很规整。注意表里的END不是退出程序,而是“当前词素到此结束,回到S0重新开始”。
2.2 标识符和关键字:先识别再查表,顺序不能反
初学最容易犯的错是:在识别过程中判断关键字,边读边比对。正确做法是先按标识符规则把一整串字母数字读完,形成词素(lexeme),再拿这个词素去查关键字表,命中了就是关键字,没命中才算用户标识符。顺序反了会出大问题,比如输入"intx"会被拆成"int"和"x"两个Token。
我先定义了完整的Token类型枚举,这是整个分析器的地基:
public enum TokenType { KEYWORD, IDENTIFIER, INTEGER, FLOAT, CHAR_CONST, STRING_CONST, OPERATOR, DELIMITER, ERROR, EOF }关键字表在这份资源的Java版本里,我习惯用集合加枚举来判断:
import java.util.HashSet; import java.util.Set; public class KeywordTable { private static final Set<String> KEYWORDS = new HashSet<>(); static { // 实验里定义的C语言子集,关键字就这十几个 String[] words = { "int", "char", "float", "double", "if", "else", "while", "for", "return", "void", "struct", "sizeof" }; for (String w : words) { KEYWORDS.add(w); } } public static boolean isKeyword(String lexeme) { return KEYWORDS.contains(lexeme); } }这段逻辑很直白:静态块一次性加载关键字集合,isKeyword做O(1)查表。要点是集合必须初始化一次,千万别写成每次识别都new一个Set,否则性能直接废掉。如果你的实验语言是C++子集,把关键字列表换成对应保留字即可。另外注意大小写敏感,像"Int"这种不能算关键字,这是最容易和助教争论的细节。
2.3 表驱动识别主循环:把IF-Else收敛成状态跳转
有了状态表,主循环就剩两件事:读一个字符,查类别,按状态跳转。这是整个实验最核心的一层,后面的符号表、错误处理都挂在这层之下。
private static final int S0 = 0, S1 = 1, S2 = 2, S3 = 3, S5 = 5; private static final int CHAR_LETTER = 0, CHAR_DIGIT = 1; private static final int CHAR_OP = 2, CHAR_SPACE = 3, CHAR_QUOTE = 4; // 状态转移表:行是当前状态,列是字符类别,-1表示词素结束 private static int[][] transTable = { // letter digit op space quote /* S0 */ { S1, S2, S3, S0, S5 }, /* S1 */ { S1, S1, -1, -1, -1 }, /* S2 */ { -1, S2, -1, -1, -1 }, /* S3 */ { -1, -1, -1, -1, -1 }, /* S5 */ { S5, S5, S5, S5, -1 }, };状态值-1代表当前字符不属于该状态的合法转移,触发-1说明词素结束,需要回退处理。写代码时有个常用的收敛手法:把-1统一导向一个“结束并回退”的出口,不要在每种状态里单独写emit逻辑。这样后面加注释状态、字符串状态,只是往表里加行加列,主循环基本不动。
从状态图到代码的映射还有一个容易漏掉的点:运算符本身要区分单字符和双字符,比如"<"和"<="是两个Token。你在表驱动方案里处理这个,要么在S3状态里多读一个字符做二次判断,要么在主循环结束后单独处理。我建议把双字符运算符的判断做成一个独立的lookahead函数,和状态表分离,否则状态数量会膨胀得很厉害。
提示:表驱动不是银弹。实验一用表驱动很顺手,但到了实验三语法分析阶段,表驱动的可读性会明显下降。你在资源里看到状态表也好、switch-case也好,关键是理解“状态—输入类别—转移”这个三元关系,而不是拘泥于某种写法。
3. 核心实现:Token识别与缓冲区的配合
3.1 Token结构与类型枚举
Token是词法阶段的输出单元,至少包含三样东西:类型、词素文本、行号。很多实验报告要求输出四元组,就是把行号、类型、词素、符号表ID一起打印。先看Token类:
public class Token { public TokenType type; public String lexeme; public int line; public int symbolId; // 符号表项下标,-1表示没有对应项 public Token(TokenType type, String lexeme, int line, int symbolId) { this.type = type; this.lexeme = lexeme; this.line = line; this.symbolId = symbolId; } @Override public String toString() { return line + " " + type + " " + lexeme + " " + symbolId; } }这里的symbolId留给符号表用,词法阶段对标识符和常量都要填上。toString格式尽量和实验要求对齐,有的班要求输出到文本文件交给实验二去读,格式中途不要随便改。我经常在验收前统一跑一遍样例,逐行比对输出文件,避免助教脚本判分时报格式不符。
3.2 缓冲区:整文件读入与双指针滑动
词法分析器的输入缓冲区有两派做法:一种是流式读字符,每次读一个,配合一个字符的推回;另一种是整文件读入内存,用两个指针在内存里滑动。我倾向后者,理由很简单:实验一手写状态机,整文件读入能减少I/O干扰,还方便排查越界。
import java.nio.file.Files; import java.nio.file.Paths; public class Lexer { private String source; private int pos = 0; // 当前扫描指针 private int start = 0; // 当前词素起始指针 private int line = 1; // 行号计数器 public Lexer(String path) throws Exception { source = new String(Files.readAllBytes(Paths.get(path))); } private char peek() { if (pos >= source.length()) return '\0'; return source.charAt(pos); } private char advance() { char c = peek(); if (c == '\n') line++; pos++; return c; } }peek是看一眼当前字符但不移动指针,advance是真正的消费。start标记当前词素的开头,一个词素结束时用source.substring(start, pos)取文本。要点是换行计数必须放在advance里,行号统计才不会漏。很多实现把行号处理散落在主循环外面,结果多行注释里一出现换行,行号就崩了。
注意:如果实验环境是Windows,读文件时用UTF-8无BOM编码。有BOM的话,文件开头会多一个不可见字符'\uFEFF',它会被当成非法字符,第一条Token就报错,所有后续行号集体偏移一位。
3.3 数字与字面量识别的边界控制
数字识别不止是“读到连续数字”。北邮实验里的数字通常包含整数、小数和科学计数法。指数部分的正负号、小数点后至少一位数字,都是边界条件。核心逻辑:
public Token scanNumber() { start = pos; boolean isFloat = false; while (Character.isDigit(peek())) { advance(); } if (peek() == '.') { isFloat = true; advance(); if (!Character.isDigit(peek())) { return emitError("小数点后缺少数字"); } while (Character.isDigit(peek())) { advance(); } } if (peek() == 'E' || peek() == 'e') { advance(); if (peek() == '+' || peek() == '-') { advance(); } if (!Character.isDigit(peek())) { return emitError("指数部分缺少数字"); } while (Character.isDigit(peek())) { advance(); } isFloat = true; } String text = source.substring(start, pos); if (isFloat) { return new Token(TokenType.FLOAT, text, line, -1); } return new Token(TokenType.INTEGER, text, insertConstant("int", text), -1); }三个坑集中在这里:第一,'.'后必须跟数字,形如"1."要报错;第二,指数符号后的正负号只能出现一次,且必须后跟数字;第三,如果实验要求整数是32位,你需要在拼出词素后检查取值范围,超出就报整数溢出。我一般把溢出检查抽成单独方法,不埋在这段识别代码里,否则数字解析和错误判定耦合在一起,Debug时很难定位。
字符串识别是另一个重头。双引号里出现换行通常非法,转义序列"要能跳过双引号而不结束字符串。基本结构是:
public Token scanString() { start = pos; advance(); // 跳过左引号 while (peek() != '"' && peek() != '\0') { if (peek() == '\\') { advance(); // 跳过反斜杠 } advance(); } if (peek() == '\0') { return emitError("字符串未闭合"); } advance(); // 跳过右引号 String text = source.substring(start, pos); return new Token(TokenType.STRING_CONST, text, line, -1); }这里的关键是转义处理:遇到反斜杠时多消费一个字符,不管它是什么。这样"不会触发循环结束条件,真正落单的"才会。
3.4 识别主循环的调度逻辑
有了scanNumber、scanString、scanIdentifier这些识别函数,主循环需要决定什么时候调用谁。这就是词法分析器的“调度中枢”:
public Token nextToken() { skipWhitespaceAndComments(); if (pos >= source.length()) { return new Token(TokenType.EOF, "EOF", line, -1); } char c = peek(); if (Character.isLetter(c)) { return scanIdentifier(); } if (Character.isDigit(c)) { return scanNumber(); } if (c == '"') { return scanString(); } // 运算符和界符走统一的scanOperator return scanOperator(); }skipWhitespaceAndComments把空格、Tab、换行和注释全部吃掉,让nextToken每次被调用时都停在真正需要识别的位置。这个分层调度值得学习:每个识别函数只管一个词法类别,互不干扰。资源里如果代码全堆在一个方法里,你就要警惕,那通常是后续实验扩展时最难改的写法。
4. 符号表与错误恢复:词法层对后续实验的影响
4.1 符号表的结构选择
词法分析阶段的符号表,主要存两类东西:用户标识符和常量。标识符又分变量名和函数名,常量分整数、浮点、字符串三类。实验报告一般会要求画出符号表结构,最省事的是按序数组加线性查找,但性能真的不行。我在这份资源的实现基础上更推荐哈希表:
import java.util.HashMap; import java.util.Map; public class SymbolTable { private Map<String, Symbol> table = new HashMap<>(); private int nextIndex = 0; public Symbol lookup(String name) { return table.get(name); } public int insert(String name, String kind, String attr) { if (table.containsKey(name)) { return table.get(name).index; } Symbol s = new Symbol(nextIndex, name, kind, attr); table.put(name, s); nextIndex++; return s.index; } }insert先查重,已存在就直接返回旧下标,不存在才分配新下标。这个细节直接决定同一变量出现五次,符号表里占几行。助教很可能拿这个卡你:重复出现的标识符,符号表里应该只有一项。kind字段填“变量”“常量”“函数”等类别,attr字段填类型或常数值。验收时把这些完整打出来,比只给一个ID更容易拿分。
4.2 常量表与字符串驻留
标识符和常量虽然都进符号表,但处理方式有区别。标识符靠名字判重,常量则要按值判重。同一个整数233出现三次,理论上只占一个表项。我在实现里会单独维护一个常量表:
public class ConstantTable { private Map<String, Integer> intTable = new HashMap<>(); private Map<String, Integer> floatTable = new HashMap<>(); private int nextIndex = 0; public int insertInt(String value) { return intTable.computeIfAbsent(value, v -> nextIndex++); } }用computeIfAbsent可以一行完成查重和插入。这里有个设计取舍:字符串常量要不要驻留?驻留的意思是内容相同的字符串只保存一份,返回同一个符号表下标。实验一阶段很多同学不做字符串驻留,导致同样一段字符串每次出现都占新表项。等实验二做语义分析时,字符串比较变成下标比较,你会感谢当初多写这几行。
4.3 错误恢复策略
词法错误主要有三类:非法字符、非法数字组合、字符串未闭合。错误处理的目标不是中断程序,而是记录错误后继续扫完整个文件,这样一次运行能暴露全部问题。常见策略是生成ERROR Token并跳过坏词素:
public List<String> scanErrors = new ArrayList<>(); public Token emitError(String message) { String bad = String.valueOf(source.charAt(pos)); scanErrors.add("Line " + line + ": lex error: " + message + " -> " + bad); pos++; return new Token(TokenType.ERROR, bad, line, -1); }注意这里跳过的是单个字符。遇到“123abc”,不能跳一个字符了事,需要把整个词素消费完再报错,否则abc会被当成标识符,错误提示变得莫名其妙。我习惯在scanNumber里检测到数字后跟字母时,直接把后续字母一起消费完,当作一个整体错误Token。错误信息列表单独存,别和Token流混在一起。到了实验二,错误Token会参与语法分析,但错误信息只打印到控制台或单独文件,避免污染Token流。
4.4 与实验二语法分析的接口约定
词法分析做完,输出的是Token流,但实验二的语法分析器需要的是结构化数据。把Token存成内存数组,实验二可以直接遍历;如果做成每次调用nextToken()的拉取模式,实验二就要同步调整。这份资源里的实现是前者:一次扫描全生成,Token列表挂在Lexer对象上。
输出到文件的话,我建议固定成这种格式:
行号 类型名 词素 符号表ID 1 KEYWORD int -1 1 IDENTIFIER a 0 2 INTEGER 233 1每行用空格分隔,末尾不要多余空格。助教脚本多是用文本比对,多余空格和Tab都是扣分点。我吃过一次亏:在Windows上写完文件,换行符是\r\n,拿到Linux验收环境一比对,每行都多出个\r,整个实验一判零分。从那以后我产出文件一律用UTF-8无BOM加LF换行,输出前强制检查一遍格式,不在细节上赌运气。
5. 词法分析器避坑指南:五处高频翻车现场
5.1 关键字被拆成两半
现象:输入“intValue=3”,输出里同时出现了“int”关键字和“Value”标识符,原本应该是一个完整的标识符被拆碎了。
原因:识别循环里每读一个字符就检查累积串是否为关键字,一旦匹配立即输出并重置状态。这是把“查表”错误地安排进了“识别”过程,导致完整词素被拦腰截断。
解决:记住识别和查表的边界。先完整读出一个词素,再交给KeywordTable判断。词素“intValue”在完整形式下查不到关键字,自然就是标识符。我改这个bug花了整整一个下午,最后发现只是把isKeyword调用从循环里挪到了循环外。
5.2 多行注释吞掉行号
现象:注释跨三行之后,后续所有报错行号比实际少了三行,调试时对着源码怎么都对不上。
原因:处理“/”和“”进入注释状态后,直接用下标循环读字符直到“/”,绕过了advance方法里的换行计数逻辑。
解决:把换行计数统一收敛到advance这一个方法里,所有字符消费都必须走它。注释状态遇到\n也要调用advance,而不是用source.charAt直接越位。这样行号统计只有一个入口,天然不可能不一致。
5.3 数字后跟字母的碎片报错
现象:输入“123abc”,报错只说“非法字符1”,然后把abc当作标识符继续处理,错误信息完全看不出问题出在哪。
原因:scanNumber遇到数字后的字母时,只把当前数字跳掉,没有消费整个词素的剩余部分。
解决:在数字识别退出逻辑里检测字母,如果数字结束后紧跟字母,把其余字母一并消费完,生成一个完整词素粒度的错误Token。错误信息写成“非法数字词素 123abc”,比碎成三条错误可读性强太多,排查时一眼就能定位。
5.4 字符串转义提前终止
现象:输入“say "hi"”,字符串在反斜杠后面的那个双引号处就结束了,后面的内容全乱套。
原因:字符串扫描循环里只判断到“”就收尾,没有处理反斜杠转义的情况,转义的引号被误认成字符串结束符。
解决:在字符串循环里加一个判断:读到反斜杠时跳过下一个字符,不管下一个是什么。这样"不会触发结束条件,真正落单的"才会。输入里出现裸换行也要单独报错,不能让它静默截断字符串。
5.5 文件末尾最后一个Token丢失
现象:样例最后一行是“return 0;”,跑完输出里找不到最后的界符“;”,甚至最后一个常量也经常消失。
原因:主循环以pos >= source.length()为终止条件,循环退出前最后一次词素结束动作没有执行,残余字符被丢弃。
解决:主循环后单独处理EOF状态,在读到'\0'时强制走一遍正常的词素结束流程,而不是直接break。还有一个连带习惯:每次写完都用一个末尾没有换行的文件测试,专门验证文件最后一个字符的处理。
6. 验证与进阶:用一组用例把分析器钉死
词法分析器写完,靠“感觉能跑”验收肯定不够。我平时把测试用例分成三类:正向样例、边界样例、错误样例,各自放单独目录。正向样例是一段正常的类C代码,边界样例是空文件、全是空白、只有注释、超长标识符、大整数、科学计数法的各种变体,错误样例就挑上面五处翻车对应的输入。
| 测试目录 | 样例内容 | 预期结果 |
|---|---|---|
| positive | int a=1; return a+2; | 无错误Token,Token流完整 |
| boundary | 空文件 / 只有注释 | 只输出EOF,行号正确 |
| error | 123abc / 未闭合字符串 | errors数量大于0,不崩溃 |
批量跑测试可以用下面这个主类:
public class TestRunner { public static void main(String[] args) throws Exception { String[] dirs = {"positive", "boundary", "error"}; for (String d : dirs) { File folder = new File(d); if (!folder.exists()) continue; for (File f : folder.listFiles()) { Lexer lexer = new Lexer(f.getPath()); List<Token> tokens = lexer.scan(); System.out.printf("%s: %d tokens, %d errors%n", f.getName(), tokens.size(), lexer.scanErrors.size()); } } } }重点看errors数量而不只是Token数量:错误样例里errors必须大于0,正向样例里errors必须等于0,边界样例里不允许出现数组越界和空指针。三组都过了,实验一验收基本稳了。进阶玩法是把Token流接到一个简单的递归下降语法分析器上,哪怕只解析赋值语句和if语句,也能反过来验证词法输出是否完整。
我最后一次帮学弟复盘实验一,最大的教训是:词法分析器八成的bug藏在边界字符上,多一个'\0'、少一个'\n',结果天差地别。从那以后我每次写完,都强制把所有样例完整跑一遍再提实验报告,不跑测试就敢提交的事再也没干过。希望这份词法分析器资源,加上你对照文章做的排查,能在实验一上帮你少熬两个夜。
本文还有配套的精品资源,点击获取