简介:重庆理工大学编译原理课程设计的完整项目,基于Java语言与JavaCC工具构建类C编译器,覆盖文法设计、词法分析、语法分析、自动测试与结果验证等核心环节,适合正在完成编译原理课程设计的学生,也适合需要参考完整编译器实现思路的开发者。整套资料共380个文件,以程序源文件、编译后的字节码文件、输出结果文件、样例测试文件及文本说明文档为主,另含语法描述文件、自动化执行脚本,总体积约3.02MB,目录划分清晰便于查看。目前已有857人学习该资源,项目自带脚本可一键输出词法分析、语法分析以及Basic和Mixed结果,并能自动归档编译产物,还实现了基于栈的函数调用内存空间变化可视化。读者可以获得一套可运行的类C编译器工程、LL1算法验证示例、自动化测试流程设计思路及课程设计报告参考范本,帮助系统掌握编译器从形式语言到实际运行的完整链路。
1. 重庆理工大学编译原理课程设计的这道“类C编译器”:先弄清它要交什么
重庆理工大学的编译原理课程设计里,有一道典型的题:用 Java 和 JavaCC 写一个类C编译器。每年课设周总有人拿到题目后第一反应是找人要现成代码,我的建议是别急着下载——这道题看着是“词法→语法→语义→代码生成”四件套,真正花时间的却是 JavaCC 的 Lookahead 冲突、JDK 版本、中文编码和符号表设计,代码量不大但坑很多。
它适合两类人:正在做课设、需要跑通“类C代码输入→词法/语法输出→三地址码”的学生,以及想用 JavaCC 把小型编译器开发周期压到一周以内的开发者。交付物不是完整C编译器,而是能处理整型变量、表达式、if/while/for、函数调用和类型检查的教学编译器,评分点集中在设计过程完整、边界自洽、错误能定位。
做到什么程度算完成?我一般定义为:能编译一个带声明、赋值、分支、循环、函数调用的 .c 文件;能输出带行列号的错误信息;能打印语法树或中间代码。守住这三条,课设就不会低分,也足够支撑你理解后续优化课里的多数概念。
2. 为什么用 JavaCC,以及最小可运行的词法分析器长什么样
2.1 解析器生成器与手写递归下降的边界
先回答一个你一定会在课设报告里被问到的问题:为什么选 JavaCC?常见做法是把手写词法分析器加手写递归下降解析器作为对比方案,写在“方案选型”里。手写方案的好处是每一步都透明,适合只有 20 个 token、10 条文法的题目;可一旦类C文法写到 40 条以上,手写递归下降里每层函数的错误定位、回溯和 token 缓冲区管理就容易失控,你会在改一处优先级时不小心弄坏另一处。
JavaCC 把词法规则和语法规则写在同一个 .jj 文件里,用类似 BNF 的格式描述文法,由工具生成 Java 词法分析器和自顶向下解析器。它自带 token 管理、错误定位和有限的向前扫描能力,生成的类是纯 Java,可以被主函数直接调用。对课程设计而言,这能让你把精力放在语义分析和中间代码生成上,而不是反复调字符串匹配。
还有一层现实原因:课设答辩时,老师大概率会问“Lookahead 冲突怎么解决”“左递归为什么不行”,这些问题在 JavaCC 里都有明确的报错信息,比手写递归下降的“莫名错位”更好解释。选型报告里写清楚这条对比,比堆一堆论文摘要更让人信服。
2.2 最小可用词法文件:跳过空白、识别标识符和数字
我一般先不直接上类C全文法,而是先写一个极小的 .jj 文件,验证“JavaCC 能生成、能跑通”这条链路。环境上只需要 JDK 8 或 11,加上 JavaCC 7.0.x 的 jar。下面是开胃文件:
options { STATIC = false; UNICODE_INPUT = true; } PARSER_BEGIN(CParserStart) import java.io.*; public class CParserStart { public static void main(String[] args) throws ParseException { CParserStart parser = new CParserStart(new StringReader("int a = 123;")); parser.start(); System.out.println("词法链路正常"); } } PARSER_END(CParserStart) SKIP: { " " | "\t" | "\n" | "\r" } TOKEN: { < INT: (["0"-"9"])+ > } TOKEN: { < ID: (["a"-"z","A"-"Z"]) (["a"-"z","A"-"Z","0"-"9"])* > } void start() : {} { ( <INT> | <ID> )* <EOF> }这段代码里有三件最容易忽略的事。options 里的 STATIC = false 让生成的解析器以实例方式工作,避免静态方法之间的状态互相污染;UNICODE_INPUT = true 是给中文注释和字符串字面量留后路,不开的话后续的课设大概率会在中文注释上翻车。PARSER_BEGIN 和 PARSER_END 之间是原样写进生成类的 Java 代码,所以 main 方法放在这里。
SKIP 定义要丢弃的字符,这里只处理了空白;TOKEN 里的 INT 用正则定义为“一个或多个数字”,ID 定义为“字母开头、字母数字随后”。start() 是最简单的语法规则,接受 INT 或 ID 的任意序列直到文件结束。注意 JavaCC 的正则里["0"-"9"]表示字符区间,语法上跟标准正则略有出入,初看容易把+写错位置。
2.3 编译产物与 IDEA 集成:javacc 命令背后发生了什么
在命令行里跑通一次非常重要,它能让你后续在 IDEA 里配 JavaCC 插件时,清楚背后到底发生了什么。推荐的法子是直接用 javacc 命令,它接受 .jj 文件作为输入:
javacc CParserStart.jj javac CParserStart.java java CParserStart三条命令分别对应生成、编译、运行。javacc 执行后,目录下会增加 CParserStart.java、CParserStartTokenManager.java、Token.java、ParseException.java 等一组文件;javac 编译时要注意,如果 JDK 版本过新,JavaCC 7.0.13 生成的代码可能在某些环境下报告 IllegalAccessError,这种情况换 JDK 11 或 8 执行 javacc 步骤就能避开。java 运行后输出“词法链路正常”就说明链路已通。
在 IDEA 里做课设时,很多人习惯装 JavaCC 插件,但我更建议命令行先跑通,再把生成的 .java 直接拖进工程。原因很简单:IDE 插件本质上也是调用 javacc,出了问题反而不容易看到是哪个环节失败。你只要记得 JavaCC 生成的是源码而不是字节码,后面任何“编译时”的报错都能按普通 Java 代码去排查。
2.4 把源文件读进来:从 StringReader 换成文件读取
StringReader 只适合验证链路,真正做课设时要读 .c 文件。这里有一个常见的坑:直接new FileReader(path)会按平台默认编码读文件,Windows 上通常是 GBK,而你的 .jj 和 .c 文件可能是 UTF-8,最后解析出来全是乱码。我一般用 InputStreamReader 显式指定 UTF-8:
public static void main(String[] args) throws Exception { if (args.length < 1) { System.err.println("用法: java CParserStart <源文件>"); return; } Reader r = new InputStreamReader( new FileInputStream(args[0]), StandardCharsets.UTF_8); CParserStart parser = new CParserStart(r); parser.start(); System.out.println("词法链路正常"); }这里 FileInputStream 负责按字节读文件,InputStreamReader 负责把字节按 UTF-8 解码成字符,缺一不可。构造 CParserStart 时传入 Reader,JavaCC 内部用这个 Reader 作为 TokenManager 的数据源。改完这一版,你的词法分析器就可以从任意 UTF-8 编码的 .c 文件读内容了,也顺带把中文注释的隐患压到最低。
到这一步,你已经有一个能工作的词法分析器了。后面的类C文法,就是在 start() 基础上把规则换成真正的声明、表达式和语句,并把每个规则里捕获到的 token 保存到 AST 节点。
3. 用 JavaCC 的 BNF 文法搭出类C语法:表达式优先级、语句与函数
3.1 从左递归改写成迭代:JavaCC 不认左递归
写过手写递归下降的人,可能习惯把四则运算写成expr -> expr + term | term。这种左递归文法在数学上简洁,但 JavaCC 生成的解析器是自顶向下递归下降,遇到expr规则会无限调用自己,运行起来直接 StackOverflowError。所以进入语法设计的第一步,是学会把左递归改写成迭代形式:
void expr() : {} { term() ( "+" term() )* } void term() : {} { factor() ( "*" factor() )* } void factor() : {} { <INT> | <ID> | "(" expr() ")" }这个写法的核心是用“循环”替代“递归”:term() ( "+" term() )*表示先解析一个 term,只要后面紧跟加号,就继续解析下一个 term。它等价于左递归文法能描述的语言,但不会造成无限递归。优先级靠嵌套层次体现:层级越低、越晚解析,优先级越高,所以 factor 放在最里层,加减放最外层。
改完左递归后,最好立刻用1+2*3和(1+2)*3两组输入验证。前者应解析成1+(2*3),后者应解析成(1+2)*3,如果结果反过来,说明你的层级写反了。
3.2 完整表达式链:从 primary 到 assignment 的五个层次
类C表达式比四则运算多出赋值、比较、逻辑、函数调用和数组下标。我一般这样组织优先级:括号和常数最内层,下来是一元运算,再往上是乘除、加减、比较、赋值。下面是课设里够用的一个表达式链,注意我用 ASTNode 作为返回值:
void assignment() : { Token id; ASTNode e; } { ( LOOKAHEAD(2) id=<ID> "=" ) e=additive() { return new AssignNode(id.image, e); } } ASTNode additive() : { ASTNode l, r; } { l=multiplicative() ( "+" r=multiplicative() { l = new BinOpNode("+", l, r); } )* { return l; } } ASTNode multiplicative() : { ASTNode l, r; } { l=unary() ( "*" r=unary() { l = new BinOpNode("*", l, r); } )* { return l; } } ASTNode unary() : { ASTNode e; } { "!" e=unary() { return new UnaryNode("!", e); } | "-" e=unary() { return new UnaryNode("-", e); } | e=postfix() { return e; } } ASTNode postfix() : { ASTNode e; } { e=primary() ( "(" args() ")" | "[" additive() "]" )* { return e; } } ASTNode primary() : {} { <INT> | <ID> | "(" additive() ")" }注意assignment()开头的LOOKAHEAD(2),这是为了区分“赋值语句”和“以变量开头的表达式语句”。当输入是a = 10;时,解析器需要向前看两个 token(a和=)才能确定这是赋值而不是表达式a。LOOKAHEAD 是 JavaCC 少数能直接改变解析行为的选项,后面避坑章节会专门展开。
每个规则都有一对花括号,第一对是局部变量声明,第二对在规则结束后执行,用来构造 AST 节点。要注意( "+" r=multiplicative() { ... } )*这个写法里,循环体内的动作会在每次迭代后执行,靠l = new BinOpNode(...)把累加结果链起来,而不是简单地返回最后一个节点。
3.3 语句与控制流:if/while/for 的写法与 choice conflict
表达式搭好后,语句就比较机械了。类C控制流的语法规则可以这样写:
void statement() : {} { <IF> "(" expression() ")" statement() [ <ELSE> statement() ] | <WHILE> "(" expression() ")" statement() | <FOR> "(" [ expression() ] ";" [ expression() ] ";" [ expression() ] ")" statement() | <RETURN> [ expression() ] ";" | "{" ( declaration() | statement() )* "}" | expression() ";" }这段文法有两个地方如果不处理会直接触发 Choice Conflict。第一个是IF后面的[ <ELSE> statement() ],JavaCC 无法确定else属于最近的 if 还是外层 if,常见做法是在 options 里设LOOKAHEAD = 3,让解析器在遇到else时能并入最近的那个 if。第二个是 FOR 里三个可选表达式,如果两个表达式之间只有一个分号,解析器需要向前看更多 token 才能确定空与不空。
我习惯把 FOR 玩成“先写一个forInit()子规则再组合”的形式,因为可选表达式和分号混在一起,是最容易让 JavaCC 报 Choice Conflict 的地方。拆开后把( expression() )?放进单独规则,报错信息会清楚很多,也方便在语义分析阶段处理“FOR 缺了第二个分号”这种用户错误。
3.4 用 JJTree 自动构建 AST,还是自己写节点类
走到这里,你会面临一个选择:用 JavaCC 自带的 JJTree 自动生成 AST,还是自己手写节点类。JJTree 的卖点是省事,在 .jj 文件里标注#Node就能生成树结构,适合只想快速出效果、语义分析点到为止的同学。但我在课设里更推荐手写节点类,原因很实际:JJTree 生成的节点是通用的,你要在它基础上加类型字段、行号、变量绑定信息,改起来反而不如自己定义类灵活。
我自己常用的节点设计是抽象基类加几个具体类,每个节点都带行列号和类型字段:
abstract class ASTNode { int line; int col; String type; // 在语义分析阶段填充,比如 "int" } class IntNode extends ASTNode { int value; } class BinOpNode extends ASTNode { String op; ASTNode left; ASTNode right; } class AssignNode extends ASTNode { String name; ASTNode value; } class IfNode extends ASTNode { ASTNode cond; ASTNode thenPart; ASTNode elsePart; // 可能为 null } class VarNode extends ASTNode { String name; Symbol sym; // 语义分析时绑定到符号表条目 }有了这个骨架,语法规则里的动作只需把new IntNode(...)填进去,后面写类型检查和三地址码生成时,用instanceof判断节点类型,再按字段取值就行。这套结构不需要引入额外的泛型或访问者框架,课设答辩时解释起来也顺畅——老师问“你怎么组织中间表示”,你说“手写 AST,每个节点带行号和类型”,基本上就过关了。
4. 语义分析和代码生成:符号表、类型检查与三地址码
4.1 符号表设计:作用域链与声明去重
AST 构建完,下一步是语义分析。第一件事是建符号表。类C有块级作用域,所以符号表不能只用一个 HashMap,那样无法区分函数里和函数外的同名变量。我用一个作用域链栈,每个作用域保留自己的符号表,并指向父作用域:
class Scope { Map<String, Symbol> table = new HashMap<>(); Scope parent; Symbol lookup(String name) { Symbol s = table.get(name); if (s != null) return s; return parent != null ? parent.lookup(name) : null; } void put(Symbol s) { table.put(s.name, s); } } class Symbol { String name; String type; boolean initialized; int line; int col; }遍历 AST 时,遇到declaration()就新建一个 Scope 入栈,遇到底层变量声明就查当前作用域有没有同名符号:有则报“重复声明”,没有则put进去。函数参数也放进函数自己的作用域,这样函数体内对参数名赋值,不会污染外层同名变量。这里需要注意一个细节:lookup要沿父作用域向上找,但put只在当前作用域生效,否则全局变量会被局部声明意外遮蔽。
我一般会在声明结束后统一检查一次“变量是否被使用”,这个属于锦上添花。课设评分通常不看这个,但如果你在报告里写清楚“本设计支持作用域遮蔽”,那语义分析这部分的完成度一下就立起来了。
4.2 用 Visitor 遍历 AST:类型检查写在哪
类型检查的常见做法是写一个递归函数,对每个节点调用自身,再在节点上做约束判断。我习惯用visit命名,类C里布尔值暂用 int 表示,所以比较运算的结果类型也设为 int,避免引入多余的 bool 类型增加课设负担:
class TypeChecker { Scope current; void visit(AssignNode n) { visit(n.value); if (!"int".equals(n.value.type)) { error(n, "右值类型不是 int: " + n.value.type); } n.type = "int"; } void visit(BinOpNode n) { visit(n.left); visit(n.right); if (n.op.equals("+") || n.op.equals("-") || n.op.equals("*") || n.op.equals("/")) { if (!"int".equals(n.left.type) || !"int".equals(n.right.type)) { error(n, "运算两侧必须是 int"); } n.type = "int"; } else if (n.op.equals("<") || n.op.equals(">") || n.op.equals("==")) { n.type = "int"; // 布尔值以 int 表示 } } }这段代码的逻辑是“先检查子节点,再设置当前节点类型”。因为 BinOpNode 的左右子节点都可能是整棵表达式树,所以必须先 visit 子树,确保子树类型已经计算出来,再拿来做比较。注意比较运算只检查两侧类型一致,不需要检查具体值,这是类型系统的常见简化。
有一点要提醒:error 里一定要带上行列号,否则用户无从定位。我一般用Token里自带的beginLine和beginColumn,这两个字段是 JavaCC 生成的 Token 类自带的,不用自己维护。
4.3 三地址码生成:临时变量、跳转标签与表达式展开
语义分析通过后,就可以生成中间代码了。课程设计里最常见、也最好解释的三地址码格式是每条指令最多一个运算,形式如t1 = a + b。核心代码可以这样写:
class TACGen { List<String> code = new ArrayList<>(); int tmp = 0; int label = 0; String newTemp() { return "t" + (tmp++); } String newLabel() { return "L" + (label++); } void emit(String instr) { code.add(instr); } String visit(BinOpNode n) { String a = visit(n.left); String b = visit(n.right); String t = newTemp(); emit(t + " = " + a + " " + n.op + " " + b); return t; } void visit(IfNode n) { String cond = visit(n.cond); String elseLabel = newLabel(); String endLabel = newLabel(); emit("if " + cond + " == 0 goto " + elseLabel); visit(n.thenPart); emit("goto " + endLabel); emit(elseLabel + ":"); if (n.elsePart != null) { visit(n.elsePart); } emit(endLabel + ":"); } }binOp 的生成逻辑很直观:递归生成左右操作数,取到两个临时变量,再生成一条新指令把运算结果放进新临时变量。IfNode 的生成则依赖标签和跳转指令,语义是“条件为 0 时跳过 then 分支”。这套方案没有做复杂的回填,而是边遍历边发射,课设阶段完全够用。
需要留意的是变量访问:visit(VarNode)应当直接返回变量名,而不是生成临时变量,否则每个变量访问都会多一层无意义的拷贝。函数调用则先生成实参的三地址码,再用 CALL 指令带上函数名,返回值放进新临时变量。做到这里,你的编译器已经是一个能输出中间代码的完整教学编译器了。
4.4 错误处理:统一格式,让评分者一眼定位
很多同学把错误处理放在最后,这是本末倒置。课设验收时老师会故意输入非法代码,这时候错误信息的可读性直接决定印象分。我建议从第一天起就统一错误格式:
class CompileException extends RuntimeException { final int line; final int col; final String message; CompileException(Token t, String msg) { super(t.beginLine + ":" + t.beginColumn + ": " + msg); this.line = t.beginLine; this.col = t.beginColumn; this.message = msg; } }词法错误可以用 JavaCC 自带的TokenMgrError,它已经包含了行列号,包装一下把 message 提取出来即可。语法错误是ParseException,其currentToken也能拿到行列号。语义错误就是我们上面写的CompileException。三者统一输出成行:列: 错误信息的格式,后续写测试脚本时直接 grep “error” 就能判断编译是否失败。
5. 编译原理课程设计的 5 个避坑点:从 Lookahead 到 JDK 版本
5.1 Choice Conflict:不是文法错,是超前扫描不够
现象:javacc 编译 .jj 文件时打出Warning: Choice conflict,有时直接报错终止生成。
原因:JavaCC 默认是一个 token 的 lookahead,当两个可选项开头 token 相同时,它无法确定走哪条分支。最容易踩中的地方就是statement()里 if 语句和表达式语句都以字母开头,表达式语句又和赋值冲突。
解决:先别急着改文法结构,直接给冲突的非终结符加LOOKAHEAD(2)或者用( LOOKAHEAD(...) ... )包裹某个分支。如果加了还不生效,再考虑提取公共因子。我在课设里最后悔的就是一开始把 LOOKAHEAD 调到 5、6 看到冲突消失就收工,结果换一种输入又冲突,后来才发现要针对具体位置加才有用。
5.2 左递归翻车:StackOverflowError 与写法修正
现象:运行解析器处理简单表达式1+2,直接抛StackOverflowError。
原因:文法里写了expr : expr "+" term | term这种左递归,递归下降解析器在第一次展开 expr 时就把自己调死了。
解决:所有表达式文法都改成迭代形式。expr -> term ( "+" term )*才是 JavaCC 能处理的形态。另一个排查技巧是:StackOverflowError 出现时,先把输入缩小到最简单的 token,再用排除法看是哪个非终结符在无限自递归。
5.3 Windows 下 javacc 命令找不到:PATH 与 java -jar 两种解决
现象:在 cmd 或 PowerShell 里敲javacc,得到“不是内部或外部命令”。
原因:大多数人是第一次在 Windows 上配 JavaCC,下载完 jar 就把终端关了,或者根本没有配环境变量。
解决:如果你没有配环境变量的权限,最简单的方法是直接java -jar /path/to/javacc.jar CParserStart.jj,把 jar 的完整路径写全。如果想敲 javacc 命令,需要把 JavaCC 的 bin 目录加进 PATH,同时保证 JAVA_HOME 指向 JDK 而不是 JRE。排查顺序是:先java -version,再echo %PATH%,最后确认你下载的确实是完整版而不是某个缺字库的源码包。
5.4 JDK 版本过高:生成代码的反射权限问题
现象:javac 编译 JavaCC 生成的 Java 文件时报IllegalAccessError或Unable to make field accessible。
原因:Java 16 开始对强封装做了限制,JavaCC 7.0.13 的运行时用反射访问 Token 类内部字段时被拦下。
解决:这一步发生在“用 javacc 生成”和“用 javac 编译”之间。如果你用的是 JDK 17,建议换 JDK 8 或 11 执行 javacc 命令,生成代码后再回到项目里编译;也可以升级到支持新 JDK 的 JavaCC 版本。有同学在 IDEA 里一编译就报这个错,查半天 java 八股文里类加载的知识也没用,其实只要换 JDK 版本跑一次生成步骤就完了。
5.5 中文注释乱码:UNICODE_INPUT 与 UTF-8 读取
现象:.c 文件里写中文注释,解析时报字符错误,或者把中文字符当成标识符的一部分。
原因:两个层面。.jj 文件本身不是 UTF-8 保存,导致 JavaCC 解析注释时按默认编码理解;或者读 .c 文件用的 Reader 没指定 UTF-8,Windows 默认用 GBK 解码。
解决:.jj 文件在编辑器里显式保存为 UTF-8,options 里写UNICODE_INPUT = true,读源文件用InputStreamReader加StandardCharsets.UTF_8。这三个动作缺一不可。别问我为什么知道,这是课设里最容易“修好一个坑又踩另一个”的地方。
6. 最后一步:设计一套自测用例,把课设的验收风险压到最低
课设验收前,我习惯给自己设计一套“黑白名单”测试集。黑名单是必须报错的非法输入,白名单是必须通过的正确输入。脚本写起来不复杂,但能帮你省掉最后一天改 bug 的时间:
#!/bin/bash pass=0 fail=0 for f in tests/valid/*.c; do out=$(java Compiler "$f" 2>&1) if echo "$out" | grep -q "error:"; then echo "FAIL(valid): $f" fail=$((fail+1)) else pass=$((pass+1)) fi done for f in tests/invalid/*.c; do out=$(java Compiler "$f" 2>&1) if echo "$out" | grep -q "error:"; then pass=$((pass+1)) else echo "FAIL(invalid): $f 应该报错但没有" fail=$((fail+1)) fi done echo "通过 $pass,失败 $fail"这个脚本的思路是:合法用例必须零错误编译,非法用例必须至少输出一条行:列: error信息。你把平时写过的所有测试代码放进 valid,再故意构造“未声明变量”、“类型不匹配”、“缺分号”、“括号不匹配”等放进 invalid,就能在提交前自动过一遍全流程。黑名单比白名单更值钱,因为评分老师最爱干的就是拿非法输入试你的错误处理。
我本人的血泪经验是:当年课设功能全通了,白名单全过,结果验收时老师输入int 123abc;,词法分析器把 123abc 切成了123和abc两个 token,语法解析居然报告“缺少分号”而不是“非法标识符”。问题出在 ID 的正则没限制“不能以数字开头”。这种边界问题就是靠自测用例逼出来的,越早构建测试集,越不会在答辩现场翻车。希望这篇笔记的路线和坑能帮到你,把 JavaCC 这条课设路走得比我当年稳一点。
本文还有配套的精品资源,点击获取