简介:这是一个基于Python实现的CNC状态机示例项目,面向数控编程初学者、状态机设计爱好者,以及需要离线模拟和预处理G代码的开发者。项目通过解析G代码指令,驱动机床在等待、移动、切割等状态间切换,覆盖指令解析、状态更新、运动仿真与基础错误检测等核心环节。压缩包共10个文件,整体仅15KB,核心为3个Python源文件,另外包含YAML/TOML格式的配置文件、Markdown说明文档、Makefile构建脚本及自动化测试用例,还带有版本约束与Git忽略文件等工程化配置;源码按src/gcode_machine组织,测试独立存放,结构清晰,便于按模块阅读与二次开发。资源已有97人学习。通过分析这份代码,读者能直观理解G代码解析与状态机驱动的配合方式,了解ISO 6983等标准在简单实现中的取舍;代码量精简、逻辑清晰,可作为后续自研小型CNC仿真工具或教学演示项目的参考起点。整个项目小巧而完整,适合作为自动化入门者快速上手的第一份状态机源码。
1. 用Python写CNC状态机,先想清楚G代码的执行模型
做CNC模拟或G代码后处理的人,迟早会碰到一个现象:同一个G代码文件,换台控制器走出来的路径不一样。大部分问题不在插补算法,而在执行模型。G代码逐行读入,但每一行真正执行时,依赖前面累计下来的机床状态;G90/G91决定坐标是绝对还是增量,G17/G18/G19决定圆弧平面,G20/G21决定单位,甚至上一次走的是G0还是G1都在影响本行怎么动。用一堆if-else逐行判断,代码很快会膨胀成没法测的泥团。把执行规则收敛到一个显式的状态机里,是更自然的解法。用Python实现这个状态机,价值主要在两条线:一是模拟,把G代码跑成刀位点、主轴事件、进给事件,供显示和超程检查;二是处理,拿同样的状态机做坐标换算、危险指令过滤,再生成新G代码。下面就从状态拆解、表驱动实现、解析回放和必调参数四个角度展开。
2. 状态机视角下的G代码执行:模态字段与状态迁移规则
状态机第一件事不是写代码,是把“机床当前处在什么状态”建模出来。坐标值不是状态机里唯一需要保存的东西,因为G代码的每一条指令都会引用之前的执行上下文。
2.1 先拆状态:坐标、模态、辅助功能三组字段
我一般会把CNC状态拆成三组:坐标组记录刀尖位置;模态组记录持续生效的G功能;辅助组记录主轴、刀具、冷却液等I/O状态。这三组字段在状态机里都需要被显式持久化,不持久化就会丢上下文。
| 分组 | 字段 | 默认值 | 说明 |
|---|---|---|---|
| 坐标 | x / y / z | 0.0 | 当前刀尖位置,绝对/增量换算的基准 |
| 工艺 | feed / spindle | 0.0 | 当前进给率与主轴转速 |
| 模态-运动 | motion | 0 | 0=G0快速,1=G1直线,2=G2顺圆,3=G3逆圆 |
| 模态-平面 | plane | 17 | 17=XY,18=ZX,19=YZ |
| 模态-单位 | unit | 21 | 21=mm,20=inch |
| 模态-距离 | distance | 90 | 90=绝对坐标,91=增量坐标 |
| 辅助 | tool / coolant | 0 / off | 当前刀具编号与冷却状态 |
这张表是状态机的“寄存器”。最常见的错误是只在碰到G90/G91的那一行去换算坐标,下一行又按绝对坐标处理。状态机要求所有读到的字都作用在state字段上,输出也基于state。另外,I/J/R、P、S、T这些当前行参数不需要全部放入持久状态,它们由当前block的handler消费,处理完就丢弃。
2.2 模态指令和一次性指令:状态迁移的条件不一样
G代码里的指令分两类:模态指令和一次性指令,它们在状态机里对应两种不同的迁移方式。G0/G1/G2/G3、G17-G19、G20/G21、G90/G91都是模态指令,执行后会持续改变状态;G4暂停、G10设置坐标系、M06换刀则是一次性指令,只产生一个事件,或者只临时影响当前block。
如果设计上不区分,会踩一个典型坑:把每条指令都当成“要写入状态”的动作。比如G4 P1只是停顿1秒,如果把它也写进坐标或模态字段,后续回放就会把暂停时间当成数值参与计算。因此指令表里需要给每个G代码加一个类型标记:模态型只改state.modal,一次性型直接执行handler并返回事件。
从状态机理论看,G代码解释器本质上是一个Mealy型状态机:输出既依赖当前状态,也依赖当前输入字。例如执行G1 X10时的终点,要依赖距离模态是G90还是G91,还要依赖当前xy坐标。状态迁移表写得越清晰,就越容易定位是状态更新错误还是参数解析错误。
2.3 表驱动状态机是Python里最稳的选型
常见做法不是引入专门的状态机库,而是用dict注册表自己实现一个极简的表驱动状态机。原因有三:G代码指令数量多但种类固定;handler函数不需要共享全局变量,所有状态显式传入;新增指令时只加一行注册,不需要改动主循环。
表驱动状态机的概念不复杂:把“当前事件+当前状态→新状态+动作”组织成以G代码为键、以handler为值的映射表。主循环只做查表、传state、收event三件事。Python的dict天然适合做这个映射,打印出来就能审查,还可以导出成JSON给外部工具校验。相比之下,用transitions这类状态机库更适合管理有限个生命周期状态,而CNC模拟里的“状态”更多是一堆字段的一致更新,硬套类库反而要在回调和字段之间绕来绕去。这也是标题里“简单状态机”最贴合的落地方式。
3. 用表驱动状态机在Python里搭一个可扩展的CNC指令内核
现在按表驱动思路实现一个最小内核。我习惯拆成三个小模块:MachineState状态对象、指令注册表、run_line主循环。三者各管一件事,改起来不会互相牵连。
3.1 用dataclass建模MachineState,固定默认状态
先定义状态对象。坐标、进给、主轴放在顶层字段,模态组用一个字典保存。模态组必须用field(default_factory=...)初始化,否则所有状态实例会共享同一个可变dict,这是Python新手最常见的坑。
from dataclasses import dataclass, field @dataclass class CncState: # 坐标组 x: float = 0.0 y: float = 0.0 z: float = 0.0 # 工艺参数 feed: float = 0.0 spindle: float = 0.0 # 模态组:跨行生效的字段都放这里 modal: dict = field(default_factory=lambda: { "motion": 0, # G0 "plane": 17, # XY平面 "unit": 21, # 公制 "distance": 90 # 绝对坐标 })这里把开关量、坐标系和平面选择全部收纳进modal字典,好处是两个:后续注册表只需要指定“改哪个键”,不需要为每个G代码单独写更新逻辑;调试时一行print(state.modal)就能看清当前生效的全部模态代码。坐标和主轴则放在dataclass顶层,因为它们是数值,序列化时更直观。
3.2 注册表:把G代码和handler的映射关系数据化
注册表的核心思想是,让“哪个G代码改哪个字段”变成声明式数据。模态类指令全部用同一个三元组结构描述:
G_TABLE = { 0: ("modal", "motion", 0), 1: ("modal", "motion", 1), 2: ("modal", "motion", 2), 3: ("modal", "motion", 3), 17: ("modal", "plane", 17), 18: ("modal", "plane", 18), 19: ("modal", "plane", 19), 20: ("modal", "unit", 20), 21: ("modal", "unit", 21), 90: ("modal", "distance", 90), 91: ("modal", "distance", 91), }每个值是("modal", 键, 值)三元组。主循环看到modal标记后,执行state.modal[键] = 值即可。G0到G3也只是一次模态写入,真正的坐标换算交给后面的运动执行函数。这样设计后,新增模态指令只需加一行数据,不需要碰主循环。
3.2.1 非模态指令和M指令怎么注册
一次性指令的注册写法略有不同,用"once"标记,并直接给一个handler。例如G4暂停:
def dwell(state, parsed): return ("dwell", parsed.get("P", [0])[-1]) G_TABLE[4] = ("once", dwell)M指令可以复用同一套注册逻辑。主轴、冷却、换刀都属于一次性事件,执行后只发事件,不进入模态状态:
def spindle_on(state, parsed): if "S" in parsed: state.spindle = parsed["S"][-1] return ("spindle", "cw", state.spindle) M_TABLE = { 3: spindle_on, 5: lambda state, parsed: ("spindle", "off"), 8: lambda state, parsed: ("coolant", "on"), 9: lambda state, parsed: ("coolant", "off"), }这种二元组和三元组混用的注册表,看起来简单,但对“模拟”和“处理”两条管线都够用。主循环不需要关心handler内部逻辑,只负责按类型分发。
3.3 主循环run_line:先更新模态,再执行运动
主循环的关键是执行顺序。我一般把一行处理拆成三步:先扫G字更新模态,再扫M字触发辅助事件,最后看有没有坐标或圆弧参数,用当前motion模态执行运动。
def run_line(state, line: str): parsed = parse_words(line) # 正则解析见4.1 if not parsed: return [] events = [] # 第一遍:处理G字,更新模态或触发一次性事件 for g in parsed.get("G", []): spec = G_TABLE.get(int(g)) if spec is None: continue if spec[0] == "modal": state.modal[spec[1]] = spec[2] else: handler = spec[1] event = handler(state, parsed) if event: events.append(event) # 第二遍:处理M字,通常是主轴、冷却、换刀 for m in parsed.get("M", []): handler = M_TABLE.get(int(m)) if handler: event = handler(state, parsed) if event: events.append(event) # 第三遍:本行有坐标或圆弧参数时,按当前motion模态运动 if any(axis in parsed for axis in "XYZIJK"): motion = state.modal["motion"] if motion in (2, 3): event = arc_move(state, parsed) else: event = linear_move(state, parsed, rapid=(motion == 0)) if event: events.append(event) return events执行顺序很重要。G0/G1/G2/G3先落到state.modal,G90/G91也先落,这样第三段执行坐标时,距离模式已经是本行修改后的值。M指令放在运动前,模拟的是真实控制器先启动主轴再走刀的时序。如果某些机床习惯先移动再开冷却,调整三个循环的顺序即可,主循环本身不需要改。
直线运动和圆弧运动分别实现。直线运动要做G90/G91换算:
def linear_move(state, parsed, rapid=False): target = {} for axis in "xyz": key = axis.upper() if key in parsed: value = parsed[key][-1] if state.modal["distance"] == 91: value += getattr(state, axis) target[axis] = value else: target[axis] = getattr(state, axis) state.x, state.y, state.z = target["x"], target["y"], target["z"] return ("linear", (target["x"], target["y"], target["z"]), rapid)这里只取parsed[key][-1],表示同一行里同一个地址字如果出现多次,以最后一个为准,接近控制器常见行为。incremental模式下,终点等于当前坐标加偏移量,换算完成后统一写回state。
圆弧运动稍微复杂一点,但状态机侧只需要算圆心、起止角,并把终点写回state,不需要在状态机内部出点:
import math def arc_move(state, parsed): # 只演示XY平面;G18/G19要做轴重映射后才能复用 if state.modal["plane"] != 17: return ("error", "暂未实现G18/G19平面圆弧") x0, y0 = state.x, state.y cx = x0 + parsed.get("I", [0])[-1] cy = y0 + parsed.get("J", [0])[-1] tx = parsed.get("X", [state.x])[-1] ty = parsed.get("Y", [state.y])[-1] if state.modal["distance"] == 91: tx += x0 ty += y0 radius = math.hypot(cx - x0, cy - y0) a0 = math.atan2(y0 - cy, x0 - cx) a1 = math.atan2(ty - cy, tx - cx) cw = state.modal["motion"] == 2 if cw: while a1 >= a0: a1 -= 2 * math.pi else: while a1 <= a0: a1 += 2 * math.pi # 全圆:终点与起点重合时,补360度扫角 if abs(tx - x0) < 1e-9 and abs(ty - y0) < 1e-9: a1 = a0 + (-2 * math.pi if cw else 2 * math.pi) state.x, state.y = tx, ty return ("arc", {"cx": cx, "cy": cy, "r": radius, "a0": a0, "a1": a1, "cw": cw})I/J表示圆心相对起点的偏移,这是Fanuc风格。角度修正用while反复加或减2π,比单个if更稳,能容忍负角度或超过一圈的写法。状态机到这里已经能处理最常见的直线和圆弧代码,并且每个动作都以事件形式返回。
4. 解析G代码与模拟回放:地址字、圆弧离散与超程检查
状态机主循环里用到的parse_words、arc_move和事件结构,放在解析与回放这一层看会更完整。手工逐字符拆G代码不可靠,用正则一次把“字母+数字”取出来是常见做法。
4.1 用正则把“G0 X10 Y5”拆成地址字
地址字就是一个字母后跟一个数字,比如G0、X-5.5、I.5。正则写法如下:
import re WORD_RE = re.compile(r"([A-Za-z])([+-]?(?:\d+(?:\.\d*)?|\.\d+))") def parse_words(line: str) -> dict: parsed = {} for addr, num in WORD_RE.findall(line.upper()): parsed.setdefault(addr, []).append(float(num)) return parsed字母部分只抓单个字符,数字部分支持正负号、整数和小数。line.upper()统一大写,避免g0和G0被当成两个键。parse_words返回的是一个字典,键是地址字母,值是数字列表;同一行重复出现的G代码会被保留在列表里,供主循环按顺序处理。
读取文件时还要去掉注释。常见做法是在外层循环里提前裁剪:
line = raw_line.split("(")[0].split(";")[0].strip()括号表示注释,分号也是多数CAM软件使用的注释符,先切再交给parse_words。
4.2 模拟回放:把arc事件离散成线段,控制密度用弓高误差
状态机只输出事件,不会画线。做路径预览或碰撞检查时,需要把arc事件离散成坐标点。这里不建议固定1度采样,因为大半径圆弧和小半径圆弧对采样密度的要求差别很大。更稳的做法是按弓高误差算采样角度步长:
def rasterize_arc(arc, tolerance=0.01): r = arc["r"] if tolerance >= r: step = math.pi else: step = 2 * math.acos(1 - tolerance / r) sweep = abs(arc["a1"] - arc["a0"]) n = max(4, int(math.ceil(sweep / step))) return [ ( arc["cx"] + r * math.cos(arc["a0"] + (arc["a1"] - arc["a0"]) * i / n), arc["cy"] + r * math.sin(arc["a0"] + (arc["a1"] - arc["a0"]) * i / n) ) for i in range(n + 1) ]tolerance的单位跟随内部统一单位,建议全部换算成mm。对R10的半圆,几个常用tolerance取值如下:
| tolerance | 适用场景 | 半圆采样段数 |
|---|---|---|
| 0.005mm | 后处理精查 | 约50段 |
| 0.02mm | 一般路径预览 | 约25段 |
| 0.1mm | 快速显示 | 约11段 |
有了rasterize_arc,模拟回放就可以把所有事件合并成一条刀位点列表:
def simulate(program, state): footprint = [] for raw_line in program: line = raw_line.split("(")[0].split(";")[0].strip() if not line: continue for event in run_line(state, line): if event[0] == "linear": footprint.append(event[1]) elif event[0] == "arc": footprint.extend(rasterize_arc(event[1])) return footprintlinear事件只返回终点,不细分中间点;如果需要快速移动的动画,按进给率或加速度在显示层做插值就行,不需要让状态机去处理时间相关细节。
4.3 超程检查与越界报错:把状态机接到运动范围上
G代码模拟最实用的副产品是超程检查。程序空跑时就能发现坐标超出机床行程,避免真的上机撞限位。给每个轴配置limits,然后统一检查footprint里的点:
class LimitError(Exception): pass def check_limits(points, limits): for x, y, z in points: for value, axis in zip((x, y, z), "XYZ"): lo, hi = limits[axis] if not lo <= value <= hi: raise LimitError(f"{axis}={value:.3f} 超出 [{lo:.3f}, {hi:.3f}]")抛异常比返回bool更符合处理管线需求,调用方可以选择在哪个环节中断模拟。
limits = {"X": (-800, 800), "Y": (-500, 500), "Z": (-50, 300)} check_limits(simulate(program, state), limits)有一个细节要注意:超程检查要放在坐标换算之后、输出G代码之前。如果状态机已经把G91增量坐标转成了绝对坐标,这里检查的才是真实行程;如果直接拿原始G代码里的数值判断,G91模式下几乎所有点都会误报到限位。
5. 处理模式的3个必调参数与对拍验证法
把状态机从模拟切到“处理”模式时,我会先盯住三个参数。这三个参数不调好,状态机逻辑再对,生成的G代码行为也可能和预期完全不一样。
5.1 单位换算、进给倍率与圆弧公差放哪一层
首先是单位换算。状态机内部只记录G20/G21状态,不在解析时把inch立即转成mm。处理模式要做的,是统一在事件输出层换算,并控制最终重新输出G代码的单位。一旦在解析层提前转换,同一个文件中间的G21/G20切单位就会错。
其次是进给倍率。倍率只应该影响插补层的表现,不能进入状态机。state.feed保存的是程序理论值,模拟时想跑快或跑慢,在消费事件的显示层乘一个feed_override即可;如果在状态机内部乘倍率,处理模式会把倍率写进新生成的G代码,实际机床速度就被改掉了。
第三个是圆弧公差。生成刀位点或做超程检查时,用弓高误差公式动态计算采样段数:
def arc_steps(radius, sweep_angle, tolerance=0.01): import math if tolerance >= radius: step = math.pi else: step = 2 * math.acos(1 - tolerance / radius) return max(4, int(math.ceil(sweep_angle / step)))这个函数和rasterize_arc里的算法完全一致,只是把采样段数单独提取出来,方便其他地方复用。
5.2 状态机输出和成熟解析器对拍
调完参数,最有用的验证方法是对拍:把同一份G代码分别交给自己的状态机和一个成熟的解释器,逐步比较模态快照和刀位点。只对比坐标不够,G90/G91这类模态错位会让后续每一行结果都不同,所以事件输出里要带状态快照。
def state_snapshot(state): return { "pos": [state.x, state.y, state.z], "modal": dict(state.modal) }每处理完一行,把state_snapshot追加到列表,连同源行号一起写入JSON Lines。对拍程序读取两份记录,按行号依次比对,浮点差异控制在1e-4mm内。如果状态机漏改了某个模态字段,对拍会立刻定位到具体行号和字段,比直接对比最终路径更省时间。把这条对拍脚本放进CI,每次改动G_TABLE或M_TABLE后自动跑一遍标准样例,能拦住九成状态机回归问题。
本文还有配套的精品资源,点击获取