
最近在帮朋友工厂做一条产线的数据采集PLC 是三菱 Q 系列上位机用 Python。需求听着不复杂把几十台设备的关键数据定时读上来再把配方参数写下去。可真跑到现场才发现程序能通是能通但慢得离谱——100 个数据点轮询一遍要 400 多毫秒数据量稍微上去一点就直接赶上半条 PLC 扫描周期这哪行。后来把所有读写全改成批量读写同样一批数据压到了 20 毫秒上下整个采集程序从“勉强能跑”变成了“随便霍霍”。这篇文章就把 Python 和三菱 MC 协议通信这件事从协议结构、选型、批量读写原理到完整代码一次说清楚重点讲怎么把性能压下来以及现场调试时那些文档里不会写的事。内容不挑人刚接触 PLC 通信的新手能跟着做有经验的老手也可以直接跳到第三节看优化部分的代码。1. 三菱 MC 协议通信先搞清楚在跟谁说话1.1 MC 协议的基础帧格式与软元件模型MC 协议是三菱 PLC 内置的一种应用层通信协议全称 Melsec Communication Protocol。走以太网时最常用的是 QnA 兼容 3E 帧这也是 Python 社区里最主流、资料最全的一种。3E 帧又分 ASCII 模式和二进制模式ASCII 模式用十六进制字符表示每个字节人能看懂但报文膨胀严重二进制模式直接发原始字节报文短、解析快实际工控场景里我基本只用二进制模式。报文结构其实不复杂主要就是“请求头 命令 批数据 设备编号”。请求头里有一堆看起来吓人的字段比如网络号、PC 号、IO 号、站号这些在单机直连场景下基本都是固定值。真正要关注的是后面这几项命令字读是 0x01 开头写是 0x14 开头后面跟子命令。软元件代码D 寄存器、M 线圈、X 输入、Y 输出各有独立编码。起始地址和数据个数从哪个软元件开始读连续读多少个。软元件模型是三菱特有的概念。D 是 16 位字寄存器M 是位软元件X/Y 是输入输出映像。做批量读写时务必要明白一件事字软元件和位软元件的地址编码算法完全不一样不能混在一个请求里算偏移。这是我见过新手翻车最多的地方后面踩坑部分会细说。1.2 Python 侧选型pymcprotocol 还是自己拼 socketPython 做三菱通信路线基本就两条用第三方库或者直接用 socket 手搓协议帧。第三方库首推pymcprotocol它把 3E 帧的封包、解包、重连、超时全部处理好了接口也非常接近“人话”pip install pymcprotocol用法大致是这样from pymcprotocol import Type3E plc Type3E(plc_typeQnA) plc.connect(192.168.0.10, 5007) # 读 D0 到 D19 连续 20 个字 word_values, bit_values plc.batch_read( word_devices[D0, D1, D2], bit_devices[] )自己拼 socket 则是另一条路。好处是能精确控制每一帧可以针对特殊场景定制坏处是对协议细节的把握要求太高。三菱的地址映射、字节序、帧长计算只要错一位响应帧根本对不上。我个人的建议是能跑标准协议就用 pymcprotocol别重复造轮子。手搓协议适合做极端性能优化或对接非标设备普通产线数据采集用库完全够而且更快。如果选 pymcprotocol务必要注意plc_type这个参数。QnA 兼容 3E 帧对应QnAiQ-R 系列对应iQ-RFX5U 等新型号也有自己的配置。选错了连接都建立不起来报错全是乱码一样的地址异常。2. 批量读写优化为什么一次读 500 个点比读 250 次快 20 倍2.1 单点轮询的性能陷阱很多刚开始做上位机的人会不自觉地写出这样的循环# 反例千万别这样写 for i in range(200): value, _ plc.batch_read(word_devices[fD{i}], bit_devices[]) data_list.append(value)这段代码从功能上讲完全没问题甚至非常“Pythonic”但它有一个致命问题每一次 batch_read 都是一次完整的 TCP 请求-响应往返。哪怕你只读一个 D 寄存器也要走一遍“组帧→发送→PLC 响应→拆帧”全流程。一次 TCP 往返在局域网里有多慢正常现场走工业以太网RTT 大约在 1~5 毫秒之间受交换机和 PLC 型号影响。加上 PLC 通信处理器的处理时间、Python 层组帧拆帧的时间单点读一次实测通常在 4~8 毫秒。看着不多对吧但循环 200 次就是 800~1600 毫秒数据量一大直接爆炸。这还没算 Python 的 GIL 和垃圾回收带来的抖动。现场测试的时候更明显上位机界面一刷新就卡PLC 那边通信负载也高整个扫描周期都受影响。所以批量读写的核心优化思路就一句话减少往返次数一次请求尽可能多带数据。把 200 个设备点打包到一个请求帧里让 PLC 一次处理完返回一个大的数据区。对 PLC 来说这只是多复制几百个字节对 Python 来说只是一次网络开销。2.2 批量读、批量写、随机读怎么选pymcprotocol 里提供的读写方法有三种很多人搞不清区别现场选错导致性能反而不如单点batch_read / batch_write针对连续地址的批量操作。比如 D0 到 D99M0 到 M31。这个效率最高请求帧里只需要“起始地址 个数”响应帧直接按顺序平铺数据。random_read / random_write针对分散地址的批量操作比如同时读 D0、D10、D100 和 M5。它会把所有目标地址塞进同一个请求帧PLC 逐个地址处理。效率比单点高得多但报文比连续批量大。单点读写只能作为最后的手段或者确实只需要操作一个寄存器时用。选型经验很简单能连续就 batch不能连续就 random千万别把 batch 当万能药。我有一次为了省事把 50 个不连续的点位用 batch_read 硬拼成连续地址读结果读回来一堆无关数据还得再做映射清洗代码比 random_read 难看好几倍性能也没占到便宜。PLC 侧对批量帧的长度是有限制的。以 Q 系列为例一次批量读字软元件的数量上限通常在 960 个左右超过上限得自己拆帧。这个数字具体看型号和协议版本保险起见我会在程序里做一层分片逻辑超过 500 个地址就拆成多帧发送。别指望一次读完几千个点协议不允许就算允许PLC 扫描周期也会被你这一个超长帧拖一下。批量写的报文结构和读类似只是命令字段不同数据区变成“值序列”。需要注意批量写对地址连续性和数据类型一致性要求很高传参时如果用字典顺序无所谓库会按地址排如果用列表顺序必须和设备列表严格一一对应。3. 实操pymcprotocol 批量读写完整代码与优化配置3.1 最小可跑通的连接与软元件地址生成先给一个最精简的连接示例确保你能和 PLC 建立通信。端口默认 5007这是三菱以太网模块的常用 TCP 端口GX Works2 自带的仿真器也用这个端口。import time from pymcprotocol import Type3E plc Type3E(plc_typeQnA) plc.connect(192.168.0.10, 5007, timeout2.0) print(连接成功)写代码时我习惯把设备地址列表用推导式生成这样批量操作时既不用手写几百个点也不会手滑漏地址# 读 D0 到 D199共 200 个连续字 word_devs [fD{i} for i in range(200)] # 读 M0 到 M63共 64 个位 bit_devs [fM{i} for i in range(64)]有人会问PLC 里明明配了变量名比如“电机电流”对应 D100为什么代码里还得自己维护 D 地址列表简单说MC 协议是“裸地址”协议它不认识你的梯形图变量名。所以现实项目里地址映射表通常用配置文件管理# 地址映射表示例 channels: temperature_1: D0 temperature_2: D2 pressure_1: D10 valve_status: M0Python 启动时读取动态生成地址列表这样设备主任改地址你只改配置文件不用改代码。3.2 批量读写的标准姿势这是本文最核心的代码段。批量读连续 D 寄存器start time.perf_counter() word_devs [fD{i} for i in range(500)] word_values, bit_values plc.batch_read( word_devicesword_devs, bit_devices[] ) print(fD0{word_values[0]}, D499{word_values[-1]}) print(f耗时: {(time.perf_counter() - start) * 1000:.1f} ms)关键点在这一行的返回值pymcprotocol 的 batch_read 返回两个列表第一个对应 word_devices 的值第二个对应 bit_devices 的值。即使不读位设备第二个列表也会返回只读单词设备时记得用word_values, _去接。批量写连续寄存器start time.perf_counter() word_write_data {fD{i}: i * 10 for i in range(100)} bit_write_data {fM{i}: (i % 2 0) for i in range(32)} plc.batch_write( word_devicesword_write_data, bit_devicesbit_write_data ) print(f耗时: {(time.perf_counter() - start) * 1000:.1f} ms)注意 batch_write 的参数是字典键是设备名值是要写入的数据。相对于 batch_read 的值列表字典的好处是可以乱序传参库内部会排序。但文件作者还是建议你保持地址有序既直观又能减少库内部的排序开销虽然这个开销微乎其微但程序可读性会好很多。对于分散地址用 random_readword_vals, bit_vals plc.random_read( word_devices[D0, D10, D100, D208], bit_devices[M0, M5, M10] )随机写的参数结构和 batch_write 一样是字典这里就不再重复。它和 batch 最大的差异在报文内部结构batch 是“基地址 连续个数”random 是“地址列表 点数 数据列表”。现场调试时抓包看最直观多抓几次你就能从报文一眼看出库帮你做了什么。3.3 超时、连接保持与线程安全配置连接超时默认值有些版本是 10 秒这个值对产线程序来说太长了。如果 PLC 断电或网线松动Python 线程会死等响应 10 秒期间的采集任务全部积压恢复后瞬间形成雪崩。我通常在 connect 时显式指定 timeoutplc.connect(192.168.0.10, 5007, timeout2.0)设置 2 秒是一个折中正常响应不会超过 100 毫秒2 秒足够排除普通异常又不会让故障线程存活太久。关于连接保持pymcprotocol 默认每个请求都复用同一个 socket 连接这很好因为频繁建立 TCP 连接本身有三次握手开销。但要注意它的 socket 不是线程安全的。如果多个线程共用同一个 Type3E 实例并发读写必须加锁import threading plc_lock threading.Lock() def safe_read(devices): with plc_lock: return plc.batch_read(word_devicesdevices, bit_devices[])否则在高频读写时会出现响应错乱读到的值编号和地址对不上。这种 bug 非常隐蔽因为错误不是每次都出现而且表现很像数据抖动。我在一个多线程采集项目里踩过排查了半天最后发现是两个线程同时调用了同一个 socket。如果是多线程高频独立采集我更建议直接让每个线程持有一个独立连接。三菱 PLC 以太网模块支持多个并发 TCP 连接通常 8~16 个没问题。这样能彻底躲开锁的争用整个程序吞吐量更高。缺点是多占几个 socket但对于现代操作系统这根本不算事。4. 现场调试的踩坑记录与排查方法4.1 常见报错和对应的真实原因报错场景真实原因处理方式connect 超时IP 地址写错、PLC 以太网模块未启用、跨网段先用 ping 测试通断再检查 PLC 参数用的 IP 是否和当前网卡同网段响应帧解析失败plc_type 选错用了 QnA 去连 iQ-R核对 PLC 具体型号和协议版本地址不存在异常软元件地址超过 PLC 实际范围或类型混用把 M 地址填到 word_devices 里就会报这个错随机读结果顺序错乱没搞清 random_read 的返回顺序是按请求列表还是按地址排序抓包确认一般按请求地址顺序返回批量读 960 个 D 寄存器报错超了协议单帧上限每 500 个一分片分批读连接超时是现场最常见的。很多电气工程师会把 PLC 的 IP 配成 192.168.1.1而办公网是 192.168.0.x两条网线同时插在同一台电脑上路由表一乱Python 连的就是另一张网卡。排查时先ping 192.168.1.1通了再谈协议。别一上来就怀疑库有问题。响应帧解析失败我遇到过两次都是因为用了 Q系列 的库配置去连 L 系列。L 系列虽然也支持 3E 帧但软元件代码有微妙差异。所以选型第一步永远是确认 PLC 完整型号而不是看外观一样就开搞。4.2 大批量读数据的分片策略与字节序陷阱分片策略是批量优化绕不开的话题。上面说了单帧读字上限大约 960 个我的经验是按 500 个一帧切既合理又能留出余量。分片后的代码建议用生成器避免内存里堆一堆中间列表def batched(seq, size): for i in range(0, len(seq), size): yield seq[i : i size] word_devs [fD{i} for i in range(2000)] all_values [] for chunk in batched(word_devs, 500): word_values, _ plc.batch_read(word_deviceschunk, bit_devices[]) all_values.extend(word_values)字节序问题主要发生在自己拼 socket 帧的场景。三菱 3E 帧二进制模式里16 位值在数据区默认是小端存储但地址字段、命令字段又要按大端理解。pymcprotocol 帮你处理了所以用库的人一般感觉不到。可一旦你将来要写一个对性能要求极高的自定义协议栈请务必用struct.unpack(H, data)这种方式解析数据区不要用int.from_bytes默认的大端去读。4.3 实测性能对比与调优复盘我拿现场一台 Q03UDV 和一台 GX Works2 仿真器分别测了一组数据环境是普通千兆局域网Python 3.10pymcprotocol 0.9.x操作方式数据点单次耗时200 次总耗时单点 batch_read 循环100 个 D约 4~7 ms/次900 ms 左右random_read 分散读100 个 D分散约 12~20 ms/次10~20 秒batch_read 连续读100 个 D约 8~15 ms/次20~40 msbatch_read 连续读500 个 D约 15~25 ms/次40~60 msbatch_write 连续写100 个 D约 8~18 ms/次20~40 ms罗列关键结论单点循环到连续批量优化幅度稳定在 20~40 倍random_read 虽然性能不如 batch但比单点循环仍然快 3~5 倍所以点位分散时优先 random 准没错500 点批量读相对 100 点批量读耗时只增加了一倍多说明协议处理时间远小于网络往返时间这是能持续优化下去的根本原因。仿真器和真实 PLC 的差异也值得说GX Simulator 跑在 PC 上响应速度快容易让人高估现场性能。我实测同一段代码仿真器 50 个点批量读 3 毫秒真实 PLC 要 12 毫秒左右。所以做节拍评估时一定要用现场真实 PLC 测试别拿仿真数据直接拍板。我还试过把 TCP_NODELAY 打开希望降低小帧的 Nagle 算法延迟。pymcprotocol 里没有暴露这个 socket 选项需要自己拿到 socket 对象设置。实测效果感知不强因为现场局域网 RTT 本来就小瓶颈更多在 PLC 侧处理。如果你将来自己实现协议栈倒是可以打开它对短请求有一定帮助。最后再分享一个实际经验我个人做这个项目的体会是优化把握一个原则就够了把“一次一个”改成“一次一堆”让协议栈和网卡都吃饱而不是频繁地小口吃。批量读写的收益来源不是代码魔法只是把原本几十次无用往返压缩成了寥寥几次。程序里加不加锁、地址生成器写得好不好都是次要的先把往返次数降下来性能问题就解决了一大半。还有一个小技巧产线数据采集任务通常带周期比如每隔 100 毫秒采一轮。不要用while True time.sleep(0.1)来控制节奏因为batch_read自身的耗时是不稳定的会造成周期偏移。正确的做法是用固定节拍器每轮循环以启动时间点为基准计算下一次执行时刻interval 0.1 next_tick time.perf_counter() while True: next_tick interval # 执行批量读写任务 time.sleep(max(0, next_tick - time.perf_counter()))这套节奏控制配合批量读写我后来在三条产线上跑了半年多没出过采集周期漂移的问题。希望这些经验能让你在调试三菱通信时少走点弯路。