简介:本资源为南京邮电大学编译原理实验一的词法分析器构造资料,面向计算机科学与技术等专业修读编译原理课程的学生,以及需要完成词法分析实验、理解单词识别与编码原理的学习者。压缩包内共1个doc文档,约7.69MB,内容为完整实验报告,涵盖实验目的、环境配置、设计概要、状态转换图、实现分析与代码解析等模块。报告以C++语言子集为分析对象,给出关键字、运算符、界限符、整型常数与标识符的正规文法定义,并说明单词类别编码规则,同时提供关键字检测、界限符与运算符识别、字母数字判断及保留字表查询等核心函数的实现思路。读者可借助该报告理解词法分析器从读取源文件、切分token到返回类别编码的完整流程,对照代码梳理状态转换逻辑,为后续语法分析实验打下基础。目前已有196人学习,适合需要参考实验报告结构、排错思路与编码实现细节的同学使用。
1. 词法分析器到底在做什么:从南邮实验一的输入输出说起
如果你手头正好有一份南京邮电大学编译原理实验一的题目,大概率第一反应是“词法分析不就是把字符流切成单词吗”,然后打开编辑器准备写一个巨大的 switch-case。但真正动手跑一遍测试用例,你会发现事情没那么简单:题目给的源程序里可能有//注释、可能有/* */跨行注释、可能有12.5e-3这种带指数的浮点数、可能有>=和>这种需要最长匹配的运算符。这些边界情况才是实验一真正要练的东西。
这份资源就是围绕南邮编译原理实验一的词法分析任务整理的,核心目标是把一段类 C 或类 Pascal 的源程序,逐字符扫描后输出<种别码, 属性值>形式的 token 序列。它适合正在上编译原理课、需要交实验报告但不想从零造轮子的同学,也适合已经工作但想回头补一补编译器前端基础的开发者。关键词就一个:编译原理实验里的词法分析,把 DFA 从纸面搬到代码里。
2. 从正则到 DFA:词法分析的状态机怎么设计
2.1 先分清三类 token 的处理策略
词法分析的本质是把正则表达式翻译成有限自动机,再让自动机逐字符跑。但实验一里通常不会让你真的去写一个正则引擎,而是手动构造状态转移。我一般会把所有 token 分成三类来处理:
第一类是关键字和标识符。关键字是标识符的子集,所以先按标识符的规则读入一整串字母数字下划线,再去查关键字表。如果命中关键字表就返回关键字种别码,否则返回标识符种别码。这样做的好处是不用为每个关键字单独写一条状态路径。
第二类是常量和数字。整数、浮点数、科学计数法浮点数要分开处理。整数就是纯数字串,浮点数需要处理小数点,科学计数法还要处理e或E后面的正负号和指数部分。这里最容易翻车的是12.这种只有小数点没有小数部分的写法,以及12.5e这种指数部分缺失的写法,必须做合法性校验。
第三类是运算符和界符。这里的关键是最长匹配:读到>不能立刻返回,要看下一个字符是不是=;读到=也要看下一个字符是不是=。常见做法是用一个 peek 函数预读下一个字符,匹配成功后再消费掉。
2.2 状态转移表怎么写才不容易乱
手动写状态机最怕的就是状态爆炸。我的经验是先把所有 token 的正则写出来,然后合并成一张状态转移表。以南邮实验一常见的类 C 语言子集为例,状态表大概长这样:
| 当前状态 | 输入字符 | 下一状态 | 动作 |
|---|---|---|---|
| S0 | 字母/下划线 | S1 | 开始读标识符 |
| S0 | 数字 | S2 | 开始读数字 |
| S0 | 空白 | S0 | 跳过 |
| S0 | / | S3 | 可能是注释或除号 |
| S1 | 字母/数字/下划线 | S1 | 继续读 |
| S1 | 其他 | S4 | 查关键字表,返回 |
| S2 | 数字 | S2 | 继续读 |
| S2 | . | S5 | 进入小数部分 |
| S2 | e/E | S6 | 进入指数部分 |
| S3 | / | S7 | 行注释 |
| S3 | * | S8 | 块注释 |
| S3 | 其他 | S9 | 返回除号 |
这张表的好处是每个状态只关心“遇到什么字符往哪走”,不用在代码里写一堆嵌套 if。实际写代码时可以用一个二维数组或者字典来存,但实验一规模不大,直接写 switch-case 也够用。
2.3 用 Python 实现一个可跑通的扫描器骨架
下面这段代码是我自己写实验一时常用的骨架,核心逻辑是get_token函数,每次调用返回一个 token 或遇到文件结束。代码里保留了状态机的痕迹,方便对照上面的状态表。
# 词法分析器核心骨架 # 输入:源程序字符串 # 输出:token 列表,每个 token 是 (种别码, 属性值) 元组 KEYWORDS = { 'int': 1, 'float': 2, 'if': 3, 'else': 4, 'while': 5, 'return': 6, 'void': 7 } # 种别码约定:关键字 1-7,标识符 10,整数 11,浮点数 12, # 运算符 20-39,界符 40-49,注释和空白不返回 class Lexer: def __init__(self, source): self.src = source self.pos = 0 self.line = 1 def peek(self, offset=0): # 预读字符,不消费 idx = self.pos + offset if idx < len(self.src): return self.src[idx] return '\0' def advance(self): # 消费一个字符 ch = self.src[self.pos] self.pos += 1 if ch == '\n': self.line += 1 return ch def skip_whitespace_and_comments(self): # 跳过空白、行注释、块注释 while self.pos < len(self.src): ch = self.peek() if ch in ' \t\r\n': self.advance() elif ch == '/' and self.peek(1) == '/': # 行注释:跳到行尾 while self.pos < len(self.src) and self.peek() != '\n': self.advance() elif ch == '/' and self.peek(1) == '*': # 块注释:跳到 */ self.advance() # 消费 / self.advance() # 消费 * while self.pos < len(self.src): if self.peek() == '*' and self.peek(1) == '/': self.advance() self.advance() break self.advance() else: break def get_token(self): self.skip_whitespace_and_comments() if self.pos >= len(self.src): return None # 文件结束 ch = self.peek() # 标识符或关键字 if ch.isalpha() or ch == '_': buf = [] while self.peek().isalnum() or self.peek() == '_': buf.append(self.advance()) word = ''.join(buf) if word in KEYWORDS: return (KEYWORDS[word], word) return (10, word) # 数字:整数、浮点数、科学计数法 if ch.isdigit(): buf = [] while self.peek().isdigit(): buf.append(self.advance()) # 检查小数点 if self.peek() == '.' and self.peek(1).isdigit(): buf.append(self.advance()) # 消费 . while self.peek().isdigit(): buf.append(self.advance()) # 检查指数部分 if self.peek() in ('e', 'E'): buf.append(self.advance()) if self.peek() in ('+', '-'): buf.append(self.advance()) if not self.peek().isdigit(): raise SyntaxError(f'第 {self.line} 行:指数部分缺少数字') while self.peek().isdigit(): buf.append(self.advance()) return (12, ''.join(buf)) return (11, ''.join(buf)) # 运算符和界符:最长匹配 two_char_ops = {'>=', '<=', '==', '!=', '&&', '||'} pair = ch + self.peek(1) if pair in two_char_ops: self.advance() self.advance() return (20 + list(two_char_ops).index(pair), pair) single_ops = {'+': 20, '-': 21, '*': 22, '/': 23, '>': 24, '<': 25, '=': 26, '!': 27} if ch in single_ops: self.advance() return (single_ops[ch], ch) delimiters = {'(': 40, ')': 41, '{': 42, '}': 43, ';': 44, ',': 45} if ch in delimiters: self.advance() return (delimiters[ch], ch) raise SyntaxError(f'第 {self.line} 行:无法识别的字符 {ch}')这段代码里几个关键点值得展开说。peek和advance分离是词法分析器的标准做法,peek只看不消费,advance消费并推进位置。skip_whitespace_and_comments把空白和注释统一处理掉,这样get_token只需要关心真正的 token。数字处理里先读整数部分,再判断小数点,再判断指数,每一步都做了合法性检查。运算符部分用了一个two_char_ops集合来做最长匹配,先拼出两个字符看看是不是双字符运算符,不是再退回单字符。
参数方面,种别码的分配没有强制标准,但建议按类别分段:关键字 1-9,标识符 10,常量 11-19,运算符 20-39,界符 40-49。这样在语法分析阶段做递归下降时,判断 token 类型会方便很多。行号追踪在报错时非常有用,实验报告里如果能输出“第几行第几个字符出错”,老师一般会给加分。
3. 把代码跑起来:输入输出格式与测试用例设计
3.1 输入文件的组织方式
南邮实验一通常会给一个test.c或test.pas作为输入,要求输出 token 序列到文件或控制台。我一般会写一个main函数把整个流程串起来:
# 主程序:读文件、跑词法分析、输出结果 import sys def main(): if len(sys.argv) < 2: print('用法: python lexer.py <源文件>') return with open(sys.argv[1], 'r', encoding='utf-8') as f: source = f.read() lexer = Lexer(source) tokens = [] while True: tok = lexer.get_token() if tok is None: break tokens.append(tok) # 输出格式:每行一个 token for code, value in tokens: print(f'<{code}, {value}>') # 同时输出统计信息,方便写实验报告 print(f'\n共识别 {len(tokens)} 个 token', file=sys.stderr) if __name__ == '__main__': main()运行方式就是python lexer.py test.c。输出格式<种别码, 属性值>是实验一最常见的格式要求,有些老师会要求输出到output.txt,改一下print的目标就行。统计信息输出到stderr是为了不干扰标准输出的 token 序列,方便用重定向做自动化对比。
3.2 测试用例要覆盖哪些边界
实验一最容易丢分的地方不是主流程,而是边界用例。我建议至少准备下面这几组测试:
第一组是关键字和标识符的区分。比如int intx = 1;,int是关键字,intx是标识符,不能因为前缀相同就误判。第二组是数字的边界,包括0、123、12.5、12.、.5、1e10、1.5e-3、1e。其中12.和.5在类 C 语言里通常不合法,你的分析器应该报错或者按最长匹配原则拆成12和.。第三组是注释,包括// 行注释、/* 块注释 */、/* 跨行\n注释 */、以及未闭合的/*。第四组是运算符的最长匹配,a >= b不能拆成>和=,a == b不能拆成两个=。
提示:测试用例不要只写正确的,故意写几个错误的看看报错信息是否包含行号,实验报告里放一张报错截图比放十张正确输出更有说服力。
3.3 输出结果怎么验证
验证 token 序列是否正确,最笨也最可靠的办法是手工推一遍。拿一个十行以内的小程序,自己逐字符走一遍状态机,把期望的 token 序列写下来,再和分析器的输出逐行对比。如果数量对不上,先看是不是注释或空白被多算了;如果某个 token 的属性值不对,看是不是最长匹配没做对。
另一个办法是写一个简单的单元测试,把几个典型输入和期望输出写成断言:
# 单元测试示例 def test_lexer(): src = 'int a = 10;' lexer = Lexer(src) tokens = [] while True: tok = lexer.get_token() if tok is None: break tokens.append(tok) expected = [(1, 'int'), (10, 'a'), (26, '='), (11, '10'), (44, ';')] assert tokens == expected, f'期望 {expected},实际 {tokens}' print('测试通过') test_lexer()这种测试跑起来很快,改代码之后跑一遍能立刻发现回归问题。实验报告里如果附上单元测试的代码和通过截图,说明你不仅实现了功能,还考虑了可验证性,这是加分项。
4. 避坑与排查:词法分析器最容易翻车的五个地方
4.1 现象:>=被拆成>和=;原因:没有做最长匹配;解决:预读下一个字符
这是最经典的翻车现场。代码里读到>就直接返回了,根本没看后面跟着什么。解决方法是读到单字符运算符后,用peek(1)看一下下一个字符,如果能组成双字符运算符就一起消费掉。注意peek不能越界,文件末尾要返回\0而不是抛异常。
4.2 现象:12.5e-3被识别成12.5、e、-、3;原因:指数部分没有纳入数字状态;解决:在浮点数状态里增加对e/E的判断
数字的识别不能只处理小数点,科学计数法是实验一常见的考点。正确做法是在读完小数部分后,检查当前字符是不是e或E,如果是就继续读指数部分,指数部分可以带正负号,但后面必须跟至少一位数字。如果e后面没有数字,应该报错而不是默默返回。
4.3 现象:块注释/* ... */跨行时行号统计错误;原因:advance里没有更新行号;解决:在消费字符的统一入口里维护行号
行号统计看起来是小事,但报错信息里行号不对,调试成本会翻倍。我的做法是在advance函数里统一判断,只要消费的字符是\n就把line加一。这样不管是在读标识符、读注释还是读字符串,行号都是准的。注意\r\n的情况,Windows 换行符里\r不增加行号,\n才增加。
4.4 现象:未闭合的/*导致死循环;原因:块注释循环没有退出条件;解决:在循环里检查pos是否越界
块注释的结束条件是遇到*/,但如果源文件里根本没有*/,循环就会一直跑到文件末尾然后越界。正确写法是在 while 循环里同时检查self.pos < len(self.src),循环结束后如果没找到*/,应该报一个“块注释未闭合”的错误,并给出行号。
4.5 现象:关键字表用列表导致大小写敏感问题;原因:没有统一大小写策略;解决:明确语言是否大小写敏感,关键字表用字典
类 C 语言是大小写敏感的,Int和int不是同一个东西。但有些实验题目用的是类 Pascal 语言,大小写不敏感。这个必须在动手前确认清楚。如果大小写不敏感,读入标识符后统一转小写再查关键字表;如果敏感,就保持原样。用字典而不是列表来存关键字,查询是 O(1),代码也更清晰。
5. 进阶技巧:把词法分析器接上语法分析做递归下降
实验一做完之后,很多人会把代码扔在一边,等实验二语法分析时再重新写。其实词法分析器的输出直接可以喂给递归下降分析器,只要 token 接口设计得干净。我一般会在 Lexer 类上加一个peek_token方法,返回下一个 token 但不消费,这样语法分析器在做选择时可以预读一个 token。
# 在 Lexer 类里增加预读 token 的能力 def peek_token(self): saved_pos = self.pos saved_line = self.line tok = self.get_token() self.pos = saved_pos self.line = saved_line return tok这个peek_token的实现方式是保存当前位置,跑一次get_token,再恢复位置。虽然效率不高,但实验规模下完全够用,而且逻辑简单不容易出错。有了这个接口,递归下降里的if self.lexer.peek_token() == (44, ';')这种判断就很好写。
另一个进阶方向是把 token 序列输出成 JSON 格式,方便用脚本做批量对比。比如输出{"line": 1, "tokens": [{"code": 1, "value": "int"}, ...]},然后用 Python 的json.load读回来做断言。这样实验报告里的测试部分可以自动化生成,不用手动截图。
还有一个容易被忽略的点是错误恢复。真正的编译器不会遇到一个非法字符就退出,而是会跳过这个字符继续分析,尽量多报几个错误。实验一里可以简单实现:遇到无法识别的字符时,记录错误信息,然后advance跳过这个字符,继续下一个 token。这样一次运行就能看到所有错误,不用改一个跑一次。
注意:错误恢复要小心不要陷入死循环,每次恢复必须至少消费一个字符。
从那以后我每次写词法分析器,都会先把状态转移表画在纸上,再写代码,最后用边界用例跑一遍。这个习惯帮我省了很多调试时间,也让我在实验报告里能写清楚每个状态的设计理由。希望帮到你。
本文还有配套的精品资源,点击获取