前言*
Modbus协议是应用于电子控制器上的一种通用语言,通过此协议,可以实现控制器相互之间或通过网络实现通信。Python因其语法简单、易于学习和使用的特点,逐渐被更多人应用于电子编程。本文将通过两个部分详细讲述如何使用Python进行Modbus通信。第一部分是了解Modbus协议基本通信规则;第二部分则是使用Python进行演示。如果读者了解Modbus协议,可直接从第二部分开始阅读。本文上下联系紧凑,建议慢慢阅读。
1. Modbus协议基本通信规则
1.1 主从模式
Modbus通信协议通常以主从(Master-Slave)模式进行部署,并且遵循一主多从。主站(Master)负责发起请求,而从站(Slave)则负责响应这些请求。换句话说就是,主站是一台发送指令的计算机,而从站则是各种数据反馈设备,如传感器、执行器等。
1.2 传输方式*
Modbus协议支持三种主要的传输方式:Modbus-RTU、Modbus-ASCII和Modbus-TCP。
- Modbus-RTU:这是使用最广泛的Modbus协议传输模式,它基于串口协议进行数据编码,采用CRC-16校验算法。
- Modbus-ASCII:它基于串口协议进行数据编码,采用LRC校验算法。它所有数据都以ASCII格式表示,一个字节的原始数据需要两个字符来表示,因此效率较低。
- Modbus-TCP:基于TCP/IP协议,占用502端口,能够应用于以太网环境。TCP协议是一个面向连接的可靠协议,其数据包中已经存在校验,所以为了避免重复使用校验机制,在Modbus TCP报文中不再需要进行数据校验。
1.3 存储区*
就如我们平常使用的电脑会将数据保存到硬盘一样,从站也会将数据保存到存储区中。Modbus协议规定了4个存储区用于满足用户对数据的读写需求。
区号 | 名称 | 读写状态 | 存储类型 | 与PLC类比 | 地址范围(6位) | 应用举例 |
---|---|---|---|---|---|---|
0 | 线圈状态(Coil Status) | 读写 | bool | DO(数字量输出) | 000001-065536 | 电磁阀输出、LED显示 |
1 | 离散输入状态(Discrete Input Status) | 只读 | bool | DI(数字量输入) | 100001-165536 | 拨码开关、接近开关 |
3 | 输入寄存器(Input Register) | 只读 | int16 | AI(模拟量输入) | 300001-365536 | 模拟量输入 |
4 | 保持寄存器(Holding Register) | 读写 | int16 | AO(模拟量输出) | 400001-465536 | 变量阀输出大小 |
1.4 功能码*
功能码用于标明一个Modbus信息帧的用途。当主站向从站发送信息时,功能码将告诉从站需要执行哪些行为。以下是常用功能码。
功能码 | 说明 |
---|---|
0x01 | 读取输出线圈 |
0x02 | 读取离散输入 |
0x03 | 读取保持寄存器 |
0x04 | 读取输入寄存器 |
0x05 | 写入单线圈 |
0x06 | 写入单寄存器 |
0x0F | 写入多线圈 |
0x10 | 写入多寄存器 |
1.5 报文格式
Modbus报文是Modbus协议中的基本通信单位。Modbus报文包含一个头部和数据部分。头部包含了从站地址、功能码和数据长度等信息,数据部分包含了请求或响应数据。只有主站发送的报文正确,从站才能顺利读写所需数据,是Modbus通信的关键。三种传输方式的报文格式有所差异,后续将使用Modbus TCP演示,故下面提供Modbus TCP报文格式。
事务标识符 | 协议标识符 | 长度字段 | 单元标识符 | 功能码 | 数据 |
---|---|---|---|---|---|
用于标识请求和响应的对应关系,客户端发起的每个请求都会分配一个唯一的事务标识符,服务器在响应时会使用相同的标识符。 | 用于识别上层协议,固定为0x0000 | 表示接下来的单元标识符、功能码和数据的总长度,单位为字节 | 用于在连接到Modbus网关时,识别远程服务器上的从站 | 用于指定主机要求从机进行的操作 | 数据部分的长度可变,包含了命令的具体参数,确切格式和长度取决于功能码 |
2. Python实现Modbus通信
2.1 环境配置
一段时间测试下来,Python有两个比较好用的Modbus依赖库,分别是pymodbus和modbus_tk。本文后续将会使用pymodbus进行讲解。
名称 | 版本 |
---|---|
python | 3.9 |
pymodbus (可选) | 3.4.1 |
2.2 初体验
2.2.1 搭建从站
# slave.py
from pymodbus.server import StartTcpServer
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext
# Modbus TCP服务器采用 IP地址和端口号
ip = "localhost"
port = 502
# 创建一个modbus数据模型,内部设立4个存储区(存储区详情见1.3)
# 默认每个存储区初始值 = [0] * 65536
store = ModbusSlaveContext()
# 初始化modbus服务器上下文的新实例,将存储区与modbus服务器关联
context = ModbusServerContext(slaves=store, single=True)
# 启动ModBusTCP服务器
StartTcpServer(context=context, address=(ip, port))
2.2.2 搭建主站
# master.py
from pymodbus.client import ModbusTcpClient
# 与从站建立连接(参数除ip外,均有默认值,请按需求填写其他参数)
mtc = ModbusTcpClient("localhost")
# 连接失败抛出错误
assert mtc.connect(), ConnectionError("连接失败!")
# pymodbus已将功能码封装进了对应的函数当中,语义化的函数名使操作更加直观
# 通过调用函数向从站发送对应的功能码,并获取返回结果
# 参数slave一般每次通信之后将被要求加1以区别不同的通信数据报文(事务标识符,按需填写)
code0x01 = mtc.read_coils(address=0, count=5, slave=0) # 功能码:0×01(读取输出线圈)
code0x02 = mtc.read_discrete_inputs(address=0, count=5, slave=1) # 功能码:0×02(读取离散输入)
code0x03 = mtc.read_holding_registers(address=0, count=5, slave=2) # 功能码:0×03(读取保持寄存器)
code0x04 = mtc.read_input_registers(address=0, count=5, slave=3) # 功能码:0×04(读取输入寄存器)
# 打印返回数据
print(code0x01.bits) # [False, False, False, False, False, False, False, False]
print(code0x02.bits) # [False, False, False, False, False, False, False, False]
print(code0x03.registers) # [0, 0, 0, 0, 0]
print(code0x04.registers) # [0, 0, 0, 0, 0]
先运行slave.py开启从站服务,再运行master.py,只要全程无报错,就可以看到从站返回的数据,这里读取到的值都是初始值0。另外,细心的读者可能已经发现,在以上读取操作中,都是从地址0开始读取5个值,但是为什么读取输出线圈和离散输入返回了8个值,读取保存寄存器和输入寄存器返回了5个值呢?这里先卖个关子,我们在后面再讲。
2.2.3 从站初始值
方法一 :在创建存储区时,可以通过输入ModbusSlaveContext参数来设置从站存储区的初始值。但有个问题是,主站请求的数据是地址0开始的5位数值,而得到的结果却都向左偏移了1个值。这是因为Modbus地址是从0开始的,而PLC地址是习惯从1开始的,所以pymodbus根据规范,对地址0到n的请求将映射到数据存储地址1到n+1。想要解决这个问题,可以在设置初始值时在列表头部添加1个值占位或者修改参数zero_mode=True。
# 方法一
# slave.py
from pymodbus.server import StartTcpServer, ServerAsyncStop
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext, ModbusSequentialDataBlock
# Modbus TCP服务器采用 IP地址和端口号
ip = "localhost"
port = 502
# 创建一个modbus数据模型,内部设立4个存储区(存储区详情见1.3)
# 自定义初始值
store = ModbusSlaveContext(
# 线圈状态 [0, 1, 0, 1, 0, 1, 0, 1, 0, 1]
co=ModbusSequentialDataBlock(address=0, values=[i % 2 for i in range(10)]),
# 离散输入 [1, 0, 1, 0, 1, 0, 1, 0, 1, 0]
di=ModbusSequentialDataBlock(address=0, values=[1, 0] * 5),
# 保存寄存器 [5, 4, 3, 2, 1, 0]
hr=ModbusSequentialDataBlock(address=0, values=[5, 4, 3, 2, 1, 0]),
# 输入寄存器 [0, 1, 2, 3, 4, 5]
ir=ModbusSequentialDataBlock(address=0, values=range(6)),
# zero_mode - 默认值False
# 当为True时,对地址0到n的请求将映射到数据存储地址0到n。
# 当为False时,根据规范第4.4节,对地址0到n的请求将映射到数据存储地址1到n+1。
zero_mode=False
)
# 初始化modbus服务器上下文的新实例,将存储区与modbus服务器关联
context = ModbusServerContext(slaves=store, single=True)
# 启动ModBusTCP服务器
StartTcpServer(context=context, address=(ip, port))
# master.py
...
code0x01 = mtc.read_coils(address=0, count=5, slave=0) # 功能码:0×01(读取输出线圈)
code0x02 = mtc.read_discrete_inputs(address=0, count=5, slave=1) # 功能码:0×02(读取离散输入)
code0x03 = mtc.read_holding_registers(address=0, count=5, slave=2) # 功能码:0×03(读取保持寄存器)
code0x04 = mtc.read_input_registers(address=0, count=5, slave=3) # 功能码:0×04(读取输入寄存器)
# 打印返回数据
print(code0x01.bits) # [True, False, True, False, True, False, False, False]
print(code0x02.bits) # [False, True, False, True, False, False, False, False]
print(code0x03.registers) # [4, 3, 2, 1, 0]
print(code0x04.registers) # [1, 2, 3, 4, 5]
方法二(推荐):通过调用ModbusSlaveContext内置函数setValues设置初始值。该方法可解决数据位置偏移问题。
# ModbusSlaveContext内置函数 - setValues
def setValues(self, fc_as_hex, address, values):
"""
使用提供的值设置数据存储.
:param fc_as_hex: 功能码
:param address: 起始地址
:param values: 数值列表
"""
查阅源码可知:
fc_as_hex: 功能码,最终会被映射为存储区编号
数据将会保存到功能码对应的存储区当中,具体表现为:
线圈状态 - 1, 5, 15
离散输入 - 2
保存寄存器 - 3, 6, 16, 22, 23
输入寄存器 - 4
# 方法二(推荐)
# slave.py
...
# 创建一个modbus数据模型,内部设立4个存储区(存储区详情见1.3)
# 默认每个存储区初始值 = [0] * 65536
store = ModbusSlaveContext()
# 设置初始值
store.setValues(23, 0, [100, 101, 102, 103])
store.setValues(4, 0, [200, 201, 202, 203])
...
# master.py
...
code0x03 = mtc.read_holding_registers(address=0, count=5, slave=2) # 功能码:0×03(读取保持寄存器)
code0x04 = mtc.read_input_registers(address=0, count=5, slave=3) # 功能码:0×04(读取输入寄存器)
# 打印返回数据
print(code0x03.registers) # [100, 101, 102, 103, 0]
print(code0x04.registers) # [200, 201, 202, 203, 0]
2.3 进阶玩法
2.3.1 异步开启从站服务
在2.2.1搭建从站的代码中,使用StartTcpServer() 启动从站服务后会阻塞进程,从而在从站服务关闭前无法进行任何操作,又因为无法进行任何操作的原因,我们无法运行停止从站服务的代码,只能通过强制停止程序来迫使其关闭。因此我们可以使用Python的threading模块异步开启从站服务。主站搭建代码沿用2.2.2,从站搭建代码如下:
# slave.py
import asyncio
import threading
from pymodbus.server import StartTcpServer, ServerAsyncStop
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext
# Modbus TCP服务器采用 IP地址和端口号
ip = "localhost"
port = 502
# 创建一个modbus数据模型,内部设立4个存储区(存储区详情见1.3)
# 默认每个存储区初始值 = [0] * 65536
store = ModbusSlaveContext()
# 初始化modbus服务器上下文的新实例,将存储区与modbus服务器关联
context = ModbusServerContext(slaves=store, single=True)
# 启动ModBusTCP服务器
server = threading.Thread(
target=StartTcpServer,
kwargs=({"context": context, "address": (ip, port)})
)
server.start()
print("从站服务器已开启")
while True:
instruct = input("请输入指令:")
if instruct == "stop":
# ServerAsyncStop()采用异步编程
# 需要使用asyncio.run()来执行ServerAsyncStop()服务停止操作
asyncio.run(ServerAsyncStop())
print("从站服务器已关闭")
break
elif instruct == "setValues":
try:
eval(input("请输入代码:"))
print("设置成功!")
except Exception as e:
print("代码执行失败!")
else:
print("指令不存在,请重新输入")
通过上述代码,我们可以通过命令行输入指令关闭从站服务,也可以通过命令行动态设置存储区值。
2.3.2 读写浮点数
在对数据精确度有要求的工作环境中,浮点数的应用是必不可少的。我们在1.3提到过4个存储区,其中线圈状态和离散输入2个存储区保存bool(布尔值)类型的数据,保存寄存器和输入寄存器2个存储区保存int16(16位整型)类型的数据,这也就说明浮点数据无法被直接保存到Modbus寄存器当中,我应该怎么解决这个问题呢?在此之前我们了解一下两个概念。
- 计算机存储单位:计算机存储单位并不是十进制,而是二进制。计算机中最小存储单位是位(bit),每个位可以是0或1。字节(Byte)由8个相邻的二进制位(bit)组成,是计算机中描述存储容量和传输容量的基本计量单位。常见的存储单位从小到大分别是 - 位(bit)、字节(B)、千字节(KB)、兆字节(MB)、吉字节(GB)、太字节(TB)。
- 字节流:字节流是以字节为单位进行数据传输的流,它是计算机中数据处理的基本形式。字节流通常用于处理二进制数据,如文件传输、网络通信等,因为它可以精确控制每个字节的传输。
知道了这些,我们就来捋一捋。首先Modbus数据的读写是通过字节流的方式实现的,字节流是以字节为单位进行数据传输的流,在有数据的前提下,1次至少会读取1字节的数据,而1字节(Byte)=8位(bit),正好1个bool(布尔值)的所占大小是1位(bit),这也就解释了2.2.2中留下的问题。
而浮点数有float(单精度浮点数)和double(双精度浮点数),其中float占用32位(4字节),double占用64位(8字节),主要就是精度上的区别,后续我们采用float进行讲解。线圈状态和离散输入存储单位是bool,存1个float值就需要占用32个地址,这显然不合理。那么我们只能用保存寄存器和输入寄存器来存float值,保存寄存器和输入寄存器的存储单位是int16占用16位,所以只需2个地址就能存储1个float值,那么如何将 float 转 (int16, int16) 就是实现读写浮点数的关键,过程涉及到浮点数转二进制的操作,如果需要了解其中计算原理可以点击跳转浮点数转二进制详解进行学习。也可以使用浮点数转二进制在线工具直接体验。接下来请看转换过程。
1、将浮点数2.3转为二进制形式
01000000000100110011001100110011
2、将32位按顺序平均分成4份,每份8位
01000000 00010011 00110011 00110011
3、选择排列模式
01000000 00010011 00110011 00110011 (大端模式)
00110011 00110011 00010011 01000000 (小端模式)
4、选择小端模式(可选),并将其分为2份,每份16位
00110011 00110011 00010011 01000000
5、再次选择排列模式
00110011 00110011 00010011 01000000 (大端模式)
00110011 00110011 01000000 00010011 (小端模式)
6、选择小端模式(可选),将二进制转为十进制后,得到两个整数
二进制:00110011 00110011 十进制:13107
二进制:01000000 00010011 十进制:16403
7、成功将32位浮点数2.3转为两个16位整数
2.3 => (13107, 16403)
补充知识点:大端模式与小端模式
当然,在Python中也有专门的模块struct可以帮助我们处理二进制数据,既能实现不同类型的数据转二进制流,也可将二进制流解析成我们需要的数据类型。使用struct模块提供的函数,我们就能便捷的实现上述操作。
import struct
# "f"表示float,
# 将1个浮点数转为二进制,默认使用小端模式
f = struct.pack("f", 2.3)
print(f) # 二进制:b'33\x13@'
print(list(f)) # 字节列表:[51, 51, 19, 64]
# "H"表示无符号short, 也是int16类型
# 将二进制数据转为2个int16,默认使用小端模式
i = struct.unpack("2H", f)
print(i) # (13107, 16403)
# 将2个int16转为二进制后,再解析成1个浮点数
n = struct.unpack("f", struct.pack("2H", 13107, 16403))
print(n) # (2.299999952316284,) 得到2.299是因为二进转换导致的精度丢失
知道了怎么将float转int16后,下面我们就可以进行浮点数的读写了。从站沿用2.2.1的代码,主站代码如下:
# master.py
from pymodbus.client import ModbusTcpClient
import struct
# 与从站建立连接(参数除ip外,均有默认值,请按需求填写其他参数)
mtc = ModbusTcpClient("localhost")
# 连接失败抛出错误
assert mtc.connect(), ConnectionError("连接失败!")
# 浮点数列表
float_list = [1.0, 2.1, 3.2, 4.3, 5.4]
count = len(float_list) * 2
# 打印浮点数列表
print("浮点数列表:", float_list)
# 将浮点数列表转为int16整数列表
int16_list = struct.unpack(
"{}H".format(count),
struct.pack(
"{}f".format(len(float_list)),
*float_list
)
)
# 打印int16整数列表
print("16位整数列表:", int16_list) # (0, 16256, 26214, 16390, 52429, 16460, 39322, 16521, 52429, 16556)
# 将数据写入保存寄存器
code0x10 = mtc.write_registers(0, list(int16_list))
# 读取保存寄存器数据
code0x03 = mtc.read_holding_registers(address=0, count=count) # 功能码:0×03(读取保持寄存器)
# 打印读取到的保存寄存器数据
print("保存寄存器数据:", code0x03.registers) # [0, 16256, 26214, 16390, 52429, 16460, 39322, 16521, 52429, 16556]
# 解析int16整数列表为float浮点数列表
data = struct.unpack(
"{}f".format(len(float_list)),
struct.pack(
"{}H".format(count),
*code0x03.registers
)
)
print("解析后的浮点数列表:", data) # (1.0, 2.0999, 3.2000, 4.3000, 5.4000)
结束语
本章所示的Python代码其实是次要的,关键还是要靠大家知道其中的通信原理。从2.2.1与2.2.2就可以看出,只要几行代码就可以建立Modbus通信,而真正需要掌握的是2.3.2中所讲述到的数据解析方法。
其实对于使用Modbus来说,我完全是个外行,只是在工作项目中,我偶然接触到了Modbus,可能使用到Modbus也就这么一次机会,但我还是花费了大量时间去学习了这个技能点。所以这篇文章是一次分享,也是一次对学习总结。
最后,谢谢观看,欢迎互动。