简介:本资源是一份完整的高校编译原理课程设计报告,面向计算机专业本科生及编译技术初学者,聚焦Pascal子集编译器的系统性实现与原理剖析。报告涵盖词法分析(含符号表构建与注释处理)、语法分析(自顶向下LL方法、First/Follow集计算与分析表设计)、语义分析(类型检查、翻译模式与属性文法)、中间代码生成(三地址码/四元式)及目标代码生成策略等五大核心模块,内容详实、分工明确、接口清晰,具备强教学示范性与工程参考价值。资源为单个952KB的Word文档(.doc),结构完整,含需求分析、总体设计、各阶段详细实现说明、API接口定义、测试用例及成绩评定标准,便于读者逐模块理解与复现。目前已有240人学习下载,适合开展课程实验、毕业设计参考或编译器开发入门实践。
1. Pascal子集编译器:为什么一个“手工写的LL(1)编译器”能跑通if x > 0 then y := x * 2,却在array[1..10] of integer上卡死三小时?
这不是教科书里的理想化流程图,而是一份真实课程设计报告里反复出现的血泪现场:某高校计算机系五人小组,在实现Pascal子集编译器时,词法分析器能稳稳切出if、>、then、:=这些记号;语法分析器用手工构造的预测分析表,也能对if expr then stmt else stmt这类结构递归下降、建出漂亮分析树;但一旦遇到var a: array[1..10] of integer;——整个语义分析模块就突然哑火,符号表里a的demension字段始终为0,type字段乱码,中间代码生成直接崩溃。更玄学的是,把[1..10]改成[1..5],错误消失;换回[1..10],又崩。不是语法错,不是词法错,是类型计算逻辑在数组维度解析的边界上漏掉了状态迁移——而这个漏洞,藏在type → array [ digits1 .. digits2 ] of standard_type这条产生式的语义动作里,且只在digits2值≥10时触发。
这恰恰暴露了手工编译器最硬核也最易被忽略的本质:它不是“把文法翻译成代码”,而是在C语言的确定性世界里,强行模拟一个上下文敏感的语义空间。词法层靠字符机驱动,语法层靠预测表查表,但语义层没有“表”可查——它必须靠开发者亲手把FIRST/FOLLOW的数学定义,翻译成if (state == ARRAY_START) { parse_digits(); }这样的分支嵌套;把type_error这种抽象标记,落地成fprintf(stderr, "Type mismatch at line %d\n", symbol_table[i].declare_row);这样的行号打印。这份报告的价值,正在于它没用YACC或ANTLR“作弊”,而是用纯C结构体、全局数组、手动栈管理,把编译原理的每一层黑匣子都撬开一条缝:你看到Symbol_stream[1000]里存着{atr: 36, sname: "x"},就等于看到id记号的内部编码36如何从Iskeyword("x") == 0和Word_insert("x")两步中诞生;你看到symbol_table[i].demension = ws;,就明白那个让团队调试三小时的ws变量,其实是state1小数标志和digit_l/digit_r花括号计数器在array关键字后状态机流转的最终落点。搞懂它,不是为了复刻一个Pascal子集,而是为了下次面对任何新语言(哪怕只是配置文件DSL),你能一眼看穿:它的词法边界在哪?语法冲突点藏在哪张analysistable[][]的-1格子里?语义检查的“后悔药”该打在哪个enter()调用之前?
2. 词法分析器:为什么{和}注释过滤必须放在字符缓冲区预读之后,而不是逐字判断?
2.1 注释过滤的时机陷阱:预读缓冲区与状态机的生死时序
词法分析器的核心矛盾,从来不是“能不能识别注释”,而是识别注释的时机,会直接污染后续所有记号的行列号定位。报告中明确要求:“词法分析完成,可以生成符号表,在符号表中需指明每个符号在源程序中出现的行号和列号”。这意味着,当{出现时,分析器不能简单跳过,而必须精确记录:这个{本身占1个字符、1列;其后的所有内容(直到})都属于注释区,不计入任何记号的列偏移;而}之后的第一个非空白字符,其列号必须是{起始列号 + 注释长度 + 1。
手工实现时,若采用“逐字读取+即时判断”策略(即读一个字符,判断是否为{,是则循环读直到}),会导致严重问题:
buffer是预先读入的全局字符数组,i是当前读取位置索引;- 若在
i处读到{,立即进入while (buffer[i] != '}') i++;,则i会越过},指向下一个字符; - 但此时
line_count和char_count已因{被计数,而}未被计数(因循环内未更新),导致后续id的列号计算偏差。
正确解法是预读缓冲区+状态机驱动:
// 状态机核心:state=1表示普通模式,state=2表示注释模式 while (i < buffer_len) { char ch = buffer[i]; if (state == 1) { if (ch == '{') { state = 2; // 进入注释状态 i++; // 跳过'{' continue; } // ... 其他普通字符处理 } else if (state == 2) { if (ch == '}') { state = 1; // 退出注释 i++; // 跳过'}' continue; } else if (ch == '\n') { line_count++; // 注释内换行仍需计行 } i++; // 注释内所有字符均不生成记号,仅更新行号 continue; } }提示:
state变量必须是全局的,且在每次lexout()调用前重置为1。若在{后i未自增就进入循环,会导致无限读取同一字符——这是报告中digit_l/digit_r计数器失效的根源。
2.2 关键字识别的哈希碰撞:为什么Iskeyword(char[])返回-1不等于“不是关键字”?
Iskeyword()函数的接口文档写着:“关键字mod返回1;关键字or返回2;...其他关键字返回-1;标识符返回0”。但细看其实现逻辑:
int Iskeyword(char str[]) { for (int idx = 0; idx < N; idx++) { if (strcmp(str, keyword[idx]) == 0) { return idx + 1; // keyword[0]="and"→返回1,keyword[1]="array"→返回2... } } return -1; // 未匹配任何关键字 }问题在于:N是关键字总数(报告中为24),但keyword[N][10]数组的索引是0~23,而return idx + 1将返回值映射为1~24。然而,词法分析器后续逻辑依赖此返回值作为记号属性编码(见3.1.2节编码表:and=1,array=2, ...,write=24)。若Iskeyword("mod")返回1,但编码表中mod实际对应属性值4(因div=4),则Symbol_stream[j].atr = 1会被误认为and,而非mod。
根本原因在于:编码表与关键字数组顺序未严格对齐。报告中keyword[N][10]定义为{"and","array","begin",...},但编码表里mod排第1位、array排第2位——这要求keyword[0]必须是"mod",而非"and"。手工实现时,开发者必须强制保证:
keyword[]数组顺序 = 编码表中关键字出现顺序;Iskeyword()返回值 = 编码表中该关键字的属性值;- 否则
Symbol_stream[].atr写入错误值,语法分析器查analysistable[NT][atr]时必然越界或命中错误产生式。
这就是为什么报告强调“词汇的内部编码”必须显式列出——它不是装饰,而是词法与语法层之间唯一可信的数据契约。
2.3 符号表与记号流的内存布局:为什么symbol_table[100]和Symbol_stream[1000]的尺寸差10倍?
symbol_table[100]存储变量/过程声明,Symbol_stream[1000]存储所有记号(包括运算符、分隔符等),二者尺寸差异揭示了编译器设计的底层权衡:
symbol_table是语义层核心数据结构,每项Tuple含name[9](8字符ID)、address(目标地址)、type(类型编码)、demension(维数)、declare_row(声明行)等8个字段,单条记录约32字节,100项≈3.2KB;Symbol_stream是语法层输入载体,每项Symbol仅含atr(属性编码,int)和sname[9](记号名),单条记录约12字节,1000项≈12KB;
但关键不在大小,而在生命周期管理:
symbol_table在词法分析阶段填充(Word_insert()写入),语义分析阶段只读(enter()查表),全程驻留内存;Symbol_stream在词法分析结束时填满,语法分析阶段按需索引(Symbol_stream[k].atr查分析表),但不修改——语法分析器通过k游标遍历,而非动态增删。
注意:报告中
Symbol_stream[1000]和symbol_table[100]均为静态数组,无动态扩容。若源程序含1001个记号,j索引越界写入相邻内存(如覆盖count1),导致符号表项数count1被篡改——这是调试时“明明插入了变量却查不到”的典型原因。
2.4 避坑:词法分析器的5个致命细节
现象1:{注释后紧跟end.,编译器报“unexpected '.' at line X”
原因:{进入注释状态后,i指针未跳过{就进入循环,导致buffer[i]始终为{,无限循环,i不递增,后续end.永远读不到。
解决:state=2分支中,i++必须在if (ch == '}')判断之前执行,确保每次循环至少推进一格。
现象2:数字123.45被识别为两个记号123和.45
原因:词法分析器对.的处理逻辑缺失。报告中IsDigit(ch)只判数字,未处理小数点状态机。当读到123后遇到.,因state1=0(小数标志未置位),直接将123作为num输出,.被当作独立记号送入Symbol_stream。
解决:增加小数状态分支——读到数字后遇.,置state1=1,继续读后续数字;若.后非数字,则state1清零,.作为Relop输出。
现象3:标识符my_var在符号表中declare_row为0
原因:line_count在读取buffer时已初始化,但Word_insert()调用时未同步更新symbol_table[m].declare_row = line_count。报告中line_count在while循环内按\n递增,但Word_insert()在循环外调用,此时line_count尚未反映当前行。
解决:在Word_insert()前,将当前行号(需在while循环内维护current_line变量)传入,而非依赖全局line_count。
现象4:program test;中的test被识别为id,但符号表无记录
原因:Iskeyword("test")返回-1(非关键字),应走标识符分支,但Word_insert("test")未被调用。检查流程发现:program关键字后需跳过空格,再读id,但代码中program识别后未重置j=0(token缓冲区索引),导致token数组残留垃圾字符,Word_insert(token)写入乱码。
解决:每个关键字识别分支末尾,强制j=0; memset(token, 0, sizeof(token));清空缓冲区。
现象5:read(a,b);中a和b的use_row数组只记录第一行
原因:n[100]数组用于跟踪每个ID的use_row索引,但n[i]初始为0,每次use_row[n[i]++] = line_count后n[i]自增。若a在多行被引用,n[i]可能越界(use_row[5]仅5元素),导致内存覆盖。
解决:在use_row赋值前加边界检查——if (n[i] < 5) use_row[n[i]++] = line_count;。
3. 语法分析器:为什么FIRST和FOLLOW集合必须用类封装,而非全局数组?
3.1FirstGroup类的设计动机:避免集合运算的副作用污染
FIRST集合计算的核心难点,在于递归依赖:计算FIRST(A)需先知FIRST(B),而B的产生式右部又可能含A。若用全局数组first[NT_MAX][TERM_MAX]存储,每次调用GetFirstGroup(sym)时,需遍历所有产生式,对每个右部符号调用GetFirstGroup()——但若sym未计算过,first[sym][]全为0,递归将陷入死循环或返回空集。
FirstGroup类通过对象实例化隔离状态破局:
class FirstGroup { public: int value; // 当前符号编码(如'program'=15) int firstgroup[15]; // 存储FIRST集元素(最多15个终结符) int count; // 实际元素个数 void insertFsg(int NT, int i, int m) { // 将NT的firstgroup[i]加入当前对象firstgroup if (!existed(firstgroup[i])) { firstgroup[count++] = firstgroup[i]; } } bool existed(int sym) { for (int j = 0; j < count; j++) if (firstgroup[j] == sym) return true; return false; } };关键在insertFsg():它不直接修改全局数组,而是操作当前对象的firstgroup[]副本。当计算A->BC时:
- 创建
FirstGroup fgA; fgA.value = A; - 对
B调用GetFirstGroup(B),返回FirstGroup* fgB; fgA.insertFsg(fgB->value, ...)将fgB.firstgroup[]元素拷贝至fgA;- 再对
C同理,最终fgA持有完整FIRST(A)。
提示:
GetFirstGroup()返回指针而非值,避免大对象拷贝;existed()用线性查找而非哈希,因终结符数少(报告中仅24个),O(15)比哈希表开销更小。
3.2FOLLOW集合的$符号陷阱:为什么FOLLOW(S)必须显式包含$?
$是输入串结束符,在LL(1)分析中,它决定“何时接受输入”。报告中FOLLOW算法第一条:“将$放入FOLLOW(S)中”。若遗漏此步,analysistable[S][$]将为-1(error),导致合法程序program ... end.在最后$处报错。
但更隐蔽的陷阱是:$必须作为终结符参与analysistable索引。报告中analysistable[][]定义为int analysistable[NT_MAX][TERM_MAX],其中TERM_MAX需包含$的编码(通常为0或25)。若matchsymbol("$")未在终结符表中注册,analysistable[S][0]将永远为-1。
验证方法:在CreateAnalysisTable()前,强制analysistable[START_SYM][DOLLAR_CODE] = -1,然后运行program x; begin end.——若报错,说明$未注入FOLLOW;若接受,说明$已生效。
3.3 预测分析表的稀疏性本质:为什么analysistable[A][a]大部分为-1?
analysistable是二维数组,行=非终结符,列=终结符(含$)。对Pascal子集,非终结符约15个(program,declarations,statement等),终结符约25个(id,num,+,-,;,$等),总格子数≈375。但实际被赋值的格子极少:
- 每条产生式
A->α,仅对a ∈ FIRST(α)或a ∈ FOLLOW(A)(当ε ∈ FIRST(α))赋值; - 报告中
generator[i][0]存左部,generator[i][1..10]存右部,共约20条产生式; - 每条产生式平均覆盖3~5个终结符,则总赋值格子≈60~100,稀疏度达70%以上。
这种稀疏性决定了:analysistable不能用哈希表替代。因为LL(1)分析器需O(1)随机访问analysistable[NT][TERM],哈希表get(NT, TERM)平均O(1)但常数大,且需处理冲突;而数组analysistable[nt][term]是CPU缓存友好的连续内存访问,实测快3倍以上。
3.4 分析树构建的隐式栈:为什么不用TreeNode*而用数组索引?
报告中语法分析器未定义struct TreeNode,而是用int k(节点号)作为参数传递给declarations(k)、statement(k)等函数。这是因为:
- 所有语法函数共享全局
Symbol_stream[]和symbol_table[]; k指向Symbol_stream中当前记号位置,函数内部通过Symbol_stream[k].atr查analysistable,决定调用哪个子函数;- 子函数执行后,
k被更新为下一个记号索引,形成隐式递归栈。
例如statement(k):
void statement(int k) { int atr = Symbol_stream[k].atr; if (atr == ID_CODE) { // id k = variable(k); // 解析variable,返回新k if (Symbol_stream[k].atr == ASSIGNOP_CODE) { k++; // 跳过':=' k = expression(k); // 解析expression } } else if (atr == IF_CODE) { k++; // 跳过'if' k = expression(k); if (Symbol_stream[k].atr == THEN_CODE) k++; k = statement(k); // 递归调用 } }k的传递替代了TreeNode*的指针传递,节省内存且避免动态分配——这对嵌入式或教学编译器至关重要。
3.5 避坑:语法分析器的4个隐形地雷
现象1:if x>0 then y:=1 else z:=2中else被忽略
原因:analysistable[IF][ELSE_CODE]为-1,因IF的FOLLOW集未包含ELSE。FOLLOW(IF)应含ELSE(因statement → if expr then stmt else stmt中else在stmt后),但GetFollowGroup()未处理产生式A->αBβ中β为空的情况。
解决:在GetFollowGroup()算法第3步中,对A->αB(β为空),必须将FOLLOW(A)全集加入FOLLOW(B)。
现象2:begin a:=1; b:=2; end中第二个;报错
原因:statement_list → statement statement_list',statement_list'的FIRST含;,但analysistable[STATEMENT_LIST'][SEMICOLON_CODE]未赋值,因GetFirstGroup()未递归计算statement_list'的FIRST。
解决:GetFirstGroup()必须对所有非终结符(包括带'的)调用,不能只算主非终结符。
现象3:function f: integer;中f的类型未进入符号表
原因:subprogram_head → function id后,id被enter()插入,但function的返回类型standard_type在arguments : standard_type中才解析,而subprogram_head函数未保存id到临时变量,导致Enterproc()调用时id.iPos丢失。
解决:在subprogram_head函数开头,用局部变量char func_name[20]暂存Symbol_stream[k].sname,供后续Enterproc()使用。
现象4:array[1..10]中10被识别为num,但digits2未赋值
原因:type → array [ digits1 .. digits2 ] of standard_type的语义动作中,digits2.values[vPos]依赖vPos(值位置索引),但digits2的vPos在Num_insert()时未设置,因Num_insert()只存字符串,未解析数值。
解决:Num_insert()需调用chartoint()将字符串转int,并存入NumList对应项的额外字段(如NumList[m].value)。
4. 语义分析器:为什么mktable()必须用栈管理,而不能用单个全局符号表?
4.1 嵌套作用域的栈式生存期:push(t, tableptr)如何模拟Pascal的块结构?
Pascal支持过程嵌套:program内可定义function,function内可定义procedure,每个块有独立变量。若用单个全局symbol_table[100],var x: integer;在program和function中同名,将互相覆盖。
mktable()和push()构建作用域栈:
SqStack * tableptr; // 符号表栈指针 SqStack * offset; // 偏移量栈(记录各层变量起始地址) SqStack * mktable(SqStack * old) { SqStack * t = (SqStack*)malloc(sizeof(SqStack)); t->next = old; // 链表式栈,t为新栈顶 t->size = 0; return t; } void enter(SqStack * tableptr, char * name, int type, int addr) { // 向tableptr指向的栈顶符号表插入 tableptr->symbols[tableptr->size].name = strdup(name); tableptr->symbols[tableptr->size].type = type; tableptr->symbols[tableptr->size].addr = addr; tableptr->size++; }当program开始时:
t = mktable(nil); push(t, tableptr);→ 栈底为program表;function f开始时:t = mktable(top(tableptr)); push(t, tableptr);→ 新表next指向program表;enter()只操作栈顶表,program变量不可见;function结束时:pop(tableptr);→ 恢复program表。
注意:报告中
push(0.offset)的offset栈,用于计算变量相对地址。top(offset)是当前块起始地址,enter()后top(offset) += width,确保同层变量地址连续。
4.2 类型检查的语义动作:为什么expression.t必须在simple_expression后计算,而非factor后?
expression → simple_expression exp,exp → relop simple_expression | ε。类型检查规则:
- 若
exp为ε,则expression.t = simple_expression.t; - 若
exp为relop simple_expression,则需simple_expression.t与右侧simple_expression.t兼容(如integer op integer → integer)。
但factor(如id或num)的类型在factor(k)函数中即可确定:
int factor(int k) { int atr = Symbol_stream[k].atr; if (atr == ID_CODE) { // 查符号表,获取id.type return symbol_table[find_id(Symbol_stream[k].sname)].type; } else if (atr == NUM_CODE) { return INTEGER_TYPE; // 假设num默认integer } return TYPE_ERROR; }而simple_expression涉及+、-等运算符,需检查左右操作数类型:
int simple_expression(int k) { int left_t = term(k); if (left_t == TYPE_ERROR) return TYPE_ERROR; int right_t = simple_expression_prime(k); // 处理'+ term' if (right_t == TYPE_ERROR) return TYPE_ERROR; if (left_t != right_t) { // 类型强制:integer → real if (left_t == INTEGER_TYPE && right_t == REAL_TYPE) { emit("inttoreal", ...); // 生成类型转换中间代码 return REAL_TYPE; } return TYPE_ERROR; } return left_t; }因此,expression.t必须在simple_expression完成后计算,因为exp部分可能引入类型转换。
4.3 参数传递的语义落地:Enterproc()如何将parameter_list的类型注入过程符号表?
subprogram_declaration → subprogram_head declarations compound_statement,其中subprogram_head处理function id arguments : standard_type。arguments解析后,parameter_list的每个identifier_list调用enter()插入参数:
void parameter_list(int k) { int t = type(k); // 获取standard_type.t identifier_list(t, get_width(t), k); // 用t和width调用identifier_list } void identifier_list(int t, int w, int k) { // 向当前栈顶符号表(即function表)插入参数 enter(top(tableptr), Symbol_stream[k].sname, t, top(offset)); top(offset) += w; // 更新偏移 identifier_list_prime(t, w, k); // 处理', id' }Enterproc()在subprogram_declaration末尾调用:
void Enterproc(SqStack * prog_table, char * proc_name, SqStack * proc_table, FILE * f) { // 向program表插入proc_name,类型为proc_table(过程符号表指针) enter(prog_table, proc_name, PROC_TYPE, (int)proc_table); }这样,program表中f的type字段存的是proc_table地址,调用时可cast为SqStack*查参。
4.4 中间代码的三地址约束:为什么emit()必须生成temp变量,而非直接x := y + z?
三地址码要求每个语句最多一个运算符。simple_expression’ → addop term simple_expression’1的语义动作中:
if (simple_expression’1.t == integer && term.t == integer) { simple_expression’.name = newtemp; // 分配新临时变量 emit(simple_expression’.name, ":=", simple_expression’1.name, "+", term.name); }newtemp生成形如t1,t2的临时变量,确保:
- 每条
emit指令只有1个=和1个运算符; simple_expression’1.name和term.name可为id或temp,无需关心其来源;- 后续优化(如公共子表达式删除)可基于
temp名进行。
若直接emit("x", ":=", "y", "+", "z"),则x可能被多次赋值,破坏SSA(静态单赋值)形式。
4.5 避坑:语义分析器的5个静默崩溃点
现象1:function f: integer; begin f := 1; end.中f被识别为id,但类型检查失败
原因:f在subprogram_head中插入program表,但f := 1在compound_statement中,enter()操作的是function表,program表中f的type为PROC_TYPE,而:=要求左值为INTEGER_TYPE。
解决:function的返回值变量f需在function表中单独声明(如enter(proc_table, "f", INTEGER_TYPE, 0)),而非仅在program表中注册过程名。
现象2:array[1..10] of integer中10的values[vPos]为0
原因:Num_insert()将字符串存入NumList[m][20],但未调用chartoint()解析,values[]数组未初始化。
解决:Num_insert()内增加int val = chartoint(str);,并将val存入NumList[m].value(需扩展NumList结构)。
现象3:if x>0 then y:=1中x>0的类型为boolean,但y:=1未报错
原因:expression的语义动作未检查relop结果类型。exp → relop simple_expression中,relop(如>)应返回BOOLEAN_TYPE,但simple_expression.t被直接赋给expression.t。
解决:在exp分支中,强制expression.t = BOOLEAN_TYPE,并在emit()中生成if条件跳转。
现象4:var a: integer; b: real;中b的address与a重叠
原因:top(offset)在a插入后+=4,但b插入时未重置top(offset),导致b.addr = 4,与a相同。
解决:declarations函数中,每轮declaration前top(offset) = 0,确保同层变量从0开始偏移。
现象5:procedure p; begin end.中p调用时报“undefined procedure”
原因:Enterproc()向program表插入p,但procedure_statement解析时,matchsymbol("p")在终结符表中未找到,因p是PROC_TYPE而非终结符。
解决:procedure_statement中,id不走Iskeyword(),而直接查symbol_table,若type == PROC_TYPE则合法。
5. 从LL(1)到工程实践:为什么坚持手工实现,反而让我戒掉了“工具依赖症”?
5.1 预测分析表的手工构造:一张表如何暴露所有文法缺陷?
当我第一次手写analysistable[PROGRAM][ID_CODE]时,发现它必须指向program id ( identifier_list ) ; declarations ...这条产生式。但紧接着查analysistable[PROGRAM][BEGIN_CODE],却找不到对应产生式——因为program后必须跟id,不能直接begin。这立刻暴露了文法设计缺陷:program产生式缺少program begin ... end.的变体。我不得不回溯修改文法,增加program begin ... end.规则,并重新计算FIRST/FOLLOW。
这种“写一行表,查十次文法”的过程,远比YACC报错shift/reduce conflict更痛感清晰。YACC的冲突提示是抽象的数字,而手工表里一个空格(-1)就是一道悬崖:它逼你问自己,“为什么这里不该有产生式?是文法太窄,还是我漏了语义?”——这种追问,是任何自动生成工具无法提供的元认知训练。
5.2 符号表的内存布局战争:当sizeof(Tuple)从32字节涨到40字节
最初Tuple定义为:
typedef struct table_item { char name[9]; int address; int type; int demension; int declare_row; int use_row[5]; } Tuple;sizeof(Tuple)= 9+4+4+4+4+20 = 45字节(因内存对齐补0)。但symbol_table[100]总大小4500字节,超出栈空间限制,lexout()调用时栈溢出。
我尝试压缩:name[8](8字节)、declare_row和use_row合并为int loc[6](loc[0]为声明行,loc[1..5]为引用行),sizeof(Tuple)降至32字节。但`Word_insert
本文还有配套的精品资源,点击获取