☰
编译原理实验:源程序预处理与状态机设计详解
2026/10/19 8:55:31 网站建设 项目流程

如果让我给编译原理的实验排个难度梯度,源程序预处理绝对属于那种“看起来白送分、写起来全是坑”的题目。拿到的要求通常只有一句话:对输入的C语言源程序进行预处理,删除注释、合并空白符、展开宏定义,输出可供下一阶段词法分析使用的源文件。很多同学包括当年的我,第一反应都是这有什么难的?正则替换一下就完事了。等到被字符串里冒出的//和“字符常量里夹着的注释界定符”教训几轮之后,才会意识到,这个实验真正想让你练的是对字符流做精细状态控制的能力。

这篇文章就围绕编译原理实验中的“源程序预处理”主线,把预处理阶段要解决的问题、规则边界、状态机设计、测试方法一次讲透。适合正在做编译原理实验的在校学生,也适合想补一遍编译器前端基础知识的开发者。文中给出的参考实现是Python版本,思路完全可以用C/C++复写,重点在于理解状态迁移而不是抄代码。

1. 预处理阶段解决的是哪几类问题:先把它放进编译全景里看

1.1 预处理在编译流水线中的位置

一个完整的编译器前端通常被切分成预处理、词法分析、语法分析、语义分析四个阶段,预处理是第一个接触源文本的环节。如果你用标准C编译器自带的前端选项跑过工程,会知道可以通过-E之类的参数单独输出预处理结果,后续的词法分析、语法分析都建立在它之上。

预处理的存在有历史原因也有工程原因。早期的编译环境里,宏展开和文件包含需要用独立程序先行完成,编译器核心只需专注于语法层面的解析。到今天,这层职责仍然用独立阶段来处理,最大的理由是“关注点分离”:词法分析器希望输入是干净、规整的记号流,而不是夹杂着注释和宏定义的原始文本。换句话说,预处理实验的成绩虽然只占一次实验的分,但它决定了你后续词法分析实验能走多顺。如果你的预处理输出行号乱跳、字符串被篡改、注释没删干净,后面所有基于行号定位的错误信息都会错位,排查起来极其痛苦。

1.2 从输入到输出:一个直观的形态变化示例

先看一个直观的例子。下面是某次实验的测试输入片段:

/* 计算面积的简单程序 */ #include <stdio.h> #define PI 3.14 int main() { // 半径 int r = 2; double area = PI * r * r; /* 面积 = pi * r^2 */ printf("area=%f\n", area); return 0; }

经过预处理(只做注释替换与空白规范化,宏指令按保留处理)之后,输出大致长这样:

#include <stdio.h> #define PI 3.14 int main() { int r = 2; double area = PI * r * r; printf("area=%f\n", area); return 0; }

注意几个关键点:单行注释和块注释都被空格替代,但是换行数量保留;#include和#define行原样保留;字符串"area=%f\n"里的内容没有任何改动。对比输入输出,你就能理解“注释替换为空格而不是删除”的真正含义——如果真把注释字符抹掉,某些行首的空白位置会导致处理后的代码风格诡异,更重要的是可能造成记号粘连。

1.3 三类核心任务与常见考核点

大多数实验要求都围绕三类任务展开:

  • 注释处理:删除单行注释//和多行注释/* */,把它们替换为等价的空白,同时保持行号不变化。
  • 空白处理:把连续的空白字符(空格、制表符、换行)按规则规范化。这里最容易踩坑的是换行符,它必须被保留。
  • 宏定义处理:对#define定义的简单宏进行展开;对#include、#if/#endif等指令至少做到正确识别与保留。

从课程设计角度说,这三类任务有优先级差异。宏展开在相当多实验里是加分项或扩展项,注释和空白处理是必须项。一个完整宏展开系统要处理带参与不带参宏、宏展开时是否继续展开宏、宏中的#和##操作符等细节,工作量不比一个小型词法分析器小。实验设置成“先做注释和空白,再做扩展宏展开”,本身就是在控制难度梯度,别一上来就追求一步到位。

根据我见过的一些实验要求,考核点大致可以归纳成以下几条:

  1. 输入的C语言源程序包含各类注释、字符串、字符常量、转义字符。
  2. 预处理后输出中,注释全部消失并被至少一个空格替代。
  3. 行号与原始输入保持一致,便于后续词法分析报错。
  4. 宏定义能够被识别,简单替换至少覆盖对象式宏,例如#define PI 3.14。
  5. 输出文件中不应出现因注释删除导致的标识符粘连,比如int a/**/b;不能变成int ab;。

别看这五条短,每一条都对应着具体的实现细节。第二条里“被至少一个空格替代”就是很多人第一次踩坑的地方。

2. 把常见实验规则翻译成精确的边界条件

2.1 注释替换的规则细节:为什么不能直接用“删除”

如果实验规则只写了“删除注释”,实践中也不能真的把注释字符全部抹掉。C语言里,注释在语法上等价于一个空格,它的作用是分隔相邻的记号。经典的例子是:

int a/**/b;

如果直接把/**/删掉,结果是int ab;,这会改变程序的含义,甚至让词法分析器把a和b合并成一个标识符。正确的输出应当是:

int a b;

所以,正确做法是把注释替换成空格,而不是删除。同理,字符串"abc"和变量name之间的//注释被替换成空格后,字符串引号与后面的普通字符之间也必须保留分隔。

这个细节的正确性判断标准在实验报告里经常作为关键采分点出现。建议实现时记牢:注释替换为空格,不是删除;多行注释内部含有的换行符要原样保留,否则行号就乱了。有些同学为了省事,把块注释里的所有字符都用一个空格替代,导致原本跨 5 行的注释被压缩成 1 行,后续词法分析报错全错位,这种输出形态就算功能“对”了,工程上也是不合格的。

2.2 空白处理与行号保持的矛盾

很多初学者在处理空白时喜欢“一口气把\n也去掉,全部压缩成单行”,这样预处理结果便于肉眼查看,但会让后续词法分析的报错完全失效。假设原始文件第 30 行有个未定义变量,你的预处理把它压到第 5 行,等编译时报错行号对不上,排查起来会非常崩溃。

正规做法是:把空格和制表符的连续序列合并为一个空格,但每个换行符必须原样保留。甚至有的课程要求“输出文件与输入文件的行数完全一致”,也就是说注释内部哪怕有换行也保留换行位置。多行注释:

/* 第一行 第二行 */

按“空格替代 + 保留换行”处理,输出会是注释起始处一个空格、一个换行、注释结束处一个空格。严格来说并不需要真的把被注释掉的每行非换行字符逐个映射成空格,只要保证注释范围覆盖的那些行,在输出文件里的行数与原始文件一致即可。简单做法是:注释内容整体吞掉,遇到注释内的\n照常输出,注释结束后补一个空格。

2.3 宏定义处理的“度”:实验通常要求到什么程度

完整的C预处理器处理宏展开时要面对四件事:对象宏、函数宏、宏嵌套展开、特殊操作符#和##。实验阶段通常不需要全都要,常见要求是:

  • 识别以#开头的预处理指令;
  • 对#define的简单对象宏建立名字到替换文本的映射;
  • 在非#开头的代码中对宏名做替换;
  • #include、#ifdef、#ifndef、#if、#else、#endif这些指令至少被原样保留或按规则跳过。

这里有个容易忽略的点:宏替换不能发生在字符串和字符常量内部。如果代码里有char *s = "PI";,这个字符串里的"PI"不能被替换成"3.14"。所以宏展开的检查位置应当放在状态机判定“当前处于普通代码状态”时,而不是简单地对全文做文本替换。这再次说明预处理实现必须和状态机绑定,宏展开不是一个独立的“查找替换”步骤。

3. 状态机实现路线:从字符流到清洗后的文本流

3.1 为什么正则表达式和简单查找在这里会失灵

看到“删注释”这个需求,第一反应可能是用正则表达式,比如把/\*[\s\S]*?\*/替换成空格,把//.*替换成空格。这个方案在纯注释文本上有效,但面对下面的代码就会翻车:

char *url = "https://example.com/path"; // 这是一个URL char slash = '/'; int x = 1 /* 注释 */ + 2;

第一行字符串里的://不是注释,正则无差别匹配会把字符串内容误伤;第二行单独的'/'更让只匹配一个斜杠的规则完全失效。原因在于,注释界定符的有效性依赖上下文:只有处于普通代码状态的//和/*才表示注释开始,出现在字符串中的这两个字符只是字面内容。

这种“依赖前文状态的逐字符判断”问题,正是状态机最擅长处理的场景。这也是这个实验的核心训练目标:不要写一堆脆弱的正则,而是建立清晰的状态迁移表。正则适合处理无状态文本模式,但源程序预处理的对象恰恰是“上下文相关”的字符流——用带状态的方式去理解它,比堆模式要可靠得多。

3.2 状态设计:正常、单行注释、块注释、字符串、字符常量

一个够用的状态集合如下表所示:

状态含义进入条件离开条件
normal普通代码初始状态遇到//、/*、"、'或#行
line_comment单行注释normal 中遇到//遇到换行符
block_comment多行注释normal 中遇到/*遇到*/
string字符串字面量normal 中遇到"遇到未转义的"
char字符字面量normal 中遇到'遇到未转义的'

其中 string 和 char 两个状态最容易漏。如果漏掉它们,字符串中的/*就会被当成注释开始处理,结果整段乱套。我见过有同学直接把整个文件的双引号当成“不会出现”来处理,结果字符串里恰好有//,后续所有行都被吞掉,调了一晚上没找到原因。

转义字符是另一个关键点。在 string 和 char 状态下,当前字符如果是一个反斜杠\,那么紧接着的下一个字符无论是什么都要原样输出,并且跳过它。例如"可以表示一个双引号"这种情况在C里写成"\"",如果不对转义符做处理,预处理会错误地提前关闭字符串状态,导致后面的代码全部被当成字符串内容。字符常量里的转义引号也是同理。

3.3 一个可运行的Python参考实现

下面给一个最小可运行的预处理参考实现,覆盖注释、空白规范化和字符串/字符常量的转义处理。宏展开的简单框架放在下一小节说明。

import sys def preprocess(src: str) -> str: out = [] i = 0 n = len(src) state = "normal" while i < n: ch = src[i] nxt = src[i + 1] if i + 1 < n else "" if state == "normal": if ch == "/" and nxt == "/": state = "line_comment" out.append(" ") i += 2 continue if ch == "/" and nxt == "*": state = "block_comment" out.append(" ") i += 2 continue if ch == '"': state = "string" out.append(ch) i += 1 continue if ch == "'": state = "char" out.append(ch) i += 1 continue out.append(ch) i += 1 continue if state == "line_comment": if ch == "\n": out.append(ch) state = "normal" i += 1 continue if state == "block_comment": if ch == "*" and nxt == "/": out.append(" ") state = "normal" i += 2 continue if ch == "\n": out.append(ch) i += 1 continue if state == "string": out.append(ch) if ch == "\\" and i + 1 < n: out.append(src[i + 1]) i += 2 continue if ch == '"': state = "normal" i += 1 continue if state == "char": out.append(ch) if ch == "\\" and i + 1 < n: out.append(src[i + 1]) i += 2 continue if ch == "'": state = "normal" i += 1 continue return "".join(out) if __name__ == "__main__": text = sys.stdin.read() print(preprocess(text), end="")

这个版本的核心逻辑只有几十行,但已经把注释处理、转义字符、字符串状态都覆盖到了。运行方式:

python3 preprocess.py < input.c > output.c

值得注意的一个位置是 normal 状态下遇到#的处理。如果实验要求保留预处理指令,可以原样输出#include那一整行;如果要求对#define做展开,就需要在这一行上做额外解析。上面代码里没有做宏展开,因为宏展开通常作为扩展任务,下面的小节会给出接入位置。

这段代码还有一个好处:它把普通代码、字符串、字符常量、两类注释区分得干干净净。即使后续要加宏展开,也只需要在 normal 状态的分支里增加逻辑,不会污染其他状态的处理。

3.4 扩展:简单宏展开与续行处理的接入点

简单对象宏的展开思路并不复杂:

  1. 扫描代码时,先记录所有#define NAME replacement形式的定义,保存到一张宏表里;
  2. 在 normal 状态读到一个完整标识符时,检查它是否在宏表中;
  3. 如果在宏表中,把标识符替换为对应的替换文本;
  4. 字符串与字符常量内部不做替换,这块状态机已经天然保证了。

因此在状态机中,normal 状态里需要一个“正在累积标识符”的逻辑。可以维护一个缓冲区,当前字符是字母、数字或下划线时继续累积,遇到分隔符时把累积结果查表输出。这部分代码不复杂,但要注意宏定义行本身也要被正确消费,不要把它误当成普通代码输出。更进阶的带参宏展开,建议先完成基本状态机再逐步加,一次性写全反而容易引入状态混乱。

还要注意一个预处理特有的语法:反斜杠续行。C语言规定,以反斜杠结尾的行会与下一行拼接,这在宏定义中非常常见。如果实验要求支持续行,需要在读到\后紧跟\n时跳过换行。这个细节可以在状态机的普通代码状态和宏定义解析里统一处理,否则遇到多行宏定义会直接解错。绝大多数实验的扩展分都卡在这个点上,我当年就是没做续行,结果一个三行的宏定义展开得乱七八糟,调试到半夜才反应过来。

4. 边界用例与自动化对比:预处理程序的验证方法

4.1 我见过最多的扣分点:字符串与注释的边界

很多人实现完基础功能后信心满满地交上去,结果在测试用例上栽跟头。常见的扣分点集中在这几类:

用例特征错误表现正确结果
字符串含://、/*字符串被拦腰截断字符串原样保留
字符常量'/'被误判为注释开始原样保留
/*内嵌套//单行逻辑提前终止整个块注释被吞掉
转义引号"\""字符串提前结束转义引号不结束字符串
文件末尾无换行行注释状态无法退出文件末尾自然结束
int a/**/b;输出成int ab;输出成int a b;

这张表可以直接当自测清单用。我在实际做的时候,是把表中每条都构造一个对应的C程序片段,再拼成一个综合测试文件,跑一遍程序看输出。注意,块注释内部的//不应该让状态机进入单行注释状态,那只是文本内容;而字符串内部的/*同理。状态机天然满足这个要求,但如果你为了“性能优化”先做字符串匹配再做注释匹配,就可能打破这个约束。

4.2 一组能榨干预处理逻辑的测试用例

推荐准备这样一个测试文件,覆盖规则的所有主要路径:

#include <stdio.h> // 行注释,后跟代码 #define PI 3.14 #define SQUARE(x) ((x) * (x)) /* 块注释第一行 块注释第二行 */ int main() { char *s = "https://example.com/path"; // 字符串里的//不能被当注释 char c = '/'; char d = '\''; /* 字符常量里有转义引号 */ int a = 1 /* 注释 */ + 2; int b = a/**/ + 1; printf("%s\n", s); // 普通行注释 return 0; }

预期输出要满足:字符串原样保留;字符常量原样保留;所有注释被替换成空格;块注释内部换行保留;#define行按实验要求处理。如果实验只要求识别并保留宏指令,则#define行原样输出;如果需要展开,则PI和SQUARE(x)按宏表展开。注意注释替换后,int b = a + 1;两侧的a和+之间有空格,不能粘连。

4.3 如何用自动化方式验证输出

如果只是人工看输出,很难覆盖到所有边界。推荐用自动化对比:

  1. 把你的程序输出命名为out.c;
  2. 构造一个expected.c作为权威答案;
  3. 用 diff 工具对比差异:
diff -u expected.c out.c

如果想对照标准预处理器的行为,可以用标准C编译器的-E选项观察宏展开和注释删除后的整体形态。但要注意,-E会真的展开#include和全部宏,如果你的实验只要求注释和空白处理,就不能全量对照,只能逐项对照自己实现范围内的部分,否则会看到大片差异,反而干扰判断。

另一个好习惯是写一个小的测试脚本,把“构造输入、调用预处理、比对预期输出”三步自动化。比如用 Python 的subprocess调用自己的预处理程序,再和手工维护的expected文件逐行比对。这样每次改动代码后跑一遍测试集,能立刻发现哪些边界情况又被改坏了,比起对着终端肉眼找差异要高效得多。

5. 实验之外的经验沉淀与后续衔接

5.1 做这个实验的几个实操心得

第一,先列规则,再写状态机,最后编码。我见过很多同学一上来就写 while 循环,边写边想,最后状态判断乱七八糟。建议先把规则写成“输入字符 + 当前状态 -> 输出 + 新状态”的表格,再翻译成代码。这张表既是你编码的蓝图,也是实验报告里比较有亮点的内容,比贴一大段代码更能体现思考过程。

第二,输出文件的行号保持比美观重要。预处理产物是要交给后续词法分析器用的,词法分析器报错时依赖行号。哪怕输出文件看起来很“乱”,只要行数一致,后续阶段就能正常工作。做实验时顺手写一个行号校验小脚本,对比输入输出逐行计数,能省掉大量排查时间。有一次我自己就是在报告里被老师追问“为什么第 12 行的报错实际在第 9 行”,才发现多行注释的换行没保留。

第三,不要在一开始就追求宏展开一步到位。先把注释和字符串处理的状态机跑稳,再往里面加宏表、加标识符累积、加续行处理。每一步都用测试用例锁住结果,再往前走。这个实验的多数扣分项都不是“宏展开实现太弱”,而是“基础注释处理在边界情况上翻车”。宏展开是锦上添花,状态机的完备性才是地基。

5.2 预处理结果对后续词法分析实验的直接帮助

做完这个实验,你会获得一个比较扎实的体会:词法分析器需要的输入是“已经没有注释干扰、格式相对规整”的文本流。等做到下一个词法分析实验时,你会发现识别标识符、关键字、数字的工作只需要关心当前字符的类别,不需要像预处理阶段那样警惕字符串里的假注释。这是因为预处理阶段已经帮你把所有“文本层面上会干扰记号识别”的东西处理掉了。

反过来,如果你在预处理阶段就理解了状态机的价值,词法分析实验会轻松得多。词法分析本质上也是一台更大的状态机,只不过它的输出从“清洗后的文本”变成了“带类型的Token序列”。我个人的经验是,认真做完预处理实验的人,在词法分析实验里往往一次就能跑通主流程;而跳过规则设计直接开写的人,通常会在后面花更多时间回头补课。这个实验表面上是“删注释”,实质上是让你第一次用工程化的方式处理有状态的字符流,这种能力在编译器的后续阶段里会反复用到。

如果你正在做这个实验,建议把重点放在状态迁移的完备性上,而不是堆正则或者堆技巧。能老老实实处理完字符串、字符常量、注释、空白、换行这几类字符流的交替,这个实验的核心价值就已经拿到了。后面扩展宏展开时,始终保持“先有稳定状态机,再加规则”的节奏,就不会被各种边界条件牵着走。希望这篇内容能帮你少走几个我当时走过的弯路。

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

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

立即咨询