ARTICLE DETAIL

资讯详情

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

PL0编译器功能扩充:重建教学型编译器的可扩展骨架

PL0编译器功能扩充:重建教学型编译器的可扩展骨架 简介本资源是一份面向计算机专业高年级学生与编译原理初学者的PL/0编译器功能扩充实验报告聚焦事业编考试中常涉及的系统底层与语言实现能力考查场景。文档完整呈现了在经典教学编译器PL/0基础上扩展整型一维数组、IF-THEN-ELSE条件分支及REPEAT循环语句的全过程涵盖词法分析新增注释识别与关键字匹配、语法分析递归下降解析规则更新和语义处理三元组代码生成与符号表管理三大核心模块并附有实验框图、流程分析与测试用例验证。资源为单个156KB的DOCX文件结构清晰含实验目的、内容、框图、过程分析GETSYM/GETCH机制、二分查找保留字、JMP指令修补等细节、测试结果及问题反思六大部分便于对照源码理解编译器各阶段协同逻辑。目前已有140人学习下载是深入掌握编译器构造原理与动手改造真实教学系统的优质实践材料。1. PL0编译器功能扩充不是写个语法补丁就完事而是重建教学型编译器的可扩展骨架PL0编译器功能扩充本质不是给一个30年前的教学编译器“打补丁”而是用现代工程思维重审它的设计契约——它本就该是编译原理课的活体教具但原始PL0N. Wirth版只支持整数、无数组、无过程嵌套、无字符串、无输入输出语句。学生一跑read(x); write(x1);就报错老师讲到词法分析就卡在token.kind IDENTIFIER讲到语义检查就绕开类型系统……这不是学生学不会是工具没给够支点。这次扩充目标很实在让PL0能跑通《编译原理》龙书第6章的典型教学用例含过程调用、局部变量、简单表达式同时保证代码结构清晰、新增模块可独立测试、错误提示能指向行号列号而不是吐出一行Error in line 1。适合两类人高校教师想拿它当实验平台迭代教学案例自学编译原理的工程师需要一个足够小、足够透明、改了就能立刻看到效果的起点——不是LLVM那种黑匣子也不是ANTLR那种生成式抽象是手写递归下降、手动管理符号表、亲手调试AST遍历的真实手感。2. 从词法分析器开始为什么必须重写scanner而不是修if-else分支PL0原始词法分析器scanner是一个单层switchwhile循环靠字符逐个比对识别关键字和标识符。这种写法在扩充read/write/begin/end等新关键字时会迅速变成维护噩梦每加一个关键字就得在case r:里塞一堆if (next_char e ...)而一旦遇到real和read前缀冲突或write与writeln歧义逻辑就崩。更致命的是它不记录列号、不跳过注释、不处理多字符运算符如:导致后续语法分析器拿到的token流根本无法支撑带位置信息的错误报告。所以扩充第一步不是动parser而是彻底重写scanner——用状态机驱动而非字符硬匹配。2.1 状态机设计5个核心状态覆盖全部PL0词法规则我们定义以下5个状态用枚举表示每个状态只关心当前字符能触发什么转移状态名触发条件转移动作输出token类型START任意非空白/非注释起始符进入对应子状态—IN_ID字母/下划线继续读取IDENTIFIERIN_NUM数字继续读取NUMBERIN_ASSIGN遇到:检查下一字符是否为ASSIGN若:或COLON若:IN_COMMENT遇到{读到}为止丢弃全部内容不输出token提示PL0标准不支持行注释//但教学实践中学生常误写我们选择静默跳过//开头的整行——不报错但也不进token流避免干扰语法分析。2.2 C语言实现状态机核心循环带行列号追踪// scanner.c #include stdio.h #include ctype.h #include string.h #define MAX_ID_LEN 32 #define MAX_TOKENS 1000 typedef enum { TK_EOF, TK_IDENTIFIER, TK_NUMBER, TK_PLUS, TK_MINUS, TK_TIMES, TK_SLASH, TK_LPAREN, TK_RPAREN, TK_EQ, TK_NEQ, TK_LT, TK_LE, TK_GT, TK_GE, TK_ASSIGN, TK_SEMICOLON, TK_COMMA, TK_PERIOD, TK_COLON, TK_BEGIN, TK_END, TK_IF, TK_THEN, TK_ELSE, TK_WHILE, TK_DO, TK_CALL, TK_CONST, TK_VAR, TK_PROCEDURE, TK_READ, TK_WRITE // ← 新增关键字token } TokenKind; typedef struct { TokenKind kind; int line; int col; char id[MAX_ID_LEN]; int num; } Token; static FILE *src_file; static int line_num 1; static int col_num 1; static int ch; // 当前读取的字符 static void next_char() { ch fgetc(src_file); if (ch \n) { line_num; col_num 1; } else { col_num; } } Token scan_token() { Token t {TK_EOF, line_num, col_num, , 0}; while (isspace(ch)) { if (ch \n) line_num, col_num 1; else col_num; next_char(); } // 处理注释{ ... } if (ch {) { do { next_char(); if (ch EOF) break; } while (ch ! }); if (ch }) next_char(); // 吃掉} return scan_token(); // 递归扫描下一个token } // 处理行注释// ... \n if (ch / (peek_char() /)) { next_char(); next_char(); // 吃掉// while (ch ! \n ch ! EOF) next_char(); if (ch \n) next_char(); // 吃掉\n return scan_token(); } switch (ch) { case EOF: t.kind TK_EOF; break; case : t.kind TK_PLUS; next_char(); break; case -: t.kind TK_MINUS; next_char(); break; case *: t.kind TK_TIMES; next_char(); break; case /: t.kind TK_SLASH; next_char(); break; case (: t.kind TK_LPAREN; next_char(); break; case ): t.kind TK_RPAREN; next_char(); break; case : next_char(); if (ch ) { t.kind TK_EQ; next_char(); } else { t.kind TK_ASSIGN; } // 注意 单独出现是赋值才是相等 break; case : next_char(); if (ch ) { t.kind TK_LE; next_char(); } else if (ch ) { t.kind TK_NEQ; next_char(); } else { t.kind TK_LT; } break; case : next_char(); if (ch ) { t.kind TK_GE; next_char(); } else { t.kind TK_GT; } break; case :: next_char(); if (ch ) { t.kind TK_ASSIGN; next_char(); } else { t.kind TK_COLON; } break; case ;: t.kind TK_SEMICOLON; next_char(); break; case ,: t.kind TK_COMMA; next_char(); break; case .: t.kind TK_PERIOD; next_char(); break; case 0: case 1: case 2: case 3: case 4: case 5: case 6: case 7: case 8: case 9: t.kind TK_NUMBER; t.num 0; while (isdigit(ch)) { t.num t.num * 10 (ch - 0); next_char(); } break; default: if (isalpha(ch) || ch _) { int i 0; while ((isalnum(ch) || ch _) i MAX_ID_LEN-1) { t.id[i] ch; next_char(); } t.id[i] \0; t.kind lookup_keyword(t.id); // 关键字查表函数 } else { fprintf(stderr, Lexical error at line %d, col %d: unexpected char %c\n, line_num, col_num, ch); exit(1); } } return t; } // 关键字查表返回对应token kind否则返回TK_IDENTIFIER TokenKind lookup_keyword(const char *id) { static const struct { const char *name; TokenKind kind; } kw_table[] { {begin, TK_BEGIN}, {end, TK_END}, {if, TK_IF}, {then, TK_THEN}, {else, TK_ELSE}, {while, TK_WHILE}, {do, TK_DO}, {call, TK_CALL}, {const, TK_CONST}, {var, TK_VAR}, {procedure, TK_PROCEDURE}, {read, TK_READ}, {write, TK_WRITE} // ← 新增 }; for (int i 0; i sizeof(kw_table)/sizeof(kw_table[0]); i) { if (strcmp(id, kw_table[i].name) 0) { return kw_table[i].kind; } } return TK_IDENTIFIER; }这段代码的关键不在“能识别read/write”而在三处设计选择line_num/col_num全程由next_char()维护所有token都携带精确位置——这是后续错误提示可定位的基础注释处理放在词法层且{...}和//都支持但//不报错教学友好lookup_keyword()用静态表查而非if-else if链新增关键字只需往表里加一行不碰主逻辑——这就是“可扩充”的第一道防线。3. 语法分析器升级从递归下降到支持过程嵌套与作用域的LL(1)解析器原始PL0语法分析器parser是典型的递归下降但它的block()函数只处理一层const/var声明statement()只支持if/while/call没有begin...end复合语句更没有过程体嵌套。一旦学生写下procedure p; var x: integer; begin x : 1; read(x); write(x) end;原始parser会在procedure p;后直接崩溃——它根本不认识procedure这个产生式也不理解var声明块可以出现在过程内部。扩充必须让语法分析器真正支持PL0的完整BNFWirth原版PL0扩展版program → block . block → [const-declaration] [var-declaration] [procedure-declaration] statement const-declaration → const ident number {, ident number} ; var-declaration → var ident {, ident} ; procedure-declaration → procedure ident ; block ; statement → empty | assign-statement | call-statement | begin-statement | if-statement | while-statement begin-statement → begin statement {; statement} end3.1 重构parser结构按BNF分层每个函数对应一个非终结符我们不再用一个巨型parse()函数而是严格按BNF拆解parse_program()入口调用parse_block()后匹配.parse_block()依次尝试parse_const_decl()、parse_var_decl()、parse_proc_decl()最后调用parse_statement()parse_proc_decl()识别procedure ident ;后递归调用parse_block()——这就是嵌套支持的核心parse_statement()用lookahead判断下一个token分发到各子函数关键在于parse_block()必须能被parse_proc_decl()递归调用且每次调用都创建新的作用域symbol table scope。3.2 符号表管理从全局一张表到栈式作用域原始PL0符号表是静态数组索引即地址无作用域概念。扩充后我们必须支持全局作用域程序顶层过程作用域每个procedure内部嵌套过程作用域PL0虽不允许多层嵌套但为未来扩展留接口我们采用栈式符号表scope stack// symbol_table.h #define MAX_SCOPES 10 #define MAX_SYMBOLS_PER_SCOPE 100 typedef enum { TYPE_INT, TYPE_PROC } SymbolType; typedef struct { char name[MAX_ID_LEN]; SymbolType type; int level; // 0global, 1proc1, 2proc2... int addr; // 相对于当前frame base的偏移 int size; // 对于过程存入口地址 } Symbol; typedef struct { Symbol symbols[MAX_SYMBOLS_PER_SCOPE]; int count; } Scope; typedef struct { Scope scopes[MAX_SCOPES]; int top; // 当前作用域栈顶索引 } SymbolTable; extern SymbolTable symtab; void enter_scope(); void leave_scope(); int declare_symbol(const char *name, SymbolType type, int size); Symbol* find_symbol(const char *name);enter_scope()在进入procedure时调用leave_scope()在procedure结束时调用。declare_symbol()只在当前栈顶scope中插入find_symbol()从栈顶向下查实现词法作用域lexical scoping。3.3 语法树节点增强为过程调用和I/O预留AST节点原始PL0 AST只有NODE_ASSIGN/NODE_IF等我们新增// ast.h typedef enum { NODE_PROGRAM, NODE_BLOCK, NODE_CONST_DECL, NODE_VAR_DECL, NODE_PROC_DECL, NODE_STATEMENT_LIST, NODE_ASSIGN, NODE_CALL, NODE_READ, NODE_WRITE, NODE_IF, NODE_WHILE, NODE_BEGIN, NODE_EMPTY, NODE_NUMBER, NODE_IDENTIFIER, NODE_OP_BINARY, NODE_OP_UNARY } NodeType; typedef struct Node { NodeType kind; struct Node *left; struct Node *right; union { int number; // for NODE_NUMBER char ident[MAX_ID_LEN]; // for NODE_IDENTIFIER char proc_name[MAX_ID_LEN]; // for NODE_CALL / NODE_PROC_DECL int op; // for NODE_OP_BINARY (PLUS, MINUS...) } attr; int line; // ← 记录该节点对应源码行号用于错误定位 int col; } Node;注意NODE_READ/NODE_WRITE节点不带表达式子节点PL0的read(x)只接受标识符但NODE_CALL需支持call p(a,b)形式后续可扩展。所有节点都带line/col为语义分析阶段报错提供依据。4. 语义分析与中间代码生成从无类型检查到带作用域的四元式生成原始PL0没有类型检查x : 3.14和x : y z都通过运行时才崩溃。扩充后我们必须在语义分析阶段捕获变量未声明就使用write(u);类型不匹配x : true;但PL0无bool过程调用参数个数/类型不符call p(1,2,3);但p只定义1个参数read/write只接受变量名不能是表达式read(x1);非法同时中间代码要从原始的“栈式指令”如LOD 0 3升级为四元式quadruple便于后续优化和跨平台目标代码生成。4.1 语义分析流程两遍遍历AST第一遍check_types()遍历AST对每个NODE_IDENTIFIER调用find_symbol()检查是否存在对NODE_ASSIGN检查左右操作数类型是否一致PL0只有int但需确保左操作数是变量右操作数是表达式对NODE_READ/NODE_WRITE检查其子节点必须是NODE_IDENTIFIER不能是NODE_OP_BINARY对NODE_CALL查过程符号比对参数个数第二遍gen_code()为每个语句生成四元式格式(op, arg1, arg2, result)例如x : y z→(ADD, y, z, x)read(x)→(READ, _, _, x)write(x)→(WRITE, _, _, x)过程调用call p→(CALL, p, _, _)4.2 四元式表与临时变量管理我们用动态数组存储四元式并在需要时生成临时变量// codegen.h typedef struct { char op[10]; // ADD, READ, WRITE, CALL char arg1[32]; // 可以是变量名、数字、或_空 char arg2[32]; char result[32]; } Quadruple; extern Quadruple *quad_list; extern int quad_count; extern int temp_count; char* new_temp() { static char buf[32]; sprintf(buf, t%d, temp_count); return strdup(buf); } void emit(const char *op, const char *arg1, const char *arg2, const char *result) { if (quad_count MAX_QUADS) { fprintf(stderr, Code generation error: quadruple table overflow\n); exit(1); } Quadruple *q quad_list[quad_count]; strncpy(q-op, op, sizeof(q-op)-1); q-op[sizeof(q-op)-1] \0; if (arg1) strncpy(q-arg1, arg1, sizeof(q-arg1)-1); else strcpy(q-arg1, _); if (arg2) strncpy(q-arg2, arg2, sizeof(q-arg2)-1); else strcpy(q-arg2, _); if (result) strncpy(q-result, result, sizeof(q-result)-1); else strcpy(q-result, _); }emit()是生成四元式的统一入口。new_temp()生成t0,t1等临时变量名供表达式求值使用。例如x : a * b c会生成(MUL, a, b, t0) (ADD, t0, c, t1) (ASSIGN, t1, _, x)注意PL0原始语义规则要求read/write只能作用于变量因此check_types()中对NODE_READ的子节点必须是NODE_IDENTIFIER否则报错“READ/WRITE operand must be an identifier, got expression”。5. 避坑PL0功能扩充中最容易翻车的5个地方PL0扩充看似只是加几个关键字、改几行parser但实际落地时90%的失败都源于对PL0设计哲学的误读。以下是我在带3届编译原理实验课、复现6个开源PL0变种后总结的血泪经验每一条都对应真实debug日志。5.1 现象read(x);编译通过但运行时报“segmentation fault”原因read语句生成的四元式(READ, _, _, x)在解释器执行时试图把输入值写入x的内存地址但x在符号表中未分配地址addr -1。原始PL0的var声明不生成任何代码只填符号表扩充后若忘记在parse_var_decl()中为每个变量调用allocate_address()就会导致空指针解引用。解决在parse_var_decl()中每声明一个变量立即调用declare_symbol(name, TYPE_INT, 1)并在SymbolTable中为其分配addr从当前frame base开始递增。5.2 现象嵌套过程p中声明的变量y在p内部write(y)正常但在外层write(y)也通过编译原因find_symbol()实现错误只查当前scope没实现“向上查找”。正确逻辑是从symtab.scopes[symtab.top]开始逐级向下top-1,top-2...查直到level 0。若只查当前层外层就看不到内层变量若查所有层不设边界内层就可能误用外层变量。解决find_symbol()必须传入int max_level当前作用域层级只查level max_level的scope。5.3 现象begin x : 1; y : 2; end编译失败提示“expected SEMICOLON before END”原因parse_statement_list()中对;的处理过于宽松。原始PL0允许begin s1; s2 end末尾无;但扩充后parse_statement_list()在读到end时错误地认为前面必须有;而没考虑end本身就是合法终止符。解决parse_statement_list()应以lookahead是否为TK_END或TK_SEMICOLON为循环条件而非强制匹配;。伪代码while (lookahead ! TK_END lookahead ! TK_SEMICOLON lookahead ! TK_EOF) { parse_statement(); if (lookahead TK_SEMICOLON) next_token(); }5.4 现象procedure p; begin write(1) end;编译通过但call p;执行时跳转到错误地址原因过程入口地址未正确记录。parse_proc_decl()在解析完procedure ident; block;后必须将当前四元式计数器quad_count作为该过程的入口地址存入符号表。若忘记这步CALL指令就会跳到0地址。解决在parse_proc_decl()末尾调用set_proc_entry(ident, quad_count)将quad_count写入对应Symbol.size字段约定size存入口地址。5.5 现象const pi 3.14;编译通过但write(pi);输出0原因const声明未生成任何四元式且NODE_IDENTIFIER在gen_code()中未做常量折叠。PL0的const是编译期常量应直接替换为数值而非查符号表运行时取值。解决在check_types()中若NODE_IDENTIFIER指向TYPE_CONST符号将其替换为NODE_NUMBER节点attr.number设为常量值gen_code()对NODE_NUMBER直接输出数值不查表。6. 验证与调试用3个最小测试用例守住扩充质量底线功能扩充不是写完就完必须建立可自动回归的验证体系。我坚持用三个“最小但致命”的测试用例覆盖PL0扩充最易断裂的环节——它们小到能在10行内复现bug又足以暴露架构缺陷。6.1 测试用例1作用域穿透验证符号表栈var x; procedure p; var x; begin x : 1; write(x) // 应输出1 end; begin x : 2; call p; write(x) // 应输出2证明p内x不污染全局x end.验证方式编译后运行观察输出是否为12。若输出11说明find_symbol()未实现作用域隔离若报“x not declared”说明enter_scope()未在procedure入口调用。6.2 测试用例2I/O语义约束验证语义分析var x, y; begin read(x); // OK read(xy); // 应报错READ operand must be identifier write(x); // OK write(x1); // 应报错WRITE operand must be identifier end.验证方式编译此程序检查错误信息是否精准定位到read(xy)和write(x1)的行号列号。若静默通过或报错位置错误说明check_types()未正确遍历AST子节点或NODE_READ/NODE_WRITE的子节点类型检查缺失。6.3 测试用例3过程调用链验证四元式生成与执行procedure p; begin write(1) end; procedure q; begin call p end; begin call q end.验证方式编译后导出四元式列表确认存在(CALL, p, _, _)和(CALL, q, _, _)且p的入口地址size字段指向write(1)对应的四元式索引运行解释器输出应为1在q中插入write(2)确认输出为21先q后p证明调用栈管理正确。我的习惯是每次提交代码前先跑这3个用例每次新增功能如加real类型先扩写对应测试用例再动手。它们不是花架子而是我给自己写的后悔药——编译器开发里最贵的不是时间是定位一个ch没next_char()导致的无限循环所花的3小时。希望帮到你。本文还有配套的精品资源点击获取
返回列表