news 2026/10/13 22:06:29

小型Pascal子集编译器:从词法分析到栈式虚拟机完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
小型Pascal子集编译器:从词法分析到栈式虚拟机完整实现

简介:面向编译原理课程设计的小型Pascal子集编译器设计报告,完整展示了一个用C++实现Pascal子集编译器的全过程。报告覆盖词法分析、语法分析、语义分析、中间代码生成与目标代码生成五个模块,包含词法/语法分析接口定义、first与follow集求解、分析表与分析栈数据结构、翻译模式和中间代码种类等关键设计,并给出团队分工、测试与成绩评定等信息,适合正在做编译器课程设计的学生参考。资源为单个doc文档,大小约952KB,内附北京邮电大学课程设计报告正文,含符号表、记号流数组及详细设计说明,便于直接对照实现或撰写课程报告。已有240人学习下载,对有类似课程设计或实验任务的同学具有较强参考价值。

1. 小型Pascal子集编译器:从“会背编译原理”到“真能跑通一个闭环”

读到这个标题的人,通常有两类:一类是正在做编译原理课程设计的在校生,一类是工作几年后想补上“编译器”这块短板的开发者。很多人把一个子集编译器想得过于简单——觉得无非是词法分析加语法分析,结果交上去的设计报告连代码生成都没有,答辩时被问“你的if语句翻译成了什么指令”就卡住了。另一类人则想得过于复杂,一上来就要支持浮点、数组、指针,最后死在测试用例的泥潭里。真正值得做的“小型Pascal子集编译器”,是在明确划出功能边界后,把“源代码→词法→语法→语义→生成指令→运行”这一整条链路完整打通,每一环节都有可演示的输入输出。这篇博文直接给出我反复用过的方案:子集怎么划、目标机怎么选、递归下降如何落地、栈式指令怎么从AST生成,以及十个项目里九个会踩的坑。新手照着做能把设计报告写成“能跑、能演示、能答辩”,熟手则能拿这里的边界条件和测试矩阵去补自己旧框架的窟窿。

2. 先定义“小型”:砍语法比写代码更考验设计能力

2.1 子集范围划定:保留Pascal的骨架,砍掉所有装饰

常见做法是拿到一个课设题目后先找Pascal语法手册,然后照着写。这是我看到翻车率最高的起点。完整的Pascal有记录类型、文件类型、变体记录、嵌套过程、指针、集合,这些对于一个小型编译器来说都是灾害。一个合理的子集应该只保留能体现编译原理主干的那部分:词法上有关键字和标识符的区分,语法上有语句嵌套和表达式优先级,语义上有类型检查和符号表作用域,代码生成上能覆盖顺序、分支、循环和过程调用。

我一般会把子集限定为:整数和布尔两种类型,变量声明与常量声明,赋值语句(:=),read和write简单I/O,if then else、while do、for do(可选)三类控制流,无参过程和一个整型参数过程。不做浮点、不做数组和结构体、不做嵌套注释、不支持case和repeat-until。

为什么这样划?第一,两种类型足以让类型检查有意义;第二,三种控制流涵盖了代码生成中所有跳转形式;第三,过程调用强迫你实现符号表的作用域嵌套,这是作业和面试里最高频的考点;第四,砍掉的东西正好写进设计报告的“不支持特性”章节,答辩时能主动解释边界。

功能类别建议状态说明
变量声明支持var a, b: integer;与var flag: boolean;
常量声明支持只支持整数常量,不做常量表达式折叠
赋值支持:=,左右类型必须一致
I/O支持read(a)和write(b)映射到虚拟机指令
分支支持if then else,不带elif
循环支持while do;for可作为扩展
过程支持最多3个整型参数,不递归或递归均可
浮点/数组/指针不支持写清楚原因,是为了让设计报告聚焦

2.2 目标机选型:直译器太薄,x86太深,栈式虚拟机刚刚好

做编译器必须回答一个前置问题:生成什么?常见三条路线,它们的工作量和答辩效果差别极大。

AST直译器:词法和语法分析后直接对语法树解释执行。开发最快,两天能跑通,但设计报告的核心“代码生成”是空的,答辩时会被追问“你生成的指令在哪里”,适合时间只剩一周的应急,不适合作为一份合格的编译器设计报告。

生成x86汇编或机器码:硬件含量最高,能学到寄存器分配、栈帧布局,但对课设而言工作量爆炸。为了一个子集语言去处理x86的寻址模式、调用约定和指令调度,会把大量精力耗在与编译原理主干无关的细节上,结果是大部分时间在调试汇编而不是调试编译器。

栈式虚拟机:第三路线。编译器生成一套自定义的栈式指令,运行时用一个小解释器执行。这是我推荐的首选方案。原因有三:指令集体积小(二十条左右),AST到指令的翻译规则一一对应,设计报告里可以画虚拟机栈的状态变化图,答辩时现场演示一条表达式如何从压栈到弹出结果,直观且扎实。

下面是一套够用的指令集设计,后续所有代码生成都基于它:

指令操作数含义
PUSH整数常量常量压栈
LOAD变量编号把变量的值压栈
STORE变量编号弹出栈顶存入变量
ADD / SUB / MUL / DIV无弹出两个操作数做运算,结果压栈
LT / GT / EQ / NE无比较两个栈顶元素,压入0或1
JMP标签地址无条件跳转
JZ标签地址栈顶为0时跳转(配合布尔条件)
CALL过程编号保存返回地址,跳转过程入口
RET无从过程返回
READ / WRITE变量编号读入/输出
HALT无停止执行

这套指令集的取舍点是:不单独设计寄存器,所有运算都在栈上完成,STORE把栈顶写回变量。实现起来不到三百行C代码,但足以支撑报告里“运行时子系统”这一章节。

2.3 分层架构与报告的总览图

在写任何代码之前,先把模块边界划好。整个系统按五层拆:

源程序 → 词法分析器 → Token流 → 语法分析器 → AST → 语义分析器(标注类型与符号表) → 代码生成器 → 指令数组 → 虚拟机执行。

这里有一个初学者常犯的错:把语义分析并进语法分析里。递归下降解析a := 1 + 2时顺手查了符号表,看似省事,但设计报告里“语义分析独立模块”就没有内容可写了。我建议的是:语法分析阶段只负责建树,语义分析阶段单独遍历一次AST做符号表填充与类型检查,再把类型信息挂到AST节点上;代码生成阶段另做一次遍历。三个阶段各司其职,报告里的每个模块都能对应到一段完整的代码。

设计报告的章节骨架也可以按此展开:需求定义、总体设计(含指令集设计表)、模块设计(词法/语法/语义/生成/虚拟机)、测试与边界分析、扩展方向。后面会逐条给出每章的落地写法。

3. 词法与语法分析:递归下降的每个细节都公开

3.1 手写词法分析器:状态机与Token定义

小型子集的词法分析器不需要引入lex/flex,手写一个确定性状态机更可控。原因是它能让报告讲清楚“每个Token是怎么被识别的”,答辩时老师大概率会问“注释是怎么跳过去的”,你在白板上画两圈状态就能答上。

Token结构体建议固定在64字节的text字段里,不要用动态字符串:

typedef struct { int type; // T_NUM, T_ID, T_KEYWORD, T_OP, T_EOF char text[64]; // 单词原文,标识符统一转小写存储 int ival; // 数字Token的整数值 int line; // 行号,错误报告必带 } Token; static const char *keywords[] = { "program", "var", "const", "begin", "end", "if", "then", "else", "while", "do", "read", "write", "integer", "boolean" }; Token next_token(const char **p) { while (**p == ' ' || **p == '\t' || **p == '\n' || **p == '\r') (*p)++; Token t = {0}; t.line = current_line; if (**p == '\0') { t.type = T_EOF; return t; } if (isdigit(**p)) { // 收集数字,遇到非数字停止,不处理浮点 int v = 0; while (isdigit(**p)) { v = v * 10 + (**p - '0'); (*p)++; } t.type = T_NUM; t.ival = v; return t; } if (isalpha(**p) || **p == '_') { int i = 0; while ((isalnum(**p) || **p == '_') && i < 63) { t.text[i++] = tolower(**p); (*p)++; } t.text[i] = '\0'; // 查关键字表 for (int j = 0; j < KEYWORD_COUNT; j++) if (strcmp(t.text, keywords[j]) == 0) { t.type = T_KEYWORD; return t; } t.type = T_ID; return t; } // 运算符分支::= 需要双字符识别 if (**p == ':') { (*p)++; if (**p == '=') { (*p)++; t.type = T_ASSIGN; } else { error("line %d: 无效冒号", t.line); } return t; } // 单字符运算符 if (**p == '+' || **p == '-' || **p == '*' || **p == '/' || **p == '=' || **p == '<' || **p == '>') { t.type = T_OP; t.text[0] = *(*p)++; return t; } error("line %d: 无法识别的字符 '%c'", t.line, **p); return t; }

这段代码有四个需要写进设计报告的点。第一,关键字表查表前统一转小写,Pascal大小写不敏感,这是语言语义决定的,不是实现喜好。第二,数字解析不处理浮点,如果源程序写了1.5,词法分析器会在读到.时报“无法识别的字符”,这个限制要写进需求定义。第三,:=必须双字符识别,且必须放在单字符冒号处理之前,顺序颠倒会让所有赋值语句全部报错。第四,text字段长度64是一个工程参数,标识符长度超过63个字符时静默截断,这样做避免了动态内存碎片,代价是最长标识符受限,接受这个边界因为你已经定义了它是“小型”子集。

词法分析器还要输出Token流文件,格式简单如line:type:text一行一个Token。这样测试时可以直接人工比对词法结果,设计报告里放一张“输入一段Pascal,输出Token流”的对照表,说服力远高于放源码截图。

3.2 递归下降语法分析:一个Token前瞻就能处理全部文法

子集文法用LL(1)描述是完全可行的,所以语法分析器我采用递归下降,不需要引入yacc/bison。核心原因是错误定位更友好——每个函数负责一种语法成分,报错时能明确说“期望什么、实际看到什么”,而不是给出一堆状态栈信息。

解析器的入口拿Token流和当前位置cur,每个parse函数消费一个或多个Token,失败时返回错误并带行号:

typedef struct { Token *tokens; // 词法分析结果 int cur; // 当前Token下标 } Parser; void expect(Parser *ps, int type) { Token *t = &ps->tokens[ps->cur]; if (t->type != type) error("line %d: 期望 %s,实际得到 %s", t->line, token_name(type), token_name(t->type)); ps->cur++; } Node *parse_program(Parser *ps) { // 语法:program ID ; var_decl_list stmt_list end. expect(ps, T_KEYWORD_PROGRAM); expect(ps, T_ID); expect(ps, T_SEMI); Node *prog = new_node(N_PROGRAM); while (peek(ps) == T_KEYWORD_VAR || peek(ps) == T_KEYWORD_CONST) parse_decl(ps, prog); while (peek(ps) != T_KEYWORD_END) parse_stmt(ps, prog); expect(ps, T_KEYWORD_END); expect(ps, T_DOT); return prog; }

这段代码演示了一个关键设计决策:用peek而不是lookahead单符号判断分支。在子集文法下,一个Token的前瞻已经完全足够,不需要构建FIRST/FOLLOW集合。两个实用细节:声明和语句的循环都是“看当前Token是不是END来决定是否继续”,这样避免了扫描一遍去找语句边界;T_DOT的匹配对应Pascal程序结尾的句点,漏掉它会在语法层就暴露“程序没结束”的问题。

表达式优先级在递归下降里靠函数分层实现:parse_expr调用parse_term再处理+ -,parse_term调用parse_factor再处理* /。不要在一个函数里用循环去折腾所有运算符,三个函数嵌套是零玄学的可靠结构。

3.3 语句序列与分号:Pascal的分号是分隔符不是终止符

这是语法分析阶段最大的坑。C语言的每个语句以分号结束,但Pascal(及其后裔)的分号是语句之间的分隔符。这意味着begin a:=1; b:=2 end的最后一个分号可以省略,而begin a:=1; b:=2; end中end前的分号也是合法的。如果不加处理,直接在parse_stmt末尾强制消费分号,遇到end前有分号的合法程序就会报错。

标准解法是在语句列表层处理分号:

void parse_stmt_list(Parser *ps, Node *parent) { parse_stmt(ps, parent); // 先解析第一条语句 while (peek(ps) == T_SEMI) { match(ps, T_SEMI); // 消费分隔符 if (peek(ps) == T_KEYWORD_END || peek(ps) == T_KEYWORD_ELSE || peek(ps) == T_EOF) break; // 分号后无语句,允许空语句 parse_stmt(ps, parent); } }

在识别到分号后先检查下一个Token是否为END/ELSE,是则立即终止。这样if a>0 then b:=1 else b:=2不会被强制要求加额外分号。注意不要在AST里生成一个N_EMPTY_STMT空节点——某些代码生成器遇到空语句也要发指令,这是多余的;用循环的提前break跳过空语句,AST更干净,后续生成少一次判断。

关于嵌套的if和else配对,递归下降天然解决了悬垂else问题:每个parse_stmt内部的parse_if都在then分支递归调用parse_stmt_list,else的归属由语法调用层级决定,不需要手动维护匹配栈。

4. AST设计与栈式代码生成:从语法树到可执行指令的翻译规则

4.1 AST节点设计:用一个op字段和三个子指针覆盖全部语法

AST是语法分析器的输出,也是代码生成器的输入,它的形状直接决定了翻译规则的复杂度。小型子集不需要构造复杂的继承体系,一个结构体加一个枚举op就够用:

typedef struct Node { int op; // N_PROGRAM, N_ASSIGN, N_IF, N_WHILE, N_BINOP, // N_NUM, N_VAR, N_READ, N_WRITE, N_PROC_CALL char name[32]; // 变量名或过程名 int ival; // N_NUM的常量值或语句的指令标签 int type; // 语义分析阶段填充:0=未定, 1=int, 2=bool struct Node *kids[3]; } Node;

用固定三个子指针数组而不是变长列表,是基于子集语法中任何语句最多需要三个成分(如if有cond、then、else三棵子树,while有cond和body两棵,binop有两棵)。固定数组让内存管理退化成简单的先递归释放kids再释放自身,不给系统留泄漏点。这个设计也便于报告里画一张清晰的AST示意图。

构建AST的递归下降函数返回Node*,每个parse函数负责构造对应节点。比如赋值语句:

Node *parse_assign(Parser *ps) { Token id = ps->tokens[ps->cur]; expect(ps, T_ID); expect(ps, T_ASSIGN); Node *rhs = parse_expr(ps); Node *n = new_node(N_ASSIGN); strcpy(n->name, id.text); n->kids[0] = rhs; return n; }

这里故意把变量名直接存储在赋值节点本身,而不是单独创建一个N_VAR子节点。当左值是一个标识符时,AST节点直接携带名字能省一层节点。右值则是完整的表达式树,其中变量出现的地方用N_VAR节点表示。这种不对称在语义分析阶段会造成一次冗余查找——左值名字和右值变量名都要查符号表——但换来AST更紧凑。代码生成阶段看到N_ASSIGN节点生成一条LOAD右值再STORE左值的指令序列即可。

4.2 语义分析:在代码生成之前就把错误全部拦住

代码生成器期望每个节点都知道自己的类型和对应的符号表编号,所以语义分析是一个独立遍历。这个遍历做两件事:建立符号表并填入变量地址,然后自底向上对每个表达式节点标注类型。

符号表用简单的作用域栈实现。全局变量和过程参数各自一个作用域,过程嵌套会压入新层。查找变量时从栈顶往栈底逐层找,找到即返回符号表项,找不到报“未声明变量”错误。每个符号表项记录name, type, addr,addr是代码生成时变量在虚拟机内存区的槽位编号。

类型检查规则只有三条:赋值语句左右类型必须一致;if和while的条件必须是布尔类型;算术运算符+ - * /的两个操作数必须是整型。底层实现就是在遍历中检查节点type字段:

void check_expr(Node *n, SymTable *stab) { if (n->op == N_NUM) { n->type = T_INT; return; } if (n->op == N_VAR) { SymEntry *e = lookup(stab, n->name); if (!e) error("未声明变量: %s", n->name); n->type = e->type; n->ival = e->addr; // 提前写入变量地址 return; } check_expr(n->kids[0], stab); check_expr(n->kids[1], stab); if (n->op == N_BINOP) { if (n->kids[0]->type != n->kids[1]->type) error("类型不匹配"); n->type = (strcmp(n->name, "+")==0 || strcmp(n->name,"-")==0 || strcmp(n->name,"*")==0 || strcmp(n->name,"/")==0) ? T_INT : T_BOOL; } }

注意这段代码里的一个工程习惯:把变量地址直接写回AST节点的ival字段,而不是在代码生成阶段重新查符号表。这让代码生成器不需要持有符号表引用,接口更简单,也减少一次查表开销。代价是AST节点和符号表出现了耦合,但对于一次性的课设编译流程,这种“趁遍历时把所有信息一次性标注完成”的做法效率最高。

语义分析还有一个功能是过程调用检查。调用过程的实参个数必须和声明一致,类型必须匹配。这个过程调用检查发生在建AST时就已经通过parse_process_call保证了参数个数,但类型匹配只能在语义层完成,因为需要查符号表才能知道形参类型。

4.3 代码生成:一次AST前序遍历,指令数组从零到满

栈式虚拟机的代码生成是整个项目中最“机械”的部分,规则是:遇到表达式节点先递归生成左孩子代码再递归生成右孩子代码,最后发一条运算指令;遇到语句节点先发条件求值指令再发跳转指令。用一个全局的指令数组和当前指令指针即可,不需要复杂的数据结构:

typedef struct { int op; // VM_OP的枚举值 int arg; // 操作数:常量值/变量地址/标签号 } Instr; Instr code[MAX_CODE]; int ip = 0; void emit(int op, int arg) { code[ip].op = op; code[ip].arg = arg; ip++; } int gen(Node *n) { int addr1, addr2, after_label; switch (n->op) { case N_NUM: emit(VM_PUSH, n->ival); break; case N_VAR: emit(VM_LOAD, n->ival); // ival已被语义分析填为变量地址 break; case N_BINOP: gen(n->kids[0]); gen(n->kids[1]); emit(opcode_of(n->name), 0); break; case N_ASSIGN: gen(n->kids[0]); // 右值压栈 emit(VM_STORE, var_addr_of(n)); // 弹出存入左值变量 break; case N_IF: gen(n->kids[0]); // 条件压栈 int skip_then = ip; emit(VM_JZ, 0); // 条件为假跳到else/after gen(n->kids[1]); // then分支 if (n->kids[2]) { // 有else int skip_else = ip; emit(VM_JMP, 0); // then执行完跳到after code[skip_then].arg = ip; // 回填JZ的目标 gen(n->kids[2]); code[skip_else].arg = ip; // 回填JMP的目标 } else { code[skip_then].arg = ip; // 无else,JZ直接跳到尾部 } break; case N_WHILE: ; int loop_start = ip; gen(n->kids[0]); // 循环条件 int exit_jump = ip; emit(VM_JZ, 0); // 条件假退出 gen(n->kids[1]); // 循环体 emit(VM_JMP, loop_start); // 跳回条件求值 code[exit_jump].arg = ip; // 回填退出目标 break; default: error("gen: 未知节点类型 %d", n->op); } return 0; }

这里要理解一个回填标签的工作方式:JZ和JMP在初发时把目标地址定为0(占位),等到后续分支代码生成完毕,当前ip已经指向正确位置时,用code[占位下标].arg = ip把真实地址填回去。这种backpatch模式是整个代码生成器的核心技巧,理解它之后,所有控制流的生成都不再是黑匣子。

一个细节代价:case N_WHILE后面多了分号;(写成case N_WHILE: ;)是因为C的case标签不能直接声明变量,需要在标签后第一个语句是声明之前插入一个空语句。loop_start和exit_jump两个变量的生命周期在case块内,C标准要求这样写才能通过编译器的语句声明混合检查。

4.4 过程调用与返回:用栈保存返回地址就够了

子集里的过程最多三个整型参数,无局部变量(局部变量可以稍后扩展)。核心翻译规则是:调用前把所有实参压栈,然后发CALL proc_id,过程执行完毕后把栈顶返回值存入一个固定位置,返回时RET跳回保存的地址。

参数传递我采用“传值+按位置对应”的方案。调用者生成的代码如下:

case N_PROC_CALL: ; for (int i = 0; i < n->nargs; i++) gen(n->kids[i]); // 每个实参求值并压栈 emit(VM_CALL, n->ival); // n->ival由语义分析填入过程编号 break;

被调用过程内部,参数已经按顺序压在两个位置。虚拟机执行CALL时需要保存当前指令指针到调用栈,然后跳转到过程入口地址;RET弹出保存的返回地址并跳回。这里不需要像x86那样维护栈帧指针,因为栈式计算天然把操作数栈和调用栈分离——操作数栈是数据,调用栈是控制流。这个简化是选型栈式虚拟机获得的直接红利,在报告里写清楚即可。

测试递归过程时(比如fac),每个CALL压返回地址、RET弹出,执行顺序与手算递推一致。若你一开始就不打算支持递归,可以在语义分析阶段检查“过程体内调用自己”并报错,然后把不支持写到文档中。两者都合法,但必须在设计报告中说明边界,是否递归不会影响栈式方案的正确性。

5. 小型Pascal子集编译器常见问题排查:五个真实踩坑记录

5.1 表达式优先级颠倒:1+2*3算出9

现象:语法分析器生成的AST是(+ 1 (* 2 3))的正确结构,但代码生成后虚拟机输出9,而不是期望的7。排查发现parse_expr在遇到+后递归调用自身而不是调用parse_term,导致所有算数变成左结合的同优先级。

原因:递归下降的优先级是靠函数调用层级体现的。parse_expr只该处理+ -,其操作数必须来自parse_term;parse_term只处理* /,其操作数来自parse_factor。写得太随意就会退化成全部在一个层级里。

解决:严格按三层写:parse_expr -> parse_term -> parse_factor。调试时可以打印AST结构,检查1+2*3的树是不是根节点+、右孩子是*。如果AST正确但虚拟机制输出错误,那就排查代码生成器的BINOP处理是否按先左后右的顺序递归——顺序反了同样会得9。

5.2 分号处理失当导致合法程序报错

现象:begin a:=1; b:=2; end被判定为语法错误,而begin a:=1; b:=2 end能编译。也就是“end前的最后一个分号”这个合法Pascal写法不能通过。

原因:在parse_stmt里强制消费分号,把分号当成语句终止符。Pascal语法中分号是语句分隔符,end前可以省略也可以出现。

解决:把分号处理上移到语句列表层,并在消费分号后检查下一个Token是否为END/ELSE。遇到则立即停止,不构建空语句节点。这个修复要同时覆盖begin end空块和if then else内部的分号场景。

5.3 语义分析报告“未声明变量”但代码生成仍然继续

现象:程序里写了未声明的变量x,语义分析阶段报错“x未声明”,但编译器没有退出,代码生成阶段又对x走了find_var并返回-1,最终LOAD -1导致虚拟机崩溃。

原因:语义分析和代码生成是独立遍历,但语义分析只打印错误没有阻止后续阶段执行。编译器缺少“出错即停止”的错误处理机制。

解决:错误处理采用全局错误计数器,任何一个阶段发现错误就累加计数,并在阶段结束时判断if (error_count > 0) exit(1)。词法、语法、语义三个阶段各自结束都要检查,这样任何错误都会在代码生成前拦截住。另外语义分析在报错时还可以把节点类型标记为T_ERROR,后续遍历看到一个T_ERROR就跳过生成,实现容错编译。

5.4 while循环代码生成遇到负标签:JZ目标跑到代码段中间

现象:两层嵌套while编译出的指令数组中,内部循环的JZ跳转目标地址指向了错误位置,执行时跳进指令中间导致崩溃。

原因:回填标签时使用了全局的ip,但嵌套循环中内部循环先回填、外部循环后回填,如果代码生成器里意外复用了跳转占位下标,或者计算目标地址时漏掉了前面process代码生成已经写入的指令数量,标签就对不齐。

解决:每个控制结构引入独立的局部变量记录占位下标,并在代码生成器里养成“先生成一个占位JMP/JZ,再在分支结束时回填”的习惯。排查手段是指令数组打印:生成完毕后把全部指令逐条打印,对照“条件为假时应跳到第几条、为真应跳到第几条”。把指令序列手工推演两遍,就能暴露几乎所有回填错误。

5.5 布尔表达式的短路求值:if (i<10) and (a[i]>0) 访问越界

现象:while (i < 10) and (a[i] > 0) do ...在i等于10时仍然执行了数组访问,虚拟机越界读内存,输出随机值。

原因:代码生成器对and运算符按普通BINOP处理——左右两边都求值压栈,再做逻辑与。这种“非短路”策略忽略了布尔运算语义:左侧为假时右侧不必求值。你的子集如果包含数组访问或过程调用,就必须处理短路。

解决:最简单做法是把子集限定为“布尔表达式只出现在条件位置,禁止在布尔表达式中调用过程或访问数组”,这属于文档闭环。如果要真正支持短路,需要给and和or生成特殊跳转链:and先把左操作数求值,为假则直接跳过整个表达式为假结果;or左真则跳过右操作数。我建议初级方案选前者——明确不支持短路,在设计报告“边界与限制”里写清楚,不给运行时留越界后门。

6. 把设计报告写厚:用测试矩阵和边界证明你的编译器不是玩具

设计报告的最终价值,不在于代码量,而在于“可被验证”。一个能跑最小例子的编译器,和一份能证明自己边界在哪里的编译器,是两回事。报告的结构我建议固定为五章:需求定义、总体设计、实现详情、测试与验证、扩展与教训。

最容易出彩的是“测试与验证”这一章,因为绝大部分人只贴一张输出截图。更专业的做法是设计一个分类测试矩阵,每一类都给出输入、预期行为和实际行为:

测试类别输入样例预期结果
词法a123 := 1;带下划线标识符正确识别T_ID
词法注释未闭合报错并指出行号
语法if a>0 then a:=1缺else编译通过,无报错
语法if a>0 then a:=1; else正确报错定位在else处
语义使用未声明变量拒绝生成指令并报错
运行时两层while循环计算1到100累加输出5050
运行时递归fac(5)输出120

验证过程要可脚本化。我一般写一个测试脚本,把每个用例的源程序喂给编译器入口,比对返回值和标准输出:

for t in tests/*.pas; do ./pascalc "$t" > "$t.out" if diff -q "$t.out" "$t.expected" > /dev/null; then echo "PASS $t" else echo "FAIL $t" fi done

这段脚本的价值是把“能跑”变成了“可重复证明能跑”。答辩现场有人怀疑某个用例时,直接跑一遍脚本展示所有PASS即可。

写设计报告时还有一个血泪经验:一定要在需求定义里放一页完整的“不支持特性”表格,包括浮点、数组、字符串、指针、嵌套过程、短路求值、case语句、repeat-until等每一项的理由。这样答辩时不会在边界上被问倒,反而能表现出“我明确知道自己做了哪些取舍”。我的习惯是,每次改代码前先把记录这类边界的表格核对一遍,新增一个特性就删掉一行,删除一个特性就补一行说明,保证文档与代码同步。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 22:05:16

Python替换PDF文本的三种可靠方案:从原理到代码实战

1. 为什么用Python替换PDF文本总是踩坑说实话&#xff0c;第一次接到“用Python查找并替换PDF中的文本”这个需求时&#xff0c;我觉得这还不简单&#xff1f;不就是读文件、找字符串、替换、保存一套组合拳吗。等真正打开一个PDF尝试改的时候才发现&#xff0c;这条路比想象中…

作者头像 李华
网站建设 2026/10/13 22:05:04

ThinkPHP在线教育系统源码实战:会员、考试与多端适配全链路拆解

简介&#xff1a;这是一套基于ThinkPHP框架开发的教育在线网完整源码&#xff0c;面向想学习PHP Web开发、研究在线教育平台架构的开发者与建站人员。系统集会员中心、PC端与WAP手机自适应页面于一体&#xff0c;并内置在线教学与考试模块&#xff0c;可用于搭建培训类教育站点…

作者头像 李华
网站建设 2026/10/13 22:04:48

游戏对象与资源管理:引擎架构中的关键设计与踩坑实践

很多刚接触游戏引擎的开发者&#xff0c;第一次写代码就是在场景里创建一个物体&#xff0c;改坐标、挂脚本、换材质。但时间长了你会发现&#xff0c;引擎里这个看似普通的“物体”&#xff0c;背后牵扯着一整套对象模型、生命周期管理和资源调度逻辑。游戏对象和资源管理&…

作者头像 李华
网站建设 2026/10/13 22:03:58

Kafka命令行高频操作实战:从Topic管理到问题排查全解析

折腾过 Kafka 的人都知道&#xff0c;平时排查问题、验证配置、做快速测试&#xff0c;最快的路径永远是敲命令行&#xff0c;而不是先打开可视化控制台。这篇是 Kafka 系列的第三篇&#xff0c;专门聊命令操作。我不打算逐个把 help 抄一遍&#xff0c;而是把生产环境里真正高…

作者头像 李华
网站建设 2026/10/13 22:01:56

Geant4蒙特卡洛模拟技术原理与应用实践

1. Geant4模拟技术概述Geant4是一款开源的蒙特卡洛模拟工具包&#xff0c;专门用于模拟粒子在物质中的输运过程。作为高能物理实验的"数字实验室"&#xff0c;它通过跟踪每个粒子的运动轨迹和相互作用&#xff0c;帮助研究人员预测实验装置的性能表现。我第一次接触G…

作者头像 李华
网站建设 2026/10/13 21:59:03

Android RecyclerView缓存机制详解:四级缓存与列表性能优化实践

做Android列表优化绕不开的一个硬核话题&#xff0c;就是RecyclerView的缓存机制。很多人都在用RecyclerView&#xff0c;也听说过ViewHolder复用&#xff0c;但一旦被问到“RecyclerView到底有几层缓存&#xff1f;”“为什么同一个item滑回来还会执行onBindViewHolder&#x…

作者头像 李华