☰
编译原理课程设计:从词法分析到中间代码生成的完整实践
2026/10/2 5:31:30 网站建设 项目流程

简介:《编译原理》课程设计报告是一份面向计算机专业学生的完整课程设计文档,围绕编译器构建的核心流程展开,系统覆盖词法分析、语法分析、语义分析、中间代码生成、代码优化及目标代码生成等关键阶段,适合需要参考课程设计写法或独立完成编译器项目的学习者,也可用于毕业设计与项目实践参考。报告基于重庆理工大学课程设计要求,从语言规范定义出发,依次记录了词法分析器与语法分析器的设计、语法树构建、类型检查、中间代码表示与优化、目标代码生成等实现细节,并附有示例程序演示完整编译过程,同时整理了设计思路、实现过程、常见问题与解决方案,能够帮助读者快速理解编译器各模块的落地方法,也可作为课程设计报告的结构蓝本;内容还包含课程设计目的、内容要求、成果展示、评估标准与参考资料等完整框架,便于对照检查自身设计是否完备。压缩包以zip格式提供,大小约4.52MB,由于平台未提供文件总数与类型明细,此处不逐一罗列;目前已有140人浏览/学习,尤其适合正在准备编译原理课程设计或希望深入理解编译器工作流程的学生学习参考。

1. 编译原理课程设计:从理论推导到能跑通的编译器骨架

编译原理课最“劝退”的一关,不是闭卷考试,而是课程设计。平时做题只要会算 FIRST 集、会画 DFA 就够了,课设要求把这些零散知识点组装成一个能编译迷你语言的程序。这份《编译原理》课程设计报告,就是我按清华大学出版社《编译原理(第3版)》的知识点路线,用 Java 实现的一整套编译器骨架:词法分析、语法分析、语义分析、中间代码生成,每一层都有可运行的代码和对应的测试用例。适用人群很明确:正在被课设折磨的本科生、想动手把教材例题变成代码的从业者,以及需要参考实验案例的教师。它能直接给你一个从 0 到 1 的框架,你只需要替换文法、扩充关键字,就能接住大多数学校的课程设计题目。

2. 词法分析器:token 种别设计与状态转移驱动的识别程序

2.1 token 种别编码与接口约定

词法分析是全流程的地基。课设要求的输入通常是一个源代码文本文件,期望的输出是 token 序列加错误报告。做这一步之前,先把种别编码表定下来,下面这份是报告里常用的种别定义,直接用能省掉大半设计时间。

种别token 类型示例正规式描述
1IDENTcount, a1[a-zA-Z_][a-zA-Z0-9_]*
2INT_CONST123, 0[0-9]+
3KW_INTint保留字
4KW_IF / KW_ELSE / KW_WHILEif else while保留字
5PLUS / MINUS+ -单字符运算符
6ASSIGN / EQ= ==单字符与双字符操作符
7LPAREN / RPAREN( )界符

种别码怎么排有讲究:保留字紧挨着标识符,但编码本身不参与算法,只是输出数据的规范。真正参与识别的是后面要讲的最大匹配逻辑。选型上,我建议用 Java 的枚举做 TokenType 而不是 int 常量,因为课设的代码量不大,枚举的可读性对后面写报告更友好。词法这一层的接口,我一般定义成Token nextToken(),Token 里带 type、lexeme、line 三个字段;遇到文件结尾返回 EOF token。很多学校的编译原理实验都要求第一阶段交可打印的 token 流,这个接口可以直接对接到主函数里循环输出。

2.2 状态转移驱动的词法分析程序

我见过不少同学的写法:一个 switch 分支套一个 switch 分支,每个字符单独判定。这种代码改一个关键字就要动五六处,而且很难讲清楚和 DFA 的关系。常见做法是维护一个状态变量,按字符类别跳到下一个状态,遇到终态就返回 token。下面这段是标识符与关键字的识别代码,核心是“先拼完整词、再查关键字表”。

public Token nextToken() { skipWhitespace(); if (pos >= source.length()) { return new Token(TokenType.EOF, "", line); } char c = source.charAt(pos); if (isLetter(c) || c == '_') { return readIdOrKeyword(); // 标识符与保留字共用一条路 } if (isDigit(c)) { return readNumber(); // 无符号整数 } // 运算符部分,注意 == 这种双字符 token 要向前多看一位 if (c == '=') { if (pos + 1 < source.length() && source.charAt(pos + 1) == '=') { pos += 2; return new Token(TokenType.EQ, "==", line); } pos++; return new Token(TokenType.ASSIGN, "=", line); } return new Token(TokenType.ERROR, "unexpected char: " + c, line); } private Token readIdOrKeyword() { int start = pos; while (pos < source.length() && isLetterOrDigit(source.charAt(pos))) { pos++; } String word = source.substring(start, pos); TokenType type = KEYWORDS.get(word); return new Token(type == null ? TokenType.IDENT : type, word, line); }

为什么要先拼完整词再查表,不能读到一半就判断?因为保留字是标识符的子集,词法规则遵循最大匹配,读到whil时并不会知道后面还有字母e。如果边读边判,等发现是while时,位置已经回不去了。这个顺序问题,是词法实验里最常见的翻车原因。数字识别同理,readNumber会一直吃到非数字字符为止,这样123abc会拆成整数 123 和标识符 abc,而不是直接报错——这符合多数 C 语言编译器的词法行为。

2.3 关键字表与手写 DFA 的取舍

有一种更理论化的做法是把所有 token 的正规式都转换成 DFA,然后用一张状态转移表驱动扫描。我在报告里也画了 DFA 图,但代码里没有用转移表,原因是转移表对课程设计来说是黑匣子,一旦状态号写错,调试成本高于收益。我一般这样处理:标识符、数字这类结构简单的用函数直接写,双字符运算符用 peek 一位特判,剩下的单字符直接用枚举跳转。这样老师在答辩时问“DFA 怎么体现在代码里”,你可以指着状态变量和分支说清楚,比甩一张几十行的转移表更有说服力。

测试词法模块时,我习惯写一个极小的 main,把int a = 10; if (a > 0) a = a - 1;喂进去,看输出的 token 流是否逐行对应。输出里必须能看到每个 token 的行号,这是文末报告截图里最有说服力的素材。

2.4 词法错误与行号追踪

词法错误最典型的场景是出现非法字符,比如中文标点、@、$。处理策略是跳过该字符、记录错误、继续扫描,行号在跳过换行时递增。输出格式统一为第X行: 错误描述,这个格式后面语法分析也会复用。

private void skipWhitespace() { while (pos < source.length()) { char c = source.charAt(pos); if (c == '\n') { line++; pos++; } else if (c == ' ' || c == '\t' || c == '\r') { pos++; } else break; } }

这段代码虽小,但容易漏:换行符必须在这里处理,否则词法正确但行号全部错位。我在报告里专门用这个例子说明“词法状态与行号维护是同一件事,不是两个循环”。行号一旦错位,语法分析的报错信息全部作废,越往后调越乱。

3. 语法分析:LL(1) 预测表与递归下降子程序的落地实现

3.1 文法改写与优先级保持

语法分析的第一步是把产生式整理成 LL(1) 能处理的形式。课程设计一般用表达式文法做主体,比如加减乘除和括号。原始文法天然带左递归,直接写递归下降必然爆栈。我用的文法如下,这里注意优先级是靠T和E'的层级来保证的:

原文法(含左递归)改写后(LL(1))
E → E + T | E - T | TE → T E'
T → T * F | T / F | FT → F T'
F → (E) | id | numE' → + T E' | - T E' | ε
T' → * F T' | / F T' | ε

改写一旦出错,最典型的现象是加减法变成右结合:8 - 3 - 2会算出7。原因是E'被写成了递归下降而不是循环,每层递归都优先吃掉剩余部分。正确实现是用循环来消化同层运算符,下面的代码把 while 写在 parseEPrime 里,把所有+ T和- T看成同一层。

private void parseE() { parseT(); parseEPrime(); } private void parseEPrime() { while (curToken == TokenType.PLUS || curToken == TokenType.MINUS) { Token op = curToken; match(curToken); parseT(); // 在这里生成中间代码,见第 4 章 } }

这段逻辑说明:因为E'的定义是+ T E' | - T E',从文法上看最后的E'会收在 ε 上,递归写法天然右结合;改成 while 循环后,运算符左边的操作数已经完成运算,结果继续参与下一次循环,这就恢复了左结合。语法分析的代码不是唯一答案,但语义上左结合和右结合的区别必须能说清楚,答辩老师很爱问这个点。

3.2 FIRST 集与 FOLLOW 集的程序化计算

预测分析表的构造需要 FIRST 和 FOLLOW,手工算小文法没问题,稍一扩充就出错。我建议把计算写成独立的方法,反复扫描产生式直到集合不再变化,也就是不动点迭代。下面是一个可运行的 FIRST 集计算方法,递归实现时注意加记忆化,防止循环文法导致死递归。

public Set<String> first(Symbol s) { if (s.isTerminal()) { return new HashSet<>(Collections.singletonList(s.name())); } if (memo.containsKey(s)) { return memo.get(s); } Set<String> result = new HashSet<>(); memo.put(s, result); // 先占位,防止间接递归死循环 for (List<Symbol> rhs : productionsOf(s)) { if (rhs.isEmpty()) { result.add("eps"); continue; } for (Symbol sym : rhs) { Set<String> fs = first(sym); result.addAll(fs); if (!fs.contains("eps")) { break; // 遇到不能推空的符号就停下 } // 如果 sym 可以推空,继续看下一个符号 } } return result; }

这里两个关键点:一是针对形如A → B、B → A的间接递归,必须先往 memo 里放一个占位集合,否则会抛栈溢出;二是break的条件,写错会把不该进 FIRST 的终结符混进来。FOLLOW 集同理,只是规则里多一条“若 β 能推出 ε,就把 FOLLOW(A) 并入 FOLLOW(B)”,并且初始要把#(结束符)加入开始符号的 FOLLOW。程序化计算时把所有非终结符的集合打印出来,和手工推导比对一次,最多五分钟就能定位哪一步算错。

3.3 预测分析表与递归下降的映射

LL(1) 分析表在代码里怎么体现?一种做法是构造二维表,代码里查询;另一种是分析表“隐含”在递归下降的子程序里。课程设计我推荐后者:每个非终结符一个方法,产生式的选择就是 if/while 的分支,表结构仅作为报告验证材料。下面给出这个课设的映射关系表,写报告时可以直接引用。

非终结符进入条件(预测表依据)对应方法
E当前 token 是 id、num、(parseE()
E'+、- 进入循环,)、# 直接返回parseEPrime()
Tid、num、(parseT()
Fid / num 匹配字面量,( 进入括号子程序parseF()

这里的规则解释:当E遇到id时,选择E → T E',接着T遇到id选T → F T',F直接匹配id。整个链路就是一次方法调用栈,出错时按栈往回退,天然自带分析树的痕迹,打印起来也方便,第 6 章会讲到怎么利用这点。

4. 语义分析与中间代码:符号表、类型检查与四元式生成

4.1 符号表的作用域设计

很多课设只做到语法分析就收工,但作为完整报告,语义分析才是工作量所在。符号表要解决三件事:声明去重、使用查找、作用域退出清理。我用的结构是链式栈:每个作用域一个 Map,进入复合语句时压栈,退出时弹栈,查找从栈顶往下逐层找,这正好对应 C 语言的作用域规则。

Deque<Map<String, Symbol>> scopeStack = new ArrayDeque<>(); public void declare(String name, DataType type, int line) { Map<String, Symbol> top = scopeStack.peek(); if (top.containsKey(name)) { errors.add("line " + line + ": redefinition of " + name); return; } top.put(name, new Symbol(name, type, line)); } public Symbol lookup(String name) { for (Map<String, Symbol> scope : scopeStack) { Symbol s = scope.get(name); if (s != null) return s; } return null; }

链式栈比单表加销毁标记更直观:声明时只在栈顶检查重名,使用时从内向外找,这不光是代码组织问题,也是报告里“作用域管理”一节的论述依据。数据类型的检查在声明阶段就能做一部分,比如int a = b + c,如果 b 或 c 没声明,lookup 返回 null,就报undeclared variable。这里有个细节要提前决定:变量是否允许在使用后声明?课程设计我统一按“先声明后使用”处理,规则简单,报告也好写。

4.2 表达式求值与四元式生成

中间代码生成采用四元式(op, arg1, arg2, result),存储在一个全局的 ArrayList 里。表达式的翻译用一遍扫描直接生成,不用显式建 AST,因为递归下降的调用顺序本身就是语法树的后续遍历。下面这段是二元运算的翻译代码,每个子表达式都会返回一个操作数,可能是常量名、变量名或临时变量名。

public String translateExpr() { if (curToken == TokenType.INT_CONST) { String v = String.valueOf(curToken.value); match(curToken); return v; } if (curToken == TokenType.IDENT) { Symbol s = lookup(curToken.lexeme); if (s == null) { error("undeclared variable: " + curToken.lexeme); } String v = curToken.lexeme; match(curToken); return v; } // 处理二元运算:左右操作数先翻译,再合并成一个临时变量 String left = translateExpr(); Token op = curToken; match(curToken); String right = translateExpr(); String temp = newTemp(); quads.add(new Quad(op.lexeme, left, right, temp)); return temp; }

临时变量命名从 t0 开始递增,属于当前编译单元的全局计数。注意这里的一个小坑:操作数顺序不能反,四元式(-, a, b, t)和(-, b, a, t)的语义完全不同,而递归下降翻译减法时,左操作数是在读取减号之前拿到的,顺序天然正确,只要你不在代码里做任何栈内元素的交换。以a = 3 + 4 * 5;为例,生成的四元式如下:

(*, 4, 5, t0) (+, 3, t0, t1) (=, t1, , a)

第一行先算乘法,第二行再算加法,第三行把结果赋给 a。可以看出四元式列表就是运算顺序的直接投影,拿它和课本上的 DAG 图对照,一眼就能看出翻译是否正确。

4.3 控制流语句的回填

if 和 while 的翻译绕不开回填技术。所谓回填,是先把跳转四元式的目标地址留空,等真正的目标位置明确后再填充。我在报告里用一段带注释的代码演示 if 语句的处理,这是课设答辩的高频考点。

public void translateIf() { match(TokenType.KW_IF); match(TokenType.LPAREN); String cond = translateExpr(); // 条件表达式的值 match(TokenType.RPAREN); int jumpLFalse = quads.size(); // 记录“假跳”位置 quads.add(new Quad("jf", cond, "", "")); // 目标未定,先占位 translateBlock(); // then 部分 int jumpLEnd = quads.size(); // 记录“跳结尾”位置 quads.add(new Quad("j", "", "", "")); // then 结束后跳过 else quads.get(jumpLFalse).setResult(String.valueOf(quads.size())); // 回填假跳目标 if (curToken == TokenType.KW_ELSE) { match(TokenType.KW_ELSE); translateBlock(); } quads.get(jumpLEnd).setResult(String.valueOf(quads.size())); }

回填要特别注意:跳转目标填的是四元式的下标,也就是quads.size(),而不是某个符号名。很多人在这里填了 label 字符串,最后生成的中间代码在解释器里根本跑不动。四元式列表下标从 0 开始,回填的值是“下一条将要生成的四元式的位置”,这个位置就是 then 分支执行完、else 分支开始的地方。while 语句走同一套逻辑,只是多了一个循环开始标记,生成j回跳到循环头。回填这块代码错位了,调试起来一半是技术一半是玄学,因为打印出来的四元式看起来全都像是对的。

5. 课程设计避坑实录:六个高频翻车点与排查方法

5.1 词法层的两个翻车点

踩坑一:关键字被识别成标识符,语法分析一启动就报错。现象:输入int a;,词法输出(标识符, int)和(标识符, a),语法分析直接说int不符合文法。 原因:关键字表没有在类加载时初始化,或者关键字表里存的是"Int"而代码扫描出的是"int",大小写对不上。 解决:把KEYWORDS定义为static final并在静态块里全部填好,同时写一个单元测试断言KEYWORDS.get("int")不为空;词法主循环里务必先拼完整个字母序列再查表,不要边读边查。

踩坑二:整个 token 流的行号全部错位,排错无从下手。现象:报错信息的行号比实际位置大一行或者小一行,查了很久发现是注释里的换行没计数。 原因:行号自增逻辑写在了主循环末尾,而skipWhitespace()跳过换行后主循环已经看不到\n了。 解决:把line++移到skipWhitespace()内部,只要遇到\n就递增,主循环里不要再单独处理换行。这里最容易漏的是块注释内部的换行,处理块注释时也要带着行号计数。

5.2 文法与集合计算的翻车点

踩坑三:左递归没消除,递归下降运行到第二个表达式就 StackOverflow。现象:程序一执行就抛栈溢出,栈顶是parseE重复出现。 原因:用的是E → E + T这样的原文法,递归下降每一层都要先调parseE(),等于无限递归。 解决:把文法改写为E → T E'、E' → + T E' | ε,并且parseEPrime用 while 循环消化同层运算符。注意转写后加减法仍然是左结合,因为循环里每次都是拿左边已算完的结果和新的T运算。不能只改文法不改进循环,两者必须配套。

踩坑四:FOLLOW 集算着算着就不收敛,预测表里一个格子塞了多条产生式。现象:用程序检查预测分析表时,发现E'的#列有两个产生式。 原因:FOLLOW 计算时把FIRST(β)里的 ε 也并进去了,或者没有做不动点扫描,只遍历了一遍产生式就以为算完了。 解决:给 FOLLOW 加一个while(changed)的外层循环,每次集合发生变化就重扫所有产生式;另外在合并时先临时取firstBeta副本,去掉 ε 再并给 FOLLOW(B)。程序化计算 FIRST 和 FOLLOW 时,把所有非终结符的集合打印出来,和手工推导比对一次,最多五分钟就能定位。

5.3 错误恢复与中间代码回填的翻车点

踩坑五:一次性报几百个语法错误,实际只有一处词法错。现象:源代码少了一个分号,错误输出列表里跟着来了一大串expected token。 原因:没有设计同步记号,语法分析出错后继续死磕当前 token,导致雪崩效应。 解决:在递归下降每个方法的开头判断当前 token 是否属于该层的 FOLLOW 集成员,出错时先skipTo(分号或右括号),然后返回上级。错误计数只加一次,后续的错误定位会明显变准。同步记号不需要多复杂,选分号和右括号作为同步点就够了,LL(1) 的 FOLLOW 集本身就是现成的同步参考。

踩坑六:if 语句生成的跳转目标全是空字符串,解释器跑不动。现象:四元式列表里jf和j的 result 字段是空串。 原因:回填时用了未初始化的 label 变量,或者跳转位置记录错了四元式下标。 解决:跳转目标一律用整数下标表示,四元式全都存放在同一个 ArrayList 中,先记录占位下标,再等目标确定后执行get(idx).setResult(String.valueOf(quads.size()))。每生成一个完整 if/while 块后,打印出整个四元式列表检查一遍,重点看回填后的下标是否落在正确的四元式上。

6. 验证方法与调试技巧:测试用例三层设计与三个打印开关

6.1 测试用例三层设计

课设报告里最容易被老师挑刺的就是测试用例不完整。常规做法是用三类用例覆盖:正向验证功能,边界验证鲁棒性,错误验证报错恢复。下面这张表可以直接挪进文档的测试章节。

测试层级典型用例预期输出
正向a = 3 + 4 * 5;四元式中先乘后加
边界空程序、只含一个分号、连续负号a = - - 3;不崩溃,有空 token 或语法错误
错误int a = ;、未声明变量、a == b = c;错误信息带行号且不再雪崩

6.2 三个打印开关与答辩材料组织

词法、语法、中间代码三个环节各留一个打印开关,这是调试性价比最高的手段。词法层打印 token 流,语法层打印递归下降的进出栈,中间代码层打印四元式列表。比如递归下降方法里加一个 indent 参数,每进入一个方法就打印,退出就打退格,能和课本上的语法树对应起来。

private void parseE(String indent) { System.out.println(indent + "enter parseE, token=" + curToken); parseT(indent + " "); parseEPrime(indent + " "); System.out.println(indent + "leave parseE"); }

对着输出观察:如果8 - 3 - 2的打印显示出先进入右边的parseT再考虑左结合,基本可以断定结合性写错了。答辩时老师问的通常就三件事:文法为什么这样改写、FIRST/FOLLOW 怎么算、回填怎么实现,这三处在报告里都要有能指到代码的具体段落。每完成一个模块就写对应章节,词法那章贴 token 定义表和识别流程,语法那章贴改写后的文法和预测表,中间代码章节贴四元式示例。测试用例全部截图存档,尤其是错误用例的输出,能很好证明你的错误处理不是摆设。如果手里有清华社《编译原理(第3版)》的课后习题和参考答案,拿答案里给的中间代码序列和你的四元式逐条比对,很快能发现哪一步推错了。从那以后,我每次写完一个模块,都强制自己先跑一遍最小的正向用例,再跑一个错误用例,最后才跑完整测试集。这份文档和代码我整理在下载页了,需要的同学直接拿去对照,省得从空白的 main 函数开始憋。希望帮到你。

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

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

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

立即咨询