ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++实现编译原理词法分析与语法分析器:从Token到AST的完整实践

C++实现编译原理词法分析与语法分析器:从Token到AST的完整实践 简介基于C实现的编译原理词法分析器与语法分析器课程设计项目完整覆盖从词法规则定义、有限自动机识别到语法分析、抽象语法树构建的完整流程面向计算机专业学生、编译技术初学者及对编译器前端感兴趣的开发者。压缩包共9个文件包含2份C源码、2个可直接运行的exe、4个txt文本说明文法定义、token表、源程序等及1份README文档整体大小仅937KB轻量便于下载和实验。已有736人学习访问包内含可直接运行的exe与完整源码结构清晰是入门级编译器前端实现。代码将词法分析与语法分析分成独立模块支持关键字、标识符、常数、运算符、分隔符等词法规则并能通过递归下降或LL(1)方法分析文法生成抽象语法树同时给出文法文件和token表帮助读者快速对照源码理解从字符流到语法树的转换过程也可作为编译原理课程设计、实验报告或后续功能拓展的参考基础。1. 项目整体设计与思路拆解看到这个标题“编译原理词法分析器和语法分析器的实现C.zip”第一反应就是经典的课程设计或者考研复试上机题。但说句实话这个项目虽然套路化却是理解“前端编译器”最扎实的一条路——词法分析器把字符流变成Token流语法分析器把Token流变成抽象语法树这两层打通之后后面的语义分析、中间代码生成都是在这棵树上做文章。先说说为什么选C而不是Java或者Python。词法分析和语法分析本质上是状态机驱动和递归下降的过程C在这两个场景下有天然优势一是std::unordered_map、std::vector这些容器能把状态转移表实现得非常直观二是你可以直接操作字符指针或者迭代器来扫描源码性能开销几乎为零三是C11之后的智能指针unique_ptr、shared_ptr能让你在构建语法树时不用手动管理内存这比裸指针舒服太多了。我当时用C17写完整套代码核心文件加起来不到900行编译后跑一个500行的测试程序词法分析加语法分析总耗时在毫秒级别如果用Python写性能大概要慢一两个数量级。还有一个很多人忽略的点这个项目最适合用来建立“编译全流程”的直觉。很多人学编译原理时被龙书里的形式化定义劝退但实际上你只要亲手把词法分析的自动机、语法分析的递归下降函数敲出来很多抽象概念就瞬间落地了。我在实现过程中最大的体会是——编译原理不是背出来的是调出来的。下面我会把整个项目从架构到每一段核心代码都拆开讲包括踩过的坑和调试验证方法争取让拿到这个压缩包的人能少走弯路。1.1 为什么“词法语法”要分开实现这是第一个需要想清楚的设计决策。词法分析和语法分析的分离不是随意为之而是因为两者处理的对象和复杂度差异非常大。词法分析处理的是字符串级别的模式匹配比如标识符、关键字、数字常量、运算符、分界符它关心的是“这个字符序列长什么样”。语法分析处理的是Token序列之间的组合关系比如表达式、声明、语句块它关心的是“这些Token按照什么样的文法规则组织起来才是合法的程序”。举个例子int a 10 b * 2;这行代码词法分析器把它切成int(关键字)、a(标识符)、(赋值符)、10(整数常量)、(加号)、b(标识符)、*(乘号)、2(整数常量)、;(分号)一共9个Token。语法分析器再把这9个Token拼成一棵语法树声明语句包含类型和初始化表达式初始化表达式是一个赋值运算右边是一个加法表达式加法的左操作数是整数常量10右操作数是一个乘法表达式……两层各司其职复杂度就被拆开了。从实现角度讲这种分离也方便调试——词法出错只会在Token流层面暴露语法出错只会在语法树构建层面暴露你不用在两套逻辑里来回排查。1.2 分析器的整体架构设计这个项目的架构我建议分三层第一层Token定义与词法分析器。Token是一个枚举类型TokenType加上值lexeme字符串、行号、列号词法分析器维护一个输入缓冲区指针逐字符扫描每识别出一个Token就返回给上层。第二层语法分析器递归下降。为每种语法成分写一个解析函数比如ParseProgram、ParseDeclaration、ParseExpression、ParseStatement等。这些函数按照文法规则调用彼此返回一个抽象语法树节点ASTNode。第三层AST数据结构与打印/验证工具。AST节点用智能指针管理每个节点记录类型、子节点列表和对应的源码位置。为了方便测试我会额外写一个AST的树形打印函数这样能直接看到语法分析的结果是否正确。关于TokenType枚举一开始可以定义这些KEYWORD关键字单独用字符串集合判断、IDENTIFIER标识符、INTEGER_CONSTANT、FLOAT_CONSTANT、OPERATOR、-、*、/、、、、、!等、DELIMITER分号、逗号、左右括号、左右花括号、EOF结束标记。在设计Token结构体时一定要把行号和列号存下来不然后面报错信息根本没法定位我就是一开始偷懒没存调语法错误时被折磨了很久。2. 词法分析器的核心实现2.1 手工构造还是自动机生成理论上词法分析器可以用正则表达式定义每个Token的模式再用工具Flex生成状态机代码。但在这个项目里我强烈建议手写词法分析器。原因很简单课程设计的文法通常很小手写状态机完全可控而且你可以在扫描过程中灵活处理错误恢复用Flex反而多了一层工具链依赖生成代码可读性差出了Bug很难排查。手写词法分析器的核心是循环扫描最长匹配。你需要维护两个指针start指向当前Token的起始位置forward向前扫描直到无法匹配为止。扫描时要处理的状态大致有这几个初始状态、标识符/关键字状态、数字状态、运算符/分界符状态、注释状态。每个状态下遇到不同字符怎么转移就是一张状态转移表。但实际写代码时通常不需要显式地定义表而是用分支判断处理因为词法规则离散度高写表反而麻烦。看一下我用来扫描标识符和关键字的代码片段// 词法分析核心循环识别标识符和关键字 if (isalpha(ch) || ch _) { start forward; while (isalnum(*forward) || *forward _) { forward; } std::string lexeme source.substr(start, forward - start); TokenType type KeywordTable.count(lexeme) ? TokenType::KEYWORD : TokenType::IDENTIFIER; tokens.push_back(Token(type, lexeme, line, column)); continue; }这段代码虽然短但关键点在于KeywordTable.count(lexeme)——先用字母/下划线开头的通用模式匹配出单词再查关键字表判断它到底是关键字还是普通标识符。这用的是“先统一定义再细分归类”的思路比在状态机里为每个关键字单独画分支简洁得多。数字常量的扫描要注意小数点和科学计数法我的建议是第一版只支持整数跑通流程后再扩展浮点数。因为浮点数的状态比整数多了小数点状态和指数状态处理不好容易在边界条件上出错。2.2 状态转移表的设计思路虽然我说手写词法分析器可以用分支判断但原理上还是要有状态转移的思维。我给你说一个我实测过的折中方案用std::unordered_mapstd::pairState, char, State来表示转移表状态和字符组成键转移到的状态作为值。这种做法的好处是逻辑清晰、扩展容易缺点是std::pair做哈希键性能略低但对于课程设计和教学演示级别的源码规模性能完全够用。举一个例子识别数字常量时的核心状态转移enum class NumberState { START, INTEGER, FLOAT, EXP_SIGN, EXP_DIGIT, DONE, ERROR }; std::unordered_mapstd::pairNumberState, char, NumberState numberTrans { {{NumberState::START, 0}, NumberState::INTEGER}, // ... 枚举所有数字和小数点、e/E、/-的转移 };当然实际项目里我不会真把转移表写得这么细因为分支判断在代码可读性上更好。但如果你想把词法分析器做得更通用比如支持正则表达式动态编译成状态机那转移表状态栈的方案就是必经之路了。从我个人的经验来看理解状态转移和特化实现并不矛盾你一定要在写分支判断时脑子里有状态转移的图景这样Debug时会快很多。2.3 关键字与标识符的冲突处理这是词法分析里最经典的问题——int既是关键字又符合标识符的模式。我见过很多初学者在这里踩坑常见错误是先匹配标识符再查关键字结果int被当成标识符或者反过来一旦发现以字母开头的字符串就优先匹配关键字集合结果用户自定义的变量名integer被错判成关键字。正确做法我上面已经写了先按标识符的通用规则字母/下划线开头后接字母、数字、下划线扫描出完整单词再去查关键字表判断类型。这样的好处是判定逻辑统一不用在字符级别区分关键字和标识符而且能够正确处理integer这种包含关键字子串的合法标识符。关键字表直接用std::unordered_setstd::string查找是O(1)的比用std::vector然后线性查找快很多。在我实现中关键字表至少包含了int、float、if、else、while、return这几个支持一门最小化的C语言子集足够了。3. 语法分析器的核心实现3.1 递归下降还是LR——我为什么选前者语法分析器的实现策略有很多种最主流的是两类一类是自顶向下的递归下降分析法一类是自底向上的LR分析法家族SLR、LR(1)、LALR。在这个项目里我强烈建议用递归下降分析法理由有三点第一递归下降的代码结构和文法规则一一对应可读性极强。你每写一个Parse函数就是在翻译一条文法产生式。什么叫“代码即文法的映射”比如产生式expr - term ((|-) term)*就对应一个ParseExpr函数函数体里先调用ParseTerm然后循环判断当前Token是加号还是减号继续调用ParseTerm组成左结合语法树。调试时你只需要对着文法和代码逐行比对定位问题非常快。第二递归下降的优先级处理直接体现在函数调用层次里。比如表达式文法可以分成5层表达式赋值、加法表达式、乘法表达式、一元表达式、基本表达式括号/数字/标识符。每层的解析函数只调用下一层的函数这样2 3 * 4就不会被解析成(2 3) * 4了。第三LR分析表的构造对于课程设计来说工作量太大了——你要计算First集合、Follow集合、构造LR(0)/SLR/LR(1)项目集族还得处理移进-归约冲突。这些过程理解一次就够了真要在项目里手搓一张分析表再写个表驱动分析器那是一门课两周的大作业体量。除非你的课程明确要求实现LR分析器否则递归下降是最务实的方案。3.2 消除左递归与提取公因子——两个必须做的预处理递归下降分析器要求文法是LL(1)的也就是说任何产生式的右部不能以同一个非终结符开头无公共前缀也不能有直接或间接的左递归。所以你要做的第一件事就是检查你的文法是否满足这两个条件。先看左递归。经典反例是表达式文法expr - expr term | term。如果按这个产生式写ParseExpr函数第一行就会无限递归调用自己直接栈溢出。解决方法是把左递归改写成右递归再加上循环处理左结合性。还是以表达式为例左递归改写的过程原始文法 expr - expr term | term 消除左递归后 expr - term expr expr - term expr | ε注意expr的递归调用在右边这样就不会无限递归了。但还有一个细节这样改写后expr右递归描述了右结合对于来说我们需要左结合。好在代码实现时可以解决这个问题——在循环中边读取边构建左结合树而不是依赖文法的结合性。比如ParseExpr函数解析完term后循环看到就继续解析下一个term然后把左节点和右节点合成新的节点这样a b c会形成(ab)c的结构符合预期的左结合。再说提取公因子。反例是if_stmt - if (expr) stmt | if (expr) stmt else stmt两个产生式都以if开头递归下降函数读到IF关键字后不知道该走哪个分支。解决方法就是把公共前缀提出来if_stmt - if (expr) stmt else_part else_part - else stmt | ε这样就不会有选择冲突了。我做项目时是先把要支持的文法完整写在注释里再逐步检查是否有左递归和公共前缀全通过后才会开始写代码。这一步不要偷懒不然调到后面你会被各种隐性调用栈问题折磨疯。3.3 抽象语法树AST的设计语法分析器的输出不是“碰巧合法”的Token序列而是一棵能够代表程序结构的信息完备的树。AST节点的设计直接决定了后续语义分析的复杂度。我当时的AST节点设计是这样的struct ASTNode { enum class NodeType { PROGRAM, DECLARATION, ASSIGNMENT, EXPRESSION, BINARY_OP, UNARY_OP, IDENTIFIER, INTEGER_LITERAL, FLOAT_LITERAL } type; NodeType nodeType; std::string value; // 标识符名称、字面量字符串等 TokenType opType; // 运算符类型 std::vectorstd::unique_ptrASTNode children; int line 0, column 0; };这个结构用了std::unique_ptr来管理子节点拷贝和移动语义都很安全不需要手动delete。需要特别注意的是children中每个节点的生命周期由父节点唯一持有所以不要用裸指针到处传。如果你需要做后续的遍历分析可以用const ASTNode*作为只读访问接口但永远不要让外部持有裸指针否则Node被释放后就成了悬空指针。我当初在这个地方吃过亏——传了一个裸指针给符号表模块结果AST回收后指针失效调试了好几个小时才发现是生命周期管理的问题。另外AST节点的构造通常伴随着少量“语义动作”最常见的就是常量折叠。比如解析2 3时如果左右两个节点都是字面量常量可以在构造节点时直接计算出5而不是生成一个包含加法和两个常量的子树。这个优化虽然简单但后续做中间代码生成时能减少一大部分无用计算。4. 实操过程与核心环节实现4.1 从文件读取源码并预处理词法分析器接收的输入是源码字符串。我建议一次性把整个文件读入内存而不是逐行读取。原因很简单词法分析经常需要往前看几个字符最长匹配逐行读取会带来缓冲管理的麻烦。读文件的代码片段std::ifstream file(test.c); std::string source((std::istreambuf_iteratorchar(file)), std::istreambuf_iteratorchar());注意这里用的是istreambuf_iterator而不是istream_iterator区别在于前者直接读原始字符流不会跳过空白字符。如果用istream_iterator默认是会跳过空格和换行的这会直接毁掉你的行列统计。这是一个非常隐蔽的坑我最初写的时候就被坑过。预处理阶段要注意的另一件事是注释的处理。我的方案是在词法分析器里遇到//时跳过到行尾遇到/*时跳到最近的*/并且在此过程中更新行号。如果注释没有闭合要给出明确的错误提示而不是直接崩溃。4.2 递归下降表达式解析——优先级与结合性的实战表达式是语法分析中最能体现“递归下降”精髓的部分。我直接贴一下表达式解析的核心代码这个结构基本是C语言子集类编译器的“标准答案”std::unique_ptrASTNode Parser::ParseExpr() { auto left ParseAdditiveExpr(); if (Match(TokenType::ASSIGN)) { auto right ParseExpr(); auto node MakeNode(ASTNode::NodeType::ASSIGNMENT); node-children.push_back(std::move(left)); node-children.push_back(std::move(right)); return node; } return left; } std::unique_ptrASTNode Parser::ParseAdditiveExpr() { auto left ParseMultiplicativeExpr(); while (Match({TokenType::PLUS, TokenType::MINUS})) { TokenType op PreviousToken().type; auto right ParseMultiplicativeExpr(); auto node MakeBinOpNode(op, left, right); left std::move(node); } return left; }ParseAdditiveExpr的循环逻辑就是“左结合”的代码实现——每次读到加号或减号就把左边已构建的子树和右边新解析的乘法表达式合并成新的左操作数。这样1 2 3就会先构建(12)再把3合并进去形成((12)3)。如果你把合并顺序反过来就会得到右结合的错误结构。优先级则是通过调用层次实现的ParseExpr调用ParseAdditiveExpr而ParseAdditiveExpr调用ParseMultiplicativeExpr。乘法层级更低所以遇到a b * c时加法解析函数先拿到a然后看到去解析b * cb * c在乘法层级内优先结合。这就是优先级从何而来的关键理解点。4.3 语句与声明解析的脑图语句解析相对于表达式要简单一些但需要注意的地方也不少。我的做法是给语句类型做一个顶层分发函数ParseStatement根据当前Token的类型决定进入哪个子解析函数if / else解析条件表达式、then分支、可选else分支。while解析条件表达式和循环体。return解析可选返回值表达式。{解析复合语句块。标识符后跟解析赋值语句。声明关键字int/float解析变量声明。一个容易被忽略的点是ParseBlock函数要负责管理作用域。虽然这个项目里符号表作用域管理可以简化处理只在语法树上标记Block节点但为了后续语义分析方便我建议在Block节点上挂一个std::unordered_mapstd::string, VariableInfo的作用域表这样每个块内变量的声明和引用就能被约束住。这个设计在你扩展语义分析时会省很多麻烦。4.4 错误处理与报错信息设计语法分析阶段遇到不符合文法的Token序列是常态所以错误处理机制不是可有可无的而是必须在一开始就设计好。我采用了两种错误恢复策略恐慌模式Panic Mode当解析出错时跳过若干Token直到遇到一个同步标记通常是分号、右花括号然后继续解析。这是最常用的恢复策略实现简单恢复效果好。单Token插入/删除对于一些拼写错误比如漏写分号、多写右括号可以尝试插入或删除Token后继续解析并在错误信息中提示推测的修复方式。报错信息至少要包含错误级别警告/错误、行号和列号、期望的Token集合和实际遇到的Token内容。千万不要只输出“Syntax Error”这几个字那不是给用户看的报错是给自己看的痛苦标记。我在测试时发现一个良好的报错信息能帮你把调试效率提升一倍不止。4.5 用AST可视化做调试——为什么Tree打印这么重要语法分析器写完之后最难的问题就是怎么判断它有没有写对。如果只看运行结束后的返回值“成功”或“失败”那你几乎无法定位是哪个产生式的哪一步出现了问题。所以我强烈建议写一个AST树形打印函数把分析结果直接输出出来看。我实现的打印函数大致是这样的逻辑void PrintAST(const ASTNode* node, int depth) { std::cout std::string(depth * 2, ) NodeTypeToString(node-nodeType); if (!node-value.empty()) { std::cout ( node-value ); } std::cout \n; for (const auto child : node-children) { PrintAST(child.get(), depth 1); } }这样一个简单的递归函数却能带来极大的调试便利。拿a 10 b * 2;来举例打印输出大概是ASSIGNMENT IDENTIFIER (a) BINARY_OP () INTEGER_LITERAL (10) BINARY_OP (*) IDENTIFIER (b) INTEGER_LITERAL (2)看到这棵树你就能一眼判断优先级处理对不对。如果输出变成BINARY_OP(*)在顶层那说明乘法优先级覆盖了加法文法层级设计出了问题。这个“肉眼验证”的步骤我每次改完文法都要做一遍比写一堆单元测试还要快。当然如果项目后期你有精力可以再写一套基于样例输入的自动断言测试但前期调试阶段树形打印绝对是最直观的。5. 常见问题与排查技巧实录5.1 词法分析边界问题汇总问题现象根因与解决方法注释闭合问题多行注释未闭合导致语法报错扫描时要从/*一直找到下一个*/同时更新行列号遇到文件末尾也没找到时要报错数字边界数字后紧跟字母被解析成两个Token扫描数字时遇到字母应该报错如123abc应报“invalid identifier”而非把123和abc拆开运算符最长匹配被误拆成和必须使用最长匹配原则当遇到时继续向前看一眼是不是是则合并为一个Token关键字大小写Int被当成标识符多数语言关键字区分大小写Int应识别为标识符而不是把字母统一转小写再判断这张表看起来基础但每一个问题都是我实际踩过坑之后总结出来的。尤其是运算符最长匹配你要是不做前瞻a b会被解析成a b语法分析直接崩溃。5.2 语法分析递归栈过深与死循环递归下降分析器的“递归”是把双刃剑——它能直接对应文法规则但也可能导致两种严重问题第一类是栈溢出。如果文法有间接左递归比如A - B xB - A y那么调用链ParseA - ParseB - ParseA - ParseB...会无限循环直到调用栈爆炸。检查方法很简单在每一个解析函数入口打印函数名和当前的Token内容或者用调试器查看调用栈。一旦发现某个函数在递归调用自己且没有读到新的Token大概率就是碰到了左递归。第二类是死循环。即使没有左递归如果某个解析函数在出错时没有“消费”任何Token就返回了调用者可能再次调用它从而陷入解析失败 - 返回 - 再解析失败 - 再返回的死循环。解决方法是在处理错误的代码路径上确保至少消耗掉一个Token比如跳过当前Token或者插入一个同步标记这样循环一定会向前推进。这里有个非常实用的排查技巧在解析器的ParseStatement或ParseExpr入口打印当前Token的类型一旦发现同一个Token被打印了很多次就可以断定是死循环。我在项目里就是加了这样一条日志几分钟内就揪出了错误恢复路径上的死循环Bug。5.3 内存管理与智能指针的使用AST节点用std::unique_ptr管理这是C11之后最推荐的方案之一。但要小心一种情况当你需要把同一个节点传给两个父节点时这在实际的AST中其实不常发生因为AST是树不是图unique_ptr就无法满足需求了需要改用std::shared_ptr。从我的项目经验看AST绝大多数情况下是严格的树结构所以你不需要用shared_ptr做别名共享。如果非要用注意别在多个父节点中共享同一个子节点——那样不仅破坏了树结构还会引发后续遍历访问时的重复处理或逻辑错误。还有一个常见坑在退出解析函数时局部变量auto right ParseExpr();的生命周期结束unique_ptr会自动释放内存。如果你在后面又把这个节点的裸指针存到了某个全局数组里那就成了一个悬空指针。我的建议是任何跨函数传递的节点都用unique_ptr移动语义任何只读访问都用get()获取裸指针并确保只在该函数作用域内使用。这条规则贯彻执行之后内存问题基本不会找上门。5.4 测试样例的选择策略测试不是随便写几个程序跑一遍就完事了。我的做法是准备三个梯度的测试样例合法程序集覆盖所有文法规则包括if/else嵌套、while循环、复杂的算术表达式混合加减乘除、括号、多层优先级、变量声明与赋值。这些样例用于验证“正确的程序能正确解析”。非法程序集缺少分号、未闭合括号、if后没有条件、类型不匹配比如给整数变量赋字符串当然这个需要语义分析阶段才能发现但对语法分析器来说至少可以通过AST结构判断赋值语句两侧类型是否一致。边界程序集空程序、只有注释的程序、连续多个分号、嵌套极深的括号测试栈是否溢出、超长标识符。每次测试完我会把通过/未通过的样例记录下来形成“回归测试清单”。这样每次修改文法或者解析逻辑后只需一键跑完全部样例就能确认新改动是否破坏了原有功能。这个习惯让我的调试效率提升了不止一倍。6. 项目扩展与进阶方向如果你把词法分析和语法分析都跑通了这个项目的骨架已经成形。接下来可以往这几个方向扩展都很有意思第一是语义分析。给符号表加上类型检查和作用域链在AST构建完成后遍历树检查变量是否声明、类型是否兼容、函数调用参数是否正确等。这一步能把“能解析”变成“能校验合法性”。第二是中间代码生成。可以生成三地址码如t1 b * 2、t2 10 t1贴近汇编指令的抽象表达。三地址码既是编译原理课程的重要内容也是理解真实编译器优化入口的最佳途径。第三是错误恢复的增强。目前我的实现只支持恐慌模式和单Token插入/删除你可以进一步实现局部修复算法——根据错误上下文自动选择最优的修复方式比如缺分号还是缺右括号并给出一份修复报告。第四是可视化调试接口。除了文本树形打印你还可以把AST导出成Graphviz的dot格式渲染成真正的树形图。这尤其在答辩或写课程报告时能让人眼前一亮毕竟一张漂亮的语法树图片往往比千行代码更有说服力。我个人在实际操作中的体会是这个项目最大的价值不在于“用C实现编译原理”而在于它逼着你以编译器的视角重新审视C/C代码。当你亲手完成词法分析和语法分析之后再遇到编译器报的奇怪的错你会瞬间明白错误产生的原因因为你就站在“编译器制造者”的位置上。这种视角转换是任何背诵都换不来的。最后再分享一个小技巧如果你想把代码写得有结构感记得把所有解析函数的声明统一放在头文件里并加上注释标明对应的文法产生式。这样过了一段时间回头再看自己的代码时仍然能像看教科书一样清晰地理解每一部分的作用和联系。希望这篇记录能帮到正在做编译原理课程设计的同学。本文还有配套的精品资源点击获取
返回列表