简介:这份资源是中国海洋大学2020年春季学期编译原理课程的完整实验代码合集,面向正在学习编译原理的高校学生与自学者,帮助读者把词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成、错误处理与编译器综合这八个环节逐一落地实践。压缩包为rar格式,共74个文件、约774KB,其中18个c源文件与8个h头文件承载核心实现,8个l与4个y文件对应Flex词法规则和Bison语法规则,另有4个makefile组织构建流程,并附实验要求文档与测试用例,便于对照实验目标验证输出。资源已有4411人学习下载,热度较高。读者可借助各实验的源码与文法文件,理解递归下降、LL(1)、LR等解析方法的实现差异,掌握符号表构建、类型检查、三地址码生成与常量折叠等优化策略,并参考综合实验把前七个模块串成从源代码到可执行文件的完整编译流程,是课程实验复盘与编译器构造入门的实用参考。
1. 从零跑通 OUC 编译原理全部实验:这份源码包到底能不能直接用
如果你正在修中国海洋大学的编译原理课,或者自学龙书配套实验,大概率会遇到一个很现实的问题:理论课听懂了 LL(1)、LR(1)、语法制导翻译,但真让你从零写一个词法分析器加语法分析器,还是不知道从哪下手。这份「OUC 编译原理全部实验」源码包,就是针对这个痛点整理的完整实验代码集合,覆盖词法分析、语法分析、语义分析等核心实验环节,适合正在跟课做实验的本科生,也适合想拿一套能跑的参考实现来对照自己代码的自学者。
我第一次拿到这类实验包的时候,最关心的不是它写了多少行,而是它能不能在我自己的环境里跑起来、输入输出格式跟老师给的验收标准对不对得上。很多同学搜「编译原理实验源码」搜到的代码,要么是残缺的,要么是某个学长随手传的半成品,跑一遍全是报错。这份资源的价值在于它是按 OUC 课程实验要求组织的,实验之间的衔接关系比较清楚,你不需要自己去猜每个实验该交什么。接下来我会按「先搞清楚每个实验在干什么,再动手跑,最后说坑」的顺序,把这份源码包拆开讲一遍。
2. 实验环境搭建与源码结构:先别急着编译,把目录看明白
2.1 环境依赖与工具链选择
编译原理实验对语言本身没有硬性要求,但这份 OUC 实验源码主要用的是 C/C++ 和 Java 两条线。常见做法是:词法分析和语法分析用 C++ 写,因为要手动管理字符指针和状态机,C++ 的控制粒度更细;语义分析和中间代码生成部分如果涉及面向对象的结构,Java 会更省事。你拿到包之后第一件事是确认每个实验目录下用的是哪种语言,别拿着 Java 的代码去配 C++ 的编译环境。
工具链方面,C/C++ 用 gcc/g++ 就行,版本不要太老,g++ 7 以上基本没问题。Java 部分需要 JDK 8 或以上,如果你用的是 IDEA 或者 Eclipse,直接导入项目目录即可。有些实验可能用到 flex 和 bison 做自动生成,但 OUC 的实验通常要求手写词法/语法分析器,所以 flex/bison 更多是作为对照参考,不是必须安装的。
提示:先跑
g++ --version和java -version确认环境,版本不对后面报的错会很有迷惑性。
2.2 目录结构与各实验对应关系
一份组织良好的编译原理实验包,目录结构通常长这样:
OUC-Compiler-Labs/ ├── Lab1-Lexer/ # 词法分析器 │ ├── src/ │ ├── test/ │ └── README.md ├── Lab2-Parser/ # 语法分析器(LL(1) 或 LR) │ ├── src/ │ ├── test/ │ └── README.md ├── Lab3-Semantic/ # 语义分析与符号表 │ ├── src/ │ └── test/ └── Lab4-CodeGen/ # 中间代码生成(四元式等) ├── src/ └── test/你拿到手之后先对照课程实验指导书,确认每个 Lab 对应哪次实验。有些年份的实验顺序是「词法→语法→语义→代码生成」,有些会把语义和代码生成合并。如果目录名跟你的实验要求对不上,不要慌,打开每个目录下的 README 或者源文件头部的注释,通常能看到实验编号和题目描述。
2.3 编译与运行第一个实验
以词法分析器为例,假设 Lab1 是 C++ 写的,典型编译命令如下:
# 进入词法分析器目录 cd OUC-Compiler-Labs/Lab1-Lexer # 编译,-o 指定输出可执行文件名 g++ -std=c++11 -o lexer src/main.cpp src/lexer.cpp # 运行,通常需要传入一个测试源文件 ./lexer test/test1.c这段命令里-std=c++11是显式指定 C++ 标准,因为有些实验代码用了auto或范围 for 循环,不指定标准可能编译不过。src/main.cpp和src/lexer.cpp是源文件列表,如果你的目录里还有token.cpp之类的文件,也要一起加进去。运行时的参数test/test1.c是待分析的源代码文件,词法分析器会读取这个文件并输出 token 序列。
如果编译报错说找不到头文件,检查一下源文件里的#include路径是不是相对路径,有些代码写的是#include "lexer.h",那你要确保lexer.h跟main.cpp在同一目录或者用-I指定了头文件搜索路径。
# 如果头文件在 include 目录下,需要加 -I g++ -std=c++11 -Iinclude -o lexer src/main.cpp src/lexer.cpp参数-Iinclude告诉编译器去include目录下找头文件。这个细节很多同学会忽略,导致明明文件都在却报「No such file or directory」。
3. 词法分析与语法分析实验:手写分析器的核心逻辑与调试方法
3.1 词法分析器的状态机实现要点
词法分析的本质是把字符流转换成 token 流。OUC 实验通常要求识别关键字、标识符、常数、运算符和界符这几类 token。手写词法分析器最常见的方式是有限状态自动机(DFA),代码里表现为一个while循环加switch或者多个if-else分支。
一个典型的标识符识别逻辑是这样的:
// 当前字符是字母或下划线,开始识别标识符 if (isalpha(ch) || ch == '_') { string token; // 持续读取字母、数字、下划线 while (isalnum(ch) || ch == '_') { token += ch; ch = fgetc(fp); // 读取下一个字符 } // 回退一个字符,因为多读了一个 ungetc(ch, fp); // 判断是关键字还是普通标识符 if (keywords.count(token)) { printf("(KEYWORD, %s)\n", token.c_str()); } else { printf("(IDENTIFIER, %s)\n", token.c_str()); } }这段代码的关键点在于ungetc回退。词法分析器在读取标识符时,会多读一个不属于标识符的字符(比如空格或运算符),这个字符不能丢掉,必须回退到输入流里,否则下一个 token 就会少一个字符。这是手写词法分析器最容易翻车的地方之一,现象是输出的 token 序列莫名其妙少了一个运算符或者多了一个空格。
参数方面,keywords是一个set<string>或map,里面预存了所有关键字。你需要在初始化阶段把int、float、if、while这些关键字塞进去。注意 C 语言的关键字和 C++ 不完全一样,按你实验要求来。
3.2 语法分析器的 LL(1) 与 LR 选择
语法分析实验一般有两种路线:LL(1) 和 LR(1)。LL(1) 是自顶向下的递归下降分析,代码结构清晰,适合手写;LR(1) 是自底向上的移进-归约分析,需要构造分析表,代码量更大但能处理的文法范围更广。
如果你拿到的源码包里语法分析器是 LL(1) 的,核心逻辑通常是这样的:
// 递归下降分析函数示例:解析表达式 void parseExpr() { parseTerm(); // 先解析一个项 while (currentToken == "+" || currentToken == "-") { string op = currentToken; advance(); // 消费运算符 parseTerm(); // 解析下一个项 // 这里可以生成四元式或直接求值 emit(op, ...); } }advance()函数负责从词法分析器获取下一个 token。递归下降的优点是跟文法产生式一一对应,你看着文法就能写出代码;缺点是遇到左递归文法需要先消除左递归,否则会无限递归导致栈溢出。
如果源码包用的是 LR 分析,你会看到一个分析表(ACTION 表和 GOTO 表),通常用二维数组或 map 存储。LR 分析器的核心是一个栈和一个循环:
// LR 分析主循环伪代码 stack.push(0); // 初始状态 while (true) { int state = stack.top(); string token = currentToken; string action = ACTION[state][token]; if (action starts with "s") { // 移进 stack.push(token); stack.push(stoi(action.substr(1))); advance(); } else if (action starts with "r") { // 归约 int prod = stoi(action.substr(1)); // 弹出产生式右部长度个符号 for (int i = 0; i < production[prod].rhs.size(); i++) { stack.pop(); } int gotoState = GOTO[stack.top()][production[prod].lhs]; stack.push(production[prod].lhs); stack.push(gotoState); } else if (action == "acc") { break; // 分析成功 } else { error("语法错误"); // 报错 } }这段代码里ACTION和GOTO表是核心数据结构,通常由实验指导书给出或者你自己根据文法构造。调试 LR 分析器时,最有效的方法是把每一步的栈内容和当前 token 打印出来,对照分析表看是哪一步走错了。
3.3 用测试用例验证分析器正确性
写完或者拿到分析器之后,不要随便找个代码文件就跑。建议按以下顺序准备测试用例:
| 测试类型 | 输入示例 | 预期结果 |
|---|---|---|
| 合法简单程序 | int a = 1; | 正常输出 token 序列 |
| 含注释程序 | /* comment */ int a; | 注释被正确跳过 |
| 含浮点数 | float x = 3.14; | 识别为 FLOAT 类型 |
| 非法字符 | int a = @; | 报错并指出位置 |
| 空输入 | 空文件 | 不崩溃,输出空或提示 |
跑测试的时候,把输出重定向到文件,方便跟预期结果做 diff:
./lexer test/test1.c > output1.txt diff output1.txt expected1.txtdiff命令会告诉你哪一行不一致。如果输出格式是(TYPE, value)这种,注意括号、逗号、空格是否跟验收标准完全一致,很多同学代码逻辑对了但格式不对,验收时被扣分。
4. 语义分析与中间代码生成:符号表、类型检查与四元式输出
4.1 符号表的组织与作用域处理
语义分析阶段的核心数据结构是符号表。符号表用来记录每个标识符的类型、作用域、存储位置等信息。OUC 实验通常要求实现一个支持嵌套作用域的符号表,常见做法是用栈式符号表或者树形符号表。
栈式符号表的思路是:进入一个新作用域时压入一个新表,退出时弹出。查找变量时从栈顶往下找,找到第一个匹配的就返回。
// 栈式符号表简化实现 vector<map<string, Symbol>> scopeStack; void enterScope() { scopeStack.push_back(map<string, Symbol>()); } void exitScope() { scopeStack.pop_back(); } Symbol* lookup(string name) { // 从栈顶往下查找 for (int i = scopeStack.size() - 1; i >= 0; i--) { if (scopeStack[i].count(name)) { return &scopeStack[i][name]; } } return nullptr; // 未声明 } void insert(string name, Symbol sym) { scopeStack.back()[name] = sym; }enterScope和exitScope分别在进入和退出代码块时调用。lookup从最内层作用域开始找,实现了「内层屏蔽外层」的语义。insert总是插入当前最内层作用域。这个结构看起来简单,但实际写的时候容易忘记在函数参数、for 循环变量等位置调用enterScope,导致变量作用域混乱。
4.2 类型检查与语义错误报告
类型检查是语义分析的另一项任务。比如赋值语句左右两边类型不匹配、运算符操作数类型不对、函数调用参数个数不对,这些都需要在语义分析阶段报出来。
一个常见的类型检查逻辑:
// 检查赋值语句类型是否兼容 void checkAssignment(string lhsType, string rhsType, int line) { if (lhsType == rhsType) return; // 类型相同,直接通过 // int 可以隐式转换为 float if (lhsType == "float" && rhsType == "int") return; // 其他情况报错 printf("Error at line %d: cannot assign %s to %s\n", line, rhsType.c_str(), lhsType.c_str()); }这段代码里line参数用来定位错误行号,方便调试。实际实验中,错误报告格式要按指导书要求来,有的要求输出到 stderr,有的要求输出到文件。类型兼容规则也要按实验要求,比如有的实验不允许 int 到 float 的隐式转换,那就不能加那条if。
4.3 四元式生成与输出格式
中间代码生成通常以四元式形式输出,格式是(op, arg1, arg2, result)。比如a = b + c对应的四元式是(+, b, c, t1)和(=, t1, _, a)。
// 四元式生成示例 int tempCount = 0; // 临时变量计数器 string newTemp() { return "t" + to_string(++tempCount); } void emit(string op, string arg1, string arg2, string result) { printf("(%s, %s, %s, %s)\n", op.c_str(), arg1.c_str(), arg2.c_str(), result.c_str()); } // 处理二元运算 string genBinaryOp(string op, string left, string right) { string temp = newTemp(); emit(op, left, right, temp); return temp; }newTemp每次生成一个新的临时变量名,避免冲突。emit负责输出四元式。genBinaryOp封装了「生成临时变量+输出四元式+返回临时变量」的流程,在递归下降的表达式分析中调用起来很方便。
输出格式方面,注意四元式的括号、逗号、空格要跟验收标准一致。有些实验要求空位用_填充,有些要求直接留空,这个细节要对照指导书确认。
5. 实验避坑与常见问题排查:那些年我们踩过的编译坑
5.1 词法分析器输出 token 序列错位
现象:输入int a = 1;,输出却是(KEYWORD, int)、(IDENTIFIER, a)、(OPERATOR, =)、(INTEGER, 1)、(DELIMITER, ;)看起来正常,但换成int a=1;就变成(IDENTIFIER, a=1)或者少一个 token。
原因:词法分析器在读取标识符或数字时,没有正确处理紧挨着的运算符。比如读到a之后继续读=,发现=不是标识符字符,但没有回退,导致=被吞掉或者被拼进上一个 token。
解决:在识别标识符、数字、字符串等需要「多读一个字符」的场景,统一使用ungetc回退。检查所有while循环读取字符的地方,确认退出循环后是否回退了不属于当前 token 的字符。
5.2 语法分析器遇到空产生式死循环
现象:程序运行后卡住不动,CPU 占用很高,或者栈溢出崩溃。
原因:LL(1) 递归下降分析中,如果文法含有左递归或者空产生式处理不当,会导致递归函数无限调用自身。比如A -> A α | β这种左递归,直接写成递归函数就是无限递归。
解决:先消除左递归,把A -> A α | β改写成A -> β A'、A' -> α A' | ε。然后在递归下降代码里,对空产生式加判断:如果当前 token 不在 FOLLOW 集中,就不进入该产生式的处理函数。
5.3 符号表作用域嵌套导致变量找不到
现象:在函数内部声明的变量,在函数外部访问时报「未声明」,或者在 if 块内声明的变量在块外还能访问。
原因:enterScope和exitScope的调用位置不对。常见错误是只在函数入口调用了enterScope,但 if、while、for 这些块级作用域没有单独处理。
解决:在语法分析器中,每遇到一个{就调用enterScope,每遇到一个}就调用exitScope。函数参数列表也要单独开一个作用域。调试时可以在enterScope和exitScope里打印当前作用域深度,观察是否跟代码块嵌套一致。
5.4 四元式临时变量命名冲突
现象:生成的四元式里出现两个不同的表达式用了同一个临时变量名,导致后续优化或解释执行时结果错误。
原因:临时变量计数器是全局的,但如果在递归调用中重置了计数器,或者多个表达式并行生成时共享了计数器但没有正确递增,就会冲突。
解决:确保tempCount是全局变量且只在一个地方递增。如果实验要求支持多函数,每个函数可以有自己的临时变量前缀,比如f1_t1、f2_t1,避免跨函数冲突。
5.5 编译通过但运行时报段错误
现象:g++编译没有报错,但运行可执行文件时提示Segmentation fault。
原因:常见的是空指针解引用、数组越界、或者栈溢出。比如符号表查找返回nullptr后没有判断就直接使用,或者递归下降分析器递归层数太深导致栈溢出。
解决:用gdb调试,运行gdb ./lexer,然后run test/test1.c,崩溃后输入bt查看调用栈。如果是空指针,检查所有lookup的返回值是否都做了非空判断。如果是栈溢出,考虑把递归改成迭代,或者增大栈大小。
6. 进阶用法:把实验代码改造成可复用的编译前端
6.1 从实验代码到通用前端的改造思路
实验代码通常只针对特定文法,输入输出格式也是固定的。如果你想把它改造成一个能处理多种语言的编译前端,需要做几件事:把词法规则和语法规则从代码里抽出来,做成配置文件或者数据结构;把 token 定义和 AST 节点定义标准化;把错误处理统一成异常或错误码。
一个实用的改造是给词法分析器加一个规则表:
// 用正则规则表驱动词法分析 struct LexRule { string pattern; // 正则表达式 string tokenType; // token 类型 }; vector<LexRule> rules = { {"[a-zA-Z_][a-zA-Z0-9_]*", "IDENTIFIER"}, {"[0-9]+", "INTEGER"}, {"[0-9]+\\.[0-9]+", "FLOAT"}, {"\\+|-|\\*|/", "OPERATOR"}, {";|\\(|\\)|\\{", "DELIMITER"} };这样改的好处是,换一种语言只需要改规则表,不用动主循环逻辑。当然,正则匹配的性能不如手写状态机,但对于实验和中小规模输入来说够用了。
6.2 用脚本批量验证实验输出
如果你有多个测试用例,手动跑一个个对比很费时间。写个 shell 脚本批量验证:
#!/bin/bash # 批量测试词法分析器 for testfile in test/*.c; do base=$(basename "$testfile" .c) ./lexer "$testfile" > "output/${base}.out" if diff -q "output/${base}.out" "expected/${base}.exp" > /dev/null; then echo "[PASS] $base" else echo "[FAIL] $base" diff "output/${base}.out" "expected/${base}.exp" fi done这个脚本遍历test目录下所有.c文件,跑一遍分析器,然后跟expected目录下的预期输出做对比。diff -q只返回是否不同,不输出具体差异;如果失败,再跑一次不带-q的diff显示具体哪一行不一致。这个习惯能帮你在验收前快速发现格式问题。
6.3 一个我踩过的坑:别在验收前改代码
最后说一个血泪经验。我当年做编译原理实验的时候,验收前一天觉得代码里有个变量命名不好看,顺手改了个名字,结果忘了改另一处引用,编译直接报错。折腾到凌晨两点才找到问题。从那以后我每次验收前都强制走一遍完整流程:先git status确认没有未提交的改动,再从头编译一遍,跑全部测试用例,确认输出跟预期一致,最后才去验收。
如果你拿到的这份 OUC 编译原理实验源码包能帮你省下从零写代码的时间,那它的价值就已经体现了。但别只是复制粘贴,至少把每个实验的核心函数读一遍,知道它在干什么,验收的时候老师问起来也能答得上。希望帮到你。
本文还有配套的精品资源,点击获取