简介:面向计算机专业本科生的编译原理课程设计与实验资料包,覆盖从词法分析到汇编代码生成的完整实现,包含词法分析、语法分析、中间代码生成及小型编译器设计等核心任务。内含词法分析器、LL(1)方法分析简单语句(如i+i*i)、LR(0)与SLR(1)方法语法分析、四元式生成、汇编代码生成,并附带一份小型编译器课程设计报告,方便对照实验要求与报告撰写。压缩包共十四个文件,包括四个cpp、三个c、三个h源程序,三个txt文本,一份doc课程设计报告,整体仅557KB,轻量易读。资源中还提供部分LL(1)文法和SLR(1)文法,便于理解从文法定义、分析表构造到语法分析的全过程,对掌握自顶向下与自底向上分析方法很有帮助。目前已有2349人学习,适合正在进行编译原理课程设计、实验复习或需要完整代码范本的学生参考。
1. 一个小型编译器课设仓库,词法分析到实验报告一步到位
编译原理课程设计这关,每年都能劝退一批人:文法推导背得明白,但真要手写一个能识别int a = 1 + 2 * 3;的小型编译器,大多数人卡在第一步——词法分析里2*3到底该切成几个 Token,浮点数和关键字怎么区分,报错时行号为什么总是往后偏一行。这个资源围绕“从零手写一个最小可用的编译器”组织:词法分析、语法分析、中间代码生成三大模块全部用 C++ 手工实现,不依赖 flex/bison 这类生成器,附带的实验报告和测试用例可以直接用于课程设计答辩。它的设计思路和国内多数教材的编译原理课设要求高度吻合,适合作为二次开发的底子,也适合抄骨架后替换成自己的文法。
2. 项目总体设计:从字符流到四元式的完整链路
2.1 仓库布局与工程入口
把源码包解压后,目录结构很清爽,没有把一堆 CMake 缓存和构建产物混进来。仓库按模块分离,每个目录职责单一:
compiler-tiny/ ├── include/ │ ├── lexer.h # 词法分析器接口 │ ├── parser.h # 语法分析器接口 │ ├── ast.h # 抽象语法树节点定义 │ └── codegen.h # 四元式生成器接口 ├── src/ │ ├── main.cpp # 驱动入口,串联各阶段 │ ├── lexer.cpp # 词法分析实现 │ ├── parser.cpp # 递归下降语法分析实现 │ ├── ast.cpp # AST 构建与打印 │ └── codegen.cpp # 四元式生成实现 ├── tests/ │ ├── case_01.txt # 简单赋值语句 │ ├── case_02.txt # 嵌套算术表达式 │ └── case_03.txt # 含变量的表达式 ├── docs/ │ ├── 实验报告模板.md │ └── 设计说明.md ├── Makefile └── README.md各模块的依赖方向是单向的:main调lexer得到 Token 流,再喂给parser生成 AST,最后codegen遍历 AST 产出四元式。我在做课设时习惯把接口头文件和实现分开,这样写测试的时候可以直接只链接lexer.o,不用把语法分析一起编译进来,排查问题快很多。
编译入口是根目录的Makefile,标准的make即可。目标产物只有一个可执行文件,运行参数是源码文件路径,输出在终端逐行打印 Token 序列和四元式列表,这样验收时能把“中间过程”直接摆到老师面前。
2.2 词法分析器的 Token 设计与状态划分
词法分析器要处理的符号集合不大:整数、标识符、关键字(int、return、if)、四则运算符、赋值号、括号和分号。头文件里定义的核心数据结构如下:
// include/lexer.h enum TokenType { TOK_IDENT, // 标识符 TOK_INT, // 整数字面量 TOK_KEYWORD, // 关键字 TOK_OP, // 运算符 + - * / TOK_ASSIGN, // 赋值号 = TOK_LPAREN, // ( TOK_RPAREN, // ) TOK_SEMI, // ; TOK_EOF // 结束标记 }; struct Token { TokenType type; std::string text; int line; // 行号,从 1 开始 int col; // 列号,从 1 开始 };line和col看着不起眼,真正写报错提示时帮了大忙。词法分析器维护一个输入缓冲区和一个游标pos,外层循环每次调用nextToken()就消费一段字符并返回一个 Token。判断依据是先扫字符类型:数字开头进数字识别路径,字母或下划线开头进标识符路径,其余按单字符映射到运算符或分隔符。这个实现没有用状态表驱动,直接基于分支扫描,对小型文法语料完全够用。
一个容易漏掉的点——空白和注释的过滤。我在这里加了个skipWhitespace(),遇到空格、\t、\n就跳过并更新行号计数,注释则要识别//到行尾。跳过逻辑里最需要注意的就是换行符必须递增line,否则后续语法分析的报错行号全错。
2.3 语法分析器用递归下降处理表达式文法
语法分析是手写递归下降,处理的文法简化成经典 EBNF:
program := statement_list statement := 'int' IDENT '=' expression ';' expression := term (('+' | '-') term)* term := factor (('*' | '/') factor)* factor := INTEGER | IDENT | '(' expression ')'递归下降对初学者比较友好:每个非终结符对应一个同名函数,函数体内按产生式右侧的符号顺序调用其他非终结符函数或匹配终结符。解析1 + 2 * 3时,函数调用栈会天然形成expression -> term -> factor的层级,优先级通过层级体现——乘法在term层先被匹配,所以它绑得更紧。
为了避免左递归,代码里全部换成了右递归或循环改写。比如expression内部先parseTerm()拿左操作数,再循环判断下一个 Token 是不是+或-,是就继续取右操作数。每次结合运算时创建一个BinOpNode,左右子节点挂好,返回值给上层。
AST 节点类型只保留三种:数字节点、变量节点、二元运算节点。结构定义如下:
// include/ast.h struct ASTNode { enum NodeKind { NUM_NODE, IDENT_NODE, BINOP_NODE } kind; int intValue; // NUM_NODE 时的值 std::string name; // IDENT_NODE 时的名称 Token op; // BINOP_NODE 的运算符 ASTNode* left; ASTNode* right; };语法分析完成后,整个源文件变成一棵 AST。codegen遍历这棵树生成四元式,每一步运算产出一条(op, arg1, arg2, result)记录,结果全部用临时变量t1、t2承接。
2.4 驱动流程与输出设计
main.cpp里的驱动流程不复杂,但把各阶段串得很清楚:
// src/main.cpp 核心流程 int main(int argc, char* argv[]) { Lexer lexer(argv[1]); Parser parser(lexer); ASTNode* root = parser.parseProgram(); std::cout << "Token 序列:" << std::endl; lexer.dumpTokens(); std::cout << "\nAST 结构:" << std::endl; root->dump(); CodeGen gen; std::cout << "\n四元式:" << std::endl; gen.generate(root); gen.dumpQuads(); return 0; }注意这里 Token 序列先被Parser消费了一遍,再dumpTokens输出,顺序上没问题,因为词法分析的 Token 流是一次性全部产出并缓存的。这种做法比流式逐个读取的实现在调试时方便很多——你能同时看到输入、Token 列表和最终的语法树,三个视图对照着定位。
3. 核心代码走读:手写词法分析与递归下降的落地细节
3.1 词法分析:分支跳转与数值识别
词法分析器的主扫描函数是nextToken。这里贴出关键实现:
// src/lexer.cpp 核心扫描逻辑 Token Lexer::nextToken() { skipWhitespaceAndComments(); // 过滤空白与注释,维护 line/col Token tok; tok.line = currentLine; tok.col = currentCol; char c = peek(); if (c == '\0') { tok.type = TOK_EOF; tok.text = "EOF"; return tok; } if (isdigit(c)) { return readNumber(); // 识别连续数字并转 int } if (isalpha(c) || c == '_') { return readIdentifier(); // 标识符,遇到关键字转为关键字类型 } advance(); tok.text = std::string(1, c); switch (c) { case '+': tok.type = TOK_OP; break; case '-': tok.type = TOK_OP; break; case '*': tok.type = TOK_OP; break; case '/': tok.type = TOK_OP; break; case '=': tok.type = TOK_ASSIGN; break; case '(': tok.type = TOK_LPAREN; break; case ')': tok.type = TOK_RPAREN; break; case ';': tok.type = TOK_SEMI; break; default: error("无法识别的字符"); } return tok; }这段代码的核心思路是“按首字符决定扫描路径”。peek()只查看当前字符不移动游标,advance()移动并更新行列号。“连续吞字符”这类操作在readNumber里更典型:
Token Lexer::readNumber() { Token tok; tok.line = currentLine; tok.col = currentCol; int value = 0; while (isdigit(peek())) { value = value * 10 + (advance() - '0'); // 边消费边累计 } tok.type = TOK_INT; tok.text = std::to_string(value); tok.intValue = value; return tok; }这里的advance()返回被消费字符的 ASCII 码,所以advance() - '0'能直接拿到数值。readNumber没有处理负数——负号在语法层由上下文解决,词法层只认正整数。实现里有个小细节:遇到123abc这种输入,目前会报错而不是只读123。我建议保持这个严格行为,课设验收时这算“错误检测能力”的加分项。
readIdentifier里除了收集字符,还要查关键字表。源代码里维护一个std::unordered_set<std::string>存{"int", "return"},命中即把 Token 改成TOK_KEYWORD。这个查表动作很便宜,别忘了在识别完标识符后做一次判断。
3.2 语法分析:递归下降函数的层次调用
语法分析器核心是parseExpression和parseTerm的分层结构:
// src/parser.cpp 表达式与项 ASTNode* Parser::parseExpression() { ASTNode* left = parseTerm(); while (lookahead.type == TOK_OP && (lookahead.text == "+" || lookahead.text == "-")) { Token op = lookahead; advance(); ASTNode* right = parseTerm(); left = new BinOpNode(op, left, right); } return left; } ASTNode* Parser::parseTerm() { ASTNode* left = parseFactor(); while (lookahead.type == TOK_OP && (lookahead.text == "*" || lookahead.text == "/")) { Token op = lookahead; advance(); ASTNode* right = parseFactor(); left = new BinOpNode(op, left, right); } return left; }这个写法就是“优先级爬升”的简化版。parseExpression遇到的第一个节点来自parseTerm,parseTerm又先拿parseFactor,于是1 + 2 * 3的 AST 必然是把乘法节点挂在加法节点的右子树上。左结合通过循环实现——1 + 2 + 3第一次循环生成(1+2)节点,第二次循环把它作为新左操作数接到+3,于是 AST 向左倾斜,符合预期。
parseFactor则处理三种情况:
ASTNode* Parser::parseFactor() { if (lookahead.type == TOK_INT) { Token num = lookahead; advance(); return new NumNode(num.intValue); } if (lookahead.type == TOK_IDENT) { Token id = lookahead; advance(); return new IdentNode(id.text); } if (lookahead.type == TOK_LPAREN) { advance(); // 跳过 ( ASTNode* inner = parseExpression(); match(TOK_RPAREN); // 必须匹配右括号 return inner; } error("语法错误:意外的 Token " + lookahead.text); }match(TOK_RPAREN)这个动作很关键:它先检查当前 Token 类型,匹配才消费,不匹配就调用error()并直接终止。语法错误的行号和列号在error()中从 Token 结构里取出,这就是词法分析维护line/col的回报。
3.3 四元式生成:遍历 AST 边计算边生成
四元式生成在codegen.cpp中实现,采用后序遍历——先处理子树,再处理当前节点:
// src/codegen.cpp 后序遍历生成四元式 std::string CodeGen::generateExpr(ASTNode* node) { if (node->kind == ASTNode::NUM_NODE) { std::string t = newTemp(); quads.push_back(Quad("=", std::to_string(node->intValue), "", t)); return t; } if (node->kind == ASTNode::IDENT_NODE) { std::string t = newTemp(); quads.push_back(Quad("=", node->name, "", t)); return t; } // BINOP:先递归生成左右子节点的临时变量 std::string l = generateExpr(node->left); std::string r = generateExpr(node->right); std::string result = newTemp(); quads.push_back(Quad(node->op.text, l, r, result)); return result; }newTemp()内部用一个计数器生成t1、t2……这类临时名。四元式结构体Quad是一个四元组(op, arg1, arg2, result)。对比一下:2 * 3被翻译成(*, 2, 3, t1),1 + t1翻译成(+, 1, t1, t2)。
这里的函数命名很直白:每个表达式都产出一个保存结果的临时变量,然后把临时变量的名字返回给上层继续参与运算。你可以把这个临时变量理解为寄存器分配的简化版本。如果课设要求再做一层优化,后续可以在这个环节做常量折叠——发现arg1是常量且arg2是常量时直接算出结果,不再生成四元式。这个思路我没有写进源码,留作二次开发方向,后面专门提。
4. 避坑指北:手写编译器时翻过的五个典型问题
4.1 Token 行号错位,语法报错全指向下一行
现象:输入文件里第 2 行出错,报错信息却标在第 3 行;接着第 4 行出错,又标到第 5 行。
原因:skipWhitespace在遇到\n时没有让currentLine自增。词法分析器把换行符当作普通空白跳过,却没有同步维护行列信息。第一个换行走失后,后面所有行号整体偏加一。
解决:每次消费\n必须让行号加一、列号归零。代码写在一处:
char c = advance(); if (c == '\n') { currentLine++; currentCol = 1; } else { currentCol++; }修复后错误定位基本一测一个准。这也是为什么我强烈建议词法分析器优先把 Token 全部缓存下来再进语法分析——先看一批 Token,行号对不对一目了然。
4.2 递归下降直接套用左递归文法,程序栈溢出
现象:解析1 + 2时没有任何输出,程序直接 segment fault。
原因:一开始我照书把表达式写成expression -> expression + term,然后在函数里写:
// 错误示例:左递归导致无限调用 ASTNode* parseExpression() { ASTNode* left = parseExpression(); // 永远不可能返回 // ...后续永远到不了 }每次调用parseExpression都先调自己,形成无终止的递归链,栈爆掉。
解决:把左递归文法改写成循环形式。经典改写是expression -> term ('+' term)*,代码就是用 while 循环反复读取运算符和操作数。注意不是简单把递归调用改成迭代就行,而是要认清“左结合”由循环维持,不是靠递归。改完之后1+2+3能正常生成左倾 AST,挂载顺序正确。
4.3 优先级写反,2 + 3 * 4算成 20
现象:语法分析能通过,但四元式生成的结果是(+, 2, 3, t1)然后(*, t1, 4, t2),最后算出 20 而不是 14。
原因:parseExpression里直接解析成平级链表——两个操作数都走parseFactor,没有经过parseTerm层级。乘法的优先级一旦和加法同级,文法就退化成了从左到右的简单运算。
解决:严格按 EBNF 分层。expression调用term,term调用factor,乘除法在低层先终结,所以它绑得更紧。这个坑的排查方式很简单:看 AST 树形结构,根节点是+还是*。如果2 + 3 * 4的根节点是+,说明乘法已经在右子树里先结合,优先级正确。
4.4 错误处理只报一处,连续错误全部吞掉
现象:文件里有三个语法错误,只输出第一处,修掉后又会冒出后面两处。课设验收时被追问“为什么不能一次全部指出错误”。
原因:语法分析器遇到第一个错误就调用error()抛异常,控制流中断,剩余 Token 不再检查。这是最简单的设计,但不是最友好的。
解决:做一个轻量错误恢复——在parseExpression的循环里遇到错误 Token 时,记录错误并尝试同步到下一个;分号处,再继续解析:
catch (const CompileError& e) { errors.push_back(e.what()); while (lookahead.type != TOK_SEMI && lookahead.type != TOK_EOF) { advance(); } if (lookahead.type == TOK_SEMI) advance(); // 跳过当前语句 }这个“跳到下一个分号”的策略对短语句很有效,能恢复且不容易产生级联干扰。实现成本不高,报告里还能多写一节“错误恢复策略”。
4.5 变量越界读,没有语义检查就直接生成中间代码
现象:int a; b = a + 1;,四元式生成了(=, a, "", t1),但b根本没声明过,执行阶段直接读到垃圾值。
原因:词法和语法都通过,语法树没问题,但语义层没有检查未声明变量。codegen 阶段对IDENT_NODE无条件生成取值四元式,没验证变量是否存在。
解决:在语法分析和 codegen 之间加一个简单的符号表检查。用std::unordered_map<std::string, int>记录已声明的变量类型,遍历 AST 时遇到标识符节点先查表,未声明就报错;遇到int声明语句则插入符号表。这个检查放在parser里收尾处做一次,几十行就能实现,能挡住一部分运行期才暴露的问题。
5. 二次开发:把玩具编译器扩成能用的课设作品
5.1 扩展点一:增加printf输出语句
给文法加一条print_statement := 'print' expression ';',词法层把print关键字加进关键字表,语法分析多写一个parsePrintStatement,生成四元式时产出(print, temp, "", "")。这样程序就有输出了,测试用例能验证每条语句的真实结果,课设演示时效果提升明显。
5.2 扩展点二:常量折叠与死代码删除
codegen 的generateExpr里加一个检查:左右子树都是NUM_NODE时直接算结果,不再生成四元式。这条优化在一本教材的“中间代码优化”章节里是必提的例子,写进实验报告能体现“不只是把功能调通”的理解。
5.3 扩展点三:把 Token 流导出为 JSON
改动点集中在 Lexer:加一个dumpToJson()方法,把 Token 的 type、text、line、column 序列化为 JSON 数组。有了 JSON,就能用脚本做回归测试,把tests/目录下的每个用例跑一遍并与期望四元式做 diff。从那以后我每次改语法规则都会强制走一遍全量用例的回归对比,看到旧四元式被新四元式替换时,能立刻判断出影响面,而不是靠肉眼盯语法树。
这几个方向的改动量都不大,最大的收益是让你真正理解“语法分析和语义分析该怎么分层”。这套 C++ 源码结构留好了扩展位,希望帮到你。
本文还有配套的精品资源,点击获取