做自动化项目这么多年我最大的感受就是控制器的品牌五花八门跨品牌的数据对接总是一件绕不开的麻烦事。以前做一个数据采集系统往往要针对每一家厂商的协议单独写一套驱动工程量大不说后期维护更是噩梦。直到我开始系统使用 Python 通过 ModbusTCP 连接 PLC才真正体会到什么叫“一套代码打天下”。这篇文章就是把我在实际项目里验证过的连接方式、示例程序、以及那些文档里不会写的踩坑经验一次性整理出来。不管你手上的 PLC 是什么品牌只要你需要走以太网通信ModbusTCP 基本都能成为你的通用方案。1. 为什么选 ModbusTCP 连 PLC选型逻辑与适用场景1.1 跨品牌连接的核心逻辑先纠正一个常见误区很多人一说到 PLC 通信第一反应是“我要用 XX 品牌专用的协议”。比如某欧系控制器有它自己的通信机制某日系小型机也有自己的总线协议。这些专用协议确实功能强大但问题在于它们是封闭的。如果你要对接的设备来自不同厂商或者你的上位机系统要同时采集多种控制器专用协议就会让你陷入无穷无尽的适配工作。Modbus 恰恰是那个“不挑品牌”的协议。它最早是在 1979 年提出的一种工业串行通信协议后来随着以太网普及又发展出了跑在 TCP/IP 上的 ModbusTCP 版本。因为它完全开放、实现简单、报文结构清晰几乎所有主流 PLC 厂商都会在自己的产品里预留 ModbusTCP 服务器或客户端功能哪怕是很多国产控制器和仪表也把 ModbusTCP 当作标配接口。用一句话概括我的选型逻辑如果设备支持 ModbusTCP我就优先用 ModbusTCP如果不支持我再考虑其他专用方案。这个原则让我在多个项目中少写了很多重复代码。1.2 ModbusTCP 和 ModbusRTU 别搞混很多新手会把 ModbusTCP 和 ModbusRTU 当成同一种东西其实它们最核心的区别在于传输层不一样。ModbusRTU 走的是串口RS232/RS485报文里带 CRC 校验波特率、数据位、停止位这些参数都要手动配对而 ModbusTCP 走的是以太网底层已经有 TCP 的可靠传输所以不再有 CRC 校验取而代之的是报文头里的报文长度和事务标识符。实际项目里该选哪种我的建议是只要现场具备以太网条件优先走 ModbusTCP。原因很简单串口通信距离受限、速度慢、还要处理地址冲突而 ModbusTCP 直接用网线或者工业交换机组网一台电脑可以同时访问几十台设备没有串口总线那种轮询负担。很多老设备只有 RS485 接口那可以加一个串口转以太网网关把 ModbusRTU 桥接成 ModbusTCP这样上位机侧依然只需要写 TCP 客户端逻辑。1.3 什么时候不该用 ModbusTCPModbusTCP 虽然好用但也不是银弹。我一般会在三类场景里主动避开它第一运动控制。比如伺服轴的实时位置同步、多轴插补这类场景对刷新周期和抖动要求极高ModbusTCP 的响应时间通常以几毫秒到几十毫秒计而且事务处理机制不是为实时运动控制设计的。第二安全相关逻辑。ModbusTCP 本身没有加密和认证机制报文在网络上明文传输如果有人故意伪造报文后果不堪设想。涉及安全联锁的场合要么走专用安全协议要么把安全逻辑完全放在 PLC 内部上位机只做只读监视。第三超高频率的数据交互。ModbusTCP 的单次事务开销比专用协议大如果你需要每 1 毫秒就采集一批数据它可能撑不住更合适的方案是让 PLC 主动往数据库或消息队列里推数据。2. 动手前的准备工作环境、依赖与网络参数2.1 Python 环境和依赖库选择Python 连 ModbusTCP 最常用的库是 pymodbus它已经有十多年的历史社区活跃支持 Python 3.8 以上的版本。安装就一行命令pip install pymodbus我建议使用 3.x 系列的新版本。早期 2.x 版本的 API 风格比较老网上很多教程还是 2.x 的写法但新版本里有些类名和参数已经调整了。比如 2.x 里的ModbusClient在 3.x 里已经明确定义为ModbusTcpClient如果你照抄老代码很可能会直接报错或者出现各种兼容性警告。另外提醒一句如果公司网络无法直接访问外网你需要用内部 PyPI 镜像安装。装完以后可以用下面的命令验证版本python -c import pymodbus; print(pymodbus.__version__)2.2 连接前需要确认的 PLC 侧参数在写任何 Python 代码之前先到 PLC 里把几个关键参数确认清楚否则后面必然来回折腾。第一个是 IP 地址。工业以太网里最常见的是 192.168.x.x 网段但不同品牌的 PLC 出厂默认 IP 可能不一样有 192.168.0.1 这类欧系风格也有 192.168.250.1 这类日系风格。拿到设备后先看铭牌或者说明书再通过设备的拨码开关、面板菜单或者调试软件修改成你规划好的网段。第二个是端口号。ModbusTCP 标准端口是 502绝大多数 PLC 默认也是 502。但有些上位机组态软件会占用 502或者有的 PLC 允许多个 Modbus 服务端口这时候你就要在 PLC 侧找到对应的端口配置。第三个是单元 ID。ModbusTCP 的报文里有一个单元 ID 字段它在串口时代叫从站地址范围是 1 到 247。很多 PLC 自己占一个 ID比如常见的 1 或者 255。这是典型的“一看就会、一查就废”参数你 IP 配得再对单元 ID 不对照样读不到数据。第四个是功能码和寄存器地址。你要读的数据是什么类型的线圈、离散输入、输入寄存器还是保持寄存器PLC 里的数据是哪个地址区这些直接决定 Python 代码里调用哪个函数、地址填多少。下面第 4 章会展开讲。2.3 先用最简单的网络工具做通断测试连接 PLC 之前我建议先忘掉 Python用命令行工具做一次基础测试ping 192.168.1.30如果 ping 不通先检查网线、交换机、电脑 IP 是否和 PLC 在同一网段。很多工厂现场的网络环境比较复杂电脑接了 WiFiPLC 在办公网里两个网段互不相通这种问题不是你写代码能解决的必须先把网络打通。如果 ping 通了还可以进一步诊断 ModbusTCP 服务是否开放。在 Windows 上可以装一个简单的端口测试工具或者使用 Python 的 socket 模拟一下 TCP 连接import socket s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(3) result s.connect_ex((192.168.1.30, 502)) if result 0: print(端口 502 开放) else: print(f端口 502 无法连接错误码 {result}) s.close()这一步能帮你区分“网络问题”和“Modbus 协议问题”。如果端口测试通过但后面 Python 读取还是报错那问题多半出在单元 ID、寄存器地址或者 PLC 的 Modbus 功能配置上。3. 完整示例程序从连接开始一步到位3.1 最简连接与断开模板先上一个最基础的模板帮助你确认 Python 到 PLC 之间的物理链路没有问题。from pymodbus.client import ModbusTcpClient PLC_IP 192.168.1.30 PLC_PORT 502 UNIT_ID 1 client ModbusTcpClient(PLC_IP, portPLC_PORT, timeout3) connected client.connect() if not connected: print(连接失败请检查网络和 PLC 服务状态) else: print(连接成功) # 在这里进行读写操作 client.close()这个模板逻辑很简单创建客户端、发起连接、如果成功就执行后续操作、最后关闭连接。timeout3的意思是如果 3 秒内没有收到服务端响应就认为超时。我一般习惯把超时设成 3 到 5 秒太短容易误报太长会让程序卡死的感觉特别明显。3.2 读取线圈和寄存器数据ModbusTCP 的读操作对应四种数据对象线圈、离散输入、输入寄存器、保持寄存器。pymodbus 分别提供了四个方法# 读取保持寄存器最常用 rr client.read_holding_registers(address0, count10, slaveUNIT_ID) # 读取输入寄存器 ir client.read_input_registers(address0, count10, slaveUNIT_ID) # 读取线圈 cr client.read_coils(address0, count10, slaveUNIT_ID) # 读取离散输入 dr client.read_discrete_inputs(address0, count10, slaveUNIT_ID)每个方法返回的结果对象里.registers保存寄存器值列表.bits保存位数据列表。以保持寄存器为例rr client.read_holding_registers(address0, count10, slaveUNIT_ID) if not rr.isError(): data rr.registers print(读取到的寄存器值, data) else: print(读取失败返回错误码, rr)要注意的是PLC 里的起始地址可能是 0 也可能是 1。很多 PLC 程序里把地址标为“40001”这是老式的 Modbus 地址表示法“40001”实际上对应协议层的地址 0。如果你按“40001”直接填进address40001那肯定读不到正确数据。通用做法是协议地址从 0 开始功能码决定数据对象类型。你和 PLC 侧程序员沟通时最好明确到“保持寄存器从 0 号地址开始读 10 个寄存器”这样才不会产生歧义。3.3 写入数据到 PLC写入操作主要分两种写单个保持寄存器、写多个保持寄存器。对应代码如下# 写单个保持寄存器 wr client.write_register(address100, value1234, slaveUNIT_ID) if not wr.isError(): print(写入单寄存器成功) # 写多个保持寄存器 values [100, 200, 300, 400] wr client.write_registers(address200, valuesvalues, slaveUNIT_ID) if not wr.isError(): print(批量写入成功)写线圈的方法名叫write_coil写多个线圈是write_coils。功能码上写单个保持寄存器对应功能码 06写多个对应功能码 16十进制。有时候你会遇到“单个写能成功批量写失败”的情况这往往是 PLC 侧没有启用对应的批量写功能或者批量写的寄存器地址范围越界了。写入操作有个容易忽略的点PLC 里的很多数据带数据类型不是简单存一个 16 位整数。比如一个温度值在 PLC 里可能定义成 32 位浮点数占了两个寄存器。如果你只写一个寄存器数据会不完整。后面第 4 章会专门讲数据类型和字节序。3.4 循环采集加断线重连实际项目里最常用的场景是周期性采集数据而不是连一次读一次。但是 PLC 和上位机的连接不可能永远稳定交换机重启、网线松动、PLC 固件更新都会让连接中断。如果程序不做重连处理过一晚上准会崩。我通常会封装一个带断线重连的采集循环import time from pymodbus.client import ModbusTcpClient client ModbusTcpClient(192.168.1.30, port502, timeout3) def read_temperature(client, unit): rr client.read_holding_registers(address100, count2, slaveunit) if rr.isError(): return None return rr.registers while True: if not client.isConnected(): print(连接已断开正在重新连接...) client.connect() time.sleep(1) continue try: data read_temperature(client, 1) if data is not None: print(温度寄存器原始值, data) except Exception as e: print(读写异常, e) time.sleep(2)这里用client.isConnected()判断连接状态读不到数据时主动重新连接避免程序长期挂死。我实际使用中的体会是重连不能太频繁最好在重连前加一个延时否则 PLC 还在恢复中的时候你拼命重连反而会把负载打到很高。3.5 把采集数据写入 CSV 文件的完整示例很多现场项目并不需要实时展示数据只要把数据记录下来供后续分析。这时可以组合 pymodbus 和标准库里的 csv 模块做一个简单的数据记录器import csv import time from datetime import datetime from pymodbus.client import ModbusTcpClient PLC_IP 192.168.1.30 UNIT_ID 1 CSV_FILE plc_data.csv client ModbusTcpClient(PLC_IP, port502, timeout3) if not client.connect(): print(连接失败退出) exit(1) with open(CSV_FILE, modea, newline, encodingutf-8) as f: writer csv.writer(f) if f.tell() 0: writer.writerow([时间, 设备状态, 运行温度]) try: while True: rr client.read_holding_registers(address0, count3, slaveUNIT_ID) if not rr.isError(): now datetime.now().strftime(%Y-%m-%d %H:%M:%S) writer.writerow([now, rr.registers[0], rr.registers[1]]) f.flush() time.sleep(5) except KeyboardInterrupt: print(采集结束) finally: client.close()这个脚本会在每 5 秒写入一行记录实测下来数据量可控即使跑几个月也不会把文件撑爆。如果你要采集的数据点很多建议加上按天分割文件的逻辑或者直接写进数据库。4. 跨品牌适配的关键不限品牌的底层原理4.1 四种数据对象和 PLC 地址的映射为什么 ModbusTCP 能做到“不限品牌”因为协议把数据定义成了非常朴素的四种对象数据对象位/寄存器方向常见用途线圈Coil位可读可写启停命令、阀门开关离散输入Discrete Input位只读传感器状态、按钮输入输入寄存器Input Register16位字只读模拟量采集值保持寄存器Holding Register16位字可读可写设定值、运行参数、报警值PLC 厂家需要做的只是把自己内部的地址映射到这四种对象上。比如某个电机电流在 PLC 程序里位于一个数据块内厂家提供的 Modbus 映射表会告诉大家这个数据对应保持寄存器地址 40012 或者 30008。对不同品牌来说地址映射规则不一样。有些厂商的 PLC 可以通过软件自由配置 Modbus 地址映射表你想把哪个变量挂到哪个地址上都可以有些则有一套默认映射你按手册去填区间即可。我做过的项目里最省事的是支持自由映射的控制器因为可以在 PLC 侧把要交换的数据集中放在一个连续的地址区间上位机一次读几十个寄存器就够了。4.2 功能码背后的协议约定ModbusTCP 中每个请求报文都会带一个功能码表示“我要做什么”。常用功能码如下功能码十进制含义对应 pymodbus 方法01读线圈read_coils02读离散输入read_discrete_inputs03读保持寄存器read_holding_registers04读输入寄存器read_input_registers05写单个线圈write_coil06写单个保持寄存器write_register15写多个线圈write_coils16写多个保持寄存器write_registers我提这个表的原因很现实大部分 PLC 都对全部功能码有响应但也有少部分设备只实现了部分功能码。比如某些传感器、电力仪表它们通常只支持 03 和 04 功能码根本不支持写操作。如果你在程序里对它们调用写寄存器就会收到“非法功能码”的错误。遇到这种情况不要怀疑 Python 代码写错了先去查设备支持哪些功能码再调整你的调用策略。4.3 字节序新手最容易翻车的区域Modbus 寄存器一个寄存器是 16 位也就是两个字节。但 PLC 里很多数据是 32 位浮点数、32 位整数甚至是 64 位累加值。这就意味着一个数据必须占用多个寄存器而多个寄存器在网络上传输时的先后顺序就是字节序问题。我当年第一次读一个 32 位浮点数的时候从 PLC 里读回来两个寄存器直接打印出来是一个很大很离谱的数字。后来查了很多资料才知道PLC 侧存放浮点数时可能把低位字放在前面的寄存器里也可能把高位字放在前面不同厂商的默认字节序完全不同。举个例子温度值 25.5 在 IEEE 754 单精度浮点格式下所对应的 4 个字节按大端顺序排列为0x41 0xCC 0x00 0x00。把它拆成两个 16 位寄存器大端字序是[0x41CC, 0x0000]小端字序是[0x0000, 0x41CC]。如果你在 Python 里用错了顺序解出来就是 2.566e-39 这种天文数字。下面这段代码可以把两个寄存器拼成 32 位浮点数并自动支持两种字序import struct def regs_to_float(regs, word_orderbig): if word_order big: # 大端字序第一个寄存器是高位字 raw struct.pack(HH, regs[0], regs[1]) else: # 小端字序第一个寄存器是低位字 raw struct.pack(HH, regs[1], regs[0]) return struct.unpack(f, raw)[0]这里的HH表示以网络字节序大端打包两个 16 位无符号整数f表示以大端解析 32 位浮点数。实际项目中如果没有把握我一般会在 PLC 侧写入一个已知的浮点数再读回来用两种字序算一遍哪个值对就用哪个。这个方法简单粗暴但确实有效。4.4 单元 ID 在不同设备上的表现单元 ID 在 ModbusTCP 里是一个历史遗留字段。在 ModbusRTU 时代一条串行总线上挂多台设备每台设备一个从站地址所以必须有单元区分。到了 ModbusTCP每台设备都有自己的 IP 地址了理论上不再需要单元 ID但协议为了兼容仍然保留了这个字段。不同设备对单元 ID 的处理方式很不一样有的 PLC 强制要求单元 ID 必须等于 1有的设备要求填 255还有的设备根本不校验这个字段填什么都行。更特殊的情况是一个网关下面挂了很多串口从站这时单元 ID 就成了区分从站的唯一依据。所以当通信失败时不要只盯着 IP把单元 ID 换成几个常见值试试常常能峰回路转。5. 常见问题与排查实录实测踩坑记录5.1 连接超时连接被拒绝这是最基础也是最常见的问题。可能的原因有三个IP 不对、端口不对、PLC 侧根本没有启用 ModbusTCP 服务。排查流程我建议从底层往上走先ping通不通再测 502 端口再看 PLC 设置里有没有把 ModbusTCP 服务使能最后看是不是网关或防火墙阻止了访问。有的 PLC 默认只允许本机访问需要在调试软件里把“作为从站”“允许远程访问”之类的选项打开。5.2 连接成功但读写操作超时这种问题最让人烦躁因为网络层没问题但协议层就是不通。我遇到过几种典型原因。一是单元 ID 不对。设备返回异常响应结果对象里有一个 IllegalFunction 或者 GatewayTargetDeviceFailed 之类的错误。二是 PLC 的寄存器地址越界。你请求读取 100 个寄存器但设备从某个地址开始根本没有那么多数据设备就会返回异常。解决方法是缩小读写范围或者从地址 0 开始逐段尝试。三是上位机同时打开了多个连接。部分 PLC 对 ModbusTCP 的并发连接数有限制比如同一时刻只允许 2 个连接。如果你开了多个上位机软件或者程序里有多个客户端实例后创建的连接可能连不上或者不稳定。5.3 读回来的数值完全不对、大得离谱数值不对90% 是数据类型和字节序问题。前面讲过的浮点数解析就是一例。还有一种常见情况是把 32 位无符号整数当成 16 位来读读出来的值只有一半或者把两个连续的 16 位寄存器当成一个 32 位值结果两个本来无关的变量被拼到了一起。我的做法是在程序里先建立一个数据点定义表每个点都写明“寄存器起始地址、数据类型、字节序、小数倍率”。比如温度数据点的定义是“保持寄存器 100-101float32大端字序无倍率”。这样代码写起来清晰排查起来也有据可依。5.4 写入操作失败写入失败的原因通常比读取更隐蔽。第一个可能PLC 侧把数据区设成了只读。有些控制器在出厂时默认某些地址区间只读必须连到调试软件里改成读写。第二个可能PLC 的钥匙开关处于编程锁定状态。很多控制器前面板上有一个钥匙或者拨码切到运行模式后外部写入功能会被禁止。把钥匙拨到“远程”或“运行”状态可能就好了。第三个可能数据类型不匹配。你写入了单个寄存器但 PLC 程序定义的是 32 位数据结果就是写入不生效或者数据变成乱码。5.5 PLC 返回异常错误码速查表Modbus 协议里定义了一组标准异常码。pymodbus 的响应对象如果isError()为 True可以直接输出它的异常信息。我整理几个高频错误码异常码含义常见原因1非法功能码设备不支持该操作2非法数据地址地址越界或不存在3非法数据值写入值超出范围4从站设备故障PLC 内程序逻辑错误或通信中断11网关目标设备响应失败网关后挂载的串口设备无响应看到异常码后我通常先在 PLC 调试软件里手动操作一遍同类型读写确认 PLC 侧本身没问题再回来调整 Python 程序。这个方法能省很多排查时间。5.6 并发读写时的线程安全项目大了以后你可能会在多线程里同时读写同一个 PLC。pymodbus 的客户端对象不是完全线程安全的同一个连接同时发起多个事务可能出现响应错乱。我的建议有两个要么每个线程创建独立的客户端连接要么用一个全局锁把读写操作串行化。工业上位机通常不会要求超高并发串行化带来的性能影响可以接受但能避免很多隐性问题。6. 从示例到稳定上线的最后几步我的个人经验看到这里相信你已经能用 Python 通过 ModbusTCP 连接自己的 PLC 了。但说实话写通一个示例程序只是开始真正上线运行之后才会碰到前面那些七七八八的坑。我个人在产品化阶段还会做几件额外的事。第一把通信逻辑封装成独立的模块。不要把 pymodbus 直接散落在业务代码里我会封装一个PlcClient类里面包含连接、重连、读取数据点、写入数据点这些方法数据点配置放在一个字典或 YAML 文件里。这样换一台设备、改一个地址只需要改配置不用动代码。第二给关键操作加日志。不只是打印控制台建议用 Python 的 logging 模块写日志文件记录每次连接、断开、读写失败和异常码。工业现场不是什么时候都有工程师盯着屏幕出问题之后翻日志比现场抓包方便得多。第三重视通信安全。ModbusTCP 没有内置加密如果你的设备暴露在办公网甚至外网任何懂得搞怀的人都可以直接读写寄存器。我的底线是上位机和 PLC 之间单独划 VLAN不让 PLC 直接暴露到不可信网络如果有跨网络需求中间一定要加网关或者防火墙规则。第四不要让 ModbusTCP 承担它不适合的任务。我见过有人想在 ModbusTCP 上做紧急停机按钮这是绝对不行的。紧急逻辑必须放在 PLC 内部或者硬接线回路里上位机通信只做监视和普通级控制这是安全底线。最后说一个我自己的体验刚开始接触 ModbusTCP 时我也被那些地址映射、字节序、功能码弄得晕头转向。后来通过一次次试验、抓包、比对异常码才慢慢建立起一套稳定的调试方法。只要你能把最基本的连接测试跑通再学会用异常码和日志定位问题后面的路就会越走越顺。希望这篇文章能让你少走我走过的弯路。