说实话我在拧驱动器接线端子之前已经写了七八年代码但第一次面对工业步进电机时心里还是有点发怵。项目标题里那句“Python Modbus RTU从串口报文到自动定位”听起来特别工科实际上也确实是一套全新的思维——以前我调的是 HTTP 接口和 JSON现在面对的是十六进制字节流和 RS485 电平跨度不小但真正跑通的瞬间那种“另一个世界也没那么神秘”的感觉特别爽。这篇文章就把我这次“第一次控制工业步进电机”的完整过程写下来从驱动器手册里那一堆寄存器地址讲起到用 Python 拼出第一帧 Modbus RTU 报文再到让电机按预设坐标自己走完一段轨迹。整个过程没有用现成的工控组态软件就是纯代码驱动颗粒度非常细。适合刚接触工控的软件工程师也适合想低成本做点小设备的自动化爱好者。读完你至少能搞明白整个控制链路是怎么串起来的代码可以直接拿去改着用。1. 项目概述为什么程序员要碰步进电机1.1 这个项目到底要解决什么问题先说场景。我最近在帮朋友做一个小型的自动打标设备需求很朴素工件放到平台上之后电机要带着执行机构移动到指定坐标然后停下来等待再移动到下一个坐标。说白了就是两轴或单轴的“点到点定位”。这种需求在工业现场到处都是但在纯软件背景的人看来第一反应往往是“这不就是发个指令让它转吗”真正上手才发现步进电机本身是听不懂人话的它只认脉冲而且不是随便给脉冲就行的——你需要一个驱动器把逻辑信号转换成驱动电机绕组的电流序列。而我们通过串口控制的其实是这个驱动器不是电机本身。这个关系先理清楚后面所有代码才有意义。我选择用步进电机而不是伺服电机主要原因是成本和调试难度。步进电机开环控制就能实现定位不需要编码器反馈对精度要求没那么变态的场景完全够用。驱动器的 Modbus RTU 通信是标配功能一根 USB 转 RS485 线就能把电脑和驱动器连起来不需要额外买 PLC 或者运动控制卡。这也是我坚持用纯 Python 写的动力——整个方案加起来硬件成本很低逻辑却一点不少。1.2 方案选型Modbus RTU 为什么够用可能有人会问现在 EtherCAT、Profinet 这些工业以太网协议不是更流行吗为什么非要用 Modbus RTU原因很简单第一我手头没有那些高级网关和专用网卡而 RS485 串口几乎是所有驱动器都保留的“底线”通信方式第二Modbus RTU 协议足够简单报文格式极容易手工拼装和解析对学习阶段来说反而是优势第三时序要求不高的定位场景9600 波特率下的响应速度绰绰有余。打个比方Modbus RTU 就像工控界的“HTTP GET”它不花哨但几乎每个设备都认识它。驱动器上电后只要你用对寄存器地址就可以读它的状态、写它的目标位置、触发它的运动指令。数据链路就是电脑 USB 转 RS485 适配器发出差分信号驱动器解析成 16 位寄存器值再转换为脉冲序列驱动电机。中间没有任何神秘环节。选型时我还考虑了软件生态。Python 的 pyserial 库是事实标准文档齐全几行代码就能收发串口数据。网上虽然也有现成的 pymodbus 库但我这次刻意没有直接用而是手工实现 Modbus 帧的拼装和 CRC 校验原因后面会细说——如果你不懂底层报文出了问题真的无从下手。1.3 硬件链路与接线实际动手前的硬件清单我给列一下都是常见物件步进电机一台我这里用的是 57 系列两相混合式步进电机配套驱动器一台支持 RS485 串口通信寄存器定义看手册USB 转 RS485 适配器一个建议买带自动收发切换的型号省去一堆麻烦24V 开关电源一个给驱动器和电机供电若干杜邦线和螺丝刀接线方面驱动器上面的 RS485 端子一般标着 A、B 两个口注意 A 对 A、B 对 B别交叉接反了。还有一点容易忽略RS485 总线两端建议接 120 欧姆终端电阻短距离一两米测试可以不接但距离超过十几米或者经常出现乱码时一定要补上。电源线我踩过坑一开始图省事用了细线电机一启动电压就跌落驱动器直接报警后来换了 1 平方以上的线才稳定。注意接线之前一定要断电操作。驱动器上的 VCC、GND 如果接反轻则烧保险重则直接报废驱动器。我见过不止一次因为接线顺序想当然导致的悲剧通电前养成“看一遍手册再对一遍线”的习惯。硬件链路理清楚之后接下来的核心问题就是电脑和驱动器之间到底用什么语言交流这就要讲到 Modbus RTU 报文本身了。2. Modbus RTU 报文拆解先看懂字节再写代码2.1 一帧报文长什么样Modbus RTU 是主从协议电脑这边是主机驱动器是从机。通信永远是主机发起请求从机响应从机不会主动说话。所以整个控制过程就是我方发送一帧请求等待响应解析响应再发下一帧。一帧完整的 Modbus RTU 报文由四部分组成字段长度说明从机地址1 字节驱动器的站号我这里设置成 0x01功能码1 字节告诉对方要做什么操作数据区可变寄存器地址、数据值、数量等CRC16 校验2 字节校验前面所有字节低字节在前举个例子读取驱动器报警状态寄存器的完整请求帧是01 03 00 00 00 01 D8 44拆开看01是从机地址03是功能码读保持寄存器00 00是寄存器起始地址00 01是读取数量D8 44是 CRC 校验。这 8 个字节就是一条完整的“问候语”。驱动器收到后如果正常会回复类似01 03 02 00 00 CRCL CRCH其中02表示后面跟了 2 个字节数据00 00就是报警状态值0 表示无报警。注意 Modbus 协议里所有多字节数值都是大端序也就是高字节在前。比如寄存器值0x1234在帧里就是12 34这和 x86 电脑内存里的小端序习惯正好相反写代码切分字节时特别容易搞混后面我封装函数时会统一处理这件事。2.2 CRC16 校验手动算一遍就懂了CRC 校验是很多初学者第一次卡住的地方。它其实不复杂就是一个循环异或位移的过程。Modbus RTU 用的是 CRC-16/MODBUS 算法多项式是0x8005但在代码里用的是反射后的0xA001初始值是0xFFFF。Python 里的实现很简洁def crc16_modbus(data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 return crc拿上面那帧01 03 00 00 00 01举例把前面的功能码和数据区字节依次送入这个函数最后算出来是0x44D8。但发送时顺序要反过来低字节在前所以帧的最后两个字节是D8 44这就是我刚才那个例子里的来历。我当时为了验证算法对不对还特意去网上找了一个在线 CRC 计算工具对了一遍结果。这里分享一个经验不要直接复制网上的 CRC 代码自己写一遍然后用“已知结果的帧”去验证这样以后遇到 CRC 校验失败的问题你才能自信地判断是自己的算法错了还是线路干扰导致的数据错误。提示如果调试过程中邮件里问供应商“CRC 对不对”对方大概率只会回“看手册”。自己把校验函数写对是绕不开的基本功。2.3 功能码读写控制的三板斧Modbus 功能码很多但控制驱动器真正常用的就三个0x03读取保持寄存器。用来读当前位置、速度、报警状态等0x06写单个寄存器。用来写速度、使能、控制模式等 16 位参数0x10写多个寄存器。用来写 32 位数据比如目标位置的高 16 位和低 16 位我知道你会问为什么写 32 位位置数据要用0x10而不是分成两个单独寄存器用0x06写答案在驱动器的内部逻辑里。很多步进驱动器的目标位置寄存器是 32 位的电机要开始运动是在你写完“最后一个字节”的那一刻触发的。如果你用两条0x06分开写就会出现一个问题写完高 16 位后驱动器看到数据不完整可能不动作或者更糟它把不完整的值当成目标位置直接开始运动。所以写 32 位参数务必用一次完整的0x10请求把高、低 16 位一起发过去。再者响应帧的处理也要注意。0x06写单个寄存器成功后驱动器的响应是原样返回请求帧0x10写多个寄存器成功后响应帧则只是从机地址、功能码、起始地址、寄存器数量加 CRC比请求帧短很多。如果程序里统一按“等响应长度等于请求长度”来收包遇到0x10就会一直等超时。这些都是实操后才会发现的细节。3. Python 串口通信从打开端口到收到第一条响应3.1 环境搭建与串口参数Python 环境没什么特殊的pyserial是唯一必需的第三方库安装命令一行搞定pip install pyserial接线插上 USB 转 RS485 适配器之后先确认电脑认到的串口号。Windows 上在设备管理器里看“端口 (COM 和 LPT)”Linux 上用ls /dev/ttyUSB*或ls /dev/ttyACM*查看。我这里设备显示的是/dev/ttyUSB0。打开串口时参数要对齐驱动器的出厂设置。我用的这款驱动器出厂波特率是 96008 个数据位无校验1 个停止位也就是常说的9600-8-N-1。这些参数在驱动器的拨码开关或软件里可以改但我建议第一次调试别改用默认值把通信跑通再说。import serial ser serial.Serial( port/dev/ttyUSB0, baudrate9600, bytesize8, parityN, stopbits1, timeout0.5 )timeout参数我习惯设成 0.5 秒这是个关键参数。如果设得太短驱动器响应稍微慢一点程序就误判超时设得太长异常情况下的报错会变得迟钝。0.5 秒对绝大多数低速响应设备是合适的。3.2 第一次手动发包实验代码写第一版的时候我建议大家不要急着封装类先做一次“裸奔”实验手动拼一帧发出去把收到的原始字节打印出来。这样做的好处是你对协议的感受是具体的而不是被封装函数吞掉之后只剩一个返回值。import serial ser serial.Serial(/dev/ttyUSB0, 9600, timeout0.5) # 读报警状态01 03 00 00 00 01 CRC(D8 44) request bytes.fromhex(01 03 00 00 00 01 D8 44) ser.write(request) response ser.read(32) print(接收原始字节:, response.hex( ))正常情况打印出来会是类似01 03 02 00 00 xx xx的 7 个字节。如果只有 5 个字节、或者干脆是空那就说明通信链路或者参数有问题先不要往下写业务代码把这里调通再继续。我这次第一次通电时打印出来的是01 03 02 07 00 xx xx报警值是0x0700查手册发现是“主电源欠压”——果然是我电源线太细的问题换线之后报警值变回00 00后面一切顺利。这一步“先读状态再动作”的流程强烈建议保持下去。3.3 封装一个干净的发送函数裸奔实验通过之后就可以把公共逻辑抽出来了。一个稳定的 Modbus 主站工具函数至少应该做到三件事自动拼 CRC、自动把请求转成字节串、自动校验响应长度和从机地址。import serial import time def crc16_modbus(data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 return crc def build_request(slave_id: int, func: int, data: bytes) - bytes: frame bytes([slave_id, func]) data crc crc16_modbus(frame) return frame crc.to_bytes(2, little) def send_and_read(ser: serial.Serial, request: bytes, expect_len: int) - bytes: ser.reset_input_buffer() ser.write(request) time.sleep(0.05) # 给从机一点处理时间 response ser.read(expect_len) if len(response) expect_len: raise TimeoutError(f响应超时只收到 {len(response)} 字节) if response[0] ! request[0]: raise ValueError(f从机地址不符合: 请求{request[0]:02X}, 响应{response[0]:02X}) return response几个细节我特别说明一下。第一发送前调用reset_input_buffer()把串口缓存里的残留数据清掉避免上一次的脏数据干扰本次解析。第二time.sleep(0.05)是给驱动器反应的时间虽然读操作一般很快但主从半双工的 RS485 在“发送结束”和“从机开始应答”之间有个切换时延稍微等等更稳妥。第三响应校验里如果从机地址都对不上说明大概率接线或者站号设置有问题直接抛异常比闷头解析好看一百倍。4. 步进驱动器的寄存器映射与控制流程4.1 寄存器地址怎么设计的每款驱动器的寄存器定义都不一样但都遵循一条规则控制状态相关的寄存器集中在某段地址参数配置集中在另一段。我参考的这款驱动器寄存器映射大致是这个样子不同品牌会有差异务必以你手里的手册为准寄存器地址名称读写说明0x0000报警状态只读0 正常非 0 按位查手册0x0001控制模式读写0 位置模式1 速度模式0x0002目标速度读写单位是 RPM0x0004 / 0x0005目标位置高 16 位 / 低 16 位读写32 位脉冲数带符号0x0006使能控制读写0 释放1 使能0x0007回零触发读写写 1 触发回零0x0008 / 0x0009当前位置高 16 位 / 低 16 位只读32 位脉冲数注意寄存器地址和数据值都是十六进制表示地址是 16 位的所以写请求时要把寄存器地址转换成两个字节。比如目标位置寄存器的起始地址是0x0004在0x10请求的数据区里就要写成00 04。第一次看到这类表很多程序员会习惯性地问为什么不用结构体、不用 JSON因为这是 1979 年定义的协议它的“接口文档”就是这张地址表。你把它当成一份“字段表”就行只不过每个字段大小、位置、意义都提前由硬件厂商定死了你不准改。4.2 使能、回零、停止这些关键动作控制流程里有几个动作的顺序是绝对不能乱的。第一个动作是“使能”。步进电机驱动器上电后默认处于“释放”状态电机轴可以用手直接转动。要让电机保持定位必须先发使能命令也就是往0x0006寄存器写 1。这一步如果跳过了后面发再多的位置指令电机也只会保持自由状态毫无反应。# 使能电机写单个寄存器 0x0006 1 req build_request(0x01, 0x06, bytes.fromhex(00 06 00 01)) resp send_and_read(ser, req, expect_len8)第二个动作是“回零”。如果设备装的是增量式编码器或者开环步进驱动器上电之后是不知道自己的绝对位置的。我这次的应用需要每次开机都从原点开始所以流程里加了回零触发回零寄存器然后轮询当前位置直到接近 0 并稳定。第三个动作是“停止”。这里要提醒一个很多人想当然的误区向目标位置发一个“当前正好等于当前位置”的指令并不能保证电机急停。更可靠的方式是发“紧急停止”命令或者直接把使能关闭。紧急停止命令在手册里通常是一个独立的寄存器或者往控制字里写特定值。我建议在任何流程里都保留一个“停止函数”并且把按钮事件绑上去关键时刻能救命。4.3 速度与位置指令的编码方式位置模式下驱动器接收的是“脉冲数”不是毫米也不是角度。所以从业务坐标换算成脉冲数是整个代码里最容易出 bug 的地方。目标位置是一个 32 位带符号整数。正数代表正方向负数代表反方向。发送时按高 16 位、低 16 位拆开用0x10一次写完。def write_target_position(ser, slave_id, position_pulses: int) - bytes: # 将 32 位位置拆成高 16 位和低 16 位 pos_high (position_pulses 16) 0xFFFF pos_low position_pulses 0xFFFF # 写多个寄存器起始地址 0x0004数量 2字节数 4 data bytes.fromhex(00 04 00 02 04) data pos_high.to_bytes(2, big) pos_low.to_bytes(2, big) req build_request(slave_id, 0x10, data) return send_and_read(ser, req, expect_len8)细节来了0x10写多个寄存器请求的数据区结构是“起始地址(2 字节) 寄存器数量(2 字节) 后续数据字节数(1 字节) 实际数据(N 字节)”。这里最容易漏掉的就是中间那个“字节数”字段04表示后面跟了 4 个字节。我一开始漏了它驱动器直接回了一个异常码幸好我打印了响应才发现问题。位置模式下驱动的运动逻辑一般是你写入目标位置寄存器的瞬间驱动器就按预先设置好的加减速曲线朝目标脉冲数运动。所以要先设速度、加速度等运动参数再写目标位置这个顺序如果在代码里反了电机会用上次的“残留参数”运动轻则贼快重则丢步。5. 自动定位功能从毫米到脉冲的换算与完整代码5.1 机械参数与脉冲当量自动定位的核心问题说白了就是业务上要“走多少毫米”驱动器眼里是“多少个脉冲”这中间需要一个换算系数工控里叫脉冲当量。我的设备结构很简单电机通过联轴器直连一根丝杆丝杆导程是 5 毫米也就是丝杆转一圈工作台移动 5 毫米。驱动器我设置的是每 1000 个脉冲转一圈驱动器内部有细分设置常见的是 1000、2000、3200、6400 等具体看拨码开关和软件配置。换算链条电机转一圈 1000 脉冲丝杆转一圈 工作台移动 5 毫米所以 1 毫米 1000 ÷ 5 200 脉冲脉冲当量pulses_per_mm 200。要移动 37.5 毫米目标脉冲数就是37.5 × 200 7500。这里有个容易踩的坑业务层的坐标可能是小数而脉冲数必须是整数所以要先做舍入而且要统一用round()而不是截断。我当时写了个到处int()的版本结果每段都差零点几个毫米累积到最后偏了快 2 毫米。如果是带减速箱或者同步带的结构换算公式会多两步减速比和带轮周长。比如减速比 10:1那就变成“电机转 10 圈输出轴转 1 圈”脉冲当量相应除以 10。不管多复杂思路就是把“电机旋转”逐步折算成“末端位移”列成一个纯数乘除的关系写到配置常量里。5.2 单轴定位代码实现我把上面的发送函数、寄存器操作、坐标换算整合成一个类可运行、可扩展import serial import time class StepperController: def __init__(self, port, slave_id1, pulses_per_mm200, baudrate9600): self.ser serial.Serial( portport, baudratebaudrate, bytesize8, parityN, stopbits1, timeout0.5 ) self.slave_id slave_id self.pulses_per_mm pulses_per_mm def _crc16(self, data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 1: crc (crc 1) ^ 0xA001 else: crc 1 return crc def _send(self, func: int, data: bytes, expect_len: int) - bytes: frame bytes([self.slave_id, func]) data crc self._crc16(frame) request frame crc.to_bytes(2, little) self.ser.reset_input_buffer() self.ser.write(request) time.sleep(0.05) response self.ser.read(expect_len) if len(response) expect_len: raise TimeoutError(f响应超时: {response.hex( )}) return response def read_register(self, addr: int, count: int 1) - bytes: data addr.to_bytes(2, big) count.to_bytes(2, big) return self._send(0x03, data, expect_len5 2 * count) def write_register(self, addr: int, value: int) - bytes: data addr.to_bytes(2, big) value.to_bytes(2, big) return self._send(0x06, data, expect_len8) def enable(self, on: bool True): self.write_register(0x0006, 1 if on else 0) def set_speed(self, rpm: int): self.write_register(0x0002, rpm 0xFFFF) def move_abs(self, target_mm: float): pulses round(target_mm * self.pulses_per_mm) pos_high (pulses 16) 0xFFFF pos_low pulses 0xFFFF data bytes.fromhex(00 04 00 02 04) data pos_high.to_bytes(2, big) pos_low.to_bytes(2, big) self._send(0x10, data, expect_len8) def read_position_mm(self) - float: resp self.read_register(0x0008, 2) # 响应格式: addr func byte_count 数据(N字节) crc raw (resp[3] 8 | resp[4]) 16 | (resp[5] 8 | resp[6]) if raw 0x80000000: raw - 0x100000000 return raw / self.pulses_per_mm这里有个 32 位负数处理的细节当前位置和目标位置寄存器都可能出现负值也就是反方向运动。Modbus 协议里没有“负数”的概念它只传 16 位或 32 位无符号整数。但底层寄存器的数值是用补码存的所以如果你看到读回来的原始值大于0x7FFFFFFF就要把它减掉0x100000000转成负数。我第一版没做这个转换电机走到负坐标区域读回来的位置变成 40 多亿业务判断直接崩溃。5.3 多点运动与状态轮询单点定位跑通之后多点连续运动就顺理成章了。比较基本的方式是给定一个坐标列表逐个move_abs()每发一个定位指令就轮询当前位置直到距离目标小于允许误差再发下一个点。def run_track(self, points_mm: list): for i, target in enumerate(points_mm): self.move_abs(target) # 轮询直到到达 for _ in range(300): pos self.read_position_mm() if abs(pos - target) 0.1: break time.sleep(0.05) else: raise TimeoutError(f第 {i1} 个点未到达: 目标{target}, 当前{pos:.2f}) print(f到达第 {i1} 个点: {pos:.2f} mm)轮询的等待时间我选在记录和响应之间留了 3 个轮询周期即每 50 毫秒读一次——太频繁会占用总线太慢又会让运动轨迹显得一顿一顿50 毫秒算是工程里一个常用的平衡点。另外for...else这个写法在 Python 里天然适合处理“循环里没有正常 break 就算超时”的逻辑比手动维护一个 flag 干净得多。如果你需要更平滑的连续轨迹那就要用到驱动器内部的缓冲或加减速规划功能那是另一个话题了。至少对于“点到点定位”这种最常见的小设备需求这套代码已经够实用了。6. 调试实录那些踩过的坑与排查方法6.1 串口打不开 / 连不上设备最常见的几个原因按概率排序权限问题、串口被占用、设备没上电。Linux 下访问串口需要权限当时我直接跑 Python 报PermissionError一看当前用户不在dialout用户组里加进去重新登录就好了。Windows 下则多半是串口号选错或者上次程序崩溃后串口没有被正常释放导致端口被占用。解决办法很简单关掉所有可能占用串口的窗口拔掉 USB 适配器重新插一次再刷新设备管理器看串口号有没有变化。注意如果程序里打开了串口但没有 close下一次运行同一个脚本时经常报“端口被占用”。养成在异常处理里finally: ser.close()的习惯不然你会被这种问题反复折磨。6.2 发指令没反应 / CRC 报错指令发出去石沉大海先从物理层排除用万用表量一下驱动器 A、B 端子的电压正常空闲时会有 2V 左右的差分电压。如果量出来是 0基本可以断定通讯链路没建立查接线和供电。如果响应回来了但是程序报 CRC 校验失败先别怀疑代码和线缆最可能的原因其实是“电噪声”——电机一启动RS485 线缆就像天线一样把干扰耦合进信号里。我当时遇到的就是这个电机静止时通信一切正常电机一跑起来就随机出现 CRC 错误。后来把 RS485 线从电机电源线旁边挪开、改成双绞线、加上终端电阻错误率立刻降到零。另外波特率不匹配也会导致收到的字节全是乱码CRC 当然对不上。如果程序打印出来的响应有01 03开头但后面字节毫无规律优先检查驱动器的波特率设置和串口参数。6.3 电机抖动 / 失步 / 定位偏差这个问题最容易让人血压升高。现象是指令发了电机动了但走几步就咔咔响位置也不对。我排查下来主要分两类原因。第一类加减速参数太激进。步进电机有个物理特性启动瞬间频率超过它的矩频特性极限就会丢步。解决办法是把驱动器的启动速度调低、加速时间调长。哪怕你追求效率也要让电机“助跑”而不是“弹射”这是步进电机的脾气。第二类脉冲当量算错了。定位误差如果是一个固定比例比如每次都差 5%那大概率不是电气问题而是换算系数的问题。检查一下驱动器是不是真的每 1000 脉冲转一圈、丝杆导程是不是真的 5 毫米、有没有减速比忘算了。用游标卡尺量一下实际位移反推脉冲当量比自己埋头猜靠谱得多。6.4 程序运行到一半掉线运动几次之后程序突然卡在等待响应上这多半是总线冲突或状态机错乱。常见的场景上一帧请求的响应还没收完程序就发了下一帧驱动器还停在半双工接收模式两帧数据在线上撞在一起谁也没收到。解决思路是“一收一发严格按序”。每次发送前清空输入缓存发送后等待足够的响应时间收到完整响应后再处理下一帧。如果你用了多线程或 GUI 事件循环还要加一个锁保证同一时刻只有一个线程在操作串口。串口这种东西天然就不适合并发写谁并发谁出事。6.5 安全保护建议最后说一点写代码之外的体会。控制工业设备安全永远比功能优先。我这套代码里加了几条硬性保护运动开始前必须检查使能状态每次定位指令发出前重读一次报警寄存器发现异常立即终止后续点脚本退出时无论什么原因都先把使能关掉让电机失去力矩避免意外转动。还有手动调试时手指永远不要放在运动部件的路径上这句看起来是废话但真的有人在电机启动瞬间下意识伸手去扶工件后果我不敢说太细。设备可以重做手不行。回到开头那个问题程序员第一次控制工业步进电机难吗说实话协议本身一点都不难难的是从“面向 json 的思维”切换到“面向字节和时序的思维”。只要你愿意先花一下午读懂一帧报文再用一个晚上把 CRC 校验和寄存器读写跑通剩下的就是耐心和细心了。这套代码我后来扩展成了双轴版本也谈不上什么大工程但每次看到电机精准停在坐标点上的那一刻还是会觉得这一趟很值。