简介:这份Pascal子集编译器设计报告面向计算机专业学习编译原理、准备课程设计的学生,围绕词法分析、语法分析、语义分析与中间代码生成等环节,梳理小型编译器从需求分析到总体结构、详细设计的实现思路。压缩包内仅含1个doc文档,大小约952KB,以设计报告形式集中呈现接口说明、符号表与记号流设计、分析表和分析树构建、类型检查与类型转换、三地址码或四元式表示、参数传递机制及目标代码对应关系,并涉及文法规则、分析栈数据结构、语义动作与翻译模式。该资料已有240人学习,可用于理解Sub_P文法、first/follow集求解、错误处理与模块分工,也能帮助读者梳理从语法树到中间代码、再到寄存器分配策略的完整链路,提升编译程序设计与问题分析能力。
1. 从一份课程设计报告到能跑通的编译器:四个接口必须先对上
拿到「小型 Pascal 子集编译器」这个题目的人,最先卡住的往往不是理论,而是不知道从哪一行开始写:FIRST/FOLLOW 会手算,词法分析器代码也知道怎么写,但词法返回什么、语法怎么拿、语义怎么消费、中间代码怎么落到四元式,这四条线一旦对不上,后面全是返工。这份资源是一份完整的 Sub_P 编译器设计报告与实现思路,用 C/C++ 手写、不使用 YACC 一类工具,把词法分析、符号表、自顶向下语法分析、语义分析与中间代码生成拆成有明确接口、明确数据结构、明确翻译方案的四个阶段。它适合正在做编译课设、手里有文法却迟迟开不了工的人,也适合想一次性把「字符流 → 记号流 → 分析树 → 四元式」整条链路走通、而不是停在课本例题上的人。
2. 词法分析器:把字符流切成记号流,顺手把符号表的行列号填对
词法这一层的真实目标,不是「认出关键字」,而是给下游交出两样能直接消费的东西:一个是记号流数组,一个是带行列号与声明/引用信息的符号表。课设要求里还压了两条硬约束——词法要能单独跑、能输出去向文件,以及词法必须作为语法调用的子程序返回「类别编码 + 属性值」。这决定了它不能做成一个读一行 print 一行的脚本,必须是一个有状态的扫描器。
2.1 先定接口和数据契约,再写状态机
我一般会先把接口和数据契约定死,再落主体逻辑,因为后面最容易返工的地方恰恰是「词法给了语法什么」。这份设计里,词法对外只暴露一个void lexout(void),没有参数和返回值,通过写入两个全局数组把结果交给下游:Symbol_stream[1000]存记号(类别编码atr+ 记号名sname),symbol_table[100]存标识符(变量名、目标地址、类型、维数、声明行、引用行数组、块索引)。同时它还顺手产出五个文本文件——符号表、常数表、统计信息、记号流、终结符,方便你脱离语法单独验证词法是否正确。这是「单独测试」这条要求在实现上最省事的一种落法。
/* 词法输出契约:下游语法只认这两个数组和一份记号类别编码 */ typedef struct { int atr; // 记号类别编码,见 2.2 的编码表 char sname[9]; // 记号原文,标识符截断到 8 个有效字符 } Symbol; Symbol Symbol_stream[1000]; // 语法分析器要的记号流 typedef struct { char name[9]; // 变量名 int address; // 目标地址(相对偏移) int type; // integer / real / boolean int demension; // 数组维数,0 表示标量 int declare_row; // 声明所在行号 int use_row[5]; // 引用行号,最多记 5 次 int block; // 块索引号,多过程时用 } Tuple; Tuple symbol_table[100]; // 语义阶段要查的符号表用引用数组use_row[5]而不是一个链表,是为了课设里「能打印、能演示」——代价是引用超过 5 次就要截断,这个坑我在第 5 章会专门讲。约定好之后,lexout()的内部就只做三件事:读字符、分门别类进不同的表、维护计数。
2.2 记号内部编码要先对齐
词法吐出来的atr是语法分析表的下标,所以这套编码必须和语法那一侧的终结符编码严格一致,中间不能靠「感觉」对齐。设计报告里给了一套完整的编码,把关键字、运算符、分隔符、标识符、常数都编进去,例如and=1、begin=3、var=22、while=23、圆括号和分号这类单字符也有各自的码。实际落地时常见的做法是:
static char keyword[][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" }; /* 关键字优先匹配,命中就返回内部编码;否则按标识符处理 */ int Iskeyword(char s[]) { for (int k = 0; k < 24; ++k) if (strcmp(s, keyword[k]) == 0) return k + 1; // 1..24 return 0; // 0 = 标识符 }这条编码在文档里一定要做成一张表,后面语法构造first/follow、语义查符号表类型、中间代码选四元式种类都要回头翻它。我见过最容易出问题的地方是「编码撞号」——比如某个运算符和某个记号用了同一个数字,编译器不会报错,只会在某个特定源程序上突然走错分支,排查起来非常费时间。写完编码表先写一个自检:把编码值丢进一个visited[]数组,重复就assert。
2.3 状态机主循环:一个字符一个字符地过
词法的核心是一个 while 循环加一组 if 分支,难点不在分支本身,而在「每类字符的边界怎么收」。数字要处理整数与小数(小数要有state1标志区分1.和1.5)、标识符要吃掉字母和数字、注释要整段吞掉但内侧的换行还要计入行号。下面是主循环的骨架,重点是看它如何把五种情况分流到不同的表:
void lexout(void) { char buffer[Max]; // 源文件整段读入 int i = 0; // buffer 当前下标 while (buffer[i] != '\0') { char ch = buffer[i]; if (ch == '\n') { line_count++; i++; continue; } if (ch == '{') { // 花括号注释:整段跳过 while (buffer[i] != '}' && buffer[i] != '\0') { if (buffer[i] == '\n') line_count++; // 关键:注释内的换行也要数 i++; } i++; continue; } if (ch == '/' && buffer[i+1] == '*') { // 星号注释:同理 i += 2; while (!(buffer[i] == '*' && buffer[i+1] == '/') && buffer[i]) { if (buffer[i] == '\n') line_count++; i++; } i += 2; continue; } if (isdigit(ch)) { // 数字:整型或实型 j = 0; while (isdigit(buffer[i]) || buffer[i] == '.') token[j++] = buffer[i++]; token[j] = '\0'; Num_insert(token); // 写常数表 Symbol_stream[word_count].atr = (strchr(token, '.')) ? 35 : 35; strcpy(Symbol_stream[word_count].sname, token); word_count++; continue; } if (isalpha(ch)) { // 标识符 / 关键字 j = 0; while (isalnum(buffer[i])) token[j++] = buffer[i++]; token[j] = '\0'; int kw = Iskeyword(token); if (kw > 0) { Symbol_stream[word_count].atr = kw; strcpy(Symbol_stream[word_count].sname, token); } else { int idx = Word_insert(token); // 已存在返回下标,不存在插入并返回位置 Symbol_stream[word_count].atr = 36; // id strcpy(Symbol_stream[word_count].sname, token); } word_count++; continue; } /* 运算符与分隔符:单字符走一张小表,双字符(:=、<=)单独处理 */ Symbol_stream[word_count].atr = lookup_op(ch); Symbol_stream[word_count].sname[0] = ch; Symbol_stream[word_count].sname[1] = '\0'; word_count++; i++; } }这段代码有五行看着多余、其实是整个词法最容易出问题的地方:注释内部的行计数。你在while (buffer[i] != '}')里如果不把\n记进line_count,从第二个注释之后的所有符号的声明行号就全部错位,到最后符号表打印出来行号集体偏移,而错误报的又是别的行,那种错位感比编译报错本身更折磨。另一处是数字分支里Symbol_stream[].atr那句写成(strchr(token,'.')) ? 35 : 35,实际编码里整型常数和实型常数应该是两个不同的码,这里只是演示位置——这种细节恰恰要在自己的实现里核对一遍。
Max缓冲区大小、token[20]的标识符长度、sname[9]的有效字符上限,这三个参数是后面 bug 的高发区。缓冲区按源文件大小开,标识符超 8 个有效字符按设计截断,但要记得打印时给出完整长度,否则两个人对结果会对不上。
2.4 让词法自己跑一遍:五个文件的交叉验证
单独测是这门设计要求里最有价值的一步。准备一个小源文件,里面只放声明、赋值、一个注释、一个while,然后单独编译词法、读源文件、跑lexout(),观察五个输出文件是否自洽:
| 输出文件 | 期望内容 | 自洽检查 |
|---|---|---|
| 终结符.txt | 每个 token 按类别编码逐行打印 | 关键字符号排在标识符前 |
| 常数表.txt | 出现的整数/实数常量 | 与源文件数量一致 |
| 符号表.txt | id 名 + 声明行 + 引用行 + 维数 | 声明行与源文件一一对应 |
| 统计信息.txt | 行数、记号数、错误数 | 行数 = 源文件逻辑行数 |
| 记号流.txt | atr + sname | 顺序与原程序一致 |
如果符号表里一个 id 的声明行比源文件慢了 1~2 行,几乎可以百分百确定是注释内的换行没有计数,或者引号字符串里的换行漏算。这一步跑顺了,再往语法走,才不至于拿一堆错位的行号去调 FOLLOW 集。
3. 语法分析:从 FIRST/FOLLOW 到 LL(1) 预测分析表的完整落地
语法这层的难点不是「判定用哪种文法」,而是要把题目里的几条硬要求对位落实:要么用 LL(1)、要么用 LR,都要有求 FIRST 和 FOLLOW 的函数、都要有明确的数据结构表示分析表,还要能以直观方式输出分析树。这份设计选了 LL(1),我判断的理由有两条,一是文法本身适合改写成递归下降,二是它的「每个非终结符对应一个函数」要求天然适合做成分析树打印的入口,实现和演示两头都占优。
3.1 为什么是 LL(1):先把选型讲透再动手
LL(1) 意味着从左到右扫描输入、最左推导、向前看一个记号。对 Sub_P 这种不含嵌套过程、语法结构接近教科书 Pascal 子集的语言,LL(1) 的文法改写成本低,而且它的分析表也可以直接落成一个二维数组analysistable[NT][T],下标就是第 2 章那套内部编码——这是选它的最现实理由:上下游数据结构共用一份编号。
如果换成 LR,就要多写一个分析栈的状态转移结构,并且 FOLLOW 只用来算归约,会跟设计里「每个非终结符一个函数」的演示目标脱节。所以课设这种以「讲清楚过程」为评分的场景,LL(1) 的实际收益更高。当然代价也明摆着:LL(1) 要求文法无左递归、无公共前缀,Pascal 子集里表达式、语句列表这几处都需要重写成带撇(')的右递归形式,改写量不小。
3.2 FIRST 与 FOLLOW:写成两个独立函数,别塞进表构造里
把 FIRST/FOLLOW 单独做成函数,是设计要求里写得最明白、也最容易被偷偷合并的一条。合并的后果是:表构造一出错,你无法区分是集合算错还是表填错。我一般会做成两个类,各自维护「已算过的符号」和「符号的集合」两个结构,然后在语法分析器初始化时各调一次:
class FirstGroup { public: int value; // 符号内部编码 int firstgroup[15]; // FIRST 集,用编码存在数组里 int count; bool existed(int sym); // sym 是否已在该集合中 void insertFsg(int NT, int i, int m); // 把 NT 的 FIRST 并入调用者 }; void GetFirstGroup(int sym) { /* 规则1:sym 是终结符,FIRST(sym) = {sym} */ /* 规则2:存在产生式 sym -> ε,把 ε(用一个特殊编码表示)加进 FIRST(sym) */ /* 规则3:对每个产生式 sym -> Y1 Y2 ... Yk: 先把 FIRST(Y1) 除 ε 外的符号全部并入; 若 Y1..Yi-1 都能推出 ε,则把 FIRST(Yi) 除 ε 外的符号并入; 若 Y1..Yk 都能推出 ε,则把 ε 并入。 */ }insertFsg的三个参数(目标符号、源非终结符、已并入到第几个)就是用来边走边去掉 ε 的,这是一个在课本伪码里不太显眼、实现起来却必须显式传参的细节。FOLLOW 这边同理,三条规则里第三条最容易漏——A -> αBβ且FIRST(β)含 ε 时,要把FOLLOW(A)也并进FOLLOW(B)。漏掉这一条,下面构造表时会出现大片的-1,症状是「明明符合文法的程序却被报语法错误」,而错误位置往往还偏后。
3.3 预测分析表的构造:算法就三行,难在边界
分析表的构造逻辑本身不复杂:先把analysistable初始化为全-1,然后逐条扫描产生式A -> α,对α的 FIRST 集里除 ε 外的每个终结符a,把analysistable[A][a]置为该产生式的编号;如果 ε 在FIRST(α)里,再对FOLLOW(A)里每个b,把analysistable[A][b]也置为同一编号。剩下仍是-1的格子就是错误入口。
void CreateAnalysisTable() { /* generator[i][0] 是产生式左部,generator[i][1..] 是右部各符号编码 */ memset(analysistable, -1, sizeof(analysistable)); for (int i = 0; i < prod_count; ++i) { int lhs = generator[i][0]; GetFirstGroup(lhs); // 确保 FIRST 已经算过 for (int a : first_of_rhs(i)) { // α 的 FIRST 去掉 ε if (a != EPSILON) analysistable[lhs][a] = i; } if (first_of_rhs(i) contains EPSILON) { for (int b : follow_of(lhs)) { // FOLLOW(A) analysistable[lhs][b] = i; } } } }这里有个很实在的实现细节:first_of_rhs不是单独算的集合,它是通过Y1 Y2 ... Yk逐个去查非终结符的 FIRST 集、遇到第一个不含 ε 的就停。如果写成「把右部所有符号的 FIRST 都并起来」,长右部会产生伪优先级错误——看起来能跑,实际上是把不该做的产生式塞进了表里,然后在分析树里体现成一棵形状怪异的树。所以我建议把这一步抽成独立函数并写单测:对program -> ...这种只含一个非终结符的右部,结果必须严格等于该非终结符的 FIRST。
3.4 打印分析树:缩进 + 节点编号,肉眼就能看出错
要求里「能直观输出分析树、改变源程序树就变」这一条,是验收时最容易拿分也最容易翻车的地方。我的做法是:递归下降函数进节点时打印当前符号名,在括号里附带一个自增的节点号,缩进层级设为「进函数 +1、出函数 -1」。这样一打出来就是树结构,节点号让它在报告里可被引用。
void compound_statement(int k) { printf("%*scompound_statement(%d)\n", depth * 2, "", ++node_counter); depth++; optional_statements(k); depth--; } void statement(int k) { switch (kind_of(k)) { case ASSIGN: /* variable := expression */ printf("%*sassign\n", depth * 2, ""); expression(k); break; case IF_STMT: printf("%*sif\n", depth * 2, ""); expression(k); // 条件表达式 statement(k); // then 分支 statement(k); // else 分支 break; /* ... */ } }输出长度要控制:一个 30 行的源程序打出来常常就是几百行,先grep一下节点类型再决定细看。树里如果看到某个statement下只有一个assign子节点而没有对应的expression,说明这个分支的递归入口拿错了节点下标,一般是对statement的判别式(看左部和右部各是什么符号)写错了,回去对照第 2 章编码表就能定位。
4. 语义分析与中间代码:把翻译方案表格落成三地址四元式
到语义这层,报告里那套翻译方案(S 属性和 L 属性的混合写法)才真正成为可执行的规格。它的要点有三个:每个文法符号都要有一张属性表(.t类型、.name临时变量或位置、.width宽度、.iPos符号表位置);符号表用栈组织以便进入/退出作用域;每个产生式都有对应的语义动作,比如enter入表、emit发四元式、newtemp生成临时变量。中间代码在这里不是单独一段,而是嵌在语义分析里顺手发出的。
4.1 属性表 + 符号表栈:先定属性再写动作
属性表是这份翻译方案里最容易被忽略、但完全没有就写不下去的部分。因为动作里到处都在引用.t、.name、.width,如果这些属性只存成局部变量,identifier_list传回给declaration时就没有约定。我一般会把它做成一张表,每个符号一行:
| 文法符号 | 属性 | 含义 | 谁设置、谁读取 |
|---|---|---|---|
identifier_list | t / width | 类型 / 数据宽度 | 由type设置,declaration读取 |
type | t / width | 同上,由standard_type或array填 | declaration读取 |
expression | t / name | 类型 / 三地址码里的名字 | assignop、if、while读取 |
variable | name / iPos | 变量名 / 符号表下标 | assignop用:=发四元式 |
standard_type | t / width | integer 宽 4,real 宽 8 | 由type读取 |
有了这张表,enter(top(tableptr), id.iPos, ...)里的iPos才不会凭空冒出来——它来自identifier_list在词法/语法阶段就挂到节点上的符号表位置。符号表用栈(mktable新建、push压栈、pop出栈),是为了处理过程的进入与退出;这个课设里不包含嵌套过程,所以在顶层程序里栈基本只有进没有出,但你仍然要按栈来组织,否则到subprogram_declaration结束时符号表无法正确回收。
typedef struct { int t; int width; } AttrType; void declaration(int k) { AttrType id_list_attr = identifier_list_attr(k); // 先拿 identifier_list.t/.width AttrType type_attr = type(k); // 再拿 type.t/.width if (id_list_attr.t != type_attr.t && !(id_list_attr.t == REAL && type_attr.t == INTEGER)) { /* 类型不一致且不做隐式转换时,报 type_error */ error_count++; return; } /* 把 identifier_list 里每个 name 用 type_attr 和对应偏移入表 */ enter(tableptr, name_of(k), type_attr.t, offset_of(k)); /* 偏移按 type 的 width 递增 */ }integer宽 4、real宽 8 这两个数不是拍脑袋定的,它们既决定偏移怎么加,也决定后面array [i..j] of t的宽度计算:(j - i + 1) * width(t)。这一行算错,要么数组越界访问不到,要么符号表整体偏移全挪,到运行期才暴露。
4.2 类型检查与类型转换:在表达式里做,不在赋值里做
设计里明确要求「类型检查 + 类型转换」,一个常见的偷懒做法是只在赋值语句左右比类型。但真正要处理的是表达式内部 int 和 real 混合运算——a + b里若a是 integer、b是 real,按语义要先inttoreal(a)再算 real 加法,生成一个临时变量。这件事必须在simple_expression的语义动作里做,所以动作会分成四个分支:
/* simple_expression' -> addop term simple_expression'1 */ int simple_expression0(int k) { int t1 = get_t(k); // 左子表达式 simple_expression'1 的类型 int t2 = get_t(right_child(k)); // term 的类型 if (t1 == INTEGER && t2 == INTEGER) { new_temp(k); emit(k, ASSIGN, temp(k), t1_node(k), "+", t2_node(k)); set_t(k, INTEGER); } else if (t1 == REAL && t2 == REAL) { new_temp(k); emit(k, ASSIGN, temp(k), t1_node(k), "+", t2_node(k)); set_t(k, REAL); } else if (t1 == INTEGER && t2 == REAL) { /* 先把整型提升为实型:u := inttoreal t1 */ int u = new_temp_node(); emit(k, INTTOREAL, u, t1_node(k)); new_temp(k); emit(k, ASSIGN, temp(k), u, "+", t2_node(k)); set_t(k, REAL); } else if (t1 == REAL && t2 == INTEGER) { int u = new_temp_node(); emit(k, INTTOREAL, u, t2_node(k)); new_temp(k); emit(k, ASSIGN, temp(k), t1_node(k), "+", u); set_t(k, REAL); } return get_t(k); }四个分支穷举了 int/real 两个类型的所有组合,任何一组不对就落到type_error。等号右边的赋值检查、if后面表达式必须是 boolean、while后同样要求 boolean,这三处检查全部放在各自产生式的动作里,而不是集中到一个「类型检查阶段」。
4.3 四元式种类:覆盖赋值、数组、指针、函数调用
最短的四元式集合要能覆盖设计要求的四类功能。我在实现里固定了下面这些种类,每条四元式就是(op, arg1, arg2, result):
| 四元式 | op | 语义 | 触发产生式 |
|---|---|---|---|
| 赋值 | := | a := b | statement -> variable assignop expression |
| 加法/乘法等 | +、*等 | t := a op b(含 int/real 转换) | simple_expression、term |
| 数组取值 | [] | t := a[i] | variable -> id [ expression ] |
| 指针取值 | =[] | t := *p | factor -> id ^ |
| 转移 | goto | 跳到标签L | if、while |
| 条件转移 | if | 条件成立则跳到L | if、while |
| 函数调用 | call | 参数已按序压入,跳进函数 | procedure_statement |
四元式打印出来也必须能被肉眼看懂,建议直接照「标号 + op + arg1 + arg2 + result」一行一条:(i)开头表示跳转目标。emit函数写起来就一行,但「先算临时变量编号、再发四元式」的顺序不能反,反了就会出现临时变量被覆盖的问题——这种错误在第一个表达式里不一定暴露,往往在连续嵌套赋值里才显形。
4.4 参数传递:值传递为主,设计文档里要写清
函数调用的参数传递机制,这份课设选了值传递:实参在调用前求值到一个临时变量或变量,按顺序放进活动的参数区,call四元式的 arg1 是函数入口、arg2 是参数个数。expression_list的动作里每遇到一个expression就enterq(q, expression.name),q 就是参数队列。参数传递和subprogram_head里的形参声明是一体两面:形参入符号表时记录类型、宽、偏移,实参与形参按顺序一对一做类型检查,不匹配就报错。
/* expression_list -> expression expression_list' */ int expression_list(int k) { /* 第一个实参先入队 */ enqueue(¶m_queue, name_of(first_child(k))); expression_list0(k); // 递归把后续实参都入队 return param_count; }这里不展开引用传递,是因为在不含嵌套过程的 Pascal 子集里值传递最稳;如果文法里带var形参,就要在形参入表时标记为引用,并在实参传递时改用地址,那是另一套动作,报告里最好单独列一张表说明,否则读的人容易把它和值传递混起来。
5. 常见问题排查:词法、语法、语义三个环节的五类翻车现场
这几类问题我在不同场合都见过,症状都很有欺骗性,原因是它们往往不是「编译不过」,而是「编译过了但结果不对」。排查顺序建议从词法到语义,因为上游错位会让下游每一个现象都多一点噪音。
5.1 符号表引用行错位,或者引用超过 5 次后半截丢失
现象:符号表里某个变量在源程序里明明用了 8 次,打印出来use_row只有 5 个;或者第二个变量以后所有行号整体偏了几行。原因有两个,一是use_row[5]是定长数组,写满后再写入就会越界写坏相邻字段;二是注释或字符串里的换行没有计入line_count,声明行之前的行号全都移位。解决方式:引用行改成环形或链表(课设里环形数组就够),并在注释扫描分支里对\n单独计数;自测时在符号表里挑一个引用次数最少的变量,对比源文件人工数一遍行号,行号对上再谈其他。
5.2 FIRST/FOLLOW 算完表里还是大片的 -1
现象:一个完全合法的 Pascal 小程序被报语法错误,错误位置还往往落在if或while之后。原因九成是 FOLLOW 的第三条规则漏了——A -> αBβ里FIRST(β)含 ε 时没有把FOLLOW(A)并进FOLLOW(B)。次常见的原因是analysistable的列下标用了字符串而不是内部编码,于是表和流对不上。解决方式:把 FOLLOW 规则第三条单独写成一个测试用例,用几个手推的短产生式验证,验证通过后再构造整张表。
5.3 分析树形状怪:某个产生式多出一个左递归分支
现象:打印出来的树里simple_expression下挂了一长串没有右括号的节点,看起来像把左递归直接写进了递归下降函数。原因:文法改写成 LL(1) 时,把E -> E + T | T改成了E -> T E'和E' -> + T E' | ε,但只改了文法的定义、没有同步改递归下降函数里的调用顺序,导致函数在读完一个T后没有去调E'而是又调了一次E。解决方式:递归下降函数必须和改写后的产生式一一对应,E里只能调T和E',不能回指E。改完重打树,形状应该是一棵「每个运算符层一个节点」的树。
5.4 词法层面编码撞号,编译器走错分支却没有任何提示
现象:某个运算符在特定源程序里被识别成另一个算子,结果算出来的值不对,但编译过程全程无错。原因:内部编码表里出现了重复值,或者运算符的双字符(如<=、:=)被拆成两个单字符分别编码。解决方式:编码表落定后写一个自检函数,用visited[]标记所有编码值,重复就断言;双字符运算符在词法里必须做两字符先行判断,不能让单字符分支先吃掉。
5.5 语义阶段 int/real 混合运算结果被吞成整型
现象:a + b里a是 integer、b是 real,打印出的四元式只有一条:= a + b,运行结果舍去了小数。原因:simple_expression的语义动作只写了「同类型」和「类型不同即报错」两个分支,没写 int 提升为 real 的两个分支。解决方式:按 4.2 四分支穷举,只要两边类型不同且不是不兼容类型,就必须先发一条inttoreal再算;自测时故意在 int 变量和 real 变量之间做加、减、乘、除各一次,看四元式里是否都有inttoreal。
6. 用三个小程序验收:从记号流到四元式的比对与收尾技巧
到这一步,编译器已经能跑,但「能跑」和「对了」之间还差一次有组织的验收。我习惯用三个层次递增的源程序来暴露不同阶段的问题,每个程序只测一件事,互相不污染。
6.1 三组测试程序的设计
program p1; var a, b, c : integer; begin a := 1; b := 2; c := a + b; write(c) end.第一个程序只测「声明-赋值-输出」这条主路径,走通说明词法、语法、语义三层的基本链路没问题;对应的四元式应该只包含:=和call write,条数少、容易逐行核对。第二个程序加数组和while:
program p2; var i, s : integer; a : array [1..5] of integer; begin i := 1; while i <= 5 do begin a[i] := i * i; i := i + 1 end end.这里看四元式里是否有[]取址、if条件转移、goto回跳,三个都出现说明while的语义动作整段是对的。第三个程序加函数与参数:
program p3; var x, y : real; function f(a : real) : real; begin f := a * a end; begin x := 1.5; y := f(x) end.这个程序的重点不在算术,而在f的入口是否有一个独立的符号表作用域、实参是否按顺序入队、call四元式的 arg2 是否等于参数个数。
6.2 一次系统比对该对什么
| 验收项 | 来源文件 | 期望 | 常见偏差 |
|---|---|---|---|
| 记号流顺序 | 记号流.txt | 与源程序逐 token 对齐 | 注释后第一个 token 漂移 |
| 符号表行号 | 符号表.txt | 声明行与源文件一致 | 注释内换行漏计 |
| 分析树根到叶 | 控制台输出 | 根节点program,叶为终结符 | 表达式层多一层左递归 |
| 四元式条数 | 中间代码.txt | p1 ≤ 6 条(含 write) | int/real 少发inttoreal |
| 标签编号 | 中间代码.txt | if/goto标号成对且连续 | while回跳标签对不上 |
p2 是暴露标签问题最多的一个:while有三条相关四元式——条件是if cond goto L1、循环体开始是L1:、循环体结束是goto L0,外加出口L2:。四条跳转目标一旦错一个,分析树看着正常,四元式序列里却会跳回条件判断而不是循环体开头,跑起来就是死循环。我的习惯是先把标签列打印出来(goto L?:的 ? 与L?:定义行),肉眼对齐一遍再跑。
6.3 一个真正省时间的习惯:先对齐临时变量编号
最后说一个我反复吃过亏的技巧。绝大多数语义 bug 不会让编译器崩,它们表现为四元式里临时变量编号乱跳,比如t1 := a + b; t3 := t1 * c——中间缺了t2。缺号本身不一定是错,因为有些临时变量是中间节点用完即弃;错的是「两个四元式复用了同一个临时变量编号但类型不同」,这种在 p1、p3 里最隐秘,往往要算到最后一步才发现结果被覆盖。
我现在每次跑完 p3,都先写一个 20 行的 Python 小脚本把中间代码读进来,抽出所有t开头的临时变量名,按出现顺序和类型做一次交叉表;如果同一个临时变量在两个四元式里被赋予不同类型,脚本会直接报出来。这个动作不到一分钟,但能省掉一整个下午对着分析树倒推「到底是哪个表达式把临时变量写坏了」。从那以后我每次做完语义分析,都会强制走一遍这个临时变量对齐的检查,再回看四元式条数和标签,最后才去对输出结果。希望这个习惯能帮到你。
本文还有配套的精品资源,点击获取