2. G代码解析引擎架构:解析器设计模式、词法分析、语法分析、语义分析

好,咱们直接切入正题。上一章聊了G代码的“前世今生”,这一章,咱们得亲手拆解一台解析引擎。说白了,就是让机器读懂G代码的那套“脑子”是怎么长的。

我个人习惯把解析引擎比作一个翻译团队。你想想看,你给老外(机器)说一句中文(G代码),他听不懂。你得先有个人把句子拆成单词(词法分析),再有人按语法规则把单词串成句子结构(语法分析),最后还得有人理解这句话到底要干嘛(语义分析)。缺一个环节,机器就得“死机”。

词法分析 Tokenizer G01 X100 Y50 F200 → Token流 语法分析 Parser Token流 → 语法树 语义分析 Interpreter 语法树 → 执行指令 G代码解析引擎三阶段流水线架构

2.1 解析器设计模式:别重复造轮子

做解析器,最容易犯的错就是“一把梭”。拿到一行G代码,从头读到尾,边读边执行。嗯,我刚开始做CNC系统时就这么干的,结果被坑得很惨——遇到一个语法错误,机器已经跑了一半行程了。

所以,成熟的解析引擎都采用分层设计模式。我个人推荐用管道过滤器模式(Pipeline & Filter)。每一层只干一件事,干完了传给下一层。

核心设计原则:

  • 单一职责:词法分析器只负责切Token,别管语法对不对
  • 开闭原则:要支持新的G代码指令,加个过滤器就行,别改核心代码
  • 依赖倒置:高层模块不依赖低层细节,都依赖抽象接口

举个例子,我做过一个项目,客户要求支持自定义的M代码。如果当初设计成“一把梭”模式,我得把整个解析器翻一遍。但用了管道模式,我只需要在语义分析层加一个自定义指令处理器,其他层完全不动。省心多了。

2.2 词法分析:把字符串变成Token

词法分析,说白了就是“分词”。把G01 X100 Y50 F200这串字符,拆成一个个有意义的单词(Token)。

为什么会需要这一步?你想想看,机器不认识“G01”是个整体,它只认识字符‘G’、‘0’、‘1’。词法分析器的作用,就是把这些字符组合成有意义的单元。

我在项目中遇到过一个问题:某台老机床的G代码里,注释居然用括号(...),但标准应该是分号;。词法分析器如果不处理这个,后面全乱套。

实战技巧:词法分析器最好支持状态机模式。比如遇到(就进入注释状态,遇到)才退出。这样处理各种边界情况特别稳。

典型的Token类型包括:

Token类型 示例 说明
ADDRESS G, M, X, Y, Z, F, S 地址字,表示指令类型或轴
NUMBER 01, 100, 50, 200 数值,跟在地址字后面
EOL \n, \r\n 行结束符,表示一条指令结束
COMMENT (钻孔循环), ; 注释 注释内容,解析时跳过
UNKNOWN @, # 等 非法字符,需要报错
// 词法分析器核心逻辑(伪代码)
function tokenize(line: string): Token[] {
    let tokens = [];
    let i = 0;
    while (i < line.length) {
        let ch = line[i];
        if (ch === 'G' || ch === 'M' || ch === 'X' || ch === 'Y' || ch === 'Z' || ch === 'F' || ch === 'S') {
            // 读取地址字
            tokens.push({ type: 'ADDRESS', value: ch });
            i++;
        } else if (isDigit(ch)) {
            // 读取数字
            let num = '';
            while (i < line.length && isDigit(line[i])) {
                num += line[i];
                i++;
            }
            tokens.push({ type: 'NUMBER', value: parseInt(num) });
        } else if (ch === '(') {
            // 跳过括号注释
            while (i < line.length && line[i] !== ')') i++;
            i++; // 跳过 ')'
        } else if (ch === ';') {
            // 分号注释,跳过整行
            break;
        } else if (ch === '\n') {
            tokens.push({ type: 'EOL' });
            i++;
        } else {
            // 空格或非法字符
            i++;
        }
    }
    return tokens;
}

2.3 语法分析:检查句子通不通

词法分析完了,你得到了一堆Token。但G01 X100和X100 G01哪个对?语法分析就是干这个的——检查Token的顺序是否符合G代码的语法规则。

我曾经接手过一个项目,之前的工程师写的语法分析器,居然用了一大堆if-else嵌套。代码长得像意大利面条,加一个新指令得改十几个地方。后来我全推倒重来,改用递归下降解析器。

注意:G代码的语法其实比大多数编程语言简单,但有个坑——模态指令。比如G01是直线插补,如果你下一行只写了X200,它默认还是G01模式。语法分析器必须维护这个状态。

语法分析的结果,通常是一棵抽象语法树(AST)。每个节点代表一个指令或参数。

// 语法分析器示例(递归下降)
class Parser {
    constructor(tokens) {
        this.tokens = tokens;
        this.pos = 0;
        this.currentModal = 'G00'; // 默认快速定位
    }

    parseLine() {
        let commands = [];
        while (this.pos < this.tokens.length) {
            let token = this.tokens[this.pos];
            if (token.type === 'ADDRESS') {
                if (token.value === 'G') {
                    commands.push(this.parseGCommand());
                } else if (token.value === 'M') {
                    commands.push(this.parseMCommand());
                } else if (['X','Y','Z'].includes(token.value)) {
                    // 轴移动指令,继承当前模态
                    commands.push(this.parseAxisMove());
                } else {
                    throw new Error(`未知地址字: ${token.value}`);
                }
            } else if (token.type === 'EOL') {
                this.pos++;
                break;
            } else {
                this.pos++;
            }
        }
        return commands;
    }

    parseGCommand() {
        this.pos++; // 跳过 'G'
        let numToken = this.tokens[this.pos];
        if (numToken.type !== 'NUMBER') throw new Error('G指令后需要数字');
        this.pos++;
        let gCode = numToken.value;
        // 更新模态状态
        if (gCode === 0) this.currentModal = 'G00';
        else if (gCode === 1) this.currentModal = 'G01';
        // ... 其他G代码
        return { type: 'G', code: gCode };
    }
}

2.4 语义分析:理解这句话的意思

语法分析只检查“句子通不通”,语义分析才真正理解“这句话什么意思”。

举个例子,G01 X100 Y50 F200语法上完全正确。但如果你当前刀具在Z轴-10的位置,而工件表面在Z0,直接执行X100 Y50,刀具会直接撞上去。语义分析就是要发现这种“逻辑上不合理”的情况。

我记得有一次调试五轴机床,客户反馈说程序跑着跑着就撞刀了。查了半天,发现是语义分析层没有检查G43(刀具长度补偿)是否在G49(取消补偿)之前被正确配对。从那以后,我就在语义分析里加了一个状态机,专门跟踪各种模态指令的配对情况。

语义分析的核心职责:

  • 类型检查:比如F后面必须是正数,不能是负数
  • 范围检查:比如主轴转速S不能超过机床最大值
  • 状态一致性:比如不能在攻丝循环中切换坐标系
  • 资源冲突:比如不能同时用两个刀具
// 语义分析器示例
class SemanticAnalyzer {
    constructor() {
        this.state = {
            currentTool: null,
            currentWorkOffset: 'G54',
            coolantOn: false,
            spindleSpeed: 0
        };
    }

    analyze(ast) {
        for (let node of ast) {
            if (node.type === 'M' && node.code === 6) {
                // M6 换刀指令
                if (this.state.spindleSpeed > 0) {
                    throw new Error('换刀前必须先停止主轴!');
                }
                // ... 更新刀具状态
            }
            if (node.type === 'G' && node.code === 43) {
                // G43 刀具长度补偿
                if (this.state.currentTool === null) {
                    throw new Error('执行G43前必须先选刀!');
                }
            }
            // ... 其他语义规则
        }
    }
}

我的经验:语义分析是解析引擎里最容易被忽视,但也是最能体现“工匠精神”的地方。很多CNC系统跑着跑着出问题,不是词法语法错了,而是语义分析没做到位。我建议你在语义分析层至少留出30%的代码量,专门处理各种边界情况和异常状态。

好了,这一章的内容就到这儿。解析引擎的三层架构——词法、语法、语义,每一层都有它的脾气。下一章咱们聊聊更具体的东西:如何用代码实现一个轻量级的G代码解析器,以及怎么处理那些“不守规矩”的自定义指令。