☰
手写词法分析器 vs Flex:从原理到避坑的编译器开发实战
2026/10/3 2:45:32 网站建设 项目流程

简介:这份资源是面向编译原理学习者与课程实践者的词法分析完整实现包,聚焦C语言文法到MIPS汇编代码的编译流程,适合正在做编译器课程设计或想深入理解前端原理的读者。压缩包共19个文件,以10个h头文件与7个cpp源文件为主,另有2个txt测试用例,整体约30KB,体量轻便但结构完整。代码覆盖词法分析、语法解析、中间代码生成、MIPS目标代码生成、优化与寄存器管理等关键环节,并配有错误处理与符号表相关模块,能帮助读者理清编译器各阶段的协作关系。目前已有121人学习下载,可作为课程作业参考或自研编译器的骨架,便于对照理解标记识别、抽象语法树构建与汇编指令映射等核心知识点。

1. 词法分析在编译器里到底卡在哪:从一段报错说起

很多人第一次写编译器,卡住的地方不是语法树,也不是代码生成,而是词法分析。你写了一个while循环去逐字符扫描,结果遇到1.5e-10这种浮点字面量直接翻车;或者把>=拆成了>和=,导致语法分析阶段报了一个完全看不懂的错。更常见的是,你明明照着某本经典教材写了 DFA,但一碰到中文标识符、嵌套注释、字符串里的转义字符,整个状态机就崩了。

词法分析(Lexical Analysis)是编译器的第一个阶段,干的事说白了一句话:把源程序的字符流,切成有意义的记号(Token)流。听起来简单,但它是整个编译器里最容易被低估的模块。你后面语法分析写得再漂亮,Token 流错了,全是白搭。这篇东西不讲教科书上的正则表达式推导,而是从一个一线工程师的角度,把词法分析从原理到落地、从手写扫描器到用工具生成、从参数配置到踩坑排查,完整走一遍。适合正在学编译原理但不知道怎么动手的人,也适合已经写了半个编译器、卡在词法阶段想找参考实现的人。热词里那些“编译器开发”“gcc 编译器的学习和使用”“编译器优化”,底层都绕不开词法分析这一关。

2. 手写词法分析器:从字符流到 Token 流的最小实现

2.1 为什么我不推荐一上来就用 Lex/Flex

很多人学词法分析,第一反应是找工具。Flex、Lex、JFlex 这些词法分析器生成器确实成熟,输入正则规则,输出 C/Java 代码,看起来省事。但我的血泪经验是:如果你还没手写过至少一个完整的词法分析器,直接用生成器,出了问题你根本不知道从哪查。

生成器的黑匣子特性体现在几个地方。第一,它生成的状态机转移表你看不懂,报错信息只告诉你“在某某行遇到未匹配字符”,但不告诉你状态机当时在哪个状态、为什么走到那。第二,优先级和最长匹配规则是隐式的,你写的规则顺序稍微一变,行为就完全不同。第三,调试困难,你没法在状态转移的每一步打日志。

所以我的建议是:先手写一个,哪怕只支持整数、标识符、四则运算符和括号。手写一遍之后,你再去用 Flex,就能看懂它生成的yylex()到底在干什么。

2.2 手写扫描器的核心结构:双指针 + 状态枚举

手写词法分析器最朴素也最可靠的结构,是两个指针加一个状态枚举。pos指向当前扫描位置,start指向当前 Token 的起始位置。每次循环从start开始,根据第一个字符决定进入哪个分支。

下面是一个能跑的最小实现,支持整数、浮点数、标识符、关键字、单字符运算符和双字符运算符:

# lexer.py - 最小手写词法分析器 from enum import Enum, auto class TokenType(Enum): INT = auto() FLOAT = auto() IDENT = auto() KEYWORD = auto() OP = auto() EOF = auto() KEYWORDS = {"if", "else", "while", "return", "int", "float"} class Token: def __init__(self, type_, value, line, col): self.type = type_ self.value = value self.line = line self.col = col def __repr__(self): return f"Token({self.type.name}, {self.value!r}, L{self.line}:C{self.col})" class Lexer: def __init__(self, src): self.src = src self.pos = 0 self.line = 1 self.col = 1 def _peek(self, offset=0): idx = self.pos + offset return self.src[idx] if idx < len(self.src) else '\0' def _advance(self): ch = self.src[self.pos] self.pos += 1 if ch == '\n': self.line += 1 self.col = 1 else: self.col += 1 return ch def tokenize(self): tokens = [] while self.pos < len(self.src): ch = self._peek() # 跳过空白 if ch in ' \t\r\n': self._advance() continue start_line, start_col = self.line, self.col # 标识符或关键字 if ch.isalpha() or ch == '_': buf = [] while self._peek().isalnum() or self._peek() == '_': buf.append(self._advance()) word = ''.join(buf) ttype = TokenType.KEYWORD if word in KEYWORDS else TokenType.IDENT tokens.append(Token(ttype, word, start_line, start_col)) continue # 数字:整数或浮点 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()) tokens.append(Token(TokenType.FLOAT, ''.join(buf), start_line, start_col)) else: tokens.append(Token(TokenType.INT, ''.join(buf), start_line, start_col)) continue # 双字符运算符 two = ch + self._peek(1) if two in ('==', '!=', '<=', '>=', '&&', '||'): self._advance(); self._advance() tokens.append(Token(TokenType.OP, two, start_line, start_col)) continue # 单字符运算符 if ch in '+-*/=<>!&|(){};,': self._advance() tokens.append(Token(TokenType.OP, ch, start_line, start_col)) continue raise SyntaxError(f"Unexpected char {ch!r} at L{self.line}:C{self.col}") tokens.append(Token(TokenType.EOF, '', self.line, self.col)) return tokens

这段代码的逻辑很直白:外层while每次处理一个 Token,内层while负责吃掉属于同一个 Token 的连续字符。_peek(offset)做前瞻,_advance()推进位置并维护行列号。关键字识别用的是查表法,KEYWORDS集合里有的就是关键字,没有的就是普通标识符。

参数方面,_peek的offset默认 0,表示看当前字符;传 1 表示看下一个。这个设计是为了处理双字符运算符和浮点数的小数点判断。line和col的维护在_advance里做,遇到换行时line加一、col归零。这两个值后面语法分析报错时非常有用,别省。

2.3 最长匹配原则与前瞻的边界

词法分析有一条铁律:最长匹配。也就是说,当>和>=都能匹配时,选>=。上面代码里先检查双字符运算符,再检查单字符,就是在落实这条规则。

但最长匹配有个边界问题:a+++b应该切成a、++、+、b还是a、+、++、b?标准做法是从左到右贪心,所以是a、++、+、b。你的扫描器必须严格按这个顺序来,不能先看后面。

前瞻的深度也要控制。大多数语言的词法只需要 1 到 2 个字符的前瞻。如果你发现需要 3 个以上,大概率是语言设计有问题,或者你该用正则表达式引擎了。我一般会把手写扫描器的前瞻限制在 2 个字符以内,超过就说明规则该重新梳理。

提示:手写扫描器时,把_peek和_advance做成独立方法,后面加新 Token 类型时不用改核心循环,只加分支就行。

3. 用 Flex 生成词法分析器:规则文件怎么写、怎么调

3.1 Flex 规则文件的三段式结构

Flex 的输入文件是.l后缀,分三段,用%%分隔。第一段是声明和选项,第二段是规则,第三段是用户代码。很多人第一次写.l文件,把规则写反了,导致匹配行为完全不对。

下面是一个能识别整数、浮点数、标识符、关键字和运算符的 Flex 规则文件:

%{ #include <stdio.h> #include <stdlib.h> int line = 1; %} %option noyywrap DIGIT [0-9] ID [a-zA-Z_][a-zA-Z0-9_]* FLOAT {DIGIT}+\.{DIGIT}+ %% "if" { printf("KEYWORD(if)\n"); } "else" { printf("KEYWORD(else)\n"); } "while" { printf("KEYWORD(while)\n"); } "return" { printf("KEYWORD(return)\n"); } "int" { printf("KEYWORD(int)\n"); } "float" { printf("KEYWORD(float)\n"); } {FLOAT} { printf("FLOAT(%s)\n", yytext); } {DIGIT}+ { printf("INT(%s)\n", yytext); } {ID} { printf("IDENT(%s)\n", yytext); } "==" { printf("OP(==)\n"); } "!=" { printf("OP(!=)\n"); } "<=" { printf("OP(<=)\n"); } ">=" { printf("OP(>=)\n"); } "&&" { printf("OP(&&)\n"); } "||" { printf("OP(||)\n"); } [+\-*/=<>!&|(){};,] { printf("OP(%s)\n", yytext); } [ \t\r]+ { /* 跳过空白 */ } \n { line++; } . { printf("ERROR: unexpected char '%s' at line %d\n", yytext, line); } %% int main(int argc, char **argv) { if (argc > 1) { yyin = fopen(argv[1], "r"); if (!yyin) { perror("fopen"); return 1; } } yylex(); return 0; }

第一段里%option noyywrap告诉 Flex 不需要yywrap函数,单文件扫描时常用。DIGIT、ID、FLOAT是命名正则,后面用{}引用。第二段是规则,每条规则左边是正则,右边是动作。第三段是main函数,把yyin指向输入文件后调用yylex()。

3.2 规则顺序为什么决定生死

Flex 的匹配策略是:在所有能匹配当前输入的正则里,选匹配长度最长的;如果长度相同,选在规则文件里出现最早的。这个“长度优先,顺序次之”的规则,决定了你写规则的顺序。

上面代码里,关键字规则写在标识符规则前面。因为if既能匹配"if"也能匹配{ID},长度相同,Flex 选先出现的,所以if被识别为关键字。如果你把{ID}写在前面,if就会被识别成标识符,后面语法分析直接崩。

浮点数规则{FLOAT}写在整数{DIGIT}+前面,也是同样的道理。1.5既能匹配{FLOAT}也能匹配{DIGIT}+(只匹配1),但{FLOAT}匹配更长,所以优先。即使顺序反过来,Flex 也会选{FLOAT},但显式写前面更清晰。

3.3 编译和调试 Flex 生成代码的常用命令

写完.l文件后,用flex生成 C 代码,再用gcc编译。下面是完整流程:

# 生成词法分析器 C 代码 flex -o lex.yy.c lexer.l # 编译,链接 flex 库 gcc -o lexer lex.yy.c -lfl # 运行,传入测试文件 ./lexer test.c

如果编译时报undefined reference to yywrap,说明没加%option noyywrap,或者需要链接-lfl。如果运行时报flex scanner jammed,说明输入里有规则匹配不到的内容,检查最后那条.规则有没有写。

调试时我一般会在规则动作里加fprintf(stderr, ...),把yytext和yyleng打出来。yytext是当前匹配的字符串,yyleng是长度。这两个变量是 Flex 内置的,不用声明。

注意:Flex 生成的代码默认用yyin作为输入流,如果你要扫描字符串而不是文件,需要用yy_scan_string()把字符串绑定到扫描器上。

4. 词法分析避坑:5 个让我加班到凌晨的坑

4.1 坑一:浮点数指数部分被吞掉

现象:输入1.5e-10,扫描器输出FLOAT(1.5)、IDENT(e)、OP(-)、INT(10),语法分析报错。

原因:浮点数正则只写了{DIGIT}+\.{DIGIT}+,没考虑科学计数法的e或E加正负号加数字。

解决:把浮点数正则改成{DIGIT}+\.{DIGIT}+([eE][+-]?{DIGIT}+)?,同时支持1e10这种没有小数点的形式,再加一条{DIGIT}+[eE][+-]?{DIGIT}+。手写扫描器里,在吃掉小数点后的数字后,检查下一个字符是不是e或E,是的话继续吃指数部分。

4.2 坑二:注释里的换行没算进行号

现象:多行注释/* ... */跨了 5 行,但后面报错的行号还是注释前的行号,定位完全错位。

原因:跳过注释时只推进了pos,没有更新line和col。

解决:所有推进字符的地方都必须走_advance(),不能在跳过注释时直接pos += n。如果为了性能要批量跳过,也得手动数换行符个数,更新line。我一般会在_advance里统一处理,注释跳过也逐字符走,性能损失可以忽略。

4.3 坑三:字符串字面量里的转义引号导致扫描提前结束

现象:输入"hello \" world",扫描器在\"处认为字符串结束,后面的world"被当成标识符和未匹配字符。

原因:字符串扫描逻辑只检查了",没检查前面有没有反斜杠。

解决:扫描字符串时,遇到\就跳过下一个字符,不管它是不是"。手写扫描器里加一个escaped标志,或者直接if self._peek() == '\\': self._advance()。Flex 里用\"([^"\\]|\\.)*\"这个正则,\\.匹配反斜杠加任意字符。

4.4 坑四:中文标识符被当成非法字符

现象:源码里有中文变量名,扫描器直接抛Unexpected char。

原因:标识符判断只用了isalpha(),而 Python 的isalpha()对中文返回True,但 C 的isalpha()只认 ASCII 字母。

解决:如果语言设计允许 Unicode 标识符,手写扫描器里用ch.isalpha()或ch == '_'判断起始,后续用ch.isalnum()。Flex 里需要显式写 Unicode 范围,或者用[a-zA-Z_\x80-\xff]这种字节级匹配。但要注意,Flex 默认按字节扫描,多字节 UTF-8 字符会被拆成多个字节,需要额外处理。

4.5 坑五:关键字表更新后忘了同步词法规则

现象:语言新增了foreach关键字,语法分析里加了对应规则,但词法分析里foreach还是被识别成标识符,语法分析报“意外的标识符”。

原因:关键字识别在词法阶段,新增关键字必须同时更新词法分析器的关键字表或 Flex 规则。

解决:把关键字列表抽成一个独立的配置文件或头文件,词法分析器和语法分析器都从同一个地方读。手写扫描器里KEYWORDS集合单独定义,Flex 里用%{ %}段定义宏或者用脚本生成规则。每次加关键字,只改一处。

5. 词法分析的验证与进阶:怎么确认你的 Token 流是对的

5.1 用单元测试锁住 Token 流

词法分析器写完后,最怕的是改了一处规则,别的地方悄悄坏了。我一般会写一组单元测试,把输入和期望的 Token 序列硬编码进去,每次改完跑一遍。

# test_lexer.py from lexer import Lexer, TokenType def test_basic(): src = "int x = 1 + 2;" tokens = Lexer(src).tokenize() types = [t.type for t in tokens] values = [t.value for t in tokens] assert types == [ TokenType.KEYWORD, TokenType.IDENT, TokenType.OP, TokenType.INT, TokenType.OP, TokenType.INT, TokenType.OP, TokenType.EOF ] assert values == ["int", "x", "=", "1", "+", "2", ";", ""] def test_float(): src = "3.14 1.5e-10" tokens = Lexer(src).tokenize() floats = [t.value for t in tokens if t.type == TokenType.FLOAT] assert floats == ["3.14", "1.5e-10"] def test_two_char_op(): src = "a >= b && c != d" tokens = Lexer(src).tokenize() ops = [t.value for t in tokens if t.type == TokenType.OP] assert ops == [">=", "&&", "!="]

这三个测试覆盖了基本 Token、浮点数科学计数法和双字符运算符。跑pytest test_lexer.py,全绿才算过关。测试用例不用多,但每个边界情况都要有一个。

5.2 用 Token 流反推源程序

另一个验证手段是“往返测试”:把 Token 流重新拼成字符串,看能不能还原源程序(忽略空白和注释)。这个测试能发现 Token 值丢失、拼接顺序错误等问题。

def test_roundtrip(): src = "if x >= 10 { y = x * 2; }" tokens = Lexer(src).tokenize() reconstructed = " ".join(t.value for t in tokens if t.type != TokenType.EOF) # 忽略空白差异,比较 Token 序列 assert reconstructed == "if x >= 10 { y = x * 2 ; }"

注意,往返测试不要求字符级完全一致,因为空白和注释在词法阶段被丢弃了。但 Token 的值和顺序必须一致。

5.3 性能边界:什么时候该换工具

手写扫描器在几千行代码的规模下完全够用,扫描速度通常在每秒几十万到几百万字符。但如果你的语言有几十个关键字、上百条词法规则,手写维护成本会急剧上升。这时候换 Flex 或者用正则表达式引擎是合理的。

判断标准很简单:如果你发现改一条词法规则要动三个地方,或者新增一个 Token 类型要改五处代码,就该考虑用生成器了。Flex 的规则文件是声明式的,加一条规则只写一行,维护成本低得多。

但换工具之前,确保你已经手写过至少一个完整扫描器。不然 Flex 报错时你连从哪查都不知道。我见过太多人直接上 Flex,结果卡在flex scanner jammed上查了一整天,最后发现是规则顺序写反了。

提示:Flex 生成的扫描器性能通常比手写的略高,因为它的状态机是表驱动的,没有函数调用开销。但差距在现代 CPU 上不明显,除非你在做每秒百万级 Token 的极端场景。

5.4 一个我常用的调试技巧

最后分享一个我调试词法分析器时最常用的技巧:在扫描器里加一个--dump-tokens命令行选项,把每个 Token 的类型、值、行号、列号打成表格输出。格式如下:

类型值行列
KEYWORDint11
IDENTx15
OP=17
INT119
OP+111
INT2113
OP;114
EOF115

这个表格一出来,Token 流对不对一眼就能看出来。比在代码里打断点、逐行单步快得多。我一般会把这个选项做成默认开启,输出到 stderr,这样不影响正常编译输出。

写词法分析器这件事,说难不难,说简单也不简单。核心就是最长匹配、前瞻控制和行列号维护这三件事。把这三件事做对了,剩下的就是体力活。但如果你跳过手写直接上工具,出了问题就是黑匣子,查都没法查。我的习惯是:任何新语言、新规则,先手写一版跑通,再用 Flex 重写一版对比 Token 流,两个版本输出一致才算过关。这个习惯帮我省了无数个加班的夜晚。希望帮到你。

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

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

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

立即咨询