☰
用Java实现C89编译器前端:词法分析到三地址码生成
2026/10/3 2:51:08 网站建设 项目流程

简介:本资源是一个面向计算机专业本科生的编译原理课程设计实践项目,聚焦用Java实现C语言子集的LL(1)文法编译器,帮助学习者深入理解词法分析、语法解析、语义检查与代码生成四大核心阶段。压缩包共67个文件,含22个Java源码文件(实现Parser、Lexer、AST等关键模块)、33个编译生成的class文件、4个txt文档(含文法定义grammer.txt及README说明)、1个C语言测试样例a.c,以及项目配置文件(.project、.classpath)、许可证与背景图等,整体仅210KB,轻量易部署。已有143人下载学习,适合课堂实践、课程设计参考或编译器原理入门复现。读者可直接运行调试完整编译流程,获取带错误定位的LL(1)解析器实现细节、AST构建逻辑、Java版编译器工程结构组织方式,以及针对C子集(变量声明、if/for/while控制流、函数定义)的语法支持边界说明。

1. 为什么用 Java 写 C 编译器?不是“玩具项目”,而是工程级语言工具链的可控入口

你可能刚看到标题就皱眉:C 编译器用 C 写不是天经地义吗?Java 做编译器,是不是又一个课堂作业级别的 parser+lexer 演示?不。这个【基于 Java 的 C 语言编译器】(编号 100013298)是真实存在于工业级教学与嵌入式工具链演进中的落地项目——它不生成 x86 机器码,也不对标 GCC 或 Clang,而是聚焦于C89/C90 子集的完整前端 + 中间表示(IR)生成 + 可验证目标代码输出(如 JVM 字节码、LLVM IR 或自定义汇编),核心价值在于:用 Java 的强类型、垃圾回收、JVM 跨平台性和成熟调试生态,把编译器开发从“黑匣子系统编程”拉回到可单步调试、可热重载、可单元测试的软件工程轨道上。它适合三类人:高校编译原理课需交付可运行、可评分、可扩展的课程设计(避免学生卡在 lex/yacc 语法冲突或内存泄漏上);嵌入式团队想为私有指令集快速构建轻量 C 前端(比如给 RISC-V 自定义扩展加语法支持);以及 Java 工程师想穿透“写业务逻辑”表层,真正吃透词法/语法/语义/优化四层流水线——这里没有玄学,每行 AST 构建、每个符号表插入、每次类型检查,都能在 IntelliJ 里打断点、看变量、改逻辑、重跑测试。它不是替代 GCC,而是让你第一次看清“int a = b + 1;”背后,到底发生了多少次哈希查找、多少次作用域跳转、多少次隐式类型转换。


2. 从词法分析到抽象语法树:用 Java 实现 C89 前端的最小可行路径

C 语言编译器前端的核心任务,是把字符流变成结构化的程序表示。Java 生态里没有 lex/yacc 那种“写规则自动生成代码”的传统路径,但反而更利于理解每一步——我们不用黑盒生成器,而用手写状态机 + 递归下降解析器,全程可控、可测、可 debug。这不是倒退,而是回归本质:当你亲手写出skipWhitespace()、readIdentifier()、parsePrimaryExpression()时,才真正明白为什么a+++++b是语法错误,而a++ + ++b是合法的。

2.1 手写词法分析器:状态机驱动的 Token 流生成

C89 关键字少(仅 32 个)、运算符固定、注释规则明确(/* */但不支持//),这让我们能用纯 Java 实现一个健壮的Lexer类,无需依赖 ANTLR 或 JavaCC。核心是维护pos指针和currentChar,按字符类型切换状态:

public class Lexer { private final String input; private int pos = 0; private char currentChar; public Lexer(String input) { this.input = input; advance(); // 初始化读取第一个字符 } private void advance() { if (pos < input.length()) { currentChar = input.charAt(pos++); } else { currentChar = '\0'; } } public Token getNextToken() { while (Character.isWhitespace(currentChar)) advance(); if (currentChar == '\0') return new Token(TokenType.EOF, ""); if (Character.isLetter(currentChar) || currentChar == '_') { return readIdentifier(); } if (Character.isDigit(currentChar)) { return readNumber(); } if (currentChar == '/' && peek() == '*') { advance(); advance(); // 跳过 /* return readBlockComment(); } // 其他单字符 token:+ - * / % = ! < > & | ^ ~ [ ] { } ( ) ; , . switch (currentChar) { case '+': advance(); return new Token(TokenType.PLUS, "+"); case '-': advance(); return new Token(TokenType.MINUS, "-"); case '*': advance(); return new Token(TokenType.STAR, "*"); case '/': advance(); return new Token(TokenType.SLASH, "/"); case '%': advance(); return new Token(TokenType.PERCENT, "%"); case '=': advance(); return (currentChar == '=') ? (advance(), new Token(TokenType.EQ, "==")) : new Token(TokenType.ASSIGN, "="); // ... 其他 case 省略,实际需覆盖全部 C89 运算符 } throw new RuntimeException("Unexpected character: " + currentChar); } private char peek() { return (pos < input.length()) ? input.charAt(pos) : '\0'; } private Token readIdentifier() { StringBuilder sb = new StringBuilder(); while (Character.isLetterOrDigit(currentChar) || currentChar == '_') { sb.append(currentChar); advance(); } String value = sb.toString(); if (KEYWORDS.contains(value)) { return new Token(TokenType.KEYWORD, value); } return new Token(TokenType.IDENTIFIER, value); } }

关键参数说明:KEYWORDS是Set.of("auto", "break", "case", ..., "while")静态常量;Token类必须包含type(枚举)、value(原始字符串)、line/column(用于报错定位);peek()方法是处理==、!=、<=等双字符运算符的基础设施——没有它,你就得在getNextToken()里反复advance()回退,极易出错。

2.2 递归下降解析器:按 C89 语法规则逐层构建 AST

C89 语法相对简单(无函数重载、无模板、无复杂声明语法),适合手写递归下降。我们不追求 100% 覆盖(比如忽略_Pragma和__attribute__),但必须保证int main() { return 0; }和struct { int x; } s;这类典型代码能正确解析。核心是Parser类,每个parseXXX()方法对应一条语法规则:

public class Parser { private final Lexer lexer; private Token currentToken; public Parser(Lexer lexer) { this.lexer = lexer; this.currentToken = lexer.getNextToken(); } public Program parseProgram() { List<Declaration> declarations = new ArrayList<>(); while (currentToken.getType() != TokenType.EOF) { declarations.add(parseDeclaration()); } return new Program(declarations); } private Declaration parseDeclaration() { // C89 声明:int a; 或 int f() { ... } 或 struct S { ... }; TokenType first = currentToken.getType(); if (first == TokenType.KEYWORD && isTypeKeyword(currentToken.getValue())) { return parseTypeDeclaration(); } else if (first == TokenType.KEYWORD && "struct".equals(currentToken.getValue())) { return parseStructDeclaration(); } else { throw new RuntimeException("Expected declaration at " + currentToken); } } private Declaration parseTypeDeclaration() { String type = currentToken.getValue(); advance(); // consume type keyword String name = expect(TokenType.IDENTIFIER).getValue(); advance(); if (accept(TokenType.SEMICOLON)) { return new VariableDeclaration(type, name); // int a; } else if (accept(TokenType.LPAREN)) { return parseFunctionDefinition(type, name); // int f() { ... } } else { throw new RuntimeException("Expected ; or ( after identifier"); } } private Token expect(TokenType expected) { if (currentToken.getType() == expected) { Token t = currentToken; advance(); return t; } throw new RuntimeException("Expected " + expected + ", got " + currentToken.getType()); } private boolean accept(TokenType type) { if (currentToken.getType() == type) { advance(); return true; } return false; } private void advance() { currentToken = lexer.getNextToken(); } }

为什么选递归下降而非 Pratt 解析?C89 运算符优先级明确(15 级),且无左递归(expr: expr '+' term在 C89 中不存在),递归下降逻辑清晰、调试友好。expect()和accept()是两个关键辅助方法:前者强制匹配并报错,后者尝试匹配返回布尔值——这是处理if (cond) stmt else stmt中else可选性的基础。isTypeKeyword()必须包含int,char,void,struct等,注意signed/unsigned是修饰符,需在parseTypeSpecifiers()中组合处理。

2.3 AST 设计:让语义分析有据可依,而非字符串拼接

很多初学者的“编译器”止步于打印 AST 结构,但真正的工程价值在于:AST 节点必须携带类型信息、作用域引用、源码位置,才能支撑后续的语义检查和 IR 生成。我们定义Node抽象基类,所有具体节点(BinaryOp,FunctionCall,VariableRef)继承它,并添加type字段(Type.INT,Type.POINTER_TO(Type.CHAR))和scope字段(指向SymbolTable实例):

abstract class Node { protected final int line, column; protected Type type; // 语义分析后填充 protected SymbolTable scope; // 该节点所在作用域 Node(int line, int column) { this.line = line; this.column = column; } } class BinaryOp extends Node { final Node left, right; final String op; // "+", "-", etc. BinaryOp(int line, int column, Node left, String op, Node right) { super(line, column); this.left = left; this.right = right; this.op = op; } } class FunctionCall extends Node { final String functionName; final List<Node> arguments; FunctionCall(int line, int column, String functionName, List<Node> arguments) { super(line, column); this.functionName = functionName; this.arguments = arguments; } }

关键设计原则:line/column必须在Lexer生成Token时就记录(new Token(type, value, line, col)),并在Parser构造Node时传入——这是实现精准报错(如error: 'x' undeclared (first use in this function))的唯一途径。type字段初始为null,由SemanticAnalyzer填充;scope字段在进入{}块时由Parser创建新SymbolTable并传递给子节点。这种设计让 AST 不再是语法树,而是带上下文的语义载体。


3. 符号表与类型系统:C89 作用域规则的 Java 实现

C89 的作用域规则看似简单(文件作用域、函数作用域、块作用域),但细节致命:int a; { int a = 1; printf("%d", a); }中内层a隐藏外层a,但函数参数int f(int a)的a属于函数作用域,与外层同名变量不冲突。用 Java 实现这套规则,核心是SymbolTable的嵌套与查找链,以及Type类的不可变性与等价判断。

3.1 嵌套符号表:用 Stack 模拟作用域栈

我们不使用 HashMap 做全局表,而是为每个作用域创建独立SymbolTable实例,通过parent引用形成链表。Parser在遇到{时pushScope(),遇到}时popScope():

public class SymbolTable { private final Map<String, Symbol> symbols = new HashMap<>(); private final SymbolTable parent; private final int depth; public SymbolTable(SymbolTable parent) { this.parent = parent; this.depth = (parent == null) ? 0 : parent.depth + 1; } public void define(String name, Symbol symbol) { symbols.put(name, symbol); } public Symbol resolve(String name) { Symbol sym = symbols.get(name); if (sym != null) return sym; if (parent != null) return parent.resolve(name); return null; // not found } public boolean hasLocal(String name) { return symbols.containsKey(name); } }

为什么不用 ThreadLocal 或静态单例?因为编译器必须支持多文件、多函数并发解析(比如 IDE 实时检查),每个Parser实例需独立作用域栈。depth字段用于调试时打印作用域层级(depth=0: global, depth=1: function, depth=2: block)。hasLocal()是关键——语义分析时,若resolve()找到符号但hasLocal()为 false,说明是外层变量被隐藏,此时应警告“variable 'x' shadows a previous declaration”。

3.2 C89 类型系统:用枚举+组合实现指针/数组/结构体

C89 类型只有基本类型(int,char,void)、指针、数组、结构体、函数类型。Java 中用Type枚举 + 组合类实现:

abstract class Type { public static final Type INT = new PrimitiveType("int"); public static final Type CHAR = new PrimitiveType("char"); public static final Type VOID = new PrimitiveType("void"); public abstract boolean isCompatibleWith(Type other); // 类型兼容性判断 public abstract boolean isArithmetic(); // 是否可参与 + - * / public abstract boolean isPointer(); // 是否为指针 } final class PrimitiveType extends Type { private final String name; PrimitiveType(String name) { this.name = name; } @Override public boolean isCompatibleWith(Type other) { return this == other || (other instanceof PrimitiveType && ("int".equals(name) && "char".equals(((PrimitiveType) other).name))); } @Override public boolean isArithmetic() { return true; } @Override public boolean isPointer() { return false; } } final class PointerType extends Type { private final Type baseType; PointerType(Type baseType) { this.baseType = baseType; } @Override public boolean isCompatibleWith(Type other) { if (!(other instanceof PointerType)) return false; return this.baseType.isCompatibleWith(((PointerType) other).baseType); } @Override public boolean isArithmetic() { return false; } @Override public boolean isPointer() { return true; } }

关键细节:isCompatibleWith()必须支持int*与char*的赋值(C89 允许,尽管不安全),但禁止int*与int直接比较。PointerType的baseType可以是PrimitiveType、另一个PointerType(int**),甚至StructType(struct S*)。StructType需要Map<String, Field>存储成员,Field包含name和type,并实现equals()以支持struct A { int x; }与struct B { int x; }的类型等价判断(C89 中结构体标签不同即为不同类型,但字段相同可赋值)。

3.3 语义分析器:遍历 AST,填充类型,报告错误

SemanticAnalyzer是连接语法与语义的桥梁。它接收Program根节点和全局SymbolTable,深度优先遍历,对每个节点执行:

  • 变量声明:define()到当前作用域,检查重复定义;
  • 变量引用:resolve()查找,若为null报 “undeclared identifier”;
  • 表达式:递归分析子节点,调用inferType()计算结果类型,检查操作数类型兼容性;
  • 函数调用:resolve()函数符号,比对参数数量与类型。
public class SemanticAnalyzer { private SymbolTable currentScope; public void analyze(Program program) { currentScope = new SymbolTable(null); // global scope for (Declaration decl : program.getDeclarations()) { analyzeDeclaration(decl); } } private void analyzeDeclaration(Declaration decl) { if (decl instanceof VariableDeclaration v) { if (currentScope.hasLocal(v.getName())) { throw new SemanticError("Redeclaration of '" + v.getName() + "'", v.getLine(), v.getColumn()); } currentScope.define(v.getName(), new Symbol(v.getType(), v.getName())); } else if (decl instanceof FunctionDefinition f) { // 函数声明进入 global scope currentScope.define(f.getName(), new Symbol(f.getReturnType(), f.getName())); // 进入函数作用域分析函数体 SymbolTable funcScope = new SymbolTable(currentScope); currentScope = funcScope; analyzeStatement(f.getBody()); currentScope = currentScope.getParent(); // 回退到 global } } private void analyzeStatement(Statement stmt) { if (stmt instanceof BlockStatement b) { SymbolTable blockScope = new SymbolTable(currentScope); currentScope = blockScope; for (Statement s : b.getStatements()) { analyzeStatement(s); } currentScope = currentScope.getParent(); } else if (stmt instanceof ExpressionStatement e) { analyzeExpression(e.getExpression()); } } private Type analyzeExpression(Expression expr) { if (expr instanceof BinaryOp b) { Type leftType = analyzeExpression(b.getLeft()); Type rightType = analyzeExpression(b.getRight()); if (!leftType.isCompatibleWith(rightType)) { throw new SemanticError("Incompatible types in binary operation: " + leftType + " and " + rightType, b.getLine(), b.getColumn()); } b.setType(leftType); // 简化:假设 + - * / 结果类型同操作数 return leftType; } else if (expr instanceof VariableRef v) { Symbol sym = currentScope.resolve(v.getName()); if (sym == null) { throw new SemanticError("Undeclared identifier '" + v.getName() + "'", v.getLine(), v.getColumn()); } v.setType(sym.getType()); return sym.getType(); } return null; } }

血泪经验:analyzeExpression()必须返回Type,因为父节点(如BinaryOp)需要它来检查兼容性。VariableRef的setType()是填充 AST 的关键一步——后续 IR 生成器直接读取v.getType(),无需再次查表。错误信息必须包含line/column,这是用户能快速定位问题的唯一依据。


4. 生成中间表示(IR):从 AST 到三地址码的确定性翻译

有了带类型和作用域的 AST,下一步是生成平台无关的中间表示。C89 的控制流简单(if,while,for,return),数据流清晰(无闭包、无异常),最适合生成三地址码(Three-Address Code, TAC)——每条指令最多三个操作数(x = y op z或goto L1),便于后续优化和目标代码生成。Java 实现的关键是:用StringBuilder累积指令,用Stack<String>管理标签,用Map<Node, String>缓存表达式计算结果。

4.1 TAC 指令集设计:覆盖 C89 所有操作

我们定义最小完备指令集(共 12 条),足够表达 C89:

  • assign x = y(变量赋值)
  • binop x = y op z(二元运算:+,-,*,/,==,!=,<,<=,>,>=)
  • unary x = op y(一元运算:-,!,*(解引用),&(取地址))
  • call x = f(a, b, c)(函数调用)
  • return x(返回值)
  • goto L1(无条件跳转)
  • if x goto L1(条件跳转)
  • label L1:(标签定义)
  • param x(函数参数传递)
  • alloc x size(栈分配,用于局部变量)
  • load x = y[i](数组加载)
  • store y[i] = x(数组存储)

为什么不用 LLVM IR 或 JVM 字节码?LLVM 太重(需 JNI 或绑定),JVM 字节码对 C 的指针模型支持差。TAC 是编译器教材标准,且完全可控:每条指令对应一行文本,可直接System.out.println()输出,也可序列化为 JSON 供后续模块消费。

4.2 表达式翻译:递归生成 TAC,管理临时变量

TACGenerator遍历 AST,为每个表达式生成 TAC 序列,并返回其计算结果所在的临时变量名(如t1,t2):

public class TACGenerator { private final StringBuilder code = new StringBuilder(); private int tempCounter = 0; private final Stack<String> breakStack = new Stack<>(); // for break private final Stack<String> continueStack = new Stack<>(); // for continue public String generate(Expression expr) { if (expr instanceof BinaryOp b) { String left = generate(b.getLeft()); String right = generate(b.getRight()); String result = freshTemp(); code.append("binop ").append(result).append(" = ").append(left) .append(" ").append(b.getOp()).append(" ").append(right).append("\n"); return result; } else if (expr instanceof VariableRef v) { return v.getName(); // 直接用变量名,无需临时变量 } else if (expr instanceof NumberLiteral n) { return String.valueOf(n.getValue()); } else if (expr instanceof UnaryOp u) { String operand = generate(u.getOperand()); String result = freshTemp(); code.append("unary ").append(result).append(" = ") .append(u.getOp()).append(" ").append(operand).append("\n"); return result; } return null; } private String freshTemp() { return "t" + tempCounter++; } public void generate(Statement stmt) { if (stmt instanceof AssignmentStatement a) { String rhs = generate(a.getExpression()); code.append("assign ").append(a.getVariable()).append(" = ").append(rhs).append("\n"); } else if (stmt instanceof IfStatement i) { String cond = generate(i.getCondition()); String thenLabel = freshLabel(); String elseLabel = freshLabel(); String endLabel = freshLabel(); code.append("if ").append(cond).append(" goto ").append(thenLabel).append("\n"); code.append("goto ").append(elseLabel).append("\n"); code.append("label ").append(thenLabel).append(":\n"); for (Statement s : i.getThenBranch()) { generate(s); } code.append("goto ").append(endLabel).append("\n"); code.append("label ").append(elseLabel).append(":\n"); for (Statement s : i.getElseBranch()) { generate(s); } code.append("label ").append(endLabel).append(":\n"); } else if (stmt instanceof WhileStatement w) { String startLabel = freshLabel(); String condLabel = freshLabel(); String bodyLabel = freshLabel(); String endLabel = freshLabel(); breakStack.push(endLabel); continueStack.push(condLabel); code.append("goto ").append(condLabel).append("\n"); code.append("label ").append(startLabel).append(":\n"); code.append("label ").append(bodyLabel).append(":\n"); for (Statement s : w.getBody()) { generate(s); } code.append("label ").append(condLabel).append(":\n"); String cond = generate(w.getCondition()); code.append("if ").append(cond).append(" goto ").append(bodyLabel).append("\n"); code.append("label ").append(endLabel).append(":\n"); breakStack.pop(); continueStack.pop(); } } private String freshLabel() { return "L" + (tempCounter++); } }

关键技巧:freshTemp()和freshLabel()共享tempCounter,确保名字不冲突;breakStack/continueStack是处理嵌套循环的关键——break生成goto breakStack.peek(),continue生成goto continueStack.peek()。generate(Expression)返回String(临时变量名),让父节点(如AssignmentStatement)能直接使用,避免冗余计算。

4.3 函数与全局变量:TAC 中的内存布局约定

C89 全局变量和函数需在 TAC 中显式声明。我们约定:

  • 全局变量:global int x、global char buf[100]
  • 函数入口:function main:
  • 参数:param int argc,param char** argv
  • 局部变量:alloc int a,alloc char* p
public void generate(Program program) { // 全局变量声明 for (Declaration decl : program.getDeclarations()) { if (decl instanceof VariableDeclaration v) { code.append("global ").append(v.getType()).append(" ").append(v.getName()).append("\n"); } } // 函数定义 for (Declaration decl : program.getDeclarations()) { if (decl instanceof FunctionDefinition f) { code.append("function ").append(f.getName()).append(":\n"); // 参数声明 for (Parameter p : f.getParameters()) { code.append("param ").append(p.getType()).append(" ").append(p.getName()).append("\n"); } // 局部变量分配 for (Declaration local : f.getLocalDeclarations()) { if (local instanceof VariableDeclaration v) { code.append("alloc ").append(v.getType()).append(" ").append(v.getName()).append("\n"); } } // 函数体 for (Statement s : f.getBody().getStatements()) { generate(s); } if (f.getReturnType() != Type.VOID) { code.append("return ").append("0").append("\n"); // 默认返回 0 } } } }

注意:alloc指令不指定内存地址,只告诉后端“为该变量预留空间”,实际栈帧布局由目标代码生成器决定。global变量在.data段,alloc变量在栈上——这是 C89 内存模型的忠实映射。return指令必须存在,即使void函数也生成return(空操作),保证控制流完整性。


5. 常见问题排查:那些让编译器开发者彻夜难眠的 5 个坑

写编译器最痛苦的不是写不出功能,而是错误信息模糊、行为不可复现、调试无从下手。以下是本项目(编号 100013298)在真实教学和工业验证中踩过的 5 个高频坑,按“现象 → 原因 → 解决”给出可立即操作的方案。

5.1 现象:int a = b + 1;报错 “undeclared identifier 'b'”,但b明明在上一行声明了

原因:Parser在解析VariableDeclaration时,只将变量名加入SymbolTable,但未将b的Type信息同步到Symbol对象中;后续analyzeExpression()调用resolve()得到Symbol,但sym.getType()为null,导致类型推导失败,误判为未声明。
解决:在SemanticAnalyzer.analyzeDeclaration()中,VariableDeclaration的define()必须传入已解析的Type对象,而非字符串。修改SymbolTable.define()接口,强制Symbol构造时绑定Type:

// 错误:currentScope.define(v.getName(), new Symbol(v.getName())); // 正确:currentScope.define(v.getName(), new Symbol(v.getType(), v.getName()));

提示:所有Symbol创建点(变量、函数、结构体)都必须传入Type,否则语义分析必然断裂。

5.2 现象:if (x) { int y = 1; } y = 2;不报错,但y在块外不可见

原因:Parser遇到{时创建了新SymbolTable,但analyzeStatement(BlockStatement)中未将currentScope切换为该新表,导致int y = 1;的声明进入了全局表。
解决:SemanticAnalyzer.analyzeStatement()对BlockStatement的处理必须显式切换作用域:

} else if (stmt instanceof BlockStatement b) { SymbolTable blockScope = new SymbolTable(currentScope); SymbolTable oldScope = currentScope; // 保存旧作用域 currentScope = blockScope; // 切换 for (Statement s : b.getStatements()) { analyzeStatement(s); } currentScope = oldScope; // 恢复 }

注意:currentScope是实例变量,必须在进入块前保存旧值,退出块后恢复,否则嵌套块会污染外层作用域。

5.3 现象:int* p; p = &x;生成 TAC 时崩溃,NullPointerException

原因:UnaryOp节点(&x)的getOperand()返回null,因为Parser在构建UnaryOp时未正确设置operand字段,或Lexer将&误识别为TokenType.AMPERSAND但未在parseUnaryExpression()中处理。
解决:检查Parser.parseUnaryExpression()是否覆盖&和*:

private Expression parseUnaryExpression() { if (accept(TokenType.AMPERSAND)) { // & Expression operand = parsePrimaryExpression(); return new UnaryOp(currentToken.getLine(), currentToken.getColumn(), "&", operand); } else if (accept(TokenType.STAR)) { // * Expression operand = parsePrimaryExpression(); return new UnaryOp(currentToken.getLine(), currentToken.getColumn(), "*", operand); } return parsePrimaryExpression(); }

血泪经验:UnaryOp构造时必须传入operand,且operand不能为空——在parseUnaryExpression()开头加if (operand == null) throw new RuntimeException(...)提前暴露问题。

5.4 现象:struct S { int x; }; struct S s;中s的类型解析为null

原因:StructType的equals()方法未重写,导致SymbolTable.resolve("S")返回的Symbol与VariableDeclaration中的Type比较时,==返回false,类型检查失败。
解决:为StructType实现equals()和hashCode(),比较字段名和类型:

@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; StructType that = (StructType) o; return fields.equals(that.fields); // fields 是 Map<String, Type> }

提示:所有Type子类都必须实现equals(),否则isCompatibleWith()和作用域查找都会失效。

5.5 现象:生成的 TAC 中goto L1后无label L1:,导致后端无法链接

原因:IfStatement的elseBranch为空时(if (x) stmt;),generate()未生成elseLabel对应的label指令,但goto elseLabel已发出。
解决:在generate(IfStatement)中,elseBranch为空时仍需生成label:

code.append("label ").append(elseLabel).append(":\n"); if (!i.getElseBranch().isEmpty()) { for (Statement s : i.getElseBranch()) { generate(s); } } // else branch empty: just the label

避坑口诀:“有 goto,必有 label;有 label,必有 goto 或代码”——TAC 的控制流图必须连通。


6. 进阶技巧:用 JUnit 5 驱动编译器测试,构建可信赖的 CI 流水线

编译器不是写完就能用的黑盒,它必须像业务系统一样接受自动化测试。Java 的 JUnit 5 是最佳选择——它支持参数化测试、生命周期钩子、断言丰富,且与 Maven/Gradle 深度集成。我们不测试“是否能编译”,而是测试“输入 C 代码,输出预期 TAC,且无错误”,这才是工程级可信度的基石。

6.1 测试架构:三层断言覆盖语法、语义、IR

每个测试用例应覆盖三个层面:

  • 词法/语法层:输入字符串,验证Lexer输出的Token序列是否匹配预期(用assertArrayEquals());
  • 语义层:输入字符串,验证SemanticAnalyzer是否抛出预期错误(assertThrows<SemanticError>())或成功完成;
  • IR 层:输入字符串,验证TACGenerator输出的 TAC 字符串是否与黄金样本(golden file)完全一致(assertEquals())。
@Nested class ArithmeticExpressionTest { @Test void shouldGenerateTACForAddition() { String input = "int main() { int a = 1; int b = 2; int c = a + b; return c; }"; Program program = new Parser(new Lexer(input)).parseProgram(); new SemanticAnalyzer().analyze(program); // 必须先语义分析,否则 AST 无类型 String tac = new TACGenerator().generateToString(program); String expected = """ function main: alloc int a alloc int b alloc int c assign a = 1 assign <p> <a href="https://download.csdn.net/download/s1t16/88351487" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

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

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

立即咨询