ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写迷你Java编译器:从词法分析到JVM字节码生成

手写迷你Java编译器:从词法分析到JVM字节码生成 简介这是一份围绕Java编译器与IDE实现的学习材料面向正在学习编译原理、Java虚拟机字节码或准备课程设计的开发者。包内仅含两个文件主要为一个Java源文件和一个Markdown文档Java源文件可作为自定义IDE或编译器的参考骨架Markdown文档则整理了项目说明、使用方式与常见问题排错思路。整个压缩包仅2KB内容精简便于快速通读适合在碎片时间对照学习。目前已有58人浏览学习。结合资源描述可知Java编译流程涵盖词法分析、语法分析、语义分析、中间代码生成、代码优化与目标代码生成等阶段而这份材料恰好能帮助读者将抽象理论对应到具体代码结构中理解javac与ECJ等编译器的核心模块如何协作。对于正在设计小型编译器的读者还可借助其中的源码结构快速定位入口扩展语法支持或补充错误处理逻辑是一份轻量但具有启发性的入门参考。1. Java编译器不是换个壳的翻译器一个.class文件背后要过五关斩六将很多人用 javac 用了好几年以为 Java编译器 就是把「源代码」翻译成「字节码」的黑匣子。直到我为了搞懂 JVM 的类加载和字节码把 javac 的-print输出扒了一遍才发现一个.java到.class至少要经历词法、语法、语义、字节码生成四道工序。自己动手实现一个迷你的 Java编译器 之后再看泛型擦除、String switch、try-with-resources 这些语法糖完全是另一番风景。这篇文章就是把我做过的这条路重走一遍不依赖 IDE、不依赖 javac用递归下降手写前端用 ASM 生成 class 文件中间踩过的坑原样摆出来。适合正在啃 java基础、准备 java面试题、或者课程设计选「Java 编译器实现」方向的开发者。2. 从字符流到AST用递归下降写一个看得见的词法与语法2.1 词法分析器Token类型、关键字与运算符的取舍编译器第一步不是翻译而是把字符流切成有意义的 Token。每个 Token 至少要有类型、文本、行列号因为后续报错全靠它。下面这个 Lexer 只处理 - * / ; ( ) { }和class public static void int return if else while足够表达一个 Java 子集。public enum TokenType { IDENT, INT, // 标识符、整数 PLUS, MINUS, STAR, SLASH, // - * / ASSIGN, SEMI, // ; LPAREN, RPAREN, LBRACE, RBRACE, KEYWORD_CLASS, KEYWORD_PUBLIC, KEYWORD_STATIC, KEYWORD_VOID, KEYWORD_INT, KEYWORD_RETURN, KEYWORD_IF, KEYWORD_ELSE, KEYWORD_WHILE, EOF } public class Token { TokenType type; String text; int line, column; Token(TokenType type, String text, int line, int column) { this.type type; this.text text; this.line line; this.column column; } Override public String toString() { return type ( text ) line : column; } }public class Lexer { private final String src; private int pos 0, line 1, column 1; public Lexer(String src) { this.src src; } public Token next() { skipWhitespace(); if (pos src.length()) return new Token(TokenType.EOF, , line, column); char c src.charAt(pos); if (Character.isDigit(c)) return readNumber(); if (Character.isLetter(c) || c _) return readIdentifier(); switch (c) { case : return oneChar(TokenType.PLUS, ); case -: return oneChar(TokenType.MINUS, -); case *: return oneChar(TokenType.STAR, *); case /: return oneChar(TokenType.SLASH, /); case : return oneChar(TokenType.ASSIGN, ); case ;: return oneChar(TokenType.SEMI, ;); case (: return oneChar(TokenType.LPAREN, (); case ): return oneChar(TokenType.RPAREN, )); case {: return oneChar(TokenType.LBRACE, {); case }: return oneChar(TokenType.RBRACE, }); default: throw new RuntimeException(无法识别的字符 c at line : column); } } private Token oneChar(TokenType type, String text) { pos; column; return new Token(type, text, line, column - 1); } private void skipWhitespace() { while (pos src.length() Character.isWhitespace(src.charAt(pos))) { char c src.charAt(pos); if (c \n) { line; column 1; } else column; } } private Token readNumber() { int startPos pos; int startColumn column; while (pos src.length() Character.isDigit(src.charAt(pos))) { pos; column; } return new Token(TokenType.INT, src.substring(startPos, pos), line, startColumn); } private Token readIdentifier() { int startPos pos; int startColumn column; while (pos src.length() (Character.isLetterOrDigit(src.charAt(pos)) || src.charAt(pos) _)) { pos; column; } String text src.substring(startPos, pos); switch (text) { case class: return new Token(TokenType.KEYWORD_CLASS, text, line, startColumn); case public: return new Token(TokenType.KEYWORD_PUBLIC, text, line, startColumn); // static、void、int、return、if、else、while 同理按需补充 default: return new Token(TokenType.IDENT, text, line, startColumn); } } }这里有几个参数细节值得说。oneChar返回的 Token 记column - 1是因为进入方法时column已经是当前字符的起始列自增后需要回退才能让它成为 Token 的起始列。skipWhitespace只在拿到换行符时更新line这样后续 token 的行号才能对得上源码。readNumber没有做越界检查因为循环条件里pos src.length()已经卡住如果生产环境要支持int a 1234567890123;词法阶段就要决定是按大数报错还是进入下一步这里先保持简单。2.2 递归下降解析用方法层级表达优先级Token 流接下来要变成 AST。我一般用递归下降因为代码结构能和语法产生式一对一映射遇到语法错误也能直接报行号。先定义最小节点集abstract class AstNode { int line; } class IntLit extends AstNode { int value; IntLit(int value, int line) { this.value value; this.line line; } } class VarRef extends AstNode { String name; VarRef(String name, int line) { this.name name; this.line line; } } class BinOp extends AstNode { String op; AstNode left, right; BinOp(String op, AstNode left, AstNode right) { this.op op; this.left left; this.right right; line left.line; } } class AssignStmt extends AstNode { String var; AstNode value; AssignStmt(String var, AstNode value) { this.var var; this.value value; } } class ReturnStmt extends AstNode { AstNode value; ReturnStmt(AstNode value) { this.value value; } }Parser 的核心是parseAdditive、parseMultiplicative、parsePrimary三层。为什么是三层因为 Java 表达式优先级要求* /先于 -而每一层的循环负责处理同一优先级的连续运算。public class Parser { private final Lexer lexer; private Token cur; public Parser(Lexer lexer) { this.lexer lexer; cur lexer.next(); } private void advance() { cur lexer.next(); } public AstNode parseExpression() { return parseAdditive(); } private AstNode parseAdditive() { AstNode left parseMultiplicative(); while (cur.type TokenType.PLUS || cur.type TokenType.MINUS) { String op cur.text; advance(); AstNode right parseMultiplicative(); left new BinOp(op, left, right); } return left; } private AstNode parseMultiplicative() { AstNode left parsePrimary(); while (cur.type TokenType.STAR || cur.type TokenType.SLASH) { String op cur.text; advance(); AstNode right parsePrimary(); left new BinOp(op, left, right); } return left; } private AstNode parsePrimary() { if (cur.type TokenType.INT) { AstNode node new IntLit(Integer.parseInt(cur.text), cur.line); advance(); return node; } if (cur.type TokenType.IDENT) { AstNode node new VarRef(cur.text, cur.line); advance(); return node; } if (cur.type TokenType.LPAREN) { advance(); AstNode expr parseExpression(); expect(TokenType.RPAREN); return expr; } throw new RuntimeException(表达式中出现意外的 token: cur); } public AstNode parseStatement() { if (cur.type TokenType.KEYWORD_RETURN) { advance(); AstNode value parseExpression(); expect(TokenType.SEMI); return new ReturnStmt(value); } if (cur.type TokenType.IDENT) { String name cur.text; advance(); expect(TokenType.ASSIGN); AstNode value parseExpression(); expect(TokenType.SEMI); return new AssignStmt(name, value); } throw new RuntimeException(未知语句从 cur.line : cur.column 开始); } private void expect(TokenType type) { if (cur.type ! type) throw new RuntimeException(期待 type 但读到 cur); advance(); } }这段代码里最重要的参数是三个方法名parseAdditive调用parseMultiplicativeparseMultiplicative再调用parsePrimary。它们之间不是相互调用而是单向向下所以不会左递归死循环。while循环负责把a b c变成((a b) c)也就是左结合。如果将来要支持一元负号只需要在parsePrimary里加一个case MINUS:分支先吞掉-再递归调用parsePrimary。parseStatement里用IDENT直接认为后面一定是这是当前子集的限制。完整 Java 里一个标识符后面可能跟着(、[、等等需要前视一个 token 才能判断。做课程设计的话先把这个限制写清楚避免后续扩展时一头雾水。2.3 把AST打印出来用可视化结构验证解析结果解析器写完第一件事不是继续写编译器而是把 AST 打印出来看形状。我习惯用一个极简的 printerpublic class AstPrinter { public static void dump(AstNode node, String indent) { if (node instanceof IntLit) { System.out.println(indent INT ((IntLit) node).value); } else if (node instanceof VarRef) { System.out.println(indent VAR ((VarRef) node).name); } else if (node instanceof BinOp) { BinOp b (BinOp) node; System.out.println(indent BINOP b.op); dump(b.left, indent ); dump(b.right, indent ); } else if (node instanceof AssignStmt) { AssignStmt a (AssignStmt) node; System.out.println(indent ASSIGN a.var); dump(a.value, indent ); } else if (node instanceof ReturnStmt) { ReturnStmt r (ReturnStmt) node; System.out.println(indent RETURN); dump(r.value, indent ); } } public static void main(String[] args) { Parser p new Parser(new Lexer(x 1 2 * 3; return x;)); AstNode s1 p.parseStatement(); AstNode s2 p.parseStatement(); dump(s1, ); dump(s2, ); } }执行后应该看到ASSIGN x BINOP INT 1 BINOP * INT 2 INT 3 RETURN VAR x如果看到的是(1 2) * 3的形状那就是parseAdditive和parseMultiplicative的调用顺序反了。这个验证只需要几秒钟却能在后期省下大量 debug 时间。AST 是所有后续阶段的唯一输入树错了语义检查和代码生成都会跟着错。3. 语义分析不是玄学符号表、类型检查与作用域3.1 符号表为什么要用栈式作用域而不是一个HashMap语法分析拿到 AST 之后编译器必须回答「这个变量是谁、是什么类型」。很多初学实现会用单个HashMap存变量名到符号遇到同名变量就直接覆盖。这在只有一层作用域的小测试里能跑一旦出现 if/while 块或者方法嵌套就乱套。Java 是面向对象编程的代表语言作用域嵌套是它的基础规则所以编译器里必须用栈式作用域。public class SymbolTable { private final ArrayDequeHashMapString, VarSymbol scopes new ArrayDeque(); public SymbolTable() { pushScope(); // 全局作用域 } public void pushScope() { scopes.push(new HashMap()); } public void popScope() { scopes.pop(); } public void define(String name, VarSymbol sym) { scopes.peek().put(name, sym); } public VarSymbol lookup(String name) { for (HashMapString, VarSymbol scope : scopes) { VarSymbol sym scope.get(name); if (sym ! null) return sym; } return null; } }pushScope和popScope对应进入和退出一个代码块lookup从栈顶向外查找也就是说内层同名变量会「遮蔽」外层变量而不是覆盖。这样在退出块之后外层变量依然是外层的。注意define永远定义在最内层作用域如果往全局塞变量会泄漏到不该出现的地方。这里我故意没写VarSymbol的字段实际使用时至少要包含typeint / boolean / String 等和slot局部变量槽索引生成字节码要用。ArrayDeque比Stack更适合做栈因为它不是同步的编译器运行时不需要担心并发。3.2 类型检查在AST上做一次遍历类型检查器本质就是一个 AST 的遍历器。每个节点返回自己的类型父节点检查子节点返回的类型是否符合预期。我一般把类型检查拆成两步先收集声明再检查表达式因为 Java 虽然要求变量先声明后使用但实现时分成两遍会更清晰。下面给出针对当前子集的简化版本。public class TypeChecker { private final SymbolTable table new SymbolTable(); public void check(AstNode node) { if (node instanceof IntLit) { // int 字面量本身没有类型错误 } else if (node instanceof VarRef) { VarRef v (VarRef) node; if (table.lookup(v.name) null) { throw new CompileError(第 v.line 行未定义变量 v.name ); } } else if (node instanceof BinOp) { BinOp b (BinOp) node; check(b.left); check(b.right); // 当前子集里所有变量都是 int所以二元运算结果也是 int } else if (node instanceof AssignStmt) { AssignStmt a (AssignStmt) node; if (table.lookup(a.var) null) { throw new CompileError(第 a.line 行赋值前未定义变量 a.var ); } check(a.value); } else if (node instanceof ReturnStmt) { ReturnStmt r (ReturnStmt) node; check(r.value); } } }这段代码刻意没有写出具体Type枚举因为我们这个子集里只有 int 一种类型类型检查退化成「变量有没有定义」。真正要接入 boolean 或 String 时你需要一个Type枚举让BinOp的 op 映射到合法的参数类型组合。AssignStmt检查的是一个常被忽略的坑很多人在只支持表达式时不查左边变量是否已声明等生成字节码时才发现ISTORE没有对应的局部变量槽然后只能在代码生成阶段报一个很奇怪的错。编译期提前拦住它是最低成本的后悔药。3.3 哪些错误编译期能抓、哪些抓不到把边界想清楚能避免给编译器乱加检查。编译期能抓的是「违反语言静态规则」的错误未定义变量、赋值给常量、参数个数不对、类型不兼容、分支没有返回值。编译期抓不到的是「运行时才能确定」的错误除数为 0、数组越界、空指针、整数溢出。这里的本质区别在于前者不依赖具体数据后者依赖运行时才有的值。JVM 的字节码验证器还会在类加载阶段检查 StackMapTable 是否匹配但那是字节码层面的静态检查和源码语义检查是两条线。额外提醒一点x / 0在 javac 里并不报错因为编译器不做常量除法折叠所以别为了证明编译器「很强」去支持常量折叠来抓除零。先把作用域和类型管好收益更大。4. 生成JVM字节码从AST到可运行的.class文件4.1 为什么选择ASM而不是手搓字节流class 文件格式本身并不复杂但它有一堆索引和长度字段要算。比如常量池里的CONSTANT_Class_info要指向CONSTANT_Utf8_info而 Utf8 的长度又限制在 65535 字节。手写字节流时最容易错的就是这些偏移量。所以我在真实落地时直接选 ASM它在生成 class 文件时会自动维护常量池、自动分配索引只要把操作数栈深度和帧交给COMPUTE_FRAMES就能稳定产出被 JVM 接受的字节码。依赖坐标形如org.ow2.asm:asm:9.x版本选最新稳定版即可。不过用 ASM 之前最好先知道它替你做了什么。下面这张表是 class 文件的最小结构也是后面读javap -v输出的依据。4.2 class文件的最小结构常量池、方法表与Code属性字段长度说明magic4字节固定0xCAFEBABEminor_version2字节次版本号major_version2字节主版本号55 Java 11constant_pool_count2字节常量池项数 10 号保留constant_pool不定类名、方法名、字符串等全部字符串引用access_flags2字节类的访问标志this_class2字节指向常量池里当前类索引super_class2字节父类索引interfaces_count2字节实现的接口数量fields_count / fields2字节 不定字段表methods_count / methods2字节 不定方法表attributes2字节长度 不定类级属性如 SourceFile每个方法内部还会有一个关键属性Code它包含max_stack、max_locals、code_length和真正的字节码数组。ASM 的ClassWriter把这些组装和长度计算全做了你只需要调用visit系列方法它自动维护常量池索引。但有一点必须注意常量池大小为 16 位无符号数最多 65535 项当方法里塞大量字符串常量时可能直接爆掉处理方式放在避坑章节。4.3 用ASM生成表达式方法体可抄代码与参数说明下面这段代码能把你前面解析出的表达式 AST生成一个包含public static int compute()方法的 class 文件。import org.objectweb.asm.*; public class BytecodeGen { public byte[] genCompute(String className, AstNode expr) { ClassWriter cw new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES); cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, className, null, java/lang/Object, null); MethodVisitor mv cw.visitMethod( Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, compute, ()I, null, null); mv.visitCode(); pushExpr(mv, expr); mv.visitInsn(Opcodes.IRETURN); mv.visitMaxs(0, 0); // 让 COMPUTE_* 自己算 mv.visitEnd(); cw.visitEnd(); return cw.toByteArray(); } private void pushExpr(MethodVisitor mv, AstNode node) { if (node instanceof IntLit) { mv.visitLdcInsn(((IntLit) node).value); } else if (node instanceof BinOp) { BinOp b (BinOp) node; pushExpr(mv, b.left); pushExpr(mv, b.right); switch (b.op) { case : mv.visitInsn(Opcodes.IADD); break; case -: mv.visitInsn(Opcodes.ISUB); break; case *: mv.visitInsn(Opcodes.IMUL); break; case /: mv.visitInsn(Opcodes.IDIV); break; default: throw new IllegalStateException(不支持运算符 b.op); } } else if (node instanceof VarRef) { throw new IllegalStateException(当前生成器只支持常量表达式); } } }visitMethod的第三个参数()I是方法描述符表示参数为空、返回 int。visitMaxs(0, 0)看起来是占位但配合构造器里的COMPUTE_MAXS | COMPUTE_FRAMESASM 会在visitEnd()时自动计算栈深、局部变量表和 StackMapTable。如果去掉这个标志visitMaxs就必须手工算否则 JVM 会在类加载时抛VerifyError。Opcodes.V11对应 Java 11主版本号 55。如果你要生成的代码跑在 Java 8 上记得改成Opcodes.V8不然目标环境可能拒绝加载。visitLdcInsn可以加载 int、float、String 等常量小整数其实更适合BIPUSH/SIPUSH但 LDC 也不会错只是多占一个常量池项。对 toy compiler 来说无所谓但对大方法体来说批量使用 LDC 是常量池爆掉的隐患之一。4.4 加载生成的class并验证编译器的验收标准是能跑生成字节码后最直观的验证是直接加载并调用。Java 9 以后可以用MethodHandles.Lookup#defineClass在内存中定义类省去临时文件。public class RunGenerated { public static void main(String[] args) throws Throwable { Parser parser new Parser(new Lexer((40 2) * 3)); AstNode expr parser.parseExpression(); byte[] classBytes new BytecodeGen().genCompute(GeneratedDemo, expr); MethodHandles.Lookup lookup MethodHandles.lookup(); Class? cls lookup.defineClass(classBytes); MethodHandle handle lookup.findStatic(cls, compute, MethodType.methodType(int.class)); int result (int) handle.invokeExact(); System.out.println(result result); } }注意lookup.defineClass要求生成类与调用链在同一个包这里都是默认包所以别把类名写成带斜杠的包路径。如果你要生成com/demo/GeneratedDemo就改成先写临时.class文件再用URLClassLoader加载。运行后如果输出result 126说明 AST 解析、语义检查、字节码生成整条链路是通的。之后再用javap从另一个角度验证javap -c -p GeneratedDemo.class预期输出public static int compute(); Code: 0: ldc #7 // int 40 2: ldc #8 // int 2 4: iadd 5: ldc #9 // int 3 7: imul 8: ireturnjavap -c只能看到指令序列javap -v还能看到常量池、行号表和 StackMapTable这些是排查VerifyError的主力。我一般把这条命令固定写进脚本每次改完代码生成器就跑一遍确保没有产生垃圾指令。5. 编译器实现避坑手册五个让我翻车过的问题5.1 现象生成的class一运行就抛VerifyError原因ClassWriter没开COMPUTE_FRAMES或者开了之后visitMaxs还是自己传了错误的栈深。Java 7 以上类文件被 JVM 加载时会做字节码验证检查每个指令前面的操作数栈状态是否一致。手写visitMaxs(0,0)而不开自动计算在分支汇合点必然验证失败。解决使用new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES)并且对每个方法都留visitMaxs(0, 0)让 ASM 自己推算。如果项目必须兼容旧版 Android 的 dex 工具链不支持 COMPUTE_FRAMES那就只能手工算栈深并且把 ASM 的 writer 版本调低到对应旧格式。5.2 现象a - b - c被解析成a - (b - c)原因递归下降表达式解析写成了直接递归而不是循环。我最早偷懒在parseAdditive的右操作数位置调用parseAdditive而不是调用parseMultiplicative得到的就是右结合减法结果完全错。解决每个优先级层用while循环反复收集同一优先级的操作数右操作数调用「下一优先级」的方法。用3 - 2 - 1做回归测试正确结果是 0如果得到 2就是结合性接反了。5.3 现象词法分析器把读成和原因词法阶段没有做双字符运算符识别遇到立即返回 Token剩下的作为赋值号进入语法自然崩。解决在Lexer.next()的、、、!分支里做一次peekChar()判断下一个字符是否是合并成一个 Token。case : if (peekChar() ) { pos; column; return new Token(TokenType.GE, , line, column - 1); } return oneChar(TokenType.GT, );peekChar()只需要return pos src.length() ? src.charAt(pos) : \0;它不会推进 pos。漏掉这一类多字符运算符和就永远进不了语法分析。5.4 现象符号表用单个HashMap作用域退出后变量仍然可见原因把define和lookup都作用于同一个 Map没有 push/pop scope。结果是在 if 块内声明的变量在块外还能查到生成字节码时局部变量索引也会错位。解决改用栈式作用域每次进入代码块 push退出 pop。同时变量名到局部变量槽的映射最好在语义分析之后单独分配因为 JVM 的局部变量槽是整个方法共用的不随源码块嵌套回收。符号表和局部变量分配表应该分开不要混在一个类里。5.5 现象常量池项数到了65535直接抛异常原因ASM 的常量池索引是 16 位一个方法里塞了几千个字符串常量、类名、字段名就会爆。这在小型 toy compiler 里少见但一旦开始支持System.out.println且每个打印字符串都不同很容易踩到。解决把字符串常量从语句中抽出来放到类的静态字段里用ldc getstatic访问再不行就把常量池使用率监控起来定期统计。对编译器实现这件事先定义好「生成代码的规模上限」比想办法突破 65535 更实际。6. 进阶从AST直接到字节码是一条黑匣子加一层IR才有后悔药到这里你已经能把一个表达式编译成 class 并跑出结果。但如果只做 AST → 字节码后面一旦想优化比如把x * 1折叠掉、想支持多后端JVM、JS、native就得回头改生成器里的每个 visit 方法非常痛苦。我自己的做法是在 AST 和字节码之间加一种极简三地址码IR格式类似字节码但不依赖 JVM。例如(40 2) * 3变成t1 40 t2 2 t3 t1 t2 t4 3 t5 t3 * t4 return t5这样的 IR 可以很容易做常量折叠扫描t3 t1 t2如果 t1 和 t2 都是常量字面量就替换成t3 42。然后在 IR 上做死代码消除、临时变量名到局部变量槽分配最后再发射字节码。它相当于给编译器加了一层缓冲优化和排错都在 IR 上做而不是在机器指令上做。给编译器加 IR 的第二个好处是报错更容易定位。AST dump 只能告诉你语法树形状对IR dump 能告诉你代码生成器有没有算错栈。我习惯在 IR 生成后打印一遍for (IrInsn insn : irList) { System.out.println(insn); }如果 IR 是对的那问题只在最后的字节码发射如果 IR 是错的就去看 AST 解析和语义检查。这个习惯帮我少翻了很多次车也算是我做编译器项目最想保留的一个小技巧。希望帮到你。本文还有配套的精品资源点击获取
返回列表