ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Pascal子集编译器实现:词法、LL(1)分析与三地址码生成

Pascal子集编译器实现:词法、LL(1)分析与三地址码生成

简介:这份资源是面向计算机专业学生与编译原理初学者的Pascal子集编译器课程设计报告,围绕词法分析、语法分析、语义分析与中间代码生成等核心环节展开,帮助读者理解并实践一个小型编译程序的完整构造流程。压缩包内共1个doc文档,约952KB,以课程设计报告为主体,涵盖需求分析、总体结构、接口描述与各阶段详细设计说明,并附有团队分工与成绩评定标准。内容预览显示报告来自北京邮电大学课程设计,按Sub_P文法组织,具体涉及符号表构建、first与follow集求解、分析表与分析栈设计、三地址码或四元式中间表示及类型检查等知识点,可作为课程设计撰写与实现的参考范本。目前已有240人学习,适合需要完成编译原理实验、梳理设计文档结构或对照检查各阶段实现思路的读者参考借鉴。

1. 一份能跑通的 Pascal 子集编译器:从课程设计报告到可复现的编译流水线

手上这份 Pascal 子集编译器设计报告,第一眼看上去像典型的课程作业,但翻到词法接口和翻译方案那几页,会发现它把「编译 pascal 子集」这条链路拆得相当完整:词法分析产出记号流和符号表,语法分析用 LL(1) 预测分析表建分析树,语义分析挂翻译方案做类型检查,中间代码用三地址码落地。对正在啃编译原理、想找一个能对着抄结构的小型编译器实现的人来说,这份报告的价值不在论文腔,而在它把每个阶段的接口、数据结构和内部编码都摊开了。适合两类人:一是课程设计要交编译器、卡在词法符号表或预测分析表构造上的学生;二是想快速理解一个无工具、纯手工实现的编译前端长什么样的从业者。下面按「它怎么组织 → 怎么复现 → 哪里会翻车」的顺序拆。

2. 词法与符号表:记号流数组和行列号是怎么落地的

2.1 为什么先啃词法接口,而不是直接看语法

很多人拿到编译器项目会直奔语法分析,觉得那才是「核心」。但这份报告里词法分析器lexout(void)是整个流水线的地基:它没有参数没有返回值,靠往两个全局数组Symbol_stream[1000]和symbol_table[100]里写数据来和语法分析器通信。这种设计在工程上不算优雅,但对课程设计场景反而友好——接口极简,语法分析器只要读数组就行,不用处理复杂的返回值协议。我一般建议复现时先把这个接口跑通,因为词法一旦有 bug,后面语法分析报的错全是假的,排查起来就是黑匣子。

词法分析器要处理的事情比想象中多:过滤{}和/**/两种注释、识别间隔符号、做行列计数、区分关键字和标识符、把常数塞进常数表。报告里给的内部编码表是关键,它定义了每个记号对应的属性值,比如and是 1、array是 2、begin是 3,一直到id是 36、num是 35。这套编码是词法和语法之间的契约,语法分析器的预测分析表就是按这套编码来查的,编码对不上,分析表全废。

2.2 符号表的数据结构:为什么 name 只留 9 个字符

报告里符号表的条目结构值得单独说:

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

name[9]意味着有效标识符最长 8 个字符加一个结束符。这是课程设计里常见的取舍——Pascal 标准其实允许更长,但截断到 8 字符能简化哈希和比较。复现时如果你照抄这个长度,遇到长变量名会被静默截断,两个前 8 字符相同的变量会撞在一起,这是后面避坑章节要重点说的。use_row[5]只记 5 次引用,超过就丢,报告没写溢出处理,实际用的时候要么扩容要么加计数。

Word_insert(char[])的返回值语义也要注意:标识符已存在返回它在WordList[]里的下标,否则返回 0。这里有个隐患——下标 0 既是「新插入」又是「第一个元素的位置」,如果第一个标识符恰好存在下标 0,返回值就歧义了。常见做法是把符号表从下标 1 开始用,或者返回 -1 表示不存在。

2.3 词法分析器的可执行步骤

复现词法部分,我一般按这个顺序走:

第一步,把源文件整体读进缓冲区buffer[Max],报告就是这么做的,一次性读入再逐字符扫描,比流式读取好调试。

第二步,实现字符分类函数。IsLetter(char ch[], int n)判断字母、IsDigit(char ch)判断数字,这两个是扫描循环的分支依据。注意IsLetter的参数是char ch[]加长度n,不是单字符,说明它可能被用来判断一个 token 是否全字母,复现时别写成单字符版本。

第三步,关键字识别。Iskeyword(char[])返回 1 到 4 分别对应mod、or、and、div,其他关键字返回 -1,标识符返回 0。这个返回值设计有点绕——为什么只有这四个运算符关键字有正数编码,其他关键字走 -1?因为mod/or/and/div在语法里当运算符处理,需要单独的属性值,而begin/end/var这类结构关键字只需要「是关键字」这个信息。理解这一点,语法分析器里对运算符和结构字的处理分支就顺了。

第四步,写输出。报告要求生成五个文件:符号表、常数表、统计信息、记号流、终结符。这一步别省,单独测试词法时这五个文件就是你的眼睛。统计信息里的行数、单词数、错误数能快速告诉你扫描有没有跑偏。

// 词法扫描主循环的骨架,按报告的状态机思路简化 while (buffer[i] != '\0') { if (buffer[i] == '\n') { line_count++; i++; continue; } if (IsLetter(&buffer[i], 1)) { // 收集完整标识符到 token j = 0; while (IsLetter(&buffer[i], 1) || IsDigit(buffer[i])) { token[j++] = buffer[i++]; } token[j] = '\0'; int kw = Iskeyword(token); if (kw == 0) { int idx = Word_insert(token); // 插入或查符号表 Symbol_stream[k].atr = 36; // id 的编码 strcpy(Symbol_stream[k].sname, token); } else { Symbol_stream[k].atr = kw; // 关键字/运算符编码 } k++; } else if (IsDigit(buffer[i])) { // 数字处理,注意小数点和 state1 标志 // ... } else { // 运算符、界符、注释过滤 // ... } }

这段骨架里state1是小数的判断标志,报告里用它区分整数和实数,复现时小数点后必须跟数字,否则要报错回退。digit_l和digit_r记录{}注释的配对,不配对就是注释未闭合错误。

提示:词法分析器作为语法分析器的子程序被调用,报告明确要求「将词法分析器作为语法分析器调用的函数」,所以lexout不要写成独立 main,要能被语法模块直接调。

3. LL(1) 预测分析:FIRST/FOLLOW 集与分析表的构造细节

3.1 选 LL(1) 而不是 LR 的理由

报告语法部分明确走 LL 方法,要求「有求 first、follow 集的函数」「对每个非终结符有相应的函数相对应」「有明确的数据结构表示分析表」。为什么课程设计偏爱 LL(1)?因为它的分析过程和分析树是同构的——你递归下降写一遍,分析树自然就出来了,直观输出分析树这个要求几乎白送。LR 虽然能力强,但分析栈和移进归约表对初学者是额外的认知负担,报告里也提到「可以用手工的方法,也可以用 YACC 方法,但不建议采用」,手工 LL 是最稳的路线。

代价是 LL(1) 对文法有要求:不能有左递归,不能有公共左因子。Pascal 子集的表达式文法天然带左递归(E -> E + T),复现时必须先做左递归消除,改成E -> T E'、E' -> + T E' | ε这种形式。报告里的翻译方案能看到simple_expression'、term'、expression_list'这些带撇的非终结符,就是消除左递归后的产物。

3.2 FIRST 集和 FOLLOW 集的实现要点

报告给了FirstGroup和FollowGroup两个类,核心方法是GetFirstGroup(int sym)和GetFollowGroup(),都在语法分析器初始化时调用一次,结果存内存供构造分析表用。FIRST 的算法就是教材三条规则:终结符的 FIRST 是自身;X -> ε则 ε 进 FIRST(X);非终结符按产生式右部逐个符号并入,遇到能推出 ε 的继续往后并。FOLLOW 同理,开始符号放$,然后按A -> αBβ把 FIRST(β){ε} 并入 FOLLOW(B),β 能推 ε 时把 FOLLOW(A) 并入 FOLLOW(B)。

这里有个实现细节报告没展开但很关键:FIRST 集的计算有依赖顺序,X -> Y1 Y2里 X 的 FIRST 依赖 Y1 的 FIRST,如果 Y1 还没算完就取,会拿到空集。常见做法是迭代到不动点——反复扫所有产生式,直到没有集合再变化为止。existed(int sym)这个判断函数就是为去重和不动点检测服务的,别写成简单的线性查找就完事,集合大了会慢,但课程设计规模下线性查找够用。

3.3 预测分析表的构造与查表

CreateAnalysisTable()按教材算法 4.2 来:先把analysistable[][]全初始化为 -1,逐个扫产生式A -> α(标号 i),对 FIRST(α) 里每个终结符 a,把analysistable[A][a]赋值为 i;如果 ε 在 FIRST(α) 里,再对 FOLLOW(A) 里每个 b 赋值为 i。剩下还是 -1 的格子就是 error。

查表时的数据结构是int analysistable[][],行是非终结符编码,列是终结符编码,值是产生式编号。这里编码一致性再次成为命门:非终结符和终结符的编码必须和词法输出的记号编码对齐,matchsymbol(char* str)就是干这个的——从终结符表和非终结符表里查符号对应的内部编码。复现时建议把这张编码对照表单独打出来贴在显示器边上,调分析表的时候一眼能对上。

// 预测分析表构造的核心逻辑 for (int i = 0; i < generator_count; i++) { int A = generator[i][0]; // 产生式左部 // 计算右部 FIRST for (each a in FirstOfRHS(i)) { analysistable[A][a] = i; // 填产生式编号 } if (EpsilonInFirstOfRHS(i)) { for (each b in FollowGroup[A]) { analysistable[A][b] = i; } } } // 未填充的格子保持 -1,查表时 -1 即语法错误

分析树用递归下降自然生成:每个非终结符一个函数,函数里查analysistable[当前非终结符][当前输入记号]拿到产生式编号,按产生式右部依次调用子函数或匹配终结符。报告要求「能够以直观的方式输出分析树」,最简单是缩进打印,每进一层加两个空格,改源程序后树结构变化一眼可见。

注意:LL(1) 分析表如果有冲突(同一格被赋两个产生式编号),说明文法不是 LL(1) 的,得回去改文法或提取左因子,别硬填覆盖。

4. 语义分析与三地址码:翻译方案怎么落到代码

4.1 语法制导翻译的组织方式

语义部分报告给了完整的翻译方案,从program一路到factor,每个产生式后面跟语义动作。这套方案的本质是语法制导定义(SDD),属性分两类:综合属性往上传递(比如expression.t类型、expression.name临时变量名),继承属性往下传(比如identifier_list.t变量类型)。复现时最容易乱的就是属性传递方向——simple_expression'的.t和.name是从右部子节点综合上来的,而identifier_list的.t是从父节点继承下去的,搞反了类型检查全错。

报告里enter(SqStack * tableptr, char * name, int type, int addr)往符号表插变量,mktable()产生新符号表,lookahead(int k)查节点 k 的最左子节点。符号表用堆栈组织(SqStack),因为有不嵌套过程的要求,其实栈的深度有限,但用栈能自然处理作用域进出。

4.2 类型检查与类型转换的四个分支

simple_expression'的语义动作是类型检查的主战场,报告写得很细,四个分支覆盖了整数和实数的所有组合:

左操作数类型右操作数类型处理方式
integerinteger直接生成三地址码,结果 integer
realreal直接生成,结果 real
integerreal先生成inttoreal转换左操作数,再运算,结果 real
realinteger先生成inttoreal转换右操作数,再运算,结果 real

这个表就是「类型转化」要求的具体落地。inttoreal是中间代码里的一条指令,用newtemp产生临时变量承接转换结果。复现时注意:转换只发生在混合类型运算,同类型不插转换指令,否则中间代码会冗余得没法看。

赋值语句的类型检查在statement -> variable assignop expression里:检查variable和expression的类型是否相同,不同报错。判断语句if expression then要求expression.t == Boolean,否则type_error。这两条是报告明确点出的检查点,别漏。

4.3 三地址码的生成与临时变量管理

中间代码用三地址码,报告要求覆盖赋值、数组、指针、函数调用。emit()是发射指令的函数,newtemp产生临时变量t1, t2...,enterq(q, expression.name)把实参压进参数队列 q。控制流的翻译是重点:

// if 语句的三地址码生成 // if expression then statement1 else statement2 // 生成: // if expression.name goto L1 // goto L2 // L1: statement1 的代码 // goto L3 // L2: statement2 的代码 // L3:

while语句类似,报告里给了Li、Li+1、Li+2三个标号的分配方式,i是标号计数器,每次用三个。复现时标号管理要小心,嵌套语句里标号不能重用,i必须是全局递增的,否则内层循环的 goto 会跳到外层去,这种 bug 跑起来就是死循环或者跳飞,血泪经验。

数组访问的中间代码报告没给完整翻译方案,但要求覆盖,常见做法是x := a[i]翻成先算地址t1 := i * width、t2 := a + t1、x := *t2三条。指针类似,解引用用*前缀。函数调用的参数传递机制报告要求文档描述,常见是值传递,实参按顺序enterq进队列,调用时按队列顺序取。

提示:newtemp产生的临时变量名要全局唯一,别用局部计数器,否则不同语句的 t1 会撞,中间代码读起来全是歧义。

5. 避坑与排查:五个真实会翻车的地方

5.1 符号表 name[9] 截断导致变量撞名

现象:两个不同变量,前 8 个字符相同,编译后行为异常,后一个变量的赋值影响了前一个。

原因:char name[9]只存 8 个有效字符,strcpy或strncpy时超长部分被截,符号表里两条记录 name 相同,Word_insert查重时误判为已存在,返回同一个下标。

解决:要么把name扩到 32 字节,要么在插入前做长度检查,超长直接报「标识符过长」错误。课程设计场景下扩长度最省事,别为了省内存留这个雷。

5.2 FIRST 集迭代顺序不对导致分析表缺项

现象:预测分析表某些格子该有产生式编号却是 -1,语法分析器对合法输入报语法错误。

原因:FIRST 集计算时按产生式顺序单遍扫描,X -> Y1 Y2里 Y1 的 FIRST 还没算完就取了,拿到空集,导致 X 的 FIRST 不全,分析表漏填。

解决:改成不动点迭代,反复扫所有产生式直到没有集合变化。加个changed标志,一轮下来没变就退出。规模小的话多扫几轮无所谓。

5.3 左递归没消干净导致递归下降栈溢出

现象:语法分析器一跑就栈溢出或死循环,分析树无限深。

原因:表达式文法E -> E + T的左递归没消除,递归下降函数E()第一件事就是调E(),直接无限递归。

解决:严格按左递归消除公式改写,E -> T E'、E' -> + T E' | ε。改完检查每个非终结符函数,确保没有函数在产生式第一个符号就调自己。

5.4 标号计数器作用域错误导致 goto 跳飞

现象:嵌套 if/while 的中间代码执行时跳到错误位置,结果错乱或死循环。

原因:标号计数器i写成了局部变量,每层语句重新从 0 开始分配标号,内外层标号重复,goto 跳到最近的定义。

解决:i必须是全局或贯穿整个语义分析的单一计数器,每次分配只增不减。生成完中间代码后打印标号分配表核对一遍。

5.5 词法行列计数在注释和换行处错位

现象:报错信息里的行号列号和源文件对不上,定位错误困难。

原因:注释{}和/**/内部有换行,扫描注释时没更新line_count;或者\r\n被当成两个字符,列计数多算。

解决:注释扫描循环里遇到\n也要line_count++;读文件时统一处理\r\n为\n。词法单独测试时拿一个带多行注释的源文件验证行号。

6. 进阶验证:用三个测试程序把编译器逼到边界

复现完别急着交,拿三个层次的测试程序过一遍,比看十遍代码管用。第一个是纯赋值和四则运算,验证词法、语法、三地址码生成的主链路通不通;第二个带 if/while 嵌套和混合类型运算,专门逼类型转换和标号分配;第三个故意塞错误——未声明变量、类型不匹配的赋值、注释不闭合——看报错信息能不能定位到行列。

验证中间代码对不对,我有个笨但有效的办法:手工把三地址码翻译回等价的 C 代码,跑一遍对比结果。比如t1 := a + b、t2 := t1 * c翻成t2 = (a+b)*c,结果对不上就说明某条 emit 的参数顺序错了。这个方法在调simple_expression'的四个类型分支时特别管用,因为混合类型的inttoreal插错位置,手工翻译一眼能看出来。

分析树的验证靠改源程序看结构变化:把a + b * c改成(a + b) * c,分析树里term和simple_expression的嵌套层次应该跟着变,如果两棵树一样,说明优先级处理有问题,多半是文法改写时把结合性搞反了。

还有个小技巧:把符号表、常数表、记号流三个输出文件按行对齐打印,词法阶段的问题基本无处遁形。记号流的属性值和符号表的下标能对上,说明Word_insert的返回值语义没写错;对不上就是 5.1 那个截断或者下标 0 歧义的老问题。

从那以后我每次碰这种手工编译器项目,都强制先把词法五个输出文件跑出来逐个核对,再动语法和语义——词法这层地基要是虚的,上面盖多高都是玄学调试。希望这份拆解帮到你,少走几个我踩过的坑。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进