☰
Python读取PLC数据实战:Modbus TCP全解析与踩坑总结
2026/10/19 2:15:29 网站建设 项目流程

做上位机开发这些年,被问得最多的问题之一就是“怎么用Python读PLC的数据”。问的人里有做产线的工程师,有做设备数据采集的乙方,也有刚入行的学生。大家真正想要的东西其实特别朴素:一台PLC,一根网线,一段能跑的代码,能把设备的状态、温度、转速这些数读回来。至于PLC是什么品牌,最好是别管。

Modbus TCP就是为这个场景准备的。它在工业圈里几乎是默认的“普通话”,无论是欧系、日系还是国产设备,基本都支持这个协议,或者通过一个网关模块就能支持。这篇文章我把自己实际调试设备用的那套Python方案完整写出来,包含能直接跑的示例代码,以及我在不同品牌PLC上踩过的地址坑、字节序坑和超时坑。

1. 为什么偏偏是 Modbus TCP:跨品牌通吃的底层逻辑

你可能会问,西门子有自己的S7协议,三菱有MC协议,欧姆龙有FINS,为什么还要用Modbus?因为Modbus TCP是工业以太网里的“世界语”,它够简单,简单到几乎所有设备厂商都愿意为它开一个口子。

1.1 从Modbus RTU到Modbus TCP:它到底改了什么

最早的Modbus出现在串行链路上,叫Modbus RTU,用RS485总线,一主多从,半双工。后来以太网普及,人们把Modbus报文直接封装进TCP/IP里,就成了Modbus TCP。它保留了RTU的核心:功能码、寄存器地址、数据长度这些概念一模一样,只是把CRC校验换成了TCP自带的校验,不再需要地址轮询,每个设备独占一个IP。

简单说,Modbus TCP就是“穿着TCP/IP外套的Modbus报文”。因为TCP天生是全双工、可靠连接,所以通信双方不再需要像RS485那样一帧一帧地排队等应答,同一台PLC可以同时被多个上位机读取,这在产线数据采集里非常实用。

1.2 请求-应答模型:你发一条指令,它会回答你一个结果

Modbus TCP的通信模型非常直白,就是客户端发请求、服务端回响应。数据模型分为四类:

  • 线圈(Coil):可读可写的开关量,比如电机的启停状态。
  • 离散输入(Discrete Input):只读的开关量,比如限位开关、急停信号。
  • 保持寄存器(Holding Register):可读可写的16位寄存器,绝大多数模拟量、设备参数都放在这里。
  • 输入寄存器(Input Register):只读的16位寄存器,通常存放传感器采集值。

功能码是理解Modbus的钥匙:

功能码作用对应数据模型
01读线圈线圈
02读离散输入离散输入
03读保持寄存器保持寄存器
04读输入寄存器输入寄存器
05写单线圈线圈
06写单寄存器保持寄存器
15写多线圈线圈
16写多寄存器保持寄存器

实际项目里用的最多的就是03功能码读保持寄存器,因为现在很多PLC的Modbus映射里,连输入信号都映射到了保持寄存器区域,读写一体,用起来最省事。

1.3 为什么它不限品牌:从专用协议到公开文档

早期各家PLC的以太网协议都是私有的,你想读西门子的数据必须用西门子的库,读三菱的数据又得换一套库,维护成本极高。后来随着设备联网的需求爆发,主流厂商纷纷在自己的PLC里增加了Modbus TCP服务端功能,或者用专门的通信网关把私有协议转换成Modbus TCP。

我在现场见过一台很老的进口设备,控制器本身死活不支持以太网,但设备旁边挂了一个网关盒子,把原本的RS485 Modbus RTU转换成Modbus TCP。上位机只需要连接网关的IP,就能读到背后那台老古董的数据。这就是Modbus TCP不限品牌的底气所在——无论是原生支持还是网关转换,最终暴露给你的都是一个标准的502端口。

2. Python库选型:从pymodbus到pyModbusTCP的取舍

Python操作Modbus TCP的库有好几个,我早期用的是 modbus_tk,后来发现它更新太慢,遇到一些新版的PLC固件,连接时会握手异常。现在主流的选择基本是两个:pymodbus和pyModbusTCP。

2.1 pymodbus:功能全、社区大,只是API变化有点快

pymodbus是我个人目前在正式项目里的首选。它支持Modbus TCP、RTU、ASCII,还有服务端模拟功能,你甚至可以用它在一台电脑上模拟一台假PLC,这对调试上位机程序非常有用。

需要注意,pymodbus在2.x和3.x版本之间API变化很大。2.x时代的写法是异步与同步混杂,到了3.x版本,官方把同步客户端统一成了ModbusTcpClient,位置也变了。

下面是我在实际项目里验证过的pymodbus连接代码:

from pymodbus.client import ModbusTcpClient client = ModbusTcpClient( host="192.168.0.10", port=502, timeout=3, ) connect_success = client.connect() if connect_success: print("连接成功") else: print("连接失败")

我强烈建议你固定pymodbus的版本,比如3.6.9,因为它的API还在演进,如果哪天升级到了4.x,我这段代码有可能跑不通。工业项目讲究“能用就别乱动”,依赖锁定是基本素养。

2.2 pyModbusTCP:上手最快,适合快速验证

如果你只是临时读几个寄存器,或者写个小工具验证点位表,pyModbusTCP会更轻量。它的客户端类就一个ModbusClient,方法名也直观:

from pyModbusTCP.client import ModbusClient c = ModbusClient(host="192.168.0.10", port=502, timeout=5) if c.open(): regs = c.read_holding_registers(0, 10) print(regs)

优点是没有那么多花哨的封装,调试时看代码一目了然。缺点也很明显:对现场总线(RTU)的支持弱,没有服务端模拟能力,扩展性不如pymodbus。

2.3 我的建议:平时用pymodbus,快速验证用pyModbusTCP

如果你的项目只是“读数据→存数据库→做报表”这种轻量级场景,两个库都能胜任。但如果你要做的东西比较复杂,比如同时采集几十台设备、需要读写多类型数据、还要做协议转换,我建议用pymodbus。后面示例代码我也以pymodbus为主线写,因为它在异常处理、批量读写的细节上做得更完备。

安装就一句话:

pip install pymodbus==3.6.9

提示:如果你的机器上已经装了别的大版本,建议用虚拟环境隔离,我吃过一次亏——系统里残留的旧版pymodbus导致新代码一直报“模块结构不同”的错误。

3. 示例程序完整拆解:从连接建立到批量读写寄存器

这部分是重点,我提供一个可以完整跑通的示例。我们假设现场PLC的IP是192.168.0.10,端口502,需要读取保持寄存器地址0到20的数据。

3.1 先确认PLC侧的Modbus TCP配置

Python代码写得再好,PLC侧没开Modbus服务也是白搭。不同品牌开启方式不同,但大方向一致:

  • 在PLC的通信设置里启用Modbus TCP服务,端口一般默认502。
  • 确认PLC的IP地址在同网段,且允许上位机访问。
  • 核对点位映射,确定你要读的地址对应PLC内部的哪个寄存器。

关于点位映射,我见过无数人在这里栽跟头:PLC内部地址和Modbus地址不是一回事。有的PLC手册上写“D100对应Modbus地址0”,这其实是厂家的映射关系,你只管查表确认就好,不要自己猜。

3.2 完整示例:连接、读线圈、读寄存器、写寄存器

下面是完整示例,我用一个类把常用操作封装起来,方便直接抄作业:

import logging import struct from pymodbus.client import ModbusTcpClient class PlcClient: def __init__(self, host, port=502, timeout=3): self.client = ModbusTcpClient( host=host, port=port, timeout=timeout ) self.connected = False def connect(self): self.connected = self.client.connect() return self.connected def close(self): self.client.close() self.connected = False def read_coils(self, address, count=1): """读取线圈状态(0x01)""" result = self.client.read_coils(address, count) if hasattr(result, "bits"): return result.bits[:count] return None def read_holding_registers(self, address, count=1): """读取保持寄存器(0x03)""" result = self.client.read_holding_registers(address, count) if hasattr(result, "registers"): return result.registers[:count] return None def read_input_registers(self, address, count=1): """读取输入寄存器(0x04)""" result = self.client.read_input_registers(address, count) if hasattr(result, "registers"): return result.registers[:count] return None def write_coil(self, address, value): """写单个线圈(0x05)""" self.client.write_coil(address, value) def write_register(self, address, value): """写单个寄存器(0x06)""" self.client.write_register(address, value) def write_registers(self, address, values): """写多个寄存器(0x10)""" self.client.write_registers(address, values) if __name__ == "__main__": logging.basicConfig(level=logging.INFO) plc = PlcClient("192.168.0.10") if not plc.connect(): print("无法连接PLC,请检查IP、端口和防火墙") exit(1) coils = plc.read_coils(100, 8) print("线圈状态:", coils) regs = plc.read_holding_registers(0, 20) print("寄存器0-19原始值:", regs) plc.write_register(30, 500) print("已写入寄存器30,值500") plc.close()

这段代码看起来简单,但有几个细节值得说。

3.3 返回值为什么是对象而不是列表:pymodbus的坑

pymodbus的读方法返回的不是纯列表,而是一个响应对象。响应对象包含registers、bits等属性,不同版本还有别的属性。我在2.x和3.x版本之间切换时踩过坑,因为2.x里字段的差异挺大,导致我的调用代码改了一处才好。

所以建议你拿到响应对象以后,不要直接拿列表去操作,而是先打印出来看看结构,再决定怎么取数。习惯写成:

resp = client.read_holding_registers(0, 10) if resp.isError(): print("读取失败") else: values = resp.registers

3.4 寄存器读出来是原始整数,不是真实物理量

很多初学者不理解,为什么读出来的寄存器值是 745,而不是 23.5℃。因为PLC寄存器里存的是原始整数,它的真实物理量要靠工程量换算。换算公式一般是:

真实值 = 原始值 × 缩放系数 + 偏移量

比如某温度变送器,4~20mA对应0~100℃,采集到的原始值是0~4000,那么真实温度的换算公式就是:

temp = raw / 40.0

这个缩放系数在PLC程序里叫“量程换算”,它是现场工程师非常关心的一环。做上位机采集时,建议把换算规则做成一个配置表,不要写死在代码里,不然每次设备参数调整都要改代码重发版本。

4. 跨品牌最大的坑:地址偏移、数据类型与字节序

这部分是我最想提醒你的。代码连接上很简单,但读出来的数不对才是最折磨人的。下面这些坑,我在不同品牌PLC上都撞过。

4.1 地址偏移:为什么读0却对应PLC内部的40001

Modbus协议里设定的数据模型地址是0起始的,但很多设备厂商习惯用1起始的“地址+1”表示。最典型的就是那些沿用Modicon习惯的寄存器编号:40001对应协议地址0,40002对应协议地址1,以此类推。

这就造成了一个问题:你的Python代码里写的地址到底是0还是1?如果PLC手册里写“保持寄存器地址从40001开始”,那么协议地址就是寄存器编号减1:

  • 40001 -> 协议地址 0
  • 40010 -> 协议地址 9

我见过一个同事,按照手册上的40001直接写进代码,结果读取的每一个地址都错了一位,数据当然全乱套。正确做法就是换算一次,或者你在读的时候统一减去1再传给函数。

4.2 不同品牌PLC的Modbus区域映射差异

再拿几个实际场景举例。某德系品牌的S系列PLC做Modbus TCP从站时,通常可以把I区、Q区、M区、DB块映射到Modbus数据模型里;某日系品牌Q系列则更直接,D寄存器区直接映射到保持寄存器,M软元件映射到线圈区。国产很多支持Modbus的仪表,则常把浮点数放在连续两个保持寄存器里。

这里没法展开每个品牌的具体映射表,因为不同固件版本、不同组态软件版本都会影响映射结果。我的建议是:

  • 查该PLC型号的官方手册中“Modbus映射”章节,以手册为准。
  • 用调试工具单地址逐个读,确认哪个协议地址能读到预期值。
  • 把确认结果做成点位表,标注协议地址、PLC内部地址、数据类型、缩放系数。

4.3 32位数据与字节序:这是100%会遇到的坑

16位寄存器只能表示0~65535,带符号的数据也就-32768~32767。但温度、压力、累计流量这些值动不动就超过范围,而且浮点数在PLC里很常见,所以PLC通常用两个连续的16位寄存器存放一个32位数据。

问题在于:高16位和低16位谁在前?字节内部的高字节和低字节谁在前?不同品牌甚至不同型号的风格都不一样。常见的有四种排列:

  • ABCD:大端模式,高16位在前,字节高位在前,最常规。
  • CDAB:字序颠倒,高16位在后,但字节内部正常。
  • BADC:字节序颠倒,字序正常。
  • DCBA:小端模式,全部翻转。

我调试某进口设备的累计流量时,读出的两个寄存器值分别是 0x4448 和 0x2000,按ABCD模式拼起来就是0x44482000,转成浮点是800.25。如果按CDAB拼就是0x204448,转出来是个完全不对的小数。所以不要盲目相信“网上说Modbus都是大端”,设备手册里写的字节序才是真理。

在Python里处理一个32位浮点数的标准写法:

def regs_to_float(regs, order="ABCD"): """将两个16位寄存器值转换为32位浮点数""" if len(regs) < 2: raise ValueError("需要至少两个寄存器") v = 0 if order == "ABCD": v = (regs[0] << 16) | regs[1] elif order == "CDAB": v = (regs[1] << 16) | regs[0] elif order == "BADC": v = ((regs[0] & 0xFF) << 24) | ((regs[0] >> 8) << 16) | ((regs[1] & 0xFF) << 8) | (regs[1] >> 8) elif order == "DCBA": v = (regs[1] << 16) | regs[0] v = ((v & 0xFF) << 24) | ((v & 0xFF00) << 8) | ((v >> 8) & 0xFF00) | ((v >> 24) & 0xFF) return struct.unpack(">f", v.to_bytes(4, "little"))[0]

这个函数我用了很久,建议你把设备手册里的字节序标到点位表里,拿到数据后按表处理,千万不要一个设备一个代码分支,维护起来你会疯掉。

4.4 有符号数和无符号数:负数读出来是65535

另一个常见问题是符号位。温度在零下时,有些PLC的寄存器存的是补码,也就是-1被表示成65535。如果用无符号数直接看,读到65535会让你误以为传感器坏了。

判断方法很简单:如果原始值接近65535或32768上下,大概率是有符号数。你可以在Python里做一次转换:

def to_signed16(value): """把无符号uint16转成有符号int16""" if value >= 32768: value -= 65536 return value

负数转完以后,再接上量程系数,才算得到真正的物理量。很多人读模拟量输入模块的数据发现“不对”,一半以上是栽在这个符号没处理上。

5. 从Demo到能上产:超时、重连、批量读取的实战改造

示例代码能跑通当然好,但工业现场不是实验室。我在现场吃过不少亏,把几个关键经验整理一下。

5.1 超时设置:3秒还是5秒?为什么不能太小

TCP连接超时太短,比如1秒,在现场网络稍有抖动时就会误报“PLC连接失败”。超时太长,比如30秒,一旦PLC掉线,你的程序会卡很久才反应过来。我个人习惯设3秒,极少数跨交换机跨地域的链路会设到5秒。

还要区分连接超时和读写超时。ModbusTcpClient里的timeout参数就是读写超时。连接TCP本身握手超时在系统层面,如果要更精细的控制,可以先用socket判断端口连通性,再建立Modbus连接。

5.2 断线重连与看门狗

工业现场PLC偶尔会重启,网线偶尔会被踢到。你的采集程序必须能在PLC恢复后自动重新建立连接,不能因为一次断线就彻底挂掉。

我常用的重连策略:

import time def ensure_connected(plc_client): if not plc_client.connected: plc_client.connect() if not plc_client.connected: print("等待PLC恢复,5秒后重试...") time.sleep(5) return False return True

这个函数在每一轮采集循环开始前调用。如果连接断了,它就尝试重新连接,重试间隔5秒,直到PLC恢复。不要在一个循环里每100毫秒就重连一次,那样会把PLC的通信模块累死。

5.3 批量读取:一包读200个寄存器,比循环读快几十倍

新手最容易犯的错是一个一个读寄存器,而Modbus最擅长的就是批量。一次读取的寄存器数量由PLC决定,通常能连续读100个甚至更多。比如你要读模拟量输入模块的64个通道,一条03功能码直接读64个寄存器,网络开销比你循环读64次小得多。

我的经验值:

  • 读取连续寄存器:每次读100~120个,幅值设成125个以下最常见。
  • 读取线圈:每次读500个以内。
  • 如果需要的数据分布在多个不连续区域,分段多次读,而不是一次读一大块再丢弃。

5.4 固定频率采集?不,按需采集

很多采集程序喜欢写死1秒采集一次。其实大部分产线场景,设备数据变化并不快,温度、液位都是缓变量。PLC的状态变化需要及时感知,但模拟量没必要1秒读一次。

我一般把数据点分成几类:

  • 快速变化的状态数据(启停、故障、报警):500ms~1s刷新。
  • 缓变模拟量(温度、液位、压力):2~5s刷新,或者变化超过阈值才上报。
  • 累计量(流量、电能):每分钟或每小时读取一次。

这种“按需调度”的采集策略,能显著降低PLC通信模块的负载,也减少网络报文量,现场调试时双方都轻松。

5.5 数据记录:先落数据库还是先落文件

上位机采集完数据,一般要入库。我个人建议先写本地缓存文件或直接写时序数据库,不要每一条直接远程写传统关系型数据库,因为现场网络到机房是跨网段,数据库连接一旦抖动,你的采集线程会被数据库拖死。

我当时处理的方式是:

import json import sqlite3 from datetime import datetime def write_point_to_sqlite(conn, point_name, value): ts = datetime.now().isoformat() conn.execute( "INSERT INTO sample_data(ts, point_name, value) VALUES(?, ?, ?)", (ts, point_name, value) ) conn.commit()

SQLite本地落库,然后由另一个线程定时把数据推到中心数据库,或者让我写的那套同步程序每天零点同步一次。这个方案在几万台设备数据采集的项目里也扛得住。

6. 排错手记:从连不上到读数乱跳的完整排查思路

写到最后,我把最常遇到的排错场景按完整的排查链路整理一遍。

6.1 连不上PLC:先别怀疑代码,按这个顺序查

如果connect()返回False,别急着改代码。先按下面的顺序一步步查:

ping 192.168.0.10

如果ping不通,说明网络链路就有问题,查网线、IP、子网、网关。能ping通说明物理链路通,再看端口:

nc -zv 192.168.0.10 502

如果端口不通,多半是PLC的Modbus TCP服务没启用,或者IP地址没配对。还可以看看防火墙有没有拦502端口——Windows防火墙默认是拦的,这是新人最容易忽略的地方。

排查顺序大概是:

  1. ping不通 -> 检查网线、IP配置、交换机。
  2. ping通但端口不通 -> 检查PLC的Modbus服务是否启用、端口是否被改。
  3. 端口通但连接返回False -> 检查ModbusTcpClient的参数是否设置正确,timeout是否太短。
  4. 连接成功但读数据超时 -> 检查PLC的通信负载,或你读的地址范围是否超出从站能力。

6.2 连接成功但读出来全是0,怎么回事

读出来全是0,一般不是协议问题,而是地址问题。你读的那个地址区域,PLC根本没映射数据。比如你把寄存器地址写成0,但PLC的D区映射是从100开始的,那你读到的当然全是0。

这时你可以尝试:

  • 用一个已知的有效值去读,把PLC里某个常数值映射到Modbus地址,然后测试读。
  • 依次扫描连续的地址,比如ReadHoldingRegisters(0, 200),看哪个断点出现非0值。
  • 用Modbus调试工具做点对点测试,确认工具和Python结果一致,排除代码问题。

6.3 数据有时对有时错,很大概率是网络延迟和并发冲突

如果同一个设备被多个客户端同时访问,有些PLC的Modbus服务端在请求并发时会异常,导致偶发超时或读取旧数据。我遇到过某个老设备的从站模块,同一时间只能处理一个连接,第二个连接虽然在TCP层能握手成功,但数据返回速度极慢。

解决办法就是统一采集入口,不要让多个上位机同时直接连PLC,而是由一台采集服务器统一读取,再分发给各个业务系统。这也是很多工厂做数据中台的原因——用一台稳定的采集站,把所有设备数据收拢,再对外提供统一接口。

6.4 数据乱跑的终极排查手段:抓包

到了这一步还没有头绪,就该上抓包工具了。Wireshark就能直接解析Modbus TCP协议,你可以看到每一帧的请求和响应的具体内容,包括你发出的功能码、起始地址、数量,以及响应里回来的一堆字节。

我常用的抓包排查方法:

tshark -i eth0 -f "tcp port 502" -Y "modbus"

抓包后重点看三件事:

  • 请求的地址和你预期是否一致。
  • 响应数据长度是否等于地址数量乘以寄存器字节数。
  • 应答的原始字节序列是否符合设备的字节序。

有一次我怎么都读不出正确温度,抓包后发现PLC回的数据是CDAB顺序,而我代码里按ABCD处理,修正字节序函数后就一切正常了。

7. 给新手的几条实在建议

最后分享几条我个人积累下来的经验,绝对都是踩坑换来的。

第一,拿到一台新PLC,先确认它的Modbus TCP从站功能是否默认开启。很多设备出厂时这个功能是关闭的,需要上位机组态软件里勾选,然后下载到PLC才生效。不是插上网线就能用的。

第二,第一次联调时,把PLC侧所有通信诊断信息打开。绝大多数PLC都有在线监控功能,能看到Modbus通信的报文数、错误数。如果PLC侧显示通信错误,而上位机却显示连接成功,那多半是你的请求格式不对,被PLC拒绝后被上位机当成无响应处理了。

第三,测试代码时,尽量用固定已知值。比如你在PLC里写死一个D100=12345的固定值,然后用Python去读,如果能读回12345,说明通信链路正常,否则就是地址映射或字节序有问题。可以排除法缩小范围。

第四,点位表是项目的生命线。我建议建一个CSV文件,包含:点位名称、PLC内部地址、Modbus协议地址、功能码、数据类型、字节序、缩放系数、单位、备注。Python程序读这个CSV去采集,而不是把地址写死在代码里。这样以后设备点位调整,只需要改CSV,不用改程序,别提多省心。

第五,不要让采集逻辑和业务逻辑混在一个文件里。我习惯把采集封装成一个独立的服务,只负责把数据读出来放到队列或者缓存里,然后由业务模块消费。这样调试的时候,你可以把采集部分单独跑,不给别人添麻烦。

Modbus TCP这个方向,入门容易,精通需要拿真实设备练手。如果你手边恰好有PLC,按我上面的代码把连接、读寄存器、批量读全部跑一遍,再把自己设备的点位表整理出来,你基本就能应付绝大多数数据采集项目了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询