☰
拆解SNL语言编译器:从词法分析、递归下降到P-Code生成
2026/10/21 2:48:14 网站建设 项目流程

简介:这是一份面向编译原理课程设计与自学者的SNL语言编译器完整源码包,基于C/C++实现,覆盖词法分析、语法递归下降、语义处理等核心编译阶段,代码可直接运行,遇到问题还可联系作者答疑。压缩包共231个文件,约77.55MB,主要包含cpp/h源文件、exe可执行程序、sln/vcxproj工程配置、obj/pdb/ilk等编译中间文件,以及tlog/log构建日志,可满足从阅读源码到调试运行的多环节需求。已有1178人学习下载。借助此包可系统梳理SNL语言从词法到语义的完整编译流程,重点参考LL(1)分析与递归下降程序的代码组织方式;保留的构建工程与日志还能帮助定位环境配置和编译报错问题,适合正在做编译原理课程设计、准备复试或需要参考经典编译器结构的开发者快速上手。

1. 编译原理snl语言编译器:这门课的“黑匣子”如何被一步步拆开

编译原理snl语言编译器源码,听起来像是一份课程设计交差用的“标准答案”,但它更接近一把钥匙。SNL是一门被刻意削瘦过的教学语言,保留了Pascal风格的类型、声明、过程嵌套和控制流,却去掉了指针、对象和模块系统,所以它的编译器可以压在几千行代码内,完整覆盖词法分析、语法分析、语义分析、中间代码生成和解释执行。我见过不少后端工程师面试回来后悔当年没吃透这份源码,因为DSL设计、热重载、构建工具链优化,底层都是这几件事。这篇文章按“先看懂它在干什么、再动手改源码”的顺序,把SNL编译器的每个Pass拆开讲,适合正在做编译课设的在校生,也适合想用最小成本落地一门领域语言的前后端工程师。

2. 从BNF到可运行的骨架:SNL词法与语法分析到底在做什么

2.1 为什么手写递归下降而不是直接套工具生成

很多课程实验一开始就引导用词法/语法分析器生成器,但我建议SNL这种规模的语言手写递归下降。原因有三:一是SNL文法足够简单,过程声明、变量声明、语句和表达式的递归结构用几十个函数就能覆盖;二是生成器生成的骨架往往把语义动作和错误恢复逻辑写得绕来绕去,出了问题你得回到生成规则里去猜;三是调试体验完全不同,递归下降的函数名就是你文法里那几个名字,崩溃时看调用栈就知道是哪条规则出了问题。

SNL的文法大致是“程序→PROGRAM标识符+声明部分+复合语句”,声明部分细分成常量、变量、过程/函数定义,语句又细分赋值、if、while、for、repeat、read、write。这些规则的递归性很强:过程定义里可以再嵌套过程定义,复合语句里可以嵌if,if分支里又可以嵌while。用工具生成虽然也能跑,但工具生成的骨架经常会把“语义动作”和“语法规则”耦合在同一个文件里,你要实现符号表和类型检查时就感觉绑手绑脚。

我一般会先把整条文法写在注释里,然后严格按文法顺序翻译:一个非终结符对应一个函数,一个终结符对应一次expect调用。这样代码读起来像文法本身,新增一种语句也只需要加一个parseXxx函数。后面拿到真正的源码时,最先去看的也是这个对应关系。

2.2 Token表结构与词法驱动:切词这一步先解决最大串

先给一个常用的Token定义。在经典的SNL编译器源码里,这个结构会放在公共头文件里,因为语法分析、语义分析、错误诊断都要用到它。

typedef enum { T_PROGRAM, T_PROCEDURE, T_FUNCTION, T_VAR, T_CONST, T_ARRAY, T_OF, T_INTEGER, T_CHAR, T_BOOLEAN, T_BEGIN, T_END, T_IF, T_THEN, T_ELSE, T_WHILE, T_DO, T_FOR, T_TO, T_REPEAT, T_UNTIL, T_READ, T_WRITE, T_TRUE, T_FALSE, T_AND, T_OR, T_NOT, T_IDENT, T_NUMBER, T_CHAR_CONST, T_PLUS, T_MINUS, T_MUL, T_DIV, T_EQ, T_NE, T_LT, T_LE, T_GT, T_GE, T_ASSIGN, T_LPAREN, T_RPAREN, T_LBRACKET, T_RBRACKET, T_COMMA, T_SEMI, T_COLON, T_DOT, T_DOTDOT, T_EOF } TokenType; typedef struct { TokenType type; int line; int col; char text[32]; int intVal; char charVal; } Token;

这里把关键字、标识符、运算符、字面量统一放在同一个枚举里,语法分析只需要对 token.type 做 switch。text 字段统一存标识符名和运算符原文,是为了报错时能打印“第几行看到什么”。intVal 和 charVal 在词法阶段就解析好,语法分析不用再转字符串。

词法驱动的核心是“按最长匹配切词”:先尝试两个字符的复合符号,再退回到单字符。SNL里最容易翻车的就是赋值号:=和数组上下界的..。

void nextToken(Lexer *lx) { skipWhitespaceAndComments(lx); if (lx->ch == ':') { if (peek(lx) == '=') { /* 两个字符一起读 */ advance(lx); advance(lx); lx->token.type = T_ASSIGN; return; } advance(lx); lx->token.type = T_COLON; return; } if (lx->ch == '.') { if (peek(lx) == '.') { advance(lx); advance(lx); lx->token.type = T_DOTDOT; return; } advance(lx); lx->token.type = T_DOT; return; } if (lx->ch == '<') { if (peek(lx) == '>') { advance(lx); advance(lx); lx->token.type = T_NE; return; } if (peek(lx) == '=') { advance(lx); advance(lx); lx->token.type = T_LE; return; } advance(lx); lx->token.type = T_LT; return; } if (isalpha(lx->ch)) { /* 读完整段字母数字,先按标识符收集,再查关键字表 */ } if (isdigit(lx->ch)) { /* 读数字,把字符串转换成 intVal */ } }

这段代码的关键在“先最大匹配”。如果把:=拆成:和=,语法分析器会把一次赋值看成一个空语句加一个运算符,诊断信息瞬间全乱。..同理,如果先匹配到单个.,后面不仅数组声明报错,连最后程序结束符也会跟着错位。lexer里还要同步注意<>、<=、>=,它们都是两个字符的复合符号。

有一个细节:SNL的保留字有限,我一般把关键字表写成一个朴素的字符串数组,读到字母段后先按标识符收集,再在数组里线性查找。几十个保留字用线性查找完全够快,而且比在switch里逐个比较字符串更容易维护。

2.3 语法分析搭树:以 if 语句和过程体为例把递归结构落下去

语法树节点在SNL源码里通常是这样一种“结构化数据”:用kind区分节点类型,用三个child指针挂子节点。三叉结构不是为了省内存,是为了统一遍历逻辑。

typedef struct Node { NodeType kind; struct Node *child[3]; int line; int intVal; char name[32]; } Node;

child 数组的语义在每种kind下固定:表达式节点用child[0]和child[1],if节点用child[0]=条件、child[1]=then分支、child[2]=else分支,while节点用child[0]=条件、child[1]=循环体。约定写清楚后,语义分析和代码生成都只需要写一套遍历函数。

if 语句的解析只有十几行:

Node *parseIf(Parser *ps) { expect(ps, T_IF); Node *n = newNode(NODE_IF, ps->line); n->child[0] = parseExpr(ps); expect(ps, T_THEN); n->child[1] = parseStmt(ps); if (peek(ps) == T_ELSE) { advance(ps); n->child[2] = parseStmt(ps); } return n; }

这里的 parseStmt 在遇到 T_IF 时会再次调用 parseIf,所以嵌套 if 天然成立。“悬挂else”问题不需要额外处理,因为递归下降天然让else归属于最近的if。expect 在不匹配时会打印“行号+期望+实际”,然后调用同步恢复逻辑:跳过当前token直到能继续解析的地方。

过程体解析是另一块重点:PROCEDURE 后面先收标识符,再收参数表,接着解析局部声明,最后解析复合语句。整个过程的声明信息会以过程节点的形式挂在语法树上,等第3章的语义分析去建作用域。语法分析阶段故意不做名字冲突检查,也不做类型检查,它只回答一个问题:这串token符不符合文法。把这一步和语义分析分开,是SNL编译器源码里最常见的分层方式,调试时也能一分为二地缩小问题范围。

3. 符号表与语义分析:让“类型错配”在编译期就死掉

3.1 作用域链与符号表:三层嵌套怎么用链表讲清

SNL语言允许过程嵌套,这意味着符号表不能是扁平结构。常见实现里,整个符号表系统是一个作用域链,每个作用域通过parent指针串起来。同一作用域内部的符号用单向链表串,虽然查起来是线性时间,但SNL测试程序里几十上百个符号的规模完全没压力。

typedef struct Symbol { char name[32]; SymKind kind; /* CONST_VAL / VARIABLE / ARRAY / PROCEDURE / FUNCTION */ DataType type; /* INT / CHAR / BOOL / VOID / ARRAY */ int level; /* 0 全局,1 一级过程,2 二级过程…… */ int offset; /* 栈帧内偏移,语义分析阶段分配 */ int lower, upper; /* 数组下界和上界 */ int paramCount; Param *params; /* 参数表,含 值参/变参 信息 */ struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; struct Scope *parent; int level; } Scope;

这里offset很关键。运行时的栈帧里,每个局部变量或参数都要一个固定的相对位置,SNL编译器常常在语义分析阶段就把offset算好,后面生成P-Code时直接用这个数,不在代码生成阶段重复计算。数组类型的lower/upper也直接存在符号里,运行时边界检查不再去翻语法树。

查名字要支持两种模式:只查当前作用域,还是可以跨层查。用同一个函数加开关区分。

Symbol *lookupSymbol(Scope *cur, const char *name, int crossLevel) { for (Scope *s = cur; s != NULL; s = s->parent) { for (Symbol *p = s->head; p != NULL; p = p->next) { if (strcmp(p->name, name) == 0) { return p; } } if (!crossLevel) { break; } } return NULL; }

crossLevel=0 时,只查当前scope,用于检测“本层是否已经声明过同名变量”;crossLevel=1 时沿parent链一路向上,用于表达式里引用一个名字时找到最近的声明。如果不区分这两种情况,你就无法报出真正的“重复声明”错误,还会在内层屏蔽外层的场景上给出错误诊断。

3.2 类型检查规则:照着运算符表把规则落到每个父节点

SNL虽然简单,但类型规则并不少。算术运算符只吃整数,逻辑运算符只吃布尔,比较运算符要求两边类型一致。这里整理一张我在语义分析时对照的规则表,也适合写进注释里当文档:

运算符操作数要求结果类型
+ - * div mod两个 integerinteger
and or not两个 booleanboolean
= <>两侧类型一致boolean
< <= > >=两侧为 integer 或 char,且一致boolean
:=左侧为变量,右侧类型与左侧一致无

这张表直接决定了语义检查函数的写法。二目运算的检查代码可以这样组织:

DataType checkBinary(Node *node) { DataType l = checkExpr(node->child[0]); DataType r = checkExpr(node->child[1]); switch (node->op) { case OP_ADD: case OP_SUB: case OP_MUL: case OP_DIV: if (l != T_INT || r != T_INT) error(node->line, "integer operands required in arithmetic"); return T_INT; case OP_AND: case OP_OR: if (l != T_BOOL || r != T_BOOL) error(node->line, "boolean operands required"); return T_BOOL; case OP_EQ: case OP_NE: if (l != r) error(node->line, "mismatched types in comparison"); return T_BOOL; default: error(node->line, "unknown operator"); return T_VOID; } }

注意这里的递归调用顺序:先算左子树类型,再算右子树类型,最后拿两个类型做比较。遇到类型错配,马上在错误里带上行号。行号存在每个Node里,语法分析阶段记录的原始行号在语义分析阶段就特别有用,否则一个错配错误指向第0行,排查半天。

到赋值语句,检查逻辑变成:左子树必须是一个变量节点,不能是常量;左右类型必须一致;如果左侧是变参对应的实参,允许的写法还要依据参数表标记。这些检查全部发生在语法树遍历阶段,一个类型错配的程序根本走不到解释执行那一步。

3.3 声明与引用的顺序:先把所有符号装进作用域,再查语句

很多SNL源码在初始版本里顺序写反,结果所有局部变量在使用时报“未声明”。正确的流程是:进入一个过程的作用域时,先把参数表声明进符号表,再声明局部变量,最后才遍历过程体里的语句块。语句里引用一个名字时,通过crossLevel=1的lookup去查。

void checkProgram(Program *p) { enterScope(rootScope, 0); checkDeclList(p->decls); checkCompoundStmt(p->body); exitScope(rootScope); } void checkSubroutine(Symbol *sym) { enterScope(subScope, sym->level + 1); declareParams(sym->params); declareLocals(sym->localDecls); checkCompoundStmt(sym->body); exitScope(subScope); }

declareParams 会把过程参数作为变量挂进当前作用域,值参和变参的区别记录在符号的param标记里。declareLocals 再处理局部变量。这样过程体里引用一个参数,lookup 时第一层就能找到;引用外层的全局变量,沿parent链也能找到。反过来如果先遍历语句块再声明局部变量,语句里的局部变量引用就全部落空,诊断里全是“identifier not declared”。

声明重复检查也在这套流程里:declareParams 时先以 crossLevel=0 查一次,若名字已在当前作用域,说明参数或局部变量与同名符号冲突,报错。同样,过程之间互相调用没问题,但过程声明里引用另一个过程,也要保证被调用的过程已经进入符号表。所以checkDeclList 里要先声明所有过程头,再逐个检查每个过程体,形成“两遍扫描”的结构。

4. 中间代码生成与执行解释:把语法树变成一条条指令

4.1 为什么SNL编译器源码普遍选P-Code而不是汇编

SNL是教学语言,它的运行时模型天然是栈式的:表达式求值用栈,过程调用用栈,连局部变量访问都用栈帧里的相对偏移。对应的中间表示也用栈式指令集最直接。我常说的P-Code就是这种指令集,每一条指令从数据栈取操作数,算完再压回栈。它不需要寄存器分配,也不用考虑目标机器的指令编码,解释器只需要三十条指令就能覆盖SNL的全部特性。

也有SNL实现选择先生成四元式,再翻译成另一种可执行格式。但更常见的是把语法树直接遍历生成P-Code。我一般推荐后一条路线,因为少一层中间表示,调试时指令序列和源码语句的对应关系更清楚。

指令的结构通常是一个简单结构体:

typedef enum { O_LIT, O_LOD, O_STO, O_ADD, O_SUB, O_MUL, O_DIV, O_NEG, O_AND, O_OR, O_NOT, O_EQ, O_NE, O_LT, O_LE, O_GT, O_GE, O_JMP, O_JZ, O_CALL, O_RET, O_READ, O_WRITE, O_HALT } OpCode; typedef struct { OpCode op; int level; /* 层差:访问哪一层过程的数据区 */ int arg; /* 常量值 / 变量偏移 / 跳转地址 / 过程入口 */ } Instruction;

level 和 arg 是唯一的两个操作数。O_LOD 表示“从第level层的栈帧里读偏移arg”,O_CALL 表示“调用在第level层声明的、入口为arg的过程”。跳转指令用arg存相对偏移,能省掉单独维护label的麻烦。

4.2 表达式和赋值生成:递归下降的输出就是指令流

生成指令的入口是对语法树做一次深度优先遍历。表达式节点生成指令的顺序就是运行时求值的顺序:先左后右,再把运算符指令接在最后。

void genExpr(Node *expr) { switch (expr->kind) { case NODE_CONST: emit(O_LIT, 0, expr->intVal); break; case NODE_VAR: emit(O_LOD, levelDiff(expr), expr->offset); break; case NODE_BINOP: genExpr(expr->child[0]); genExpr(expr->child[1]); emit(opFor(expr->op), 0, 0); break; case NODE_CALL: /* 先按声明顺序反着生成实参,保证运行时栈内顺序正确 */ for (int i = expr->argCount - 1; i >= 0; i--) { genExpr(expr->args[i]); } emit(O_CALL, levelDiff(expr), expr->entryAddr); break; } }

levelDiff 计算当前过程与目标声明过程之间的层差,这是栈式虚拟机访问外部过程变量的核心。如果实现用的是display表,层差就是目标层编号;如果实现用静态链,层差是沿着链表需要跳的步数。参数压栈顺序建议固定为“从右往左”,这样被调用过程读取第1个参数时总是栈顶方向,参数顺序不会乱。

赋值语句生成更直接:先生成右值表达式,再生成左侧变量的STO指令。右侧表达式算完后栈顶就是值,STO取出它并写进对应变量的栈帧位置。while和if的跳转目标在这里用的是相对偏移,生成时先发一条JMP/JZ,等子语句生成完毕再回头把偏移填上,这需要代码生成器保存一个patch列表。

4.3 栈式解释器怎么跑起来:从主循环到display

解释器的骨架是一个for循环,每次取出当前指令,按op分类执行。教学编译器里最直观的执行器结构是这样:

void run(const Instruction *code) { int pc = 0, sp = 0; int data[STACK_SIZE] = {0}; int base[MAX_LEVEL] = {0}; emitPushFrame(code[0].arg); /* 先把主过程的栈帧建好 */ for (;;) { const Instruction *c = &code[pc++]; switch (c->op) { case O_LIT: data[++sp] = c->arg; break; case O_LOD: data[++sp] = data[base[c->level] + c->arg]; break; case O_STO: data[base[c->level] + c->arg] = data[sp]; sp--; break; case O_ADD: data[sp - 1] += data[sp]; sp--; break; case O_JZ: if (data[sp--] == 0) { pc += c->arg; } break; case O_CALL: { int oldBase = base[c->level]; /* 保存旧基地址,用于返回后恢复 */ data[++sp] = oldBase; data[++sp] = pc; /* 保存返回地址 */ base[c->level] = sp + 1; /* 新帧基地址指向参数区 */ pc += c->arg; break; } case O_RET: sp = base[c->level] - 1; /* 回收本层栈帧 */ pc = data[sp + 2]; /* 取回返回地址 */ base[c->level] = data[sp + 1]; /* 恢复旧基地址 */ break; case O_HALT: return; default: break; } } }

这个过程里最容易被忽略的是CALL和RET的对称性:CALL在栈帧里保存了旧基地址和返回地址,RET必须按相反顺序取回来并恢复base数组。如果只保存返回地址而不恢复base,第二次调用同一过程就会读到上一轮留下的垃圾数据。display表在嵌套过程里同理:进入第level层时把base[level]切换到新帧,返回时切回旧帧。

到这里,SNL编译器的主干已经能跑通:词法给出token,语法给出语法树,语义保证类型合法,代码生成产出指令流,解释器把指令流变成结果。剩下的痛点在调试和边界上。

5. SNL编译器避坑手册:这些翻车现场我基本都遇过

5.1 现象:所有赋值语句报“expect expected”,语法分析根本进不了正确的分支

现象:用x := 5;测试,词法分析输出一直是 T_COLON 和 T_EQ 两个token,语法分析在赋值语句分支里等 T_ASSIGN 等不到,于是一路报错。

原因:词法分析里先匹配了单字符:,返回T_COLON后,下一次再匹配=。语法分析无法把两个token合并成一个赋值号。

解决:按照“最长匹配”原则调整词法分析,遇到:时向前看一个字符,若为=则生成 T_ASSIGN;同样处理..。修复后要补一条覆盖所有多字符符号的回归用例。

5.2 现象:内层过程引用外层变量报“identifier not declared”

现象:全局声明var count: integer;,内层过程里写count := count + 1,语义分析报 count 未声明。

原因:lookupSymbol 只查了当前作用域,没有 parent 链向上查。常见实现里还容易在enterScope 时把parent链漏连。

解决:把 lookup 改成从当前scope沿parent逐层查,用 crossLevel=1 的语义在表达式引用的地方调用;在声明检查时用 crossLevel=0。写完用三层嵌套过程测试一次全局变量访问。

5.3 现象:过程第二次调用后局部变量读出的值是垃圾

现象:同一个过程第一次调用输出对,第二次输出垃圾数字。

原因:RET 只恢复了 pc 和 sp,没有恢复 display/base 数组。第二次调用时 LOD 仍引用旧的基地址,读到上次调用遗留的栈数据。

解决:CALL 时在栈帧里保存旧的基地址,RET 时弹出恢复。排查这类问题最快的办法是在 CALL/RET 前后打印 base 数组,比对进入和离开时每一层的值。

5.4 现象:数组越界不报错,或者报错行号指向数组声明处

现象:声明a: array[1..10] of integer;,执行a[0] := 1;没有报错,甚至写穿到相邻变量;另一种情况是报了错但行号指向声明行。

原因:解释器做边界判断时用的是“声明时的lower/upper”,但没把实际下标与声明范围比较;或者错误节点里没有记录使用时的行号。

解决:语义分析时把数组符号的lower/upper拷进符号表,解释器在 O_STO/O_LOD 数组元素前判断下标是否在范围内,越界错误用语法树里数组访问节点的line字段。还有一个细节:下标换算是index - lower,不是直接用index,否则下界从1开始时每个元素偏移都错一位。

5.5 现象:语法错误程序让编译器死循环,CPU直接拉满

现象:输入一段缺分号的程序,编译器不停打印“expect ;”,进程不退出。

原因:错误恢复逻辑只负责报错,没有同步token流。报错后当前token还是那个分号之前的字符,恢复函数继续解析同一处,形成死循环。

解决:报错后做“同步恢复”:跳到下一个分号或end,并把跳过的token消费掉;同时限制每行最多报一次错误,避免同行错误刷屏。调试时在恢复函数入口打印当前token和行号,能立刻看出是否卡在同一位置。

提示:错误恢复只做“能继续解析”还不够,必须同时限制错误上报次数,不然一条错误会被刷屏成几十条。

6. 验证你的SNL编译器:从最小用例到一晚上跑通回归

写完编译器不等于敢交出去。我见过太多课程项目,主流程跑通就收工,结果换一个测试顺序就崩。SNL编译器体量小,完全可以用“每个Pass一层用例”的方式把回归做扎实。

6.1 最小用例集怎么组织

我给工程建四个目录:lex、parser、sema、exec,每个目录放对应的测试脚本。词法用例放各种符号组合和数字字面量;语法用例放合法/非法的程序骨架;语义用例专门放类型错配和重复声明;执行用例才是最终要出结果的那批。非法用例在文件里加后缀标记,回归脚本统一检查编译器的输出和退出码。每次改完代码后一条命令跑完整个目录,比手工点十个测试快得多。

6.2 一个容易被忽略的验证点:打印每个阶段的中间输出

调试开关比print函数堆砌更可靠。我在编译器每个Pass入口放一个统一入口,用命令行参数控制要打开哪些调试通道。典型用法是这样:

make && ./snl --debug=tokens,ast,code,vm ./tests/exec/06_array_bounds.snl

这个参数会依次打印token流、语法树缩进结构、生成的P-Code指令序列、解释器执行时的栈变化。之前遇到过数组越界但排查半天没结果,后来加了vm通道看每个指令执行前后栈顶,发现是下界换算少了1,一秒钟定位。

我做SNL编译器时最深的教训是:用例不能等代码写完再补,你会不自觉把用例写成“让自己通过的版本”。先把错误用例写在最前面,再用它们逼着自己实现错误恢复和报错逻辑,编译器会健壮得多。这份经验后来做DSL时也一直用得上,希望帮到你。

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

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

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

立即咨询