
简介本资源是重庆理工大学编译原理课程设计的完整实现成果面向计算机专业本科生及编译技术初学者聚焦类C语言编译器的设计与开发实践。项目基于Java与JavaCC工具链构建涵盖词法分析、语法分析递归下降LL1验证、语义处理与中间结果可视化等核心环节配套Python辅助脚本实现自动化测试、文件归类与执行流程封装显著降低调试门槛。压缩包共380个文件含46个Java源码、55个class字节码、148个out输出样例、38个sample测试用例及多个脚本sh/py和配置文件总大小3.02MB目录结构规范支持一键运行Basic/Mixed测试并自动导出各阶段分析结果。已有857人学习下载提供从文法设计、工具选型、代码实现到人工验证的全流程闭环方案特别包含栈式内存变化可视化模块与注释预处理机制兼具教学示范性与工程参考价值。1. 重庆理工大学编译原理课程设计一个能跑通、能调试、能交作业的类C编译器实战包这不是一份“理论正确但跑不起来”的教学幻灯片而是一个从词法分析到内存栈可视化全链路可复现的 Java 编译器工程——它用 JavaCC 实现词法与语法分析骨架用递归下降补全 LL(1) 语义动作用自定义符号表和运行时栈模拟函数调用过程最终能编译一段带变量声明、if/while、基本算术和输入输出的类C代码并在命令行下自动输出词法流、语法树、中间代码Basic、执行结果Mixed四层验证输出。它不是玩具而是重庆理工大学《编译原理》课程设计的真实交付物所有测试用例Basic/Mixed人工核验无误注释被正则精准剥离JJ/JJT/Java 文件自动归位脚本一键触发全流程。适合正在赶课设 deadline 的本科生——你不需要重写 parser只需要理解每个 .jjt 节点怎么映射到 AST 节点搞懂 symbolTable 如何在 enterScope/exitScope 中增删查看懂 stackFrame 在 call/return 时如何 push/pop。它不教你“什么是 FIRST 集”但它让你亲手看到id (num * id)这条表达式在 parseExpr() 里被拆成哪三步、每步返回什么 AST 节点、symbolTable 里存了几个 entry、runtimeStack 顶帧里 locals 数组长度是多少。这才是编译原理落地的第一公里。2. 从 .jjt 到 .javaJavaCC 生成器的结构拆解与语义动作注入逻辑JavaCC 是这个项目词法与语法分析的基石但它的核心价值不在“自动生成”而在“可控注入”——你必须在 .jjt 文件里写清楚每个非终结符对应的 Java 代码块让语法分析器一边识别结构一边构建 AST、填充符号表、生成中间代码。本节不讲 BNF 语法只聚焦三个真实问题为什么用 .jjt 而不是 .jjAST 节点类怎么和 JavaCC 规则一一绑定语义动作里的jjtreeOpen()和jjtreeClose()到底在什么时候被调用2.1 .jjt 与 .jj 的本质区别AST 构建开关必须显式打开JavaCC 默认生成的是纯 recognizer识别器不产生 AST。而本项目所有语法分析结果如ProgramNode,IfNode,ExprNode都继承自SimpleNode这依赖于 JJTree 的 AST 生成功能。关键在于.jjt文件是 JJTree 的输入它会在PARSER_BEGIN和PARSER_END之间插入options { VISITOR true; }并在每个产生式后用...标记节点类型// 示例赋值语句规则来自 src/parser/MiniC.jjt AssignmentStatement : { ExprNode left null; ExprNode right null; } { ID Expression() SEMICOLON { left new IdNode(token.image); right (ExprNode) jjtreeOpen(); // 此处 jjtreeOpen() 返回刚创建的 Expression 对应的 AST 节点 jjtreeClose(); // 封闭当前节点将其作为 AssignmentStatement 的子节点 return new AssignNode(left, right); } }提示jjtreeOpen()并非立即返回子节点而是返回当前 JJTree 栈顶节点即刚由Expression()规则生成的节点。jjtreeClose()则将该节点 pop 出栈并作为当前规则AssignmentStatement的子节点挂载。这是 JJTree 的隐式栈机制不理解这点你会在调试时发现 AST 子节点永远为空。2.2 AST 节点类的设计契约必须继承 SimpleNode 且提供 accept() 方法所有 AST 节点如AssignNode,IfNode,WhileNode都位于src/ast/目录下它们不是随意写的 POJO而是严格遵循 JJTree 的 Visitor 模式契约// src/ast/AssignNode.java public class AssignNode extends SimpleNode { private ExprNode left; private ExprNode right; public AssignNode(int id) { super(id); } public AssignNode(MiniCParser p, int id) { super(p, id); } public void setLeft(ExprNode left) { this.left left; } public void setRight(ExprNode right) { this.right right; } Override public Object accept(ASTVisitor visitor) throws Exception { return visitor.visit(this); // 所有节点都必须实现此方法 } }这个accept()方法是后续遍历语义分析、中间代码生成、解释执行的统一入口。ASTVisitor接口定义了visit(AssignNode),visit(IfNode)等方法而具体实现类如SymbolTableBuilder,CodeGenerator,Interpreter通过重载这些方法把“做什么”和“对谁做”解耦。如果你漏掉accept()或参数类型写错比如写成visit(AssignNode node)却没在接口里声明编译会直接报错因为 Visitor 模式靠编译期类型匹配驱动。2.3 语义动作中的 symbolTable 与 runtimeStack作用域嵌套的两层抽象本项目用两个核心数据结构管理名字空间SymbolTable编译期静态作用域和RuntimeStack运行期动态栈帧。它们的初始化和生命周期完全由语义动作控制// src/parser/MiniC.jjt 片段函数定义规则 FunctionDefinition : { String funcName ; SymbolTable currentTable null; } { TYPE ID ( ParameterList() ) { StatementList() } { funcName token.image; // 获取函数名 currentTable new SymbolTable(); // 新建局部作用域 // 注意此处未将 currentTable 注入全局 symbolTable而是传给 StatementList() // 因为 StatementList() 语义动作中会调用 enterScope(currentTable) return new FuncDefNode(funcName, (ParamListNode) jjtreeOpen(), (StmtListNode) jjtreeOpen()); } }enterScope()和exitScope()不是 JavaCC 内置函数而是SymbolTable类提供的方法其内部维护一个DequeSymbolTable。每次进入{}块就push()离开就pop()。而RuntimeStack则在Interpreter类中被call()和return()方法操作每个call()创建新StackFramereturn()销毁顶层帧。二者分离的意义在于SymbolTable解决“变量在哪声明、能否访问”RuntimeStack解决“变量值存在哪、生命周期多长”。混淆这两者是导致“变量未声明却能访问”或“函数返回后局部变量仍可读”这类玄学 bug 的根源。3. 递归下降解析器LL(1) 分析器的手动实现与教材例题验证项目摘要第 7 条明确提到“编译器的语法分析采用递归下降方法实现故额外使用 Python 编写 LL(1) 算法通过教材例子测试验证结果准确无误。” 这句话容易被忽略但它揭示了一个关键事实JavaCC 生成的是基于预测分析表的自顶向下分析器而本项目同时保留了一套独立的手写递归下降解析器位于src/parser/RecursiveDescentParser.java用于教学对比和算法验证。它不是摆设而是理解 LL(1) 构造过程的黑匣子解剖刀。3.1 为什么需要手写递归下降JavaCC 已经够用了JavaCC 生成的分析器是“黑盒”你写.jjt它吐.java中间的 FIRST/FOLLOW 集计算、预测分析表构造、冲突消解全被封装。但课程设计要求你“理解 LL(1) 算法”这就必须暴露底层逻辑。手写递归下降强制你面对三个问题如何为每个非终结符A编写parseA()方法parseA()如何根据当前 token 判断该走哪个产生式当遇到A → ε时如何判断是否该跳过答案就是手动计算FIRST(A)和FOLLOW(A)并把它们硬编码进if-else if链。例如对Statement的 LL(1) 分析# tools/ll1_validator.pyPython 实现用于验证 def parse_statement(): token lookahead() if token in FIRST[IfStatement]: # [if] parse_if_statement() elif token in FIRST[WhileStatement]: # [while] parse_while_statement() elif token in FIRST[Assignment]: # [id] parse_assignment() elif token in FIRST[InputStatement]: # [input] parse_input_statement() elif token in FOLLOW[Statement]: # [}] —— 表示空产生式 return None # ε else: raise SyntaxError(fUnexpected token {token})这个 Python 脚本不是用来替代 JavaCC 的而是作为“验证器”它读取教材给出的文法自动计算 FIRST/FOLLOW生成上述if-else结构并用教材例题如if (x 0) { y x 1; }跑一遍比对输出的推导步骤是否与教材一致。只有当 Python 版本能正确推导才说明你对文法的理解没有偏差——这是防止你在 JavaCC 规则里写错LOOKAHEAD的后悔药。3.2 JavaCC 的 LOOKAHEAD 与手写 LL(1) 的本质一致性JavaCC 的LOOKAHEAD(n)本质上就是在模拟 LL(k) 分析。本项目中大量使用LOOKAHEAD(2)例如// src/parser/MiniC.jjt Statement : {} { LOOKAHEAD(2) IF ( Expression() ) { StatementList() } // if 语句 | LOOKAHEAD(2) WHILE ( Expression() ) { StatementList() } // while 语句 | ID Expression() SEMICOLON // 赋值语句 }为什么是LOOKAHEAD(2)因为if和while都以关键字开头仅看第一个 tokenif/while就能区分但 JavaCC 默认只看 1 个 token而if和while的 FIRST 集不相交所以理论上LOOKAHEAD(1)就够。但实际中当存在if (cond) if (...) ... else ...这类嵌套时else的归属需要向前看 2 个 tokenif(才能确定是否属于外层if。本项目用LOOKAHEAD(2)是为覆盖此类边界 case而非文法本身需要。手写 LL(1) 不处理这种嵌套歧义它只保证单层if/while无冲突——这正是教学重点先掌握无歧义文法的 LL(1) 构造再理解工具如何扩展解决现实问题。3.3 教材例题验证用 Python 脚本跑通龙书Dragon Book经典案例项目提到“通过教材例子测试”所指极大概率是《编译原理》龙书第 4.4 节的 LL(1) 文法示例E → T E E → T E | ε T → F T T → * F T | ε F → ( E ) | idtools/ll1_validator.py就是为此定制它接受该文法的字符串描述自动计算 FIRST/FOLLOW生成parseE(),parseEp(),parseT()等函数并用输入id id * id测试。关键验证点有三个验证项预期行为实际检查方式parseE()是否调用parseT()后立即调用parseEp()是在 Python 脚本中插入print(call parseEp)parseEp()遇到时是否递归调用自身是输入id id id检查parseEp被调用次数parseT()遇到*时是否进入parseTp()循环是输入id * id * id检查parseTp被调用次数只有这三项全部通过才证明你对 LL(1) 的递归下降实现逻辑真正吃透。否则你在 JavaCC 里写的LOOKAHEAD可能只是碰巧 work而不是真正理解。4. 四层输出验证词法/语法/Basic/Mixed 结果的自动化生成与人工核验逻辑课程设计报告要求“词法分析结果全部准确无误语法分析结果全部准确无误Basic 结果全部准确无误Mixed 结果人工验证全部准确无误”。这四层输出不是四个独立模块而是一个数据流管道词法分析器输出 token 流 → 语法分析器消费 token 流并构建 AST → 语义分析器遍历 AST 填充符号表并生成 Basic 中间代码 → 解释器执行 Basic 代码并输出 Mixed即运行结果。本节拆解run_all.sh脚本如何串联这四步并指出每一层输出的人工核验要点。4.1 词法分析输出正则剥离注释后的 token 序列词法分析器src/lexer/MiniCLexer.java由 JavaCC 从MiniCLexer.jj生成核心是TOKEN定义// src/lexer/MiniCLexer.jj TOKEN : { ID: [a-z, A-Z] ([a-z, A-Z, 0-9, _])* | NUM: [0-9] | COMMENT: /* (~[*] | * ~[/, *])* * / | LINE_COMMENT: // (~[\n, \r])* (\n | \r | \r\n)? | WS: [ , \t, \n, \r] { skip(); } }关键点在于COMMENT和LINE_COMMENT的正则定义/* ... */使用非贪婪匹配(~[*] | * ~[/, *])*确保不会跨行错误吞掉*///注释则明确以\n或\r结束。skip()对WS空白符生效但对COMMENT不 skip——因为注释需被主动剥离而非忽略。run_all.sh中调用java MiniCLexer input.c后输出格式为TOKEN: ID, imagex TOKEN: ASSIGN, image TOKEN: NUM, image5 TOKEN: SEMICOLON, image; ...人工核验要点所有/* ... */和// ...必须完全消失不残留任何字符x5;应拆为ID(x),ASSIGN(),NUM(5),SEMICOLON(;)不能合并或错分if、while等关键字必须识别为KEYWORD类型而非ID。4.2 语法分析输出AST 的文本化树形结构语法分析器src/parser/MiniCParser.java生成的 AST 通过ASTPrinter类转为缩进文本// src/ast/ASTPrinter.java public class ASTPrinter implements ASTVisitor { private int indent 0; Override public Object visit(AssignNode node) throws Exception { System.out.println( .repeat(indent) AssignNode); indent 2; node.getLeft().accept(this); node.getRight().accept(this); indent - 2; return null; } }run_all.sh执行java ASTPrinter input.c输出类似ProgramNode FuncDefNode IdNode: main ParamListNode StmtListNode AssignNode IdNode: x NumNode: 5 IfNode RelExprNode IdNode: x GT NumNode: 0 StmtListNode AssignNode IdNode: y AddNode IdNode: x NumNode: 1人工核验要点IfNode必须有且仅有两个子节点RelExprNode条件和StmtListNodethen 分支else分支为空时不显示AddNode的左右子节点顺序不能颠倒左为x右为1这关系到后续中间代码生成的 operand 顺序FuncDefNode下必须有ParamListNode和StmtListNode即使函数无参无 body也要输出空节点。4.3 Basic 中间代码三地址码的生成规则与寄存器分配逻辑CodeGenerator类遍历 AST为每个节点生成 Basic 格式中间代码三地址码格式为t1 x 5或if t1 goto L2。关键规则AST 节点Basic 生成逻辑示例输入输出AddNode(l, r)tN l.code r.code其中l.code是左操作数寄存器名如t1r.code是右操作数寄存器名如t2x yt1 x; t2 y; t3 t1 t2IfNode(cond, thenBody)先生成cond.code再if cond.code goto L1然后thenBody.code最后L1:if (x 0) { y 1; }t1 x 0; if t1 goto L1; goto L2; L1: t2 1; L2:run_all.sh输出basic_output.txt人工核验要点每个左侧必须是tN形式临时变量不能是x或ygoto标签必须唯一且按顺序L1,L2,L3…不能重复或跳号if条件必须是布尔表达式结果如t1不能直接写if x 0 goto L1这是语法错误。4.4 Mixed 运行结果解释器的栈帧管理与输入模拟Interpreter类执行 Basic 代码核心是RuntimeStack和inputBuffer// src/interpreter/Interpreter.java private RuntimeStack stack new RuntimeStack(); private QueueString inputBuffer new ArrayDeque(); public void setInput(String... inputs) { inputBuffer.addAll(Arrays.asList(inputs)); } public String readInput() { return inputBuffer.poll(); // 模拟 stdin }run_all.sh执行java Interpreter basic_output.txt 1 2 3时1 2 3被注入inputBufferreadInput()每次返回队首。Mixed输出即System.out.println()的内容。人工核验要点input语句必须消耗inputBuffer中的值且顺序严格对应函数调用时stack.push(new StackFrame())必须在call前stack.pop()必须在return后局部变量修改不能影响外层作用域如内层x10不能改变外层x5。5. 避坑指南课程设计中最常翻车的五个边界场景与血泪排查路径编译器课程设计最折磨人的不是写不出代码而是写出来后“看起来对跑起来错debug 不出原因”。以下是我在重庆理工大学助教期间收集的最高频 5 类翻车现场每一条都来自真实提交的崩溃日志和深夜 QQ 截图。现象、原因、解决三步到位不绕弯。5.1 现象词法分析器卡死在/*注释里CPU 占用 100%原因COMMENT正则/* (~[*] | * ~[/, *])* * /中的*是贪婪匹配当遇到/* comment *** /末尾多个*时* ~[/, *]会反复尝试匹配*直到耗尽回溯栈。JavaCC 默认回溯深度有限最终抛出TokenMgrError: Lexical error。解决将COMMENT改为非贪婪、线性扫描式TOKEN : { COMMENT: /* ( (~[*])* | (* ~[/])* )* * / { skip(); } }更稳妥的做法是彻底放弃正则改用MORE状态机见MiniCLexer.jj中MORE : { ... }块但课程设计允许简化上述修正已足够。5.2 现象语法分析器报ParseException: Encountered if at line X, column Y. Was expecting one of: EOF ...原因if语句规则写成了IF ( Expression() ) { StatementList() }但Expression()规则可能以id开头而id也是Assignment的开头。JavaCC 在if后看到id无法决定该走Expression()还是跳过if去匹配其他语句触发预测失败。解决强制LOOKAHEAD指定if后必须跟(LOOKAHEAD(IF () IF ( Expression() ) { StatementList() }或者在Expression()规则前加LOOKAHEAD(2)确保它不会和Assignment冲突。5.3 现象Basic 输出里出现t1 t1 5导致无限循环原因AddNode的generateCode()方法错误地将左操作数的 code 直接赋给result而没新建临时变量// 错误写法 public String generateCode() { String leftCode left.generateCode(); String rightCode right.generateCode(); result leftCode; // ❌ 错应是 new temp var return result rightCode; }解决generateCode()必须返回新临时变量名并记录赋值语句到全局basicCode列表private static int tempCount 0; public String generateCode() { String leftCode left.generateCode(); String rightCode right.generateCode(); String temp t tempCount; basicCode.add(temp leftCode rightCode); return temp; // ✅ 正确返回新 temp 名 }5.4 现象Mixed输出中input语句读到null程序崩溃原因Interpreter.readInput()调用inputBuffer.poll()但inputBuffer初始化为空且run_all.sh未传入参数。脚本里java Interpreter basic.txt缺少输入值。解决在Interpreter构造函数中设置默认输入或修改脚本# run_all.sh 正确写法 java Interpreter basic_output.txt ${INPUT_ARGS:-0 0 0}并在Interpreter中public Interpreter(String basicFile, String... defaults) { this.inputBuffer.addAll(Arrays.asList(defaults)); }5.5 现象函数调用后局部变量丢失Mixed输出y为 0 而非预期值原因RuntimeStack的pop()发生在return语句执行前导致return读取的是已被销毁帧里的y。call()创建新帧但return逻辑写在pop()之后。解决return必须先保存返回值再pop()最后返回public Value executeReturn(ReturnNode node) { Value retValue node.getExpr().execute(this); stack.pop(); // ✅ 在 return 值获取后 pop return retValue; }6. 从命令行到可视化内存栈变化的实时打印与函数调用链还原技巧课程设计报告第 8 条要求“可视化展示函数调用时内存空间变化基于栈实现经人工验证结果正确无误。” 这里的“可视化”不是指 GUI 图形界面而是指在命令行中以结构化文本形式清晰呈现每次call/return时RuntimeStack的状态变化。本节给出一套可直接粘贴进Interpreter的打印模板并说明如何用它还原完整的函数调用链Call Stack这是人工核验“内存空间变化”的唯一可靠手段。6.1 RuntimeStack 的分层打印协议每一帧必须标注 scopeId 和 localsRuntimeStack不是简单的StackStackFrame而是维护一个DequeStackFrame每个StackFrame包含scopeId作用域编号和locals局部变量 Map。打印协议要求每次call时在stack.push(frame)后立即打印 CALL [funcName] (scopeIdN)每次return时在stack.pop()前打印 RETURN [funcName] (scopeIdN) value...每帧内容用缩进表示嵌套层级locals按keyvalue格式列出。// src/interpreter/RuntimeStack.java public class RuntimeStack { private DequeStackFrame frames new ArrayDeque(); private int scopeCounter 0; public void push(StackFrame frame) { frame.setScopeId(scopeCounter); frames.push(frame); System.out.println( CALL frame.getFuncName() (scopeId frame.getScopeId() )); printCurrentState(); } public StackFrame pop() { StackFrame top frames.pop(); System.out.println( RETURN top.getFuncName() (scopeId top.getScopeId() ) value top.getReturnValue()); printCurrentState(); return top; } private void printCurrentState() { int depth 0; for (StackFrame f : frames) { System.out.println( .repeat(depth) ├─ Scope f.getScopeId() ( f.getFuncName() )); for (Map.EntryString, Value e : f.getLocals().entrySet()) { System.out.println( .repeat(depth 1) │ e.getKey() e.getValue()); } depth; } if (frames.isEmpty()) { System.out.println( └─ [Empty Stack]); } } }6.2 函数调用链还原用 scopeId 关联 call/return 事件仅靠 CALL和 RETURN日志还不够必须能回答“main调用了foofoo又调用了bar那么bar的scopeId是多少它的locals里x的值从哪来” 答案藏在scopeId的递增序列里。假设日志如下 CALL main (scopeId1) ├─ Scope 1 (main) │ x 5 CALL foo (scopeId2) ├─ Scope 2 (foo) │ y 10 ├─ Scope 1 (main) │ x 5 CALL bar (scopeId3) ├─ Scope 3 (bar) │ z 15 ├─ Scope 2 (foo) │ y 10 ├─ Scope 1 (main) │ x 5 RETURN bar (scopeId3) value15 ├─ Scope 2 (foo) │ y 10 ├─ Scope 1 (main) │ x 5 RETURN foo (scopeId2) value10 ├─ Scope 1 (main) │ x 5 RETURN main (scopeId1) value5 └─ [Empty Stack]人工核验时只需按scopeId排序就能还原调用链scopeId1是mainscopeId2是main调用的fooscopeId3是foo调用的bar。每个scopeId下的locals就是该函数的私有内存空间x在scopeId1中定义y在scopeId2中定义z在scopeId3中定义——它们互不干扰完美体现栈的隔离性。6.3 一个真实调试案例为什么foo返回后main的x变成了 0某同学提交的日志显示 CALL main (scopeId1) ├─ Scope 1 (main) │ x 5 CALL foo (scopeId2) ├─ Scope 2 (foo) │ x 0 // ❌ 问题在这里foo 里声明了同名 x但没隔离 RETURN foo (scopeId2) value0 ├─ Scope 1 (main) │ x 0 // ❌ main 的 x 被污染了原因是他没在foo的enterScope()中新建SymbolTable而是复用了main的表导致foo的x覆盖了main的x。修复方法在foo的parseFunctionDefinition()语义动作中显式new SymbolTable()并传入StatementList()确保foo的locals存在独立StackFrame中。从那以后我每次 review 编译器作业第一件事就是 grep 日志里的scopeId看是否严格递增、是否每帧locals独立、是否call/return成对出现。这比盯着 AST 树看一百遍都管用——因为内存栈是编译原理里最诚实的裁判它不撒谎只反映你代码里真实的控制流与数据流。希望帮到你。本文还有配套的精品资源点击获取