多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

南航编译原理课程设计:Java手写词法与语法分析器实战指南

南航编译原理课程设计:Java手写词法与语法分析器实战指南 简介本资源是南京航空航天大学《编译原理》课程设计的完整实现包面向计算机专业本科生及编译技术初学者聚焦词法分析与语法分析核心环节提供可直接运行、经验证无BUG的工程级实践方案。压缩包共32个文件涵盖8个关键源码与头文件如lex.c、token.h、parse.txt、symtable.h等、5个可执行程序含词法分析器lex.exe及语法分析器text.exe、3个目标文件、2个Word课设报告与答辩PPT以及调试所需的pdb、ilk、ncb等开发辅助文件整体961KB结构完整便于分模块学习与调试。已有749人下载学习内容包含从C语言实现的词法扫描器到语法分析框架的全过程代码、配套文档说明及可视化答辩材料特别适合课程设计参考、实验复现与编译前端开发入门实践。1. 为什么南航的编译原理课程设计总让本科生“一边写词法分析器一边怀疑人生”南京航空航天大学《编译原理》课程设计不是一道课后习题而是一次对工程能力的立体拷问它要求你用 Java或 C/C从零手写一个能跑通真实代码片段的微型编译器前端——必须完成词法分析、语法分析、语义检查、中间代码生成四阶段闭环最后输出三地址码或类 JVM 字节码。这不是调用 ANTLR 自动生成 parser 的“黑匣子流水线”而是强制你亲手实现 DFA 状态迁移表、递归下降解析器的冲突消解、符号表的嵌套作用域管理、类型兼容性校验规则。很多同学卡在“识别完int a 3 b;却报b 未声明”不是语法错是符号表插入时机没对齐更多人调试到凌晨发现 LR(1) 项目集规范族构造时漏了闭包传递——这些血泪经验背后是南航对“理解编译器如何把人类语言翻译成机器可执行逻辑”这一底层能力的硬性锚定。适合想夯实系统级编程基础、为后续操作系统/虚拟机/静态分析工具开发铺路的计算机专业本科生也适合准备考研复试中被问“你真写过语法树遍历吗”的实战派。2. 用 Java 实现词法分析器从正则定义到 Token 流的确定性落地2.1 为什么不用 JFlex手写 DFA 才是南航要考的“肌肉记忆”南航课程设计明确要求“禁止直接使用 Lex/Yacc 或 ANTLR 等自动生成工具”核心意图是逼你暴露状态机设计的思维断层。JFlex 虽快但会掩盖if (state S1 ch i) state S2;这种底层跳转逻辑。我带过三届助教发现学生用 JFlex 生成的 lexer 在处理和二义性时常因优先级配置错误导致被拆成两个 token——这恰恰暴露了对最长匹配原则Maximal Munch的理解缺失。手写 DFA 强制你画出状态图比如识别标识符必须显式定义S0 → S1 (letter) → S1 (letter|digit) → S2 (accept)并在代码中用 switch-case 模拟状态迁移。这不是复古是训练你对“输入字符流如何被有限状态机切割”的直觉。2.2 Java 版词法分析器最小可运行骨架含关键注释// Lexer.java - 南航课程设计标准起点 public class Lexer { private String input; private int pos 0; private char currentChar; public Lexer(String input) { this.input input \0; // 末尾哨兵避免越界判断 this.currentChar input.length() 0 ? input.charAt(0) : \0; } private void advance() { pos; currentChar (pos input.length()) ? input.charAt(pos) : \0; } public Token getNextToken() { while (Character.isWhitespace(currentChar)) advance(); // 跳过空格制表符 if (currentChar \0) return new Token(TokenType.EOF, ); // 处理数字字面量支持整数不支持小数点 if (Character.isDigit(currentChar)) { StringBuilder num new StringBuilder(); while (Character.isDigit(currentChar)) { num.append(currentChar); advance(); } return new Token(TokenType.NUMBER, num.toString()); } // 处理标识符和关键字先读完整标识符再查保留字表 if (Character.isLetter(currentChar)) { StringBuilder id new StringBuilder(); while (Character.isLetterOrDigit(currentChar)) { id.append(currentChar); advance(); } String value id.toString(); // 关键字表必须硬编码不能用 HashMap 查体现手动分类思想 switch (value) { case int: return new Token(TokenType.INT, value); case if: return new Token(TokenType.IF, value); case while: return new Token(TokenType.WHILE, value); default: return new Token(TokenType.IDENTIFIER, value); // 标识符兜底 } } // 处理运算符单字符优先双字符需 peek switch (currentChar) { case : advance(); return new Token(TokenType.PLUS, ); case -: advance(); return new Token(TokenType.MINUS, -); case *: advance(); return new Token(TokenType.MUL, *); case /: advance(); return new Token(TokenType.DIV, /); case : advance(); if (currentChar ) { // peek 下一字符 advance(); return new Token(TokenType.LE, ); } else { return new Token(TokenType.LT, ); } case : advance(); if (currentChar ) { advance(); return new Token(TokenType.EQ, ); } else { return new Token(TokenType.ASSIGN, ); } case ;: advance(); return new Token(TokenType.SEMICOLON, ;); case (: advance(); return new Token(TokenType.LPAREN, (); case ): advance(); return new Token(TokenType.RPAREN, )); case {: advance(); return new Token(TokenType.LBRACE, {); case }: advance(); return new Token(TokenType.RBRACE, }); default: throw new RuntimeException(Illegal character: currentChar); } } }逻辑说明该骨架严格遵循南航实验指导书要求的 token 类型INT,IF,NUMBER,IDENTIFIER等所有分支均对应教材《编译原理龙书》第3章 DFA 设计原则。advance()中的\0哨兵避免每次循环都做pos input.length()判断提升性能peek机制如后检查是处理双字符运算符的标准解法比正则预编译更贴近手写本质。参数说明input字符串必须是完整源码如int a 3 4;不可含换行符南航测试用例均为单行Token类需自行定义至少包含type枚举和valueString字段TokenType枚举必须与南航实验报告模板中的 token 名称完全一致如ASSIGN而非EQUALS否则后续语法分析器无法对接。3. 递归下降语法分析器如何用 Java 写出无冲突、可调试的 Parser3.1 为什么选递归下降南航评分细则里藏着的“可读性”陷阱南航课程设计评分表中“语法分析器结构清晰度”占 20 分远高于“是否通过全部测试用例”15 分。这意味着用 LR(1) 自动构造状态表虽强但若代码全是goto state37助教根本无法快速定位if-else缺少else分支时的 panic 恢复逻辑。递归下降天然对应 BNF 文法每个非终结符对应一个方法parseIfStmt(),parseExpr()调试时直接在 IDE 里 step into 就能看到expr → term { addop term }的展开路径。更重要的是南航提供的测试用例如if (a b) { c d; } else { e f; }刻意设计了左递归陷阱expr → expr addop term强制你改写为右递归或提取左因子——这正是龙书第4章强调的“消除左递归”实操环节。3.2 支持赋值、算术、条件语句的 Java Parser 骨架含错误恢复// Parser.java - 严格按南航文法扩展 public class Parser { private Lexer lexer; private Token currentToken; public Parser(Lexer lexer) { this.lexer lexer; this.currentToken lexer.getNextToken(); } private void eat(TokenType type) { if (currentToken.getType() type) { currentToken lexer.getNextToken(); } else { throw new RuntimeException(Expected type , got currentToken.getType()); } } // program → stmt* public ASTNode parseProgram() { ListASTNode stmts new ArrayList(); while (currentToken.getType() ! TokenType.EOF) { stmts.add(parseStmt()); } return new ProgramNode(stmts); } // stmt → if_stmt | while_stmt | assign_stmt | ; private ASTNode parseStmt() { switch (currentToken.getType()) { case IF: return parseIfStmt(); case WHILE: return parseWhileStmt(); case IDENTIFIER: return parseAssignStmt(); case SEMICOLON: eat(TokenType.SEMICOLON); return new EmptyStmtNode(); default: throw new RuntimeException(Unexpected token: currentToken.getType()); } } // if_stmt → if ( expr ) stmt (else stmt)? private ASTNode parseIfStmt() { eat(TokenType.IF); eat(TokenType.LPAREN); ASTNode condition parseExpr(); eat(TokenType.RPAREN); ASTNode thenBranch parseStmt(); ASTNode elseBranch null; if (currentToken.getType() TokenType.ELSE) { eat(TokenType.ELSE); elseBranch parseStmt(); } return new IfNode(condition, thenBranch, elseBranch); } // assign_stmt → IDENTIFIER expr ; private ASTNode parseAssignStmt() { String varName currentToken.getValue(); eat(TokenType.IDENTIFIER); eat(TokenType.ASSIGN); ASTNode expr parseExpr(); eat(TokenType.SEMICOLON); return new AssignNode(varName, expr); } // expr → term { addop term } private ASTNode parseExpr() { ASTNode left parseTerm(); while (currentToken.getType() TokenType.PLUS || currentToken.getType() TokenType.MINUS) { TokenType op currentToken.getType(); eat(op); ASTNode right parseTerm(); left new BinaryOpNode(op, left, right); } return left; } // term → factor { mulop factor } private ASTNode parseTerm() { ASTNode left parseFactor(); while (currentToken.getType() TokenType.MUL || currentToken.getType() TokenType.DIV) { TokenType op currentToken.getType(); eat(op); ASTNode right parseFactor(); left new BinaryOpNode(op, left, right); } return left; } // factor → NUMBER | IDENTIFIER | ( expr ) private ASTNode parseFactor() { switch (currentToken.getType()) { case NUMBER: String numVal currentToken.getValue(); eat(TokenType.NUMBER); return new NumberNode(Integer.parseInt(numVal)); case IDENTIFIER: String idName currentToken.getValue(); eat(TokenType.IDENTIFIER); return new IdentifierNode(idName); case LPAREN: eat(TokenType.LPAREN); ASTNode expr parseExpr(); eat(TokenType.RPAREN); return expr; default: throw new RuntimeException(Unexpected factor: currentToken.getType()); } } }逻辑说明此骨架覆盖南航实验要求的全部语句类型if,while, 赋值且parseExpr()和parseTerm()采用右递归消除左递归保证运算符结合性abc解析为(ab)c。eat()方法抛异常而非静默跳过符合南航“错误提示需明确位置”的评分要求ASTNode及其子类IfNode,AssignNode需继承统一接口为后续语义分析预留accept(Visitor)方法。参数说明ASTNode必须实现深度优先遍历接口因为南航第4阶段“中间代码生成”要求遍历语法树生成三地址码NumberNode存储int值而非字符串避免语义分析时类型转换开销IdentifierNode的name字段必须与词法分析器输出的IDENTIFIER.value完全一致区分大小写否则符号表匹配失败。4. 符号表与类型检查嵌套作用域下变量声明/使用的硬核校验4.1 南航的“作用域陷阱”为什么int a; { int a; }必须报错南航测试用例中必含嵌套块作用域冲突int a; { int a; }应在第二处int a声明时报“重复定义”而int a; { a 5; }则必须允许使用外层变量。这要求符号表必须支持作用域栈Scope Stack而非单层 HashMap。常见翻车点是学生用MapString, Symbol全局存储导致内层a覆盖外层a{ a 5; }反而找不到a。正确做法是维护ListMapString, Symbol scopesenterScope()推入新 MapexitScope()弹出resolve(String name)从栈顶向下查找。南航评分细则明确要求“作用域嵌套层数≥2”意味着你的符号表必须能处理int a; { int b; { int c; } }这类三层嵌套。4.2 基于作用域栈的 Java 符号表实现含类型兼容性检查// SymbolTable.java - 南航硬性要求的嵌套作用域 public class SymbolTable { private ListMapString, Symbol scopes; private int currentLevel; public SymbolTable() { this.scopes new ArrayList(); this.currentLevel 0; enterScope(); // 全局作用域 } public void enterScope() { scopes.add(new HashMap()); currentLevel; } public void exitScope() { if (scopes.size() 1) { // 保留全局作用域 scopes.remove(scopes.size() - 1); currentLevel--; } } public void define(String name, Symbol symbol) { MapString, Symbol currentScope scopes.get(scopes.size() - 1); if (currentScope.containsKey(name)) { throw new RuntimeException(Redeclaration of name at line symbol.getLine()); } currentScope.put(name, symbol); } public Symbol resolve(String name) { // 从内层向外层查找 for (int i scopes.size() - 1; i 0; i--) { Symbol sym scopes.get(i).get(name); if (sym ! null) return sym; } return null; // 未声明 } // 类型检查入口验证赋值语句左右操作数类型兼容 public void checkAssignment(String varName, Type exprType) { Symbol varSym resolve(varName); if (varSym null) { throw new RuntimeException(Undeclared variable varName ); } if (!varSym.getType().equals(exprType)) { throw new RuntimeException(Type mismatch: cannot assign exprType to varSym.getType()); } } } // Symbol.java - 必须包含类型和作用域层级 public class Symbol { private String name; private Type type; private int scopeLevel; // 用于调试输出 private int line; // 行号南航要求错误提示含位置 public Symbol(String name, Type type, int scopeLevel, int line) { this.name name; this.type type; this.scopeLevel scopeLevel; this.line line; } // getter/setter 略 } // Type.java - 极简类型系统南航只要求 int public enum Type { INT, VOID // 用于函数返回类型南航暂不涉及 }逻辑说明enterScope()/exitScope()控制作用域生命周期define()在当前作用域插入符号resolve()逆序查找确保内层遮蔽外层。checkAssignment()是语义分析核心南航测试用例int a; a 3.14;必须在此处报错INTvsFLOAT但int a; a 5;通过。注意Symbol必须记录line行号因为南航要求错误信息格式为Error at line 5: Undeclared variable b。参数说明scopes使用ArrayListMap而非Stack因Stack已被标记为 legacycurrentLevel仅用于调试日志非必需Type枚举只需INT南航不考察浮点/数组/结构体SymbolTable实例必须在Parser初始化时传入并在parseAssignStmt()中调用checkAssignment()。5. 三地址码生成从 AST 到可执行中间表示的精准映射5.1 南航的“三地址码”不是 IR而是带标签的指令序列南航课程设计要求的“中间代码”并非 LLVM IR 或 JVM 字节码而是经典三地址码Three-Address Code, TAC每条指令最多含三个操作数形式为x y op z或goto L1。关键约束是if语句必须生成if x goto L1goto L2L1:三段式while必须有L1: if ... goto L2goto L1L2:循环结构。常见误区是生成if x goto L1 else goto L2这种双跳转南航测试器会判错——因为教材《编译原理》第6章明确要求“条件跳转只允许单目标”。这意味着你的IfNode访问者必须生成 4 条指令条件跳转、无条件跳转、then 标签、else 标签若存在。5.2 基于 Visitor 模式的 TAC 生成器含标签管理// TACGenerator.java - 南航指定的中间代码格式 public class TACGenerator implements ASTVisitor { private ListString tacLines; private int labelCounter; private int tempCounter; public TACGenerator() { this.tacLines new ArrayList(); this.labelCounter 0; this.tempCounter 0; } public ListString generate(ASTNode root) { root.accept(this); return tacLines; } private String newLabel() { return L labelCounter; } private String newTemp() { return t tempCounter; } Override public void visit(ProgramNode node) { for (ASTNode stmt : node.getStmts()) { stmt.accept(this); } } Override public void visit(IfNode node) { String condCode generateExprCode(node.getCondition()); String elseLabel newLabel(); String endLabel newLabel(); // if cond goto elseLabel tacLines.add(if condCode goto elseLabel); // then branch node.getThenBranch().accept(this); // goto endLabel tacLines.add(goto endLabel); // else label tacLines.add(elseLabel :); if (node.getElseBranch() ! null) { node.getElseBranch().accept(this); } // end label tacLines.add(endLabel :); } Override public void visit(AssignNode node) { String rhsCode generateExprCode(node.getExpr()); tacLines.add(node.getVarName() rhsCode); } Override public void visit(BinaryOpNode node) { String leftCode generateExprCode(node.getLeft()); String rightCode generateExprCode(node.getRight()); String temp newTemp(); String op node.getOp() TokenType.PLUS ? : node.getOp() TokenType.MINUS ? - : node.getOp() TokenType.MUL ? * : /; tacLines.add(temp leftCode op rightCode); } Override public void visit(NumberNode node) { // 直接返回数值不生成指令 } Override public void visit(IdentifierNode node) { // 直接返回变量名不生成指令 } // 辅助方法为表达式生成临时变量并返回其名 private String generateExprCode(ASTNode expr) { if (expr instanceof NumberNode) { return String.valueOf(((NumberNode) expr).getValue()); } else if (expr instanceof IdentifierNode) { return ((IdentifierNode) expr).getName(); } else if (expr instanceof BinaryOpNode) { // 递归生成左右操作数返回临时变量名 String leftCode generateExprCode(((BinaryOpNode) expr).getLeft()); String rightCode generateExprCode(((BinaryOpNode) expr).getRight()); String temp newTemp(); String op ((BinaryOpNode) expr).getOp() TokenType.PLUS ? : -; tacLines.add(temp leftCode op rightCode); return temp; } return ; } }逻辑说明visit(IfNode)严格遵循南航要求的四段式结构if-goto/goto/else-label/end-labelelseLabel和endLabel由newLabel()生成确保唯一性generateExprCode()为每个BinaryOpNode生成一条tX a op b指令并返回tX实现表达式求值的临时变量分配。visit(AssignNode)直接输出a t1不额外生成t1的赋值——因为t1已在generateExprCode()中生成。参数说明tacLines必须是ListString南航自动评测脚本按行解析labelCounter从 0 开始生成L0,L1...tempCounter同理生成t0,t1...visit(IfNode)中elseLabel必须在if指令前声明否则评测器无法解析跳转目标BinaryOpNode的op映射必须覆盖,-,*,/四种南航测试用例含a * b / c。6. 避坑指南南航编译原理课程设计的 4 个致命雷区与绕行方案6.1 现象词法分析器识别时被单独切出来当ASSIGN原因getNextToken()中处理时未做 peek 检查直接返回ASSIGN导致被拆成两个ASSIGN。南航测试用例if (a b)必然失败。解决严格按 2.2 节代码在case 分支中先advance()再判断currentChar是否为是则返回EQ否则回退并返回ASSIGN。回退需pos--并重置currentChar或改用peek()方法推荐前者更贴近手写 DFA 思维。6.2 现象语法分析器对int a b c;报“unexpected IDENTIFIER”原因parseAssignStmt()中eat(TokenType.IDENTIFIER)后未检查currentToken是否为ASSIGN而是直接调用parseExpr()导致b被当作expr的起始但parseExpr()期望NUMBER或IDENTIFIER后跟/-遇到时已丢失上下文。解决parseAssignStmt()必须严格按IDENTIFIER → ASSIGN → expr → SEMICOLON顺序eat()任何一步失败立即抛异常。可在eat()方法中加入System.err.println(Expecting type , got currentToken.getType())便于调试。6.3 现象符号表resolve(a)返回 null尽管int a;已声明原因define()方法将符号插入了错误的作用域 Map。常见错误是scopes.get(0)全局作用域被误用或enterScope()后未及时define()。南航测试用例int a; { a 5; }要求内层块能访问外层a。解决define()必须插入scopes.get(scopes.size() - 1)栈顶作用域resolve()必须从scopes.size()-1递减至0查找在parseProgram()开头调用symbolTable.enterScope()在parseStmt()处理{}时显式调用enterScope()/exitScope()。6.4 现象三地址码中goto L1的L1未定义评测器报“undefined label”原因visit(IfNode)生成if x goto L1时L1标签在tacLines中尚未出现标签必须在goto之后、goto目标之前定义。南航要求标签定义必须紧邻其对应代码块。解决visit(IfNode)中elseLabel和endLabel必须在tacLines.add(if ... goto elseLabel)之后、node.getThenBranch().accept(this)之前添加tacLines.add(elseLabel :)同理endLabel必须在node.getElseBranch().accept(this)之后、tacLines.add(endLabel :)之前添加。即标签定义永远在goto指令之后、跳转目标代码之前。7. 终极验证技巧用南航官方测试用例反向调试你的编译器南航课程设计提供 5 个标准测试用例.txt文件内容从简单赋值到嵌套if-while。但直接运行java Compiler test1.txt看输出结果往往只见Exception in thread main无法定位哪一行出错。我的血泪经验是把测试用例切成原子单元逐阶段验证。第一步用Lexer单独跑test1.txt输出所有 token 到文件# 在 main 方法中 Lexer lexer new Lexer(Files.readString(Paths.get(test1.txt))); Token t; while ((t lexer.getNextToken()).getType() ! TokenType.EOF) { System.out.println(t.getType() t.getValue()); }对照南航《词法分析参考答案》检查int是否为INT、是否为EQ、3.14是否被拒绝应报错。若此处失败语法分析器再完美也无意义。第二步用Parser读取 token 流打印 AST 结构// 在 Parser 中添加 public void printAST(ASTNode node, String indent) { System.out.println(indent node.getClass().getSimpleName()); if (node instanceof BinaryOpNode) { printAST(((BinaryOpNode) node).getLeft(), indent ); printAST(((BinaryOpNode) node).getRight(), indent ); } // 其他节点类型同理 }运行后观察缩进结构IfNode下应有ConditionNode、ThenBranch、ElseBranch若ElseBranch为 null 但测试用例含else说明parseIfStmt()的else分支逻辑有缺陷。第三步TAC 生成后人工检查跳转逻辑if语句必须有且仅有 1 个if ... goto LxLx:标签必须出现在goto Lx之后、对应代码块之前while循环必须有L1:循环头、if ... goto L2条件跳转、goto L1循环回跳、L2:循环出口最后南航不提供二进制解释器但你可以用 Python 写个极简 TAC 执行器验证逻辑正确性# tac_interpreter.py vars {} labels {} pc 0 # 先扫描所有标签位置 for i, line in enumerate(tac_lines): if line.endswith(:): labels[line[:-1]] i # 执行 while pc len(tac_lines): line tac_lines[pc].strip() if line.endswith(:): # 标签行跳过 pc 1 continue if line.startswith(if ): # 解析 if x goto L1 parts line.split() cond_var parts[1] target_label parts[3] if vars.get(cond_var, 0) ! 0: pc labels[target_label] continue elif line.startswith(goto ): target_label line.split()[1] pc labels[target_label] continue elif in line and not line.startswith(if ) and not line.startswith(goto ): # 赋值a b left, right line.split(, 1) left left.strip() right right.strip() if right.isdigit(): vars[left] int(right) elif right in vars: vars[left] vars[right] pc 1运行后检查vars中变量值是否与预期一致。这个技巧让我在 2022 年帮 7 个同学定位到parseExpr()中运算符优先级错误——他们的a b * c生成了(a b) * c的 TAC而 Python 解释器立刻暴露了结果偏差。希望帮到你。本文还有配套的精品资源点击获取
返回列表