ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PL0编译器实战:手写词法语法分析与虚拟机代码生成

PL0编译器实战:手写词法语法分析与虚拟机代码生成 简介本资源是南京航空航天大学编译原理课程设计的完整实现包面向计算机专业本科生及编译技术初学者聚焦PL0语言编译器从理论到落地的全流程实践。资源共7个文件含1个C源码文件实现词法分析、语法分析与代码生成核心模块、1个可执行程序支持PL0源码输入并输出目标代码、1份详实的课程设计报告PDF涵盖设计思路、关键算法、问题调试与测试验证以及4个文本文件含3个典型PL0测试用例和1个编译输出示例整体压缩包仅1.71MB轻量易用。已有420人学习下载体现其在教学实践中的高参考价值。读者可直接运行exe验证编译逻辑对照cpp源码理解编译器各阶段实现细节结合报告深入掌握符号表管理、语法树构建与中间代码生成等核心知识点是贯通编译原理课堂理论与工程实践的优质范例。1. PL0 编译器不是玩具它是一把能切开编译原理黑匣子的手术刀你在南京航空航天大学NUAA的编译原理课上拿到“PL0语言编译器”课程设计题目的那一刻大概率正盯着《编译原理》清华大学出版社第三版第二章发呆——那一页讲的是词法分析器状态转换图但你手头连一个能跑起来的hello.pl0都没有。别慌这不是让你从零造轮子而是用 PL0 这个被教科书反复锤炼了四十年的“最小可运行编译器原型”亲手拆解词法→语法→语义→中间代码→目标代码的全链路。它不依赖 LLVM、不调用 Clang、不碰大模型原理与技术nuaa这类高阶概念就用 C 或 C 写死的递归下降分析器 固定栈式目标机模拟器把“编译器未包含main类型”“编译器的堆空间不足”这些玄学报错变成你能单步调试、打桩验证、改一行代码就看到效果的真实过程。适合 NUAA 计算机学院大三学生、刚接触编译流程的研究生以及所有想甩掉“只会调 gcc”的认知惯性、真正理解“为什么语法树要先建再遍历”“为什么符号表必须分层”“为什么四元式比三地址码更适合优化”的人。它不解决工业级问题但它能让你在答辩时指着自己写的gen(ADD, 0, lev, dx)函数说清这行代码正在把a : b c编译成能在虚拟机上执行的加法指令。2. 从 PL0 语言规范到可执行编译器五步落地路径PL0 不是方言是为教学而生的精密标尺。它的文法极简仅 9 条产生式语义清晰静态作用域、无指针、无动态内存但恰恰因此任何一处实现偏差都会立刻暴露——比如漏处理const声明顺序会导致后续所有变量偏移量错位比如factor规则里没判ident后紧跟(就会让函数调用直接崩在语法分析阶段。下面这条路径是我带 NUAA 三届学生做课设时验证过的最小可行闭环不绕弯、不堆砌工具链全程本地 Windows 或 Linux 下纯命令行完成。2.1 先吃透 PL0 语言的“宪法”BNF 文法与语义约束PL0 的 BNF 定义必须手抄一遍别跳过重点盯死三条边界规则常量声明必须在变量声明前const x 1; var y;合法var y; const x 1;非法 → 编译器需在词法扫描后强制校验声明块顺序过程嵌套深度 ≤ 3 层PL0 虚拟机栈帧只预留 3 级base指针位置 → 若procedure p1; procedure p2; procedure p3; procedure p4;第 4 层过程体解析时必须报错标识符作用域严格按块嵌套var a; begin a : 1; call p; end.中p内部若声明var a;则p内a是局部变量外部a不可见 → 符号表必须支持多层哈希或链表嵌套。提示清华第三版教材 P35 的 PL0 文法有印刷错误——statement规则中call ident缺少分号终结符实际应为call ident ;。这个细节会导致你的语法分析器在遇到call read; write(x);时卡死在write前。务必对照 ALGOL60 原始定义修正。2.2 词法分析器手写 DFA 还是 flex选前者理由很现实很多同学一上来就flex pl0.l结果生成的lex.yy.c里全是宏定义和 goto调试时根本找不到token IDENT是在哪一行触发的。我坚持手写 DFA因为 PL0 的 token 集合极小仅ident,number,,-,*,/,,,!,,,,,;,,,.,(,),begin,end,if,then,else,while,do,call,const,var,procedure,odd,read,write用 switch-case 就能覆盖全部。关键不是快是可控。// scanner.c 核心逻辑C99 int get_next_token() { static char buf[256]; static int pos 0; int ch; while ((ch fgetc(src_file)) ! EOF isspace(ch)) {} // 跳过空白 if (ch EOF) return DONE; if (isalpha(ch)) { // 标识符或关键字 int i 0; do { buf[i] ch; ch fgetc(src_file); } while (isalnum(ch)); ungetc(ch, src_file); buf[i] \0; return lookup_keyword(buf); // 查关键字表返回 CONST_KW / VAR_KW / IDENT 等 } if (isdigit(ch)) { // 数字字面量 int val 0; do { val val * 10 ch - 0; ch fgetc(src_file); } while (isdigit(ch)); ungetc(ch, src_file); num_val val; // 全局变量存数值 return NUMBER; } // 其他单字符/双字符运算符... switch (ch) { case : return PLUS; case -: return MINUS; case *: return TIMES; case /: return SLASH; case : if ((ch fgetc(src_file)) ) return EQ; // else { ungetc(ch, src_file); return ASSIGN; } // case : if ((ch fgetc(src_file)) ) return LE; // else { ungetc(ch, src_file); return LT; } // // ... 其他 case } return ERROR; }参数说明src_file是fopen(test.pl0, r)打开的文件指针num_val是全局整型变量供语法分析器读取数字值lookup_keyword()查表返回预定义 token 类型如CONST_KW查不到则返回IDENT。这里不引入 flex是因为你必须亲手处理ungetc()的时机——比如读到时第二个必须退回去否则下一个 token 会错位。这个细节flex 自动生成的代码里藏得太深debug 成本远高于手写。2.3 语法分析器递归下降 错误恢复拒绝“一错全崩”PL0 的文法是 LL(1)天然适配递归下降。但教科书常忽略一点真实课设中学生写的test.pl0文件 80% 都有语法错误少分号、括号不匹配、begin没end。如果分析器遇到第一个错误就exit(1)你根本没法看到后续更多 bug。必须加入同步集synchronizing set机制。// parser.c 关键片段 void statement() { switch (lookahead) { case IDENT: // 处理赋值语句 match(IDENT); match(ASSIGN); expression(); match(SEMICOLON); break; case CALL: match(CALL); match(IDENT); match(SEMICOLON); break; case BEGIN: match(BEGIN); statement_list(); match(END); break; case IF: match(IF); condition(); match(THEN); statement(); if (lookahead ELSE) { match(ELSE); statement(); } break; case WHILE: match(WHILE); condition(); match(DO); statement(); break; default: // 错误恢复跳过当前 token尝试用同步集恢复 error(Unexpected token in statement); sync_to_statement_end(); // 同步到 ; 或 END 或 ELSE break; } } void sync_to_statement_end() { // 同步集语句结束的合法 token 是 ; END ELSE while (lookahead ! SEMICOLON lookahead ! END lookahead ! ELSE lookahead ! DONE) { lookahead get_next_token(); } }逻辑说明sync_to_statement_end()是救命稻草。当if x 1 then y : 2写成if x 1 then y : 2缺分号分析器在then后读到y发现不是BEGIN/IF/WHILE就进入default分支打印错误并跳过y直到遇到;或END然后继续解析下一条语句。没有它整个文件后半部分全废。2.4 符号表与语义检查三层结构撑起作用域PL0 的符号表不能是单层哈希表。必须支持全局层level 0存放const和var声明过程层level 1~3每个procedure开辟新层继承外层const但屏蔽同名var参数层level n1过程形参单独一层位于过程体层之上。我用结构体数组实现每层记录first该层第一个符号索引、last最后一个、prev_level父层索引#define MAX_SYMBOLS 1000 typedef struct { char name[32]; int kind; // CONST / VAR / PROC / PARAM int level; // 0~3 int addr; // 内存地址const 存值var/procedure 存偏移 int size; // 数组大小非数组为 0 } Symbol; Symbol symtab[MAX_SYMBOLS]; int symtop 0; // 当前符号总数 int level_stack[4] {0}; // level_stack[i] 表示第 i 层第一个符号索引 int current_level 0; int enter_symbol(const char* name, int kind, int addr, int size) { if (symtop MAX_SYMBOLS) return -1; strcpy(symtab[symtop].name, name); symtab[symtop].kind kind; symtab[symtop].level current_level; symtab[symtop].addr addr; symtab[symtop].size size; if (current_level 4) { if (level_stack[current_level] 0) level_stack[current_level] symtop; } return symtop; } int find_symbol(const char* name) { // 从当前层向上查优先返回最内层匹配 for (int l current_level; l 0; l--) { int start level_stack[l]; int end (l 3) ? symtop : level_stack[l1]; for (int i start; i end; i) { if (strcmp(symtab[i].name, name) 0) return i; } } return -1; }参数说明enter_symbol()在const/var/procedure解析时调用find_symbol()在ident出现时调用用于检查是否已声明、是否在作用域内。注意level_stack[4]的设计——level_stack[3]是第 3 层起始level_stack[4]是符号表末尾即symtop这样for (istart; iend; i)就能安全遍历某一层。2.5 代码生成PL0 虚拟机指令集与栈帧布局PL0 不生成 x86 或 ARM 机器码而是生成 8 条虚拟机指令LIT加载常量、LOD加载变量、STO存变量、CAL调用过程、INT分配栈空间、OPR运算/控制流、JMP无条件跳转、JPC条件跳转。目标代码是struct { int op; int l; int a; } code[MAX_CODE];数组其中l是层次差level differencea是地址偏移或操作码。// codegen.c void gen(int op, int l, int a) { if (codeptr MAX_CODE) { fprintf(stderr, Code buffer overflow at %d\n, codeptr); exit(1); } code[codeptr].op op; code[codeptr].l l; code[codeptr].a a; codeptr; } // 生成赋值语句 a : b c 的代码 // 先生成 b 的加载LOD 0, addr_b // 再生成 c 的加载LOD 0, addr_c // 再生成加法OPR 0, 2 2 表示加法 // 最后存 aSTO 0, addr_a void assignment_gen(int var_addr) { // 左值已在 symbol table 中查出 addr右值表达式已生成代码 // 此处只需补 STO 指令 gen(STO, 0, var_addr); }关键点LOD和STO的l参数不是绝对层次而是当前过程层次与变量声明层次的差值。例如全局变量x在 level 0 声明当前在 level 2 过程中访问l 2 - 0 2。虚拟机运行时通过base指针链base[0]→base[1]→base[2]找到对应栈帧。这个设计让嵌套过程调用成为可能也是 PL0 教学价值的核心。3. 编译器未包含 main 类型堆空间不足三个血泪避坑指南PL0 编译器课设里90% 的失败不是逻辑错而是环境与边界没控住。下面这三条是我在 NUAA 实验室帮学生 debug 时从凌晨两点到天亮反复验证过的真问题。3.1 现象编译器报 “undefined reference tomain”但你的 C 文件明明写了int main()原因你用gcc -o pl0 parser.o scanner.o codegen.o编译但忘了链接标准库启动代码crt0.o。PL0 编译器本身是int main()入口但如果你的main()函数里调用了printf比如打印错误信息而链接时没带-lcld 就会找不到__libc_start_main符号最终报undefined reference to main—— 这是个经典误导它不是说你没写main而是说main的调用者crt0没找到。解决方案一推荐用gcc -o pl0 main.c parser.c scanner.c codegen.c让 gcc 自动处理所有链接方案二手动链接gcc -o pl0 parser.o scanner.o codegen.o main.o -lc -lgcc方案三根治彻底不用printf改用write(1, buf, len)系统调用避免 libc 依赖。3.2 现象程序运行到gen()函数时崩溃gdb 显示Segmentation fault (core dumped)且codeptr值异常大原因code数组越界写入。PL0 的MAX_CODE默认设为 500但一个含 3 层嵌套、10 个变量、5 个过程的程序生成的指令数轻松破 800。更隐蔽的是gen()函数里没检查codeptr MAX_CODE就直接code[codeptr]赋值导致写到非法内存。解决立即在gen()开头加断言if (codeptr MAX_CODE) { fprintf(stderr, Code overflow! Increase MAX_CODE.\n); exit(1); }动态扩容方案进阶把code改成malloc分配每次codeptr接近上限时realloc预估技巧PL0 每条语句平均生成 3~5 条指令const/var声明各占 1 条procedure体额外加 10 条含INT,OPR等按此估算设MAX_CODE 2000更安全。3.3 现象test.pl0语法完全正确但虚拟机执行时报 “Stack overflow”或计算结果全为 0原因符号表查找失效导致LOD指令加载了错误地址。典型场景是find_symbol()函数里level_stack[l1]越界——当current_level 3时level_stack[4]应等于symtop但如果enter_symbol()时没更新level_stack[4]level_stack[4]仍是初始 0for (istart; iend; i)就变成for (ix; i0; i)循环不执行find_symbol()返回 -1后续LOD用 -1 当地址读到垃圾数据。解决在enter_symbol()结尾加if (current_level 4) level_stack[current_level1] symtop;在begin_procedure()时current_level前确保level_stack[current_level1]已初始化加调试桩在find_symbol()返回前printf(find %s - %d\n, name, ret);一眼看出是否命中。4. 让 PL0 编译器“活”起来三步验证法与性能压测技巧编译器写完不是终点是验证的开始。很多同学./pl0 test.pl0输出Success就交作业结果答辩时老师输入begin const x1; var y; y:x1; write(y); end.程序直接 segfault——因为你没验证过真实数据流。下面这套验证法是我要求 NUAA 学生必须完成的“及格线”。4.1 第一步语法树可视化揪出文法理解偏差PL0 的递归下降分析器会隐式构建语法树但你不打印出来永远不知道condition规则是否真的把a b解析成了(LT, a, b)节点。加一个print_tree()函数在parse_expression()返回前输出当前子树// tree.h typedef enum { NODE_ADD, NODE_SUB, NODE_LT, NODE_LE, NODE_EQ, NODE_IDENT, NODE_NUMBER } NodeType; typedef struct Node { NodeType type; struct Node* left; struct Node* right; char ident_name[32]; int number_val; } Node; Node* parse_expression() { Node* node parse_simple(); while (lookahead PLUS || lookahead MINUS) { NodeType op (lookahead PLUS) ? NODE_ADD : NODE_SUB; match(lookahead); Node* right parse_simple(); Node* new_node malloc(sizeof(Node)); new_node-type op; new_node-left node; new_node-right right; node new_node; } return node; } void print_tree(Node* n, int indent) { if (!n) return; for (int i 0; i indent; i) printf( ); switch (n-type) { case NODE_ADD: printf(ADD\n); break; case NODE_SUB: printf(SUB\n); break; case NODE_LT: printf(LT\n); break; case NODE_IDENT: printf(IDENT %s\n, n-ident_name); break; case NODE_NUMBER: printf(NUMBER %d\n, n-number_val); break; } print_tree(n-left, indent 1); print_tree(n-right, indent 1); }验证动作对ab*c输入观察输出是否为ADD IDENT a MUL IDENT b IDENT c如果不是说明parse_expression()和parse_term()的优先级没处理对——MUL应该在ADD的右子树而非同级。这是文法理解的根本错误必须返工。4.2 第二步中间代码人工审计锁定生成逻辑漏洞PL0 的中间代码是code[]数组但直接看十六进制毫无意义。写一个dump_code()函数把指令翻译成人话void dump_code() { printf(Code dump (%d instructions):\n, codeptr); for (int i 0; i codeptr; i) { switch (code[i].op) { case LIT: printf(%3d: LIT %d %d\n, i, code[i].l, code[i].a); break; case LOD: printf(%3d: LOD %d %d\n, i, code[i].l, code[i].a); break; case STO: printf(%3d: STO %d %d\n, i, code[i].l, code[i].a); break; case CAL: printf(%3d: CAL %d %d\n, i, code[i].l, code[i].a); break; case INT: printf(%3d: INT %d %d\n, i, code[i].l, code[i].a); break; case OPR: switch (code[i].a) { case 0: printf(%3d: OPR %d RET\n, i, code[i].l); break; case 1: printf(%3d: OPR %d NEG\n, i, code[i].l); break; case 2: printf(%3d: OPR %d ADD\n, i, code[i].l); break; case 3: printf(%3d: OPR %d SUB\n, i, code[i].l); break; case 4: printf(%3d: OPR %d MUL\n, i, code[i].l); break; case 5: printf(%3d: OPR %d DIV\n, i, code[i].l); break; case 6: printf(%3d: OPR %d ODD\n, i, code[i].l); break; case 7: printf(%3d: OPR %d MOD\n, i, code[i].l); break; case 8: printf(%3d: OPR %d EQL\n, i, code[i].l); break; case 9: printf(%3d: OPR %d NEQ\n, i, code[i].l); break; case 10: printf(%3d: OPR %d LSS\n, i, code[i].l); break; case 11: printf(%3d: OPR %d LEQ\n, i, code[i].l); break; case 12: printf(%3d: OPR %d GTR\n, i, code[i].l); break; case 13: printf(%3d: OPR %d GEQ\n, i, code[i].l); break; } break; case JMP: printf(%3d: JMP %d %d\n, i, code[i].l, code[i].a); break; case JPC: printf(%3d: JPC %d %d\n, i, code[i].l, code[i].a); break; } } }验证动作对if a 1 then b : 2 else b : 3;检查JPC指令的跳转地址是否指向else分支起点JMP是否指向if结束后的位置。如果JPC的a字段是 0表示跳转到 0 号指令说明条件跳转地址没计算对——根源在gen(JPC, 0, 0)时没把目标地址填进去必须在if解析完then部分后用patch_address()修正。4.3 第三步虚拟机指令级单步用 printf 当 debuggerPL0 虚拟机只有 8 条指令但它是整个编译器的“最后一公里”。别信./pl0 test.pl0的最终输出要亲眼看到LOD是否真的从base[0]10读到了x的值。在虚拟机execute()循环里加日志void execute() { int pc 0; int sp 0; int base[4] {0}; // base[0] is global frame int stack[MAX_STACK]; while (pc codeptr) { Instruction i code[pc]; printf(PC%d OP%d L%d A%d | SP%d BASE[%d,%d,%d,%d]\n, pc-1, i.op, i.l, i.a, sp, base[0], base[1], base[2], base[3]); switch (i.op) { case LIT: stack[sp] i.a; break; case LOD: stack[sp] stack[base[i.l] i.a]; break; case STO: stack[base[i.l] i.a] stack[sp--]; break; // ... 其他指令 } } }验证动作运行begin const x5; var y; y:x; write(y); end.观察日志中LOD指令是否读到 5STO是否存到y的地址write前stack[sp]是否为 5。如果LOD读到 0说明x的地址没记对——回到符号表查enter_symbol(x, CONST, 5, 0)时addr字段是否传成了 5常量值而非内存地址PL0 中const值存在stack[0]开始处addr应为 0。5. 把 PL0 编译器变成你的技术锚点从课设到能力迁移的三个硬核习惯做完 NUAA 的 PL0 课设你手上握着的不该是一份 PDF 报告而是一个可生长的技术锚点。我见过太多学生交完作业就把代码删了半年后面试被问“怎么实现作用域”只能背教材定义。真正的复利在于把 PL0 的每一个模块变成你后续学习的参照系。下面这三个习惯是我带过的最优秀的学生共同具备的。5.1 习惯一给每个gen()调用打标签建立指令溯源链PL0 的gen(OPR, 0, 2)看似简单但它背后对应着expression中的运算。如果不在生成时记录上下文你永远无法回答“这条加法指令是由哪一行 PL0 源码触发的”。我在codegen.c里加了一个struct { int line; int col; char* src; }的debug_info数组和code[]并行存储typedef struct { int line; int col; char src_line[128]; } DebugInfo; DebugInfo debug_info[MAX_CODE]; int debug_ptr 0; void gen_with_debug(int op, int l, int a, int line, int col, const char* src) { gen(op, l, a); debug_info[debug_ptr].line line; debug_info[debug_ptr].col col; strncpy(debug_info[debug_ptr].src_line, src, 127); debug_info[debug_ptr].src_line[127] \0; debug_ptr; }然后在scanner.c的get_next_token()里维护current_line和current_col在parser.c的expression()开头调用gen_with_debug(..., current_line, current_col, ab)。这样当虚拟机执行到第 42 条指令时你可以查debug_info[42]知道它来自源码第 15 行c : a b;。这个习惯迁移到 LLVM IR 生成时就是IRBuilder::SetCurrentDebugLocation()迁移到 Go 编译器开发时就是objfile.LineInfo。它训练的是一种“指令级可追溯性”思维——任何代码生成都必须能回溯到源头。5.2 习惯二用#ifdef DEBUG切换符号表模式直面内存管理真相PL0 的符号表用数组实现但工业编译器用哈希表或红黑树。我要求学生写两套find_symbol()一套是课设用的线性查找#ifndef DEBUG一套是#ifdef DEBUG下的哈希实现用djb2哈希函数 链地址法。切换开关只需改一行#define DEBUG。这样做不是为了炫技而是让你亲手感受当符号数从 100 增到 1000线性查找耗时从 0.1ms 涨到 1.0ms而哈希稳定在 0.02ms哈希表rehash()时所有符号要重新散列这就是为什么 Rust 编译器用FxHashMap无 rehash而非std::collections::HashMapenter_symbol()如果忘了free()旧内存就会内存泄漏——而 PL0 的MAX_SYMBOLS是固定值掩盖了这个问题。提示在#ifdef DEBUG分支里加printf(Hash collision at %s, chain length %d\n, name, chain_len);。你会第一次意识到为什么 Java HotSpot 要用ConcurrentHashMap而不是HashMap——不是并发需求是链长爆炸带来的性能悬崖。5.3 习惯三把 PL0 虚拟机指令集当成理解现代 CPU 的显微镜PL0 的LOD/STO/CAL看似原始但它精准映射了 x86 的mov/call/ret。我在vm.c里加了一个--trace模式输出每条指令对应的 x86 伪码#ifdef TRACE_X86 switch (i.op) { case LOD: printf(; LOD %d %d mov %%rax, [%%rbp%d]\n, i.l, i.a, i.a * 8); break; case CAL: printf(; CAL %d %d call *[%d*8%%rip]\n, i.l, i.a, i.a); break; case OPR: if (i.a 2) printf(; OPR %d ADD add %%rax, %%rdx\n, i.l); break; } #endif这样当你看到LOD 0, 5生成mov %rax, [rbp40]你就懂了为什么 C 的局部变量地址是rbp - offset当你看到CAL指令需要push rbp; mov rbp, rsp你就明白frame pointer的存在意义。PL0 不是古董它是把现代 CPU 的复杂性用 8 条指令剥开给你看的解剖刀。后来学 RISC-V 时我的学生能立刻指出lw t0, 0(sp)对应 PL0 的LODjal ra, func对应CAL——因为他们早已在 PL0 里亲手写过 100 遍栈帧切换。做完这一切PL0 就不再是 NUAA 课表上的一门课而成了你技术判断的基准线。下次看到“大模型原理与技术nuaa”这种热词你不会被名词吓住而是会问它的 tokenizer 是不是也该有个get_next_token()它的 attention 计算是不是也该有类似gen(OPR, 0, 14)的中间表示这种穿透力才是编译原理课设真正想给你的东西。希望帮到你。本文还有配套的精品资源点击获取
返回列表