☰
JavaCC实战:完整构建类C编译器课设,从词法分析到栈帧可视化
2026/10/3 2:51:24 网站建设 项目流程

简介:该资源为重庆理工大学编译原理课程设计完整项目,面向学习JavaCC与类C语言编译器实现的本科生,可用于课程设计、期末复习或实验参考。项目基于JavaCC完成类C语言编译器的词法分析、语法分析及语义处理,采用递归下降方法实现语法分析,并额外提供Python编写的LL1算法作为验证;脚本可自动执行Basic结果输出,配合多组测试数据对词法、语法结果进行校验。文件共380个,涵盖java源码、jj语法文件、class字节码、out运行结果、sample测试样例、sh自动脚本及大量中间版本文件,压缩包仅3.02MB,便于本地运行与二次开发。资源已有857人浏览学习,适合需要借鉴完整课程设计实现、快速理解JavaCC工作流程,或参照其代码结构完成自身编译实验的读者。

1. 为什么拿 JavaCC 写类 C 编译器:一份能跑通全流程的课设

重庆理工大学编译原理课程设计里有一道很经典的题:用 JavaCC 做一个类 C 编译器。市面上能找到的很多同名工程,多半是贴一段文法文件就草草收场,真正能把词法分析、语法分析、属性文法驱动的结果输出、函数调用栈帧可视化、Python 实现的 LL(1) 校验和自动执行脚本串成一条完整流水线的,并不多见。这份 Java 课程设计案例源码的价值就在这里。

它解决的是几类人的具体问题:正在做编译原理课设、需要同时交付可运行程序和课程设计报告的同学;想看清 JavaCC 生成的 Parser 怎么和手写代码协作的初学者;以及想找一份结构规范、方便扩展的编译器骨架的从业者。如果你只需要一个能出演示结果的工程,它拆开就能跑;如果你想弄懂每一部分为什么这么拼,下面按依赖顺序给出完整脉络。

2. 先读懂工程结构:从 JJT 到 Java 的代码生成链路

拿到资源先别急着点运行。工程里保留了一串版本记录,文件虽然多,但核心链路只有一条:JJTree 读取 .jjt 文件生成 AST 节点和 .jj 文件,JavaCC 读取 .jj 文件生成词法分析器和递归下降解析器,javac 把生成的 Java 文件和手写的主程序一起编译,最后由脚本驱动执行并归档输出。这一步理清楚,后面改文法、调参数才不会迷路。

刚开始接触这套工程的人,最容易犯的错是拿到手就双击运行 run.bat,报错了也不知道在哪一步挂的。我建议按依赖顺序分开跑:先确认 JavaCC 和 JDK 的版本,再单独执行 jjtree,接着执行 javacc,最后才连起来跑整条脚本。每一步的报错信息在这个阶段会明确很多,也方便对照报告里的操作记录。

2.1 从 .jjt 到 .java:完整的生成链路与文件职责

多数人以为 .jj 文件就是全部,其实 .jjt 才是源头。JJTree 读 .jjt 文件时,会同时产出带 AST 节点定义的 .jj 文件和一堆 AST*.java 文件;JavaCC 再读 .jj 文件,产出 Parser.java、TokenManager.java、Token.java、ParseException.java。这两步都属于代码生成,不是手写解析器。对应的命令是这样:

# 进入源码目录,用 jjtree 处理带树结构的语法文件 cd src jjtree CCompiler.jjt # 使用 javacc 处理语法文件,生成解析器和词法器 javacc CCompiler.jj cd .. # 编译生成的 Java 文件和工程里的手写类 javac -d build src/*.java # 运行编译器主程序,参数是测试源文件 java -cp build compiler.CCompiler tests/basic.c

第一行命令先把 .jjt 展开成 .jj,并生成 AST 节点类;第二行是 JavaCC 的核心步骤,生成 Parser.java 与 TokenManager.java;第三行把所有 Java 文件编译成字节码,输出到 build 目录;第四行是执行入口。注意 javacc 的 bin 目录必须提前加到 PATH 里,否则会报 command not found,这就是最常见的 java 环境变量配置问题。如果你用 JDK 17 以上版本,还要额外检查 javacc 是否版本过老,后面避坑环节会专门说。

整个工程建议对照这张职责表来看,比直接翻源码高效得多:

文件/目录职责是否需要手工维护
CCompiler.jjtJJTree 源文件,定义语法树节点是,核心文件
CCompiler.jj由 jjt 展开,也可直接编写通常是生成物
AST*.java语法树节点类生成物,改 jjt 后自动更新
Parser.java / TokenManager.java递归下降解析器、词法分析器生成物,不建议手改
tests/basic.c基础功能用例是,验收依据
tests/mixed.c混合场景用例是,验收依据
run.sh / run.bat自动执行脚本是,可改参数
scripts/ll1_check.pyLL(1) 文法校验工具是,自写的 Python 算法
output/四类结果输出目录运行后生成

这里要反复强调:Parser.java、TokenManager.java 这些生成文件不要手改。如果语法有调整,必须回到 .jjt 或 .jj 文件里改,然后重新跑生成命令。手改生成代码等于给自己埋雷,下次重新生成时所有修改都会被覆盖,而不重新生成又会让工程停在一种不可复现的状态。

2.2 测试用例与输出目录:Basic 和 Mixed 不是玄学

很多课设验收会问“Basic 结果是什么,Mixed 结果是什么”。这套工程的划分很明确:Basic 用例覆盖变量声明、赋值运算、if/else、while 循环这些主干功能,验证常规路径没有缺陷;Mixed 用例则把函数定义、函数调用、参数传递、返回值、复杂表达式混在一个文件里,专门验证组合场景。报告里说 Basic 全部准确无误、Mixed 经人工验证准确无误,你复现时同样要在这两类输入上都拿到稳定输出。

工程里的脚本会自动切换模式跑用例。主程序通常从标准输入读一个数字来决定行为:1 词法分析、2 语法分析、3 Basic、4 Mixed。脚本用管道把数字喂给程序,再把 stdout 重定向到 output 目录下的对应文件。这样设计的好处是答辩现场不用手敲参数,输入文件名即可演示全流程。output 目录里的结果文件命名是有规律的:lex.txt 是对应源码切分后的 token 序列,parse.txt 是语法树结构,basic.txt 和 mixed.txt 才是带语义信息的最终输出。后期写课程设计报告时,直接按文件截图就能把过程和结果一一对上。

3. 词法与语法规则:在 .jj 文件里定义你的类 C 语言

从这一章开始动真格。JavaCC 工程的灵魂是 .jj 文件里的文法描述:词法部分用正则式定义注释、空白、关键字、标识符和数字,语法部分用产生式定义表达式、语句和函数结构。JavaCC 会把这些产生式直接翻译成递归下降解析器的 Java 方法,所以文法文件的质量直接决定 Parser 的行为。编译原理实验里最常见的就是卡在词法规则和文法冲突上,下面把两段核心代码拆开讲。

3.1 词法规则:注释、空白与关键字的定义顺序

词法规则的标准写法是这样:

// 跳过空白,不产生 token SKIP : { " " | "\t" | "\n" | "\r" } // 单行注释:以 // 开头,到换行结束 SKIP : { <SINGLE_LINE_COMMENT: "//" (~["\n"])* "\n"> } // 多行注释:允许注释中间出现星号 SKIP : { <MULTI_LINE_COMMENT: "/*" ( (~["*"]) | ("*" ~["/"]) )* "*/"> } // 关键字和运算符的 token 定义 TOKEN : { < IF: "if" > | < ELSE: "else" > | < WHILE: "while" > | < INT: "int" > | < RETURN: "return" > | < LBRACE: "{" > | < RBRACE: "}" > | < LPAREN: "(" > | < RPAREN: ")" > | < ASSIGN: "=" > | < SEMICOLON: ";" > } // 标识符必须在关键字之后定义 TOKEN : { < IDENTIFIER: (["a"-"z","A"-"Z"]) (["a"-"z","A"-"Z","0"-"9","_"])* > | < NUMBER: (["0"-"9"])+ > }

上面这段代码里,SKIP 的 token 不会进入语法分析流,注释和空白在词法阶段就被丢弃,“注释不影响整个程序”这条要求就是在这一层实现的。两份注释规则分开写:单行注释正则匹配//加任意非换行字符加\n;多行注释正则允许*出现在注释体内部,不会因为看到*就提前结束匹配。最容易翻车的是把多行注释写成"/*" (~["/"])* "*/",遇到/* 内容 * 还有内容 */就会在中间的*处错误终止。

Token 的定义顺序同样是重灾区。JavaCC 对 TOKEN 不是按最长匹配,而是按定义顺序优先匹配。如果把 IDENTIFIER 写在 IF、WHILE 之前,词法分析器读到 if 时会优先命中 IDENTIFIER,后面语法分析就全乱了。所以关键字规则必须放在标识符规则前面,这是写 JavaCC 词法的一条铁律。

提示:JavaCC 的 TOKEN 匹配按定义顺序优先,关键字定义必须放在 IDENTIFIER 之前,否则 if 会被当普通变量。

3.2 语法产生式:用递归下降描述类 C 语句

词法只解决切分,语法部分解决组织。下面是语法部分的骨架:

// 程序 = 若干函数定义,文件结尾必须是 EOF void CompilationUnit() : {} { ( FunctionDefinition() )* <EOF> } // 函数 = 返回类型 + 函数名 + 形参表 + 花括号块 void FunctionDefinition() : {} { Type() <IDENTIFIER> "(" FormalParameters() ")" Block() } // 类型当前只支持 int,扩展时可加 void、char void Type() : {} { <INT> } // 形参表:可为空,也可为 int 标识符的逗号列表 void FormalParameters() : {} { // 空参数表 | <INT> <IDENTIFIER> ("," <INT> <IDENTIFIER>)* } // 块 = 一对花括号包裹的语句序列 void Block() : {} { <LBRACE> ( Statement() )* <RBRACE> } void Statement() : {} { <INT> <IDENTIFIER> <SEMICOLON> | <IDENTIFIER> <ASSIGN> Expression() <SEMICOLON> | <WHILE> "(" Expression() ")" Block() }

JavaCC 对每个非终结符生成一个对应的 Java 方法,方法之间的调用关系就是递归下降。Statement() 里的|表示分支选择,JavaCC 默认用下一个 token 判断走哪条分支:声明语句以 int 开头,赋值语句以标识符开头,while 语句以 while 开头,单 token 就能区分,所以这段不需要额外 lookahead。

真正需要 LOOKAHEAD 的地方在表达式层。例如左括号既可能是函数调用也可能表示括号运算,赋值号左边既可能是普通变量也可能是将来扩展的数组元素。表达式的设计要体现运算符优先级,标准做法是把优先级压进文法层次而不是交给优先级表:

void Expression() : {} { Term() ( "+" Term() | "-" Term() )* } void Term() : {} { Factor() ( "*" Factor() | "/" Factor() )* } void Factor() : {} { <NUMBER> | <IDENTIFIER> | "(" Expression() ")" | <IDENTIFIER> "(" ArgList() ")" }

这个结构同时解决了两个问题:一是左递归被消除,Factor() ("*" Factor())*是右递归闭包形式,递归下降解析器可以安全处理;二是乘除的优先级天然高于加减,不需要额外的符号表参与。工程里把 LOOKAHEAD 设为 2,意思是解析器可以多看一个 token 再决定走哪条分支。常用 options 参数如下:

options 参数默认值作用
LOOKAHEAD1向前看 token 数量,解决分支冲突
STATICtrue生成静态方法,多实例场景应改为 false
JAVA_TEMPLATE_OPTION与 JavaCC 版本相关控制生成代码的 JDK 兼容级别
ERROR_REPORTINGtrue是否输出详细错误信息

递归下降解析器对文法有一个硬性要求:不能有左递归,不能有同一位置都能匹配两个分支的 FIRST 集冲突。如果你改完文法,JavaCC 报 Choice conflict 警告,不要下意识把 LOOKAHEAD 加到 10。先检查产生式本身是不是有歧义,把文法改回 LL(1) 形态,再加 lookahead 处理小概率前瞻需求。手写递归下降和 JavaCC 生成解析器用的是同一套逻辑,这一点在下一章的 LL(1) 校验里还会继续验证。

注意:递归下降对左递归文法会直接死循环。LL(1) 校验脚本跑出冲突时,先改文法,再谈 lookahead 数值。

4. 属性文法与栈帧可视化:把语义动作做进 AST

词法语法分析通过后,编译器还只是一个能识别句子的前端。课程设计要求的 Basic 和 Mixed 结果必须带出语义信息,比如变量有没有声明、类型是否一致、函数调用时参数怎么传递。这一步靠属性文法驱动。工程的做法是遍历 JJTree 生成的 AST,在关键节点上挂语义动作,每一步输出都来自实实在在的数据结构,而不是格式化字符串硬凑。

4.1 属性文法落进代码:符号表与类型检查的协作方式

AST 里的每个节点对应一个语法产生式,语义动作也按节点类型分工。常用映射如下:

AST 节点语义动作
VarDecl往符号表加入变量名和类型
Assign查符号表确认左值已声明
If / While对条件表达式做类型检查
FunctionCall按函数签名校验实参个数和类型
Return校验返回类型与函数声明一致

符号表最常见的实现是HashMap<String, Symbol>,作用域管理靠嵌套的 Scope 栈。遇到{push 新作用域,遇到}pop 当前作用域,变量查找从内向外,正好和函数调用栈的 push/pop 节奏对上。属性文法的求值顺序也很直观:每个节点进入时把继承属性传进来,子节点处理完之后把综合属性带回父节点,最后在函数定义节点做整体校验。这个求值顺序如果反了,会出现“子节点还没算完就检查父节点属性”的问题,表现就是 Mixed 结果里类型检查报错却定位不到具体语句。

4.2 栈帧可视化:函数调用时内存空间怎么演示

“可视化展示函数调用时内存空间变化基于栈实现”这一条,对应一个小型运行时模拟器。我用一个显式的 Stack 来模拟调用栈,翻译器遇到 FunctionCall 节点就往栈里 push 一个 Frame,函数执行完遇到 Return 就 pop 出栈,同时打印栈内所有帧的变量表。代码示意如下:

// 栈帧:一个函数调用占一帧 class Frame { String functionName; Map<String, Integer> locals = new HashMap<>(); int returnAddress; Frame(String functionName, int returnAddress) { this.functionName = functionName; this.returnAddress = returnAddress; } void dump() { System.out.println("frame: " + functionName + " vars: " + locals); } } // 调用栈用显式 Stack 管理 Stack<Frame> callStack = new Stack<>(); // 模拟函数调用 void callFunction(String name) { callStack.push(new Frame(name, currentStatementIndex)); printStack(); } // 模拟函数返回 void returnFromFunction() { callStack.pop(); printStack(); } // 打印每一帧 void printStack() { for (Frame f : callStack) { f.dump(); } }

这里用显式 Stack 而不是系统调用栈,是为了能直观打印每一帧的状态。callFunction 里的 currentStatementIndex 是返回地址的简化表示,真实课程设计里通常记 AST 节点序号;打印顺序从栈顶到栈底,方便观察嵌套调用时内层函数先出栈。可以在 dump 方法里把变量地址也打出来,Mixed 输出会更有说服力。

和栈模拟配套的是 Python 实现的 LL(1) 校验脚本。递归下降解析器本质上是手写的 LL(1) 预测分析器,脚本的任务就是计算文法的 FIRST 集合、FOLLOW 集合和预测分析表,检查是否存在冲突。核心代码类似:

# 计算非终结符的 FIRST 集合 def compute_first(productions, terminals, non_terminals): first = {nt: set() for nt in non_terminals} changed = True while changed: changed = False for lhs, rhs in productions: for symbol in rhs: if symbol in terminals: if symbol not in first[lhs]: first[lhs].add(symbol) changed = True break else: before = len(first[lhs]) first[lhs] |= first[symbol] if len(first[lhs]) != before: changed = True if "ε" not in first[symbol]: break return first

这段代码是标准的 fixpoint 算法,反复迭代直到 FIRST 集合不再变化。productions 是文法产生式列表,terminals 与 non_terminals 分别传入终结符和非终结符集合,空产生式用"ε"表示。脚本里还包含 FOLLOW 集合和预测分析表的计算,思路类似,只是集合的传播方向不同。把教材里的表达式文法作为输入,脚本能正确输出完整预测分析表,就说明算法本身没有实现偏差。

这套 Python 脚本的实际价值,是给“语法分析结果全部准确无误”提供佐证:先证明文法满足 LL(1) 条件,再让 JavaCC 生成对应解析器,两边结论一致时才能放心说递归下降实现没问题。改动 .jj 文法后,要同步改 Python 里的产生式列表,再跑一次校验,最后重新生成 Java 代码。顺序反了的话,脚本验的是旧文法,JavaCC 用的是新文法,两边对不上,答辩演示时最容易翻车。

5. 自动执行脚本与避坑记录:参数设计与五处翻车现场

整套流程的最后一公里是脚本。课程设计报告里要求“脚本文件可以自动执行 Basic 输出词法分析结果、语法分析结果、Basic 结果、Mixed 结果”,还要求把编译后的 JJ、JJT、JAVA 文件放入特定路径。这一步做扎实,答辩演示几乎零失误,也方便导师顺着目录结构还原你的操作过程。

5.1 一键跑完四类输出:脚本参数与标准输入自动喂数

我把脚本按“清理 - 生成 - 编译 - 归档 - 测试”五段组织,下面是常用写法:

#!/bin/bash set -e BUILD=build OUT=output GEN=generated rm -rf "$BUILD" "$OUT" "$GEN" mkdir -p "$BUILD" "$OUT" "$GEN" # 阶段 1:在 src 目录内用 JJTree 生成 AST 节点和 .jj 文件 cd src jjtree CCompiler.jjt javacc CCompiler.jj cd .. # 阶段 2:编译所有 Java 文件到 build 目录 javac -d "$BUILD" src/*.java # 阶段 3:把生成文件归档,保留可追溯的生成记录 cp src/*.java "$GEN" # 阶段 4:按模式执行测试,自动喂入交互数字 for tc in tests/basic.c tests/mixed.c; do base=$(basename "$tc" .c) printf "1\n" | java -cp "$BUILD" compiler.CCompiler "$tc" > "$OUT/${base}.lex.txt" printf "2\n" | java -cp "$BUILD" compiler.CCompiler "$tc" > "$OUT/${base}.parse.txt" printf "3\n" | java -cp "$BUILD" compiler.CCompiler "$tc" > "$OUT/${base}.basic.txt" printf "4\n" | java -cp "$BUILD" compiler.CCompiler "$tc" > "$OUT/${base}.mixed.txt" done

set -e 保证任一步失败就停止,不会带着半成品继续跑。j jtree 和 javacc 的 bin 目录要提前加入 PATH,Windows 下对应 run.bat,把命令换成 j jtree.bat 和 j avacc.bat,路径分隔符按 cmd 的写法调整即可。printf "1\n" 是往主程序标准输入喂模式数字,1 到 4 分别对应词法、语法、Basic、Mixed;如果改了主程序的菜单顺序,这里要同步改。

归档那一步用的是 cp 而不是 mv,是因为 class 文件虽然已经编译完成,但保留一份 src 下的生成代码方便追溯更稳妥。根目录只保留手写代码、脚本和测试用例,目录层次对答辩观众更友好。脚本里的模式参数可以做成变量,也可以接受命令行参数只跑 Basic 或只跑 Mixed,核心逻辑不变。验收时如果换了测试文件,只要把新文件放进 tests 目录,脚本会自动按文件名生成对应的输出。

5.2 避坑记录:JavaCC 课程设计最容易翻车的五处

下面五条都是实际跑过才总结出来的,每一条按“现象 -> 原因 -> 解决”记录。

第一,关键字被识别成标识符。现象是 if、while 出现在符号表里,语法分析报错却找不到具体原因。原因是 IDENTIFIER 的词法规则写在了 IF、WHILE 前面,JavaCC 按定义顺序优先匹配。解决方法是把关键字 TOKEN 全部移到标识符之前,然后重新 j jtree、javacc。

第二,多行注释提前终止。现象是/* a * b */之后的源码被吞掉,语法分析出现莫名错误。原因是注释正则写成了"/*" (~["/"])* "*/",注释体里的*会提前和*/的*配对。解决方法是改成"/*" ( (~["*"]) | ("*" ~["/"]) )* "*/",把星号后的下一个字符也纳入匹配。

第三,JavaCC 版本与 JDK 版本不匹配。现象是生成的 Java 文件在 javac 编译时报错,有些是过时 API 被移除,有些是 JAVA_TEMPLATE_OPTION 不被识别。原因是老版本 JavaCC 生成代码面向老 JDK。解决方法是把 JavaCC 升到 7.x,在 options 里显式写兼容级别,再确认 CLASSPATH 包含对应 jar。做课设前先跑一遍 j jtree、javacc、javac 三连,确认版本组合没问题再动文法。

第四,脚本跨平台跑不通。现象是 Linux 下生成的脚本在 Windows 上报 command not found,或者 cp 时目标目录不存在。原因是脚本用了 bash 语法和硬编码路径。解决方法是 Windows 单独提供 run.bat,所有路径用相对路径,执行前先 mkdir -p。脚本开头也建议判断 java、javacc 是否可用,给出明确的错误提示而不是让系统报 command not found。

第五,JJTree 节点和手写语义类冲突。现象是 Mixed 输出抛 ClassCastException。原因是 .jjt 里没有给节点指定 NODE_CLASS,或者生成的 AST 节点和手写的语义分析器类同名。解决方法是检查 .jjt 的 options 配置,把节点类的包名固定下来,不要和主程序类放同一个默认包还互相覆盖。这个问题通常不在第一次运行出现,而是等扩展 AST 节点类型时才暴露,所以越早定好包名越省事。

6. 验证技巧:用回归流程确认编译器没有改坏

6.1 回归式验证:先跑 LL(1) 校验,再跑四类输出

我每次改完文法,都会固定走一遍完整回归,顺序一次不乱。先跑 Python 的 LL(1) 校验脚本检查产生式冲突,再 j jtree、javacc、javac 重新生成编译器,然后执行脚本跑 basic.c 和 mixed.c,最后用 diff 对比新输出与上一次的基线输出:

python3 scripts/ll1_check.py ./run.sh all diff output/basic.txt output/baseline/basic.txt diff output/mixed.txt output/baseline/mixed.txt

diff 无输出表示一致。基线目录是第一次验证通过后保存的副本,没有基线的话就把旧的 output 整个复制一份再跑新一轮。这个习惯帮我避免过至少两次低级回归:一次是改了运算符优先级导致表达式求值顺序变化,另一次是调整 Token 顺序影响了注释识别,单独看语法分析都没问题,一对比 mixed.txt 立刻露馅。

6.2 扩展思路:把课设变成一个可以讲的编译器

学有余力可以从三个方向做深:一是给 Mixed 用例加嵌套函数调用和递归,栈帧可视化会呈现层层压栈再逐层弹出的完整过程;二是在符号表里记录变量地址,栈帧 dump 时打印地址变化,内存分配过程看得更清楚;三是把 Python LL(1) 脚本扩展成可以读 .jj 文件、自动提取产生式的工具,让文法校验和 JavaCC 真正共用同一份定义。

如果有面试问到编译原理,这套工程里的递归下降、LL(1) 校验、符号表和栈帧模拟都是能落地的素材,比背八股文更有说服力。但前提是你真的把每条命令跑过一遍,知道 Token 顺序和 LOOKAHEAD 改了会发生什么。从那以后我每次改文法都强制走一遍“LL(1) 校验 -> JavaCC 生成 -> 四类输出回归”的流程,顺序乱过一次,后来再也没在演示时翻过车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询