2026/10/29 11:44:23

编译原理实验包全解析:从词法分析到目标代码的工程实践

编译原理实验包全解析:从词法分析到目标代码的工程实践 简介这份资源是中国海洋大学2020年春季学期编译原理课程的实验代码合集面向计算机专业学生及自学者用于在理论学习之外动手实践编译器构造的完整流程。包内共74个文件以C源码、头文件、Flex词法规则.l、Bison语法规则.y、Makefile及可执行文件为主另附实验要求文档与测试用例压缩包约774KB体量轻便、结构清晰。内容覆盖词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成、错误处理与诊断、编译器综合八个实验涉及二义文法处理、符号表构建、三地址码与AST等具体知识点并配有实验目标、预期输出与评估标准说明。已有4418人学习下载适合按实验顺序逐步复现编译流程也可作为课程设计或编译工具链入门的参考素材。1. 从一份“能跑通”的编译原理实验包说起编译原理这门课理论课听懂了一到实验就卡壳是很多人的真实体验。词法分析器写完了语法分析器不知道怎么接语法树建出来了语义分析又不知道从哪下手中间代码生成、目标代码生成更是像黑匣子。这份 OUC 编译原理全部实验源码就是一套把整条编译流水线拆开摆在你面前的工程代码——从词法分析、语法分析、语义分析到中间代码生成和目标代码生成每个阶段都有独立可运行的模块。它适合两类人一是正在上编译原理课、实验卡住需要对照参考的同学二是想二次开发、把实验改造成自己课程设计或小型编译器原型的开发者。下面我按“这项目是什么、怎么跑、怎么改”的顺序把这份源码拆给你看。2. 项目定位与技术选型一套分阶段可运行的编译器实验骨架2.1 这个项目到底解决什么问题编译原理实验最典型的痛点是“阶段割裂”。教材把编译过程分成词法、语法、语义、中间代码、目标代码几个阶段但实验往往只让你实现其中一两个前后怎么衔接、数据怎么传递课上不一定讲透。这份实验包的价值在于它把每个阶段都做成了可独立编译、可独立运行、又能串成完整流水线的模块。你可以单独跑词法分析看 token 流也可以把词法输出喂给语法分析看语法树再往下走语义检查和代码生成。从工程角度看它更像一个“教学型编译器骨架”每个阶段有明确的输入输出约定阶段之间通过中间数据结构token 列表、语法树、符号表、四元式衔接。这种设计的好处是你改任何一个阶段只要保持接口不变其他阶段不用动。对于想理解“编译器到底怎么把源码变成目标代码”的人来说这种分阶段可观测的结构比一个黑盒式的完整编译器更有学习价值。技术选型上这类实验包常见做法是用 C 或 C 实现因为编译原理课程本身偏系统方向指针操作、结构体、递归下降这些内容用 C 系语言表达最直接。词法分析一般用手写状态机或正则匹配语法分析多用递归下降或 LL(1) 预测分析语义分析配合符号表做类型检查中间代码生成输出四元式或三地址码目标代码生成则常见为简单的汇编指令序列或伪代码。下面章节里涉及的具体实现我会按这个场景下合格开发最可能采用的做法来讲你对照自己手头的源码时重点看接口和数据结构是否一致。2.2 目录结构长什么样一份完整的编译原理实验包目录通常按“阶段 公共模块”组织。下面是我拆这类项目时常见的结构你可以对照自己下载的源码看compiler-lab/ ├── src/ │ ├── lexer/ # 词法分析状态机、token 定义 │ ├── parser/ # 语法分析递归下降、语法树节点 │ ├── semantic/ # 语义分析符号表、类型检查 │ ├── ir/ # 中间代码四元式生成 │ ├── codegen/ # 目标代码汇编/伪指令生成 │ └── common/ # 公共token 结构、错误处理、工具函数 ├── test/ │ ├── lexer/ # 各阶段测试用例源码 │ ├── parser/ │ └── ... ├── Makefile # 统一构建入口 └── README.md这个结构的关键在于common/目录。token 结构、语法树节点定义、错误码这些跨阶段共享的数据结构放在这里各阶段模块只依赖 common不互相依赖。这样你单独编译 lexer 时不需要把 parser 也拉进来调试成本低很多。如果你的源码里没有这么清晰的分层建议二次开发时先做这一步拆分后面改起来会顺很多。2.3 阶段之间的数据流约定理解这份源码核心是理解阶段之间的数据流。典型链路是源程序 → [词法] → token 流 → [语法] → 语法树 → [语义] → 带类型标注的语法树 符号表 → [中间代码] → 四元式序列 → [目标代码] → 汇编/伪指令每个箭头都是一次“序列化 反序列化”的机会。常见做法是每个阶段提供一个run(input) - output的函数输入输出都是内存中的结构体或类。调试时你可以把中间结果打印出来比如词法阶段输出(类型, 值, 行号)三元组语法阶段输出带缩进的树形结构。这种可打印的中间表示是排查“到底哪一步错了”的关键。后面避坑章节会专门讲几个因为数据流约定不一致导致的翻车场景。3. 跑起来从编译到单阶段验证的完整步骤3.1 环境准备与构建这类 C/C 实验包构建方式通常有两种Makefile 或 CMake。先看根目录有没有Makefile有的话直接# 进入项目根目录 cd compiler-lab # 查看可用的构建目标 make help # 全量构建常见默认目标 make # 如果 Makefile 支持分阶段构建 make lexer make parser如果项目用的是 CMake步骤是mkdir build cd build cmake .. make -j4构建失败时先看编译器版本。老实验包常见问题是用了 C99 之前的写法或者依赖某个特定版本的 gcc。我一般会先跑gcc --version确认然后看报错是“语法错误”还是“链接错误”。语法错误多半是标准不匹配可以在 Makefile 里加-stdc99或-stdc11试试链接错误则要看是不是某个阶段的.o没生成或者公共模块没被正确引用。提示如果 Makefile 里写死了绝对路径比如某个同学机器上的/home/xxx/...构建会直接失败。这种情况把路径改成相对路径或$(PWD)即可这是实验包从别人机器拷过来最常见的翻车点之一。3.2 单阶段运行与输入输出验证构建成功后先别急着跑完整流水线按阶段单独验证。以词法分析为例常见入口是# 假设可执行文件在 bin/ 下输入是一个测试源文件 ./bin/lexer test/lexer/sample1.c预期输出是一串 token格式可能是KEYWORD int、IDENT main、SYMBOL (这样。你要做的第一件事是对照测试用例看 token 数量和类型对不对。如果输出为空先检查输入文件路径对不对、文件编码是不是 UTF-8有些实验包对中文注释处理不好会直接崩。语法分析阶段通常需要词法阶段的输出作为输入。有些实现是内部直接调用词法模块有些则是要求你先重定向# 方式一内部串联直接给源文件 ./bin/parser test/parser/sample1.c # 方式二管道传递 token 流 ./bin/lexer test/parser/sample1.c | ./bin/parser两种方式都要试一下因为不同实验包的阶段耦合方式不一样。如果管道方式报“输入格式错误”说明 parser 期望的是结构化 token 而不是纯文本那就用方式一。这一步的验证标准是语法树能正确打印出来括号匹配、语句嵌套层级和源码一致。3.3 完整流水线跑通单阶段都通过后跑完整链路。常见入口是一个main或driver程序# 完整编译流程输出中间代码或目标代码 ./bin/compiler test/full/sample1.c # 有些项目支持指定输出阶段 ./bin/compiler --stageir test/full/sample1.c ./bin/compiler --stagecodegen test/full/sample1.c跑通的标准不是“没报错”而是“每一阶段的中间产物都符合预期”。我的习惯是加一个-v或--verbose参数如果源码支持把 token 流、语法树、符号表、四元式逐段打印出来人工扫一遍。比如四元式阶段检查(, a, b, t1)这种格式里临时变量编号是否连续、操作数是否都在符号表里。这一步花十分钟能省掉后面调目标代码时几个小时的迷茫。注意完整流水线跑通后建议把每个测试用例的输出保存成基线文件expected/目录后面改代码时用diff对比这是二次开发阶段最省事的回归手段。4. 核心模块拆解词法、语法、语义三段代码逐段看4.1 词法分析状态机与 token 结构词法分析的核心是一个字符级状态机。下面这段是这类实验包里最常见的实现骨架我用 C 风格伪代码写你对照自己源码看结构// token 类型枚举放在 common/token.h typedef enum { TOK_INT, TOK_IDENT, TOK_NUMBER, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_SEMI, TOK_EOF, TOK_ERROR } TokenType; typedef struct { TokenType type; char value[64]; // 标识符/数字的字面值 int line; // 行号报错用 } Token; // 主循环跳过空白按首字符分派 Token next_token(FILE *fp) { int ch; // 跳过空白和注释 while ((ch fgetc(fp)) || ch \n || ch \t) { if (ch \n) line_no; } if (ch EOF) return make_token(TOK_EOF, , line_no); if (isalpha(ch)) { // 标识符或关键字 return read_ident(fp, ch); } else if (isdigit(ch)) { // 数字 return read_number(fp, ch); } else { // 单字符符号 return read_symbol(ch); } }这段代码的逻辑很直白外层循环负责跳过空白、维护行号内层按首字符分派到三个读取函数。read_ident会继续读字母数字直到非标识符字符然后查关键字表决定是TOK_INT还是TOK_IDENTread_number读连续数字read_symbol处理单字符运算符和分隔符。参数上line字段一定要维护好后面语法和语义阶段报错全靠它定位。这里有个容易忽略的点read_ident读完后多读了一个字符因为要判断标识符结束这个字符必须“退回”输入流否则下一个 token 会丢首字符。常见做法是用ungetc(ch, fp)。如果你发现 token 流里莫名其妙少字符先查这里。4.2 语法分析递归下降与语法树节点语法分析常见两种实现递归下降和 LL(1) 预测分析表。实验包里递归下降更常见因为代码直观、和文法对应关系清楚。核心是每个非终结符对应一个函数// 语法树节点放在 common/ast.h typedef struct ASTNode { char *label; // 节点类型如 IfStmt、Assign struct ASTNode **children; int child_count; Token token; // 关联的 token保留行号 } ASTNode; // 表达式解析加减法左结合 ASTNode* parse_expr(Parser *p) { ASTNode *left parse_term(p); // 先解析乘除层 while (peek(p).type TOK_PLUS || peek(p).type TOK_MINUS) { Token op advance(p); ASTNode *right parse_term(p); left make_binary_node(op, left, right); // 构造二元节点 } return left; } // 项解析乘除法优先级高于加减 ASTNode* parse_term(Parser *p) { ASTNode *left parse_factor(p); // 因子层数字、标识符、括号 while (peek(p).type TOK_STAR || peek(p).type TOK_SLASH) { Token op advance(p); ASTNode *right parse_factor(p); left make_binary_node(op, left, right); } return left; }这段的关键是“分层”parse_expr处理加减parse_term处理乘除parse_factor处理括号和原子。每层只关心自己这级的运算符优先级自然就对了。peek看下一个 token 但不消费advance消费并前进这两个辅助函数是递归下降的标配。make_binary_node把运算符和左右子树组装成一个节点label存节点类型token存运算符位置用于报错。参数上要注意Parser结构体里至少要有 token 数组、当前下标、错误计数。错误恢复策略常见做法是“panic mode”遇到语法错误就跳到下一个分号或右括号继续解析这样一次能报多个错而不是遇到第一个就退出。如果你手头的源码一遇错就exit(1)二次开发时可以把这个改成错误计数 跳过调试体验会好很多。4.3 语义分析符号表与类型检查语义分析阶段的核心数据结构是符号表。常见做法是用哈希表或链表数组按作用域分层// 符号表条目 typedef struct Symbol { char name[64]; char type[32]; // int、float 等 int scope_level; // 作用域层级 struct Symbol *next; // 同一作用域链表 } Symbol; // 作用域栈进入块压栈离开块弹栈 typedef struct Scope { Symbol *symbols; struct Scope *parent; } Scope; // 变量声明检查同一作用域重复声明报错 int declare_symbol(Scope *cur, const char *name, const char *type) { Symbol *s cur-symbols; while (s) { if (strcmp(s-name, name) 0) { error(duplicate declaration: %s, name); return -1; } s s-next; } // 插入新符号 Symbol *new_sym malloc(sizeof(Symbol)); strcpy(new_sym-name, name); strcpy(new_sym-type, type); new_sym-scope_level cur-parent ? cur-parent-symbols-scope_level 1 : 0; new_sym-next cur-symbols; cur-symbols new_sym; return 0; }这段代码展示了符号表最基础的两个操作查重和插入。declare_symbol只在当前作用域查重不查父作用域这是对的——内层可以遮蔽外层同名变量。查找变量时则要从当前作用域一路往上找直到根作用域。类型检查的常见做法是在语法树上做一次遍历遇到赋值语句检查左右类型是否兼容遇到函数调用检查实参个数和类型。这里有个血泪经验作用域层级scope_level的维护很容易出错。如果进入块时忘了压栈或者离开块时忘了弹栈符号表就会串层表现为“内层变量在外层可见”或者“外层变量被误报重复声明”。排查方法是打印符号表快照对照源码的{}嵌套看层级对不对。5. 避坑与排查五个真实翻车场景5.1 现象词法分析输出 token 数量比预期少原因通常是标识符读取后没有正确回退多读的字符或者注释处理逻辑把//后面的换行也吞了导致下一行首个 token 丢失。解决方法是检查read_ident和注释跳过逻辑里的ungetc调用并在每个 token 输出时打印行号对照源码逐行核对。5.2 现象语法分析报“unexpected token”但源码看起来没问题先查 token 类型枚举和 parser 里peek的比较是否一致。常见翻车是词法阶段把关键字识别成了TOK_IDENTparser 却按TOK_INT去匹配自然对不上。排查手段是在 parser 入口把 token 流完整打印一遍看关键字 token 的类型对不对。另一个常见原因是文法里漏了某个产生式比如if语句没处理else分支。5.3 现象语义分析阶段符号表查不到已声明的变量多半是作用域栈的压栈/弹栈时机不对。比如在解析函数体时提前弹了栈或者块作用域的{}没有正确触发压栈。解决方法是给enter_scope和exit_scope加日志打印当前层级和符号数量对照源码的嵌套结构看哪一层少了。还有一种可能是变量声明语句本身没被语义分析遍历到检查语法树遍历函数是否覆盖了所有节点类型。5.4 现象中间代码生成的四元式临时变量编号重复或跳号临时变量计数器通常是全局或挂在生成器结构体上的。重复编号说明计数器被重置了比如每个函数重新从 0 开始但没做作用域隔离跳号说明某些分支生成了临时变量但没被使用。解决方法是把临时变量计数器做成单调递增并在每个四元式输出时打印编号人工扫一遍连续性。如果项目要求临时变量按函数隔离就在进入函数时保存现场、退出时恢复。5.5 现象目标代码生成后简单程序能跑复杂表达式结果错误这类问题多半出在寄存器分配或求值顺序上。教学型代码生成常见做法是用固定寄存器 栈保存中间结果如果表达式嵌套深栈操作顺序错了就会算错。排查方法是拿一个a b c * d这样的表达式把生成的目标指令逐条手算一遍对照四元式的求值顺序。另一个常见坑是函数调用时没保存调用者寄存器导致返回后值被覆盖。6. 二次开发把实验包改造成你自己的编译器原型6.1 先做接口固化再动实现二次开发最容易犯的错是一上来就改核心算法改完发现上下游全崩了。我的建议是先做一件事把每个阶段的输入输出接口用头文件或接口类固化下来写一组回归测试用例跑通基线。具体做法是给每个阶段定义清晰的run函数签名输入输出都用 common 里的结构体然后在test/下放一组覆盖典型语法赋值、if、while、函数调用的源文件把当前输出存成expected/基线。# 固化基线每个测试用例跑一遍保存输出 for f in test/full/*.c; do ./bin/compiler $f expected/$(basename $f).out 21 done # 改代码后回归对比 for f in test/full/*.c; do ./bin/compiler $f /tmp/out.txt 21 diff expected/$(basename $f).out /tmp/out.txt || echo FAIL: $f done这套基线机制建立起来后你改任何阶段都能立刻知道有没有破坏原有行为。这是我从多个实验包改造里总结出的最省事的手段比事后 debug 划算得多。6.2 三个可落地的改造方向第一个方向是扩展语言特性。比如给文法加for循环、do-while、数组或结构体。改造顺序是先改词法加关键字和符号再改语法加产生式和 AST 节点然后语义分析加类型规则最后中间代码和目标代码加对应生成逻辑。每加一个特性就跑一遍基线确保老用例不回归。第二个方向是替换某个阶段的实现。比如把递归下降换成 LL(1) 预测分析表或者把四元式换成三地址码的另一种表示。这种改造的关键是保持接口不变内部随便换。我一般会保留旧实现作为legacy/分支新实现通过编译开关切换方便对比。第三个方向是加可视化输出。把语法树导出成 DOT 格式用 Graphviz 渲染成图把符号表按作用域层级打印成缩进结构把四元式序列导出成表格。这些可视化对调试和演示都有用而且改动集中在输出层风险低。改造方向改动范围风险验证方式扩展语言特性词法语法语义代码生成中高基线回归 新特性用例替换阶段实现单阶段内部中新旧实现输出对比加可视化输出输出层低人工检查导出文件6.3 一个具体技巧用中间产物做“断点”编译器调试最难受的是“结果错了但不知道哪一步错的”。我的习惯是在每个阶段结束时把中间产物落盘token 流存.tok语法树存.ast符号表存.sym四元式存.ir。这样出错时不用重跑整个流程直接看对应文件就能定位。实现上就是在各阶段run函数末尾加一个dump_to_file调用用环境变量控制开关比如DUMP_STAGEir ./bin/compiler sample.c。这个技巧配合基线对比基本能做到“改一处、验一处”。从那以后我每次动编译器代码都强制先跑一遍基线回归再看中间产物 dump确认数据流每一段都对得上才继续往下改。希望这套拆解能帮到你把这份实验包真正跑起来、改下去。本文还有配套的精品资源点击获取