☰
Pascal子集编译器设计:词法、语法、语义与中间代码生成全流程
2026/10/27 10:41:43 网站建设 项目流程

简介:这份资源是面向计算机专业学生与编译原理初学者的Pascal子集编译器课程设计报告,围绕词法分析、语法分析、语义分析与中间代码生成等核心环节展开,帮助读者理解并实践一个用C++手工实现的小型编译器。报告以北京邮电大学课程设计为背景,涵盖需求分析、总体结构、接口描述与各阶段详细设计,并给出团队分工与成绩评定标准,适合作为课程设计参考或编译原理复习材料。资源包共1个doc文件,约952KB,内容完整、结构清晰,便于按模块查阅。目前已有240人学习下载,读者可从中获取词法分析器接口设计、符号表构建、LL或LR分析方法选择、三地址码与四元式表示、类型检查与参数传递机制等具体设计思路,以及错误处理与目标代码生成的实现要点,对完成同类编译器实验具有较高的参考价值。

1. 从一份“能跑通”的编译课设说起:Pascal 子集编译器到底要交什么

每年到编译原理课设周,总有人拿着 Sub_P 文法来问:词法、语法、语义、中间代码四块,到底哪块该先动手?反直觉的地方在于,多数人以为先把词法分析器写完就稳了,结果卡在语法分析的分析表构造上,回头发现词法输出的记号编码和语法分析器要的对不上,整条流水线推倒重来。这份小型 Pascal 子集编译器设计报告解决的正是这个问题:它把词法分析、语法分析、语义分析与中间代码生成串成一条可交付的流水线,用 C++ 手工实现,不依赖 YACC 之类的生成工具。适合正在做编译原理毕设、课设,或者要交一份完整编译器设计方案的人,照着这套结构走,至少不会在接口对齐上翻车。

2. 总体架构设计:三模块流水线与全局数据契约

编译器的骨架不复杂,难的是模块之间用什么数据说话。这套方案把整个程序切成三个模块:词法分析器、语法分析器、语义分析器,中间代码生成嵌在语义分析里。三个模块不直接互相调用内部函数,而是通过两组全局数组做契约——记号流数组Symbol_stream[1000]和符号表数组symbol_table[100]。词法分析器负责往这两个数组里填数据,语法分析器只读记号流,语义分析器只读符号表和分析树。这种“生产者填数组、消费者读数组”的做法,好处是每个模块可以单独测试,坏处是全局状态多,调试时容易互相污染,后面避坑章节会专门讲。

2.1 模块职责与调用关系

词法分析器对外只暴露一个入口void lexout(void),它没有参数也没有返回值,靠读源文件、写全局数组和几个 txt 文件完成工作。语法分析器在启动时先调用一次lexout(),拿到完整的记号流,再构造 FIRST 集、FOLLOW 集和预测分析表,然后自顶向下建分析树。语义分析器拿到分析树后,按翻译方案遍历树,边做类型检查边发射三地址码。三个模块的边界清晰,接口就是那两个全局数组加分析树的节点编号,文档里明确标出lexout()是词法与语法之间的唯一接口,这一点在答辩时经常被追问。

2.2 全局数据结构约定

记号流和符号表的结构体定义是整个方案的地基,字段设计错了后面全得改。记号流里每个元素存记号属性和记号名,符号表里每个元素存变量名、目标地址、类型、维数、声明行、引用行数组和块索引号。这里有个细节值得抄:引用行用int use_row[5]固定长度数组,而不是链表,因为课设规模的源程序里一个标识符引用超过五次的情况极少,固定数组省去了动态内存管理的麻烦,代价是超过五次要截断,文档里注明即可。

// 符号表项:一个标识符的全部静态属性 typedef struct table_item { char name[9]; // 变量名,有效字符数 8,留一位给结束符 int address; // 目标地址(相对偏移) int type; // 类型编码:integer / real / boolean / array int demension; // 维数,0 表示标量 int declare_row; // 声明所在行号 int use_row[5]; // 引用行号,最多记 5 次 int block; // 块索引号,用于区分作用域 } Tuple; Tuple symbol_table[100]; // 记号流项:语法分析器的输入单元 typedef struct symbol { int atr; // 记号属性值(内部编码) char sname[9]; // 记号名 } Symbol; Symbol Symbol_stream[1000];

name[9]这个长度不是随便定的,Pascal 标准标识符有效长度通常取 8,多留一位放\0。atr字段存的是内部编码,比如关键字and编码为 1、begin为 3、标识符为 36、数字为 35,这套编码表必须在词法和语法两边共用同一份,否则语法分析器查表时会对不上号。我一般会把编码表单独抽成一个头文件,两边都 include,避免各写一份导致漂移。

2.3 编译流水线的数据流

一次完整编译的数据流是这样的:源文件先被整体读入字符缓冲区buffer,词法分析器逐字符扫描,识别出的标识符插入符号表并分配下标,常数插入常数表,关键字和运算符直接映射成内部编码,最终产出记号流数组和五个 txt 文件(符号表、常数表、统计信息、记号流、终结符)。语法分析器读记号流,用预测分析表驱动,每匹配一个产生式就在分析树上挂一个节点。语义分析器从根节点开始递归,遇到声明就填符号表的类型和地址,遇到表达式就做类型检查并生成临时变量,遇到赋值和跳转就发射三地址码。整条链路里,符号表是唯一被词法和语义两个阶段都写入的结构,词法写名字和行列号,语义写类型和地址,这种“分阶段填表”的设计让符号表成为贯穿全流程的黑匣子,出问题时先 dump 符号表往往能定位大半错误。

3. 词法分析器实现:状态机、符号表与五个输出文件

词法分析器是整个流水线的入口,也是最容易“看起来简单、写起来全是边界”的部分。这套方案用 C 语言手工实现,核心是一个逐字符的状态机,状态变量state为 1 表示当前在识别常数,state1标记是否进入小数部分,digit_l和digit_r分别记录{和}的嵌套层数用于过滤注释。关键字表用二维字符数组硬编码 24 个 Pascal 关键字,识别出一个 token 后先查关键字表,命中就返回对应编码,没命中再判断是不是标识符或数字。

3.1 关键字识别与内部编码

关键字判断函数int Iskeyword(char[])的返回值设计有点意思:mod返回 1、or返回 2、and返回 3、div返回 4,其他关键字返回 -1,标识符返回 0。这种“部分关键字给正数、其余给 -1”的写法是为了让调用方用一次判断区分三类 token:大于 0 是运算符类关键字,等于 0 是标识符,等于 -1 是普通关键字。内部编码表里program和procedure都编码为 15,这是原方案里的一个已知冲突,实际实现时建议改成不同编码,否则语法分析器在区分程序头和过程头时会出错。编码表覆盖了 24 个关键字、关系运算符(33)、赋值符(34)、乘性运算符(35)、加性运算符(14)、数字(38)、标识符(36)以及括号、分号、逗号、点号等分隔符,语法分析器的分析表直接引用这套编码,两边必须严格一致。

3.2 符号表插入与行列号记录

标识符插入符号表由int Word_insert(char[])完成,逻辑是:先遍历WordList查是否已存在,存在就返回下标,不存在就追加并返回 0。这里返回 0 表示“新插入”,返回非零表示“已存在”,调用方据此决定是否更新引用行。行列号记录是课设的硬性要求,行计数line_count在遇到换行符时自增,列号则靠当前缓冲区位置i减去行首位置得到。符号表里declare_row只在声明语句处写入,use_row在每次引用时追加,超过五次就丢弃,文档里注明“引用行最多记录五次”即可,不必为此上动态结构。

// 标识符插入符号表:返回已有下标,新插入返回 0 int Word_insert(char token[]) { for (int idx = 0; idx < count1; idx++) { if (strcmp(WordList[idx], token) == 0) { // 已存在,记录引用行(最多 5 次) if (n[idx] < 5) symbol_table[idx].use_row[n[idx]++] = line_count; return idx; } } // 新标识符:写入名字、声明行,初始化引用计数 strcpy(WordList[count1], token); strcpy(symbol_table[count1].name, token); symbol_table[count1].declare_row = line_count; symbol_table[count1].block = 0; n[count1] = 0; return 0; }

这段代码的关键在n[]数组,它为每个符号表项维护一个引用行写入游标,避免每次插入都从头数use_row里填了几个。block字段初始化为 0,因为 Sub_P 文法不包含嵌套过程,所有变量都在最外层块,这个字段实际用不上,但保留着方便以后扩展。declare_row在插入时直接取当前行号,前提是词法分析器在扫描到声明语句时已经正确累加了line_count,如果行计数在注释块里漏加,声明行就会整体偏移,语义分析报错时定位全错,这是血泪经验。

3.3 五个输出文件与独立测试

词法分析器要求能单独测试,输入源程序文件后产出五个 txt:符号表.txt记录 id 及声明行、引用行、维数;常数表.txt记录识别出的常数;统计信息.txt记录语句行数、单词个数、错误个数;记号流.txt记录记号和属性;终结符.txt记录识别出的终结符。这五个文件不是摆设,语法分析器调试时如果分析表报错,第一件事就是打开记号流.txt看是不是某个 token 的编码错了。独立测试的入口可以写一个main函数只调lexout(),不启动语法分析,这样词法阶段的 bug 不会污染后面的调试。统计信息里的错误计数error_count和错误行数组error_line[30]在遇到非法字符时累加,词法阶段能报的错主要是不可识别字符和未闭合注释,类型错误留给语义阶段。

4. 语法分析器实现:FIRST/FOLLOW 集与预测分析表

语法分析采用自顶向下的 LL(1) 预测分析法,这是 Sub_P 文法下最稳妥的选择,因为文法本身经过改写后满足 LL(1) 条件,不需要处理左递归和回溯。整个语法分析器在启动时做三件事:读入产生式、求 FIRST 和 FOLLOW 集、构造预测分析表,之后每读一个记号就查表决定展开哪个产生式,同时往分析树上挂节点。这套流程对应课本上的算法 4.2,但落到代码里有几个工程细节值得展开。

4.1 FIRST 集与 FOLLOW 集的求法

FIRST 集用FirstGroup类封装,每个符号对象存value(符号编码)、firstgroup[15](FIRST 集数组)和count(元素个数)。求 FIRST 的规则按课本三条来:终结符的 FIRST 是它自己;产生式右部以 ε 开头则 ε 入 FIRST;非终结符则把右部第一个符号的 FIRST(去掉 ε)并入,若第一个符号能推出 ε 则继续看第二个,依此类推。insertFsg(int NT, int i, int m)负责把符号 NT 的 FIRST 集并入调用者的集合,existed(int sym)做去重。FOLLOW 集用FollowGroup类,规则同样三条:开始符号的 FOLLOW 含$;产生式A -> αBβ把 FIRST(β) 去掉 ε 并入 FOLLOW(B);若 β 能推出 ε 则把 FOLLOW(A) 并入 FOLLOW(B)。两个集合都在语法分析器初始化时算一次,结果存内存,构造分析表时直接查。

4.2 预测分析表的构造与存储

分析表用二维整型数组analysistable[][]表示,行是非终结符,列是终结符,初始化为全 -1 表示 error。构造时逐个扫描产生式A -> α,编号为 i,先求 FIRST(α),对每个a ∈ FIRST(α)把analysistable[A][a]赋值为 i;若 ε ∈ FIRST(α),再求 FOLLOW(A),对每个b ∈ FOLLOW(A)把analysistable[A][b]也赋值为 i。没被赋值的格子保持 -1,语法分析时查到 -1 就报语法错误并记录当前行号。产生式存储用int generator[][],每行第一个元素存左部符号编码,后面存右部符号编码,这样查表得到产生式编号后直接按行展开,不需要再解析字符串。

// 构造预测分析表:遍历产生式,按 FIRST/FOLLOW 填表 void CreateAnalysisTable() { // 初始化全 -1,表示错误入口 for (int i = 0; i < NT_COUNT; i++) for (int j = 0; j < T_COUNT; j++) analysistable[i][j] = -1; for (int p = 0; p < generator_count; p++) { int left = generator[p][0]; // 产生式左部 // 求右部 FIRST 集 int firstSet[15], fcount = 0; getFirstOfRHS(p, firstSet, &fcount); for (int k = 0; k < fcount; k++) { if (firstSet[k] != EPSILON) analysistable[left][firstSet[k]] = p; } // 若右部可推出 ε,用 FOLLOW 集补填 if (containsEpsilon(firstSet, fcount)) { int followSet[15], gcount = 0; getFollow(left, followSet, &gcount); for (int k = 0; k < gcount; k++) analysistable[left][followSet[k]] = p; } } }

getFirstOfRHS和getFollow是对前面 FIRST/FOLLOW 结果的封装查询,EPSILON用一个不会与正常编码冲突的负数表示。填表顺序很重要:先填 FIRST 部分,再填 FOLLOW 部分,如果某个格子被重复赋值,说明文法有冲突,LL(1) 条件下不应该发生,一旦发生就是文法改写没到位,得回去检查产生式。分析表构造完成后可以 dump 成文本文件,答辩时展示这张表比讲一堆理论更有说服力。

4.3 分析树的构建与直观输出

分析树用节点数组表示,每个节点存符号编码、父节点下标和子节点列表。语法分析主循环维护一个分析栈,栈顶是非终结符就查表展开,把产生式右部逆序压栈并挂子节点;栈顶是终结符就与当前输入记号匹配,匹配成功则读下一个记号。树建好后用缩进递归打印,每深一层多两个空格,输出到分析树.txt。改变源程序后重新编译,分析树结构随之变化,这是验证语法分析正确性的直观手段。我一般会在树节点里额外存一个行号字段,报错时能直接定位到源码行,比只打印符号名好用得多。

5. 语义分析与中间代码生成:翻译方案与三地址码

语义分析是这套方案里工作量最大、也最能体现设计功力的部分。它要做三件事:建符号表并计算类型和相对地址、做类型检查与类型转换、按翻译方案生成三地址码。原方案用递归下降的翻译方案,每个文法符号对应一个函数,函数参数是分析树节点号,返回值是类型编码或临时变量名。这种“一个非终结符一个函数”的写法虽然函数多,但和文法一一对应,调试时哪个产生式出问题就查哪个函数,定位效率高。

5.1 符号表的栈式组织与类型计算

符号表用SqStack栈结构组织,mktable()新建一张表并返回指针,enter(tableptr, name, type, addr)往表里插变量,lookahead(k)查节点 k 的最左子节点。类型计算集中在type0(int k)函数,返回一个二元数组,第一个元素是类型编码,第二个是变量宽度。标量integer宽度 4、real宽度 8、boolean宽度 1;数组类型则用(上界 - 下界) * 元素宽度算总宽度,上下界必须是整型,否则报类型错误。相对地址offset用一个栈维护,每进入一个过程压 0,退出时弹出,变量声明时top(offset)累加宽度,这样每个变量的地址都是相对当前过程栈帧的偏移。

5.2 类型检查与类型转换规则

类型检查覆盖三类场景:赋值语句检查等号两边类型是否相同,不同则报错;条件语句检查表达式是否为boolean型;基本运算检查两个操作数类型是否一致,不一致时按规则转换。转换规则是:integer与real混合运算时,integer通过inttoreal临时变量提升为real,结果类型为real;同类型运算结果保持原类型。翻译方案里simple_expression'的产生式把四种组合(int+int、real+real、int+real、real+int)分别处理,前两种直接发射三地址码,后两种先发射一条inttoreal转换指令再运算。这套规则写在文档的文法符号属性表里,每个符号的.t(类型)和.name(临时变量名或地址)两个属性列清楚,答辩时对着表讲比空口说更清楚。

5.3 三地址码的发射与覆盖范围

中间代码用三地址码表示,发射函数emit()把一条指令追加到指令表。覆盖的功能包括赋值、数组访问、指针解引用和函数调用参数传递。赋值语句发射x := y op z形式;数组访问先算下标偏移再发射x := a[i];指针用x := *p和*p := x;函数调用按值传递参数,实参依次enterq进参数队列,调用时发射param指令和call指令。控制流用标号加goto实现,if语句发射条件跳转和两个标号,while语句发射循环头标号、条件跳转和回跳。临时变量用newtemp()生成,命名规则T1、T2递增,避免与用户标识符冲突。

// 赋值语句的语义动作:检查类型后发射三地址码 void statement(int k) { int node = k; if (isAssign(node)) { int lhsType = variable(node); // 左部变量类型 int rhsType = expression(node); // 右部表达式类型 if (lhsType != rhsType) { // 允许 int 赋给 real,插入转换 if (lhsType == TYPE_REAL && rhsType == TYPE_INT) { char* tmp = newtemp(); emit(tmp, ":=", "inttoreal", exprName, ""); emit(varName, ":=", tmp, "", ""); } else { reportError(line_count, "assignment type mismatch"); } } else { emit(varName, ":=", exprName, "", ""); } } // 其他语句类型:if / while / compound 分别处理 }

emit的五个参数分别是结果、运算符、操作数一、操作数二和空占位,实际实现时可以重载成不同参数个数。类型不匹配时不是直接崩溃,而是记录错误行号后继续分析,这样一次编译能报出多个错误,用户体验好得多。newtemp生成的临时变量也要登记到符号表里,类型随运算结果定,否则后面寄存器分配时找不到临时变量的宽度。

5.4 参数传递机制与语法制导定义

Sub_P 的函数调用采用值传递,实参在调用前求值并压入参数队列,被调函数按形参顺序从队列取。翻译方案里expression_list负责收集实参,每收集一个就enterq(q, expression.name),procedure_statement在发射call前把队列里的参数依次emit("param", ...)。语法制导定义写在文档里,每个产生式对应一组语义动作,动作里对符号属性的读写用.t、.name、.place标注,和课本上的翻译方案格式一致。参数传递这部分最容易翻车的是实参个数和形参个数不匹配,语义分析时要在call前比对两个计数,不等就报错,别等到运行时才崩。

6. 避坑与排查:编译课设里最容易翻车的五个点

6.1 记号编码两边不一致

现象是语法分析器查表时频繁报错,但单独跑词法分析器输出的记号流看起来正常。原因是词法分析器的内部编码表和语法分析器的终结符编码表各写了一份,某个关键字或运算符的编码对不上。解决办法是把编码表抽成公共头文件,两边 include 同一份,改编码只改一处。我一般还会在语法分析器初始化时做一次自检,遍历所有终结符编码,确认每个都能在词法输出里找到对应项。

6.2 行计数在注释块里漏加

现象是语义分析报错的行号整体偏移,声明行和引用行对不上。原因是词法分析器过滤{...}和/*...*/注释时,只跳过了字符但没有累加line_count,注释里如果有换行,后续所有行号都少算。解决办法是在注释过滤循环里遇到\n就line_count++,并且注释嵌套层数digit_l和digit_r要配对,未闭合注释在文件末尾报错。

6.3 FIRST/FOLLOW 集算出空集

现象是预测分析表大量格子为 -1,稍微复杂一点的源程序就报语法错误。原因通常是产生式读入时右部符号编码没查对,或者 FIRST 集合并时existed去重逻辑写错导致元素丢失。排查方法是把每个非终结符的 FIRST 和 FOLLOW 集 dump 出来,和手工推导的结果逐项比对,先确认单个符号的集合正确,再看合并逻辑。另一个常见原因是 ε 的表示用了 0,和某个正常编码冲突,换一个负数表示即可。

6.4 符号表全局状态被污染

现象是编译第二个源文件时结果错乱,或者同一文件里后面的声明覆盖前面的。原因是symbol_table和WordList是全局数组,count1计数没在每次编译开始时清零。解决办法是在lexout()入口处重置count1、count2、line_count、error_count等所有全局计数器,符号表数组用memset清空。如果要做多文件编译,更稳妥的做法是把这些全局状态收进一个CompilerContext结构体,每次编译新建一个实例。

6.5 临时变量命名冲突

现象是三地址码里出现两个同名临时变量,导致后续解释执行结果错误。原因是newtemp()的计数器在多次调用间没重置,或者临时变量名用了T加数字但用户标识符里也有T1这种名字。解决办法是临时变量统一加前缀比如_t,用户标识符不允许以下划线开头,从词法阶段就禁止,这样命名空间天然隔离。计数器在每次编译开始时归零,和符号表重置放在一起。

7. 验收与答辩:怎么证明这套编译器真的能跑

验收的核心不是演示一个 Hello World,而是证明每个阶段都能独立工作、错误能被正确捕获。我一般会准备三组测试用例:第一组是合法程序,覆盖赋值、数组、指针、函数调用、if/while 控制流,验收时展示从源文件到三地址码的完整输出,重点看符号表里的类型和地址是否正确、三地址码的临时变量是否连贯;第二组是词法错误程序,故意放非法字符和未闭合注释,展示统计信息.txt里的错误行号和错误计数;第三组是语法和语义错误程序,放类型不匹配的赋值和缺失分号的语句,展示分析树在出错点的展开情况和错误报告。每组用例都提前跑一遍,把输出文件存好,答辩时直接打开对照讲,比现场跑更稳。

答辩提问高频集中在三个方向:为什么选 LL(1) 不选 LR、FIRST/FOLLOW 集怎么保证正确、类型转换在哪些产生式里触发。第一个问题答“Sub_P 文法改写后满足 LL(1),手工实现分析表比 LR 的项目集构造更直观,课设规模下 LL 的调试成本更低”;第二个问题现场打开 dump 文件,挑一个非终结符手工推一遍对照;第三个问题直接翻到翻译方案里simple_expression'的四种组合,指着inttoreal那条指令讲。文档里的文法符号属性表要提前做好,每个符号的.t和.name两列填满,答辩时被问到某个符号的属性,直接查表回答,别现场推。

验收前还有一件事值得做:把五个输出文件和最终三地址码打包成一个output目录,文件名带阶段前缀,比如01_记号流.txt、02_分析树.txt、03_三地址码.txt,评委翻起来一目了然。我吃过一次亏,所有输出文件平铺在根目录,答辩时找某个文件翻了半天,评委等得不耐烦直接问下一个问题。从那以后我每次交课设都强制走一遍“输出文件按阶段编号归档”的流程,顺便在 README 里写清楚每个文件对应哪个阶段、怎么复现。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询