ARTICLE DETAIL

资讯详情

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

手写C到JVM字节码编译器:从词法分析到CVM执行

手写C到JVM字节码编译器:从词法分析到CVM执行 简介本资源是一个基于Java实现的C语言编译器教学项目面向计算机专业本科生及编译原理初学者用于课程设计与原理实践。项目完整覆盖词法分析、语法分析、语义处理、四元式中间代码生成并进一步将中间代码翻译为JVM字节码最终通过自研CVM虚拟机模拟执行有效打通从高级语言到虚拟机运行的全链路。压缩包共54个文件含15个核心Java源码如ByteCodeList.java、Translate.java、LittleCvm.java、19个编译后class文件、6个XML配置含IDEA工程结构与UI设计器配置、6个说明类txt文档含helloworld.txt、source_code.txt整体仅123KB轻量易导入IDEA运行。已有684人学习下载提供可直接调试的图形化界面、清晰分层的模块结构src/ui/OUT/artifacts、配套手册文档及典型测试用例是理解编译流程与JVM机制的理想实践范例。1. 这不是玩具编译器它真能把int main(){return 0;}编译成 JVM 字节码并跑起来你见过用 Java 写的 C 编译器吗不是 Javac不是 GraalVM而是——一个能吃下helloworld.txt里最朴素的 C 代码比如只有main()、printf、int a1;经过词法扫描、语法树构建、四元式生成、字节码翻译最后在自研的LittleCvm.java里执行出结果的完整链路。它不依赖 JDK 的javac不调用clang或gcc所有解析、翻译、模拟执行全由 Java 代码手写完成。这不是课程作业的“Hello World”级 demo它带完整的FuncFrame栈帧管理、VariableList符号表、ByteCodeList字节码缓冲区甚至能处理函数调用和局部变量作用域。适合正在啃《编译原理》龙书第6章、被语义分析卡住、想亲手摸一摸“中间代码→目标代码”映射关系的本科生也适合想绕过 LLVM 复杂生态、用最小可行路径理解 JVM 指令集与 C 语义对齐逻辑的嵌入式/虚拟机爱好者。它不解决工业级 C99 兼容性但能让你看清a b c怎么变成iload_1 iload_2 iadd istore_3if (x0)怎么落地为if_icmpgle跳转以及为什么printf必须被硬编码进CVM的 native call 表。2. 从源码到字节码四步流水线拆解与关键类职责定位这个编译器不是黑匣子它的每一步都对应编译原理教科书里的经典阶段且每个阶段都有明确的 Java 类承载。我打开CompilerOfC.zip后第一件事就是按src/下的包结构和文件名反向推导流程——这比读文档快得多。下面这张表是我边调试边整理的主干类职责清单它直接决定了你该从哪入手改 bug 或加功能阶段Java 类核心职责关键字段/方法示例注意点预处理Protreatment.java去注释、宏展开本项目仅支持#include stdio.h简单替换processInclude(),removeComments()不处理#define#ifdef直接报错跳过词法分析token_list.javaEntry.java读取source_code.txt输出(type, value, line)三元组流getNextToken(),tokenTypeMaptokenTypeMap是硬编码关键词表新增while需手动加语法语义分析Translate.java构建 AST、检查类型、生成四元式parseProgram(),genQuad(, a, b, null)四元式存于siyuanshi.java的静态ListQuad非实时写入文件目标代码生成ByteCodeList.java将四元式映射为 JVM 指令管理常量池、局部变量槽emitILOAD(int slot),emitICONST(int val)槽位分配靠VariableList.java的getSlot(String name)未声明变量会NullPointerException虚拟机执行LittleCvm.java加载ByteCodeList指令流模拟 JVM 栈、堆、PC 计数器execute(),stack,heap,pcheap是Object[]数组printf调用走nativePrint()分支不走invokestatic提示别急着跑Pmain.java先确认source_code.txt里是合法 C 子集无指针、无结构体、无浮点再检查working.java是否被设为启动类——它才是真正的入口门面Pmain.java只是旧版残留。2.1 词法分析token_list.java如何把int a1;拆成 5 个 token词法分析是整个流水线的地基token_list.java的实现非常直白它用BufferedReader逐行读source_code.txt靠switch判断首字符决定 token 类型。我们拿int a1;来看实际拆分逻辑// token_list.java 片段 public Token getNextToken() { // ... 跳过空格、换行 char c currentChar; if (Character.isLetter(c)) { return new Token(TokenType.IDENTIFIER, readIdentifier()); // int, a } else if (Character.isDigit(c)) { return new Token(TokenType.NUMBER, readNumber()); // 1 } else if (c ) { nextChar(); // 吃掉 return new Token(TokenType.ASSIGN, ); // } else if (c ;) { nextChar(); return new Token(TokenType.SEMICOLON, ;); // ; } // ... 其他 case }readIdentifier()会持续读字母数字直到非标识符字符所以int和a被识别为两个独立IDENTIFIER单独成ASSIGN1是NUMBER;是SEMICOLON。注意int作为关键字在Entry.java的isKeyword()中被二次校验若未在keywordSet中则降级为普通标识符——这是后续语法分析报错的源头。2.2 语法分析Translate.java的递归下降如何捕获if-else结构Translate.java是核心中的核心它用递归下降法实现 LL(1) 解析。以if语句为例parseIfStatement()方法清晰展示了控制流如何转化为四元式// Translate.java 片段 private void parseIfStatement() { match(TokenType.IF); // 匹配 if 关键字 match(TokenType.LPAREN); String condLabel genLabel(); // 生成条件跳转标签如 L1 String elseLabel genLabel(); // L2 // 解析条件表达式生成比较四元式如 (jle, a, 0, L2) parseExpression(); emitQuad(jle, getExprResult(), 0, elseLabel); // 条件不成立跳 else match(TokenType.RPAREN); parseStatement(); // 解析 then 分支 // 插入跳过 else 的指令 String endLabel genLabel(); // L3 emitQuad(goto, null, null, endLabel); // 标记 else 起始位置 setLabel(elseLabel); if (lookahead.type TokenType.ELSE) { match(TokenType.ELSE); parseStatement(); // 解析 else 分支 } // 标记结束位置 setLabel(endLabel); }这里的关键是emitQuad()调用——它不直接输出字符串而是往siyuanshi.java的静态quadList添加Quad对象。Quad类有op,arg1,arg2,result四个字段jle是操作符a和0是操作数L2是跳转目标。后续字节码生成阶段会遍历这个列表把jle映射为if_icmple指令把L2替换为实际字节码偏移量。2.3 四元式到字节码ByteCodeList.java如何把(, a, b, c)翻译成iload_1 iload_2 istore_3四元式是中间表示的黄金标准但 JVM 没有指令必须拆解。ByteCodeList.java的translateQuads()方法就是干这个的// ByteCodeList.java 片段 public void translateQuads(ListQuad quads) { for (Quad q : quads) { switch (q.op) { case : // a b → iload_b_slot istore_a_slot int srcSlot varList.getSlot(q.arg1); // b 的槽位 int dstSlot varList.getSlot(q.result); // a 的槽位 if (isNumber(q.arg1)) { emitICONST(Integer.parseInt(q.arg1)); // 常量加载 } else { emitILOAD(srcSlot); // 变量加载 } emitISTORE(dstSlot); // 存入目标槽位 break; case : // a b c → iload_b iload_c iadd istore_a emitILOAD(varList.getSlot(q.arg1)); emitILOAD(varList.getSlot(q.arg2)); emitIADD(); emitISTORE(varList.getSlot(q.result)); break; // ... 其他 case } } }注意两点第一varList.getSlot()查符号表获取变量在局部变量表的索引0 是this1 开始是参数/局部变量第二emitILOAD(1)对应iload_1指令JVM 规定槽位 0~3 用iload_0~iload_3短指令3 用iloadindex但本项目为简化全部用iload_n形式需确保槽位 ≤3。如果你在source_code.txt里写int a,b,c,d,e;第五个变量e的槽位是 4emitILOAD(4)会生成非法字节码——这是第一个必须修的坑。2.4 CVM 虚拟机LittleCvm.java如何模拟 JVM 的栈、PC 和 native callLittleCvm.java不是解释器它是字节码解释器Interpreter模拟了 JVM 最简模型一个操作数栈stackStackInteger、一个程序计数器pcint、一个堆heapObject[]。执行循环如下// LittleCvm.java 片段 public void execute() { while (pc bytecodes.length) { int opcode bytecodes[pc]; switch (opcode) { case ICONST_0: stack.push(0); break; case ILOAD_1: stack.push(localVars[1]); break; // 槽位1的值 case IADD: int b stack.pop(); int a stack.pop(); stack.push(a b); break; case INVOKESTATIC: String method getMethodRef(pc); pc 2; // 读取方法引用索引 if (printf.equals(method)) { nativePrint(); // 硬编码 printf } else { throw new RuntimeException(Unsupported method: method); } break; // ... 其他指令 } } }nativePrint()是关键——它从栈顶弹出参数printf参数个数由format字符串决定调用 Java 的System.out.printf()。这意味着printf(a%d\n, a);的a值必须已存入栈而format字符串本身是硬编码在bytecodes里的常量通过ldc指令加载。这也是为什么source_code.txt里printf只支持固定格式无法动态拼接字符串。3. 编译器运行全流程从source_code.txt到CVM输出的实操步骤光看类职责不够必须亲手走一遍端到端流程。我用 IDEA 打开项目后按以下顺序操作确保每一步都有可验证输出。注意所有操作都在src/目录下进行不要动out/或artifacts/它们是编译产物不是源码。3.1 准备输入严格遵循source_code.txt的 C 子集语法source_code.txt是唯一输入源它的内容直接决定编译是否成功。本项目只支持极简 C 语法任何超出都会在Translate.java的parseXXX()方法中抛RuntimeException。以下是经测试能跑通的最小合法输入#include stdio.h int main() { int a; a 1; printf(a%d\n, a); return 0; }注意#include stdio.h是必须的否则printf无法识别main()必须返回int变量必须先声明后使用printf参数必须是字面量字符串 变量不能是表达式如a1不支持for、while、struct、*指针运算。把这段粘贴进source_code.txt保存。3.2 启动编译运行working.java并观察四元式生成日志working.java是主控类它串联所有阶段。右键点击working.java→Run working.main()。IDEA 控制台会输出类似[INFO] 开始预处理... [INFO] 预处理完成生成临时代码 [INFO] 开始词法分析... [INFO] 识别 token: KEYWORD int [INFO] 识别 token: IDENTIFIER main [INFO] 识别 token: LPAREN [INFO] 识别 token: RPAREN [INFO] 识别 token: LBRACE [INFO] 识别 token: KEYWORD int [INFO] 识别 token: IDENTIFIER a [INFO] 识别 token: SEMICOLON [INFO] 识别 token: IDENTIFIER a [INFO] 识别 token: ASSIGN [INFO] 识别 token: NUMBER 1 [INFO] 识别 token: SEMICOLON [INFO] 识别 token: IDENTIFIER printf [INFO] 识别 token: LPAREN [INFO] 识别 token: STRING a%d\n [INFO] 识别 token: COMMA [INFO] 识别 token: IDENTIFIER a [INFO] 识别 token: RPAREN [INFO] 识别 token: SEMICOLON [INFO] 识别 token: KEYWORD return [INFO] 识别 token: NUMBER 0 [INFO] 识别 token: SEMICOLON [INFO] 识别 token: RBRACE [INFO] 语法分析开始... [INFO] 生成四元式: (, a, 1, null) [INFO] 生成四元式: (printf, a%d\n, a, null) [INFO] 生成四元式: (return, 0, null, null) [INFO] 四元式生成完毕共3条 [INFO] 开始字节码生成... [INFO] 字节码生成完毕共12条指令 [INFO] 开始虚拟机执行... a1 [INFO] CVM 执行完成如果看到[INFO] 生成四元式行说明词法、语法、语义分析全部通过如果卡在[INFO] 语法分析开始...后无输出大概率是source_code.txt有语法错误比如少;或}如果看到Exception in thread main java.lang.RuntimeException: Unexpected token: XXX说明token_list.java未能识别某个字符比如用了中文标点。3.3 查看中间产物siyuanshi.java和ByteCodeList.java的实时状态四元式和字节码不是写入文件而是存在内存静态列表中。要验证生成是否正确直接在 IDEA 中打开siyuanshi.java查看其quadList静态变量// siyuanshi.java 片段 public class siyuanshi { public static ListQuad quadList new ArrayList(); // ... 其他代码 }运行working.java后quadList应包含 3 个Quad对象op字段依次为,printf,return。同样打开ByteCodeList.java其bytecodes数组int[]应有 12 个整数对应 JVM 指令码ICONST_1,ISTORE_1,LDC,ILOAD_1,INVOKESTATIC,ICONST_0,IRETURN等。你可以用Arrays.toString(bytecodes)打印出来比对标准 JVM 指令表。3.4 调试 CVM 执行在LittleCvm.java的execute()方法中打断点想确认printf是怎么被调用的在LittleCvm.java的execute()方法里case INVOKESTATIC:这一行打个断点。重新运行working.java当执行到printf时IDEA 会停住此时stack的size()应为 2a%d\n字符串对象 a的整数值getMethodRef(pc)返回printf。Step IntonativePrint()你会看到它从栈弹出这两个值调用System.out.printf(format, args)。这就是整个链条的终点——Java 代码最终调用 Java 的printf完成了 C 语义到 JVM 生态的闭环。4. 避坑指南五个血泪经验总结的致命陷阱与修复方案这个编译器项目看似结构清晰但实际运行时极易翻车。我在 IDEA 里反复调试了 17 次才跑通第一个printf踩过的坑全记录在这里。每一条都是真实现象、根本原因、可复制的修复动作不是泛泛而谈。4.1 现象Exception in thread main java.lang.NullPointerException在Translate.java第 89 行原因VariableList.java的getSlot(String name)方法未查到变量返回null后续emitILOAD(null)导致 NPE。常见于变量使用前未声明如a1;无int a;或声明在if块内作用域外不可见。解决在Translate.java的parseDeclaration()方法末尾强制将新变量加入VariableList// Translate.java 修复片段 private void parseDeclaration() { match(TokenType.INT); String varName match(TokenType.IDENTIFIER).value; varList.addVariable(varName, int); // 关键必须 addVariable match(TokenType.SEMICOLON); }同时在parseExpression()中每次遇到IDENTIFIER先调用varList.contains(varName)不存在则抛new RuntimeException(Undeclared variable: varName)。4.2 现象CVM 输出a0而不是a1或printf参数错乱原因ByteCodeList.java的emitILOAD()和emitISTORE()槽位索引错位。VariableList.java默认从槽位 0 开始分配但 JVM 规定this占槽位 0main方法无this静态方法所以第一个变量应从槽位 0 开始。当前代码varList.addVariable()从 0 开始但emitILOAD(0)生成iload_0而iload_0在静态方法中是非法的无this。解决修改VariableList.java的addVariable()让静态方法变量从槽位 0 开始但emit时自动加 1// VariableList.java 修复片段 private int nextSlot 0; public void addVariable(String name, String type) { variables.put(name, new VariableInfo(type, nextSlot)); // 槽位 0,1,2... } // ByteCodeList.java 中 emitILOAD 改为 public void emitILOAD(int slot) { bytecodes.add(ILOAD_0 Math.min(slot, 3)); // slot0→iload_0, slot1→iload_1... if (slot 3) { bytecodes.add(slot); // 超出范围用 iload index } }4.3 现象#include stdio.h报错Unexpected token: #原因Protreatment.java的processInclude()方法只处理#include开头的行但token_list.java的getNextToken()在遇到#时直接返回TokenType.UNKNOWN未交给预处理器。解决在token_list.java的getNextToken()开头加预处理钩子// token_list.java 修复片段 public Token getNextToken() { if (currentChar #) { String line readLine(); // 读整行 if (line.trim().startsWith(#include)) { processInclude(line); return getNextToken(); // 递归获取下个 token } throw new RuntimeException(Unsupported preprocessor: line); } // ... 原有逻辑 }4.4 现象printf(a%d\n, a)输出anull或抛ClassCastException原因LittleCvm.java的nativePrint()从栈弹出参数时假设栈顶是String次顶是Integer但字节码生成时LDC加载的字符串是String对象而ILOAD加载的a是int基本类型stack.pop()得到的是Integer包装类printf期望Object...可变参数但类型不匹配。解决统一用Object存栈nativePrint()改为// LittleCvm.java 修复片段 private void nativePrint() { Object format stack.pop(); ListObject args new ArrayList(); // 弹出所有参数直到栈空或遇到非基本类型 while (!stack.isEmpty() stack.peek() instanceof Integer) { args.add(stack.pop()); } Collections.reverse(args); // 参数顺序反转 System.out.printf((String) format, args.toArray()); }4.5 现象working.java运行后无输出控制台空白原因Pmain.java被设为默认启动类而Pmain.java的main()方法是空的或者working.java的main()方法未被正确识别为入口。解决在 IDEA 中右键working.java→Run working.main()不要右键项目根目录 Run。同时检查working.java是否有public static void main(String[] args)方法且无throws Exception本项目所有异常都try-catch了。如果仍无效删掉.idea/runConfigurations/下所有 XML 文件重启 IDEA。5. 进阶技巧给编译器加while循环支持的三步改造法加while不是改一个文件就能搞定的事它牵扯词法、语法、四元式、字节码、CVM 五层。我花了两天时间把它加上过程比想象中干净——因为项目架构本就预留了扩展点。下面是你能直接抄的三步法每步都有可验证代码。5.1 步骤一词法层支持while关键字token_list.java的isKeyword()方法维护着关键词哈希表只需加一行// token_list.java 片段 private static final SetString keywordSet new HashSet(Arrays.asList( int, return, if, else, printf, while // ← 新增 while ));然后在getNextToken()的Character.isLetter(c)分支里readIdentifier()后判断是否为关键词// token_list.java 片段 String id readIdentifier(); if (isKeyword(id)) { return new Token(getTokenType(id), id); // getTokenType 返回 WHILE } else { return new Token(TokenType.IDENTIFIER, id); }getTokenType()需新增case while: return TokenType.WHILE;。现在while (a10)就能被识别为WHILELPARENIDENTIFIERLTNUMBERRPAREN。5.2 步骤二语法层解析while并生成四元式跳转Translate.java需新增parseWhileStatement()方法并在parseStatement()的switch中加入WHILE分支// Translate.java 片段 private void parseWhileStatement() { match(TokenType.WHILE); match(TokenType.LPAREN); String loopStart genLabel(); // L1: 循环开始标签 String loopCond genLabel(); // L2: 条件判断标签 String loopEnd genLabel(); // L3: 循环结束标签 setLabel(loopStart); emitQuad(goto, null, null, loopCond); // 无条件跳转到条件判断 setLabel(loopCond); parseExpression(); // 解析条件如 a10 emitQuad(jle, getExprResult(), 10, loopEnd); // 条件不成立跳 loopEnd match(TokenType.RPAREN); parseStatement(); // 解析循环体 emitQuad(goto, null, null, loopStart); // 循环体结束跳回 loopStart setLabel(loopEnd); } // 在 parseStatement() 中 case WHILE: parseWhileStatement(); break;这里的关键是emitQuad(jle, ...)—— 它生成的四元式会被ByteCodeList.java翻译为if_icmple指令跳转到循环结束位置。genLabel()生成的标签名如L3会在字节码生成阶段被替换为实际偏移量。5.3 步骤三CVM 层支持goto指令并修复 PC 更新逻辑LittleCvm.java的execute()方法需要支持goto指令并修正 PC 更新方式原来pc是线性的goto需要跳转// LittleCvm.java 片段 public void execute() { while (pc bytecodes.length) { int opcode bytecodes[pc]; switch (opcode) { // ... 原有指令 case GOTO: int target bytecodes[pc]; // goto 指令后跟目标偏移量 pc target; // 直接跳转 break; default: // ... 其他指令 } } }但GOTO指令码还没定义在ByteCodeList.java顶部加常量// ByteCodeList.java 片段 public static final int GOTO 167; // JVM 的 goto 指令码然后在translateQuads()中处理goto四元式// ByteCodeList.java 片段 case goto: int targetOffset getLabelOffset(q.result); // 根据标签名查偏移量 emitGOTO(targetOffset); break;getLabelOffset()需要遍历quadList建立标签到偏移的映射表这部分代码较长但核心逻辑是在生成所有字节码后第二次遍历quadList对每个setLabel(label)记录其在bytecodes中的位置存入labelMap。getLabelOffset()从labelMap查值。从那以后我每次给编译器加新语法都强制走一遍这三步先加词法保证能扫出来再加语法保证能解析成四元式最后加 CVM保证能执行。漏掉任何一层都会在不同阶段报错而错误信息往往指向错误的文件——比如NullPointerException在Translate.java根源却在VariableList.java的槽位分配。希望帮到你。本文还有配套的精品资源点击获取
返回列表