☰
OUC编译原理实验:词法→语法→中间代码的可调试工程链
2026/10/16 2:10:28 网站建设 项目流程

简介:本资源为中国海洋大学2020年春季《编译原理》课程全套实验代码与配套文档,面向计算机专业本科生及编译技术初学者,系统覆盖词法分析、语法解析、语义检查、中间代码生成、优化、目标代码生成、错误处理及编译器集成八大核心环节,助力学习者从理论走向工程实践。压缩包共74个文件(774KB),含18个C源码(实现核心算法与主程序)、8个Lex/Bison生成的.l/.y文件(文法定义与词法规范)、8个.h头文件(模块接口声明)、7个.txt说明文档(测试用例与要求)、4个Makefile(构建脚本)及11个可执行文件(含各阶段验证结果),结构完整、层次清晰,支持逐实验调试与全流程集成验证。已有4415人学习下载,内容紧扣教学大纲,附带实验要求文档与典型输入输出样例,提供可运行、可复现、可拓展的编译器开发范例,是理解编译器构造原理与提升系统编程能力的优质实践素材。

1. OUC编译原理全部实验:不是抄作业的压缩包,而是能跑通词法分析→语法分析→中间代码生成的完整可调试工程链

你手头那份标着“OUC编译原理全部实验”的压缩包,大概率不是零散的C语言习题答案,而是一套覆盖编译前端全流程、带完整Makefile和测试用例的可执行工程集合——它能让你在本地g++/clang下逐行调试lexer.cpp里正则匹配失败时的状态机跳转,也能在parser.y里加printf验证LL(1)预测分析表的查表逻辑,甚至能把test.c源码一路编译成三地址码并输出到ir.txt。这不是为应付课程设计交差的半成品,而是某高校编译原理教学组多年迭代出的“最小可运行教学系统”:所有实验模块解耦清晰、输入输出接口统一、错误提示带行号定位,连error.h里都预埋了17种语义错误码。适合刚学完龙书第2章就想动手写lexer的新手,也适合想快速验证自己手写递归下降解析器是否漏掉空产生式的熟手。如果你正卡在“为什么我的FIRST集算出来和课件对不上”,或者“生成的四元式顺序总比参考答案多一条goto”,这份资源就是你该打开的第一个真实工程。


2. 实验结构与技术栈:从词法分析器到中间代码生成器的五层递进实现

2.1 项目目录结构与模块职责划分

解压后你会看到标准的五级实验目录(对应编译流程的五个关键阶段),每个子目录下均含src/、test/、build/三件套:

OUC-Compiler-Labs/ ├── lab1_lexer/ # 词法分析:基于状态机的手写C++实现 │ ├── src/ │ │ ├── lexer.cpp # 核心状态机,含12个状态分支 │ │ └── token.h # Token类型枚举(KEYWORD, IDENTIFIER, NUMBER...) │ ├── test/ │ │ ├── valid.c # 合法测试用例(含中文注释、浮点数、十六进制字面量) │ │ └── invalid.c # 非法测试用例(未闭合字符串、非法转义符) ├── lab2_parser/ # 语法分析:LL(1)预测分析表驱动 │ ├── src/ │ │ ├── parser.cpp # 主分析循环,调用predict_table[nonterm][token] │ │ └── grammar.txt # BNF描述的简化C子集文法(含左递归消除后版本) ├── lab3_semantic/ # 语义分析:符号表+类型检查 │ ├── src/ │ │ ├── symbol_table.cpp # 哈希表实现,支持作用域嵌套 │ │ └── type_checker.cpp # 检查赋值兼容性、函数调用实参个数 ├── lab4_ir_generator/ # 中间代码生成:三地址码(四元式) │ ├── src/ │ │ ├── ir_gen.cpp # 表达式树遍历生成temp变量+op+arg1+arg2 │ │ └── ir.h # 四元式结构体定义(op, arg1, arg2, result) └── lab5_codegen/ # 目标代码生成(可选):x86-64汇编片段 └── src/ └── codegen.cpp # 将四元式映射为mov/add/jmp指令

提示:所有build/目录为空,需手动执行make生成可执行文件;test/目录下的.c文件是唯一合法输入源,不要尝试喂入.cpp或带模板的代码。

2.2 编译与运行全流程:从源码到四元式的端到端验证

以lab1_lexer为例,完整走通一次需三步(其他实验同理,仅替换目录名):

# 进入词法分析实验目录 cd OUC-Compiler-Labs/lab1_lexer # 编译(依赖g++ 7.5+,自动链接stdc++) make # 运行词法分析器,输入test/valid.c并重定向输出 ./lexer < test/valid.c > tokens.txt # 查看结果:每行格式为"<TOKEN_TYPE, lexeme, line_num>" head -n 10 tokens.txt # 输出示例: # <KEYWORD, int, 1> # <IDENTIFIER, main, 1> # <LPAREN, (, 1> # <RPAREN, ), 1> # <LBRACE, {, 1>

关键参数说明:

  • lexer可执行文件默认读取stdin,故必须用<重定向输入;若直接./lexer test/valid.c会报错——它不接受命令行参数。
  • 输出文件tokens.txt的第三列是物理行号(非逻辑行号),遇到#include等预处理指令时仍会计数,这是为后续语法分析器定位错误服务的设计。
  • 若需调试状态机,修改src/lexer.cpp中DEBUG_STATE_TRANSITION宏为1,重新make后会输出每次状态跳转的from_state -> to_state日志。

2.3 语法分析器的核心机制:LL(1)预测分析表的生成与查表逻辑

lab2_parser的难点不在Bison/Yacc,而在手写预测分析表。其src/parser.cpp中核心查表逻辑如下:

// parser.cpp 关键片段 int predict_table[NT_NUM][T_NUM] = { // NT_NUM=9(非终结符数),T_NUM=15(终结符数) // 行:非终结符索引(0=Program, 1=Declaration, ...) // 列:终结符索引(0=INT, 1=VOID, 2=ID, 3=SEMI, ...) {1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, // Program → DeclarationList {0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, // Declaration → TypeSpecifier ID SEMI // ... 共9行×15列,全整数索引指向grammar.txt中产生式序号 }; // 查表函数 int get_production_index(int nonterminal, int terminal) { if (nonterminal < 0 || nonterminal >= NT_NUM) return -1; if (terminal < 0 || terminal >= T_NUM) return -1; return predict_table[nonterminal][terminal]; // 返回产生式编号(1-based) }

为什么必须手写表?
因为教学要求学生理解FIRST/FOLLOW集如何推导预测分析表。grammar.txt中明确标注了各产生式对应的FIRST集(如Declaration → TypeSpecifier ID SEMI的FIRST={INT, VOID}),你在调试时可打印get_production_index(INT_TOKEN, ID_TOKEN)验证是否返回预期值。若返回-1,说明该输入组合在文法中无定义——这正是LL(1)文法要求“无冲突”的直观体现。

2.4 符号表与类型检查的工程化实现

lab3_semantic的符号表设计直击教学痛点:支持嵌套作用域且避免全局污染。其symbol_table.cpp中关键结构如下:

// symbol_table.cpp struct SymbolEntry { string name; string type; // "int", "float", "function" int scope_level; // 0=global, 1=func1, 2=block1... int line_declared; // 声明所在行号,用于错误提示 }; class SymbolTable { private: vector<vector<SymbolEntry>> scopes; // 多层vector模拟作用域栈 int current_scope; // 当前作用域深度 public: void enter_scope() { scopes.push_back(vector<SymbolEntry>()); current_scope++; } void exit_scope() { if (!scopes.empty()) scopes.pop_back(); current_scope--; } bool insert(const string& name, const string& type, int line) { // 检查当前作用域是否已存在同名标识符 for (auto& entry : scopes.back()) { if (entry.name == name && entry.scope_level == current_scope) { cerr << "Error: redeclaration of '" << name << "' at line " << line << endl; return false; } } scopes.back().push_back({name, type, current_scope, line}); return true; } };

类型检查的边界场景:
type_checker.cpp中check_assignment函数会校验左右值类型兼容性,但不检查数组越界或指针解引用——这是为聚焦编译前端而刻意限定的范围。例如int a[10]; a[100] = 5;会被词法分析器接受(因a[100]被识别为IDENTIFIER+LBRACKET+NUMBER+RBRACKET),但语义分析器只检查a是否为数组类型,不验证100是否超限。这种“有意识的不完整”正是教学实验的价值:让你看清编译器各阶段的职责边界。


3. 避坑指南:五个让90%新手当场翻车的硬核细节

3.1 现象:词法分析器对0x1A十六进制字面量识别为两个Token(0和x1A)

原因:lexer.cpp中状态机对0开头的数字处理存在优先级漏洞。当读到0后,若下一个字符是x或X,应进入十六进制解析分支;但原代码将0直接判定为NUMBER并结束状态,导致x被当作独立字符处理。
解决:在lexer.cpp的STATE_NUMBER分支中,添加对x/X的前瞻判断:

// 修改前(错误) if (ch == '0') { state = STATE_NUMBER; } // 修改后(正确) if (ch == '0') { // 读取下一个字符 char next_ch = peek_next_char(); if (next_ch == 'x' || next_ch == 'X') { state = STATE_HEX_PREFIX; // 新增状态,专门处理0x consume_char(); // 消费x/X } else { state = STATE_NUMBER; } }

3.2 现象:语法分析器在if (x) { y=1; } else { z=2; }中报错“unexpected ELSE”

原因:LL(1)文法未处理悬空else(dangling else)问题。原grammar.txt中IfStmt产生式为IfStmt → IF LPAREN Expr RPAREN Stmt | IF LPAREN Expr RPAREN Stmt ELSE Stmt,导致ELSE在预测分析表中同时出现在IF和ELSE的FOLLOW集中,引发冲突。
解决:采用经典解决方案——将IfStmt拆分为MatchedIf和UnmatchedIf,并在predict_table中为UnmatchedIf的ELSE列填入对应产生式索引。需同步修改parser.cpp中parse_IfStmt函数,根据当前token动态选择解析路径。

3.3 现象:符号表插入int func(void);时崩溃,报vector::_M_range_check异常

原因:函数声明的符号表插入逻辑未区分“函数声明”与“函数定义”。原代码将func作为IDENTIFIER插入,但未标记其类型为function,导致后续insert时试图访问空scopes向量。
解决:在symbol_table.cpp的insert_function方法中,强制初始化函数作用域:

void insert_function(const string& name, const string& ret_type, int line) { // 在插入前确保至少有一层作用域 if (scopes.empty()) { scopes.push_back(vector<SymbolEntry>()); current_scope = 0; } scopes.back().push_back({name, "function:" + ret_type, current_scope, line}); }

3.4 现象:中间代码生成器对a = b + c * d生成的四元式顺序与预期不符(先算b+c再算*)

原因:表达式树遍历采用中序而非后序。ir_gen.cpp中gen_expr函数对二元运算符的处理顺序为:先递归生成左子树→输出当前运算符四元式→再递归生成右子树,这违反了运算符优先级。
解决:改为后序遍历,并引入临时变量管理:

string gen_expr(Node* node) { if (node->type == NODE_OP && node->op == "+") { string left_temp = gen_expr(node->left); // 先生成左子树 string right_temp = gen_expr(node->right); // 再生成右子树 string result_temp = new_temp(); // 分配新temp fprintf(ir_file, "+, %s, %s, %s\n", left_temp.c_str(), right_temp.c_str(), result_temp.c_str()); return result_temp; } // ... 其他运算符同理 }

3.5 现象:make编译时报错undefined reference to 'yywrap'

原因:lab1_lexer使用Flex生成词法分析器,但未链接-lfl库。原Makefile中LIBS变量为空。
解决:编辑lab1_lexer/Makefile,在LIBS行添加-lfl:

# 修改前 LIBS = # 修改后 LIBS = -lfl

并确保系统已安装flex:sudo apt install flex(Ubuntu)或brew install flex(macOS)。


4. 调试技巧与验证方法:用三类测试用例穿透整个编译流程

4.1 构建分层验证体系:从单元测试到端到端回归

不要等到lab5_codegen才验证,应在每个实验目录下用三类测试用例交叉验证:

测试类型用例特征验证目标执行命令示例
边界用例单行最简代码(如int a;)检查基础语法能否通过./lexer < test/minimal.c | wc -l(应输出≥3行token)
错误用例故意破坏语法(如int;)验证错误提示是否含行号./parser < test/error.c 2>&1 | grep "line 1"
压力用例200+行含嵌套循环/函数调用的代码检查内存泄漏与栈溢出valgrind --leak-check=full ./parser < test/large.c

注意:valgrind检测需先用make clean && make DEBUG=1重新编译,否则优化会隐藏内存问题。

4.2 语法分析器的黄金验证法:对比预测分析表与手工推导

当parser行为异常时,不要盲目改代码,先做表一致性验证:

  1. 提取实际使用的预测分析表:在parser.cpp的get_production_index函数入口添加日志:
cout << "Predicting: NT=" << nonterminal << ", T=" << terminal << " -> Prod=" << predict_table[nonterminal][terminal] << endl;
  1. 手工推导FIRST/FOLLOW集:对grammar.txt中Declaration非终结符,计算其FIRST={INT, VOID},FOLLOW={ID, SEMI, LBRACE}。
  2. 对照验证:运行./parser < test/decl.c,观察日志中NT=1(Declaration)在T=0(INT)时是否返回1(即第一条产生式)。若返回-1,说明表生成错误,需回溯grammar.txt的FIRST集标注。

4.3 中间代码生成的可视化验证:将四元式转DOT图

lab4_ir_generator输出的ir.txt是纯文本,但可借助Graphviz可视化控制流。编写转换脚本ir2dot.py:

# ir2dot.py import sys with open(sys.argv[1], 'r') as f: lines = [l.strip() for l in f if l.strip()] print("digraph G {") print(" rankdir=LR;") for i, line in enumerate(lines): parts = line.split(', ') if len(parts) == 4 and parts[0] in ['+', '-', '*', '/']: print(f' n{i} [label="{parts[0]}({parts[1]},{parts[2]})"];') print(f' n{i} -> n{i+1};') print("}")

执行:

python ir2dot.py ir.txt > ir.dot dot -Tpng ir.dot -o ir.png

生成的ir.png将清晰显示表达式计算顺序,若发现+节点指向*节点(应为反向),说明运算符优先级逻辑有误。

4.4 符号表的实时快照调试:注入作用域打印钩子

在symbol_table.cpp的enter_scope/exit_scope中添加作用域快照:

void enter_scope() { scopes.push_back(vector<SymbolEntry>()); current_scope++; // 打印当前作用域深度 cout << "[SCOPE_ENTER] Level " << current_scope << endl; } void dump_current_scope() { cout << "[SCOPE_DUMP] Level " << current_scope << ": "; for (auto& e : scopes.back()) { cout << e.name << "(" << e.type << ") "; } cout << endl; }

在parser.cpp的parse_Block函数末尾调用dump_current_scope(),即可在解析{ int x; x=1; }时看到:

[SCOPE_ENTER] Level 1 [SCOPE_DUMP] Level 1: x(int)

这种实时反馈比翻gdb堆栈高效十倍。


5. 进阶技巧:用GDB单步调试词法分析器状态机与语法分析器预测表查表过程

5.1 词法分析器状态机的GDB断点策略

lexer.cpp的状态机是线性switch结构,但直接在每个case设断点效率低下。更高效的做法是在状态跳转处统一断点:

# 启动GDB并加载lexer gdb ./lexer # 在状态更新位置设断点(lexer.cpp第87行:state = next_state;) (gdb) break lexer.cpp:87 # 运行并输入测试用例 (gdb) run < test/valid.c # 当命中断点时,查看当前状态与输入字符 (gdb) print state (gdb) print ch (gdb) print next_state

关键技巧:使用display命令自动打印关键变量,避免每次next后手动print:

(gdb) display state (gdb) display ch (gdb) display next_state

此后每次next或step,GDB会自动输出这三者的值,你能清晰看到state=1(INIT)→ch='i'→next_state=2(可能进入KEYWORD分支)的完整链条。

5.2 语法分析器预测表查表逻辑的逆向验证

当parser对某个输入报错时,往往不是代码bug,而是预测分析表与实际输入不匹配。此时需用GDB反向追踪:

# 在查表函数设断点 (gdb) break parser.cpp:152 # 假设get_production_index在152行 # 运行至报错前的最后一次查表 (gdb) run < test/error.c # 查看传入参数 (gdb) print nonterminal (gdb) print terminal # 手动计算该位置的表值 (gdb) print predict_table[nonterminal][terminal]

若返回-1,说明表未覆盖此组合。此时打开grammar.txt,找到nonterminal对应的非终结符(如2=Expression),检查其FIRST集是否包含terminal代表的终结符(如terminal=5对应PLUS)。若不包含,则需修正grammar.txt的FIRST集标注或调整文法。

5.3 编译器全流程的自动化回归测试脚本

为防止修改一个实验破坏其他实验,编写run_all_tests.sh:

#!/bin/bash # run_all_tests.sh EXPERIMENTS=("lab1_lexer" "lab2_parser" "lab3_semantic" "lab4_ir_generator") for exp in "${EXPERIMENTS[@]}"; do echo "=== Testing $exp ===" cd "$exp" # 清理并重编译 make clean if ! make; then echo "FAIL: $exp compilation error" exit 1 fi # 运行所有test/*.c并检查输出行数 for test_file in test/*.c; do if [[ -f "$test_file" ]]; then base_name=$(basename "$test_file" .c) if timeout 5s ./lexer < "$test_file" > "/dev/null" 2>&1; then echo "PASS: $exp/$base_name" else echo "FAIL: $exp/$base_name (timeout or crash)" exit 1 fi fi done cd .. done echo "All experiments passed!"

血泪经验:这个脚本必须包含timeout 5s,否则lab2_parser在遇到左递归文法时会无限循环卡死。我曾因漏加timeout,在凌晨三点看着风扇狂转却不知程序早已挂起。

5.4 从错误信息反推编译器阶段定位法

当拿到一个晦涩错误时,按此流程5秒定位问题阶段:

错误信息关键词对应编译阶段应检查的实验目录
"unrecognized character"或"invalid number format"词法分析lab1_lexer的lexer.cpp状态机
"syntax error before..."或"unexpected token"语法分析lab2_parser的predict_table与grammar.txt
"undeclared identifier"或"type mismatch"语义分析lab3_semantic的symbol_table.cpp与type_checker.cpp
"undefined temp variable t3"中间代码生成lab4_ir_generator的gen_expr临时变量分配逻辑

提示:所有错误信息均带line X,直接用vim +X src/xxx.cpp跳转到对应行,比在GDB里list快得多。

从那以后我每次修改predict_table,都强制走一遍run_all_tests.sh并用git diff确认只改了表值——因为上一次我手抖多删了一个逗号,导致整个lab2_parser的表错位,花了六小时才定位到那个隐藏的JSON格式错误。希望帮到你。

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

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

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

立即咨询