简介:编译原理是计算机科学的核心领域,这套实验代码来自中国海洋大学2020年春季学期编译原理课程,面向该校计算机相关专业学生和自学编译器构造的开发者。资源覆盖词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成、错误处理与编译器综合的完整教学链条,对应8个实验及实验要求,既有手工实现也涉及Flex、Bison等工具,可系统训练编译器各阶段实现思路。压缩包共74个文件,约774KB,包含C源码、Flex词法规则、Bison语法规则、头文件、可执行程序、makefile与实验要求文本,目录按实验编号整理,便于对照练习和反复调试。实验配有测试用例、产生式说明和运行结果输出,能够帮助理解抽象语法树、符号表、类型检查等关键概念;尤其适合需要完整实验代码与可运行样例的课程学习者作为参考,已有4400余人学习下载。
1. 编译原理全套实验:与其从零硬写,不如先拆框架
如果你这学期也在啃编译原理,大概率绕不开这么一套组合拳:词法分析、语法分析、语义分析、中间代码生成。某高校的课程把这几个环节拆成五个实验,每个实验单独交源码和实验报告,最后合成一个能跑通 if-else、while、函数声明的迷你编译器。我拿到这套实验源码包时的第一反应是“又是个黑匣子”,翻完才发现里面的坑早有人趟过一遍——词法分析里最容易翻车的换行符处理,语法分析里悬空 else 的歧义,语义阶段符号表作用域没回滚导致的重复声明误报,每一条都有对应解法。这份资源适合三类人:实验截止前需要快速补进度的,想照着完整框架重写一遍验证理解的,以及想知道课程设计里哪些点容易丢分的。下面按实验顺序拆开讲。
2. 实验全景:一份源码包如何覆盖前端到中间代码
2.1 五个实验的划分与提交清单
编译原理课程实验一般不会让“一个编译器一次交齐”,而是拆成阶段提交,既避免最后一晚通宵,也方便在中间卡进度。这套资源包的划分方式很典型:词法分析、语法分析、符号表与语义分析、中间代码生成,最后是一个综合性集成实验。有些学校还会追加目标代码生成或自己写虚拟机,但核心始终是前面这四段。
下面这个表格基本就是这套实验的评分结构,交作业前照着这一列检查比对着课本目录检查靠谱得多。
| 实验编号 | 覆盖内容 | 核心考察点 | 常见交付物 |
|---|---|---|---|
| 实验一 | 词法分析 | 状态机建模、正则表达式、Token 分类 | Lexer 源码 + 测试用例 |
| 实验二 | 语法分析 | 文法改写、递归下降 / LR、错误处理 | Parser 源码 + 文法说明 |
| 实验三 | 符号表与语义分析 | 作用域、类型检查、属性计算 | SymbolTable 源码 + 语义测试 |
| 实验四 | 中间代码生成 | 三地址码、控制流翻译、数组寻址 | IR 输出 + 运行记录 |
| 实验五 | 综合集成 | 前后端串联、错误恢复、报告 | 完整工程 + 演示记录 |
注意实验三和实验四经常被合并成一个“语义分析”大实验,但内部还是分两段验收:先检查符号表,再检查中间代码。这份资源里两者分开建目录,说明课程组确实是想让学生分两次提交。
2.2 开发环境与工具链选型
很多人在环境选择上纠结半天,其实课程实验的核心就一句话:老师要在你的机器上能一键编译、一键跑通。资源里的源码是用 C++ 写的,工程结构保持得比较干净,不依赖 flex、bison 这类生成器,而是纯手写词法状态机和递归下降解析器。
手写还是用生成器,是个值得说清楚的选择。flex/bison 的优势是开发快,正则表达式和文法写完后自动生成 C 代码,但劣势也明显:一旦匹配行为不符合预期,生成的 DFA 像一个黑匣子,很难定位到底是哪一条转移写错了。课程实验通常还要求交实验报告,手写代码更容易把“状态转移图”和“递归下降子程序调用关系”画出来。另一个现实情况是,部分课程明确禁止使用 bison/yacc,要求必须体现自己对状态转移和栈式解析的理解。所以我一般建议:除非老师明确允许,否则用手写,至少把词法分析器手写出来。
编译器版本用支持 C++11 以上的就行,资源里的代码没有用到特别新的特性,最常用的就是vector、unordered_map、string和标准 IO。如果你本机只有默认 GCC,直接编译通常不会报错。
工程根目录放一个 Makefile 管构建,测试脚本一般放在tests/下。看这份资源的目录结构,基本是一个标准的前端项目布局:
. ├── include/ │ ├── token.h │ ├── lexer.h │ ├── parser.h │ ├── ast.h │ └── symbol_table.h ├── src/ │ ├── lexer.cpp │ ├── parser.cpp │ ├── ast.cpp │ ├── symbol_table.cpp │ └── main.cpp ├── tests/ │ ├── test_lexer.cpp │ ├── test_parser.cpp │ └── test_ir.cpp ├── examples/ │ ├── test_if.c │ ├── test_while.c │ └── test_array.c ├── Makefile └── README.md2.3 源码目录怎么读:入口、依赖和 Makefile
拿到源码包建议按照“Token -> Lexer -> Parser -> AST -> SymbolTable -> main”的顺序读,不要一上来就打开main.cpp。token.h定义了所有 Token 类型,比如关键字、标识符、整型字面量、运算符和分号,这是前端模块之间的通用接口;lexer.h只负责把字符流切成长度为 1 的 Token 流;parser.h消费 Token 流并构建 AST。模块之间通过头文件的接口咬合,内部实现互不牵扯。
这里先看 Makefile,确认构建目标是什么,通常目标名是compiler或minic。然后直接跑make,如果编译通过,再进入tests目录逐个跑测试,最后看examples里的样例源文件。
常见调用方式是接受一个文件路径作为参数:
make ./minic examples/test_if.c如果实验要求生成中间代码,程序会把三地址码打到终端或写进out.ir。main.cpp里一般长这样:
int main(int argc, char* argv[]) { if (argc < 2) { std::cerr << "usage: minic <file>" << std::endl; return 1; } std::ifstream in(argv[1]); Lexer lexer(in); Parser parser(lexer); auto program = parser.parseProgram(); SemanticAnalyzer analyzer; analyzer.check(program); IrGenerator generator; generator.generate(program); return 0; }argc判断必须有,很多同学在自己机器上跑通,交到老师那里因为没传文件名直接崩了,这种零分很冤。Lexer拿到文件流后,Parser从 Lexer 里逐个取 Token,AST 构建完再做语义检查和中间代码生成,整个链路非常直白。
3. 词法分析:手写状态机的实现细节
3.1 手写自动机还是 flex 生成?
词法分析实验的核心是正则表达式到状态机的转换。用 flex 写几行正则确实很快,但实验报告要求画 DFA 状态转移图时,很多人就答不上来了。手写状态机则相反,每个状态对应代码里的一个枚举值,每次读入一个字符就做一次状态转移,逻辑完全透明。
以 C 语言子集为例,词法需要处理的 Token 大致分四类:标识符与关键字、整数与浮点数、运算符与分界符、字符串与注释。每类对应一个或多个状态。我见过不少实现把状态枚举写得超级长,其实没必要,把状态数控制在 START、IN_ID、IN_NUM、IN_STRING、IN_COMMENT 这几个核心状态,用一个switch就能覆盖绝大部分语言。
选手写还有一个实践原因:编译器前端最花时间的不是逻辑,而是“边界条件”。flex 的正则把浮点数、负数、注释这些边界条件隐藏了,一旦出问题,你不知道是正则写错还是优先级不对。手写状态机则要求你显式处理字符回退和最长匹配,这套肌肉记忆对后面做语法分析也很有用。
3.2 状态机核心代码与关键参数
下面这段代码是从资源包里提炼出的核心框架,只看nextToken()这一个函数就能理解整套词法设计。为了避免每次判断是不是文件尾,构造时会在输入缓冲末尾偷偷塞一个EOF字符。
Token Lexer::nextToken() { LexState state = START; std::string lexeme; while (true) { char c = peek(); // 只看不消费 switch (state) { case START: if (isalpha(c) || c == '_') { state = IN_ID; lexeme += c; advance(); } else if (isdigit(c)) { state = IN_NUM; lexeme += c; advance(); } else if (c == '+' || c == '-' || c == '*' || c == '/') { lexeme += c; advance(); return Token(OP, lexeme, line, col); } else if (c == ' ' || c == '\t') { advance(); // 空白跳过 } else if (c == '\n') { advance(); line++; col = 0; } else if (c == EOF_CHAR) { return Token(END, "", line, col); } else { error("unexpected character"); } break; case IN_ID: if (isalnum(c) || c == '_') { lexeme += c; advance(); } else { return makeKeywordOrId(lexeme); } break; case IN_NUM: if (isdigit(c)) { lexeme += c; advance(); } else if (c == '.') { lexeme += c; advance(); state = IN_FLOAT; } else { return Token(NUM, lexeme, line, col); } break; case IN_FLOAT: if (isdigit(c)) { lexeme += c; advance(); } else if (c == '.') { error("invalid float: two dots"); } else { return Token(FLOAT, lexeme, line, col); } break; } } }peek()只返回当前位置字符但不移动指针,advance()才真正消费字符并更新行号列号,这样的分离让你可以在一个状态里多看几个字符而不丢失位置。所有能被归纳为同一类 Token 的字符流,最终都汇聚到对应的结束分支。
从参数角度看,line和col应该从 1 开始累计,col在遇到\n时清零。错误处理里必须携带行列号,否则语法分析阶段的报错无法定位。makeKeywordOrId内部查关键字表,查到了就是关键字 Token,查不到就是标识符 Token。
3.3 测试用例:把贪婪匹配的坑堵在前面
词法分析的测试用例不需要多,但必须覆盖边界。我通常建议至少准备下面这五组输入,跑不过就不用进语法分析了。
// 用例1:单个运算符"<="不能拆成"<"和"=" 1 <= 2 // 用例2:数字后面紧跟字母,报错还是切分? 123abc // 用例3:浮点数".5"和"1."要不要支持 x = 1.5 + .5 // 用例4:注释未闭合 /* comment // 用例5:最后一个 token 没有换行符 a = 1很多人的词法器能处理前面四组,却在第五组上翻车。原因是nextToken()依赖空格或运算符来触发“返回当前标识符”,如果读到了文件尾还没触发,标识符就永久停留在缓冲区里。解决办法是在初始化时给输入末尾追加一个哨兵字符,或者在advance()里检测到 EOF 后主动返回当前 lexeme。资源里对这种情况有处理,但我在课上见过太多实现都没加这一层判断。
4. 语法分析:递归下降与错误恢复
4.1 文法改写:消除左递归与提取公共因子
语法分析实验通常要求你选的文法能被递归下降解析器处理。递归下降本质上是 LL(1) 解析,最怕两件事:左递归和公共前缀。
左递归的出现很自然,因为常人习惯把表达式写成E -> E + T | T,但解析器不能先调用自己再读 token,否则会无限递归。标准做法是把左递归改写成右递归:
原文法: E -> E + T | T 改写后: E -> T E' E' -> + T E' | ε这里E'对应 EBNF 里的( + T )*,在递归下降里实现时其实不需要真的建一个parseEPrime函数,用一个while循环就能表示“循环匹配加法运算符和操作数”。所以代码比文法更简洁。
公共前缀的问题类似:如果两个产生式都以同一个非终结符开头,那么解析器读完第一个 token 后无法决定走哪条分支。解决方法是提取左公因子。比如if语句的悬空 else 改造,本质上就是一次公因子提取,这个在第 5 章会专门讲。
4.2 递归下降子程序与 FIRST/FOLLOW 集
递归下降解析器的每个非终结符对应一个解析函数,函数返回一个 AST 节点。下面这段代码是表达式层级的典型实现:
std::unique_ptr<Expr> Parser::parseExpression() { auto expr = parseTerm(); // 先处理乘除法所在的层级 while (token.is(TokenType::PLUS) || token.is(TokenType::MINUS)) { Token op = token; advance(); auto right = parseTerm(); expr = std::make_unique<BinaryExpr>(op, std::move(expr), std::move(right)); } return expr; }逻辑说明:parseTerm()对应文法里的T,它内部再调用parseFactor()处理括号和单个数字。PLUS和MINUS的判断放在 while 里,语义就是“任意多个 ( + | - ) T ”,这与改写后的E'完全等价,并且天然保留了左结合性。std::make_unique生成新节点后,左边子节点是之前循环累积的结果,右边是当前新解析出的操作数。
这个实现里最有价值的点是:循环就是右递归的迭代形态。写代码时可以不用显式计算 FIRST 集,但你要知道为什么这里能形成循环:因为parseExpression调用parseTerm后,下一轮只要 peek 到加减号就能进入下一次循环,不可能死递归。
真正的 FIRST/FOLLOW 集计算一般被放在parseFactor或语句解析里,用于决定是否调用某个分支。比如解析if语句时,求值表达式之后遇到RBRACE还是ELSE,直接决定了走哪个分支。手算 FIRST 集不复杂,把每条产生式右部首字符收集起来即可;FOLLOW 集要小心空串传递,资源里的实验报告模板把这两个集合的计算过程写成了表格,这是报告里最值钱的部分。
4.3 错误恢复:同步 token 集怎么设
好的语法分析器不应该碰到第一个错误就崩溃,而应该尽量跳过一段代码,接着解析后面的内容,让一次编译能暴露多个错误。递归下降解析器里最常用的错误恢复手段是同步 token 集合。
做法是:每当一个解析函数发现自己期望的 token 对不上,就调用同步例程,把 token 一直丢弃到某个安全的同步点。同步点一般选分号、右花括号,或者某些关键字的开始位置。
void Parser::synchronize() { advance(); while (!token.is(TokenType::EOF)) { if (token.is(TokenType::SEMI)) { advance(); return; } if (token.is(TokenType::RBRACE)) { return; // 不消费右花括号,留给上层处理 } advance(); } }这个方法很简单,但同步 token 的选择有讲究。选少了,比如只看分号,遇到漏写右花括号的错误就会一路跳过整个函数;选多了,比如把RBRACE也消费掉,又可能导致块语句的括号配平出错,产生连锁误报。我一般建议分号在同步时消费掉,右花括号留给上级处理,这样块结构不容易被破坏。
错误恢复的判定标准是“少误报、不掩盖真错误”,实验里老师会用几组带语法错误的输入来测,看你的编译器报错位置准不准。同步 token 没设计好的代码,最典型的表现是“错一行,报三行”,那基本就是同步过度了。
5. 避坑:课程实验里最常见的五个翻车点
5.1 未初始化变量让状态机卡死在循环里
现象:词法分析器一运行就卡死,CPU 占用百分之百,终端输出一堆乱码或直接无输出。
原因:advance()函数里用来暂存当前字符的成员变量没有初始化,第一次调用peek()时读到的是一块栈上残留数据,既不是字母也不是数字,更不是空格和文件结束符。状态机一边读一边往前挪指针,永远等不到一个能触发return的字符,于是陷入死循环。
解决:在 Lexer 构造函数里把所有成员变量初始化,尤其是当前字符和行列号,然后主动调用一次advance()把第一个真实字符读进来。
Lexer::Lexer(std::istream& input) : input(input), line(1), col(0), currentChar(0) { advance(); // 读入第一个字符 }另外不能只依赖currentChar判断是否到文件尾,advance()里要把input.get(currentChar)的返回值保存下来,文件读到底要同步设置一个isAtEof标志,后续状态转移优先检查该标志。
5.2 换行和 EOF 处理不当导致最后一个 token 丢失
现象:源程序最后一行如果没写换行符,最后一个变量名没有被识别成 token,编译报“无效输入”。
原因:词法分析器通常以空白、运算符作为标识符结束的触发条件。文件已经读完,但currentChar还没被“消费”,也没有触发标识符结束的逻辑,整个 lexeme 留在缓冲区里,最终被直接丢弃。
解决:在advance()中检测到读不到新字符时,把当前状态强制切到“缓冲区内 lexeme 可返回”的状态。可以在nextToken()的IN_ID分支里显式判断 EOF,也可以更简单:打开文件后先向内存 buffer 尾部追加一个\n,这样最后一个标识符一定会被空白符终结。很多生产级编译器的做法是在输入流末尾加一个不可见的哨兵 token,词法器读到哨兵就返回 END。这个思路是通用的。
5.3 浮点数与负数的词法冲突
现象:输入1..2时,词法器把1.识别成浮点数,然后剩下.2报“非法字符”;输入-3时被拆成负号-和数字3,语法分析阶段不好处理。
原因:浮点数正则写成[0-9]+(.[0-9]+)?时,扫描器看到小数点就直接贪婪吞下,不会向后多看一个字符来判断这是否是..,而某些教学语言里..是区间运算符。负数则是一个语法问题,把一元负号交给词法层处理会让 Token 种类膨胀,也破坏了词法和语法之间的职责边界。
解决:词法分析器在数字状态遇到.时,先往后读一位,如果下一个也是.,说明这是区间运算符的开头,数字解析立即结束,并把小数点“吐回”输入流。负数不放进词法器,统一在语法层用“一元负号”产生式处理:
Factor -> NUM | - Factor | ( Expr ) | ID这样-3会被解析成- (3)的 Factor 节点,类型检查和中间代码生成都只需要考虑一元运算符,而不需要单独定义负数 Token。这个决策看起来简单,但能省掉后面很多麻烦。
5.4 悬空 else 与语句结束符的歧义
现象:输入if (a) if (b) x = 1; else y = 2;,按 C 语言规则 else 应该匹配内层 if,但解析器却把它挂到了外层 if 上,语义结果不对。
原因:递归下降解析器在解析完第一个 if 的 then 分支后,如果 then 分支本身也是 if,它无法确定当前遇到的 else 是属于内层还是外层。普通文法存在二义性,递归下降默认“就近匹配”是碰巧,不是可靠保证。
解决:把语句文法拆成“完整语句”和“不完整语句”两个非终结符,这是教科书标准做法:
Stmt -> MatchedStmt | UnmatchedStmt MatchedStmt -> if ( Expr ) MatchedStmt else MatchedStmt | otherStmt UnmatchedStmt-> if ( Expr ) Stmt | if ( Expr ) MatchedStmt else UnmatchedStmt解析器的parseIfStatement要设计成:先匹配接收条件表达式,随后判断当前 token 是不是else,从而决定走哪个分支。从实现角度,把悬空 else 的判断放在语法层,比在 AST 构造后做修正干净得多。这个坑在实验报告里属于“设计亮点”,写出来比单纯贴代码更容易拿分。
5.5 符号表作用域没回滚导致重复声明误报
现象:两个完全不同的函数里都声明了int x;,语义检查阶段却报“变量 x 重复声明”。
原因:符号表实现只有一个平铺的std::unordered_map<std::string, VarInfo>,插入和查找都只对着这一个表。进入函数 A 时插入x,接着进入函数 B 又插入x,查找时只看有没有同名 key,完全没考虑这两个x在不同的作用域里。
解决:把符号表改成栈式作用域结构,每一层作用域对应一个独立的unordered_map,进入大括号或函数体时压栈,退出时弹栈。查找变量时从栈顶往下逐层查:
struct SymbolTable { std::vector<std::unordered_map<std::string, VarInfo>> scopes; void pushScope() { scopes.emplace_back(); } void popScope() { scopes.pop_back(); } VarInfo* find(const std::string& name) { for (auto it = scopes.rbegin(); it != scopes.rend(); ++it) { auto found = it->find(name); if (found != it->end()) { return &found->second; } } return nullptr; } bool declare(const std::string& name, const VarInfo& info) { auto& top = scopes.back(); return top.emplace(name, info).second; } };注意declare调用top.emplace的返回值,插入失败说明当前作用域已经存在同名变量,这是“重复声明”的唯一判断依据。查找时要考虑嵌套作用域,比如外层函数变量可以被内层引用。如果popScope忘记调用,错误照样会出现,所以实验三的测试用例里专门设计了一段跨函数的同名变量程序,用来验证作用域是否正确回滚。
6. 验收技巧:用三地址码自检整条编译链路
6.1 先把语义动作写进属性栈
做语义分析和中间代码生成时,有一个很实用的技巧:不要等到 AST 遍历完成后才生成 IR,而是在递归下降解析的过程中同步生成。这样语义错误能提前暴露,调试时也能直接看到“某条语句翻译成了哪几条指令”。
拿a + b * c来说,递归下降从parseExpression进入parseTerm,再进入parseFactor,从最深的乘法开始翻译。关键函数是生成三地址码的辅助过程:
struct Attr { std::string type; std::string addr; }; std::string IrGenerator::newTemp() { return "t" + std::to_string(tempCount++); } void IrGenerator::genBinaryOp( Attr& result, const Attr& left, const Attr& right, const std::string& op) { result.addr = newTemp(); emit(result.addr + " = " + left.addr + " " + op + " " + right.addr); }emit把生成的字符串写进全局指令列表。每次生成新临时变量,tempCount自增一次,这样每一行 IR 的编号不会重复。参数里left和right的addr可能是普通变量名,也可能是上一个临时变量,最终都能拼出合法的表达式。
6.2 两分钟自检法
输入下面这段小程序,对比自己编译器的输出是否符合预期:
a = 2 + 3 * 4;预期三地址码:
t1 = 3 * 4 t2 = 2 + t1 a = t2如果输出变成了a = 2 + 3 * 4这种一条指令的形态,说明parseExpression没有正确划分层级;如果输出是t1 = 3 * 4、t2 = 2 + 3、a = t2 * 4,说明节点合并顺序出了错。自检的思路就是拿几个已知语义的小程序,先手写出三地址码,再比较机器输出,不匹配就逐层打断点看 AST 结构。
这个方法在跑if-else和控制流时更好用。if (a > 0) x = 1; else x = 2;的 IR 里必须出现两个标签,一个跳过 then 分支,一个跳转到 if 结尾。见过太多同学代码里只生成了一个标签,自然是流程图没画对。
从那以后我每次提交实验前都强制自己跑一遍“中间代码自检清单”:表达式层级对不对、临时变量编号从 1 开始还是从 0 开始、标签是否成对出现。这套习惯帮我避开了大部分白扣的分,资源包里的实验报告模板也留了一张空表让你自己填测试记录,直接照着写就行。希望帮到你。
本文还有配套的精品资源,点击获取