PL/0运算符扩充实战:词法、优先级爬升与P-code代码生成
简介:这份资源是编译原理课程设计的完整实践包,面向计算机专业本科生及正在学习编译原理、需要完成课程设计或实验的学生。内容以经典 PL/0 语言为基础进行语法与语义扩充,包括赋值运算 += 与 -=,以及 Pascal 风格的 FOR…TO…DO 与 FOR…DOWNTO…DO 循环语句,分别对应步长 1 与 -1;选做部分实现自增自减运算和一维数组类型,涉及词法分析、语法分析、符号表与目标代码生成的协同修改,适合希望深入理解编译器前端与解释执行机制的读者参考。压缩包共 42 个文件,约 1.25MB,包含 pl0 源程序与测试用例、cod 目标代码、cpp/h 等 C++ 工程源码、PAS 参考实现、exe 可执行文件以及 doc 说明文档,覆盖源程序、编译产物与调试工程文件,便于直接运行验证和对照修改。已有 1056 人学习,可作为课程设计实现思路与测试样例的参考。
1. 需求先立住:PL/0 的运算符为什么非扩不可
有人接到编译原理课设题目“对 PL/0 作出修改扩充”,第一反应是加几个关键字、补两条语句,结果导师看完只问了一句:你这门语言能算(a + b) % 7吗?原始 PL/0 的表达式只有加减乘除和六个关系符,连取余都得靠循环减法硬凑,写个闰年判断、算个平均分都别扭。运算符是语言的门面,也是词法、语法、语义、代码生成四条链路唯一都要经过的公共通道——把它扩好,整条编译流水线才算真正打通,而不是在语法树上挂几个摆设。
这套方案就围绕运算符扩充展开:算术补%与**,逻辑补&& || !,位运算补& | ^ << >> ~,再加+= -= *= /= %=复合赋值,配套改词法扫描、优先级文法、语义检查和 P-code 虚拟机。适合拿 PL/0 做课设、需要一条能照着改完并验收的实现路线的人。
2. 词法分析:运算符表怎么从单字符扫描扩到最长匹配
词法分析器是整条流水线的第一道闸门。原始 PL/0 的getsym大概六十行,处理<=、>=、<>时手工 peek 一个字符,剩下全是+ - * / = #这种一字符一符号的直白映射。运算符一旦扩到二十多个,再按老写法堆if-else,读代码的人得拿放大镜找分派顺序,改一个符号要提心吊胆看三处。合理的做法是把“字符到符号”的映射拆成两层:一层是符号枚举与关键字表,另一层是统一的“首字符分派 + 前瞻”扫描。
2.1 符号枚举与关键字表的分层组织
枚举别图省事全塞一堆,按语义分组,后面优先级表和代码生成都要按组查表。分组的好处是bin_prec()和gen_binary()可以直接用switch覆盖一整段区间,不用逐个列举。
/* sym.h:按语义分组的符号枚举 */ typedef enum { SYM_NULL, SYM_IDENT, SYM_NUMBER, /* 算术:+ - * / % ** */ SYM_PLUS, SYM_MINUS, SYM_TIMES, SYM_SLASH, SYM_PERCENT, SYM_POWER, /* 关系:= <> < <= > >= */ SYM_EQL, SYM_NEQ, SYM_LESS, SYM_LEQ, SYM_GREATER, SYM_GEQ, /* 逻辑:&& || ! */ SYM_AND, SYM_OR, SYM_NOT, /* 位运算:& | ^ << >> ~ */ SYM_BAND, SYM_BOR, SYM_BXOR, SYM_SHL, SYM_SHR, SYM_BNOT, /* 赋值::= += -= *= /= %= */ SYM_BECOMES, SYM_PLUSEQ, SYM_MINUSEQ, SYM_TIMESEQ, SYM_SLASHEQ, SYM_PERCENTEQ, /* 界符 */ SYM_LPAREN, SYM_RPAREN, SYM_COMMA, SYM_SEMICOLON, SYM_PERIOD, /* 关键字 */ SYM_CONST, SYM_VAR, SYM_PROCEDURE, SYM_BEGIN, SYM_END, SYM_IF, SYM_THEN, SYM_ELSE, SYM_WHILE, SYM_DO, SYM_CALL, SYM_ODD, SYM_READ, SYM_WRITE, SYM_REPEAT, SYM_UNTIL, SYM_FOR, SYM_TO } SymType;关键字就十几个,线性查表足够,别上来就写哈希,课设里维护一张静态数组最省心。查表函数在标识符扫描结束后调用一次,命中就改写sym,没命中保留SYM_IDENT。
/* 关键字表:顺序无关,线性扫描即可 */ static const struct { const char *word; SymType sym; } keywords[] = { {"const", SYM_CONST}, {"var", SYM_VAR}, {"procedure", SYM_PROCEDURE}, {"begin", SYM_BEGIN}, {"end", SYM_END}, {"if", SYM_IF}, {"then", SYM_THEN}, {"else", SYM_ELSE}, {"while", SYM_WHILE}, {"do", SYM_DO}, {"call", SYM_CALL}, {"odd", SYM_ODD}, {"read", SYM_READ}, {"write", SYM_WRITE}, {"repeat", SYM_REPEAT}, {"until", SYM_UNTIL}, {"for", SYM_FOR}, {"to", SYM_TO}, {NULL, SYM_NULL} }; static SymType lookup_keyword(const char *id) { for (int i = 0; keywords[i].word; i++) if (strcmp(keywords[i].word, id) == 0) return keywords[i].sym; return SYM_IDENT; /* 不是关键字就是普通标识符 */ }lookup_keyword的返回值直接赋给全局sym,调用方不需要再判断,这样标识符与关键字的分支在getsym里只出现一次。参数说明:id是以\0结尾的标识符缓冲,长度由getsym的字母数字循环保证不超过MAX_IDENT_LEN。
2.2 多字符运算符的最长匹配实现
运算符扩充后,绝大多数翻车都出在“先看了一个字符就急着下结论”。>=被拆成>和=、**被当成两个乘号、&&被当成两个位与,这类问题在词法阶段不拦,语法阶段报的错会离现场十万八千里,调试起来纯属玄学。统一的做法是:读到运算符首字符后,先把下一个字符读进来,按两字符组合优先匹配,匹配不上再退回单字符。因为本方案里最长的运算符只有两个字符,一个字符的前瞻缓冲就够,不需要回退整个输入流。
/* 扫描一个符号,结果写入全局 sym,必要时携带 num 或 id */ static void getsym(void) { while (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') getch(); if (isalpha(ch)) { /* 标识符或关键字 */ int n = 0; while (isalnum(ch) && n < MAX_IDENT_LEN) { id[n++] = ch; getch(); } id[n] = '\0'; sym = lookup_keyword(id); return; } if (isdigit(ch)) { /* 无符号整数 */ num = 0; while (isdigit(ch)) { num = num * 10 + (ch - '0'); getch(); } sym = SYM_NUMBER; return; } /* 以下按首字符分派,先试两字符,再退回单字符 */ switch (ch) { case '>': getch(); if (ch == '=') { sym = SYM_GEQ; getch(); } else if (ch == '>') { sym = SYM_SHR; getch(); } else sym = SYM_GREATER; return; case '<': getch(); if (ch == '=') { sym = SYM_LEQ; getch(); } else if (ch == '<') { sym = SYM_SHL; getch(); } else if (ch == '>') { sym = SYM_NEQ; getch(); } else sym = SYM_LESS; return; case '*': getch(); if (ch == '*') { sym = SYM_POWER; getch(); } /* ** 优先于 *= */ else if (ch == '=') { sym = SYM_TIMESEQ; getch(); } else sym = SYM_TIMES; return; case '/': getch(); if (ch == '=') { sym = SYM_SLASHEQ; getch(); } else sym = SYM_SLASH; return; case '&': getch(); if (ch == '&') { sym = SYM_AND; getch(); } else sym = SYM_BAND; return; case '|': getch(); if (ch == '|') { sym = SYM_OR; getch(); } else sym = SYM_BOR; return; case '+': getch(); if (ch == '=') { sym = SYM_PLUSEQ; getch(); } else sym = SYM_PLUS; return; case '-': getch(); if (ch == '=') { sym = SYM_MINUSEQ; getch(); } else sym = SYM_MINUS; return; case '=': sym = SYM_EQL; getch(); return; /* = 只作相等,赋值走 := */ case ':': getch(); if (ch == '=') { sym = SYM_BECOMES; getch(); } else error(LEX_ERR_COLON); /* 孤立冒号直接报错 */ return; case '%': getch(); if (ch == '=') { sym = SYM_PERCENTEQ; getch(); } else sym = SYM_PERCENT; return; case '^': sym = SYM_BXOR; getch(); return; case '~': sym = SYM_BNOT; getch(); return; case '!': sym = SYM_NOT; getch(); return; case '(': sym = SYM_LPAREN; getch(); return; case ')': sym = SYM_RPAREN; getch(); return; case ',': sym = SYM_COMMA; getch(); return; case ';': sym = SYM_SEMICOLON; getch(); return; case '.': sym = SYM_PERIOD; getch(); return; default: error(LEX_ERR_ILLEGAL_CHAR); } }几个参数要盯住。ch是当前未消费字符,getch()负责从输入流取下一个并赋给它;sym是当前符号,供语法分析器消费。分派顺序本身就是规则:*分支里**必须先于*=判断,否则**=(如果你以后要支持)永远匹配不到;<分支里<=、<<、<>的判断次序无冲突,但仍建议固定下来,方便对照。若将来要加三字符运算符(比如<<=),把前瞻缓冲扩成两个字符、按长度从长到短依次尝试即可,结构不变。
要注意全局sym的更新时机:getsym每次被调用就会推进一个符号,所以语法分析里凡是“看下一个符号再决定”的地方,都要么先保存sym到局部变量,要么设计成标准的一符号前瞻(先getsym再判断)。课设里绝大多数“解析器莫名其妙提前结束”的最后查出来都是这里多调了一次或少调了一次。
2.3 词法阶段就该拦住的三类输入
第一类是非法字符。@、$、反引号这些不在符号表里的字符,必须在getsym的default分支立刻报错并打印行列号,不能默默跳过。跳过一次的代价是后面语法分析在一个完全错位的符号序列上做判断,报出来的错误位置和真实原因差出十几行。
第二类是数字与标识符粘连。12abc这种输入,有的实现会先读成数字12,再读成标识符abc,于是语法树变成两个并列的因子,报错信息却是“缺少分号”,非常误导。稳妥做法是在数字扫描结束后检查isalpha(ch),命中就报“数字后不能紧跟字母”,错误码单列。
第三类是注释符与除号的冲突。原始 PL/0 用{ }或(* *)作注释,如果你顺手改成//行注释,就必须在getsym的空白跳过循环里先判//,再在运算符分派里判/,顺序颠倒会把注释开头当成分号除。还有一个容易被忽略的点::=与=的语义分离要守住。加了+=、-=之后,很多实现为了省事把=也当成赋值,结果关系表达式里的相等判断静默变成赋值,运行结果全错却没有任何编译错误,这种坑最深。
3. 语法分析:优先级爬升怎么把十级运算符压进一个表达式函数
词法把符号切出来,语法决定谁先算。原始 PL/0 用三层递归硬编码两个优先级:expression管加减,term管乘除,factor管括号和原子。扩到十级还这么写,就是十个函数层层下探,改一个优先级要动一串函数签名,添加一元运算符还得再插层。优先级爬升(precedence climbing)把层级压成一张表加一个递归函数,改优先级只改表里的数字,这是本方案在语法层最值得讲的一个设计选择。
3.1 从分层递归到优先级表驱动
先定表。级别数字越大绑定越紧,结合性单独标注:
| 级别 | 运算符 | 结合性 | 说明 |
|---|---|---|---|
| 1 | || | 左 | 逻辑或,短路 |
| 2 | && | 左 | 逻辑与,短路 |
| 3 | | | 左 | 位或 |
| 4 | ^ | 左 | 位异或 |
| 5 | & | 左 | 位与 |
| 6 | = <> < <= > >= | 左 | 关系比较 |
| 7 | << >> | 左 | 移位 |
| 8 | + - | 左 | 加减 |
| 9 | * / % | 左 | 乘、除、取余 |
| 10 | ** | 右 | 幂 |
| 前缀 | - + ! ~ | 右 | 一元运算 |
这里要交代一个刻意的取舍:C 语言里关系运算的优先级高于位运算,a & b == c会被解析成a & (b == c),是出了名的反直觉。本方案把位运算整体提到关系之上,理由是课设里的位运算多用于标志位拼接,很少和关系比较混写,这样排更符合“先算位、再比大小”的直觉。如果你想和 C 对齐,把 3~5 级挪到 6 之下即可——这正是表驱动的好处,改表不改函数。答辩时被问到“为什么不按 C 来”,这就是现成的回答。
3.2 结合性与一元前缀的处理
核心函数只有一个,参数min_prec表示“当前这一层能接受的最低优先级”:
/* 二元运算符优先级,0 表示不是二元运算符 */ static int bin_prec(SymType op) { switch (op) { case SYM_OR: return 1; case SYM_AND: return 2; case SYM_BOR: return 3; case SYM_BXOR:return 4; case SYM_BAND:return 5; case SYM_EQL: case SYM_NEQ: case SYM_LESS: case SYM_LEQ: case SYM_GREATER: case SYM_GEQ: return 6; case SYM_SHL: case SYM_SHR: return 7; case SYM_PLUS: case SYM_MINUS: return 8; case SYM_TIMES: case SYM_SLASH: case SYM_PERCENT: return 9; case SYM_POWER: return 10; default: return 0; } } /* 解析表达式,min_prec 以下的运算符留给外层处理 */ static void parse_expr(int min_prec) { parse_unary(); /* 左操作数(含一元前缀) */ for (;;) { SymType op = sym; int prec = bin_prec(op); if (prec == 0 || prec < min_prec) return; /* 优先级不够,返回给外层 */ getsym(); /* 越过运算符 */ /* 左结合右操作数用 prec+1,右结合用 prec,这一行决定 ** 是不是右结合 */ int next_min = (op == SYM_POWER) ? prec : prec + 1; if (op == SYM_AND || op == SYM_OR) { gen_logic(op, next_min); /* 短路,单独处理,见 3.3 */ } else { parse_expr(next_min); gen_binary(op); /* 生成二元运算指令 */ } } } /* 一元前缀:允许 - - a、!~b 这类连续前缀 */ static void parse_unary(void) { switch (sym) { case SYM_MINUS: getsym(); parse_unary(); gen_opr(OPR_NEG); break; case SYM_PLUS: getsym(); parse_unary(); break; /* 无操作 */ case SYM_NOT: getsym(); parse_unary(); gen_opr(OPR_NOT); break; case SYM_BNOT: getsym(); parse_unary(); gen_opr(OPR_BNOT); break; default: parse_primary(); break; } }min_prec是整套逻辑的枢纽。进入循环时,如果当前符号的优先级低于门槛,说明这个运算符属于外层,直接返回,让调用它的那层继续消费;如果高于门槛,就吃掉运算符、递归解析右侧。左结合和右结合的差别全在next_min这一行:2 ** 3 ** 2因为**是右结合,右侧递归传的是prec本身,于是先算出3 ** 2;如果误写成prec + 1,结果就变成(2 ** 3) ** 2,数值从 512 掉到 64,输入合法、输出错误,这类 bug 只靠看代码很难发现,必须靠用例表兜住。
parse_unary用递归而不是循环,是为了让- -a和!(!b)都能自然解析。一元运算符右结合,正好对应递归调用。
3.3 短路逻辑在语法层的骨架
&&和||不能像加减乘除那样“先把两边都算出来再合并”——x != 0 && y / x > 1里右边求值有除零风险,p != NULL && p->next != NULL里右边依赖左边为真的前提。语法层必须把它们从parse_expr的通用分支里摘出来,生成“条件跳转 + 回填”的结构,而不是先解析完右子树再统一生成指令。
/* 生成短路逻辑:占位跳转 → 解析右侧 → 回填目标 */ static void gen_logic(SymType op, int next_min) { int jump = gen_jpc_placeholder(); /* 生成一条 JPC,目标地址先写 -1 */ parse_expr(next_min); /* 右侧照常解析,但只在需要时执行 */ if (op == SYM_AND) { patch_jump(jump, next_instr); /* 左侧为假,跳到整个表达式之后 */ gen_opr(OPR_AND_MERGE); /* 右侧为真则压 1,否则压 0 */ } else { patch_jump(jump, next_instr); gen_opr(OPR_OR_MERGE); } }占位与回填是课设里最值得讲的一个技巧,也是答辩高频追问点:生成跳转指令时目标地址还不知道,先留一个空位,等右侧代码生成完,目标地址确定,再回头把空位填上。gen_jpc_placeholder()返回的是该跳转指令在代码段中的下标,patch_jump(idx, target)按这个下标改指令参数。参数next_min保证右侧只吃比当前逻辑运算符更紧的运算符,a || b && c会解析成a || (b && c),与优先级表一致。
4. 语义检查与代码生成:新运算符怎么落到 P-code 指令上
语法树正确只是第一步,运算符真正的语义要落到两件事上:编译期把非法的组合拦下来,运行期把每个运算符映射成栈式虚拟机能执行的指令。这一步做不扎实,扩充出来的运算符就是“能解析、不能算”,演示时一按就露馅。
4.1 单一整型下的类型检查与错误码
PL/0 原本只有整型,符号表里只有名字、种类、层级、地址四个字段,虚拟机也只有整数栈。要不要为逻辑运算引入boolean类型,是个需要提前定的事。稳妥的做法是不引入:逻辑运算结果用 0/1 表示,!x定义为x == 0 ? 1 : 0,~x按位取反。理由很实际——引一个布尔类型要同时改符号表、存储分配、比较指令、输出格式四处,课设周期内收益远小于成本,而且 0/1 约定足够让if、while的条件判断照常工作。
类型不引,语义检查不能省。至少要覆盖三条,每条给独立错误码,报错时打印行号:
| 错误码 | 含义 | 触发场景 |
|---|---|---|
| E30 | 复合赋值左值非法 | +=左侧不是简单变量,如a + b += 1 |
| E31 | 常量除零或模零 | 常量折叠阶段发现除数为 0 |
| E32 | 幂运算指数为负 | 常量折叠阶段发现2 ** -1 |
| E33 | 移位位数越界 | 常量折叠阶段发现移位量 ≥ 32 或为负 |
复合赋值这条最容易被放过。a += b展开成a := a + b看起来天经地义,但如果左侧是arr[i++],展开后i++执行两次,下标直接错位。课设级别的合理收敛是:左值只允许简单变量,遇到parse_primary返回的不是SYM_IDENT就报 E30,把问题挡在编译期,而不是运行期留下一颗定时炸弹。
/* 语义检查:复合赋值左侧必须是已声明的简单变量 */ static void check_compound_lvalue(void) { if (sym != SYM_IDENT) error(SEM_ERR_LVALUE); if (table[cur_level].kind != VAR) error(SEM_ERR_LVALUE); /* 常量、过程名都不行 */ }cur_level是当前嵌套层级,table是符号表的当前层,kind区分常量、变量、过程。这个检查放在解析赋值语句的分支里、生成代码之前,保证出错时一行代码都还没生成,不会留下半截中间代码干扰后续判断。
4.2 OPR 指令扩展与栈式求值
P-code 的算术运算全走OPR指令,操作数从栈顶弹出,结果压回栈顶。原始 PL/0 用了OPR 0 2到OPR 0 5表示加减乘除,新运算符接着往后编号即可,不必新增指令类型。
| OPR 编号 | 语义 | 栈变化 |
|---|---|---|
| 2 / 3 / 4 / 5 | 加 / 减 / 乘 / 除 | pop b, pop a, push a op b |
| 7 | 取余 | pop b, pop a, push a % b |
| 8 / 9 | 相等 / 不等 | pop b, pop a, push 0 或 1 |
| 10 / 12 | 小于 / 大于 | 同上 |
| 11 / 13 | 大于等于 / 小于等于 | 同上 |
| 14 | 幂 | pop b, pop a, push a ** b |
| 15 | 位与 | pop b, pop a, push a & b |
| 16 / 17 | 位或 / 位异或 | 同上 |
| 18 / 19 | 左移 / 右移 | pop b, pop a, push a << b |
| 20 / 21 | 按位取反 / 逻辑非 | pop a, push ~a 或 !a |
| 22 | 取负 | pop a, push -a |
生成端只做映射,不掺逻辑,保持一个switch到底:
/* 根据运算符生成对应的 OPR 指令,两操作数已在栈顶 */ static void gen_binary(SymType op) { switch (op) { case SYM_PLUS: gen_opr(2); break; case SYM_MINUS: gen_opr(3); break; case SYM_TIMES: gen_opr(4); break; case SYM_SLASH: gen_opr(5); break; case SYM_PERCENT: gen_opr(7); break; case SYM_POWER: gen_opr(14); break; case SYM_BAND: gen_opr(15); break; case SYM_BOR: gen_opr(16); break; case SYM_BXOR: gen_opr(17); break; case SYM_SHL: gen_opr(18); break; case SYM_SHR: gen_opr(19); break; default: error(SEM_ERR_BAD_OP); /* 理论上到不了这里 */ } }执行端在虚拟机的interpret循环里补case,重点是边界处理:
case 7: { /* 取余 */ pop(&b); pop(&a); if (b == 0) rt_error(RT_ERR_MOD_ZERO, pc); push(a % b); /* C 的 % 向零取整,与常见语言一致 */ break; } case 14: { /* 幂:快速幂,避免 O(n) 次连乘 */ pop(&b); pop(&a); if (b < 0) rt_error(RT_ERR_POW_NEG, pc); long r = 1, base = a; int e = b; while (e) { if (e & 1) r *= base; base *= base; e >>= 1; } push((int)r); break; } case 19: { /* 右移:先校验位数,避免未定义行为 */ pop(&b); pop(&a); if (b < 0 || b >= 32) rt_error(RT_ERR_SHIFT_RANGE, pc); push(a >> b); /* 算术右移,负数补符号位 */ break; }rt_error打印当前pc和行号后终止执行,这是运行期唯一的“黑匣子”出口,所有除零、模零、指数为负、移位越界都从这里报,方便定位。幂用快速幂而不是循环连乘,一方面演示时有讲头,另一方面避免2 ** 30把虚拟机卡死。
4.3 短路与复合赋值的中间代码形态
短路逻辑的最终形态是一段带跳转的指令序列。以x && y为例,约定结果压 0 或 1:
LOD x JPC L1 ; x 为假,直接跳到 L1 LOD y JPC L1 ; y 为假,跳到 L1 LIT 1 JMP L2 L1: LIT 0 L2: ... ; 整个表达式的结果在栈顶||对称,只是把JPC(假跳)换成JPT(真跳),首值为真就短路。这里的关键是右侧代码必须嵌在跳转之后生成,如果先解析完再统一生成,短路语义就丢了。
复合赋值的形态更简单,a += b直接展开:
LOD a ; 取 a 的值 LOD b OPR 2 ; 加 STO a ; 写回 a因为前面已经把左值限制为简单变量,LOD a和STO a用的是同一个地址,不存在重复求值问题。如果以后要放开到数组元素,就必须引入临时单元先存下标,这一步在课设里不建议展开。
5. 避坑排查:运算符扩充里最容易翻车的五处
5.1 最长匹配顺序颠倒
现象:a >= b报“缺少操作数”,x ** 2算出来是x * 2再乘一个空。原因:getsym里先判了单字符>和*就return,双字符分支根本没机会执行;或者*分支里*=排在**前面,**永远匹配不到。解决:固定“长优先”原则,每个首字符分支内按两字符、单字符的顺序排列;写完后用一张符号对照表逐行过一遍,把>= <= <> ** << >> && || += -= *= /= %= :=全部手工验证一遍,别等语法分析报错再回头找。
5.2 优先级表与结合性错位
现象:2 ** 3 ** 2得 64 而不是 512;a - b - c得a - (b - c)。原因:parse_expr里左结合传了prec、右结合传了prec + 1,正好写反;或者bin_prec里**和*给了同一级。解决:在parse_expr里把next_min的那一行单独加注释;准备三条最小用例——2 ** 3 ** 2、10 - 3 - 2、16 / 4 / 2——每次改优先级表都跑一遍。左右结合写反是最典型的“输入合法、输出错误”,只能靠用例兜住。
5.3 复合赋值左值重复求值
现象:arr[i++] += 1之后i增加了 2,数组被写到了错误位置。原因:把a += b直接展开成a := a + b,左值表达式在LOD和STO里各求值一次。解决:在语义检查阶段直接把左值限定为简单变量,非SYM_IDENT或非VAR一律报 E30。这一刀砍得越早越省事,别指望在代码生成阶段“聪明地”识别副作用。
5.4 短路求值退化成普通二元运算
现象:x != 0 && y / x > 1在x = 0时抛除零;p != NULL && p->val > 0直接崩溃。原因:&&和||被当成普通二元运算符,先解析两边再合并,右侧无条件求值。解决:在parse_expr的循环体里把逻辑运算符单独摘出来走gen_logic,先生成占位跳转、再解析右侧、最后回填目标地址。验证方法很直接:写一个左侧恒假、右侧必然除零的表达式,能正常返回 0 就说明短路生效。
5.5 虚拟机取余与除零行为不一致
现象:-7 % 3在编译器里算出来是-1,换台机器变成2;除数为 0 时虚拟机直接段错误退出,没有任何提示。原因:C 标准规定整数除法向零取整,取余符号随被除数,但不同实现若混用了浮点或自行推导,就会漂移;运行期错误没有统一出口。解决:虚拟机里显式检查b == 0并调用rt_error报错退出,取余统一用 C 的%语义并在文档里写明“向零取整”;把-7 % 3、7 % -3、0 % 5三条写进回归用例。
6. 验收与答辩:用一张用例矩阵把每个扩充点证明一遍
课设验收不是“跑通一个 Hello World”就完事,导师要看的是每个扩充点都有对应证据。我的习惯是先列一张用例矩阵,一行一个运算符或一类交互,四列:输入、期望输出、覆盖点、备注。矩阵填满,演示脚本自然就有了。
| 输入片段 | 期望输出 | 覆盖点 | 备注 |
|---|---|---|---|
write(17 % 5); | 2 | 取余 | 基础算术 |
write(2 ** 10); | 1024 | 幂、快速幂 | 对比循环连乘 |
write(2 ** 3 ** 2); | 512 | 幂右结合 | 写错会得 64 |
write(1 < 2 && 3 > 2); | 1 | 逻辑与、关系 | 短路基本形态 |
write(0 && 1 / 0); | 0 | 短路 | 不报除零即通过 |
write(5 & 3); | 1 | 位与 | 二进制101 & 011 |
write(1 << 4); | 16 | 左移 | 移位量校验 |
a := 10; a += 5; write(a); | 15 | 复合赋值 | 左值简单变量 |
a + b += 1; | 编译错误 E30 | 语义检查 | 左值非法 |
write(1 / 0); | 运行期错误 | 除零 | 错误码 + 行号 |
表格之外,答辩现场最容易被追问的三个点是:优先级表为什么这么排、短路是怎么实现的、复合赋值为什么限制左值。前两个在方案里有明确答案,第三个就按“避免重复求值副作用”回答,顺手把arr[i++] += 1的反例写在纸上,比空口解释有说服力。
演示顺序也有讲究。先跑一条全绿的用例证明基本功能,再故意改坏一处——比如把**的next_min从prec改成prec + 1——现场展示512变成64,然后改回来。这一步是在告诉评审:我知道每个设计点为什么这么写,不是抄来的。最后留两分钟讲错误处理,把 E30 和运行期除零各触发一次,展示行号和错误码,比讲十分钟架构更能证明工程完成度。
从那以后我每次带课设验收,都强制自己先把用例矩阵填满再写演示脚本,矩阵里空一行就说明有一个扩充点没被证明。希望这套结构能帮到你,少走几段我当年踩过的弯路。
本文还有配套的精品资源,点击获取