☰
从字符流到Token:编译原理实验一词法分析器DFA实现
2026/10/28 18:23:27 网站建设 项目流程

简介:面向计算机专业编译原理初学者与课程实验需求,这份北京邮电大学课程实验一词法分析器压缩包对应编译器前端扫描器的设计与实现任务,可用于理解词法规则定义、字符流读取与分词输出。包内共4个文件,以2个txt说明与测试文本、1个h头文件和1个cpp源文件为主,压缩包约10KB,体量轻便,适合对照课程要求梳理实现思路。已有1000人学习下载,说明其在同类实验资料中具有一定参考度。代码围绕词素识别与分类展开,涵盖输入处理、分词逻辑、符号管理及基础错误处理等环节,读者可据此观察正则式或EBNF规则如何落地为可运行扫描器,并结合测试文本检查合法与非法输入下的健壮性。整体适合需要完成词法分析实验、复盘前端流程或为后续语法分析打基础的学生参考。

1. 从一行 if 语句到一串二元组:这个实验一交付的到底是什么

把if (a >= 1) b = a + 2.0;这一行丢进去,屏幕上按顺序吐出一串二元组:(IF, if)、(LPAREN, ()、(ID, a)……很多人在编译原理课上第一次看到这个画面,就是词法分析器这个实验。这份资源是某高校编译原理课程实验一的完整交付包,事情很明确:读入一段类 C 源程序,按字符流切成 token 序列,把空白和注释去掉,遇到非法字符记下行号列号,最后按实验要求的格式打印出来。它适合三类人:正卡在实验一门口、需要一份能跑通的参考实现的人;想给自己的小语言补一个前端词法层的人;以及想把 DFA 从纸上状态图真正落成代码的人。它不做语法分析,也不做语义检查,只把字符流变成 token 流,边界特别清楚。

2. 工程结构与技术选型:三个源文件如何拼出一条扫描链路

拿到压缩包,别急着双击main.c就编译。课程实验的代码体量通常不大,但从文件划分能直接看出作者有没有把“扫描器状态”和“输出格式”绑死在一起。一份能二次开发的词法分析器,理想划分是:词法规则只出现在lexer.c,Token 的表示只出现在token.h,而屏幕怎么打印、文件怎么读都只出现在main.c。这样你后面想把输出格式改成 CSV、JSON 或者喂给语法分析器,都只动一个文件,不会连带改坏状态机。

2.1 目录骨架:规则、数据、驱动三层分家

一份典型的实验一工程结构如下,不同实现细节会有差异,但分层思路基本一致:

lexer-lab/ ├── src/ │ ├── token.h # Token 类型枚举与结构体,全工程共用 │ ├── lexer.c # 扫描主循环、状态判断、错误处理 │ └── main.c # 读文件、驱动扫描、按实验格式输出 ├── tests/ │ ├── case_basic.c # 关键字、标识符、整型浮点常量 │ ├── case_op.c # 运算符与界符的最长匹配 │ └── case_err.c # 非法字符、未闭合注释、超长标识符 └── Makefile

token.h是约定层,所有模块都依赖它,所以它要尽量不加业务逻辑;lexer.c是变化最频繁的部分,实验一里被扣分的地方九成出在这里;main.c只做胶水,读入源文件、循环调用next_token、按规定格式打印,它不应该出现任何isalpha、isdigit这种字符分类判断。tests/目录别当摆设,很多人的实现只在自己手写的那几行例子上跑得通,换个带注释和浮点数的文件就崩,原因就是没有反例可跑。

这里有个判断代码质量的小方法:打开main.c,如果看到里面写着一堆if (c == '=')之类的字符判断,基本可以确定扫描逻辑漏到了驱动层,后面接语法分析时会很麻烦。

2.2 为什么这门实验普遍要求手写扫描器而不是直接上 Flex

课程实验里最常见的两种做法是:手写 DFA 状态机,或者用 Flex/Lex 写正则规则自动生成扫描器。这份交付走的是前一条路,这也是我推荐你先跑通的一条路。

原因是词法分析器的核心不是“能切出 token”,而是“你能解释每个 token 为什么被这样切”。手写版本的每个分支都对应状态图里的一条边,出问题时你能把某个字符、某一次前瞻、某一次回退完整地讲出来;正则生成的扫描器出问题时,你面对的是一个生成出来的状态表,调起来就像面对一个黑匣子。课程实验一般还会要求交状态转换图或者 DFA 表格,手写实现和图能一一对应,写实验报告时不用二次翻译。

代价是你要自己处理最长匹配、回退、错误恢复这些繁琐活。常见做法是维护一个“当前字符 + 下一字符”的双前瞻缓冲,遇到复合运算符(>=、==、!=)和注释(//、/* */)时先看两个字符再决定走哪条边。这一步设计对了,后面写起来会顺很多。

2.3 编译与运行:三步跑通最小闭环

先用最小的命令把工程跑起来,别一上来就加一堆编译选项:

# 1. 编译:开告警,先把零警告做到,再谈功能 gcc -std=c99 -Wall -Wextra -g -o lexer src/main.c src/lexer.c # 2. 用自带用例跑一遍,看输出格式是否符合实验要求 ./lexer tests/case_basic.c # 3. 跑错误用例,重点看行号列号是否准确 ./lexer tests/case_err.c

-std=c99是必要的,代码里普遍用到//行注释、snprintf和<stdbool.h>;-Wall -Wextra能把“有符号和无符号比较”“隐式截断”这类问题提前暴露,词法分析器里字符和整数来回转换,这类警告往往是真的 bug;-g留着方便用 gdb 单步看某个字符是怎么被消费掉的。Windows 上 MinGW 的命令一致,MSVC 则换成cl /W4 /std:c11 src\main.c src\lexer.c。

如果工程带 Makefile,直接make && make test就行,但建议你至少手动执行一次上面的命令,搞清楚最终产物依赖哪几个文件。很多人后面加了新源文件却忘了改 Makefile,编出来还是旧逻辑,调半天以为是自己算法错了。

3. 核心状态机拆解:一个字符进来之后分几条岔路

词法分析器的骨架其实就是一个大的switch加几段while。真正拉开差距的是三个细节:Token 怎么表示、前瞻字符怎么维护、关键字和标识符谁先判。下面按调用顺序拆。

3.1 Token 结构:四个字段就够,别塞业务数据

/* token.h:一个 token 只带四个字段,够打印也够后续语法分析定位 */ typedef enum { TOK_ID = 0, TOK_NUM, TOK_IF, TOK_ELSE, TOK_WHILE, TOK_RETURN, TOK_TYPE, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_ASSIGN, TOK_EQ, TOK_NE, TOK_LT, TOK_LE, TOK_GT, TOK_GE, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_SEMI, TOK_COMMA, TOK_EOF, TOK_ERROR } TokenType; typedef struct { TokenType type; /* 枚举,后续 switch 分发 */ char lexeme[64];/* 词素原文,超长直接截断并置 TOK_ERROR */ int line; /* 起始行号,从 1 开始 */ int col; /* 起始列号,从 1 开始 */ } Token;

lexeme固定 64 字节是课程实验里比较务实的取舍:比它长的标识符本身就该报错,截断比动态分配简单得多,也避免了一堆malloc/free带来的内存问题。line和col记的是 token 的起始位置而不是结束位置,因为报错时大家习惯说“第 3 行第 7 列这里有问题”,指向开头更符合直觉。枚举里把TOK_ERROR和TOK_EOF放在最后,是为了让判断“是否是有效 token”时可以直接比较大小。

如果你后面要接语法分析器,lexeme之外可能还需要一个数值字段(整型值或浮点值),常见做法是在Token里加一个union { long ival; double fval; },在扫描数字时顺手填上。这样可以省掉语法分析阶段再做一次strtod。

3.2 主循环:消费一个字符,判一个类,必要时回退

/* lexer.c:一次调用产出恰好一个 token,空白和注释在入口处统一跳过 */ Token next_token(Lexer *lx) { skip_space_and_comment(lx); /* 见 4.2,含两字符前瞻 */ Token tk = { TOK_ERROR, {0}, lx->line, lx->col }; int c = lx_getc(lx); /* 取当前字符并前移一个位置 */ if (c == EOF) { tk.type = TOK_EOF; return tk; } if (isalpha(c) || c == '_') { /* 标识符或关键字 */ int n = 0; tk.lexeme[n++] = (char)c; while (isalnum(lx->cur) || lx->cur == '_') { if (n < (int)sizeof(tk.lexeme) - 1) tk.lexeme[n++] = (char)lx->cur; lx_getc(lx); /* 持续消费,直到遇到非标识符字符 */ } tk.lexeme[n] = '\0'; tk.type = lookup_keyword(tk.lexeme); /* 先收全词,再查表 */ return tk; } /* 数字、运算符、界符的分支见下面两节 */ ... }

lx->cur是“当前尚未消费的字符”,lx_getc是“消费它并读入下一个”。这种写法等价于单字符前瞻:进入while时已经多读了一个不属于标识符的字符,但它没被消费,留给下一次next_token处理,所以不需要显式回退。这就是很多教材里说的“多读一个字符”的实现方式,比维护一个ungetc栈要直观。

skip_space_and_comment放在最前面统一处理,而不是散落在各个分支里,好处是行号和列号的推进只有一处负责,出错的概率小很多。计数器line在遇到\n时加一、col归零,其余字符col加一,这套规则写对了,报错定位基本就不会偏。

3.3 关键字是标识符的二次查表,顺序反了就会出错

/* 关键字数量通常在三十个以内,线性查表比建哈希表更省事 */ static const struct { const char *word; TokenType type; } KEYWORDS[] = { {"if", TOK_IF}, {"else", TOK_ELSE}, {"while", TOK_WHILE}, {"return", TOK_RETURN}, {"int", TOK_TYPE}, {"float", TOK_TYPE}, {"void", TOK_TYPE}, {"char", TOK_TYPE}, {"for", TOK_FOR}, }; static TokenType lookup_keyword(const char *s) { for (size_t i = 0; i < sizeof(KEYWORDS) / sizeof(KEYWORDS[0]); ++i) { if (strcmp(s, KEYWORDS[i].word) == 0) return KEYWORDS[i].type; } return TOK_ID; /* 查不到就是普通标识符,默认路径只有一条 */ }

这段的关键在于“先按标识符规则把整词收完,再去查关键字表”。反过来做——先判断首字符是不是i,再看后面是不是f——在int、if、inline同时存在时会立刻翻车,而且代码会膨胀成一棵判断树。线性查表看着朴素,但关键字表最多几十项,每次比对又短,实际开销可以忽略;真要优化,按首字母分桶比上哈希更划算。

这里还有一个容易忽略的点:关键字表要和实验指导书里的保留字列表严格一致。少一个do,它就会被当成TOK_ID悄悄放过去,语法分析阶段才炸,而那时你早就忘了问题出在词法层。

3.4 数字字面量:整数、小数、指数三段,每段都要能退回

/* 数字扫描:小数点和指数部分都是可选的,任一段失败就停在已确认位置 */ if (isdigit(c) || (c == '.' && isdigit(lx->cur))) { int n = 0; tk.lexeme[n++] = (char)c; while (isdigit(lx->cur)) { tk.lexeme[n++] = (char)lx_getc(lx); } if (lx->cur == '.' && isdigit(lx->next)) { /* 1. 后面必须跟数字才算小数 */ tk.lexeme[n++] = (char)lx_getc(lx); while (isdigit(lx->cur)) { tk.lexeme[n++] = (char)lx_getc(lx); } } if (lx->cur == 'e' || lx->cur == 'E') { /* 指数部分 */ tk.lexeme[n++] = (char)lx_getc(lx); if (lx->cur == '+' || lx->cur == '-') tk.lexeme[n++] = (char)lx_getc(lx); if (!isdigit(lx->cur)) { tk.type = TOK_ERROR; return tk; } while (isdigit(lx->cur)) { tk.lexeme[n++] = (char)lx_getc(lx); } } tk.lexeme[n] = '\0'; tk.type = TOK_NUM; return tk; }

1.到底算不算合法浮点数,不同实验指导书要求不同。上面这段的判断是“小数点后必须紧跟数字”,所以1.会被切成1和.两个 token,.大概率落到非法字符分支。如果你的指导书允许1.,把条件里的isdigit(lx->next)去掉即可,但记得同时处理1..2这种输入,否则第二个点会被吃掉。

指数部分里e后面没跟数字时必须报错,不能默默当作标识符的一部分。这个分支是最容易写漏的,因为正常用例里几乎不会出现1e,等到错误用例才发现,行号已经对不上了。

4. 运算符、界符与注释:写漏一处用例就挂的细节

前面三节决定了正常输入能不能切对,这一节决定的是边界输入会不会把扫描器带崩。运算符的最长匹配、注释的两字符前瞻、错误的恢复策略,是课程实验里扣分最集中的三块。

4.1 双字符运算符必须做最长匹配

/* 运算符分支:先看两个字符再决定,保证 >= 不会被拆成 > 和 = */ case '>': if (lx->cur == '=') { /* 已消费 '>',再看下一个 */ lx_getc(lx); tk.type = TOK_GE; strcpy(tk.lexeme, ">="); } else { tk.type = TOK_GT; strcpy(tk.lexeme, ">"); } break; case '!': if (lx->cur == '=') { lx_getc(lx); tk.type = TOK_NE; strcpy(tk.lexeme, "!="); } else { tk.type = TOK_ERROR; strcpy(tk.lexeme, "!"); } /* 单独 ! 视语言而定 */ break;

最长匹配是词法分析的基本原则:只要存在更长的合法 token,就不能提前截断。>=、<=、==、!=、&&、||都属于这一类。如果你的语言还支持>>=、<<=这种三字符运算符,就得再往前多看一个字符,这时双前瞻缓冲就不够了,常见做法是把前瞻扩成“当前 + 后续两个”,或者改成先读入整行再在行内游标扫描。

顺序也很关键:>的分支里必须先判>=,再判单独的>,反过来就永远匹配不到两字符形式。这类错误在代码 review 时很难用眼睛看出来,但用例里放一行a >= b;立刻现原形。

4.2 注释和空白:需要看两个字符,靠skip统一推进

/* 跳过空白与两类注释,循环处理连续出现的多段注释 */ static void skip_space_and_comment(Lexer *lx) { for (;;) { if (lx->cur == ' ' || lx->cur == '\t' || lx->cur == '\r' || lx->cur == '\n') { lx_getc(lx); continue; } if (lx->cur == '/' && lx->next == '/') { /* 行注释到行尾 */ while (lx->cur != '\n' && lx->cur != EOF) lx_getc(lx); continue; } if (lx->cur == '/' && lx->next == '*') { /* 块注释 */ lx_getc(lx); lx_getc(lx); /* 吃掉 '/' 和 '*' */ while (lx->cur != EOF && !(lx->cur == '*' && lx->next == '/')) lx_getc(lx); if (lx->cur == EOF) { report_error(lx, "块注释未闭合"); return; } lx_getc(lx); lx_getc(lx); /* 吃掉 '*' 和 '/' */ continue; } return; /* 无空白无注释,交回主循环 */ } }

lx->next是cur的下一个字符,只读不消费。行注释的判断依据是“/后面紧跟/”,块注释靠“*后面紧跟/”收尾。这两个判断都需要双前瞻,所以Lexer结构里至少要有cur和next两个字段,并且lx_getc每次消费时要同步推进两个位置。

如果漏掉最外层的for (;;),那么/* a */ /* b */ int x;这种连续注释只会被跳掉第一段,第二段会原样进入主循环,被当成/和*两个运算符,然后你的 token 流里就冒出一堆莫名其妙的符号。块注释未闭合时直接return,让主循环继续,能在报告错误的同时把后面的内容扫完,比直接退出要好。

4.3 词法错误的记录与恢复:报错不中断,一次扫完

/* 非法字符:记一个 TOK_ERROR,把位置和字符带上,继续往下扫 */ default: tk.type = TOK_ERROR; snprintf(tk.lexeme, sizeof(tk.lexeme), "%c", c); report_error(lx, tk.line, tk.col, "非法字符"); return tk;
/* 错误汇总:文件结束时统一按行打印,行号小的在前 */ void report_error(Lexer *lx, int line, int col, const char *msg) { if (lx->err_count < MAX_ERRORS) { lx->errors[lx->err_count].line = line; lx->errors[lx->err_count].col = col; lx->errors[lx->err_count].msg = msg; /* 指向字符串常量,无需释放 */ lx->err_count++; } }

课程实验一般都要求“一次扫描报告全部错误”,所以非法字符不能让程序exit,也不能直接把该字符丢掉假装没看见,正确做法是产出一个TOK_ERROR交给上层,同时把位置记进错误数组,扫描结束后统一输出。MAX_ERRORS通常给 64 或 128 就够,超出的错误直接丢弃,避免一个全是乱码的文件把内存打满。

msg指向字符串常量这一点要注意:如果你的错误消息是动态拼出来的(比如带上标识符名),就得改成固定长度字符数组,否则悬垂指针会在打印时出现乱码,而且这种问题在不同编译器下表现还不一样。

5. 避坑与排查:五类跑通之后才会暴露的问题

能编译、能跑通自带样例,不代表实验能拿分。下面这几条是我踩过和见过次数最多的,每条都按现象、原因、解决三段来说。

第一,文件最后一个 token 凭空消失。现象是tests/case_basic.c结尾的return 0;只输出到0,分号没出来。原因是主循环里lx_getc在读到 EOF 后没有把cur重置,next_token下一次进入时直接返回TOK_EOF,导致最后一个字符既没被消费也没被产出。解决方式是在lx_getc里把“读入失败”和“读到文件尾”区分开,用一个finished标志位标记,保证 EOF 之前的内容全部产出,最后再返回一次TOK_EOF。

第二,关键字被识别成普通标识符。现象是if、while全部输出成(ID, if)。原因通常是判断顺序写反了:先做了一次“首字符是字母”的快速判断,然后直接返回TOK_ID,根本没走到关键字表。解决办法是把关键字查表放在标识符收词之后,作为唯一出口,代码里只保留return lookup_keyword(tk.lexeme)这一条路径。

第三,块注释里的*/在字符串中被误判。现象是含"*/"的字符串常量把后面半段代码全当成注释跳掉了。原因是扫描器只有一套全局状态,没有“字符串内”这个子状态。解决方式是给Lexer加一个in_string标志,在字符串分支里遇到\要转义下一个字符,遇到*/不触发注释结束判断,退出字符串后再恢复正常逻辑。

第四,Windows 下换行导致列号整体偏一。现象是同一个文件在 Linux 上第 3 行第 5 列报错,在 Windows 上报成第 3 行第 6 列。原因是\r\n里\r被当成普通字符增加了列计数。解决办法是在空白跳过逻辑里把\r和\n一起处理,遇到\r时只消费不增列,或者在读文件阶段统一把\r\n转成\n。后者更省事,但要注意别破坏字符串字面量里的\r。

第五,浮点数1..2把点吃掉导致后面错位。现象是1..2的输出里只剩一个数字 token 和两个数字,中间的点不见了。原因是小数分支只判断了“当前是.”,没有判断“下一个是不是数字”,遇到连续两个点时会消费第一个点并进入小数循环,然后第二个点又被主循环当成下一个 token 的起始字符处理。解决方式就是 3.4 节里的写法:小数点必须后跟数字才构成小数,否则把点留给主循环,由它走非法字符或成员访问分支。

6. 二次开发:把它改成语法分析器能直接消费的前端

实验一交完,很多人会把代码一放就不再动;真正值得做的一步改造,是把它从“打印 token 的小工具”变成“可被调用的前端库”。具体做法有两处:把Token next_token(Lexer*)改成int next_token(Lexer*, Token *out),用返回值表示成功与否,避免调用方拿到一个含义不明的结构体;再在外面套一层TokenStream,一次性把整个文件扫描进数组,同时提供peek()和consume()两个操作给语法分析器用。

/* token_stream.c:一次性扫完,语法分析器按需取,回退成本为零 */ typedef struct { Token *buf; /* 连续存放的 token 数组 */ int len; /* 已产出数量 */ int cap; /* 容量,按 2 倍扩容 */ int pos; /* 当前读取位置,peek 不改变它 */ } TokenStream; int ts_peek(TokenStream *ts, Token *out) { if (ts->pos >= ts->len) return 0; /* 已经到 EOF 之后 */ *out = ts->buf[ts->pos]; return 1; } int ts_consume(TokenStream *ts, Token *out) { if (ts->pos >= ts->len) return 0; *out = ts->buf[ts->pos++]; return 1; }

这样改的好处很直接:语法分析里递归下降经常需要“看一眼下一个 token 再决定走哪个产生式”,如果每次都只能前进不能回退,你就得在语法分析器里自己维护一个Token缓存变量,越写越乱。全量扫描进数组之后,peek只是读下标,几乎零成本,代码也更短。代价是内存占用,但课程实验的源文件一般也就几百行,完全不是问题。

验证方法建议用“快照对比”而不是人眼核对:把tests/下每个用例的期望输出存成同名的.expected文件,跑一遍把实际输出 diff 进去,任何一次规则改动都能立刻看出影响了哪些用例。再补一条自动检查——扫描结束后len必须大于 0,且最后一个 token 必须是TOK_EOF——能把第 5 章里提到的“最后一个 token 消失”问题直接拦住。

我自己的教训是:当年第一次做这个实验,只写了main.c和lexer.c,所有输出都直接printf在扫描函数里,后来要加一个“统计各类 token 数量”的功能,得把整段扫描逻辑复制一遍。从那以后我每次写扫描器,都先把Token结构和next_token的签名单独定下来,再把输出模块隔开,宁可一开始多写一个文件,也不让打印逻辑混进状态机。希望帮到你。

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

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

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

立即咨询