简介:这份资源是面向计算机专业学生与编译原理学习者的课程设计项目,主题为基于 Java 实现的 C 语言子集编译器,采用 LL(1) 文法完成自顶向下解析。项目覆盖词法分析、语法分析、语义分析与代码生成四个阶段,可处理变量声明、运算符、if-else 与循环等控制流语句及函数定义,并包含错误检测与恢复机制,帮助读者把课堂上的编译理论落到可运行的代码上。压缩包共 67 个文件,约 210KB,以 22 个 java 源文件与 33 个 class 字节码为主,另含文法定义、测试用例、说明文档与项目配置等辅助内容,目录结构清晰,便于对照源码理解各模块职责。目前已有 143 人学习下载。对于需要完成同类课程设计或想动手实现解析器的读者,这份资源提供了完整的工程骨架与可参考的实现思路,适合作为编译原理实践与 Java 编程训练的学习素材。
1. 用 Java 写一个 C 语言编译器:从词法到代码生成的最小闭环
很多人第一次听到「基于 Java 的 C 语言编译器」会本能地皱眉:Java 跑在 JVM 上,C 是贴近底层的系统语言,这两者凑一起图什么?我当初也这么想,直到接手一个需要在服务端动态校验并执行用户上传 C 代码片段的需求——不可能让每段代码都走一遍本地 gcc,也不允许在生产环境随意 fork 进程。这时候用 Java 实现一个 C 子集编译器,把源码翻译成可解释执行的中间表示,反而成了最稳的落地路径。它解决的不是「替代 gcc」,而是「在 JVM 生态里可控地解析、校验、执行 C 代码」。适合谁?写过 Java、懂基本编译原理、又想真正把「编译器开发」从课程作业变成能跑通工具链的工程师。下面这套方案,我按词法、语法、语义、代码生成四段拆开,每段都能单独跑。
2. 词法分析器:把 C 源码切成 Java 能消化的 Token 流
2.1 为什么不用正则硬切,而是手写状态机
C 语言的词法看着简单,实际坑不少:0x1F是十六进制、1e-5是浮点、//和/* */两种注释、->和--要区分、字符串里的转义\"不能当结束符。用正则一条条匹配,遇到a---b这种就会翻车——它到底是a-- - b还是a - --b,正则很难回溯对。常见做法是手写一个带pos指针的状态机,逐字符推进,遇到分支再回退。我一般会把 Token 类型定义成枚举,每个 Token 带type、lexeme、line、column四个字段,后面报错定位全靠它。
2.2 最小可跑的 Lexer 代码
public class Lexer { private final String src; private int pos = 0; private int line = 1; private int col = 1; public Lexer(String src) { this.src = src; } // 主循环:跳过空白和注释,识别下一个 Token public Token next() { skipWhitespaceAndComments(); if (pos >= src.length()) return new Token(TokenType.EOF, "", line, col); char c = src.charAt(pos); if (Character.isLetter(c) || c == '_') return readIdentifier(); if (Character.isDigit(c)) return readNumber(); return readOperatorOrPunct(); } private void skipWhitespaceAndComments() { while (pos < src.length()) { char c = src.charAt(pos); if (c == ' ' || c == '\t' || c == '\r') { advance(); } else if (c == '\n') { line++; col = 1; pos++; } else if (c == '/' && pos + 1 < src.length() && src.charAt(pos + 1) == '/') { while (pos < src.length() && src.charAt(pos) != '\n') advance(); } else if (c == '/' && pos + 1 < src.length() && src.charAt(pos + 1) == '*') { advance(); advance(); while (pos + 1 < src.length() && !(src.charAt(pos) == '*' && src.charAt(pos + 1) == '/')) advance(); advance(); advance(); // 吃掉 */ } else break; } } private Token readIdentifier() { int start = pos; while (pos < src.length() && (Character.isLetterOrDigit(src.charAt(pos)) || src.charAt(pos) == '_')) advance(); String word = src.substring(start, pos); // 关键字表命中就返回关键字类型,否则是普通标识符 TokenType t = Keywords.lookup(word); return new Token(t != null ? t : TokenType.IDENT, word, line, col); } private Token readNumber() { int start = pos; if (src.charAt(pos) == '0' && pos + 1 < src.length() && (src.charAt(pos + 1) == 'x' || src.charAt(pos + 1) == 'X')) { advance(); advance(); while (pos < src.length() && isHex(src.charAt(pos))) advance(); return new Token(TokenType.INT_LIT, src.substring(start, pos), line, col); } while (pos < src.length() && Character.isDigit(src.charAt(pos))) advance(); if (pos < src.length() && src.charAt(pos) == '.') { advance(); while (pos < src.length() && Character.isDigit(src.charAt(pos))) advance(); return new Token(TokenType.FLOAT_LIT, src.substring(start, pos), line, col); } return new Token(TokenType.INT_LIT, src.substring(start, pos), line, col); } private void advance() { pos++; col++; } }这段代码的逻辑很直白:next()是唯一入口,先吃掉空白和注释,再按首字符分流。readIdentifier里查关键字表是关键——int、if、while这些必须和普通变量名区分开,否则语法分析阶段没法判断语句类型。readNumber里对0x前缀单独处理,是因为十六进制字面量在 C 里太常见,混进十进制解析会直接算错值。参数上唯一要调的是关键字表Keywords.lookup,C89 和 C99 的关键字集合不同,比如inline、restrict是 C99 才有的,按你支持的子集裁剪即可。
提示:
line和col一定要在advance()里同步维护,不然后面语法报错只能给出行号,定位不到列,调试体验差一大截。
3. 语法分析:递归下降把 Token 流搭成 AST
3.1 为什么选递归下降而不是用 ANTLR 生成
ANTLR 确实能省事,但生成出来的代码对新手是黑匣子,报错信息也绕。递归下降的好处是每个非终结符对应一个方法,parseExpr、parseStatement、parseDeclaration一目了然,出错时你能精确知道卡在哪个产生式。C 的声明语法是出了名的反人类(int (*fp)(int)这种函数指针),递归下降配合一张优先级表能扛住大部分子集。我一般把 AST 节点设计成抽象类Node,下面挂Expr和Stmt两大分支,每个节点带line方便回溯源码。
3.2 表达式解析与优先级处理
public class Parser { private final List<Token> tokens; private int idx = 0; // 表达式入口:先解析赋值,再逐层下降 public Expr parseExpr() { return parseAssign(); } private Expr parseAssign() { Expr left = parseLogicalOr(); if (match(TokenType.ASSIGN)) { Expr right = parseAssign(); // 右结合 return new AssignExpr(left, right); } return left; } private Expr parseLogicalOr() { Expr left = parseLogicalAnd(); while (match(TokenType.OR_OR)) { Expr right = parseLogicalAnd(); left = new BinaryExpr("||", left, right); } return left; } private Expr parseAdditive() { Expr left = parseMultiplicative(); while (match(TokenType.PLUS) || match(TokenType.MINUS)) { String op = prev().lexeme; Expr right = parseMultiplicative(); left = new BinaryExpr(op, left, right); } return left; } private Expr parsePrimary() { if (match(TokenType.INT_LIT)) return new IntLiteral(Integer.parseInt(prev().lexeme)); if (match(TokenType.IDENT)) return new VarRef(prev().lexeme); if (match(TokenType.LPAREN)) { Expr e = parseExpr(); expect(TokenType.RPAREN); return e; } throw new ParseException("unexpected token: " + peek().lexeme, peek().line); } }优先级从低到高排:赋值 → 逻辑或 → 逻辑与 → 相等 → 关系 → 加减 → 乘除 → 一元 → 基本表达式。parseAssign里递归调用自己是故意的,因为赋值是右结合,a = b = c要解析成a = (b = c)。parseAdditive用while循环处理左结合,a + b + c会自然变成(a + b) + c。parsePrimary是兜底,遇到字面量、变量、括号表达式都在这里收口。参数上要注意的是match和expect的区别:match命中就消费并返回 true,expect不命中直接抛异常,语法错误就靠它暴露。
注意:C 的
*既可能是乘法也可能是解引用,&既可能是取地址也可能是按位与。递归下降里靠上下文区分——在parsePrimary里遇到*且后面跟标识符,就当解引用处理,这是子集实现里最常见的简化。
4. 语义分析与符号表:类型检查不能等到运行时
4.1 符号表用栈式作用域,别用全局 Map
C 有块作用域,{ int x; { int x; } }里内层x要遮蔽外层。用全局HashMap存变量,内层声明会直接覆盖外层,退出块时又没法恢复,这是血泪教训。正确做法是Deque<Map<String, Symbol>>,进入块push一层,退出块pop。每个Symbol记录类型、是否初始化、声明行号。类型检查在遍历 AST 时做:赋值左右类型要兼容,函数调用参数个数和类型要对上,void函数不能用在表达式里。
4.2 类型检查的核心逻辑
public class SemanticAnalyzer { private final Deque<Map<String, Symbol>> scopes = new ArrayDeque<>(); public SemanticAnalyzer() { scopes.push(new HashMap<>()); } public void enterScope() { scopes.push(new HashMap<>()); } public void exitScope() { scopes.pop(); } public void declare(String name, Type type, int line) { Map<String, Symbol> cur = scopes.peek(); if (cur.containsKey(name)) { throw new SemanticException("redeclaration of '" + name + "'", line); } cur.put(name, new Symbol(name, type, false, line)); } public Symbol resolve(String name, int line) { for (Map<String, Symbol> scope : scopes) { Symbol s = scope.get(name); if (s != null) return s; } throw new SemanticException("undeclared identifier '" + name + "'", line); } // 二元运算类型推导:int 和 float 混合时提升为 float public Type binaryResult(Type l, Type r, String op) { if (l == Type.FLOAT || r == Type.FLOAT) return Type.FLOAT; if (l == Type.INT && r == Type.INT) return Type.INT; throw new SemanticException("invalid operands for '" + op + "'", 0); } }declare里先查当前作用域有没有同名,有就报重复声明——注意只查当前层,外层同名是合法的遮蔽。resolve从内到外逐层找,找到就返回,找不到才报未声明。binaryResult处理类型提升,int + float结果是float,这是 C 的隐式转换规则,不实现的话后面代码生成会生成错误的指令。参数上Type枚举至少要有INT、FLOAT、VOID、CHAR四种,够覆盖大部分教学子集。
提示:函数声明和变量声明要分开处理,函数名进全局作用域,参数进函数自己的作用域。混在一起会导致参数名和全局变量冲突时判断错误。
5. 代码生成与解释执行:把 AST 变成能跑的东西
5.1 两条路:生成字节码还是直接树遍历
代码生成有两条常见路线。一是生成 JVM 字节码,用 ASM 库直接产出.class,优点是跑得快,缺点是调试困难,栈映射帧处理不好就VerifyError。二是写一个树遍历解释器,直接对 AST 求值,优点是实现简单、报错直观,缺点是慢。我一般先用树遍历跑通全流程,验证语义分析没问题后,再把热点路径换成字节码生成。对「基于 Java 的 C 编译器」这个目标,树遍历已经能覆盖 90% 的验证和教学场景。
5.2 树遍历解释器核心
public class Interpreter { private final Deque<Map<String, Object>> scopes = new ArrayDeque<>(); public Object eval(Expr expr) { if (expr instanceof IntLiteral) return ((IntLiteral) expr).value; if (expr instanceof VarRef) { String name = ((VarRef) expr).name; for (Map<String, Object> scope : scopes) { if (scope.containsKey(name)) return scope.get(name); } throw new RuntimeException("undefined variable: " + name); } if (expr instanceof BinaryExpr) { BinaryExpr b = (BinaryExpr) expr; Object l = eval(b.left); Object r = eval(b.right); return applyBinary(b.op, l, r); } if (expr instanceof AssignExpr) { AssignExpr a = (AssignExpr) expr; Object val = eval(a.right); assign(a.left, val); return val; } throw new RuntimeException("unknown expr: " + expr); } private Object applyBinary(String op, Object l, Object r) { // 只要有一边是 Double,就按浮点算,否则按整型算 if (l instanceof Double || r instanceof Double) { double a = ((Number) l).doubleValue(); double b = ((Number) r).doubleValue(); switch (op) { case "+": return a + b; case "-": return a - b; case "*": return a * b; case "/": return b == 0 ? Double.NaN : a / b; default: throw new RuntimeException("bad op: " + op); } } int a = ((Number) l).intValue(); int b = ((Number) r).intValue(); switch (op) { case "+": return a + b; case "-": return a - b; case "*": return a * b; case "/": return b == 0 ? 0 : a / b; // 整型除零返回 0,和 C 的未定义行为对齐 case "%": return b == 0 ? 0 : a % b; default: throw new RuntimeException("bad op: " + op); } } }eval用instanceof分发节点类型,简单直接。VarRef从内到外查作用域,和语义分析里的resolve逻辑一致。applyBinary里先判断有没有Double,有就按浮点算,这是 C 的类型提升规则在运行时的体现。整型除零返回 0 是权宜之计,真实 C 里是未定义行为,但解释器里抛异常会打断整个执行流,返回 0 更适合教学演示。参数上唯一要调的是作用域栈的初始层,main函数执行前要先把全局变量和函数声明塞进去。
注意:
assign方法要校验左值必须是VarRef,不能是字面量或表达式。1 = 2这种在语义分析阶段就该拦掉,解释器里再兜一层是防御性编程。
6. 避坑与排查:编译器开发里最容易翻车的 5 个点
6.1 现象:词法分析把a---b切成a--和-b
原因:readOperatorOrPunct里贪心匹配最长运算符,遇到--就消费两个字符,没考虑后面可能跟-。解决:运算符匹配按长度从长到短排,先试三字符(<<=、>>=),再试两字符(--、++、->),最后单字符。同时维护一个「上一个 Token 类型」字段,如果上一个是标识符或字面量,--优先当后缀自减,否则当前缀。
6.2 现象:递归下降解析int (*fp)(int)时栈溢出
原因:parseDeclaration里遇到(就递归调parseExpr,函数指针的括号里是*fp,不是表达式,递归进去出不来。解决:在parseDeclaration里先看(后面是不是*,是就按函数指针声明处理,单独走一条分支,不要进表达式解析。这是 C 声明语法里最反直觉的地方,子集实现里可以直接不支持函数指针,遇到就报「unsupported declaration」。
6.3 现象:语义分析报「未声明变量」,但变量明明在上一行定义了
原因:符号表作用域栈在if语句块退出时pop了,但if没有花括号时,变量声明的作用域判断出错。解决:C 里if (x) int y = 1;是合法的,y的作用域就是这条语句。解析时给每个Stmt节点带一个scopeLevel,语义分析按节点粒度进出作用域,不要按花括号粒度。
6.4 现象:解释器执行while循环时死循环,但源码里循环条件会变
原因:eval对AssignExpr返回了值,但while循环里没重新求值条件表达式,用的是缓存的旧值。解决:while实现里每次迭代都重新eval(condition),不要提前算好。这是树遍历解释器最经典的坑,条件表达式必须每轮重新求值。
6.5 现象:生成的 AST 节点没有行号,报错只能给「某处」
原因:Token带了line,但构造 AST 节点时没把line传进去。解决:所有Expr和Stmt构造函数强制要求line参数,Parser里每次new节点都从peek().line取。这个改动很小,但调试效率提升巨大,血泪经验。
7. 进阶技巧:用 Visitor 模式解耦遍历逻辑,顺手加个常量折叠
写到后面你会发现,语义分析、代码生成、解释执行都在遍历 AST,每个类里塞一堆instanceof判断,加个新节点类型要改三处。我一般会引入 Visitor 模式:定义AstVisitor<R>接口,每个节点实现accept(visitor),遍历逻辑集中到 Visitor 实现类里。这样加新节点只需实现accept,加新 pass 只需写新 Visitor。配合一个常量折叠优化:在语义分析阶段,遇到BinaryExpr两边都是字面量就直接算出结果替换节点,2 + 3 * 4在 AST 里就变成14,解释器少跑几层递归。
public interface AstVisitor<R> { R visitIntLiteral(IntLiteral node); R visitBinaryExpr(BinaryExpr node); R visitVarRef(VarRef node); R visitAssignExpr(AssignExpr node); } public class ConstantFolder implements AstVisitor<Expr> { @Override public Expr visitBinaryExpr(BinaryExpr node) { Expr l = node.left.accept(this); Expr r = node.right.accept(this); if (l instanceof IntLiteral && r instanceof IntLiteral) { int a = ((IntLiteral) l).value; int b = ((IntLiteral) r).value; switch (node.op) { case "+": return new IntLiteral(a + b); case "-": return new IntLiteral(a - b); case "*": return new IntLiteral(a * b); case "/": return b == 0 ? new IntLiteral(0) : new IntLiteral(a / b); } } return new BinaryExpr(node.op, l, r); } // 其余 visit 方法直接返回节点本身 }ConstantFolder在语义分析之后、代码生成之前跑一遍,把能算的都在编译期算掉。visitBinaryExpr先递归折叠左右子树,再判断两边是不是都成了字面量,是就当场算。除零返回 0 和解释器保持一致,避免编译期和运行期行为不一致。参数上要注意折叠只对纯字面量做,变量参与的表达式不能折,否则会改变求值顺序。
验证方法很简单:写一段int x = 2 + 3 * 4;,在ConstantFolder里打日志,看BinaryExpr是不是被替换成了IntLiteral(14)。再跑一遍解释器,结果一致就说明折叠没改变语义。我习惯每加一个优化 pass 就跑一遍回归用例,把之前跑通的 C 片段全过一遍,确认没翻车再继续。这套东西从词法到解释执行,代码量不大,但每个环节都能单独测、单独调,适合拿来当编译器开发的第一个完整闭环。希望帮到你。
本文还有配套的精品资源,点击获取