☰
SNL语言编译器源码实战:词法分析、递归下降与四元式
2026/11/6 5:40:58 网站建设 项目流程

简介:面向编译原理课程学习、课程设计与小型编译器实践者,这份 SNL 语言编译器源码包给出从词法分析、语法递归与 LL(1) 分析到语义处理、主程序调用的完整实现线索,可用于对照课堂理论理解编译前端各阶段的衔接方式,也适合作为课程实验的参考底稿。压缩包为 zip 格式,共 231 个文件、约 77.55MB,主体包含 C/C++ 源码与少量头文件,并配有 sln、vcxproj 等 Visual Studio 工程配置,以及 exe 可执行文件、obj/pdb/ipch/tlog 等编译调试中间文件和日志,便于直接查看工程结构、复现构建过程与排查运行问题。目前已有 1179 人学习。读者可据此梳理 SNL 语言从源程序输入到语义检查的模块划分,参考词法分析、语法分析与语义程序的实现思路,并结合可执行文件快速验证;多套工程与调试产物也方便对比不同实现版本和定位常见错误。

1. SNL语言编译器源码:一份能把编译原理从期末考卷拽回终端的代码

课设答辩前一晚,最怕的不是程序跑不出结果,而是被追问一句「你这个语法分析器为什么这么写」。这份 SNL语言编译器源码的价值就在这里:SNL(Simple Nested Language)是一门教学用的嵌套语言,而这份 C/C++ 源码把它从字符流一路干到可执行结果——词法扫描、递归下降语法分析、符号表与类型检查、四元式中间代码生成,最后接一个栈式虚拟机解释执行。它适合两类人:一是编译原理课设需要交完整前端、又不想只交一堆看不懂的生成式解析器骨架的人;二是工作几年后想把「编译器前端到底怎么落地」补上的人。整份代码规模不大,但分层清楚,改起来不心虚。

2. 工程结构拆解:五个模块的边界在哪里

2.1 一份典型的源码目录长什么样

这类课程级编译器源码,通常不是一个大文件硬写完的,而是按编译阶段切成若干编译单元,每个单元配一个头文件对外暴露少量接口。我第一次拿到这份代码时先扫了一遍目录,看到模块划分,心里就有底了——它不是那种「所有函数都塞在 main.c 里」的作业。

文件职责改动频率
globals.h跨模块共享的 Token、语法树节点、四元式、错误码定义高,加语法就要动
util.c/h内存分配、字符串池、打印缩进、错误输出低
lexer.c/h字符流到 Token 序列的扫描器,维护行号低,加关键字才动
parse.c/h递归下降语法分析,产出语法树高
symtab.c/h哈希桶符号表,作用域链的压入与弹出中
analyze.c/h遍历语法树做类型检查、符号解析高
code.c/h四元式生成、跳转回填中
main.c命令行入口,串起整条流水线低
test/*.snl示例源程序随便加

拆成这个粒度最大的好处是:当你只想验证「while 语句的四元式对不对」时,只需要在parse.c和code.c之间来回看,不必翻遍全文。代价是结构体定义必须集中,否则symtab.c想用一个TreeNode就得反向包含parse.h,很容易搞出循环依赖。

2.2 层与层之间的约定:三个核心数据结构

编译器的模块化不是靠注释喊出来的,是靠数据结构固定下来的。上层往下层传什么、下层往上返回什么,全靠这几个结构体约束。

/* globals.h —— 各模块共享的数据结构,改这里等于改契约 */ typedef enum { TK_NUM, TK_ID, TK_CHAR, /* 字面量与标识符 */ TK_IF, TK_THEN, TK_ELSE, TK_END, /* 关键字:加语句先加这里 */ TK_WHILE, TK_DO, TK_VAR, TK_PROC, TK_ASSIGN, TK_EQ, TK_LT, TK_PLUS, TK_MINUS, TK_LPAREN, TK_RPAREN, TK_SEMI, TK_EOF, TK_ERR } TokenType; typedef struct { TokenType type; /* 供 switch 分派,决定语法分支 */ char lexeme[64];/* 原始拼写,报错时直接打印给人看 */ int line; /* 行号,错误定位的关键依据 */ union { int ival; char cval; } val; /* 字面量的真实值 */ } Token; typedef struct TreeNode { enum { StmtK, ExpK } nodekind; enum { IfK, WhileK, AssignK, ReadK, WriteK, CompoundK } stmt; enum { OpK, ConstK, IdK } exp; TokenType attr_op; /* 运算符 */ int attr_val; /* 常量值 */ char attr_name[32]; /* 标识符名 */ struct TreeNode *child[3]; /* 定长子节点,够用且不用管理链表 */ struct TreeNode *sibling; /* 兄弟链表串起语句序列 */ } TreeNode;

Token里多留一个lexeme是我强烈建议保留的设计,很多精简版为了省事只存type和一个整数值,结果报错时只能说「第 17 行有语法错误」,而不能说「第 17 行的whilee附近有问题」。调试期这一条信息能省半小时。TreeNode用「子节点数组 + 兄弟链表」是标准做法:语句序列靠sibling串成链,if的 then 和 else 靠child[0]、child[1]挂上去,方向明确,遍历时不会绕晕。

四元式则更简单,本质就是一个定长数组,下标即指令地址:

/* 四元式:op, arg1, arg2, result —— 跳转类指令的 result 存目标地址 */ typedef struct { OpCode op; /* ADD/SUB/ASSIGN/JMP/JLT/LABEL/... */ int arg1, arg2; /* 操作数:临时变量编号,或符号表下标 */ int result; /* 目标:临时变量编号,或跳转目标指令下标 */ } Quad;

这里有个必须统一的约定:arg1/arg2/result里到底存什么。常见做法是「正数表示临时变量编号,负数表示符号表下标」,或者干脆全部走符号表、临时变量也塞进符号表。前一种省内存、查表快,后一种实现简单、dump 出来好看。两种都行,但全文必须统一,中途换约定基本等于重写。我一般选后者,因为四元式打印出来给人检查时,能直接看到变量名。

2.3 构建方式:Makefile 与单文件编译

源码包一般会带一个Makefile,把util.c、lexer.c、parse.c、symtab.c、analyze.c、code.c、main.c一起编译成可执行文件。

# 最常见的构建方式 make clean && make # 产物一般是 snlc / snl / compiler,看 Makefile 里的 TARGET # 如果没有 Makefile,手动编译也一样 gcc -std=c99 -g -Wall -o snlc *.c

-g不能省。这类代码最常见的崩溃位置是符号表查找返回空指针后继续解引用,没有调试符号你只能看着段错误干瞪眼。-Wall也要留着,递归下降里最容易犯的「函数声明了但某个分支忘记 return」全靠它提醒。如果 Makefile 里用了-O2,建议调试期先手动降到-O0,否则断点跳来跳去,变量还会被优化掉。

注意:有些源码包把main.c里的入口写成int main(int argc, char *argv[])并直接用argv[1]当文件名,不检查参数个数。第一次运行如果忘了带文件名,段错误会伪装成「编译器有 bug」。

3. 词法与语法分析:手写扫描器 + 递归下降的完整链路

3.1 扫描器主循环:最大匹配、行号与回退

手写扫描器的骨架非常简单,本质是一个带前瞻的循环:跳过空白、看首字符决定走哪条分支、把整个词素吃干净、返回 Token。难点全在边缘情况上——关键字和标识符前缀相同(if和iffy)、注释跨行、行号维护。

/* lexer.c —— 每调用一次返回一个 Token,靠全局游标推进 */ static char *src; /* 指向当前字符 */ static int line = 1; /* 行号,出错时要用 */ Token next_token(void) { /* 1. 跳过空白与换行,换行必须计数 */ while (*src == ' ' || *src == '\t' || *src == '\n' || *src == '\r') { if (*src == '\n') line++; src++; } Token t; t.line = line; t.lexeme[0] = '\0'; /* 2. 数字:连续吃数字字符,最大匹配 */ if (isdigit((unsigned char)*src)) { char *p = t.lexeme; while (isdigit((unsigned char)*src)) *p++ = *src++; *p = '\0'; t.type = TK_NUM; t.val.ival = atoi(t.lexeme); return t; } /* 3. 标识符或关键字:先按标识符吃,吃完再查关键字表 */ if (isalpha((unsigned char)*src)) { char *p = t.lexeme; while (isalnum((unsigned char)*src) || *src == '_') *p++ = *src++; *p = '\0'; t.type = lookup_keyword(t.lexeme); /* 表里没有就返回 TK_ID */ return t; } /* 4. 运算符:双字符必须优先于单字符匹配 */ if (*src == '<') { src++; t.type = TK_LT; strcpy(t.lexeme, "<"); return t; } if (*src == ':' && *(src + 1) == '=') { src += 2; t.type = TK_ASSIGN; strcpy(t.lexeme, ":="); return t; } if (*src == '\0') { t.type = TK_EOF; return t; } /* 5. 兜底:吃一个未知字符,返回错误 Token,不要死循环 */ t.type = TK_ERR; t.lexeme[0] = *src++; t.lexeme[1] = '\0'; return t; }

逐段说清楚:第一步的换行计数是整个错误定位体系的基础,\r也要一起跳过,否则 Windows 换行会让扫描器把回车当成未知字符报错。第二步数字识别只处理十进制整数,若要支持十六进制,得在这里加0x前瞻。第三步的关键是「先吃完整的标识符,再查关键字表」——反过来做就会把iffy拆成if和fy,这是初学者最典型的翻车点。第四步双字符运算符必须在单字符之前判断,: =和:=的处理顺序写反,赋值语句全线崩。第五步的兜底返回错误 Token 而不是while(1),是因为扫描器一旦卡住不前进,整个程序就假死,你连错在哪一行都看不到。

关键字表本身就是一个静态数组加线性查找:

static struct { const char *word; TokenType type; } keywords[] = { {"if", TK_IF}, {"then", TK_THEN}, {"else", TK_ELSE}, {"end", TK_END}, {"while", TK_WHILE}, {"do", TK_DO}, {"var", TK_VAR}, {"procedure", TK_PROC}, {NULL, TK_ID} };

只有十来个关键字,线性查找的开销完全可以忽略,不用上来就建哈希表。加新关键字只需要在这个数组里补一行,别的地方一行不用动,这就是把字符串和 Token 类型解耦的收益。

3.2 递归下降子程序怎么写才能和 EBNF 一一对应

递归下降最大的优势是「文法长什么样,代码就长什么样」。比如表达式这一层,EBNF 写成expr -> term { (+|-) term },代码里花括号对应一个while循环,而不是递归调用自己——这一点非常关键,用递归写左结合会产生右结合的结果,1-2-3会算成1-(2-3)=2而不是-4。

/* parse.c —— 加减层,左结合靠循环,不靠递归 */ static TreeNode *expr(void) { TreeNode *t = term(); /* 先拿一个项,必然存在 */ while (token.type == TK_PLUS || token.type == TK_MINUS) { TokenType op = token.type; match(op); /* 消费掉运算符 */ TreeNode *n = new_node(ExpK); n->exp = OpK; n->attr_op = op; n->child[0] = t; /* 左操作数:已经算好的部分 */ n->child[1] = term(); /* 右操作数:递归进乘除层 */ t = n; /* 结果继续当左操作数,形成左倾树 */ } return t; } /* 乘除层,优先级更高,所以被加减层调用 */ static TreeNode *term(void) { TreeNode *t = factor(); while (token.type == TK_MUL || token.type == TK_DIV) { TokenType op = token.type; match(op); TreeNode *n = new_node(ExpK); n->exp = OpK; n->attr_op = op; n->child[0] = t; n->child[1] = factor(); t = n; } return t; }

match()的语义是「断言当前 Token 类型符合预期,符合就前进到下一个,不符合就记一条语法错误」。这里有个实现细节值得抄作业:match失败后不要立刻 return,而是记录错误、尽量继续往下走。编译器前端如果遇到第一个错误就退出,用户改一个错、重新编译、再冒一个错,来回十几次,体验极差。常见的补救手段是「插入式恢复」——表达式缺右括号时补一个假的右括号继续解析,只要不陷入死循环,多报几条错误反而更有用。

语句层的子程序结构同理。stmt_sequence()循环解析语句直到遇到end或else这类结束符号,statement()用switch(token.type)按首 Token 分派到stmt_if()、stmt_while()、stmt_assign()等分支。分派依据就是每个候选式的 FIRST 集,写代码时对着文法表抄一遍分支顺序即可,不存在玄学。

3.3 语法错误的定位与信息质量

错误信息质量直接决定这份代码好不好用。我见过的最低配版本只打印syntax error,连行号都没有,调试时只能靠二分注释源码。合格的做法是带上行号、当前 Token 的原文、以及一句人话:

void syntax_error(const char *msg) { fprintf(stderr, "Line %d: %s (near \"%s\")\n", token.line, msg, token.lexeme[0] ? token.lexeme : "EOF"); }

token.lexeme为空时说明已经读到文件尾,此时打印EOF比打印空白有用得多——很多「语法错误」的真实原因是源程序少了最后那个end,报错位置在文件末尾,看到EOF就知道该往哪查。

再往上一步,是把语法树打印出来做可视化确认。写一个print_tree(TreeNode *t, int indent),用两空格缩进递归打印,几十行代码,但它能让你在写语义分析之前就确认while x < 10 do的条件子树挂对了位置。我一般会在main.c里加个-t开关,只在需要时打印,避免正常运行时刷屏。

4. 语义分析与中间代码:符号表、类型检查与四元式回填

4.1 符号表:哈希桶加作用域链

SNL 支持过程和函数嵌套,符号表就必然要处理作用域。最省事的实现是「一张哈希表 + 每个条目记录所属作用域层级 + 查找时取层级最深的那条」;更清晰的做法是「每个作用域一张表,用parent指针串成链,退出作用域时整张表丢弃」。

/* symtab.c —— 经典哈希桶 + 作用域链 */ #define BUCKET_SIZE 211 /* 常用小质数,冲突率够低 */ typedef struct Bucket { char name[32]; SymbolKind kind; /* VarK / ProcK / FunK / ConstK */ Type type; /* Int / Char / Bool / Array(...) */ int memloc; /* 运行时的栈偏移,代码生成时用 */ int scope_level; /* 层级,越大越内层 */ struct Bucket *next; /* 同桶链表 */ } Bucket; static Bucket *table[BUCKET_SIZE]; static int current_level = 0; /* 进入过程 +1,退出 -1 */ static unsigned hash(const char *key) { unsigned h = 0; while (*key) h = h * 31 + (unsigned char)*key++; return h % BUCKET_SIZE; } Bucket *st_lookup(const char *name) { Bucket *best = NULL; for (Bucket *p = table[hash(name)]; p; p = p->next) { if (strcmp(p->name, name) == 0 && p->scope_level <= current_level) { if (!best || p->scope_level > best->scope_level) best = p; /* 取可见范围内层级最深的那个,实现内层遮蔽 */ } } return best; }

st_lookup里那句「取层级最深的」是内层变量遮蔽外层同名变量的核心。如果偷懒直接返回链表中第一个匹配项,就会出现「内层定义了同名变量,但读写的还是外层那个」的诡异现象,而且这种 bug 不会报错,只会算错数——属于最难受的一类问题。

插入时则要防重定义,但只防同层重定义:var x : integer;在一个过程里写两遍应该报错,外层和内层各写一个x是合法的。所以st_insert里遍历同桶时判断条件是strcmp 相等 && scope_level == current_level,一个==写错就变成「所有嵌套都用不了同名变量」。

memloc字段提前给变量分配栈偏移,是语义分析和代码生成之间的桥梁。常见做法是从当前作用域的基址往上递增,过程调用时靠这个偏移在运行栈上定位实参和局部变量。

4.2 类型检查放在哪一遍做

有三种常见组织方式:一是完全融进语法分析(边解析边检查,类似一遍式编译器);二是语法树建好之后单独跑一遍analyze.c;三是再拆成「符号解析」和「类型检查」两遍。

这份源码走的是第二种,也就是main.c里清晰的流水线:

/* main.c —— 五个阶段,顺序不能换 */ Token t; TreeNode *tree; while ((t = next_token()).type != TK_EOF) { /* 通常由 parse 内部驱动 */ } tree = parse(); /* 1. 语法树 */ if (Error) { print_errors(); return 1; } build_symtab(tree); /* 2. 建符号表(先声明后使用) */ type_check(tree); /* 3. 类型检查 */ code_gen(tree); /* 4. 生成四元式 */ vm_run(); /* 5. 解释执行 */

分开的好处是:类型检查报错时语法树已经完整,可以继续检查下一棵子树,一次报出所有类型错误,而不是解析到一半就中断。代价是要在树节点上多存一些信息,比如「这个IdK节点解析后绑定到了符号表哪个条目」,通常加一个Bucket *sym指针,避免后面反复查表。

类型检查本身的规则不复杂,就是几条:

/* analyze.c —— 赋值语句的类型规则,其余类似 */ static void check_assign(TreeNode *t) { Bucket *lhs = st_lookup(t->child[0]->attr_name); if (lhs == NULL) { sem_error(t->line, "undeclared variable %s", t->child[0]->attr_name); return; } Type rt = check_expr(t->child[1]); if (rt != lhs->type) sem_error(t->line, "type mismatch in assignment to %s", t->child[0]->attr_name); }

关键是所有分支都要return,且不能因为一次错误就中断整体遍历。很多版本在这里用exit(1)直接退出,导致用户一次只能看到一个错,体验和只报syntax error一样糟。

4.3 四元式生成与跳转回填

表达式生成四元式是自底向上的:先递归生成子表达式的四元式,结果放在某个临时变量里,然后把临时变量编号填进当前指令的arg。

/* code.c —— 表达式生成,返回存放结果的临时变量编号 */ int gen_expr(TreeNode *t) { if (t->exp == ConstK) { int tmp = new_temp(); emit(OP_LOADI, t->attr_val, 0, tmp); /* 常量装入临时变量 */ return tmp; } if (t->exp == IdK) { Bucket *b = st_lookup(t->attr_name); int tmp = new_temp(); emit(OP_LOAD, b->memloc, 0, tmp); /* 从栈槽读到临时变量 */ return tmp; } /* 运算符:先算子节点,再算自己 */ int a = gen_expr(t->child[0]); int b = gen_expr(t->child[1]); int r = new_temp(); emit(op_of(t->attr_op), a, b, r); return r; }

控制流才是真正需要技巧的地方。if和while在生成时还不知道跳转目标的具体位置——条件分支的目标是「then 之后」,循环回跳的目标是「条件开头」。标准解法是回填:先emit一条目标地址待定的跳转,记住它的指令下标,等目标位置确定后再把下标填回去。

/* 回填:先发一条空目标指令,占位后补 */ int emit_jump(OpCode op) { return emit(op, 0, 0, -1); /* result = -1 表示待回填 */ } void backpatch(int quad_index, int target) { quads[quad_index].result = target; /* target 是四元式数组下标 */ } /* while 语句的生成顺序,环环相扣 */ void gen_while(TreeNode *t) { int loop_start = nquad; /* 条件表达式的起始位置 */ int cond = gen_expr(t->child[0]); int jf = emit_jump(OP_JF); /* 条件为假就跳出循环 */ gen_stmt(t->child[1]); /* 循环体 */ emit(OP_JMP, loop_start, 0, 0); /* 回跳到条件开头 */ backpatch(jf, nquad); /* 假出口 = 循环体之后 */ }

backpatch(jf, nquad)这一句,nquad在回填那一刻正好等于「循环体之后第一条指令」的下标。这个写法是整个代码生成里最容易写错的地方:如果把backpatch挪到gen_stmt前面,假出口就会指进循环体内部;如果把loop_start记在gen_expr之后,回跳会跳过条件判断,变成死循环。这类错误不会在编译期暴露,只能靠跑用例。

临时变量计数new_temp()必须全局唯一,跨语句、跨过程都不能重号。有些版本在每个函数开头把计数器清零,看起来「每个函数有自己的临时变量区」很合理,实际上会导致不同过程的临时变量占用同一个栈槽,递归调用时直接互相覆盖。

5. 跑通与排查:从 make 到四元式输出的 5 个常见坑

5.1 构建、运行与验证一条龙

先确认能编译通过,再拿示例程序跑通,最后才是改代码。顺序反了容易把「环境问题」误判成「代码 bug」。

# 1. 编译,保留调试符号,打开全部警告 make clean && make CFLAGS="-std=c99 -g -O0 -Wall" # 2. 跑一个示例程序,看整体输出 ./snlc test/while_sum.snl # 3. 只想看中间代码时,走 -q 开关(如果源码带了) ./snlc -q test/while_sum.snl

配套的验证程序建议自己写一个,语法以源码里的parse.c为准,别照抄别的教材:

program sum_demo var i, sum : integer; begin sum := 0; i := 1; while i < 11 do sum := sum + i; i := i + 1 end; write(sum) end

期望的输出分两段:四元式列表里应该能看到一个LABEL或循环条件起点、一条JLT/JF条件跳转、循环体三条赋值、一条回跳JMP;程序运行结果是55。如果四元式看着合理但结果不对,问题在虚拟机;如果四元式本身就错了,问题在代码生成阶段。用这个二分法定位,比漫无目的地读代码快得多。

5.2 五个真实踩过的坑

坑一:源文件看着没错,扫描器却报未知字符。现象是在第 1 行就报unexpected character,打开文件看第一行是program开头的正常内容。原因通常是文件带了 CRLF 换行或者被编辑器存成了带 BOM 的 UTF-8,扫描器读到\r或\xEF就直接当非法字符。解决方式是在扫描器跳过空白的循环里加上*src == '\r',并在文件头显式跳过 BOM 三字节;实在懒得改代码,用dos2unix转一遍也行。

坑二:while 循环永远只执行一次。现象是sum最终等于1而不是55。原因是backpatch把跳出目标填成了循环体的第一条指令下标,而不是循环体之后的下标,导致第一次循环结束时并没有真正跳出,反而跳回体内某处然后一路走到结束。排查方法是在gen_while里临时打印jf和nquad两个数,对照四元式 dump 看跳转目标落在哪条指令上,一眼就能看出来。

坑三:语义分析阶段段错误。现象是程序在type_check里崩溃,gdb 栈顶停在lhs->type那一行。原因是st_lookup返回了NULL(变量未声明),而代码没有判空就直接解引用。这类崩溃在「故意写一个错误程序测试报错功能」时才暴露,正常用例跑不出来。解决办法是在所有st_lookup调用点强制判空,并且养成习惯:查表函数的返回值默认可能为 NULL,先判再用。

坑四:递归过程调用结果错乱。现象是单个过程调用没问题,一旦递归(比如求阶乘)结果就离谱。原因是临时变量编号在每个过程内部从 0 重新计数,不同过程的临时变量映射到了同一个栈槽,递归展开时相互覆盖。解决办法是把临时变量计数器做成全局的,或者按过程名加前缀区段分配,让每个过程的临时变量区间不重叠。

坑五:嵌套过程里读到了外层的同名变量。现象是内层过程声明了var x,但赋值后外层看到的还是旧值。原因就是前面提到的st_lookup只返回链表中第一个匹配项,没有按scope_level取最深的一层。排查方法是写一个三行的小程序:外层x := 1,进入内层再x := 2,内层write(x),退出后再write(x)。正常应该输出2和1,如果两次都是2或者都是1,问题就锁死在符号表查找上。

提示:这五个坑里有三个的根因都是「没有判空」和「作用域没取最深」,改代码之前先把这两处收紧,能省掉一大半调试时间。

6. 二次开发:给 SNL 加一条 for 语句的完整改法

拿到这份源码后最想做的事,多半是「加个新语法」。for语句是最合适的练手目标,因为它能一次性串起词法、语法、语义、代码生成四个阶段,改完你就彻底摸清这条流水线了。

第一步,globals.h的TokenType里加TK_FOR和TK_TO,同时去lexer.c的关键字表里补两行{"for", TK_FOR}, {"to", TK_TO}。第二步,parse.c加一个stmt_for(),文法写成for_stmt -> FOR ID ASSIGN expr TO expr DO stmt_seq END,函数体照着stmt_while()的结构抄,只是把条件部分换成「循环变量 <= 上界」。第三步,analyze.c里检查循环变量已声明且是整型,上界表达式也是整型。

真正省事的是第四步——代码生成阶段不要新增四元式指令,直接降糖成已有的赋值和 while:

/* gen_for:把 for 还原成 i := lo; while i <= hi do body; i := i + 1 */ void gen_for(TreeNode *t) { int ivar = st_lookup(t->var_name)->memloc; int lo = gen_expr(t->child[0]); emit(OP_STORE, lo, 0, ivar); /* i := lo */ int loop_start = nquad; int tmp = new_temp(); emit(OP_LOAD, ivar, 0, tmp); /* 取 i */ int hi = gen_expr(t->child[1]); /* 上界,每轮重算,语义上等价 */ int cmp = new_temp(); emit(OP_LE, tmp, hi, cmp); int jf = emit_jump(OP_JF); gen_stmt(t->child[2]); /* 循环体 */ emit(OP_ADD, ivar, 1, ivar); /* i := i + 1 */ emit(OP_JMP, loop_start, 0, 0); backpatch(jf, nquad); }

这么写的收益是虚拟机一行都不用改,风险全压在语法和语义两层,出问题好定位。想再加数组,思路类似:Type里加TyArray(base, size),符号表条目多存一个size字段和一段连续memloc,代码生成时新增一条OP_INDEX四元式算地址,虚拟机里补一个case。验证方法永远是老三样——先打印语法树看结构对不对,再 dump 四元式看跳转和下标有没有算错,最后才跑用例比结果。

血泪经验是:每加一个语法特性,先把「错误写法」的用例准备好,比如for的上界写成字符常量、循环变量没声明、循环体里给循环变量重新赋值。这些用例在新功能刚写完时跑一遍,能立刻暴露符号表和行为约定的漏洞,比等一周后别人拿去用再报错强得多。从那以后我每次动语法,都强制走一遍「语法树 → 四元式 → 正确/错误用例」这三关,没有例外。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询