☰
编译原理实验全流程:从词法分析到目标代码生成的Java实现
2026/10/1 17:27:52 网站建设 项目流程

简介:这份资源是东南大学软件学院编译原理课程实验项目的完整实践平台,面向正在学习编译原理的高校学生及需要动手实现编译器的开发者。它围绕词法分析、语法分析、语义分析、中间代码生成、目标代码生成与代码优化等核心环节,构建了一个从源代码到可执行代码的编译器模拟系统,帮助读者在实验中理解编译全流程并掌握工程实现方法。压缩包共28个文件,以12个java源文件和12个class编译文件为主体,另含2个txt说明、1个iml工程配置与1个md文档,整体约20KB,结构紧凑,便于直接导入IDE运行与调试。目前已有63人学习下载。通过该平台,读者可获得一套可运行的编译器实验框架,对照各阶段模块理解抽象语法树构建、语义检查、中间表示转换与目标代码优化等关键知识点,并在此基础上修改与扩展,提升解决实际编译问题的能力。

1. 从一份课程大作业说起:编译原理实验到底在练什么

很多人对编译原理课的记忆停留在推导 First 集、算 Follow 集、画 DFA 图,考试一过就还给老师。但真正把「词法分析、语法分析、语义分析、中间代码生成、目标代码优化」串成一个能跑通的编译器模拟系统,是另一回事。东南大学软件学院这个课程实验项目的定位,就是让你从零搭一条完整的编译流水线:输入一段类 C 或类 Pascal 的源代码,输出四元式中间代码,再翻译成可执行的目标指令序列。它解决的不是「考试怎么算」,而是「一个源文件从字符流到机器能跑的代码,中间到底发生了什么」。适合正在做编译原理实验的本科生、想补编译基础的 Java 或 C++ 后端开发者,以及需要给课程设计找一条可复现路径的人。热搜里「编译原理实验」「java+编译原理」反复出现,说明大量人卡在「知道理论但不知道怎么落地」这一步,这篇就按可复现的顺序把每一步拆开。

2. 词法分析与语法分析:先把字符流变成能检查的语法树

2.1 词法分析器的最小实现与正则到 DFA 的落地

词法分析的任务是把源代码字符流切成 Token 序列,每个 Token 带类型和值。常见做法是先用正则表达式描述各类词素,再手工构造或工具生成 DFA。课程实验里我一般直接写一个状态机扫描器,不依赖 Lex 或 JFlex,因为手写一遍才能真正理解「最长匹配」和「回溯」的边界。

下面是一个 Java 版词法分析核心片段,支持标识符、整数、关键字和运算符:

// Lexer.java —— 逐字符扫描,返回 Token 列表 public class Lexer { private String src; // 源代码字符串 private int pos = 0; // 当前扫描位置 private int line = 1; // 行号,报错用 // 关键字表,命中后 Token 类型从 ID 改为对应关键字 private static final Set<String> KEYWORDS = Set.of( "int", "float", "if", "else", "while", "return" ); public List<Token> tokenize() { List<Token> tokens = new ArrayList<>(); while (pos < src.length()) { char c = src.charAt(pos); if (Character.isWhitespace(c)) { // 跳过空白,换行时行号加一 if (c == '\n') line++; pos++; } else if (Character.isLetter(c)) { tokens.add(readIdentifierOrKeyword()); } else if (Character.isDigit(c)) { tokens.add(readNumber()); } else { tokens.add(readOperatorOrDelimiter()); } } tokens.add(new Token(TokenType.EOF, "EOF", line)); return tokens; } private Token readIdentifierOrKeyword() { int start = pos; while (pos < src.length() && (Character.isLetterOrDigit(src.charAt(pos)) || src.charAt(pos) == '_')) { pos++; } String word = src.substring(start, pos); // 关键字优先于普通标识符,这是最容易漏的一步 TokenType type = KEYWORDS.contains(word) ? TokenType.KEYWORD : TokenType.ID; return new Token(type, word, line); } private Token readNumber() { int start = pos; while (pos < src.length() && Character.isDigit(src.charAt(pos))) pos++; return new Token(TokenType.NUMBER, src.substring(start, pos), line); } private Token readOperatorOrDelimiter() { char c = src.charAt(pos++); // 双字符运算符需要前瞻一位,比如 ==、<=、!= if (pos < src.length()) { char next = src.charAt(pos); if ((c == '=' && next == '=') || (c == '<' && next == '=') || (c == '>' && next == '=') || (c == '!' && next == '=')) { pos++; return new Token(TokenType.OPERATOR, "" + c + next, line); } } return new Token(TokenType.OPERATOR, String.valueOf(c), line); } }

逻辑说明:tokenize是主循环,按字符类别分派到三个读取方法。readIdentifierOrKeyword里关键字判断必须在标识符识别之后做,否则int会被当成普通 ID。readOperatorOrDelimiter的前瞻处理是双字符运算符的关键,漏掉它a==b会被切成两个=。参数方面,line用于后续语法分析报错定位,KEYWORDS集合按实验文法增删,比如加上for、do、break。

2.2 递归下降语法分析:把 Token 流构造成语法树

拿到 Token 序列后,语法分析要判断它是否符合文法,并构造抽象语法树(AST)。课程实验常用递归下降法,因为每个非终结符对应一个函数,结构清晰、便于调试。以表达式文法E -> T E'、E' -> + T E' | ε为例:

// Parser.java —— 递归下降,构造 AST public class Parser { private List<Token> tokens; private int idx = 0; // 当前 Token 下标 private Token peek() { return tokens.get(idx); } private Token consume() { return tokens.get(idx++); } // 解析表达式,返回 AST 节点 public ASTNode parseExpression() { ASTNode left = parseTerm(); while (peek().type == TokenType.OPERATOR && (peek().value.equals("+") || peek().value.equals("-"))) { String op = consume().value; ASTNode right = parseTerm(); left = new BinaryNode(op, left, right); // 构造二元运算节点 } return left; } private ASTNode parseTerm() { ASTNode left = parseFactor(); while (peek().type == TokenType.OPERATOR && (peek().value.equals("*") || peek().value.equals("/"))) { String op = consume().value; ASTNode right = parseFactor(); left = new BinaryNode(op, left, right); } return left; } private ASTNode parseFactor() { Token t = peek(); if (t.type == TokenType.NUMBER) { consume(); return new NumberNode(Integer.parseInt(t.value)); } else if (t.type == TokenType.ID) { consume(); return new VarNode(t.value); } else if (t.value.equals("(")) { consume(); ASTNode node = parseExpression(); expect(")"); // 括号必须配对,否则报语法错误 return node; } throw new SyntaxException("Unexpected token: " + t.value + " at line " + t.line); } private void expect(String value) { if (!peek().value.equals(value)) { throw new SyntaxException("Expected " + value + " but got " + peek().value); } consume(); } }

逻辑说明:parseExpression处理加减,parseTerm处理乘除,parseFactor处理数字、变量和括号,三层递归自然实现了运算符优先级。expect用于强制匹配右括号等终结符,不匹配就抛异常。参数上,idx是全局 Token 游标,BinaryNode和NumberNode是 AST 节点类,按实验要求可扩展赋值语句、if 语句、while 语句的解析函数。递归下降的局限是左递归文法需要改写,比如E -> E + T必须改成E -> T E',否则会无限递归。

3. 语义分析与中间代码生成:让语法树带上类型和四元式

3.1 符号表设计与类型检查

语法树只保证结构正确,语义分析要检查变量是否声明、类型是否匹配、函数调用参数是否对。核心数据结构是符号表,常见做法是用栈式作用域链,进入块时压栈,离开时弹栈。

// SymbolTable.java —— 支持嵌套作用域的符号表 public class SymbolTable { // 栈中每个元素是一个作用域,Map 存变量名到类型 private Deque<Map<String, String>> scopes = new ArrayDeque<>(); public SymbolTable() { scopes.push(new HashMap<>()); // 全局作用域 } public void enterScope() { scopes.push(new HashMap<>()); } public void exitScope() { scopes.pop(); } public void declare(String name, String type) { Map<String, String> current = scopes.peek(); if (current.containsKey(name)) { throw new SemanticException("Duplicate declaration: " + name); } current.put(name, type); } public String lookup(String name) { for (Map<String, String> scope : scopes) { if (scope.containsKey(name)) return scope.get(name); } throw new SemanticException("Undeclared variable: " + name); } }

逻辑说明:declare只在当前作用域查重,允许内层遮蔽外层同名变量。lookup从内到外逐层查找,找不到就报未声明。参数上,类型用字符串表示("int"、"float"),实验要求严格的话可以换成枚举。类型检查在遍历 AST 时做,比如加法节点要求左右子树类型一致,不一致就报错或插入隐式转换。

3.2 四元式生成:从 AST 到中间代码

中间代码生成的目标是把 AST 翻译成四元式(op, arg1, arg2, result)。常见做法是后序遍历 AST,为每个子表达式分配临时变量。

// QuadGenerator.java —— 后序遍历 AST 生成四元式 public class QuadGenerator { private List<Quad> quads = new ArrayList<>(); private int tempCount = 0; // 临时变量编号 public List<Quad> generate(ASTNode node) { visit(node); return quads; } private String visit(ASTNode node) { if (node instanceof NumberNode n) { return String.valueOf(n.value); // 常量直接返回字面量 } else if (node instanceof VarNode v) { return v.name; // 变量返回名字 } else if (node instanceof BinaryNode b) { String left = visit(b.left); String right = visit(b.right); String temp = "t" + (tempCount++); // 分配新临时变量 quads.add(new Quad(b.op, left, right, temp)); return temp; } throw new CodeGenException("Unknown node type"); } }

逻辑说明:visit返回该子树的计算结果存放位置,可能是常量、变量名或临时变量。BinaryNode先递归处理左右子树,再生成一条四元式,结果存入新临时变量。参数上,tempCount保证临时变量不重名,Quad是四元式记录类。生成的四元式序列可以直接解释执行,也可以作为目标代码生成的输入。常见坑是赋值语句和条件跳转的四元式设计,比如if需要生成条件跳转和标签,建议单独设计label和goto四元式。

4. 目标代码生成与优化:从四元式到可执行指令

4.1 寄存器分配与简单代码生成

目标代码生成把四元式翻译成汇编或虚拟机指令。课程实验通常不要求真实硬件,而是生成一种简化指令集,比如三地址码或栈式虚拟机指令。寄存器分配是难点,简单做法是「每次用到就加载,算完就存回」,虽然低效但正确。

// CodeGen.java —— 四元式到栈式虚拟机指令 public class CodeGen { private List<String> instructions = new ArrayList<>(); public List<String> generate(List<Quad> quads) { for (Quad q : quads) { switch (q.op) { case "+" -> { instructions.add("LOAD " + q.arg1); // 加载左操作数 instructions.add("LOAD " + q.arg2); // 加载右操作数 instructions.add("ADD"); // 栈顶两数相加 instructions.add("STORE " + q.result); // 结果存回 } case "-" -> { instructions.add("LOAD " + q.arg1); instructions.add("LOAD " + q.arg2); instructions.add("SUB"); instructions.add("STORE " + q.result); } case "*" -> { instructions.add("LOAD " + q.arg1); instructions.add("LOAD " + q.arg2); instructions.add("MUL"); instructions.add("STORE " + q.result); } case "/" -> { instructions.add("LOAD " + q.arg1); instructions.add("LOAD " + q.arg2); instructions.add("DIV"); instructions.add("STORE " + q.result); } default -> throw new CodeGenException("Unsupported op: " + q.op); } } return instructions; } }

逻辑说明:栈式虚拟机指令简单直观,LOAD把操作数压栈,ADD等弹出两个操作数计算后压回,STORE弹出结果存入变量。参数上,q.arg1、q.arg2可能是常量、变量或临时变量,STORE的目标可以是临时变量或用户变量。这种生成方式没有寄存器分配,所有中间结果都在内存,适合实验验证正确性,不适合性能对比。

4.2 局部优化:常量折叠与公共子表达式消除

目标代码优化是实验的加分项,常见局部优化包括常量折叠、公共子表达式消除、死代码删除。常量折叠在四元式层面做:如果两个操作数都是常量,直接计算结果并替换。

// Optimizer.java —— 常量折叠与公共子表达式消除 public class Optimizer { public List<Quad> optimize(List<Quad> quads) { List<Quad> result = new ArrayList<>(); Map<String, String> constMap = new HashMap<>(); // 记录已知常量 Map<String, String> exprMap = new HashMap<>(); // 记录已计算表达式 for (Quad q : quads) { String a1 = constMap.getOrDefault(q.arg1, q.arg1); String a2 = constMap.getOrDefault(q.arg2, q.arg2); // 常量折叠:两个操作数都是数字 if (isNumber(a1) && isNumber(a2)) { int val = calc(q.op, Integer.parseInt(a1), Integer.parseInt(a2)); constMap.put(q.result, String.valueOf(val)); result.add(new Quad("=", String.valueOf(val), null, q.result)); continue; } // 公共子表达式消除:相同 op 和操作数只算一次 String key = q.op + "|" + a1 + "|" + a2; if (exprMap.containsKey(key)) { result.add(new Quad("=", exprMap.get(key), null, q.result)); continue; } exprMap.put(key, q.result); result.add(new Quad(q.op, a1, a2, q.result)); } return result; } private boolean isNumber(String s) { return s != null && s.matches("-?\\d+"); } private int calc(String op, int a, int b) { return switch (op) { case "+" -> a + b; case "-" -> a - b; case "*" -> a * b; case "/" -> a / b; default -> throw new OptimizeException("Bad op: " + op); }; } }

逻辑说明:constMap记录变量已知常量值,exprMap记录已计算的表达式到结果变量的映射。常量折叠把t1 = 3 + 5直接变成t1 = 8,公共子表达式消除把重复的a + b复用同一个临时变量。参数上,isNumber用正则判断,calc做整数运算,实验要求浮点的话需要改成double并处理精度。优化顺序建议先折叠再消除,否则公共子表达式可能因为常量未折叠而漏掉。

5. 避坑与排查:编译实验里最容易翻车的五个地方

5.1 词法分析把关键字当标识符

现象:int a = 1;解析时报「未声明变量 int」。原因:readIdentifierOrKeyword里先返回了 ID 类型,没有查关键字表。解决:在识别完单词后立即查KEYWORDS,命中就改类型,且关键字表要覆盖文法里所有保留字。

5.2 递归下降遇到左递归直接栈溢出

现象:解析表达式时程序卡死或抛StackOverflowError。原因:文法写成E -> E + T,递归下降会无限调用自身。解决:改写文法消除左递归,变成E -> T E'、E' -> + T E' | ε,再对应写函数。

5.3 符号表作用域没弹栈导致变量泄漏

现象:内层块声明的变量在外层还能查到。原因:exitScope没调用或调用时机不对。解决:在解析块语句时严格配对enterScope和exitScope,建议用try-finally保证异常时也能弹栈。

5.4 四元式临时变量重名

现象:优化后计算结果错乱,两个不同表达式用了同一个t1。原因:tempCount在多个生成阶段被重置,或者优化时复用了已释放的临时变量。解决:临时变量编号全局唯一,优化阶段引入新临时变量时继续递增,不要重置计数器。

5.5 目标代码生成漏掉类型转换

现象:整数和浮点数混合运算结果截断。原因:四元式生成时没插入int2float转换指令。解决:在语义分析阶段标记需要转换的节点,代码生成时对混合类型操作数先转换再运算,转换指令单独设计。

6. 把实验跑通之后:用测试用例反推编译器正确性

编译器实验最怕「看起来能跑,一换输入就崩」。我一般会准备三组测试用例:第一组是正常程序,覆盖赋值、算术、条件、循环;第二组是边界输入,比如空语句、嵌套括号、深层表达式;第三组是错误输入,比如未声明变量、括号不匹配、类型不匹配,用来验证报错信息是否准确。每组用例都记录预期输出,用脚本自动对比。

一个实用的验证技巧是「四元式回读」:把生成的四元式序列反向解释执行一遍,看结果和直接解释 AST 是否一致。如果一致,说明中间代码生成没引入语义偏差;如果不一致,问题多半在临时变量分配或运算符优先级上。这个习惯帮我省了很多后悔药,因为目标代码生成阶段的 bug 往往很难定位,而四元式层面更容易打印和比对。

另一个习惯是每加一个语法特性就补一条端到端测试,从源代码字符串一路跑到目标指令输出,不跳过任何阶段。编译原理实验的价值不在于写出多高效的编译器,而在于你亲手把「字符流 → Token → AST → 四元式 → 目标指令」这条链路走通一遍,之后再看任何语言工具链都不会觉得是黑匣子。希望帮到你。

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

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

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

立即咨询