ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用C++手写词法分析器与递归下降语法分析器:从零构建微型语言前端

2026/9/8 9:40:22 拓冰建站 浏览量
用C++手写词法分析器与递归下降语法分析器:从零构建微型语言前端 简介面向计算机专业学生、编译器入门开发者及语言处理研究者这是一份用C实现编译器前端的实践项目完整包含词法分析器和语法分析器。词法分析基于有限自动机理论负责识别关键字、标识符、常数、运算符和分隔符并处理非法字符等词法错误语法分析采用递归下降或LL(1)解析思路按上下文无关文法将词法单元转换为抽象语法树同时覆盖常见语法错误处理。压缩包共9个文件含2个可直接运行的exe演示程序、2个cpp源码、4个txt文法/源程序/token表文件及1个md说明文档整体937KB体量小、便于快速开展实验。资源以源码、可执行程序和中间输出为主适合课程设计、实验对照和自学复盘。目前已有736人学习下载可帮助读者把编译原理核心概念落到C代码中理解词法规则、文法设计与语法树构建的完整流程。 又是一年编译原理课设季。如果你也拿到了类似的题目——用C实现一个词法分析器和语法分析器大概率已经看到了两种路径一种是去网上找一份“能交差”的代码直接改另一种是老老实实自己写一个。我选的是后者倒不是觉得前者多丢人而是这门课如果不亲手写一遍词法分析和语法分析你真的很难理解那些符号表、DFA、递归下降到底在干什么。这篇文章就是我重写整个项目后的完整记录。我用C17从零实现了一个叫TL的微型语言前端包含完整的词法分析器和递归下降语法分析器最终能把源代码变成一棵可打印的语法树AST。它不依赖任何第三方词法/语法生成工具适合用来做课程设计、应付实验报告也适合想在面试前快速复习编译原理核心概念的人。全文会围绕词法分析的状态机设计、语法分析器的文法处理、C工程组织、错误定位和测试验证这几个核心问题展开文中所有代码都可以直接编译运行。1. 这个项目到底做了什么一台能“读懂”微型语言的编译器前端先把范围说清楚。我实现的语言叫TLTiny Language它不是完整的C也不是阉割版C语言而是一个刚好能覆盖编译原理课程设计核心知识点的小型语言。它支持类型声明int、float变量赋值int a 10;算术表达式a b * 3关系表达式a b、x y控制流if (expr) stmt else stmt、while (expr) stmt打印语句print(expr);花括号代码块{ stmt_list }注释// 行注释和/* 块注释 */这些特性看起来不多但已经足够让词法分析器处理数字、标识符、关键字、运算符、分隔符、注释这六类输入形态也足够让语法分析器处理递归表达式、嵌套语句块、悬挂else等问题。再往上加语法特性比如函数定义、数组、结构体本质上都是在这个框架上扩展不会改变整体的分析架构。我想强调一个容易被忽略的点词法分析器负责“分词”语法分析器负责“组句”它们在整个编译流程中位于最前端。后面的语义分析、中间代码生成、目标代码生成全部建立在它们输出的Token流和语法树之上。所以这个课设的真正意义不是做出两个可以单独运行的程序而是搭好一个编译器的地基。地基不正后面全都白搭。1.1 为什么我坚持用C而不是Java或者Python选C写这个项目一部分原因是课设要求另一部分是个人偏好。C在表达编译器数据结构时有明显优势枚举类型天然适合描述Token种类std::variant和std::unique_ptr可以干净地表达语法树节点而各种标准库容器unordered_map、vector又省去了自己造轮子的时间。不过也得说实话C写这类项目比Python啰嗦不少。Python几行就能把Token列表打出来C得先写一个枚举转字符串的函数再处理输出流。但这些“额外工作”其实是在强迫你更早地思考数据结构和接口设计想清楚之后反而会让整个项目更扎实。别忘了很多课程设计的隐藏要求就是“必须用C完成”用其他语言可能直接不满足题目。1.2 两个分析器在编译器全家桶里的分工我再把概念拧一下防止有同学做着做着就迷糊了。编译器的前端大致是这样一个流水线源代码 - 词法分析器 - Token序列 - 语法分析器 - 语法树(AST)词法分析器把一串字符流拆成一个个有意义的“词”也就是Token。比如int a 10;会被拆成关键字int、标识符a、赋值符号、数字10、分号;五个Token每个Token还带着行号和列号。语法分析器接收这串Token按照文法规则识别出它们之间的结构关系。它发现int后面要跟一个标识符然后允许一个可选的赋值表达式最后必须以分号结尾于是把这一整组Token组织成一个“变量声明节点”。两者分工完全清晰一个管“有什么词”一个管“词怎么组合成句”。如果你在实现过程中发现自己不知道某个功能应该放在词法里做还是语法里做就回到这个分工原则来判断。2. 词法分析器一台不能偷懒的“分字符号机器”词法分析看起来是整个课设里最简单的部分无非是“读字符、判断类型、生成Token”但真写起来陷阱不少。最典型的问题是数字后面跟个字母怎么办3.14abc到底算合法还是非法行注释和块注释嵌套怎么处理这些细节如果不在设计阶段想清楚写着写着就会变得非常难受。2.1 用手写状态机还是用Flex我的最终选择动手之前其实纠结过要不要直接用Flexlex自动生成词法分析器。Flex用正则表达式描述Token规则几行配置就能生成一个像模像样的C文件效率极高。但问题在于课程设计要交报告老师会问“你介绍一下DFA是怎么设计的”如果回答“Flex生成的”那这部分的分数基本就危险了。更重要的是手动实现词法分析器可以帮你把正则、DFA、状态转移这些概念图彻底打通。写Flex你会觉得一切都理所当然手写一遍你才会发现“匹配最长的合法Token”这个原则在代码里到底怎么落地。我的建议是时间允许务必手写核心逻辑最多把Flex/Bison作为交叉验证工具用来确认自己的分析结果没有问题。手写词法分析器有两个主流方案一个是显式定义状态枚举写一个switch驱动的状态转移表另一个是简化版每个字符走一个处理函数相当于把状态机“摊开”成if-else。我选择了前者因为状态枚举可以直观对应到DFA的各个状态调试的时候也更容易看清当前处于什么阶段。为了不把文章写得太长我这里给一个最精简的状态机骨架enum class LexState { START, // 起始状态 ID, // 标识符/关键字 INT, // 整数 FLOAT, // 浮点数 OP, // 运算符 COMMENT, // 注释 };大致的处理循环如下while (!source.eof()) { char ch peekChar(); switch (state) { case LexState::START: if (isalpha(ch) || ch _) state LexState::ID, buffer.clear(), buffer ch, advance(); else if (isdigit(ch)) state LexState::INT, buffer.clear(), buffer ch, advance(); else if (isOperatorStart(ch)) state LexState::OP, buffer.clear(), buffer ch, advance(); // 空白、换行、注释符号等分支省略 break; case LexState::ID: if (isalnum(peekChar()) || peekChar() _) buffer peekChar(), advance(); else { // 已拼完一个词判断是关键字还是标识符 addToken(classifyIdentifier(buffer), buffer, line, col); state LexState::START; } break; // INT、FLOAT、OP等其他状态的处理类似 } }实际项目里还要处理peekChar和advance之间的边界关系但核心思想就是这样每读一个字符根据当前状态决定下一步行为当一个词的终止条件满足时比如下一个字符已经不属于合法字符集合立即生成Token。使用std::istream::peek可以避免频繁回退字符这是我自己的一个心得。2.2 Token粒度怎么划分这是很多人一上来就犯的错Token不是拍脑袋分的。应该是一个Token还是两个呢答案是在所有现代语言的词法规则里双字符运算符如、、都是一个独立Token而不是拆开。原因很简单如果拆成两个那么语法分析器看到第一个时根本不知道这是赋值还是比较得额外向前看一个Token才能判断白白增加语法分析器的复杂度。同理3.14是浮点数字面量3是整数字面量。你可能会问为什么3.14不拆成3、.、14三个Token因为字面量的数值必须作为一个整体被语义分析阶段使用拆开之后还得重新拼装得不偿失。所以我的Token划分规则是关键字int, float, if, else, while, print, return标识符以字母或下划线开头后跟字母、数字、下划线整数字面量纯数字序列浮点字面量数字序列 小数点 数字序列如3.14运算符 - * / ! 分隔符( ) { } ; ,注释不产生Token直接跳过看起来很自然的设计但实现时有一个细节值得注意判断3.14的合法性需要在小数点出现后继续向后检查至少一位数字。3.这种写法在C里是合法的浮点数值为3.0但在TL里我选择直接报错因为语义比较简单没必要引入这种容易让人困惑的规则。这个决定需要在报告里写清楚否则老师可能会问“为什么3.不行”。2.3 边界问题最长匹配、注释跳过和错误恢复词法分析器里的自定义难点往往不是主流程而是各种边角情况。第一个是最长匹配。比如输入是你读到时不能立刻生成一个Token因为下一个字符是两者组合起来才是合法的。所以我的实现是读到先“临时记下”再看下一个字符是不是是则组合成一个Token否则回退并生成单字符Token。这需要实现一个“单字符预读”机制我用的是std::istream::peek它不会消耗当前字符特别适合做这种前瞻判断。第二个是注释跳过。行注释从//开始到行尾结束块注释从/*开始到*/结束。注意C/C的块注释不嵌套所以用一个bool inBlockComment标记即可读到*/再关闭。这个处理过程与Token生成无关但必须保留行列号否则后续错误提示的行号会错位。第三个是词法错误恢复。当遇到完全不认识的字符比如、#我选择输出一条带行列号的错误信息然后跳过该字符继续分析。这样做的好处是一个源文件里多个词法错误可以一次性全部暴露出来而不是第一个错误就中断整个编译。这个设计虽然“宽容”但对于课程设计和实验调试来说非常实用。3. 语法分析器从Token序列到一棵能打印的语法树词法分析只是给语法分析“喂料”。真正决定这个课设难度的是语法分析器。这部分也是网上代码最乱、最容易抄错的部分。3.1 递归下降还是LALR课程设计里为什么选前者现在的编译器大多使用自底向上的LR或LALR分析器比如Yacc/Bison生成的parser它们功能强大、查错精确连C这种复杂文法都能解析。但问题是一样的Bison生成的代码不适合交课设更不适合用来学习。一两千行由状态转移表驱动的代码你可能看半天也不知道某个语法错误是怎么报出来的。递归下降分析器则完全不同。它是一种自顶向下的分析策略每个非终结符对应一个C函数函数体里直接按照产生式逐个匹配终结符或调用其他非终结符函数。它的结构天然贴近文法可读性强报错逻辑也是显式写在代码里的非常适合课程设计和教学。代价是它对文法有要求不能有左递归必要时得提取左因子这些我们下一节详细说。递归下降分两种带回溯的和不带回溯的。带回溯虽然更通用但效率低且代码复杂不带回溯的版本依赖“向前看一个Token就能决定走哪个分支”这要求文法满足LL(1)条件。对于TL这种规模的语言我设计的文法是可以满足LL(1)的所以直接用不带回溯的版本代码简洁性能又好。3.2 消除左递归与预测冲突写Parser前必须先做文法规整这一步非常关键我见过太多人直接抄别人的递归下降代码却不理解为什么某个函数要那么写。我先给出TL的文法设计简化版program - stmt_list stmt_list - stmt stmt_list | ε stmt - var_decl | assign_stmt | if_stmt | while_stmt | print_stmt | block var_decl - type IDENT expr ; assign_stmt - IDENT expr ; if_stmt - if ( expr ) stmt [else stmt] while_stmt - while ( expr ) stmt print_stmt - print ( expr ) ; block - { stmt_list } expr - term (( | -) term)* term - factor ((* | /) factor)* factor - IDENT | NUMBER | ( expr ) type - int | float注意这里的expr和term我用了星号*表示“重复零次或多次”而不是经典的左递归写法expr - expr term | term。这是为什么因为递归下降无法直接处理左递归——如果expr函数第一件事就是调用expr那就直接无限递归了。所以我把“左递归”转换成“右递归加循环”在C里就是用while循环去匹配连续的和-每匹配一个就生成一个左结合节点。还有个细节是悬挂else问题。TL里的if (a) if (b) c; else d;这个else到底跟哪个if匹配传统的做法是“就近匹配”也就是else与最近的尚未配对的if结合。我在递归下降实现里只需要在if_stmt的函数内部保证“当前if分支优先尝试匹配else”就能自然实现这个规则代码逻辑如下bool Parser::parseIfStmt() { expectToken(TokenType::KEYWORD, if); expectToken(TokenType::LPAREN); parseExpr(); expectToken(TokenType::RPAREN); parseStmt(); // then分支 if (currentTokenIs(TokenType::KEYWORD, else)) { advance(); parseStmt(); // else分支 } return true; }这段逻辑顺序一旦写对悬挂else就不会出错。3.3 从非终结符到AST节点纯C风格的Parser实现语法分析器不能只判断“对还是不对”还应该把语法结构记录下来也就是生成AST。我定义了一组简单的节点类struct ASTNode { virtual ~ASTNode() default; }; using ASTNodePtr std::unique_ptrASTNode; struct NumberNode : ASTNode { double value; explicit NumberNode(double v) : value(v) {} }; struct VarNode : ASTNode { std::string name; }; struct BinaryExprNode : ASTNode { std::string op; ASTNodePtr lhs, rhs; BinaryExprNode(std::string o, ASTNodePtr l, ASTNodePtr r) : op(std::move(o)), lhs(std::move(l)), rhs(std::move(r)) {} };然后每个解析函数返回一个ASTNodePtr。例如解析表达式时先解析一个term然后循环处理加减操作ASTNodePtr Parser::parseExpr() { ASTNodePtr node parseTerm(); while (currentTokenIs(TokenType::OPERATOR, ) || currentTokenIs(TokenType::OPERATOR, -)) { std::string op currentToken().text; advance(); ASTNodePtr rhs parseTerm(); node std::make_uniqueBinaryExprNode(op, std::move(node), std::move(rhs)); } return node; }这里用std::unique_ptr管理节点生命周期整个AST是树状结构指针所有权关系清晰当根节点析构时整棵树自动释放完全不用手写delete。这个设计虽然简单但我在代码组织上是认真的因为后续可以很自然地扩展新的节点类型比如IfNode、WhileNode、VarDeclNode。说一个C相关的坑std::move(node)之后node就不再持有旧对象了因此后续循环里不能再用node来读旧值。我刚才这个实现是安全的因为BinaryExprNode的构造参数已经通过std::move获得了左子树的所有权。如果你照着网上一些旧代码用裸指针写很容易出现悬空指针或内存泄漏这也是我一直推荐用智能指针的原因。4. 错误处理与调试比“能跑”更重要的是“错得明白”一个只会在合法代码上工作的分析器不是好分析器。课程设计里“非法输入怎么报错”往往是拉开分差的地方。如果你能给出行列号和具体的错误原因报告和演示都会上一个台阶。4.1 词法错误的行列定位与同步恢复每个Token都携带line和col信息。词法分析器在读到不认识的字符时会输出类似这样的错误[词法错误] 第3行第5列: 无法识别的字符 然后跳过这个字符继续分析。这里的“跳过”就是同步恢复panic mode recovery最简单的形式。真正要小心的是换行计数很多初学者在读取\r\n这种Windows换行时会把行号多加一次。正确做法是只在读\n时增加行号遇到\r直接忽略。4.2 语法错误的提示策略告诉用户缺了什么语法错误比词法错误难处理因为有时候错误产生的原因并不在当前看到的Token上。比如用户写了int a ;语法分析器期望在后面看到一个表达式结果看到了分号。这个时候报“期望表达式但得到分号”就是比较友好的提示。递归下降在这里有天然优势因为每个函数都知道自己刚匹配到了什么、下一个需要什么。我的示例代码如下bool Parser::expectToken(TokenType type, const std::string text ) { if (currentToken().type ! type || (!text.empty() currentToken().text ! text)) { reportError(期望 tokenDescription(type, text) 但得到 currentToken().text 第 std::to_string(currentToken().line) 行 ); return false; } advance(); return true; }语法错误出现后需要决定如何恢复不要一个错误就导致整个程序崩溃。我采用简单的同步恢复遇到错误时不断放弃当前Token直到遇到一个“同步点”——分号、右花括号或文件结尾然后尝试重新开始解析下一条语句。这种策略会牺牲一点报错精确度但胜在能继续找后面的错误。4.3 我在调试递归下降时用过的几个实用技巧调试递归下降和最头疼的问题一样往往都是“程序异常崩溃”或者“解析结果和我想的不一样”。我总结出几个亲测有效的办法第一给每个非终结符函数加一个带缩进的进入/退出日志。比如parseExpr进入时打印“parseExpr enter”退出时打印“parseExpr exit”。递归过程一目了然能迅速定位到哪个函数提前返回或死循环。第二写一个Token流打印函数把分析器读到的Token序列完整打印出来。很多语法错误其实是词法分析搞出的比如把拆成了两个Token。先看Token流能过滤掉一半问题。第三优先做最小复现。拿到一个出错的大源文件我会不断精简直到找出最短的出错代码再针对这个最小样例调试。这能有效避免在排查问题时被无关代码干扰。我调试时还遇到过一个问题递归下降解析非常依赖currentToken的状态一旦某处忘记调用advance()就会出现无穷循环。后来我的习惯是每个parseXxx函数的第一行务必明确“是否消费掉了自己应该消费的Token”这个习惯帮我在后续写更复杂的解析器时少踩很多坑。5. 测试与验证拿什么证明自己的实现是对的写完分析器不是终点证明“它对”才是。课程设计答辩时老师一定会现场测试几个合法和非法样例如果你心里没底一紧张就很容易露馅。5.1 三层测试递进单Token、语法片段、完整程序我的测试策略分三层层层递进第一层词法单元测试。测试单个Token的识别输入int、abc123、3.14、等预期得到某个具体的Token类型。这层测试用于保证词法分析的基础正确性。第二层语法片段测试。比如测试表达式解析输入a b * 3打印AST人工比对树结构是否正确。这能快速暴露左结合、优先级处理的问题。第三层完整程序测试。准备10个左右完整的TL源码文件涵盖合法程序循环、条件、嵌套块和非法程序缺分号、括号不匹配、类型错误逐一跑确认输出符合预期。合法程序样例我放一个大家可以感受一下int a 10; float b 3.14; int i 0; while (i 3) { if (a b) { print(a); } else { print(b); } i i 1; }测试时我会同时打印Token流和AST树。Token流用来验证词法阶段AST树用来验证语法阶段。AST打印我采用简单的缩进方式比如表达式a b * 3打印成BinaryExprNode() VarNode(a) BinaryExprNode(*) VarNode(b) NumberNode(3)这种打印输出方便人眼检查也方便写自动化测试脚本做文本对比。5.2 与Flex/Bison结果对照的交叉验证如果有条件建议用Flex和Bison搭一个同样的TL语言前端作为“标准答案”然后将同一份源代码分别交给两套系统处理对比Token流和语法树结构。这不代表手写的一定错误但可以帮你发现一些隐藏问题比如某个算符优先级写反了、某个Token分类不一致等。当然课程设计环境里装Flex/Bison不一定方便所以你完全可以退而求其次用一份公开的C语言词法规则文档对照检查你的Token分类是否正确。比对的目的并不是“抄答案”而是用独立工具验证自己实现的正确性这在工程实践里被称为“交叉验证”。6. 从课设到工程这个项目还能往哪里长写完词法分析和语法分析相当于完成了编译器的“前端”。但整个编译器的路还很长。如果你对这个方向感兴趣后面可以继续扩展。6.1 加语义分析和中间代码生成我用TL写了一个print语句但在真正编译器里print需要通过符号表检查变量是否声明过、类型是否匹配然后生成中间代码最后才是目标代码。你可以把AST遍历一遍做一个简单的语义分析器检查“变量未声明就使用”这类错误。再往前走就是三地址码或栈式中间代码这是C课设里比较高级的扩展方向做出来答辩是妥妥的加分项。6.2 让C代码更容易扩展的几个设计建议如果打算后续继续扩展我建议在一开始就注意两点第一用std::variant代替大量继承来设计AST节点。C17以后std::variantNodeExpr, NodeStmt, ...是一种很优雅的树节点表示方案配合std::visit遍历比传统虚函数少很多样板代码。第二别把Lexer和Parser揉在一个类里。两个类分开设计接口明确以后的替换成本会低很多。我自己的代码结构是Token.h、Lexer.h/cpp、Parser.h/cpp、AST.h、main.cpp编译测试都不用改别的文件。我现在回过头看这个课设真正带来的收获不是那些C代码本身而是让我搞懂了一门语言从文本到结构到底经历了什么。以后再看到编译原理相关的八股题脑子里会有具体的代码画面而不是死记硬背。如果你正在做类似的项目最后分享一个我踩过多次的小坑第一次写完时先用尽量短的代码测试哪怕是一句int a;确认最基本路径没问题再逐步增加语法特性。别一上来就上完整程序否则你根本分不清报错来自词法还是语法排查起来非常痛苦。拿这个小小的TL语言练手跑通之后你会觉得编译器前端真的没有想象中那么神秘。接下来不管是去啃龙书还是去看真实编译器源码你都会比之前从容得多。本文还有配套的精品资源点击获取