☰
Flex块注释规则实战:从状态机入门到嵌套与未闭合处理
2026/10/8 3:00:55 网站建设 项目流程

最近在给团队内部一个配置文件解析器写词法分析阶段,flex 依然是首选工具。说句实话,“块注释规则”这几个字看着简单,/* ... */嘛,谁不会?但真正落到 flex 的规则段里,要处理注释内容、状态切换、未闭合、嵌套、行号维护这些细节时,我前后改了四个版本才算稳。这篇就把整个过程写透:从最基础的吃掉注释,到抗住边界情况的完整规则,再到调试技巧和一套可以直接抄的测试用例。

标题里的“规则”,其实指的是 flex 源文件中规则段(rules section)里的“模式-动作”对。理解了这一点,再看下面的内容会顺很多。适合正在做编译原理课设、自研脚本语言、或者刚把 flex 捡起来干活的朋友。

1. 先从一个小需求说起:块注释到底要解决什么问题

1.1 词法分析阶段,块注释的职责是什么

词法分析器的工作是把字符流切成 token。注释不是 token,注释里的任何内容都不能参与语法分析,否则int a = 1; /* 这是一段注释 */里的“这是一段注释”会被当成标识符或者别的什么,语法分析直接崩掉。

但注释又不能简单地从文件里物理删除。原因有两个:

第一,字符串常量里如果出现/*,它不是注释。比如const char *s = "/* not comment */";,这里的/*是字符串内容,必须由字符串规则先匹配掉,块注释规则不能抢先。

第二,注释经常跨行。块注释可以包含换行符,词法分析器在注释状态里如果还按普通规则扫描,就会出现:注释里的引号、括号、字母数字全部被错误地当成代码 token,然后输出一大堆垃圾。

所以,块注释的核心问题可以拆成三个:

  • 进入识别:看到一个/*,知道下面开始是注释,不再按普通规则走。
  • 内容忽略:注释内的所有字符,包括换行,都不能产生 token,也不能丢到输出流。
  • 退出识别:看到第一个*/,回到普通词法状态。

这三个问题,本质上是让词法分析器记住“当前处于注释上下文”,这正是 flex 开始条件(start condition)最典型的用途。

1.2 两种处理路径:先剥注释 vs 词法规则吸收

我在项目里见过有人用一个预处理步骤先把注释全删掉,再把剩下的文本喂给 flex 生成的扫描器。这条路看起来简单,但坑很深:

  • 预处理用正则匹配注释时,遇到字符串里的/*容易误删。比如"/*"这种字符串字面量,正则如果不考虑上下文,会把引号里的内容也当成注释剥掉。
  • 剥离注释后行号全乱了。一旦语法报错,错误信息里的行号和原始文件对不上,排查问题极其痛苦。
  • 注释与代码之间如果没有空格,直接删除注释会让两个 token 物理上贴到一起。虽然对 flex 这类逐 token 的扫描器来说一般没有影响,但对做源码格式化、代码高亮这类需要保留字符位置的工具来说,是个大问题。

我的建议是:注释处理必须放在词法层,用 flex 的状态机吸收。这样注释内容怎么被跳过、换行怎么数、报错能不能定位,都清清楚楚。下面这个最小示例可以看到 flex 处理注释的基本骨架:

%option noyywrap %x COMMENT %% "/*" { BEGIN(COMMENT); } <COMMENT>"*/" { BEGIN(INITIAL); } <COMMENT>. ; <COMMENT>\n ; %% int main(void) { int token; while ((token = yylex()) != 0) ; return 0; }

先把注释状态里的字符都吃掉,遇到*/再切回普通状态。跑起来之后,注释内容不会出现在输出里,也不会被当成 token。

1.3 一个能直接编译运行的最小实验

上面这段代码保存成comment.l,然后执行:

flex comment.l cc lex.yy.c -o comment

如果环境里没有安装 flex,Debian/Ubuntu 系用sudo apt install flex,macOS 用brew install flex。

准备一个测试文件test.txt:

int a = 1; /* hello world */ int b = 2;

运行./comment < test.txt,程序正常退出,没有任何输出。这说明注释块被完整吸收了。但这里有个重要细节:为什么<COMMENT>.和<COMMENT>\n后面必须加分号表示空动作?

因为 flex 规则如果没有动作,默认动作是ECHO,会把匹配到的文本直接输出到 stdout。如果你把注释规则里的分号去掉,注释内容就会原样打印出来。这是初学 flex 处理注释最常见的翻车点。

2. 吃透 flex 的开始条件,注释规则才算真正入门

2.1 开始条件到底是什么

flex 扫描器在任何时刻都处于某个“开始条件”下,默认状态叫INITIAL。规则可以带前缀,比如<COMMENT>"*/",意思是在COMMENT状态下,这个规则才参与匹配。不带前缀的规则在任何状态下都参与匹配(排他状态除外)。

开始条件用%s或%x声明。差别非常关键:

声明方式含义对普通规则的影响
%s name包容型开始条件普通规则仍然生效,只是在name状态时,带<name>前缀的规则也额外生效
%x name排他型开始条件只有带<name>前缀的规则生效,普通规则全部不生效

处理注释必须用%x。如果误写成%s,在注释状态下,标识符、数字、字符串等普通规则依然会匹配。比如注释里有",字符串规则会开始匹配,然后一路吞字符,整个状态被搅乱。

我给团队新人讲开始条件时常用一个类比:普通规则是一家公司的公共区域,谁都能进;排他状态是一个需要门禁的房间,只有名单上的人(带前缀的规则)能进。注释就是这样一个带门禁的独立空间。

2.2 BEGIN 宏和状态切换

BEGIN(COMMENT);是 flex 提供的宏,作用是把当前状态切换到COMMENT。BEGIN(INITIAL);则切回默认状态。

这两个切换发生在规则的动作里,而不是模式里。比如:

"/*" { BEGIN(COMMENT); }

含义是:在初始状态下匹配到/*,执行动作,进入注释状态。

这里还有个小技巧:调试时可以用YY_START宏查看当前状态。YY_START返回的是当前开始条件的整数编号,写日志非常方便:

fprintf(stderr, "当前状态编号: %d,匹配文本: [%s]\n", YY_START, yytext);

我刚调 flex 时,经常靠这一行临时日志确认状态有没有按预期切换。

2.3 匹配优先级:最长匹配和规则顺序

flex 的匹配规则有两条铁律:

  1. 选择最长的匹配。
  2. 如果长度相同,选择规则段里靠前的那条规则。

这对注释规则的影响很大。看这个例子:

"/*" { BEGIN(COMMENT); } "/*/" { /* 想匹配这种特殊输入 */ }

输入/*/时,/*/长度是3,/*长度是2,flex 会优先匹配/*/。如果规则段里没有/*/这条,才会选/*。所以,如果希望非正常输入能被特殊处理,模式要写全,不能指望顺序。

在注释状态里还有一个经典问题。下面的写法看起来很平常:

<COMMENT>"*/" { BEGIN(INITIAL); } <COMMENT>.|\n ;

<COMMENT>.|\n在 flex 里实际的含义是<COMMENT>(.|\n),即在COMMENT状态下,.和\n都匹配。这是 flex 手册里经典的写法,不是 bug。

但为了可读性,也为了避免读者误解,我在项目里更喜欢拆开写:

<COMMENT>"*/" { BEGIN(INITIAL); } <COMMENT>. ; <COMMENT>\n ;

两条规则分别处理非换行字符和换行。这样每个模式都明确在COMMENT状态下生效,后面维护的人不会看错。

2.4 为什么“点号加换行”经常被写错

flex 的.匹配除换行符以外的任意单个字符。这一点和很多正则引擎不一样,在 Perl、Python 里.能匹配换行的情况很少,但 flex 里是明确不匹配\n的。所以注释规则里必须单独写一条<COMMENT>\n。

漏掉\n规则会怎样?注释里的换行符无法匹配任何规则,flex 会走默认动作,把换行符当匹配文本并 ECHO 到输出。结果就是注释里的换行被输出了,词法分析器输出流里多出一堆空行,行为非常诡异。

还有更隐蔽的:如果注释规则漏掉了对全部字符的覆盖,比如只写了<COMMENT>[^*],没处理*号,那么注释里出现*时同样会触发默认动作。最稳妥的做法是:排除模式下必须有一条能覆盖任意单字符的兜底规则。.加\n就是最常用的组合。

3. 从能跑变成能用的四版块注释规则

3.1 第一版:认真吃掉注释

这一版只做一件事:进入注释后,吃掉所有字符,遇到*/退出。

%option noyywrap %x COMMENT %% "/*" { BEGIN(COMMENT); } <COMMENT>"*/" { BEGIN(INITIAL); } <COMMENT>. ; <COMMENT>\n ; %%

这一版能应付绝大多数正常场景。但有几个细节值得展开:

为什么"/*"要加双引号?flex 里用双引号包裹的字符串表示精确匹配文本,不需要转义。你也可以写成\/\*,效果一样,但可读性差。双引号写法最直观。

为什么<COMMENT>"*/"要在<COMMENT>.前面?其实就算放在后面也没有关系,因为*/长度是2,.长度是1,最长匹配原则保证了*/优先。但为了阅读顺序清晰,我习惯把更具体的退出规则放在最前面,然后才是吞任意字符的兜底规则。

这一版有什么隐患?最大隐患是未闭合注释。如果输入文件只有/* foo,没有*/,flex 生成的扫描器会一路吞掉所有字符直到 EOF,然后静默结束。程序没有任何报错,后面的代码可能已经被注释内容污染了一部分,等到语法分析阶段才爆出难以定位的错误。所以第二版必须解决这个问题。

3.2 第二版:未闭合注释不能静默

添加一个针对 EOF 的规则:

%option noyywrap %x COMMENT %% "/*" { BEGIN(COMMENT); } <COMMENT>"*/" { BEGIN(INITIAL); } <COMMENT>. ; <COMMENT>\n ; <COMMENT><<EOF>> { fprintf(stderr, "行 %d: 块注释未闭合\n", yylineno); yyterminate(); } %%

<<EOF>>是 flex 的特殊模式,表示输入流结束。它也可以带状态前缀,写成<COMMENT><<EOF>>就是指“在 COMMENT 状态下遇到了 EOF”。

这一版需要配合%option yylineno才能让yylineno自动更新。注意看上面的代码我在文件开头没有写%option yylineno,实际使用时应该加上:

%option noyywrap yylineno

这样在遇到未闭合注释时,报错信息可以带上行号。比如输入:

int a = 1; /* oops

会输出:

行 2: 块注释未闭合

这个报错信息的价值极高。没有它,一个未闭合注释会吞掉后面所有输入,错误表现千奇百怪;有它在,问题直接定位到具体行。

EOF 规则的动作也可以选择不终止扫描,而是恢复状态继续处理后面的输入。但注释到了 EOF 还没有结束符,本身是致命错误,后面也没有输入可处理了。所以yyterminate()是合理选择。如果和 Bison 集成,更精细的做法是返回一个自定义错误 token,让语法层决定如何处理,这个在 4.3 节再展开。

3.3 第三版:嵌套注释的计数器处理

C 语言标准规定块注释不嵌套,但很多脚本语言和配置格式支持嵌套注释。如果你的目标语言允许嵌套,比如下面这种输入:

/* 外层 /* 内层 */ 还在注释里 */

那 ANSI C 风格的规则会错误地在第一个*/处退出注释,后半截还在注释里 */会被当成代码 token,进而引发一连串解析错误。

支持嵌套其实不复杂,维护一个计数器就行:

%{ int comment_depth = 0; %} %option noyywrap yylineno %x COMMENT %% "/*" { comment_depth = 1; BEGIN(COMMENT); } <COMMENT>"/*" { comment_depth++; } <COMMENT>"*/" { if (--comment_depth == 0) BEGIN(INITIAL); } <COMMENT>. ; <COMMENT>\n ; <COMMENT><<EOF>> { fprintf(stderr, "行 %d: 块注释未闭合\n", yylineno); yyterminate(); } %%

进入时把深度设为 1,注释内每遇到一个/*就加一,每遇到一个*/就减一,减到零才退出注释状态。

写这个计数器时有一个坑:<COMMENT>"/*"这条规则必须能匹配到,否则嵌套支持就是空话。在COMMENT状态下,如果输入正好是/*,它长度是2,而.长度是1,最长匹配原则保证"/*"规则生效。这个没问题。

另一个坑是计数器类型。如果注释深度可能非常大,建议用size_t,防止深度超过 2 的31次方。实际工程里注释嵌套深度不可能那么夸张,用int也够,但养成用无符号类型的习惯不是坏事。

3.4 第四版:把注释内容留给后续阶段

前三版都是把注释内容直接丢掉。但有些场景需要保留注释,常见的有两类:

  1. 写注释剥离工具,要把注释替换成空格和换行,保持原始行列位置不变。
  2. 写文档生成器,要提取文件头部/* license ... */这类特定注释。

第二类要先匹配进去,把内容存到缓冲区,等退出注释时再处理。这里给出一个最直观的剥离工具版本,不仅能删注释,还不会破坏行列位置:

%option noyywrap %x COMMENT %% "/*" { fputs(" ", stdout); BEGIN(COMMENT); } <COMMENT>"*/" { fputs(" ", stdout); BEGIN(INITIAL); } <COMMENT>\n { putchar('\n'); } <COMMENT>. { putchar(' '); } %%

注意进入注释的那两个字符/*也要用两个空格占位,否则输出结果的每一列都会和原始文件错位。这个版本做的是字符级替换,代码文件的行数保持不变,列数也只有制表符场景下会出现偏差,普通场景足够用了。

如果要统计注释总长度,还可以这样:

%{ long comment_chars = 0; %} %x COMMENT %% "/*" { comment_chars += 2; BEGIN(COMMENT); } <COMMENT>"*/" { comment_chars += 2; BEGIN(INITIAL); } <COMMENT>. { comment_chars++; } <COMMENT>\n { comment_chars++; } %%

这个版本的思路是:让每条规则的动作里都累计yyleng,而不是等注释结束再统计。yyleng是匹配文本的长度,在注释内容被丢弃的同时记录长度,对性能影响很小。

4. 实战集成:行号、缓冲区和可重入扫描器

4.1 行号维护和列号维护的细节

开启%option yylineno之后,flex 会在每次匹配动作前自动统计yytext里的换行符并更新yylineno。这意味着:无论是普通规则匹配了\n,还是注释规则里<COMMENT>\n匹配了换行,行号都会自动加一,不要自己再写yylineno++,否则行号会重复递增。

但列号没有现成方案。flex 不追踪列号,Bison 的yylloc里即使有first_column、last_column字段,也需要词法分析器自己填。我常用的做法是维护一个全局变量column,在每条规则动作里统一更新:

#define YY_USER_ACTION \ do { \ for (int i = 0; i < yyleng; i++) { \ if (yytext[i] == '\n') column = 0; \ else column++; \ } \ } while (0)

这段宏定义要放在 flex 文件的定义部分(第一个%%之前)。它会在每个规则动作之前自动执行,扫描yytext里的每个字符,遇到换行重置列号,其他字符加一。

对注释规则来说,这个宏同样生效。因为<COMMENT>\n匹配时,宏扫描到换行符,列号归零,行号由 flex 自动更新,两边都对得上。

有一个坑要注意:YY_USER_ACTION会对所有规则生效,包括那些你有特殊列号需求的规则。比如字符串里的转义序列\\n是两个字符,宏会把它算成两列,这通常是符合直觉的;但如果有更特殊的字符串语义,就需要单独处理,把通用宏排除掉。

4.2 超长注释和缓冲区的关系

flex 默认情况下会把一条规则匹配到的完整文本存进yytext。如果一个注释块有几十 MB,比如某些压缩过的 JavaScript 文件头部长注释,flex 要把整个注释文本保存在内存里,这就可能导致内存占用过高,甚至触发 flex 内部对 token 大小的限制。

处理思路是:不要让一条注释规则把整个注释一次吃完,拆成小块匹配。

下面这种写法是实际项目中比较稳的:

<COMMENT>"*/" { BEGIN(INITIAL); } <COMMENT>[^*\n]+ ; <COMMENT>\*+[^*/\n]* ; <COMMENT>\n ; <COMMENT>\* ;

这里的逻辑是:

  • [^*\n]+:匹配不含*和换行的大段普通注释内容,一次可以吃掉很多,但不会把整个文件吞完,因为遇到*或换行就停。
  • \*+[^*/\n]*:匹配连续的星号,后面跟着不是/和换行的字符。
  • \n:单独处理换行。
  • \*:兜底吃掉单独的星号。
  • "*/":负责正常退出。

这种分块模式比<COMMENT>.|\n在长注释场景下稳定得多。代价是规则多了几条,DFA 状态数略微增加,但扫描性能影响可以忽略。

大多数情况下,普通几 KB 的注释用<COMMENT>.|\n完全没问题。只有当你确定注释可能非常大时,才值得用分块写法。我把这条经验写进文档后,团队里再没人因为长注释卡内存了。

4.3 与 Bison 集成时的注意点

flex 和 Bison 配合时,注释规则的动作通常不需要返回 token。也就是说,注释被吸收后,yylex()会继续往下扫,语法分析层根本感知不到注释存在。

但未闭合注释这种错误是例外。最简单的方式是让规则动作返回一个错误 token:

<COMMENT><<EOF>> { fprintf(stderr, "行 %d: 块注释未闭合\n", yylineno); return COMMENT_ERROR; }

然后在 Bison 语法文件里声明%token COMMENT_ERROR,并在起始规则里处理它:

%token COMMENT_ERROR program: statement_list | error COMMENT_ERROR { yyerror("注释错误"); } ;

这样错误不会在词法层直接终止,而是交给语法层做统一的错误恢复。实际项目中,这种处理方式比yyterminate()更可控。

如果用了%option reentrant生成可重入扫描器,块注释规则本身不用改,但要注意:comment_depth这类全局变量在可重入扫描器里不应该再用全局变量,而是放到yyextra里。大致骨架如下:

%option reentrant bison-bridge extra-type="struct scanner_extra*" %{ #define comment_depth (yyextra->comment_depth) %} %x COMMENT %% "/*" { comment_depth = 1; BEGIN(COMMENT); } <COMMENT>"/*" { comment_depth++; } <COMMENT>"*/" { if (--comment_depth == 0) BEGIN(INITIAL); } <COMMENT>. ; <COMMENT>\n ;

每个扫描器实例有自己的yyextra,多线程环境下注释嵌套深度就不会互相干扰。

4.4 调试块注释的实用工具

调状态机最有效的工具,是让 flex 自己打印匹配过程。

flex生成时加-d选项,或者源文件里写%option debug,程序运行时设置yy_flex_debug = 1;,就会在 stderr 输出每条规则的匹配日志:

-- accepting rule at line 8 ("/*") -- accepting rule at line 10 ("hello") -- accepting rule at line 11 (" ") -- accepting rule at line 9 ("*/")

每一行日志里的规则行号指向.l文件里的具体行,看日志就能知道注释状态切换是否按预期执行。

如果觉得日志不够直观,我习惯在关键动作里加临时打印:

"/*" { fprintf(stderr, "进入注释, 行=%d\n", yylineno); BEGIN(COMMENT); } <COMMENT>"*/" { fprintf(stderr, "退出注释, 行=%d\n", yylineno); BEGIN(INITIAL); }

跑完测试用例,第一件事是数“进入注释”和“退出注释”的次数是否相等。不相等就说明有未闭合注释或者状态错乱。

还有一个被低估的调试工具:flex -v。它会输出 DFA 状态的统计信息,包括状态数、转换数等。如果某个版本加了几条规则后状态数暴增,多半是正则写得过于复杂,需要重新审视规则设计。

5. 容易翻车的边界场景清单

5.1 症状与原因速查表

整理一下我在实际项目中遇到过的问题,直接做成速查表:

现象原因解决办法
注释内容被原样打印到输出注释规则没有写空动作,触发默认 ECHO每条注释规则末尾加分号或空花括号
注释里的字符串常量引发解析错误用%s声明了开始条件,普通规则在注释状态仍生效改成%x声明排他状态
未闭合注释没有任何报错缺少<COMMENT><<EOF>>规则添加 EOF 规则并报告错误位置
注释跨行后错误行号不准没有开启%option yylineno,或自己重复递增开启 yylineno,删除手动yylineno++
嵌套注释在第一个*/就退出了没有增加计数器逻辑用comment_depth维护嵌套深度
注释内出现*时输出乱码注释规则没有覆盖所有字符,*触发了默认动作添加兜底规则[]或<COMMENT>.
长注释导致内存占用异常高一条规则匹配了整个长注释,yytext 保存全部内容拆成小块规则,避免一次性吞入整段
字符串里的/*被当成注释开头字符串规则和注释规则顺序或结构不对确保字符串规则优先,且注释状态使用排他型

这张表基本覆盖了从入门到中高级的典型问题。遇到过其中两三个,说明你已经真正在用 flex 处理注释了,不是照抄示例而是理解状态机行为。

5.2 一份可以直接抄的测试用例

调试注释规则,不能只靠一两个正常用例。我每次写词法分析器都会准备下面这组测试输入,覆盖正常、边界、错误三类:

测试输入预期行为
int a = 1; /* comment */ int b = 2;注释被吃掉,正常产生代码 token
/* single line */正常
/* multi\nline */注释内换行被吃掉,行号正确递增
/* unterminated报未闭合注释错误
/* outer /* inner */ end */不启用嵌套时,内层*/结束注释,其余按代码处理;启用嵌套时整体是一个注释
/****/正常,星号连续出现也能退出
// /* not comment */行注释优先,内部/*不触发块注释
"/* not comment */"字符串规则优先,内部/*不触发块注释
int/**/x注释吃掉后,int和x被识别为两个 token
文件以/*块注释结束且没有闭合报未闭合注释错误

把这些输入分别存成文件,用./comment < input.txt跑一遍,检查输出和返回值。这个测试集虽然简单,但能覆盖 90% 的块注释边界行为。

5.3 我踩过的一个真实案例

有次给旧项目加一个源码统计工具,统计代码里有效行数。第一版注释规则就写得太随便,只写了"/*"和<COMMENT>"*/"两条规则,忘了吃掉注释内容,也忘了处理 EOF。结果一个 C 源文件里某处少写了一个*/,扫描器把从注释位置到文件末尾的所有代码全部当成注释吞掉,有效行数统计结果比实际少了三分之一。当时花了大半天排查,最后用flex -d调试日志才发现是注释状态一直没有退出。这个经历让我养成了两个习惯:

第一,所有注释规则,不管多简单,都先把 EOF 规则写上。哪怕目标语言绝对不可能出现未闭合注释,这条规则也不删。

第二,任何状态机规则改完,立刻用 5.2 节那组测试用例跑一遍。特别是unterminated和/****/这两个用例,基本能暴露绝大多数状态切换问题。

写到这里,块注释规则这件事基本就完整了。总结性的话不多说,但有一个小技巧值得单独分享:每次调整注释规则后,我都会在动作里临时加一行fprintf(stderr, "%d\n", YY_START);,跑完用例确认状态编号变化符合预期再删掉。这个小动作帮我省下的调试时间,比想象中多得多。

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

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

立即咨询