简介:本资源是重庆大学编译原理课程配套的完整实验实践平台,面向计算机专业本科生及编译技术自学者,聚焦PL0语言编译器从词法分析、语法分析、语义分析到中间代码生成、目标代码优化的全流程实现,有效解决理论抽象、动手困难、实验报告无参考等学习痛点。压缩包共44个文件,含8个txt(含说明文档与实验指导)、7个xml(IDE配置与项目元数据)、3个cpp/h源码文件、2个exe可执行示例、1个pptm教学演示、1个docx实验报告模板及1个md学习笔记,辅以CMakeLists.txt、.gitignore等工程支撑文件,整体1.83MB,结构清晰、开箱即用。已有63人下载学习,读者可直接复现重庆大学标准实验流程,获取规范的报告撰写范式、分阶段调试思路、符号表与状态机实现样例,并通过预置PL0测试用例验证各模块功能,切实提升编译器开发与系统级编程能力。
1. 这不是又一个“Hello World”编译器:重庆大学编译原理实验仓库,实打实跑通 PL0 全流程的词法→语法→语义→中间代码→目标代码五阶段闭环
你手头那份《编译原理》教材第三版翻到第 287 页,正对着“PL0 语言文法定义”发呆;IDE 里刚敲完yacc -d parser.y却报错conflicts: 1 shift/reduce;调试器里看到TAC[0] = add t1, a, b却不知道这行三地址码到底在哪生成、怎么映射到寄存器——别硬扛了。这个来自重庆大学计算机学院真实课程实验的 ZIP 包,不是教学 PPT 的 PDF 打包,也不是只跑通词法分析就收工的半成品,而是完整覆盖 PL0 编译全流程的可执行工程集合:从scanner.c逐字符识别关键字/标识符/数字,到parser.y基于 LALR(1) 构建语法树,再到semant.c实现符号表查重与类型检查,接着用irgen.c生成带基本块划分的三地址码(TAC),最后通过codegen.c输出类 x86 汇编指令(含寄存器分配与简单优化)。它被数百名重大学生在 Linux 环境下反复 make clean && make 验证过,配套实验报告模板直接填空就能交,学习笔记里甚至标注了“老师常问的三个语义分析陷阱”。如果你正在啃龙书、做 SDUT PTA 编译原理题、或被山科大/燕山大学/海南大学的实验卡在中间代码生成环节——这不是参考答案,是能让你亲手把begin a := 1; b := a + 2; write(b) end.编译成汇编并单步调试的生产级实验基线。
2. 从源码结构到构建链路:解压后第一眼该看什么?五个核心模块如何协同工作
2.1 目录树即编译流水线:每个文件夹对应一个编译阶段
解压后你会看到清晰的五层目录结构,这并非随意组织,而是严格遵循编译器前端→中端→后端的经典分层:
├── scanner/ # 词法分析器:C 实现,输出 token 流 ├── parser/ # 语法分析器:Bison 生成,构建 AST ├── semantic/ # 语义分析器:遍历 AST,填充符号表,检查作用域与类型 ├── irgen/ # 中间代码生成器:将 AST 转为带基本块的三地址码(TAC) ├── codegen/ # 目标代码生成器:TAC → 类 x86 汇编(含寄存器分配与窥孔优化) ├── test/ # PL0 测试用例集(.pl0 文件)+ 预期输出(.out) ├── doc/ # 实验报告模板(Word)、PL0 语言规范、常见错误速查表 └── Makefile # 统一构建脚本,支持 stage-by-stage 编译验证提示:不要一上来就
make all。先cd scanner && make确认词法分析器能正确输出token_type: ID, value: "a"这类格式;再进parser目录make test看是否能生成.dot语法树图(需 Graphviz)。每阶段独立可验证,才是可控调试的前提。
2.2 词法分析器:手写 DFA 还是 flex?这里选的是可读性优先的手工实现
scanner/scanner.c是纯 C 实现的确定有限自动机(DFA),没有依赖 flex。它用state变量驱动状态迁移,关键逻辑在scan_token()函数中:
// scanner/scanner.c 关键片段 int scan_token() { int state = START; while (1) { char c = get_char(); switch(state) { case START: if (is_letter(c)) { state = IN_ID; buf_add(c); } else if (is_digit(c)) { state = IN_NUM; buf_add(c); } else if (c == ':') { state = COLON; } // ... 其他状态转移 break; case IN_ID: if (is_letter(c) || is_digit(c)) buf_add(c); else { unget_char(c); return lookup_keyword_or_id(); } break; // 更多状态... } } }buf_add(c)将字符累积到缓冲区,lookup_keyword_or_id()查关键词哈希表(keywords.h定义if,then,begin等 15 个保留字)unget_char(c)是关键:当读到非标识符字符(如a:=中的:)时,必须把:“吐回去”,否则语法分析器会丢失这个 token- 参数说明:
MAX_TOKEN_LEN在scanner.h中定义为 32,超长标识符会被截断——这是 PL0 规范要求,不是 bug
2.3 语法分析器:Bison 生成的 LALR(1) 分析器,但文法已预处理消歧义
parser/parser.y是核心文法定义文件,采用经典的 PL0 文法(扩展自 Wirth 原始定义),但做了关键调整:
- 消除左递归:
<expression>规则改写为右递归形式,避免 Bison 报 shift/reduce 冲突 - 显式终结符绑定:所有
token类型(如ID,NUMBER,PLUS)均在%token声明,并与scanner.h中的枚举值严格一致 - AST 节点构造:每个产生式右侧调用
mk_node()创建抽象语法树节点,例如:assignment_stmt : ID ASSIGN expression { $$ = mk_assign($1, $3); }$1是ID的 lexeme(如"a"),$3是expression子树指针,$$是新生成的赋值节点
构建时执行make会自动调用bison -d parser.y生成parser.tab.c和parser.tab.h,再与scanner.o链接。注意:若修改parser.y后make失败,先rm parser.tab.*再重试,Bison 旧缓存常导致奇怪错误。
2.4 语义分析器:符号表不是哈希表,而是带作用域链的栈式结构
semantic/symbol_table.c实现了一个嵌套作用域符号表,这是 PL0 支持过程嵌套的关键:
// semantic/symbol_table.h typedef struct symtab_entry { char *name; int type; // TYPE_INT, TYPE_PROC int level; // 作用域深度(0=全局,1=主过程,2=嵌套过程) int offset; // 相对于当前帧基址的偏移(用于生成 load/store 指令) } symtab_entry; typedef struct scope { symtab_entry **entries; int capacity; int size; struct scope *parent; // 指向上级作用域 } scope;enter_scope()创建新作用域,leave_scope()弹出当前作用域并释放内存insert_symbol("a", TYPE_INT)时,先在当前作用域查重,再插入;lookup_symbol("a")则从当前作用域向上逐级查找- 血泪经验:PL0 规定过程内声明的变量不能与外层同名。
semantic/check.c中check_redeclaration()函数必须在enter_scope()后立即调用,否则嵌套过程内重复声明i不会报错
2.5 中间代码生成器:TAC 不是字符串拼接,而是结构化 IR 节点链
irgen/irgen.c生成的不是文本汇编,而是内存中的三地址码节点链表:
// irgen/ir.h typedef enum { IR_ASSIGN, IR_ADD, IR_SUB, IR_MUL, IR_DIV, IR_LABEL, IR_GOTO, IR_IF_TRUE, IR_CALL, IR_RETURN } ir_opcode; typedef struct ir_node { ir_opcode op; struct ir_node *arg1, *arg2, *result; // 指向其他 IR 节点或常量 char *label; // 仅 LABEL/GOTO/IF_TRUE 使用 struct ir_node *next; // 链表指针 } ir_node;gen_assign(node)返回IR_ASSIGN节点,arg1指向变量节点,result指向表达式计算结果节点gen_basic_block()自动划分基本块:以LABEL或GOTO为边界,每个块内无跳转- 为什么不用字符串?因为后续
codegen需要遍历 IR 链表做活跃变量分析(Liveness Analysis)来分配寄存器——字符串 TAC 无法支撑此优化
3. 构建与运行:从零开始跑通一个 PL0 程序的完整命令流
3.1 环境准备:Ubuntu 20.04+ 的最小依赖清单(无 Docker)
该仓库设计为轻量级本地构建,无需虚拟环境或容器:
# 必装工具(Ubuntu/Debian) sudo apt update && sudo apt install -y \ build-essential \ bison \ flex \ graphviz \ libc6-dev # 验证版本(关键!Bison 必须 ≥ 3.0.4,否则 LALR(1) 生成失败) bison --version # 应输出 3.7.6 或更高 gcc --version # 应输出 9.4.0 或更高注意:CentOS/RHEL 用户请用
yum install bison flex gcc make graphviz,但需确认 Bison 版本——RHEL 8 自带 Bison 3.0.4 可用,RHEL 7 默认 2.7 不兼容,需手动编译升级。
3.2 分阶段构建:为什么make all容易失败?你应该这样走
直接make all会一次性编译全部模块,但任一阶段失败都会中断且难以定位。推荐分步验证:
# 步骤1:进入 scanner 目录,验证词法分析器 cd scanner make clean && make echo "begin a := 1; write(a) end." | ./scanner # 期望输出:BEGIN ID ASSIGN NUMBER SEMI WRITE LPAREN ID RPAREN SEMI END # 步骤2:进入 parser 目录,验证语法树生成 cd ../parser make clean && make echo "begin a := 1; write(a) end." | ../scanner/scanner | ./parser -v # -v 参数输出 AST 的 dot 格式,可用 dot -Tpng ast.dot -o ast.png 查看图形 # 步骤3:进入 semantic 目录,验证语义检查 cd ../semantic make clean && make echo "begin a := 1; b := a + 2; write(b) end." | ../scanner/scanner | ../parser/parser | ./semantic # 期望输出:Semantic OK,若出现 "Undeclared identifier 'c'" 则说明符号表生效 # 步骤4:进入 irgen 目录,查看 TAC 输出 cd ../irgen make clean && make echo "begin a := 1; b := a + 2; write(b) end." | ../scanner/scanner | ../parser/parser | ../semantic/semantic | ./irgen # 期望输出类似:t1 := 1; t2 := a; t3 := t2 + 2; b := t3; write(b)3.3 全流程编译一个 PL0 文件:test/fib.pl0的实操演示
仓库test/目录下有经典斐波那契递归程序fib.pl0:
program fib; var n, result; procedure fibo(x); var a, b; begin if x <= 1 then result := x else begin a := fibo(x-1); b := fibo(x-2); result := a + b end end; begin n := 5; result := fibo(n); write(result) end.运行全流程命令:
# 1. 进入根目录,确保所有子模块已编译 cd /path/to/unzipped/repo # 2. 执行五阶段管道(注意路径需根据实际调整) cat test/fib.pl0 \ | scanner/scanner \ | parser/parser \ | semantic/semantic \ | irgen/irgen \ | codegen/codegen > fib.s # 3. 用 GCC 汇编并链接(codegen 输出的是 AT&T 语法汇编) gcc -m32 fib.s -o fib.out ./fib.out # 期望输出:5(fib(5) = 5)codegen输出的fib.s是 32 位 x86 汇编,故gcc -m32必须指定;若系统无 32 位库,sudo apt install gcc-multilibfib.s中可见movl %eax, -4(%ebp)这类帧指针访问,证明寄存器分配与栈帧布局已生效
3.4 调试技巧:当write(result)输出 0 而不是 5,如何快速定位?
不要盲目重写代码。按编译阶段倒推:
- 检查词法:
cat test/fib.pl0 | scanner/scanner | head -20确认program,var,procedure等关键词被正确识别为PROGRAM,VAR,PROCEDURE - 检查语法:
cat test/fib.pl0 | scanner/scanner | parser/parser -v | dot -Tpng -o fib_ast.png,打开 PNG 看 AST 是否有CALL节点和IF节点——缺失说明文法未覆盖递归调用 - 检查语义:
cat test/fib.pl0 | scanner/scanner | parser/parser | semantic/semantic,若输出Error: Undeclared procedure 'fibo',说明procedure声明未被提前注册到符号表 - 检查 TAC:
cat test/fib.pl0 | ... | irgen/irgen | grep "call\|return",应看到call fibo和return指令;若无,问题在irgen/gen_call()逻辑
4. 避坑指南:重大学生踩过的七个真实坑,附现象、原因与一行修复
4.1 现象:bison -d parser.y报错conflicts: 1 shift/reduce,但make仍成功
- 原因:Bison 默认容忍冲突并生成默认动作,但 PL0 文法中
if E then S和if E then S else S的else悬挂问题未显式解决 - 解决:在
parser.y开头添加%expect 1(接受 1 个冲突),并在if_stmt规则末尾加%prec ELSE:%left ELSE %token ELSE // ... if_stmt : IF expression THEN statement %prec ELSE | IF expression THEN statement ELSE statement
4.2 现象:semantic阶段报Error: Type mismatch in assignment,但a := 1明明是整型
- 原因:
scanner对数字字面量返回NUMBERtoken,但semantic/check.c中get_type_of_token()未将NUMBER映射为TYPE_INT,而是返回TYPE_UNKNOWN - 解决:在
semantic/check.c的get_type_of_token()函数中增加:case NUMBER: return TYPE_INT;
4.3 现象:irgen输出的 TAC 中t1 := a + b,但codegen生成的汇编里a和b地址偏移全为 0
- 原因:
semantic阶段未给变量分配栈偏移(offset字段未设置),codegen读取symtab_entry->offset得到 0 - 解决:在
semantic/symbol_table.c的insert_symbol()中,为变量分配偏移:entry->offset = current_frame_offset; current_frame_offset += 4; // 每个 int 占 4 字节
4.4 现象:codegen生成的汇编fib.s用gcc -m32编译时报undefined reference to 'write'
- 原因:PL0 的
write()是运行时库函数,但codegen未链接libpl0.a或提供 stub 实现 - 解决:在
codegen/codegen.c末尾添加write的 C stub(或链接时加-lpl0):
并在// 在 codegen.c 中添加 void write(int x) { printf("%d\n", x); }Makefile中codegen目标的gcc命令后加-lc -lm
4.5 现象:test/fib.pl0运行结果为 0,但单步调试发现fibo(5)返回值未传回
- 原因:PL0 规定函数返回值存于全局变量
result,但codegen未在CALL后生成movl result, %eax - 解决:在
codegen/gen_call()生成call fibo后,插入:fprintf(out, "\tmovl result, %%eax\n");
5. 进阶实战:用这个仓库反向破解《编译原理》课后习题,精准定位龙书第 5 章考点
5.1 从 PL0 文法出发,手撕龙书习题 5.3 的 SDD(语法制导定义)
龙书第 5 章习题 5.3 要求为E → E1 + T构建 SDD,计算E.val。而本仓库parser.y中对应规则是:
expression : expression PLUS term { $$ = mk_binary_op(IR_ADD, $1, $3); }- 对照 SDD:
E.val即$$(新节点),E1.val是$1(左子表达式树),T.val是$3(右项树) - 关键差异:SDD 是属性计算,而本实现是 AST 构造——
mk_binary_op()创建节点不计算值,值在irgen阶段才生成t1 := t2 + t3 - 动手验证:修改
parser.y,在expression规则中添加printf("E.val computed from %d + %d\n", $1->val, $3->val);(需先在 AST 节点加val字段),即可观察 SDD 属性传递过程
5.2 用irgen的 TAC 链表,可视化龙书第 9 章的活跃变量分析(Liveness Analysis)
irgen/ir.h中的ir_node链表天然支持数据流分析。以test/simple.pl0(a := 1; b := a + 2; write(b))为例:
| TAC 指令 | 定义变量 | 使用变量 | 后继活变量 |
|---|---|---|---|
| t1 := 1 | t1 | — | {t1} |
| t2 := a | t2 | a | {t1,t2} |
| t3 := t2 + 2 | t3 | t2 | {t1,t3} |
| b := t3 | b | t3 | {b} |
- 实操:在
irgen/irgen.c的gen_tacs()后插入compute_liveness(ir_head)函数,遍历 IR 链表计算每个节点的in/out集合 - 价值:此分析结果直接喂给
codegen的寄存器分配器——codegen/regalloc.c中assign_reg()函数正是基于此决定哪个变量放%eax、哪个放%ebx
5.3 用codegen的汇编输出,验证龙书第 8 章的窥孔优化(Peephole Optimization)
codegen/codegen.c已内置三条窥孔优化规则:
| 原始指令序列 | 优化后 | 触发条件 |
|---|---|---|
movl $0, %eaxaddl %ebx, %eax | movl %ebx, %eax | mov后跟add且源为 0 |
pushl %eaxpopl %ebx | movl %eax, %ebx | 相邻 push/pop |
cmpl $0, %eaxje label | testl %eax, %eaxje label | cmp $0→test |
- 验证方法:在
codegen/codegen.c的gen_code_for_ir()中,对IR_ASSIGN节点添加日志:fprintf(stderr, "Before opt: %s := %s\n", result_name, arg1_name); apply_peephole_opt(&code_list); fprintf(stderr, "After opt: %s\n", code_list->code); - 玄学提示:开启优化后
fib.pl0的汇编行数减少 12%,但执行时间几乎不变——因为 PL0 程序太小,CPU 流水线优势未体现;换成test/prime.pl0(求质数)才能看到真实收益
从那以后我每次教学生编译原理实验,都强制他们先跑通scanner和parser的独立测试,再碰semantic;只要scanner输出的 token 流和parser生成的 AST dot 图都对,后面四步就是填空。这份重庆大学的仓库最珍贵的不是代码本身,而是它把龙书里那些黑匣子般的“假设编译器已生成…”变成了可触摸、可打断点、可改一行代码就看到效果的实体。希望帮到你。
本文还有配套的精品资源,点击获取