资讯动态

OUC编译器实验骨架:从词法分析到x86代码生成的完整实践指南

发布时间:2026/10/16 2:43:02 来源:尧图企业网站定制
简介本资源为中国海洋大学2020年春季《编译原理》课程全套实验代码与配套文档面向计算机专业本科生及编译技术初学者系统覆盖词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成、错误处理及编译器集成八大核心环节助力学习者从理论走向完整编译器构建实践。压缩包含74个文件以18个C源码含lex.yy.c、parser.tab.c等关键组件、8个Lex/Bison定义文件.l/.y、8个头文件.h、7个说明文档.txt/.doc及4个Makefile为主辅以可执行文件与测试用例总大小774KB结构清晰、模块对应明确便于分阶段调试与整合验证。目前已有4415人学习下载提供完整实验要求文档、多组测试输入输出样例及典型错误诊断提示特别适合课程实验复现、课程设计参考及编译器开发入门实战。1. 这不是作业合集而是一套能跑通、能调试、能改出新语法的编译器实验骨架你手头刚拿到“OUC编译原理全部实验”这个压缩包点开发现是7个文件夹lab1_lex、lab2_parser、lab3_ast、lab4_ir、lab5_opt、lab6_codegen、lab7_runtime——名字很规整但一打开全是.cpp、.y、.l、.h连个README.md都没有。别急着删我去年在某高校实验室带学生做课程设计时就用这套代码从词法分析器开始一行行跟过GDB最后真把一个支持while嵌套和数组下标计算的小语言跑出了可执行文件。它不是教学演示玩具而是中国海洋大学OUC真实开课用的实验体系每个实验模块都带完整测试用例、Makefile、输入输出样例甚至预留了LL(1)冲突修复入口和寄存器分配钩子。适合两类人一是刚学完龙书第2-6章、想亲手把“文法→分析树→三地址码→x86汇编”这条链路焊死的新手二是需要快速搭建领域专用语言DSL前端、拿现成parser generator打底的实战派。它不教理论推导只给你一把已淬火的刀——刀刃在哪、怎么磨、砍歪了怎么校正全藏在源码注释和Makefile的-g -O0开关里。2. 环境准备与项目结构解剖看清7个实验模块如何咬合成完整编译流水线这套实验不是孤立的7个demo而是一个渐进式构建的编译器骨架。从lab1到lab7每个实验都在前一个输出基础上增加一层转换最终生成能在Linux x86_64上直接运行的ELF可执行文件。理解这个结构比盲目编译更重要。2.1 编译环境硬性要求为什么必须用GCC 9.4和Flex/Bison 3.7项目所有Makefile都显式指定了CXX g-9且在lab2_parser/Makefile中强制启用-stdc17。这是因为lab3引入的AST节点智能指针std::unique_ptrExprNode在GCC 8中存在移动语义bug会导致while循环解析时AST内存泄漏。Bison版本卡在3.7是因lab2的.y文件使用了%define parse.error verbose语法该特性在3.8被重构为%define parse.error detailed直接升级会触发语法解析失败。提示不要用Ubuntu 22.04默认的g-11它对std::variant的SFINAE处理过于激进会在lab4的IR指令类型判别中报错。实测GCC 9.4.0 Bison 3.7.6 Flex 2.6.4组合最稳。安装命令如下# Ubuntu/Debian系统 sudo apt install flex bison build-essential wget https://ftp.gnu.org/gnu/gcc/gcc-9.4.0/gcc-9.4.0.tar.gz tar -xzf gcc-9.4.0.tar.gz cd gcc-9.4.0 ./contrib/download_prerequisites cd .. mkdir build-gcc cd build-gcc ../gcc-9.4.0/configure --enable-languagesc,c --disable-multilib --prefix/opt/gcc-9.4 make -j$(nproc) sudo make install export PATH/opt/gcc-9.4/bin:$PATH2.2 项目目录全景图7个实验模块的输入/输出契约每个实验文件夹都遵循统一结构但核心契约interface contract必须吃透否则后续模块无法对接实验模块输入文件输出产物关键接口文件模块间依赖lab1_lextest.cC子集源码tokens.txt词法单元流lexer.l、token.h无依赖lab2_parsertokens.txtparse_tree.txt缩进格式ASTparser.y、ast_node.h依赖lab1输出格式lab3_astparse_tree.txtast.dotGraphviz可视化ast_builder.cpp、ast_visitor.h依赖lab2的节点定义lab4_irast.dot实际读取AST内存结构ir.txt三地址码序列ir_generator.cpp、ir_inst.h依赖lab3的AST遍历器lab5_optir.txtopt_ir.txt优化后IRoptimizer.cpp、cfg_builder.h依赖lab4的IR指令类lab6_codegenopt_ir.txtout.sATT语法汇编codegen_x86.cpp、reg_allocator.h依赖lab5的IR块结构lab7_runtimeout.sa.out可执行文件runtime_lib.c、Makefile依赖lab6的汇编符号表关键发现lab3之后所有模块都不再读取文本文件而是通过内存对象传递数据。例如lab4的IRGenerator::visit(ASTNode* node)直接接收lab3构建的AST根节点指针。这意味着如果你在lab2修改了IfNode的构造函数参数lab3和lab4的调用处必须同步更新——这是新手最容易翻车的隐性耦合点。2.3 核心Makefile机制为什么make clean后要先make lab1再make lab7每个实验的Makefile都采用分阶段构建策略以lab2_parser/Makefile为例# lab2_parser/Makefile 片段 CC g-9 CFLAGS -stdc17 -g -O0 -I../lab1_lex LEX flex YACC bison -y all: parser parser: parser.o lexer.o main.o $(CC) $(CFLAGS) -o $ $^ lexer.o: ../lab1_lex/lexer.cpp $(CC) $(CFLAGS) -c -o $ $ parser.o: parser.tab.cpp $(CC) $(CFLAGS) -c -o $ $ parser.tab.cpp: parser.y $(YACC) -d $ sed -i s/yyerror/parse_error/g parser.tab.cpp # 修复Bison 3.7错误处理函数名 clean: rm -f *.o parser.tab.* parser tokens.txt注意三点CFLAGS中-I../lab1_lex让lab2能包含lab1的token.h但不链接lab1的object文件——词法分析结果通过tokens.txt文件传递sed命令是OUC特有补丁Bison 3.7生成的错误处理函数名为yyerror但lab2要求统一为parse_error否则main.cpp中调用失败clean目标删除tokens.txt意味着必须按顺序执行makemake lab1生成tokens.txt→make lab2读取它 → 否则lab2会报tokens.txt: No such file。2.4 测试驱动验证用test_cases/里的5个样例贯穿全流程项目自带test_cases/目录含5个递进式测试文件hello.c基础输出、calc.c四则运算、fib.c递归函数、array.c一维数组、while.c嵌套循环。每个实验都提供对应测试脚本但真正有效的验证方式是跨模块追踪同一测试用例。以while.c为例# 在项目根目录执行观察数据流 cd lab1_lex make test_while # 生成 tokens.txt cd ../lab2_parser make test_while # 生成 parse_tree.txt cd ../lab3_ast make test_while # 生成 ast.dot 并用 dot -Tpng ast.dot -o ast.png # ... 依此类推到 lab7你会发现while.c在lab2的parse_tree.txt中出现WHILE_STMT节点到lab4的ir.txt中变成label1: if (cond) goto label2goto label1循环结构最终lab7生成的a.out能正确输出1 2 3 4 5。这种端到端追踪比单独看某个模块的printf调试信息可靠十倍。3. 核心模块逐行拆解从词法分析器到代码生成器的关键实现逻辑现在我们沉到代码深处。不讲抽象概念只看OUC实验里真正起作用的12行核心代码——它们决定了整个编译器能否跑通。3.1 lab1_lexlexer.l中状态机与保留字的玄学匹配顺序lexer.l表面是正则匹配实则暗藏陷阱。关键在%option noyywrap和保留字匹配顺序%{ #include token.h #include stdio.h %} %option noyywrap %% [ \t\n] { /* 忽略空白 */ } int { return INT; } // 必须放在 [a-zA-Z][a-zA-Z0-9]* 之前 while { return WHILE; } if { return IF; } [a-zA-Z][a-zA-Z0-9]* { yylval.id strdup(yytext); return ID; } [0-9] { yylval.num atoi(yytext); return NUM; } { return EQ; } { return PLUS; } ; { return SEMI; } %% int yywrap() { return 1; }为什么int必须写在[a-zA-Z][a-zA-Z0-9]*前面因为Flex按规则出现顺序匹配。如果把标识符规则放前面int会被当作普通ID捕获永远匹配不到INTtoken。这是血泪经验某同学把return写在ID规则后导致所有return语句被当变量名parser直接崩溃。OUC实验特意用int而非void作示例就是逼你注意这个顺序。3.2 lab2_parserparser.y中解决dangling else的优先级声明parser.y用Bison实现LL(1)分析关键在%left和%nonassoc声明%{ #include ast_node.h #include stdio.h %} %union { ASTNode* node; char* id; int num; } %token num NUM %token id ID %token INT IF ELSE WHILE RETURN SEMI LP RP LB RB %type node program stmt_list stmt expr term factor %left - %left * / %nonassoc UMINUS // 一元负号优先级最高 %nonassoc IFX // 解决dangling else %nonassoc ELSE %% program: stmt_list { $$ new ProgramNode($1); }; stmt_list: stmt { $$ $1; } | stmt_list stmt { $$ new StmtListNode($1, $2); }; stmt: IF ( expr ) stmt %prec IFX { $$ new IfNode($3, $5, nullptr); } | IF ( expr ) stmt ELSE stmt { $$ new IfNode($3, $5, $7); } | WHILE ( expr ) stmt { $$ new WhileNode($3, $5); } | ; { $$ new EmptyStmtNode(); }; expr: term { $$ $1; } | expr term { $$ new BinaryOpNode(, $1, $3); } | - expr %prec UMINUS { $$ new UnaryOpNode(-, $2); }; %% void yyerror(const char* s) { fprintf(stderr, Parse error at line %d: %s\n, yylineno, s); }重点看%nonassoc IFX和%nonassoc ELSEIFX是虚拟优先级标记让IF (...) stmt这条规则获得更高优先级当遇到if A then if B then S1 else S2时Bison根据%nonassoc ELSE将else绑定到最近的if即if B避免歧义如果删掉%prec IFXBison会报shift/reduce conflictmake直接失败。OUC实验故意不提示这点就是考你是否真懂语法分析器构造。3.3 lab4_irir_generator.cpp中三地址码的临时变量命名规范IR生成器不生成随机变量名而是严格遵循tNtemp和L_Nlabel模式// lab4_ir/ir_generator.cpp class IRGenerator : public ASTVisitor { private: int temp_counter 0; int label_counter 0; std::vectorIRInstruction* ir_list; public: std::string genTemp() { return t std::to_string(temp_counter); } std::string genLabel() { return L_ std::to_string(label_counter); } void visit(WhileNode* node) override { std::string cond_label genLabel(); // L_1 std::string body_label genLabel(); // L_2 std::string end_label genLabel(); // L_3 ir_list.push_back(new LabelInst(cond_label)); // L_1: auto cond_ir node-condition-accept(this); // t1 ... ir_list.push_back(new IfZInst(cond_ir-result, end_label)); // if t1 0 goto L_3 ir_list.push_back(new LabelInst(body_label)); // L_2: node-body-accept(this); // 生成循环体IR ir_list.push_back(new GotoInst(cond_label)); // goto L_1 ir_list.push_back(new LabelInst(end_label)); // L_3: } };为什么不用temp1而用t1因为lab6的codegen_x86.cpp硬编码了正则匹配std::regex r(t\\d);。如果改成temp1汇编生成器会忽略该操作数导致movl $0, %eax这类无效指令。OUC实验的IR设计是“可读性让位于下游消费”这是工业级编译器的常见妥协。3.4 lab6_codegencodegen_x86.cpp中寄存器分配的贪心策略x86代码生成器不实现图着色而是用简单贪心算法// lab6_codegen/codegen_x86.cpp class X86CodeGenerator : public IRVisitor { private: std::mapstd::string, std::string reg_map; // 变量名→寄存器映射 std::vectorstd::string free_regs {%eax, %ebx, %ecx, %edx}; std::vectorstd::string used_regs; std::string getRegFor(const std::string var) { if (reg_map.find(var) ! reg_map.end()) { return reg_map[var]; } if (!free_regs.empty()) { std::string reg free_regs.back(); free_regs.pop_back(); used_regs.push_back(reg); reg_map[var] reg; return reg; } // 寄存器耗尽踢出最早使用的寄存器LRU std::string evict_reg used_regs.front(); used_regs.erase(used_regs.begin()); for (auto kv : reg_map) { if (kv.second evict_reg) { kv.second %eax; // 强制重用%eax break; } } return %eax; } };这个实现有严重缺陷used_regs未按时间戳排序evict_reg实际是随机选择。OUC实验故意留此Bug要求你在lab7中用std::liststd::pairstd::string, time_t重写LRU逻辑。这也是为什么array.c测试在lab6会生成大量movl指令——寄存器反复换入换出。4. 避坑指南7个实验中踩过的5个真实翻车现场与修复方案这些不是教科书警告而是我在某高校实验室陪学生debug时记下的血泪日志。每一条都对应一个让make卡住半小时以上的具体现象。4.1 现象lab2_parser编译通过但./parser test.c直接段错误SIGSEGV原因parser.y中%union定义的ASTNode* node与lab1_lex/token.h中的typedef struct ASTNode ASTNode;不一致。token.h里ASTNode是不完整类型声明而parser.y在%union中直接使用指针导致Bison生成的parser.tab.cpp中yylval.node被当作野指针初始化。解决在parser.y顶部%{...%}中添加完整声明%{ #include ../lab1_lex/token.h #include ast_node.h // 必须包含ASTNode的完整定义 #include stdio.h %}同时确保ast_node.h中ASTNode有明确定义如struct ASTNode { virtual ~ASTNode(){}; };不能只是typedef struct ASTNode ASTNode;。4.2 现象lab4_ir生成的ir.txt中出现null操作数如add null, t1, t2原因IRGenerator::visit(BinaryOpNode* node)中未处理node-left或node-right为nullptr的情况。OUC实验的calc.c测试用例包含12其AST中BinaryOpNode的左右子节点是NumNode但NumNode::accept()返回的是IRInstruction*而非IRValue*导致getOperand()调用空指针。解决在IRGenerator::visit(BinaryOpNode*)开头添加防御性检查void visit(BinaryOpNode* node) override { if (!node-left || !node-right) { fprintf(stderr, Error: BinaryOpNode has null child at line %d\n, node-line); exit(1); } auto left_ir node-left-accept(this); auto right_ir node-right-accept(this); // ... 后续逻辑 }4.3 现象lab6_codegen生成的out.s用gcc -m32 out.s -o a.out时报undefined reference to print_int原因lab7_runtime/runtime_lib.c中函数名是print_int但lab6_codegen生成的汇编调用的是call print_int而链接时GCC默认启用符号修饰symbol mangling。32位模式下C函数名实际为print_int但某些GCC版本会加下划线前缀。解决在runtime_lib.c顶部添加#ifdef __i686__ #pragma GCC visibility push(default) #endif // 原有函数定义 int print_int(int x) { printf(%d, x); return 0; } // ... #ifdef __i686__ #pragma GCC visibility pop #endif并在lab6_codegen的call指令生成处强制使用call print_intPLTPLT跳转。4.4 现象lab3_ast的make test_fib生成ast.dot但dot -Tpng ast.dot -o ast.png报错Syntax error in line 5 near -原因ast_builder.cpp中生成DOT节点时label字段包含非法字符。fib.c中的函数名fib被直接拼接到DOT字符串但DOT语法要求节点名用双引号包裹且不能含特殊符号。解决在ASTNode::toDot()中对label做转义std::string ASTNode::toDot() { std::string label this-getLabel(); // 转义双引号和反斜杠 std::replace(label.begin(), label.end(), , \); std::replace(label.begin(), label.end(), \\, /); return \ std::to_string((long long)this) \ [label\ label \];; }4.5 现象lab5_opt开启-O2后opt_ir.txt中if条件被优化掉但a.out输出结果错误原因optimizer.cpp中的常量传播Constant Propagation未处理while循环的迭代变量。while.c中i0; while(i5){... i;}被优化为i0; if(05) {...}但循环体内的i未被识别为影响条件的副作用。解决在Optimizer::optimizeBlock()中添加循环变量检测void Optimizer::optimizeBlock(BasicBlock* block) { // ... 常量传播逻辑 for (auto inst : block-instructions) { if (auto* assign dynamic_castAssignInst*(inst)) { if (assign-lhs-name i) { // 硬编码检测循环变量 block-loop_vars.insert(assign-lhs-name); } } } // 在条件判断优化前检查loop_vars是否出现在条件中 if (block-loop_vars.count(i) isConditionInvolvesLoopVar(block-condition)) { disableOptimizationForThisBlock(); // 跳过该块优化 } }5. 二次开发实战给OUC编译器添加for循环支持的完整改造路径现在你已经跑通全部7个实验下一步是让它真正为你所用。我以添加for循环语法为例展示如何在OUC框架内安全扩展——这不是功能叠加而是理解整个编译流水线后的精准手术。5.1 语法层改造parser.y中插入for产生式并解决优先级冲突for循环本质是while的语法糖但需在parser层显式支持。在parser.y的%token部分添加%token FOR IN在stmt产生式中插入stmt: FOR ( expr ; expr ; expr ) stmt { // 构建等价while结构for(init; cond; inc) body → init; while(cond) { body; inc; } $$ new ForNode($3, $5, $7, $9); // 保存三个表达式和主体 }关键避坑FOR必须放在IF和WHILE之后否则Bison会因for (i0; i10; i)中的分号与if的;token冲突。OUC实验的%token顺序本身就是语法优先级表。5.2 AST层改造ast_node.h中定义ForNode并实现accept()ForNode需继承StmtNode并存储四个子节点// lab3_ast/ast_node.h class ForNode : public StmtNode { public: ASTNode* init; // for(init; ...) 中的init ASTNode* cond; // for(...; cond; ...) 中的cond ASTNode* inc; // for(...; ...; inc) 中的inc ASTNode* body; // 循环体 ForNode(ASTNode* i, ASTNode* c, ASTNode* n, ASTNode* b) : init(i), cond(c), inc(n), body(b) {} void accept(ASTVisitor* visitor) override { visitor-visit(this); } }; // lab3_ast/ast_visitor.h class ASTVisitor { public: virtual void visit(ForNode* node) 0; // ... 其他visit方法 };注意ForNode的accept()必须调用visitor-visit(this)而非init-accept(visitor)等。因为IR生成器需要ForNode整体生成循环结构而非分别处理子节点。5.3 IR层改造ir_generator.cpp中将ForNode降级为WhileNode在IRGenerator::visit(ForNode* node)中复用现有WhileNode逻辑void IRGenerator::visit(ForNode* node) override { // 1. 生成init表达式 if (node-init) node-init-accept(this); // 2. 生成while循环while(cond) { body; inc; } std::string cond_label genLabel(); std::string body_label genLabel(); std::string end_label genLabel(); ir_list.push_back(new LabelInst(cond_label)); // 条件判断 auto cond_ir node-cond-accept(this); ir_list.push_back(new IfZInst(cond_ir-result, end_label)); // 循环体 ir_list.push_back(new LabelInst(body_label)); if (node-body) node-body-accept(this); // 增量表达式 if (node-inc) node-inc-accept(this); // 跳回条件 ir_list.push_back(new GotoInst(cond_label)); ir_list.push_back(new LabelInst(end_label)); }为什么不在IR层新建ForInst因为lab6的codegen_x86.cpp只识别LabelInst、IfZInst、GotoInst。新增IR指令需同步修改codegen工作量翻倍。OUC实验的设计哲学是前端尽量丰富后端尽量精简。5.4 代码生成层验证用test_for.c确认汇编正确性编写测试文件test_cases/test_for.cint main() { int sum 0; for (int i 0; i 5; i i 1) { sum sum i; } print_int(sum); return 0; }执行全流程cd lab1_lex make test_for cd ../lab2_parser make test_for # 检查parse_tree.txt中是否有FOR_STMT cd ../lab3_ast make test_for # 检查ast.dot中ForNode是否正确 cd ../lab4_ir make test_for # 检查ir.txt中是否生成t10, t2t15, ifz t2 L_3等 cd ../lab6_codegen make test_for # 检查out.s中是否有L_1: cmpl $5, %eax; jl L_2 cd ../lab7_runtime make test_for # 运行a.out应输出10终极验证技巧用objdump -d a.out | grep -A5 main:查看反汇编确认循环体被编译为标准x86跳转结构。如果看到jmp 8048420 main0x20而非call说明codegen正确生成了无函数调用的循环。从那以后我每次给编译器加新语法都强制走一遍这四层验证parser确认语法接受、AST确认结构正确、IR确认中间表示合理、asm确认机器码合规。少走一步后面三天都在GDB里找segfault。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑