简介:面向重庆大学编译原理课程的实验整合资源,适合正在学习词法分析、语法分析、语义分析、中间代码生成与目标代码优化的学生,以及需要实现PL0语言编译器的开发者参考。压缩包共44个文件,大小1.83MB,主要包含txt说明文档、xml/cmake工程配置、c/cpp源码、exe可执行程序以及docx实验报告模板等,既有可读的代码实现也有能直接运行的编译产物;目前已有63人学习,覆盖了从实验报告撰写到完整编译器构建的常用场景。资源中的PL0编译器工程包含递归下降分析、符号表管理等核心模块,配合实验报告模板和学习笔,可帮助读者理解编译流程各阶段的实际衔接;CMakeLists与工程文件也便于快速打开项目进行二次修改。整体内容针对性强,适合按步骤完成实验、核对代码细节或作为课程设计基础模板。
1. 从零搭一个PL0编译器是什么体验:这份仓库把五个实验阶段一次讲透
拿到重庆大学编译原理课程的实验任务,很多人第一反应是“又要写编译器了”。当你把这份代码仓库与学习资源整合平台解压后,会发现它不是单一源码,而是把词法分析器、语法分析器、语义分析、中间代码生成、目标代码优化五个阶段拆成独立工程,还配了PL0语言编译器实现、实验报告模板和学习笔记。这个方向值不值得投入?我的判断是值得,但关键不是抄,而是弄明白每一步为什么这么做。适合刚领任务找不到入手点的学生,也适合想把手写编译器写进简历的开发者。先从词法分析器说起。
2. 词法分析器:把PL0源码变成Token流的第一道门槛
2.1 为什么不直接用Lex生成器,而要手写扫描器
在课程实验里,用Lex生成词法分析器看起来效率高,但实际上把学校最想考察的东西绕过去了——状态转换的手工实现。期末考试会让你在白纸上推DFA,如果你在实验阶段没有经历过“字符推进、符号识别、错误回退”这个过程,考试时只能靠背。
常见做法是写一个getSym()函数,每调用一次,就从前端字符流里切出一个Token。这个函数的返回值是一个枚举类型,代表着TOKEN的种类。PL0的文法很小,保留字固定,运算符只有十几种,一个手写扫描器用两三百行C代码就能跑起来,完全用不上Lex那种重型生成器。
这里先要分清编译器和编辑器的区别:编辑器只负责高亮、补全和格式化,编译器必须把字符流切碎并转成结构化的Token流,后续的语法分析才能在这些Token上做推导。想通了这句话,词法分析器的定位就清楚了——它不关心“这段代码对不对”,只关心“这段代码能不能被切成合法的词”。
2.2 PL0的Token表设计:保留字、运算符与界符都要进一张枚举
PL0语言保留了11个关键字:begin、end、if、then、while、do、call、const、var、procedure、odd。运算符和界符按类别整理如下:
| 类别 | 符号 | 枚举值 |
|---|---|---|
| 算术运算符 | + - * / | SYM_PLUSSYM_MINUSSYM_TIMESSYM_SLASH |
| 关系运算符 | = <> < > <= >= | SYM_EQSYM_NEQSYM_LTSYM_GTSYM_LEQSYM_GEQ |
| 界符 | ( ) , ; . := | SYM_LPARENSYM_RPARENSYM_COMMASYM_SEMICOLONSYM_PERIODSYM_BECOMES |
注意<>表示不等于,它和<、>都不是一个Token。枚举设计必须和后面语法分析的switch分支完全对齐,否则词法层漏一个符号,语法层就要跟着改一大片。
设计Token表时有一个细节:保留字不需要单独建表,识别到字母开头的标识符后,先分配一个缓冲区,再把缓冲区里的字符串和保留字表顺序比对,命中就返回对应的枚举,否则返回SYM_IDENT。PL0保留字只有11个,顺序查表的开销完全可以忽略。
2.3 getSym核心代码实现与参数说明
下面这段扫描器代码是PL0实验最常见的实现方式,你关注三个点:ch的推进时机、sym的赋值位置、错误分支的处理策略。
#define MAX_IDENT_LEN 10 typedef enum { SYM_NUL, SYM_IDENT, SYM_NUMBER, SYM_PLUS, SYM_MINUS, SYM_TIMES, SYM_SLASH, SYM_ODD, SYM_LPAREN, SYM_RPAREN, SYM_COMMA, SYM_SEMICOLON, SYM_PERIOD, SYM_BECOMES, SYM_EQ, SYM_NEQ, SYM_LT, SYM_LEQ, SYM_GT, SYM_GEQ, SYM_BEGIN, SYM_END, SYM_IF, SYM_THEN, SYM_WHILE, SYM_DO, SYM_CALL, SYM_CONST, SYM_VAR, SYM_PROCEDURE } Symbol; static char ch; // 当前读到的字符 static char ident[MAX_IDENT_LEN + 1]; // 当前标识符字符串 static int num; // 当前数字值(用于常量) static Symbol sym; // 当前识别出的Token类型 void getSym(void) { while (ch == ' ' || ch == '\n' || ch == '\t') getChar(); // 跳过空白字符,直到遇到有效字符 if (isalpha(ch)) { // 标识符或保留字 int len = 0; while (isalnum(ch)) { if (len < MAX_IDENT_LEN) ident[len++] = ch; // 超长部分直接丢弃 getChar(); } ident[len] = '\0'; sym = reserve(ident); // 查保留字表,非保留字返回 SYM_IDENT } else if (isdigit(ch)) { // 无符号整数 num = 0; while (isdigit(ch)) { num = num * 10 + (ch - '0'); getChar(); } sym = SYM_NUMBER; } else { switch (ch) { case '+': sym = SYM_PLUS; getChar(); break; case ':': getChar(); if (ch == '=') { sym = SYM_BECOMES; getChar(); } else { error("赋值符号应为 :="); } break; case '<': getChar(); if (ch == '=') { sym = SYM_LEQ; getChar(); } else if (ch == '>') { sym = SYM_NEQ; getChar(); } else { sym = SYM_LT; } break; case '>': getChar(); if (ch == '=') { sym = SYM_GEQ; getChar(); } else { sym = SYM_GT; } break; default: sym = SYM_NUL; error("无法识别的字符"); } } }这段代码有三个关键点。第一,getChar()必须在每个分支里都被正确调用,忘记推进ch会让扫描器在同一个字符上死循环,编译器看起来就像卡死了一样。第二,数字累加用num = num * 10 + digit,没有做溢出保护,课程实验一般也不做,但你要知道这是一个隐患。第三,reserve()函数负责把ident和保留字表比对,返回对应的枚举值,查不到就返回SYM_IDENT,这个函数通常只有十来个strcmp,不复杂。
参数说明:ident数组长度固定为10,这是PL0的经典限制——标识符最长10个字符;num是int类型,超过int范围的数字会直接溢出。实验报告里要写清楚这些限制,不要试图扩展成任意长度,那会让后面所有符号表查表逻辑的复杂度翻倍。
2.4 词法阶段的3个边界情况
第一个是标识符截断。PL0语法规定标识符超过10个字符就丢弃后面的字符,但很多学生没注意到这个限制,导致两个长名字变量被当成同一个标识符,语义分析阶段查符号表时永远找不到正确声明。解决方法是把截断行为写进实验报告,并输出一条警告信息,而不是静默截断。
第二个是数字前导零。PL0允许007这样的写法,但如果你的中间代码生成阶段做了常数合并或常数折叠优化,前导零会干扰常数表的归一化处理。处理办法是在词法层面直接按十进制累加,不保留前导零信息,后续优化就不会被干扰。
第三个是空文件。很多测试用例的第一个字符就是EOF,getSym里如果没有对EOF做哨兵处理,就会陷入死循环。我一般在getChar()里判断:读不到字符时把ch置为0,扫描器遇到ch == 0直接返回SYM_NUL,语法分析看到SYM_NUL就知道输入结束了。
3. 语法分析器:递归下降如何把Token流变成语法树
3.1 为什么递归下降法适配PL0这种紧凑文法
PL0的文法天然是LL(1)的,每个非终结符都能对应一个独立的递归函数。这意味着你在语法分析阶段不需要引入任何LR分析器生成工具,直接照着产生式写函数就行。核心产生式如下:
program := block . block := [constDecl] [varDecl] {procedureDecl} statement statement := ident := expression | call ident | begin statement {; statement} end | if condition then statement | while condition do statement condition := odd expression | expression relOp expression expression := [+|-] term {(+|-) term} term := factor {(*|/) factor} factor := ident | number | ( expression )每一个非终结符对应一个C函数,函数名就叫expression()、term()、factor()。函数体就是产生式右侧的代码化:遇到终结符就比对当前sym,遇到非终结符就调用对应的子函数。
这种做法的最大好处是调试直观。语法分析到哪一步出错,调用栈就是最自然的错误路径。比如在factor()里报了“因子非法”,你立刻知道当前Token既不是标识符、不是数字、也不是左括号。
3.2 表达式、项与因子的三级子程序代码
语法分析器中最经典的结构就是表达式、项和因子三级嵌套。这一层先只做语法校验,不生成任何代码,把Token流按文法吃掉。
// expression → [+|-] term {(+|-) term} void expression(void) { if (sym == SYM_PLUS || sym == SYM_MINUS) { advance(); // 处理一元正负号 } term(); while (sym == SYM_PLUS || sym == SYM_MINUS) { advance(); // 吃掉 + 或 - term(); } } // term → factor {(*|/) factor} void term(void) { factor(); while (sym == SYM_TIMES || sym == SYM_SLASH) { advance(); // 吃掉 * 或 / factor(); } } // factor → ident | number | ( expression ) void factor(void) { if (sym == SYM_IDENT) { advance(); // 标识符因子 } else if (sym == SYM_NUMBER) { advance(); // 数字因子 } else if (sym == SYM_LPAREN) { advance(); // 左括号,进入子表达式 expression(); if (sym == SYM_RPAREN) { advance(); // 必须闭合右括号 } else { error("缺少右括号 )"); } } else { error("因子只能以标识符、数字或括号开头"); } }这段代码背后的推导逻辑是:expression由一到多个term组成,term由一到多个factor组成,factor又是表达式的最小单元。如果你反推一下,就会发现2 + 3 * 4这颗语法树里,3 * 4会先被term吃掉,而2 +留在expression层,这就天然实现了运算符优先级——乘除先于加减。
参数说明:advance()每次调用读取下一个Token,更新全局变量sym。这里最容易犯的错是在某个分支里忘记调用advance(),导致sym一直没变,while循环永远出不去。我的习惯是每个advance()后面紧跟一行注释,标注“吃掉的是哪个Token”。
3.3 语句序列与IF/WHILE的翻译框架
PL0的语句类型包括赋值、过程调用、复合语句、条件语句和循环语句。statement()函数要用一个大switch分支把它们区分开。
void statement(void) { switch (sym) { case SYM_IDENT: advance(); // 左值标识符 if (sym == SYM_BECOMES) { advance(); // 吃掉 := expression(); } else { error("赋值语句缺少 :="); } break; case SYM_CALL: advance(); // 吃掉 call if (sym == SYM_IDENT) { advance(); // 过程名 } else { error("call 后应为过程名"); } break; case SYM_BEGIN: advance(); // 吃掉 begin statement(); while (sym == SYM_SEMICOLON) { advance(); // 吃掉 ; statement(); } if (sym == SYM_END) { advance(); // 吃掉 end } else { error("缺少 end"); } break; case SYM_IF: advance(); // 吃掉 if condition(); // 条件部分 if (sym == SYM_THEN) { advance(); // 吃掉 then statement(); } else { error("缺少 then"); } break; case SYM_WHILE: advance(); // 吃掉 while condition(); if (sym == SYM_DO) { advance(); // 吃掉 do statement(); } else { error("缺少 do"); } break; default: error("语句必须以标识符、call、begin、if 或 while 开头"); } }这里有一个很隐蔽的坑:begin ... end复合语句里,statement()是递归调用自身,而且shift掉的;也是循环处理的。如果用户代码里写了空语句,比如begin ; end,那么第一个;会让statement()立刻走到default分支报错。PL0本身不支持空语句,但很多学生会觉得“多写个分号也没事”,实际上语法分析器会直接翻车。
3.4 常见翻车点:符号预读与advance机制
递归下降分析器采用“预读一个Token”策略:每个分析函数在进入时,全局变量sym已经指向当前待处理的Token。函数内部每消费掉一个Token,就必须调用一次advance(),把sym更新为下一个。
这个机制最大的问题出现在错误恢复。比如用户写了if x > 1 then := y,:=出现在then后面是合法的语法位置吗?不合法。但如果你在statement()的SYM_BECOMES分支里直接error并退出,后续所有Token都会连环报错,一次实验下来满屏都是假的错误信息。
常见做法是引入“恐慌模式”错误恢复:遇到不可恢复的错误时,丢弃当前Token,把sym推进到下一个语句可能开始的位置(比如;、end、begin等同步Token),然后继续分析。这样一次编译能报告多个真实的错误,而不是卡死在第一个错误上。这个策略在实验报告里值得专门写一段,老师会认为是你在错误处理上有独立思考。
4. 语义分析与中间代码生成:一边语法翻译一边输出P-code
4.1 PL0的P-code指令集与虚拟机模型
PL0编译器里有一台“栈式虚拟机”,它执行的是称为P-code的中间代码。指令种类非常有限,全表如下:
| 指令 | 操作数 | 含义 |
|---|---|---|
LIT 0, a | 常量值a | 把常量a压入栈顶 |
OPR 0, a | 运算码a | 对栈顶数据做算术/比较运算 |
LOD l, a | 层级l,偏移a | 把变量值压入栈顶 |
STO l, a | 层级l,偏移a | 把栈顶值存入变量 |
CAL l, a | 层级l,地址a | 调用过程 |
INT 0, a | 栈空间大小a | 为过程分配局部变量空间 |
JMP 0, a | 地址a | 无条件跳转 |
JPC 0, a | 地址a | 栈顶为假时跳转 |
RED l, a | 层级l,偏移a | 从输入读一个数存入变量 |
WRT 0, 0 | 无 | 从栈顶弹出一个数并输出 |
这一步的战略选择是:不为PL0直接生成x86汇编,而是先翻译到P-code。这样语法分析、语义分析、中间代码生成是三层完全独立的模块,调试时先在虚拟机上看结果,再考虑目标代码优化。很多学校的实验只能做到P-code这一层,因为栈式指令写解释器非常简单。
4.2 语义栈、符号表与层级寻址的配合
生成P-code时,最难的部分是变量寻址。PL0支持过程嵌套,变量不是拿名字直接定位的,而是通过(level, offset)二元组在运行栈上定位。LOD 1, 3的含义是:从当前过程向上数1层,在那一层栈帧的偏移3处取值。
符号表里的每条记录至少要保存四个字段:
| 字段 | 含义 |
|---|---|
name | 变量名 |
kind | const / var / procedure |
level | 所在层数,最外层为0 |
offset | 在栈帧中的偏移量 |
每进入一个过程声明,level加1;每声明一个局部变量,offset按变量大小递增。offset是从栈帧基址开始算的,偏移0一般留给返回地址或动态链,具体布局要看你的栈帧设计。
这个过程里最容易出错的是“层级差”计算。生成LOD l, a时,l不是变量的绝对层级,而是“当前层级到变量层级的差值”。比如当前过程在层级2,要访问层级0的全局变量,l应该是2而不是0。很多初学者把l写成变量的绝对层级,编译时没报错,但运行时取到的完全是另一个栈位置的数据。
4.3 语法制导翻译的关键代码片段
中间代码生成不是另写一个遍历语法树的模块,而是在语法分析的同时“边归约边生成”。以factor()为例:
void factor(void) { if (sym == SYM_IDENT) { // 查符号表,找到变量的层级和偏移 SymbolNode *node = lookup(ident); if (node == NULL) { error("变量未声明:" + ident); advance(); return; } gen(LOD, currentLevel - node->level, node->offset); advance(); } else if (sym == SYM_NUMBER) { gen(LIT, 0, num); advance(); } else if (sym == SYM_LPAREN) { advance(); expression(); if (sym == SYM_RPAREN) advance(); else error("缺少右括号"); } }gen()函数是全局代码生成入口,每调一次就往P-code数组里追加一条指令,同时记录当前代码长度供跳转回填。这里的currentLevel是全局变量,进入新的过程声明时递增,过程结束时递减。
逻辑说明:factor遇到数字时生成LIT,遇到变量时生成LOD,这就是中间代码生成的最小雏形。而expression()里的加减号,会翻译成后续的OPR 0, 2(加法)或OPR 0, 3(减法),在运行时把栈顶两个数弹出,计算结果再压回栈顶。
4.4 错误处理:语义错误和运行时错误分开看
语义错误是编译阶段能发现的,典型的有:变量未声明、常量赋值、过程名重复声明。这类错误不能等到目标代码执行时才暴露,必须在生成中间代码时检查并报告。
运行时错误则不同,比如除以零、栈空间溢出、读入数据格式非法。这些错误发生在P-code解释器执行期间,不会在编译时暴露。
常见做法是用两个独立的错误计数器:编译期错误计数和运行期错误计数。编译期错误累计到一定数量(比如超过50个)就停止生成中间代码,避免垃圾代码污染代码区。运行期错误则由解释器直接报出,并且要带上当前指令的编号和指令内容,方便你定位是哪一次计算出的问题。
5. 目标代码生成与编译器优化:PL0实验最容易翻车的5个坑
5.1 P-code解释器的取指循环
P-code生成完毕后,最后一步是让虚拟机执行它。解释器的核心是一个取指-解码-执行的循环:
typedef struct { int op; // 指令码:LIT / OPR / LOD / STO / CAL / INT / JMP / JPC / RED / WRT int l; // 层级或保留0 int a; // 偏移、常量值、地址或运算码 } Instruction; static Instruction code[MAX_CODE_SIZE]; // P-code指令区 static int stack[MAX_STACK_SIZE]; // 运行栈 void interpret(void) { int pc = 0; // 指令指针 int top = -1; // 栈顶指针 while (pc < codeLen) { Instruction *inst = &code[pc]; switch (inst->op) { case LIT: stack[++top] = inst->a; pc++; break; case LOD: stack[++top] = stack[base(inst->l) + inst->a]; pc++; break; case STO: stack[base(inst->l) + inst->a] = stack[top--]; pc++; break; case INT: top += inst->a; pc++; break; case JMP: pc = inst->a; break; case JPC: if (stack[top--] == 0) pc = inst->a; else pc++; break; case OPR: // 运算码 2:加法 3:减法 4:乘法 5:除法 0:返回 switch (inst->a) { case 2: stack[top-1] += stack[top]; top--; break; case 3: stack[top-1] -= stack[top]; top--; break; case 4: stack[top-1] *= stack[top]; top--; break; case 5: stack[top-1] /= stack[top]; top--; break; case 0: top -= inst->l; pc = stack[top]; break; } pc++; break; case WRT: printf("%d\n", stack[top--]); pc++; break; default: pc++; break; } } }base(l)函数是栈式虚拟机里最核心的寻址逻辑:它从当前栈帧基址出发,沿静态链向上跳l层,返回目标层的栈帧基址。实现时用一个数组保存每层过程的栈帧基址,或者用指针链串起来。INT 0, a每次为过程的局部变量分配栈空间,本质上是让top直接加上一个常数。
OPR的运算码是实验里最容易记混的地方:不是直接用指令码区分运算,而是通过inst->a这个子字段。很多学生把OPR 0, 5理解成“比较相等”,实际它对应的是除法,比较相等的运算码通常是8。这个表一定要打印出来贴在屏幕边,否则调试到半夜会疯。
5.2 目标代码优化:常数折叠与跳转修正
PL0实验一般不会要求你做什么复杂的寄存器分配优化,但有两个优化是老师最喜欢在答辩环节追问的:常数折叠和跳转修正。
常数折叠的意思是:如果表达式两侧都是数字常量,比如3 + 5,那就没必要生成“先LIT 3、再LIT 5、最后OPR加法”三条指令,直接生成一条LIT 0, 8。实现方式是在expression()里加一个语义值函数:
int evalConst(int left, int op, int right) { switch (op) { case OPR_ADD: return left + right; case OPR_SUB: return left - right; case OPR_MUL: return left * right; case OPR_DIV: return left / right; default: return -1; } }跳转修正是另一个必问的点。PL0的if和while都需要跳转指令,但跳转目标在语法分析时还不知道,所以先gen(JMP, 0, 0)占位,等条件语句完整翻译完,再把真实的地址回填到占位指令的a字段里:
int jmpPos = codeLen; // 记录占位指令的位置 gen(JMP, 0, 0); // 占位,地址先用0 // ... 生成语句体的中间代码 ... code[jmpPos].a = codeLen; // 回填真实跳转地址这个过程就是编译原理教材里讲的 backpatch,实验里能手工实现一次,后面看任何资料里的回填代码都能秒懂。
5.3 五个必踩的坑:现象、原因与解决
坑一:编译器未包含main类型
现象:代码编译后发现目标文件里没有入口点,或者运行报错“undefined reference to main”。
原因:很多人把词法分析、语法分析、中间代码生成、解释器写成了多个源文件,但最后链接时没有把包含main()函数的主控文件加进编译列表。这个词法分析器本身没问题,问题出在工程配置。
解决:先确认工程里只有一个main()函数,再确认所有源文件都加入了链接,最后在统一起跑点把main()写成调用四个阶段的顺序逻辑:读文件、词法分析、语法分析、生成P-code、解释执行。
坑二:LOD取错层级导致变量值错乱
现象:嵌套过程里访问外层变量时,运行时栈顶的值莫名其妙,外层循环计数器的值被算成了负数或者巨大的垃圾值。
原因:LOD l, a里的l写成了变量的绝对层级,而不是当前层级到目标层级的差值。过程嵌套两级以上时,这种错误必然出现。
解决:生成指令时打印一条调试日志,输出LOD(%d, %d),对照手算的(当前层级 - 变量声明层级)核对,确认无误后再关闭日志。
坑三:OPR运算码记混,除法和比较结果全错
现象:x / 2的结果完全不对,但加减乘都正常;if x < 5的判断也混乱。
原因:OPR的子操作码背错。PL0里2是加、3是减、4是乘、5是除,后面才是比较。如果你把5当成了“等于”,一路错到底。
解决:写一个带注释的常量表放文件头部,不要背,按表查。
坑四:EOF处理不当导致词法扫描死循环
现象:程序最后几行总是重复报错“无法识别的字符”,或者编译器在文件末尾卡住不退出。
原因:getChar()读到EOF后没有把ch置为哨兵值,getSym()的isalnum(ch)一直对同一个EOF字符做判断。
解决:在getChar()里判断读不到时执行ch = 0,并在getSym()开头检测ch == 0直接返回SYM_NUL。
坑五:语法分析函数里漏调advance()导致死循环
现象:编译某个合法输入时,程序卡住不输出任何结果,CPU占用率100%。
原因:某个分支里消费了当前Token但没有调用advance()读下一个Token,while循环的条件永远为真。
解决:在每个case分支末尾检查是否执行了advance()。最有效的做法是短跑测试:构造一个只含单个语句的输入,单步调试走一遍,确认每条路径都推进了Token。
6. 实验报告模板与学习笔记整理:把代码仓库变成面试加分项
6.1 实验报告的关键:结构清晰比长度重要
实验报告的常见结构是:实验背景、文法定义、总体设计、分模块实现、测试用例、总结。但老师真正看的是三个地方:一是你有没有说清楚每个模块的输入输出,二是错误处理策略是否有独立思考,三是测试用例是否覆盖了边界情况。
PL0实验报告的测试用例至少要有:正常四则运算、外层变量与内层变量的遮蔽、if-else的嵌套(如果扩展过)、while循环的边界条件、除零错误、未声明变量、非法Token注入。每个用例配一张截图或一段输入输出对照表,比长篇大论管用得多。
6.2 学习笔记怎么整理才不会变成流水账
我的习惯是每完成一个阶段,写一份“调试日志转正为知识卡片”的笔记。比如词法分析阶段踩了EOF死循环的坑,就把“EOF哨兵处理”写成一条带示例的知识点,而不是只留下一个抱怨。这比任何现成的学习笔记都更适合你,因为它是从你的真实错误里长出来的。
如果你用Java写实验,思路也完全一样:Java版的词法分析器改成Scanner或手写nextToken(),文法部分不变,P-code解释器照样用数组模拟栈。语言只是外衣,编译原理的核心在阶段划分和数据结构设计。
到最后你会发现,这份仓库里真正值钱的不是代码能跑,而是五层模块的边界在哪里、哪一层出的错该去哪个模块查。把编译器的每一层都亲手写过一遍,面对“怎么处理变量遮蔽”“怎么做跳转回填”这类面试题时,你不需要背,直接讲当年的坑就够了。希望这份笔记能帮你的PL0实验少走几段弯路。
本文还有配套的精品资源,点击获取