☰
福州大学编译原理实践包:词法语法语义三阶段Java工程实战
2026/10/10 1:11:09 网站建设 项目流程

简介:本资源是福州大学《编译原理》课程配套实践项目完整交付包,面向计算机专业本科生及编译技术初学者,系统覆盖词法分析、语法分析与语义分析三大核心实验环节。压缩包共25个文件,含18个Java源码(实现词法扫描器、SLR(1)语法分析器及三地址码生成器)、3份Word实践报告(含文法设计、分析表构造、SDD/SDT定义与错误处理机制说明)、3个PPT实验要求文档及1个文法描述文本,整体体积3.07MB,结构清晰、模块对应明确。已有967人学习下载,可直接用于课程实验复现、SLR(1)分析表手算验证、错误信息定向捕获与三地址码生成逻辑调试。代码具备完整输入解析、栈状态跟踪、错误文件输出及中间代码可视化能力,报告中详述了大小写不敏感语言的Token设计、SLR(1)冲突消解过程及语义动作嵌入策略,是理解编译前端全流程的优质教学参考。

1. 福州大学编译原理实践:为什么一个 ZIP 包能帮你把词法分析、语法分析、语义分析三关从“纸上谈兵”拉进真实可跑的工程现场?

这不是一份泛泛而谈的实验讲义 PDF,也不是只贴伪代码的 PPT 汇报稿——它是一个结构清晰、开箱即用的完整实践工程包(.zip),内含福州大学计算机学院多年迭代打磨的编译原理课程实践骨架。里面没有空洞的“请自行实现 Tokenizer”,而是直接给你Lexer.java的起始框架 + 预置正则规则表;不是让你凭空手写 LL(1) 分析表,而是附带了ParserGenerator工具链和配套的.g4语法定义模板;更关键的是,它把最容易被跳过的语义分析环节落到了实处:有符号表管理类SymbolTable的接口定义、类型检查的桩函数、甚至预留了中间代码生成(三地址码)的插入点。适合两类人:一是刚学完龙书第 2–4 章、对着课本例题能推但一写代码就卡在状态机跳转或 FIRST/FOLLOW 集计算上的本科生;二是想快速搭建教学演示系统、避免从零造轮子的助教或青年教师。它不替代理论推导,但能让你在 2 小时内跑通一个带错误定位的整数加减乘除表达式编译器,并亲眼看到 token 流 → 语法树 → 符号表 → 四元式序列的全链路输出。


2. 从解压到首行输出:用 Java 快速跑通词法分析器最小闭环

福州大学这套实践包默认以 Java 为实现语言(符合国内高校主流教学栈),所有模块均基于 JDK 8+ 编写,无外部 Maven 依赖,纯 JDK 标准库即可驱动。核心逻辑全部封装在src/目录下,结构干净:lexer/、parser/、semantics/三级包名直指三大分析阶段。我们先聚焦最底层——词法分析,这是整个编译流程的“守门员”,也是最容易验证、最容错的起点。

2.1 解压与目录结构确认:别急着编译,先看清骨架

unzip "福州大学编译原理实践(词法分析、语法分析、语义分析).zip" cd fzu-compiler-practice/ ls -R src/

你会看到:

src/ ├── lexer/ │ ├── Lexer.java # 主词法分析器入口,已含 run() 方法 │ ├── Token.java # Token 类定义(type, value, line, col) │ └── RegexRule.java # 正则规则配置类(预置 NUM, ID, PLUS, MINUS...) ├── parser/ │ ├── Parser.java # 语法分析器桩(待你填充递归下降逻辑) │ └── ASTNode.java # 抽象语法树节点基类 └── Main.java # 入口类,调用 lexer.run("test.exp")

提示:test.exp是包内自带的测试文件,内容为a = 123 + 45 * b;—— 这是典型的教学级表达式,覆盖标识符、数字、运算符、分号,足够验证词法层。

2.2 修改 Lexer.java:三步注入你的规则,让正则真正“动起来”

打开src/lexer/Lexer.java,找到private List<RegexRule> rules初始化块。默认规则是静态数组,但实际运行时需动态加载。常见翻车点在于:规则顺序没按“最长匹配优先”排列,导致>=被拆成>和=两个 token。我们手动重排并补全:

// src/lexer/Lexer.java 内部,替换原有 rules 初始化 private List<RegexRule> rules = Arrays.asList( new RegexRule("ASSIGN", "="), // 赋值号 new RegexRule("GE", ">="), // 大于等于(必须放 GT 前!) new RegexRule("GT", ">"), // 大于 new RegexRule("LE", "<="), // 小于等于 new RegexRule("LT", "<"), // 小于 new RegexRule("PLUS", "\\+"), // 加号(注意转义) new RegexRule("MINUS", "-"), new RegexRule("MUL", "\\*"), new RegexRule("DIV", "/"), new RegexRule("SEMI", ";"), new RegexRule("LPAREN", "\\("), new RegexRule("RPAREN", "\\)"), new RegexRule("ID", "[a-zA-Z_][a-zA-Z0-9_]*"), // 标识符:字母/下划线开头 new RegexRule("NUM", "[0-9]+"), // 整数:至少一位数字 new RegexRule("WS", "\\s+"), // 空白符:跳过,不生成 token new RegexRule("ERROR", ".") // 兜底:任何非法字符 );

参数说明:

  • RegexRule(String type, String pattern)中pattern是 JavaPattern语法,\\+表示字面量+,\\s+匹配空白;
  • WS规则必须存在且类型为"WS",Lexer 内部逻辑会自动跳过它,不加入 token 列表;
  • ERROR规则放在最后,确保只有前面所有规则都不匹配时才触发,便于定位非法输入。

2.3 编译并运行:观察 token 流是否符合预期

# 在项目根目录执行(确保 JAVA_HOME 指向 JDK 8+) javac -d bin src/*.java src/lexer/*.java src/parser/*.java java -cp bin Main

预期输出(截取关键部分):

[Token{type='ID', value='a', line=1, col=1}] [Token{type='ASSIGN', value='=', line=1, col=3}] [Token{type='NUM', value='123', line=1, col=5}] [Token{type='PLUS', value='+', line=1, col=9}] [Token{type='NUM', value='45', line=1, col=11}] [Token{type='MUL', value='*', line=1, col=14}] [Token{type='ID', value='b', line=1, col=16}] [Token{type='SEMI', value=';', line=1, col=17}]

逻辑说明:Main.java中new Lexer().run("test.exp")会逐行读取文件,对每行调用scanLine(),内部用Matcher.find()按规则顺序尝试匹配。每次成功匹配后,将start()到end()的子串提取为value,结合当前行列号构造Token。若遇到ERROR规则匹配,则抛出LexicalException("Illegal character at line X, col Y")—— 这是你调试输入格式的第一道防线。


3. 语法分析落地:用递归下降法手写 Parser,绕过 ANTLR 黑匣子

很多学生一听到“语法分析”就本能想搜 ANTLR 或 JavaCC,但福州大学这套实践的深意在于:强制你亲手写parseExpr() → parseTerm() → parseFactor()的调用链,理解预测分析中“向前看符号”的真实作用。包里Parser.java不是空文件,而是留了骨架和占位异常,你要填的是控制流逻辑,不是胶水代码。

3.1 理解给定文法:它决定了你必须写的函数数量

打开doc/grammar.txt(包内文档),核心文法如下(简化版):

Program → StmtList EOF StmtList → Stmt StmtList | ε Stmt → ID ASSIGN Expr SEMI | PRINT LPAREN Expr RPAREN SEMI Expr → Term { AddOp Term } Term → Factor { MulOp Factor } Factor → ID | NUM | LPAREN Expr RPAREN AddOp → PLUS | MINUS MulOp → MUL | DIV

选型理由:这是典型的 LL(1) 文法(无左递归、无公共前缀),适合递归下降。{...}表示 0 到多次重复,对应 Java 中while (lookahead == PLUS || lookahead == MINUS)循环。你不需要手算 FIRST/FOLLOW 集,但必须根据lookahead(当前未消费的 token 类型)决定分支——这正是Parser.java中nextToken()和match(TokenType)的设计目的。

3.2 实现 parseExpr():抓住“左结合性”和“运算符优先级”的代码映射

在src/parser/Parser.java中,补全parseExpr()方法:

// src/parser/Parser.java public ASTNode parseExpr() { ASTNode left = parseTerm(); // 先解析高优先级的 term(乘除) while (lookahead.getType() == TokenType.PLUS || lookahead.getType() == TokenType.MINUS) { TokenType op = lookahead.getType(); match(op); // 消费运算符 token ASTNode right = parseTerm(); // 再解析下一个 term left = new BinaryOpNode(op, left, right); // 构建左结合树 } return left; }

参数说明与关键细节:

  • lookahead是 Parser 维护的“窥视”token,初始由nextToken()从 Lexer 获取;
  • match(TokenType t)会校验lookahead.getType() == t,若不符则抛SyntaxException并打印期望类型(如 “expected PLUS or MINUS, got SEMI”);
  • BinaryOpNode是ASTNode子类,op存运算符类型,left/right指向子树——这直接对应龙书图 2.15 的抽象语法树结构;
  • 为什么 parseTerm() 在循环内?因为Expr → Term {AddOp Term},每个AddOp后必须跟一个Term,而非Expr,否则无法保证乘除优先于加减。

3.3 验证语法树:用 toString() 可视化结构,比 debug 断点更高效

ASTNode类已重写toString(),返回缩进式树形文本。在Main.java末尾添加:

// Main.java 新增 Lexer lexer = new Lexer(); Parser parser = new Parser(lexer); ASTNode root = parser.parseProgram(); // 调用顶层解析函数 System.out.println(root.toString());

对a = 1 + 2 * 3;的输出应为:

AssignStmt id: a expr: BinaryOp(PLUS) left: Num(1) right: BinaryOp(MUL) left: Num(2) right: Num(3)

逻辑说明:toString()采用深度优先递归,每层缩进 2 空格。BinaryOp(MUL)出现在right下,证明2 * 3被正确识别为Term的子结构,而非1 + 2先算——这正是parseExpr()中parseTerm()被反复调用的结果。如果输出变成BinaryOp(PLUS)下挂Num(1)和Num(2),说明你误在parseExpr()循环里调用了parseExpr()自身,导致左递归崩溃。


4. 语义分析实战:符号表不是概念,是必须手写的 HashMap + 类型检查桩

词法、语法分析产出的是“形式正确”的结构,但int a = "hello";在语法上完全合法(ID ASSIGN STRING SEMI),语义分析才是真正的“守门员”。福州大学包里semantics/目录不是空的,它提供了SymbolTable接口和SimpleSymbolTable实现,但类型检查逻辑(TypeChecker)完全留白——这正是你建立“编译器是有血有肉的程序”认知的关键一步。

4.1 SymbolTable 设计:为什么用嵌套 HashMap 而非单层?

打开src/semantics/SymbolTable.java,核心是:

public class SimpleSymbolTable implements SymbolTable { private final Map<String, Symbol> currentScope = new HashMap<>(); private final Stack<Map<String, Symbol>> scopeStack = new Stack<>(); public void enterScope() { scopeStack.push(new HashMap<>()); } public void exitScope() { scopeStack.pop(); } public void define(String name, Symbol symbol) { if (!scopeStack.isEmpty()) { scopeStack.peek().put(name, symbol); } else { currentScope.put(name, symbol); } } public Symbol resolve(String name) { // 从栈顶 scope 向下查,模拟 C 语言作用域 for (int i = scopeStack.size() - 1; i >= 0; i--) { Symbol s = scopeStack.get(i).get(name); if (s != null) return s; } return currentScope.get(name); } }

为什么这样设计?

  • enterScope()/exitScope()对应{ }代码块,支持局部变量遮蔽全局变量(如函数内int a;遮蔽全局a);
  • resolve()从栈顶向下查,保证最近作用域优先,无需传参“当前作用域”;
  • Symbol类含name,type,line,isConst字段,type是枚举Type.INT / Type.STRING / Type.VOID—— 这是你后续做类型检查的基石。

4.2 手写 TypeChecker:三类必检错误的代码模式

在src/semantics/TypeChecker.java中,补全checkAssignment(AssignStmt node):

public void checkAssignment(AssignStmt node) { Symbol lhsSym = symbolTable.resolve(node.getId()); if (lhsSym == null) { throw new SemanticException("Undeclared identifier: " + node.getId() + " at line " + node.getLine()); } Type rhsType = checkExpr(node.getExpr()); // 递归检查右值类型 if (!lhsSym.getType().equals(rhsType)) { throw new SemanticException("Type mismatch: cannot assign " + rhsType + " to " + lhsSym.getType() + " at line " + node.getLine()); } } private Type checkExpr(ASTNode expr) { if (expr instanceof NumNode) { return Type.INT; } else if (expr instanceof IdNode) { Symbol sym = symbolTable.resolve(((IdNode) expr).getName()); if (sym == null) { throw new SemanticException("Undeclared identifier: " + ((IdNode) expr).getName()); } return sym.getType(); } else if (expr instanceof BinaryOpNode) { BinaryOpNode opNode = (BinaryOpNode) expr; Type leftType = checkExpr(opNode.getLeft()); Type rightType = checkExpr(opNode.getRight()); // 仅支持 INT 运算 if (leftType != Type.INT || rightType != Type.INT) { throw new SemanticException("Arithmetic operation on non-INT types at line " + expr.getLine()); } return Type.INT; } return Type.ERROR; }

参数说明:

  • checkExpr()是典型的递归下降式类型检查,对每种 AST 节点做模式匹配;
  • BinaryOpNode的类型由操作数决定,+不产生新类型,只继承操作数类型(此处限定为 INT);
  • 错误信息包含line号,与 Lexer 的Token.line字段打通,形成端到端定位能力。

4.3 集成到 Parser:在语法树构建后立即触发语义检查

修改Parser.java的parseProgram(),在构建完 AST 后插入检查:

public ASTNode parseProgram() { ASTNode program = parseStmtList(); match(TokenType.EOF); // 新增:构建完 AST 立即语义检查 SymbolTable table = new SimpleSymbolTable(); TypeChecker checker = new TypeChecker(table); checker.checkProgram(program); // 你需要实现此方法,遍历 StmtList 填充符号表并检查 return program; }

逻辑说明:checkProgram()会先遍历所有AssignStmt,调用table.define(id, new Symbol(id, Type.INT, line))注册变量;再二次遍历检查赋值合法性。这种“两遍扫描”是教学级编译器的务实选择——避免在解析时混杂语义逻辑,保持各阶段职责清晰。


5. 避坑指南:词法、语法、语义三阶段踩过的 5 个真实血泪坑

编译原理实践最残酷的地方在于:错误不报在你写的代码行,而报在Lexer.java第 87 行matcher.find()抛出的NullPointerException。以下是我在带 3 届学生跑通此包时,高频出现的 5 个坑,按发生频率排序:

5.1 现象:test.exp输入a = 1 + ;,词法分析器卡死或无限循环

原因:WS规则(\\s+)放在ERROR规则之后,当遇到;后的换行符时,ERROR先匹配\n,导致lookahead永远停在换行符,scanLine()循环无法退出。
解决:严格按规则顺序:WS必须在ERROR之前;且WS的value字段设为null,避免污染 token 列表。

5.2 现象:parseExpr()报StackOverflowError

原因:在parseExpr()循环内错误调用了parseExpr()而非parseTerm(),造成直接左递归。例如while (addOp) { left = parseExpr(); }。
解决:对照文法Expr → Term {AddOp Term},循环体内只能调用parseTerm();递归下降的本质是“用函数调用栈模拟文法推导栈”。

5.3 现象:a = b + c;类型检查通过,但b和c未声明

原因:TypeChecker.checkProgram()中只做了“定义”未做“使用”检查,resolve()在checkExpr(IdNode)时返回null,但异常被吞掉或未 throw。
解决:checkExpr(IdNode)中if (sym == null) throw ...必须存在;且checkProgram()的遍历顺序必须是“先 define 再 use”,不能边遍历边检查。

5.4 现象:1 + 2 * 3的 AST 树中*节点在顶层,+成了子节点

原因:parseExpr()和parseTerm()的调用关系颠倒。正确应是parseExpr()调用parseTerm(),parseTerm()调用parseFactor();若parseTerm()调用parseExpr(),则乘法优先级被破坏。
解决:画出文法推导过程:Expr → Term → Factor → NUM,Term → Factor MUL Factor,确保函数调用链与文法层级严格一致。

5.5 现象:中文注释// 这是注释导致词法分析失败,报Illegal character

原因:RegexRule未覆盖//开头的单行注释,ERROR规则匹配了/,但下一个/未被消费,导致状态错乱。
解决:在rules列表中加入new RegexRule("COMMENT", "//.*"),并确保它在DIV规则之前(因//以/开头,必须优先匹配);COMMENT的value设为null,不生成 token。


6. 进阶技巧:用断点 + token 行列号实现精准错误定位,让调试效率翻倍

编译器的终极价值不是“跑通”,而是“报错准”。福州大学包里所有Token都携带line和col,但多数学生只用它打印日志,没把它变成调试利器。我带学生做的第一件进阶事,就是给SemanticException加上源码上下文快照。

6.1 改造 Exception:从单行报错到三行源码预览

修改src/semantics/SemanticException.java:

public class SemanticException extends RuntimeException { private final int line; private final int col; private final String sourceCode; // 新增:缓存原始源码 public SemanticException(String message, int line, int col, String sourceCode) { super(String.format("[%d:%d] %s\n%s", line, col, message, getSurroundingLines(sourceCode, line))); this.line = line; this.col = col; this.sourceCode = sourceCode; } private static String getSurroundingLines(String code, int targetLine) { String[] lines = code.split("\\r?\\n"); StringBuilder sb = new StringBuilder("\n--- Source context ---\n"); int start = Math.max(0, targetLine - 2); int end = Math.min(lines.length, targetLine + 1); for (int i = start; i < end; i++) { String prefix = (i + 1 == targetLine) ? ">>> " : " "; sb.append(prefix).append((i + 1)).append(": ").append(lines[i]).append("\n"); if (i + 1 == targetLine) { // 在错误行下方加指针 int pointerPos = 0; for (int j = 0; j < col && j < lines[i].length(); j++) { if (lines[i].charAt(j) == '\t') pointerPos += 4; // 制表符算 4 空格 else pointerPos++; } sb.append(" ").append(" ".repeat(Math.max(0, pointerPos))).append("^\n"); } } return sb.toString(); } }

6.2 在 TypeChecker 中传递源码:让错误信息“活”起来

在Main.java中,读取文件时缓存内容:

String sourceCode = Files.readString(Paths.get("test.exp")); Lexer lexer = new Lexer(sourceCode); // 修改 Lexer 构造函数,接受 String // ... 后续解析

然后在TypeChecker.checkAssignment()中,当抛异常时:

throw new SemanticException("Undeclared identifier: " + node.getId(), node.getLine(), node.getCol(), sourceCode);

效果对比:
旧报错:[3:5] Undeclared identifier: b
新报错:

[3:5] Undeclared identifier: b --- Source context --- 1: a = 1 + 2; >>> 2: c = a * b; ^ 3: d = c + 1;

为什么这招管用?

  • 行列号来自Token,而Token的line/col在Lexer.scanLine()中由String.indexOf('\n')精确计算,误差为 0;
  • getSurroundingLines()用split("\\r?\\n")兼容 Windows/Mac/Linux 换行,pointerPos计算考虑制表符宽度,指针位置肉眼可验证;
  • 学生不再需要grep -n "b" test.exp查行号,错误信息本身已是完整调试上下文。

我坚持让学生在第一次提交前,必须让所有异常都带上下文预览。因为编译原理不是数学考试,它的“正确”最终要落在开发者看到错误时,能否 10 秒内定位到问题根源。这个习惯,让他们在后续做操作系统、数据库实验时,debug 效率明显高于其他组。希望帮到你。

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

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

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

立即咨询