简介:面向编译原理课程实验的 PL/0 编译器改造资料,适合正在完成词法分析、语法分析与语义处理扩充任务的高校学生参考,也可用于课程设计前的预习与验收自查,尤其适合需要提交实验报告与源码的课程考核场景。内容围绕为教学型 PL/0 增加 ELSE、FOR、TO、DOWNTO、RETURN 等保留字,新增 +=、-=、++、-- 运算符,将不等号 # 改为 <>,并为条件语句补充 ELSE 子句,帮助读者定位编译程序各阶段的修改位置与实现思路。压缩包共 24 个文件,约 644KB,包含 C++ 源程序与头文件、工程配置、可执行文件、调试信息、文本说明及实验报告文档,既保留可运行项目结构,也提供实验过程记录。已有 791 人学习,可作为课程实验对照、代码修改与报告撰写的参考资料,便于按实验要求逐项核查保留字、运算符和 ELSE 子句的完成情况。
1. 从一条 ELSE 报错说起:这个 PL/0 实验包到底能干什么
做编译原理实验时,PL/0 的 IF 条件 THEN 语句已经跑通,实验要求加一个 ELSE 分支,结果一编译就报“标识符未定义”。反直觉的地方在于,问题经常不在语法分析,而是词法分析的保留字表里少了一个 else,ELSE 被当成普通标识符送进了语法分析器。这个 PL/0 编译器实验包来自某高校编译原理课程,覆盖词法分析、递归下降语法分析、符号表、P-code 代码生成和虚拟机执行,重点把保留字识别和 ELSE 语句的跳转回填链路打通。它适合正在做编译原理实验、想把 PL/0 从“能跑”改到“能扩展”的开发者,也适合想看清一个小型编译器完整数据流的自学者。源码按模块拆开,测试用例带 IF-ELSE 嵌套,拿下来就能对照实验报告逐段改。
2. 项目定位与技术选型:为什么用 C 写一个带 ELSE 的 PL/0 方言
2.1 原始 PL/0 的五件套:词法、语法、符号表、代码生成、虚拟机
PL/0 实验的经典结构是五件套:词法分析把源程序切成 token,语法分析用递归下降识别语句和表达式,符号表管理常量、变量和过程,代码生成把语法结构翻译成一维 P-code 数组,虚拟机负责解释执行这些指令。这个实验包没有先建完整 AST 再遍历生成,而是边解析边生成指令。这么做在实验规模下很实用,代码量小,调试时能直接在statement()里看到cx增长;代价是 ELSE 这种需要回填跳转地址的语句,必须在解析过程中把指令下标记下来,后面再补。
技术栈是 C99 + GCC + Make,没有引入第三方解析库。原因不复杂:PL/0 的符号表和代码数组用定长数组就能装下,指针操作也直观,课程实验环境里gcc和make基本是现成的。用 C++ 当然也能写,但实验报告里通常要求把词法、语法、符号表、代码生成拆成独立文件,C 的函数边界更硬,反而少一些“顺手写进类里”的耦合。
| 文件 | 职责 | 关键函数 |
|---|---|---|
lexer.c | 跳过空白和注释,识别标识符、数字、运算符和保留字 | next_token()、scan_identifier() |
parser.c | 递归下降解析程序、过程、语句、表达式 | parse_statement()、parse_if() |
symtab.c | 常量、变量、过程的声明与查找 | enter()、lookup() |
codegen.c | 生成 P-code,维护code[]和cx | gen_jpc()、patch() |
vm.c | 解释执行 P-code,维护数据栈和指令指针 | interpret() |
main.c | 读源文件、调用编译流程、处理-d调试开关 | main() |
这个拆分方式对二次开发很友好。比如要加 ELSE,改动集中在lexer.c的保留字表、parser.c的 IF 分支和codegen.c的跳转生成,虚拟机不需要动,因为 JPC 和 JMP 原本就存在。对做实验的人来说,最怕的是改一处、崩三处;按模块拆开之后,至少能先用-d把 token 流打出来,确认词法层没把 ELSE 认错,再去追语法和代码生成。
原始 PL/0 的条件语句只有IF 条件 THEN 语句,没有 ELSE。它的代码生成相对简单:条件编译完发一条 JPC 占位,THEN 分支编译完把 JPC 回填到当前cx,条件为假就直接跳过 THEN。这个模型跑通之后,加 ELSE 的直觉做法是“在 THEN 后面再补一段”,但真正的难点是两条跳转的先后顺序:条件为假要跳到 ELSE 起点,THEN 执行完要跳过 ELSE 到结束。顺序反了,就会出现“ELSE 分支永远不执行”或者“THEN 分支执行完又掉进 ELSE”的翻车现场。
2.2 ELSE 扩展带来的三个改动点:保留字、文法、跳转
把 ELSE 加进 PL/0,表面上是文法多了一个可选分支,实际要动三个地方。第一是词法层的保留字表:ELSE 必须被识别成独立 token,不能留给语法分析去比较字符串。第二是文法产生式:IF 条件 THEN 语句 [ELSE 语句],递归下降在 THEN 语句解析完之后检查当前 token 是不是 ELSE。第三是代码生成:有 ELSE 时多一条 JMP,并且 JPC 和 JMP 的回填目标不同。
保留字表通常是一个有序字符串数组,扫描到字母开头的标识符后,先把字符收进缓冲区,再查表。ELSE 加进去之后,else返回TOK_ELSE,没查到才返回TOK_ID。这里有一个常见做法是统一转小写再查表,避免输入ELSE或Else时查不到。PL/0 教材里的保留字一般小写,实验输入也以小写为主,但调试时手滑输入大写并不少见,统一转小写能省一次玄学排查。
/* 词法分析器的保留字表,按字典序排列便于二分查找 */ static const char *keywords[] = { "begin", "call", "const", "do", "else", "end", "if", "odd", "procedure", "read", "then", "var", "while", "write" }; #define KEYWORD_COUNT (sizeof(keywords) / sizeof(keywords[0])) /* 在保留字表中查找,找到返回下标,找不到返回 -1 */ static int lookup_keyword(const char *name) { int low = 0, high = KEYWORD_COUNT - 1; while (low <= high) { int mid = (low + high) / 2; int cmp = strcmp(name, keywords[mid]); if (cmp == 0) return mid; if (cmp < 0) high = mid - 1; else low = mid + 1; } return -1; }这段代码的关键点是keywords[]必须保持字典序,二分查找才成立。lookup_keyword()返回下标而不是布尔值,是为了后续可以把下标映射到TokenType枚举;如果只想判断是不是保留字,返回 0/1 也够用。参数name是已经转成小写的标识符缓冲区,长度在扫描时限制在 31 个字符以内,避免strcmp读到未初始化内存。实验里符号表名字也用同样长度限制,否则声明和引用时的截断行为不一致,会出现“明明写了却找不到”的怪问题。
文法层面,ELSE 的可选性让 IF 语句的递归下降分支需要做一次“看当前 token”的判断。这里不需要构造完整的 LL(1) 分析表,直接accept(TOK_ELSE)即可:当前 token 是 ELSE 就消费并解析 ELSE 分支,不是就当作没有 ELSE。关键是这个判断必须放在 THEN 语句解析完之后,不能提前。提前判断会把内层 IF 的 ELSE 抢走,形成悬空 ELSE 匹配错误。
代码生成层面,有 ELSE 时指令序列是:条件代码、JPC 占位、THEN 代码、JMP 占位、回填 JPC 到 ELSE 起点、ELSE 代码、回填 JMP 到结束。没有 ELSE 时只有 JPC 回填到结束。JPC 的语义是栈顶为 0 时跳转,JMP 是无条件跳转。这两个指令在虚拟机里原本就有,扩展 ELSE 不需要改 VM,只需要在codegen.c里把地址算对。
2.3 构建与运行:一条 make 命令背后的参数
实验包根目录一般有Makefile,常见目标是all、clean、test。make之后生成可执行文件pl0,用法是./pl0 源文件.pl0。如果main.c里加了调试开关,还可以用-d先打印 token 流,再打印生成的 P-code。这个开关不是标准,但非常值得加:词法层认错保留字时,看 token 流比看语法报错快得多。
# 编译整个实验包,Makefile 里通常有 all、clean、test 三个目标 make # 直接解释执行一个 PL/0 源程序 ./pl0 test/if_else.pl0 # 打开调试开关,先打印 token 流,再打印生成的 P-code ./pl0 -d test/if_else.pl0make的默认目标一般依赖lexer.o parser.o symtab.o codegen.o vm.o main.o,最后链接成pl0。-d参数在main()里解析,设置一个全局debug标志;词法分析每返回一个 token 就打印类型和原文,代码生成结束后打印code[0..cx-1]。参数源文件.pl0是第一个非选项参数,源程序以.结束,解析器看到TOK_DOT后停止。运行结果分两种:如果main()调用interpret(),WRITE 语句的输出直接到 stdout;如果只生成代码,就把code[]打印出来人工核对。实验里建议两种都保留,先看代码,再跑虚拟机,出错时能分清是生成错了还是执行错了。
3. 目录结构与关键数据结构:token、符号表和指令数组怎么摆
3.1 工程目录:把词法、语法、符号表、代码生成拆开
目录结构不需要复杂,但要把“编译前端”和“虚拟机”分开。常见布局是src/放 C 源文件,include/放头文件,test/放.pl0测试用例,根目录放Makefile和实验说明。这样拆的好处是编译单元清晰,改词法不会连带重编虚拟机;坏处是头文件依赖要自己管,parser.h里会引用lexer.h的Token,codegen.h又会引用symtab.h的Symbol。实验规模小,用 include guard 加上前置声明就能压住。
# 典型的 PL/0 实验包目录,按编译阶段拆分 pl0-lab/ ├── Makefile # all、clean、test 三个目标 ├── include/ │ ├── lexer.h # TokenType、Token、next_token() │ ├── parser.h # parse_program() │ ├── symtab.h # Symbol、enter()、lookup() │ ├── codegen.h # Instruction、code[]、cx │ └── vm.h # interpret() ├── src/ │ ├── lexer.c # 跳过空白/注释,识别保留字和符号 │ ├── parser.c # 递归下降,边解析边生成 │ ├── symtab.c # 符号表栈式作用域 │ ├── codegen.c # JPC/JMP 生成与回填 │ ├── vm.c # 数据栈、指令指针、解释循环 │ └── main.c # 读文件、调试开关、编译入口 └── test/ ├── if_else.pl0 # IF-THEN-ELSE 基本用例 ├── dangling_else.pl0 # 悬空 ELSE 用例 └── nested_if.pl0 # 嵌套 IF 用例这个目录树里,test/下的文件名直接对应要验证的行为,比在main.c里硬编码字符串好维护。dangling_else.pl0专门放if a then if b then x:=1 else y:=2这种用例,跑通它基本能确认 ELSE 的最近匹配没写错。nested_if.pl0可以放两层以上嵌套,检查 JPC/JMP 的回填地址有没有串。Makefile的test目标可以循环跑这些文件,输出 diff 或者直接看 WRITE 结果。
3.2 Token 与保留字表:词法分析的第一道关
Token 类型枚举要把保留字和普通符号分开。ELSE 单独一个TOK_ELSE,不要复用TOK_ID再在语法分析里比较字符串。复用会让词法层和语法层耦合,调试时看到TOK_ID根本分不清是变量名还是漏掉的保留字。枚举顺序不影响逻辑,但打印 token 名时要用一个对应的字符串数组,否则调试输出全是数字。
typedef enum { TOK_EOF, TOK_ID, TOK_NUM, TOK_CONST, TOK_VAR, TOK_PROCEDURE, TOK_BEGIN, TOK_END, TOK_IF, TOK_THEN, TOK_ELSE, TOK_WHILE, TOK_DO, TOK_CALL, TOK_READ, TOK_WRITE, TOK_ODD, TOK_ASSIGN, TOK_EQ, TOK_NE, TOK_LT, TOK_LE, TOK_GT, TOK_GE, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_COMMA, TOK_SEMI, TOK_DOT } TokenType; typedef struct { TokenType type; char name[32]; /* 标识符或保留字的原文,便于报错 */ int value; /* 数字常量的值 */ int line; /* 行号,报错定位用 */ int col; /* 列号 */ } Token;Token里保留name字段是有用的:报错时能说“第 5 行的 else 附近期望 then”,而不是只给一个枚举值。value只在TOK_NUM时有效,其他 token 可以置 0。line和col在扫描时维护,遇到换行就line++、col=1,其他字符col++。参数name[32]是实验里够用的长度;如果标识符超过 31 个字符,扫描函数应该报错或者截断并记录,不要静默截断,否则符号表里存的名字和源程序里的名字不一致,查表会失败。
保留字识别发生在scan_identifier()里:先读入字母开头的字母数字串,统一转小写,再查lookup_keyword()。查到就把下标映射成对应的TokenType,比如else映射到TOK_ELSE;查不到就返回TOK_ID。这个顺序不能反:如果先返回TOK_ID再在语法分析里查字符串,等于把词法分析该做的活推给了语法分析,ELSE 的最近匹配也会变得难写。
3.3 符号表与指令结构:定长数组够用,但层号不能丢
符号表用定长数组加栈式作用域就够。每个Symbol记录名字、种类、层号、地址或值、过程体大小。层号是作用域的关键:主程序是 0 层,每进入一个过程level++,离开时level--。查找时从symtab_top-1往下扫,先匹配当前层,再匹配外层;遇到同层同名就是重复声明,遇到外层同名是合法的遮蔽。实验里最容易翻车的是离开过程时忘了恢复level,导致后面的变量全被当成内层变量,地址偏移全错。
typedef enum { SYM_CONST, SYM_VAR, SYM_PROC } SymKind; typedef struct { char name[32]; SymKind kind; int level; /* 声明所在层,0 为主程序 */ int addr; /* 变量:相对当前层的偏移;过程:代码入口 */ int value; /* 常量值 */ int size; /* 过程体需要的局部变量个数 */ } Symbol; static Symbol symtab[1000]; static int symtab_top = 0; static int current_level = 0;kind决定addr和value哪个有意义:常量用value,变量用addr,过程用addr存代码入口、size存局部变量个数。level在生成 LOD/STO 时用来算层差,调用过程时也要靠它恢复基址。参数symtab[1000]是实验规模的经验值;如果测试用例里过程很多,可以改成 4096 或者动态扩容,但定长数组在调试时更直观,越界也容易发现。
指令结构用op/l/a三字段。op是操作码,l是层差,a是地址、立即数或操作码。JMP 和 JPC 只用a存跳转目标,l置 0;LOD/STO 用l和a定位变量;LIT 用a存立即数;OPR 用a存具体运算(加、减、乘、除、比较等)。回填就是修改code[pc].a,所以code[]必须是全局可写的数组,cx是当前空闲位置。
typedef enum { OPR_LIT, OPR_LOD, OPR_STO, OPR_CAL, OPR_INT, OPR_JMP, OPR_JPC, OPR_OPR } OpCode; typedef struct { OpCode op; int l; /* 层差 */ int a; /* 地址、立即数或操作码 */ } Instruction; static Instruction code[1000]; static int cx = 0;OPR_JPC的语义要固定:栈顶为 0 时跳转到a,否则继续。OPR_JMP是无条件跳转到a。这两个语义在 VM 的switch里各占一个 case,扩展 ELSE 时不需要改 VM。参数code[1000]对实验够用;如果生成的指令超过上限,gen_*函数要报错而不是越界写,否则会踩坏相邻内存,出现完全不相干的玄学崩溃。
4. 核心模块拆解:保留字识别与 ELSE 语句的递归下降实现
4.1 词法分析:扫描到字母后先查保留字表
词法分析的入口是next_token(),它跳过空白和注释后,根据当前字符分派:字母开头走scan_identifier(),数字开头走scan_number(),运算符和分隔符走单字符或双字符匹配。ELSE 的识别发生在scan_identifier()里,流程是收字符、转小写、查保留字表、映射 token 类型。这个函数写错,后面语法分析再正确也白搭。
/* 扫描标识符或保留字,返回对应的 Token */ static Token scan_identifier(void) { Token tok; int len = 0; tok.line = line; tok.col = col; /* 收集字母和数字,限制长度防止缓冲区溢出 */ while (isalnum((unsigned char)current_char)) { if (len < (int)sizeof(tok.name) - 1) { tok.name[len++] = (char)tolower((unsigned char)current_char); } else { error("标识符过长"); } advance(); } tok.name[len] = '\0'; /* 先查保留字表,命中就返回保留字类型,否则是普通标识符 */ int idx = lookup_keyword(tok.name); if (idx >= 0) { tok.type = keyword_token_type(idx); } else { tok.type = TOK_ID; } tok.value = 0; return tok; }这段代码先保存line和col,再循环收集字符。tolower()让ELSE和else都能命中保留字表;如果实验要求大小写敏感,就去掉tolower,但测试用例里要明确写小写。len限制在sizeof(tok.name)-1,超长时报错而不是继续写,避免缓冲区溢出。lookup_keyword()返回下标,keyword_token_type()把下标映射到TOK_ELSE等枚举。参数current_char是全局当前字符,advance()负责读下一个字符并维护行列号。注意:数字后直接跟字母的情况要在scan_number()里单独检查,123abc不能悄悄切成123和abc,否则语法分析会收到一个莫名其妙的标识符。
4.2 语法分析:IF-ELSE 的最近匹配是怎么自然发生的
递归下降解析 IF 语句的代码不长,但顺序很关键。正确顺序是:匹配IF,解析条件,匹配THEN,生成 JPC 占位,解析 THEN 语句,然后看当前 token 是不是ELSE。如果是,消费 ELSE,生成 JMP 占位,回填 JPC 到 ELSE 起点,解析 ELSE 语句,最后回填 JMP 到结束;如果不是,直接回填 JPC 到结束。这个顺序让内层 IF 先消费自己的 ELSE,外层 IF 在 THEN 语句返回后看到的下一个 token 已经不是 ELSE,最近匹配自然成立。
/* 解析 IF 条件 THEN 语句 [ELSE 语句] */ static void parse_if(void) { int jpc, jmp; match(TOK_IF); parse_condition(); match(TOK_THEN); jpc = gen_jpc(0); /* 条件为假时跳转,地址先占位 */ parse_statement(); /* THEN 分支 */ if (accept(TOK_ELSE)) { /* 当前 token 是 ELSE 才消费 */ jmp = gen_jmp(0); /* THEN 执行完跳过 ELSE */ patch(jpc, cx); /* 条件为假跳到 ELSE 起点 */ parse_statement(); /* ELSE 分支 */ patch(jmp, cx); /* ELSE 执行完跳到结束 */ } else { patch(jpc, cx); /* 没有 ELSE,条件为假直接到结束 */ } }match()在当前 token 符合期望时前进,否则报“期望 X,实际 Y”;accept()只在当前 token 符合时前进并返回 1,否则返回 0,不报错。gen_jpc(0)生成一条 JPC 占位指令并返回下标,gen_jmp(0)同理。patch(pc, target)把code[pc].a改成target。参数jpc和jmp保存的是指令下标,不是地址值;cx是当前代码指针,回填时它正好指向下一条待生成指令。这个写法对if a then if b then x:=1 else y:=2能正确匹配内层 ELSE:外层解析 THEN 语句时进入内层parse_if(),内层看到 ELSE 就消费,外层返回后当前 token 已是分号或 END,不会再进 ELSE 分支。
提示:不要在解析条件时提前检查 ELSE,也不要在
parse_statement()外面再包一层“预读两个 token”的逻辑。递归下降的最近匹配靠的就是“谁先看到谁消费”,提前预读反而会把内层 ELSE 抢走。
4.3 代码生成:JPC 与 JMP 的回填顺序
代码生成的核心是两条跳转的地址。条件为假要跳到 ELSE 起点,THEN 执行完要跳过 ELSE 到结束。所以有 ELSE 时,先生成 JPC 占位,再生成 THEN 代码,然后生成 JMP 占位,回填 JPC 到当前cx(ELSE 起点),再生成 ELSE 代码,最后回填 JMP 到当前cx(结束)。没有 ELSE 时,JPC 直接回填到当前cx。回填顺序反了,最常见的现象是 ELSE 分支永远不执行,或者 THEN 执行完又掉进 ELSE。
/* 把 code[pc].a 回填为 target */ static void patch(int pc, int target) { if (pc < 0 || pc >= cx) { error("回填地址越界"); return; } code[pc].a = target; } /* 生成 JPC 占位指令,返回其下标 */ static int gen_jpc(int target) { code[cx].op = OPR_JPC; code[cx].l = 0; code[cx].a = target; return cx++; } /* 生成 JMP 占位指令,返回其下标 */ static int gen_jmp(int target) { code[cx].op = OPR_JMP; code[cx].l = 0; code[cx].a = target; return cx++; }patch()先检查pc是否落在已生成的指令范围内,防止把地址写到未初始化区域。gen_jpc()和 `
本文还有配套的精品资源,点击获取