ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

吉林大学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”“生成的中间代码格式不对”。这不是一道课后习题,而是一次对词法→语法→语义→中间代码→目标代码全链路的硬核压力测试。SNL(Simple 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()里无条件加1;start_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::vector<TokenType> 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会跳过token,line_num已失真。我在2022级课设中见过3个组因此导致Error: Line 0,直接扣5分。


3. 符号表与类型检查:用作用域链解决SNL嵌套块的变量遮蔽,让编译器报错比人眼还准

SNL支持嵌套块({ int a; { bool a; } }),这意味着同一标识符在不同作用域可声明不同类型。吉大课设要求:内层变量遮蔽外层同名变量,且类型检查必须在赋值前完成。很多同学把符号表做成全局哈希表,结果{ int a; a = true; }类型检查通过(因为a在全局表里是int,但实际a=true发生在内层块,此时a应是bool)。

3.1 作用域链符号表:每个块对应一个Scope对象,链式管理

// symbol_table.h struct Symbol { std::string name; Type type; int line; // 声明行号,用于重复定义检查 }; class Scope { public: std::unordered_map<std::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::vector<std::string> errors; public: SemanticAnalyzer(SymbolTable& st) : symTable(st) {} Type analyze(ASTNode* node) { if (!node) return Type::ERROR; if (auto* assign = dynamic_cast<AssignNode*>(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_cast<BinaryOpNode*>(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::vector<std::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::vector<TacInstr> 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::vector<TacInstr>& getInstrs() { return instrs; } };

4.2 表达式翻译:用递归下降风格生成TAC,确保运算符优先级

<expr>的TAC生成必须遵循运算符优先级(*/优先于+-)。我们不依赖栈,而是用表达式树后序遍历:

// tac_generator.cpp std::string TacGenerator::genExpr(ASTNode* node) { if (auto* binop = dynamic_cast<BinaryOpNode*>(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_cast<IdNode*>(node)) { return idNode->name; // 直接返回变量名 } else if (auto* numNode = dynamic_cast<NumNode*>(node)) { return std::to_string(numNode->value); } return ""; // error }

玄学参数:tempCount必须是实例变量,不能是静态局部变量。因为SNL支持嵌套块,每个块的表达式生成应独立编号({ t1 = a+b; { 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) { b=1; } else { c=2; }被解析为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第一个token;expect(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; a=1; }。
  • 原因:吉大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; a=5; 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生成添加-v(verbose)模式,输出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字段,都是在向工程严谨性低头。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进