简介:面向编译原理课程设计与JavaCC实践,这一完整项目包适合需要实现类C编译器、理解词法/语法/语义分析全流程的计算机专业学生。压缩包共380个文件,约3.02MB,核心为46个java源文件、3个jj语法定义和55个class编译产物,同时包含38个sample测试样例、148个out输出文件及sh/py自动化脚本、PDF报告,便于对照结果和复现实验。目前已有857人浏览学习。除常规编译器源码外,还针对课程设计要求提供递归下降分析器、Python编写的LL1算法验证(通过教材例子测试)、函数调用内存空间变化的栈可视化展示,并配有可自动执行词法、语法、语义分析并输出结果的一键脚本;脚本还能将编译后的JJ、JJT、JAVA文件及输出结果自动归档,目录结构清晰。测试样例全部通过人工验证,注释经正则清除,可作为课程设计报告撰写、编译技术验证和环境配置的完整参考。
1. 从课程设计到可用的类 C 编译器:JavaCC 为什么是捷径
编译原理课程设计落到 Java 生态,最常见的地狱开局是一边手写递归下降,一边被左递归和 FIRST/FOLLOW 集折磨。重庆理工大学的这次课设要求用 Java 和 JavaCC 实现一个类 C 编译器,实际目标不是造出 GCC,而是让你走通「词法→语法→语义→代码生成」这条完整链路。JavaCC 的价值在于把词法分析和语法分析的生成过程自动化:你只要写好 .jj 文法文件,它能产出带准确行号信息的 Java 解析器,省下大量调试时间,把精力留给类型检查和中间代码。适合做完传统 C 语言课设后想进阶、或者准备把编译原理写进简历和面试话术的人。下面这套方案默认你已经有 Java 基础,能跑通 Maven 或普通 javac 命令。
2. 类C编译器整体架构:从源码到目标代码的四层分工
2.1 为什么选 JavaCC 而不是 ANTLR 或手写递归下降
类 C 编译器的难点不在“认识单词”,而在“文法怎么组织”。手写递归下降最痛苦的是处理表达式优先级和左递归,虽然能通过分级函数绕开,但代码量和调试成本都上去了。ANTLR 功能更强,但学习曲线陡,生成的监听器模式对新手不直观。JavaCC 介于两者之间:它用 LL(1) 文法加词法状态机,提供优先级声明和 EOF 处理,生成的是可读的 Java 代码,出错了你能直接看调用栈。另一个实际原因是课程设计通常限定 Java 技术栈,JavaCC 是 .jj 单文件方式,和 IDE 结合好,适合在重庆理工大学的上机环境里跑,不需要额外装 Python 或外部工具链。
2.2 四层结构的落地划分
类 C 编译器的整体流程可以划分为四层,每一层都有一个明确的输入输出:
| 层级 | 输入 | 输出 | 主要产物 |
|---|---|---|---|
| 词法分析 | 源文件字符串 | Token 流 | JavaCC 的 Token 类 |
| 语法分析 | Token 流 | AST 节点树 | 自定义 Node 类与 JavaCC 生成的 Parser |
| 语义分析 | AST | 带类型标注的符号表与 AST | SymbolTable、TypeCheck Visitor |
| 代码生成 | 带类型信息的 AST | 目标代码(如 x86 汇编 / 自定义字节码 / C 代码) | CodeGenerator |
词法分析在 JavaCC 里不是单独写代码,而是写在 .jj 文件的PARSER_BEGIN与PARSER_END之间,用SKIP、TOKEN定义空白和关键字。语法分析则对应 .jj 里的产生式方法,每个非终结符就是一个 Java 方法。语义分析不是必须写在 .jj 里,常见做法是让语法分析直接构建 AST,然后单独写一个 Visitor 遍历 AST 做类型检查和符号表填充。代码生成可以输出到标准输出或文件,最简单的是生成一段 C 语言代码,再用本机 gcc 编译验证;想要更“编译器”,就输出自定义虚拟机字节码,但课设一般没有时间做寄存器分配,输出类 C 的中间代码已经是高分水准。
3. 用 JavaCC 定义词法与语法:.jj 文件与生成器调用
3.1 最小 .jj 文件骨架与生成命令
新建一个MiniC.jj文件,里面只放最基本的词法与一个入口产生式。下面是一个能识别整数与变量声明的骨架:
// MiniC.jj options { STATIC = false; LOOKAHEAD = 2; } PARSER_BEGIN(MiniC) import java.util.*; public class MiniC { public static void main(String[] args) throws Exception { MiniC parser = new MiniC(new java.io.StringReader( "int a; a = 3;" )); parser.CompilationUnit(); System.out.println("解析通过"); } } PARSER_END(MiniC) SKIP : { " " | "\t" | "\n" | "\r" } TOKEN : { < INT: "int" > } TOKEN : { < ID: (["a"-"z","A"-"Z"]) (["a"-"z","A"-"Z","0"-"9"])* > } TOKEN : { < NUM: (["0"-"9"])+ > } void CompilationUnit() : {} { ( Declaration() )* <EOF> } void Declaration() : {} { <INT> <ID> ";" { System.out.println("声明变量 " + token.image); } }生成命令用 Maven 或直接 javacc 命令行都可以。最简单的方式是把 javacc 的 jar 包放到项目目录,然后执行:
java -cp javacc.jar javacc MiniC.jj运行后会在同目录生成MiniC.java、MiniCTokenManager.java、MiniCConstants.java和ParseException.java。编译运行:
javac MiniC.java java MiniC如果看到“解析通过”,说明词法和语法骨架已经跑通。注意LOOKAHEAD=2是为了处理某些需要向前看两个 token 的规则,比如区分声明和赋值语句;如果你的文法简单,可以改回 1,减少生成代码体积。
这个骨架的核心逻辑是:SKIP跳过空白,TOKEN定义三类词法单元,PARSER_BEGIN/END里写的是你在 Java 代码里要用的解析器类名,它和文件名必须一致。每个产生式方法体里,双冒号后面是局部变量声明,花括号里是 Java 动作代码,匹配到<ID>时可以通过token.image拿到对应的字符串。后续构建 AST 时,这里不再是打印,而是new IDNode(token.image)放进节点列表。
3.2 处理表达式优先级:用层级展开而不是优先级声明
JavaCC 支持LOOKAHEAD但不支持像 Yacc 那样的优先级指令(新版有MODULE等,但课设别折腾)。表达式优先级要用分层产生式表达。类 C 语言的表达式至少分四层:赋值、逻辑或、等值、关系、加减、乘除、一元、基本表达式。下面这段是常见的加法与乘法处理方式:
void Expression() : {} { AdditiveExpression() } void AdditiveExpression() : {} { MultiplicativeExpression() ( "+" MultiplicativeExpression() | "-" MultiplicativeExpression() )* } void MultiplicativeExpression() : {} { UnaryExpression() ( "*" UnaryExpression() | "/" UnaryExpression() )* } void UnaryExpression() : {} { "-" UnaryExpression() | PrimaryExpression() } void PrimaryExpression() : {} { <NUM> | <ID> "(" ArgumentList() ")" | "(" Expression() ")" }这里有个新手最容易翻车的点:( ... )*循环里如果同时包含加号和减号,JavaCC 生成的选择子会按第一个 token 决定走哪个分支。如果写成( "+" | "-" ) MultiplicativeExpression()也有效,但如果某个操作符是++或--,必须把两个字符形态的词法单元放在单独的TOKEN规则里,否则会不断消耗 token 导致死循环。另一个坑是表达式递归:UnaryExpression里的"-" UnaryExpression()是右递归,JavaCC 默认支持,但如果写成UnaryExpression() "-"就变成左递归,直接编译报错。遇到左递归的解决办法是把左递归改成右递归加循环,上面代码已经做到了。
参数说明:LOOKAHEAD越大,JavaCC 生成的选择函数越复杂,解析速度会变慢但歧义更少。对于类 C 语言,大部分回溯发生在Declaration和ExpressionStatement之间,建议对这两个地方单独加LOOKAHEAD(2)局部前瞻,而不是全局开 3。写法是在方法前加LOOKAHEAD(2)关键字,例如:
void Statement() : {} { LOOKAHEAD(2) Declaration() | ExpressionStatement() }这类局部前瞻能显著减少ParseException误报,也是我在课设里花时间最多的调整点。
4. 语义分析与符号表:类型检查、作用域与错误报告
4.1 用 Visitor 模式遍历 AST 而不是边解析边检查
语法分析生成 AST 后,类型检查最好和语法分析解耦。做法是给每个 AST 节点类实现一个accept(Visitor v)方法,然后写一个SemanticVisitor负责填充符号表和做类型检查。这样可以单独测试,也方便后续代码生成复用同一个遍历逻辑。下面是一个简化版:
public interface Node { void accept(Visitor v); } public class VarDeclNode implements Node { public String type; public String name; public ExprNode init; public void accept(Visitor v) { v.visit(this); } } public class SymbolTable { private Map<String, Symbol> symbols = new HashMap<>(); private SymbolTable parent; public Symbol lookup(String name) { Symbol s = symbols.get(name); if (s != null) return s; return parent != null ? parent.lookup(name) : null; } public void define(String name, Type type) { if (symbols.containsKey(name)) { throw new SemanticException("变量 " + name + " 重复定义"); } symbols.put(name, new Symbol(name, type)); } } public class SemanticVisitor implements Visitor { private SymbolTable currentScope; public void visit(VarDeclNode node) { Type t = resolveType(node.type); if (t == null) { throw new SemanticException("未知类型 " + node.type + " 在行 " + node.line); } currentScope.define(node.name, t); if (node.init != null) { node.init.accept(this); } } private Type resolveType(String typeName) { return "int".equals(typeName) ? new IntType() : null; } }符号表的关键设计是作用域链:每个函数体对应一个子符号表,parent指向全局符号表。类 C 语言里没有块级作用域(C89 标准),但变量可以跨函数访问全局符号。查变量时先查当前作用域,找不到就递归向上找。这里最容易踩的坑是:符号表里存的是类型和地址,而不是值;如果你把值也塞进去,后面代码生成会搞混引用和赋值。
类型检查的常见规则包括:
- 赋值语句左边必须是左值(变量或通过指针解引用),不能是常量或算术表达式。
- 检查二元运算符两边的类型,
+要求两个 int,==要求两边类型一致。 - 函数调用时实参个数与形参个数必须一致,实参类型必须兼容。
- 返回值类型必须匹配函数声明的返回类型。
每个SemanticException都要携带行号和列号,这在考察里是加分项。JavaCC 生成的 Token 对象自带beginLine和beginColumn,在构建 AST 节点时保存一份即可。
4.2 错误收集策略:别在第一个错误就崩溃
很多课设程序一旦遇到错误就抛异常退出,导致用户每次只能修一个错。更好的做法是收集错误列表,解析结束后一次性打印。做法是给SemanticVisitor增加一个List<String> errors,把throw改成errors.add("行 " + node.line + ": " + message)。但要小心:如果发现了一个类型错误就继续遍历,可能会出现空指针,因为节点信息可能不完整。我的习惯是分两轮:第一轮只填符号表不做全面检查,第二轮再对所有表达式做类型检查。这样可以避免因为重复/缺失符号导致后续连锁报错。
常见错误描述要带上上下文,比如“第 5 行:变量 c 未声明”。不要只写“semantic error”。因为课设评测通常会人工查看输出信息,信息友好度直接决定分数上限。
4.3 类型系统的最小实现:int、char、float 与隐式转换
类 C 编译器不只要支持 int。最少要支持 int、char、float,以及int -> float的隐式转换。类型可以定义成一个抽象类,用 Singleton 模式避免重复创建对象:
public abstract class Type { public abstract String getName(); public abstract int getSize(); } public class IntType extends Type { private static final IntType INSTANCE = new IntType(); public static IntType instance() { return INSTANCE; } public String getName() { return "int"; } public int getSize() { return 4; } }类型检查里最麻烦的是==和赋值里的隐式转换。比如float a = 1;合法,int b = 2.5;最好也允许,但要有警告(因为窄化转换)。我不会给出强制的禁止,而是加一个DoWideningCast的标记,代码生成时根据目标类型插入转换指令。如果你选择生成 C 代码,这一步就不需要太多处理,因为 C 语言本身会隐式转换。但如果你生成的是自定义虚拟机字节码,就必须为float->int生成(int)强制转换指令。
我常用的编译器前端调试法是写一个printAST()方法,输出缩进树结构:
VarDecl Type: int Name: a Init: AssignmentExpr Left: VarRef a Right: Num 3这个输出能让你在面试或答辩时一眼看出 AST 结构是否正确,也是排查错误的最好工具。
5. 代码生成与集成:从 AST 到可执行文件及常见避坑
5.1 两种可行的代码生成路径与选型
代码生成是课程设计里最开放的环节。常见做法有三种:
| 目标 | 难度 | 可验证性 | 课设适用度 |
|---|---|---|---|
| 输出 C 代码 | 低 | 用 gcc 编译运行 | 高 |
| 输出自定义虚拟机字节码 | 中 | 自己写解释器运行 | 高(加分) |
| 输出 x86 汇编 | 高 | 用 nasm/gcc 链接 | 中(容易卡壳) |
我在课设里选择的是“输出 C 代码”。理由是:类 C 到 C 的映射几乎是逐句翻译,int a; a = 3;直接输出int a; a = 3;,然后调用外部 gcc 编译成可执行文件。代码生成器的核心就是遍历 AST,按节点类型拼接字符串。下面是最小实现:
public class CGenerator implements Visitor { private StringBuilder sb = new StringBuilder(); public void visit(CompilationUnit node) { for (Node child : node.decls) { child.accept(this); sb.append(";\n"); } } public void visit(VarDeclNode node) { sb.append(node.type).append(" ").append(node.name); if (node.init != null) { sb.append(" = "); node.init.accept(this); } } public void visit(AssignNode node) { node.left.accept(this); sb.append(" = "); node.right.accept(this); } public void visit(NumNode node) { sb.append(node.value); } public String getCode() { // 包一层 main 函数 return "#include <stdio.h>\nint main() {\n" + sb.toString() + "\nreturn 0;\n}\n"; } }这个生成器看似简单,但有几个必须处理的细节:
- 变量声明必须放在函数内,不能全塞进全局,否则 C 里会出现
global initializer is not constant问题。 - 输出缩进可以不做,但换行必须有。
- 表达式里的括号要打全。因为 AST 层次结构包含了运算优先级,输出时要为所有二元运算加括号,比如
a + b * c要输出成(a + (b * c))。如何加括号?遍历节点时根据节点类型判断,或者在每个二元运算节点输出时用(+ 左 + 运算符 + 右 +)。后者实现简单但会多很多括号,gcc 能接受,可读性略差,但课设没要求美观。
5.2 常见踩坑:现象、原因与解决办法
现象 1:生成的 C 代码用 gcc 编译报
'a' undeclared,但输入源文件明明声明了变量。 原因:代码生成时没有区分声明和赋值语句,把声明丢掉或者放到了表达式后。 解决:在 SymbolTable 里记录变量出现的顺序,代码生成时把声明统一放到函数开头,赋值保留原位。现象 2:解析
if语句时,else总是匹配到最近的if,导致逻辑错误。 原因:JavaCC 生成的解析器默认采用贪婪匹配,else会直接归入前一个if。但类 C 的悬挂 else 本来就该就近匹配,所以这不是 bug;真正的问题是文法写成了if (e) stmt else stmt而不是用LOOKAHEAD(2)隔离嵌套 if。 解决:实现时把 if 产生式写成"if" "(" Expression() ")" Statement() [ "else" Statement() ],中间的 Statement 能吃掉else前的所有内容,匹配自然正确。现象 3:类型检查时报告“变量未声明”,但源文件里明明已经
int a;了。 原因:符号表作用域管理错了。比如在函数体里int a;是局部变量,但你遍历 AST 时直接把根节点 CompilationUnit 的作用域当成当前作用域,导致局部变量没被填入。 解决:每进入一个函数体,创建新的 SymbolTable,把 parent 指向全局表。遍历完函数体后,再把当前作用域切回父作用域。最简单的办法是在visit(FunctionDefNode)里pushScope(),在离开前popScope()。现象 4:代码生成后运行结果不对,比如
3 / 2得到 1,而不是 1.5。 原因:这是整型除法的正常语义,不是编译器 bug。但如果你同时支持 float 类型,需要在类型检查时记录表达式结果的类型。3 / 2是 int 除法,3.0 / 2是 float 除法。你的 AST 节点里必须存有resultType字段,代码生成时根据它决定输出3 / 2还是((float)3) / 2。 解决:给每个 ExprNode 增加Type exprType,在类型检查阶段填充。这样代码生成器不需要重新分析类型。现象 5:JavaCC 生成的代码在反复调用
ReInit时出现 token 错乱。 原因:STATIC = false没有开启,默认静态成员导致多个实例共享全局状态。在options里加上STATIC = false;,然后每次创建新 Parser 之前调用parser.ReInit(reader)。 解决:不要在 main 里 new 多个 Parser,而是只 new 一个,每次解析新输入时用ReInit(new java.io.StringReader(input))。
5.3 集成调用流程:从源码到输出可执行文件
在 Java 主程序里串起整个流程,可以做成一个可复用的Compiler类:
public class Compiler { public static void main(String[] args) throws Exception { if (args.length != 1) { System.err.println("用法: java Compiler 源文件.c"); System.exit(1); } String source = new String(java.nio.file.Files.readAllBytes( java.nio.file.Paths.get(args[0])), "UTF-8"); // 1. 词法/语法 MiniC parser = new MiniC(new java.io.StringReader(source)); CompilationUnit ast = parser.CompilationUnit(); // 2. 语义 SemanticVisitor sem = new SemanticVisitor(); ast.accept(sem); if (!sem.getErrors().isEmpty()) { for (String e : sem.getErrors()) System.err.println(e); System.exit(2); } // 3. 代码生成 CGenerator gen = new CGenerator(); ast.accept(gen); String cCode = gen.getCode(); System.out.println(cCode); // 或写入文件 // 4. 调用 gcc Process p = new ProcessBuilder("gcc", "-x", "c", "-o", "out", "-") .start(); // 把 cCode 写入 process 的标准输入 } }这个流程把四个阶段串成一条流水线。第 1 步的解析可能抛出ParseException,需要在外面捕获并打印错误行号。第 2 步的错误收集不是抛出异常,所以要用getErrors()判断。第 3 步生成的是字符串,可以直接输出,也可以保存成临时文件。第 4 步调用 gcc 时如果系统没有装 gcc 会抛IOException,课设环境里一般没问题。如果你的最终目标不是生成 C 而是自定义字节码,第 4 步就要写成自己的解释器加载字节码文件。
6. 把课程设计做成答辩能讲透的作品:验证方法与三个进阶技巧
有了能跑的编译器,接下来要做的不是加更多语法特性,而是把验证方法和“编译器思维”沉淀成答辩话术。验证方法我建议三件套:
第一,写一个覆盖了变量声明、算术运算、函数调用、if/else、while、数组访问的样例程序,运行后对比实际输出和预期输出。第二,写一个包含至少五种错误(未声明变量、类型不匹配、缺少分号、括号不匹配、函数参数个数错误)的负面测试,每行一个错误,看你报错信息和行号是否准确。第三,做一次性能测试:生成一个 1000 行的源码文件,统计从解析到生成代码的时间,JavaCC 的 LL 解析器通常在几十毫秒内完成,这个数据可以作为“已优化”的证据。
三个进阶技巧值得花时间:
第一个技巧是给 AST 节点增加line和column字段并在语义分析中使用。这会让你的错误提示从“unknown variable”变成“第 4 行:未声明的变量 x”,直接拉开与其他课设的差距。第二个技巧是增加一个--dump-ast命令行参数,输出缩进树结构。这不仅帮你调试,也是答辩现场展示“我们真的处理了语法树”的最佳道具。第三个技巧是类型检查里加入隐式转换与常量折叠:比如int a = 1 + 2 + 3;在语义分析阶段直接求值成6,代码生成时输出int a = 6;。这虽然简单,但体现了编译优化意识,面试官问到“你做过优化吗”时你有东西可讲。
我会在自己的课设报告里放一张流程图,说明 JavaCC 承担的是哪部分工作,AST 是由我们自己用 Java 类构建的,语义分析也是手写的。诚实地说,用 JavaCC 会让课设看起来比“手写词法”更容易,但实际工作量集中在语义分析、符号表和代码生成这三个环节,这才是体现设计能力的地方。
最后说一个我的血泪教训:不要试图支持 C 语言的全集。课设要求是“类C编译器”,不是“C标准编译器”。我最开始做了struct、指针、多维数组,结果语法文件越来越复杂,局部前瞻调了半天,最后放弃了。后来把范围缩到 int/char/float、数组、函数、if/while,整个项目三天就稳了。先做出一个功能少但正确的小编译器,再按需加特性,这才是课程设计的正确打开方式。希望帮到你。
本文还有配套的精品资源,点击获取