简介:面向高校编译原理课程学习者与课程实验开发者,这份广工编译原理实验资料围绕教学型PL/0编译程序展开,重点解决词法分析、语法分析与语义处理环节的修改扩充问题,适合在掌握编译流程基础后开展课程设计或期末实验复现。压缩包共24个文件、约644KB,包含C++源码、头文件、工程配置、可执行程序与调试中间文件,以及实验报告文档,能够支撑从代码阅读、工程编译到结果验证的完整实验链路。资源已有791人学习,说明其在同类课程实验中具有一定参考价值。读者可据此完成保留字ELSE、FOR、TO、DOWNTO、RETURN的加入,实现+=、-=、++、--等运算符识别,将不等号#改为<>,并为条件语句补充ELSE子句,同时结合实验报告理解修改思路与测试方法,积累PL/0编译器改造和排错经验。
1. 从一台只认 if-then 的 PL/0 说起
词法分析器把else当成普通标识符,语法分析器在then分支之后撞上elsesym直接报 16 号错误——这是编译原理实验里最常见的翻车现场之一。这份 PL/0 编译器源码把「保留字表扩表」和「ELSE 语句文法规约」两件事拆成可替换的模块:词法层负责把字符流切成 token,语法层用递归下降吃掉 if-then-else,代码层往栈式虚拟机里吐 JPC/JMP 指令并做地址回填。它适合刚上编译课、既要交实验报告又要能对着代码讲清实现细节的同学,也适合想拿一个千行以内的 C 工程练手写编译器的老手。跑通之后你能亲眼看到源程序从文本变成指令序列,再被解释器一条条执行出来的全过程。
2. 项目骨架与技术选型:PL/0 的五段式流水线
2.1 语言选型和目录结构
这类实验最怕选型选歪。用 C99 而不是 Java,是因为递归下降里大量用全局状态(当前 tokensym、当前字符ch、当前代码分配指针cx),C 的结构体和全局变量让状态流转一眼可见,调试时打几个断点就能看到符号表和指令数组怎么长出来;用 Java 包一层类反而让状态散在若干对象里,排查回填地址错位时很痛苦。也不建议直接上 ANTLR 之类的工具生成解析器,实验的评分点往往就是手写getsym和statement这一层。
目录按编译阶段切分,一个文件只干一件事:
| 路径 | 职责 | 关键全局状态 |
|---|---|---|
include/pl0.h | 符号枚举、指令枚举、全局声明 | cx、tx、lev |
src/lexer.c | nextch/getsym,字符流到 token | ch、sym、id |
src/symtab.c | 符号表table[]的进入与查找 | tx、table |
src/parser.c | 递归下降,block/statement/expression | fsys集合 |
src/codegen.c | gen与指令数组code[] | cx、code |
src/interp.c | 栈式虚拟机解释执行 | s[]、p、b |
src/main.c | 参数解析、串起整条流程 | errcnt |
test/*.pl0 | 用例,含带 else 的正常与异常样本 | - |
test/目录别省。我见过太多人改完else分支后只跑一个if x > 1 then y := 1 else y := 2;,看着输出对了就交,结果老师换一个嵌套if的用例立刻挂掉。用例要覆盖四种形态:无 else 的 if、单层 if-else、if-else 里再嵌 if(悬空 else)、else 后面直接跟语句序列。
2.2 一次编译的完整数据流
整条链路是这样串起来的:main打开源文件 →nextch()预读第一个字符 →getsym()取第一个 token →block(0)进入最外层分程序 → 遇到if走statement的 if 分支 →condition()编译条件表达式并留一个布尔值在栈顶 →gen(OP_JPC, 0, 0)先占位 → 编译 then 分支 → 若遇到else再补一条gen(OP_JMP, 0, 0)→ 最后把两条指令的a字段回填成真实跳转地址。整个过程里cx是唯一"当前指令写到哪"的游标,gen写一条就加一,回填就是事后改code[i].a。
block的分程序逻辑也要留意:进入时lev++、dx归零、tx记下当前符号表位置,退出时把这三个值还原。这一套是 PL/0 的经典约定,else分支里如果再声明局部变量(虽然 PL/0 文法不允许语句块内声明,但改造成begin...end语句块时容易手滑),就必须走同一套lev/dx/tx保存恢复流程,否则运行时的LOD/STO层次参数会错位,表现为变量读出来是别人的值。
2.3 构建与运行:Makefile 加两条命令
构建脚本很短,关键是开-Wall -Wextra,让编译器替你抓未初始化变量和隐式声明——词法分析里ch忘了初始化是很典型的低级错误:
# Makefile:不依赖第三方库,只要求 C99 CC = gcc CFLAGS = -std=c11 -Wall -Wextra -g -Iinclude SRC = src/main.c src/lexer.c src/symtab.c src/parser.c src/codegen.c src/interp.c OBJ = $(SRC:.c=.o) pl0: $(OBJ) $(CC) $(CFLAGS) -o $@ $(OBJ) %.o: %.c include/pl0.h $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(OBJ) pl0-Iinclude让#include "pl0.h"不用写相对路径;-g是给 gdb 用的,回填地址错了要靠它打断点看code[]。编出来之后两种跑法:
# 只编译,打印目标指令清单,方便对着源码逐句核对 ./pl0 test/case_else.pl0 # 编译并立刻解释执行,输出 read 语句读入、write 语句写出的结果 ./pl0 -r test/case_else.pl0-r这个开关不是装饰。实验验收时老师经常问"你这条 JMP 到底跳没跳对",只看指令清单是看不出来的,必须跑起来看输出。遇到结果不对,先别急着改语法分析,用只编译模式把指令 dump 出来,和手算的地址表对一遍——八成是回填偏了一条。
2.4 目标指令集约定
虚拟机指令只有九条,gen(f, l, a)三个字段分别是指令码、层次差、参数。理解清楚这几条指令的语义,改 else 的时候才敢动a字段:
| 指令 | 语义 | else 改造中的作用 |
|---|---|---|
LIT 0 a | 常量 a 压栈 | 条件里比较 0 |
LOD l a | 取 l 层偏移 a 的变量 | 读变量 |
STO l a | 栈顶存入变量 | 赋值 |
JMP 0 a | 无条件跳到 a | then 分支结束跳过 else |
JPC 0 a | 弹栈顶,为 0 则跳到 a | 条件为假跳 else |
OPR 0 a | 算术/关系/读写子功能 | 比较运算 |
CAL/INT/RET | 过程调用/栈帧分配/返回 | 与 else 无关 |
关键点:JPC是弹栈的。条件表达式编译完,栈顶一定有一个布尔值,JPC把它弹掉再判断。这个约定决定了你后面加if ... and ...之类的短路逻辑时不能简单堆两条JPC,得先想清楚栈上的东西什么时候消失。
3. 词法分析改造:保留字表与 ELSE 的识别链路
3.1 符号枚举与保留字表怎么排
原版 PL/0 的保留字表通常是 13 个(begin、call、const、do、end、if、odd、procedure、program、read、then、var、while),else是这道实验要求新增的那一个。改造时最容易犯的错是只往表里塞字符串,忘了同步枚举和计数。正确做法是把枚举和表放一起维护,让编译器帮你数:
/* include/pl0.h:符号类型,新增 SYM_ELSE 放在末尾不影响已有编号 */ typedef enum { SYM_NUL, SYM_IDENT, SYM_NUMBER, SYM_PLUS, SYM_MINUS, SYM_TIMES, SYM_SLASH, SYM_ODD, SYM_EQL, SYM_NEQ, SYM_LSS, SYM_LEQ, SYM_GTR, SYM_GEQ, SYM_LPAREN, SYM_RPAREN, SYM_COMMA, SYM_SEMICOLON, SYM_PERIOD, SYM_BECOMES, SYM_BEGIN, SYM_CALL, SYM_CONST, SYM_DO, SYM_END, SYM_IF, SYM_PROCEDURE, SYM_PROGRAM, SYM_READ, SYM_THEN, SYM_VAR, SYM_WHILE, SYM_ELSE /* 新增,放在最后,避免打乱前面的编号 */ } SymKind; typedef struct { const char *name; SymKind kind; } ReservedWord;枚举值放在末尾是有讲究的:有些同学的代码里用整数硬编码比较过sym > SYM_BECOMES来判断是否是关键字区间,插在中间会把判断全打乱。表本身这样写:
/* src/lexer.c:保留字表,长度由 sizeof 推导,不写死数字 */ static const ReservedWord reserved[] = { {"begin", SYM_BEGIN}, {"call", SYM_CALL}, {"const", SYM_CONST}, {"do", SYM_DO}, {"end", SYM_END}, {"else", SYM_ELSE}, {"if", SYM_IF}, {"odd", SYM_ODD}, {"procedure", SYM_PROCEDURE}, {"program", SYM_PROGRAM}, {"read", SYM_READ}, {"then", SYM_THEN}, {"var", SYM_VAR}, {"while", SYM_WHILE} }; #define RESERVED_COUNT ((int)(sizeof(reserved) / sizeof(reserved[0])))用sizeof推长度,是给自己留后悔药。原版教材代码里常写const char *word[13]加一个norw = 13,加 else 的时候只改了数组没改norw,查表循环跑不到第 14 个元素,else永远匹配不上,然后你就开始怀疑strcmp坏了。用宏推长度,这种错误从源头上没了。
顺带说大小写:PL/0 原版一律小写。如果你的实验要求关键字不区分大小写,别在getsym里到处tolower,收完标识符一次性转小写再查表,代码干净得多。
3.2 getsym:从字符流到 token 的分流
getsym是整个项目里被改次数最多的函数,因为每个实验都会让你加点东西。它的骨架是"跳空白 → 按首字符分三路":
/* src/lexer.c:取下一个符号,核心是三路分流 */ void getsym(void) { while (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') nextch(); if (isalpha((unsigned char)ch)) { /* 路径一:标识符或保留字 */ int k = 0; while (isalnum((unsigned char)ch)) { if (k < IDMAX - 2) id[k++] = (char)tolower((unsigned char)ch); nextch(); } id[k] = '\0'; sym = SYM_IDENT; /* 先假定是普通标识符 */ for (int i = 0; i < RESERVED_COUNT; i++) { if (strcmp(id, reserved[i].name) == 0) { sym = reserved[i].kind; /* 命中保留字则改判 */ break; } } } else if (isdigit((unsigned char)ch)) { /* 路径二:无符号整数 */ int k = 0; num = 0; while (isdigit((unsigned char)ch)) { num = num * 10 + (ch - '0'); if (k < IDMAX - 2) id[k++] = ch; nextch(); } id[k] = '\0'; sym = SYM_NUMBER; } else if (ch == ':') { /* 路径三:赋值号 := */ nextch(); if (ch == '=') { sym = SYM_BECOMES; nextch(); } else { sym = SYM_NUL; error(4); } } else if (ch == '<') { /* 关系运算符,注意双字符 */ nextch(); if (ch == '=') { sym = SYM_LEQ; nextch(); } else if (ch == '>') { sym = SYM_NEQ; nextch(); } else sym = SYM_LSS; } else if (ch == '>') { nextch(); if (ch == '=') { sym = SYM_GEQ; nextch(); } else sym = SYM_GTR; } else if (ch == EOF) { /* 文件尾,交给语法层报错 */ sym = SYM_NUL; } else { sym = single_char_sym(ch); /* + - * / ( ) , ; . 等 */ nextch(); } }几个参数和边界值得逐条说明。IDMAX决定标识符最大长度,一般取 10 到 32,写小了长变量名会被静默截断,两个不同变量撞成同一个,表现为"改了 a 的值 b 也变了",这种坑非常难查。k < IDMAX - 2留的余量是给结尾\0和可能的超标一位用的。tolower之前先(unsigned char)强转,是防止传进负数导致未定义行为,虽然实验用例里不会出现,但养成习惯不吃亏。
<=和<>这类双字符运算符一定要先nextch()再多看一位,别对ch做switch之后忘了推进,否则会在同一个符号上死循环——这个 bug 的典型表现是程序卡住不动,CPU 拉满,你还以为是递归没出口。
3.3 加完 else 之后怎么自测
别急着接语法分析。先给词法加一个-ttoken dump 模式,跑十几行代码看看符号序列对不对:
/* src/main.c:-t 模式只做词法扫描并打印,不进入 parser */ if (tokdump) { do { getsym(); printf("%-12s %s\n", sym_name(sym), sym == SYM_IDENT || sym == SYM_NUMBER ? id : ""); } while (sym != SYM_NUL); return 0; }sym_name是一张switch到字符串的表,写它花五分钟,能省下后面两小时。跑一段if x > 0 then y := 1 else y := 2;,你应该看到SYM_IF、SYM_IDENT、SYM_GTR、SYM_NUMBER、SYM_THEN……中间出现SYM_ELSE而不是SYM_IDENT(else)。这一步过了,词法改造就算完成,后面语法报错一定是语法层的事,定位范围直接砍一半。
4. 语法分析改造:把 if-then 扩成 if-then-else
4.1 文法与 FIRST 集:为什么必须先看 elsesym
改造后的语句文法大致是:
<语句> ::= <赋值语句> | <条件语句> | <当型循环> | <复合语句> | <读语句> | <写语句> | ε <条件语句> ::= if <条件> then <语句> [ else <语句> ]问题出在可选项[ else <语句> ]。递归下降要在then分支的statement返回之后判断"下一个符号是不是 else",这要求then分支的statement结束时不能把else当成错误吞掉。也就是说,语句的 FOLLOW 集合必须包含SYM_ELSE,statement末尾那个test(fsys, n)检查要放行它。这一步没做,你会看到在then分支结束位置稳定报一个语法错误,位置永远卡在 else 上——这是 ELES 改造里第二常见的坑,仅次于保留字表没改。
具体做法是把语句级的fsys集合从{分号, end, 0}扩成{分号, end, else, 0}:
/* src/parser.c:语句的跟随符号集合,必须显式加入 SYM_ELSE */ static int fsys_stmt[] = { SYM_SEMICOLON, SYM_END, SYM_ELSE, 0 }; static int inset(int *set, int s) { for (int i = 0; set[i] != 0; i++) if (set[i] == s) return 1; return 0; } static void test(int *fsys, int errcode) { if (!inset(fsys, sym)) { error(errcode); } }用 0 结尾的数组当集合,是 C 里最简单的做法,inset线性扫描在几十个符号的规模下完全不值得优化。但要注意:集合里加SYM_ELSE之后,statement的调用方(比如block里statement(fsys)之后跟着test(fsys, ...)的地方)不能再指望"语句结束一定看到分号"。所有上层的 fsys 都要重新审一遍,漏掉一处就会出现"else 被当成结束符,if 语句被提前判定结束"的诡异现象。
4.2 statement() 的改写与 JPC/JMP 回填
这是整个实验的核心二十行。写法如下:
/* src/parser.c:if 语句带 else 的代码生成与地址回填 */ static void statement(int *fsys) { if (sym == SYM_IF) { getsym(); condition(); /* 生成条件代码,栈顶留布尔值 */ if (sym == SYM_THEN) getsym(); else error(16); /* 16 号:缺 then */ int jpc = cx; /* 占位:条件为假时跳哪 */ gen(OP_JPC, 0, 0); statement(fsys); /* then 分支 */ if (sym == SYM_ELSE) { getsym(); int jmp = cx; /* 占位:then 结束跳到整体后面 */ gen(OP_JMP, 0, 0); code[jpc].a = cx; /* 回填:假分支落到 else 首条指令 */ statement(fsys); /* else 分支 */ code[jmp].a = cx; /* 回填:then 结束跳过 else */ } else { code[jpc].a = cx; /* 没有 else,假分支直接落到语句后 */ } } else if (sym == SYM_IDENT) { /* 赋值语句,略 */ } else if (sym == SYM_BEGIN) { getsym(); statement(fsys); while (sym == SYM_SEMICOLON) { getsym(); statement(fsys); } if (sym == SYM_END) getsym(); else error(17); } /* ... 其他语句分支 ... */ test(fsys, 18); }参数和时序逐条说明。jpc存的是那条JPC指令在code[]里的下标,不是地址值;gen写指令时cx会自增,所以必须在gen之前把cx记下来,写完再拿这个下标去改code[jpc].a。code[jpc].a = cx赋值的那一刻,cx正好等于 else 分支第一条指令的下标,因为中间没有任何别的gen调用。这个"刚 gen 完就回填"的顺序,一旦中间插了代码(哪怕是一条注释翻成的空操作),地址就偏了。
不用可变参数或者gen返回值也能写对,但心算要清楚:JPC之后立刻statement(fsys)编译 then 分支,再判断elsesym,这是唯一正确的时序。有人图省事先判断sym == SYM_ELSE再编译 then 分支,逻辑上说不通——你还没读完 then 分支,怎么知道后面是不是 else。这种写法能过一部分用例,遇到if a then if b then x:=1 else y:=2才会暴露。
condition()那边要保证比较运算编译成OPR之后栈顶是一个 0/1 值,JPC弹掉它判断。关系运算符到 OPR 子功能号的映射建议做成一张小表,别在switch里手写映射:
/* src/parser.c:关系运算符到 OPR 子功能的映射表 */ static int relop_to_opr(int s) { switch (s) { case SYM_EQL: return 8; /* = */ case SYM_NEQ: return 9; /* <> */ case SYM_LSS: return 10; /* < */ case SYM_GEQ: return 11; /* >= */ case SYM_GTR: return 12; /* > */ case SYM_LEQ: return 13; /* <= */ default: return -1; } }子功能号直接决定虚拟机OPR的switch走哪个分支,写错一个数字的表现是"比较结果恒为真"或者"大于变成小于",输出全对但逻辑全反,跑小用例根本看不出来。写完这张表拿x > 0、x >= 0、x <= 0三条各跑一遍,输出符合预期再往下走。
4.3 悬空 else 与错误恢复
if a then if b then x := 1 else y := 2;这行代码,else 到底是配里层 if 还是外层?几乎所有语言的规定是就近匹配,递归下降天然满足这个规则:外层statement编译 then 分支时递归调用内层statement,内层看到else就先吃掉,外层根本看不见它。所以不需要额外的优先级处理,但必须保证内层的fsys里也有SYM_ELSE,否则内层在编译x := 1之后会因为看到 else 而报错退出。
错误恢复策略要提前定。error()只打印行号和错误码不中断编译,否则第一个错误之后什么都看不到。但如果getsym卡在一个错误 token 上,递归下降可能死循环,所以要设一个"连续错误上限",超过就exit(1):
/* src/lexer.c:错误计数与保护,防止错误恢复时死循环 */ int errcnt = 0; void error(int code) { if (errcnt++ > 20) { fprintf(stderr, "too many errors, abort.\n"); exit(1); } fprintf(stderr, "line %d, error %d\n", line_no, code); }这个上限设成 20 还是 50 无所谓,关键是得有。没有它,一个漏掉分号的源文件能让你的程序在statement和getsym之间来回弹跳直到文件尾,最后死在越界读上,你还以为是数组开小了。
5. 避坑与排查:五个把实验卡到凌晨的问题
5.1 else 被当成标识符
现象:语法分析在then分支之后报 16 号错误(缺 then)或"unexpected identifier",位置恰好是else这个词。用-t模式跑,else显示成SYM_IDENT而不是SYM_ELSE。
原因:保留字表长度用宏推的话不会出这问题,出问题的一定是写死了长度。常见写法是const char *word[] = {"begin", ... "while"}配一个循环上界norw,加字符串没改norw;或者表加了但枚举里没加SYM_ELSE,编译器报未定义,同学转手写了个魔法数字sym = 27,编号撞上别的符号。
解决:查表循环上界一律用sizeof(reserved)/sizeof(reserved[0])。新符号只往枚举末尾加,不动前面编号。改完跑-t确认else那行的符号名打印的是SYM_ELSE,再进语法层。
5.2 回填地址差一位,else 被执行两遍
现象:if x > 0 then y := 1 else y := 2;在 x > 0 时输出 2,或者 then 分支执行完之后 else 分支又被执行一次,最终值不对但程序不崩。dump 出来的指令和手算的地址表对不上,JMP的a字段明显偏了。
原因:把code[jpc].a = cx写成了code[jpc].a = cx - 1或cx + 1,或者回填的位置在gen(OP_JMP, 0, 0)之前。前者是典型的"觉得当前指令也算一条"的错觉——cx在gen之后已经指向下一条空位,直接赋值就对了。后者更隐蔽:先回填JPC再genJMP,导致JPC跳到JMP那条指令上,等价于无条件跳走。
解决:把 f 顺序背下来:先记jpc = cx,gen(JPC),编译 then,遇到 else 记jmp = cx,gen(JMP),然后才code[jpc].a = cx,编译 else,最后code[jmp].a = cx。写完之后在gen里加一行#ifdef DEBUG_CODEGEN打印cx / f / l / a,跟 dump 出来的指令清单对齐看,偏一位立刻能发现。
5.3 语句 fsys 没加 else,then 分支提前报错
现象:单层 if 正常,一旦写if a then b := 1 else c := 2就在 else 处报 18 号语法错误(语句后不该出现的符号)。把 else 去掉改成两行 if 反而能过。
原因:statement末尾的test(fsys, 18)用的集合里只有分号和 end。then 分支的statement返回时栈还没轮到外层判断 else,内层先做了一次 "当前符号在不在 fsys 里" 的检查,else 不在集合里就报错。
解决:把SYM_ELSE加进语句级 fsys,同时检查所有调用statement的上层函数,看它们的 fsys 参数是不是都从同一个数组传下来的。如果各写了一份,改一处漏一处,就要统一成一个fsys_stmt常量数组。改完再跑悬空 else 的用例,确认内层吃掉 else、外层不受影响。
5.4 条件表达式编译完忘了弹栈
现象:if 语句跑几次之后虚拟机报栈溢出,或者局部变量的值莫名其妙被覆盖。单看输出有时对有时不对,非常像"玄学"。
原因:condition()里编译完比较运算,栈顶留了一个布尔值,JPC弹掉它再判断。但如果条件里嵌套了odd x或在condition之外又被别的语句复用了表达式编译函数,多留下一个值,栈指针就会一直往上漂。
解决:给虚拟机加一个栈顶指针越界检查,s[sp]的sp超过STACKSIZE立刻打印当前指令地址p和sp,再配合 dump 出来的指令清单,能直接定位是哪一类语句少弹了。condition的效果必须是"净压入 1 个值",写完拿一个if odd x then ...的用例单独验。
5.5 虚拟机指令数组开小了
现象:大一点的源程序(几百行)编译到一半程序直接段错误,或者指令 dump 出来尾部全是垃圾值。
原因:PL/0 原版给code[]开的 200 条,只够教材里的小例子用。加了 else 之后每条 if 多一条JMP,中等的测试用例轻松超。更麻烦的是数组越界写不一定立刻崩,可能写到隔壁的符号表数组上,表现为符号查错。
解决:把code[]开到 4096 以上,table[]开到 1024,并在gen里加一句边界检查:
/* src/codegen.c:写指令前检查容量,越界立刻停,别静默踩内存 */ void gen(int f, int l, int a) { if (cx >= CODE_MAX) { fprintf(stderr, "code overflow at cx=%d\n", cx); exit(1); } code[cx].f = f; code[cx].l = l; code[cx].a = a; cx++; }越界检查本身两行,但它把"内存被悄悄踩坏"变成"明确报错",排查时间从半天降到五分钟。
6. 二次开发:在 else 之上继续加东西
跑通之后真正有意思的是继续改。第一个改造点是else if链。递归下降不需要任何特殊处理——else后面直接调statement,而statement遇到SYM_IF就走 if 分支,自然形成链式。要验证这一点,写一个三分支的成绩等级用例,dump 指令后确认每一层的JMP都指向同一个出口附近,偏差应该在两三条指令内。如果发现第二层的JMP跳到了第一层的 else 分支里,说明你在 else 分支的statement调用处传错了 fsys 参数。
第二个改造点是加repeat ... until。它和 while 的区别是条件在尾部、且假时回跳,代码生成上对应"先记循环起点 → 编译循环体 → 编译条件 →gen(JPC, 0, 起点)"。注意JPC是假跳,正好对上 until 的语义;如果照抄 while 的JMP加JPC两条,会多出一条无用跳转,指令清单上看得出来但不影响正确性。
第三个是给write加字符串输出。PL/0 原版只能写整数,加字符串意味着词法要认双引号、符号表要存字符串常量、虚拟机要加一条OPR子功能。改动面比 else 大,建议单独拉一个分支做,别和 else 的改动混在一个提交里——不然验收时出问题,你分不清是哪次改坏的。
验证方法我一般固定成三步:先跑test/下全部用例,比对期望输出;再对每个新语法构造手算一份指令地址表,和 dump 结果逐条对;最后故意写三个错误用例(缺 then、else 后没语句、括号不配对),确认错误码和行号都正确。三步都过,这次改造才算稳。
我自己的习惯是每加一个语法结构,先在纸上把code[]从 0 号指令手写到结束,标出每条JMP/JPC的a值应该是多少,再让程序跑一遍对照。这个笨办法救过我很多次——特别是回填地址这类"错一位看不出来、错多了才崩"的问题,纸面推演比 debug 快得多。从那以后我每次动代码生成部分,都强制先写地址表再敲键盘。希望帮到你。
本文还有配套的精品资源,点击获取