news 2026/9/16 17:41:52

Python状态机驱动CNC G代码解析与模拟校验

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python状态机驱动CNC G代码解析与模拟校验

简介:一个用Python实现的简单CNC状态机项目,面向自动化控制初学者、Python开发者及对G代码解析感兴趣的编程爱好者。项目通过解析G代码指令,模拟机床在等待、移动、切割等状态间的流转,帮助读者理解状态机模型在真实工业场景中的应用。压缩包共10个文件,以Python源码为核心,辅以配置文件、测试脚本、README说明及Makefile,整体仅15KB,轻量易读。其中G代码解析器负责将指令拆解为可操作元素,状态引擎根据当前机床状态和输入更新状态,仿真器计算刀具路径、速度与位置,错误检测检查指令合法性,从解析到仿真形成完整闭环。资源已有97人学习,适合作为学习CNC控制、状态机设计及G代码标准的入门实践项目。

1. 一个Python状态机怎么把G代码从文本变成可验证的执行序列

CNC项目里最常见的翻车现场不在插补算法,而在"状态没管住":程序还在退刀,操作员已经按了暂停,G代码解析器直接吐了个乱序坐标;换刀指令还没落位,下一段G01就压进来了。G代码本身是顺序文本,但机床行为是分状态的,逐行执行和机床状态是两回事。用Python写一个简单的CNC状态机,把G代码逐行喂进去,由状态机决定当前行能不能执行、执行完进入什么状态,这是做G代码模拟和处理最稳的骨架。下面把状态划分、转移表、解析和模态处理串起来,给出能跑的最小实现,以及模拟和校验时要调的参数。

2. CNC状态机的状态划分与事件转移表

2.1 先从机床行为拆状态,别从代码行数拆

很多第一次写CNC模拟器的人,会把状态直接映射成"执行到第几行",这等于没做状态机。状态机描述的是"机床现在允许做什么",程序走到哪一行是另一个维度。一行G代码能不能执行,取决于机床当前状态,跟行号没有关系。把这两个维度分开,暂停、停止、报警才有清晰语义,UI上也才知道该显示哪个按钮。

一个常规三轴铣床,七个状态足够:

状态含义允许执行的动作
IDLE上电未回零仅回零(HOME)和复位(RESET)
HOMING正在回机械原点等HOMED反馈,不接任何程序指令
READY已回零,机床空闲接受启动(START),可做手动进给
RUNNING正在执行G代码处理当前行,可暂停、停止
PAUSED程序暂停,进给与主轴停恢复(RESUME)或停止(STOP)
STOPPED程序结束或被手动终止只能复位回IDLE
ALARM报警或急停后的软件态仅复位,复位前不执行任何运动

这里有一个容易踩的坑:把"暂停"和"停止"当成同一个状态。暂停是RUNNING的可逆子集,恢复后继续执行下一行;停止是终态,剩下的行全部作废。两者在转移表里通向不同的目标状态,如果合并,暂停后想继续会直接变成非法转移。

2.2 事件是外部输入和内部异常的统一点

状态机只认事件。按键、网络指令、解析错误、插补越界,只要统一转成事件,转移表就只需要维护一个维度。常用八个事件:

事件来源典型触发场景
HOME外部命令执行G28回零或面板按回零
HOMED内部反馈三轴到位信号全部有效
START外部命令面板启动键或上位机下发
PAUSE外部命令暂停键或断刀检测信号
RESUME外部命令恢复键,继续执行下一行
STOP外部命令或程序停止键或M30程序结束
FAULT内部异常解析失败、驱动报警、软限位
RESET外部命令清除报警,回到IDLE

急停没有单列一个状态,它归到FAULT加RESET这条链路里。机械急停需要独立硬接线逻辑,软件状态机里给一个FAULT事件就够,否则状态矩阵会指数级膨胀,维护成本立刻上来。

2.3 表驱动状态机:把一坨if-else压成一张字典

用枚举定义状态和事件,字典做转移表,是Python里最自然的表驱动状态机写法,也是从单片机裸机状态机一直延伸到上位机的同一套思路:

from enum import Enum, auto class CncState(Enum): IDLE = auto() HOMING = auto() READY = auto() RUNNING = auto() PAUSED = auto() STOPPED = auto() ALARM = auto() class CncEvent(Enum): HOME = auto() HOMED = auto() START = auto() PAUSE = auto() RESUME = auto() STOP = auto() FAULT = auto() RESET = auto() TRANSITIONS = { (CncState.IDLE, CncEvent.HOME): CncState.HOMING, (CncState.HOMING, CncEvent.HOMED): CncState.READY, (CncState.READY, CncEvent.START): CncState.RUNNING, (CncState.RUNNING, CncEvent.PAUSE): CncState.PAUSED, (CncState.PAUSED, CncEvent.RESUME): CncState.RUNNING, (CncState.RUNNING, CncEvent.STOP): CncState.STOPPED, (CncState.PAUSED, CncEvent.STOP): CncState.STOPPED, (CncState.RUNNING, CncEvent.FAULT): CncState.ALARM, (CncState.ALARM, CncEvent.RESET): CncState.IDLE, }

键值对里缺哪个键,哪个转移就是非法的。比如STOPPED状态再来START,字典查不到,直接报错,不用靠一堆else兜底。表驱动状态机最大的收益是可测试:遍历这张表,每个键就是一个测试用例,状态机行为的覆盖率是100%,if-else堆出来的判断链做不到这一点。

3. 用Python写一个最小可跑的CNC状态机骨架

3.1 核心类只做三件事:保存状态、查表、分发

这个类的职责边界要清楚:它不管G代码怎么解析,不管坐标怎么算,只负责"当前状态是什么、收到事件后去哪里、要不要通知外部"。通知机制用回调注册,模拟器可以在特定转移发生时插入动作:

方法 / 属性作用典型挂接点
send(event, ctx)提交事件,查表并切换状态按键回调、解析器异常分支
on(state, event, handler)注册转移前的动作进READY前坐标清零,进ALARM前记录错误码
state只读当前状态UI显示、主循环条件判断
from typing import Callable, Dict class CncStateMachine: def __init__(self): self._state = CncState.IDLE self._handlers: Dict[CncState, Dict[CncEvent, Callable]] = {} @property def state(self): return self._state def on(self, state: CncState, event: CncEvent, handler: Callable): # 注册转移动作,目标状态生效前执行 self._handlers.setdefault(state, {})[event] = handler def send(self, event: CncEvent, ctx=None): key = (self._state, event) if key not in TRANSITIONS: raise ValueError( f"illegal transition: {self._state.name} + {event.name}") handler = self._handlers.get(self._state, {}).get(event) if handler is not None: handler(ctx) self._state = TRANSITIONS[key]

on()注册的回调在状态切换之前执行,这样可以在进入READY前做一次坐标系清零,在进入ALARM前把错误码写进日志。send()里的大部分逻辑就是一次字典查找,量级可以忽略,频繁调用不会成为模拟器的瓶颈。

3.2 非法转移直接抛异常,而不是静默忽略

send()里的ValueError是这个类最重要的设计决策。不少第一次写状态机的人倾向"收到不认识的转移就忽略掉",这在CNC场景里是危险的:START事件在STOPPED状态被忽略,操作员看到的是没有任何反馈,以为程序已经启动。显式抛异常配合上层try/except,至少能留下日志和错误码,排查时一眼定位。

把状态机接到模拟执行循环里,逐行先查状态再执行:

sm = CncStateMachine() sm.send(CncEvent.HOME) # IDLE -> HOMING sm.send(CncEvent.HOMED) # HOMING -> READY for line_no, line in enumerate(gcode_lines, 1): if sm.state == CncState.STOPPED: print(f"aborted at line {line_no}") break if sm.state == CncState.READY: sm.send(CncEvent.START) # 首行前进入RUNNING try: simulate_line(line) # 逐行模拟,见第4章 except GCodeError as exc: sm.send(CncEvent.FAULT) # RUNNING -> ALARM raise sm.send(CncEvent.STOP)

循环里"先看状态、再决定是否继续"是整台模拟器的核心节奏。解析异常用FAULT事件进ALARM,程序立即终止,不会带着错误坐标继续往下算。enumerate从1开始编号,报错信息里的行号直接对应文件里的真实行号,省去一行换算。

3.3 把暂停恢复做成生成器,模拟器才能边跑边响应

真实机床的暂停发生在"两条指令之间",不是指令执行到一半。用Python模拟时,把执行循环改造成生成器,每处理一行向外yield一次,外部就能在这一刻插入PAUSE、RESUME、STOP:

def run_program(lines): sm = CncStateMachine() sm.send(CncEvent.HOME) sm.send(CncEvent.HOMED) sm.send(CncEvent.START) for line in lines: if sm.state == CncState.ALARM: return if sm.state == CncState.PAUSED: yield "paused" # 等待外部RESUME continue if sm.state == CncState.STOPPED: return # 外部已停止,剩余行作废 yield simulate_line(line) sm.send(CncEvent.STOP) yield "finished"

外部调用方用next()拉取,在任意两行之间设置断点、调整进给倍率、或者注入一个FAULT事件测试报警逻辑。这个模式比线程加锁简单得多,单线程内就完成了"边跑边响应",也和屏幕UI的刷新循环天然配合。

4. G代码解析与模态命令的状态化处理

4.1 用一套正则把一行文本变成指令序列

G代码一行可以同时出现多个同字母字,比如G00 G91 X10 Y20,所以解析结果用(letter, value)对的列表保存顺序,而不是字典。一个能处理大多数程序文件的解析函数:

import re WORD_RE = re.compile(r"([A-Z])(-?\d*\.?\d*)") def parse_gcode_line(line: str): # 先剥掉注释: 圆括号注释和分号注释 raw = line.split("(")[0].split(";")[0].strip() if not raw: return [] items = [] for m in WORD_RE.finditer(raw.upper()): letter = m.group(1) num = m.group(2) if num == "": items.append((letter, None)) # 有地址无数值,沿用当前模态 elif letter in "GNMTS": items.append((letter, int(float(num)))) # G/M/T是整型模态码 else: items.append((letter, float(num))) # XYZIJKRF是浮点数值 return items

int(float(num))先转float再转int,是因为G01.这种写法在部分后处理器里会出现,直接int()会抛ValueError。坐标轴字母和G/M分开处理,因为类型不同,G/M是模态码,XYZ是数值,混在一起强制转换会埋下隐性bug。

4.2 模态命令本身就是一组状态,处理它们就是在做状态转移

G代码最容易被新手忽略的特征是模态:一组运动指令里,G00/G01/G02/G03同属一个模态组,写了G02之后,后续行不加G字也默认走圆弧。这就是典型的状态保持,按状态机的思路分层:

模态组可取值默认影响范围
组1 运动G00 G01 G02 G03G01后续运动指令的插补方式
组2 平面G17 G18 G19G17圆弧插补所在平面
组3 坐标G90 G91G90坐标是绝对还是增量
组4 单位G20 G21G21英寸/毫米,影响所有坐标数值
组7 主轴M03 M04 M05M05主轴正转/反转/停止

模态处理在解释器里就是对组内值的覆盖,上一行写的模态码要一直保持到被同组新值替换:

class GCodeInterpreter: def __init__(self): self.motion = "G01" # 模态组1 self.plane = "G17" # 模态组2 self.abs_mode = True # G90 self.units = 1.0 # G21为1.0, G20为25.4 self.feed = 0.0 self.pos = [0.0, 0.0, 0.0] def apply_g(self, g: int): if g in (0, 1, 2, 3): self.motion = f"G0{g}" elif g in (17, 18, 19): self.plane = f"G{g}" elif g == 20: self.units = 25.4 elif g == 21: self.units = 1.0 elif g == 90: self.abs_mode = True elif g == 91: self.abs_mode = False def target(self, items): # 绝对/增量换算, 增量基于本行已移动后的坐标 t = list(self.pos) for letter, val in items: if letter in "XYZ": idx = "XYZ".index(letter) t[idx] = val if self.abs_mode else t[idx] + val return t

target()里增量模式用的是t[idx]而不是self.pos[idx],因为同一行G91 X10 Y20的Y增量,要基于X已经移动后的位置计算。这个细节写错的人很多,现象是斜线变成阶梯线。

注意:模态值的持久化只属于解释器,不要把模态值塞进状态机的CncState里。状态机管安全,解释器管语义,两者混在一起,转移表很快就会失控。

4.3 直线和圆弧插补的模拟,参数化到可以调精度

轨迹模拟的核心参数是弦高误差(chord error),不是固定步数。for i in range(100)的写法在零件尺寸变化时,要么顶点冗余要么轮廓不够平滑。按弦高误差反推步数,是数控系统里实际使用的做法:

import math def line_to(self, target, tol=0.01): if self.check_only: self.pos = list(target) return start = list(self.pos) dist = math.dist(start, target) steps = max(1, int(dist / tol)) for i in range(1, steps + 1): t = i / steps self.traj.append([a + (b - a) * t for a, b in zip(start, target)]) self.pos = list(target) def arc_to(self, target, center, clockwise, tol=0.01): sx, sy = self.pos[0], self.pos[1] cx, cy = center[0], center[1] r = math.hypot(sx - cx, sy - cy) if r < tol: self.line_to(target, tol) # 半径趋近零, 退化成直线 return ang0 = math.atan2(sy - cy, sx - cx) ang1 = math.atan2(target[1] - cy, target[0] - cx) sweep = ang1 - ang0 while sweep > math.pi: sweep -= 2 * math.pi while sweep < -math.pi: sweep += 2 * math.pi if clockwise and sweep > 0: sweep -= 2 * math.pi if not clockwise and sweep < 0: sweep += 2 * math.pi step_ang = 2 * math.acos( min(1.0, max(-1.0, 1.0 - tol / r))) steps = max(1, int(abs(sweep) / step_ang)) for i in range(1, steps + 1): ang = ang0 + sweep * i / steps self.traj.append([cx + r * math.cos(ang), cy + r * math.sin(ang), self.pos[2]]) self.pos = list(target)

弦高误差固定为tol时,小圆弧自动少采样,大圆弧自动加密,同一份代码处理不同尺寸的零件都合理。atan2返回[-pi, pi],跨零点的圆弧(比如从170°转到-170°)如果不做while校正,sweep会差出接近一整圈,圆心角算错,整个圆弧形状都错。min/max夹紧是为了防止浮点精度把1 - tol/r推到(-1, 1)之外让acos返回nan。

4.4 常见的解析和状态错误,以及一眼看穿它们的日志

模拟器跑起来之后,最先遇到的不是插补bug,而是G代码本身的脏数据。见得多的几类:

错误类型典型现象处理方式
注释未过滤圆括号里的刀号被当成指令解析前先切分"("和";"
G00带F值快速移动的速度写进进给G00下忽略F,保留原模态值
圆弧半径为0圆心和起点重合r < tol时按直线处理
负半径R部分控制器允许,多数不允许按控制器规范决定报错或取绝对值

半径接近零的处理在arc_to里已经体现:退化直线而不是抛异常。这更贴近实际数控系统的行为——刀尖轨迹在极小圆弧处本来就近似直线,直接报错会把一批后处理器生成的正常程序误判。

5. 用状态机反过来校验G代码,以及两个调参技巧

5.1 把转移表直接写成自动化测试

转移表本身就是测试用例列表。遍历所有键值对逐个断言,几分钟写完整个状态图的回归测试:

def test_all_transitions(): sm = CncStateMachine() for (state, event), target in TRANSITIONS.items(): sm._state = state # 注入状态,只测转移逻辑 sm.send(event) assert sm.state == target def test_illegal_transition(): import pytest sm = CncStateMachine() sm._state = CncState.STOPPED with pytest.raises(ValueError): sm.send(CncEvent.START)

直接注入状态、发事件、断言结果,不需要mock任何硬件。新增事件时把新键同时补进表里和测试里,行为变更立刻可见。

5.2 加一个check_only开关,把解释器变成G代码体检工具

模拟和校验共用解析与执行逻辑,区别只在是否写轨迹。check_only=True时运动指令只更新坐标,不append轨迹点,几千行的程序也能在几十毫秒内完成全量校验:

def execute(self, items, check_only: bool): self.check_only = check_only # 运动指令内部用self.check_only判断是否跳过traj.append

CAM出的程序上线前先体检一遍,哪一行非法转移、哪一行圆弧越界,一次全部暴露。

5.3 两个直接影响性能和内存的参数

参数默认值调大/调小的影响
tol 弦高误差0.01 mm调大顶点少、内存省,曲面轮廓变糙
check_onlyFalse调True跳过轨迹记录,校验快几个数量级

正则的预编译也在性能清单里:WORD_RE放在模块级,避免每行重复compile。轨迹点用list嵌套list即可,如果顶点量到了百万级,换array('f')按XYZ连续存放,再配合numpy的reshape直接喂可视化,省掉一次数据拷贝。给run_program生成器加一个返回(line_no, state)的yield,配合check_only,就是一套不需要任何界面框架的G代码预检管道,状态机本身不必为此多开一个事件。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 17:38:00

从Docker启动到自建工具:Agent Zero AI智能体框架完整指南

从Docker启动到自建工具&#xff1a;Agent Zero AI智能体框架完整指南 【免费下载链接】agent-zero Agent Zero AI framework 项目地址: https://gitcode.com/GitHub_Trending/ag/agent-zero 想让 AI 自己查资料、跑任务、记住上次聊到哪&#xff1f;Agent Zero 是开源A…

作者头像 李华
网站建设 2026/9/16 17:36:22

抖音合集批量下载工具:3 步跑通 douyin-downloader 无水印下载

抖音合集批量下载工具&#xff1a;3 步跑通 douyin-downloader 无水印下载 【免费下载链接】douyin-downloader A practical Douyin downloader for both single-item and profile batch downloads, with progress display, retries, SQLite deduplication, and browser fallba…

作者头像 李华