资讯详情 手写SNL编译器:从词法分析到四元式生成的完整实践
📅 2026/10/3 14:23:02
简介面向高校编译原理课程设计学习者这份资源是吉林大学计算机学院2022年SNL语言编译器课程设计项目基于C实现完整的词法、语法、语义分析流程适合需要参考完整实现与高分课程设计思路的学生。项目由作者独立完成相似性低非GitHub常见资源。压缩包共91个文件包含C源码.cpp/.h、Visual Studio工程配置及调试文件.vcxproj/.sln/.ipch/.tlog、可执行程序、多组测试用例txt与输出日志等整体约142.28MB可直接查看工程结构和运行效果。目前已有1373人学习可作为编写词法分析、LL1语法分析、语义分析与符号表管理等模块的落地参考尤其适合追求高质量课程设计、希望快速理解整体架构的同学。1. 为什么课程设计选 SNL 编译器它逼你走完全程而不是调通框架在编译原理课程设计里“SNL 语言编译器”是吉林大学计算机学院的一个经典选题。SNL 是 Simplified Nested Language 的缩写一个专门为教学设计的迷你语言语法接近 Pascal/C 的子集支持变量声明、表达式、控制流、函数定义和调用。课程设计的目标不是让你研发出生产级编译器而是让你在一个学期内把词法分析、语法分析、语义分析、中间代码生成这几大模块亲手写一遍——不借助 Yacc/Lex 全家桶也不允许直接拿现成编译器改改界面交差。这个题目“值不值得做”取决于你怎么看。如果只求过它有大量现成思路可以参考如果你想在简历上写一条“实现了完整的 SNL 语言编译器”那它就是一个能让你把理论课上的 First 集、Follow 集、递归下降、符号表管理真正落地的机会。整个项目大约 30005000 行代码工作量集中在一个月内可以完成适合独立开发也适合两人组队——一个人负责词法与语法另一个人负责语义与代码生成。本文按我平时带学生做这个题目的路线把语言定义、模块划分、关键代码和最容易翻车的地方一次讲清楚。2. 语言定义与词法分析先锁死文法再动手写代码很多人的课程设计翻车不是栽在递归下降而是栽在第一步——SNL 的语言定义根本没锁定就开始写词法分析器结果程序写到一半发现语法定义和代码实现互相矛盾。2.1 用 EBNF 把 SNL 文法钉在文档里SNL 没有官方统一标准不同年份的课程设计题会稍有差异。吉林大学常见的版本包含如下要素变量声明INTEGER、CHAR、ARRAY 三种基本类型数组长度是整型常量语句赋值、IF 条件、FOR 循环、WHILE 循环、READ 读入、WRITE 输出、CALL 函数调用表达式算术加减乘除、括号、关系运算、逻辑与优先级按常规处理函数声明支持无参和有参函数参数传递默认值传递作用域支持嵌套内层可以引用外层变量。写法上建议用 EBNF 全量定义一遍。下面是一个简化的 SNL 文法骨架program :: { declaration } { function } main_program declaration :: variable_declaration | constant_declaration variable_declaration :: INTEGER ident_list ; | CHAR ident_list ; | ARRAY [ integer ] OF type ident_list ; ident_list :: ident { , ident } function :: FUNCTION ident ( [ parameter_list ] ) ; { declaration } compound_statement ; main_program :: MAIN ; { declaration } compound_statement . compound_statement :: BEGIN { statement } END statement :: assignment | if_statement | while_statement | for_statement | call_statement | read_statement | write_statement注意 FUNCTION 声明和 MAIN 程序体的分隔用分号主程序用点号结束这是 Pascal 传统也是这门课设计里最容易忽略的细节。先把这个 EBNF 写完并走查两遍再写词法扫描器你会发现后续的递归下降解析器基本就是对照 EBNF 翻译成代码。2.2 手写词法分析器状态机与关键字表词法分析有两种路线一种是写正则然后用 LEX 生成另一种是手写状态机。课程设计原则上要求手写而且手写在调试上更可控。SNL 词法单元分为几类标识符以字母开头后跟字母或数字关键字IF、THEN、ELSE、WHILE、DO、FOR、TO、BEGIN、END、FUNCTION、MAIN、INTEGER、CHAR、ARRAY、OF、READ、WRITE、CALL、RETURN、CONSTANT、VAR整型常量十进制数字串注意不能以 0 开头除非本身就是 0字符常量单引号括起的单个字符如 A操作符与界符 - * / ( ) [ ] , ; : . : 注意 : 是赋值号 是相等判断。核心扫描循环可以写成这样用 C 实现一个 token 流接口Token Scanner::nextToken() { while (isspace(ch)) advance(); // 跳过空格和换行 if (isalpha(ch)) return scanIdent(); // 标识符或关键字 if (isdigit(ch)) return scanNumber(); // 数字常量 if (ch \) return scanChar(); // 字符常量 return scanOperator(); // 操作符与界符 }scanIdent 里需要先收集完整字符串然后查关键字表如果匹配到关键字就返回对应 token 类型否则返回 IDENT。scanNumber 要注意溢出检查和非法前缀。scanOperator 需要处理两字符操作符比如 : 、 、 必须读两个字符后做最长匹配不能只看第一个字符就急着返回。参数说明token 结构体里建议保留三个字段——type枚举、lexeme原字符串、lineNo行号。lineNo 必须记录否则错误报告到后面根本没法定位。还有一个容易忽略的点SNL 关键字不区分大小写。Pascal 风格的语言习惯是关键字大小写不敏感变量名也大小写不敏感。实现时统一把标识符在扫描阶段转小写存储否则 IF 写成 if 之后就变成两个不同的 token整个文法都会乱掉。2.3 词法错误的处理策略词法层常见的输入错误有非法字符如 、#、标识符中间出现数字、字符常量没闭合、数字后面紧跟字母。比较好的做法是在 scan 阶段直接抛错并跳过错误字符而不是把未知字符原样传给语法层。Token Scanner::scanOperator() { char first ch; if (first :) { advance(); if (ch ) { advance(); return Token(ASSIGN, :, line); } else { error(unexpected : after :, line); } } if (first ) { advance(); if (ch ) { advance(); return Token(LEQ, , line); } if (ch ) { advance(); return Token(NEQ, , line); } return Token(LT, , line); } // 其余操作符类似处理 }这段代码的逻辑是“读第一个字符再根据下一个字符决定返回哪个 token”好处是状态转换清晰坏处是分支多。注意一个容易出错的点在 SNL 里表示不等于而 Pascal 里相同符号也是不等于但 C 风格的人容易下意识把!写进词法规则里导致测试用例全部报错。文法定义阶段就要把这一点写清楚否则后患无穷。3. 语法分析递归下降 LL(1) 预读把 EBNF 直接翻译成代码词法层把源程序变成 token 流之后语法层负责判断这个 token 流是否符合文法。SNL 的文法设计出来就是为了让递归下降分析器可以无冲突工作你不需要 LALR 那套工具手写足矣。3.1 为什么选递归下降而不是 LR 分析器生成器市场上有 Yacc、Bison、ANTLR为什么要手写两个原因。第一是课程设计的明确要求多数老师不允许使用自动生成工具因为那样学不到预测分析和错误恢复的精髓第二是 SNL 的语法规模很小递归下降写出来的代码量不过一千行上下而引入 ANTLR 会带来运行时依赖和构建复杂度在课程设计这种一次性项目里得不偿失。递归下降的实质是每个非终结符对应一个解析函数函数内部根据当前 token 选择产生式分支。它要求文法满足 LL(1) 条件即任意一个非终结符的候选产生式其终结符起始符集合互不相交。SNL 标准文法本身是满足的比如 statement 层级的各个分支分别以赋值目标标识符、IF、WHILE、FOR、CALL、READ、WRITE 开头不存在二义性。如果你在写解析器的过程中发现某个函数里出现了“拿当前 token 不知道该进哪个分支”的情况不要急着加回溯逻辑先回去改文法——这通常是文法有公共前缀需要提取公因子。一个即时判断的方式是画出每个非终结符的 First 集合如果两个产生式 First 集合有重叠就用“提取左因子”或“改写成 EBNF 重复项”消除冲突。3.2 表达式解析的优先级与结合性实现表达式是语法分析里最典型的部分。SNL 算术表达式的优先级从低到高是加减、乘除、一元负号、括号和操作数。实现上采用分层法一级函数对应一层优先级// factor : IDENT | NUMBER | CHAR_LIT | ( expr ) | func_call ASTNode* Parser::parseFactor() { if (match(TokenType::IDENT)) { if (peek().type TokenType::LPAREN) { return parseFunctionCall(); // 函数调用 } return new VarNode(previous().lexeme); } if (match(TokenType::NUMBER)) { return new ConstNode(std::stoi(previous().lexeme)); } if (match(TokenType::CHAR_LIT)) { return new CharConstNode(previous().lexeme[1]); } if (match(TokenType::LPAREN)) { ASTNode* e parseExpr(); expect(TokenType::RPAREN); return e; } error(factor expected, got peek().lexeme); } // term : factor { (*|/) factor } ASTNode* Parser::parseTerm() { ASTNode* left parseFactor(); while (match(TokenType::MUL) || match(TokenType::DIV)) { Token op previous(); ASTNode* right parseFactor(); left new BinOpNode(left, op.type, right); } return left; } // expr : term { (|-) term } ASTNode* Parser::parseExpr() { ASTNode* left parseTerm(); while (match(TokenType::PLUS) || match(TokenType::MINUS)) { Token op previous(); ASTNode* right parseTerm(); left new BinOpNode(left, op.type, right); } return left; }这里的关键点在于循环内调用“下一层”的解析函数保证右操作数会吸收掉所有更高优先级的运算。比如a b * cparseExpr 先拿到 a遇到加号后调用 parseTerm 解析 b * c于是 b * c 整体成为加号的右子节点。结合性的问题同样由循环实现加减法都是左结合的while 循环保证左子树已经归约完成新节点直接作为右操作的父节点。3.3 语句层解析与嵌套块结构语句层解析比表达式简单但有个 SNL 特性要特殊处理FOR 循环语法是FOR ident : expr TO expr DO statement循环变量必须是整型变量且循环体内不允许对循环变量赋值这是语义层的职责但语法层最好也做一次结构检查。IF 语句里可选的 ELSE 部分用“悬空 else 匹配最近 IF”的原则处理递归下降天然支持这一点ASTNode* Parser::parseIfStatement() { expect(TokenType::IF); ASTNode* cond parseExpr(); expect(TokenType::THEN); ASTNode* thenBody parseStatement(); ASTNode* elseBody nullptr; if (match(TokenType::ELSE)) { elseBody parseStatement(); } return new IfNode(cond, thenBody, elseBody); }上述代码中ELSE 是在当前层面对应最近一个未匹配 IF 的因为递归下降过程中内层 parseStatement 会先把内层 IF 的 ELSE 消费掉外层 IF 拿到的永远是自己的 ELSE。这个行为无需显式处理只要保持递归下降顺序就自动正确。语法层做完后写一个 dump 函数把 AST 以缩进形式打印出来这一步极其重要。我看到不少同学跳过 AST dump 直接写语义分析结果节点结构错了还浑然不知。AST 可视化输出的代码非常简单就是深度遍历打印节点类型和值void dumpAST(ASTNode* node, int depth) { string indent(depth * 2, ); cout indent node-typeName() ; if (node-val ! ) cout node-val; cout endl; for (auto* child : node-children) dumpAST(child, depth 1); }4. 语义分析与中间代码符号表是灵魂四元式是骨架语法分析只解决“这句话合不合语法”语义层要解决“这句话有没有意义”。SNL 语义检查包括变量是否先声明后使用、赋值类型是否匹配、函数调用参数个数与类型是否一致、FOR 循环变量是否被非法赋值。中间代码生成通常采用三地址码风格的四元式。4.1 符号表结构嵌套作用域怎么设计SNL 支持函数嵌套符号表必须支持作用域。常见的两种设计是单一哈希表加作用域栈或树形符号表。课程设计量级下一个能 push/pop 的栈式符号表足够用struct Symbol { string name; VarKind kind; // variable, parameter, function, constant VarType type; // integer, char, array, bool int dim; // 数组长度非数组为 0 int offset; // 相对当前帧的偏移代码生成用 }; class ScopeStack { vectorunordered_mapstring, Symbol scopes; public: void push() { scopes.emplace_back(); } void pop() { scopes.pop_back(); } void insert(const Symbol sym) { auto cur scopes.back(); if (cur.count(sym.name)) error(duplicate name: sym.name); cur[sym.name] sym; } Symbol* lookup(const string name) { for (auto it scopes.rbegin(); it ! scopes.rend(); it) { auto found it-find(name); if (found ! it-end()) return found-second; } error(undefined symbol: name); return nullptr; } int depth() const { return scopes.size(); } };push 在进入函数或块时调用pop 在离开时调用。lookup 从内层往外层找这保证了内层能访问外层变量但外层访问不到内层变量。这里有个坑剖析一下在函数里声明局部变量时它的生命周期是“函数执行期间”但词法作用域是“声明点之后到块结束”。课程设计的语义分析阶段通常不做运行时生命周期分析只需要按块维护符号表即可等到代码生成再考虑如何分配栈空间。4.2 语义检查的三个必经关卡第一关是类型检查。SNL 的算术运算符只能用于整数关系运算符的结果是布尔值但 SNL 没有显式布尔类型——常见的处理是把布尔值映射为整数 1/0。赋值语句要求左值和右值类型一致INTEGER 和 CHAR 之间不允许隐式转换。第二关是流程控制检查。FOR 循环变量的类型必须是 INTEGER且必须是简单变量不允许是数组元素循环体内不允许出现对该变量的赋值。这个检查要在遍历 AST 时携带“当前循环变量”上下文进入 FOR 节点时记录循环变量名在其子节点的赋值表达式里做比对。第三关是函数调用检查。参数个数要求与声明时一致且类型匹配返回值类型必须与函数声明一致。SNL 的一个特殊设计点是它没有显式的 RETURN 语句——有些版本用“函数名作为赋值目标”来表示返回值这一点必须在语义层强制检查否则代码生成阶段会傻眼。void SemanticAnalyzer::visit(FunctionCallNode* node, Symbol* callee) { if (node-args.size() ! callee-paramCount) { err(function callee-name expects to_string(callee-paramCount) args, got to_string(node-args.size()), node-line); } for (int i 0; i node-args.size(); i) { VarType t inferType(node-args[i]); if (t ! callee-paramTypes[i]) err(type mismatch, node-line); } }4.3 生成四元式从 AST 到中间代码中间代码格式常见的是四元式(op, arg1, arg2, result)比如(, a, b, t1)表示 t1 a b。SNL 代码生成时需要给每个中间变量临时分配编号。符号表里记录的 offset 这时候派上用场——它代表变量在函数栈帧中的相对地址中间代码里的 result 如果是临时变量就分配一个新的 offset如果对应源变量就查符号表拿 offset。表达式翻译的经典过程是通过 AST 后序遍历遇到二元运算就把左右子树翻译到临时变量然后合并成一个新四元式string CodeGen::genExpr(ASTNode* node) { if (auto* c dynamic_castConstNode*(node)) { return c-val; // 常量直接返回字面量 } if (auto* v dynamic_castVarNode*(node)) { Symbol* s symTable.lookup(v-name); return to_string(s-offset); // 返回变量地址 } if (auto* b dynamic_castBinOpNode*(node)) { string lhs genExpr(b-left); string rhs genExpr(b-right); string temp newTemp(); // 生成新临时变量 emit(OpCode(b-op), lhs, rhs, temp); return temp; } // 函数调用节点类似处理 }注意 newTemp 的编号是递增的生成的中间代码形如t1 a b t2 t1 * c这个中间表示的好处是它独立于具体目标机器后续既可以翻译成 MIPS 汇编也可以翻译成自己设计的一个简单栈式虚拟机指令集。课程设计一般要求模拟执行这段中间代码所以常见做法是写一个解释器直接解释执行存储的四元式序列。5. 踩坑记录SNL 课程设计里的 6 个高频翻车点5.1 冒号等号 : 和等号 的区分错误现象词法分析器能跑通但程序一执行赋值语句就报语法错误提示 expect ASSIGN got EQUAL。原因SNL 的赋值运算符是:相等判断是。很多同学受 C 语言影响在测试用例里把赋值写成或者词法扫描器没有做最长匹配:被扫成了:和两个 token。解决词法扫描时必须先尝试匹配两字符操作符再回退到单字符测试用例里要严格区分赋值和比较。写测试样例时可以刻意把a : 1和if a 1 then同时写进一个程序验证词法层正确处理两种符号。5.2 关键字大小写不敏感被忽略现象声明INTEGER i;可以通过但Integer i;报错 undefined type或者反过来。原因SNL 继承 Pascal 传统关键字大小写不敏感但手写词法分析器时直接用了strcmp比较关键字表导致大小写必须完全一致。解决扫描阶段收集完标识符字符串后统一调用 tolower 再查关键字表。注意变量名也要统一小写存储否则Int和int会被当成两个不同变量。5.3 FOR 循环变量被循环体内赋值现象语义检查阶段漏掉了这个检查生成的中间代码在模拟执行时无限循环或结果错误。原因FOR 循环语义是循环变量由编译器自动管理程序员不得手动修改。但很多同学在语义分析时只检查了类型没检查这个特殊约束。解决遍历 AST 进入 ForNode 时保存循环变量名到一个字段处理子树的赋值节点时如果赋值目标是循环变量就报错。这个检查只针对 FOR 语句自身的循环体嵌套的另一个 FOR 使用同名变量时不要误判注意处理上下文恢复。5.4 数组下标只支持常量但按变量处理了现象声明ARRAY[10] OF INTEGER arr;后使用arr[i] : 1语法分析通过了语义分析却报越界或找不到符号。原因部分 SNL 版本只允许数组下标是整型常量不允许变量。不同版本规则不一致但课程设计题里一般会明确说明。如果题目没说明建议按“允许常量运行期检查越界”来实现更贴近真实编译器。解决查清楚题目要求。如果需禁止变量下标在语义层 visit ArrayNode 时检查下标是否为 ConstNode如果允许变量下标则在代码生成时对下标做边界检查越界时报告运行时错误。5.5 嵌套函数里的变量寻址错误现象函数 f 内部定义了内层函数 gg 访问 f 的局部变量时生成的 offset 指向错误内存。原因符号表查找时返回了正确符号但代码生成阶段把 offset 当作绝对地址直接用没有考虑当前栈帧的基址偏移。嵌套函数访问外层变量时需要通过静态链static link或显示显示层数计算地址。解决课程设计若不做中间代码解释执行而是直接翻译汇编建议采用静态链方案在每个函数帧里保存指向直接外层函数帧的指针访问变量时从当前帧沿静态链跳 depth 层再做偏移。若是走解释执行路线可以把符号表的 depth 一并存入符号条目运行时计算实际地址。5.6 错误恢复缺失报错一次就崩溃现象测试程序第一个错误后解析器直接退出后面几十个错误在用户修正前完全看不到。原因递归下降解析器遇到语法错误后直接 throw 或 exit不做任何同步恢复。解决实现一个初步的错误恢复机制——在 panic 模式下错误发生后跳过分号、END 等同步 token继续解析后续语句。这对验证大测试集特别重要不然每跑一次只发现一个错调试效率极低。参考实现如下void Parser::error(const string msg) { cerr line currentToken().line : msg endl; recover(); // 跳到下一个同步点 } void Parser::recover() { while (!isAtEnd() currentToken().type ! TokenType::SEMICOLON currentToken().type ! TokenType::BEGIN currentToken().type ! TokenType::END) { advance(); } if (match(TokenType::SEMICOLON)) advance(); }6. 让编译器“能说人话”错误报告、调试辅助与自动化测试技巧课程设计做到能编译、能跑通、能出结果只能算 80 分。剩下 20 分在于错误报告的质量、调试手段和测试体系的完备度。这些恰恰是评审老师在答辩环节最爱追问的细节。6.1 错误信息的格式建议一个好的错误报告应该包含三要素行号、期望的 token、实际遇到的 token。比如line 12: expected THEN, got ASSIGN。做到这一点只需要在 expect 函数里统一处理Token Parser::expect(TokenType expected) { if (currentToken().type ! expected) { error(expected tokenName(expected) , got tokenName(currentToken().type) ( currentToken().lexeme )); } return advance(); }这里不要只打印 token 类型要把实际 lexeme 打出来否则调试时看到unexpected token type 5你还要去查枚举表浪费时间。词法、语法、语义三层错误建议用不同的错误前缀比如词法错误LexError、语法错误SyntaxError、语义错误SemanticError让使用者一眼知道问题出在哪一层。6.2 中间代码结构化 dump一个比 GDB 更好用的调试视角语义分析生成四元式集合后如果按照执行顺序打印出来很难看出控制流结构。更直观的做法是按函数为单位打印函数名、符号表和指令序列三节。指令打印时不要直接输出(, t1, t2, t3)可以伪装成可读伪代码t1 a b t2 t1 * c if t2 100 goto L3这种输出在验证代码生成正确性时特别好用因为它同时暴露了表达式翻译错误、临时变量编号错误、跳转目标错误三类问题。我一般会在项目里加一个--dump-ir命令行参数打开后只输出 IR 不做模拟执行。6.3 测试驱动用分层测试集代替靠感觉调试翻车最多的地方就是“跑一下看看对不对”。SNL 编译器的每个模块依赖前一个模块如果词法错误没有全部清理干净就进语法调试你会分不清错误来自哪一层。建议建立四层测试集词法测试覆盖所有 token 类型、边界字符、注释和错误输入语法测试每个产生式至少一个正例和一个负例语义测试类型不匹配、重复声明、未声明引用、FOR 变量非法赋值等代码生成测试表达式优先级、控制流跳转、函数调用参数传递、数组读写。每一层测试写成一个独立目录文件命名为test-lex-01.snl、test-lex-error-01.snl这样的格式。负例统一以.bad.snl结尾自动测试脚本检查编译器是否按预期报错正例以.snl结尾检查输出结果是否与预期一致。#!/bin/bash for f in tests/semantic/*.snl; do ./snlc $f /tmp/out.txt 21 if [ $? -ne 0 ]; then echo FAIL: $f (expected pass) ; fi done for f in tests/semantic/*.bad.snl; do ./snlc $f /tmp/out.txt 21 if [ $? -eq 0 ]; then echo FAIL: $f (expected error) ; fi done这套脚本还有一个隐藏好处答辩演示时可以现场跑全量测试亮出几十个测试用例全部通过的记录远比单独演示两个 Demo 有说服力。6.4 最后一个习惯把 AST dump 当作第一调试工具我带学生做这类项目时定过一条铁律任何语义 bug先看 AST dump不要直接改语义代码。AST 如果错了语义和代码生成一定不对AST 没错再查语义逻辑和代码生成。这个习惯帮我省下来的调试时间比任何工具都值。如果你觉得本文的路子值得试建议先花一个晚上把 EBNF、词法扫描器、AST 节点类型三个文件写好再开始写递归下降后面你会感谢这个决定的。希望这篇记录能帮你在课程设计路上少踩几个我已经替你踩过的坑。本文还有配套的精品资源点击获取