简介:湖南大学编译原理课程实验一资料包,面向正在修读该课程、需要完成 DFA 相关实验的本科生,可作为实验报告与代码撰写的参考。内容涵盖 DFA 状态图文件、C++ 源码、可执行程序及实验报告文档,共 7 个文件,压缩包大小 764KB,便于快速下载解压。其中 .dfa 文件用于描述自动机状态转换,.cpp 与 .exe 可配合查看、运行验证实验逻辑,docx 报告则提供了完整的实验思路记录。已有 708 人学习使用,资源在课程中得分较高,说明其具备实际参考价值。建议结合教材与课堂内容动手改进,避免照搬;同时可配合陈果老师的讲解加深理解,帮助实验与课程学习更顺利。 说实话,每年编译原理课程一开,最先被问爆的就是“实验一到底要做什么”。如果你拿到的是“湖南大学 编译原理实验一.zip”这个压缩包,大概率是一个从零实现词法分析器的任务。这个实验是所有编译原理实验的地基,它解决的是“怎么把源代码字符串变成编译器能理解的最小单元”这个问题。适合所有正在修编译原理、或者想搞懂前端工具链(比如Babel、ESLint)底层原理的人参考。
我当年做这个实验的时候,最大的感受是:书上的正则表达式和DFA都看得懂,一上手写C语言就不知道从哪开始。这篇博文就按我自己的实操经历,把实验一的完整思路、核心代码细节、以及我踩过的坑全部拆开讲。
1. 实验一到底在做什么:定位与整体设计思路
1.1 为什么编译原理的第一个实验总是词法分析
编译器处理源码的第一步,绝对不是直接去解析语法树,而是先把一串字符“切”成有意义的单词。这个过程就是词法分析,它做的事情本质上和人类读英文句子时先把句子拆成单词是一个道理。只不过编译器拆出来的单词叫“Token”,每个Token带上了类别信息,比如“这是一个标识符”“这是一个数字常量”“这是一个运算符”。
实验一让你实现词法分析器,核心目的有三个。第一,让你真正理解源代码是怎么从“char数组”变成“Token流”的,这是后续语法分析、语义分析的数据基础。第二,让你亲手实践正则表达式和有限自动机这些理论,把课本上的DFA最小化、NFA转DFA变成可以跑的代码。第三,让你体会错误处理的形式——遇到非法字符时怎么办,是报错终止,还是跳过继续,这会直接影响后面实验的联调体验。
我当时拿到的实验要求(不同年份可能有微调)大致是:输入一段源代码,输出每个Token的种别码、单词值和所在行号。代码要求用C/C++实现,不能直接调用lex这类工具,得手写扫描器。
1.2 实验需求的逐条拆解:别急着写代码
很多人拿到实验题第一反应就是开IDE敲代码,结果写到一半发现逻辑混乱。我的建议是先花半小时把需求拆成几个明确的功能点。一个典型的词法分析实验通常包含以下需求:
- 识别关键字(如if、else、while、return、int、float等)
- 识别标识符(变量名、函数名)
- 识别无符号整数(可能包含十进制、八进制、十六进制)
- 识别运算符(+、-、*、/、=、==、!=、<、<=、>、>=等)
- 识别界符(;、,、()、{}、[])
- 能够跳过空白符和注释(单行注释、块注释)
- 出现非法字符时给出错误提示
把需求拆成这样之后,你会发现核心难点集中在两部分:一是“如何确定一个单词的边界”(什么时候算读完一个标识符),二是“多字符运算符怎么处理”(比如=和==,<和<=)。这两点直接决定了你的扫描器健壮性。
还有一个容易被忽略的点:实验一通常要求输出种别码。种别码不是随便定的,一般实验文档里会给你一张“编码表”,比如1代表标识符、2代表整数常量、3代表关键字、4代表运算符。如果你自己定义编码,也要保证和实验要求一致,否则验收的时候对不上分。
2. 核心数据结构与算法选择:从理论到代码的桥
2.1 手写扫描器 vs 自动生成器:为什么实验要求手写
你可能听说过lex/flex这类工具,它们可以根据正则表达式自动生成词法分析器。实验不让你用,不是因为老师老古董,而是因为手写才能让你真正理解扫描器的执行过程。自动生成器封装了太多细节——正则怎么编译成DFA、状态怎么转移、冲突怎么消解,你一键生成根本看不到。
手写扫描器有两种主流实现方式:一种是完全模拟DFA的“状态转移表驱动”,一种是根据字符类型直接写“分支逻辑”。我在实验里用的是“分支逻辑 + 一个核心扫描循环”,因为对实验规模的词法规则来说,状态表反而显得笨重,而直接写分支逻辑更直观、更好调试,也更容易展示你理解了词法规则。
2.2 核心数据结构:Token结构、关键字表、符号表
无论用哪种写法,有几个数据结构是绕不开的。我实际用的结构长这样:
typedef struct { int type; // 种别码 char value[128]; // 单词原文 int line; // 所在行号 } Token;Token是最基本的输出单元。type用整数,对应实验文档里的种别码;value存单词本身,方便后续语法分析阶段直接取用;line是行号,这个非常重要,因为后面的语法分析报错需要“第几行出错”,没有行号,定位Bug会非常痛苦。
关键字表我用的最简单的方式——字符串数组加二分查找:
const char* keywords[] = {"if", "else", "while", "return", "int", "float", ...}; int isKeyword(char* str) { int low = 0, high = sizeof(keywords)/sizeof(char*) - 1; while (low <= high) { int mid = (low + high) / 2; int cmp = strcmp(str, keywords[mid]); if (cmp == 0) return mid + 1; // 返回关键字种别码 else if (cmp < 0) high = mid - 1; else low = mid + 1; } return -1; // 不是关键字 }这里有个小细节:关键字表必须按字典序排列,二分查找才有意义。我第一次写的时候随手排了个数组,结果一直报错,排查了半天才发现是表没排序。另外,用二分查找而不是线性查找,虽然实验规模下差别不大,但这是一个习惯问题,体现你对算法效率的敏感度。
符号表在实验一里可以做得轻量一点。它的核心作用是存“标识符的名称 — 某些属性”的映射。实验一阶段,符号表只需要保存名字和类别,做去重用(同一变量名多次出现时,种别码应该是同一个)。我用了一个简单的链表:
typedef struct SymbolEntry { char name[128]; int tokenType; struct SymbolEntry* next; } SymbolEntry; SymbolEntry* symbolTable = NULL;每次遇到底层标识符,先查符号表,如果存在就沿用之前的tokenType,不存在就插入新条目。这样做的好处是:如果后面实验要求做变量作用域和类型检查,符号表可以直接扩展,不用推倒重来。
2.3 扫描主循环的设计思路:一个字符一个字符地“吃掉”
手写扫描器的主循环没有玄学,核心思路就一句话:每次从输入缓冲区读一个字符,根据当前状态决定下一步动作。我习惯写成一个“前看一个字符”的扫描器,也就是维护一个当前字符和一个“预读字符”,因为很多词法规则(比如=和==)依赖“看完当前字符后再看一眼下一个字符”。
用伪代码表示主循环大概是:
char ch = getNextChar(); while (ch != EOF) { if (isspace(ch)) { ch = getNextChar(); continue; } if (isalpha(ch) || ch == '_') { // 识别标识符或关键字 } else if (isdigit(ch)) { // 识别数字常量 } else if (ch == '/') { // 可能是注释,也可能是除法运算符 } else if (isOperatorChar(ch)) { // 识别运算符 } else { // 报错:非法字符 } }这个循环看起来简单,但每个分支内部都有陷阱。比如标识符分支,你要一直读到第一个非字母数字字符才停,而且这个“多读进去”的字符得放回去,留给下一轮循环用。这就需要你维护一个“回退指针”,或者用ungetc,或者自己写一个带缓存指针的读取器。我在实际试验中发现,用ungetc最方便,但要注意有些编译器对ungetc多次回退的支持不太一样,所以自己写一个缓冲区指针更可控。
另外,很多教程会强调“最长匹配”原则:比如输入a1b,你不能读到a1就停,必须继续读,直到遇到既不是字母也不是数字的字符才算完。同理,遇到<时,你要先看下一个是不是=,如果是就组成<=,否则<单独成Token。这个“先预读、再决定、必要时回退”的逻辑,是这个实验最重要的代码技巧。
3. 实操过程与核心代码实现:一步一步跑起来
3.1 输入缓冲区的选择:整读 vs 逐行读
实验一处理的源代码规模一般不大,我建议直接把整个文件读入内存,然后维护一个全局的“当前读取位置”指针。这样比反复调用fgetc快,而且方便实现“回退一个字符”的操作。
我的读取器实现大概是这样:
char* sourceCode; // 整个源文件的内存拷贝 int pos = 0; // 当前扫描位置 int line = 1; // 当前行号 char getNextChar() { char c = sourceCode[pos++]; if (c == '\n') line++; return c; } void unreadChar() { pos--; if (sourceCode[pos] == '\n') line--; }这个unreadChar函数就是“把多读的字符放回去”的关键实现。原理很简单:将pos减一,同时修正line计数。这里我踩过一个坑:如果回退的字符正好是换行符,行号会重复计算,导致后面的报错行号偏大。所以unreadChar里必须同步修正line,不能只改pos。
读取整个文件也需要注意编码问题。实验代码一般是纯ASCII或UTF-8无BOM,直接按字节读就行,但如果你在Windows上用fopen,记得用"rb"模式打开,避免\r\n被自动转换导致行号错乱。这个细节我是在对比输出结果时发现的,Windows下文本模式会把\r\n转成\n,而Linux下不会,导致同一个测试文件在两个平台的输出结果,行号相差很大。
3.2 标识符、关键字、数字:三个最核心的分支
标识符的识别分支是这样写的(注意边界条件和回退逻辑):
if (isalpha(ch) || ch == '_') { char buf[128]; int len = 0; buf[len++] = ch; ch = getNextChar(); while (isalnum(ch) || ch == '_') { buf[len++] = ch; ch = getNextChar(); } buf[len] = '\0'; // 多读进去的字符要放回去 if (ch != EOF) unreadChar(); int kwType = isKeyword(buf); if (kwType != -1) { token.type = kwType; // 关键字种别码 } else { insertSymbol(buf, IDENTIFIER_TYPE); token.type = IDENTIFIER_TYPE; } strcpy(token.value, buf); token.line = line; }这里面最值得注意的一点是:while循环结束退出的时候,当前ch一定是“不属于标识符字符集合的字符”,所以必须unreadChar。但有一种情况是ch为EOF,这时不能回退,否则会陷入死循环。这个EOF判断是个经典的隐藏Bug来源,我见过很多同学卡在这里。
数字的分支要区分整数、小数和科学计数法(如果实验要求支持的话)。我的整数识别逻辑是:
if (isdigit(ch)) { char buf[64]; int len = 0; // 处理十进制整数,也可以加0x前缀判断 while (isdigit(ch)) { buf[len++] = ch; ch = getNextChar(); } buf[len] = '\0'; if (ch == '.') { // 处理浮点数 buf[len++] = ch; ch = getNextChar(); while (isdigit(ch)) { buf[len++] = ch; ch = getNextChar(); } } if (ch != EOF) unreadChar(); token.type = INTEGER_TYPE; // 或 REAL_TYPE strcpy(token.value, buf); token.line = line; }这里如果你想要支持浮点数,需要在整数结束后判断下一个是否为小数点,但要注意“1..2”这种情况,虽然C语言语法里它不合法,但词法分析阶段一般不会管语法错误,遇到“1.”就接受为一个浮点数Token,然后在语法分析阶段再报错。这是“词法阶段只负责按词法规则切分,不负责语法判断”的典型例子。
3.3 运算符和界符的处理:最容易出细节错的区域
运算符分支里最大的陷阱就是多字符运算符。我的实现方式是先读第一个字符,然后预读第二个,组成一个两字符的字符串去匹配:
if (ch == '=' || ch == '!' || ch == '<' || ch == '>' || ch == '+' || ch == '-' || ch == '*') { char twoChar[3]; twoChar[0] = ch; char next = getNextChar(); if (next == '=') { twoChar[1] = '='; twoChar[2] = '\0'; // 匹配 == != <= >= token.type = TWO_OP_TYPE; } else { // 单个运算符 if (next != EOF) unreadChar(); token.type = ONE_OP_TYPE; } strcpy(token.value, twoChar); }注意我用的是“先尝试匹配双字符运算符,匹配不上再回退”的策略,这是实现最长匹配的通用做法。遇到+的时候还要当心“++”,遇到“-”要当心“--”,这些在C语言语法里都是独立的运算符,如果你实验要求支持它们,需要把this逻辑扩展。
注释处理也是容易忽略的。遇到'/'时,要连续往后看两个字符,如果是'//',就一直读到行尾;如果是'/',就一直读到'/'为止。这里有个大坑:如果读到文件结尾都还没找到'*/',应该报错“未闭合的块注释”。我见过有同学在块注释没闭合的情况下直接退出,导致后续所有代码都不识别,这种错误在验收时特别容易被测试用例抓住。注释在词法分析阶段应该是直接跳过,不产生Token,但它需要消耗输入字符并可能增加行号,所以必须在主循环里特别处理。
3.4 错误处理与恢复:实验一的加分项
实验要求一般只提“报错”,但怎么报、报完之后怎么办,体现的是你对实际问题考量的完整性。我采用的策略是:遇到非法字符(比如@、#、$这类在C语言词法规则中完全无意义的字符),打印一条错误信息(包括行号和非法字符本身),然后跳过这个字符继续扫描。
这种“跳过继续”的策略好处非常明显:一次编译可以输出所有的词法错误,而不是报一个错就停。如果每次报错就终止,你调试一个稍微复杂点的测试用例时,需要反复编译几十次才把那几个错误找全。但对于错误数量,我限制了一下,最多报10个错误后就终止,防止死循环或者刷屏过多,这个阈值是个可调参数。
4. 真实踩坑记录与调试心得
4.1 最长匹配:一个让我多花了三个小时的Bug
我在写标识符识别时,一开始没注意“读完一个标识符后必须回退”这件事。结果输入source时,第一次循环识别出sou,然后后面的rce会被当作新的标识符。输出结果里全是这种半截单词,我当时第一反应是“我的字符串读取函数有问题”,后来单步调试才发现:问题不在读取,在于循环结束前那个字符没有放回去。
这个教训很深刻:词法扫描器的每一个分支结束时,都必须有一个明确的“当前字符状态”约定,要么你已经消费了它,要么你把它放回去了。写代码之前先用一段非常简单的输入(比如abc def 123)在纸上推演一遍每个字符的归宿,能省下大量调试时间。
4.2 符号表的引入时机:有人过度设计,有人完全没有
有些同学会在实验一里就把符号表做成带作用域嵌套的复杂结构,其实不需要。实验一的核心是“识别Token”,符号表在这里的用途只是把标识符去重,并且为了后续实验做铺垫。过度设计会让你花大量时间写符号表管理代码,反而忽略词法分析本身。
反过来,完全不建符号表也有问题。如果你直接每次都把标识符当作新Token输出,后面的实验(尤其是语义分析)需要查符号表记录类型时,就得回头改词法分析器。我在实验一里只做了一个最简单的插入查找链表,大约50行代码,但到了实验三做类型检查时,在这个基础上改成哈希表、加上作用域栈,非常顺手。建议你在一开始就保留一个符号表模块,哪怕功能很简陋,也别省。
4.3 测试用例设计:别只测老师给的样例
实验课一般会提供一个或几个示例输入,但如果你只测示例,验收的时候大概率会被隐藏测试用例打懵。我的做法是构造了几类边界测试:
- 空文件和只有注释的文件,验证程序不会崩溃
- 连续多个运算符(例如a+++b),是应该输出a、++、+、b,还是其他组合,这取决于你的运算符定义
- 数字和标识符贴在一起(例如2a),合法情况是什么,非法时应该报错,而不是扫描出2和a
- 超长标识符(比如超过你的缓冲区128字节),会不会导致缓冲区溢出
- 注释中包含关键字和运算符,确保注释全部被跳过
尤其是超长标识符和缓冲区溢出这个问题,很多固定长度的char buf[128]实现都会踩坑。我的做法是:当标识符长度超过127时,报错“标识符过长”,然后继续读但不再写入缓冲区。这样至少不会段错误。
还有一个调试大杀器:写一个输出“对照文本文件”的功能。把每一次扫描的结果(Token的种别码、值、行号)输出到一个文件,然后和正确结果用diff命令对比。这样测试几十个用例时,不用肉眼盯着屏幕一个个看。我在做实验时写了一个简单的shell脚本自动跑所有测试用例并diff结果,节省了大量时间。
5. 常见问题速查与优化方向
5.1 常见问题速查表
为了方便你自查,我把这个实验里最常见的坑整理成了一张表:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 标识符被切成两截 | 读完单词后没有回退多余字符 | 在循环结束后unreadChar,注意EOF判断 |
| 返回的行号总差1或偏大 | 换行符被回退时没有修正line计数 | unreadChar中同步处理\n |
| 出现了“半个运算符” | 多字符运算符匹配时提前返回 | 先预读第二字符,匹配不上的回退 |
| 块注释永远跳不出 | 没考虑‘*/’的查找,或忘了EOF判断 | 用循环找*/,同时判断文件结尾 |
| ==被拆成=和= | 缺少预读合并逻辑 | 匹配双字符运算符 |
| 数字识别到a就停 | 边界判断没包含字母 | 数字后紧跟字母时应该报词法错误 |
| Windows输出的行号比Linux多 | fopen用了文本模式 | 统一用“rb”模式读取 |
| 出错后死循环 | 报错分支没有消费非法字符 | 报错后pos++跳过非法字符 |
这些问题的共同根源,基本都是“扫描器的状态处理不够严格”。我的建议是:每写完一个分支,都回头检查这个分支结束时,当前字符是否被正确消费或回退,以及line计数器是否与实际输入同步。
5.2 做完实验一之后,还能往哪些方向优化
如果你做完基本要求还有余力,有几个方向可以拓展。第一,把查找关键字从二分查找换成“直接哈希”,用字符串哈希把查找时间降为O(1),实验规模上差别不大,但思路值得练。第二,把硬编码的种别码改成枚举类型,代码可读性提升明显。第三,尝试把扫描器改成“流式”的,不一次性读入全部源码,而是边读边扫。这样做的好处是内存占用恒定,能处理超大文件。
还有一个比较有趣的扩展:你可以试着把实验一的扫描器“通用化”,让它通过读取一个配置文件来识别不同的词法规则,这就变成了一个mini版lex。虽然工作量会增加不少,但做完后你对词法分析器的理解会上升一个台阶。我当年做实验时没时间搞这个,后来工作了回头写一些命令行工具时,才发现这种思路在实际工程里的价值。
5.3 给即将交实验的同学的一点个人体会
最后说点实在的。实验一能在编译原理课程里作为“第一关”,它的意义就是逼你老老实实处理字符串、状态、边界。这个过程中,你可能觉得自己在写“不是编程的编程”,整天和字符、指针、回退打交道,非常琐碎。
但实际做下来你会发现,你对一个问题维度的理解会变得更清楚:一个程序从源码到可执行,第一步不是画架构图,而是先把最原始的字节流按规则切分好。这个习惯和感觉,会在你以后阅读任何需要解析文本的工具源码时派上用场。比如你去看Babel怎么解析JavaScript、去看JSON库怎么解析JSON字符串,你会发现它们最核心的那层,和你实验一写的扫描器逻辑惊人地相似。
我个人的一个小建议是:不要只把实验一当作“过关任务”,尝试把它写成自己的“代码资产”。一个模块化良好的词法分析器,后面做语法分析时需要加Token类型、需要关联符号表属性、需要接错误恢复,都是很容易扩展的。相反,如果实验一写得一团乱麻,后面每个实验你都得在痛苦的代码上缝缝补补,那才是真正的煎熬。祝你的编译器,从这第一个实验就开始顺顺当当。
本文还有配套的精品资源,点击获取