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 Token类型? G指令 M指令 / 坐标 / 参数 换行/结束 构建AST节点 循环直到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 错误恢复机制

解析器不能一遇到错误就崩溃。好的解析器应该能「跳过」错误,继续解析后面的代码,然后一次性报告所有错误。

我常用的策略:

  1. 恐慌模式:遇到错误时,跳过当前行所有的Token,直到遇到换行符
  2. 错误节点:在AST里插入一个ErrorNode,记录错误信息
  3. 继续解析:从下一行重新开始
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]

嗯,看到这个输出,我就知道解析器工作正常了。