Python Modbus-RTU 串口编程中结构数据收发的相关问题
一、引言
异步串口通信往往是以字符(字节)为单位进行的,但在很多情况下,需要用串口收发具有规定结构的一组数据(数据帧或数据包),例如 Modbus-RTU 的数据帧。为了防止丢失数据或粘包的现象发生,往往会采用以下几种措施:
1. 使用定界符来区隔数据帧或数据包,这种方法对于ASCII字符的传输是比较有效的,但其问题是不支持完全透明的二进制数据传输。
2. 在两个连续发送的数据帧或数据包之间设置保护时间间隔,保证收发的完整性,其缺点是会增加时间开销,降低收发能力。
3. 采用请求 - 响应的方式进行数据收发,它适合在一个接口上有多个发送源的场合,Modbus-RTU 就采用这种方式。
理论上,采用上述措施以后,可以在传输中保证数据结构的完整性。但是,在实现接口程序中我们发现,在这方面有些编程的具体问题还需要解决。
二、问题
如前所述,Modbus-RTU 是用 请求 - 响应方式进行通信的(Modbus-TCP 也一样)。在一个 RS-485 串行接口上,有一个 Modbus 主站和一个或多个 Modbus 从站,在交互过程中,由主站发送命令,命令中带有从站标识,标识所指定的从站收到命令后发送响应。
用 Python 编程实现 Modbus-RTU 主站通信模块的流程如下:
1. 等待调用者从队列发送的请求;
2. 收到请求后,向串行接口发送一个Modbus-RTU 命令帧;
3. 等待从站的响应;
4. 收到从站完整的响应帧以后,将其转发给调用者。
下面是这段程序的实现(用 pyserial_asyncio 模块):
import serial_asyncio
......
class SerialModbusRTU
......
async def write_to_serial(self):
while self.running:
# 从其他线程输入的请求在 self.north_input_queue 中,为防止异步协程被阻塞,
# 只有其不为空时才进行后续操作
while self.north_input_queue.empty():
await asyncio.sleep(0.05)
# 从队列中获取调用者的请求数据(Modbus-RTU 命令帧)
frm = self.north_input_queue.get()
try:
# 将命令帧发送给从站
self.writer.write(frm)
await self.writer.drain()
# 等待从站的响应,设置超时时间为4秒
data = await asyncio.wait_for(self.reader.read(1000), timeout=4)
# 将响应收到的数据从另一个队列返回给调用者
local_var.fw_south_input_queue.put(data)
# 如果超时,返回超时报告(编码为1)
except TimeoutError as e:
print(self.module_name + '.write_to_serial ', e)
data = make_error_report(self.module_code, 1)
local_var.fw_south_input_queue.put(data)
# 发生其他错误,报告其他错误(编码为2)
except Exception as e:
print(self.module_name + '.write_to_serial ', e)
data = make_error_report(self.module_code, 2)
local_var.fw_south_input_queue.put(data)
self.running = False
self.writer.close()
这段程序开始运行时比较正常,没有发现什么问题。但在与一种新的传感设备连接时,却发现时常有接收到不完整帧的现象发生。经过硬件测试分析,发现这种传感设备在发送数据时,会有几个毫秒的停顿,这时,就会发生接收不完整的现象。
三、解决方案
为了解决上述问题,最直接的方法就是对接收到的数据进行解析,如果没有完成,就继续等待,直至收到完整的数据帧。
在分析中发现,在通信过程中这种停顿的时间很短,只有一两个毫秒,而且一次交互中返回的数据包也不是很长,一般只有十几到一百多个字节。根据这种情况,我们采用了一种简化方法,具体做法如下:
完成第一次接收以后,再一次启动等待接收,调整接收的超时时间,使得既不漏掉停顿后发送的数据,也不至于等待时间过长而引起的通信效率下降。修改后的程序如下(见其中“增加的代码段”和“增加结束”两个注释之间的代码):
async def write_to_serial(self):
while self.running:
# 从其他线程输入的请求在 self.north_input_queue 中,为防止异步协程被阻塞,
# 只有其不为空时才进行后续操作
while self.north_input_queue.empty():
await asyncio.sleep(0.05)
# 从队列中获取调用者的请求数据(Modbus-RTU 命令帧)
frm = self.north_input_queue.get()
try:
# 将命令帧发送给从站
self.writer.write(frm)
await self.writer.drain()
# 等待从站的响应,设置超时时间为4秒
data = await asyncio.wait_for(self.reader.read(1000), timeout=4)
# 增加的代码段
try:
# 第二次接收,接收停顿后发送的部分数据,超时设为20ms,需要根据具体情况选择
data = data + await asyncio.wait_for(self.reader.read(1000), timeout=0.02)
= except TimeoutError as e:
# 第二次接收的超时处理,如果超时,表示没有停顿,接收完成,打印即可
print(‘接收完成’)
# 增加结束
# 将响应收到的数据从另一个队列返回给调用者
local_var.fw_south_input_queue.put(data)
# 如果超时,返回超时报告(编码为1)
except TimeoutError as e:
print(self.module_name + '.write_to_serial ', e)
data = make_error_report(self.module_code, 1)
local_var.fw_south_input_queue.put(data)
# 发生其他错误,报告其他错误(编码为2)
except Exception as e:
print(self.module_name + '.write_to_serial ', e)
data = make_error_report(self.module_code, 2)
local_var.fw_south_input_queue.put(data)
self.running = False
self.writer.close()
修改后,就能够确保接收到完整的Modbus数据帧了。如果修改后仍然会收到不完整的数据包,可以延长第二次读串口的等待时间(程序中是20ms)。
相对于加入数据包解析方法,这种解决方案简单易行,降低了程序的复杂性。与此同时,由于不需要解析数据,所以它对数据格式的适应性也很好。
这种方法也适用于引言中的第 2 种情况。
应当注意的是:和解析的方式相比,使用这种方法的限制是需要两个数据包之间有保护时间,而且第二次读串口的等待时间要小于数据包之间的保护时间。