简介:这份资源是面向计算机专业学生与编译原理初学者的Pascal子集编译器课程设计报告,围绕词法分析、语法分析、语义分析与中间代码生成等核心环节展开,帮助读者理解并动手实现一个小型编译程序的完整流程。压缩包内共1个doc文档,约952KB,以课程设计报告形式系统整理了需求分析、总体结构、接口描述与各阶段设计资料,并附有团队分工与成绩评定说明。文档中详细说明了词法分析器的接口函数、符号表构建、关键字与标识符识别,以及语法分析中分析表与自顶向下分析树的建立方式,还涉及三地址码或四元式中间代码的生成规则与类型检查思路。目前已有240人学习,适合需要完成编译原理课程设计、参考Pascal子集实现方案或梳理编译器各阶段设计文档的读者使用。
1. 手写一个 Pascal 子集编译器:为什么“不用工具”反而是理解编译原理的捷径
很多人第一次接触编译原理,印象里全是龙书里那套抽象符号和自动机,考试能过,但真给一段源码,脑子里跑不出“它到底怎么被机器读懂”的画面。这份小型 Pascal 子集编译器设计报告,恰恰反着来:词法、语法、语义、中间代码全部手工实现,明确禁用 YACC 这类生成器。听起来像自虐,但它逼你把每个字符怎么变成记号、每条产生式怎么落到分析表、每个类型错误在哪一步被拦下来,全部想清楚。编译器本质上是一条流水线:字符流进,记号流出,语法树长出来,中间代码吐出来。搞懂这条流水线每一站的输入输出契约,比背十遍 FIRST/FOLLOW 定义都管用。下面就从这套手工实现里,把“为什么这么设计、怎么实现的”一层层拆开。
2. 词法分析器:字符流怎么变成带属性的记号流
2.1 接口设计:为什么用全局数组而不是返回值
词法分析器对外只暴露一个void lexout(void),没有参数也没有返回值,结果全写进两个全局数组:Symbol_stream[1000]存记号流,symbol_table[100]存符号表。这种设计在工程上不算优雅,但在教学编译器里是刻意为之——语法分析器需要的是“一次性拿到整条记号流”,而不是边解析边回调。全局数组相当于一个约定好的共享缓冲区,词法跑完,语法直接遍历,省掉了函数间传大结构体的麻烦。
记号流的结构体只有两个字段:
typedef struct symbol { int atr; // 记号属性(内部编码) char sname[9]; // 记号名,有效字符数 8 } Symbol; Symbol Symbol_stream[1000];atr是内部编码,sname是原始拼写。为什么两个都要留?因为语法分析只看atr做表驱动,而报错信息和符号表回填需要原始名字。常见的实现思路是让词法返回一个(token_type, lexeme)二元组,这里用结构体数组把整条流固化下来,好处是语法分析阶段可以反复回看前面的记号,做预测分析时不用维护额外的回退逻辑。
符号表项则更重:
typedef struct table_item { char name[9]; // 变量名 int address; // 目标地址 int type; // 类型 int demension; // 维数 int declare_row; // 声明行 int use_row[5]; // 引用行 int block; // 块索引号 } Tuple; Tuple symbol_table[100];注意declare_row和use_row[5]——设计要求明确要求符号表记录每个符号出现的行号和列号。这是词法阶段就要完成的“副作用”:识别到一个标识符时,不光要产出记号,还要判断它是首次声明还是引用,把行号填进对应槽位。use_row只留 5 个位置,说明这套实现假设单个变量引用不超过 5 次,超出就丢——教学编译器里这种“够用就行”的取舍很常见,真做产品得换成动态数组或链表。
2.2 关键字识别与内部编码:一张表定生死
关键字判断走的是int Iskeyword(char[]),内部拿识别出的字符串去和静态数组比对:
static char keyword[N][10] = { "and","array","begin","boolean","do","else","end","false", "function","if","integer","not","of","or","procedure", "program","read","real","record","then","true","var", "while","write" };返回值设计得有点怪:mod返回 1、or返回 2、and返回 3、div返回 4,其他关键字返回 -1,标识符返回 0。为什么偏偏把这四个运算符单独给正数?因为它们在语法里既像关键字又像运算符,词法阶段提前给固定编码,语法分析构造预测分析表时可以直接当终结符用,不用再查一次表。这是典型的“把歧义消解提前到词法层”的思路。
完整的内部编码表覆盖了关键字、运算符、分隔符和字面量类别,比如+是 40、-是 41、id是 36、num是 35。这套编码是词法和语法之间的契约:语法分析器的分析表行列索引直接用的就是这些整数,改一个编码,整张表都得跟着动。所以文档里专门要求“标明哪个 API 是词法和语法之间的接口”——就是lexout产出的Symbol_stream和这套编码约定。
2.3 注释过滤与状态机:一个state变量扛下所有
词法分析器把源文件整个读进buffer[Max],然后逐字符扫描。注释处理靠两个计数器digit_l、digit_r记录{}的配对,/**/则另走分支。真正决定当前字符怎么归类的是一个state变量:state=1表示当前正在拼的是常数,否则按标识符或符号处理;state1单独标记小数点的出现,防止1.2.3这种非法数字被吞掉。
这种“单状态变量 + 逐字符 switch”的写法,本质是一个极简的确定性有限自动机,只是没画状态转移图,而是用 if-else 硬编码。血泪经验在于:注释嵌套和字符串里的引号是最容易翻车的地方。比如{出现在字符串'...'里不能被当注释起始,这套实现靠strings[50]缓冲区单独处理双引号内容来规避。如果让重来一遍,我会把状态机显式化成枚举加转移表,而不是靠state和state1两个布尔量互相纠缠——后者在加新词法规则时极容易踩坑。
2.4 输出文件:为什么词法要能单独测试
设计要求里有一条很关键:词法分析器要能单独测试,输入源程序文件,把识别的终结符号打印到文件。这套实现产出了五个文件:符号表、常数表、统计信息、记号流、终结符。单独可测意味着词法层的正确性可以脱离语法独立验证——给一段只有声明没有语句的源码,看符号表的declare_row填得对不对;给一段带注释的代码,看记号流里注释有没有被干净滤掉。
统计信息里的line_count、word_count、char_count、error_count不是摆设。行计数在报错时定位用,记号流计数用来校验数组有没有越界(上限 1000),错误计数则让测试者一眼看出这段源码触发了多少词法错误。常见的实现思路是把这些计数器做成结构体统一管理,这里散成全局变量,好处是写起来快,坏处是任何一个函数都可能偷偷改掉它们,调试时得全局搜索。
3. 语法分析:预测分析表是怎么从 FIRST/FOLLOW 长出来的
3.1 FIRST 集计算:递归下降求闭包
FIRST 集的计算封装在FirstGroup类里,核心接口是GetFirstGroup(int sym),对每个文法符号求一次,结果存进firstgroup[15]数组。算法严格按三条规则来:终结符的 FIRST 是它自己;产生式右部以 ε 开头则 ε 入集;非终结符则沿着产生式右部逐个符号展开,把每个Yi的 FIRST 去掉 ε 后并入,直到某个Yi不含 ε 为止。
insertFsg(int NT, int i, int m)这个接口名里的三个参数,我一般会这么理解:NT是目标非终结符,i是源符号,m是模式标志(是否跳过 ε)。它做的是把源符号的 FIRST 集合并进目标集,同时用existed(int sym)去重。为什么 FIRST 集数组只开 15 个位置?因为 Pascal 子集的单个非终结符可推导出的首终结符种类有限,15 是拍脑袋定的上限,超了就会静默截断——这是教学代码里典型的“假设输入规模可控”。
3.2 FOLLOW 集:从开始符号倒推
FOLLOW 集的计算没有参数,GetFollowGroup()一次性把所有非终结符的 FOLLOW 算完。规则同样三条:$进开始符号的 FOLLOW;对A -> αBβ,FIRST(β) 去掉 ε 并入 FOLLOW(B);若 β 能推导出 ε,则 FOLLOW(A) 整体并入 FOLLOW(B)。
这里有个实现细节值得说:matchsymbol(char* str)负责把符号名映射成内部编码,FIRST 和 FOLLOW 的计算都依赖它。也就是说,符号表(终结符表Tsymbol和非终结符表NTsymbol)必须在求集之前就建好。顺序错了,matchsymbol返回垃圾值,整个分析表就是错的,而且不会报错——这是语法阶段最隐蔽的坑之一。
3.3 预测分析表构造:-1 就是 error
分析表存在int analysistable[][]里,构造算法直接照搬教材:先全填 -1,然后逐条扫描产生式A -> α,编号为 i,对 FIRST(α) 里每个终结符 a,把analysistable[A][a]赋成 i;如果 ε 在 FIRST(α) 里,再对 FOLLOW(A) 里每个 b 赋 i。没被赋值的格子保持 -1,表示语法错误。
产生式本身存在int generator[][]里,每行一个产生式,generator[i][0]是左部,generator[i][1..10]是右部符号编码。这个二维数组就是整个语法分析器的“程序”——分析表里的数字 i 直接索引到这一行,取出右部再压栈。为什么用定长 10?因为 Pascal 子集的产生式右部最长不超过 10 个符号,超了就得改结构。这种硬编码上限在教学项目里到处都是,读源码时看到数组第二维是常数,基本就能反推出作者对文法规模的假设。
3.4 分析树输出:让“看不见的推导”变得可验证
设计要求里明确要“以直观的方式输出分析树”,并且“改变源程序,分析树将发生变化”。这套实现走的是自顶向下预测分析,每匹配一个终结符、每展开一个非终结符,就往树结构里挂一个节点。输出时按层缩进打印,一眼能看出program下面挂了identifier_list、declarations、subprogram_declarations、compound_statement四棵子树。
分析树的价值不在“好看”,而在调试。语法分析出错时,-1 格子只告诉你“当前非终结符遇到当前终结符不合法”,但分析树能告诉你“走到这一步之前,前面已经成功展开了哪些产生式”。常见的排查手法是:拿一段最小可复现源码,对比分析树和预期文法推导,看第一个分叉点在哪。如果分析树在statement层就断了,问题多半在表达式相关的产生式;如果在declarations层就歪了,回去查type和identifier_list的 FIRST 集有没有算错。
4. 语义分析与中间代码:类型检查怎么嵌进语法树遍历
4.1 翻译方案:把语义动作写进产生式
语义部分的核心是一套语法制导翻译方案,每个产生式后面跟一段语义动作。比如变量声明的翻译模式:
declaration -> identifier_list : type { identifier_list.t := type.t; identifier_list.width := type.width } ; declaration'这段的意思是:当语法树走到declaration节点,先递归处理identifier_list和type两个子节点,拿到类型t和宽度width,再回填给identifier_list。width是给符号表算偏移量用的——integer宽 4、real宽 8,数组则用(上界 - 下界) * 元素宽度算总大小。符号表的address字段就是靠这个width累加出来的相对偏移。
为什么语义动作要挂在产生式上而不是单独遍历一遍树?因为类型信息是自底向上汇聚的:叶子节点的类型确定后,父节点才能做类型检查。把动作嵌在推导过程里,天然就是后序遍历的顺序,省掉一次额外的树遍历。代价是语法分析和语义分析耦合在一起,改文法就得同步改动作——这也是为什么文档要求“在写程序之前,先设计语义动作,写出翻译模式”。
4.2 类型检查与类型转换:inttoreal从哪来
类型检查覆盖三类场景:赋值语句检查等号两边类型是否一致;if/while的条件表达式必须是boolean;基本运算的两个操作数类型要匹配。不匹配时不是直接报错,而是尝试类型转换——整数和实数混合运算时,插入一条inttoreal中间代码把整数提升为实数。
看simple_expression'的语义动作,四种类型组合分别处理:int op int直接生成临时变量;real op real同理;int op real先把左边inttoreal再算;real op int先把右边转实数。每种情况都调newtemp申请临时变量名,然后emit一条三地址码。这套逻辑就是编译原理教材里“类型强制”的标准实现,难点不在算法,在于四种分支容易漏写一种,漏了就会在混合运算时生成类型不一致的中间代码,后面跑起来结果错得莫名其妙。
4.3 符号表管理:堆栈式作用域
符号表用SqStack组织,mktable()新建一张表,enter()插入变量,push/pop管理作用域。虽然设计要求写明“不包含嵌套过程”,但翻译方案里仍然保留了subprogram_head里mktable+push的结构,说明这套代码是从完整 Pascal 编译器裁剪下来的,嵌套过程的骨架还在,只是没启用。
enter(SqStack * tableptr, char * name, int type, int addr)的addr参数就是前面算出来的偏移量。符号表查找走lookahead(int k),返回节点 k 最左子节点对应的字符串——这个接口名字起得有点误导,它实际做的是“取语法树某节点的最左叶子”,用来在语义动作里拿到标识符名字。为什么不用节点直接存名字?因为语法树节点只存了子节点指针,名字在叶子节点上,得顺着最左路径摸下去。
4.4 中间代码生成:三地址码覆盖赋值、数组、指针、函数
中间代码采用三地址码,emit负责往代码序列里追加一条。覆盖的功能包括赋值、数组访问、指针解引用和函数调用。赋值语句的语义动作最简单:
statement -> variable assignop expression { emit(variable.name ':=' expression.name) }数组和指针的处理在variable的推导里,通过factor和variable的语义动作组合出带下标的地址计算。函数调用的参数传递走expression_list,每个实参enterq(q, expression.name)压进参数队列,调用点再统一取。设计要求要求文档里描述“采用何种参数传递机制”——这套实现用的是值传递,实参表达式先求值成临时变量,再按顺序传给形参。
中间代码的种类在文档里列了清单,包括赋值、加减乘除、关系运算、无条件跳转、条件跳转、参数传递、函数调用、返回等。每种都对应emit的一个调用点,读源码时搜emit(就能把所有中间代码生成位置找全。这种“一种中间代码一个 emit 调用”的写法,好处是生成逻辑和语法树结构一一对应,坏处是优化空间几乎为零——每条三地址码都是独立的,没有基本块划分,也没有公共子表达式消除。教学编译器做到这一步就够了,真要做优化得在中间代码之上再加一层。
5. 避坑与排查:手工编译器的五个高频翻车点
5.1 符号表行号错位:现象是报错指向错误行
现象:语法或语义报错时,提示的行号和源码实际行对不上,差一两行甚至更多。原因多半是词法阶段的行计数在注释或换行符处理时漏加或多加——{}注释跨行时,里面的换行符有没有计入line_count,不同实现选择不同。解决:在词法分析器里单独打日志,每读一个\n就打印当前行号,和源文件对照,确认注释块内的换行是否被正确统计。我一般会在lexout开头把line_count初始化为 1 而不是 0,避免第一行报错显示第 0 行。
5.2 FIRST 集算错导致分析表大面积 -1
现象:一段合法源码在语法分析时频繁报错,分析表里大量格子是 -1,但文法本身没问题。原因通常是 FIRST 集计算时 ε 的处理有误——某个非终结符明明能推导出 ε,但insertFsg的m参数传错,导致 ε 没被正确传播,进而 FOLLOW 集也跟着错,分析表该填的格子没填。解决:写一个小测试,对每个非终结符打印 FIRST 和 FOLLOW 集,和手工推导结果逐项比对。重点查那些右部以可空非终结符结尾的产生式。
5.3 类型转换漏分支:混合运算结果类型飘忽
现象:integer和real混合运算时,中间代码里临时变量的类型和预期不符,运行时结果精度丢失或直接类型错误。原因:simple_expression'的四种类型组合只写了三种,漏了real op int或int op real其中一种,走了默认分支。解决:在emit生成临时变量时强制打印变量类型,跑一段包含全部四种组合的测试源码,看每种组合生成的inttoreal指令数量对不对。四种组合应该各有一条对应的转换路径,少一条就是漏了。
5.4 数组维数计算越界:demension字段永远是 0
现象:符号表里数组变量的demension字段始终是 0,语义分析时数组访问不报错但地址算错。原因:type -> array [digits1 .. digits2] of standard_type的语义动作里,type.width算对了,但忘了把维数写回符号表项的demension字段——enter调用时传的参数里没有维数。解决:检查declaration的语义动作,确认identifier_list回填时把type的维数信息一并带过去。这个坑很隐蔽,因为宽度算对了,只有数组下标访问才会暴露。
5.5 分析树输出层级错乱:缩进对不上推导顺序
现象:分析树打印出来,子节点挂到了错误的父节点下,或者同一层的节点缩进不一致。原因:树节点插入时用的父节点指针取错了——lookahead(k)返回的是最左叶子,如果拿它当父节点挂新节点,树结构就歪了。解决:在挂节点的地方打印父节点和子节点的符号名,对照文法推导手工核对前三层。预测分析是自顶向下的,父节点一定比子节点先创建,按创建顺序打日志就能定位哪一步挂错了。
6. 设计取舍:为什么教学编译器敢用全局数组和定长上限
这套实现里到处都是“够用就行”的痕迹:记号流上限 1000、符号表上限 100、FIRST 集数组 15 个位置、产生式右部定长 10、引用行只记 5 次。放在生产环境里,这些全是定时炸弹;但在教学编译器里,它们是刻意的简化,目的是把注意力从内存管理挪到编译流程本身。全局数组省掉了模块间传参和生命周期管理,定长上限省掉了动态扩容逻辑,换来的是每个阶段的输入输出都能用肉眼在 txt 文件里核对——符号表、常数表、记号流、终结符、统计信息,五个文件一摆,整条流水线的中间状态全透明。
真正值得琢磨的取舍在“手工实现 vs 工具生成”这一层。用 YACC 或 ANTLR,语法分析几行声明就出来了,但 FIRST/FOLLOW 怎么算、分析表怎么填、冲突怎么消解,全被工具藏进了黑匣子。手工写一遍,你会真切体会到:预测分析表的每个 -1 格子都是一次“这里不能这么推导”的硬约束,类型检查的每个分支都是一次“编译器替程序员兜底”的决策。这种体感,看多少遍教材都换不来。
验证这套编译器是否真的 work,我的习惯是准备三组源码:一组全合法,跑通看中间代码和最终结果;一组只有词法错误(比如非法字符),确认报错行号准确;一组语法合法但语义错误(比如把real赋给integer变量不做转换),确认类型检查能拦住。三组都过,基本就能信这条流水线是通的。从那以后我每次读别人的编译器代码,都强制先找它的符号表结构和中间代码种类——这两样定了,整个编译器的设计骨架就清楚了。希望帮到你。
本文还有配套的精品资源,点击获取