简介:面向重庆大学编译原理课程及国内同类课程学习者,这份资料包以PL0语言编译器实现为主线,完整覆盖词法分析、语法分析、语义分析、中间代码生成与目标代码优化等编译全过程,既能服务实验环节,也适合自学与复习备考。资源共44个文件、压缩包仅1.83MB,含C/C++及头文件源码、EXE可执行程序、docx实验报告模板、md与txt说明文档,以及CMakeLists、cbp等工程配置文件,导入开发环境即可查看与调试。已有63人学习下载。资料包内提供多份实验报告模板与学习笔记,可引导规范记录实验数据、总结编译原理关键知识点;PL0编译器各阶段代码结构清晰,便于对照理解递归下降分析、符号表管理等核心算法实现。对正在修读编译原理或准备课程设计的学生而言,这是一套内容紧凑的完整参考样例,既能加快实验进度,也能深入掌握编译器从源码到目标代码的整体流程。
1. 编译原理实验的全套PL0实现:从词法分析到目标代码优化的一次性跑通
编译原理这门课,给人的感觉是很典型的“理论能看懂,实验抓瞎”:词法、语法、语义、中间代码生成、目标代码优化五关串成一条流水线,前一步写错了后面全崩,而大部分实验报告又只会让老师在最后一页给一个“通过”了事。这套资源的核心是一条完整的 PL0 语言编译器实现链路,从词法分析器、语法分析器、语义分析到中间代码生成、目标代码优化,源码、实验报告模板、学习笔记都在一个仓库里,属于那种“拿过来就能对着改”的工程型资源,尤其适合正在赶编译原理实验、或者准备复试机试的同学。下面按我拆解这个仓库的顺序,逐个模块说明怎么跑通、接口怎么定、以及哪些位置最容易翻车。
2. PL0 语言编译器全貌:为什么选 PL0 做实验,以及仓库怎么跑通
2.1 PL0 为什么是编译实验的“标准答案”
PL0 是教材《编译原理》里经典的教学语言,它保留了高级语言最关键的结构:常量、变量、过程、表达式、赋值、条件跳转、循环,又砍掉了数组、指针、函数返回值这类容易让实验复杂度失控的语法。一套手写编译器覆盖的知识点非常完整,这也是国内高校编译原理实验普遍选它的原因。它的词法规则属于固定集合,语法上每个产生式的 FIRST 集合清晰,递归下降子程序数量有限,适合一个人在一学期内从头到尾写完。
如果你之前做过别的语言的实验,会发现 PL0 的实际定位是“麻雀虽小,五脏俱全”:常量声明、变量声明、过程声明互相嵌套,表达式递归求值,语句序列用 BEGIN...END 包裹,所有符号都遵循分程序结构的作用域规则。整体代码量不大,但编译原理教材里那套“词法→语法→语义→中间代码→目标代码”的抽象链条一点没少。
此类实验最常见的错误是做成了“玩具”:只实现了表达式求值就交差,过程调用、嵌套块、中间代码输出一概没有。这个仓库的做法更接近一个完整编译器该有的样子,适合作为课程设计或综合实验的基础工程。
2.2 仓库目录规划与首个测试程序
把仓库解压之后,目录组织是大致这样的,我用常规做法把它拆成五个模块加一个报告区,方便按阶段提交实验:
compiler-lab/ ├── 01-lexer/ # 词法分析器:token 定义、状态转换、保留字表 ├── 02-parser/ # 语法分析器:递归下降子程序 ├── 03-semantic/ # 语义分析:符号表、类型检查 ├── 04-inter-code/ # 中间代码生成:四元式输出 ├── 05-optimizer/ # 目标代码生成与优化 ├── pl0-compiler/ # 完整 PL0 编译器主程序 ├── reports/ # 实验报告模板(每个阶段对应一份) └── notes/ # 学习笔记与 PL0 语法总结第一次跑通建议直接使用完整编译器目录,我一般会先准备一个最小的测试程序,能覆盖常量、变量、赋值和表达式就够了:
const max = 100; var a, b, c; begin a := 0; b := 1; c := a + b; end.用 gcc 编译并执行,典型流程是:
gcc -o pl0 pl0-compiler/*.c ./pl0 test.pas如果词法表和语法表都能正常打开,屏幕上会输出 token 流和四元式序列。看到类似下面这样的四元式列表,就说明整条分析链路已经打通,后面每个模块的修改都能在这个基础上做回归验证:
:= 0 _ a := 1 _ b + a b t1 := t1 _ c这个快照非常重要。整个实验周期里,改动任何一个模块后第一件事就是拿这份输出做 diff,而不是重新读一遍代码。编译实验的大部分返工,都源于压根没有一个可对比的基准输出。
3. 词法分析器到语法分析器:token 码表设计与递归下降实现
3.1 词法分析的输入输出约定:token 码表怎么定
词法分析的输入是一串字符,输出是一个 token 序列,这一步的接口设计决定了后面所有模块的写法。PL0 的 token 类型可以归纳成四类:保留字、标识符、数字、运算符与分隔符。保留字有 BEGIN、END、IF、THEN、WHILE、DO、CALL、CONST、VAR、PROCEDURE、ODD 等;运算符和分隔符包括+ - * / = < > <= >= := ( ) , ; .。
常见的实现方式是先用枚举把 token 类型定死:
typedef enum { TOK_IDENT, TOK_NUMBER, TOK_BEGIN, TOK_END, TOK_IF, TOK_THEN, TOK_WHILE, TOK_DO, TOK_CALL, TOK_CONST, TOK_VAR, TOK_PROCEDURE, TOK_ODD, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_EQ, TOK_NEQ, TOK_LT, TOK_LE, TOK_GT, TOK_GE, TOK_ASSIGN, TOK_LPAREN, TOK_RPAREN, TOK_COMMA, TOK_SEMICOLON, TOK_DOT, TOK_EOF } TokenType;这个枚举的顺序有一点讲究:把保留字集中放在中间位置,后面查保留字表时就可以用一个偏移量映射到枚举值,不用每次写一堆 if-else。实际实现中我常用二维数组存放保留字字符串,查表命中时直接返回TOK_BEGIN + index,这个设计能让词法分析器的分支复杂度下降不少。
3.2 标识符与保留字的边界:查表时机不能反
词法分析器最容易翻车的位置,是处理标识符和保留字时的“查表时机”。PL0 的保留字同时也是合法的标识符字符序列,如果不先查保留字表就直接当标识符登记到符号表,后面语法分析全乱。
我一般在 getToken 里维护一个全局字符变量ch,配合 getchar 边读边判断。核心代码大致是这个结构:
int getToken(void) { while (isspace(ch)) ch = getchar(); if (isalpha(ch)) { int i = 0; while (isalnum(ch)) { tokenBuf[i++] = ch; ch = getchar(); } tokenBuf[i] = '\0'; // 先查保留字表,命中就返回保留字 token;否则才是标识符 int idx = lookupReserved(tokenBuf); if (idx >= 0) { return TOK_BEGIN + idx; } strcpy(tokenName, tokenBuf); return TOK_IDENT; } if (isdigit(ch)) { int val = 0; while (isdigit(ch)) { val = val * 10 + (ch - '0'); ch = getchar(); } tokenValue = val; return TOK_NUMBER; } // 分隔符部分,重点处理 := 和 <= >= <> switch (ch) { case ':': ch = getchar(); if (ch == '=') { ch = getchar(); return TOK_ASSIGN; } // 单冒号在 PL0 里不合法,需要报错处理 return TOK_UNKNOWN; case '<': ch = getchar(); if (ch == '=') { ch = getchar(); return TOK_LE; } if (ch == '>') { ch = getchar(); return TOK_NEQ; } return TOK_LT; // 其余单字符 token 直接返回 default: ch = getchar(); return mapSingleChar(ch); } }需要注意两个参数层面的套路:一是lookupReserved必须在把字符拼成 token 之后、在修改全局tokenName之前调用,因为它依赖的是一个完整的、以\0结尾的字符串;二是多字符操作符的判定一定要用“先读下一个字符、不匹配再回退”的方式。这里我用的是直接更新全局ch,因为 PL0 没有需要回退到上一个字符的冲突场景,但如果你改做别的语言,最好维护一个 pushback 缓冲。
词法分析每个 token 返回后,语法分析器拿着 token 类型做分支判断,而tokenName和tokenValue则留给语义分析阶段查符号表时使用。这个“返回类型 + 全局属性”的组合,是整个递归下降实现中最常见的传参方式。
3.3 递归下降子程序与文法的一一对应
语法分析器按 PL0 文法写递归下降子程序,每个非终结符对应一个函数。比如 PL0 的程序结构是这样的:
program ::= block '.'. block ::= [constDecl] [varDecl] {procedureDecl} statement. statement ::= [assign | call | if | while | begin | empty].对应的递归下降函数可以写成:
void parseBlock(void) { if (token == TOK_CONST) { parseConstDecl(); // parseConstDecl 会消费到分号结尾 } if (token == TOK_VAR) { parseVarDecl(); // parseVarDecl 同样消费到分号 } while (token == TOK_PROCEDURE) { parseProcedureDecl(); } parseStatement(); }写这种函数时有个关键习惯:每个子程序只消费属于自己的部分,剩下的 token 交给调用者判断。比如parseConstDecl遇到CONST后处理一组“标识符 = 数字”的序列,遇到分号就返回,不应该多读 statement 的第一个 token。这个边界如果不守住,两个函数之间就会互相吞 token,调试起来非常难受。
从我拆这个仓库的经验看,递归下降子程序的错误多半不在语法正确性,而在“返回时机”。最好的自检方法是把每个子程序的入口 token 集合列出来。比如parseStatement能接受的 token 有TOK_IDENT(赋值)、TOK_BEGIN(语句块)、TOK_IF、TOK_WHILE、TOK_CALL,那么函数入口就写:
void parseStatement(void) { switch (token) { case TOK_IDENT: parseAssignStatement(); break; case TOK_BEGIN: parseBeginStatement(); break; case TOK_IF: parseIfStatement(); break; case TOK_WHILE: parseWhileStatement(); break; case TOK_CALL: parseCallStatement(); break; default: // 空语句属于合法分支,直接返回 break; } }这套对应关系与文法的 FIRST 集合一一对应,如果文法改了,函数入口也同步改,这就是编译原理实验训练的核心能力,而不是死记硬背某个实现。
4. 语义分析与中间代码生成:符号表、类型检查和四元式
4.1 符号表要登记哪些字段
语法分析只回答“句子结构对不对”,语义分析回答“这个标识符是否存在、用对没有”。PL0 的符号表要做到按作用域嵌套,每个符号至少登记这些字段:
typedef struct Symbol { char name[32]; // 标识符名 int kind; // 0-常量 1-变量 2-过程 int type; // 0-整型 1-布尔 int value; // 常量初值 int level; // 所在嵌套层数 int addr; // 相对地址,变量/过程入口的偏移 struct Symbol *next; } Symbol;level 字段是 PL0 符号表的灵魂。每当进入一个 BEGIN 块或过程体,level 加 1,退出时恢复。变量寻址、过程调用时的活动记录、静态作用域规则的查表顺序,全部依赖于这个层级关系。如果一个实验里符号表没有 level 字段,后面做过程调用几乎必然出错。
符号表的插入与查找时机,我习惯放在语法分析的声明部分:语法分析器解析到VAR a, b, c时,逐个调用insertSymbol,解析到表达式引用标识符时,调用lookupSymbol。这样语义分析就嵌在语法分析的过程里,是典型的“语法制导翻译”,也顺便解决了中间代码生成的时机问题。
4.2 嵌套作用域与 level 字段:PL0 块结构的关键
PL0 的过程声明可以嵌套,过程内部又可以引用外层符号。查找符号的规则是:从当前 level 开始逐层向外找,同一 level 内靠 name 匹配。我在实现 lookup 时是从符号表链表头向后扫,但过滤条件是symbol->level <= currentLevel,保证内层不会误用外层的局部变量。
还有一个常见的边界问题:过程调用的参数传递。如果实验只要求整型变量和常量,参数传递可以简化成“按值传参”,但过程体内引用外层变量时,必须维持 level 链。很多同学在这一步改崩,就是因为符号表只有一层,过程体里一查外层的变量就查不到。仓库里的做法是把符号表做成一个栈结构,enter block 时压入新 level,exit block 时弹掉本 level 的所有符号,这个思路和词法分析器里维护缓冲区一样,属于编译实验的标准套路。
4.3 把 a := b + c * 2 翻译成四元式
中间代码这一层我用的是四元式。每条指令固定四个部分:op、arg1、arg2、result。比如表达式b + c * 2的翻译结果是这样:
Quad quads[100]; int quadCount = 0; void emitQuad(char *op, char *arg1, char *arg2, char *result) { strcpy(quads[quadCount].op, op); strcpy(quads[quadCount].arg1, arg1); strcpy(quads[quadCount].arg2, arg2); strcpy(quads[quadCount].result, result); quadCount++; } // 语义动作示例:表达式节点生成了两个临时变量后,合并为一条 ADD // 变量名是符号名,临时变量形如 t1 t2... // const 传播:若操作数都是常量,直接计算出一个新常量对c * 2会把常量 2 存进临时常量池,生成:
* c 2 t1 + b t1 t2 := t2 _ a中间代码生成的关键是临时变量的编号统一管理。我一般维护一个tempCount全局变量,每生成一个新的临时变量就自增,这样四元式可读性很好,diff 验证也方便。注意四元式的 result 是地址而不是值,所以a := b + c * 2中a出现的位置是 result 字段,而b、c是 arg1、arg2,这个约定要在整套代码里保持一致,否则目标代码生成阶段会变量名对不上。
四元式的顺序与表达式求值顺序一致,这也是为什么用递归下降自带的状态来生成中间代码是最省事的做法:语法分析器每解析完一个算术表达式,就当场生成对应的四元式,不需要额外构造 AST。
5. 避坑与常见问题:五个能让实验翻车的隐蔽细节
5.1 词法里的超前读入::=后面的=哪去了
现象:编译到赋值语句时,语法分析在期待标识符的位置收到一个=或 EOF,程序直接卡死。原因:词法分析器处理:时,用 getchar 读下一个字符去确认:=,确认之后忘了更新全局ch,导致=永久丢失。解决:确认多字符 token 后,ch必须指向已读字符之后的位置;如果不小心先存了 next char,那在函数返回前显式ch = getchar()再退出。
5.2 保留字判定:先查表还是先登记符号表
现象:变量命名为begin时,语法分析器把它当保留字处理,报变量名缺失。原因:标识符收集完成后,先查保留字表再决定返回类型,这两步写反会导致所有与保留字同名的标识符全部错乱。解决:查表命中直接返回保留字 token,未命中才把字符串复制到tokenName,这个顺序是硬约定,测试文件里专门放几个“保留字大小写”和“保留字拼写”的用例一起验证。PL0 本身一般区分大小写,建议统一用大写保留字,避免引入额外的归一化逻辑。
5.3 语句块边界的“消费”时机
现象:BEGIN a := 1; END.后面报语法错误,提示在END处期望标识符。原因:parseStatement处理完整条赋值语句后,没有让外层parseBeginStatement去消费分号,而是自己把分号吞了;于是循环判断下一条语句时,读到END却进入赋值分支。解决:分号属于语句序列的分隔符,不属于任意一条语句。常见做法是parseBeginStatement在循环里先parseStatement再判断token == TOK_SEMICOLON,是则继续循环读下一条;遇到END则退出循环返回,不要在整个块外面再等一个多余的END。
5.4 中间代码没有基准输出,优化无从谈起
现象:目标代码生成阶段改了一处寄存器映射,运行结果整体错乱,却看不出是哪一步引入的错误。原因:优化和代码生成之间没有一个可回滚的基准快照,四元式输出到了这一步才临时打印,没法定位“错误是在中间代码层还是目标代码层”。解决:从第一个测试程序跑通开始,就把 token 列表和四元式序列分别保存为token.snapshot和quad.snapshot。后续改动分析器、语义检查、优化器,都重定向输出做 diff,先确认中间层没被改坏,再往上排查目标代码层。我在实操里通常是直接写一个 diff.sh 脚本一键做三路对比,省掉大量肉眼比对时间。
5.5 实验报告模板不是提交物,而是写作框架
现象:报告里贴了全部源码,流程图和设计说明完全缺失,被实验室老师打回重写。原因:把模板当成了“要填的空”,而实际上它要求的是每个阶段的设计思路和验证过程。解决:按模板的章节结构,把每个实验阶段写清楚“输入是什么、输出是什么、关键函数怎么划分、测试用例跑出什么结果”。重点放设计说明和四元式输出截图,源码只要体现关键函数即可。这属于报告写作的坑,但实验课的最终成绩恰恰是由这部分决定的。
6. 进阶:PL0 编译器的增量优化与 Java 移植的验证方法
6.1 先做常量折叠,再动寄存器分配
一套做课程设计级别的实验,目标代码优化不用做太多,增量式的三步就够用:常量折叠、复写传播、死代码删除。先从常量折叠开始,因为它改动面最小。例如表达式c * 2在语义分析阶段就应算成8而不是等到目标代码阶段再处理,实现只涉及对四元式生成器的少数分支做判断。
// 常量折叠:检测两个操作数都是常量,直接算结果 if (isConst(arg1) && isConst(arg2)) { int result = constValue(arg1) * constValue(arg2); emitQuad("CONST", intToStr(result), "", temp); return; }6.2 Java 移植:类划分与中间层 diff 测试
许多学校会把编译原理实验的上机环境换成 Java,用来验收代码结构和可读性。PL0 的递归下降结构天生适合 Java 类划分:Tokenizer、Parser、SymbolTable、QuadGenerator各自独立,每个类对应一个单一职责。移植的关键不是语法翻译,而是把 token 枚举和四元式结构体转换为 Java 的枚举类型与对象。
public enum TokenType { IDENT, NUMBER, BEGIN, END, IF, THEN, WHILE, DO, CALL, CONST, VAR, PROCEDURE, PLUS, MINUS, STAR, SLASH, ASSIGN, EQ, NEQ, LT, LE, GT, GE, SEMICOLON, DOT, LPAREN, RPAREN }移植之后,四元式统一序列化成文本格式,直接和 C 版本跑同一批测试用例做 diff。Java 端的 IDE 调试环境更适合逐步断点跟踪:=和BEGIN...END的解析过程,对新手查错也更友好。整个过程里最珍贵的资产就是前面说的 snapshot 基准,每次改完一个模块就全量回归一遍,不做回归的优化不建议碰。从那以后我每次拿到一个编译实验框架,第一件事就是先跑通基线测试程序,把 token 列表和四元式快照留档,改动任何模块都强制做一次对比验证。这个习惯救了我后面的过程调用与作用域实现,也把大量排查时间压到了最低,希望帮到你。
本文还有配套的精品资源,点击获取