资讯动态

编译原理实验:源程序预处理与状态机设计详解

发布时间:2026/10/19 8:55:41 来源:尧图企业网站定制
如果让我给编译原理的实验排个难度梯度源程序预处理绝对属于那种“看起来白送分、写起来全是坑”的题目。拿到的要求通常只有一句话对输入的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等指令至少做到正确识别与保留。从课程设计角度说这三类任务有优先级差异。宏展开在相当多实验里是加分项或扩展项注释和空白处理是必须项。一个完整宏展开系统要处理带参与不带参宏、宏展开时是否继续展开宏、宏中的#和##操作符等细节工作量不比一个小型词法分析器小。实验设置成“先做注释和空白再做扩展宏展开”本身就是在控制难度梯度别一上来就追求一步到位。根据我见过的一些实验要求考核点大致可以归纳成以下几条输入的C语言源程序包含各类注释、字符串、字符常量、转义字符。预处理后输出中注释全部消失并被至少一个空格替代。行号与原始输入保持一致便于后续词法分析报错。宏定义能够被识别简单替换至少覆盖对象式宏例如#define PI 3.14。输出文件中不应出现因注释删除导致的标识符粘连比如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 扩展简单宏展开与续行处理的接入点简单对象宏的展开思路并不复杂扫描代码时先记录所有#define NAME replacement形式的定义保存到一张宏表里在 normal 状态读到一个完整标识符时检查它是否在宏表中如果在宏表中把标识符替换为对应的替换文本字符串与字符常量内部不做替换这块状态机已经天然保证了。因此在状态机中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 如何用自动化方式验证输出如果只是人工看输出很难覆盖到所有边界。推荐用自动化对比把你的程序输出命名为out.c构造一个expected.c作为权威答案用 diff 工具对比差异diff -u expected.c out.c如果想对照标准预处理器的行为可以用标准C编译器的-E选项观察宏展开和注释删除后的整体形态。但要注意-E会真的展开#include和全部宏如果你的实验只要求注释和空白处理就不能全量对照只能逐项对照自己实现范围内的部分否则会看到大片差异反而干扰判断。另一个好习惯是写一个小的测试脚本把“构造输入、调用预处理、比对预期输出”三步自动化。比如用 Python 的subprocess调用自己的预处理程序再和手工维护的expected文件逐行比对。这样每次改动代码后跑一遍测试集能立刻发现哪些边界情况又被改坏了比起对着终端肉眼找差异要高效得多。5. 实验之外的经验沉淀与后续衔接5.1 做这个实验的几个实操心得第一先列规则再写状态机最后编码。我见过很多同学一上来就写 while 循环边写边想最后状态判断乱七八糟。建议先把规则写成“输入字符 当前状态 - 输出 新状态”的表格再翻译成代码。这张表既是你编码的蓝图也是实验报告里比较有亮点的内容比贴一大段代码更能体现思考过程。第二输出文件的行号保持比美观重要。预处理产物是要交给后续词法分析器用的词法分析器报错时依赖行号。哪怕输出文件看起来很“乱”只要行数一致后续阶段就能正常工作。做实验时顺手写一个行号校验小脚本对比输入输出逐行计数能省掉大量排查时间。有一次我自己就是在报告里被老师追问“为什么第 12 行的报错实际在第 9 行”才发现多行注释的换行没保留。第三不要在一开始就追求宏展开一步到位。先把注释和字符串处理的状态机跑稳再往里面加宏表、加标识符累积、加续行处理。每一步都用测试用例锁住结果再往前走。这个实验的多数扣分项都不是“宏展开实现太弱”而是“基础注释处理在边界情况上翻车”。宏展开是锦上添花状态机的完备性才是地基。5.2 预处理结果对后续词法分析实验的直接帮助做完这个实验你会获得一个比较扎实的体会词法分析器需要的输入是“已经没有注释干扰、格式相对规整”的文本流。等做到下一个词法分析实验时你会发现识别标识符、关键字、数字的工作只需要关心当前字符的类别不需要像预处理阶段那样警惕字符串里的假注释。这是因为预处理阶段已经帮你把所有“文本层面上会干扰记号识别”的东西处理掉了。反过来如果你在预处理阶段就理解了状态机的价值词法分析实验会轻松得多。词法分析本质上也是一台更大的状态机只不过它的输出从“清洗后的文本”变成了“带类型的Token序列”。我个人的经验是认真做完预处理实验的人在词法分析实验里往往一次就能跑通主流程而跳过规则设计直接开写的人通常会在后面花更多时间回头补课。这个实验表面上是“删注释”实质上是让你第一次用工程化的方式处理有状态的字符流这种能力在编译器的后续阶段里会反复用到。如果你正在做这个实验建议把重点放在状态迁移的完备性上而不是堆正则或者堆技巧。能老老实实处理完字符串、字符常量、注释、空白、换行这几类字符流的交替这个实验的核心价值就已经拿到了。后面扩展宏展开时始终保持“先有稳定状态机再加规则”的节奏就不会被各种边界条件牵着走。希望这篇内容能帮你少走几个我当时走过的弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑