本文用 Python(pyserial)从零实现一个步进电机驱动器的控制客户端:Modbus RTU 帧构造、CRC16 校验、写目标位置、轮询运行状态,最终封装成一个 30 行的 StepperDriver 类,实现“一行代码让电机走到指定位置”。全程无需 PLC。代码中的寄存器布局为示意,实际以驱动器协议手册为准。
一、背景:软件工程师为什么要碰这个
最近接了个厂内小项目:给产线的单轴送料机构做参数管理工具。硬件侧选了驱控一体步进电机驱动器(本文用广州致良运控的 SD6 做测试)——这类驱动器内置了运动控制,对外暴露 Modbus RTU 通讯口。
翻译成人话:它就是一个挂在 RS485 总线上的、带寄存器的“智能外设” 。对我们来说,控制它和控制任何一个串口设备没有本质区别——不需要懂 PLC,只需要会发 Modbus 帧。
二、准备工作
硬件:SD6 驱控一体步进电机驱动器、USB 转 RS485 模块、步进电机、开关电源。接线按手册:电源、电机、A/B 通讯线。
软件:
pip install pyserial
先确认基本串口参数(以手册为准,本文示意用 8N1、19200bps)。
三、Modbus RTU 帧:手写一遍就懂了
Modbus RTU 的请求帧结构很简单:
[站号 1B][功能码 1B][数据 N B][CRC16 2B 低字节在前]
写单个寄存器用功能码 0x06。假设(示意)寄存器布局如下:
| 寄存器 | 地址 | 说明 |
|---|---|---|
| 目标位置 | 0x0000 | int,脉冲数或用户单位 |
| 运行速度 | 0x0001 | Hz |
| 加减速时间 | 0x0002 | ms |
| 启动命令 | 0x0003 | 写 1 启动 |
| 状态字 | 0x0010 | bit0 运行中 / bit1 到位 / bit2 报警 |
CRC16 的坑:Modbus 用的是 CRC16/MODBUS 变体,多项式 0xA001(反射),低字节在前。当年我在这儿浪费了一小时,直接给结论:
def crc16(data: bytes) -> bytes:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return bytes([crc & 0xFF, crc >> 8]) # 低字节在前!
四、先裸发一帧验证链路
目标:向 1 号站的 0x0000 寄存器写入 3000(目标位置)。
import serial
ser = serial.Serial('COM3', 19200, bytesize=8, parity='N',
stopbits=1, timeout=0.5)
frame = bytes.fromhex('01 06 00 00 0B B8') # 3000 = 0x0BB8
frame += crc16(frame)
ser.write(frame)
print('resp:', ser.read(8).hex()) # 正常响应应与请求帧一致
发出去之后,电机不会动——因为我们只写了位置,还没发启动命令。这正是调试期最安全的地方:参数写入与动作触发是分离的。
链路验证通过的标准:响应帧 == 请求帧。收不到响应?按这个顺序查:站号 → 波特率/校验位 → A/B 线是否接反 → CRC。
五、封装成 StepperDriver 类
裸发帧太原始,封装一层,让调用方用“人话”操作:
class StepperDriver:
def __init__(self, port: str, addr: int = 1, baud: int = 19200):
self.ser = serial.Serial(port, baud, bytesize=8,
parity='N', stopbits=1, timeout=0.5)
self.addr = addr
def _request(self, pdu: bytes, resp_len: int) -> bytes:
frame = bytes([self.addr]) + pdu
frame += crc16(frame)
self.ser.write(frame)
resp = self.ser.read(resp_len)
if len(resp) < resp_len or resp[:2] != frame[:2]:
raise TimeoutError(f'无响应或响应异常: {resp.hex()}')
if not self._check_crc(resp):
raise ValueError('CRC 校验失败')
return resp
@staticmethod
def _check_crc(resp: bytes) -> bool:
return crc16(resp[:-2]) == resp[-2:]
def write_register(self, addr: int, value: int):
pdu = bytes([0x06]) + addr.to_bytes(2, 'big') + \
value.to_bytes(2, 'big')
self._request(pdu, 8)
def read_status(self) -> int:
pdu = bytes([0x03]) + (0x0010).to_bytes(2, 'big') + (1).to_bytes(2, 'big')
resp = self._request(pdu, 7)
return resp[3] # 状态字(示意:1字节)
def move_to(self, position: int, speed: int, accel_ms: int = 200):
self.write_register(0x0000, position) # 目标位置
self.write_register(0x0001, speed) # 速度
self.write_register(0x0002, accel_ms) # 加减速时间
self.write_register(0x0003, 1) # 启动!
def wait_done(self, timeout: float = 30.0):
import time
t0 = time.time()
while time.time() - t0 < timeout:
status = self.read_status()
if status & 0x02: # 到位位(示意)
return True
if status & 0x04: # 报警位(示意)
raise RuntimeError('驱动器报警')
time.sleep(0.1)
raise TimeoutError('等待到位超时')
六、三行代码,让电机自己走到位
drv = StepperDriver('COM3', addr=1)
drv.move_to(position=3000, speed=1500, accel_ms=300)
drv.wait_done()
print('定位完成 ✅')
再往上加一层,就是多轴场景:每台驱动器一个站号,实例化多个对象,轮询各自状态——一个简易的“轴群监控面板”就是几个 for 循环的事,这里不展开。
七、踩坑记录
- CRC 字节序:低字节在前。高字节在前就是不通,报文一个字都不会错,纯粹是校验被拒——最阴的一个坑;
- 串口被占用:调试软件和你的脚本别同时开,Windows 下串口是独占的;
- timeout 太短:多段加减速末端轮询别太密,建议 ≥100ms 间隔,总线没必要打满;
- 单位换算:位置寄存器的“脉冲数”和机械位移之间隔着丝杆导程和细分,写个换算函数,别在业务代码里硬算;
- 务必核对协议手册:本文寄存器地址全部为示意布局,不同型号的地址表、状态位定义都不一样——接入前拿官方协议文档对一遍。
八、总结
对软件工程师来说,驱控一体驱动器只是串口总线上一个“会转的寄存器集合”:写位置、写速度、写启动、读状态——四个寄存器操作封装完,工业控制和调一个普通外设没有区别。硬件团队负责机构与参数,软件团队用几十行代码接管参数管理与状态监控,分工干净,联机方案的天花板也高。
觉得有帮助的话点个赞 ⭐ 收藏一下;评论区可以聊:你们的产线数据采集是怎么做的?下篇可能写“给这个类加个 Web 控制面板(Node.js + SerialPort)”,感兴趣的扣个 1。
标签:#Python #硬件 #IoT #Modbus #嵌入式 #串口通信