☰
编译原理实验指南:用C++实现词法分析、递归下降与四元式生成
2026/10/3 9:07:59 网站建设 项目流程

简介:面向杭电编译原理课程学习者的实验源代码包,用C++完成并配有可直接运行的exe程序,覆盖词法分析、NFA转DFA的子集构造法、递归下降分析与LL(1)语法分析等核心实验。资源包共11个文件,包含4个C++源文件、4个可执行程序、2个文本说明及1个SysY测试源文件,整体仅334KB,轻量便于下载与课程对照。已有1605人学习使用。借助源码、可运行程序与测试文件,可直观比对运行结果,理解词法识别、自动机转换及语法分析流程,适合正在完成相关实验或复习编译原理重点内容的本专科学生参考。

1. 编译原理实验的真相:跑通一个词法分析器,胜过抄十份源码

杭电的编译原理实验,普通安排在第三学年,实验从词法分析做到语法分析,有的班还要求做到中间代码生成或一个简易解释器,默认语言是 C++,验收方式是现场编译、现场跑、现场答。每年这时候都有人到处找“源代码”应付,结果在验收台上一问三不知——代码能跑,人过不了。这篇文章不贴所谓“完整源码”,而是把这项实验真正被检验的能力拆开讲:你如何用 C++ 把一个正则表达式变成可执行的 DFA,再把 DFA 变成递归下降的语法分析,最后顺手产出四元式。适合正在做课设、想自己动手又怕走弯路的人。下面每一段代码都可以直接改造成你自己的,坑也会逐个标出来。

2. 杭电实验的三段式结构与 C++ 选型理由:为什么不是一个“大编译器”

2.1 实验为什么是“词法—语法—中间代码”三段,而不是一个大编译器

编译原理这门课,课本从正则表达式讲到优化,到最后“生成目标代码”只有薄薄一章。杭电这类教学型课程的实验排布也遵循同一个节奏:词法分析对应教材第二章,语法分析对应第三、四章,中间代码生成对应第七章。这样拆开的好处是每一段都能单独验收、单独给分,不会出现“前面错了后面全崩”的连锁反应。网上一搜“编译原理清华大学出版社第三版第二章答案”能搜出大量文档,词法分析那章的习题答案满天飞,但实验题和教材习题的重合度其实很低。实验考的是把状态机跑起来,不是把课后题的表格画出来。

常见做法是三个实验各占一个独立工程,或者一个工程分三个编译宏开关。我见过不少同学把三段全部写进一个 main 函数,最终代码超过一千行,验收时导师随手改一个输入就崩。更稳的结构是:实验一输出 token 流,实验二吃 token 流输出语法树或直接报错,实验三在语法分析过程中顺带输出四元式。三段之间的接口只有两个——Token 的 vector 和四元式的 vector。接口定好了,每一段都可以单独改、单独测,最后才串起来。这本身就是编译原理课程想让你体会的“阶段划分”思想。

还有一个容易忽略的点:实验二的输入不是源程序字符串,而是实验一产出的 token 序列。很多人的语法分析器里还写字符扫描逻辑,属于重复造轮子。验收时导师会问“你的语法分析器输入是什么”,答“token 流”或“源代码字符串”都行,但你的代码必须和答案一致。最怕的是词法分析器输出带格式的文本,语法分析器再去解析那个文本,中间多一层不稳定。直接用 C++ 的结构体 vector 传递,内存里交接,不要落地成文件再读回来,能少踩一半坑。

2.2 C++ 选型的真实理由:STL、对象模型与工作量边界

为什么课程默认 C++ 而不是 Python?最直接的原因是编译器本身要处理“内存里的程序表示”——token、语法树、符号表,这些用 C++ 的结构体和类表达最自然,也最贴近教材里那些伪代码。另一个原因是杭电这类课程大多配的是 C++ 程序设计的先修课,STL 里的 vector、string、unordered_map 足够覆盖词法分析和语法分析的所有数据结构需求,不需要额外引入第三方库。用 Python 写确实快,但验收现场的问答环节会围绕指针、引用、内存生命周期展开,你用 Python 糊过去,问题答不上来照样扣分。

具体到数据结构选型,三个核心容器就够了:Token 用 struct 而不是 class,避免一大堆访问器方法;关键字表用 unordered_set 做 O(1) 查找;四元式用 vector 顺序存储。符号表在实验阶段不需要哈希表,一个 vector<pair<string, string>> 记录“名字 → 类型”就够,等做到中间代码生成时再用 unordered_map 加速查找。我一般会提醒第一次写的人:不要一开始就设计 Visitor 模式、抽象语法树基类这些教科书里的高级玩意儿,课设工作量摆在那里,简单直接的数据结构反而更容易通过验收。

还有一个现实原因:C++ 的调试工具链成熟。实验一的状态机出错,可以用 gdb 打断点看当前状态值;实验二的递归下降函数栈天然对应文法推导过程,栈顶函数就是当前正在展开的非终结符。这些在答辩时要讲给导师听,也是加分点。相比之下 Python 的递归栈信息对语法错误定位帮助有限,解释器的动态类型还会让“类型存错”这类问题延迟暴露。用 C++ 写编译原理实验,本质上是把你前两年学的内存、指针、STL 全部复用一遍,这是这门课隐藏的考察目标。

2.3 验收现场看什么:代码能跑只是及格线,现场问答才是拉分项

杭电编译原理实验的评分,大致是“功能分 + 代码质量分 + 答辩分”的结构。功能分看测试用例是否通过,这部分代码能跑就有;代码质量分看状态转移表是不是写死的、有没有魔数、报错信息是否带行列号;答辩分最直接——导师会随机指一个函数问你“这段在干什么”,或者给你一个新的关键字让你当场加进去。很多人的源代码是从学长那里拷来的,功能全对,但导师指着 DFA 最小化函数问“这个 partition 为什么按终态和非终态分组”,就答不上来了。

这一章想说的核心是:把实验拆成三段,每段都用最朴素的数据结构实现,保留足够多的注释和中间打印,这些才是答辩时的素材。下一章开始进入第一段,词法分析器——这里也是全文代码量最大、坑最密集的部分。

3. 用 C++ 写词法分析器:Token 定义、状态转移表与 DFA 最小化落地

3.1 先定义 Token 与符号表:头文件里不急着写状态机

很多人的第一个错误是上来就写状态转移逻辑,Token 类型只用一个整数表示。结果是报错信息只能输出“第 10 行有错”,说不出错在哪个词、哪一列。实验一的评分标准里通常有“错误定位”这一项,所以 Token 结构体里必须带行列号。我习惯在 token.h 里这样定义:

// token.h:实验一、实验二共用,不要改动接口 #ifndef TOKEN_H #define TOKEN_H #include <string> enum TokenType { T_KEYWORD, // int, float, if, else, while, return ... T_IDENTIFIER, // 变量名、函数名 T_CONSTANT, // 整数、浮点数常量 T_OPERATOR, // + - * / = < > <= >= == != T_DELIMITER, // ; ( ) { } , T_EOF // 文件结束 }; struct Token { TokenType type; std::string lexeme; // 原始字符串,比如 "while" int line; // 行号,从 1 开始 int col; // 列号,从 1 开始 }; #endif

逻辑说明:lexeme存原始文本,line和col用于报错定位。enum 按顺序排列,测试时可以写switch(token.type)按类型处理,EnumClass 在这里反而啰嗦。你可能会想加double value字段存常量数值,但实验阶段不建议加——解析数值是语法分析的事,词法分析只负责切出“这是一个数字常量”,不做类型转换。加了反而让词法分析器和语法分析器的职责边界模糊,答辩时容易被追问。

关键字表单独放一个文件。注意“c++字符串数组初始化”这个热搜词对应的需求就在这里——很多人会在 main 里写一个巨大的if-else if链判断关键字,那是坏味道。正确的是先按标识符读入完整单词,再查哈希表:

// keywords.h #pragma once #include <string> #include <unordered_set> static const std::unordered_set<std::string> kKeywords = { "int", "float", "double", "char", "if", "else", "while", "do", "for", "return", "void", "break", "continue" };

逻辑说明:词法扫描读到一串字母,先认为它是标识符,读完之后去kKeywords里查一次,命中就把 type 改成T_KEYWORD。这就是“最长匹配 + 关键字表回查”的标准做法,下一节展开讲。static const放在头文件里,多个源文件包含时各自持有一份副本,对实验规模来说无所谓,但记住了,这比你用#define宏定义关键字数组要正规得多。

3.2 状态转移表的两种组织方式:二维数组与 switch-case 的取舍

词法分析器的核心是一个 DFA。教材里画状态图,代码里要落地,常见两种做法:二维数组转移表,或者 switch-case 硬编码。二维数组更接近教材,适合写在实验报告里;switch-case 迭代更快但代码冗长。我推荐二维数组,因为验收时导师会问“你的 DFA 怎么表示的”,你指着一张小表格讲状态迁移,比在一百行 switch 里翻 case 要清楚得多。

先定义字符类别。注意这里决定转移表有多少列,类别分得越细表越精确,但写起来越烦。实验规模下分 5 类就够:字母、数字、运算符、空白、其他。列的顺序要固定,全文件统一:

// dfa_table.cpp:状态转移表,-1 表示非法转移 // 行索引 = 状态编号,列索引 = 字符类别 // 列定义:0=字母 1=数字 2=运算符 3=空白 4=其他 static const int kClassCount = 5; static const int kStateCount = 8; static const int kDfa[kStateCount][kClassCount] = { // S0 字母 数字 运算符 空白 其他 /* 0 */ { 1, 2, 3, 0, -1 }, /* 1 */ { 1, 1, -1, -1, -1 }, // 标识符/关键字 /* 2 */ {-1, 2, -1, -1, -1 }, // 整数常量 /* 3 */ {-1, -1, -1, -1, -1 }, // 运算符(按实际运算符扩展) // 状态 4-7 留给多字符运算符,如 == != <= >= };

逻辑说明:状态 0 是起始态。读到字母进状态 1,状态 1 里继续读字母或数字都留在状态 1,读到非字母数字就结束一个词;读到数字进状态 2,状态 2 只接受数字。运算符相关状态这里简写成一行,真正实现时==需要两个字符才能判定,所以状态 3 读到=要进状态 4,状态 4 读到=才输出T_OPERATOR。这正好对应教材里“识别 <= 和 < 的区别”那道经典题。

主扫描循环用一个char前看字符。注意 C++ 里peek()和get()的处理,很多翻车都发生在“读了一个字符没放回去”。我习惯用一个int lookahead变量保存当前字符,循环体开头判断:

// scanner.cpp:主扫描循环骨架 // 每次调用 NextToken() 返回一个 Token,文件读完返回 T_EOF Token NextToken() { SkipWhitespace(); // 跳过空白,内部维护行号列号 int line = currentLine, col = currentCol; int state = 0; std::string lexeme; while (state != -1) { int ch = GetChar(); // 读取一个字符,-1 表示 EOF int cls = CharClass(ch); // 映射到 0-4 的类别 int next = kDfa[state][cls]; if (next == -1) { UngetChar(); // 撤销读取,词不在状态机的接受范围内 break; } lexeme.push_back((char)ch); state = next; } return MakeToken(state, lexeme, line, col); }

逻辑说明:SkipWhitespace()负责跳过空格、制表符、换行,并在跳过时累计currentLine和currentCol,这样 Token 不用额外保存位置信息就能定位。CharClass()把字符映射成 0 到 4 的整数,字母和数字用std::isalpha/std::isdigit判断,运算符用一个 switch 匹配+ - * / < > = !。UngetChar()是关键——DFA 在某个状态发现下一个字符无转移时,这个字符属于下一个 Token,必须放回输入流。漏掉这一步,词法分析器会丢掉字符,最常见的现象是int a=1;里的a后面直接跟=时,=被吞掉。

你可能会问:如果状态 1 是接受态,但当前字符已经读过头了怎么办?这正好是“最长匹配”的实现要领——不要一看到接受态就立刻返回,要继续读,直到无转移为止。状态 1 里读字母或数字都留在状态 1,所以abc123会被完整读成一个标识符,而不是先输出abc再输出123。教材里这一点只写在图注里,代码里实现错的人非常多。

3.3 DFA 最小化:划分法的 C++ 实现与验收加分点

杭电的实验一通常有“对 DFA 进行化简”的加分要求,做法是等价类划分。原理很简单:把所有状态按“是否为终态”分成两组,然后反复检查——如果两个状态在同一组里,对任意输入字符它们跳转到的状态必须也在同一组,否则分裂。直到没有组能再分裂,同一组的状态就可以合并,最终得到一个状态数最少的 DFA。

用 C++ 实现的核心是维护一个vector<int> group数组,group[i]表示状态 i 当前属于哪一组:

// minimize.cpp:等价类划分法的核心循环 #include <vector> #include <map> // states 里标记了每个状态是否为终态,trans是原始转移表 std::vector<int> MinimizeDfa(const std::vector<int>& isFinal, const std::vector<std::vector<int>>& trans) { int n = isFinal.size(); std::vector<int> group(n, 0); int groupCount = 2; for (int i = 0; i < n; ++i) group[i] = isFinal[i] ? 1 : 0; // 初始分组:终态/非终态 bool changed = true; while (changed) { changed = false; std::map<int, std::vector<int>> split; // 新分组结果 for (int state = 0; state < n; ++state) { // 对每个状态,算出一个“签名”:对每个输入字符去到的组号 std::vector<int> signature; for (int cls = 0; cls < kClassCount; ++cls) { int target = trans[state][cls]; signature.push_back(target == -1 ? -1 : group[target]); } // 签名按顺序拼起来,用 map 收集同签名状态 int key = 0; for (int sig : signature) key = key * 10 + (sig + 1); split[key].push_back(state); } if (split.size() > groupCount) { changed = true; groupCount = split.size(); int g = 0; std::vector<int> newGroup(n, 0); for (auto& [key, statesInGroup] : split) for (int s : statesInGroup) newGroup[s] = g++; group = newGroup; } } return group; }

逻辑说明:签名的构造是整个算法的灵魂。两个状态等价的前提,是对每个字符类别都跳到等价的状态,这个“跳去哪一组”的序列就是签名。用map<int, vector<int>>收集签名相同的状态,一组就对应合并后的一个新状态。key的计算方式只是把签名序列压成一个整数,避免用vector<int>直接做 map 键的繁琐写法;如果字符类别超过 5 类,这个压法可能会溢出,改用std::map<std::vector<int>, std::vector<int>>就行了。

参数说明:isFinal数组里1表示终态,0表示非终态;trans就是上一节的kDfa转移表。最小化之后,你还需要根据group数组重新生成一张更小的转移表,这一步没什么难度,把group映射到新状态编号即可。答辩时导师会问你“初始分组为什么只分两组”,答案是非终态和终态不可能等价,因为终态意味着“识别完一个词”,非终态不是。这个问题的标准答法就是这句话,先背住。

这里有一个常见的翻车点:最小化合并状态后,老的起始态 group 编号不一定是 0,新表的起始态对应group[0]。很多人合并完直接拿 0 当起始态,结果识别全部错位,状态数倒是少了,一个词也认不出来了。正确做法是先查group[0]得到新起始态编号,再重新标记。

4. 语法分析选递归下降还是 LR 表驱动:C++ 代码骨架与四元式生成

4.1 选递归下降的三个理由:代码量、报错质量与提问环节

语法分析是编译原理实验里争议最大的一段。教材花了三章讲 LL(1)、LR(0)、SLR(1)、LR(1) 的自动机构造,实验课上真正动手写时,大多数人会问:到底手写递归下降,还是先构造分析表再写驱动?我的建议很明确:选递归下降。原因有三:第一,代码量少一个量级,LL(1) 分析表需要手动计算 FIRST 和 FOLLOW,写错一个集合整张表就废了,而递归下降的每个函数对应一个非终结符,出错时函数调用栈直接告诉你“正在展开哪个非终结符”。第二,报错质量高,递归下降天然知道当前期望什么,能输出“第 12 行:期望 < 运算符 >,实际看到标识符 a”这种带上下文的信息。第三,答辩环节导师更愿意问递归下降——因为每个函数都能指着讲,而 LR 分析表驱动是一个大循环加一张表,讲不出太多代码设计。

这里要澄清一个误区:实验要求是“语法分析器”而不是“必须用 LR”。杭电的编译原理课讲 LR 是重点,但那是理论课的重点;实验课的验收标准是“能正确判断合法/非法程序,并给出合理报错”。递归下降是文法 LL 的子集,但课设语言的文法完全可以用 LL 描述。如果你对 LR 自动机构造有执念,可以额外写一份 SLR(1) 分析表生成器,作为加分项,而不是替换递归下降。两条路都做的人也有,但那是拿优秀项目的节奏,普通目标没必要。

4.2 一个能跑通表达式与赋值语句的递归下降骨架

先定义实验语言的文法。我按常见的课设规模取一个子集,覆盖赋值、算术表达式、括号和分号:

program → stmt_list stmt_list → stmt stmt_list | ε stmt → if_stmt | assign_stmt | expr_stmt assign_stmt → ID = expr ; expr_stmt → expr ; expr → term ( + term | - term )* term → factor ( * factor | / factor )* factor → ID | NUM | ( expr )

这个文法已经是 EBNF 形式,(...)*表示循环,消除了左递归。递归下降就是让每个非终结符对应一个 bool 函数,返回 true 表示解析成功:

// parser.h:递归下降语法分析器 #include "token.h" #include <vector> class Parser { public: explicit Parser(const std::vector<Token>& tokens) : tokens_(tokens), pos_(0) {} bool ParseProgram() { while (!Check(T_EOF)) { if (!ParseStatement()) { ReportError("非法语句"); return false; } } return true; } private: bool ParseStatement() { // if 开头走 if 分支,ID 后跟 = 走赋值,否则按表达式语句处理 if (Check(T_KEYWORD) && Current().lexeme == "if") return ParseIf(); if (Check(T_IDENTIFIER) && PeekNext().type == T_OPERATOR && PeekNext().lexeme == "=") return ParseAssign(); return ParseExprStmt(); } bool ParseAssign() { Advance(); // 吃掉 ID Advance(); // 吃掉 = if (!ParseExpr()) return false; if (!Match(T_DELIMITER, ";")) { ReportError("赋值语句末尾缺少分号"); return false; } return true; } bool ParseExpr() { if (!ParseTerm()) return false; while (Match(T_OPERATOR, "+") || Match(T_OPERATOR, "-")) { if (!ParseTerm()) return false; } return true; } bool ParseTerm() { if (!ParseFactor()) return false; while (Match(T_OPERATOR, "*") || Match(T_OPERATOR, "/")) { if (!ParseFactor()) return false; } return true; } bool ParseFactor() { if (Match(T_IDENTIFIER)) return true; if (Match(T_CONSTANT)) return true; if (Match(T_DELIMITER, "(")) { if (!ParseExpr()) return false; return Match(T_DELIMITER, ")"); } ReportError("期望标识符、常量或左括号"); return false; } // Check / PeekNext / Match / Advance / Current 是辅助函数,下一节补全 };

逻辑说明:ParseExpr对应term ( + term | - term )*,先调ParseTerm解析第一个因子,再用 while 循环处理+或-的重复。这个结构的巧妙之处在于运算符优先级是嵌套在调用层级里的——expr调term,term调factor,所以a + b * c会被正确解析成a + (b * c)。如果你把加减乘除都写在同一个函数里平铺循环,优先级就会变成从左到右,这是新手最容易翻车的地方。

补充一个细节:ParseStatement里的“ID 后跟 =”判断依赖PeekNext()提前看下一个 token。如果当前是 ID 而下一个是=,这是赋值语句;否则是表达式语句。这种“前看两个 token”的判断在递归下降里很常见,代价是代码里要多写几个辅助函数,但能避免回溯。回溯在递归下降里是灾难——它会把报错信息变混乱,因为你不知道哪个分支才是对的。

辅助函数的关键实现:

// parser_helpers.cpp:Parser 内部辅助函数 bool Check(TokenType type) const { return pos_ < tokens_.size() && tokens_[pos_].type == type; } bool Match(TokenType type, const std::string& lexeme) { if (pos_ < tokens_.size() && tokens_[pos_].type == type && tokens_[pos_].lexeme == lexeme) { ++pos_; return true; } return false; } const Token& PeekNext() const { // 越界时返回一个静态的 EOF Token,避免写 if 判断 static const Token kEof = { T_EOF, "", -1, -1 }; return pos_ + 1 < tokens_.size() ? tokens_[pos_ + 1] : kEof; }

参数说明:Match是递归下降里最常用的函数,它既做“当前 token 是否符合预期”的判断,又做“吃掉 token”的动作。PeekNext返回一个静态的kEof来兜底越界,这是 C++ 里避免“返回引用指向局部变量”的惯用写法。很多人的段错误就出在这里——PeekNext直接返回tokens_[pos_ + 1],而 pos_ 已经在最后一个 token 上,越界访问。用静态对象的引用做兜底,一劳永逸。

4.3 左递归消除与优先级:把文法改写成 EBNF 再落成 C++ 的步骤

教材里的表达式文法长这样:

E → E + T | T T → T * F | F F → ( E ) | id | num

这个文法是正确的上下文无关文法,但不能直接写递归下降,因为ParseE的第一行就要调ParseE,无限递归。消除左递归的标准做法是改写为右递归或 EBNF 循环。右递归版本是:

E → T E' E' → + T E' | ε

对应的 C++ 要写两个函数ParseE和ParseEPrime,其中ParseEPrime先判断当前 token 是不是+,是就继续,不是就返回 true(空串)。这个写法正确但别扭,因为 ε 分支让代码多一层,而循环版本更直观:

// 把 E → E + T | T 变成 while 循环 bool ParseE() { ParseT(); while (NextIsPlus()) { Advance(); ParseT(); } return true; }

两种写法在功能上等价,但循环版本的处理顺序和代码阅读体验更好。EBNF 里的*本质上就是 while 循环,所以我在实验语言里直接用 EBNF 定义文法,省去“手动消左递归”这一步。写报告时把文法定义成 EBNF,代码和报告完全对应,答辩时不用额外解释“E' 对应哪个函数”。

优先级处理的要诀,一句话:优先级越低的运算,对应越外层的函数。加减在最外层(ParseExpr),乘除在中间层(ParseTerm),括号和因子在最里层(ParseFactor)。如果你想加一元负号-a,在ParseFactor里加一个分支;想加幂运算a ^ b(结合性是右结合,优先级高于乘除),需要加一个比ParseTerm更深的ParsePower,且循环里不能简单 while——右结合要用递归而不是循环实现。这属于进阶扩展,实验能跑通加减乘除和括号,就已经覆盖大纲要求。

4.4 顺手生成四元式:把语法制导翻译嵌进下降函数里

很多实验要求“在语法分析过程中生成中间代码”,也就是语法制导翻译。做法很朴素:在递归下降的循环里,每归约一个产生式,就往四元式数组里 push 一条。先是四元式的结构定义:

// quad.h:四元式定义与输出 #include <string> #include <vector> struct Quad { std::string op; // 运算符:+ - * / = JMP JZ std::string arg1; // 左操作数 std::string arg2; // 右操作数,可空 std::string result; // 结果变量或跳转目标 }; static std::vector<Quad> g_quads; // 全局四元式表,实验规模够用 static int g_tempIndex = 0; std::string NewTemp() { return "t" + std::to_string(++g_tempIndex); }

逻辑说明:NewTemp()生成形如t1、t2的临时变量名。全局变量在课设规模是能接受的,省去到处传引用的麻烦,但答辩时你最好补一句“真实项目不会用全局变量,这里为了实验简洁”。这句话能显得你懂工程实践。四元式的输出格式通常要求对齐打印,报告里贴出来像这样:

1: + a b t1 2: * t1 c t2

然后把生成逻辑嵌进ParseTerm。原来的ParseTerm只做“是否匹配”,现在要让ParseFactor返回操作数的名字,在循环里生成临时变量:

// parser_translate.cpp:在 ParseTerm 里生成算术四元式 std::string ParseTerm() { std::string left = ParseFactorValue(); // 返回 "a" 或 "3" 或临时变量名 while (Match(T_OPERATOR, "*") || Match(T_OPERATOR, "/")) { std::string op = Previous().lexeme; // 上一轮 Match 吃掉的运算符 std::string right = ParseFactorValue(); std::string result = NewTemp(); g_quads.push_back({op, left, right, result}); left = result; // 关键:链式运算的左手边更新 } return left; }

逻辑说明:a * b * c的翻译过程是——第一次循环生成* a b t1,第二次循环左手边从a变成了t1,生成* t1 c t2。这个“左手边更新”是四元式生成最容易漏的一步。漏掉的话,输出会变成* a b t1和* a c t2,两个计算互不关联,c直接参与乘法,中间结果t1被丢弃。实验验收时,导师只要拿a * b * c一跑就能看出来。

赋值语句的翻译更简单。ParseAssign里解析完表达式拿到右边结果,直接生成一条=四元式:

// 赋值语句的翻译 bool ParseAssign() { std::string name = Current().lexeme; // 变量名 Advance(); // 吃掉 ID Advance(); // 吃掉 = std::string value = ParseExprValue(); g_quads.push_back({"=", value, "", name}); Match(T_DELIMITER, ";"); // 吃掉分号 return true; }

逻辑说明:四元式= value "" name表示把value赋值给name,arg2 留空。跳转相关的四元式(if 语句的JZ)需要回填处理,这放下一章讲,因为回填是坑最密集的地方。

5. 编译原理实验常见问题与排查:源代码跑不起来先查这五处

5.1 一运行就段错误:数组越界还是对象生命周期问题

现象:输入一个最简单的int a = 1;,程序直接崩,终端打印 Segmentation fault,没有任何报错信息。用 gdb 打断点在 main 函数入口,逐行执行到第二次调用NextToken()时崩溃。

原因:PeekNext()或扫描循环里越界访问 token 数组。最常见的是词法分析器在UngetChar()的实现上出错——用一个字符变量保存“回退的字符”,结果连续回退两次把变量覆盖了,输入流错位。语法分析器那边则是pos_已经等于tokens_.size(),还去访问tokens_[pos_ + 1]。

解决:给所有辅助函数加边界检查。PeekNext()用静态 EOF Token 兜底,Check()和Match()里先判断pos_ < tokens_.size()。词法分析器的UngetChar()用一个独立栈保存回退字符,不要用单一变量;回退两个字符的情况在识别<=和==时一定会出现。写完这两处,段错误基本绝迹。

5.2 关键字被当成标识符:最长匹配的扫描顺序错了

现象:输入int a;,词法输出第一个 token 是T_IDENTIFIER,lexeme 是int,而不是T_KEYWORD。语法分析器看到int不是标识符,报“非法语句”。

原因:扫描循环在状态 1(标识符状态)里遇到空白字符时,DFA 无转移直接返回了 token,类型标记为标识符,没有查关键字表。这是扫描逻辑顺序问题——三个动作的先后必须是“读完整词 → 查关键字表 → 决定类型”,而不是“在状态机里提前判断”。

解决:在MakeToken里加上关键字表回查:

// MakeToken:状态机识别完成后统一处理 Token MakeToken(int state, const std::string& lexeme, int line, int col) { Token tok; tok.lexeme = lexeme; tok.line = line; tok.col = col; if (state == 1) { auto it = kKeywords.find(lexeme); tok.type = (it != kKeywords.end()) ? T_KEYWORD : T_IDENTIFIER; } else if (state == 2) { tok.type = T_CONSTANT; } else { tok.type = T_OPERATOR; } return tok; }

逻辑说明:只有走出状态 1 的词才可能是关键字,所以只在state == 1时查表。有些人的做法是在扫描循环里每个字符都判断“当前字符串是不是关键字”,那是错的——intx的前两个字符构不成int就漏判,而且会误判in。查表必须在完整读词后进行。

5.3 递归下降死循环:空产生式与 EOF 边界没处理

现象:程序跑起来不崩溃,但也不结束,CPU 占用 100%,像是卡死了。打印日志发现ParseProgram无限调用ParseStatement,而ParseStatement返回 true 但pos_没变。

原因:文法里有stmt → ε(空产生式),但代码里没处理“当前 token 不属于 stmt 的 FIRST 集就退出”的情况。典型场景是输入文件末尾多了一个换行,词法分析器没有输出 T_EOF,语法分析器拿到空 token 流,每轮都匹配失败但也不报错,pos_ 永远是 0。

解决:ParseProgram的循环条件必须是!Check(T_EOF),并且ParseStatement里所有分支都不匹配时,直接返回 false 让外层报错退出,不要静默返回 true。另一个检查点是词法分析器:文件读取到末尾必须显式输出一个T_EOFtoken,而不是返回空 vector。养成写好这两个边界的习惯,死循环基本不会出现。

5.4 四元式跳转目标全是 -1:迭代器失效还是回填时机错

现象:if (a > b) c = 1;生成的中间代码里,JZ四元式的result是-1或者空字符串,只有if没有跳转目标,中间代码无法继续处理。

原因:if 语句的翻译需要在生成条件表达式之后、生成语句体之前,先留一条JZ四元式,等语句体生成完再把实际跳转标签填回去。很多人的代码是这样写的:auto& quad = g_quads.back();拿到引用,然后 push 更多四元式,回头再改quad——g_quads是std::vector,push 导致扩容,引用失效,回填全部落空。

解决:不要存引用,存下标。回填时用下标访问:

// 正确的回填方式:存下标而不是存引用 int jumpIndex = g_quads.size(); g_quads.push_back({"JZ", condResult, "", ""}); // result 待回填 ParseStatement(); // 生成语句体的四元式 g_quads[jumpIndex].result = "L" + std::to_string(label++);

逻辑说明:jumpIndex是size_t类型,在 push 之后依然有效。回填的本质是“先占位,后补地址”,这是编译原理教材里明确讲的案例,代码里用下标实现最稳妥。另一个常见错误是回填的标签编号冲突——if 和 while 都用一个label++全局计数器就没问题,如果每个分支里单独初始化计数器,标签就会重复,跳转全乱。

5.5 中文注释与文件编码:VSCode 配置下 C++ 源码的常见翻车

现象:代码在本机 Visual Studio 或 VSCode 里编译运行正常,拿到实验室的 Linux 机器上一编译,注释乱码,或者词法分析器把中文注释里的字符当成非法输入直接报错。

原因:Windows 下 VSCode 默认可能是 GBK 编码保存源文件,Linux 的 GCC 默认按 UTF-8 解析。注释里的中文变成乱码还算小事,字符串或注释里的全角符号(比如中文分号“;”)会被词法分析器的CharClass映射成“其他”类别,触发非法字符报错。

解决:统一编码。VSCode 里点右下角编码按钮,选择“通过编码保存”为 UTF-8。编译命令加参数指定输入编码:g++ -finput-charset=UTF-8 -std=c++17 main.cpp。词法分析器这边,CharClass里对“其他”类别的处理要输出明确报错,带上行列号,而不是静默跳过——静默跳过会让非法字符凭空消失,程序“能跑”但行为错误。这条属于环境坑,和编译原理本身无关,但每年都有不少人死在验收前的最后一步。

6. 用随机用例验证整条流水线:一份能自证正确的最小测试方案

实验做完后最重要的一个习惯,是不要用手敲的三五个用例验证。手敲用例覆盖不到上下文边界,比如a*b+c和a*(b+c)的优先级差异,再比如if嵌套while。我建议写一个最简单的随机程序生成器,批量生成合法程序,再准备一小批非法的,跑全链路验证。

// test_generator.cpp:生成随机合法程序用于回归 // 用法:./generator 100 | ./compiler --parse // 100 表示生成 100 条语句 #include <cstdlib> #include <iostream> #include <string> #include <vector> std::string RandExpr(int depth) { if (depth <= 0) { return (rand() % 2) ? "a" : std::to_string(rand() % 100); } int op = rand() % 4; const char* ops[] = {"+", "-", "*", "/"}; std::string l = RandExpr(depth - 1); std::string r = RandExpr(depth - 1); return "(" + l + " " + ops[op] + " " + r + ")"; } int main(int argc, char* argv[]) { int n = argc > 1 ? std::atoi(argv[1]) : 100; srand(42); // 固定种子,保证可复现 for (int i = 0; i < n; ++i) { if (rand() % 3 == 0) { std::cout << "a = " << RandExpr(3) << ";\n"; } else { std::cout << "b = " << RandExpr(2) << ";\n"; } } return 0; }

逻辑说明:固定随机种子 42 保证每次生成的用例完全一致,这是可复现测试的底线。RandExpr用递归深度控制表达式嵌套,深度 3 会生成类似((a + 3) * (b - 8))的结构,覆盖括号和运算符优先级。为什么用(expr op expr)而非expr op expr?为了让生成的表达式总是带括号,避免优先级歧义把“合法程序”误判成“非法”。测试生成器只负责制造合法输入,不该制造边界争议。

随机合法程序能验证“不该报错时别报错”,但还要准备非法样例验证报错路径:缺分号、括号不匹配、a + = 1这类运算符连续、空文件、只有注释、超长标识符。这些样例应该手写,放进一个invalid_cases/目录,一条条跑,确认每条都报错且报错位置合理。我的习惯是保留三份固定文件:valid_min.txt(最小合法集)、valid_random.txt(随机生成)、invalid_cases.txt(非法样例),每次改完代码都先跑这三份再提交,这个习惯帮我躲过了至少三次验收现场翻车。最后说一句:编译原理实验的价值不在源码本身,而在于你亲手把“字符串变成结构化表示”这个过程走了一遍,这套思维在以后写解释器、写 DSL、做静态分析时都会回来找你。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询