☰
编译原理课程实验:用C++手写词法分析、递归下降与中间代码生成
2026/10/12 11:20:20 网站建设 项目流程

简介:湖南大学信息科学与工程学院计科拔尖班2024-2025学年秋季编译系统课程实验设计源码,面向编译原理学习者与课程实践者,覆盖从词法分析、语法树构建到中间表示生成与代码输出等完整实验流程。项目以C++为主,辅以C、Python与Shell脚本,共241个文件,约5.32MB,包含cminus测试源文件、syntax_tree语法树文件、cpp/h/hpp源码头文件、ll中间表示、out执行输出、Markdown与txt说明文档、png/jpg示意图以及json、CMakeLists等构建与配置内容,结构清晰便于按模块学习。压缩包内还配有include、src、Documentations、tests等目录,分别对应接口定义、核心实现、文档资料与验证用例,方便读者对照运行和排查测试。目前已有373人学习下载,适合需要完整课程实验参考或想深入理解编译系统前端设计的本科生与自学者。

1. 编译系统课程实验源码在做什么:一个可运行的微型编译器项目定位

期末前两三周,总能见到有人在找编译原理实验的现成代码,下载一个只做词法分析的 demo 就去交差,验收被问几句就露馅。这套基于 C++ 的编译系统课程实验设计源码,指的不是那种几百行的玩具,而是能跑通“源文件 → Token → AST → 中间代码 → 目标代码”完整链路的工程骨架,湖南大学 2024-2025 学年秋季学期的编译系统实验只是它的使用场景之一。你把它看懂、跑通,再改造成自己的语言特性上交,比直接抄一份未知质量的代码靠谱得多。

这套源码适合两类人:一是正在做编译原理课程实验、需要交源码和验收报告的学生,二是想把编译原理教材从纸面落到工程里的 C++ 学习者。它的价值不在某一处算法多精妙,而在每个阶段都能独立打印、独立测试、独立调错——这正是课程实验最看重的东西。下面我按自己搭这套实验的路径,把词法、语法、语义、代码生成以及调试时踩过的坑一次说清。

2. 用 C++ 组织词法分析:字符流处理的落地写法

2.1 先把语言规范钉死:目标语言与 Token 表

很多人拿到实验题第一反应是开写代码,我劝你先花半天把“要被编译的语言”写成一页纸。不需要抄完整的 C 语法,保留核心子集就够了:基本类型 int、double,控制流 if/else/while,函数调用,数组下标,注释和字符串字面量。这一页纸后面既是词法分析的输入,也是验收报告里的“语言定义”章节。

我做的这套目标语言叫 C-minus 的变体,Token 类型用下面这张表定死:

Token 类型词例示例说明
关键字int, double, if, else, while, return标识符的子集,优先识别
标识符foo, _tmp1字母/下划线开头,含数字
整型字面量123, 0x1F十进制和十六进制
浮点字面量3.14, 1e-3必须有小数点或指数部分
运算符+, -, *, /, =, ==, <, <=注意单双字符的最长匹配
界符( ) { } [ ] ; ,结构化语法的基础
注释// 与 /* */词法阶段直接丢弃

这张表里最容易翻车的是浮点字面量里的负号和科学计数法。我在 2.2 节会专门给出处理逻辑。

2.2 手写词法分析器:状态机、字面量与错误定位

课程实验一般不建议直接上 Flex,手写状态机反而更好:错误定位精确到行号列号,代码和语言定义一一对应,答辩时更容易讲清楚。核心做法是维护一个Lexer类,用std::string作为输入流,按字符推进,同时记录当前行列位置。

// lexer.h 核心接口 enum class TokenType { Int, Double, Identifier, IntLiteral, FloatLiteral, Plus, Minus, Star, Slash, Assign, Equal, Less, LessEqual, LParen, RParen, LBrace, RBrace, LBracket, RBracket, Semicolon, Comma, End }; struct Token { TokenType type; std::string text; // 原始文本 int line; // 起始行 int column; // 起始列 }; class Lexer { public: explicit Lexer(const std::string& source); Token next(); // 每次取一个 Token private: std::string src; size_t pos = 0; int line = 1, col = 1; };
// 词法分析:数字字面量,含小数和科学计数法 Token Lexer::lexNumber() { size_t start = pos; int startLine = line, startCol = col; bool isFloat = false; while (pos < src.size() && isdigit(src[pos])) pos++; if (pos < src.size() && src[pos] == '.') { isFloat = true; pos++; while (pos < src.size() && isdigit(src[pos])) pos++; } // 指数部分:e/E 后允许 +/- if (pos < src.size() && (src[pos] == 'e' || src[pos] == 'E')) { size_t save = pos; pos++; if (pos < src.size() && (src[pos] == '+' || src[pos] == '-')) pos++; if (pos < src.size() && isdigit(src[pos])) { isFloat = true; while (pos < src.size() && isdigit(src[pos])) pos++; } else { pos = save; // 不是指数,回退 } } std::string text = src.substr(start, pos - start); return { isFloat ? TokenType::FloatLiteral : TokenType::IntLiteral, text, startLine, startCol }; }

这段代码的关键在指数部分的“保存现场再判断”逻辑:读到e时不立即认定是科学计数法,先试探后面是不是数字或带符号的数字,不是就把pos回退,让e作为普通标识符的一部分继续读。这样1e-3被完整识别为一个浮点字面量,而a[e]里的e不会被误吞。参数上的取舍是:只要可能产生歧义,就用“试探-回退”,不要图省事直接吞掉符号;回退前保存pos是底线,否则会丢字符,这是词法分析最常见的隐性 bug。

2.3 用 std::unordered_map 管理关键字表:C++ 标准库的取舍

识别完标识符后要判断它是不是关键字。常见做法是用一张哈希表做映射,C++ 标准库直接提供std::unordered_map,相比一串if-else if干净得多,也比std::map的平均 O(1) 查找更快。课程实验的源码规模下性能不重要,但实现方式更接近工程习惯。

// 关键字表:标识符到这里查表,命中则把类型改为对应关键字 #include <unordered_map> static const std::unordered_map<std::string, TokenType> keywords = { {"int", TokenType::Int}, {"double", TokenType::Double}, {"if", TokenType::If}, {"else", TokenType::Else}, {"while", TokenType::While}, {"return", TokenType::Return} }; TokenType lookupKeyword(const std::string& id) { auto it = keywords.find(id); if (it != keywords.end()) return it->second; return TokenType::Identifier; }

逻辑说明:Lexer里识别出连续字母数字后,用lookupKeyword查表决定 Token 类型。这里有两个 C++ 细节值得注意。第一,unordered_map的初始化列表在 MSVC 和 GCC 上行为一致,但如果你用的是老版本编译器,要保证花括号写法被支持,否则退回到insert逐条插入。第二,查表用的std::string会和标识符的临时string发生一次拷贝,课程实验规模完全不必优化;真到性能敏感时再考虑std::string_view,但它会牵扯字符串生命周期问题,不建议初学阶段引入。

另一个坑和 C++ 字符串数组初始化有关:有人图快把char*数组塞进哈希表,回头const char*的哈希值基于指针地址而非内容,查表永远失败。要么统一用std::string,要么给unordered_map自定义指针哈希,没有第三条路。

提示:关键字表要做到“先识别完整标识符,再查表”。如果边读字母边判断前缀,intx会被误判为int加x,这类问题定位起来很耗时间。

3. 语法分析与符号表:从递归下降到语义动作的 C++ 结构

3.1 为什么课程实验常见做法选择递归下降而不是搬 LALR

语法分析有两条路线:用 Bison 这类生成器,或者手写递归下降。课程实验我一般建议手写。原因是递归下降的每个函数对应文法里的一条产生式,代码结构即文法结构,出现语法错误时可以直接抛出包含行号的异常;而 LALR 生成器虽然对二义性文法更省心,但生成的是状态表驱动的黑匣子,验收时被问“这个冲突怎么解决”很容易答不上来。

维度手写递归下降Bison 生成器
文法对应性一个函数一条产生式需要理解 LALR 状态机
错误定位精确,直接抛异常依赖 yacc 错误栈,定位远
二义性处理靠优先级分层显式处理靠 %left/%right 声明
调试成本低,可用 GDB 单步高,黑匣子

二义性的处理是递归下降必须面对的问题:1 + 2 * 3之所以结果正确,是因为我按优先级把表达式拆成expr → term → factor三层,乘除那层在加减下面,天然形成结合规则。实验报告里把这三层关系用文法写清楚,比任何长段解释都有说服力。

3.2 AST 节点设计:用 std::variant 还是继承多态

语法分析的结果应该是抽象语法树(AST),而不是边解析边输出汇编。AST 节点的设计有很多种,我比较推荐 C++17 的std::variant写法,理由很实在:值语义,不需要到处new/delete,遍历时用std::visit一个函数就能分发。

// 表达式节点的变体设计(C++17) struct BinaryExpr { TokenType op; // Plus / Minus / Star / Slash std::unique_ptr<Expr> left; std::unique_ptr<Expr> right; }; struct NumberExpr { double value; // 统一存 double,整数也转成它 }; struct VarExpr { std::string name; }; struct Expr { std::variant<BinaryExpr, NumberExpr, VarExpr> payload; };

逻辑说明:std::variant把可能性收进一个类型,表达式的三种形态由payload区分。优点是不需要为每个节点实现虚函数accept;缺点是后续新增节点类型,所有visit调用点都要补分支,但课程实验的语言规模不会让这点成为负担。另一种常见做法是经典访问者模式:抽象基类加虚函数,代码更“教科书”,但涉及大量动态分配和delete,调试时容易出悬挂指针。我的建议是:语言特性少、想快速跑通,用variant;想练继承多态,用访问者模式,但要做好内存管理。

结构化语句(if/while/函数调用)的处理同理:Stmt也用variant装IfStmt、WhileStmt、ReturnStmt,递归下降函数返回Stmt或Expr,语义阶段再逐层遍历。

3.3 符号表作用域链:作用域进出与重定义检测

符号表不能塞进 AST 节点里,最好单独建一个Scope类。每个作用域一张std::unordered_map,再加一个指向父作用域的指针,进入花括号块时压栈,退出弹栈,查找从内向外。重定义检查只需在声明时看当前作用域是否已存在同名符号。

class Scope { public: explicit Scope(Scope* parent) : parent_(parent) {} void define(const std::string& name, const Symbol& sym) { if (symbols_.count(name)) { throw SemanticError("duplicate symbol: " + name); } symbols_[name] = sym; } const Symbol* lookup(const std::string& name) const { auto it = symbols_.find(name); if (it != symbols_.end()) return &it->second; if (parent_) return parent_->lookup(name); return nullptr; } private: Scope* parent_; std::unordered_map<std::string, Symbol> symbols_; };

逻辑说明:define只允许同名符号在同一个作用域出现一次,而lookup会沿着父链向上找,这正好实现“内层变量遮蔽外层变量”的语义。这里有一个容易忽略的顺序问题:定义符号必须发生在语义检查阶段,而不是语法分析阶段。否则函数声明处int f()内部块引用了外层变量,语法分析刚读到函数体第一个变量时,外层作用域可能还没建立完整。我习惯的做法是语法分析只建 AST,遍历 AST 的过程里维护作用域栈,两阶段彻底分离,定位问题时会清爽很多。

4. 中间代码生成与目标代码输出:语义动作和 C++ 容器的配合

4.1 三地址码的 C++ 表示:指令序列与基本块

中间代码我选三地址码(TAC)。它的结构非常贴 C++ 的一个struct:操作码、目标操作数、两个源操作数。每个操作数是带类型的字符串表示,t1这样的名字直接充当临时变量。指令序列就一个std::vector<Instr>,有序、可打印、可以加断言验证。

enum class OpCode { Assign, // dest = src1 Add, Sub, Mul, Div, // dest = src1 op src2 Less, LessEq, Equal, // 关系运算,结果 0/1 Label, Jump, JumpIfFalse, Call, Return }; struct Instr { OpCode op; std::string dest; std::string src1; std::string src2; int line = 0; // 对应源码行,方便跑测试时对回查 };

逻辑说明:临时变量统一用t0、t1递增编号,不区分类型;类型信息单独维护一张“临时变量 → 类型”的表,这样三地址码本身保持简洁,生成目标代码时再查类型决定用整数还是浮点指令。line字段是调试时的救命稻草,中间代码出了问题,能从它直接跳回源码位置。我在实验报告里专门列了一段:每一条中间代码对应源码的第几行。

4.2 寄存器分配的简化策略:常量合并与临时变量编号

完整做寄存器分配要到图着色那一步,课程实验没必要。常见做法是:先用无限临时变量生成 TAC,然后用一个简单的栈式分配器把临时变量映射到少量寄存器;常量直接回填到指令里,不占寄存器。下面这段是赋值语句生成中间代码的核心逻辑。

std::vector<Instr> code; int tempCounter = 0; std::string newTemp() { return "t" + std::to_string(tempCounter++); } void genAssign(const VarExpr& var, const Expr& rhs, const SymbolTable& types) { std::string src = genExpr(rhs); // 递归生成 rhs 的求值代码 if (const auto* num = std::get_if<NumberExpr>(&rhs.payload)) { code.push_back({OpCode::Assign, var.name, stripQuotes(formatNumber(num->value)), "", 0}); } else { code.push_back({OpCode::Assign, var.name, src, "", 0}); } }

逻辑说明:genExpr是递归下降的语义动作版,每个Expr节点返回一个“存放求值结果的临时或变量名”。常量被直接回填,不产生临时变量;非常量则通过newTemp获取新名字。教材里的“语法制导翻译”讲的就是这个,只是翻译到 C++ 时,emit函数变成了向std::vector尾部push_back。参数调整的核心在newTemp:编号单调递增,保证生成顺序和求值顺序一致,这是后续做目标代码分析的基础。

4.3 输出模块的位宽依赖:int/double 输出与跨平台打印

中间代码本身数据量小,但要在终端或文件里打印它的可读形式,类型处理就很敏感。有人直接把t1当成int打印,浮点运算的全变成了截断整数,结果看似正常实则全错。

std::string formatOperand(const std::string& name, const SymbolTable& types) { auto it = types.find(name); if (it == types.end()) { // 字面量:纯数字看有没有小数点或 e if (name.find('.') != std::string::npos || name.find('e') != std::string::npos) return "double " + name; return "int " + name; } return it->second == Type::Double ? "double " + name : "int " + name; }

这段代码解决的问题是:打印中间代码时,每个操作数前补上int或double前缀,让汇编生成阶段能直接判断该用整数指令还是浮点指令。注意它是基于SymbolTable查类型的,而不是基于变量名猜——这是 print 模块唯一可靠的做法。另一个跨平台细节是打印double时格式化函数要在.0和e两种表示里保持一致,我在 Linux 和 Windows 的双重测试里发现%f和%g的行为不一致,最后统一用std::ostringstream加固定精度。

5. 避坑:课程实验调试中的 5 条踩坑与排查记录

5.1 浮点科学计数法和负号被拆成多个 Token

现象:输入double x = 1e-3;,词法分析输出1、e、-、3四个 Token,语法分析直接报错,但初看代码逻辑没问题。

原因:数字状态机没有处理e后的正负号,把e当成了普通标识符字符,-又被单独识别成减号运算符。

解决:按第 2.2 节的方法写“试探-回退”。先记下pos,读取e后的符号和数字,如果后面不是数字就回退。更稳妥的做法是在实验报告里明确语言规范:只有e后紧跟整数指数才算科学计数法,否则e回归标识符角色。

5.2 字符串里的//被当成注释

现象:源代码出现printf("http://x")这类内容,词法分析把//后面全吞了,实际字符串内容丢失。

原因:注释识别在字符串识别之前执行,没有判断当前是否处于字符串字面量状态。

解决:状态机里不能让注释和字符串各扫各的。正确顺序是:每次循环先判断当前字符是否属于引号内侧,是则把后续直到配对引号的内容整体作为字符串 Token;出了引号才轮得到注释判断。最简单可靠的实现是维护一个inString标志位,扫描时同步更新它。

5.3 C++ 字符串处理越界导致随机崩溃

现象:同一份测试用例,Visual Studio 里跑得好好的,换到 Linux 上用 GCC 编译就段错误,而且崩溃位置每次都在变。

原因:有人把字符流存成char buf[]后直接下标访问buf[i+1],没判断i+1是否越界;不同平台栈布局不同,越界踩到的内存不同,表现就完全不同。

解决:统一用std::string存源码,访问前总是判断pos < src.size()。不要用char[]加裸指针,那是 C 的写法,C++ 在这里的内置防线是at()方法。我写这套源码时给自己定的规矩:词法分析器里所有src[pos]前面必有长度判断,宁可多写两行,不赌“字符串不会恰好停在末尾”。

5.4 中间代码顺序正确但是目标代码结果错误

现象:三地址码打印出来每一条都对,按顺序执行手算结果也对,但生成汇编后结果偏移一位。

原因:寄存器分配没考虑临时变量生命周期,两个不同活期的临时变量被分配到了同一个寄存器,后者覆盖了前者还没用完的值。这属于典型的“每个阶段单独看都对”的集成 bug。

解决:直接改分配策略不划算,我的做法是把分配器改成“编号严格递增 + 不重用寄存器”的最小正确方案,先保证结果对;再在报告里写明这是简化分配,并给出可扩展为线性扫描寄存器分配的接口位置。课程实验评估的是正确性思路,不是最优分配。

5.5 语法分析正常但 AST 打印为空

现象:程序能解析到结束,没有任何报错,但dump_ast()输出的树是空的。

原因:递归下降的某个产生式在返回时把子节点挂错位置,典型的比如if (cond) stmt里的stmt没有被挂到IfStmt的thenBranch字段,而是被丢弃了。语法分析器只管“匹配并返回值”,不会校验树的完整性。

解决:在语义遍历前加一道 AST 完整性断言。遍历入口处检查根节点非空;每个语句节点检查关键字段非空。这个断言成本极低,能在语法正确但语义没挂上时第一时间报错,而不是等到代码生成阶段才暴露。我在第 4 章特意用单独的SemanticChecker类而不是把检查塞进Parser,就是为这种场景留一个开关。

注意:第 5.4 和 5.5 这类问题最耗时间,因为它们没有报错信息。调试时不要靠眼睛盯输出,直接把中间表示 dump 到文件,写一个“对照正文”脚本逐行比对,比盯着终端滚动有确定性得多。

6. 验收前值得做的 3 个进阶动作:边界测试、分阶段产物与自举式验证

临近交实验,与其反复刷新源码看哪行还能调,不如做三个能让验收变得顺畅的动作。这些不算加分项,但我观察下来,做了的人答辩都顺很多。

第一个是建一个testcases/目录,按类型分文件:empty.cm(空程序)、assign.cm(单语句赋值)、if_else.cm、while_loop.cm(含嵌套)、array_index.cm、type_error.cm(故意写类型不匹配)。写一个极简的回归脚本,跑完所有用例并把退出码和打印结果汇总成一个表格。验收时现场跑一遍,证明不是只对某个示例有效。

#!/bin/bash for f in testcases/*.cm; do echo "=== $f ===" ./compiler --dump-tac "$f" || echo "FAIL" done

这段脚本的逻辑很直白:每个.cm文件都当作输入,--dump-tac输出中间代码;任何一个用例崩溃或退出码非零,都能从终端明确看到。脚本里的|| echo "FAIL"是关键,否则某些错误会被外层脚本吞掉,排查时误以为全部通过。

第二个进阶动作是给每个编译阶段加一个显式输出开关:--dump-tokens、--dump-ast、--dump-tac、--dump-reg、--dump-asm。验收时老师问你“中间代码长什么样”,你现场一条命令把五份产物都打出来,比口头描述有力得多。这也逼着你自己把每阶段的输出格式做规范,否则没法打印。

第三个动作最有意思,叫“自举式验证”:拿编译器去编译一段用目标语言写的简单程序,比如用 C-minus 写一个整数累加求和,再对照手算结果。这一步的意义是同时验证了词法、语法、语义、代码生成整条链路,比单独调任一阶段更能发现“阶段接口不匹配”的问题。如果条件允许,再让它编译自身源码的一个子集,这是编译原理课上最经典的一课,也是这套源码“不是黑匣子”的最好证明。

我在做自己的课程实验时最深的教训是:先让每个阶段能打印,再谈正确性。词法能的 token 打出来,语法能打的 AST 打出来,后面的一切都是在这两个稳定输出上堆出来的;反过来,任何阶段不可见,出问题时它就是黑匣子,而你会在黑匣子前面浪费最多的夜晚。希望帮到你。

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

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

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

立即咨询