手写词法分析器 vs Flex:从原理到避坑的编译器开发实战
发布时间:2026/10/3 2:45:36 作者:尧图编辑部 阅读量:1,286

简介这份资源是面向编译原理学习者与课程实践者的词法分析完整实现包聚焦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 fToken({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, offset0): 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(fUnexpected 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 最长匹配原则与前瞻的边界词法分析有一条铁律最长匹配。也就是说当和都能匹配时选。上面代码里先检查双字符运算符再检查单字符就是在落实这条规则。但最长匹配有个边界问题ab应该切成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 的类型、值、行号、列号打成表格输出。格式如下类型值行列KEYWORDint11IDENTx15OP17INT119OP111INT2113OP;114EOF115这个表格一出来Token 流对不对一眼就能看出来。比在代码里打断点、逐行单步快得多。我一般会把这个选项做成默认开启输出到 stderr这样不影响正常编译输出。写词法分析器这件事说难不难说简单也不简单。核心就是最长匹配、前瞻控制和行列号维护这三件事。把这三件事做对了剩下的就是体力活。但如果你跳过手写直接上工具出了问题就是黑匣子查都没法查。我的习惯是任何新语言、新规则先手写一版跑通再用 Flex 重写一版对比 Token 流两个版本输出一致才算过关。这个习惯帮我省了无数个加班的夜晚。希望帮到你。本文还有配套的精品资源点击获取