用JavaCC实现类C编译器:词法、语法、语义与三地址码全解析
发布时间:2026/10/10 12:06:06 锦皓数字建站

简介重庆理工大学编译原理课程设计的完整项目基于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-8public 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 时传入 ReaderJavaCC 内部用这个 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 放在最里层加减放最外层。改完左递归后最好立刻用12*3和(12)*3两组输入验证。前者应解析成1(2*3)后者应解析成(12)*3如果结果反过来说明你的层级写反了。3.2 完整表达式链从 primary 到 assignment 的五个层次类C表达式比四则运算多出赋值、比较、逻辑、函数调用和数组下标。我一般这样组织优先级括号和常数最内层下来是一元运算再往上是乘除、加减、比较、赋值。下面是课设里够用的一个表达式链注意我用 ASTNode 作为返回值void assignment() : { Token id; ASTNode e; } { ( LOOKAHEAD(2) idID ) eadditive() { return new AssignNode(id.image, e); } } ASTNode additive() : { ASTNode l, r; } { lmultiplicative() ( rmultiplicative() { l new BinOpNode(, l, r); } )* { return l; } } ASTNode multiplicative() : { ASTNode l, r; } { lunary() ( * runary() { l new BinOpNode(*, l, r); } )* { return l; } } ASTNode unary() : { ASTNode e; } { ! eunary() { return new UnaryNode(!, e); } | - eunary() { return new UnaryNode(-, e); } | epostfix() { return e; } } ASTNode postfix() : { ASTNode e; } { eprimary() ( ( args() ) | [ additive() ] )* { return e; } } ASTNode primary() : {} { INT | ID | ( additive() ) }注意assignment()开头的LOOKAHEAD(2)这是为了区分“赋值语句”和“以变量开头的表达式语句”。当输入是a 10;时解析器需要向前看两个 tokena和才能确定这是赋值而不是表达式a。LOOKAHEAD 是 JavaCC 少数能直接改变解析行为的选项后面避坑章节会专门展开。每个规则都有一对花括号第一对是局部变量声明第二对在规则结束后执行用来构造 AST 节点。要注意( rmultiplicative() { ... } )*这个写法里循环体内的动作会在每次迭代后执行靠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 { MapString, 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 { ListString 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 与写法修正现象运行解析器处理简单表达式12直接抛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-8Windows 默认用 GBK 解码。解决.jj 文件在编辑器里显式保存为 UTF-8options 里写UNICODE_INPUT true读源文件用InputStreamReader加StandardCharsets.UTF_8。这三个动作缺一不可。别问我为什么知道这是课设里最容易“修好一个坑又踩另一个”的地方。6. 最后一步设计一套自测用例把课设的验收风险压到最低课设验收前我习惯给自己设计一套“黑白名单”测试集。黑名单是必须报错的非法输入白名单是必须通过的正确输入。脚本写起来不复杂但能帮你省掉最后一天改 bug 的时间#!/bin/bash pass0 fail0 for f in tests/valid/*.c; do out$(java Compiler $f 21) if echo $out | grep -q error:; then echo FAIL(valid): $f fail$((fail1)) else pass$((pass1)) fi done for f in tests/invalid/*.c; do out$(java Compiler $f 21) if echo $out | grep -q error:; then pass$((pass1)) else echo FAIL(invalid): $f 应该报错但没有 fail$((fail1)) fi done echo 通过 $pass失败 $fail这个脚本的思路是合法用例必须零错误编译非法用例必须至少输出一条行:列: error信息。你把平时写过的所有测试代码放进 valid再故意构造“未声明变量”、“类型不匹配”、“缺分号”、“括号不匹配”等放进 invalid就能在提交前自动过一遍全流程。黑名单比白名单更值钱因为评分老师最爱干的就是拿非法输入试你的错误处理。我本人的血泪经验是当年课设功能全通了白名单全过结果验收时老师输入int 123abc;词法分析器把 123abc 切成了123和abc两个 token语法解析居然报告“缺少分号”而不是“非法标识符”。问题出在 ID 的正则没限制“不能以数字开头”。这种边界问题就是靠自测用例逼出来的越早构建测试集越不会在答辩现场翻车。希望这篇笔记的路线和坑能帮到你把 JavaCC 这条课设路走得比我当年稳一点。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。