简介:这份资源是北京邮电大学编译原理课程设计的完整实践项目,面向计算机专业学生及希望深入理解编译器构建的开发者。内容以Pascal语言为示例,覆盖词法分析、语法分析、语义分析、代码生成等核心阶段,并涉及符号表管理、错误处理与中间代码生成等工程环节,适合作为课程作业参考或编译器入门实战。压缩包共83个文件,约123KB,以21个h头文件与19个cpp源文件为主体,另含8个pas测试用例、8个asm汇编文件、8个bin可执行文件及rez、rc等资源文件,并附带dsw、dsp、vcxproj等工程配置,结构完整便于直接编译运行。目前已有1192人学习下载,读者可从中获得可运行的编译器框架、多组Pascal与汇编对照样例、虚拟机实现及编辑器相关代码,有助于对照理解各编译阶段的衔接与优化思路,为系统级软件开发打下基础。
1. 北邮编译原理课程设计:从词法分析到目标代码生成的完整落地路径
北邮编译原理课程设计这门课,真正动手做的时候你会发现,它跟课堂上讲的龙书理论完全是两码事。课堂上你学的是有限自动机、LL(1) 分析表、语法制导翻译,但课程设计要你交的是一个能跑通的编译器前端,能把一段类 C 语言源码变成四元式或者汇编。很多同学卡住的地方不是不懂原理,而是不知道从哪一行代码开始写。我当年做这个课设的时候,光是在词法分析和语法分析之间怎么传 token 就折腾了整整两天。这篇笔记就是把我踩过的坑、调过的参数、验证过的步骤完整拆开,让你拿到题目之后能直接照着走。适合正在做北邮编译原理课程设计的同学,也适合任何想自己手写一个 mini 编译器前端的开发者。整个方案用 C++ 或者 Java 都能落地,我以 C++ 为主讲,Java 版本在关键位置会给出对应写法。
2. 词法分析器:用状态机把源码切成 token 流
2.1 为什么手写词法分析器比用 Lex 更稳
北邮课设通常要求你实现一个完整的编译器前端,词法分析是第一步。常见做法有两种:用 Flex/Lex 自动生成,或者手写一个确定性有限自动机(DFA)。我一般会建议手写,原因有三个。第一,课设答辩的时候老师会问你状态转移怎么设计的,你用 Lex 生成的话这部分就是黑匣子,答不上来很尴尬。第二,手写 DFA 代码量并不大,核心逻辑大概 200 行左右,但你对 token 的切分规则有完全控制权。第三,后续语法分析需要 token 携带行号、列号信息,手写的话扩展起来方便得多。
词法分析器的输入是源程序字符串,输出是 token 序列。每个 token 至少包含四个字段:类型(关键字、标识符、数字、运算符、界符)、原始字符串、行号、列号。类型用枚举表示,行号和列号用于后续报错定位。
2.2 手写 DFA 的核心代码与状态转移逻辑
下面是一个能直接跑的最小词法分析器骨架,用 C++ 实现。它支持关键字、标识符、整数、浮点数、单字符运算符和双字符运算符(如==、!=、<=、>=)。
#include <string> #include <vector> #include <cctype> #include <unordered_map> #include <iostream> enum TokenType { TOKEN_KEYWORD, TOKEN_IDENTIFIER, TOKEN_NUMBER, TOKEN_OPERATOR, TOKEN_DELIMITER, TOKEN_EOF, TOKEN_ERROR }; struct Token { TokenType type; std::string value; int line; int col; }; class Lexer { std::string src; size_t pos = 0; int line = 1; int col = 1; std::unordered_map<std::string, TokenType> keywords = { {"int", TOKEN_KEYWORD}, {"float", TOKEN_KEYWORD}, {"if", TOKEN_KEYWORD}, {"else", TOKEN_KEYWORD}, {"while", TOKEN_KEYWORD}, {"return", TOKEN_KEYWORD} }; char peek() { return pos < src.size() ? src[pos] : '\0'; } char advance() { char c = src[pos++]; if (c == '\n') { line++; col = 1; } else { col++; } return c; } public: Lexer(const std::string& s) : src(s) {} std::vector<Token> tokenize() { std::vector<Token> tokens; while (pos < src.size()) { char c = peek(); if (isspace(c)) { advance(); continue; } int startLine = line, startCol = col; // 标识符或关键字 if (isalpha(c) || c == '_') { std::string buf; while (isalnum(peek()) || peek() == '_') buf += advance(); auto it = keywords.find(buf); tokens.push_back({it != keywords.end() ? TOKEN_KEYWORD : TOKEN_IDENTIFIER, buf, startLine, startCol}); continue; } // 数字(整数或浮点) if (isdigit(c)) { std::string buf; while (isdigit(peek())) buf += advance(); if (peek() == '.') { buf += advance(); while (isdigit(peek())) buf += advance(); } tokens.push_back({TOKEN_NUMBER, buf, startLine, startCol}); continue; } // 双字符运算符 if (c == '=' || c == '!' || c == '<' || c == '>') { std::string buf; buf += advance(); if (peek() == '=') buf += advance(); tokens.push_back({TOKEN_OPERATOR, buf, startLine, startCol}); continue; } // 单字符运算符和界符 if (strchr("+-*/%(),;{}", c)) { std::string buf(1, advance()); tokens.push_back({TOKEN_OPERATOR, buf, startLine, startCol}); continue; } // 无法识别的字符 tokens.push_back({TOKEN_ERROR, std::string(1, advance()), startLine, startCol}); } tokens.push_back({TOKEN_EOF, "", line, col}); return tokens; } };这段代码的逻辑很直白:每次循环先跳过空白字符,然后根据当前字符判断进入哪个分支。标识符分支会一直吃字母数字下划线,吃完后查关键字表决定是关键字还是普通标识符。数字分支处理整数和小数点。运算符分支先看是不是双字符运算符,不是的话按单字符处理。每个 token 都记录了起始行号和列号,方便后面语法分析报错时定位。
参数方面,keywords表可以根据你的课设要求增删,比如加上for、do、break等。strchr那行列出了所有单字符运算符和界符,如果你的语言支持[]数组下标,记得加进去。行号和列号的维护在advance()里,遇到换行时行号加一、列号归零,这个细节很多同学会漏掉,导致报错信息里的位置全是错的。
2.3 词法分析器的验证方法与常见翻车点
写完词法分析器之后,不要急着往下做语法分析。先写一个 main 函数,把一段测试代码喂进去,打印所有 token。测试代码要覆盖所有 token 类型,包括边界情况:最长的标识符、带小数点的数字、连续的双字符运算符、非法字符。
我当年翻车的地方是浮点数处理。3.14.15这种输入,我的代码会切成3.14和.15,但.15的第一个字符是点号,会走到错误分支。后来加了一个判断:如果点号后面跟数字,且点号前面没有数字,就报错。另一个坑是注释处理,北邮课设通常要求支持//和/* */两种注释。注释里的内容不能产生 token,但行号要正常累加。我建议在tokenize()开头加一个跳过注释的分支,遇到//就吃到行尾,遇到/*就吃到*/,同时维护行号。
提示:词法分析阶段不要做任何语法层面的判断,比如括号匹配、变量是否声明。这些留给语法分析和语义分析,混在一起会让代码逻辑变得极其混乱。
3. 语法分析器:用递归下降把 token 流变成语法树
3.1 递归下降 vs LR 分析:课设场景下怎么选
语法分析是编译原理课程设计的核心。常见做法有递归下降、LL(1) 预测分析、LR(1) 分析。北邮课设一般允许自选,但我强烈建议用递归下降。原因很简单:递归下降的代码结构和文法产生式几乎一一对应,写起来快,调试也直观。LL(1) 需要你构造预测分析表,LR 需要构造项目集规范族,这两样在纸面上推还行,写成代码之后一旦出错,排查起来非常痛苦。
递归下降的核心思想是:每个非终结符对应一个函数,函数内部根据当前 token 决定走哪条产生式。比如表达式文法E -> T E',E' -> + T E' | ε,你就可以写两个函数parseE()和parseE'()。遇到+就继续递归,遇到其他就返回。
3.2 语法树节点定义与递归下降框架代码
先定义语法树节点。每个节点有类型、子节点列表、可选的属性值(比如标识符名字、数字值)。
struct ASTNode { std::string type; // "Program", "VarDecl", "BinaryOp", "Number", ... std::string value; // 标识符名字或字面量值 std::vector<ASTNode*> children; int line; };然后写递归下降解析器。下面以表达式和变量声明为例,展示核心框架。
class Parser { std::vector<Token> tokens; size_t pos = 0; Token& current() { return tokens[pos]; } Token& consume() { return tokens[pos++]; } bool match(TokenType t) { if (current().type == t) { consume(); return true; } return false; } void expect(TokenType t, const std::string& msg) { if (!match(t)) { std::cerr << "Line " << current().line << ": expected " << msg << " but got '" << current().value << "'\n"; exit(1); } } public: Parser(const std::vector<Token>& t) : tokens(t) {} ASTNode* parseProgram() { auto* node = new ASTNode{"Program", "", {}, current().line}; while (current().type != TOKEN_EOF) { node->children.push_back(parseStatement()); } return node; } ASTNode* parseStatement() { if (current().type == TOKEN_KEYWORD && (current().value == "int" || current().value == "float")) { return parseVarDecl(); } if (current().type == TOKEN_KEYWORD && current().value == "if") { return parseIfStmt(); } if (current().type == TOKEN_KEYWORD && current().value == "while") { return parseWhileStmt(); } return parseExprStmt(); } ASTNode* parseVarDecl() { std::string typeName = consume().value; // int 或 float auto* node = new ASTNode{"VarDecl", typeName, {}, current().line}; expect(TOKEN_IDENTIFIER, "identifier"); node->children.push_back(new ASTNode{"Identifier", tokens[pos-1].value, {}, tokens[pos-1].line}); if (match(TOKEN_OPERATOR) && tokens[pos-1].value == "=") { node->children.push_back(parseExpression()); } expect(TOKEN_DELIMITER, ";"); return node; } ASTNode* parseExpression() { return parseAdditive(); } ASTNode* parseAdditive() { auto* left = parseMultiplicative(); while (current().type == TOKEN_OPERATOR && (current().value == "+" || current().value == "-")) { std::string op = consume().value; auto* right = parseMultiplicative(); auto* node = new ASTNode{"BinaryOp", op, {left, right}, left->line}; left = node; } return left; } ASTNode* parseMultiplicative() { auto* left = parsePrimary(); while (current().type == TOKEN_OPERATOR && (current().value == "*" || current().value == "/")) { std::string op = consume().value; auto* right = parsePrimary(); auto* node = new ASTNode{"BinaryOp", op, {left, right}, left->line}; left = node; } return left; } ASTNode* parsePrimary() { if (current().type == TOKEN_NUMBER) { return new ASTNode{"Number", consume().value, {}, tokens[pos-1].line}; } if (current().type == TOKEN_IDENTIFIER) { return new ASTNode{"Identifier", consume().value, {}, tokens[pos-1].line}; } if (match(TOKEN_DELIMITER) && tokens[pos-1].value == "(") { auto* node = parseExpression(); expect(TOKEN_DELIMITER, ")"); return node; } std::cerr << "Line " << current().line << ": unexpected token '" << current().value << "'\n"; exit(1); } };这段代码展示了递归下降的典型结构。parseAdditive处理加减法,parseMultiplicative处理乘除法,parsePrimary处理数字、标识符和括号表达式。每个函数返回一个 ASTNode 指针,运算符节点把左右操作数作为子节点。expect函数在遇到不符合预期的 token 时直接报错退出,报错信息里带了行号,方便定位。
参数方面,parseStatement里的关键字判断可以根据你的文法扩展。比如加上for循环、return语句。parsePrimary里目前只处理了数字、标识符和括号,如果你的语言支持函数调用,需要在这里加一个分支:标识符后面跟(就解析参数列表。
3.3 语法错误恢复:别让一个分号缺失导致满屏报错
递归下降最大的问题是错误恢复。如果源程序里少了一个分号,expect直接 exit,后面的代码全都不分析了。这在课设答辩演示的时候很致命,老师随便改一个字符你就崩了。常见做法是引入同步集合:在parseStatement里,如果当前 token 不在任何语句的开头集合里,就跳过当前 token 继续找下一个分号或右大括号。
我一般会这样改:把expect改成返回 bool,不直接 exit。在parseProgram的循环里,如果parseStatement返回 nullptr,就调用synchronize()跳过 token 直到遇到;或}。这样即使中间有语法错误,后面的语句还能继续解析,报错信息也不会刷屏。
注意:语法分析阶段不要做类型检查。比如
int a = "hello";这种错误,语法上是合法的,类型不匹配留给语义分析阶段处理。混在一起会让你的代码耦合度飙升。
4. 语义分析与中间代码生成:把语法树翻译成四元式
4.1 符号表的设计与作用域管理
语义分析的第一步是建符号表。符号表记录每个标识符的类型、作用域层级、是否初始化等信息。北邮课设通常要求支持嵌套作用域,比如 if 块和 while 块内部可以声明同名变量。常见做法是用栈式符号表:进入一个块就压入一个新作用域,退出就弹出。
struct Symbol { std::string name; std::string type; // "int" 或 "float" bool initialized; int scopeLevel; }; class SymbolTable { std::vector<std::unordered_map<std::string, Symbol>> scopes; public: SymbolTable() { scopes.emplace_back(); } void enterScope() { scopes.emplace_back(); } void exitScope() { scopes.pop_back(); } bool declare(const Symbol& s) { auto& current = scopes.back(); if (current.count(s.name)) return false; // 重复声明 current[s.name] = s; return true; } Symbol* lookup(const std::string& name) { for (auto it = scopes.rbegin(); it != scopes.rend(); ++it) { auto found = it->find(name); if (found != it->end()) return &found->second; } return nullptr; } };符号表的核心逻辑是lookup从最内层作用域往外找,找到就返回。declare只在当前作用域检查重复,不同作用域可以同名。这个设计能覆盖绝大多数课设要求。
4.2 四元式生成规则与代码实现
四元式是中间代码的常见形式,格式是(op, arg1, arg2, result)。比如a = b + c翻译成(+, b, c, t1)和(=, t1, _, a)。生成四元式的时候需要维护一个临时变量计数器。
struct Quadruple { std::string op; std::string arg1; std::string arg2; std::string result; }; class IRGenerator { std::vector<Quadruple> quads; int tempCount = 0; SymbolTable symtab; std::string newTemp() { return "t" + std::to_string(++tempCount); } public: void generate(ASTNode* node) { if (node->type == "Program") { for (auto* child : node->children) generate(child); } else if (node->type == "VarDecl") { std::string varName = node->children[0]->value; symtab.declare({varName, node->value, false, 0}); if (node->children.size() > 1) { std::string val = generateExpr(node->children[1]); quads.push_back({"=", val, "_", varName}); symtab.lookup(varName)->initialized = true; } } else if (node->type == "BinaryOp") { // 由 generateExpr 处理 } } std::string generateExpr(ASTNode* node) { if (node->type == "Number") return node->value; if (node->type == "Identifier") { auto* sym = symtab.lookup(node->value); if (!sym) { std::cerr << "Line " << node->line << ": undeclared variable '" << node->value << "'\n"; exit(1); } return node->value; } if (node->type == "BinaryOp") { std::string left = generateExpr(node->children[0]); std::string right = generateExpr(node->children[1]); std::string temp = newTemp(); quads.push_back({node->value, left, right, temp}); return temp; } return ""; } void printQuads() { for (auto& q : quads) { std::cout << "(" << q.op << ", " << q.arg1 << ", " << q.arg2 << ", " << q.result << ")\n"; } } };这段代码里,generateExpr递归处理表达式,遇到二元运算符就生成一条四元式,把结果放到临时变量里返回。generate处理声明语句,先声明符号再生成赋值四元式。newTemp每次生成一个新的临时变量名,保证不重复。
参数方面,tempCount从 0 开始,生成的临时变量是t1、t2、t3。如果你的课设要求临时变量从 100 开始或者用其他前缀,改newTemp就行。symtab的作用域管理需要在进入 if 和 while 块的时候调用enterScope(),退出时调用exitScope(),这部分代码我上面省略了,你可以在generate里加对应的分支。
4.3 类型检查与隐式转换的处理边界
语义分析阶段必须做类型检查。北邮课设通常要求支持 int 和 float 两种类型,并且允许隐式转换。规则一般是:int 和 float 运算时,int 自动提升为 float。赋值的时候,float 赋给 int 变量要报错或者警告。
实现方式是在generateExpr里给每个表达式返回一个类型标记。比如Number节点根据有没有小数点判断是 int 还是 float。BinaryOp节点检查左右操作数类型,如果一个是 float 一个是 int,就在四元式里插入一条(int2float, intVal, _, temp)转换指令。这个细节很多同学会忽略,导致生成的中间代码在后续目标代码生成阶段类型不匹配。
提示:类型检查的错误信息要尽量具体。不要只报「类型不匹配」,要报「Line 12: cannot assign float to int variable 'a'」。答辩的时候老师看到这种报错信息会觉得你考虑得很周全。
5. 避坑与排查:课设答辩前必须过的五道坎
5.1 词法分析把->切成-和>
现象:源程序里的指针成员访问p->x被解析成减号和大于号,语法分析报错。原因:词法分析器的双字符运算符分支只处理了==、!=、<=、>=,漏了->。解决:在双字符运算符判断里加上-后面跟>的情况,合并成一个 token。如果你的语言不支持指针,可以忽略,但北邮课设的测试用例里经常出现这个。
5.2 递归下降解析表达式时左递归导致栈溢出
现象:程序一跑就崩溃,报 stack overflow。原因:文法里写了E -> E + T | T,递归下降直接照搬,parseE一上来就调用自己,无限递归。解决:消除左递归,改成E -> T E',E' -> + T E' | ε。代码上就是parseAdditive先调parseMultiplicative,然后在循环里处理加减号。这个坑几乎每个人都会踩一次。
5.3 符号表作用域没弹出导致变量重复声明误报
现象:在 if 块里声明了一个变量,退出 if 块之后再声明同名变量,报「重复声明」。原因:进入 if 块时调用了enterScope(),但退出时忘了exitScope()。解决:在解析块语句的函数里,用 RAII 或者手动保证enterScope和exitScope成对出现。我一般会在parseBlock函数开头enterScope(),结尾exitScope(),中间不管怎么 return 都不会漏。
5.4 四元式临时变量命名冲突
现象:生成的中间代码里出现两个t1,后续优化阶段直接混乱。原因:tempCount是全局的,但如果有多个IRGenerator实例,每个实例的tempCount都从 0 开始。解决:把tempCount改成静态成员,或者在整个编译过程中只用一个IRGenerator实例。课设规模不大,后者更简单。
5.5 语法错误报错行号偏移一位
现象:报错信息里的行号总是比实际行号小 1 或者大 1。原因:advance()里遇到换行时先line++再col=1,但 token 的line记录的是起始行号,如果 token 正好在换行符后面,行号就错了。解决:在tokenize()循环开头记录startLine和startCol,用这两个值构造 token,而不是用当前的line和col。这个坑很隐蔽,因为大部分时候行号是对的,只有跨行的时候才暴露。
6. 从四元式到目标代码:一个可验证的收尾技巧
目标代码生成是课设的最后一步,也是最能体现你编译器完整度的地方。北邮课设通常要求生成 x86 汇编或者某种简易指令集。我建议不要一上来就写完整的寄存器分配,先用一个「栈式虚拟机」作为目标,把四元式翻译成 push/pop/add/sub 这种指令。这样你能快速跑通整个流程,验证前面所有阶段的正确性。
具体做法是:定义一个指令结构{op, operand},然后遍历四元式列表。对于(+, a, b, t1),生成push a、push b、add、pop t1。对于(=, t1, _, a),生成push t1、pop a。这个映射规则非常简单,但能让你在半小时内看到从源码到目标代码的完整输出。
验证方法:写一个简单的虚拟机,用一个栈和一个变量表来执行这些指令。跑几个测试用例,比如int a = 3; int b = 4; int c = a + b * 2;,看最终c的值是不是 11。如果不对,就逐条打印指令和栈状态,定位是哪一步翻译错了。
我当年做课设的时候,最后两天才发现四元式里临时变量的生命周期有问题:t1被后面的表达式覆盖了,导致计算结果错误。后来改成每个临时变量只在当前语句内有效,生成目标代码时及时 pop 掉,问题才解决。这个血泪经验告诉我,中间代码的临时变量管理一定要有明确的规则,不能随手起名。
注意:目标代码生成阶段不要追求性能优化。课设评分看的是完整性和正确性,寄存器分配、常量折叠这些留到你有余力的时候再做。先跑通,再优化。
如果你正在做北邮编译原理课程设计,我建议你按这个顺序推进:第一天写完词法分析器并测试通过,第二天写完语法分析器能生成 AST,第三天做语义分析和四元式生成,第四天做目标代码生成和虚拟机验证,第五天留出来调 bug 和准备答辩。这个节奏看起来紧,但每一步都有明确的验证方法,不会卡死。希望帮到你。
本文还有配套的精品资源,点击获取