OUC编译原理实验源码解析:五阶段手写C++编译器实现 简介这份资源是中国海洋大学2020年春季学期编译原理课程的全部实验代码集合面向正在学习编译原理的学生也适合需要课程实践参考的开发者。内容覆盖词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成、错误处理与诊断、编译器综合共8个实验并附有实验要求文档系统对应编译器前端与后端的完整流程每一部分都配有可运行示例。压缩包采用RAR格式共74个文件包含C源码、Flex词法文件、Bison语法文件、可执行程序、头文件与说明文档等类型整体仅774KB目录按实验编号组织便于按需查阅与局部复现。目前已吸引4415人浏览学习。对于希望通过完整实验代码理解词法、语法、语义分析以及代码优化生成等关键环节的同学资料中提供的大量可运行示例和测试用例能让动手复现与排错更有方向。1. 从跑通到能答辩一套能层层复用的OUC编译原理实验源码解析如果你只打算拿一份别人写好的编译原理实验代码来当参考会发现真正麻烦的从来不是没有源码而是五个实验各自来自不同作者、风格对不上最后只能用文本工具硬拼。这份OUC编译原理全部实验把词法分析、语法分析、语义分析、中间代码生成和简单优化串在了一条主线上每个实验目录独立可编译又能靠一份固定的中间文件把前一个实验的输出喂给后一个实验。它来自某高校编译原理课程源码以C为主不依赖flex/bison适合课程设计参考、考研复试时讲清编译器工作流程也适合想手写一遍词法分析和递归下降解析器的开发者。2. 工程结构拆解五个实验如何共享同一套符号表与错误处理一套能连续跑完五个阶段的编译实验代码最忌讳的就是每个实验各写各的数据结构词法阶段定义一份Token语法阶段再定义一份一模一样的Token结果两个模块对接时全靠口头约定。这份源码在工程结构上先解决了一个问题公共部分单独拆出来所有实验共用同一份类型定义和错误处理机制。2.1 五个实验目录的功能边界整个工程按实验阶段拆成五个子目录再加上公共目录、测试用例和构建脚本。我拿到源码后第一件事就是画目录树因为后续跑命令和改代码都要靠它定位。oucc/ ├── common/ │ ├── token.h │ ├── token.cpp │ ├── error.h │ ├── symbol.h │ └── sym_table.h ├── expr1_lexer/ │ ├── lexer.h │ ├── lexer.cpp │ └── main.cpp ├── expr2_parser/ │ ├── parser.h │ ├── parser.cpp │ ├── ast.h │ └── main.cpp ├── expr3_semantic/ │ ├── analyzer.h │ ├── analyzer.cpp │ └── main.cpp ├── expr4_intercode/ │ ├── ir.h │ ├── generator.cpp │ └── main.cpp ├── expr5_optimize/ │ ├── optimizer.cpp │ └── main.cpp ├── tests/ │ ├── case1.c │ ├── case2_calc.c │ └── run_all.sh └── Makefile每个expr目录都是一个独立的可执行文件而不是一个必须全量编译的库。词法分析阶段输入一个.c源文件输出token流文件语法分析阶段读入token流文件输出AST文本语义分析阶段读入AST文本输出符号表中间代码生成阶段读入AST文本输出三地址码最后的优化阶段读入三地址码做常量折叠和临时变量合并后输出优化结果。这种“一个目录一个可执行文件”的组织方式对课程设计答辩非常友好老师问“实验二在哪里”你直接指出expr2_parser而不是在几千行代码里翻main函数。它也让每个实验可以单独编译单独调试不必因为改了一个公共头文件就把整个项目全部重编。2.2 公共模块Token约定、符号表与错误处理公共目录common里最核心的是token.h。编译原理实验最常见的返工原因就是词法分析阶段和语法分析阶段各写了一份Token结构字段名对不上或者把枚举值顺序改了导致序列化全乱。这里的做法是把Token统一放公共头文件所有实验include同一份定义。// common/token.h enum class TokenType { IDENT, INT_CONST, FLOAT_CONST, KEYWORD, OPERATOR, DELIMITER, UNKNOWN, END_OF_FILE }; struct Token { TokenType type; std::string text; int line; int column; };text字段保留原始字符串这是为了语义分析阶段查符号表时能拿到变量的原始名字而不是只能看到一个枚举值。line和column记录行列号报错信息里能直接指到出错位置。我一般会把这两个字段初始化成1避免第一个token报错时出现第0行第0列。符号表设计同样值得一提。它没有用单个unordered_map存所有变量而是用vector模拟作用域栈符号信息里同时保存类型指针和栈偏移量。// common/sym_table.h struct SymbolInfo { const Type* type; // 指向类型节点 int stackOffset; // 实验四计算栈偏移量时使用 }; class SymTable { public: void enterScope(); void exitScope(); void insert(const std::string name, const SymbolInfo info); bool lookup_local(const std::string name) const; const SymbolInfo* lookup(const std::string name) const; private: std::vectorstd::unordered_mapstd::string, SymbolInfo scopes; };lookup从顶层向下找遇到第一个命中的符号就返回。这样能正确处理内层变量遮蔽外层变量的语义在if块里声明了一个与外部同名的变量块内查到的是内层那个块外查到的是外层那个。常见误用是只用一层map直接覆盖导致块内变量在块外依然可见语义分析阶段会被老师当场问住。lookup_local只查最顶层作用域专门用来判断当前块内是否重复声明。错误处理采用全局计数加可恢复报告的设计遇到语法错误先打印error: line x: ...然后尝试跳过一段token继续分析最后只在main里检查全局错误数是否为零。这么做的价值在于一次运行能收集多条错误而不是改一个错跑一次编译。对课设来说这个行为比用assert一错就崩要体面得多。2.3 隐藏依赖Makefile编译顺序与中间文件约定五个实验能串起来跑靠的不是一个巨大的类而是固定的中间文件名。Makefile里定义了编译规则run_all.sh定义了运行顺序。我把这部分依赖关系单独拿出来说是因为很多人拿到源码后不看这个直接把某个main.cpp单独编译就报“缺头文件”。all: expr1_lexer expr2_parser expr3_semantic expr4_intercode expr5_optimize expr1_lexer: g -stdc17 -I common -o bin/lexer expr1_lexer/main.cpp common/token.cpp expr2_parser: g -stdc17 -I common -o bin/parser expr2_parser/main.cpp common/token.cpp编译时用-I common指定公共头文件目录链接时按需带上token.cpp。这里有个隐藏约定词法分析输出out/tokens.txt语法分析默认从out/tokens.txt读入输出out/ast.txt语义分析读out/ast.txt输出out/symbols.txt中间代码生成读out/ast.txt输出out/ir.txt。文件名都写死在main.cpp的参数解析里如果某个实验单独运行时找不到文件去main.cpp顶部看默认路径比在代码里大海捞针要快。我一般会先把common目录整体看一遍再从头跑一次run_all.sh确认中间文件格式没被改动过才开始看某个具体实验。3. 核心模块拆解词法分析、语法分析到中间代码生成的完整链路这里从四个阶段各挑一段核心代码来拆你不会看到完整的几千行源码但把四段读明白整个编译器的数据流就清楚了源码变成tokentoken变成ASTAST带上类型信息变成三地址码。3.1 词法分析用状态机切分token而不是正则硬匹配词法分析部分没有采用flex生成器而是用手写循环逐字符扫描。原因有两个课程答辩时老师几乎一定会问“这个状态是怎么迁移的”手写代码能直接讲另外flex生成的文件风格不统一放在课设里和手写部分混着改也费劲。核心循环长这样// expr1_lexer/lexer.cpp 词法分析主循环简化版 std::vectorToken Lexer::run() { std::vectorToken tokens; size_t i 0; while (i src_.size()) { char ch src_[i]; if (isspace(ch)) { if (ch \n) { line_; column_ 1; } else { column_; } i; continue; } if (isalpha(ch) || ch _) { size_t start i; while (i src_.size() (isalnum(src_[i]) || src_[i] _)) { i; column_; } std::string word src_.substr(start, i - start); TokenType type keyword_table.count(word) ? TokenType::KEYWORD : TokenType::IDENT; tokens.push_back({type, word, line_, column_}); continue; } if (isdigit(ch)) { size_t start i; while (i src_.size() isdigit(src_[i])) { i; column_; } tokens.push_back({TokenType::INT_CONST, src_.substr(start, i - start), line_, column_}); continue; } std::string two src_.substr(i, 2); if (two || two ! || two || two ) { tokens.push_back({TokenType::OPERATOR, two, line_, column_}); i 2; column_ 2; } else if (ispunct(ch)) { tokens.push_back({TokenType::OPERATOR, std::string(1, ch), line_, column_}); i; column_; } else { addError(无法识别的字符: std::string(1, ch)); i; column_; } } return tokens; }这段代码的逻辑是跳过空白并维护行列号遇到字母或下划线就连续读取标识符字符然后查keyword_table确定是关键字还是普通标识符遇到数字就连续读数字生成整型常量token遇到两个字符的操作符先看子串i到i2是不是双字符运算符是就作为一个token消费两个字符。keyword_table在Lexer构造函数里用std::unordered_set初始化放if、else、int、return等关键字。数字识别这里故意没有处理浮点如果要支持浮点常量常见做法是在while循环里加一个标记变量记录小数点出现次数第二次遇到小数点就停止也可以先切成整数再由语法分析阶段决定怎么合并。有一个非常容易翻车的点while循环里是否每消费一个字符就更新column_。我见过同学直接把column_ word.size()结果遇到多行字符串时行号和列号全部错位后续所有报错位置都是错的。建议跟随每个字符消费来更新行列号哪怕多写几行。3.2 语法分析递归下降的关键在于拆优先级语法分析采用的是递归下降而不是LL(1)自动分析表。选择递归下降的原因很直白表达式优先级用多层函数调用表达直观调试时加一行printf就能看到当前解析到哪个优先级而且手写过程对错误恢复更可控。// expr2_parser/parser.cpp 表达式优先级add/sub mul/div factor std::unique_ptrASTNode Parser::parseExpr() { auto node parseTerm(); while (cur_-type TokenType::OPERATOR (cur_-text || cur_-text -)) { std::string op cur_-text; advance(); auto right parseTerm(); node std::make_uniqueBinaryOpNode( std::move(node), op, std::move(right)); } return node; } std::unique_ptrASTNode Parser::parseTerm() { auto node parseFactor(); while (cur_-type TokenType::OPERATOR (cur_-text * || cur_-text /)) { std::string op cur_-text; advance(); auto right parseFactor(); node std::make_uniqueBinaryOpNode( std::move(node), op, std::move(right)); } return node; }parseExpr先调用parseTerm保证乘法除法先于加减法执行parseTerm再调用parseFactor保证括号和数字等因子最优先。每次构建BinaryOpNode时把运算符文本存进去后续语义分析和中间代码生成都能直接读取。AST节点用unique_ptr管理节点的析构会递归释放子树不需要手写delete。cur_是Parser内部的当前Token指针advance()每次推进一个token并更新行列号。这里有一个必须注意的顺序问题先调用parseTerm拿到左操作数再判断当前运算符是不是属于这个优先级层级的运算符如果是就循环构建左结合节点。如果先把运算符判断写在parseTerm调用之前表达式“12*3”会被解析成(12)*3优先级彻底反了。遇到语法错误时这段代码会调用synchronize()跳过一段token直到遇到分号或右大括号。这样的错误恢复允许一次编译报出多个错误比较接近真实编译器的行为。递归下降最常见的坑是parseFactor里忘了处理括号表达式导致“(12)*3”被当成两个独立的表达式报错信息完全摸不着头脑。3.3 语义分析作用域链符号表与类型检查实验三在AST上做语义检查核心是符号表的插入、查找和重复声明判断。以下是变量声明节点的处理逻辑// expr3_semantic/analyzer.cpp 变量声明的处理 bool Analyzer::visitDecl(const DeclNode* decl) { SymTable sym current_scope_; if (sym.lookup_local(decl-name)) { addError(decl-line, decl-column, 重复声明变量 decl-name); return false; } const Type* ty resolveType(decl-typeName); sym.insert(decl-name, SymbolInfo{ty, 0}); return true; }lookup_local只在当前作用域层查找不会越过层边界所以外层同名变量不会导致内层声明被判成重复。SymbolInfo里的stackOffset字段在实验四生成中间代码时用来保存栈偏移量这里先置0。从这里已经能看出这套实验的数据结构是层层递进的词法阶段的Token在语法阶段直接用语法阶段的AST在语义阶段遍历语义阶段的类型信息又传递给中间代码生成。类型检查的通用做法是自底向上推导对二元表达式先递归获取左右子节点的类型再检查两边是否一致不一致就在当前节点位置报错。布尔值在不同语言的AST里可能会被表示为不同的节点类型这套源码的处理方式是统一在getType里做判断而不是在每个visit函数里重复写类型匹配逻辑。3.4 中间代码生成三地址码与临时变量最后一个核心模块把AST翻译成三地址码。这里的思路很典型每遇到一个二元表达式就生成一个新的临时变量承接结果。// expr4_intercode/generator.cpp 二元表达式的三地址码生成 std::string IRGenerator::genBinaryOp(const BinaryOpNode* node) { std::string left genExpr(node-left); std::string right genExpr(node-right); std::string temp newTemp(); std::string op mapOp(node-op); ir_.push_back(temp left op right); return temp; }genExpr递归地生成左右子表达式的代码每次递归返回的是存放结果的那个临时变量名。比如“a b * c”会先生成t1 b MUL c再生成t2 a ADD t1最后return出去的temp是t2。newTemp内部维护一个计数器每次调用生成t1、t2、t3这样的名字。mapOp把源语言的运算符字符串映射成中间代码助记符比如“”映射为ADD“”映射为LT这样做是为了实验五优化时只需要处理一套统一的助记符集合不用在判断里堆满源码操作符。中间代码这一层的价值在于它把前端和后端解耦。前端无论是C语法的哪个方言只要AST生成的中间代码格式一致后端优化器就不需要感知源码细节。我实际改代码时会把ir.txt打印出来对照如果某条语句的临时变量编号跳过了或者操作数是操作符而不是变量名问题基本出在genExpr的返回值和预期不符。4. 本地跑起来环境准备、Makefile 编译顺序与一条命令验证全流程源码拿到手别急着打开编辑器。先确认环境再按顺序编译最后用测试脚本把五个实验串起来跑通这样你再改代码时有个可对照的基线。4.1 环境准备Linux/macOS为主Windows走MSYS2这套实验用的是C17标准库除了标准库外没有第三方依赖。Linux上g版本7以上即可macOS的clang默认支持C17Windows上建议装MSYS2或WSL后使用g编译。我这里演示的是Linux环境命令在macOS上除包管理器不同外基本通用。sudo apt-get install g make g --version make --version确认g和make都可用之后进入源码根目录不用额外配置环境变量所有include路径都在编译命令里通过-I参数指定。4.2 用Makefile分段编译不要直接手动敲五条g命令直接用工程自带的Makefile。第一次编译建议不加-j并行参数方便看清楚每条编译命令的参数顺序。cd oucc make编译结束后在bin目录下会生成五个可执行文件lexer、parser、semantic、intercode、optimize。这里有一个容易踩的坑有人图省事直接把expr2_parser/main.cpp单独g编译忘记带-I common和common/token.cpp结果报一堆“TokenType未定义”的错误。我一般会记住编译任何实验目录下的main.cpp都必须链接common里的实现文件除非你把公共代码编译成了静态库。如果只想编译其中某一个实验Makefile里也提供了单独的目标make expr1_lexer make expr2_parser单独目标适合你只改了一个阶段的代码、想快速验证它是否还能编译的情况。全量make适合改完代码后一次性确认整个项目仍然通过编译。4.3 用测试用例打通全流程编译通过只是第一步五个实验要能串起来跑靠的是run_all.sh这个脚本。它的核心内容如下#!/bin/bash set -e mkdir -p out ./bin/lexer tests/case1.c out/tokens.txt ./bin/parser out/tokens.txt out/ast.txt ./bin/semantic out/ast.txt out/symbols.txt ./bin/intercode out/ast.txt out/ir.txt ./bin/optimize out/ir.txt out/ir_opt.txt if grep -q error out/*.txt; then echo failed with errors exit 1 fi echo pass第一行set -e保证任何一条命令返回非零就停止避免后续阶段拿着一个空的中间文件继续跑。mkdir -p out预创建输出目录防止可执行文件写文件时找不到目录。然后依次执行五个阶段每个阶段把上一阶段的输出文件作为输入。脚本最后检查out目录下所有文本文件里是否出现“error”字符串有则判失败。这个设计很讨巧它利用了两个约定错误信息统一以error开头且错误也会写入输出文件。所以grep一下就能覆盖五个阶段的错误检查不需要为每个阶段单独写测试逻辑。要验证某个阶段单独运行比如只想看词法分析结果就执行./bin/lexer tests/case1.c并直接看标准输出。注意默认文件名是相对路径建议所有命令都在源码根目录下执行否则找不到out目录。4.4 输入输出约定没跑通时先看这里五个阶段的关系可以用一张表说明这也是我排查问题时最先对照的清单。阶段输入文件输出文件默认文件词法分析.c源码token流out/tokens.txt语法分析out/tokens.txtAST文本out/ast.txt语义分析out/ast.txt符号表out/symbols.txt中间代码生成out/ast.txt三地址码out/ir.txt优化out/ir.txt优化后IRout/ir_opt.txt提示out/ast.txt被实验三和实验四同时消费。这意味着实验二的AST打印格式是这个项目的“接口契约”改实验二时千万注意不要破坏大括号缩进格式否则语义分析和中间代码生成都会跟着挂。我实际跑这套代码时最深的感受是报错时先看中间文件再改代码。比如语义分析报变量找不到先打开out/ast.txt看变量是否出现在AST里如果是再看符号表输出两步就能定位是哪个阶段的锅。把中间文件当成调试的黑匣子比在代码里盲打printf要高效得多。5. 常见问题排查状态机死循环、符号表串味与接口对不上这套源码整体风格比较规整但跑起来仍然有四个高频问题。我在帮同学排查时发现它们基本都不是算法不会写而是数据结构契约或者边界条件没处理好。以下四条按现象、原因、解决的顺序记录。5.1 现象一词法分析进程CPU打满程序卡死现象运行lexer后程序不退出CPU占用100%终端里看不到任何输出。原因词法分析主循环在遇到无法识别的字符时既没有让指针前进也没有continue导致同一个字符被反复处理。这种情况大概率出现在else分支漏写了i或者加号、单引号等字符被上一级判断拦截但又没有返回。解决先检查状态机每个分支末尾是否都消费了至少一个字符。我一般会在while循环开头加一个防御性判断记录每次迭代的i值如果本轮结束时i没有增大直接打印“死循环位置在第x行”并退出。这个判断能帮你在半分钟内定位到卡死字符而不需要对着单步调试一遍。5.2 现象二parser对“12”报语法错误但肉眼觉得代码没问题现象输入一个最简单的表达式语法分析却报“期望数字类型token却遇到操作符”。原因词法分析把“”识别成了DELIMITER而语法分析的parseFactor只接受INT_CONST和IDENT不接受DELIMITER两边对token类型的判断不一致。这类问题通常来自TokenType枚举被改过或者实验二从零实现了自己的token类型没有include common/token.h。解决先运行lexer把out/tokens.txt打开确认“”的类型到底是OPERATOR还是DELIMITER。再检查parser的parseFactor里是否漏写了OPERATOR作为运算符的入口。以公共头文件为准统一两边的枚举不要再单独定义一份token结构。这是这套源码里出现频率最高的接口冲突也是编译原理实验里最容易让人血压升高的一类问题。5.3 现象三语义分析阶段变量名串味块外能访问块内变量现象if块内声明的变量在if结束后依然能被查找到后续类型检查时出现一堆诡异报错。原因遍历AST进入if块时调用了enterScope但离开块时没有调用exitScope符号表的作用域层一直堆着没弹出。还有一种情况是遍历函数提前return把exitScope放在return之前执行正常路径没问题异常路径直接跳过。解决保证enterScope和exitScope成对出现最稳妥的做法是写一个小的scope guard类在析构函数里调用exitScope。这样无论函数是正常返回还是中途跳出符号表都会正确退出当前层。我已经用这个方式避免过无数次忘记exitScope的问题属于典型的用“结构性手段”对抗“人为疏漏”。5.4 现象四实验四生成的中间代码在实验五里没法直接跑现象实验四输出的三地址码是t1 a ADD b这种可读文本实验五的优化器却解析不了报预期“”实际拿到“ADD”。原因实验四和实验五对IR文本格式的约定不一致。实验四用助记符ADD是因为它认为优化器会处理助记符实验五则假设IR里直接存源语言操作符两边各按各的理解实现结果就是谁都跑不通。解决以实验四的输出为准在实验五里写一个小的IR解析函数把ADD、SUB、MUL、DIV等助记符映射回内部枚举。不要在优化器外面再包一层文本替换因为助记符出现的位置不止运算符还有变量名和常量直接替换会带来误伤。这个问题的本质是中间表示的一致性问题一旦确定了IR的文本格式所有后续阶段都只能适配它不能反过来改。这四条经验放在一起其实指向同一个原则编译原理实验的各个阶段是协议耦合的最脆弱的永远是边界约定而不是算法本身。6. 二次开发思路给词法分析增加位运算支持的完整改动假设你的课设要求在现有源码基础上支持、|、^三个位运算操作符并且要求优先级低于算术运算符。这个改造能体现这套源码是否真的好扩展。改动点其实只有三处词法识别、语法优先级、中间代码助记符映射。6.1 词法、语法与中间代码三处联动词法分析部分在现有操作符判断分支里加一段// 在 lexer 的操作符分支中增加位运算支持 std::string two src_.substr(i, 2); if (two || two ! || two || two ) { // 双字符运算符 tokens.push_back({TokenType::OPERATOR, two, line_, column_}); i 2; column_ 2; } else if (ch || ch | || ch ^) { // 单字符位运算这里暂时不处理 和 ||避免歧义 tokens.push_back({TokenType::OPERATOR, std::string(1, ch), line_, column_}); i; column_; }词法层只需保证这三个字符能进入token流不需要区分它是位与还是逻辑与那是语法分析的职责。语法分析这边要调整表达式优先级层级从低到高依次是逻辑或、逻辑与、按位或、按位异或、按位与、相等性、关系、加减、乘除。在parseExpr和parseTerm之间插入对应的几个新函数每个函数的结构和parseTerm完全一样只把判断的运算符换成当前层级的那一两个。中间代码生成不需要改逻辑只需要在mapOp里增加三个映射“”映射为BAND“|”映射为BOR“^”映射为BXOR。这样生成的三地址码可以直接进入实验五做常量折叠。验证方法也简单写一个测试文件里面放int a 5 3;跑完五个阶段后打开out/ir_opt.txt期望看到实验五把常量折叠成a 1的形式。如果优化器还是输出原始的BAND指令说明常量折叠的表里没有注册BAND去optimizer的常量折叠分支里补上即可。这个改造最需要注意的地方是不要在词法阶段就把和||也拆成两个否则原本的逻辑与会被误解析成按位与。我一般会先在tests目录下放一个同时包含a b和a b的用例跑完确认两者分别生成了BAND和LAND后再往下继续。从那以后我每次拿到别人给的课设源码都会先跑一遍run_all.sh再分别把每个可执行文件用--help看一眼确认中间文件的格式约定然后才决定改哪里。这套流程帮我避开过很多“改完实验二实验三四全挂”的尴尬时刻。希望帮到你。本文还有配套的精品资源点击获取