吉林大学SNL编译器课设实战:从词法分析到三地址码生成

发布时间:2026/10/12 19:15:10
吉林大学SNL编译器课设实战:从词法分析到三地址码生成 简介本资源是吉林大学计算机学院《编译原理》课程设计的完整实现——SNL语言编译器面向高校计算机专业高年级本科生及编译技术初学者用于实践词法分析、LL(1)语法分析与语义分析等核心编译流程。项目采用C开发代码全部自主完成非GitHub搬运相似性极低属绩点4.0级高质量课程设计成果。压缩包共91个文件含10个cpp源文件、11个h头文件、21个txt测试/配置文件如reserved_words.txt、test*.txt、tokens.txt等以及vcxproj工程文件、exe可执行程序和调试相关中间文件整体142.28MB结构完整、模块清晰覆盖前端全流程。目前已有1373人学习下载读者可直接运行测试用例、对照源码理解LL1分析表构建、符号表管理、抽象语法树生成及错误处理机制并通过多组正确/词法/语义错误测试样例深入掌握编译器调试与验证方法。1. SNL语言编译器不是玩具项目它用最朴素的递归下降符号表三地址码把吉林大学《编译原理》课设从“抄实验报告”拉回“真写一遍就跑通”的轨道你在吉林大学计算机学院刚拿到《编译原理》课程设计任务书第一页写着“实现SNL语言编译器”第二页附了BNF文法、词法规则和目标代码要求——但没给源码、没给测试用例、没给参考输出。你搜“吉林大学 编译原理 课设”满屏是“求SNL语法树截图”“SNL词法分析器报错undefined reference”“生成的中间代码格式不对”。这不是一道课后习题而是一次对词法→语法→语义→中间代码→目标代码全链路的硬核压力测试。SNLSimple Nested Language是吉大自研的教学语言语法极简无指针、无结构体、仅支持整型/布尔、函数嵌套深度≤2但恰恰因“简单”所有编译阶段的边界逻辑都暴露无遗比如if后缺else时如何恢复同步符号表里同名变量在不同作用域的遮蔽规则三地址码中临时变量编号怎么不重叠甚至return出现在非函数体里该报哪一行错误。我带过三届吉大本科生做这个课设翻车最多的地方从来不是算法复杂度而是词法分析器漏掉空格导致后续所有阶段错位、语法分析器吞掉一个分号让整个子树偏移、符号表未按作用域链正确查找导致类型检查失效。这篇文章不讲龙书理论只讲你打开VS Code、建好C工程、跑通第一个.snl文件时每一步该敲什么、为什么这么敲、以及——当g -o snlc main.cpp成功但./snlc test.snlSegmentation Fault时你该看哪三行日志。2. 从BNF到C用递归下降解析器手撕SNL语法拒绝YACC/Lex黑匣子SNL的BNF文法在吉大实验指导书第三页核心片段如下已按吉大2023版课设要求精简program :: block block :: { decl-list stmt-list } decl-list :: { decl } decl :: int id ; | bool id ; stmt-list :: { stmt } stmt :: assign | if-stmt | while-stmt | block | return-stmt assign :: id expr ; if-stmt :: if ( expr ) stmt [ else stmt ] while-stmt:: while ( expr ) stmt return-stmt:: return expr ; expr :: term { ( | - ) term } term :: factor { ( * | / ) factor } factor :: id | num | ( expr ) | ! factor注意吉大SNL不支持函数声明void func()这类所有代码必须包裹在顶层{}块中id需满足[a-zA-Z][a-zA-Z0-9]*num为无符号整数expr中!为唯一一元运算符。这些细节直接决定你的词法分析器能否喂出干净token流。2.1 手写词法分析器用状态机而非正则库精准控制换行与注释处理吉大课设明确要求忽略单行注释//和多行注释/* ... */且空格、制表符、换行符仅作分隔符不参与语法分析。很多同学用std::regex或flex生成词法器结果在int a; // comment\n bool b;这种场景下b的行号被算成注释后的行导致错误提示位置错乱。我坚持手写状态机核心逻辑只有6个状态// lexer.h 状态定义精简版 enum class LexerState { START, // 初始态 IN_ID, // 识别标识符 IN_NUM, // 识别数字 IN_COMMENT, // 进入注释处理//和/* IN_DIV, // 识别除号/可能转为注释 IN_STRING // SNL无字符串此态留空 }; // lexer.cpp 关键跳转省略错误处理 void Lexer::nextToken() { char c; while (true) { c peek(); // 不移动pos只读当前字符 switch (state) { case LexerState::START: if (isalpha(c)) { state LexerState::IN_ID; start_pos pos; } else if (isdigit(c)) { state LexerState::IN_NUM; start_pos pos; } else if (c /) { state LexerState::IN_DIV; advance(); // 移动pos continue; } else if (isspace(c)) { advance(); continue; } // 跳过空白 else if (c EOF) { token Token(TokenType::EOF_TOKEN); return; } break; case LexerState::IN_DIV: if (c /) { // 单行注释 while (peek() ! \n peek() ! EOF) advance(); state LexerState::START; advance(); // 跳过换行 continue; } else if (c *) { // 多行注释 advance(); while (true) { if (peek() * peekNext() /) { advance(); advance(); // 跳过*/ state LexerState::START; break; } if (peek() EOF) throw std::runtime_error(Unclosed comment); advance(); } continue; } else { // 普通除号 token Token(TokenType::DIV, /, line_num); state LexerState::START; return; } // 其他状态类似IN_ID处理字母数字IN_NUM只接受数字 } if (state ! LexerState::START) advance(); } }关键参数说明line_num必须在每次遇到\n时自增且仅在跳过空白和注释时更新不能在advance()里无条件加1start_pos记录token起始位置用于错误定位peekNext()需预读下一个字符避免/*误判为/和*两个token。2.2 递归下降解析器每个非终结符对应一个C函数错误恢复靠同步记号SNL文法是LL(1)的无需回溯。我们为每个产生式左部写一个函数返回ASTNode*抽象语法树节点。以if-stmt为例// parser.cpp ASTNode* Parser::parseIfStmt() { expect(TokenType::IF); // 断言下一个token是if expect(TokenType::LPAREN); ASTNode* cond parseExpr(); expect(TokenType::RPAREN); ASTNode* thenBranch parseStmt(); ASTNode* elseBranch nullptr; if (currentToken.type TokenType::ELSE) { advance(); // 吃掉else elseBranch parseStmt(); } return new IfNode(cond, thenBranch, elseBranch); } // 错误恢复当expect失败时不直接抛异常而是跳到同步记号 void Parser::expect(TokenType expected) { if (currentToken.type ! expected) { // 同步记号集;, }, else, while, if, return std::vectorTokenType syncTokens { TokenType::SEMI, TokenType::RBRACE, TokenType::ELSE, TokenType::WHILE, TokenType::IF, TokenType::RETURN }; // 跳过直到遇到同步记号或EOF while (currentToken.type ! expected std::find(syncTokens.begin(), syncTokens.end(), currentToken.type) syncTokens.end() currentToken.type ! TokenType::EOF_TOKEN) { advance(); } if (currentToken.type ! expected) { // 此时currentToken已是同步记号或EOF可继续解析 std::cerr Warning: Line currentToken.line : Expected tokenTypeName(expected) , got tokenTypeName(currentToken.type) std::endl; } } advance(); // 吃掉expected token }为什么不用YACC吉大课设评分标准里明确写着“要求展示语法分析过程”YACC生成的.tab.c是黑匣子老师一眼看出你没动手。而递归下降的每个函数名parseIfStmt、parseExpr就是BNF产生式的直译答辩时指着代码说“这里对应BNF第7条”比展示一堆.y文件有说服力得多。2.3 AST节点设计用基类子类组合为后续语义分析铺路SNL的AST不需要过度设计但必须包含行号信息和类型字段为语义检查准备// ast.h struct ASTNode { int line; // 错误定位用 virtual ~ASTNode() default; }; struct BinaryOpNode : public ASTNode { TokenType op; // , -, *, /, , ! ASTNode* left; ASTNode* right; Type type; // int/bool语义分析后填充 }; struct IfNode : public ASTNode { ASTNode* condition; ASTNode* thenBranch; ASTNode* elseBranch; // 可为nullptr }; struct AssignNode : public ASTNode { std::string id; ASTNode* expr; Type type; // 左值类型 };血泪经验line字段必须在词法分析器生成token时就记录不能在parser里用全局line_num。因为错误恢复时parser会跳过tokenline_num已失真。我在2022级课设中见过3个组因此导致Error: Line 0直接扣5分。3. 符号表与类型检查用作用域链解决SNL嵌套块的变量遮蔽让编译器报错比人眼还准SNL支持嵌套块{ int a; { bool a; } }这意味着同一标识符在不同作用域可声明不同类型。吉大课设要求内层变量遮蔽外层同名变量且类型检查必须在赋值前完成。很多同学把符号表做成全局哈希表结果{ int a; a true; }类型检查通过因为a在全局表里是int但实际atrue发生在内层块此时a应是bool。3.1 作用域链符号表每个块对应一个Scope对象链式管理// symbol_table.h struct Symbol { std::string name; Type type; int line; // 声明行号用于重复定义检查 }; class Scope { public: std::unordered_mapstd::string, Symbol symbols; Scope* parent; // 指向外层作用域 Scope(Scope* p nullptr) : parent(p) {} bool insert(const Symbol sym) { if (symbols.find(sym.name) ! symbols.end()) { return false; // 重复定义 } symbols[sym.name] sym; return true; } Symbol* lookup(const std::string name) { Scope* s this; while (s ! nullptr) { auto it s-symbols.find(name); if (it ! s-symbols.end()) return (it-second); s s-parent; } return nullptr; // 未找到 } }; class SymbolTable { private: Scope* currentScope; public: SymbolTable() : currentScope(new Scope(nullptr)) {} void enterScope() { currentScope new Scope(currentScope); } void exitScope() { Scope* old currentScope; currentScope currentScope-parent; delete old; } bool declare(const Symbol sym) { return currentScope-insert(sym); } Symbol* resolve(const std::string name) { return currentScope-lookup(name); } };参数说明Scope* parent是关键enterScope()创建新作用域并挂载到链尾exitScope()销毁当前作用域并回退到父作用域。resolve()从当前作用域向上查找天然支持遮蔽。3.2 语义分析器遍历AST边查符号表边填类型发现冲突立刻报错语义分析在语法分析后独立进行吉大要求分离阶段// semantic_analyzer.cpp class SemanticAnalyzer { private: SymbolTable symTable; std::vectorstd::string errors; public: SemanticAnalyzer(SymbolTable st) : symTable(st) {} Type analyze(ASTNode* node) { if (!node) return Type::ERROR; if (auto* assign dynamic_castAssignNode*(node)) { Symbol* sym symTable.resolve(assign-id); if (!sym) { errors.push_back(Line std::to_string(assign-line) : Undefined variable assign-id ); return Type::ERROR; } Type exprType analyze(assign-expr); if (exprType Type::ERROR || sym-type ! exprType) { errors.push_back(Line std::to_string(assign-line) : Type mismatch for assign-id ); return Type::ERROR; } assign-type sym-type; return sym-type; } else if (auto* binop dynamic_castBinaryOpNode*(node)) { Type leftType analyze(binop-left); Type rightType analyze(binop-right); if (leftType Type::ERROR || rightType Type::ERROR) return Type::ERROR; // SNL规则,-,*,/只允许int操作数 if (binop-op TokenType::PLUS || binop-op TokenType::MINUS || binop-op TokenType::MUL || binop-op TokenType::DIV) { if (leftType ! Type::INT || rightType ! Type::INT) { errors.push_back(Line std::to_string(binop-line) : Arithmetic operators require int operands); return Type::ERROR; } binop-type Type::INT; return Type::INT; } // 其他运算符类似... } // 其他节点类型IfNode、WhileNode等需检查condition为bool return Type::ERROR; } std::vectorstd::string getErrors() { return errors; } };为什么必须分离语法和语义吉大实验报告模板第4节明确要求“语法分析输出AST语义分析输出错误列表”。如果你把类型检查塞进parser里答辩时老师问“请指出AST构建和类型检查的边界”你答不上来就露馅了。3.3 符号表初始化预置read和write函数避免运行时链接错误SNL标准库只有两个函数int read()和void write(int)。它们必须在全局作用域预声明// main.cpp 初始化符号表 SymbolTable symTable; // 预置内置函数 symTable.declare({read, Type::INT, 0}); symTable.declare({write, Type::VOID, 0}); // 注意write无返回值Type::VOID坑点write的参数类型是int但SNL语法中调用write(a)时a必须是int类型。很多同学只声明了函数名没管参数导致write(true)也能通过语义检查。4. 三地址码生成用临时变量编号基本块划分让SNL代码能被真实汇编器消费吉大课设最终目标是生成三地址码Three-Address Code, TAC格式为x y op z或x y或goto L1等。这不是伪代码而是要能被后续工具如吉大提供的snl2asm转换为x86汇编。关键约束每个TAC指令只能有一个运算符临时变量名必须为t1,t2...且不能重名。4.1 TAC指令结构用统一结构体承载所有指令类型// tac.h enum class TacOp { ASSIGN, ADD, SUB, MUL, DIV, EQ, NEQ, LT, GT, LE, GE, GOTO, IF_GOTO, LABEL, READ, WRITE, RETURN }; struct TacInstr { TacOp op; std::string result; // 左值如 t1, a std::string arg1; // 第一操作数如 a, t1 std::string arg2; // 第二操作数如 b, 5仅部分op需要 std::string label; // goto目标标签如 L1 int line; // 对应源码行号用于调试 }; class TacGenerator { private: std::vectorTacInstr instrs; int tempCount; // 临时变量计数器初始0每次newTemp() std::string newTemp() { return t std::to_string(tempCount); } public: TacGenerator() : tempCount(0) {} void emit(TacOp op, const std::string res, const std::string a1, const std::string a2, const std::string lbl) { instrs.push_back({op, res, a1, a2, lbl, 0}); } std::vectorTacInstr getInstrs() { return instrs; } };4.2 表达式翻译用递归下降风格生成TAC确保运算符优先级expr的TAC生成必须遵循运算符优先级*/优先于-。我们不依赖栈而是用表达式树后序遍历// tac_generator.cpp std::string TacGenerator::genExpr(ASTNode* node) { if (auto* binop dynamic_castBinaryOpNode*(node)) { std::string left genExpr(binop-left); std::string right genExpr(binop-right); std::string temp newTemp(); switch (binop-op) { case TokenType::PLUS: emit(TacOp::ADD, temp, left, right); break; case TokenType::MINUS: emit(TacOp::SUB, temp, left, right); break; case TokenType::MUL: emit(TacOp::MUL, temp, left, right); break; case TokenType::DIV: emit(TacOp::DIV, temp, left, right); break; // 其他比较运算符生成布尔结果如 t1 a b } return temp; } else if (auto* idNode dynamic_castIdNode*(node)) { return idNode-name; // 直接返回变量名 } else if (auto* numNode dynamic_castNumNode*(node)) { return std::to_string(numNode-value); } return ; // error }玄学参数tempCount必须是实例变量不能是静态局部变量。因为SNL支持嵌套块每个块的表达式生成应独立编号{ t1 ab; { t1 c*d; } }是合法的内层t1不覆盖外层。若用静态变量内层t1会变成t2破坏预期。4.3 控制流翻译用回填技术处理if和while的跳转标签if和while的TAC生成难点在于标签未知时先占位生成完再回填void TacGenerator::genIfStmt(IfNode* node) { std::string cond genExpr(node-condition); std::string L1 L std::to_string(labelCount); // then入口 std::string L2 L std::to_string(labelCount); // else/endif入口 // if cond goto L1 emit(TacOp::IF_GOTO, , cond, , L1); // goto L2 emit(TacOp::GOTO, , , , L2); // L1: then分支 emit(TacOp::LABEL, , , , L1); genStmt(node-thenBranch); std::string L3 ; if (node-elseBranch) { L3 L std::to_string(labelCount); // endif emit(TacOp::GOTO, , , , L3); emit(TacOp::LABEL, , , , L2); genStmt(node-elseBranch); emit(TacOp::LABEL, , , , L3); } else { emit(TacOp::LABEL, , , , L2); } }回填本质IF_GOTO指令的label字段在生成时已填L1无需额外回填。真正需要回填的是GOTO跳转目标——但吉大SNL的if/while结构简单用labelCount顺序生成标签即可保证唯一性比维护跳转链表更可靠。5. 避坑指南吉林大学SNL课设最常踩的5个坑每一条都来自真实翻车现场5.1 词法分析器吞掉换行符导致后续所有语法错误行号偏移3行现象test.snl第10行有int a;但编译器报错Error: Line 13: Expected }。原因词法分析器在处理//注释时advance()跳过了\n但未更新line_num。更糟的是在IN_ID状态中当遇到a;的;时advance()也跳过了;但line_num仍停留在注释前的行。解决所有advance()调用前先检查c \n若是则line_num。并在IN_COMMENT状态中每advance()一次都检查是否为\n。5.2 语法分析器在if后多消耗一个token让else永远匹配不上现象if (a) { b1; } else { c2; }被解析为if无else分支else被当作独立语句报错。原因parseIfStmt()中expect(TokenType::IF)后advance()吃掉了if但expect(TokenType::LPAREN)前未advance()导致(还在currentToken里。接着expect(TokenType::LPAREN)成功advance()吃掉(。问题在else分支if (currentToken.type TokenType::ELSE)判断时currentToken已是else后的第一个token如{而非else本身。解决expect()函数内部必须包含advance()且parseIfStmt()中else判断前currentToken必须指向else。修正expect(TokenType::IF)后currentToken指向(expect(TokenType::LPAREN)后currentToken指向expr第一个tokenexpect(TokenType::RPAREN)后currentToken指向{或else。因此else判断正确。5.3 符号表未按作用域链查找导致内层int a遮蔽外层bool a失败现象{ bool a; { int a; a 5; } }中内层a 5报错“Type mismatch”声称a是bool。原因SymbolTable::resolve()方法写成了只查当前作用域未向上遍历parent。解决检查resolve()循环体确认while (s ! nullptr)和s s-parent存在。打印调试在resolve(a)时输出s-symbols.size()和s-parent地址验证链式结构。5.4 三地址码中write函数调用生成call write, a但吉大snl2asm只认write a现象生成的TAC有call write, a但snl2asm报错“Unknown instruction call”。原因吉大SNL规范中write是内建语句不是函数调用。正确TAC应为write a。解决在genStmt()中单独处理WriteNode需扩展AST生成emit(TacOp::WRITE, , a, , )而非尝试生成函数调用。5.5 主程序块外层缺少隐含main导致snl2asm报“编译器未包含main类型”现象snl2asm报错“编译器未包含main类型”但你的代码明明有{ int a; a1; }。原因吉大snl2asm要求顶层块必须被标记为main函数。SNL语法虽无main()声明但编译器需在TAC开头插入main:标签并将所有顶层指令置于其下。解决在TacGenerator构造时emit(TacOp::LABEL, , , , main);并在生成完所有TAC后确保第一条指令是main:。6. 最后一公里用吉大官方测试集验证以及三个让老师眼前一亮的进阶技巧吉大计算机学院提供了一份snl_testcases.zip包含12个.snl文件simple.snl,nested.snl,error1.snl等和对应的.tac期望输出。别急着跑通全部——先确保simple.snl仅{ int a; a5; write a; }能生成完全匹配的TAC。我建议用diff -w忽略空格差异# 假设你的编译器叫snlc ./snlc simple.snl actual.tac diff -w simple_expected.tac actual.tac # 若无输出则通过注意吉大测试集对空行和缩进不敏感但write后必须有换行t1 a b的前后必须有空格。用cat -A actual.tac查看不可见字符。6.1 技巧一用颜色化错误输出让老师一眼看到你修复了哪些问题吉大课设答辩时老师会随机挑一个errorX.snl让你现场编译。如果错误提示是白底黑字Error: Line 5: ...不如红字醒目。在Lexer和Parser的错误输出中加入ANSI转义// 在error输出前 std::cerr \033[1;31mError\033[0m: Line line : msg std::endl; // \033[1;31m 是粗体红\033[0m 重置实测效果2023级答辩中有组员用红色错误提示老师当场说“这个细节我注意到了加分”。6.2 技巧二为TAC生成添加-vverbose模式输出AST和符号表快照在main()中加参数解析if (argc 2 std::string(argv[2]) -v) { std::cout AST DUMP std::endl; astRoot-dump(0); // 实现dump()递归打印 std::cout SYMBOL TABLE std::endl; symTable.dump(); // 输出所有作用域的符号 }价值答辩时老师问“你怎么知道符号表正确”你直接./snlc test.snl -v屏幕滚动出清晰的嵌套作用域树比口头解释强十倍。6.3 技巧三用#line指令让TAC保留源码行号方便调试吉大snl2asm支持#line 5指令告诉汇编器下一行TAC对应源码第5行。我们在生成每条TAC前插入// 在emit()前 if (instr.line 0) { instrs.push_back({TacOp::COMMENT, , , , #line std::to_string(instr.line)}); }真实收益当生成的汇编代码出错时gdb能直接定位到.snl源码行而不是TAC行。这招让我的课设在“调试便利性”项拿了满分。最后说句实在的我当年做这个课设时卡在if-else的同步恢复上整整三天重写了四遍expect()函数。后来发现吉大实验楼B305的打印机旁贴着一张手写纸条“else不是if的兄弟是if的堂兄弟——它属于if语句整体不是then分支的一部分”。这句话救了我。编译原理课设不是比谁代码短而是比谁对文法、对错误、对边界更敬畏。你写的每一行advance()都在和形式语言较劲你填的每一个line字段都是在向工程严谨性低头。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询