简介:围绕OUC(中国海洋大学)编译原理课程的全部实验代码合集,覆盖2020年春季学期8个实验,从词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成到错误处理与编译器综合,并附实验要求文档,适合正在学习编译器构造或需要完整实验参考的本科生。压缩包共74个文件,以C源码(18个.c)、Flex/Bison文法文件(8个.l、4个.y)、头文件与生成文件、可执行程序、Makefile及说明文档为主,整体仅774KB,便于按目录对照实验要求逐项运行与修改。已有4408人学习浏览。内容还提供lex.yy.c、parser.tab.c、AST相关代码等中间产物,以及test*.p测试文件,能帮助理解从源码到目标代码的完整编译流程。实验要求文档对每个实验的目标、输出和评估标准做了明确说明,配合代码可快速定位关键实现,适合课程设计、期末复习或自学编译原理时参考。
1. 先看懂实验地图:一学期编译实验都在做什么
很多同学拿到实验文档就急着动手,结果写了两星期发现方向错了。我的建议是,第一周不要写代码,先把整个实验体系在脑子里过一遍。编译原理这门课,实验往往比期末考试更能让人崩溃,但也是最能建立成就感的一环。我当年做OUC编译原理全部实验的时候,从词法分析一路写到中间代码生成,几乎每个阶段都要经历一次“看不懂实验要求”和“编译器报错看不懂”的双重折磨。这篇内容就把这些实验的整体脉络、每一步的核心思路和踩过的坑整理出来,给正在被FIRST集、LR(1)项集和四元式折磨的同学一份可以照着走的参考。不管你是刚上手词法分析,还是已经卡在语法分析、中间代码生成,这篇都能帮你把“这实验到底要我干嘛”变成“原来就这么回事”。
1.1 每个实验对应编译器的哪个阶段
编译器的经典阶段划分是:词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成。高校的实验课通常把前四个阶段拆成2到5个独立实验,每个实验都有明确的输入和输出要求。
如果你手里是王生原老师那本编译原理教材(清华大学出版社第三版),你会发现实验内容和课本章节的对应关系非常整齐:词法分析对应“词法分析”一章,语法分析对应“语法分析”那一章,语义分析部分会用到语法制导翻译和中间代码生成。很多同学搜课后答案是为了刷概念题,但实际上实验报告里真正卡人的不是概念,而是怎么把课本上的算法翻译成可运行的代码。所以我的建议是:课后题可以用来校验FIRST集、FOLLOW集、LR项集这些手算结果,但写代码时别老想着翻答案,算法本身比答案重要得多。
1.2 用什么语言写,一句话讲清楚
理论上任何语言都能写一个实验编译器,但实际选择会直接影响调试效率。如果老师没有强行限制语言,Java是更省心的选择,理由是类语法天然适合表达Token、语法树节点和符号表条目;IDE的调试体验好;JUnit写单元测试很方便,能支撑“跑一个用例看一个阶段”的实验节奏。
当然,如果你的实验环境是C语言,也不是不能做,用struct加函数指针一样能组织出一套清晰的代码。C写起来更贴近“编译器底层”的感觉,但内存管理会多消耗很多时间,尤其是字符串处理和对象生命周期管理,极易出错。我见到过不少小组用C写到语法分析阶段就崩掉,大多不是算法问题,而是malloc和字符串拷贝翻车。
提示:选语言之前先问清楚老师对提交物有没有限制。有的课程要求最终提交C/C++代码,这种情况下直接放弃Java,别做无谓的切换成本。
1.3 动手前先读三样东西
第一是实验文档里的“输入输出示例”,这部分决定了你测试用例怎么写;第二是这门课的评分标准,看看哪个实验权重高,哪个可以“能跑就行”;第三是老师给的基础代码框架,如果实验平台已经给了Token定义和Parser骨架,你的工作量会少很多,但也要清楚模板里哪些地方是留给你填的。别一上来就喊“老师模板有bug”,很可能是你没有注意到模板里预留的TODO。
2. 词法分析:把源文件变成Token流
词法分析是整个编译前端最不性感但最不能出错的部分。它做的事很简单:读入字符流,跳过空白和注释,识别出标识符、关键字、数字、运算符和界符,输出一串带位置信息的Token。很多同学的第一个实验就挂在这里,大部分不是因为算法多难,而是边界情况没处理干净。
2.1 手工构造DFA,而不是写一堆if-else
正规的实验做法是手工设计一个确定有限自动机,把扫描逻辑写成状态转移。核心思路很简单:读进一个字符,根据当前状态决定下一个状态;状态改变说明一个Token结束了。这个过程中最关键的两个原则是最长匹配和词素定界。
给你们一个极简的状态图设计思路。识别标识符和关键字时,进入identifier状态后持续读字母、数字和下划线,直到遇到空白或界符为止。识别数字时进入digit状态,支持整数和开头是小数的实数,注意指数部分是否需要支持,要提前对照实验文档。识别运算符时,对单字符运算符如+、-、*、/、=、<、>等直接返回;对可能组成双字符的运算符,比如<=、>=、==、!=、&&、||,得分状态处理:读到一个<后不能立刻返回,必须试读下一个字符,如果是=则返回<=,否则把=字符“退回去”。这里最典型的坑是“最长匹配”:输入是>=时,如果贪心不足先返回了>再返回=,后续语法分析绝对会疯掉。
注意:空白、换行和注释不要直接丢弃就完事,行号和列号是错误定位的关键信息。token结构里至少要有type、lexeme、line、col四个字段。
2.2 Token设计与保留字表的处理
Token类型建议用枚举表达:IDENT、INT_LITERAL、REAL_LITERAL、KEYWORD、OP、DELIMITER。这里有一个最常见的错误:把关键字当成标识符处理。正确做法是先按标识符的规则识别出整个词素,再查保留字表,如果在保留字表里就标记为KEYWORD,否则才是IDENT。反过来做(先判断首字母是不是某个关键字开头)会出现把ifx识别成if加x这种低级问题。
推荐在词法分析器里维护一个HashMap做保留字表,这样查表是常数时间。如果实验结果要求输出词法分析报告,通常需要打印token类型、词素、所在行号,一个简单的循环遍历输出就行。写词法分析器要记得把每个识别逻辑拆成独立函数,比把所有状态都堆在一个大while里好调试得多。
2.3 注释与除法的恩怨
类C语言的注释是/* .../和//两种风格。扫描到/时,必须向后看一位:如果是,进入注释状态,一直读到*/;如果是/,读到行尾;否则才把/当除号返回。处理/*/时一定要考虑注释未闭合就遇到文件末尾的情况,这时候要给出明确报错,而不是静默结束或死循环。我做实验时就在这个分支里挂过一次,原因是只判断了读到但没有判断流是否已经到EOF,结果程序卡死。
代码骨架用Java大概是这个样子:
public Token nextToken() throws IOException { skipWhitespaceAndComments(); if (!reader.ready()) return new Token(TokenType.EOF, "", line, col); char c = reader.peek(); if (Character.isLetter(c) || c == '_') return readIdentifierOrKeyword(); if (Character.isDigit(c)) return readNumber(); return readOperatorOrDelimiter(); }测试用例至少覆盖:空程序、纯注释程序、关键字与标识符混输、带小数的数字串、连续运算符、不合法字符,以及“ifx这类以关键字开头的标识符”。
3. 语法分析:两种主流路线的落地细节
词法分析跑通之后,实验难度会瞬间上一个台阶,因为语法分析要求你把上下文无关文法变成可执行的识别程序。常见的实验路线有两条:递归下降分析,以及基于预测分析表的LL(1)分析。很多课程还会单独安排一个LR分析实验,这个我放到下一节单独讲。
3.1 先手算一遍FIRST和FOLLOW,再写代码
写语法分析器之前,一定要先把你给定文法的FIRST集和FOLLOW集手算出来,这个步骤省不得。手算的目的不是为了交报告,而是为了让你知道代码里每个非终结符在什么输入下应该做什么选择。
计算FIRST集的要点是:如果X能推导出ε,那ε就算进FIRST(X)里,并且这个“可推导出ε”的信息会向前传播。计算FOLLOW集时,要关注产生式右部某个非终结符后面跟的是什么,如果后面是另一个非终结符,要看它能推出的第一个终结符集合;如果后面什么也没有,就把左边非终结符的FOLLOW集继承过来。这个“继承”环节特别容易漏,一旦漏了,后面构建预测分析表时就会缺表项。期末试题里手算FIRST、FOLLOW的题也是高频考点,实验里算过一遍,考试基本送分。
3.2 递归下降写法:函数代替状态机
递归下降分析器本质上是把文法直接翻译成一组互相调用的函数,每个非终结符对应一个函数。它的实现思路是:函数开头先读取当前的lookahead token,然后按产生式右侧的顺序依次匹配终结符或调用其他非终结符函数。需要特别注意的是,递归下降直接支持的是LL(1)类文法,遇到左递归必须先处理。
比如文法E -> E + T | T,直接写成递归会无限递归,必须先消除左递归,变成E -> T E',E' -> + T E' | ε。用循环结构可以更直观地实现E':先parseT(),再看当前lookahead是不是+号,是就继续循环,直到没有+号为止。这个写法在报告里描述为“用循环等价实现了右递归文法”即可,不必把E'单独拆成一个函数写到死。
代码结构往往是:
parseE() { parseT(); while (lookahead.type == PLUS) { match(PLUS); parseT(); } }是不是很像在写表达式求值?对,递归下降本来就是一种“看见什么就吞什么”的匹配逻辑,写起来非常直观。而它的坑也出在直观:如果文法存在公共左因子,比如stmt -> if E then stmt | if E then stmt else stmt,直接写两个branch代码会不知道选哪个,必须先提取公共左因子。
3.3 预测分析表和表驱动分析程序
如果你选的路线是LL(1),那核心工作是构建一张预测分析表:行是非终结符,列是终结符,表项填产生式。构建规则很简单:对每个产生式A -> α,把α能推出的每个首终结符对应的格子填上这条产生式;如果α能推出ε,就把FOLLOW(A)里的终结符对应格子也填上这条产生式。
表驱动的分析程序维护一个分析栈,初始把#和开始符号压栈,然后不断读输入、查表、弹栈,遇到匹配的终结符就吃掉一个输入token,遇到非终结符就把栈顶弹出后按产生式右部逆序压入。这部分的调试,我强烈建议打印分析栈的状态变化,每一步都输出“当前栈、当前输入、查表结果”,一旦走偏立刻能看出是哪一步压错了栈。
3.4 错误处理:不能报个错就完事
语法分析实验往往会被忽略错误恢复,但评分老师非常看重这一点。最简单的做法是恐慌模式:发现错误时,不断丢弃输入token,直到遇到同步集合中的token(一般是语句结束符、右括号、分号等),然后继续分析。这样一次输入可以报告多个语法错误,而不是死在一个错误上。你别小看这个细节,很多同学的代码遇到一个错误就退出,测试用例一多直接崩,最后分数拉不开差距往往就靠这些。
4. LR分析:手工构造分析表的完整思路
LR分析是很多同学觉得最抽象的一环,其实它和LL(1)的差异只在“如何决策”上。LL(1)靠的是递归下降或预测表,LR靠的是状态机加ACTION/GOTO两张表。我不建议只背算法,要理解它到底在记录什么。
4.1 为什么需要LR分析
左递归文法比如E -> E + T | T,对递归下降不友好,但对LR来说完全没问题,因为它天生就是从右向左规约的思路。LR分析时看到的是栈上已有的符号序列和接下来的输入,它决定当前应该“移进”还是“规约”,本质上是在用有限状态机模拟一个推导过程。很多课程把这个实验安排在后半段,就是为了让你跳出“手写递归下降很爽”的舒适区,真正理解编译器的通用识别框架。
4.2 手工构造项集族和ACTION/GOTO表
构造步骤是:先给文法加一个S' -> S的拓广产生式,然后计算LR(0)项集族。每个LR(0)项集是一个闭包,闭包规则是:如果项中圆点后面是非终结符B,就把所有B -> γ的产生式加进去,圆点放在最左端。通过move函数(也就是读入某个文法符号后圆点右移)得到状态转换关系。
随后给每个可以规约的项目打上规约标签,填ACTION表时:遇到终结符移进填s,遇到可以规约的项目填r(产生式编号),根据FOLLOW集判断哪些输入列可以规约(这是SLR简化之处,所以叫SLR(1))。GOTO表则记录遇到非终结符后的状态跳转。如果某一格同时被填了移进和规约,就产生冲突,说明文法不是SLR(1),这时可能要考虑LR(1)或LALR,不过课程实验里通常不会难到这一步。
用一个极小的例子来感受一下过程。文法:
S' -> S S -> (L) | a L -> S | L, S手工构造时会看到:在初始项集里,圆点后的S是非终结符,所以要同时加入S -> (L)和S -> a;读入左括号后进入一个新状态,那里圆点后面是L,于是又加入L -> S和L -> S右部的展开。把每一条状态转移写下来,就是一张状态图。这个过程确实繁琐,但理解之后,任何LR分析表题都只是体力活。手算题建议对照着教材的表格多练几次,用Excel或者纯手写都行,重点是别跳过。哈工大陈鄞老师的编译原理课程对这一部分讲得特别清楚,如果课堂没听明白,可以去看那套视频补一下理论,再回到实验里手动构造一次,印象会深很多。
4.3 表驱动LR程序的调试技巧
实现上,LR分析程序比LL(1)还简单:维护状态栈和符号栈,循环里查ACTION表。真正的难点是调试。我第一次运行时,状态栈里全是数字,根本不知道自己在哪。后来我在每次移进、规约时打印一行日志,包含“状态栈顶、符号栈顶、当前输入token、执行的动作”,逻辑一下子清楚了很多。遇到归约后GOTO跳错,十有八九是表里的状态编号填错了,对照着状态图逐行查。
建议:把ACTION表和GOTO表存成二维数组或哈希表,打印成易读的表格格式方便对照检查。直接手写一堆switch-case是维护噩梦,我不推荐。
5. 语义分析与中间代码生成:让程序真正“有含义”
前面几关通过的代码还只是“能识别语言结构”,到了语义分析,编译器开始关心“这句话到底是什么意思”,需要维护符号表做声明检查和类型检查,再把表达式翻译成中间代码。
5.1 符号表设计:作用域和遮蔽关系的处理
最简单的符号表设计是作用域栈:进入一块代码时往栈里压一层,退出代码块时弹出。查找符号时从栈顶往栈底找,这样内层变量能遮蔽外层同名变量,符合大多数语言的语义。如果你们实验要求支持函数,那符号表里还要记录参数列表、返回类型这些信息,函数符号和普通变量符号建议分开建表,或者用统一的符号条目加个kind字段。
这一阶段很容易出现“变量明明声明了,却报未定义”的问题,原因几乎都是符号表作用域没有正确压栈和弹栈。建议在进入和退出作用域时打印一行日志,观察符号表的层次变化,这比反复读代码管用。
5.2 类型检查:宁可多报,不可漏报
实验里的类型检查不用做得很复杂,但至少要做到:赋值表达式左右类型兼容、二元运算的两个操作数类型一致、数组下标是整数、条件表达式是布尔类型。实现方式是在语义动作里对每个节点做类型推断,子节点类型不符合要求时输出语义错误。这一步最容易出错的是“类型谁兼容谁”的判断逻辑,我见过有人把int和float直接判不相等,导致所有浮点表达式都报错,调试半天才发现是不小心用了引用比较而不是值比较。
5.3 四元式与语法制导翻译
中间代码常见形式是三地址码,落地实现常用四元式结构:
(op, arg1, arg2, result)表达式 a + b * c 会生成:
(*) b c t1 (+) a t1 t2 (=) t2 - x生成四元式时,递归下降的返回值不能只是布尔“是否匹配成功”,得把该表达式的“结果位置”传出来。比如parseExpr里解析完每一项后,知道这项的临时变量名是什么,就能正确拼出运算的四元式。这其实就是标准教材里说的语法制导翻译思想:在语法分析过程中嵌入语义动作。
5.4 控制流语句:回填的想法
if和while的翻译要用到标签跳转。最简单的做法是先生成一个无目标地址的跳转四元式,等条件翻译完、目标位置确定后再把地址填回去,这就是回填。先在纸上画好if-then-else流程图的四元式接入点,再写代码,能省很多事。break语句要维护一个待回填链,否则不知道跳去哪。
如果你们实验没到中间代码生成就结束了,那这一节可以直接跳过,但要理解“为什么这个阶段存在”,因为期末复习时经常会考到回填和四元式之间的对应关系,实验里见过一遍印象完全不同。同样,如果老师追加了目标代码生成实验,核心就是把四元式映射到汇编指令加寄存器分配,思路仍然是逐条翻译加临时变量管理。
6. 高频报错与调试技巧速查
这个部分集中整理我实际做实验时反复踩过的坑,按症状和解决思路列出来,省得你们一个个去搜索引擎试。编译原理实验的排错和普通编程作业不一样,问题常常横跨好几个阶段,找对定位点是第一步。
6.1 高频问题速查表
| 现象 | 常见原因 | 排查思路 |
|---|---|---|
| FIRST集计算时程序死循环 | 没有正确计算可推出ε的非终结符集合 | 先单独算出nullable集合,再算FIRST,避免循环依赖 |
| 词法分析把ifx识别成关键字 | 用了首字母匹配而非完整词素匹配 | 识别完整标识符后查保留字表 |
| 多行注释不结束程序卡死 | 没处理EOF | 在注释循环里检查文件是否读完 |
| 递归下降栈溢出 | 文法存在左递归未消除 | 先消除左递归,再写parse函数 |
| 预测分析表同一格多个产生式 | 文法不是LL(1) | 提取公共左因子,或检查FIRST/FOLLOW是否计算错误 |
| LR分析时状态栈数字完全看不懂 | 缺少动作日志 | 每次移进/规约都打印状态栈、符号栈、输入token |
| 类型检查不报任何错 | 语义动作没有真正执行,或符号表作用域没处理好 | 打印符号表,单独构造类型不匹配的用例验证 |
| 四元式顺序与表达式运算优先级不符 | 递归下降子函数返回顺序与优先级层级不匹配 | 用a + b * c这种用例逐步跟踪生成的四元式 |
6.2 调试编译器的实用工具与习惯
调试编译器这种“输出巨大、状态复杂”的程序,靠println是最高效的,不要一上来就上断点。设定一个环境变量或命令行参数来开关调试日志,比如-debug开启日志,正式运行时关掉。别忘了用专门的测试用例目录,每次改完代码跑一遍全量测试,这就是回归测试。不用复杂框架,写一个简单的shell脚本或者Java main函数遍历测试目录就行。
排查错误时有一个通用套路:先确认当前阶段输出对不对,再看下一阶段。比如中间代码生成了但结果不对,先用一个已知正确的Token流喂给语义分析模块,确认是前面传来的Token有误,还是语义分析本身出错。这样可以快速切断问题链条。
6.3 测试用例怎么造才能兜住老师
我的经验是:宁可自己多造50组测试,不要在评分时让老师发现一个边界bug。测试用例至少包括:最简单的合法程序、复杂的嵌套表达式、包含嵌套块和作用域遮蔽的代码、大量注释和空行干扰、未声明变量、类型错误的程序、缺少分号的错误恢复场景。每种错误类型至少准备一个用例,记录期望输出,再逐一核对你的程序输出。
如果实验要求支持语法错误定位,用例里还要包含“错误发生在文件中间位置”的情况,检查报错的行号和列号是否准确。这个细节经常被忽视,但编译器的错误提示质量其实是评分里很看重的一个维度。
最后再说几句
我实际做完这一整套实验最大的感受是:编译器不是玄学,它就是一个输入输出极其明确的软件,问题变得复杂只是因为每个阶段的输出都变成了下一阶段的输入,调试链条拉长了。所以你别试图一次写完一个大文件,词法、语法、语义一层层拆开,每一层跑通并产出独立结果再进下一层,出问题只查当前层,效率会高很多。最后再分享一个小建议:每个实验阶段都留一个一键运行的入口,测试用例统一放在一个目录里,从词法阶段开始全程保留,这样写到中间代码生成时,随时可以回来回归测试前几关。这个方法帮我省下的时间,足以让我多刷两套编译原理期末试题。祝你们顺利跑通编译器前端。
本文还有配套的精品资源,点击获取