资讯详情

C++手写编译系统实验:从词法分析到三地址码的完整实现指南

📅 2026/10/10 21:07:47 | 华诺云谱 👁 阅读
C++手写编译系统实验:从词法分析到三地址码的完整实现指南
简介湖南大学信息科学与工程学院计科拔尖班2024-2025学年秋季学期编译系统课程实验设计源码面向编译原理学习与课程实践以C为主融合C、Python、Shell覆盖词法分析、语法树构建、中间代码生成等编译前端核心环节适合高校学生与编译初学者参考。资源包共241个文件约5.32MB类型包括cpp/hpp源文件、h头文件、cminus测试用例、syntax_tree语法树文件、ll中间代码、out运行结果、md文档及py/sh脚本能较完整地呈现实验从源码到测试输出的全过程。源码目录按源代码、头文件、文档、测试用例等模块组织并带有CMakeLists.txt与.gitignore配置便于跨平台构建与版本管理文档和脚本可辅助理解设计思路并支持自动化测试。其中的cminus测试用例与语法树输出相互对应可配合源码逐步追踪词法、语法分析过程。已有373人学习下载适合用来复盘编译系统课程实验、理解编译器前端模块划分也可作为C工程实践与课程报告撰写的参考资料。1. 编译系统课程实验到底在做什么从词法分析到代码生成的完整链路很多人第一次看到“编译系统课程实验设计源码”这个标题下意识会以为是一份可以直接编译通过的“标准答案”。等你真动手才会发现编译系统实验的源码更像一件要自己捏的陶器大纲给了工具给了但每个接口怎么设计、每个阶段怎么衔接全都要你自己拍板。这门实验的核心任务是用C实现一条从源码文本到中间代码或目标代码的完整编译链路通常包括词法分析、语法分析、语义分析、中间代码生成以及可选的简单代码生成。适合正在上编译原理课、需要交课程设计的高年级本科生也适合想补编译器底子、平时习惯读LevelDB这类C工程源码的从业者。它能帮你把课本上的正则表达式、递归下降、三地址码全部变成能跑的代码同时把C的STL、智能指针和构建流程练扎实。2. 词法分析器用C怎么搭状态机、Token流与输入缓冲的设计2.1 为什么实验选C而不是C或JavaSTL与内存管理的权衡编译系统课程实验选C的原因非常实际。C版本的词法分析器往往要手写动态数组管理Token缓冲区内存释放稍有遗漏就崩Java版本虽然有垃圾回收省心但实验讲义里推荐的参考资料多半以C/C为主而且Java的类体系在书写递归下降时更容易变得臃肿。C的std::vector、std::string和std::unordered_map能直接把Token流和符号表变成数组和哈希表大量底层代码被省掉。一个学期要完成五六个实验环节用STL节省的时间通常有三分之二。但C也有自己的坑。最常见的误用是拿std::vector存Token时反复push_back导致悬空引用如果你在遍历Token流时提前取了一个迭代器或指针后续再push_back那个指针可能已经失效这在词法分析器里非常隐蔽。我的习惯是用std::vector 收集完所有Token之后再做只读遍历不要边收集边解析。另外如果你在Windows上用Visual Studio写、在Linux上评测要注意C标准版本差异。实验环境通常用g编译建议统一用C17MSVC的某些默认行为比如fopen的Secure CRT警告在Linux上不会出现反而会让你的代码里塞满#ifdef _MSC_VER。为了清爽我一般会把所有文件操作改成std::ifstream这样两边行为一致。2.2 词法分析器的核心数据结构与状态转移实现词法分析器的核心是状态机。对于课程实验不需要用完整的Lex生成器手写一个基于逐字符扫描的状态机更直观。下面是一份可直接复现的最小实现支持标识符、整数常量、浮点数、运算符和字符串字面量识别。#include iostream #include vector #include string #include sstream #include cctype #include stdexcept enum class TokenType { Identifier, Integer, Float, Operator, StringLit, EndOfFile }; struct Token { TokenType type; std::string lexeme; // 原始文本 int line, column; // 出错定位用 }; class Lexer { public: explicit Lexer(const std::string src) : src_(src) {} std::vectorToken tokenize() { std::vectorToken tokens; while (true) { Token t nextToken(); tokens.push_back(t); if (t.type TokenType::EndOfFile) break; } return tokens; } private: Token nextToken() { skipWhitespace(); if (pos_ src_.size()) { return Token{TokenType::EndOfFile, , line_, col_}; } int start pos_; int startLine line_; int startCol col_; char c peek(); if (isalpha(static_castunsigned char(c)) || c _) { advance(); while (isalnum(static_castunsigned char(peek())) || peek() _) advance(); return Token{TokenType::Identifier, src_.substr(start, pos_ - start), startLine, startCol}; } if (isdigit(static_castunsigned char(c))) { bool isFloat false; advance(); while (isdigit(static_castunsigned char(peek()))) advance(); if (peek() . isdigit(static_castunsigned char(peek(1)))) { isFloat true; advance(); while (isdigit(static_castunsigned char(peek()))) advance(); } return Token{isFloat ? TokenType::Float : TokenType::Integer, src_.substr(start, pos_ - start), startLine, startCol}; } if (c ) { advance(); std::string lit; while (peek() ! peek() ! \0) { if (peek() \\) { advance(); lit.push_back(peek()); advance(); } else { lit.push_back(peek()); advance(); } } if (peek() ) advance(); return Token{TokenType::StringLit, lit, startLine, startCol}; } // 运算符按最长匹配原则读两个字符 std::string two src_.substr(start, 2); if (two || two ! || two || two || two || two ||) { advance(); advance(); return Token{TokenType::Operator, two, startLine, startCol}; } if (std::string(-*/;(),{}).find(c) ! std::string::npos) { advance(); return Token{TokenType::Operator, std::string(1, c), startLine, startCol}; } std::cerr Unrecognized char c at line line_ std::endl; throw std::runtime_error(lex error); } char peek(int offset 0) const { size_t p pos_ offset; if (p src_.size()) return \0; return src_[p]; } void advance() { if (pos_ src_.size()) { if (src_[pos_] \n) { line_; col_ 1; } else col_; pos_; } } void skipWhitespace() { while (pos_ src_.size() std::isspace(static_castunsigned char(src_[pos_]))) advance(); } std::string src_; size_t pos_ 0; int line_ 1, col_ 1; };TokenType用enum class定义避免隐式转换污染整型比较这是C11之后推荐的写法。peek和advance的组合模拟了词法分析里的一字符前瞻运算符的最长匹配用substr先看两个字符来实现。注意所有传给isalpha、isdigit的字符都用static_cast 做了转换否则在传入负char值时有未定义行为风险。字符串字面量里的回车换行处理依赖advance中的line计数这比逐行处理文本靠谱得多。这段代码还有一个容易忽略的决策点运算符集合用了查表法而不是switch-case。当操作符种类超过十个时查表的可维护性明显更好如果课程文法要求支持更多复合运算符比如“-”、“::”建议把双字符匹配扩展成一个静态的字符串数组按最长匹配顺序扫描。2.3 输入缓冲与字符回退读文件时最容易翻车的两个细节课程实验最常见的输入源是文件。有的同学直接用fscanf逐字符读顺便在循环里做状态判断结果每读到换行符都要单独处理还容易把\r和\n的组合搞错。现代C推荐直接读进std::string再扫描简单且不会有缓冲区溢出风险。读文件这段代码值得单独写#include fstream #include iterator std::string readWholeFile(const std::string path) { std::ifstream in(path, std::ios::binary); if (!in) { throw std::runtime_error(cannot open file: path); } return std::string((std::istreambuf_iteratorchar(in)), std::istreambuf_iteratorchar()); }用istreambuf_iterator读全文比getline循环少很多边界问题。binary模式能避免Windows下\r\n被自动转换成\n导致的行号偏移。如果你在本地编译运行正常、提交到评测环境后却报行号对不上先检查是不是这里没加binary。另一处容易翻车的是注释处理。如果只在检测到“/”时检查下一个字符是否为“”但跳过注释正文时忘了处理“/”出现前的文件结尾词法分析器会抛出一个让人摸不着头脑的字符错误。我习惯在nextToken里加一个特判当前字符是“/”时先看下一字符。如果是“//”则一直跳到换行如果是“/”就逐字符扫描直到“/”或文件尾。注意课程实验基本不要求嵌套注释不写嵌套支持反而省事。3. 语法分析的选择递归下降的实现、AST构建与左递归改造3.1 为什么课程设计偏爱递归下降而不是YACC/LR如果你在网上搜过“c为什么没有普遍”“c/c构建”这类话题会发现很多人抱怨编译器工具链复杂。放到编译实验里也一样LR分析器生成器能自动处理文法但生成的报错信息晦涩难懂调试时基本是在黑盒里猜。递归下降分析器是手写解析函数每个非终结符对应一个函数调用关系就是语法树结构调试时加打印就能看到当前回溯到哪个层级。对课程实验这种以“理解原理”为目标的场景递归下降几乎是标准答案。递归下降最明显的弱点是无法直接处理左递归文法。比如E - E T | T这样的规则直接写成函数会让E函数无限调用自己。解决办法有两个改写文法消除左递归或者用循环代替递归去解析同一优先级的运算符序列。后一种在表达式解析里更实用我下面给出例子。这里的选型理由对你后续实验同样重要用递归下降意味着你的代码结构和文法规则一一对应任何一个非终结符函数都能单独测试。这比调试一张巨大的LR状态表要直观得多也因此在答辩演示时更容易讲清楚每一步在做什么。3.2 抽象语法树的构建用std::variant还是继承体系AST节点的设计影响整个后续环节很多人的实验写到语义分析才返工。课程实验的节点种类有限通常就几类表达式、语句、声明。最常见的做法是用继承体系基类ASTNode子类ExprNode、StmtNode、DeclNode。但C里用继承就要处理虚析构和动态类型转换最容易出内存泄漏。更现代的替代方案是用std::variant定义节点类型。虽然不能用多态但节点数量有限时访问器代码可读性更高。如果你的实验要在生成中间代码时频繁区分节点类型std::holds_alternative和std::get_if比dynamic_cast更快也更安全。这里给出一份不依赖继承的AST定义#include variant #include memory #include vector #include string struct ExprNode; struct StmtNode; using NodeVariant std::variantstd::monostate, ExprNode, StmtNode; struct ExprNode { std::string op; // , -, *, /, num, id std::string value; // 操作数或变量名 std::vectorstd::unique_ptrNodeVariant operands; }; struct StmtNode { std::string kind; // assign, if, while std::vectorstd::unique_ptrNodeVariant children; };用unique_ptr管理子节点配合variant可以完全避开裸new和delete。std::monostate占位是为了让variant可默认构造否则如果你的代码需要默认构造AST节点就要自己写构造函数。std::unique_ptr也是不可拷贝的所以后续遍历时要么用引用接收要么用std::move转移。如果你不熟悉std::variant先查一下cppreference上的get_if用法这比dynamic_cast更不容易写出访问空指针的代码。你要注意一个容易踩的坑如果你从vector的push_back里拿出一个NodeVariant的引用并保存下来后面继续往vector里push_back会让这个引用失效。解决方法和Token流一样——先完整构建AST再开始后续的语义分析遍历。3.3 消除左递归与回溯两个必调的参数与C代码示例表达式解析是最常用的示例它天然有左递归文法E - E T | T。递归下降不处理这种我采用循环式的优先级爬升。下面这个解析器处理加减乘除和括号用循环替代左递归。class Parser { public: explicit Parser(const std::vectorToken tokens) : tokens_(tokens) {} std::unique_ptrExprNode parseExpression() { return parseAdditive(); } private: std::unique_ptrExprNode parseAdditive() { auto left parseMultiplicative(); while (peekType() TokenType::Operator (peekText() || peekText() -)) { std::string op peekText(); advance(); auto right parseMultiplicative(); auto node std::make_uniqueExprNode(); node-op op; node-operands.push_back(std::make_uniqueNodeVariant(std::move(*left))); node-operands.push_back(std::make_uniqueNodeVariant(std::move(*right))); left std::move(node); } return left; } std::unique_ptrExprNode parseMultiplicative() { auto left parsePrimary(); while (peekType() TokenType::Operator (peekText() * || peekText() /)) { std::string op peekText(); advance(); auto right parsePrimary(); auto node std::make_uniqueExprNode(); node-op op; node-operands.push_back(std::make_uniqueNodeVariant(std::move(*left))); node-operands.push_back(std::make_uniqueNodeVariant(std::move(*right))); left std::move(node); } return left; } std::unique_ptrExprNode parsePrimary() { if (peekType() TokenType::Integer) { auto node std::make_uniqueExprNode(); node-op num; node-value peekText(); advance(); return node; } if (peekType() TokenType::Identifier) { auto node std::make_uniqueExprNode(); node-op id; node-value peekText(); advance(); return node; } if (peekType() TokenType::Operator peekText() () { advance(); auto inner parseExpression(); if (peekType() ! TokenType::Operator || peekText() ! )) { throw std::runtime_error(expect )); } advance(); return inner; } throw std::runtime_error(unexpected token in primary); } TokenType peekType() const { return tokens_[pos_].type; } std::string peekText() const { return tokens_[pos_].lexeme; } void advance() { pos_; } const std::vectorToken tokens_; size_t pos_ 0; };这个实现里所有unique_ptr都用std::move转移所有权。如果你直接把unique_ptr传给另一个unique_ptr的构造函数会编译失败这是C11之后写AST最安全也最烦人的地方——但正是这种“烦”防止了二义性拷贝。两个必调的参数一是parseAdditive和parseMultiplicative里的while循环条件表达式它决定运算符的优先级和结合性二是parsePrimary里对“)”的处理——匹配到右括号时记得advance()否则下次循环会在同一个位置打转形成死循环。如果你发现解析器对某个输入卡住先检查是不是所有分支都推进了pos_。4. 语义分析与中间代码生成符号表、类型检查和三地址码4.1 符号表作用域管理散列表的隐藏坑编译系统实验的符号表通常用std::unordered_map实现。但作用域管理不是简单的insert和find就能解决的嵌套作用域中内层声明的变量会遮蔽外层同名变量弹出作用域时要恢复外层绑定。常见做法是维护一个作用域栈每个作用域一张表查符号时从栈顶往下找。有个容易踩的坑是用std::unordered_map保存变量名时如果在同一作用域重复声明一个变量可能不小心覆盖旧绑定而没有留下任何错误。课程实验通常会要求检测重复声明。所以在声明变量时要先在该作用域查找存在则报错而不是直接覆盖。同时std::unordered_map的迭代顺序不稳定。如果后面生成汇编时要按声明顺序输出局部变量用unordered_map会得到随机顺序。解决办法是保留一个std::vector std::string 记录声明顺序或者直接用std::map。我一般用std::map符号表不大性能差异可以忽略却能保证按字典序稳定输出。#include map #include string #include vector #include optional class SymbolTable { public: void pushScope() { scopes_.emplace_back(); } void popScope() { if (scopes_.empty()) throw std::runtime_error(scope stack underflow); scopes_.pop_back(); } bool declare(const std::string name, const std::string type) { auto top scopes_.back(); if (top.count(name)) return false; // 重复声明 top[name] type; return true; } std::optionalstd::string lookup(const std::string name) const { for (auto it scopes_.rbegin(); it ! scopes_.rend(); it) { auto found it-find(name); if (found ! it-end()) return found-second; } return std::nullopt; } private: std::vectorstd::mapstd::string, std::string scopes_; };pushScope和popScope对应进入和退出一个块作用域。lookup从栈顶向栈底搜索天然实现了内层变量遮蔽外层变量。declare返回false时语义分析阶段应打印“duplicate declaration”并记录错误信息。如果你还把一个变量的类型、初始值等附加信息存进符号表可以把这个map的值换成一个struct不要只存string。4.2 三地址码的生成临时变量命名与引用计数三地址码生成的难点在于临时变量管理。表达式(AB)*C需要生成类似t1 A B; t2 t1 * C的中间代码。临时变量名如果每次用全局计数器自增生成的中间代码会比较长但课程实验不要求做寄存器分配优化所以简单自增是可行的。不过这里有个和C STL相关的坑如果用std::to_string拼接临时变量名比如t std::to_string(counter)在循环里生成大量临时变量时字符串反复构造会拖慢速度。一般实验规模只有几千行代码性能无所谓但如果你在做带循环展开的优化实验建议用std::string的append或直接std::stringstream一次性拼接。三地址码的数据结构也要选好。我建议每条指令保存为四元组(op, arg1, arg2, result)。其中op是字符串如“ADD”“MUL”“ASSIGN”arg和result都是字符串。不要用void*不要用union这些在C里调试时太痛苦。struct Quad { std::string op; std::string arg1; std::string arg2; std::string result; std::string toString() const { if (op ASSIGN) return result arg1; return result arg1 op arg2; } }; class IRGenerator { public: std::string newTemp() { return t std::to_string(tempCounter_); } void emit(const std::string op, const std::string a1, const std::string a2, const std::string res) { quads_.push_back(Quad{op, a1, a2, res}); } const std::vectorQuad quads() const { return quads_; } private: int tempCounter_ 0; std::vectorQuad quads_; };newTemp每次返回新名字这要求你的表达式递归下降代码在每解析一个子表达式时调用一次newTemp。如果你把newTemp调用放在parseExpression外层而不是每个操作数节点里就会生成重复的临时变量名。4.3 类型检查的断言位置编译期报错与运行期崩溃的分界类型检查是在AST遍历时做的通常和符号表查询一起。这里有个经验法则所有类型错误尽量在语义分析阶段报不要拖到中间代码生成或目标代码生成阶段。因为中间代码一旦生成类型信息已经部分丢失报错信息会变得很难看。我的做法是在IRGenerator之外单独写一个TypeChecker遍历AST在遍历时调用符号表的lookup。C里实现这个可以用visitor模式也可以用之前提过的std::variant配合get_if如果是继承体系就dynamic_cast。类型不匹配时抛出带文件名、行号和列号的异常语义分析器捕获后打印并退出。有一个C特有的坑很多同学用std::any来存类型信息结果在遍历AST时std::any_cast失败后的处理格外麻烦。不要用std::any存编译器内部的类型信息直接给AST节点加一个Type字段更直接。虽然会占用额外内存但课程实验的AST节点数量通常不超过十万个这个成本完全能接受。类型检查的顺序也值得注意先查符号表确认变量已声明再查操作数类型是否匹配最后检查赋值语句左右类型是否兼容。如果你把符号表查询放在类型检查之后遇到“未声明变量”时可能会先抛出“类型不匹配”的误导性错误。5. 编译系统实验常见问题与避坑排查5.1 现象词法分析在Windows和Linux下行为不一致原因Windows下fopen打开文本文件时\r\n会被转换为\n如果你用fseek/ftell定位文件长度得到的大小可能和Linux上不同导致读入字符串末尾多出无关内容。解决统一用std::ifstream加binary模式读文件行号在扫描时手动统计不依赖系统文件指针。如果你在其他课程实验里遇到类似问题也是同样套路。5.2 现象递归下降解析对输入串死循环原因解析函数没有推进pos_。最常见的错误是在匹配右括号或分号后忘了advance()或者跳过空格的逻辑放在了错误位置。解决在解析器的advance()函数里加一个断言pos_ tokens_.size()并在外部循环里加一个最大步数限制防止死循环导致评测程序挂起。调试时报错信息要带当前Token的行列号否则你无法定位是输入文件的哪个问题导致无限循环。另一个隐蔽场景遇到非法Token时如果解析函数只是返回而不抛异常父调用可能继续循环造成原地打转。我的原则是解析函数遇到不认识的Token必须抛异常不静默跳过。5.3 现象AST节点内存泄漏导致程序崩溃原因如果你用了裸指针new ASTNode又在多态容器里delete时忘了写virtual析构函数delete基类指针时子类析构函数不会被调用资源会累积泄漏。解决第一选择是用unique_ptr管理所有AST节点如果非要裸指针基类析构函数必须标virtual并在所有new/delete代码里反复检查配对。用AddressSanitizer检查泄漏在g里加-fsanitizeaddress即可。5.4 现象符号表作用域判断错误导致变量遮蔽问题原因简单的符号表可能只在全局表和局部表之间切换语言里多重嵌套作用域被合并。比如if语句块里的变量与函数级变量同名本该遮蔽外层变量简单实现可能直接覆盖外层绑定。解决采用作用域栈每个作用域一个表出作用域弹出栈顶。声明变量时先在当前栈顶查重查询时从栈顶到栈底搜索。对if/else及其嵌套块进入括号时push出括号时pop不要用全局计数器模拟作用域ID。5.5 现象三地址码生成的临时变量重名原因有些实现错误地在同一表达式里复用了newTemp返回的同一个名字导致后生成的指令覆盖了前面还没用到临时值的生命周期。解决在每个newTemp返回前打印当前计数器值确认每次生成的都是新名字发现重名时检查是不是误把newTemp调用放在了循环外面。另一种常见错误是生成表达式树时只给根节点分配临时变量而操作数节点没有导致中间代码里的源操作数直接是复杂表达式。6. 实验评分答辩的五个演示技巧与一个自查脚本做编译系统实验光学不练确实容易虚但有五个点能明显提升答辩效果。第一是演示的输入样例要覆盖关键字、注释、嵌套作用域和复杂表达式只跑“a12”不足以展示系统能力。第二是中间代码生成后把IR和源文件对照展示让评委一眼看出对应关系这比直接跳到最后输出更有说服力。第三是如果做不出完整的目标代码生成不要硬编一个假汇编可以把符号表内容和指令统计打印出来这也是有效证据。第四是准备几个故意出错的样例让系统报出带行列号的错误信息。这比正确路径更能证明你的词法语法分析器真的有边界处理。第五是别忽略性能分析——打印词法分析、语法分析各阶段耗时能让答辩更有工程感。我平时会在提交前跑一个自查脚本检查几件事用AddressSanitizer跑一遍测试用例集确认无泄漏检查所有输入文件都能以零退出码结束用diff对比自己实现的IR和参考IR以及验证错误输入的报错信息里关键词是否齐全。这套检查花的时间很少但能挡住大量低级翻车。#!/bin/bash # build_and_test.sh g -stdc17 -fsanitizeaddress -g main.cpp lexer.cpp parser.cpp -o compiler for f in tests/*.c; do echo $f ./compiler $f /tmp/$(basename $f).ir 21 if [ $? -ne 0 ]; then echo FAIL: exit code nonzero fi done如果你有参考编译器可以再加一层diff。关于C的构建建议把编译命令写进Makefile或CMakeLists不要每次手动打g。CMakeLists.txt三行就能搞定而且能让你在答辩现场用cmake --build .一键构建不用向评委解释为什么手动敲命令。cmake_minimum_required(VERSION 3.16) project(compiler LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) add_executable(compiler main.cpp lexer.cpp parser.cpp)这个实验做完我最大的体会是写编译器是在学习“程序如何理解程序”。当你亲手完成词法、语法、语义三个阶段你会发现网上很多关于“c stl”“c为什么没有普遍”的争论都可以用实验中的真实感受来回答。用C写编译器最难的不是语法而是让这个多模块系统在不违反单一职责的情况下保持可调试。如果你在实验里遇到同样的问题我有两条血泪经验分享给你第一永远不要让AST节点裸持有子节点要么unique_ptr要么引用第二语义分析阶段报错比在目标代码阶段让程序崩掉好一百倍。希望这个方案能帮你把编译实验真正跑通透让你在C和编译原理两个方向上建立可靠的技术直觉。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑