4. 语法分析器(Parser)实现:根据Token流构建抽象语法树(AST),处理G代码的语法规则
好,咱们继续往前走。上一章我们把G代码拆成了一个个Token,就像把一整句话拆成了单词。但光有单词还不够,你得知道这些单词怎么组合成有意义的句子。这就是语法分析器要做的事——把Token流变成一棵抽象语法树(AST)。
说白了,AST就是代码的结构化表示。它把线性的Token序列,变成了一棵有层次关系的树。我刚开始接触这个概念时也觉得抽象,后来自己手写了一个解析器才真正明白——树结构能让我们很方便地处理嵌套关系和运算优先级。
核心思想:语法分析器读取Token流,根据G代码的语法规则,构建出一棵反映程序结构的树。每个节点代表一个语法结构,比如一个G指令、一个坐标值、一个循环体。
4.1 为什么需要AST?
你可能会问:直接处理Token流不行吗?嗯,理论上可以,但会很痛苦。想想看,如果你要处理G02(圆弧插补),它后面跟着X、Y、I、J、F这些参数,而且顺序可能不固定。用Token流硬编码判断,代码会变得又臭又长。
AST的好处在于:
- 结构清晰:每个节点只关心自己的子节点,职责单一
- 便于遍历:后续的语义分析、代码生成都可以递归遍历AST
- 易于扩展:新增一种G指令,只需要加一个节点类型
- 错误定位:可以精确知道哪个语法结构出错了
我记得有一次接手一个老项目,之前的同事直接用正则表达式处理G代码,结果遇到M98(子程序调用)和M99(子程序返回)时,嵌套逻辑处理得一塌糊涂。后来我改成AST方式,两周就重构完了。
4.2 AST节点设计
设计AST节点,其实就是定义「G代码世界里有哪些东西」。我一般会先画一个类图,把节点类型列出来。
常见的节点类型:
| 节点类型 | 含义 | 子节点/属性 |
|---|---|---|
| ProgramNode | 整个程序 | 子节点列表(语句列表) |
| GCodeNode | G指令(如G00、G01) | 指令号、参数列表 |
| MCodeNode | M指令(如M03、M05) | 指令号、参数列表 |
| CoordinateNode | 坐标轴(X、Y、Z等) | 轴名、数值 |
| ParameterNode | 参数(F、S、T等) | 参数名、数值 |
| BlockNode | 程序段(一行G代码) | 行号、指令列表 |
| SubProgramNode | 子程序调用 | 程序号、参数 |
每个节点我习惯用一个基类:
class ASTNode:
def __init__(self, node_type):
self.node_type = node_type
self.children = []
self.line_number = 0 # 记录行号,方便报错
def add_child(self, node):
self.children.append(node)
def __repr__(self):
return f"{self.node_type}({len(self.children)} children)"
我的习惯:每个节点都记录行号。这样后面做错误提示时,可以直接告诉用户「第N行第M列出错了」。这个细节在调试时能省很多时间。
4.3 语法分析流程
语法分析的核心逻辑其实不复杂——就是「看当前Token是什么,然后决定怎么构建节点」。我常用的方法是递归下降解析,说白了就是写一堆互相调用的函数,每个函数负责解析一种语法结构。
来看一个简化的流程图:
流程其实很直观:读一个Token,看它是什么类型,然后调用对应的解析函数。解析函数内部可能又会递归调用自己,处理嵌套结构。
4.4 核心解析逻辑
咱们直接看代码。我写了一个简化版的解析器,重点展示核心逻辑:
class Parser:
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
self.current_token = tokens[0] if tokens else None
def parse(self):
"""解析整个程序"""
program = ASTNode("Program")
while self.current_token is not None:
block = self.parse_block()
if block:
program.add_child(block)
return program
def parse_block(self):
"""解析一个程序段(一行)"""
block = ASTNode("Block")
# 处理行号(N代码)
if self.current_token.type == 'N':
block.line_number = self.current_token.value
self.advance()
# 解析本行的所有指令
while (self.current_token and
self.current_token.type != 'NEWLINE' and
self.current_token.type != 'EOF'):
node = self.parse_statement()
if node:
block.add_child(node)
else:
break # 解析出错,跳出循环
# 跳过换行符
if self.current_token and self.current_token.type == 'NEWLINE':
self.advance()
return block
def parse_statement(self):
"""解析一条语句"""
token = self.current_token
if token.type == 'G':
return self.parse_g_code()
elif token.type == 'M':
return self.parse_m_code()
elif token.type in ('X', 'Y', 'Z', 'A', 'B', 'C'):
return self.parse_coordinate()
elif token.type in ('F', 'S', 'T', 'D', 'H'):
return self.parse_parameter()
else:
# 遇到不认识的Token,报错
print(f"语法错误:第{token.line}行,意外的Token '{token.value}'")
self.advance()
return None
def parse_g_code(self):
"""解析G指令及其参数"""
token = self.current_token
g_node = ASTNode("GCode")
g_node.value = token.value # 比如 00, 01, 02
g_node.line_number = token.line
self.advance()
# 读取后续的参数(X、Y、Z、F等)
while (self.current_token and
self.current_token.type in ('X', 'Y', 'Z', 'F', 'I', 'J', 'K')):
param_node = self.parse_parameter()
if param_node:
g_node.add_child(param_node)
return g_node
我曾经踩过的坑:解析G02/G03圆弧指令时,I、J、K参数可能出现在X、Y之前,也可能在之后。一开始我写死了顺序,结果解析某些机床的代码就崩了。后来改成「贪婪匹配」——只要是合法参数就收进来,不管顺序。
4.5 处理特殊情况
G代码里有些特殊情况,解析时得小心:
- 模态指令:G00、G01这些指令会「记住」上一次的状态。比如上一行用了G01,下一行没写G代码,但默认还是G01。解析时需要在Parser里维护一个状态表。
- 子程序调用:M98 Pxxxx Lxx,调用外部子程序。我习惯在AST里单独建一个SubProgramNode,把程序号和调用次数存进去。
- 宏程序:有些高端机床支持变量和条件判断(#1=10, IF[#1 GT 5]...)。这已经超出标准G代码范畴了,解析器得额外支持表达式解析。
我记得有一次调试一个五轴机床的后处理程序,发现解析出来的AST总是少几个节点。查了半天,原来是G43.1(刀具长度动态补偿)后面跟了四个参数,而我代码里只处理了三个。嗯,从那以后我养成了一个习惯——每加一种新指令,先看它的参数列表。
4.6 错误恢复机制
解析器不能一遇到错误就崩溃。好的解析器应该能「跳过」错误,继续解析后面的代码,然后一次性报告所有错误。
我常用的策略:
- 恐慌模式:遇到错误时,跳过当前行所有的Token,直到遇到换行符
- 错误节点:在AST里插入一个ErrorNode,记录错误信息
- 继续解析:从下一行重新开始
def synchronize(self):
"""错误恢复:跳到下一行"""
while self.current_token:
if self.current_token.type == 'NEWLINE':
self.advance()
return
self.advance()
一个小技巧:错误恢复时,别忘了把跳过的Token记录下来。有时候用户想知道「到底跳过了什么」,这些信息能帮他们快速定位问题。
4.7 测试你的解析器
写完解析器,怎么验证对不对?我的做法是准备一批测试用例:
- 正常用例:标准的G代码,看AST结构是否符合预期
- 边界用例:空行、只有注释的行、超大坐标值
- 错误用例:缺少参数、非法指令、格式错误
- 兼容性用例:不同机床厂商的G代码变体
我一般会写一个AST可视化函数,把树结构打印出来:
def print_ast(node, indent=0):
prefix = " " * indent
print(f"{prefix}{node.node_type}", end="")
if hasattr(node, 'value'):
print(f" [{node.value}]", end="")
if node.line_number:
print(f" (行{node.line_number})", end="")
print()
for child in node.children:
print_ast(child, indent + 1)
这样一眼就能看出树结构对不对。比如解析 G01 X100 Y200 F500,输出应该是:
Block (行1)
GCode [01]
Coordinate [X: 100.0]
Coordinate [Y: 200.0]
Parameter [F: 500.0]
嗯,看到这个输出,我就知道解析器工作正常了。