资讯详情

资讯详情

编译原理课设全流程指南:从词法分析到目标代码生成

简介这份资源是北京邮电大学编译原理课程设计的完整实践工程面向计算机专业学生及希望动手理解编译器构造的学习者围绕将Pascal语言逐步翻译为可执行目标代码这一主线覆盖词法分析、语法分析、语义分析、代码生成与符号表管理、错误处理等核心环节。压缩包共83个文件约123KB以19个cpp与21个h源文件为主体配合8个pas示例程序、8个asm汇编文件及bin、rez等资源文件另有工程配置与界面相关文件构成一套可编译运行的课程设计代码框架。已有1196人学习下载说明其在编译原理实践学习中具有一定参考价值。读者可从中获得从正则表达式定义词法规则、上下文无关文法构造抽象语法树到类型检查、三地址码生成与目标代码优化的完整实现思路并借助Pascal测试用例与汇编输出对照验证各阶段结果适合作为课程设计参考或编译器入门练手项目。1. 从一份“能跑但不敢交”的课设说起每年到编译原理课程设计收尾那两周总有人拿着一个能跑通加减乘除的 Demo 来找我看问能不能交。问题不在代码能不能跑而在于它经不起追问词法分析器遇到非法字符怎么处理、语法树建出来长什么样、中间代码为什么这么生成、目标代码跑出来的结果和解释执行对不对得上。编译原理课设的难点从来不是写一个能算12的程序而是把“源程序 → 词法 → 语法 → 语义 → 中间代码 → 目标代码”这条链路完整地立起来每一段都有可验证的输入输出。这份方案面向的正是要交一份完整编译原理课程设计的人给定一个精简的语言子集从词法分析一路做到目标代码生成与运行配套错误处理、符号表、测试用例和验收口径。下面按需求、总体设计、各阶段实现、避坑、验收的顺序展开结构可以直接照着搭参数和模块划分可以按自己课题的要求裁剪。2. 需求与语言子集定义先把“编译什么”钉死动手写第一行词法分析代码之前最该做的是把被编译的语言子集写成一页纸的规格。血泪经验是语言子集定得越模糊后面语法分析和语义检查就越容易反复返工。我一般会先确定四件事——支持哪些数据类型、有哪些语句、表达式优先级怎么排、错误怎么报。这四件事定完词法、语法、语义三个阶段的分工边界自然就清楚了。2.1 语言子集的四张清单一份合格的课设语言子集通常控制在“能体现编译全流程、又不至于写不完”的规模。下面这套清单是多数课程设计里比较稳妥的取舍可以直接改类别支持内容说明数据类型int、bool只保留整型和布尔避免浮点带来额外的目标代码复杂度声明语句变量声明、常量声明声明必须带初始化简化语义检查表达式算术 - * /、关系 !、逻辑 || !需要体现优先级与结合性控制语句if-else、while足以覆盖回填与跳转指令输入输出read、write作为与运行时交互的出口注释单行注释词法阶段直接跳过清单之外的东西一律明确“不支持”并在文档里写清楚。比如不支持数组、不支持函数调用、不支持字符串这些不是偷懒而是把有限的时间投到编译流程本身的完整性上。导师看课设看的是你对每个阶段的理解决策不是一个功能堆砌的玩具语言。2.2 词法单元与文法的最初约定语言子集定完紧接着要落两份东西词法单元Token表和文法产生式。Token 表决定词法分析器的输出格式文法决定语法分析器的输入。两者必须对齐否则后面调试时会出现“词法认了、语法不认”的玄学问题。我习惯把 Token 定义成一个枚举加一个结构体枚举列类型结构体存类型、原始文本、行号和列号——行号列号是后面报错定位的后悔药一开始不加后面补起来非常痛苦。// token.h —— 词法单元定义类型枚举 携带位置信息的结构体 typedef enum { TOK_INT, TOK_BOOL, TOK_IF, TOK_ELSE, TOK_WHILE, TOK_READ, TOK_WRITE, TOK_ID, TOK_NUM, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LT, TOK_LE, TOK_GT, TOK_GE, TOK_EQ, TOK_NE, TOK_AND, TOK_OR, TOK_NOT, TOK_ASSIGN, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_SEMI, TOK_EOF, TOK_ERROR } TokenType; typedef struct { TokenType type; // 词法单元类型 char text[64]; // 原始文本便于报错时回显 int line; // 行号报错定位用 int col; // 列号 } Token;这段定义的关键在于line和col两个字段。很多同学第一版只存类型和文本等到语法分析报“意外的 Token”时根本不知道是源文件哪一行出的问题只能靠肉眼数行。加上位置信息后错误信息可以直接输出成第 12 行第 5 列期望 ;实际得到 }调试效率完全不是一个量级。text字段同理报错时把原始文本带出来比只报枚举名直观得多。2.3 技术选型为什么用 C/C 手写而不是生成器课设常见的选择有两类用 Lex/Yacc 或 ANTLR 这类工具生成或者纯手写递归下降。我的建议是手写理由有三。第一课设的评分点往往在“你是否理解每个阶段在做什么”工具生成会把状态机藏进黑匣子答辩时被问到“这个状态转移为什么这么设计”就答不上来。第二手写递归下降的代码结构和文法产生式几乎一一对应可读性高改文法时改函数就行。第三手写能自然地把错误恢复、符号表管理这些“工具不替你决定”的部分做进去。工具不是不能用但作为课程设计手写一遍的收获和可解释性都更强。语言上 C 或 C 都行C 的std::string和std::vector能省不少内存管理的琐事但核心数据结构仍然建议用结构体和显式指针保持对内存布局的掌控。3. 总体架构设计五阶段流水线与三张核心表编译器的骨架是一条单向流水线源程序进来经过词法、语法、语义、中间代码、目标代码五个阶段最后产出可执行结果。每个阶段的输出是下一阶段的输入阶段之间用明确的数据结构衔接。这条流水线之外还有三张贯穿全程的表——符号表、常量表、临时变量表它们不是某个阶段的私有产物而是多个阶段共享的状态。把流水线和共享表的关系画清楚整个项目的模块划分就定了。3.1 五阶段的输入输出契约每个阶段都必须有清晰的输入输出契约这是让项目可测试、可分工的前提。下面这张表是我一般会写进设计文档的契约表阶段输入输出核心数据结构词法分析源文件字符流Token 序列Token 数组/链表语法分析Token 序列抽象语法树 ASTAST 节点树语义分析AST带类型标注的 AST 符号表符号表、类型表中间代码生成带标注 AST三地址码序列四元式数组目标代码生成三地址码汇编/伪指令序列指令数组契约一旦定下每个阶段都可以单独写单元测试给词法分析器喂一段源码断言 Token 序列给语法分析器喂 Token 序列断言 AST 结构给中间代码生成器喂 AST断言四元式。这种“逐段可测”的组织方式比最后整体跑通再调试要省太多时间。很多课设翻车就翻在把所有逻辑揉进一个main里出错时根本定位不到是哪一段的问题。3.2 符号表与作用域管理符号表是语义分析的核心也是后面目标代码生成分配存储的依据。课设语言子集如果没有函数嵌套作用域通常只有全局一层但即便如此符号表也要设计成“可扩展成多层”的结构因为答辩时很可能被问“如果要支持块作用域怎么改”。我一般用“作用域栈 哈希表”的组合每个作用域一张哈希表进入块压栈离开块弹栈查找时从栈顶往下找。// symtab.h —— 作用域栈式符号表支持多层嵌套查找 struct Symbol { std::string name; // 标识符名 std::string type; // int 或 bool int scopeLevel; // 所在作用域层级 int offset; // 相对栈帧的偏移目标代码生成时用 bool isConst; // 是否常量 }; class SymbolTable { std::vectorstd::unordered_mapstd::string, Symbol scopes; public: void enterScope() { scopes.emplace_back(); } void exitScope() { scopes.pop_back(); } bool insert(const Symbol s) { auto top scopes.back(); if (top.count(s.name)) return false; // 同作用域重定义 top[s.name] s; return true; } Symbol* lookup(const std::string name) { // 从最内层作用域向外查找实现名字遮蔽 for (int i scopes.size() - 1; i 0; --i) { auto it scopes[i].find(name); if (it ! scopes[i].end()) return it-second; } return nullptr; } };offset字段是给目标代码生成阶段预留的。语义分析时确定每个变量在栈帧里的位置目标代码生成时直接查表拿偏移不用再算一遍。insert返回布尔值用于检测同作用域重复定义这是语义检查里最基础也最容易被忽略的一条——重复定义不报错后面生成代码时两个同名变量会互相覆盖症状是“程序结果莫名其妙不对”排查起来非常费劲。lookup从内层向外层找天然支持名字遮蔽虽然当前语言子集可能用不上但结构留好了扩展成本几乎为零。3.3 AST 节点设计与遍历框架抽象语法树是连接语法分析和后续阶段的枢纽。节点设计的原则是“一种语法结构对应一种节点类型”不要为了省事把所有节点塞进一个大结构体。我一般用基类加派生类或者用带kind字段的联合结构。遍历框架用访问者模式或者简单的递归函数都行关键是让“对 AST 做一件事”变成一个可复用的遍历而不是每个阶段各写一套递归。// ast.h —— AST 节点基类与表达式节点示例 enum class NodeKind { Num, Var, BinOp, Assign, If, While, Read, Write, Block }; struct ASTNode { NodeKind kind; int line; virtual ~ASTNode() default; }; struct BinOpNode : ASTNode { std::string op; // , -, *, /, , 等 ASTNode* left; ASTNode* right; BinOpNode(std::string o, ASTNode* l, ASTNode* r) : op(std::move(o)), left(l), right(r) { kind NodeKind::BinOp; } };BinOpNode把运算符存成字符串而不是枚举看起来不够“类型安全”但在课设规模下反而方便打印 AST、生成三地址码时直接拿op拼字符串不用维护一张枚举到符号的映射表。line字段从 Token 一路带上来语义错误比如类型不匹配、未声明变量报错时能精确到行。AST 的析构要记得递归释放子节点否则跑大一点的测试用例会内存泄漏——虽然课设不查内存但养成习惯没坏处。4. 各阶段实现从字符流到可执行结果架构定完进入逐阶段实现。这一章按流水线顺序把五个阶段的关键实现讲透每个阶段给出核心代码和设计取舍。需要强调的是阶段之间一定要能单独跑通、单独验证不要等全部写完再联调。我的习惯是每写完一个阶段立刻用几组手工构造的输入输出做断言确认契约没破再往下走。4.1 词法分析状态机与最长匹配词法分析器的本质是一个确定有限状态机核心是“最长匹配”原则读到不能只认要往前多看一个字符。手写实现通常用一个主循环加switch遇到标识符和数字时进入内层循环继续吃字符。关键字识别放在标识符识别之后用一个关键字表查一下即可。// lexer.c —— 主扫描循环片段体现最长匹配与关键字回查 Token next_token(Lexer* lx) { skip_whitespace_and_comments(lx); Token t {0}; t.line lx-line; t.col lx-col; char c peek(lx); if (isalpha(c) || c _) { int n 0; while (isalnum(peek(lx)) || peek(lx) _) t.text[n] advance(lx); t.text[n] \0; t.type lookup_keyword(t.text); // 先按标识符收再查关键字表 return t; } if (isdigit(c)) { int n 0; while (isdigit(peek(lx))) t.text[n] advance(lx); t.text[n] \0; t.type TOK_NUM; return t; } // 双字符运算符优先匹配体现最长匹配 if (c ) { advance(lx); if (peek(lx) ) { advance(lx); t.type TOK_LE; strcpy(t.text, ); } else { t.type TOK_LT; strcpy(t.text, ); } return t; } // ... 其余单字符与错误处理 }lookup_keyword用一张静态字符串表做线性查找即可关键字就十来个没必要上哈希。双字符运算符的处理是词法阶段最容易翻车的地方和、和、!和!、和每一对都要先看下一个字符再决定。如果偷懒先匹配单字符语法分析阶段就会收到一堆本不该出现的 Token报错信息还指向语法层实际根因在词法层这种跨层误诊是调试时最耗时的坑之一。非法字符统一返回TOK_ERROR并记录位置由上层决定是终止还是跳过继续。4.2 语法分析递归下降与错误恢复递归下降的核心是“一个非终结符对应一个函数”函数内部按产生式右部的顺序依次匹配 Token。表达式部分要处理优先级通常用多层函数实现parse_expr调parse_termparse_term调parse_factor每层负责一级优先级。这样写出来的代码和文法产生式几乎一一对应改文法时改对应函数即可。// parser.cpp —— 表达式解析多层函数体现优先级 ASTNode* Parser::parseExpr() { ASTNode* left parseTerm(); while (check(TOK_PLUS) || check(TOK_MINUS)) { std::string op advance().text; ASTNode* right parseTerm(); left new BinOpNode(op, left, right); } return left; } ASTNode* Parser::parseTerm() { ASTNode* left parseFactor(); while (check(TOK_STAR) || check(TOK_SLASH)) { std::string op advance().text; ASTNode* right parseFactor(); left new BinOpNode(op, left, right); } return left; }parseExpr处理加减左结合parseTerm处理乘除优先级更高parseFactor处理括号、数字、变量。左结合通过while循环实现a - b - c会先建(a-b)再建((a-b)-c)符合语义。错误恢复是语法分析里最容易被跳过、又最影响体验的部分。最简单的策略是“恐慌模式”遇到不认识的 Token 就报错然后一直跳到下一个分号或右花括号再继续这样一次编译能报出多个错误而不是遇到第一个就停。课设不要求工业级恢复但至少要做到“报错带行号、报错后不崩溃”这两条做到了答辩演示时就不会因为一个手误输入而全场卡住。4.3 语义分析类型检查与符号表填充语义分析在 AST 上做两件事填符号表、做类型检查。遍历到声明节点时插入符号遍历到变量引用时查符号表确认已声明遍历到二元运算时检查左右操作数类型是否兼容。类型系统用字符串int/bool表示足够检查规则写成一张小表算术运算要求两边都是 int逻辑运算要求两边都是 bool关系运算两边同类型、结果 bool。// sema.cpp —— 二元运算类型检查片段 std::string Sema::checkBinOp(BinOpNode* node) { std::string lt visit(node-left); std::string rt visit(node-right); const std::string op node-op; if (op || op - || op * || op /) { if (lt ! int || rt ! int) error(node-line, 算术运算要求 int 操作数实际为 lt 和 rt); return int; } if (op || op ||) { if (lt ! bool || rt ! bool) error(node-line, 逻辑运算要求 bool 操作数); return bool; } if (op || op || op || op || op || op !) { if (lt ! rt) error(node-line, 关系运算两侧类型不一致 lt vs rt); return bool; } error(node-line, 未知运算符 op); return error; }visit是递归遍历返回子表达式的类型。错误信息里带上实际类型比只报“类型错误”有用得多——调试时一眼能看出是哪个操作数出了问题。语义分析还要处理“变量未声明就使用”和“重复声明”前者在visit变量节点时查表为空就报错后者在插入符号表时insert返回 false 就报错。这两条是语义检查的底线漏掉任何一条后面生成代码时都会出现难以定位的运行时错误。4.4 中间代码生成三地址码与回填中间代码用三地址码四元式表示每条指令形如(op, arg1, arg2, result)。表达式生成是自底向上的先递归生成左右子表达式的代码再用一个临时变量存结果。控制语句是难点if和while需要跳转指令而跳转目标在生成时还不知道要用“回填”技术先发射占位指令记下待填的位置等目标确定后再回填。// irgen.cpp —— if 语句的三地址码生成与回填 void IRGen::genIf(IfNode* node) { std::string cond genExpr(node-cond); // 生成条件表达式代码 int jumpFalse emit(jump_if_false, cond, _, _); // 占位待回填 genStmt(node-thenBranch); // then 分支代码 if (node-elseBranch) { int jumpEnd emit(jump, _, _, _); // then 结束后跳过 else backpatch(jumpFalse, nextQuad()); // 回填 false 跳到这里 genStmt(node-elseBranch); backpatch(jumpEnd, nextQuad()); // 回填 else 结束跳转 } else { backpatch(jumpFalse, nextQuad()); // 无 else直接回填到 if 之后 } }emit返回新指令的下标backpatch把占位指令的目标字段改成指定下标nextQuad返回下一条待发射指令的下标。回填的关键是“先占位、后填坑”顺序不能乱jump_if_false必须在 then 分支代码之前发射否则条件为假时会错误地执行 then 分支。while的结构类似只是多一个“循环开始”的标号条件为假时跳到循环之后循环体末尾无条件跳回开始。临时变量的分配用一个计数器t1, t2, ...递增即可课设规模不用担心寄存器压力。4.5 目标代码生成与运行验证目标代码生成把三地址码翻译成目标机器的指令序列。课设常见两种做法生成真实汇编如 x86 或 MIPS再汇编链接或者生成一种简单的栈式虚拟机指令由自己写的解释器执行。后者更可控调试也方便我一般推荐后者——写一个几十行的虚拟机指令集就push、pop、add、jmp、jz这些把三地址码映射过去跑出来的结果直接和预期对比。# vm.py —— 极简栈式虚拟机执行目标代码并输出结果 def run(code, memory): stack, pc [], 0 while pc len(code): op, a, b, r code[pc] if op load: stack.append(memory[a]) elif op store: memory[r] stack.pop() elif op add: stack.append(stack.pop() stack.pop()) elif op sub: y, x stack.pop(), stack.pop(); stack.append(x - y) elif op jmp: pc r; continue elif op jz: if stack.pop() 0: pc r; continue elif op out: print(memory[a]) pc 1这段虚拟机代码故意写得极简目的是让“目标代码执行”这一步可观测、可断点。sub要特别注意操作数顺序栈是后进先出先弹出的是右操作数减法必须用x - y而不是y - x这个顺序搞反是目标代码阶段最经典的翻车点症状是“加法都对、减法全错”。jz弹出条件值判断是否为零对应三地址码里的jump_if_false。把三地址码到虚拟机指令的映射写成一张表生成阶段照着表翻译出错时逐条对照比直接生成汇编再猜哪里错了要高效得多。5. 避坑与排查课设里最容易翻车的五个点编译原理课设的 bug 有个特点症状出现在后面的阶段根因往往在前面。下面五条是我带项目时见得最多、也最耗时间的坑每条按“现象 → 原因 → 解决”写清楚遇到类似症状可以直接对照排查。5.1 症状是语法报错根因在词法现象语法分析报“意外的 Token”但源程序看起来完全合法。原因词法阶段的最长匹配没做对比如被拆成和或者关键字没识别出来被当成普通标识符。解决单独给词法分析器写一组测试把每个双字符运算符、每个关键字、每个边界情况标识符后紧跟运算符、数字后紧跟字母都过一遍断言 Token 序列。词法没测透就往下写后面所有阶段的调试都会被这个根因污染。5.2 符号表查不到其实是作用域没配对现象变量明明声明了语义分析却报“未声明”。原因enterScope和exitScope没有成对调用或者声明插入到了错误的作用域层级。解决在enterScope和exitScope里加层级计数调试时打印当前作用域栈深度和每层的符号遍历 AST 时确保进入块节点压栈、离开块节点弹栈异常路径也要保证弹栈。作用域不配对是典型的“代码看着对、跑起来不对”靠打印栈状态最快定位。5.3 回填顺序错控制流跑飞现象if条件为假时仍然执行了 then 分支或者while死循环。原因占位指令的发射顺序和回填目标算错常见于jump_if_false发射在 then 分支之后或者backpatch填了下一条指令而不是目标指令的下标。解决把每个控制语句生成的三地址码打印出来手工模拟一遍执行流程对照nextQuad的值逐条核对。回填这类逻辑纸上推一遍比盯着代码看快得多。5.4 临时变量命名冲突现象复杂表达式的结果莫名其妙不对简单表达式正常。原因临时变量计数器在递归生成时被重置或者多个表达式复用了同一个临时变量名。解决临时变量计数器作为生成器的成员变量全局递增绝不在递归函数内部重新初始化生成完一段代码后打印临时变量分配表确认没有重名。这个坑在嵌套表达式里特别隐蔽因为单层表达式测不出来。5.5 目标代码操作数顺序反了现象加法和乘法结果正确减法和除法结果错误或异常。原因栈式虚拟机里操作数弹出顺序与运算顺序不一致非交换运算必须用“先弹出的作右操作数”。解决给每条非交换指令写一个最小测试用例如10 - 3期望 7在虚拟机层面单独验证不要等到整个程序跑通才发现。这类错误一旦混进大测试用例定位成本会成倍上升。6. 验收与答辩用一张对照表把整条链路讲清楚课设的验收和答辩本质是让评审在有限时间里相信“这条编译链路你是真跑通了、真理解了”。我的做法是准备一张“阶段—输入—输出—验证方式”的对照表答辩时按表走一遍每个阶段现场演示一组输入输出比口头描述有说服力得多。下面这张表可以直接改成自己的版本阶段演示输入期望输出现场验证方式词法含、、注释的源码片段正确切分的 Token 序列打印 Token 列表指出最长匹配语法嵌套 if-else 的源码结构正确的 AST树形打印 AST对照源码语义含类型错误的源码带行号的类型错误信息故意写错一处展示报错定位中间代码while 循环源码含回填跳转的三地址码打印四元式手工走一遍跳转目标代码完整测试程序与预期一致的运行结果跑 3 组用例含边界值答辩时最容易被追问的三个问题提前准备好答案第一“你的错误处理做到什么程度”——答“词法报非法字符带行列号、语法恐慌模式恢复、语义报类型和未声明错误一次编译能报多个错”第二“符号表怎么支持作用域”——答“作用域栈加哈希表进块压栈出块弹栈查找从内向外”第三“回填为什么这么设计”——答“跳转目标生成时未知先占位记下标目标确定后回填保证控制流正确”。这三个问题答得利落基本就说明你不是照着模板抄的。还有一个实操技巧答辩前把测试用例分成“正常路径”和“错误路径”两组正常路径证明功能完整错误路径证明健壮性。很多人只准备正常用例被要求“演示一下报错”时现场手忙脚乱改源码反而暴露了错误处理没做好。错误路径的用例要提前写好、跑通、记住预期输出现场直接调用。从那以后我每次带课设都强制要求先交一页语言子集规格和一张阶段契约表规格没定清楚不许写代码契约表没填完不许联调。这两样东西看着是文档工作实际上是把后面百分之八十的返工提前挡掉了。希望这套结构能帮你把课设从“能跑”做到“敢交、经得起问”。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →