简介面向计算机专业学生及编译原理课程设计者的完整资料包来自重庆理工大学基于Java与JavaCC实现一个类C语言编译器。资源覆盖从文法设计、词法分析、递归下降语法分析到结果输出与验证的完整流程并额外使用Python编写LL1算法通过教材实例验证准确无误。压缩包共380个文件约3.02MB主要包含源码、语法定义文件、编译生成类文件、测试样例、运行输出结果及课程设计报告所需脚本目录清晰便于按模块查阅与二次开发。资源内置自动化脚本可一键执行词法分析、语法分析以及Basic、Mixed多组结果测试并自动整理输出到指定路径同时提供可视化栈演示帮助理解函数调用时内存空间变化。目前已有857人学习下载适合正在完成编译原理课程设计、需要完整可运行参考方案的同学。1. 拿到“重庆理工大学编译原理课程设计 java javacc 类C编译器”这个题目先别急着抄代码这个标题看起来是一行课程设计题目实际上是一整套完整的编译原理实验要求用 Java 语言、借助 JavaCC 工具实现一个类 C 语言的编译器。第一次接触的人最容易踩的坑是把重点放在“复现某个开源编译器”上而忽略了这门课真正要考察的东西——词法分析、语法分析、语义分析和代码生成每一步都要能在你提交的源码里被看到、被运行、被验证。这个项目能解决的核心问题很明确把编译原理教材里的理论落到一个能跑的最小编译器上。它适合正在做编译原理课程设计的学生也适合想快速体验 JavaCC 工作流的 Java 开发者。下面这套做法是我把类 C 子集编译器从零搭起来的完整路径包含 .jj 文法的设计、符号表与四元式的实现、以及我在调试过程中翻车最多的地方。2. 先拆需求再动手类C编译器要交什么、跑什么、怎么被评判2.1 把课程设计拆成四个能独立验收的模块类 C 编译器不是非要支持完整的 C 语言课设题目里“类C”三个字给了你很大的裁剪空间。我一般会先划出四层每一层都有独立的交付物这样既好分工也方便老师验收模块实现方式交付物词法分析JavaCC 的 TOKEN / SKIP 规则token 种类定义语法分析JavaCC 的 production产生式文法文件.jj语义分析手写符号表 类型检查Symbol 类、作用域栈代码生成手写遍历 AST 生成四元式 / 目标指令可执行的输出结果最容易被忽略的是第一模块和第三模块之间的接口。JavaCC 默认只做词法 语法分析它不会自动构建 AST也不会帮你维护符号表。所以你的 .jj 文件里每个 production 都要显式返回一个 AST 节点或者直接在一遍扫描时顺带做语义动作。课程设计阶段我建议前者——先建 AST再单独遍历生成代码逻辑更清楚调试时也好定位问题。2.2 准备环境javacc.jar 和一条能跑通的命令环境准备只需要 JDK 和 javacc.jar 两样东西。JDK 配置属于标配确保java和javac命令在终端里可用——这里容易卡住的就是环境变量配置问题JAVA_HOME 指到 JDK 安装目录、PATH 里加上%JAVA_HOME%\binWindows 和 Linux 都一样。javacc.jar 从官网或 Maven 仓库拿不需要安装它就是一个可执行的 jar。下面是我常用的目录结构# 项目目录规划 mkdir -p compiler/src/main/javacc # 放 .jj 源文件 mkdir -p compiler/src/main/java/cc # 放生成的 Java 代码 mkdir -p compiler/src/main/java/ast # 放手写的 AST 节点 mkdir -p compiler/test/cases # 放测试用的 .c 文件把 javacc.jar 放进项目根目录后用命令行生成解析器# 从 .jj 生成 Java 解析器代码 java -cp javacc.jar javacc \ -OUTPUT_DIRECTORY:src/main/java/cc \ src/main/javacc/C.jj这里有两个参数需要说明。-OUTPUT_DIRECTORY指定生成的 Java 文件输出路径目录必须存在否则命令直接失败.jj文件路径是最后一个参数javacc 会读取它并生成C.java解析器、CTokenManager.java词法分析器、Token.java等文件。生成的这些文件不要手工改因为只要你重新执行 javacc它们就会被覆盖——.jj才是唯一的源码这是我第一次做课设时才明白的“后悔药”逻辑。2.3 跑通第一个最小 .jj 文件先别急着写完整文法用一个只识别“一个标识符后跟文件结束符”的最小示例验证工具链是通的。.jj 文件的结构分为三段PARSER_BEGIN/PARSER_END 包裹的类体、TOKEN 定义、以及语法产生式。// src/main/javacc/C.jj options { STATIC false; // 生成非静态解析器便于多次实例化 } PARSER_BEGIN(C) package cc; import java.io.*; public class C { public static void main(String[] args) throws ParseException { C parser new C(new InputStreamReader(System.in)); parser.program(); System.out.println(parse ok); } } PARSER_END(C) // 跳过空白与换行 SKIP : { | \t | \n | \r } // 标识符字母开头后跟字母或数字 TOKEN : { ID: ([A-Z,a-z]) ([A-Z,a-z,0-9])* } // 语法入口产生式 void program() : {} { ID EOF }这个文件的逻辑很直白SKIP声明哪些字符序列在词法分析时直接丢弃TOKEN声明标识符的正则规则program()是一个产生式要求输入流是“一个标识符后紧跟文件结束符”。生成后用 javac 编译全部 Java 文件再运行java cc.C输入hello就会看到parse ok输入hello world就会抛出 ParseException。选项里STATIC false值得专门说一句。JavaCC 默认生成静态解析器所有方法都是 static这在单次调用时没问题但如果你想在一个测试类里反复创建解析器解析多个文件静态成员会互相污染状态。课程设计阶段建议一律设成 false代价只是生成的代码里有几个实例方法而已。3. 用 JavaCC 写类C子集的词法与文法一份能运行的 .jj 长什么样3.1 token 设计关键字、标识符和字面量怎么不打架类 C 语言的关键字一般选int、char、if、else、while、for、return、void这八个就够。很多人写 .jj 时会把关键字像标识符一样定义成TOKEN结果发现输入if时被识别成ID。这是因为 JavaCC 的词法规则有一个隐性优先级匹配长度相同的 token 时先定义的规则优先。TOKEN : { INT: int | CHAR: char | VOID: void | IF: if | ELSE: else | WHILE: while | FOR: for | RETURN:return } TOKEN : { ID: ([A-Z,a-z]) ([A-Z,a-z,0-9,_])* } TOKEN : { NUM: ([0-9]) | CHAR_LIT: (~[,\\,\n,\r]) }关键字必须声明在ID之前这是 JavaCC 使用者的血泪经验。否则int这个输入会被ID规则完整匹配而INT规则根本轮不到。把关键字放在前面之后JavaCC 遇到int时会优先返回INTtoken。数字和字符字面量同样放在后面。CHAR_LIT的正则里~[,\\,\n,\r]表示“除单引号、反斜杠、换行、回车之外的任意字符”注意反斜杠必须写成\\因为 .jj 文件本身也是 Java 字符串的转义规则。如果后面要支持转义字符如\n这个正则还需要扩展。3.2 表达式文法用层级产生式解决优先级与结合性表达式是语法分析里最容易写崩的地方。C 语言的运算符优先级有十几层课设不必全做实现 - * / %、比较运算、逻辑与和逻辑或就够。JavaCC 不像 yacc 有%left声明优先级它只能靠产生式的嵌套来体现优先级。// 最低优先级逻辑或 void expression() : {} { logical_or_expression() } void logical_or_expression() : {} { logical_and_expression() ( || logical_and_expression() )* } void logical_and_expression() : {} { equality_expression() ( equality_expression() )* } void equality_expression() : {} { relational_expression() ( ( | !) relational_expression() )* } void relational_expression() : {} { additive_expression() ( ( | | | ) additive_expression() )* } // 加减 void additive_expression() : {} { multiplicative_expression() ( ( | -) multiplicative_expression() )* } // 乘除模 void multiplicative_expression() : {} { unary_expression() ( (* | / | %) unary_expression() )* } void unary_expression() : {} { ( ! | - ) unary_expression() | primary_expression() } void primary_expression() : {} { NUM | CHAR_LIT | ID | ( expression() ) }这个写法有两点值得展开。第一优先级从低到高逐层包裹expression()在最外层越往下运算符优先级越高unary_expression()用递归处理单目运算符primary_expression()落到原子。第二左结合通过(op term)*这个循环实现——每遇到一个运算符就再解析一个右侧操作数生成的 AST 是左结合的。这里最忌讳的是照抄教科书里的左递归写法// 千万不要这样写 void additive_expression() : {} { additive_expression() multiplicative_expression() }JavaCC 生成的是递归下降解析器递归下降天然不支持左递归。上面这个产生式会让解析器在additive_expression()里无限调用自身运行起来直接抛 StackOverflowError。把左递归改写为循环是必须的这不是风格问题是能不能跑起来的问题。3.3 语句与函数支撑课设验收的最小语法集合语句的语法相对固定if、while、for和return是标配。书写时注意每个产生式都要能返回 AST 节点至少也要返回一个布尔值表示是否匹配成功。下面是语句产生式的核心片段void statement() : {} { ID expression() ; | IF ( expression() ) statement() ( ELSE statement() )? | WHILE ( expression() ) statement() | RETURN expression()? ; | block_statement() } void block_statement() : {} { { ( statement() )* } } void local_declaration() : {} { ( INT | CHAR ) ID ( expression() )? ; }这里有个细节容易忽略( ELSE statement() )?表示 else 分支是可选的这在 LL(1) 文法里是经典的“悬空 else”冲突点。JavaCC 遇到这种可选分支默认选择“匹配最近的 if”这个行为和 C 语言一致所以大多数情况下不用额外处理。但如果你在课设报告里宣称自己处理了悬空 else就要在文法里显式区分“内层语句不允许是裸 if”否则老师会拿嵌套 if 的测试用例来验证你的解析结果。函数定义和函数调用也是必须的否则没法写递归程序做验证。函数定义在入口产生式里void program() : {} { ( function_definition() )* EOF } void function_definition() : {} { ( INT | CHAR | VOID ) ID ( formal_parameters()? ) block_statement() } void formal_parameters() : {} { ( INT | CHAR ) ID ( , ( INT | CHAR ) ID )* } void function_call() : {} { ID ( actual_parameters()? ) }( function_definition() )* EOF让 program() 接受零个或多个函数定义。注意 formal_parameters 用了?和*处理可选参数和多个参数这些符号的含义和正则一致。写完这些你的 .jj 就已经能解析一个包含函数、循环、条件、局部变量的类 C 子集了——语法分析这一关就算过了。4. 语义分析与代码生成从语法树到能跑的结果4.1 符号表变量去哪了、类型对不对语法分析只回答“输入合不合文法”不回答“变量x有没有声明”“x 1里的x是 int 还是 char”。这些是语义分析要解决的。符号表是最朴素的做法一个 HashMap 加一个作用域栈就能支撑课设需求。// ast/Symbol.java package ast; public class Symbol { public String name; // 变量名 public String type; // int / char public int offset; // 相对栈帧基址的偏移 public int size; // 占字节数int4, char1 public Symbol(String name, String type, int offset) { this.name name; this.type type; this.size type.equals(int) ? 4 : 1; this.offset offset; } }作用域用栈来维护进入一个{}块时压入一层新表退出时弹掉。查变量时从栈顶往栈底找这正好对应 C 语言的“内层屏蔽外层”规则。// ast/ScopeStack.java public class ScopeStack { private java.util.Stackjava.util.HashMapString, Symbol stack; public ScopeStack() { stack new java.util.Stack(); stack.push(new java.util.HashMap()); // 全局作用域 } public void enterScope() { stack.push(new java.util.HashMap()); } public void exitScope() { stack.pop(); } public void declare(String name, Symbol symbol) { if (stack.peek().containsKey(name)) { throw new RuntimeException(变量重复声明: name); } stack.peek().put(name, symbol); } public Symbol lookup(String name) { for (int i stack.size() - 1; i 0; i--) { Symbol symbol stack.get(i).get(name); if (symbol ! null) return symbol; } throw new RuntimeException(未声明的变量: name); } private int nextOffset 0; public int allocOffset() { int off nextOffset; nextOffset 4; return off; } }这个实现里值得说明的是allocOffset()每个新变量分配一个递增的偏移量代码生成时用它计算变量在栈帧里的位置。课设阶段不需要做寄存器分配栈式分配最简单、出错率最低。类型检查可以放在declare和生成表达式的代码里赋值语句要求左右两侧类型一致return的类型要和函数返回类型一致这些全部通过Symbol.type字符串比较完成。4.2 从 AST 到四元式中间代码的生成本质是拼接字符串如果不想在 .jj 里塞太多动作可以单独做一遍 AST 遍历来生成四元式。四元式用一条记录表示一个运算字段是op, arg1, arg2, result。最简单的实现就是四个字段的类输出时拼成字符串。// codegen/Quad.java package codegen; public class Quad { public String op; // 运算符如 ADD、SUB、JMP、LABEL public String arg1; // 操作数1 public String arg2; // 操作数2可空 public String result;// 结果 public Quad(String op, String arg1, String arg2, String result) { this.op op; this.arg1 arg1; this.arg2 arg2; this.result result; } Override public String toString() { return op (arg1 null ? _ : arg1) (arg2 null ? _ : arg2) (result null ? _ : result); } }表达式a b * 3翻译成四元式的过程是递归的先算b * 3得到临时变量t1再算a t1得到t2。翻译 if 语句时用回填——先记录跳转指令所在的编号等 then 分支翻译完再补上目标标签。// codegen/ExprTranslator.java 片段 public String translateExpr(ExprNode node) { if (node instanceof BinOpNode) { BinOpNode bin (BinOpNode) node; String left translateExpr(bin.left); String right translateExpr(bin.right); String tmp newTemp(); // 生成 t1, t2, t3... String op mapOp(bin.op); // - ADD quads.add(new Quad(op, left, right, tmp)); return tmp; } if (node instanceof NumNode) { return String.valueOf(((NumNode) node).value); } if (node instanceof VarNode) { return ((VarNode) node).name; } throw new RuntimeException(无法翻译的表达式节点); }newTemp()内部只做一个计数器递增每次返回t (counter)。这个翻译规则的核心思路是每个中间节点生成一个临时变量把子表达式的结果存入临时变量再输出一条四元式。等全部翻译完整个quads列表就是中间代码。表达式a b * 3会输出类似这样的序列MUL b 3 t1 ADD a t1 t2有了四元式后面无论是生成汇编还是虚拟机指令都只需要做机械的指令映射不需要再关心表达式的树结构。4.3 目标代码做 MIPS 还是做自定义虚拟机课程设计的最后一个环节是把四元式翻译成可执行的东西。两条路线二选一生成 MIPS 汇编交给 SPIM 模拟器运行或者设计一套极简的自定义虚拟机指令再写一个解释器执行。MIPS 路线更“正统”但要处理寄存器分配和栈帧约定工作量明显更大自定义虚拟机只需要设计十几条指令解释器两百行就能写完而且测试起来非常直观。对比项MIPS 汇编自定义虚拟机验收观感专业能跑 SPIM直观能看指令流工作量寄存器分配、栈帧、系统调用指令设计 while 循环解释器调试难度看汇编和寄存器看指令和栈内容即可课设建议时间充裕且想冲高分两周内能稳交付我通常推荐自定义虚拟机。指令集简单到 12 条就够LOAD_CONST、LOAD_VAR、STORE_VAR、ADD、SUB、MUL、DIV、JMP、JMP_IF_FALSE、CALL、RET、HALT。四元式到虚拟机的映射是理想中的一对一ADD a b t1翻译成ADD指令操作数换成变量的内存地址。解释器就一个while (true)循环读取指令、执行、更新 PC看到HALT就结束。// vm/VirtualMachine.java 片段 public void run() { while (true) { int op code[pc]; switch (op) { case ADD: { int a stack[stack[pc]]; int b stack[stack[pc]]; int d stack[pc]; stack[d] a b; break; } case HALT: return; // 其余指令照此扩展 } } }这段解释器代码的要点是栈上既存变量值也存变量地址。ADD指令的三个操作数都是地址解释器先从栈里按地址取出两个值相加后写回目标地址。这种“地址栈”设计比直接存值更容易映射四元式里的临时变量因为四元式的每个临时变量本质上就是一个“虚拟地址”。课程设计做到这一步整个编译器已经能输出并执行程序剩下的就是系统性测试和准备答辩演示。5. 课设避坑指南5 个让我翻车的细节5.1 左递归导致无限递归StackOverflowError 还是 javacc 卡死现象把表达式文法按照教科书“expr → expr term”的形式写进 .jj执行 javacc 时生成成功但运行解析器解析任意带表达式的输入程序瞬间抛出 StackOverflowError严重时连生成阶段都会报错。原因JavaCC 生成的是递归下降解析器每个产生式对应一个 Java 方法。左递归产生式会让expression()方法在入口处直接调用自身永远没有机会消费任何 token栈一层层积累直到溢出。解决所有二元运算都要改写成term (op term)*的形式用一个循环替代左递归。检查方法很简单看你的 .jj 里有没有一个产生式的方法名出现在自己右侧的第一个位置如果有就是左递归。我见过不少同学把additive_expression的左递归已经改对了却在写unary_expression时又写了unary_expression : unary_expression ...这类排查要逐个产生式过。5.2 JavaCC 报 choice conflict警告不处理运行时行为会“玄学”现象执行 javacc 生成代码时控制台打出类似Warning: Choice conflict in (...)的警告后面跟着两个产生式的名字。程序有时候能跑有时候解析同样的输入却报错看起来毫无规律。原因JavaCC 默认用 LL(1) 判断即只看一个 token 决定走哪个分支。如果两个分支的 FIRST 集合有交集JavaCC 就会提示 choice conflict并默认选择先声明的分支这个选择未必是你想要的。解决优先改写文法提取公共前缀让两个分支的第一个 token 不同改写不了时在冲突点前加显式 LOOKAHEAD例如( LOOKAHEAD(2) ELSE statement() )?告诉 JavaCC 向前看两个 token 决策。注意 LOOKAHEAD 是“报警后”的手段不是一开始就到处加的——加太多会拖慢解析速度而且掩盖文法本身的问题。课设报告里写明哪些地方用了 LOOKAHEAD、为什么用这反而是加分项。5.3 中文注释乱码.jj 和测试用例的编码必须统一现象类 C 源码里写了中文注释词法分析时要么报非法字符要么注释里的中文变成乱码后污染了后面的 token。原因问题出在两个地方。第一javacc 生成解析器代码时默认按平台编码读取 .jj 文件Windows 下可能是 GBKLinux 下可能是 UTF-8第二生成的解析器读取输入源文件时用的是FileReader这类默认编码的 Reader和你的源码编码不一致就会出现乱码。解决第一处在生成时显式指定编码命令行加-ENCODING:UTF-8第二处在构造解析器时自己包一层 Reader例如new InputStreamReader(new FileInputStream(file), UTF-8)。建议整个项目的 .jj、.java、测试 .c 文件全部统一为 UTF-8并且把编码参数写进生成脚本里不要依赖系统默认值——这是最不显眼但也最能拉开交付质量差距的细节。5.4 悬空 else 的归属JavaCC 默认行为和你以为的不一样现象输入if (a) if (b) x 1; else y 2;有的同学预期 else 匹配外层 if但运行自己的编译器后发现 else 匹配的是内层 if。原因这是编译原理里最经典的二义性。JavaCC 对可选分支的处理策略是“贪心匹配”( ELSE statement() )?这个可选结构在遇到 else 时倾向于消费它因此 else 默认归最近的 if。解决C 语言的真实行为就是“else 匹配最近未匹配的 if”所以如果你的目标是类 C保持 JavaCC 的默认行为反而是对的。真正要处理的是在文法层面对“内层语句”做限制内层 if 的 then 分支不允许出现一个裸的 if 语句作为单语句或者干脆在课设文档里写明“不支持这种嵌套写法”测试用例里避开即可。最怕的是两种都没做老师现场输入嵌套 if 程序你的编译器给出的结果和预期不符。我的做法是保留默认行为然后在测试用例里加一条嵌套 if 的案例输出里注明 else 绑定关系让验收方看到你是清楚这个语义的。5.5 提交物组织混乱只交生成的 Java 文件等于没交源码现象答辩时老师打开你的项目看到几十个生成的 Java 文件——C.java、CTokenManager.java、TokenMgrError.java——翻了半天找不到文法定义最后只能问“你的语法规则在哪”。原因JavaCC 的工作方式决定了生成代码不是人该去维护的东西真正的源文件只有.jj一个。但很多同学的提交方式就是把整个 src 目录打包让验收方在几百行生成的 token 管理代码里找设计意图这非常减分。解决按下面这个结构提交每个文件都有明确职责src/main/javacc/C.jj词法与语法规则的唯一来源修改编译行为只改这个文件src/main/java/ast/*.javaAST 节点类定义表达式、语句、函数等结构src/main/java/codegen/*.java符号表、四元式生成、代码生成src/main/java/vm/*.java虚拟机解释器src/main/java/cc/C.java入口 main 方法负责读文件、调解析器、输出结果test/cases/*.c测试用例每个用例配一个.out预期输出文件build.sh或run.bat一键生成、编译、运行脚本这样做还有一个好处老师如果要看你的文法设计只需要打开一个几百行的.jj文件而不是去翻生成代码。如果答辩要演示修改文法后的效果直接改.jj再跑脚本一分钟内能看到改动生效这个流畅度在演示时非常加分。6. 验证与调试技巧如何让别人信服你的编译器真的能跑验证一个编译器不能只跑一个1 2就结束。我建议准备三组测试用例第一组是正常程序包含变量声明、赋值、四则运算、if 和 while跑出正确结果第二组是边界输入比如嵌套 20 层的括号表达式、大整数加法、空函数体用来检验解析器会不会栈溢出、词法分析会不会丢字符第三组是非法输入故意写未声明变量、类型不匹配的赋值、缺少分号的语句预期行为是编译器打印清晰的报错信息而不是抛出一串 Java 异常栈。第三组最容易被忽略但也最能让验收方确认你的语义分析真的做了。调试解析过程时一个非常趁手的技巧是打开解析器的 tracing 开关。JavaCC 生成的解析器类自带enable_tracing()和disable_tracing()方法在 main 方法里调用后再解析会在标准输出上打印每一个被匹配的 token 和进入的产生式方法名。比如输入if (a) b 1;trace 会显示出statement进入、匹配IF、匹配(、匹配ID a……这时如果出现某个产生式被不预期地跳过了你能直接定位到文法冲突。如果嫌修改代码麻烦也可以在生成时给 javacc 加-DEBUG_PARSER:true参数生成的代码默认就带 trace 输出。进阶方面我给一个能写进课设报告里的亮点常量折叠。在四元式生成阶段每生成一条二元运算四元式之前先检查两个操作数是否都是数字字面量如果是就直接算出结果不再生成这条四元式。比如a 2 3 * 4;直接翻译成a 14的一轮赋值既减少了中间代码量又能展示你对优化的理解。实现时只需要在translateExpr里加一个字符串转数字的判断整个改动不超过三十行但答辩时可以展开讲“这是编译原理里数据流分析的雏形”。最后说一个我的习惯所有中间代码和虚拟机输出我都保留了“可观测”的打印入口四元式列表、指令流、栈变化都能单独开关。这让我在调错时从黑匣子变成了白盒子也让答辩现场随时能把一个输入到输出的完整过程展示出来。希望这套从 .jj 到虚拟机、从测试用例到答辩演示的路径能帮你在做这个课设时少走几步弯路。本文还有配套的精品资源点击获取