资讯动态

编译原理词法分析与语法分析器C++实现:从零构建到工程实践

发布时间:2026/10/13 4:44:58 来源:尧图企业网站定制
简介这份资源是编译原理课程配套的编译器前端实践项目面向计算机专业学生、对编译器与解释器实现感兴趣的开发者以及希望夯实C工程能力的编程学习者。项目以C实现词法分析器与语法分析器覆盖关键字、标识符、常数、运算符等词法规则定义基于有限自动机完成词法单元序列转换并借助上下文无关文法与递归下降或LL(1)方法构建抽象语法树同时处理非法字符、格式错误数字及语法错误等异常情况。压缩包共9个文件包含2个cpp源码、2个可执行程序、4个txt文法与token表说明及1个md文档整体约937KB便于直接运行验证与对照阅读。目前已有736人学习下载。读者可从中获得完整的词法规则与文法定义、可编译运行的C实现代码、token表与源程序样例以及从词法分析到语法分析的完整前端流程参考适合作为课程实验、编译原理入门实践与后续语义分析扩展的基础素材。1. 从一份 .zip 说起编译原理词法分析器和语法分析器到底在做什么很多人第一次拿到「编译原理词法分析器和语法分析器的实现C.zip」这类压缩包是在课程实验周。解压之后通常能看到几个 .cpp、.h 文件一个 Makefile 或者 CMakeLists.txt可能还有测试用例和一份实验报告模板。但真正打开代码之前得先想清楚一件事词法分析器和语法分析器不是两个孤立的程序它们是一条流水线上的前后两道工序。词法分析器把字符流切成有意义的记号Token语法分析器再把 Token 序列组装成语法树。C 实现的好处是性能可控、内存布局清晰适合处理大规模输入代价是手动管理内存和状态机稍不留神就翻车。这篇文章面向正在做编译原理实验、或者想自己从零写一个可用的词法/语法分析器的 C 开发者从最小可运行版本开始逐步补上符号表、错误恢复和测试验证最后给出几个能直接抄的工程习惯。2. 词法分析器从字符流到 Token 序列的最小实现2.1 为什么先写词法分析器而不是直接上语法分析编译原理实验里最常见的翻车顺序是先写语法分析写到一半发现 Token 类型不够用回头改词法分析器结果语法分析的状态机全乱了。正确的做法是先把词法分析器的接口定死输入是一个字符串或文件流输出是一个std::vectorToken每个 Token 包含类型、原始文本、行号和列号。行号和列号不是可选项后面语法分析报错时没有位置信息调试基本靠猜。词法分析器的核心是一个状态机。对于大多数教学语言比如类 C 语言、类 Pascal 语言状态不会超过二十个。常见做法是用switch嵌套while而不是正则表达式库。原因很简单正则库引入额外依赖而且性能不可控手写状态机虽然代码长一点但每一步都能打断点。2.2 用 C 写一个可扩展的词法分析器骨架下面是一个最小可运行版本支持标识符、整数、浮点数、四则运算符和括号。代码可以直接编译运行。#include iostream #include string #include vector #include cctype enum class TokenType { Identifier, Number, Plus, Minus, Star, Slash, LParen, RParen, EndOfFile, Unknown }; struct Token { TokenType type; std::string text; int line; int column; }; class Lexer { public: Lexer(const std::string src) : src_(src), pos_(0), line_(1), col_(1) {} std::vectorToken tokenize() { std::vectorToken tokens; while (pos_ src_.size()) { char c src_[pos_]; if (std::isspace(c)) { advance(); continue; } if (std::isalpha(c) || c _) { tokens.push_back(readIdentifier()); } else if (std::isdigit(c)) { tokens.push_back(readNumber()); } else { tokens.push_back(readOperatorOrParen()); } } tokens.push_back({TokenType::EndOfFile, , line_, col_}); return tokens; } private: std::string src_; size_t pos_; int line_; int col_; void advance() { if (src_[pos_] \n) { line_; col_ 1; } else { col_; } pos_; } Token readIdentifier() { int startLine line_, startCol col_; std::string text; while (pos_ src_.size() (std::isalnum(src_[pos_]) || src_[pos_] _)) { text src_[pos_]; advance(); } return {TokenType::Identifier, text, startLine, startCol}; } Token readNumber() { int startLine line_, startCol col_; std::string text; bool hasDot false; while (pos_ src_.size() (std::isdigit(src_[pos_]) || src_[pos_] .)) { if (src_[pos_] .) { if (hasDot) break; // 第二个小数点不合法留给错误处理 hasDot true; } text src_[pos_]; advance(); } return {TokenType::Number, text, startLine, startCol}; } Token readOperatorOrParen() { int startLine line_, startCol col_; char c src_[pos_]; advance(); switch (c) { case : return {TokenType::Plus, , startLine, startCol}; case -: return {TokenType::Minus, -, startLine, startCol}; case *: return {TokenType::Star, *, startLine, startCol}; case /: return {TokenType::Slash, /, startLine, startCol}; case (: return {TokenType::LParen, (, startLine, startCol}; case ): return {TokenType::RParen, ), startLine, startCol}; default: return {TokenType::Unknown, std::string(1, c), startLine, startCol}; } } }; int main() { std::string input x1 3.14 (y2 - 1); Lexer lexer(input); auto tokens lexer.tokenize(); for (const auto t : tokens) { std::cout Token(type static_castint(t.type) , text t.text , line t.line , col t.column )\n; } return 0; }逻辑说明advance()负责推进位置并维护行列号readIdentifier()和readNumber()分别处理标识符和数字数字里用hasDot防止3.14.15这种输入被吞成一个 TokenreadOperatorOrParen()用switch处理单字符运算符。参数方面line_和col_从 1 开始计数方便和编辑器行号对齐EndOfFileToken 是语法分析器的终止信号不能省。2.3 词法分析器里最容易忽略的三个参数第一个是缓冲区大小。如果直接读整个文件到std::string对于几十 MB 的输入没问题但如果要处理流式输入就得改成固定大小缓冲区加滑动窗口。第二个是最大标识符长度。C 标准建议至少支持 1024 个字符但实际实验里超过 255 个字符的标识符基本是恶意输入可以截断并报错。第三个是注释处理。单行注释//和多行注释/* */必须在词法阶段吃掉否则语法分析器会收到一堆无意义的/和*。常见做法是在tokenize()主循环里加一个skipComment()分支遇到//就跳到行尾遇到/*就跳到*/同时更新行列号。3. 语法分析器递归下降还是 LR选型决定后面三天的调试量3.1 递归下降的适用边界与 C 实现要点递归下降是手写语法分析器里最直观的方案每个非终结符对应一个函数函数体里按产生式右部依次调用其他函数或匹配 Token。它的优点是调试方便断点打在哪一目了然缺点是遇到左递归文法会无限递归必须提前消除左递归。对于编译原理实验里常见的表达式文法消除左递归之后写起来很快。下面是一个支持加减乘除和括号的递归下降分析器接在上一节的词法分析器后面。class Parser { public: Parser(std::vectorToken tokens) : tokens_(std::move(tokens)), pos_(0) {} // Expr - Term (( | -) Term)* double parseExpr() { double left parseTerm(); while (match(TokenType::Plus) || match(TokenType::Minus)) { TokenType op tokens_[pos_ - 1].type; double right parseTerm(); if (op TokenType::Plus) left right; else left - right; } return left; } private: std::vectorToken tokens_; size_t pos_; bool match(TokenType type) { if (pos_ tokens_.size() tokens_[pos_].type type) { pos_; return true; } return false; } Token expect(TokenType type) { if (pos_ tokens_.size() tokens_[pos_].type type) { return tokens_[pos_]; } throw std::runtime_error(Unexpected token at line std::to_string(tokens_[pos_].line)); } // Term - Factor ((* | /) Factor)* double parseTerm() { double left parseFactor(); while (match(TokenType::Star) || match(TokenType::Slash)) { TokenType op tokens_[pos_ - 1].type; double right parseFactor(); if (op TokenType::Star) left * right; else left / right; } return left; } // Factor - Number | ( Expr ) double parseFactor() { if (match(TokenType::Number)) { return std::stod(tokens_[pos_ - 1].text); } if (match(TokenType::LParen)) { double val parseExpr(); expect(TokenType::RParen); return val; } throw std::runtime_error(Expected number or ( at line std::to_string(tokens_[pos_].line)); } };逻辑说明parseExpr处理加减parseTerm处理乘除parseFactor处理数字和括号三层结构对应运算符优先级。match是“看当前 Token 是否匹配匹配就前进”expect是“必须匹配否则抛异常”。参数方面pos_是当前 Token 下标所有函数都通过它读写异常里带上行号方便定位错误。3.2 LR 分析器在 C 里的落地成本如果文法不是 LL(1)或者需要处理更复杂的语言特性比如 C 语言的声明与表达式歧义递归下降就得靠回溯或者改写文法代码会迅速膨胀。这时候 LR 分析器更合适。但 LR 分析器需要先构造 ACTION 表和 GOTO 表手工构造几乎不可能通常用工具生成。在 C 实验环境里常见做法是用 Python 脚本生成分析表导出成 C 数组再写一个通用的移进-归约驱动器。移进-归约驱动器的核心是一个状态栈和一个符号栈。每次循环读栈顶状态和当前 Token查 ACTION 表如果是移进把 Token 和下一个状态压栈如果是归约按产生式弹出相应数量的符号和状态查 GOTO 表压入新状态如果是接受返回语法树根节点。这个驱动器代码量不大但分析表的正确性完全依赖生成工具调试时如果发现归约顺序不对基本是文法写错了而不是驱动器的问题。3.3 语法树节点设计与内存管理递归下降版本可以直接在函数返回值里算数但真实编译器需要构造语法树。C 里语法树节点常见做法是用std::unique_ptr管理子节点父节点持有子节点的所有权。节点基类定义accept(Visitor)方法具体节点继承并实现。这样后面写语义分析、代码生成时可以用访问者模式遍历不用改节点定义。struct ASTNode { virtual ~ASTNode() default; }; struct NumberNode : ASTNode { double value; explicit NumberNode(double v) : value(v) {} }; struct BinaryNode : ASTNode { char op; std::unique_ptrASTNode left; std::unique_ptrASTNode right; BinaryNode(char o, std::unique_ptrASTNode l, std::unique_ptrASTNode r) : op(o), left(std::move(l)), right(std::move(r)) {} };参数说明std::unique_ptr保证节点析构时自动释放子树不需要手写递归 deleteBinaryNode的构造函数用移动语义接管左右子树避免拷贝。如果实验要求里禁止用智能指针那就得在节点析构函数里手动 delete 左右子节点并且注意不要重复释放。4. 符号表与错误恢复让分析器从“能跑”变成“能用”4.1 符号表的数据结构选择与作用域链词法分析和语法分析只关心语法结构但实验里经常要求同时输出变量声明和引用信息这就需要符号表。符号表最简单的实现是std::unordered_mapstd::string, Symbol但作用域嵌套时单层 map 不够用。常见做法是用栈式符号表进入一个作用域压入一个新 map退出时弹出。查找时从栈顶往下找找到第一个匹配就返回。class SymbolTable { public: void enterScope() { scopes_.emplace_back(); } void exitScope() { scopes_.pop_back(); } bool declare(const std::string name, const std::string type) { auto current scopes_.back(); if (current.count(name)) return false; // 重复声明 current[name] type; return true; } std::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 ; // 未声明 } private: std::vectorstd::unordered_mapstd::string, std::string scopes_; };逻辑说明scopes_是 vector 当栈用enterScope和exitScope成对出现。declare只在当前作用域检查重复允许内层作用域遮蔽外层同名变量。lookup从最内层往外找符合大多数语言的名称解析规则。参数方面type用字符串存实验够用如果要做类型检查得换成枚举或类型对象。4.2 语法错误恢复的三种策略语法分析器遇到错误时直接抛异常退出对于实验演示够用但真实场景需要继续分析后面的代码尽可能多报几个错。常见策略有三种恐慌模式、短语级恢复和错误产生式。恐慌模式是丢弃 Token 直到遇到同步集合比如分号、右花括号然后继续短语级恢复是尝试插入或删除一个 Token 让文法继续错误产生式是在文法里显式写出常见错误模式匹配到就报特定错误。在递归下降里恐慌模式最容易实现在parseExpr等函数里捕获异常跳到下一个分号或右括号然后返回一个占位节点。注意不要无限循环——如果当前 Token 就是同步符号必须强制前进一个 Token。4.3 用测试用例驱动分析器开发写分析器最怕的是“看起来能跑一换输入就崩”。建议先写一组测试用例覆盖正常表达式、缺少右括号、多余运算符、空输入、超长标识符。每个用例给出期望的 Token 序列或期望的异常信息。C 里可以用简单的assert或者 Catch2 单测框架。如果实验环境不允许引入第三方库就写一个runTest()函数手动比对输出。void runTest(const std::string input, const std::string expected) { Lexer lexer(input); auto tokens lexer.tokenize(); std::string actual; for (const auto t : tokens) actual t.text |; if (actual ! expected) { std::cerr FAIL: input input \n expected: expected \n actual: actual \n; } }参数说明expected用|分隔 Token 文本方便肉眼比对EndOfFile的文本为空所以末尾会多一个|写期望值时注意。这个测试函数不依赖任何框架直接编译进主程序即可。5. 避坑与排查编译原理实验里最常见的五个翻车现场5.1 现象词法分析器把3.14.15当成一个数字语法分析器解析出错误结果原因readNumber()里没有检查第二个小数点while条件只判断了isdigit和.导致连续小数点被吞。解决在循环里加hasDot标志遇到第二个.时 break让后续字符走Unknown分支或者报错。更稳妥的做法是数字状态机里区分“整数部分”“小数点”“小数部分”三个状态。5.2 现象递归下降分析器在输入((12)时栈溢出崩溃原因parseFactor遇到(后递归调用parseExpr但缺少右括号时expect(RParen)抛异常异常沿调用栈向上传播如果上层没有捕获程序直接终止。解决在parseExpr入口加 try-catch捕获后跳到同步符号比如行尾或右括号返回一个NumberNode(0)占位。注意 catch 里要检查pos_是否前进否则会死循环。5.3 现象符号表在退出作用域后仍然能查到内层变量原因exitScope()只弹出了 map但lookup遍历的是scopes_的副本或者引用失效。解决确保lookup用const引用遍历并且exitScope确实调用了pop_back()。如果用了迭代器缓存弹出后迭代器失效必须重新获取。5.4 现象CMake 编译通过但链接时报undefined reference to Lexer::tokenize()原因头文件里声明了tokenize()但 .cpp 文件里函数签名不一致比如漏了const或者参数类型不同。解决用nm查看目标文件符号对比声明和定义的签名。C 里std::string和const std::string是不同的符号必须完全一致。5.5 现象VS Code 里能跳转但命令行编译报找不到头文件原因VS Code 的 C 插件用c_cpp_properties.json里的includePath做智能提示但实际编译用的是tasks.json里的命令两者配置不同步。解决把includePath和编译命令里的-I参数对齐或者直接用 CMake 生成compile_commands.json让插件和编译共用一套配置。6. 进阶技巧把分析器接上真实输入并验证正确性6.1 从文件读入并输出带行号的错误信息实验里的输入通常是一个 .txt 文件。用std::ifstream读入整个文件到std::string然后交给 Lexer。错误信息里带上行号和列号格式建议是[line:col] error: message方便和编辑器的问题面板对接。std::string readFile(const std::string path) { std::ifstream ifs(path); if (!ifs) throw std::runtime_error(Cannot open file: path); return std::string((std::istreambuf_iteratorchar(ifs)), std::istreambuf_iteratorchar()); }参数说明std::istreambuf_iterator比更快且不会跳过空白字符如果文件很大可以改成逐行读取但要注意跨行 Token比如多行注释的处理。6.2 用 AST 打印验证语法结构递归下降版本可以在每个parseXxx函数返回后打印节点信息但更清晰的做法是单独写一个printAST函数按缩进输出树形结构。下面是一个简单实现void printAST(const ASTNode* node, int indent 0) { std::string pad(indent * 2, ); if (auto num dynamic_castconst NumberNode*(node)) { std::cout pad Number( num-value )\n; } else if (auto bin dynamic_castconst BinaryNode*(node)) { std::cout pad Binary( bin-op )\n; printAST(bin-left.get(), indent 1); printAST(bin-right.get(), indent 1); } }逻辑说明dynamic_cast在调试阶段够用性能不是瓶颈如果节点类型很多可以改成访问者模式。参数方面indent控制缩进层级每层两个空格输出结果可以直接和手画的语法树对比。6.3 一个我常用的验证习惯每次改完词法或语法分析器先跑三个输入一个正常表达式、一个缺右括号的表达式、一个空文件。正常表达式验证主流程缺右括号验证错误恢复空文件验证边界条件。这三个过了再跑课程提供的测试用例。如果测试用例里有用例失败先看行号再看 Token 序列最后看语法树。大部分问题出在词法阶段——Token 类型不对语法分析器再正确也白搭。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑