资讯详情

编译原理课程设计实战:词法分析、语法分析与小型编译器四阶段流水线实现

📅 2026/10/21 11:58:11 | 华诺云谱 👁 阅读
编译原理课程设计实战:词法分析、语法分析与小型编译器四阶段流水线实现
简介这份资源面向计算机专业学生与编译原理初学者提供一套完整的课程设计实践方案帮助读者把课堂上的形式语言与自动机理论落到可运行的代码上。包内围绕词法分析、LL(1)语法分析、LR(0)与SLR(1)语法分析、四元式生成及汇编代码生成等核心环节展开并附有小型编译器的课程设计报告可用于课程作业参考、实验复现与期末复习。资源共14个文件以cpp、c与h源码为主体配合txt说明与doc实验报告压缩包约557KB体量轻便便于本地编译调试与逐模块研读。其中包含部分LL(1)文法和一个SLR(1)文法可对照分析简单语句如ii*i的推导与归约过程。目前已有2349人学习下载适合希望理解编译器前端流程、补齐实验环节或准备课程答辩的读者参考借鉴。1. 从一次课程设计翻车说起为什么词法分析和语法分析必须一起做很多同学做编译原理课程设计时习惯先把词法分析器写完再单独写语法分析器最后拼在一起。这个顺序看起来合理实际上是最容易翻车的做法。我见过一个模拟项目词法分析器单独跑没问题能正确识别标识符、关键字、运算符和界符但一接到语法分析器上就崩了——因为词法分析器把换行符当成了普通空白丢弃而语法分析器需要靠换行来判断语句结束。两边对 token 的定义不一致接口对不上最后只能推倒重来。这套小型编译器课程设计要解决的核心问题就是让词法分析、语法分析、语义处理、代码生成四个阶段在统一的数据结构上协同工作。它适合正在做编译原理课设的本科生也适合需要交一份完整实验报告的课程项目。关键词里提到的词法分析、语法分析、小型编译器本质上是一条流水线上的三道工序任何一道工序的接口定义不清楚整条线就跑不通。下面按需求、设计、实现、验收的顺序把这份方案拆开讲清楚。2. 需求分析与技术选型从源语言子集到四阶段流水线2.1 源语言子集的定义边界做课程设计最怕的一件事是语言定义太贪心。有的同学一上来就想支持完整的 C 语言结果写到一半发现指针和结构体根本处理不了。通常的做法是定义一个足够小但完整的子集覆盖编译原理的核心知识点即可。这份方案定义的源语言子集包含以下要素基本数据类型只有 int 和 float 两种支持变量声明与赋值支持加减乘除四则运算和比较运算支持 if-else 条件分支和 while 循环支持函数定义与调用函数参数不超过三个不支持指针、数组、结构体和多文件编译。这个子集看起来简单但已经覆盖了词法分析中的关键字、标识符、常量、运算符、界符五类 token语法分析中的表达式文法、语句文法、程序结构文法以及语义分析中的类型检查和符号表管理。注意语言子集的边界一定要在需求阶段写死不要边做边加。每加一个特性词法、语法、语义、代码生成四个模块都要改工作量是乘法增长的。2.2 四阶段流水线的技术选型编译器的四个阶段各有不同的实现策略选型时需要考虑课程设计的时间成本和可展示性。词法分析器有两种做法手写状态机和使用词法分析器生成工具。手写状态机的优点是代码完全可控调试方便适合需要展示实现细节的课程设计。使用生成工具的优点是开发快但实验报告里不好体现个人工作。这份方案选择手写状态机用 C 实现核心是一个nextToken()函数每次从源文件读取字符流跳过空白和注释识别出下一个 token 并返回其类型和值。语法分析器同样有两种做法递归下降和 LR 分析表。递归下降的优点是代码结构清晰每个非终结符对应一个函数适合手写缺点是处理左递归文法时需要改写。LR 分析表的优点是能处理更复杂的文法但需要构造分析表代码量大。这份方案选择递归下降因为源语言子集的文法不复杂递归下降足够用而且实验报告里画调用关系图很直观。语义分析和代码生成合并为一个阶段在语法分析过程中同步完成。具体做法是在递归下降的每个函数里除了匹配文法规则还执行符号表插入、类型检查、生成中间代码或目标代码。这种边解析边生成的方式适合小型编译器不需要单独构建抽象语法树。2.3 开发环境与工具链开发语言选择 C因为编译器的字符处理和指针操作比较多C 的性能和灵活性都合适。编译器用 g版本不低于 C11。构建工具用 Makefile方便一键编译和清理。测试用例用文本文件存放每个文件是一段源程序预期输出放在同名.expected文件里用脚本自动比对。目录结构按模块划分src/存放源代码include/存放头文件tests/存放测试用例docs/存放实验报告和设计文档。这种结构在实验报告里展示出来能体现工程化思维。3. 总体架构设计四个模块的职责划分与接口约定3.1 模块划分与数据流整个编译器分为四个模块数据流是单向的源文件 → 词法分析器 → token 流 → 语法分析器 → 中间表示 → 代码生成器 → 目标代码。词法分析器的职责是把字符流转换成 token 流。每个 token 包含三个字段类型枚举值如TOKEN_INT、TOKEN_ID、TOKEN_PLUS、值字符串如变量名x、常量42、行号用于报错定位。token 流的终点是一个TOKEN_EOF表示源文件结束。语法分析器的职责是消费 token 流按照文法规则匹配同时执行语义动作。语法分析器不直接输出抽象语法树而是在匹配过程中调用代码生成器的接口生成三地址码形式的中间代码。代码生成器的职责是把中间代码转换成目标代码。这份方案的目标代码选择简单的栈式虚拟机指令每条指令包含操作码和操作数比如PUSH 42、ADD、STORE x。这种指令集容易实现也容易在实验报告里展示执行过程。符号表的职责是管理变量和函数的作用域与类型信息。符号表用哈希表实现键是变量名或函数名值是类型、作用域层级、内存偏移等。进入一个新的作用域时压入一层退出时弹出。3.2 关键接口定义模块之间的接口用头文件约定下面是核心接口的代码示例// token.h — 词法分析器输出的 token 结构 enum TokenType { TOKEN_INT, // int 关键字 TOKEN_FLOAT, // float 关键字 TOKEN_ID, // 标识符 TOKEN_NUM, // 数字常量 TOKEN_PLUS, // TOKEN_MINUS, // - TOKEN_STAR, // * TOKEN_SLASH, // / TOKEN_ASSIGN, // TOKEN_LPAREN, // ( TOKEN_RPAREN, // ) TOKEN_LBRACE, // { TOKEN_RBRACE, // } TOKEN_SEMI, // ; TOKEN_IF, // if TOKEN_ELSE, // else TOKEN_WHILE, // while TOKEN_EOF // 文件结束 }; struct Token { TokenType type; // token 类型 std::string value; // token 原始值 int line; // 所在行号用于报错 };这个接口定义的关键点是token 类型用枚举而不是字符串避免运行时比较字符串的开销token 值保留原始字符串方便语法分析器在需要时取用行号字段是必须的否则报错时无法定位。// lexer.h — 词法分析器接口 class Lexer { public: Lexer(const std::string source); // 构造函数接收源文件内容 Token nextToken(); // 返回下一个 token private: std::string src; // 源文件内容 size_t pos; // 当前读取位置 int line; // 当前行号 char peek(); // 查看当前字符但不移动位置 char advance(); // 读取当前字符并移动位置 void skipWhitespace(); // 跳过空白和注释 };词法分析器的接口只有一个nextToken()方法语法分析器每次调用它获取下一个 token。这种设计把词法分析器的内部状态完全封装起来语法分析器不需要知道字符流是怎么读取的。3.3 符号表的设计符号表是语义分析的核心数据结构设计好坏直接影响类型检查和作用域管理的复杂度。这份方案用栈式哈希表实现// symbol_table.h — 符号表结构 struct Symbol { std::string name; // 变量名或函数名 std::string type; // 类型int 或 float int scopeLevel; // 作用域层级 int offset; // 在栈帧中的偏移量 }; class SymbolTable { public: void enterScope(); // 进入新作用域 void exitScope(); // 退出当前作用域 bool insert(const Symbol sym); // 插入符号重名返回 false Symbol* lookup(const std::string name); // 查找符号 private: std::vectorstd::unordered_mapstd::string, Symbol scopes; };符号表用vector嵌套unordered_map实现外层 vector 的每个元素是一个作用域内层 map 存储该作用域内的符号。进入作用域时 push 一个新的 map退出时 pop。查找符号时从最内层作用域向外层逐层查找找到第一个匹配的就返回。这种设计天然支持变量遮蔽——内层作用域的同名变量会覆盖外层。4. 词法分析器实现状态机设计与 token 识别4.1 状态机的状态划分词法分析器的核心是一个有限状态机状态划分决定了代码的结构。这份方案把状态分为以下几类起始状态跳过空白字符和注释根据当前字符决定进入哪个识别状态。如果当前字符是字母或下划线进入标识符识别状态如果是数字进入数字识别状态如果是运算符或界符直接返回对应的 token如果是文件结束符返回TOKEN_EOF。标识符识别状态持续读取字母、数字和下划线直到遇到非标识符字符。然后把读到的字符串与关键字表比对如果是关键字则返回对应的关键字 token否则返回TOKEN_ID。数字识别状态持续读取数字如果遇到小数点则进入浮点数识别状态否则返回整数常量 token。浮点数识别状态继续读取数字遇到非数字字符时返回浮点数常量 token。注释处理源语言支持//单行注释和/* */多行注释。单行注释直接跳到行尾多行注释持续读取直到遇到*/如果文件结束还没遇到*/则报错。4.2 核心代码实现// lexer.cpp — nextToken 的核心实现 Token Lexer::nextToken() { skipWhitespace(); // 跳过空白和注释 if (pos src.size()) { return {TOKEN_EOF, , line}; // 文件结束 } char c peek(); // 标识符或关键字 if (isalpha(c) || c _) { std::string value; while (pos src.size() (isalnum(peek()) || peek() _)) { value advance(); } // 关键字表比对 if (value int) return {TOKEN_INT, value, line}; if (value float) return {TOKEN_FLOAT, value, line}; if (value if) return {TOKEN_IF, value, line}; if (value else) return {TOKEN_ELSE, value, line}; if (value while) return {TOKEN_WHILE, value, line}; return {TOKEN_ID, value, line}; } // 数字常量 if (isdigit(c)) { std::string value; bool isFloat false; while (pos src.size() (isdigit(peek()) || peek() .)) { if (peek() .) { if (isFloat) break; // 第二个小数点停止 isFloat true; } value advance(); } return {TOKEN_NUM, value, line}; } // 运算符和界符 switch (c) { case : advance(); return {TOKEN_PLUS, , line}; case -: advance(); return {TOKEN_MINUS, -, line}; case *: advance(); return {TOKEN_STAR, *, line}; case /: advance(); return {TOKEN_SLASH, /, line}; case : advance(); return {TOKEN_ASSIGN, , line}; case (: advance(); return {TOKEN_LPAREN, (, line}; case ): advance(); return {TOKEN_RPAREN, ), line}; case {: advance(); return {TOKEN_LBRACE, {, line}; case }: advance(); return {TOKEN_RBRACE, }, line}; case ;: advance(); return {TOKEN_SEMI, ;, line}; default: throw std::runtime_error(非法字符: std::string(1, c) 在第 std::to_string(line) 行); } }这段代码的关键设计点skipWhitespace()在每次nextToken()开头调用保证空白和注释不会影响 token 识别标识符识别时先收集完整字符串再与关键字表比对避免边读边判断的复杂性数字识别时用isFloat标志防止多个小数点非法字符直接抛异常并附带行号方便定位错误。4.3 关键字表与 token 映射关键字表用静态 map 实现在词法分析器初始化时构建// 关键字表字符串到 token 类型的映射 static const std::unordered_mapstd::string, TokenType keywords { {int, TOKEN_INT}, {float, TOKEN_FLOAT}, {if, TOKEN_IF}, {else, TOKEN_ELSE}, {while, TOKEN_WHILE} };用 map 而不是 if-else 链的好处是扩展方便增加关键字只需在 map 里加一行。查找时间复杂度是 O(1)对于课程设计的规模完全够用。提示词法分析器写完后先单独测试。写一个main函数循环调用nextToken()直到TOKEN_EOF把每个 token 的类型和值打印出来。用一段包含所有 token 类型的源程序测试确认没有遗漏。5. 语法分析与代码生成递归下降与三地址码输出5.1 文法规则与递归下降函数映射源语言的文法用 EBNF 描述每个非终结符对应一个递归下降函数。核心文法规则如下程序 → 语句列表 语句 → 声明语句 | 赋值语句 | if 语句 | while 语句 | 复合语句 声明语句 → 类型 标识符 ; 赋值语句 → 标识符 表达式 ; if 语句 → if ( 表达式 ) 语句 [ else 语句 ] while 语句 → while ( 表达式 ) 语句 复合语句 → { 语句列表 } 表达式 → 比较表达式 比较表达式 → 加法表达式 [ ( | | ) 加法表达式 ] 加法表达式 → 乘法表达式 { ( | - ) 乘法表达式 } 乘法表达式 → 基本表达式 { ( * | / ) 基本表达式 } 基本表达式 → 标识符 | 数字 | ( 表达式 )每个非终结符对应一个函数函数名与文法规则对应。比如parseExpression()对应表达式规则parseStatement()对应语句规则。函数内部按照文法规则匹配 token匹配成功则执行语义动作匹配失败则报错。5.2 递归下降的核心实现// parser.cpp — 表达式解析与代码生成 // 解析加法表达式生成三地址码 void Parser::parseAddExpr() { parseMulExpr(); // 先解析乘法表达式 while (current.type TOKEN_PLUS || current.type TOKEN_MINUS) { TokenType op current.type; advance(); // 消费运算符 parseMulExpr(); // 解析右操作数 // 生成三地址码t left op right std::string temp newTemp(); // 申请临时变量 if (op TOKEN_PLUS) { emit(temp lastTemp lastTemp2); } else { emit(temp lastTemp - lastTemp2); } lastTemp temp; // 更新最近临时变量 } } // 解析乘法表达式生成三地址码 void Parser::parseMulExpr() { parsePrimary(); // 先解析基本表达式 while (current.type TOKEN_STAR || current.type TOKEN_SLASH) { TokenType op current.type; advance(); parsePrimary(); std::string temp newTemp(); if (op TOKEN_STAR) { emit(temp lastTemp * lastTemp2); } else { emit(temp lastTemp / lastTemp2); } lastTemp temp; } }这段代码展示了递归下降的核心模式每个函数先调用优先级更高的函数然后在循环中处理当前优先级的运算符。emit()函数把生成的三地址码追加到输出缓冲区newTemp()函数生成唯一的临时变量名如t1、t2。三地址码的格式是结果 操作数1 运算符 操作数2每条指令最多一个运算符。这种形式便于后续转换成目标代码也便于在实验报告里展示中间代码的生成过程。5.3 符号表与类型检查的集成在解析声明语句时需要把变量插入符号表在解析赋值语句时需要检查变量是否已声明、类型是否匹配。下面是声明语句的解析代码// parser.cpp — 声明语句解析与符号表插入 void Parser::parseDeclaration() { std::string type current.value; // int 或 float advance(); // 消费类型关键字 if (current.type ! TOKEN_ID) { error(声明语句缺少标识符); } std::string name current.value; advance(); // 消费标识符 if (current.type ! TOKEN_SEMI) { error(声明语句缺少分号); } advance(); // 消费分号 // 插入符号表 Symbol sym; sym.name name; sym.type type; sym.scopeLevel symbolTable.currentLevel(); sym.offset symbolTable.nextOffset(type); // 分配栈偏移 if (!symbolTable.insert(sym)) { error(变量 name 重复声明); } // 生成目标代码为变量分配栈空间 emit(DECL name type); }类型检查在赋值语句中执行解析赋值语句时先查找左侧变量的类型再解析右侧表达式并获取其类型如果两者不匹配则报错。对于 int 和 float 的混合运算通常的做法是自动提升 int 到 float在生成三地址码时插入类型转换指令。5.4 目标代码生成与虚拟机执行三地址码生成后需要转换成栈式虚拟机的指令。转换规则很简单每个三地址码指令t a op b转换成PUSH a、PUSH b、OP、STORE t四条指令。下面是转换函数的代码// codegen.cpp — 三地址码转虚拟机指令 void CodeGen::generate(const std::vectorstd::string threeAddrCode) { for (const auto line : threeAddrCode) { // 解析三地址码result left op right std::istringstream iss(line); std::string result, eq, left, op, right; iss result eq left op right; // 生成虚拟机指令 if (op || op - || op * || op /) { emitVM(PUSH left); // 压入左操作数 emitVM(PUSH right); // 压入右操作数 if (op ) emitVM(ADD); if (op -) emitVM(SUB); if (op *) emitVM(MUL); if (op /) emitVM(DIV); emitVM(STORE result); // 存储结果 } else { // 简单赋值result left emitVM(PUSH left); emitVM(STORE result); } } }虚拟机执行器用一个栈来模拟运算PUSH把值压栈ADD弹出两个值相加后压回STORE弹出值存入变量表。执行完所有指令后变量表里就是最终结果。这种设计在实验报告里很容易展示——打印指令序列和每步执行后的栈状态即可。6. 避坑与常见问题课程设计里最容易翻车的五个点6.1 词法分析器把换行符当空白丢弃现象语法分析器报错「意外的文件结束」但源文件明明没有写完。原因词法分析器的skipWhitespace()把换行符也当作空白跳过了导致语法分析器无法判断语句边界。如果文法依赖换行来分隔语句就会出错。解决在 token 结构里增加行号字段语法分析器用行号变化来判断语句是否跨行。或者干脆在文法里用分号强制语句结束不依赖换行。这份方案采用后者所有语句必须以分号结尾。6.2 递归下降遇到左递归导致栈溢出现象解析表达式时程序崩溃报「栈溢出」或「段错误」。原因文法中存在左递归比如表达式 → 表达式 项递归下降函数直接调用自身形成无限递归。解决改写文法消除左递归。把表达式 → 表达式 项 | 项改写成表达式 → 项 { 项 }用循环代替递归。这份方案的加法表达式和乘法表达式都采用了这种改写。6.3 符号表作用域管理错误现象内层作用域声明的变量在外层也能访问或者退出作用域后变量没有正确销毁。原因符号表的enterScope()和exitScope()没有成对调用或者查找时没有从内层向外层逐层查找。解决在解析复合语句{ ... }时进入时调用enterScope()退出时调用exitScope()确保成对。查找符号时从scopes的末尾向前遍历找到第一个匹配就返回。6.4 类型检查遗漏导致运行时错误现象int 变量被赋了 float 值编译时没报错运行时结果不对。原因赋值语句的解析函数只检查了变量是否存在没有检查右侧表达式的类型是否与左侧匹配。解决在赋值语句的解析函数里先查找左侧变量的类型再解析右侧表达式并获取其类型两者不一致时报错。对于 int 和 float 的混合运算在生成三地址码时插入类型转换指令。6.5 错误恢复缺失导致一个错误报出几十条现象源程序有一个拼写错误编译器输出了几十条错误信息根本看不过来。原因语法分析器遇到错误后直接抛异常终止没有跳过错误 token 继续解析。解决在递归下降的每个函数里遇到错误时不要直接抛异常而是记录错误信息然后跳过当前 token 直到遇到分号或右花括号再继续解析。这样一次编译能报出所有错误而不是遇到第一个就停。7. 验收与答辩怎么证明你的编译器真的能跑7.1 测试用例的设计与覆盖验收的第一步是准备测试用例。测试用例要覆盖所有语言特性每个特性至少一个正例和一个反例。正例验证编译器能正确编译反例验证编译器能正确报错。测试用例按功能分组词法测试关键字识别、标识符识别、常量识别、运算符识别、注释处理、语法测试声明语句、赋值语句、if 语句、while 语句、复合语句、表达式优先级、语义测试重复声明、未声明变量、类型不匹配、代码生成测试算术运算、条件分支、循环。每个测试用例是一个.src文件预期输出是一个.expected文件。用脚本自动运行所有测试用例比对实际输出和预期输出。脚本用 bash 写#!/bin/bash # run_tests.sh — 自动运行所有测试用例 PASS0 FAIL0 for src in tests/*.src; do expected${src%.src}.expected actual$(./compiler $src 21) if [ $actual $(cat $expected) ]; then echo PASS: $src PASS$((PASS 1)) else echo FAIL: $src echo 预期: $(cat $expected) echo 实际: $actual FAIL$((FAIL 1)) fi done echo 通过: $PASS, 失败: $FAIL这个脚本遍历tests/目录下所有.src文件运行编译器并比对输出。答辩时直接跑这个脚本通过率一目了然。7.2 实验报告的结构与重点实验报告通常包含以下部分需求分析、总体设计、详细设计、测试与结果、总结与体会。重点放在详细设计和测试结果上。详细设计部分要展示每个模块的核心数据结构和算法。词法分析器展示状态机图和nextToken()的流程图语法分析器展示文法规则和递归下降函数的调用关系代码生成器展示三地址码到虚拟机指令的转换规则。用表格列出每个模块的输入、输出和关键函数。测试结果部分要展示测试用例的覆盖情况和通过率。用表格列出每个测试用例的名称、输入、预期输出、实际输出、是否通过。对于失败的用例分析原因并给出修复方案。7.3 答辩演示的技巧答辩演示时不要只展示代码要展示编译器的运行过程。准备一段有代表性的源程序包含变量声明、赋值、if 分支、while 循环和函数调用。运行编译器展示词法分析输出的 token 流、语法分析生成的三地址码、代码生成输出的虚拟机指令以及虚拟机执行后的结果。如果时间允许现场修改源程序演示编译器如何报错。比如把int x 5;改成int x hello;展示类型检查的错误信息。这种现场演示比静态截图更有说服力。提示答辩前把演示用的源程序和命令写在一个脚本里避免现场手忙脚乱。脚本里加上注释说明每一步在演示什么。7.4 验收标准清单验收项合格标准检查方法词法分析能识别所有五类 token注释处理正确运行词法测试用例通过率 100%语法分析能正确解析所有语句类型表达式优先级正确运行语法测试用例通过率 100%语义分析能检测重复声明、未声明变量、类型不匹配运行语义测试用例通过率 100%代码生成能生成正确的三地址码和虚拟机指令运行代码生成测试用例通过率 100%错误处理能定位错误行号一次编译报出所有错误运行反例测试用例检查错误信息实验报告包含需求、设计、实现、测试、总结五部分检查报告目录和内容完整性这份验收清单可以直接用在答辩 PPT 里每一项都有明确的检查方法导师一看就知道你的工作做到什么程度。从那以后我每次带课程设计都强制要求学生先写测试用例再写代码测试用例就是验收标准代码写完跑一遍通过率不到 100% 就不算完成。这个习惯看起来笨但能省掉答辩前通宵改 bug 的痛苦。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑