逐行精读SNL编译器源码:从词法分析到递归下降

发布时间:2026/11/6 6:56:57
逐行精读SNL编译器源码:从词法分析到递归下降

简介:面向编译原理课程学习者的 SNL 语言编译器完整源码,覆盖词法分析、语法分析(含 LL(1) 与递归下降两套实现)、语义分析到目标代码生成的典型实验链路,适合正在做课程设计、需要对照理解编译各阶段实现细节的本科生与自学者。压缩包共 231 个文件、约 77.55MB,以 cpp、h 源文件为主体,配 sln、vcxproj 等 Visual Studio 工程文件,可直接打开编译;exe、obj、pdb 为编译产物与调试符号,tlog、log、ipch 记录了构建过程,便于排查环境问题。资源已有 1179 人学习下载。读者可获得一套可运行的多阶段编译器参考实现,对照词法分析、语法递归程序、语义处理等模块的代码组织方式,快速定位实验中的报错与逻辑偏差,也可作为二次修改扩展的基础框架。

1. SNL 编译器源码为什么值得逐行读

很多人学完编译原理的第一反应是:正则表达式、FIRST 集、四元式,这些名词我都认识,但让我从零写一个能跑的语言实现,还是不知道从哪下手。差距不在概念量,而在一条把字符流一步步变成可执行动作的完整链路没有被真正走通过。SNL 语言编译器源码的价值就在这里:它把这条链路压进几千行 C/C++ 代码里,没有构建框架、没有第三方库兜底,每一个if分支背后都对应教材里一句带过的推导。读它,是从“知道有词法分析”变成“知道词法分析为什么不那么写”的唯一路径。

2. 词法分析:最长匹配是怎么把字符流切成 token 的

2.1 手写扫描器与自动机:状态机为什么最后变成了 switch

教材推导词法分析的链路很标准:正则表达式 → NFA → 子集构造 → DFA → 最小化 → 表驱动。要是照这条路走完,你会得到一张二维状态转移表,外加一个while (state != DEAD) state = table[state][ch];的主循环。但真去翻 SNL 这类课程级编译器的源码,几乎清一色是手写扫描器,用switch和while把状态转移铺开成代码,连张转移表都找不到。

常见的实现思路是把每个 token 类别的识别写成一个真代码里的小流程,按优先级排列,先判断字符大类,再进入对应的子循环。这样做的直接好处有三个。一是 token 种类太少,SNL 的保留字也就二十来个,符号十几个,手写代码量比配置生成器还短。二是最长匹配在表驱动里要额外维护状态栈和回退,而手写版只要不提前return,自然就匹配到最长的前缀。三是报错位置精确,表驱动跑到 DEAD 状态时,你已经丢失了“这个 token 是从哪一列开始”的信息,手写版只要在入口记下start,报错时直接给出起始列。

/* 扫描主循环:每次调用返回一个 token,读指针只前进不后退 */ Token nextToken(Lexer *lx) { skipBlankAndComment(lx); /* 跳过空白与 { ... } 注释,同时累计行号列号 */ if (lx->ch == EOF) return makeToken(TOK_EOF, lx); if (isalpha(lx->ch)) { /* 标识符和关键字走同一个入口状态 */ const char *start = lx->cursor; int len = 0; while (isalnum(lx->ch) || lx->ch == '_') { advance(lx); len++; } return makeIdentOrKeyword(lx, start, len); /* 最长匹配结束后才查关键字表 */ } if (isdigit(lx->ch)) return scanNumber(lx); switch (lx->ch) { /* 单字符与双字符运算符 */ case ':': advance(lx); if (lx->ch == '=') { advance(lx); return makeToken(TOK_ASSIGN, lx); } return makeToken(TOK_COLON, lx); case '+': advance(lx); return makeToken(TOK_PLUS, lx); /* ... 其余符号同理 */ default: return makeError(lx, "非法字符"); } }

这段代码的关键点不在语法,而在两个决策。advance(lx)里同时更新行号列号,保证后续所有报错都能定位到行列,不用再二次扫描源文件。makeIdentOrKeyword被放在最长匹配结束之后调用,而不是在读到第一个字符时就查表,这直接解决了integerx这种“关键字前缀 + 后缀”被误判成关键字的经典问题。很多手写扫描器翻车就翻在这里:先匹配到integer就返回,剩下的x变成下一个 token,语法分析阶段报一个莫名其妙的错误。

提示:判断一个扫描器写得对不对,最简单的办法是喂它integer1、if_、whilea三个输入,合格的实现应该识别成三个标识符,一个关键字都不出。

2.2 token 结构与读入策略:为什么要让 token 指向源缓冲

token 结构体的设计看起来是琐碎细节,实际决定了后面语法分析的写法和性能。新手最容易写成固定长度的char lexeme[64],然后strcpy拷贝进去。这份源码的正确做法是指针加长度,让 token 直接指向源文件缓冲区的某一段。

typedef enum { TOK_ID, TOK_NUM, TOK_KEYWORD, TOK_ASSIGN, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_SEMI, TOK_COMMA, TOK_EOF, TOK_ERROR } TokenKind; typedef struct { TokenKind kind; const char *start; /* 指向整份源文件缓冲,不做拷贝 */ int length; /* 配合 start 使用,比较时先比长度再 memcmp */ int line, col;/* 报错定位,从 1 开始计数 */ long ival; /* kind 为 TOK_NUM 时有效,扫描阶段就完成进制转换 */ } Token;

指针加长度的好处在符号表查找时才显现出来:SNL 的标识符平均长度不到 8 字节,用length做哈希初筛,桶内比较先比长度再memcmp,绝大多数情况下连一次完整字符串比较都不需要。固定数组方案则逼着你在每次nextToken里做一次拷贝,几千行源码扫下来就是几十万次无意义的memcpy。同时ival在词法阶段就完成转换,把“字符'1'、'2'、'3'变成数值 123”这一步和语法分析彻底解耦,语义分析阶段就不用再关心进制问题。

读入策略上,整文件一次读进内存是更常见的选择。fgetc每次调用都要走一次带锁的流操作,几 MB 的源文件扫下来开销相当可观;而把文件整个fread进缓冲后,扫描器只需要维护一个const char *cursor,前进就是自增。经典的“双缓冲”做法(两块缓冲区轮换,一块读满再处理另一块)主要服务于流式输入场景,对编译器这种一次性处理完整个文件、还要支持报错回溯的场景反而增加复杂度。理解了这一层,你就明白那些看起来“不够教科书”的实现选择,其实都是在具体约束下做出的取舍,而不是作者没学过自动机理论。

还有一个细节值得单独拎出来:缓冲区末尾放一个'\0'哨兵。这样内层while (isalnum(*p)) p++;循环就不用每次都判断p < end,省掉一次比较。对扫描器这种每字节都要跑一遍的热点代码,这类微观优化累积起来的效果不小。

2.3 关键字、标识符、常量的分流:最长匹配之后还剩什么

最长匹配解决的是“切多长”,切完之后还要解决“切出来的是什么”。标识符和关键字在词法层面完全是同一类东西(字母开头的字母数字串),区别只在于是否出现在保留字表里。SNL 的保留字数量在二十上下,线性查表最多二十次memcmp,完全可接受;如果追求极致,可以按首字母建一个 26 项的索引数组,先定位到对应首字母的小表,再线性比较,平均两三次就能命中。

真正需要小心的是数字常量的收尾判断。扫描器读完一串数字后,如果下一个字符还是字母,说明遇到了123abc这种非法 token。这时有两种处理:立刻报警并返回TOK_NUM,让abc变成下一个标识符;或者把整个非法串吞掉再报错。前一种做法能让扫描继续,但会引发连锁报错,用户看到的是“第 3 行 12 列数字错误”和“第 3 行 15 列未定义标识符 abc”,后者纯属噪音。

static Token scanNumber(Lexer *lx) { long val = 0; int digits = 0; while (isdigit(lx->ch)) { val = val * 10 + (lx->ch - '0'); advance(lx); digits++; } /* 数字后面紧跟字母,说明这是一个非法 token;吞掉整段再报错,避免连锁报错 */ if (isalpha(lx->ch)) { while (isalnum(lx->ch) || lx->ch == '_') advance(lx); return makeError(lx, "数字常量后不能紧跟字母"); } /* 溢出检查放在转换完成后统一处理,比在循环里逐位判断更清晰 */ if (val > INT32_MAX) return makeError(lx, "整型常量超出范围"); return makeNumToken(lx, val); }

这里有个容易被忽略的设计点:while (isdigit(...))循环里不做溢出检查,而是在退出循环后统一判断。原因是逐位检查会在每次迭代里增加一次比较,而溢出是极低概率事件,把它挪到冷路径上更划算。这类“把检查放到循环外”的写法在扫描器里到处都是,本质上是用可读性换执行效率。

3. 语法分析:递归下降怎么把 EBNF 直接翻译成可执行代码

3.1 FIRST 集落在哪一行代码里

递归下降最迷人的地方,是它把文法里抽象的选择关系直接变成源代码里的分支结构。每个非终结符对应一个parseXxx函数,函数体内部就是根据当前 token 决定走哪个产生式,决定依据的正是这个非终结符各个候选式的 FIRST 集。

拿 SNL 的语句列表来说,合法的语句首 token 只可能是标识符、if、while、read、write、return这六种,这个集合就是FIRST(statement)。写成代码就是一次switch。

/* statement 的 FIRST 集完整地翻译成 switch 的六个分支,没有 default 之外的隐藏路径 */ AstNode *parseStmt(Parser *p) { switch (peek(p)->kind) { case TOK_ID: /* 标识符打头有两种可能:赋值 ID := ... 或过程调用 ID(...),需要第二个 token */ if (peekAt(p, 1)->kind == TOK_ASSIGN) return parseAssign(p); if (peekAt(p, 1)->kind == TOK_LPAREN) return parseCall(p); return errorStmt(p, "标识符后应为 := 或 ("); case TOK_IF: return parseIf(p); case TOK_WHILE: return parseWhile(p); case TOK_READ: return parseRead(p); case TOK_WRITE: return parseWrite(p); case TOK_RETURN: return parseReturn(p); default: return errorStmt(p, "语句不能以此符号开头"); } }

peekAt(p, 1)这一行值得多看两眼。它意味着解析器必须维护至少两个 token 的前瞻缓冲,而不只是一个“当前 token”。教科书讲 LL(1) 时默认看到当前 token 就能决策,但真实的手写解析器里,标识符打头的两种语句结构完全不同,必须多看一个才能分流。这就是为什么很多实现明明号称是 LL(1),代码里却总有个长度为 2 的lookahead数组——它说明文法在设计阶段为了书写简洁牺牲了一点点前瞻,代价很小,理解上却容易卡壳。

同理,声明部分要区分type、var、procedure三种声明,类型定义里要区分:=和:,都遵循同一个模式:FIRST 集里有什么,switch里就有几个case。

3.2 左递归为什么必须改写,改写后又该怎么写

表达式文法是递归下降最容易出问题的地方。教材写的产生式通常是左递归形式:

E -> E + T | T T -> T * F | F F -> ( E ) | id | num

如果照着这份产生式逐行翻译,parseE的第一件事就是调用parseE,栈瞬间耗尽。改写是必须的:

E -> T { (+|-) T } T -> F { (*|/) F } F -> ( E ) | id | num

改写后的形式用 EBNF 的{ }表示“重复零次或多次”,落到代码里就是while循环。这一步转换是整套实现里最值得反复默写的地方,因为一旦转换写错,表现出来的问题往往是表达式结果错误,与栈溢出完全不同。

/* 左递归改写为循环后,递归深度只与括号嵌套层数有关,与表达式长度无关 */ AstNode *parseExpr(Parser *p) { AstNode *left = parseTerm(p); while (peek(p)->kind == TOK_PLUS || peek(p)->kind == TOK_MINUS) { TokenKind op = next(p)->kind; AstNode *right = parseTerm(p); /* 右边的项先独立成树,再和左边合并 */ left = makeBinOp(op, left, right); } return left; }

这个循环结构天然产生左结合语义:a - b - c会先合并成(a-b),再和c合并成((a-b)-c),符合算术运算符的常规约定。如果换成右结合运算符(比如幂运算),就得把它写成真正的递归调用而不是循环。

改写带来的一个副作用是:表达式深度不再等于运算符个数,而是等于括号嵌套层数。这对解释执行的实现极为重要——一份嵌套超过几百层的括号表达式就能把默认栈打穿,而一个包含几千项的长表达式却只占用常数栈空间。

3.3 AST 在哪一层构建:边解析边建树还是事后转换

解析器的产物有两种可能:一棵完整的语法分析树(每个非终结符对应一个节点),或者一棵抽象语法树(只保留语义相关的结构)。后者省内存也省后续遍历,代价是构建时机必须精确。

常见的做法是在“归约点”建节点——所谓归约点,就是这个产生式所有右部符号都已经被解析完、马上要返回的瞬间。以if语句为例,parseIf返回前那一刻,条件表达式、then 分支、可选的 else 分支都已经拿到手,这时构造节点是最自然的。

typedef enum { AST_NUM, AST_VAR, AST_BINOP, AST_IF, AST_WHILE, AST_ASSIGN, AST_CALL, AST_READ, AST_WRITE } AstKind; typedef struct AstNode { AstKind kind; int line; union { struct { long value; } num; struct { BinOp op; struct AstNode *lhs, *rhs; } binop; struct { struct AstNode *cond, *thenPart, *elsePart; } ifStmt; struct { char *name; struct AstNode *index; } var; /* 数组下标可为 NULL */ } u; } AstNode;

union节省内存,但也意味着节点的所有访问路径必须先用kind判断再取字段,取错字段不会报错,只会读到一段被解释成其它类型的字节。这类 bug 在调试时表现为“语义分析阶段某些节点类型检查莫名失败”,而错误位置指向的源码看起来毫无问题。

另一个常被忽略的问题是节点上的line字段。它的作用在错误报告,但更实际的价值在调试:编译器的中间表示一旦出错,能手边有个对应的源码行号,排查效率完全不同量级。

3.4 错误恢复:一次报错为什么不应该是雪崩

任何一个真实用户写的代码都会有语法错误,解析器遇到错误后的行为直接决定了工具好不好用。最简单粗暴的做法是exit(1),立刻终止;稍好一点是抛异常跳到文件末尾;真正可用的做法是“恐慌模式恢复”——跳过若干 token,直到遇到一个明确能重新同步的位置。

同步位置的来源是 FOLLOW 集。;、end、else这类符号在语法结构里扮演分隔角色,它们几乎出现在所有语句级非终结符的 FOLLOW 集里,所以是最自然的同步点。

/* 跳过 token 直到遇到同步集合中的符号,然后让上层函数重新开始解析 */ void syncTo(Parser *p, const TokenKind *syncSet, int n) { while (peek(p)->kind != TOK_EOF) { for (int i = 0; i < n; i++) if (peek(p)->kind == syncSet[i]) return; advance(p); /* 关键:必须消耗 token,否则遇到 EOF 之前会死循环 */ } }

advance(p)那一行是整套错误恢复的生命线。如果错误处理逻辑里存在“当前 token 不匹配就报错并返回”的路径,而调用方又原样再次进入同一个函数,解析器就会卡在同一个 token 上反复报错,输出几百条一模一样的错误信息。这种情况在自研解析器里出现的频率高到可以当作新手检测器。

提示:测试错误恢复质量的办法是故意删掉一个;,看编译器最终输出几条错误。一条到三条属于正常,上百条说明同步逻辑没生效。

4. 符号表与类型系统:嵌套作用域是怎么被一条链串起来的

4.1 作用域栈:哈希桶加父指针,还是单表加遮蔽

SNL 支持过程嵌套声明,这直接决定了符号表必须支持多层作用域。经典实现是“每个作用域一张哈希表,表里存符号,表本身组成一棵树,查找时从当前结点沿父指针向上走”。

typedef enum { SYM_VAR, SYM_TYPE, SYM_PROC } SymKind; typedef struct Symbol { char *name; SymKind kind; struct Type *type; /* 变量和参数的类型;类型名指向被定义的类型 */ int level; /* 所在过程层数,最外层 program 记为 0 */ int offset; /* 相对活动记录基址的偏移,解释器可不用 */ struct Symbol *nextInBucket; } Symbol; typedef struct Scope { Symbol *bucket[BUCKET_N]; /* 每层独立一套桶,退出作用域时整块释放 */ struct Scope *parent; /* 指向外层作用域,查找时逐层上溯 */ int level; } Scope;

查找逻辑很短:在当前层的桶里找,找不到就parent = parent->parent,直到NULL。插入只发生在当前层,所以同名变量在内层会自然遮蔽外层,不需要任何额外处理。退出作用域时,把整个Scope连同它所有桶里的链表节点一次性释放即可,唯一需要小心的是符号名可能指向源缓冲,别顺手free了。

另一种方案是全局只有一张哈希表,每个桶维护一个“遮蔽链”,同一个名字在链上按声明顺序排列,查找永远只查最近的那个。这个方案的查找是 O(1),但作用域退出时必须精确地“弹出”本层声明的所有符号,一旦往回恢复的顺序错了,就会出现外层变量凭空消失的现象。两种实现的复杂度对比大致如下。

维度分层哈希表 + 父指针单表 + 遮蔽链
查找复杂度O(层数),通常不超过 3O(1)
退出作用域释放整块 Scope,简单需按声明逆序逐条弹出,易错
内存占用每层独立桶,层数多时偏大单表,占用稳定
调试友好度高,能直观 dump 某一层低,链上的顺序靠打印推断

嵌套深度通常不超过三层,查找路径很短,所以分层方案在课程级实现里更常见。理解这个取舍之后,看任何一份编译器源码里的符号表模块,都能很快判断出它走的是哪条路线。

4.2 类型等价:结构比较为什么绕不开

类型系统的核心问题只有一个:两处写出来的类型,什么时候算同一个类型。SNL 里存在类型别名,type MyInt = integer;之后MyInt和integer必须等价;同时又存在复合类型,两个独立声明的array [1..10] of integer是否等价,取决于采用名字等价还是结构等价。

名字等价判定简单(比较类型 id),但表达力弱,别名一多就处处不等。结构等价更符合直觉,代价是要递归比较整棵类型树:

/* 结构等价:数组逐维比较界标和元素类型,记录逐字段比对名字与类型 */ bool typeEqual(Type *a, Type *b) { if (a == b) return true; /* 指针相同直接短路,覆盖自比较与共享情况 */ if (a->kind != b->kind) return false; switch (a->kind) { case TY_INT: case TY_CHAR: return true; case TY_ARRAY: return a->u.array.low == b->u.array.low && a->u.array.high == b->u.array.high && typeEqual(a->u.array.elem, b->u.array.elem); case TY_RECORD: return fieldListEqual(a->u.record.fields, b->u.record.fields); default: return false; } }

这段递归在类型结构是树的时候没问题,但如果两个类型共享了大量子结构,比较次数会按指数膨胀。SNL 一般不允许递归类型定义(类型不能直接或间接引用自身),所以指数膨胀不会发生,但从工程角度看,更稳妥的做法是类型构造时做一次哈希去重,保证结构相同的类型在内存里只有一份,之后所有等价判断退化成指针比较。这个技巧在很多成熟编译器里被称为“类型驻留”,实现成本不高,收益是把一个可能递归的函数变成一次比较。

fieldListEqual里必须同时比较字段名和字段类型,只比类型不比名字会让record x: integer; y: char end和record a: integer; b: char end判为等价,而这在语义上是两个完全不同的记录类型。这条规则在实现时非常容易漏,因为两个字段列表长度相同、类型序列也相同时,直觉上就“应该相等”。

4.3 静态链与 display 表:内层过程怎么摸到外层变量

嵌套过程带来的第二个问题是运行时访问。过程P里声明的变量,在嵌套于P内部的过程Q里可以直接引用,但Q被调用时,P的活动记录并不在Q的记录里,Q凭什么找到它?

答案是在活动记录里放一个指针,指向外层过程最近一次激活的记录,这就是静态链。变量在符号表里被记成(level, offset)二元组,运行时从当前记录出发,沿静态链向上跳curLevel - level步就到达目标记录,再加上偏移就拿到地址。

/* 沿静态链上溯到变量声明所在的那一层,返回该层的活动记录基址 */ Frame *resolveFrame(Frame *cur, int curLevel, int targetLevel) { while (curLevel > targetLevel) { cur = cur->staticLink; /* 每跳一次静态链,层数恰好减一 */ curLevel--; } return cur; }

跳转次数等于嵌套层差,通常很小。代价是每次访问都要走一遍循环,于是有了 display 表:一个数组,display[i]直接存第i层最近一次激活的记录基址,访问变 O(1)。代价是过程调用和返回时需要保存并恢复被覆盖的那一格,实现复杂度上去一个台阶。

对一份树遍历解释执行的实现来说,这两者其实都可以省掉。解释器维护的是“环境链”——一张从变量名到值的映射表,加一个指向外层环境的指针,查变量时沿链上溯按名字找。这种做法慢,但不需要知道活动记录的布局,也不需要计算偏移,非常适合把重点放在前端逻辑上的课程实现。理解这层差异之后,看源码时就能分辨出哪些部分是真正被使用的,哪些只是为将来生成目标代码预留的骨架。

5. 常见问题与排查:SNL 编译器最容易翻车的五个地方

5.1 注释吞掉半个文件:块注释没有独立状态

现象:某一行之后的所有代码都被当成注释,解析器在文件末尾报“缺少 end”,但错误位置指向源码中间某个完全正常的行。

原因:块注释的处理被塞进了主扫描循环里,用一个inComment布尔量标记。一旦注释符号出现在字符串或另一个注释内部,或者注释本身没有正确闭合,这个布尔量就会停留在错误状态,后续所有字符被无差别吞掉。

解决:注释应该有独立的状态机,和 token 扫描互不干扰。skipBlankAndComment里用一个while循环反复处理注释起始,遇到}就退出,遇到 EOF 就报“注释未闭合”。关键是主循环只调用它,不感知注释状态;任何情况下都不允许注释状态泄漏到 token 扫描逻辑中。

5.2 长表达式把栈撑爆:左递归直接翻译成函数调用

现象:几百个加法连在一起的表达式,编译时进程直接崩溃,错误信息是栈溢出或段错误,而三段式的短表达式一切正常。

原因:E -> E + T | T被逐行翻译成parseE开头就调用parseE,形成无界递归。递归深度等于表达式里运算符的个数,几百个运算符就是几百层栈帧,几个大的栈帧就足以打穿默认栈。

解决:把左递归改写成 EBNF 的重复形式,落成while循环。改写后递归深度只与括号嵌套有关。排查时可以临时在parseExpr入口加一句打印当前递归深度,如果数字随着表达式的项数线性增长,基本可以确认是这个问题。

5.3 块结束后报“未声明变量”:作用域恢复顺序错了

现象:一段内层过程里声明了和全局同名的变量,过程结束后,回到外层再次使用该名字,报“标识符未声明”,但外层明明声明过。

原因:符号表用了单表加遮蔽链的方案,退出作用域时按声明顺序正向弹出,而不是逆序。正向弹出会把更早压入的、属于外层的同名符号一起弹掉,外层绑定凭空消失。

解决:如果采用单表方案,退出作用域必须严格按“本层声明列表的逆序”恢复;更稳妥的做法是改用分层作用域,退出时整块释放本层 Scope,父指针自然回退到外层。排查方法是准备一个“外层声明、内层同名遮蔽、内层结束、外层再用”的最小用例,只要这个用例过了,作用域逻辑基本没问题。

5.4 结构相同的两个 record 判不等:指针比较冒充类型比较

现象:两个record类型在源码里长得一模一样,字段名和字段类型全都对应,但把其中一个类型的变量赋给另一个类型的变量时报“类型不匹配”。

原因:类型等价判定退化成了指针比较。两个 record 是分别在两处类型定义里创建的,内存地址不同,指针比较自然判不等。这和结构等价的语义预期完全相反。

解决:把类型等价实现成递归的结构比较,记录类型要比字段名和字段类型,数组类型要比上下界和元素类型。排查时构造两组“结构相同但分别声明”的类型做赋值,通过结果就能立刻判断出实现走的是哪条路线。若想进一步优化,可以在类型构造阶段做哈希去重,把结构比较变成指针比较。

5.5 else 分支永远被执行:回填的目标位置错了

现象:if语句无论条件真假,else分支的代码都会执行,而then分支的行为看起来正常。

原因:生成中间代码时,then分支末尾应该有一条无条件跳转,跳到整个if语句之后,同时if条件为假时要跳到else分支开头。这两处跳转的目标地址在生成时还不存在,需要事后回填。如果回填时把两条跳转的目标写反了,或者then分支末尾根本没生成那条跳转,就会导致else无条件落入。

解决:把每个待回填的四元式下标记在一个链表里,等目标位置确定后统一回填。排查时把生成的四元式序列整体打印出来,逐个核对跳转目标的编号是否指向预期的位置。这类错误的特征是“语法树看起来对、语义检查也没报错、就是运行结果错”,只能靠对中间代码序列逐条人工核对来定位。

6. 设计取舍:为什么这份实现停在解释执行

读完前端部分,很自然会问一个问题:为什么不一路做到目标代码生成。答案藏在课程级编译器最核心的约束里——它的目标是让整条流水线可见、可调试、可修改,而不是产出性能。

四元式加回填这套中间表示,恰好卡在一个甜点位置。它比抽象语法树更扁平,控制流被显式化成跳转,做数据流分析的难度降了一个量级;它又比目标机器的汇编更抽象,不需要操心寄存器分配和指令选择。继续往下走一步,就不得不引入寄存器描述符、活跃变量分析、指令调度这些与语言本身无关的工程细节,整份源码的阅读焦点会从“语言怎么被理解”偏移到“机器怎么被压榨”。

树遍历解释执行是同一个取舍的延续。它直接消费语法树,环境用哈希表加父指针实现,变量查找走名字匹配,不涉及活动记录布局。慢是真慢,但每次执行都能精确对应到源码里的一个节点,出错时栈回溯里看到的全是有意义的函数名,而不是一堆偏移量。真去写一个生成机器码的后端,调试成本会翻好几倍,而这份源码要展示的东西——词法状态机、FIRST 集、作用域链、类型等价——全都在前端,后端加进来只是徒增噪音。

验证这个取舍是否合理,有个很实用的办法:把解释器里某个节点的求值函数临时改成打印一行日志,跑一个小程序,看这行日志出现的次数和顺序是否与你的预期一致。如果整个执行轨迹和你脑子里的推导完全对得上,说明这份源码的中间表示设计是自洽的;如果对不上,那大概率不是后端缺失的问题,而是更靠前的某个环节,比如作用域恢复或者类型判定,悄悄出了偏差。我自己的习惯是,拿到任何一份编译器源码,先不急着看代码规模,而是找一个小程序,从main一路单步跟到输出,把每个阶段的数据结构打印出来对一遍。

踩过的坑里印象最深的一次,是花了整整一个下午去查一个“数组下标算错”的问题,最后发现根因在词法阶段:某个保留字的表项少了一个字符,while被识别成了标识符,一路传下去,在语义分析里被当成未声明变量处理,报错位置完全指向别处。从那以后,我每次改动扫描器,都强制走一遍“保留字全表逐个打印”的验证,不管改动看起来多小。希望这个习惯能帮到你。

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