☰
手工构造TINY语言词法分析器:从状态机到C代码实现
2026/10/17 2:58:23 网站建设 项目流程

简介:面向编译原理学习者,这份资源围绕手工构造TINY语言词法分析器展开,适合正在学习词法分析、DFA状态机以及C/C++实现的本科生或自学者。TINY语言语法简洁,将其词法规则映射为状态转移过程,能帮助读者直观理解关键字、标识符、常量与运算符的识别原理。压缩包共10个文件,大小仅779KB,包含C++源代码(tinyscan.cpp)、Dev-C++工程文件、Makefile、编译生成的可执行程序及对象文件,另有多个.tny测试用例和一份实验报告docx,覆盖源码编写、构建到结果验证的完整链路。实验报告详细记录了DFA设计、状态转换、标记识别等关键步骤,并附有测试输出,可直接对照代码学习调试。目前已有1330人浏览/学习,对深入掌握编译器第一阶段原理和提升手写分析器能力都很有参考价值。

1. 手工构造词法分析器:先写状态机,再写代码

把TINY语言的词法分析器从零开始手工写一遍,是编译原理课程里最容易被低估的一个实验。很多人觉得“不就是读字符、判断一下是什么单词吗”,结果一上手才发现:注释怎么跨行、:=和<=这种两字符记号怎么不回退错乱、读到文件末尾注释还没闭合怎么办——每个都是能让你调试一下午的细节。这次拆的资源就是一份C/C++实现的手工词法分析器,不依赖lex/flex,直接用状态转换图和代码硬写。它适合两类人:一类是正在做编译原理课设、需要一份能讲清楚原理的参考实现的学生,另一类是工作后想补编译器基础、想看看“教材里的状态图怎么变成真实数组和switch”的开发者。这份资源解决的不是“能跑就行”,而是让你把每个字符的流向都看得明明白白。

2. TINY语言的记号世界:先定Token表,再谈DFA

2.1 为什么TINY适合做第一个词法分析器

TINY语言是编译器教材里常用的一种迷你语言,它的词法规则刻意做得很小,但五脏俱全。一个典型的TINY程序大概长这样:

{ 计算阶乘 } read n; fact := 1; while n > 0 do fact := fact * n; n := n - 1 end; write fact

这个资源里的分析器要识别的东西并不多:关键字(if、then、else、repeat、until、read、write,有些版本还有while、do、end)、标识符、数字常量、运算符和界符(+ - * / = < ( ) ; :=),以及{...}形式的注释。相比C语言要处理预处理指令、字符串转义、宽字符,TINY的记号种类大概只有十几类,非常适合作为你逐个搞清楚“DFA怎么落地”的第一站。

在打开代码之前,建议你先自己拿一张纸,把每个记号对应的正则式写出来。TINY的标识符一般是“字母开头的字母数字串”,数字是“无符号整数”,注释是“左花括号到右花括号之间的任意内容”。你会发现,除了注释,其他记号几乎都是“一两个字符就能定夺”的。这份资源里把每个记号映射成枚举值,这一步虽然机械,却是后面所有代码的地基——枚举值顺序直接决定语法分析器那边看到的是“ID还是NUM”。

2.2 两个最容易画错的状态:两字符运算符和注释

状态转换图里最经典的两个“坑位”,在这份资源里处理得比较讲究。

第一个是:=和<=。当你读到:时,你不能立刻确定它是一个冒号记号还是赋值号的开头,必须再读一个字符:如果是=,那就是:=,否则就得把多读的那个字符“还回去”。很多初版实现会在这里翻车——把字符读走了就丢了,导致下一个token凭空少一个字符。我见过不少同学在while n > 0 do这一行上反复出问题,因为大于号后面紧跟空格和数字,回退逻辑一错,数字就变成>0的一部分了。

第二个是注释。TINY的注释是{...},注释内部可以包含任意字符,包括换行。这意味着你的词法分析器不能在读到一个换行符时就结束一个token。更麻烦的是,如果文件末尾都没有出现右花括号,分析器必须给出“注释未闭合”的错误,而不能死循环。这份资源在这两处都做了细致处理:两字符记号使用一个字符回退指针,注释则设了“当前行号记录”和“读到EOF强制报错”两个保护逻辑。你在读代码时可以重点看这两个分支,它们是整个实现里最值得模仿的部分。

2.3 从正则到状态转换图再到枚举值

在动手写代码前,建议你先画一个总的状态转换图,把所有记号的状态合并成一张图而不是每个记号一张图。资源里的做法是:用一个全局的state变量表示当前状态,主循环不断地读入下一个字符,根据(state, char)决定跳到哪个状态或返回哪个Token。这样做的好处是,最后你得到的是一张可以打印出来的表,方便你检查和别人的lex输出逐行对比。

下面这个表是我按这份资源的设计思路整理出的核心记号映射,可以作为你阅读代码时的对照表:

Token类型触发模式返回值枚举备注
标识符[a-zA-Z][a-zA-Z0-9]*TID结束后查关键字表
数字[0-9]+TNUM不认负数,负号是运算符
赋值号:=TASSIGN需要读两个字符
加/减/乘/除+ - * /TPLUS/TMINUS/TMUL/TDIV单字符,无需前瞻
大于>TGT不包含>=(部分版本没有)
小于<TLT部分实现会把它与<=合并
左右括号( )TLPAREN/TRPAREN单字符
分号;TSEMI单字符
注释{...}跳过换行或EOF时特殊处理
EOF文件结束TEOF主循环退出条件

这张表的顺序建议和代码中的enum顺序保持一致。我自己整理这种表时习惯把“需要前瞻”的记号放在一起,调试时方便数回退步数。

3. 从状态图到C代码:两种实现风格与缓冲区设计

3.1 核心数据结构:Token类型与全局状态

打开这份资源里的主头文件,最先看到的通常是一段枚举定义。常见做法是把所有Token类型放在一个.h文件里,供词法分析器和后续的语法分析器共同引用:

/* token.h */ typedef enum { TEOF, TID, TNUM, TPLUS, TMINUS, TMUL, TDIV, TLT, TGT, TASSIGN, TLPAREN, TRPAREN, TSEMI, TIF, TTHEN, TELSE, TREPEAT, TUNTIL, TREAD, TWRITE } TokenType; typedef struct { TokenType type; char lexeme[256]; /* 实际抓到的单词文本 */ int line; /* 行号,用于报错 */ int value; /* 如果是数字,存整数值 */ } Token;

Token结构体里的lexeme字段非常关键。很多初学实现只保存type,结果语法分析器想打印“哪一行出错了”都做不到。这资源里把单词原文和行号都存了下来,调试阶段你能直接看到“第3行附近读到了什么内容”。

它的主循环本质就是一个巨大的switch (state),每个case处理一个状态。我见过不少实现是“每读一个字符就switch一次”,但这份资源更偏向“每个状态一个循环,直到状态跳转为止”。后者的好处是逻辑更接近状态图,坏处是代码重复多一点——不过对于TINY这种小语言,重复完全可接受。

3.2 直接编码风格:每个状态一个switch分支

第一版实现通常是“直接编码风格”,即把状态转换图毫无保留地翻译成代码。下面这段是这类实现中处理标识符和数字的典型模式:

/* lexer.c —— 直接编码风格片段 */ int state = 0; char ch; while ((ch = getNextChar()) != EOF) { switch (state) { case 0: /* 初始状态:跳过空白,识别起点 */ if (isspace(ch)) { /* 保持state = 0 */ } else if (isalpha(ch)) { lexbuf[0] = ch; len = 1; state = 1; /* 进入标识符状态 */ } else if (isdigit(ch)) { numval = ch - '0'; state = 2; /* 进入数字状态 */ } /* 其他单字符运算符在这里直接返回 */ break; case 1: /* 标识符状态:继续读字母或数字 */ if (isalnum(ch)) { lexbuf[len++] = ch; } else { lexbuf[len] = '\0'; ungetChar(ch); /* 把多读的字符还回去 */ return checkKeyword(lexbuf); /* 查关键字表 */ } break; case 2: /* 数字状态 */ if (isdigit(ch)) { numval = numval * 10 + (ch - '0'); } else { ungetChar(ch); return createNumToken(numval); } break; } }

逻辑说明:这代码的核心思想是“读了一个字符先不急着做决定,而是根据当前状态决定它该被吸收进当前token,还是该触发token结束”。ungetChar把多读的那个字符还给缓冲区,让下一个token从它开始。参数上需要注意:getNextChar和ungetChar必须成对使用,否则两次unget会把字符顺序搞乱。

这种“直接编码”风格的问题在于,状态一多,switch分支会膨胀,而且你很难直观地看出“哪些状态之间有过渡”。但它依旧是最好的第一版。

3.3 表驱动风格:把状态图变成二维数组

进阶一点的做法是表驱动。资源里的第二种实现把状态转换关系整理成一个二维数组trans[state][charClass],每个元素是“下一个状态”。如果值是-1,表示当前状态在该字符下没有合法迁移,那就该结束当前token了。

/* table_driven_lexer.c —— 表驱动风格核心 */ /* 字符分类:0=字母, 1=数字, 2=空格, 3=运算符, 4=其他 */ static int trans[][5] = { /* 状态0:初始 */ { 1, 2, 0, 5, -1 }, /* 状态1:标识符 */ { 1, 1, -1, -1, -1 }, /* 状态2:数字 */ { -1, 2, -1, -1, -1 }, /* 状态3:运算符已确认 */ { -1, -1, -1, -1, -1 }, /* 状态4:注释开始 */ { 4, 4, 4, 4, 4 }, /* 状态5:第一字符是运算符,等待确认 */ { -1, -1, -1, -1, -1 } }; int nextState = trans[currentState][classify(ch)]; if (nextState == -1) { /* 结束当前token的处理 */ } else { currentState = nextState; }

逻辑说明:表驱动把“状态转移规则”和“动作代码”分离,规则全在数据里。如果想改词法规则,只需要动数组,不用动控制流。缺点是动作代码需要额外挂接,处理“进入状态时该做什么”会比直接编码绕一些。参数说明:这里classify(ch)函数负责把字符归类成0-4五类,是表驱动实现里最容易出错的地方——分类函数一错,整张表就全错了。

3.4 缓冲区设计:getNextChar和ungetChar的取舍

手工词法分析器绕不开一个基础问题:从哪里读字符?这份资源用了最常规的“整行读入+字符指针滚动”方案,而不是fgetc一个字符一个字符读文件,因为后者的系统调用开销大,也不方便实现“回退”。

/* buffer.c —— 行缓冲读取实现 */ static char linebuf[512]; static int bufpos = 0; static int buflen = 0; char getNextChar(void) { if (bufpos >= buflen) { /* 读下一行到缓冲区 */ if (fgets(linebuf, sizeof(linebuf), sourceFile) == NULL) { return EOF; } buflen = (int)strlen(linebuf); bufpos = 0; /* 去掉末尾换行的处理 */ if (buflen > 0 && linebuf[buflen - 1] == '\n') { linebuf[buflen - 1] = '\0'; buflen--; } } return linebuf[bufpos++]; } void ungetChar(char c) { if (bufpos > 0) { bufpos--; } }

逻辑说明:getNextChar每次从当前行指针位置取一个字符,取完了就换下一行;ungetChar只是把bufpos回退一位。这个方案的关键在于:你只能回退一个字符,因为回退超过一个字符的话,多退的部分可能跨行,处理起来就麻烦了。TINY的两字符运算符最多需要回退一位,所以这个限制完全够用。参数说明:缓冲区大小512是经验值,TINY的行一般不会超,但如果你改语言支持超长行,记得扩大到2048或直接动态分配。

4. 编译运行与验证通路:从Makefile到与lex对拍

4.1 编译环境与命令行参数

这份资源提供的是标准C代码,不含平台特定API。在Linux下用gcc直接编译即可:

gcc -Wall -g -o lexer lexer.c buffer.c token.c main.c ./lexer test.tny

如果你的系统是Windows,用Dev-C++或Visual Studio建立空项目后,把.c文件全部加进去就能编译。注意-Wall这个编译选项一定要开,它能把“变量未初始化”“类型不匹配”这类问题提前暴露出来。

main.c的职责是打开指定文件,逐行调用getToken(),把每个token的type和lexeme打印到控制台。资源里通常还带一个-v选项,打开后打印每个字符的读入过程,用于调试状态转换。我建议你拿到资源后第一次运行就用-v跑一个最简单的程序,这样能看到“读入f→进入状态1→读入a→继续状态1”,对整个分析过程建立直观感受。

4.2 一个最小测试用例的期望输出

用下面这个极小程序来验证词法分析器是否正常工作:

{ test } read a; write a + 10; if a < 100 then a := a * 2

如果你成功跑通,输出应该类似:

(1, TREAD: read) (2, TID: a) (3, TSEMI: ;) (4, TWRITE: write) (5, TID: a) (6, TPLUS: +) (7, TNUM: 10) (8, TSEMI: ;) (9, TIF: if) (10, TID: a) (11, TLT: <) (12, TNUM: 100) (13, TTHEN: then) (14, TID: a) (15, TASSIGN: :=) (16, TID: a) (17, TMUL: *) (18, TNUM: 2)

注意这里的行号是token出现的行号,不是文件行号。从第2行读取时,行号就变成2了。如果你的输出里出现了ERROR:混合在token流里,不用慌,先定位是哪个token触发的错误,再回头看状态图。

4.3 对拍验证:手写版如何自我证明正确

大多数同学验证词法分析器正确性的方式是“写几个程序跑跑看”,但这远远不够。更专业的验证手段是“对拍”:用lex/flex生成一个“标准答案”版分析器,拿同一个测试文件给两个分析器跑,逐行对比输出。

# 用flex生成标准答案 flex tinylex.l gcc -o lex_ref lex.yy.c -lfl ./lex_ref test.tny > ref.out # 手写版输出 ./lexer test.tny > mine.out # 对拍 diff ref.out mine.out

diff没有任何输出,表示两份token流完全一致。如果出现不一致,diff会指出是哪一行token不一样,然后你拿着行号回查状态图。对拍时建议故意构造一些边界用例:连续注释、注释紧接着标识符、:=后被数字跟随、多个空格混排。网上能找到TINY语言的参考教学文,里面有覆盖各种边界的测试用例列表,你可以直接拿来当素材。

5. 避坑手记:词法分析器最常见的五个翻车现场

5.1 token丢字符或重复字符

现象::=识别出来后,紧接着的标识符丢了第一个字符;或者反之,前一个token尾巴上有两个字符。

原因:ungetChar用多了或与getNextChar不成对。比如处理:=时读了:、再读=、然后回退一次,接着主循环又回退一次,相当于回退了两步,把原本不属于当前token的字符悄悄覆盖掉了。

解决:给ungetChar加一个计数器ungetCount,禁止超过1次连续回退,并在每次getNextChar时检查两者配对关系。我一般还会在getToken()入口断言bufpos的合法性,保证每次回到主循环时缓冲区指针都在预期位置。

5.2 注释一直读到文件末尾都没有闭合

现象:测试文件少了右花括号,程序没有报错,反而把后面的代码全吞了,或者卡死在某一行。

原因:注释状态下没有检查EOF。多数实现把“读入字符”写成while (ch != '}') { ch = getNextChar(); },但当getNextChar()返回EOF时,这个循环会一直转下去,因为EOF不等于},造成死循环。

解决:注释循环里必须检查EOF,一旦读到EOF就报错并返回一个专门错误Token。代码里应该长这样:

/* 注释跳过逻辑 */ ch = getNextChar(); while (ch != '}') { if (ch == EOF) { fprintf(stderr, "行%d: 注释未闭合\n", lineNo); return TERROR; } if (ch == '\n') lineNo++; ch = getNextChar(); }

5.3 关键字被当成普通标识符

现象:输入了if,输出却是TID而不是TIF。

原因:要么忘了查关键字表,要么关键字表里的单词不对。常见错误是把关键字表建成“数组”却忘了它是需要二分查找的,又或者关键字表里存的是“带引号的字符串”,比较时没有去掉引号。

解决:关键字表必须和“标识符识别”分开。先按标识符规则收集lexeme,然后去查表;查表可以用一个最朴素的strcmp循环,因为关键字只有十来个,线性查找完全够。查表后如果是关键字,就返回对应的枚举值;否则返回TID。注意TINY是大小写敏感的,IF不是关键字。

5.4 数字后面跟字母,程序直接报错

现象:输入123abc,分析器返回TNUM和TID两个token,但某些版本直接崩溃或报错。

原因:有些实现的数字状态里遇到字母时,直接调用atoi(lexbuf)转换数字值,然后返回数字。但如果当前字符不是数字也不是运算符,属于“非法字符”,需要走错误分支,而很多初版的错误分支只打印信息却忘了回退字符,导致abc部分被吞。

解决:状态2(数字状态)中,如果遇到字母,正确的行为是把当前数字token返回,但把字母作为下一个token的第一个字符保留。也就是说,数字状态应当把“字母字符”视为结束条件,然后ungetChar,让上层重新从初始状态处理它。不要直接报错,因为TINY允许1a这类写法吗?虽然语义荒谬,但词法层面是合法的“数字后紧跟标识符”,语法分析阶段才会暴露问题。

5.5 Windows环境下的回车换行干扰

现象:在Linux下跑得好好的代码,放到Windows上编译运行后,每个标识符后面多一个\r字符,或者token末尾出现奇怪的符号。

原因:Windows文本文件的行尾是\r\n,而Linux是\n。在Windows上以文本方式打开文件时,fgets读到的行尾应该是\n,可如果文件是以二进制方式打开的,或者文件来自Git仓库且autocrlf配置不当,行尾就会夹杂\r,词法分析器把\r当成非法字符。

解决:在getNextChar或fgets后面统一处理\r。一行代码就够:

if (ch == '\r') ch = getNextChar(); /* 跳过 \r */

或者更规范一点:在读取行缓冲后,把所有\r字符从字符串里去掉。这个坑属于典型的“环境问题”,遇到时先别怀疑逻辑,检查一下十六进制字节。

6. 进阶技巧:让词法分析器顺着语法分析器的性子来

如果你接下来要写递归下降语法分析器,建议在动手前先调整词法分析器的一个小习惯:把getToken()设计的从“内部不断拉取字符”改成“需要时才调用一次”。也就是说,语法分析器的每个函数在需要下一个token时,调一次getToken(),然后把返回值存进一个lookahead变量。语法分析器可以“超前看”一个token,但不能超前读两个。这个设计能避免语法分析器因为多看了token而卡在回溯上。

我习惯在词法分析器里额外提供一个match(TokenType expected)辅助函数,让语法分析器的代码像下面这样简洁:

/* parser.c 中的辅助函数 */ int match(TokenType expected) { if (lookahead.type != expected) { fprintf(stderr, "第%d行: 期望%s,实际是%s\n", lookahead.line, tokenName(expected), tokenName(lookahead.type)); return 0; } lookahead = getToken(); return 1; }

这样一来,每个语法函数的调用都变成“检查当前token是否是预期的,然后消费掉,再取下一个”。词法分析器本身不用改,但全局流控更清晰。这份资源里没有把这层封装做进去,但你可以自己加上,十分钟就够。

验证词法分析器最终是否合格,我会强制自己做一遍完整的“三跑测试”:第一跑跑一个只有注释的空文件,看是否正常返回一个TEOF;第二跑跑满运算符的表达式,看两字符运算符是否都正确;第三跑故意构造未闭合注释,看是否给出带行号的错误。三跑全过后,再把输出与flex对拍一次。从那以后我每次调词法分析器,都强制先走这三步,再去看其他逻辑。别小看这十分钟的自检,它能帮你省下后面查语法分析器半天的时间。希望帮到你。

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

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

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

立即咨询