news 2026/10/27 10:41:46

手工实现Pascal子集编译器:词法、语法与语义分析全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手工实现Pascal子集编译器:词法、语法与语义分析全解析

简介:这份资源是面向计算机专业学生与编译原理初学者的Pascal子集编译器课程设计报告,围绕词法分析、语法分析、语义分析与中间代码生成等核心环节展开,帮助读者理解并实践一个用C++手工实现的小型编译器。压缩包内共1个doc文档,约952KB,内容涵盖需求分析、总体结构、词法接口描述、符号表设计、语法分析方法选择、语义动作与翻译模式、三地址码或四元式中间表示,以及目标代码与寄存器分配策略的说明,并附有课程设计成绩评定标准与团队分工示例。文档以北京邮电大学课程设计为背景,按模块给出各阶段设计资料与实现成果,读者可据此梳理编译器各阶段的接口与数据结构,参考符号表行号列号记录、first与follow集求解、分析树输出等具体做法,也可借鉴报告的组织框架与分工安排。目前已有240人学习,适合需要完成同类课程设计或系统复习编译流程的读者参考。

1. 一个子集编译器,为什么比“完整编译器”更值得拆开看

很多人第一次接触编译原理,都会被龙书里那套“完整语言”的流程吓退:词法、语法、语义、中间代码、优化、目标代码,每一层都像黑匣子。但真正能让人把机制看透的,往往不是完整编译器,而是一个刻意做小的 Pascal 子集编译器——它把每个阶段的输入输出都摊在桌面上,连符号表里每个标识符的声明行、引用行、维数都写得清清楚楚。这份设计报告最反直觉的地方在于:它没有用 YACC、没有用 LLVM,所有阶段全部手工实现,连 FIRST/FOLLOW 集都是自己写函数算出来的。正因为没有工具兜底,每个设计决策背后的“为什么”才暴露得彻底。搞懂这套手工链路,你再看任何现代编译器的前端,都能一眼认出哪部分是词法、哪部分是预测分析表、哪部分是语法制导翻译在悄悄干活。

2. 词法分析:为什么一个lexout()要同时吐出记号流和符号表

2.1 接口设计里的隐藏契约

报告里词法分析器的入口是一个无参数、无返回值的void lexout(void)。乍看很粗糙,但它通过两个全局数组完成交付:Symbol_stream[1000]存记号流,symbol_table[100]存符号表。这个设计不是偷懒,而是把“词法分析器作为语法分析器调用的子程序”这条要求落到了实处——语法分析器不需要关心字符怎么读,只需要拿记号流数组和分析表对撞。

真正值得看的是它同时生成了五个 txt 文件:符号表、常数表、统计信息、记号流、终结符。这五个文件对应了词法阶段必须能独立验证的五件事:标识符有没有正确入表、常数有没有单独管理、行列计数准不准、记号属性对不对、终结符识别全不全。常见的实现思路是只输出一个 token 列表就完事,但那样一旦语法分析报错,你根本分不清是词法切错了还是语法表建错了。这份报告把中间产物全部落盘,等于给每个阶段留了后悔药。

关键字判断函数int Iskeyword(char[])的返回值设计也很有意思:mod返回 1、or返回 2、and返回 3、div返回 4,其他关键字返回 -1,标识符返回 0。为什么偏偏这四个关键字要单独给正数?因为它们同时是运算符——mod、div是乘除类运算,and、or是逻辑运算。词法阶段就把它们和普通关键字区分开,语法阶段做表达式分析时可以直接按运算符处理,不用再查一遍表。这是一个很小的设计取舍,但省掉了一层运行时判断。

2.2 符号表结构:为什么引用行要开五个槽

符号表项Tuple的结构是这份报告里信息密度最高的地方:

typedef struct table_item { char name[9]; // 变量名,有效字符数为 8 int address; // 目标地址 int type; // 类型 int demension; // 维数 int declare_row; // 声明行 int use_row[5]; // 引用行 int block; // 块索引号 } Tuple;

use_row[5]只开五个槽,说明设计者预期一个标识符在源程序里被引用的次数不会太多,超出部分大概率被截断或覆盖。这是一个典型的教学编译器取舍:不追求完备,追求结构清晰。demension字段拼写有误(应为 dimension),但它承担的功能很关键——数组变量的维数在词法阶段就要记录,因为后面语义分析做类型计算时,array [digits1..digits2] of standard_type的宽度计算依赖它。

block字段对应块索引号,虽然报告明确写了“不包含嵌套过程”,但这个字段保留了下来。我一般会这么理解:这是为符号表的堆栈式组织留的接口。语义部分用SqStack * mktable()和enter()来管理符号表,块索引号就是堆栈层次的标记。即使当前不嵌套,结构上也已经准备好了。

2.3 内部编码表:一张表定下前后端契约

报告里给了一张词汇内部编码表,把and编为 1、array编为 2、begin编为 3……一直到id编为 36、num编为 35。这张表是词法分析和语法分析之间的硬契约:语法分析器的预测分析表里,行是非终结符编码,列是终结符编码,两边必须用同一套数字。

注意表里有个细节:procedure和program都编为 15。这大概率是笔误,但在手工实现里,这种编码冲突会直接导致预测分析表某一列被覆盖。血泪经验是:内部编码表一旦定下来,就要用一张独立的头文件或常量表来维护,词法端和语法端都引用同一份定义,绝不能各写各的。报告里词法部分用static char keyword[N][10]存关键字字符串,语法部分用struct Tsymbol存终结符表,两边的对应关系靠matchsymbol()函数来查——这个函数就是防止编码漂移的闸门。

3. 语法分析:FIRST/FOLLOW 集为什么要自己算,而不是查现成表

3.1 从产生式到预测分析表的完整链路

语法部分的核心是自顶向下的 LL(1) 分析。报告里明确要求“有求 first、follow 集的函数”“对于每个非终结符有相应的函数相对应”“有明确的数据结构表示分析表”。这三条要求串起来就是一条完整的构造链路:读入产生式 → 求 FIRST 集 → 求 FOLLOW 集 → 填预测分析表 → 递归下降或表驱动分析。

FIRST 集的计算用了一个FirstGroup类,核心方法是GetFirstGroup(int sym),内部按三条规则迭代:终结符的 FIRST 是自身;X -> ε则 ε 入 FIRST(X);X -> Y1Y2…Yk则把 FIRST(Y1) 的非 ε 符号加入,若 Y1 能推出 ε 则继续看 Y2,依此类推。insertFsg(int NT, int i, int m)负责把一个符号的 FIRST 集合并进另一个,existed(int sym)负责去重。这个迭代要一直跑到“没有新符号可加入”为止,报告里写的是“直到没有终结符或 ε 可加到某个 FIRST 集合为止”。

FOLLOW 集的计算规则更绕:$入 FOLLOW(S);对A -> αBβ,FIRST(β) 的非 ε 符号入 FOLLOW(B);对A -> αB或A -> αBβ且 β 能推出 ε,FOLLOW(A) 入 FOLLOW(B)。这里最容易翻车的是第三条——很多手工实现只处理了A -> αB的直接后继情况,漏掉了 β 可空时的传递。报告里FollowGroup类的GetFollowGroup()没有参数,说明它是在所有产生式读入后一次性全局计算的,这比边读边算更不容易漏。

3.2 预测分析表的填充算法

填表用的是课本上的经典算法:先把analysistable[][]全部初始化为 -1,然后逐个扫描产生式A -> α,标号为 i。对 FIRST(α) 中每个终结符 a,把analysistable[A][a]赋为 i;如果 ε 在 FIRST(α) 中,再对 FOLLOW(A) 中每个 b,把analysistable[A][b]赋为 i。没被赋值的位置保持 -1,分析时遇到 -1 就报语法错误。

// 预测分析表填充的伪代码结构 for (int i = 0; i < production_count; i++) { int A = generator[i][0]; // 产生式左部 // 计算右部符号串的 FIRST 集 compute_first_of_rhs(i); for (each terminal a in FIRST(rhs)) { analysistable[A][a] = i; // 用产生式编号填充 } if (epsilon_in_FIRST(rhs)) { for (each terminal b in FOLLOW(A)) { analysistable[A][b] = i; } } }

这段逻辑的关键在于“产生式编号”这个设计:表里存的不是符号,而是产生式的下标。分析时查表拿到编号,再去generator[][]里取右部符号串压栈。这样做的好处是产生式只存一份,表和产生式数组之间靠编号解耦。常见的实现思路是把右部直接塞进表里,那样表会变得巨大且难以维护。

3.3 分析树的直观输出

报告要求“能够以直观的方式输出分析树”“改变源程序,分析树将发生变化”。手工 LL(1) 分析天然适合输出树:每展开一个非终结符就打印一层缩进,每匹配一个终结符就打印叶子。matchsymbol(char* str)负责把符号名映射回内部编码,这样打印出来的树既能看名字也能对编码。测试时改一个表达式,树的分支结构立刻变化,这是验证分析表有没有填错的最快手段——比盯着 -1 的表格看有效得多。

4. 语义分析与中间代码:类型检查为什么必须和翻译模式绑在一起

4.1 翻译模式:每个文法符号都带属性

语义部分最厚重的设计是那张翻译方案表。它给每个产生式配了语义动作,核心属性有三类:t表示类型,width表示宽度(字节数),name表示临时变量名或值。比如:

type → integer { standard_type.t := integer; standard_type.width := 4 } type → real { standard_type.t := real; standard_type.width := 8 }

整数宽度 4、实数宽度 8,这是目标机器的字长假设。数组类型的宽度计算更复杂:

type → array [ digits1 .. digits2 ] of standard_type { type.t := if digits1.t=integer and digits2.t=integer then array(digits1..digits2, standard_type.t) else type_error; type.width := (digits2.values[vPos] - digits1.values[vPos]) * standard_type.width }

这里values[vPos]取的是数组上下界的常量值,宽度等于元素个数乘以元素宽度。如果上下界不是整数常量,直接置type_error。这个检查放在语义动作里而不是单独一遍扫描,是因为数组声明和类型计算在语法树上是同一个节点,边遍历边算最自然。

4.2 类型转化:inttoreal什么时候插入

表达式部分的语义动作是类型检查的主战场。以加法为例,simple_expression' → addop term simple_expression'要处理四种类型组合:int+int、real+real、int+real、real+int。前两种直接生成临时变量并emit三地址码;后两种必须先插入inttoreal转换:

else if simple_expression'.t=integer and term.t=real then begin u := newtemp; emit(u ':= ' 'inttoreal' simple_expression'.name); emit(simple_expression'.name ':= ' u 'real' addoplexeme term.name); simple_expression'.t := real end

注意这里用了两个临时变量:u存转换结果,simple_expression'.name存最终结果。为什么不能复用一个?因为三地址码的每个临时变量只被赋值一次,复用会破坏后续可能的活跃性分析。虽然这个教学编译器不做优化,但中间代码的 SSA 风格已经在这里埋下了种子。

赋值语句的类型检查在statement → variable assignop expression里:如果variable.t和expression.t不一致,报类型错误。判断语句if expression then statement则要求expression.t = Boolean,否则type_error。这些检查全部嵌在翻译动作里,没有单独的语义检查遍——这是语法制导翻译的典型做法:语法走到哪,语义查到哪,中间代码顺手生成。

4.3 符号表的堆栈式管理

语义部分用SqStack * mktable()创建符号表,enter(SqStack * tableptr, char * name, int type, int addr)插入变量,lookahead(int k)查找节点。堆栈的组织方式对应作用域:进入program压一层,进入subprogram_declaration再压一层,退出时pop。报告明确“不包含嵌套过程”,所以堆栈深度最多两层,但block字段和push/pop的框架已经完整。

enter的addr参数是相对偏移量,由top(offset)维护。每插入一个变量,偏移量加上该变量的width。整数加 4、实数加 8、数组加元素个数乘元素宽度。这套偏移量计算就是后面生成目标代码时分配栈帧的依据。虽然报告说目标代码部分“不实现也可以”,但偏移量算对了,中间代码里的变量地址就是可执行的。

4.4 中间代码覆盖的三类功能

报告要求中间代码覆盖赋值、数组、指针。赋值对应emit(variable.name ':=' expression.name);数组在factor里处理下标访问,生成形如t := base + index * width的地址计算;指针在 Pascal 子集里通过^解引用,语义动作里做类型检查确保解引用后的类型匹配。三地址码的emit是统一出口,所有语义动作最终都落到这个函数上,中间代码文件就是emit的调用序列。

5. 避坑与排查:手工编译器最容易翻车的五个地方

5.1 现象:语法分析报错位置总是偏一行

原因:词法分析器的line_count在遇到换行符时自增,但注释{...}跨行时没有同步更新行计数,导致后续所有记号的行号整体偏移。报告里digit_l、digit_r记录{}个数,但行计数只在主循环里加,注释内部的换行被吞掉了。

解决:在注释过滤的循环里也判断\n并自增line_count,同时把注释起始行记下来,报错时如果落在注释区间内就指向注释起始行。符号表的declare_row和use_row都依赖这个计数,错一行后面全错。

5.2 现象:预测分析表某一列全是 -1,合法程序也报错

原因:内部编码表里procedure和program都编为 15,填表时后一个覆盖了前一个,导致以program开头的产生式在分析表里找不到对应列。这是编码冲突的典型症状——词法端按字符串匹配没问题,语法端按数字查表就撞车。

解决:给每个关键字分配唯一编码,用一张独立的常量定义表维护,词法和语法都include同一份定义。填表前先做一次编码唯一性断言,发现重复直接终止并打印冲突项。

5.3 现象:int + real的表达式结果类型是 integer

原因:simple_expression'的类型转化分支写漏了int + real这一种组合,只处理了int+int和real+real,落到else分支时把simple_expression'.name置成了type_error,但外层没有检查这个错误标记,继续按 integer 往下算。

解决:在simple_expression'的每个分支结束后统一检查t是否为type_error,是则向上传播。更稳妥的做法是把四种类型组合做成一张 2x2 的表,查表决定是否插入inttoreal,避免手写分支漏项。

5.4 现象:数组声明的宽度算出来是负数

原因:digits2.values[vPos] - digits1.values[vPos]没有检查上下界顺序,源程序写array [10..1] of integer时直接算出负宽度,后续偏移量分配全部错乱。

解决:在数组类型的语义动作里加一条检查:digits2 >= digits1,否则报type_error并跳过宽度计算。教学编译器可以不支持空数组,但必须把非法上下界拦在语义阶段,不能让它污染符号表的偏移量。

5.5 现象:符号表里同一个标识符出现多条记录

原因:Word_insert(char[])在插入前用线性扫描查重,但查重只比较了name字段,没有比较block字段。当同名变量在不同作用域出现时(虽然报告说不嵌套,但函数参数和全局变量可能同名),后一个被误判为重复而返回已有下标,导致类型和地址错位。

解决:查重时同时比较name和block,只有两者都相同才视为重复。如果确实要支持同名不同作用域,Word_insert应该返回新插入项的下标而不是旧项。符号表的count1计数也要相应调整。

6. 设计取舍:为什么手工实现比工具生成更值得走一遍

这份报告最值得琢磨的取舍,是它明确写了“可以用手工的方法,也可以用 YACC 方法,但是不建议大家采用”。站在工程角度,YACC 生成的分析器又快又稳,手工写 LL(1) 纯属重复造轮子。但站在理解机制的角度,手工实现逼着你把每个环节的输入输出都想清楚:FIRST 集算错一个符号,预测分析表就填错一格;符号表的use_row少开一个槽,引用行就丢;inttoreal漏插一次,类型检查就形同虚设。这些错误在工具链里会被自动兜住,你永远看不到它们为什么被兜住。

另一个取舍是中间代码选三地址码而不是四元式。三地址码的可读性更好,t1 := a + b一眼能看懂,但四元式在后续优化和寄存器分配时更容易程序化处理。报告选了前者,说明它的优先级是“让人看懂”而不是“让机器好处理”。目标代码部分直接标注“不实现也可以”,也是同一个逻辑:寄存器分配策略可以讲清楚,但不必真的写一个分配器出来。

如果要把这套东西往深里推,一个自然的延伸是把预测分析表从硬编码的二维数组改成从文件加载,这样改文法不用重编译;再进一步,把语义动作从switch分支改成函数指针表,每个产生式挂一个动作函数,就是一个小型的语法制导翻译引擎。我一般会这么理解:教学编译器的价值不在于它能编译多大的程序,而在于它的每一行代码都对应一个你能叫出名字的编译原理概念。

从那以后我每次看一个新编译器的前端,都会先找它的符号表结构和 FIRST/FOLLOW 计算入口——这两个地方对了,后面的类型检查和代码生成基本不会跑偏。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 10:41:34

多模态情感计算工程指南:从特征对齐到D-S融合落地

简介&#xff1a;《情感人机交互系统》围绕情感识别与理解在情感人机交互中的核心作用展开&#xff0c;面向人工智能、机器人学与认知科学方向的研究者与研究生&#xff0c;帮助读者建立从多模态特征提取到情感意图理解的完整技术框架。书中结合面部表情、语音与手势等多通道信…

作者头像 李华
网站建设 2026/10/27 10:38:01

编译原理词法分析器:从正则表达式到DFA状态转移表

简介&#xff1a;该资源是西南科技大学《编译系统综合设计》课程实验一的完整实验报告&#xff0c;面向计算机相关专业正在学习编译原理、需要完成词法分析实验的学生。报告围绕TEST语言词法分析程序的设计展开&#xff0c;完整梳理了从正则表达式描述词法规则、构造NFA、合并确…

作者头像 李华
网站建设 2026/10/27 10:36:26

RNN与LSTM在短序列处理中的优势与优化策略

1. RNN循环机制的本质解析循环神经网络&#xff08;RNN&#xff09;之所以在序列数据处理中占据重要地位&#xff0c;关键在于其独特的"记忆"机制。与传统前馈神经网络不同&#xff0c;RNN通过隐藏状态的循环传递&#xff0c;使网络能够保留历史信息。这种设计在语言…

作者头像 李华
网站建设 2026/10/27 10:36:02

学籍管理系统毕业论文:角色权限、状态机与数据一致性实践

简介&#xff1a;这份毕业设计资料包面向高校计算机专业撰写学籍管理系统论文与课程设计的学生&#xff0c;围绕学生信息、课程成绩、学籍异动和报表统计等教务场景&#xff0c;提供从需求分析、数据库设计到开发测试与部署维护的完整参考。压缩包共24个文件&#xff0c;约323K…

作者头像 李华
网站建设 2026/10/27 10:35:44

微信小程序接入AI图像识别:云函数中转、参数调优与上线避坑

简介&#xff1a;这份人工智能实战微信小程序Demo面向希望把AI能力落地到微信端的开发者、学生与课程设计者&#xff0c;提供可直接导入微信开发者工具运行的完整示例工程&#xff0c;可用于学习小程序页面组织、组件拆分、录音与播放、待办清单、消息展示及二维码等常见交互的…

作者头像 李华
网站建设 2026/10/27 10:33:21

AI辅助教学:调用大模型从教材中生成习题,方便学员练习

一个AI辅助教学的小项目&#xff0c;记录一下。 先看效果&#xff0c;用户在选择资料库上传文件&#xff0c;勾选加入增强检索。 系统上传文件并将切片存入neo4j&#xff0c;用户选择题库&#xff0c;点击添加问题&#xff0c;系统按照选择题型、难度和数量生成问题并说明来源…

作者头像 李华