编译原理实验第一关,通常不是词法分析,而是源程序预处理。很多人觉得这个实验很水,无非就是把注释删一删、空行清一清。但实际写下来你会发现,它比想象中更考验细节处理能力:一个注释符号的边界判断错误,就会把后面的整段有效代码吞掉;一个换行符没统一好,词法分析的报错行号就全乱套。我当年做这个实验时,光“字符串常量里出现 // 会不会被误删”这一个问题就折腾了很久。这篇就围绕“编译原理实验(一)源程序预处理”,把设计思路、状态机实现、测试边界和常见坑完整梳理一遍,希望对正在做这个实验或者想搞懂编译器前端预处理机制的人有帮助。
1. 预处理到底该做什么——实验目标拆细
1.1 从源文件到“干净代码”:这个关口到底解决了什么
源程序预处理是编译器前端的第一步,它夹在“源文件读取”和“词法分析”之间。它的职责不是判断语法,也不是识别标识符,而是把源程序整理成一种“适合下一步处理”的中间文本。以类C语言为例,需要干掉的脏东西包括:行注释、块注释、多余空白、不相干的换行符风格、续行符,有时还要检查非法字符。之所以要单独做这一步,是因为词法分析器的状态机如果还要同时处理注释和各种空白,复杂度会明显上升,而且一旦注释处理逻辑混在词法规则里,出错后很难定位。
在实际教学实验环境中,预处理产物通常有两种形态:一种是直接生成一个清理后的源文件,供后面的词法分析器读取;另一种是在内存里做转换,把处理结果作为字符流交给词法分析器。我个人推荐后面这种,因为少一次文件IO,也方便带行号映射。但无论哪种形态,核心目标都一样:保证输出的每个字符都属于词法分析真正关心的内容,不包含注释和无效空白。
这里再强调一个容易被忽略的点:预处理不是要把所有换行都删掉。很多语言里,换行是语句分隔的辅助信息,即使词法分析阶段把所有空白都当作分隔符,也需要在预处理结果中保留换行位置,否则后面做行号映射和错误定位时全得靠猜。所以输出文本应当尽量保持“原来的行结构”,只把行内的无用空白压缩。
1.2 为什么注释删除不能只靠字符串替换
一些同学拿到实验后,第一反应是读文件,然后用字符串查找替换的方式把所有“//”到行尾的内容删掉,再把“/* ... */”部分删掉。这种思路在小规模测试用例上看起来没问题,但遇到字符串常量里的注释符号就会立刻翻车。比如下面这行代码:
printf("/* 这不是注释 */");如果直接做字符串替换,块注释起始符号“/”出现在字符串常量内部,会被错误判定为注释开始,结果就是后面的内容全被吞掉,直到后面某处出现“/”为止。同理,printf("http://example.com");也会因为“//”被误删导致字符串不完整。所以预处理虽然是个“小关口”,但它本身必须做一次轻量级的词法扫描,至少要能区分出哪些字符是普通代码、哪些字符位于字符串或字符字面量内部。
这就是为什么要用状态机而不是简单替换。状态机能记住当前是否处在字符串、字符、行注释、块注释等不同上下文中,只有真正的代码上下文里出现的注释符号才触发生效。对于教学实验来说,这个需求正好为后面学习词法分析做了铺垫,等于提前用最简方式实践了一次“根据上下文识别词素”的思想。
1.3 先想清楚输出约束:行号、换行、空白保留策略
动手写代码前,建议先把输出格式定下来。没有明确输出约束的预处理程序,最后往往写成什么样都有,调试时也会很痛苦。我建议约定以下几点:
- 所有换行统一为
\n,不管原文件是\r\n、\r还是\n。 - 行内连续空格、Tab 压缩为一个空格;但不要删除所有空白,因为空白是词法分析切分 token 的依据之一。
- 注释中的内容不输出,但注释本身占据的换行要保留(或者通过行号映射补偿)。
- 块注释内部可能跨多行,注释删除后原本换行信息要记录到映射表里。
输出文件可以叫source.i,同时输出一个line.map文件,记录预处理后的第几行对应原文件的第几行。这个设计看起来只是在做实验,实际工程里的编译器前端也有类似的东西,比如 GCC 处理完预处理指令后照样需要行号标记,就是为了让后续的诊断信息能指回源代码。实验做一遍,这个思想就刻在脑子里了。
2. 状态机方案:把预处理当成一次小词法分析
2.1 状态定义与转移逻辑
最稳妥的实现方式,是把整个预处理循环写成一个有限状态机。状态可以定义为普通代码、行注释、块注释、字符串、字符字面量这五种。状态转移的核心逻辑如下:
- 普通代码状态下遇到
/且下一个字符是/,进入行注释状态。 - 普通代码状态下遇到
/且下一个字符是*,进入块注释状态。 - 普通代码状态下遇到
",进入字符串状态,同时把"输出。 - 普通代码状态下遇到
',进入字符字面量状态,同时把'输出。 - 行注释状态遇到换行,结束注释,换行保留。
- 块注释状态遇到
*/,结束注释。 - 字符串/字符状态里遇到转义符
\,把当前字符和下一个字符都原样输出,然后跳过下一个字符;遇到字符串结束符则回到普通代码状态。
千万不要漏掉“字符字面量”这个状态。类C语言中,char c = '/*';这种代码虽然少见,却是合法写法。如果把字符字面量里的内容当作字符串,或者根本不管字符状态,大概率会把字符常量里的内容误删或误处理。虽然教学实验的测试用例不一定覆盖这种极端情况,但作为预处理程序,状态设计越完整,后面遇到隐藏测试才不会翻车。
2.2 字符串与转义字符的陷阱
字符串状态里最经典的坑有两个。第一个是转义字符:比如"\"",如果你看到第一个"后直接找下一个",就会在第二个"处提前结束,导致后面的"被误判为字符串开始。正确做法是遇到\时,跳过紧跟在后面的一个字符,因为它是被转义的内容,不能影响状态判断。第二个是字符串跨行:标准 C 语言字符串不允许直接跨普通换行,但很多教学编译器可能有扩展;从预处理稳健性出发,字符串状态遇到换行时应给出错误提示,而不是继续吞掉后面内容,这样可以尽早发现源代码问题。
还有一个小细节,字符字面量里也可能出现注释符号,例如char c = '\'';,这里的转义和单引号边界要处理正确。我的经验是,调试的时候用一组“注释符号出现在字符串、字符、普通代码三种位置”的测试用例,全部通过后才能算状态机基本可靠。
2.3 关于续行符与跨行注释的取舍
有些类C语言支持反斜杠续行,也就是行尾的\加换行表示逻辑上两行是一行。如果实验要求支持这种语法,预处理时需要把\和\n一起吞掉,并映射回原行号。但要注意,不是所有场景都能吞:如果反斜杠出现在注释内,它只是普通字符;如果出现在字符串内,反斜杠起转义作用,后面换行反而要保留或按语义处理。处理续行符和注释的优先级,通常应该放在“当前状态”里判断:普通代码状态下的行长反斜杠才做拼接,其他状态下按各自状态规则处理。
跨行块注释相对简单,只需要记录起始行列号。遇到未闭合的块注释时,要在文件末尾报告“missing terminator”错误,并尽量给出块注释开始位置。很多初学者只在文件结束时报错,却不知道起始位置在哪里,排查起来特别费劲。所以状态机里进入块注释时就要攒下开始位置,哪怕只是行号。
3. 编码实现与关键细节
3.1 C语言版本核心数据结构与循环
如果你选择用 C 语言完成实验,建议定义两个清晰的结构:一个用于动态累积输出字符,一个用于保存行号映射。核心扫描循环不要写得过于复杂,一个switch根据当前状态分支即可。
typedef enum { ST_CODE, ST_LINE_COMMENT, ST_BLOCK_COMMENT, ST_STRING, ST_CHAR } State; typedef struct { char *data; int len; int cap; } Buffer;下面是一个简化版的状态转移示意,只展示关键分支,真实实验里你还要补上缓冲区扩容、文件读取、错误报告等辅助逻辑。
while ((ch = src[i++]) != EOF) { if (st == ST_CODE) { if (ch == '/' && src[i] == '/') { st = ST_LINE_COMMENT; i++; } else if (ch == '/' && src[i] == '*') { st = ST_BLOCK_COMMENT; i++; } else if (ch == '"') { st = ST_STRING; append(&out, ch); } else if (ch == '\'') { st = ST_CHAR; append(&out, ch); } else append(&out, ch); } else if (st == ST_LINE_COMMENT) { if (ch == '\n') { st = ST_CODE; append(&out, '\n'); } } else if (st == ST_BLOCK_COMMENT) { if (ch == '*' && src[i] == '/') { st = ST_CODE; i++; } } else if (st == ST_STRING) { if (ch == '\\') { append(&out, ch); ch = src[i++]; append(&out, ch); } else if (ch == '"') { st = ST_CODE; append(&out, ch); } else append(&out, ch); } else if (st == ST_CHAR) { if (ch == '\\') { append(&out, ch); ch = src[i++]; append(&out, ch); } else if (ch == '\'') { st = ST_CODE; append(&out, ch); } else append(&out, ch); } }这里特别提醒一个细节:当在块注释中判断*后面的字符是不是/时,要小心数组越界。src[i]可能已经访问到末尾。建议在读文件时给缓冲末尾多预留一个哨兵字符,比如\0,或者在取下一个字符前判断索引。很多段错误都是在这个边界上产生的。
3.2 空白压缩和换行统一的具体规则
空白压缩规则不要拍脑袋,要根据后续词法分析的需求来定。我的做法是:遇到普通空格和 Tab 时,先不直接输出,设置一个pending_space标志;如果下一个需要输出的字符不是换行且前序没有输出过空格,才输出一个空格。这样可以把连续多个空格和 Tab 压缩成单个空格,又不会在行首或者换行前留多余空格。换行统一为\n,如果是\r\n就只输出一个\n,如果是单独的\r也转成\n。
有一个容易被测试用例卡住的地方:int a=1;与int a = 1;经过预处理后是否要一致?如果按“连续空白压缩为单空格”的规则,前者没有空格就不加,后者有空格就压缩为一个,最终输出分别是int a=1;和int a = 1;,它们并不完全相同。这通常不影响词法分析,因为等号是否带空白不影响 token 序列。如果你希望输出更规整,可以把分隔符统一换成空格,但那样处理起来要额外判断 token 边界,反而容易出错。教学实验一般只要求删除注释和无效空白,不要求格式化代码,所以保守策略更安全。
3.3 行号映射表的生成与使用
预处理过程中,每处理一个字符,都要跟踪它来自原文件的哪一行。实现时可以维护一个current_orig_line变量,扫描到原文件换行符时加一。当向输出缓冲写入一个换行时,记录一行映射:map[out_line] = current_orig_line。如果 Block Comment 内部跨行,注释内容虽然不输出,但进到注释后遇到原文件换行时,仍要更新current_orig_line,同时这个换行不会输出到预处理结果里,导致后续输出行号跳变,所以映射表恰好能弥补这个差异。
映射表本质是一个数组,数组下标是预处理输出行号,元素是原文件行号。词法分析阶段报错时,比如它说“line 8: unexpected character”,你就能用这个数组orig_line = map[8]转回源代码行号,方便定位问题。测试时建议故意在注释块后面放一行出错代码,验证报错行号是否符合预期。这个功能虽然不直接计入评分,但能体现你对预处理整体流程的理解。
3.4 用Python做快速原型验证
如果实验允许自选语言,或者你打算先用快速原型试通逻辑,再改写 C 版本,我建议先用 Python 写一版。Python 处理字符串和文件很方便,状态机逻辑可以用几个while循环实现,十来分钟就能跑通基本流程。下面是一个高压缩版本的 Python 状态机框架,只保留最核心的注释删除逻辑:
def preprocess(text): out = [] i, n = 0, len(text) state = "code" line = 1 while i < n: ch = text[i] nxt = text[i + 1] if i + 1 < n else "" if state == "code": if ch == "/" and nxt == "/": state = "line_comment" i += 2 continue elif ch == "/" and nxt == "*": state = "block_comment" i += 2 continue elif ch == '"': state = "string" out.append(ch) elif ch == "'": state = "char" out.append(ch) else: out.append(ch) elif state == "line_comment": if ch == "\n": state = "code" out.append("\n") elif state == "block_comment": if ch == "*" and nxt == "/": state = "code" i += 2 continue elif state == "string" or state == "char": out.append(ch) if ch == "\\" and i + 1 < n: out.append(text[i + 1]) i += 2 continue end = '"' if state == "string" else "'" if ch == end: state = "code" if ch == "\n": line += 1 i += 1 return "".join(out)这个原型没有做空白压缩,也没有行号映射,但足够帮助你把注释和字符串的交互逻辑调通。原型跑通后,再用 C 语言实现时,思维负担会小很多。我自己做实验时经常先用脚本语言验证状态机思路,再落到 C 表达上,能少走不少弯路。
4. 测试用例设计与边界情况盘点
4.1 正常程序与特殊注释组合
测试用例不能只放教材上那种干净的代码。我建议分成几组。第一组是常规代码:包含行注释、块注释、嵌套逻辑、函数定义等,确保基本功能正常。第二组是注释紧贴代码的情况,比如a/*comment*/b;,预处理后应当输出ab;,如果输出成a b;就不对了,因为注释连接的两个 token 在语义上可能是一个整体,比如int/*中间没空格*/x;。块注释被删除后,如果前后字符本是同一个标识符的一部分,直接拼接才是正确的。这个细节很多同学会忽略。
第三组是连续多个注释相邻:比如/*1*//*2*/,中间没有分隔,删除后什么都不留。第四组是注释中包含//或/*,比如/***/,块注释内容为空,但必须正确识别。块注释里的//只是普通字符,不需要进入行注释状态。行注释里出现/*也不需要理会。这些边界情况最能检验状态机是否健壮。
4.2 字符串常量里的“注释符号”与转义序列
这一组专门用来测试状态机是否“看得到”字符串边界。至少准备这些行:
const char *s1 = "// 不是注释"; const char *s2 = "/* 也不是注释 */"; const char *s3 = "hello \"//\" world"; char c1 = '\''; char c2 = '\\';预处理后,字符串内容要原封不动保留,不能因为里面有注释标记而删除。难点在于s3里的转义引号:"hello \"//\" world"中的\"是一个被转义的引号,状态机遇到\后半段要直接跳过",避免它被当成字符串结束符。char c2 = '\\';里包含两个反斜杠,字符字面量中的转义也要跳过第二个字符。把这组用例跑通,说明字符串和字符状态基本正确。
我见过一个很冤的 bug:字符串里出现//,程序会把从//到行尾的字符全部删掉,结果输出的字符串末尾引号不见,后续所有代码状态全部错乱。这种问题在测试字符串用例时立刻就会暴露,所以千万别省这组测试。
4.3 非法字符、未闭合注释、超长行等异常输入
预处理程序除了处理正常逻辑,还要对异常输入给出诊断。至少需要考虑四类异常:第一类是未闭合的块注释。测试文件内容为/* comment后直接结束,程序应当在文件末尾报告错误,并指出注释起始行。第二类是未闭合的字符串或字符字面量,同样要报告。第三类是非法字符,比如源代码中出现@、$、#等实验语言规范里不存在的字符,普通代码状态下遇到时应记录位置。注意不是所有非法字符都不能出现在字符串里,所以判断位置时也要基于状态机。第四类是文件末尾没有换行,最后一个 token 之后直接结束,处理流程不能多读越界,也不能造成死循环。
另外,超长行可能触发缓冲扩容问题。如果用固定缓冲区char buf[1024]存输出,一旦注释很少的代码行超过 1024 字节,就会截断或溢出。建议从一开始就写好动态缓冲区追加逻辑,或者使用fgets逐行读取、逐行输出加行映射的策略,避免被测试用例的“超长行”吊打。
5. 调试实录与常见问题排查
5.1 注释误删的几种典型现场
第一次跑测试时,最常见的现象是“整个文件后半部分消失”。打开预处理后的文件,发现从某个位置开始输出的内容全是乱的,或者后面所有代码都没了。这类问题九成出在块注释匹配上。比如源代码里有int a = 0; /* 注释 / 这里少了一个星号,程序会一直找不到*/,于是把后面所有代码都当作注释吞掉。排查时先看状态机是否在块注释中正确移动指针;再看是否把/*中的*输出出去了,导致明明遇到*/却因为状态不对没匹配上。
还有一个典型现场:字符串里的注释标记导致后续代码被吞。这种问题用上面 4.2 的测试用例可以第一时间发现。如果状态机已经区分字符串状态,还是出错,多半是在字符串转义处理时忘了跳过下一个字符,导致字符串提前结束,注释标记被当成普通代码看到了。
5.2 行号错位和报错定位问题
预处理后的文件里,行号错位几乎必然发生,除非你不删除任何跨行注释。常见表现是:词法分析器报错在第 5 行,但原文件第 5 行明明没有错;实际错误源在第 8 行。这就是映射表没做或者没做对。检查点有三个:进入块注释后,原文件换行是否仍累计;注释内换行是否没有输出到预处理结果;输出换行时是否恰好记录映射。还有一个隐藏问题:如果采用了“连续多个空行压缩为一个换行”的策略,映射表会变得复杂,因为一次输出换行可能对应多个原文件位置。建议实验里不要压缩空行,保持“空行仍然输出为换行”的策略,映射表就是最简单的一一对应。
如果实验只要求调试输出内容,不要求词法分析报错,那么行号映射不是必做项。但一旦后续实验需要把词法分析和语法分析串起来,行号映射能帮你节省大量排查时间。我后来做完整编译器实验时,前几个实验没有保留行号映射,结果在语法分析阶段报错时根本不知道错误在源代码的哪一行,被迫回头补模块。所以建议第一次做预处理就把映射表顺手搭好。
5.3 编码、换行符与缓冲区边界问题
中文注释是很多教学实验容易忽略的地方。如果源代码以 UTF-8 编码保存,中文注释的每个汉字占 3 个字节。预处理状态机不应该只按 ASCII 字符判断,但也不用做太复杂的编码识别,只要不在扫描过程中把中文字符的某个字节误判成/、*、"等 ASCII 符号就行。由于 UTF-8 多字节字符的高位都是 1,通常不会和 ASCII 字母符号冲突,所以安全。但如果用 GBK 编码,汉字内码有可能包含0x2F这种值,理论上有歧义,教学实验里一般不做特殊处理。稳妥做法是:读取文件时保持二进制模式,不做编码转换;如果发现无法打印的字节,可以按非法字符处理。
换行符问题也很常见。Windows 记事本存的文件是\r\n,Linux 下打开会多出一个\r。如果预处理不统一,输出文件里的空行可能变成“看起来是空行实际有\r”,用十六进制工具看会非常难受。我的处理方式是扫描时遇到\r直接跳过,遇到\n按正常换行处理;如果遇到单独\r且后面不是\n,也把它当作换行处理。这样三种换行风格都能统一。
缓冲区边界问题前面提过,再具体化一点:读完文件后,在最后一个字符后面补一个哨兵\0,然后扫描时看下一个字符时直接取src[i],就不用担心越界。如果是动态读取,建议一次性把整个文件读进内存,预处理本来就不需要流式处理;文件若特别大,也可以按块读,但要处理好跨块路径,比如块注释在文件分块处跨了两块,处理起来容易出 bug。对实验级源码,最简单的方案就是一个fread读进来处理。
5.4 踩过坑之后的设计反思
这个实验做完之后,我有一个很深的体会:预处理程序虽然小,但它是“状态机思维”的第一次实践。如果你能在写预处理时把状态转移、转义跳过、行号跟踪这些细节想清楚,后面学词法分析会轻松很多。反过来,如果直接靠正则替换或暴力字符串查找蒙混过关,遇到复杂测试用例就会非常痛苦。另一个反思是,尽早为后续实验预留接口——行号映射表、统一换行、动态缓冲区这些设计,越早做越好,不要等到词法分析、语法分析阶段再来补。哪怕实验文档没要求,也建议自己加上,因为这对理解整个编译流程帮助很大。
如果后续还要扩展,可以让预处理程序接受命令行参数,比如指定是否保留空行、是否输出行号映射、是否开启详细错误报告。这样同一个实验模块可以复用在后续的词法分析、语法分析甚至中间代码生成实验中。我给后来同学看的参考实现里,通常会把预处理封装成一个独立函数:输入是源文件路径,输出是预处理后的字符流、行号映射表和错误列表。这种模块化设计,比把全部逻辑堆在main函数里清晰得多,也更容易做单元测试。