资讯详情

资讯详情

语法分析器.cpp全解析:从Token流到AST与虚拟机指令生成

简介编译原理课程中语法分析器环节的完整C实现代码面向计算机专业学生与需要动手构建词法/语法分析模块的开发者。资源包仅含1个cpp文件压缩后体积2KB结构精简便于直接阅读算法主流程也适合作为课程实验的对照样例。语法分析涉及LL/LR解析、抽象语法树AST构建、BNF文法定义等核心知识点代码中结合collegevm5虚拟机场景将源代码扫描后转换为虚拟机可执行的指令序列兼顾自底向上的语法判定与后续语义衔接能直观呈现编译器前端的完整工作脉络。已有177人学习下载可作为编译原理课程设计参考、实验调试对照或入门阶段理解解析器框架的素材对于正在完成大作业、需要借鉴递归下降或表格驱动实现细节的同学这份小而完整的示例很有参考价值。1. 语法分析器编译原理课程设计里那个让人又爱又恨的.cpp拿到collegevm5配套项目时我原以为“语法分析器”只是编译原理课程设计里一个普通模块直到打开语法分析器.cpp才意识到自己要面对的不只是括号配对而是一整条从Token流到虚拟机指令的流水线。这个语法分析器吃的是词法分析产出的Token输出的是collegevm5能直接执行的指令序列中间还要构造抽象语法树、处理优先级和报错。对正在做编译原理实验的同学这段代码是你理解递归下降和AST生成的最好样本也是编译原理第三版里那些规则的最直观落地。我会在下面把代码结构、运行方式、常见坑和进阶用法一次讲清楚照着走能少踩很多坑。2. 从文法到抽象语法树读透语法分析器.cpp的四个关键点2.1 先分清分析器在整个编译管道里的位置编译器或解释器的前端一般可以拆成四段词法分析Tokenizer、语法分析Parser、语义分析Semantic Analyzer和中间代码生成Code Generator。这里的主角语法分析器.cpp在第一段和第二段的交界处工作——它接收的是词法分析器传过来的Token流而不是直接对源文件逐字符做模式匹配。词法分析把源码切成一个个Token比如ID、NUM、、、;语法分析器再根据文法规则把这些Token组装成有结构的树这棵树就是常说的AST。如果只是一个“括号匹配”程序那根本不需要建树。但这个课程设计后面带了一个collegevm5虚拟机虚拟机不认识运算符和赋值它只认识自己的指令码比如PUSH、ADD、STORE、LOAD。所以这个cpp里通常还会有一个“生成指令”的模块递归遍历AST把每个节点翻译成一条或几条虚拟机指令。这样的结构决定了语法分析器其实是一个“半编译器”先判断语法是否正确再把正确的结构投影成指令序列。我打开.cpp的习惯是先看main函数。课程设计代码通常不长main里基本是完整流程打开源文件 - 逐字符读取 - 生成Token - 调用Parser - 拿到AST - 调用CodeGen - 输出指令文件。先找到main里依次调了谁整个文件的结构就清楚了。如果入口函数有太多参数拼接就先画一个简单的调用顺序线免得迷路。2.2 递归下降还是LALR代码风格决定调试方式语法分析器的实现路线主要分两派一是自上而下的递归下降分析写起来像一堆互相调用的函数二是自下而上的LR分析核心是状态栈加二维分析表。识别方法很简单如果代码里能看到ParseExpr、ParseTerm、ParseFactor这样的函数名而且函数之间是层层调用关系那基本就是递归下降。如果看到一个while(true)循环里不断查action[s][a]表格那多半是LR分析器。这两种风格在排障时差别非常大。递归下降的调用栈直接反映了当前正在解析的文法符号哪里崩了一眼就能看到LR分析器则全是状态编号需要在动作表里翻译成文法符号才能定位。我帮山科大、燕山大学那边的同学看编译原理实验代码时发现大家交上来的分析器绝大多数都是递归下降原因很简单好写、好调、答辩时容易讲清楚。递归下降还有一个隐藏特性运算符优先级是靠“函数调用层级”来保证的而不是靠某个显式的优先级变量。比如加减法函数调用乘除法函数乘除法函数再调用原子表达式函数层级越深优先级越高。这个设计在排故《12*3 被算成9》的bug时非常重要后面2.4会具体展开。2.3 Token与AST节点的数据结构从.cpp里快速扒出来读一个分析器数据结构比算法优先。正常情况下Token结构至少有三个成员type枚举类型表示NUM、ID、OP还是KEYWORDvalue原始字符串或数值line行号用于报错。很多课程设计会用union保存字面值整数和字符串都塞得进去。AST节点则更简单一个节点类型加一个子节点数组就够了。我在类似项目里看到过的结构体大概是这样的enum TokenType { NUM, ID, PLUS, MINUS, STAR, SLASH, ASSIGN, SEMI, LPAREN, RPAREN, END }; struct Token { TokenType type; string value; int line; }; enum NodeType { N_NUM, N_ID, N_BINOP, N_ASSIGN, N_PRINT }; struct ASTNode { NodeType nodeType; string value; vectorASTNode* children; };代码说明Token和ASTNode是两种完全不同的结构前者是分析器的输入单位后者是分析器的输出产物。ASTNode里的children用的指针数组意味着每个new出来的节点最后都要有人负责清理。课程设计里最经典的崩溃现场就是节点new了一堆退出时没有统一释放或者重复释放导致段错误。我一般会在Parser的析构函数里写一个递归的deleteNode(ASTNode*)确保错误分支也不会泄漏。这里有一个容易混淆的点TokenType和NodeType并不是一回事。TokenType描述“词法单位”比如PLUS代表加号NodeType描述“语法单位”比如N_BINOP代表一个二元运算节点。有的代码图省事把它们合并成一个枚举后面到处做判断维护起来很痛苦。你拿到cpp时可以先列出来分清楚这两层再去追语法树构造。2.4 文法与代码的对应关系从BNF到Parse函数递归下降分析器和文法几乎是逐行对应的。假设我们要处理一个最简单的表达式文法expr - term { ( | -) term } term - factor { (* | /) factor } factor - number | id | ( expr )用递归下降实现parseExpr长这样ASTNode* parseExpr() { ASTNode* left parseTerm(); while (lookahead.type PLUS || lookahead.type MINUS) { TokenType op lookahead.type; eat(); ASTNode* right parseTerm(); ASTNode* node new ASTNode(); node-nodeType N_BINOP; node-value (op PLUS) ? : -; node-children.push_back(left); node-children.push_back(right); left node; } return left; }代码里的while循环对应文法里的花括号部分只要下一个Token是或-就再解析一个term并把当前已经解析好的部分作为左子树新的term作为右子树构造一个N_BINOP节点。lookahead是一个全局超前查看Tokeneat()消费当前Token并更新lookahead。这段代码的关键在于parseTerm()的调用位置如果先调用parseTerm()再检查运算符那么加减法的优先级就天然低于乘除法因为term会在更内层完成结合。这个分析器实际上是LL(1)的一个变体当前lookahead决定了要选择哪条产生式。如果文里有公共前缀比如factor - id | id ( expr )那么当lookahead是id时分析器不知道后面跟着的是(还是结束就会出现选错路的问题。解决办法是提取左因子把文法改写成factor - id rest再用另一个函数处理rest可能是(...)还是空串。这也是递归下降分析和LL(1)预测分析之间的核心联系。我在对照编译原理第三版里递归下降那一节时发现它讲的预测集和这里函数嵌套完全是同一件事区别只是前者手动推导后者直接用代码表达。3. 构建运行与调试让collegevm5跑起来的完整流程3.1 环境准备和编译命令语法分析器.cpp是单个C源文件理论上在任何有C11编译器的环境都能编。Linux下我一般这样操作g -stdc11 -Wall -g -o parser syntax_parser.cpp如果代码里用了std::stoi、std::make_unique这类新特性需要把标准版本提高到c14或c17。-g选项保留调试符号后续用gdb排查段错误时必须有它。-Wall会提示被忽略的类型转换和未使用变量这类警告在课程设计代码里非常常见建议不要忽略。习惯上我会再放一个Makefile免得每次敲一长串命令CXX g CXXFLAGS -stdc11 -Wall -g parser: syntax_parser.cpp $(CXX) $(CXXFLAGS) -o parser syntax_parser.cpp这个Makefile定义了两个变量CXX指定编译器CXXFLAGS指定编译选项。下面的规则表示目标文件parser依赖syntax_parser.cpp缺失或更新时执行命令重建。每次修改代码后直接在终端跑make就行比手敲命令省事。编译成功后先做一次空跑确保程序能正常启动。如果没有任何输出很可能程序在等待标准输入这时创建空的empty.cm5再试./parser empty.cm5。如果还是没反应就用grep搜一下argv和fopen八成是文件参数没处理好。3.2 写一个最小的测试用例表达式语句验证“Token到AST再到指令”这条路我一般会先写一条只含赋值语句的文件a 1 2 * 3;保存为test.cm5后运行./parser test.cm5 out.isc如果程序支持打印指令流输出可能类似PUSH 3 PUSH 2 MULT PUSH 1 ADD STORE a这段输出看起来和1 2 * 3的计算顺序相反其实是栈式虚拟机的正常表现。AST根节点是左子树是1右子树是*节点。生成指令时先处理右子树把3和2压栈并MULT再回过去处理左子树压入1最后根节点的ADD把两个结果相加。顺序本身无关对错只要保证值和源码语义一致就行。如果看到输出里PUSH 1在最前面也不用急着改只要虚拟机能算出正确结果就说明生成器用的是前序遍历。真正需要警惕的是12*3算成9的情况这说明优先级映射失败了问题在2.4提到的嵌套结构里。3.3 打印AST调试分析器不够用时的第一招遇到语法分析结果不对不能光靠打点猜。我一般会给项目加一个dumpAST函数把AST以文本树形式打出来void dumpAST(ASTNode* node, int depth) { if (node nullptr) return; for (int i 0; i depth; i) cout ; cout nodeTypeToString(node-nodeType) : node-value \n; for (ASTNode* child : node-children) { dumpAST(child, depth 1); } }这个函数做深度优先遍历每下降一层缩进两个空格把12*3变成类似N_BINOP:下挂N_NUM:1和N_BINOP:*的层级结构。只要AST长这样优先级就是对的如果根节点成了N_BINOP:*说明加减法先被结合了问题出在parseExpr里调用了parseFactor而不是parseTerm。调用时机有两个选择在parseExpr返回前打可以看到语法分析阶段的产物在CodeGen开始前打可以看到给虚拟机的最终AST。两个地方都调用也花不了多少时间但能快速把“语法分析错”和“代码生成错”区分开。注意如果输出的是节点枚举数字可读性很差建议先写一个nodeTypeToString的转换函数把枚举映射成*这类符号。3.4 从指令流到虚拟机的运行验证语法分析器自己只负责生成指令真正验证指令对不对需要把out.isc塞给collegevm5。教学用虚拟机启动方式大同小异常见的是./collegevm5 -run out.isc-run参数让虚拟机读入指令文件并逐条执行。如果你的虚拟机还有打印寄存器状态的功能运行后直接看AX或栈顶值就知道结果对不对。另一种方法是启用单步执行比如./collegevm5 -s out.isc每条指令后打印当前栈顶、SP和PC。单步执行对排查指令顺序问题非常有效。比如减法指令SUB会从栈顶弹出右操作数和左操作数如果压栈顺序反了结果就会变号。你盯着PC走一遍就能看到是哪个PUSH的顺序不对再回到AST生成器里去调整。这个方法虽然原始但也是我所有编译原理实验里最常用的一招比对着代码空想要快得多。此外很多课程设计里的变量名并不是直接传给虚拟机而是要经过一个符号表映射。AST里的N_ID节点最终会变成虚拟机的变量编号。做法是维护一个unordered_mapstring, int symTab第一次遇到变量时分配编号并放入表里后面的STORE a实际生成STORE 0或者LOAD 0。这一步如果不做虚拟机在遇到重名变量时会把它们当成两个不同的存储单元运算结果就会错得莫名其妙。下面是一张常见的指令表帮助你在看out.isc时快速对应指令作用PUSH n将整数或变量值压栈ADD弹出栈顶两个数相加后压栈MULT弹出栈顶两个数相乘后压栈STORE x弹出栈顶值写入变量 xLOAD x将变量 x 的值压栈PRINT弹出栈顶并输出指令表里STORE x和LOAD x的 x 在实际代码里往往被替换成符号表编号所以你在指令文件里看到的可能是STORE 0。分析器日志里保留变量名只是为了人眼可读真正下发到虚拟机前必须完成这一步替换否则变量名进入虚拟机只会被当成未定义的指令。4. 避坑指南语法分析器.cpp里最常见的五个问题4.1 段错误一运行就崩连报错都不给现象程序刚读取文件或者刚调用parseExpr()就崩溃gdb提示在delete或vector.push_back附近段错误。原因这是课程设计代码里最典型的“玄学”问题。第一种是AST节点用完后没有释放退出时在析构函数里重复释放同一块内存第二种是lookahead的Token没有正确更新递归下降到文件末尾仍然读取最终拿到一个越界指针。解决先-g编译用gdb ./parser test.cm5跑到崩溃处输入bt看调用栈。如果崩在ASTNode的析构里就把所有new ASTNode的地方列出来做成“谁创建谁释放”的清单如果崩在某个parse函数里检查eat()是否在文件末尾停下了给lookahead增加一个END哨兵Token并且所有parse函数在END时直接返回空节点。还有一个容易被忽视的点new ASTNode()之后如果没有把children初始化成空数组第一次push_back时也可能会触发未定义行为。4.2 左递归导致死循环进程占满CPU但不输出现象程序运行后没有任何输出CPU占用率接近100%。用gdb attach查看PC一直停在同一行parseExpr()的调用上。原因文法写成左递归expr - expr term递归下降分析器进入parseExpr后第一件事又是调用parseExpr永远不会结束。就算没死循环也会因为递归层数太深栈溢出。解决把左递归改写成右递归加循环即expr - term { (|-) term }。具体到代码里parseExpr先解析一个term然后用while循环判断下一个Token是不是或-是则继续解析下一个term。注意千万不要用“先调另一个同名函数”的方式试图绕过问题那样只会拉长调用链。判断左递归的一个快速方法是看文法产生式右侧第一个符号是否与非终结符同名如果是必定左递归有些是间接左递归比如A-BB-A这种也要展开消除。4.3 算符优先级颠倒12*3 算出 9现象测试代码里的12*3输出9而不是7。调用dumpAST后看到根节点是*12被当成一个整体。原因parseExpr和parseTerm的调用顺序写反了。如果parseExpr里直接调parseFactor或者parseTerm和parseExpr的内容几乎一样优先级就彻底消失了。解决严格按照文法层级拆函数。优先级低的运算符放在外层函数优先级高的放在内层函数也就是说parseExpr调parseTermparseTerm调parseFactor。改完以后用12*3和(12)*3两个用例分别观察AST前者根节点是后者根节点是*。这一步几乎是我每次做完分析器必跑的验证用例因为很多翻车都是从优先级开始的。4.4 指令顺序不对虚拟机算出来结果等于“反的”现象生成的指令流没有语法错误但虚拟机的执行结果和源码差异很大。比如a-b被算成了b-a或者ab被算成了a*b。原因栈式虚拟机的运算指令通常弹两次栈第一次弹出右操作数第二次弹出左操作数。如果生成器把左操作数先压栈、右操作数后压栈弹出后顺序就会变成“右 左 运算符”和预期相反。代码里常见的错误是genCode(b); genCode(a);的顺序写反了。解决先在纸上画一个栈模拟。正确做法是先把右子树压栈再把左子树压栈这样栈顶才是右操作数。遇到减法特别容易踩坑a - b的指令期望是PUSH b; PUSH a; SUB因为SUB弹出栈顶作为被减数再弹出一个作为减数。这个问题的验证方法是用3.4里的单步执行跑一条减法一眼就能看出压栈顺序。如果不想每次开虚拟机也可以写一个小的栈模拟函数输入指令流输出最终栈状态快速筛出错误指令。4.5 错误恢复一塌糊涂一条错语句让后面全崩现象源码里某一行缺少右括号分析器报错后继续解析结果后面的合法代码也全部报错最后输出一堆无意义的提示。原因分析器遇到错误后只是返回nullptr没有做任何同步。递归下降的恢复手段通常是“跳过当前语句到下一个分号”但很多课程设计代码根本没有这一步于是错误不断向上传递。解决在expect失败的地方设置全局errorCount并调用synchronize()。synchronize()的逻辑很简单不断消费Token直到遇到SEMI或END。同时在parseStmt入口判断一下当前lookahead是不是END如果是就直接返回空节点。这样错误被限制在一条语句里后面的合法代码还能继续解析用户也能一次看到所有问题。注意synchronize()里一定要处理END否则如果没有分号程序会在文件末尾死循环。一个比较稳妥的做法是同时限制跳过Token的最大数量比如连续跳过100个还没有分号就强制终止。5. 进阶技巧错误恢复和优先级表的一个组合用法前面提到的坑大多在“只处理正确代码”的情况下还能容忍但如果想让这份语法分析器达到能交差甚至能答辩的完成度我建议加两个小功能统一的运算符优先级表和更完善的错误同步。先说优先级表。递归下降的优先级靠函数嵌套层数表达好处是直观坏处是每次调整优先级都得挪函数。一个省力的做法是把二元运算符提取成一张表int precedence(TokenType op) { switch (op) { case PLUS: case MINUS: return 1; case STAR: case SLASH: return 2; default: return 0; } }这张表可以配合一个循环形式的表达式解析也就是所谓的Pratt Parsing。它的核心思路是先解析一个term遇到二元运算符时拿出当前运算符的优先级和已解析因子的优先级比较若新运算符优先级更高则递归解析右边部分。这个写法和递归下降需要提前确定层级不同优先级放在数据里后续扩展幂运算、取模运算符都只需要在表里加一行代码。我给不少同学改过类似代码反馈都说比维护多层函数舒服。错误同步我一般用一个更直接但有效的技巧在parseStmt开头记录当前行号出现语法错误时打印错误信息和行号后直接调用synchronize()跳到下一个;。这样做的目标是让你一次拿到所有错误而不是卡在第一个错误后面。虽然报错信息不如真正的编译器那么精确但课程设计答辩时“能稳定报错并继续分析”已经比绝大多数同学厉害不少。最后分享一个我的习惯每次写完语法分析器我都会强制自己跑两遍“体检”。第一遍故意删掉一个右括号确认分析器稳定报错并恢复第二遍跑12*3和(12)*3对比AST和虚拟机结果。这两遍不一定每次都能通过但确实救了我很多次尤其是在实验要求“支持常见错误处理”时我可以直接拿出实现和测试用例。如果你也要应付编译原理实验这份语法分析器.zip里的cpp值得下载下来自己跑一遍尤其是把错误恢复那段看明白。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →