编译原理实验C++实现:从词法分析到中间代码生成的完整路径
简介面向杭电编译原理课程的实验源码包完整覆盖词法分析、NFA到DFA的子集构造法、递归下降分析及LL(1)语法分析等核心模块适合正在学习编译器构造、需要课程设计参考或备战实验考核的计算机专业学生。压缩包内共11个文件包括4个C源文件、4个可直接运行的exe程序、2个txt文本文件及1个sy测试文件整体大小仅334KB。其中cpp代码便于阅读算法实现exe可快速验证运行效果txt与sy文件则提供输入样例和辅助说明目录按模块划分结构清晰。已有1605人学习下载。通过对照源码与运行结果可直观理解词法分析中八进制/十六进制识别与注释处理、子集构造法的状态转换过程以及递归下降和LL(1)解析器的设计与实现思路对深入掌握编译原理关键知识点具有实用价值。1. 编译原理实验在杭电课程里到底在考什么动手前先想清的三件事很多人拿到「编译原理实验 杭电 源代码 C」这个组合时第一反应是找现成代码第二反应才是打开编译器。但说句扎心的这门实验的验收重点从来不是「语法分析背得多熟」而是你的 C 程序怎么把自动机、递归下降、符号表这些纸上概念变成能跑通的源码。杭电的编译原理实验一般是给一个微型语言子集让你从词法分析一路做到中间代码生成按阶段提交和验收。适合谁看正在赶实验、想抄但怕翻车的本科生以及想搞清楚「为什么书上的例子一写进 C 就崩」的从业者。本文不提供「一键交作业」的魔法只讲一条我自己踩过坑之后信服的落地路径。2. 词法分析器的 C 实现怎么把字符流干净地切成 Token2.1 手工状态机还是正则库选型先看你的语言子集词法分析是编译原理实验里第一个能「跑出东西」的环节也是很多人第一次意识到「教材和代码是两回事」的地方。杭电历届实验里常见的做法有两种一是拿 C 的std::regex逐个匹配关键字、标识符、数字二是手工写一个基于状态转换的扫描器。我一般建议选手工状态机理由很直接正则库写起来快但报错位置和分支控制是黑盒等到语法分析阶段你要追「这个 Token 是怎么被切出来的」时regex_match帮不了你。手工状态机也不复杂核心就三个组件一个按字符驱动状态转移的switch/if骨架一张关键字哈希表和一个带缓冲区管理的读取接口。对杭电这类课设里常见的 PL/0 子集、类 C 表达式语言状态数通常在 10 到 20 个之间用代码流程控制完全够。比正则库更靠谱的地方在于你能明确知道每个字符被吞进了哪个 token排查「标识符和关键字串位」这类问题只需要盯状态转移。2.2 可复刻的最简 C 词法器骨架状态转移与 Token 产出这里给一个可以直接改着用的 C 骨架。它面向一个简化语言关键字if、else、while、int标识符、整数字面量、运算符 - * / ( ) { } ;外加赋值。代码思路是逐字符读入跳过空白按首字符分流进数字、字母和符号三条路。#include iostream #include string #include unordered_map #include vector enum class TokenType { KEYWORD, IDENT, NUMBER, OP, DELIM, END }; struct Token { TokenType type; std::string lexeme; int line; // 行号给语法分析报错用 int col; // 列号 }; // 关键字表识别出字母串后先查表命中就是关键字 const std::unordered_mapstd::string, TokenType keywords { {if, TokenType::KEYWORD}, {else, TokenType::KEYWORD}, {while, TokenType::KEYWORD}, {int, TokenType::KEYWORD} }; bool isDigit(char c) { return c 0 c 9; } bool isAlpha(char c) { return (c a c z) || (c A c Z); } class Lexer { public: explicit Lexer(const std::string src) : src(src), pos(0), line(1), col(1) {} // 核心入口一次调用产出一个 Token遇到文件尾返回 END Token nextToken() { skipWhitespaceAndComments(); if (pos src.size()) return {TokenType::END, , line, col}; char c src[pos]; // 数字开头一路吃到非数字字符为止 if (isDigit(c)) return readNumber(); // 字母或下划线开头按标识符/关键字处理 if (isAlpha(c) || c _) return readIdentOrKeyword(); // 运算符和分隔符单字符直接产出 return readSymbol(); } private: void advance() { if (src[pos] \n) { line; col 1; } else { col; } pos; } void skipWhitespaceAndComments() { // 跳过空格、制表符、换行顺手处理 // 行注释 while (pos src.size()) { if (src[pos] || src[pos] \t || src[pos] \n || src[pos] \r) { advance(); } else if (src[pos] / pos 1 src.size() src[pos 1] /) { while (pos src.size() src[pos] ! \n) advance(); } else { break; } } } Token readNumber() { int startLine line, startCol col; std::string lexeme; while (pos src.size() isDigit(src[pos])) { lexeme.push_back(src[pos]); advance(); } // 注意数字后面直接跟字母的情况没处理属于词法错误 return {TokenType::NUMBER, lexeme, startLine, startCol}; } Token readIdentOrKeyword() { int startLine line, startCol col; std::string lexeme; while (pos src.size() (isAlpha(src[pos]) || isDigit(src[pos]) || src[pos] _)) { lexeme.push_back(src[pos]); advance(); } auto it keywords.find(lexeme); if (it ! keywords.end()) return {it-second, lexeme, startLine, startCol}; return {TokenType::IDENT, lexeme, startLine, startCol}; } Token readSymbol() { int startLine line, startCol col; char c src[pos]; advance(); // 单字符运算符和分隔符如果想支持 、!在这里补一个双字符匹配分支 return {TokenType::OP, std::string(1, c), startLine, startCol}; } const std::string src; size_t pos; int line, col; };代码逻辑不复杂但有三个参数值得盯。第一是advance()里的行列维护它直接决定后续语法分析报错的定位质量很多人懒得存行列号等到递归下降里「第几行语法错误」变成「第 0 行」时才知道后悔。第二是关键字表的查找时机必须先做完整读取、再查表不能一边读一边查否则ifx这种合法标识符会被错认成if加x。第三是注释跳过放在 Token 读取前而不是塞进每个分支里这样「注释在运算符后面」的情况才不会卡住状态机。2.3 Token 流转给语法分析超前读与缓冲区边界词法器写完很多人直接while (lexer.nextToken().type ! END)逐个打印看着挺对接进语法分析就崩。崩的根源通常是两个。其一是超前读lookahead递归下降解析器往往需要「预读一个 Token 决定走哪个分支」而简单地一个nextToken()是消费式接口。常见做法是给语法分析器配一个peek()/advance()的包装层内部用一个Token缓存。其二是缓冲区边界C 里如果从std::ifstream读取整个文件要按std::string一次性读入并保持生命周期比Lexer更长否则Lexer持有的是悬垂引用词法阶段可能一切正常一进语法分析就随机段错误——这种坑跟机器玄学一样难查但原因往往只是字符串被移动了。3. 语法分析递归下降解析与 LL(1) 冲突的调参思路3.1 为什么多数 C 课设选递归下降而不是 LR杭电编译原理实验的语法分析阶段主流路线有两条手写递归下降或者用工具生成 LR(1) 的 parser比如 Bison。我的判断是除非你的语言子集大得离谱、或者老师明确要求用自动生成器否则递归下降更划算。原因很简单LR 自动机一旦出冲突错误信息读起来像天书你很难在验收现场快速说清「为什么这里是 shift 那里是 reduce」而递归下降每个非终结符对应一个 C 函数逻辑跟文法一一映照答辩时老师问「你的expr怎么处理左递归」你能直接打开函数讲。代价就是左递归文法需要手动消除——这一步也是实验报告里很重要的一页。3.2 一个四则运算表达式的递归下降实现下面给一个能嵌入你的实验框架的表达式解析器文法取最简单的消除左递归版本expr - term expr_tail expr_tail - term expr_tail | - term expr_tail | ε term - factor term_tail term_tail - * factor term_tail | / factor term_tail | ε factor - ( expr ) | number | ident#include iostream #include string #include vector #include stdexcept class Parser { public: explicit Parser(const std::vectorToken tokens) : tokens(tokens), pos(0) {} // 对外的解析入口返回整棵表达式的根节点可以替换成你自己的 AST 类型 void parse() { expr(); if (current().type ! TokenType::END) { throw std::runtime_error(unexpected token after expression); } } private: Token current() const { return tokens[pos]; } void advance() { if (pos tokens.size()) pos; } void expr() { term(); while (current().type TokenType::OP (current().lexeme || current().lexeme -)) { // 这里按赋值语句的语义动作处理等到第 4 章再展开 // 语法阶段只需要消费掉运算符并继续解析右侧 std::string op current().lexeme; advance(); term(); } } void term() { factor(); while (current().type TokenType::OP (current().lexeme * || current().lexeme /)) { std::string op current().lexeme; advance(); factor(); } } void factor() { Token tok current(); if (tok.type TokenType::NUMBER || tok.type TokenType::IDENT) { advance(); } else if (tok.lexeme () { advance(); expr(); if (current().lexeme ! )) { throw std::runtime_error(missing right parenthesis); } advance(); } else { throw std::runtime_error(unexpected token in factor: tok.lexeme); } } const std::vectorToken tokens; size_t pos; };有一个值得注意的设计细节expr和term里的while循环就是文法里expr_tail和term_tail的迭代版这比单独写递归的expr_tail()函数更不容易爆栈也更贴近「循环处理同一优先级运算符」的直觉。factor里对(的递归调用是重点如果不在这里递归expr()括号嵌套就无从谈起同时要把)缺失当成运行时错误抛出来——语法分析器必须报错而不是默默返回。3.3 FIRST/FOLLOW 集合手工构建与代码检查递归下降写成代码之后理论题就来了老师大概率会问「你的文法为什么是 LL(1)」。你要能说清两点每个非终结符的 FIRST 集合里没有冲突以及每个产生式候选的 FIRST 集合两两不相交。拿上面的表达式文法举例expr_tail有两个候选 term expr_tail和ε它们的 FIRST 集合分别是{}和空集此时要看 FOLLOW(expr_tail)而 FOLLOW 里包含)和 END不包含所以这两个候选能区分。用代码验证的办法很简单在expr()的while循环里打印进入分支的 Token对照手工算的 FIRST 集合一次就能发现「哦我这里漏了-运算符」之类的问题。常见误用是不管 FIRST 集合直接在代码里堆if (token || token -)当前文法没问题等扩展到逻辑运算符和比较运算符时就会混淆优先级。3.4 语法错误恢复Token 同步机制很多人的语法分析器写到「能解析合法输入」就停了验收时被老师一输入x (1 2;就现出原形。错误恢复的常见做法是「panic mode」在factor或expr的报错分支里不直接throw而是跳过 Token 直到遇到一个同步标记比如;、}或 END。但这里要控制好跳过范围跳到头会导致后续一堆「连锁报错」刷屏反而让老师觉得你的程序是事故现场。我的做法是抛出一个带行号和列号的异常在语法分析的顶层调用处捕获并打印「第几行第几列出错」然后继续解析下一个语句这样一次只报一个错看起来更像正经诊断器也符合实验报告里「错误定位与恢复」这一小节的要求。4. 符号表与语义动作从分析树到三地址码的落地路径4.1 符号表结构设计作用域栈加哈希表符号表如果不当心写到中途就会发现「变量重名」「类型对不上」根本无从查起。杭电这类实验里我的常用设计是「一个全局作用域栈 每层一个unordered_map」。为什么要栈而不是单张表因为 C 语言风格的子语言里有花括号作用域块结束时要能整体退出符号表单表也能做但你得给每个变量记一个「所属作用域深度」查询时按深度过滤麻烦且容易漏。#include unordered_map #include vector #include string #include optional struct SymbolInfo { std::string name; std::string type; // int 或后续扩展的自定义类型 int scopeDepth; bool initialized; // 语义分析检查「未初始化就使用」用 }; class SymbolTable { public: void enterScope() { depth; } void exitScope() { if (!scopes.empty()) scopes.pop_back(); depth--; } bool declare(const SymbolInfo info) { if (scopes.empty()) scopes.push_back({}); auto cur scopes.back(); if (cur.find(info.name) ! cur.end()) return false; // 当前作用域重复声明 cur[info.name] info; return true; } // 查找要逐层向外找当前作用域优先找不到再查上层 std::optionalSymbolInfo lookup(const std::string name) const { for (int i (int)scopes.size() - 1; i 0; i--) { auto it scopes[i].find(name); if (it ! scopes[i].end()) return it-second; } return std::nullopt; } private: std::vectorstd::unordered_mapstd::string, SymbolInfo scopes; int depth 0; };declare返回false的情况要跟语义错误绑定比如int a; int a;就要报「变量重复声明」。lookup从栈顶往栈底查这保证内层变量能遮蔽外层变量符合多数语言的作用域规则。一个容易踩的坑是块结束调用exitScope()之前必须先回收这个块产生的三地址码临时变量列表后做完里一块的临时变量会残留到外层写中间代码时就会多出大量没用的t1、t2看着乱且占据寄存器编号。4.2 语义动作示例赋值语句和表达式的三地址码生成语义分析阶段最核心的产出是中间代码。对课设规模的语言三地址码是最常用的表示。这里给一个在递归下降里插入语义动作的示例框架struct TAC { std::string op; // 、-、、goto等 std::string arg1; std::string arg2; std::string result; }; class CodeGenerator { public: // 生成形如 t1 a b 的三地址指令 TAC generateBinaryOp(const std::string op, const std::string a, const std::string b) { std::string temp newTemp(); TAC inst{op, a, b, temp}; code.push_back(inst); return inst; } TAC generateAssign(const std::string lhs, const std::string rhs) { TAC inst{, rhs, , lhs}; code.push_back(inst); return inst; } void printCode() const { for (size_t i 0; i code.size(); i) { const auto instr code[i]; if (instr.op ) { std::cout i : instr.result instr.arg1 \n; } else { std::cout i : instr.result instr.arg1 instr.op instr.arg2 \n; } } } private: std::string newTemp() { return t std::to_string(tempCount); } std::vectorTAC code; int tempCount 0; };这个生成器的关键在newTemp()每次生成二元运算都申请一个新临时变量虽然代码会冗余但正确性最好保证。别想着「复用临时变量省数目」那是寄存器分配阶段的事在语义分析阶段做优化只会让逻辑纠缠在一起。拿着这个框架上面语法分析的expr()里的分支就能改成读取左操作数的地址可能来自符号表也可能是嵌套表达式生成的临时变量解析右操作数然后generateBinaryOp(, leftAddr, rightAddr)。赋值语句类似generateAssign(varName, exprResult)。这一步做完你就有了一个可打印、可单步跟踪的三地址码列表。4.3 类型检查与作用域嵌套挂在语义动作上的两个易漏点类型检查写不写深课设层次通常只要「表达式两侧类型一致」和「赋值类型兼容」。最容易漏的是隐式类型转换如果语言子集里有int和float你必须在generateBinaryOp前检查两侧类型不一致时决定是「报错」还是「生成一条类型转换指令」。另一个易漏点是函数如果有的话的参数匹配符号表里参数列表要单独存一份类型数组调用时逐个比对不能只看参数个数。作用域嵌套的坑则在上面提到过exitScope和临时变量清理必须同步否则中间代码末尾会挂着一堆悬空变量引用后端一跑就段错误。5. 杭电编译原理实验的常见坑与排查清单三条血泪经验5.1 现象VSCode 里编译通过一运行就中文乱码或注释串位很多人在 Windows 上用 VSCode 配 C 编译环境实验代码里写了中文注释结果词法分析器把注释里的中文按多字节字符逐个吞掉状态机一路「吃」到下一行代码语法全乱。原因在于C 的std::string按字节存取UTF-8 中文每个字占 3 字节你的//注释检测只把换行当结束如果某个中文里恰好有字节值和\n一样概率虽小但理论存在且 GBK 编码更容易踩注释会提前结束。解决词法分析阶段强制按 ASCII 子集处理遇到非 ASCII 字节直接报「非法字符」或者更省事——实验源码统一用英文注释验收时能解释清逻辑就行没人查注释语言。这个坑在 VSCode 里尤其容易翻车因为默认编码是 UTF-8而老式实验平台可能按 GBK 保存。5.2 现象递归下降解析器遇到长表达式直接爆栈a b c ...连续几十层右结合因子时递归调用深度等于表达式长度默认栈深一满就stack overflow。原因有两层一是expr - term expr_tail虽然消除了左递归但factor - ( expr )的嵌套括号仍会递归二是表达式本身如果写得长加上调试打印栈消耗就上去了。解决思路把while循环已经覆盖的expr_tail部分保持迭代不要把循环改写成递归真正必须递归的地方只有括号嵌套和函数调用参数这两处的深度通常可控。用迭代版本后上万字符的表达式也不会爆栈。这是我第一次实验翻车时查了很久才定位到的——听到「编译原理实验崩了」第一反应是语法错误完全没想到栈空间。5.3 现象文法 FIRST 集合有明显冲突但代码怎么跑都对有时你手工写递归下降发现expr_tail的 FIRST 和 FOLLOW 集合会碰到同一个 Token按理说 LL(1) 条件不满足但程序跑起来永远正确。原因是测试用例太少。比如你的语法里expr_tail有空转移ε并且 FOLLOW(expr_tail) 包含)而某个表达式恰好是(a)——此时解析完a后看到)会走 ε 分支退回上层逻辑正确。但如果改成(a b) c外层的会怎样它会先被内层expr_tail消费还是留给外层这两个选择路径不同跑出来结果一样是因为你恰好把处理成了同一层循环。真正暴露冲突的办法打印每个非终结符进入时预读的 Token对照你手工算的 FIRST/FOLLOW 表逐行核。我一般会写一段测试代码把文法结构打印成一棵树输入故意设计成「右括号后直接跟运算符」这种边界形态冲突立刻现形。5.4 现象符号表查不到变量但明明声明过这个问题最常见于作用域栈实现有 bugenterScope和exitScope的调用位置不对。比如在while语句的处理里你在解析条件表达式之前就enterScope解析完又忘了exitScope导致循环体里声明的变量「污染」了外层作用域。解决把进入和退出作用域的调用包成一个 RAII 风格的结构或者用try/finally语义保证exitScope一定执行。更稳的做法是在Parser里配一个独立的nestingDepth计数跟符号表的depth对拍解析器每下降一层括号就打印一次两个值跑几个用例马上能看出谁多进少出。6. 让实验能被自信验收的验证技巧最小测试集与单步跟踪到这一步词法、语法、符号表和中间代码都已经在跑但「能跑」和「敢答辩」之间还有一道鸿沟你怎么证明它是错的还是对的我的习惯是准备三个测试文件每个控制在 30 行以内。第一个只测词法故意放几种非法字符、类型不对的 Token、跨行注释验证错误定位到行号列号。第二个只测表达式嵌套括号、连续加减乘除混合、一元负号如果你实现了的话这是递归下降最容易翻车的地方。第三个测完整流程几段带变量的赋值语句最后打印三地址码人眼核对「每个临时变量的赋值和使用是否匹配」。调试靠 print 就够但别乱打。推荐在CodeGenerator里加一个setDebug(bool)开关打开时每生成一条指令就打印t3 t1 * t2并显示当前符号表栈深度。为什么这比断点好用因为编译器的执行路径是同一段代码反复递归断点会让你陷进几十帧调用栈出口打完还要算上下文打印则能连续看到「解析到哪个 Token、生成哪条指令」的完整轨迹。另一个实用技巧是把语法分析阶段的预见 Token 打印和词法阶段的行号列号对齐一旦语义动作结果对不上你立刻能确定是哪个阶段出的错而不是在三个模块之间来回试探。最后说一个我自己带过很多次教训的习惯验收前至少把程序拿到另一台机器或另一个平台的编译器上编一遍——我吃过一次「在 VSCode 上跑得好好的换到 GCC 就报 warning 当 error」的亏从那以后所有实验项目都额外加一条g -Wall -Wextra的编译检查花不了两分钟但能让你在现场少很多手忙脚乱。希望你也能顺顺利利过验收。本文还有配套的精品资源点击获取