多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

编译原理实验:手写Cminusf编译器前端与中间代码生成

编译原理实验:手写Cminusf编译器前端与中间代码生成 简介面向计算机专业本科生与编译原理学习者这是一份重庆大学计算机学院编译原理课程实验完整项目集合覆盖实验一至实验三聚焦Cminusf教学语言的词法分析、语法分析、语义分析及中间代码生成并附带各实验得分与调试过程记录可作为课程设计、实验复现与答辩准备的参考方案。资源共361个文件压缩包仅1.52MB以out、sy、tk、json等程序运行与中间结果文件为主另有h/cpp源代码、in测试输入、txt说明文档及Python辅助脚本可清晰还原从源码到中间代码的完整编译流程。核心代码位于Compilation_principle_ex-main目录配合说明文件与调试笔记便于对照实验要求逐模块理解实现思路。目前已有78人学习下载。通过完整代码、调试记录与错误诊断过程读者能直观看到词法分析如何识别关键字与标识符、语法分析如何构建分析树、语义分析如何检查类型与作用域以及中间代码生成器的设计策略尤其是得分记录与调试笔记能帮助后来者规避常见问题、深化对编译原理理论的理解。1. 拿到这份编译原理课程实验集合时我看到的是三个递进关卡拿到这份重庆大学计算机学院编译原理课程实验集合时我的第一反应是三个实验不是孤立的作业而是一条完整的编译器前端流水线从词法分析一路铺到中间代码生成。Cminusf 是 C 语言去掉指针、结构体等复杂特性后的教学子集token 类型不多文法规整恰好能在一个学期里把词法分析、语法分析、语义分析到中间代码生成整条链路走通。实验一产出 token 流实验二产出 AST实验三在 AST 上做符号表、类型检查和中间代码生成前一个实验的输出就是后一个实验的输入层次推进非常清晰。下面把三个实验的实现思路、关键代码片段和调试记录里的常见坑拆开讲适合正在做同类课程实验、或者想把编译前端手写一遍的同学直接照着搭。2. 先搭整体架构Cminusf语言要点与词法/语法/语义的接口设计很多同学拿到实验的第一反应是打开 IDE 直接写词法分析器我建议反过来先把 Cminusf 的文法和三阶段接口定清楚。编译器前端就是一条流水线词法输出的 token 流、语法输出的 AST、语义输出的中间代码是三个阶段的握手协议。协议不定实验一做完实验二推倒重来的情况在课程实验里太常见了。2.1 Cminusf 长什么样去掉了什么的CCminusf 是 C 的一个教学子集有的资料写作 C-Minus。它去掉了指针、结构体、联合体、字符串类型、switch 和 do-while 这些对编译主线不必要的东西留下的是 int/float/void 三种类型、一维数组、函数定义与调用、if/else、while、return、赋值和算术比较逻辑运算。token 种类少让词法分析器好写但语法和语义部分仍然完整所以它非常适合做课程实验。/* Cminusf 示例求数组最大值 */ int max(int arr[], int n) { int i; int m; m arr[0]; i 1; while (i n) { if (arr[i] m) m arr[i]; i i 1; } return m; }这段代码几乎覆盖了课程实验的考核点函数声明与参数、数组参数、变量声明、赋值、if、while、return、加减、比较、数组下标。Cminusf 的标准文法里还有 declaration-list、var-declaration、fun-declaration、param-list、compound-stmt 这些非终结符实验二就是在这些文法规则上做递归下降。注意 int arr[] 这种数组参数写法在 Cminusf 里是合法的词法分析阶段看到 [ 和 ] 也要当作独立 token 输出语法分析阶段再组合成数组类型。2.2 三个阶段的握手协议token流、AST、四元式三个实验的输入输出关系可以用一张表说明阶段输入输出典型错误实验一 词法分析源程序文本Token 流非法字符、数字越界实验二 语法分析Token 流AST缺少分号、括号不匹配实验三 语义分析中间代码AST四元式序列未声明变量、类型不匹配我见过有同学在第一阶段就把 token 做成字符串数组第二阶段重新 split绕了一圈发现行号丢了后面语义分析报错定位不到位置。正确做法是定义 Token 类携带 type、lexeme、line、col 四个字段三个阶段共用。AST 节点也一样至少要有 nodeType 和 line 两个字段类型检查报错全靠行号。这里有个容易模糊的边界词法分析只管报非法字符语法分析只管报语法错误语义分析只管报类型错误。一旦越界比如词法分析顺手去检查括号配对错误信息会重复定位也会乱我实验一就干过这种事最后把错误处理统一收口才理顺。用 Java 写编译原理实验是很多人的首选原因很实际AST 节点不需要手写析构符号表的 HashMap 直接用四元式列表就是 ArrayList。我这份课程实验里的词法、语法、语义部分就是用 Java 写的。如果你用 CAST 节点的所有权和释放是个大坑语义分析阶段很容易因为悬垂指针翻车用 Java 主要坑在作用域符号表的生命周期第 4 章会专门说。2.3 工程组织一个包名搞定三个实验我一般把工程组织成下面这种结构每个包对应一个实验阶段直接照着分就能让三个实验各自独立运行src/cminus/ lexer/ Token.java, Lexer.java parser/ Parser.java, ASTNode.java semantic/ Symbol.java, SymbolTable.java, TypeChecker.java icode/ Quadruple.java, ICodeGen.java Main.java这样的好处是实验一交付时只提交 lexer 包就能运行实验二在 lexer 基础上加 parser实验三加 semantic 和 icode。每阶段都保留一个 main 入口分别输出 token 序列文件、AST 文本文件、四元式序列文件。这三个中间文件既是调试工具也是实验报告里最有说服力的依据——评分老师看的就是你能不能把源程序一步一步变成规范产物。接口协议定了、工程结构清了词法和语法分析器写起来才不会走偏。3. 实验一和实验二手写词法状态机与递归下降语法分析在动笔之前先确认一件事词法分析器到底是手写还是用 flex/jflexCminusf 的 token 集合不大手写完全可控而且更容易讲清楚状态机逻辑用 flex 生成的代码在实验报告里不太能展示你的理解。我倾向于手写。语法分析也一样LR 生成器yacc/bison对 Cminusf 是杀鸡用牛刀递归下降手动实现报错位置和恢复策略完全自己掌握这也是课程考察的重点。3.1 词法分析五类token、保留字表与字符回退词法分析核心逻辑一句话从左往右扫描根据当前字符决定进入哪个识别分支识别完一个 token 回到初始状态。Cminusf 的 token 分五类关键字、标识符、数字字面量、运算符和分隔符、注释注释被丢弃。运算符包括 - * / ! ; , ( ) [ ] { }其中 和 是两个 token词法阶段必须区分。public Token nextToken() throws LexException { skipWhitespaceAndComments(); // 空白和注释不产出 token if (!hasMore()) return new Token(TT_EOF, EOF, line, col); char c peek(); if (Character.isLetter(c)) return readIdentifier(); if (Character.isDigit(c)) return readNumber(); return readOperator(); } private Token readIdentifier() { StringBuilder sb new StringBuilder(); while (hasMore() (Character.isLetterOrDigit(peek()) || peek() _)) { sb.append(advance()); } TokenType type reservedWords.getOrDefault(sb.toString(), TT_ID); return new Token(type, sb.toString(), startLine, startCol); }逻辑说明skipWhitespaceAndComments 负责跳过空格、换行、// 行注释和 /* 块注释其中块注释遇到换行时 line 必须加一。readIdentifier 里 reservedWords 是一个 HashMap初始化时把 if、else、while、return、int、float、void 放进去不在表里的一律当标识符处理。readOperator 里对每个运算符做最长匹配读到 时要再看下一个字符是不是 是就输出 不是就回退。字符回退这个动作是整个词法分析里最容易被忽略的细节第 5 章会单独把它拎出来讲。参数说明保留字表大小写敏感Cminusf 中 If 是合法标识符、if 才是关键字标识符可以包含下划线但不能以数字开头数字字面量 Cminusf 只要求十进制整数转 int、带小数点的转 float。像 12. 这种点后面没有数字的输入课程实验通常要求报错拿不准就在 readNumber 里按「点后必须有数字」处理。3.2 语法分析递归下降、AST构建与else悬挂Cminusf 的文法是 LL(1) 风格的非常适合递归下降。从 program 开始每个非终结符对应一个方法parseDeclarationList、parseVarDeclaration、parseFunDeclaration、parseCompoundStmt、parseExpression、parseStatement 等。每个方法开头用 peek 判断当前 token 决定走哪个产生式遇到不符合预期的 token 就抛语法错误。public ASTNode parseSelectionStmt() { match(TT_IF); match(TT_LPAREN); ASTNode cond parseExpression(); match(TT_RPAREN); ASTNode thenStmt parseStatement(); ASTNode elseStmt null; if (peek().type TT_ELSE) { // else 是可选的用 peek 判断 match(TT_ELSE); elseStmt parseStatement(); } return new IfNode(cond, thenStmt, elseStmt, startLine); }match 方法逻辑很简单当前 token 类型等于参数就消费并前进否则抛带行号的 SyntaxException。这里的关键是每个 AST 节点都保留起始行号后面语义分析报错全靠它。IfNode 的三个子节点分别是条件、then 分支、else 分支else 可以为 null这就是 C 的 else 悬挂语义else 总是与最近的未配对 if 结合递归下降的写法天然满足这个规则。写递归下降最容易翻车的三个点一是左递归表达式文法里 term - term * factor 这种写法会导致无限递归必须改写成 term - factor { * factor } 这种右递归形式二是空产生式比如无参数函数的 param-list 是空串处理时先用 peek 判断不能无脑 match三是表达式优先级Cminusf 的优先级从低到高是 ||、、比较、加减、乘除、一元负号、括号必须用不同层级的 parse 方法逐层下降不能在一个方法里一把梭。4. 实验三符号表、类型检查与四元式中间代码生成到第三个实验输入是实验二产出的 AST输出是四元式序列。这一步要同时解决两件事第一把 AST 中每个标识符和它声明的类型绑定起来检查所有使用是否合法第二把合法的 AST 翻译成接近机器指令的中间表示。两个任务可以分两趟也可以一趟完成——常见做法是先在 AST 上完整做一遍类型检查再遍历 AST 生成中间代码这样中间代码生成器不用承担报错职责逻辑清晰得多。4.1 符号表与作用域双层栈、重复声明与遮蔽Cminusf 的作用域只有全局和函数内两层函数参数算函数内部变量。符号表可以用一个栈实现进入函数体时压入一层新表函数结束弹出。查找时从栈顶往下逐层查这样局部变量可以遮蔽全局同名变量。class SymbolTable { private DequeMapString, Symbol scopes new ArrayDeque(); SymbolTable() { scopes.push(new HashMap()); } // 全局层 void enterScope() { scopes.push(new HashMap()); } void exitScope() { scopes.pop(); } void declare(Symbol s) throws SemanticException { MapString, Symbol top scopes.peek(); if (top.containsKey(s.name)) { throw new SemanticException(变量重复声明: s.name, s.line); } top.put(s.name, s); } Symbol lookup(String name) { for (MapString, Symbol scope : scopes) { Symbol s scope.get(name); if (s ! null) return s; } return null; } }declare 里要注意同作用域重复声明必须报错但内层可以和外层同名这是遮蔽不是重复。Symbol 至少包含 name、typeINT/FLOAT/VOID/ARR、elemType数组元素类型、arrayLen数组长度非数组时为 -1、line。数组参数 int arr[] 的 arrayLen 记为 0 表示长度未指定全局数组 int a[10] 的 arrayLen 是 10。AST 遍历到 var-declaration 节点时调用 declare遍历到标识符引用时调用 lookup查不到就报未声明错误。类型检查规则按 Cminusf 课程要求逐条过赋值左侧必须是变量或数组元素右侧类型与左侧一致int 可以赋给 float数组下标表达式类型必须是 int数组名单独出现除了作为函数实参要报错函数调用实参个数必须等于形参个数且逐个类型兼容void 函数里 return 后不能跟表达式int/float 函数必须有 return 且返回类型匹配。4.2 四元式生成临时变量编号、标签编号与 while 的翻译模式中间代码我推荐四元式op, arg1, arg2, result比三地址码多一个操作数字段后面要做常量折叠或寄存器分配时更顺手。所有算术逻辑运算都引入一个新的临时变量保存结果临时变量编号从 t1 递增跳转标签从 L1 递增绝不在代码里硬编码编号。class Quad { String op, arg1, arg2, result; Quad(String op, String a1, String a2, String res) { this.op op; this.arg1 a1; this.arg2 a2; this.result res; } public String toString() { return ( op , arg1 , arg2 , result ); } }表达式生成的核心方法是每遇到一个运算符递归生成左右操作数然后申请临时变量、追加四元式、返回临时变量名。while 循环的标准翻译模式是L_start: (j, a, b, L_body) // 条件满足跳进循环体 (j, _, _, L_end) // 不满足跳出 L_body: ...循环体代码... (j, _, _, L_start) // 回到条件判断 L_end:翻译时先用 genLabel 生成 L_start 和 L_end再顺序生成条件代码和循环体代码最后把跳转补全。if/else 的翻译类似用条件跳转加无条件跳转组合。算术运算如 a b c 翻译成 (, b, c, a)数组访问 arr[i] 通常要求用 (, arr, i, addrTemp) 先算地址再取值。参数说明arg1、arg2 可能是变量名、临时变量名或常量字面量统一用字符串存储实验报告展示更直观op 建议用小写英文单词如 add、sub、mul、div、j、j、j、je、jne、call、ret评分老师一眼能看懂。5. 编译原理实验避坑五个让得分缩水的常见问题这一章把课程实验调试过程里最常见的坑按现象、原因、解决整理出来。这些坑不会让程序直接崩但会让运行结果和参考答案对不上而输出对不上恰恰是实验评分里最冤的扣分点。我当年吃过不少亏换成一句话说都是血泪经验。5.1 标识符后面的字符被吞token 流少了一个符号现象输入 i i 1; 时语法分析器报「期望分号但遇到 i」或者语义分析时 i 后面的 不见了。原因readIdentifier 在读到标识符结束后多消费了下一个字符。比如 i 后面的空格或运算符有的实现用 pos 读取下一个字符时把 也消费掉了token 流里就丢了一个符号。解决在 Java 里我这样封装前进和预读private char peek() { return src[pos]; } private char advance() { return src[pos]; }所有识别分支里只调用这两个方法绝不直接用 pos 加一。这样 readIdentifier 结束时位置一定停在最后一个合法字符上不会多吞。凡是需要预读下一个字符来决定的场景都用 peek 只读不消费读进了错误分支就用回退把位置还原。5.2 多行注释里的换行没有计数报错行号整体偏移现象一个 /* 多行注释 */ 之后的所有语法错误都报在错误行之前的位置而且越往后偏差越大。原因skipWhitespaceAndComments 处理块注释时没有把注释内部的换行符计入 line导致 line 计数落后于实际行数。解决在处理 /* 和 // 时都显式维护 line块注释每遇到一个 \n 就让 line 加一单行注释遇到 \n 就结束注释由外层循环统一处理换行。写完注释跳过逻辑后用一个包含多行注释的测试文件单独跑一次词法确认注释前后的 token 行号准确。5.3 可空的非终结符让递归下降死循环现象解析无参数函数或空语句块时程序卡住或栈溢出。原因param-list 或 statement-list 存在空产生式而处理方法里没有先用 peek 判断就调用 matchmatch 失败抛异常后上层没有正确恢复或者 while 循环条件写反导致永不前进。Cminusf 里空语句块完全合法局部变量声明后直接跟右花括号的情况很常见。解决对每一个可能为空的非终结符进入循环前先看当前 token 是否属于该非终结符的开始符号集合。处理 statement-list 时先判断当前 token 是否能开始一条语句while (canStartStatement(peek().type)) { stmts.add(parseStatement()); }canStartStatement 里把 if、while、return、标识符等能出现在语句开头的 token 类型列出来遇到分号或右括号就停下来。这样空列表和正常列表都能正确收口。5.4 行号没有透传语义分析报错定位不到现象语义分析报「第 0 行变量 x 未声明」或者所有错误都定位到文件结尾。原因AST 节点构造时没有保存起始行号或者词法阶段就把 token 的行号丢弃了只保留 lexeme 字符串。解决Token 必须携带 line 和 col每创建一个 AST 节点把当前 token 的 startLine 存进节点字段。例如public IfNode(ASTNode cond, ASTNode then, ASTNode els, int line) { this.cond cond; this.thenStmt then; this.elseStmt els; this.line line; }语义分析报错时直接取 node.line。三个阶段的行号一脉相承这是排查一切定位问题的基础。结构体、数组、赋值语句的节点也都一样构造方法里统一收一个 line 参数。5.5 测试用例太少边界输入在评分用例上翻车现象本地测试全过评分时在浮点数、边界数字、空程序或缺少 return 的程序上输出错误。原因自测用例只覆盖了正常路径。比如 12. 这种没有小数部分的输入或者 a 999999999999 这种超 int 范围的字面量还有没有 return 的非 void 函数。解决按三条线补测试用例类别用例期望行为词法边界12. 点后无数字按实验要求报错或按 float 处理词法边界超 long 范围的数字报数字越界错误语法边界仅注释的空程序正常退出无输出语法边界if 后无 else正常解析else 节点为 null语义边界void 函数内 return 1;报类型错误语义边界数组下标用 float 变量报下标非 int每补一条用例都要跑全量回归看是否影响之前的正确输出。测试文件和 golden 输出留好后面一章讲的验证方法就靠它。6. 验证方法token流、AST、四元式的三步对照法课程实验最大的痛点是「不知道哪里错了」。我养成的习惯是每写完一个阶段立刻把该阶段的输出 dump 成文本文件和参考答案做 diff。三个阶段各有一个中间产物任何一个对不上都能定位到具体阶段不用猜。第一步词法分析完成后把 token 流逐行打印成「行号:列号 token类型 lexeme」例如 3:5 TT_ID arr。拿到参考答案的 token 输出后直接 diff词法阶段有没有吞字符、保留字表有没有漏一眼就能看出来。第二步语法分析完成后把 AST 按缩进打印成树形文本。每个节点输出节点类型和行号子节点缩进。这个树形文件比调试器直观得多特别是检查 else 悬挂和表达式优先级时树的形状对不对就是文法理解对不对。第三步语义分析前先不做任何输出等四元式生成完把 (op, arg1, arg2, result) 逐行打印。手拿几个小表达式a b c * d; while 循环手算一遍期望的四元式和输出对照。这里如果期望值算错多半是对翻译模式理解偏了。#!/bin/bash # 全量回归跑所有测试用例并对比 golden 输出 for f in tests/*.cm; do base$(basename $f .cm) java -cp out cminus.Main $f out/$base.quads 21 if diff -q out/$base.quads golden/$base.quads /dev/null; then echo PASS $base else echo FAIL $base fi done这个脚本我在整个实验周期里一直用每改一次词法或语法全量回归一遍保证没把之前跑通的功能改坏。所谓「改了这里坏了那里」的问题用全量回归基本能消灭。golden 文件一开始可以来自老师给的参考答案没有参考答案就自己人工核对第一批用例确认无误后冻结为 golden。我自己的教训是最亏的不是编译原理难而是没有早做 token dump。语法分析一出错我老在猜是词法吞了字符还是文法写错等我把词法输出打了 diff才发现大半问题都在字符回退上。把三个阶段的中间产物留好、diff 勤快跑这门实验的得分会比想象中稳。希望帮到你。本文还有配套的精品资源点击获取
返回列表