简介:这份面向高校编译原理课程设计与实验环节的完整资料包,围绕词法分析、语法分析与小型编译器实现展开,适合正在完成课程设计或希望打通编译前端流程的学生对照学习。包内共14个文件,以cpp、c与h源码为主体,分别对应词法分析器、LL(1)语法分析、LR(0)与SLR(1)分析以及四元式生成、汇编代码生成等模块,另附txt说明与doc格式课程设计报告,压缩包约557KB,体量轻便、结构清晰。已有2350人学习下载。读者可从中获得可运行的简单语句(如i+i*i)分析示例、LL(1)与SLR(1)文法的具体组织方式,以及从词法分析到目标代码生成的完整链路参考;报告部分还可作为实验记录与课程设计文档的写作范本,便于对照排查分析表构造、文法处理等常见问题。
1. 从一次答辩卡壳说起
编译原理课程设计最尴尬的一幕,不是代码跑不出结果,而是程序能跑、实验报告也写了几十页,被追问“你这个表达式文法为什么可以直接递归下降”“消除左递归之后a-b-c为什么还算对”时答不上来。多数小型编译器作业卡在同一个地方:词法分析、语法分析、语义处理、代码生成被写成四段互不相干的代码,报告里的文法和程序里的实现对不上,一改文法就得推倒重来。
这套方案按一条完整的四段流水线组织:词法分析产出 Token 流,语法分析构建抽象语法树,语义阶段填符号表并做类型检查,最后生成四元式并解释执行,同时给出一份能和代码逐节对应的实验报告骨架。它面向的是要在两三周内交出一份可运行、可演示、经得起追问的编译原理课程设计的人,也适合想把编译器前端流程真正走一遍的开发者。
2. 需求拆解与总体架构:四段流水线与一套能落地的文法
2.1 先划源语言子集,再谈架构
课程设计最容易犯的错是先写代码后定语言,写到一半发现“要不要支持数组”“函数能不能递归”没有结论,语法分析器改到崩溃。我的习惯是第一天只干一件事:把源语言子集写成一页 EBNF,当作整个项目的合同,后面所有模块都以它为准。
下面这套 MiniC 子集支撑得起一次完整的课设演示,又不至于把工期拖长:
program → declList declList → (varDecl | statement)* varDecl → type ident ("=" expr)? ("," ident ("=" expr)?)* ";" type → "int" | "float" | "bool" statement → assignStmt | ifStmt | whileStmt | block | printStmt block → "{" statement* "}" ifStmt → "if" "(" expr ")" statement ("else" statement)? whileStmt → "while" "(" expr ")" statement assignStmt → ident "=" expr ";" printStmt → "print" "(" expr ")" ";" expr → logicOr logicOr → logicAnd ("||" logicAnd)* logicAnd → equality ("&&" equality)* equality → relational (("==" | "!=") relational)* relational → additive (("<" | "<=" | ">" | ">=") additive)* additive → term (("+" | "-") term)* term → unary (("*" | "/" | "%") unary)* unary → ("!" | "-") unary | primary primary → INT_LIT | FLOAT_LIT | "true" | "false" | ident | "(" expr ")"这份文法的关键在于分层:从logicOr到primary一共八层,每层只处理一种优先级,递归下降写起来就是一个函数对应一层,报告里也能画出一张漂亮的优先级表。类型只保留int、float、bool三种,是为了让语义分析里的类型检查有内容可做(算术运算要求数值类型、逻辑运算要求 bool、赋值要求类型相容),又不至于引入指针和数组把内存布局问题带进来。
函数、数组、指针这三样,课设里我一般直接砍掉,理由很实在:加了函数就要处理调用约定和活动记录,加了数组就要处理地址计算和越界检查,工作量翻倍但答辩时老师未必细看。真想做加分项,留一个print内置语句和完整的表达式求值,演示效果反而更好。
2.2 四个模块的边界与核心数据结构
流水线拆成四段,每段的输入输出必须是明确的数据结构,不要让下游模块回头去读源文件字符串。这是整个项目能不能改得动、报告能不能写得清的分水岭。
// Token:词法分析的唯一输出,带位置信息,方便报错定位 struct Token { TokenType type; // 关键字、标识符、字面量、运算符、界符、EOF std::string lexeme; // 原始词素,报错时原样打印 int line; // 行号,从 1 开始 int col; // 列号,从 1 开始 double numVal; // 仅 INT_LIT / FLOAT_LIT 有效 }; // AST 节点:语法分析的输出,也是语义分析的输入 struct Expr { enum Kind { BINARY, UNARY, LITERAL, IDENT } kind; Token op; // 运算符 std::string name; // 标识符名 double value; // 字面量值 std::unique_ptr<Expr> lhs, rhs; // 子节点,unique_ptr 管生命周期 }; // 四元式:语义阶段的输出,解释器的输入 struct Quad { std::string op; // + - * / < j jz = print std::string arg1; std::string arg2; std::string result; };Token里带行列号这一条要重视。很多同学的第一版只存类型和词素,等到语法错误报“第 ? 行附近出错”时才回头改,结果词法器、语法器、报错函数全要动。位置信息从第一行代码就带上,后面出错提示“第 12 行第 8 列:期望;,实际读到}”,演示时观感完全不同。
Expr用unique_ptr持有子节点,是为了让 AST 的生命周期跟着解析函数走,不用手写delete。如果用的是 C 语言,就老老实实写create_node()和free_tree()一对函数,并在报告里说明内存管理策略,这本身也是一个可写的小节。
2.3 工程目录与构建方式
课设代码量不大,但目录一定要分清楚,答辩时老师翻代码,第一眼看的就是组织方式:
minic/ ├── include/ # 头文件:token.h ast.h lexer.h parser.h symtab.h codegen.h ├── src/ │ ├── lexer.cpp # 词法分析 │ ├── parser.cpp # 语法分析 + AST 构造 │ ├── symtab.cpp # 符号表与作用域 │ ├── codegen.cpp # 类型检查与四元式生成 │ └── main.cpp # 驱动:读文件 → 四段流水线 → 输出 ├── tests/ # 正例、反例、边界用例,按编号命名 ├── report/ # 实验报告与截图 └── CMakeLists.txt构建用 CMake 而不是手写一长串g++命令,好处是加文件不用改命令行,交叉编译也不用重写。main.cpp里给三个开关:--dump-tokens、--dump-ast、--dump-quads,分别打印词法、语法、中间代码三个阶段的产物。这三个开关是演示时的救命稻草,任何一个阶段出问题,打开对应开关就能把范围缩到一段函数里,而不是对着一个“结果不对”干瞪眼。
2.4 实验报告与代码的对应关系
实验报告不要另起炉灶重新编一套内容,直接按流水线一节对一节写,保证报告里出现的每张表、每段伪代码都能在代码里找到落点:
| 报告章节 | 对应代码位置 | 交付产物 |
|---|---|---|
| 需求分析与语言子集 | report/grammar.ebnf | 一页 EBNF 文法 |
| 词法分析与 DFA | src/lexer.cpp | 状态转移表 + Token 表 |
| 语法分析与 FIRST/FOLLOW | src/parser.cpp | 递归下降函数清单 |
| 语义分析与符号表 | src/symtab.cpp | 作用域栈结构图 |
| 中间代码生成 | src/codegen.cpp | 四元式示例序列 |
| 测试与结果分析 | tests/ | 不少于 20 组用例与截图 |
这张表还有一层用处:写报告时按行填,哪一行填不出来,说明对应模块还没真做完,而不是“代码写完了报告凑一凑”。答辩前拿它当检查清单过一遍,比翻代码快得多。
3. 词法分析:从正则式到 DFA 状态表的落地
3.1 手写扫描器还是用工具生成
课程设计里词法分析有两种做法:用 Lex/Flex 自动生成,或者手写扫描器。工具生成快,但报告里能写的东西少,答辩时被问“你的 DFA 有几个状态”容易空场;手写慢一点,但状态转移表可以画进报告,每个状态对应什么输入一目了然,出错也能单步调试。
我一般选手写 + 状态表驱动的折中方案:主循环用代码写,状态转移关系用一张二维表表达,表可以手工推、也可以写个小脚本从正则式生成。这样代码是可控的,报告里那张表也是真实的,不是编的。
词法单元按下面的粒度划分,够用且不会太碎:
| 类别 | 内容 | 备注 |
|---|---|---|
| 关键字 | int float bool if else while print true false | 查表判断,不单独写规则 |
| 标识符 | 字母或下划线开头,后接字母、数字、下划线 | 最长匹配 |
| 整数字面量 | 0或非零开头的数字串 | 拒绝012这类前导零 |
| 浮点字面量 | 整数部分.小数部分 | 1.5、0.25 |
| 运算符 | + - * / % < <= > >= == != && || ! = | 双字符优先于单字符 |
| 界符 | ( ) { } ; , | 单独一类 |
| 注释 | //行注释、/* */块注释 | 不产生 Token,只更新行号 |
3.2 Token 分类与关键字表的初始化
关键字不要写成一长串if (s == "if") ... else if (s == "while") ...,用哈希表一次建好,扫描到标识符后查表决定改判还是保留:
// 关键字表:只在初始化时构建一次,扫描时 O(1) 查询 static const std::unordered_map<std::string, TokenType> kKeywords = { {"int", TK_INT}, {"float", TK_FLOAT}, {"bool", TK_BOOL}, {"if", TK_IF}, {"else", TK_ELSE}, {"while", TK_WHILE}, {"print", TK_PRINT}, {"true", TK_TRUE}, {"false", TK_FALSE}, }; TokenType classifyIdent(const std::string& s) { auto it = kKeywords.find(s); return it == kKeywords.end() ? TK_IDENT : it->second; // 查不到就是普通标识符 }这样写的好处是可扩展:以后要加for、break,只往表里加一行,扫描逻辑一行不动。classifyIdent的返回值直接决定 Token 类型,不需要在下游再做字符串比较,语法分析里判断“当前是不是while”就变成判断枚举值,速度和可读性都更好。
3.3 状态转移表驱动的扫描主循环
主循环只做四件事:跳过空白与注释、记录起始位置、按首字符分流、交给对应的子扫描函数。分流依据就是 DFA 的起始状态:
Token Lexer::next() { skipWhitespaceAndComments(); // 注释在这里吞掉,同时累加行号 int sl = line_, sc = col_; if (pos_ >= src_.size()) return {TK_EOF, "", sl, sc}; char c = src_[pos_]; if (std::isalpha((unsigned char)c) || c == '_') return scanIdent(sl, sc); if (std::isdigit((unsigned char)c)) return scanNumber(sl, sc); if (c == '"') return scanString(sl, sc); return scanOperator(sl, sc); // 运算符与界符,含双字符判定 }skipWhitespaceAndComments里遇到\n要把line_加一、col_归一,遇到//就一直读到行尾,遇到/*就找到配对的*/,找不到就抛“块注释未闭合”的错误并带上起始行号。这里有个容易被忽略的点:块注释跨行时行号必须跟着涨,否则后面所有报错的行号全错位,排查时会被误导到完全无关的位置。
scanIdent用一个while吃字母、数字、下划线,退出后截取子串查关键字表。scanNumber稍复杂,先吃整数部分,再看下一个字符是不是.,是就转浮点分支;如果点后面不是数字(比如1..2),要么报错,要么回退成1和..两个 Token,具体选哪种要在报告里写清楚,别让行为悬空。
3.4 数字、字符串与注释的边界处理
词法分析翻车,八成翻在“最长匹配”和“边界”上。下面几种输入建议直接写进测试集:
1..2:第一个点后面不是数字,不能把1.当成浮点数吞掉。0123:前导零,很多语言的八进制写法,这里应当报错或按十进制处理,总之要有明确规则。a1:标识符里可以有数字,不能扫成a和1两个 Token。==与=:双字符运算符必须先看两个字符,否则a == b会被拆成两次赋值。/* ...没有闭合:必须报错,不能静默吃到文件尾。- 字符串里的
\n、\":转义要么支持要么明确不支持,别一半支持一半不支持。
最长匹配的实现方式很朴素:只要能继续吃就继续吃,吃到不能再吃为止,然后回退一个字符。回退逻辑写在每个子扫描函数里,比集中在一个大状态机里更好调。
3.5 错误处理与位置信息维护
词法器遇到非法字符(比如@、#)不应该直接exit(1),正确做法是记录一条错误、跳过一个字符、继续扫描。原因是语法分析需要尽可能多的 Token 才能给出有意义的错误恢复,词法器一崩,后面全看不到。
错误信息统一格式化成文件:行:列: 错误类型: 描述,例如test03.mc:7:12: 词法错误: 非法字符 '@'。这个格式和主流的报错风格一致,演示时贴出来很直观。所有错误收进一个std::vector<Diag>,最后统一打印并返回非零退出码,测试脚本也好判断。
提示:词法阶段的错误数和语法阶段的错误数要分开统计。如果词法有错还硬跑语法,解析器会被一堆非法 Token 带偏,报出一串无关错误,掩盖真正的问题。
4. 语法分析:递归下降、AST 与语义动作的一次成型
4.1 文法改写:消除左递归与保住结合性
2.1 里那份 EBNF 已经是改写后的形式。原始文法里expr → expr "+" term是左递归的,递归下降遇到它会立刻无限递归,所以必须改成additive → term (("+"|"-") term)*这种循环形式。
改写本身不难,难的是改写之后结合性不能丢。原始左递归文法定义的是左结合,a-b-c等价于(a-b)-c;如果实现时不小心写成右结合,结果是a-(b-c),减法除法全错。保住左结合的办法是在循环里用“新节点包住旧节点”的方式构造:
// additive → term (("+" | "-") term)* ExprPtr Parser::parseAdditive() { auto lhs = parseTerm(); while (check(TK_PLUS) || check(TK_MINUS)) { Token op = advance(); auto rhs = parseTerm(); // 关键:新节点以 lhs 为左子树,保证左结合 lhs = makeBinary(op, std::move(lhs), std::move(rhs)); } return lhs; }如果写成makeBinary(op, std::move(rhs), std::move(lhs)),或者递归调用自己,就变成了右结合。这个 bug 很隐蔽,因为a+b+c两种结合性结果一样,只有减法和除法才暴露,测试集里必须放10-3-2和100/10/5。
4.2 递归下降骨架:一个函数一层优先级
八层优先级对应八个函数,每个函数的结构都一样:先调用下一层拿左操作数,然后while看当前 Token 是不是本层的运算符,是就吃进去、调下一层拿右操作数、合并。整个解析器的骨架就是这八个函数加一个语句分发函数。
// 语句分发:按首 Token 决定走哪条分支,遇到不认识的就报错并同步 StmtPtr Parser::parseStatement() { if (check(TK_IF)) return parseIf(); if (check(TK_WHILE)) return parseWhile(); if (check(TK_LBRACE))return parseBlock(); if (check(TK_PRINT)) return parsePrint(); if (check(TK_IDENT) && peek(1).type == TK_ASSIGN) return parseAssign(); if (check(TK_INT) || check(TK_FLOAT) || check(TK_BOOL)) return parseVarDecl(); errorAt(current(), "期望一条语句"); return recoverStatement(); // 同步到 ';' 或 '}' 后返回空节点 }peek(1)这种向前看一个 Token 的能力,在区分“赋值语句”和“变量声明”时是必需的:两者都以标识符或类型关键字开头,只看一个 Token 分不清。解析器内部维护一个std::vector<Token>和游标pos_,peek(n)返回游标后第 n 个,越界返回 EOF,实现起来只有几行。
4.3 AST 节点设计与构造时机
AST 的构造有两种时机:解析时直接建节点,或者先建分析树再转换。课设规模下直接建 AST 更省事,解析函数的返回值就是子树根节点,makeBinary这类工厂函数负责分配节点并挂子树。
节点类型不要太细。表达式只分BINARY、UNARY、LITERAL、IDENT四种,语句分ASSIGN、IF、WHILE、BLOCK、PRINT、VARDECL六种,加起来十个类型,用enum Kind加一个struct就够。见过有同学给每个运算符建一个类,写了二十多个类,最后自己都记不清哪个是哪个。
unique_ptr的所有权语义在这里正好合适:父节点持有子节点,函数返回时移动语义自动转移所有权,不用手写释放。如果坚持用裸指针,就在Parser析构时统一释放,并在报告里说明“AST 由解析器统一管理”,别一半new一半delete。
4.4 语义动作挂载:符号表、类型检查与四元式
语法制导翻译的核心思想是:在解析函数的合适位置插入语义动作,边建树边干活。课设里最省事的做法是在工厂函数里挂动作,解析函数保持干净:
// 构造二元表达式节点时立刻做类型检查和四元式生成 ExprPtr CodeGen::makeBinary(Token op, ExprPtr l, ExprPtr r) { Type lt = typeOf(l), rt = typeOf(r); Type res = promote(lt, rt); // int 与 float 混合时提升为 float if (!isNumeric(lt) || !isNumeric(rt)) error(op, "算术运算要求数值类型"); std::string t = newTemp(); // 申请临时变量 t1、t2…… emit(op.lexeme, nameOf(l), nameOf(r), t); return makeNode(op, res, t, std::move(l), std::move(r)); }promote负责隐式类型转换,int + float提升成float;isNumeric拦住true + 1这类非法运算。nameOf返回操作数在四元式里的表示:字面量直接转字符串,变量返回变量名,子表达式返回它已经算出的临时变量名。这样每个表达式节点天然对应一个临时变量,四元式序列是一条线性的求值链。
符号表用作用域栈实现,进块压栈、出块弹栈,查找由内向外:
class SymbolTable { public: void enterScope() { scopes_.emplace_back(); } void exitScope() { scopes_.pop_back(); } bool declare(const std::string& name, Type t, int line) { auto& top = scopes_.back(); if (top.count(name)) { error(line, "重复定义: " + name); return false; } top[name] = Symbol{name, t, (int)scopes_.size() - 1}; return true; } Symbol* lookup(const std::string& name) { for (auto it = scopes_.rbegin(); it != scopes_.rend(); ++it) { auto f = it->find(name); if (f != it->end()) return &f->second; } return nullptr; // 未定义,由调用方报错 } private: std::vector<std::unordered_map<std::string, Symbol>> scopes_; };scopes_是vector套unordered_map,栈顶是当前作用域。declare只查当前作用域,允许内层遮蔽外层同名变量;lookup从内向外找,找不到返回空指针让调用方报“未定义标识符”。这两个函数的区别是很多人搞混的地方,写成一样的逻辑就会出现“内层变量泄漏到外层”或者“遮蔽被误判成重复定义”。
4.5 错误恢复:同步集合与 panic mode
递归下降的一大优势是错误恢复好写。基本策略是恐慌模式:发现错误后,丢弃 Token 直到遇到同步符号(语句级的同步集合通常是;和}),然后返回上一层继续解析。这样一份文件里的多个错误能一次报出来,而不是改一个跑一次。
recoverStatement的实现就是循环advance()直到当前 Token 属于同步集合或到达 EOF,返回一个空的语句节点让上层跳过。要注意的是:如果当前 Token 已经在同步集合里,必须至少前进一个,否则上层再次调用解析函数时会卡在同一个位置,形成死循环。这个坑在第 5 章还会细说。
5. 常见问题排查:编译课设里最容易翻车的五个点
5.1 消除左递归后结合性反了
现象:10-3-2输出 9 而不是 5,100/10/5输出 50 而不是 2,加法乘法却一切正常。
原因:8.1 里提过,改写左递归时把新节点挂成了右子树,或者解析循环里递归调用了自己。因为加减乘在数学上满足结合律,只有减法和除法会暴露,测试集不做针对性设计就发现不了。
解决:固定写法是“新节点包住旧节点”,并在测试集里强制放三条链式减法和两条链式除法。另外可以打印 AST 做肉眼核对,a-b-c的树根应该是第二个减号,左子树是a-b,形状对了逻辑就对了。
5.2 最长匹配没做对,字面量和运算符被切错
现象:1..2报奇怪的错误,a==b被解析成赋值,a1变成两个 Token。
原因:扫描数字时看到点就无条件转浮点;扫描运算符时只读一个字符;扫描标识符时遇到数字就停。三者都是没贯彻“能继续吃就继续吃”的最长匹配原则。
解决:数字看到点之后必须再看一位是不是数字;运算符先读两个字符判断是不是==、!=、<=、>=、&&、||,不是再回退;标识符的循环条件里包含数字。把这几条写进词法测试集,用--dump-tokens打印出来逐个核对,比在脑子里推演靠谱。
5.3 符号表没做作用域栈,内层变量泄漏
现象:块里定义的变量出了块还能用,或者内层同名变量报“重复定义”。
原因:只用一个全局map存符号,从不区分作用域层级;或者进了块忘记压栈,出了块忘记弹栈。
解决:enterScope/exitScope成对出现在parseBlock的首尾,用 RAII 思路包一层也行。declare只查栈顶,lookup从栈顶往下找。写完后拿一段嵌套块代码验证:外层x、内层x应该各自独立,内层能读外层,外层读不到内层。
5.4 四元式临时变量重名与跳转回填错位
现象:生成的中间代码里出现两个t3,或者while循环跳到了循环体中间,甚至跳到了下一条语句。
原因:临时变量计数器是按函数局部重置的,递归调用时被覆盖;跳转指令的目标地址在生成时还不知道,回填时把四元式下标记错了,差一。
解决:临时变量计数器做成全局单调递增,永不复用;跳转用“先占位、后回填”的两步法,emitJump返回当前四元式下标,patch(index, target)在目标确定后写回result字段。回填的下标一律用quads_.size()而不是size()-1,因为新指令还没入队时size()才是它的位置。这一条建议写进代码注释,改的时候不容易再错。
5.5 错误恢复不消耗 Token,解析器死循环
现象:输入一个语法错误的文件,程序卡住不返回,CPU 跑满。
原因:recoverStatement里判断“当前 Token 是同步符号就返回”,但上层又原样调回来,位置没前进。
解决:恢复函数开头先advance()一次,保证至少前进一个 Token;或者加一个lastPos_记录上次恢复位置,两次相同就强制前进。调试时用timeout 5 ./minic bad.mc跑一遍,卡住立刻能发现,比等到答辩现场强。
6. 验收与答辩:用一套测试用例和三条演示路径交差
验收不是把程序跑通就完事,老师要看到的是“你知道自己做了什么、边界在哪”。我的做法是准备一张测试用例表,按功能分组,每组至少两正一反,现场按表演示:
| 编号 | 输入要点 | 期望结果 | 覆盖模块 |
|---|---|---|---|
| T01 | 变量声明与算术表达式 | 四元式序列正确 | 词法、语法、代码生成 |
| T02 | 10-3-2与100/10/5 | 结果 5 和 2 | 结合性 |
| T03 | 嵌套if-else与while | 跳转目标正确 | 回填 |
| T04 | 块内变量遮蔽 | 内外层互不影响 | 符号表 |
| T05 | true + 1 | 报类型错误,定位到行 | 类型检查 |
| T06 | 缺分号、括号不匹配 | 报错后继续解析后续语句 | 错误恢复 |
| T07 | 1..2、a==b | 切分正确或给出明确错误 | 最长匹配 |
演示路径固定三条:先跑一个正确程序看四元式输出,再跑一个带语法错误的程序看报错与恢复,最后跑一个类型错误的程序看语义检查。三条路径分别对应三个 dump 开关,顺序不要乱,从简到繁,老师跟得上。
答辩常问的问题也就那几个:你的文法是不是 LL(1)、FIRST 和 FOLLOW 怎么算、为什么选递归下降不选 LR、临时变量怎么分配、跳转怎么回填。前两个用报告里的表回答,后三个用代码里的函数回答,提前把这几段代码在编辑器里打开,问到时直接跳转,比现场翻文件从容得多。
我第一年带课设时吃过亏:学生把程序跑通了就以为万事大吉,结果被问“你的错误恢复为什么不会死循环”,当场打开代码翻了五分钟没找到advance()。从那以后我要求每个人答辩前必须做一遍“三分钟自问自答”,把每条测试用例的预期输出写在纸上,再对照程序实际输出逐条核对,对不上的地方就是答辩时最可能被追问的地方。希望这套流程能帮到你。
本文还有配套的精品资源,点击获取