news 2026/10/13 4:31:04

编译原理实验一:词法分析器从零到过评的核心与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
编译原理实验一:词法分析器从零到过评的核心与避坑指南

简介:一份面向湖南大学编译原理课程的实验参考资料,聚焦实验一从DFA设计与代码实现到实验报告撰写的完整流程。课程实验分数占比很高,作者特别强调代码与报告仅供思路参考,建议结合自身理解进行实践,不宜直接照搬。内容预览显示包内包含DFA_1.cpp源文件、4个dfa状态定义文件、一份docx实验报告以及一个可直接运行的exe程序,可对照查看词法分析中确定有限自动机的构造、状态转换表与程序运行效果,能直观理解自动机如何完成输入串的识别与状态跳转。资源整体共7个文件,压缩包仅764KB,体量虽小但结构完整,便于快速下载查阅。目前已有709人浏览学习,说明其在同课程学生中具备一定的参考热度,适合需要理解实验一设计思路、比对报告结构或快速上手编程实现的学习者。

1. 拿到《编译原理实验一.zip》之后,你先想清楚这三件事

解压《编译原理实验一.zip》那一刻,大多数人心里想的其实是“我能不能找到一个能直接交的代码”。但等你真的跑过一轮评测就会发现,实验一考的不是代码量,而是你有没有把“一堆字符”变成“一串有结构的 token”这件事想明白。A 同学第一次拿到这份压缩包的时候,花了一个晚上在搞环境,真正写词法的时间还不如调一个中文注释的编码问题多。

这份实验包能解决的事情很具体:给你一个语法定义的子集,让你实现词法分析,把输入源码翻译成带行号、列号和类型的 token 序列,再跟标准输出做比对。适合现在正在修编译原理课、或者想自己补一遍编译基础的人。而我写这篇文章的目的只有一个:把这门实验从“打开压缩包”到“能过基础样例、知道坑在哪”的完整路径讲清楚,不绕弯子。

2. 实验一的真实考察点:词法分析的两种实现路线

2.1 拿到压缩包后的信息核对顺序

我拿到这类实验压缩包,第一件事永远是先看目录,而不是急着打开代码文件。常见做法是解压之后先执行一条命令:

unzip 实验一.zip -d exp1 ls -R exp1

然后依次确认四类东西:实验文档、骨架代码、测试样例、构建脚本。文档决定你用什么语言、输出什么格式;骨架代码决定你能不能直接编译;测试样例决定你拿什么做验收;构建脚本决定你到底是手写编译命令还是用 CMake/Makefile。四个缺一个,都会在最后提交的时候出问题。

所以不要一上来就翻 flex 的教程。先看文档里写的输出格式。实验一最常用的评分方式是“把你的输出和标准输出逐字符比对”,所以每一行的格式、空格、逗号、换行都算分。先找文档里的“样例输出”,把它当作你在实现的规格说明书,而不是把老师上课给的 PPT 当规格。

2.2 手写状态机还是用生成器:先看评分方式再选

这是选型问题。常见的实现路线有两条:手写一个 DFA 状态机扫描器,或者用 flex 这类生成器写 .l 文件。我用一张表直接对比:

路线优点缺点适合场景
手写扫描器逻辑完全可控,调试直观,能精确控制行列号与错误恢复代码量大,数字、注释这些状态容易漏边界实验限定 C/C++/Python 手写,或者你想把自动机原理彻底弄懂
flex 生成器正则表达能力强,代码短,产出稳定生成代码是黑匣子,行号列号要靠 yytext 手动维护,报错信息难改实验允许提交 .l 文件,或者你只想把重心放在后面的语法分析

如果压缩包里的骨架是 C 工程,且明确要求“实现一个词法分析程序”,那通常默认是手写。如果文档里写了“请使用 flex 编写”,那别犹豫,用 flex。还有一条判断标准:看测试样例的输入规模。如果样例里有大量非法字符、形如.5的浮点数、/*和除号混排的情况,手写反而更容易处理这类错误恢复,因为 flex 的默认行为是遇到匹配失败就报错并停止。

2.3 词法边界:关键字、数字、注释与错误恢复的最小状态集

不管哪条路线,你要管理的状态至少包含这几类:标识符、关键字、整数、浮点数、字符串、注释、空白、非法字符。我见过最多的翻车不是不会写,而是边界没建全。

例如关键字,大部分实现是先识别出标识符然后查表,这没问题;但有人为了省事直接在正则里把关键字写在标识符规则前面,导致ifx这种名字被切成了if和x,这就是典型的边界错。数字的状态要拆成整数、小数、指数三段,因为1e不是合法浮点数,.5合法,3.在多数语言里合法。注释要区分行注释和块注释,块注释必须处理未闭合的情况。非法字符不能直接终止程序,而是记录行列号后跳过,恢复扫描。

这套最小状态集就是实验一的核心。你不需要在一开始设计成一个能编译完整 C 语言的词法器,只需要保证实验文档里给出的语法子集全部覆盖。做减法比做加法重要,先把你明确要支持的 token 类型列成表,再动手。

3. 先跑通骨架再写代码:最小命令与工程组织

3.1 解压缩后的第一件事:文档、构建脚本、测试样例先过一遍

一个经常被忽略的经验:先试试不写任何代码,能不能把骨架工程编译出一个可执行文件。如果这一步都过不去,后面写了也白写。建议按这个顺序检查:

cd exp1 find . -maxdepth 2 -type f | sort head -n 60 README* 2>/dev/null || ls -la

如果有 Makefile 就直接make,如果没有,就在文档里找编译命令。很多实验包的骨架是故意留了缺口的,比如某个函数返回空、某个头文件没包含,这时候跑一次编译才能看到真实缺口是什么。我一般会记录下第一条编译错误,先修环境问题,不碰业务逻辑。

判断标准很简单:能不能跑出一个“接收输入并原样返回”的程序。这一步成功之后,再去动词法逻辑。A 同学第一次拿到包,没先编译,直接写了两百行状态机,最后发现 main 函数入口跟实验要求的参数形式不一致,整个重来。

3.2 原样编译一次官方样例,确认输出格式

编译通过之后,找测试样例目录里的第一个样例,直接喂给程序。如果是命令行程序:

./lexer < test/01_basic.c

输出会是一堆你不认识的 token 序列。这时候不要慌,拿它和实验文档里的“预期输出”做一次 diff。这里要说一个血泪经验:格式比逻辑更先决定你的分数。行号从 0 还是从 1 开始,列号是字符偏移还是字节偏移,token 类型名是大写还是小写,都可能导致整段输出对不上。

diff <(./lexer < test/01_basic.c) test/01_basic.out

diff 的输出会精确告诉你是哪一行不一致。修格式问题的成本远低于修逻辑,所以这一部一定不能省。任何一份实验一的压缩包,文档里必然写了输出格式,把它当作协议来对齐。

3.3 token 类型表与关键字表怎么组织

骨架代码里通常会有一个枚举或者宏定义,例如:

typedef enum { TOKEN_IDENT, TOKEN_KEYWORD, TOKEN_INT, TOKEN_FLOAT, TOKEN_OP, TOKEN_EOF } TokenType;

关键字表我一般单独放一个数组,方便扩展。用 C 的话,可以直接用字符串数组和二分查找,也可以接受线性查找,因为关键字数量极少:

const char* keywords[] = { "if", "else", "while", "return", "int", "float", "void", NULL };

这里带出一个参数:关键字表的大小写是否敏感。实验文档没说,默认按小写处理。如果你把If误判成关键字,多半是查表前没有做规范化。token 类型表的设计决定了后面所有输出的可读性,也决定了你是否能在测试阶段快速定位问题。我习惯在 token 结构里同时保留原始文本、类型、行号、列号,这四个字段在调试时缺一不可。

4. 核心实现:写一个能过基础样例的词法分析器

4.1 输入缓冲与主循环:先跳过空白再识别 token

我通常会写成一个类或者一个结构体,把输入字符流和当前位置、当前行列号放在一起。主循环的逻辑非常简单:跳过空白和注释,读一个 token,输出,直到 EOF。下面是一个能直接运行的 Python 骨架,逻辑与 C 版本通用,只是把内存管理省掉了。

import sys class SimplaLexer: def __init__(self, text: str): self.text = text self.pos = 0 # 当前字符下标 self.row = 1 self.col = 0 def peek(self, offset: int = 0) -> str: idx = self.pos + offset if idx >= len(self.text): return "" return self.text[idx] def advance(self) -> str: ch = self.peek() self.pos += 1 if ch == "\n": self.row += 1 self.col = 0 else: self.col += 1 return ch def skip_whitespace(self): while self.peek() in (" ", "\t", "\n", "\r"): self.advance()

这里的关键参数是pos和row/col的维护。很多人会在跳过换行时忘记把col归零,导致所有行号对但列号错。我习惯把advance()做成唯一的读取入口,这样可见字符、换行、EOF 的处理都集中在一处,不会出现某个分支漏更新行列号的情况。

4.2 标识符与关键字:先拼完再查保留字表

标识符的规则一般是字母或下划线开头,后续跟字母、数字、下划线。实现时注意:不要一边读一边判断是不是关键字,而应该先把整个标识符拼出来,然后查表。

def read_identifier(self): start_row, start_col = self.row, self.col parts = [] while self.peek().isalnum() or self.peek() == "_": parts.append(self.advance()) word = "".join(parts) token_type = "KEYWORD" if word in self.keywords else "IDENT" return { "type": token_type, "value": word, "row": start_row, "col": start_col, }

这段逻辑值得说明:先拼词,再判定类型。如果你在循环里见到i就猜它是关键字,那就没法处理ifx、int_value这类正常标识符。查表用 Python 的 set 就够了;如果是 C 语言,可以写一个静态数组然后逐个比较,或者按首字母分桶,效果差不多。keyword 集合里放什么,以实验文档中的关键字列表为准,不要自己加。

4.3 数字识别:整数、小数、科学计数法的三种边界

数字是实验一里最容易翻车的部分。一个完整的数字状态流转应该是:先收整数部分,如果遇到小数点则进入小数部分,如果遇到e或E则进入指数部分。三种边界分别是:.5这种小数点开头、3.这种小数点结尾、1e5这种指数形式。

def read_number(self): start_row, start_col = self.row, self.col parts = [] while self.peek().isdigit(): parts.append(self.advance()) is_float = False if self.peek() == ".": is_float = True parts.append(self.advance()) while self.peek().isdigit(): parts.append(self.advance()) if self.peek() in ("e", "E"): is_float = True parts.append(self.advance()) if self.peek() in ("+", "-"): parts.append(self.advance()) if not self.peek().isdigit(): # 1e 这种输入属于非法数字,需要报错 raise SyntaxError(f"invalid number at row {self.row}, col {self.col}") while self.peek().isdigit(): parts.append(self.advance()) return { "type": "FLOAT" if is_float else "INT", "value": "".join(parts), "row": start_row, "col": start_col, }

这里三个边界对应的处理逻辑分别是:.开头时会先进入小数点分支,但由于整数部分为空,这时要在外层判断是否允许这种“省略整数部分”的写法;3.的情况要看实验文档的语法定义,有的语言允许,有的要求小数点后必须跟数字;1e是明确的错误,必须抛出异常或记录错误,而不能默默吞掉。

4.4 注释跳过与非法字符的错误恢复

注释有两种,行注释//和块注释/* ... */。块注释的特例是未闭合。我的做法是在主循环里先判断当前字符是否为/,如果下一个字符是/则跳到行尾,如果是*则进入块注释扫描。

def skip_comment(self): if self.peek() == "/" and self.peek(1) == "/": while self.peek() and self.peek() != "\n": self.advance() return True if self.peek() == "/" and self.peek(1) == "*": self.advance() self.advance() while True: if not self.peek(): raise SyntaxError("unclosed block comment") if self.peek() == "*" and self.peek(1) == "/": self.advance() self.advance() break self.advance() return True return False

碰到非法字符时,比如@、#这类不在任何 token 规则里的字符,我的处理方式是记录错误信息、打印到 stderr,然后跳过该字符继续扫描。千万别直接 return 退出,因为评测样例里常常故意混入非法字符来验证错误恢复能力。跳过非法字符时行列号照样更新,这样后续 token 的行列号不会错位。

5. 常见问题与避坑:实验一里最容易翻车的五个点

5.1 关键字被误判成标识符:先拼完再查表

现象:输入if(x) return 1;,输出的第一个 token 是IDENT: if,而不是KEYWORD: if。 原因:代码里先匹配了“字母开头”的分支,并且这个分支只识别为一个普通标识符,没有在收尾时查关键字表。 解决:把标识符的字符收集完整后,统一查一次保留字表。查表动作必须在拼词之后,不能在拼词过程中提前决定类型。 我见过有人用正则把关键字写在前面,结果ifx被拆成if和x,这就是另一个方向的反例。记住一个原则:关键字表中的单词必须准确等于完整标识符才匹配。

5.2 浮点数边界漏判:.5、3.、1e5各占一坑

现象:.5报非法字符,3.后面跟着的字符被吞掉,1e被当作合法数字输出。 原因:数字状态机只覆盖了“整数部分 + 可选小数点 + 小数点后数字”这一条理想路径,没有考虑省略整数部分、小数点结尾、指数部分缺失三种情况。 解决:先读整数部分,再看小数点;遇到小数点后无论后面是否还有数字,先确认实验语法是否允许;指数部分遇到e/E后必须要求后面至少有一位数字,否则报错。这三个边界是评审样例最爱挖的位置,也是最能拉开分数差距的位置。

5.3/*与除号歧义:向前看一个字符的代价

现象:输入a = b /* c,结果程序以为注释以/*开始,然后找不到结束符,直接抛异常。 原因:扫描器看到/就把下一个字符固定下来,而没有考虑/后面跟空格、或者/本身就是除法运算符的场景。 解决:在识别到/时向前看一个字符。如果下一个字符是*且中间没有空格,才进入注释;否则把/当作运算符输出。同理,//行注释必须在/后紧跟/才触发。 这个坑的典型特征是最简单的算术表达式样例能过,稍微复杂一点的表达式就崩溃。

5.4 中文注释与编码:字节流别乱解码

现象:本地跑样例输出正确,换一台机器或者评测端跑就出现乱码,甚至把中文注释的内容输出成非法 token。 原因:词法分析器把源文件按字节流读入,遇到中文字符的 UTF-8 多字节序列时,每个字节都可能是不可打印字符,如果代码里用isalpha()这类函数判断,就会把它们当成标识符的一部分或者非法字符。 解决:注释和字符串之外,确保跳过注释的逻辑不检查内容;如果你明确知道源文件是 UTF-8,读出字节后先解码,再判断字符类型。如果实验要求只支持 ASCII 输入,在文档限制集合外直接报“非法字符”也算正确行为,关键是不能稳定复现的乱码问题一定要在提交前用中文注释样例试一遍。

5.5 EOF 收尾丢 token:循环退出前补一次 flush

现象:输出少最后一个标识符或数字,比如return 0;的输出里只有return,没有0。 原因:主循环里检测到 EOF 时直接 break,但此时正在拼的 token 还没从缓冲里吐出来。很多人写循环条件是while (peek() != EOF),结果最后一个字符被当作结束标志,而不是先完成 pending token 再退出。 解决:循环退出前,检查当前是否处于“正在读标识符/数字”的中间状态,如果有,则补做一次收尾函数调用。更稳妥的做法是让每个 token 读取都从“空白之后读取第一个有效字符”开始,读到不可继续的字符时就立即返回 token,而不是等循环退出再处理。

6. 进阶:把实验一的 lexer 接到语法分析去

6.1 给 token 加行列区间,而不仅仅是一个起点

如果你打算继续做实验二,那词法器输出的信息会直接决定语法分析报错体验。起点行列号只够你告诉用户“错误发生在某一行”,但不够指出“错误范围”。我习惯在 token 结构里额外记录结束行列位置,也就是记录读取最后一个字符之后的状态。

这个信息在实验二里非常有用。比如语法分析遇到if (a == ),你要报错就得指出从a到)之间丢了表达式。如果你只有起点,就只能报“第 2 行有错”,用户还得自己猜。行列区间的维护成本不高,就是在advance()里同步更新 row/col,token 读完后再读一次当前状态作为终点。

6.2 把 token 流序列化成 JSON 行,方便调试与后续对接

这是我在本地调试时的习惯,也推荐给你:在程序里加一个“调试输出模式”,把 token 按 JSON 行格式输出,每行一个 token。

import json def emit_token(tok): print(json.dumps({ "type": tok["type"], "value": tok["value"], "start": [tok["row"], tok["col"]], "end": [tok["end_row"], tok["end_col"]], }, ensure_ascii=False))

好处是你可以用 Python 脚本或者 jq 快速过滤 token 类型,做统计和回归测试。比如统计所有关键字出现的次数、检查某个标识符的覆盖范围。再往后做实验二时,这个 JSON 流还能直接喂给语法分析器作为输入 token 源,省掉重新读文件解析的重复工作。如果实验要求必须输出固定文本格式,那就把这个开关作为运行时参数,不影响正式提交。

6.3 一个习惯:三正三负的测试夹具先行

最后说一个我自己的习惯:写主循环之前,先定测试用例。六个用例,三个合法输入,三个非法输入。合法输入覆盖普通表达式、嵌套注释、浮点数;非法输入覆盖1e、未闭合注释、非法字符。每实现一个 token 类型就立刻跑一遍这六个用例,而不是全部写完再统一调试。

这个习惯帮我避免了很多“全写完一起查 bug”的尴尬。词法分析的逻辑是线性流,任何一步状态机错位,都会让后面的 token 全部偏移。逐个类型推进、每个类型用一个样例验证,虽然看起来慢,但整体调试时间反而是最少的。实验一不是靠惊艳的设计拿分,是靠稳定输出拿分。希望这个思路帮到你,少走一点我在反复编译、反复 diff 里走过的弯路。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 4:30:32

开放式代码审查实战:从流程设计到团队落地的完整指南

1. 先说清楚&#xff1a;什么叫"开放式代码审查"入行前几年&#xff0c;我对代码审查的态度一直很矛盾。说它重要吧&#xff0c;每次合代码之前确实都卡了一道流程&#xff1b;说它有用吧&#xff0c;很多时候又只是走过场——Reviewer扫一眼格式、点几个"看起来…

作者头像 李华
网站建设 2026/10/13 4:30:20

统一大模型接入层:LiteLLM网关部署与切换实践

最近这段时间&#xff0c;我把手头项目的AI接入层整个翻新了一遍。之前代码里同时维护着好几套大模型API的调用逻辑&#xff0c;每家服务商的鉴权方式不一样&#xff0c;请求结构不一样&#xff0c;返回字段更是各写各的&#xff0c;哪怕是增加一个新模型&#xff0c;也要改动一…

作者头像 李华
网站建设 2026/10/13 4:30:14

Loop Engineering实战:从零构建带反馈闭环的AI智能体循环

这两年做AI应用开发&#xff0c;躲不开一个词&#xff1a;Loop Engineering。每次看到群里有人讨论&#xff0c;总有人问&#xff1a;这不就是while循环吗&#xff1f;还真不是。如果你要做的是那种“能自己决定下一步干啥、干完这步看结果再决定下一步”的智能体应用&#xff…

作者头像 李华
网站建设 2026/10/13 4:30:03

agent技能层:从零构建可复用可评估的工具调用能力

我接到这个项目标题的时候&#xff0c;心里的第一反应是&#xff1a;终于有人把 agent 的“手和脚”当成正经工程来对待了。过去一年里我做了不少 agent 原型&#xff0c;身边朋友也常问同一个问题&#xff1a;为什么智能助手接了七八个工具之后&#xff0c;还是经常答非所问、…

作者头像 李华
网站建设 2026/10/13 4:29:07

Vim高效编辑原理与实战:从模式驱动到文本对象操作

1. 为什么一个“老古董”编辑器&#xff0c;至今仍是工程师案头标配&#xff1f;Vim 不是工具&#xff0c;是肌肉记忆的延伸。我第一次在某高校实验室的 Linux 服务器上被导师扔进 Vim 时&#xff0c;连怎么退出都不知道——:q!这三个字符&#xff0c;是我当天敲了 47 次才刻进…

作者头像 李华