ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaCC实现类C编译器:从词法分析到MIPS生成的完整实践

JavaCC实现类C编译器:从词法分析到MIPS生成的完整实践 简介重庆理工大学编译原理课程设计完整项目基于Java与JavaCC实现了一个类C编译器适合编译原理课程学习者、准备相关课程设计的学生参考。项目涵盖词法分析、语法分析、递归下降解析、LL1算法验证及函数调用时内存空间变化的可视化展示功能完整验证结果准确。资源共380个文件压缩包仅3.02MB主要包括Java源码、jj语法文件、class字节码、out运行输出、sample测试用例、txt说明文档及自动化脚本等目录层次清晰便于按模块查阅。已有857人学习下载说明该资源对同类课程设计有不错的参考价值。通过这份资源读者可获得完整工程结构与实现思路包括多个测试样例的运行结果、脚本自动生成解析输出的方法以及基于栈的内存变化可视化演示能够直接用于学习与复现类C编译器的关键构建环节。1. 拿到“类C编译器”课设先别慌JavaCC把最枯燥的词法语法活干了一半如果拿到重庆理工大学的编译原理课程设计题目《java javacc 类C编译器》第一反应往往是先翻编译原理教材第二章的答案想背熟文法理论再动手。但现实是课设里真正让你翻车的不是文法推导而是 JavaCC 这个生成器的脾气左递归、LOOKAHEAD、token 优先级、符号表作用域、MIPS 栈偏移每一项都能耗掉一个通宵。这个题目要做的事很明确用 JavaCC 写一个类C子集的词法分析与语法分析器再做语义分析和目标代码生成最终把一个 .c 文件编译成 MIPS 汇编并在 SPIM 里跑出正确输出。适合想把前端后端完整走一遍、同时不排斥用 Java 写工程代码的同学。只求及格的话停在语法分析也能交差但既然工具选了 JavaCC不把后端打通就太亏了。2. 用JavaCC把词法语法一次做完.jj文件结构与LL(1)表达式的写法2.1 JavaCC的.jj三段式options、PARSER_BEGIN与token优先级JavaCC 与手写递归下降的最大区别是你只需要维护一个 .jj 源文件词法分析器和语法分析器的 Java 代码都由它生成。.jj 文件的结构分三段开头是options {}中间是PARSER_BEGIN(类名)到PARSER_END(类名)的 Java 代码区后面是 token 规则和语法产生式。初学者最容易犯的第一个错是把PARSER_BEGIN(CCParser)里的类名和文件名搞不一致。JavaCC 会按这个名字生成对应的 .java 文件文件名对不上时 javac 直接拒绝编译。常见做法是统一叫CCParser生成的 Java 入口也叫CCParser.java。下面是一个最小可用的 token 定义片段覆盖类C编译器最基础的关键字、标识符、整型字面量和运算符options { LOOKAHEAD 1; STATIC false; } PARSER_BEGIN(CCParser) import java.io.*; import java.util.*; public class CCParser { public ListInstr ir new ArrayList(); } PARSER_END(CCParser) SKIP : { | \t | \n | \r } TOKEN : { IF: if | ELSE: else | WHILE: while | INT: int | RETURN: return | ID: ([a-z,A-Z,_]) ([a-z,A-Z,0-9,_])* | DEC: ([0-9]) | PLUS: | MINUS: - | STAR: * | SLASH: / | ASSIGN: | EQ: | NEQ: ! | LT: }这里有两个关键点。第一LOOKAHEAD 1是最保守的设置意思是解析器只需要看一个 token 就能决定走哪个分支如果你后面遇到 ParseException再按实际情况调大但默认值不要乱改。第二关键字 token 必须写在 ID 之前。JavaCC 的匹配规则是“最长匹配优先长度相同则按定义顺序优先”如果先把 ID 放在前面if、int、while全会被当成标识符后面所有语法规则瞬间失效。这也是很多 java 课程设计案例源码里一跑就报“Encountered ...”的根源。运行阶段只需要三步javacc CCParser.jj生成 Java 源文件javac CCParser.java编译最后写一个 main 方法读入 .c 文件并调用CCParser。生成的 Java 文件里真正需要你关心的只有两张表生成文件职责CCParser.java语法分析器主类包含所有产生式的解析方法CCParserTokenManager.java词法分析器负责把字符流切成 tokenCCParserConstants.javatoken 类型的常量定义ParseException.java / TokenMgrError.java语法错误和词法错误的异常类Token.java / SimpleCharStream.javatoken 对象与字符流实现2.2 类C表达式文法左递归改写成LL(1)与左结合动作JavaCC 默认生成 LL(1) 解析器意味着文法里不能出现左递归。教材里的表达式文法通常写成E - E T这种规则在 JavaCC 里直接写会报“Left recursion detected”你需要把它改写成循环形式。表达式优先级从低到高可以分解成五层逻辑或、逻辑与、相等比较、关系比较、加减、乘除、一元运算、基础表达式。每一层都是一个独立产生式下一层被上一层调用。下面拿加减法这一层举例int AddExpr(): { int left; int right; } { left MulExpr() ( PLUS right MulExpr() { left emitBinary(left, , right); } | MINUS right MulExpr() { left emitBinary(left, -, right); } )* { return left; } }AddExpr的返回值是一个临时变量编号。每解析到一个或-就把左边的临时编号、运算符和右边的临时编号交给emitBinary由它生成一条三地址码指令。辅助方法放在PARSER_BEGIN的 Java 代码区里private int tempCount 0; private int emitBinary(int a, String op, int b) { int t tempCount; ir.add(new Instr(BINARY, t, a, op, b)); return t; }两个参数说明tempCount是全局递增的临时变量编号这样每条指令的目标变量都唯一ir是之前定义的三地址码列表语法分析结束后直接遍历它生成 MIPS。这里把“解析”和“中间代码生成”揉在一起是课设最省事的做法省掉了一整套 AST 节点和遍历器。等做完这个版本再谈 AST 也不迟。2.3 语句与函数定义if/while的TAC骨架与悬空else语句层的核心是 if、while、复合语句和函数定义。下面这段是 if 语句的完整 JavaCC 产生式注意它一边解析一边把跳转指令写进irvoid IfStmt(): { int cond; int L1; int L2; } { IF ( cond Expression() ) { L1 newLabel(); L2 newLabel(); ir.add(new Instr(IF_FALSE, cond, L1)); } Statement() { ir.add(new Instr(GOTO, L2)); ir.add(new Instr(LABEL, L1)); } [ ELSE Statement() ] { ir.add(new Instr(LABEL, L2)); } }这段代码对应的是典型的 if-else 跳转结构条件为假跳到L1跳过 then 分支then 分支执行完跳到L2也就是 else 结束的地方。newLabel()和newTemp()一样都是全局计数器保证每个 label 编号唯一。悬空 else 的问题在 JavaCC 里其实没那么可怕。JavaCC 的 LL(1) 决策天然让 else 就近匹配到最近的 if不需要像 Yacc 那样靠优先级声明消除冲突。你真正要注意的是别在[ ELSE Statement() ]前面乱加LOOKAHEAD否则可能改变匹配行为。保持默认即可。函数定义的骨架比语句更看重作用域。常见做法是解析函数头时新建一个符号表作用域把形参按顺序写入然后解析函数体复合语句解析完成后弹出这个作用域。形参的偏移量在这里就要算好留给后面的 MIPS 生成用void FunctionDef(): { String name; } { typeSpec() name ID ( parameterList() ) { enterScope(); } CompoundStmt() { exitScope(); } }enterScope/exitScope是第三章要说的符号表操作这里只要理解一个原则函数每进入一层{}就压入一个作用域出{}就弹出。写反了后面的变量遮蔽和栈偏移会全部错乱。3. 语义分析与三地址码符号表、类型检查和TAC指令集设计3.1 作用域链符号表重复定义、变量遮蔽与栈偏移分配语法分析只解决“句子合不合文法”至于变量有没有定义、类型匹不匹配需要符号表来回答。这个课设的符号表不复杂用一张 Map 加上一个指向父作用域的指针就够。设计成链表式而不是单个 Map 的好处是天然支持块级作用域和变量遮蔽。class Scope { Scope parent; MapString, VarInfo vars new LinkedHashMap(); Scope(Scope parent) { this.parent parent; } void define(String name, Type type, int offset) { if (vars.containsKey(name)) { throw new CompileError(duplicate definition: name); } vars.put(name, new VarInfo(type, offset)); } VarInfo lookup(String name) { for (Scope s this; s ! null; s s.parent) { if (s.vars.containsKey(name)) { return s.vars.get(name); } } return null; } }三个设计细节要说明。第一define用的是“当前层是否已存在”判断存在就报重复定义lookup则逐层往上找允许内层遮蔽外层这是 C 语言语义的一部分。第二每个VarInfo里带了一个offset这个偏移是相对$fp的栈偏移在语义分析阶段先分配一个假想偏移最终由代码生成阶段换算成实际数值。第三LinkedHashMap保持变量定义顺序方便排查问题时按声明顺序输出符号表。作用域链的压栈和弹栈必须由解析方法严格配对。常见写法是解析复合语句时enterScope()解析完exitScope()。如果中途抛出异常很可能导致作用域没弹出后面的解析全部建立在脏作用域上。所以我一般会在CompoundStmt()里用 try/finallyvoid CompoundStmt(): {} { { enterScope(); } { try { } finally { exitScope(); } } ( declaration() | statement() )* }try/finally保证即使语义错误中断作用域也会被弹出。这种防御性写法看起来笨但在课设答辩时能少解释很多“为什么这个变量莫名其妙能用”的问题。3.2 类型检查与隐式转换int/float混合运算的规则类C语言里最常见的类型错误是float和int混用、比较运算两边类型不一致、把数组名当普通变量赋值。类型检查的正确姿态不是一报错就停而是尽量多收集错误让报告看起来更完整。类型转换核心是一个unify函数它返回两个类型运算后的公共类型Type unify(Type a, Type b, String op) { if (a b) { return a; } if (a Type.FLOAT b Type.INT) { return Type.FLOAT; } if (a Type.INT b Type.FLOAT) { return Type.FLOAT; } throw new CompileError(type mismatch in op : a vs b); }逻辑很直白同类型直接返回int 遇到 float 就提升成 float其余组合一律报错。真正要小心的是赋值运算的方向性。float f 1;是 int 到 float 的隐式转换允许int i 1.5;是 float 到 int在标准 C 里会有精度丢失警告课设里可以直接报错也可以降级成警告并保留误差。我的建议是报成警告、继续生成代码这样测试样例里不会因为一个浮点赋值直接崩掉。数组类型在符号表里也要单独标记。int a[10]的VarInfo要记录数组维度lookup之后访问元素需要loadArray类型指令。类型检查时数组名本身不能参与加减乘除只能出现在下标表达式的左边。3.3 TAC指令集为什么选择线性IR而不是AST三地址码TAC是这个课设的中间表示核心它的特点是每条指令最多三个地址一个目标、两个源外加一个运算符。线性 IR 的好处是数据结构简单、生成 MIPS 时遍历顺序和机器码顺序完全一致对课设这种不追求优化的场景非常合适。TAC指令含义t a op b二元运算op 支持 - * / % ! ||t op a一元运算op 支持 - 和 !GOTO L无条件跳转到标签 LIF_FALSE t L临时变量 t 为 0 时跳转到 LCALL t f a1 a2 a3调用函数 f实参最多支持 4 个返回值存入 tRET t从当前函数返回 t 的值LABEL L标签定义作为跳转目标PRINT_INT t打印整数映射到 MIPS syscall 1为什么选线性 IR 而不是先建 ASTAST 的优点是可以做多趟分析比如先建树、再做类型检查、最后生成代码。但课设周期就这么点时间边解析边发 TAC 能省掉 AST 节点类和遍历器的代码量出问题也好定位——直接看ir列表就能知道某条语句翻译成了什么。代价是如果想做优化或者更严格的语义分析后续还得把 IR 再展开一层。Instr类不需要设计得太复杂一个枚举操作码、三个参数字段、一个标签字段就够了class Instr { String op; int dst; int arg1; String arg2; // 运算符或跳转标签 int arg3; }字段说明dst是目标临时变量编号arg1和arg3是源临时变量编号arg2是字符串形式的运算符或标签名。临时变量与源码变量统一编号MIPS 生成时通过一个stackOff(编号)方法换算成实际栈偏移。这套设计撑住几百行 C 子集代码完全没有问题真正到 2000 行以上才会感觉到线性 IR 排错变累那就是后续重构 AST 的信号了。4. 目标代码生成TAC到MIPS的映射策略与函数栈帧4.1 表达式求值的MIPS模板临时变量全上栈的栈式寄存器分配目标代码生成最让课设新手头疼的是寄存器分配。完整做图染色寄存器分配在课设里不现实但可以换一种思路把所有变量和临时变量全部放在栈上运算时用$t0和$t1两个通用寄存器临时取值算完立刻写回栈。这就是栈式局部寄存器分配牺牲性能换取正确性和实现速度。每个函数在序言阶段就根据符号表统计出的变量数量一次性分配栈空间。比如当前函数有 3 个局部变量、6 个临时变量总共 9 个槽位每个槽 4 字节帧大小就是 36 字节。MIPS 生成器只需要维护一张“变量编号 - 栈偏移”的映射表生成代码时查表即可。void genBinary(Instr i) { int off1 stackOff(i.arg1); int off2 stackOff(i.arg3); int offDst stackOff(i.dst); out.printf(lw $t0, %d($fp)%n, off1); out.printf(lw $t1, %d($fp)%n, off2); switch (i.arg2) { case - out.println(add $t0, $t0, $t1); case - - out.println(sub $t0, $t0, $t1); case * - out.println(mul $t0, $t0, $t1); case / - out.println(div $t0, $t0, $t1); case % - { out.println(div $t0, $t0, $t1); out.println(mfhi $t0); } } out.printf(sw $t0, %d($fp)%n, offDst); }参数说明偏移量是负数因为局部变量区在$fp下方MARS支持mul伪指令直接写三个寄存器div在 SPIM 里没有三操作数版本必须配合mflo/mfhi取商和余数。这里最需要注意的是除法和取模很多人在这两个指令上翻车因为div的结果不会自动出现在$t0里。比较运算也遵循同样模板只不过用slt、seq等指令生成 0/1 整数C表达式MIPS模板a bslt $t0, $t0, $t1a bsle $t0, $t0, $t1a bseq $t0, $t0, $t1a band $t0, $t0, $t1非短路a || bor $t0, $t0, $t1非短路非短路求值是故意简化。标准 C 的和||有短路语义左边结果确定后右边不再求值课设如果按普通二元运算生成遇到int x (1 ! 0) (a / 0 1);这类用例会得出错误结果。最稳妥的做法是在文档里明确写“不支持短路求值”或者为这两种运算符单独生成IF_FALSE跳转实现短路后者涉及临时变量的延迟赋值代码量不小。4.2 控制流的Label编号与跳转模板TAC 里的GOTO和IF_FALSE翻译成 MIPS 非常简单难的是 label 的编号策略。翻译时不能直接用字符串标签名否则冲突和覆盖很难排查。我用一个整数计数器给每个 label 按顺序编号输出时统一加前缀.Lint labelCount 0; int newLabel() { return labelCount; }这样 while 循环的 TAC 序列长这样L1: # 循环开始 cond a b IF_FALSE cond L2 循环体 GOTO L1 L2: # 循环出口对应 MIPS 代码是.L1: # cond 表达式求值结果保存在栈槽 lw $t0, off_cond($fp) beq $t0, $zero, .L2 # 循环体 j .L1 .L2:beq $t0, $zero, .L2就是IF_FALSE的直接翻译条件为 0 时跳走。这里要提醒一个 MIPS 延迟槽的问题SPIM 默认不模拟延迟槽beq后面那条指令不会先执行但如果你用某些带延迟槽模拟的 MIPS 模拟器j .L1后面那条指令会被执行一次导致莫名其妙多跑一行。课设统一用 SPIM 默认配置就行别在报告里引入“延迟槽填充分配”这种自己给自己加戏的概念。break 和 continue 的翻译需要维护一个循环 label 栈。进入 while 或 for 时把{beginLabel, endLabel}压栈break 生成GOTO endLabelcontinue 生成GOTO beginLabel。出循环时弹栈。没有这个栈嵌套循环里的 break 会跳到错误的出口这种 bug 在命令行调试里很难一眼看出来。4.3 函数调用约定$fp/$ra保存、参数压栈顺序与栈帧布局函数调用是 MIPS 生成里最容易写乱的部分。我的做法是定一个简单统一的调用约定实参全部压栈传递不用$a0-$a3返回值统一放$v0被调函数负责用栈保存$fp和$ra。栈帧布局从高地址到低地址固定如下偏移内容8($fp)第一个参数第二个参数在 12($fp)依次递增4($fp)旧 $ra0($fp)旧 $fp-4($fp) 及以下局部变量和临时变量区函数序言和返回代码是两块模板funcName: addiu $sp, $sp, -frameSize sw $fp, 0($sp) sw $ra, 4($sp) move $fp, $sp # 函数体 move $sp, $fp lw $ra, 4($sp) lw $fp, 0($sp) addiu $sp, $sp, frameSize jr $raframeSize由符号表统计的局部变量槽数决定编译器生成函数头时先查表算出这个数值再输出序言。参数为什么在8($fp)而不是负偏移因为参数是调用者压栈的压栈方向是从高地址向低地址生长返回地址和被调函数保存的$fp叠在参数区的上方。调用点的翻译相对直接。下面这段生成实参压栈和jal指令void genCall(Instr i) { // 从右往左压栈保证形参顺序 for (int k i.args.size() - 1; k 0; k--) { out.printf(lw $t0, %d($fp)%n, stackOff(i.args.get(k))); out.printf(addiu $sp, $sp, -4%n); out.printf(sw $t0, 0($sp)%n); } out.printf(jal %s%n, i.funcName); out.printf(addiu $sp, $sp, %d%n, i.args.size() * 4); // 弹出参数区 if (i.dst 0) { out.printf(sw $v0, %d($fp)%n, stackOff(i.dst)); } }参数说明从右往左压栈是为了让第一个参数最后入栈、处于栈帧中8($fp)的位置这样被调函数用固定偏移就能取到参数。jal之后立刻弹出参数区属于调用者清理压栈这符合简化调用约定。递归函数在这种约定下也能正确工作因为每次调用都有独立的栈帧和参数区。5. 编译原理课设避坑JavaCC、符号表与MIPS阶段的5个现场问题5.1 ParseException发生在奇怪位置LOOKAHEAD与公共前缀现象输入int a 1;报错位置指向 1;附近或者直接说 “Encountered expected one of ...”但看源码明明语法没问题。原因JavaCC 是 LL 文法当前产生式面临多个可选分支时如果公共前缀太长一个 token 的 lookahead 不足以区分。比如声明语句和表达式语句都可能以标识符开头LOOKAHEAD 1时解析器只能猜。解决先找公共前缀把( A | B )改写成A 开头的内容 ( 区分token之后的分支 )*实在区分不了就在产生式开头加LOOKAHEAD(2)。我一般做法是先用最小编译例子定位是哪个产生式再逐层往上加 lookahead而不是一上来就全局LOOKAHEAD(10)——那会让解析器性能崩掉而且掩盖真正的文法缺陷。5.2 变量“没定义”却到处能用作用域链pop时机错乱现象函数结束后函数内的局部变量在全局作用域居然还能查到或者两个同级函数之间变量串味。原因enterScope()和exitScope()的调用没有严格包围复合语句。遇到语义错误抛异常时exitScope()没执行作用域栈里残留了本该弹出的层。解决把符号表作用域的进入和退出包在CompoundStmt()里用try/finally保证必然弹出。建议在符号表里加一个assertScopeDepth()方法在函数结束点检查作用域深度是否回到基准值不然就抛内部错误。这个检查在课设调试期能救命。5.3 生成的MIPS在SPIM里跑飞忘了退出系统调用现象程序输出正确但 SPIM 报Exception occurred at PC...或者退出码异常。原因main 函数执行完直接jr $ra但 SPIM 的启动代码不会帮你做系统退出。MARS 里jr $ra可能直接终止SPIM 里则继续执行栈上的垃圾数据。解决在 main 返回后追加退出系统调用而不是依赖jr $ramain: # 调用编译器入口 li $v0, 10 syscall这段代码写死在 main 函数生成的末尾无论 main 是否有 return。另外要检查所有jal的返回路径是否都有jr $ra漏掉一个函数返回时就会顺着栈乱跳。5.4 字符串常量存进.data后内容不对转义和JavaCC字面量现象print(hello\n);输出结果里出现了反斜杠和 n 两个字符而不是换行。原因JavaCC 匹配到的字符串 token 是原始字符序列\n在 C 源码里是两个字符编译器没有把它们转换成一个换行符就写进了.asciiz。解决解析字符串 token 时手动做转义转换。遇到\\转成反斜杠、\转成双引号、\n转成 0x0A、\t转成 0x09。这个处理要放在字符数组写入符号表之前否则后续所有字符串输出都会错位。我在避坑清单里专门提它是因为字符串转义 bug 在命令行肉眼检查时最容易被误判成 SPIM 的问题。5.5 实参顺序反了MIPS调用约定和栈式传参的偏移现象写一个sub(int a, int b)调用sub(x, y)函数里拿到的第一个参数变成了 y。原因压栈顺序写反或者形参偏移计算错误。如果从前往后压栈第一个参数反而被压到栈底距离$fp更远导致参数错位。解决统一从右往左压栈形参偏移严格按8 index * 4计算。写完后用一个简单的sum(1,2,3,4)用例验证四个参数分别取出来相加结果不对就打印符号表里的形参偏移表逐个核对。6. 验收前自测冒烟用例矩阵与一个可回归的shell脚本课设验收最尴尬的事是现场演示时编译器崩在一个没测过的用例上。提前准备一张冒烟用例矩阵覆盖最常见的语法和语义边界比自己随手点几个文件可靠得多。测试文件覆盖点期望输出hello.c表达式、打印、main返回Helloarith.c运算符优先级、括号、取模计算结果scope.c块级作用域、变量遮蔽内外层变量值分离loop.cwhile、break、continue循环累计值fib.c递归函数、参数、返回值斐波那契数列type_err.c类型不匹配、重复定义编译期报错且定位准确把期望输出存成.expected文件再用脚本批量比对。以下是一个简单的回归脚本每次改完编译器跑一遍10 秒内能发现改动是否破坏旧功能#!/bin/bash fail0 mkdir -p build for c in tests/*.c; do name$(basename $c .c) asmbuild/$name.asm outbuild/$name.out java -cp out CCCompiler $c $asm || { echo COMPILE FAIL: $name fail1 continue } spim -quiet -file $asm $out 21 if ! diff -q tests/$name.expected $out /dev/null; then echo RUNTIME FAIL: $name fail1 fi done exit $fail脚本说明spim -quiet关闭欢迎信息让输出干净可比对diff -q只返回是否一致。如果环境里没有命令行spim可以用 MARS 的 jar 包跑java -jar mars.jar nc file.asm两者的退出码和输出格式略有差异先单独跑 hello.c 确认环境可用再上回归。我的个人习惯是每次都把“字符串转义 递归函数 break 嵌套”这三个最容易出鬼的用例放在回归集第一位因为它们分别对应词法、调用约定和控制流三条最容易出边界 bug 的链路。这个课设做完你收获的不只是一套能跑的编译器还有一套“改动代码后如何验证没改坏”的方法论。面试时被问到 java 基础、JVM 类加载或者编译原理八股你能从符号表作用域链和栈帧布局的角度讲出细节比单纯背题生动得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表