第2章:G代码解析器架构设计
好,咱们直接进入正题。上一章聊了G代码解析器到底是个什么东西,这一章咱们得把它的骨架搭起来。说白了,就是回答一个问题:这个解析器,到底该怎么拆?
我个人习惯,在设计任何解析器之前,先画一张大图。把整个流程走一遍,心里才有底。你想想看,G代码从文件里读出来,到最终驱动电机转动,中间要经过多少道工序?
2.1 核心模块:三驾马车
我把整个解析器拆成了三个核心模块。这三个模块各司其职,缺一不可。我在项目中遇到过不少新手,上来就把所有逻辑揉在一起写,结果后期改一个指令格式,整个代码都得重写。嗯,那是真痛苦。
核心三模块:
- 词法分析器(Lexer):负责把原始G代码字符串,拆成一个个有意义的“单词”(Token)。
- 语法分析器(Parser):负责把这些“单词”按照语法规则,组装成一条条可执行的“指令”(Command)。
- 执行器(Executor):负责真正去执行这些指令,比如更新坐标、控制IO、调用插补器。
为了让你看得更清楚,我画了一张流程图。这张图我每次讲课都会拿出来,它清晰地展示了数据在三个模块之间的流转过程。
你看,数据流非常清晰。从原始字符串开始,经过词法分析变成Token流,再经过语法分析变成指令对象,最后执行器干活。每一层只做一件事,这就是模块化的精髓。
2.2 词法分析器:把字符串变成Token
词法分析,说白了就是“分词”。G代码的格式其实很规整,比如 G01 X100 Y200 F500,一眼就能看出来:一个字母后面跟一串数字。
我建议你把它设计成一个状态机。为什么?因为G代码的字符类型就那么几种:字母、数字、正负号、小数点、空格、换行。状态机处理起来干净利落。
我的经验: 词法分析器不要做得太复杂。你只需要识别出Token的类型和值就行。比如 X100,类型是 AXIS_X,值是 100.0。至于这个X轴坐标合不合法,那是语法分析器的事。
来看看核心的Token类型定义:
// 定义Token类型
typedef enum {
TOKEN_G_CODE, // G代码,如 G01, G02
TOKEN_M_CODE, // M代码,如 M03, M05
TOKEN_AXIS_X, // X轴坐标
TOKEN_AXIS_Y, // Y轴坐标
TOKEN_AXIS_Z, // Z轴坐标
TOKEN_FEEDRATE, // 进给率 F
TOKEN_SPINDLE, // 主轴转速 S
TOKEN_TOOL, // 刀具号 T
TOKEN_NUMBER, // 纯数字
TOKEN_EOL, // 行结束
TOKEN_EOF // 文件结束
} TokenType;
// Token结构体
typedef struct {
TokenType type;
double value; // 数值
char text[32]; // 原始文本,用于调试
} Token;
词法分析器的核心逻辑,就是一个 getNextToken() 函数。每次调用,它从输入流里读一个字符,判断类型,然后返回一个Token。我曾经见过有人用正则表达式来做词法分析,结果性能惨不忍睹。对于G代码这种格式固定的东西,手写一个状态机,又快又稳。
2.3 语法分析器:把Token变成指令
Token流出来了,接下来要干嘛?组装成指令。语法分析器要回答的问题是:这一行G代码,到底想让我干什么?
这里我强烈推荐使用策略模式。为什么?因为G代码的指令种类太多了:直线插补、圆弧插补、暂停、换刀……每种指令的处理逻辑都不一样。如果用一堆 if-else,代码会变得又臭又长。
策略模式的核心思想很简单:把每种指令的处理逻辑封装成一个独立的策略类。解析器只负责根据G代码的类型,找到对应的策略,然后执行。
策略模式在G代码解析器中的应用:
- 上下文(Context):语法分析器本身。它持有当前解析到的Token流。
- 策略接口(Strategy):定义一个
parse(TokenStream)方法,返回一个指令对象。 - 具体策略(ConcreteStrategy):比如
G01ParseStrategy、G02ParseStrategy、M03ParseStrategy。
来看代码实现:
// 策略接口
typedef struct {
Command* (*parse)(TokenStream* stream);
} ParseStrategy;
// 具体策略:G01 直线插补
Command* parseG01(TokenStream* stream) {
Command* cmd = createCommand(CMD_LINEAR);
// 从Token流中读取X、Y、Z、F值
while (hasNext(stream)) {
Token token = peek(stream);
switch (token.type) {
case TOKEN_AXIS_X: cmd->x = token.value; break;
case TOKEN_AXIS_Y: cmd->y = token.value; break;
case TOKEN_AXIS_Z: cmd->z = token.value; break;
case TOKEN_FEEDRATE: cmd->feedrate = token.value; break;
default: break;
}
next(stream); // 消费Token
}
return cmd;
}
// 策略注册表
ParseStrategy* strategies[256];
strategies[1] = &parseG01; // G01 对应策略
strategies[2] = &parseG02; // G02 对应策略
// ... 以此类推
注意: 策略模式虽然好,但不要滥用。对于M代码这种逻辑简单的指令,直接用函数指针数组就够了。过度设计也是坑,我曾经见过有人把每个M代码都写成一个类,结果光文件就建了几十个,维护起来反而更麻烦。
2.4 执行器:让指令动起来
执行器是最后一环。它拿到语法分析器生成的指令对象,然后去更新机床的状态。这里我建议用状态机来管理。
机床的状态太多了:当前位置、当前进给率、主轴状态、冷却液状态……这些状态之间还有复杂的转换关系。比如,执行换刀指令之前,必须先执行主轴停止指令。用状态机来管理这些约束,逻辑会非常清晰。
执行器的核心状态:
| 状态 | 说明 | 可执行的指令 |
|---|---|---|
| IDLE | 空闲状态 | G00, G01, M03, M05 |
| MOVING | 正在运动 | G01, G02, G03(连续插补) |
| SPINDLE_ON | 主轴旋转中 | G01, M05(停止主轴) |
| TOOL_CHANGE | 换刀中 | M06(执行换刀) |
| ERROR | 错误状态 | M30(复位) |
状态机的实现,我习惯用一个二维数组来存储状态转换表。行是当前状态,列是触发事件(指令类型),值是下一个状态。这样写出来的代码,可读性极高,而且容易扩展。
// 状态转换表
State stateTable[STATE_COUNT][EVENT_COUNT] = {
// IDLE状态
[IDLE] = {
[EVENT_G01] = MOVING,
[EVENT_M03] = SPINDLE_ON,
[EVENT_M05] = IDLE,
[EVENT_M06] = TOOL_CHANGE,
},
// MOVING状态
[MOVING] = {
[EVENT_G01] = MOVING, // 连续插补
[EVENT_M05] = IDLE, // 停止运动
},
// ... 其他状态
};
避坑指南: 我曾经在状态机里犯过一个低级错误——忘记处理非法状态转换。比如在主轴旋转时,直接执行换刀指令。结果就是机床报警,工件报废。所以,状态机里一定要有一个 ERROR 状态,用来处理所有不合法的转换。
2.5 设计模式的选择:为什么是策略+状态机?
你可能会问:为什么选这两个模式?其实答案很简单——因为它们正好对应了G代码解析器的两个核心问题。
- 策略模式解决的是“如何解析”的问题。每种G代码的解析逻辑不同,策略模式让它们各司其职,互不干扰。
- 状态机解决的是“何时执行”的问题。机床的状态变化是有严格顺序的,状态机让这些顺序变得可预测、可管理。
这两个模式组合在一起,就像一对黄金搭档。策略模式负责“横向”扩展(增加新的G代码指令),状态机负责“纵向”约束(控制执行流程)。我在实际项目中,用这套架构处理过超过200种G代码指令,代码依然清晰可维护。
嗯,这一章的内容就到这里。记住这个三模块架构,它是你后续所有修改和测试的基础。下一章,咱们会深入词法分析器的源码,一行一行地把它拆开来看。