☰
语法分析器.cpp全解析:从Token流到AST与虚拟机指令生成
2026/10/3 9:09:38 网站建设 项目流程

简介:编译原理课程中语法分析器环节的完整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分析器则全是状态编号,需要在动作表里翻译成文法符号才能定位。我帮山科大、燕山大学那边的同学看编译原理实验代码时,发现大家交上来的分析器绝大多数都是递归下降,原因很简单:好写、好调、答辩时容易讲清楚。

递归下降还有一个隐藏特性:运算符优先级是靠“函数调用层级”来保证的,而不是靠某个显式的优先级变量。比如加减法函数调用乘除法函数,乘除法函数再调用原子表达式函数,层级越深优先级越高。这个设计在排故《1+2*3 被算成9》的bug时非常重要,后面2.4会具体展开。

2.3 Token与AST节点的数据结构:从.cpp里快速扒出来

读一个分析器,数据结构比算法优先。正常情况下,Token结构至少有三个成员:type(枚举类型,表示NUM、ID、OP还是KEYWORD),value(原始字符串或数值),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; vector<ASTNode*> 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是一个全局超前查看Token,eat()消费当前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++源文件,理论上在任何有C++11编译器的环境都能编。Linux下我一般这样操作:

g++ -std=c++11 -Wall -g -o parser syntax_parser.cpp

如果代码里用了std::stoi、std::make_unique这类新特性,需要把标准版本提高到c++14或c++17。-g选项保留调试符号,后续用gdb排查段错误时必须有它。-Wall会提示被忽略的类型转换和未使用变量,这类警告在课程设计代码里非常常见,建议不要忽略。

习惯上我会再放一个Makefile,免得每次敲一长串命令:

CXX = g++ CXXFLAGS = -std=c++11 -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在最前面,也不用急着改,只要虚拟机能算出正确结果就说明生成器用的是前序遍历。真正需要警惕的是1+2*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); } }

这个函数做深度优先遍历,每下降一层缩进两个空格,把1+2*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_map<string, int> symTab,第一次遇到变量时分配编号并放入表里,后面的STORE a实际生成STORE 0或者LOAD 0。这一步如果不做,虚拟机在遇到重名变量时会把它们当成两个不同的存储单元,运算结果就会错得莫名其妙。

下面是一张常见的指令表,帮助你在看out.isc时快速对应:

指令作用
PUSH n将整数或变量值压栈
ADD弹出栈顶两个数,相加后压栈
MULT弹出栈顶两个数,相乘后压栈
STORE x弹出栈顶值,写入变量 x
LOAD 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->B,B->A,这种也要展开消除。

4.3 算符优先级颠倒:1+2*3 算出 9

现象:测试代码里的1+2*3输出9,而不是7。调用dumpAST后看到根节点是*,1+2被当成一个整体。

原因:parseExpr和parseTerm的调用顺序写反了。如果parseExpr里直接调parseFactor,或者parseTerm和parseExpr的内容几乎一样,优先级就彻底消失了。

解决:严格按照文法层级拆函数。优先级低的运算符放在外层函数,优先级高的放在内层函数,也就是说parseExpr调parseTerm,parseTerm调parseFactor。改完以后用1+2*3和(1+2)*3两个用例分别观察AST,前者根节点是+,后者根节点是*。这一步几乎是我每次做完分析器必跑的验证用例,因为很多翻车都是从优先级开始的。

4.4 指令顺序不对:虚拟机算出来结果等于“反的”

现象:生成的指令流没有语法错误,但虚拟机的执行结果和源码差异很大。比如a-b被算成了b-a,或者a+b被算成了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()跳到下一个;。这样做的目标是让你一次拿到所有错误,而不是卡在第一个错误后面。虽然报错信息不如真正的编译器那么精确,但课程设计答辩时,“能稳定报错并继续分析”已经比绝大多数同学厉害不少。

最后分享一个我的习惯:每次写完语法分析器,我都会强制自己跑两遍“体检”。第一遍故意删掉一个右括号,确认分析器稳定报错并恢复;第二遍跑1+2*3和(1+2)*3,对比AST和虚拟机结果。这两遍不一定每次都能通过,但确实救了我很多次,尤其是在实验要求“支持常见错误处理”时,我可以直接拿出实现和测试用例。如果你也要应付编译原理实验,这份语法分析器.zip里的cpp值得下载下来自己跑一遍,尤其是把错误恢复那段看明白。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询