编译原理实验C++实现:词法分析、语法分析与中间代码生成
2026/9/23 23:11:44 网站建设 项目流程

简介:面向杭电编译原理课程实验的配套源代码包,覆盖词法分析、非确定到确定自动机的子集构造、递归下降分析与LL(1)语法分析四个核心模块,适合正在学习编译器构造的学生对照实践。资源共11个文件,其中C++源文件与可执行程序各4个,另有文本说明和SysY测试样例,包体仅334KB,轻量易读。已有1605人访问学习。通过阅读源码,可掌握SysY语言词法分析器中八进制、十六进制常量的识别方法,以及块注释与行注释两种格式的处理逻辑;子集构造部分展示NFA到DFA的转换实现,递归下降分析模块提供典型的递归子程序框架,LL(1)部分则体现预测分析表驱动的解析过程。可执行文件便于直接运行验证,测试输出文本有助于对比结果,整体适合课内作业参考与考前复习。

1. 编译原理实验源代码:先把课程实验的边界说清楚,再决定怎么写

“编译原理实验 杭电 源代码 C++”这个标题背后,是一套用 C++ 写的编译原理课程实验代码,覆盖词法分析、语法分析、语义分析与中间代码生成。杭电这类学校的编译原理课,普遍把实验拆成三次上机加一次综合设计,源码工程要能一个命令编译、三个模块顺序执行、最后输出可读的中间代码文件。我见过太多人从各种渠道拿到“完整源码”,却跑不出老师给的测试点,原因基本不是算法不会,而是代码组织和边界处理出了问题。这篇笔记就按我做这类实验的路径来讲:工程怎么搭、词法状态机怎么写、递归下降怎么保证不崩,以及哪些地方最容易让测试点翻车。适合理工科正在赶实验的学生,也适合毕业设计选了编译器方向、打算拿现成框架改一版的人。

2. C++ 工程骨架:源码阅读器、Token 定义与三个模块的分工

2.1 目录怎么分:头文件、实现、测试数据各归其位

课程实验代码最忌讳把所有内容塞进一个 main.cpp。编译原理实验天然分三个阶段:词法分析输出 Token 流,语法分析消费 Token 流,语义分析和中间代码生成挂在语法动作里。按阶段分目录,后续查错会省很多时间。我一般用这样的结构:

compiler-lab/ ├── CMakeLists.txt # 用 CMake 组织构建 ├── include/ │ ├── source.hpp # 源码字符流读取器 │ ├── token.hpp # Token 类型与结构 │ ├── lexer.hpp # 词法分析器 │ ├── parser.hpp # 递归下降语法分析器 │ └── symtab.hpp # 符号表 ├── src/ │ ├── source.cpp │ ├── lexer.cpp │ ├── parser.cpp │ └── symtab.cpp ├── tests/ │ ├── case01_basic.txt # 最小测试用例 │ └── case02_expr.txt └── output/ ├── tokens.txt # 词法输出 └── quads.txt # 四元式输出

这样划分的理由很直接:词法分析器只依赖 source.hpp,不碰语法分析的任何头文件;语法分析器只消费 Token,不关心 Token 是怎么扫描出来的。依赖单向流动,哪个模块崩了,直接看对应目录下的实现。CMakeLists 里只需要把 src 下的 cpp 全部编进来,测试用例放在 tests 目录,输出统一进 output 目录,避免可执行文件和工作目录混在一起导致相对路径读不到文件。

2.2 先写 SourceFile 而不是直接写 Lexer:行号、列号与 unread 的现实理由

很多课程实验代码把文件读进一个 string,然后 Lexer 直接按下标取字符。这种做法在报错阶段会很难受:语法分析报错要定位到第几行第几列,如果字符串里没有行号索引,就得每次从头数换行符。所以代码的第一步,是写一个带行号、列号维护的源码读取器,这是我做这类实验时第一个落地的东西。

// src/source.cpp #include "source.hpp" #include <fstream> #include <stdexcept> bool SourceFile::open(const std::string& path) { std::ifstream in(path, std::ios::binary); if (!in) return false; buffer_ = std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); pos_ = 0; line_ = 1; col_ = 1; return true; } char SourceFile::next_char() { if (pos_ >= buffer_.size()) { return EOF; // 文件结束统一返回 EOF,不抛异常 } char c = buffer_[pos_++]; if (c == '\n') { line_++; // 只有在真正消费换行符时更新行号 col_ = 1; } else { col_++; } return c; } void SourceFile::unread_char() { if (pos_ == 0) return; char c = buffer_[--pos_]; if (c == '\n') { line_--; // 回退时同步恢复行号 col_ = 1; } else if (col_ > 1) { col_--; } }

读取器只干三件事:逐字符读取、记录当前位置、支持回退一个字符。回退功能在词法分析里几乎是刚需,因为识别完一个标识符或数字后,总会多读一个不属于当前 Token 的字符,需要放回去。这里有个细节:unread_char 必须同步回滚行号和列号,否则回退到换行符前面的字符后,行号会多算一行。我也见过直接在 next_char 里做行号累计、但 unread 不处理行号的做法,报错信息错得莫名其妙,排查起来非常折磨。

2.3 用什么 C++ 版本:C++17 顺手,老机房则退到 C++98

C++ 标准的选择直接影响代码写法。如果可以自由选择,我建议直接用 C++17,理由有三个:std::string_view做 Token 文本引用性能好且不拷贝,结构化绑定让遍历符号表更清爽,if constexpr没必要但在实验里可以用来处理泛型打印逻辑。但很多学校的实验机房还是老版本编译器,甚至 Dev-C++ 5.x 默认走 C++98,这种情况就不要硬上 C++17 特性,否则编译报错一堆,浪费一个晚上。

# CMakeLists.txt 中指定标准,两个版本都能跑 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON)

如果必须要兼容老环境,代码里避开std::string_view,统一用const std::string&传参;能不用auto就不用,避免老编译器对类型推导的兼容性问题。有一点要提醒:实验代码的目标不是写出多现代的 C++,而是稳定复现教材里的编译过程。清华版编译原理教材第三版是理论依据,实验代码的每个模块几乎都能在教材里找到对应算法,把教材里的数据结构翻译成 C++ 类,比从零设计一套精妙的架构更稳妥。

3. 词法分析器:用一手状态机替代正则库,才能对得上测试点

3.1 Token 设计与类型枚举:做完词法分析,后面所有阶段都只认 Token

词法分析器的输出是 Token 流,Token 的结构决定了后面语法分析怎么写。课程实验里 Token 不需要很复杂,核心字段就四个:类型、文本、行号、列号。行号和列号必须保留,语法分析报错、老师核对测试点都要用。

// include/token.hpp #ifndef TOKEN_HPP #define TOKEN_HPP #include <string> enum class TokenType { IDENT, // 标识符 INTEGER, // 整型字面量,比如 123 KEYWORD, // 保留字,比如 int、if OP, // 运算符,比如 + - * / = DELIM, // 界符,比如 ; ( ) { } EOF_TOKEN // 文件结束标志 }; struct Token { TokenType type; std::string text; int line; int col; std::string type_name() const { switch (type) { case TokenType::IDENT: return "IDENT"; case TokenType::INTEGER: return "INTEGER"; case TokenType::KEYWORD: return "KEYWORD"; case TokenType::OP: return "OP"; case TokenType::DELIM: return "DELIM"; case TokenType::EOF_TOKEN: return "EOF"; } return "UNKNOWN"; } }; #endif

把 KEYWORD 单独列出来而不是让词法分析直接返回 IDENT,是因为语法分析阶段要频繁判断“当前 Token 是不是 if”“是不是 int”,有了独立类型,匹配函数可以少写一个字符串比较。这里的取舍是:Token 文本直接拷贝一份 string 还是保存源码里的区间引用?课程实验规模小,直接拷贝就行,不必用 string_view 做优化,代码更直观。

3.2 手工状态机扫描:一个循环吃遍所有字符

识别标识符和整型字面量是词法分析最核心的部分。常见做法是手写状态机,不用正则库的原因是库的行为和老师期望的识别规则常有出入,测试点一跑就暴露差别。核心扫描逻辑长这样:

// src/lexer.cpp bool Lexer::next(Token& tok) { skip_whitespace_and_comments(); // 跳过空白与注释 int start_line = reader_.line(); int start_col = reader_.col(); char c = reader_.next_char(); tok.line = start_line; tok.col = start_col; tok.text.clear(); if (c == EOF) { tok.type = TokenType::EOF_TOKEN; return false; } // 标识符或保留字:字母或下划线开头 if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') { do { tok.text.push_back(c); c = reader_.next_char(); } while (std::isalnum(static_cast<unsigned char>(c)) || c == '_'); reader_.unread_char(); // 多读的字符放回去 tok.type = is_keyword(tok.text) ? TokenType::KEYWORD : TokenType::IDENT; return true; } // 整型字面量:数字开头 if (std::isdigit(static_cast<unsigned char>(c))) { do { tok.text.push_back(c); c = reader_.next_char(); } while (std::isdigit(static_cast<unsigned char>(c))); reader_.unread_char(); tok.type = TokenType::INTEGER; return true; } // 运算符与界符走同一分支,双字符运算符需要向前看一位 return scan_operator_or_delim(c, tok); }

这段逻辑要做两件事:识别 Token 并设置正确的行号和列号,以及管理字符回退。注意标识符循环结束后那个 unread_char,因为 while 条件里多读的字符不属于当前 Token,必须放回缓冲区,否则下一个 Token 会丢掉第一个字符。整型字面量同样处理。start_line 和 start_col 在扫描开始前记录,Token 内部保存的是起始位置,这对语法分析定位错误很有用。

3.3 运算符与双字符运算符:向前看一位就能避开匹配陷阱

运算符里面最容易出问题的是双字符运算符,===<<=看起来相似,语义完全不同。扫描到第一个字符后,要向前看一个字符决定是单字符还是双字符 Token。我用一个分支函数处理:

// src/lexer.cpp bool Lexer::scan_operator_or_delim(char c, Token& tok) { char next = reader_.next_char(); bool is_double = false; // 双字符运算符的候选组合 if ((c == '=' && next == '=') || (c == '!' && next == '=') || (c == '<' && next == '=') || (c == '>' && next == '=') || (c == '&' && next == '&') || (c == '|' && next == '|')) { is_double = true; } if (is_double) { tok.text.push_back(c); tok.text.push_back(next); tok.type = TokenType::OP; return true; } // 单字符运算符或界符 if (next != EOF) { reader_.unread_char(); // 不是双字符,放回 next } tok.text.push_back(c); tok.type = is_operator(c) ? TokenType::OP : TokenType::DELIM; return true; }

这里有个边界情况很阴:文件末尾只有一个=,next_char 返回 EOF,此时不能直接 unread,否则缓冲区下标会被改乱。我加了个next != EOF判断,EOF 不进入回退分支。测试点如果包含+这类合法单字符运算符,代码走正常的放回逻辑,没有任何问题。

4. 递归下降语法分析:文法消除左递归、语义动作与符号表同步做

4.1 消除左递归:为什么直接按教材文法写会栈溢出

递归下降分析器要求文法不含左递归。教材里表达式的经典文法E -> E + T | T直接翻译成函数,会无限递归直到栈溢出。必须先把文法改写成等价的右递归或迭代形式。我最常给实验用的表达式文法如下:

expr -> term { (+|-) term } term -> factor { (*|/) factor } factor -> INTEGER | IDENT | ( expr )

这个文法用花括号表示循环,对应到递归下降代码里就是一个 while。它的巧妙之处在于,优先级通过“层”体现:expr 层处理加减,term 层处理乘除,factor 层处理括号和原子项。消除左递归后,每个非终结符对应一个解析函数,函数间互相调用形成下降路径,输入串从左到右扫描一遍即可完成语法检查。

// src/parser.cpp bool Parser::parse_expr() { if (!parse_term()) { return error("表达式缺少操作数", peek()); } while (match_op("+") || match_op("-")) { std::string op = previous().text; // 记录当前运算符 if (!parse_term()) { return error("运算符后缺少操作数", peek()); } do_semantic_action("BINOP", op, temp_var()); // 生成四元式 } return true; } bool Parser::parse_term() { if (!parse_factor()) { return error("term 层无法解析", peek()); } while (match_op("*") || match_op("/")) { std::string op = previous().text; if (!parse_factor()) { return error("运算符后缺少因子", peek()); } do_semantic_action("BINOP", op, temp_var()); } return true; } bool Parser::parse_factor() { if (match(TokenType::INTEGER) || match(TokenType::IDENT)) { return true; } if (match_op("(")) { if (!parse_expr()) return false; if (!match_op(")")) { return error("缺少右括号", peek()); } return true; } return error("无法识别的因子", peek()); }

每个函数的结构高度一致:先判断当前 Token 是否是该层能处理的起始符号,然后循环处理后续操作符。令牌匹配用 match 和 match_op 两个函数,match 成功就消费 Token 并返回 true,失败则不动 Token。这样一套实现下来,错误恢复策略也简单:在某个层解析失败就立即返回,由上层决定是否终止还是尝试其他分支。递归下降代码的调试效率远高于查 LL(1) 分析表,这也是这类实验普遍选择它的原因。

4.2 四元式输出:语义动作和语法分析同步做

中间代码生成在课程实验里一般用四元式表示,四元式的四个字段是操作符、左操作数、右操作数和结果。语法分析过程中,每遇到一个运算符就调用一次语义动作,生成一条四元式并写入文件。这样做的好处是语法分析结束后,中间代码文件也有了,不需要再遍历一遍语法树。

// src/parser.cpp void Parser::do_semantic_action(const std::string& op, const std::string& arg1, const std::string& arg2, const std::string& result) { quads_.push_back({op, arg1, arg2, result}); } std::string Parser::temp_var() { return "t" + std::to_string(temp_counter_++); }

对于a + b * c这样输入,语法分析生成的四元式序列一般是:先处理 b * c 生成临时变量 t0,再处理 a + t0 生成 t1。这个结果和教材里的中间代码示例一致,测试点核对的就是这种格式。四元式最好用定长结构体存储,输出时按固定格式打印。每个临时变量用一个计数器生成,保证名字不冲突。

4.3 符号表:实验规模不需要哈希表,线性查找反而更方便

符号表的实现选型常被过度设计。课程实验的源码文件一般只有几百行,符号数量在几十到几百个之间,用std::unordered_map反而会丢失符号的声明顺序,而实验报告经常要求“按声明顺序输出符号表”。我一般用 vector 加线性查找:

// src/symtab.cpp int SymTab::lookup(const std::string& name) { for (size_t i = 0; i < entries_.size(); ++i) { if (entries_[i].name == name) { return static_cast<int>(i); } } return -1; } int SymTab::insert(const std::string& name, const std::string& type) { // 如果已经存在,返回原下标;否则追加一条 int idx = lookup(name); if (idx >= 0) return idx; entries_.push_back({name, type, /* line */ 0}); return static_cast<int>(entries_.size() - 1); }

线性查找的复杂度是 O(n),对实验规模来说完全够用。处理变量重复声明很直接:插入前先查一遍,已存在且同作用域就报语义错误,否则正常追加。符号表行号字段在构造时记录,后续查错可以直接引用到源码行。这里要跟写哈希表的同学说一句:实验报告不需要你在复杂度上体现优越感,稳定可复现的结果才是得分关键。

5. 编译原理实验避坑记录:死循环、错位行号与悬垂 else

5.1 一跑测试点就卡死,任务管理器里 CPU 拉满

现象:词法分析器处理含中文注释或非法字符的源码时程序完全无响应。原因:扫描遇到不认识的字符,代码没有消费它就直接返回,外层循环反复调用 next 得到同一个非法字符,形成死循环。解决:在 Lexer 主循环里加一个 default 分支,遇到任何类型都匹配不上的字符,记录错误信息并强制读取下一个字符,保证循环必然推进。

// src/lexer.cpp // 默认分支:非法字符强制消费,避免死循环 { error_list_.push_back("无法识别的字符: " + std::string(1, c)); tok.type = TokenType::IDENT; // 给一个无害的兜底类型 tok.text = "ERROR"; return true; // 已经用掉一个字符,下次必然前进 }

这个 fix 的价值在于把“分析器遇到未知输入”从行为不确定变成行为确定。错误收集在 error_list_ 里,最后统一输出,不会漏报也不会卡死。我做实验时第一次遇到这种情况,怀疑是自己的状态机逻辑错了,排查了半天才发现是注释里的中文字符被逐字节读出来,没有匹配分支。

5.2 报错信息里行号永远是 1,或者错位到完全对不上

现象:词法分析报错永远指向第一行,语法分析报错位置离谱。原因:大多数情况下是 unread_char 回退时没有恢复 line 和 col,或者 token 行号在扫描过程中被后续字符更新覆盖了。解决:在 Token 结构里,line 和 col 在扫描开始前就固定记录,后续无论读多少字符都不改动。我的代码里 start_line 和 start_col 用局部变量保存,然后只赋值一次,后续字符的读取完全不碰这两个变量。

// src/lexer.cpp int start_line = reader_.line(); int start_col = reader_.col(); // 扫描过程中 reader_ 的 line / col 会变,但 start_* 不会 tok.line = start_line; tok.col = start_col;

如果你发现报错行号差一行,多半是文件末尾多了一个换行符,或者 windows 的 \r\n 被当成两个字符处理。在 open 阶段把 \r 过滤掉,可以让行号统计只依赖 \n,处理跨平台文件更可靠。

5.3 悬垂 else:if 的匹配和你想要的不一样

现象:语法分析对if (a) if (b) c = 1; else d = 2;的处理,else 跟了内层 if,但语义分析阶段属性计算出来结果不对。原因:递归下降天然最内层匹配,else 总是结合最近的 if,这是很多程序语言的设计选择,符合 C++ 标准行为,但如果你没在文法里显式定义,考试题和实验报告里的解释要自洽。解决:实验场景下明确采用最近匹配策略,在代码注释里写清楚,然后在报告里说明这是递归下降实现方式的自然行为,不做特殊处理。

// src/parser.cpp // 最内层匹配:else 优先绑定最近的未匹配 if // 这是递归下降实现的自然语义,无需额外栈结构 if (match(TokenType::KEYWORD, "if")) { if (!parse_expr()) return false; if (!match_op(")")) return error("if 条件缺少右括号", peek()); if (!parse_statement()) return false; if (match(TokenType::KEYWORD, "else")) { if (!parse_statement()) return false; } return true; }

这段代码刻意没有做悬垂 else 修正,是推荐做法。因为多数课程实验不要求 else 绑定规则可配置,保持最内层匹配反而和 C++ 实际行为一致,报错概率更低。

5.4 123abc 被整体识别还是拆开识别?测试点说了算

现象:输入int a = 123abc;,有的实现报词法错误,有的实现拆分出123abc,还有的完全卡住。原因:数字扫描结束后遇到字母,不同的实验指导书要求不同。常见做法是拆开,词法分析阶段不检查这种跨类别粘连,交给语法分析去报错。解决:数字扫描后,多读的字母放回缓冲区,让下一个 Token 从字母开始识别,这样错误定位在语法分析阶段也更准确。

// src/lexer.cpp // 数字后紧跟字母,把字母放回去,拆分处理 if (std::isalpha(static_cast<unsigned char>(c))) { reader_.unread_char(); // 让字母成为下一个 Token 的开头 }

但要注意有些测试点明确要求报“非法标识符”错误,拆分会直接导致测试点判错。看清实验指导书再决定,拿不定的情况在报告里写清楚你的处理方式,一般不会被扣分。

5.5 注释跨行导致 token 错乱

现象:/* 注释里包含换行符,注释结束后 parser 报错位置偏移。原因:跳过注释时直接把换行符丢弃,但行号没有同步更新。解决:在 skip_whitespace_and_comments 里,遇到注释内容时逐字符读入并调用 reader_ 自己的 next_char,让行号统计自然推进,而不是用整行字符串处理。

// src/lexer.cpp void Lexer::skip_whitespace_and_comments() { for (;;) { char c = reader_.peek_char(); if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { reader_.next_char(); // 消费,行号由 reader_ 维护 continue; } if (c == '/' && reader_.peek_next_char() == '/') { while (reader_.peek_char() != '\n' && reader_.peek_char() != EOF) { reader_.next_char(); } continue; } if (c == '/' && reader_.peek_next_char() == '*') { reader_.next_char(); // 消费 '/' reader_.next_char(); // 消费 '*' while (true) { char ch = reader_.next_char(); if (ch == EOF) break; // 注释未闭合,直接终止 if (ch == '*' && reader_.peek_char() == '/') { reader_.next_char(); break; } } continue; } break; } }

块注释内部的所有换行都会经过 next_char 正常更新行号,注释结束后行号是准确的。未闭合注释在 EOF 处自然终止,不会造成死循环,也能在错误报告里给出提示。

6. 提交前的最后一道工序:Token 转储、最小测试集与二分定位

6.1 写一个 Token 转储工具,肉眼对账

词法分析器的调试手段里最直接的不是断点,而是把 Token 流转储成文件,一行一个 Token,对照源代码逐行读。格式固定为“行号: 类型(文本)”,这样一眼能看出遗漏、多余或者位置错位。

// src/dump_tokens.cpp #include "lexer.hpp" #include <iostream> #include <fstream> int main(int argc, char* argv[]) { if (argc < 3) { std::cerr << "用法: dump_tokens <输入文件> <输出文件>\n"; return 1; } SourceFile src; if (!src.open(argv[1])) { std::cerr << "无法打开输入文件\n"; return 1; } Lexer lexer(src); Token tok; std::ofstream out(argv[2]); while (lexer.next(tok)) { out << tok.line << ": " << tok.type_name() << "(" << tok.text << ")\n"; } return 0; }

转储文件的价值在于:语法分析出错时,直接打开 tokens.txt 看当前位置前后的 Token,就能判断是词法阶段丢了字符还是语法阶段匹配逻辑写错。我每次改完词法分析器都先跑一遍 dump,和手写期望对比,对账通过才继续做语法分析。

6.2 最小测试集:五个用例覆盖 90% 的边界

不需要准备几十个测试文件,五个精心设计的用例比一大摞乱写的输入更有用。我的最小测试集是这样设计的:

case01_empty.txt (空文件,期望词法输出 EOF,不崩溃) case02_comments.txt // 单行注释 /* 跨行 注释 */ int a; case03_identifiers.txt int abc = 10; int _tmp = abc + 20; case04_operators.txt a = b == c; d = e <= f; g = h >= i; case05_error.txt int 123abc; if (a ) b = 1; else c = 2;

每个文件对应一类风险:空文件验证初始化,注释验证行号与跳过逻辑,标识符验证下划线和关键字查表,运算符验证双字符识别,错误输入验证非法行为能报错不死循环。五个用例全部通过,测试点大概率不会出大问题。如果某个用例行为怪异,先用 dump 工具看 Token 流,再定位到对应函数。

6.3 二分定位:把“全崩”变成“这条规则崩”

最后一个技巧来自我自己的血泪经验:语法分析全盘报错时,不是检查整个 parser,而是用“裁剪输入”的方式定位。把一个复杂测试用例按行注释掉一半,如果错误消失,说明问题在注释掉的那一半里;保留问题一半,继续注释掉一半,几次下来就能定位到具体表达式。这套方法本质上和 git bisect 找回归是同一个思路,但用在不支持版本回退的实验代码上更直接。我习惯在每次修改后跑一遍最小测试集,确认没有引入新问题,再继续加功能。

给初学者的最后一个建议:不要急着把所有模块一次写完。先跑通词法,dump 出正确的 Token 流,再写语法。每完成一层都落一次盘,这样最后即使做不完语义分析,前面两层的得分也保住了。文本输出用固定格式,行号和列号对齐,这是我在做这类实验时最值得的一个习惯。希望帮到你。

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

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

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

立即咨询