2026/10/16 1:13:14

Express Pi上实现Modbus RTU主站:从协议原理到工业数据采集实战

Express Pi上实现Modbus RTU主站:从协议原理到工业数据采集实战 1. 从一个真实需求说起为什么要在嵌入式设备上跑Modbus RTU做过工业现场数据采集的人大概都有类似的经历现场一堆传感器、电表、温控器说明书上清一色写着“支持Modbus RTU”但真要把数据接进自己的系统才发现从接线到轮询再到解析每一步都有坑。我最近在一个小型环境监测项目里就遇到了这个场景——需要把分布在三个机柜里的温湿度变送器、电量采集模块统一接入一台边缘计算设备再通过本地网络把数据汇总到监控端。选型的时候考虑过几种方案用PLC做主站成本高且灵活性差用工控机跑组态软件功耗和体积都不适合现场最后决定用一块Express Pi开发板来做这件事。Express Pi这类板子本质上是一台微型Linux计算机带原生串口或者可以通过USB转串口扩展跑Python或者C都方便功耗低、体积小塞进机柜里完全不占地方。而Modbus RTU作为工业现场最普遍的串行通信协议几乎所有的仪表都支持用它来做数据采集是最稳妥的选择。这篇文章就是把这个项目的完整实现过程整理出来。从Modbus RTU的协议原理、Express Pi上的串口配置、Python脚本的编写到现场调试时遇到的各种问题我都会详细展开。如果你手头也有类似的采集需求或者正在学习工业通信协议这篇内容可以直接拿来参考。即使你之前没接触过Modbus跟着走一遍也能理解整个链路是怎么跑通的。2. Modbus RTU协议核心要点拆解2.1 协议本质主从轮询加寄存器映射Modbus RTU的本质其实非常朴素——它是一种主从架构的串行通信协议一个总线上只能有一个主站可以有多个从站。主站主动发起请求从站被动响应从站之间不会互相通信。这种设计在工业现场非常实用因为布线简单一根RS485双绞线就能挂几十个设备。通信的内容围绕“寄存器”展开。你可以把每个从站设备想象成一排带编号的抽屉每个抽屉里放着一个16位的数据。主站要做的就是告诉从站“我要读你第几号到第几号抽屉里的东西。”从站收到请求后把对应抽屉里的数据打包回传。这些抽屉按功能分为几种类型线圈Coil可读可写的开关量地址范围00001-09999对应功能码01读、05写单个、15写多个离散输入Discrete Input只读的开关量地址范围10001-19999对应功能码02输入寄存器Input Register只读的模拟量地址范围30001-39999对应功能码04保持寄存器Holding Register可读可写的模拟量地址范围40001-49999对应功能码03读、06写单个、16写多个实际项目里最常用的是功能码03和04因为大部分传感器采集的都是模拟量比如温度、湿度、电压、电流这些。需要注意的是不同厂家对寄存器地址的定义可能不一样有的从0开始编有的从1开始编有的还会加偏移量。这个后面在实操部分会详细说。2.2 RTU帧结构每个字节都有讲究Modbus RTU的帧结构非常紧凑一个典型的读请求帧长这样[从站地址 1字节] [功能码 1字节] [起始地址 2字节] [寄存器数量 2字节] [CRC校验 2字节]总共8个字节。从站地址范围是1-2470是广播地址248-255保留。功能码就是上面说的01、02、03、04这些。起始地址和寄存器数量都是大端序高字节在前。CRC校验用的是CRC-16/MODBUS算法多项式0xA001初始值0xFFFF结果低字节在前。响应帧的结构根据功能码不同略有差异。以功能码03为例[从站地址 1字节] [功能码 1字节] [字节数 1字节] [数据 N字节] [CRC校验 2字节]字节数等于寄存器数量乘以2因为每个寄存器是16位。数据部分同样是每个寄存器高字节在前。这里有个容易踩坑的地方帧与帧之间的间隔。Modbus RTU规定帧间隔至少3.5个字符时间。在波特率9600下一个字符是11位1起始位8数据位1校验位1停止位3.5个字符时间大约是4毫秒。如果两帧之间的间隔小于这个值从站可能会把两帧当成一帧处理导致CRC校验失败。这个在高速轮询的时候特别容易出问题。2.3 CRC校验手算一遍就懂了CRC校验是Modbus RTU里唯一需要稍微动点脑筋的地方。虽然代码里直接调库就行但理解它的计算过程对排查问题很有帮助。我刚开始做的时候遇到过一次CRC错误查了半天发现是字节序搞反了。CRC-16/MODBUS的计算逻辑是这样的先把CRC寄存器初始化为0xFFFF然后对每个字节进行处理。每个字节先和CRC寄存器的低字节异或然后对结果进行8次循环每次判断最低位是否为1如果是就右移一位再异或0xA001否则只右移一位。处理完所有字节后得到的CRC值低字节在前、高字节在后。我写了一段Python代码来演示这个过程你可以直接复制运行def crc16_modbus(data: bytes) - bytes: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 # 低字节在前 return bytes([crc 0xFF, (crc 8) 0xFF]) # 测试读从站1的保持寄存器起始地址0数量2 frame bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x02]) crc crc16_modbus(frame) print(f完整帧: {(frame crc).hex( ).upper()}) # 输出: 01 03 00 00 00 02 C4 0B实测下来这个算法和大多数设备手册里给的示例帧是一致的。如果你手头的设备返回的CRC对不上先检查一下是不是把高低字节搞反了这是最常见的问题。3. Express Pi环境准备与串口配置3.1 硬件连接RS485接线不能想当然Express Pi开发板通常带有一个或多个UART串口但大多数板子的UART电平是3.3V TTL不能直接接RS485总线。你需要一个TTL转RS485的模块常见的有MAX485、SP3485这些芯片做的模块价格很便宜。接线的时候注意几个点A接AB接BRS485的A和B是差分信号线所有设备的A接在一起B接在一起。如果接反了通信会完全不通但不会烧设备所以调试的时候可以先试一种不行就对调。共地虽然RS485是差分传输理论上不需要共地但实际现场如果设备之间地电位差太大还是建议把GND连起来否则容易损坏收发器。终端电阻如果总线长度超过50米或者波特率高于19200建议在总线两端各加一个120欧姆的终端电阻减少信号反射。屏蔽层如果现场电磁干扰严重用屏蔽双绞线屏蔽层单端接地。我用的转接模块是某宝上常见的XY-485模块芯片是MAX4853.3V供电直接插在Express Pi的UART引脚上。模块上有个DE/RE引脚用来控制发送和接收的切换。有些模块自动控制方向有些需要手动控制GPIO。我用的这个需要手动控制所以后面代码里会有一个GPIO操作。3.2 系统串口配置释放被占用的UARTExpress Pi跑的是Linux系统默认情况下UART可能被系统控制台占用。你需要先确认串口设备节点然后禁用控制台功能。查看串口设备ls -l /dev/ttyS* ls -l /dev/ttyAMA*不同板子的串口命名不一样有的叫ttyS0有的叫ttyAMA0有的叫ttyFIQ0。你需要根据板子的文档确认哪个是你要用的。我用的这块板子UART0对应的是/dev/ttyS0。如果这个串口被系统控制台占用需要修改启动参数。编辑/boot/cmdline.txt或者/boot/config.txt把console相关的参数去掉。具体操作因板子而异这里不展开但核心思路就是让系统不要往这个串口打印日志。配置串口参数stty -F /dev/ttyS0 9600 cs8 -cstopb -parenb -echo raw这行命令的意思是波特率96008位数据位1位停止位无校验关闭回显原始模式。这些参数必须和你的从站设备一致否则通信会失败。3.3 Python环境与依赖安装Express Pi上一般自带Python3如果没有就装一个。然后需要安装串口库pip3 install pyserialpyserial是Python下最常用的串口库跨平台API简单。如果你用的是其他语言比如C或者Node.js也有对应的串口库逻辑类似。安装完之后先写个简单的测试脚本确认串口能打开import serial ser serial.Serial( port/dev/ttyS0, baudrate9600, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout1 ) if ser.is_open: print(串口打开成功) ser.close() else: print(串口打开失败)如果这一步就报错先检查串口设备节点是否存在、权限是否足够可能需要sudo或者把用户加入dialout组。4. 完整实操从零实现Modbus RTU主站4.1 代码框架设计分层解耦写这种通信代码最忌讳把所有逻辑堆在一个函数里。我的做法是分三层物理层负责串口的打开、关闭、读写以及RS485方向控制协议层负责Modbus帧的组装、解析、CRC校验应用层负责具体的业务逻辑比如轮询哪些设备、读哪些寄存器、数据怎么处理这样分层的好处是如果以后要换串口设备或者换协议只需要改对应的层其他层不用动。4.2 物理层实现串口读写与方向控制先定义一个ModbusRTU类初始化的时候打开串口并配置RS485方向控制引脚如果需要的话import serial import time import RPi.GPIO as GPIO # 根据实际板子替换 class ModbusRTU: def __init__(self, port, baudrate9600, de_pinNone): self.ser serial.Serial( portport, baudratebaudrate, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout0.5 ) self.de_pin de_pin if de_pin is not None: GPIO.setmode(GPIO.BCM) GPIO.setup(de_pin, GPIO.OUT) GPIO.output(de_pin, GPIO.LOW) # 默认接收模式 def _send(self, data: bytes): if self.de_pin is not None: GPIO.output(self.de_pin, GPIO.HIGH) # 切换到发送模式 self.ser.reset_input_buffer() self.ser.write(data) self.ser.flush() time.sleep(0.002) # 等待最后一个字节发送完成 if self.de_pin is not None: GPIO.output(self.de_pin, GPIO.LOW) # 切换回接收模式 def _recv(self, length: int) - bytes: return self.ser.read(length) def close(self): self.ser.close() if self.de_pin is not None: GPIO.cleanup()这里有个细节发送完之后要等一小会儿再切换回接收模式否则最后一个字节可能还没发完就被切断了。我实测下来9600波特率下2毫秒的延时足够了。如果你用更高的波特率这个延时可以更短但建议不要低于1毫秒。4.3 协议层实现帧组装与解析协议层负责把应用层的请求转换成Modbus帧以及把收到的响应解析成数据def crc16_modbus(data: bytes) - bytes: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 return bytes([crc 0xFF, (crc 8) 0xFF]) def build_read_request(slave_id: int, func_code: int, start_addr: int, count: int) - bytes: frame bytes([ slave_id, func_code, (start_addr 8) 0xFF, start_addr 0xFF, (count 8) 0xFF, count 0xFF ]) return frame crc16_modbus(frame) def parse_read_response(response: bytes, count: int): if len(response) 5: raise ValueError(响应帧太短) slave_id response[0] func_code response[1] if func_code 0x80: # 异常响应 exception_code response[2] raise RuntimeError(f从站{slave_id}返回异常代码: {exception_code}) byte_count response[2] expected_len 3 byte_count 2 if len(response) ! expected_len: raise ValueError(f响应长度不对期望{expected_len}实际{len(response)}) # 校验CRC recv_crc response[-2:] calc_crc crc16_modbus(response[:-2]) if recv_crc ! calc_crc: raise ValueError(CRC校验失败) # 解析数据 data [] for i in range(count): high response[3 i * 2] low response[4 i * 2] data.append((high 8) | low) return data异常响应这里要特别注意当从站返回错误时功能码的最高位会被置1。比如请求功能码03异常响应就是0x83。异常码的含义可以参考Modbus规范常见的比如01非法功能、02非法数据地址、03非法数据值、04从站设备故障等。4.4 应用层实现轮询与数据处理应用层就是具体的业务逻辑了。假设我有三个设备从站地址分别是1、2、3每个设备有两个保持寄存器需要读取def poll_devices(modbus: ModbusRTU, devices: list): results {} for dev in devices: slave_id dev[slave_id] start_addr dev[start_addr] count dev[count] try: request build_read_request(slave_id, 0x03, start_addr, count) modbus._send(request) # 响应长度 1(地址) 1(功能码) 1(字节数) count*2 2(CRC) response modbus._recv(3 count * 2 2) data parse_read_response(response, count) results[slave_id] data print(f从站{slave_id}数据: {data}) except Exception as e: print(f从站{slave_id}读取失败: {e}) results[slave_id] None time.sleep(0.05) # 帧间隔 return results这里的帧间隔我设了50毫秒比协议要求的3.5个字符时间大很多主要是为了兼容一些响应慢的老设备。实际调试的时候可以根据设备手册调整但建议不要低于10毫秒。4.5 数据解析从寄存器到物理量读到的寄存器值是原始的16位整数需要根据设备手册转换成实际的物理量。比如温度变送器手册上可能写着“温度值 寄存器值 / 10”那么读到250就代表25.0度。有些设备用的是有符号数需要做补码转换def to_signed(value: int) - int: if value 32767: return value - 65536 return value # 示例读取温度 raw_temp 250 temperature raw_temp / 10.0 print(f温度: {temperature}°C) # 示例读取有符号数 raw_value 65526 # 对应-10 signed_value to_signed(raw_value) print(f有符号值: {signed_value})有些设备还会把32位数据拆成两个寄存器这时候需要把两个16位拼起来。注意字节序和字序的问题不同厂家可能不一样有的高字在前有的低字在前。这个只能看手册或者实测。5. 现场调试常见问题与排查技巧5.1 通信完全不通从物理层开始查这是最常见的问题现象是发送请求后没有任何响应或者一直超时。排查顺序应该是从物理层往上查排查项检查方法常见问题接线万用表测A-B之间电压A-B接反、断线供电测从站电源电压从站没供电串口参数确认波特率、数据位、校验位参数不匹配串口设备节点ls /dev/tty*设备节点不对方向控制示波器看DE引脚方向切换时序不对终端电阻测A-B之间电阻缺少或多余终端电阻我遇到过一次查了半天发现是RS485模块的DE引脚接错了GPIO导致一直处于接收模式发不出去数据。所以如果你用的是需要手动控制方向的模块一定要先确认方向控制是否正常。5.2 CRC校验失败字节序和帧间隔是重灾区CRC校验失败通常有几个原因字节序搞反CRC的低字节和高字节位置反了。Modbus RTU规定低字节在前但有些库或者示例代码可能搞反。帧间隔不够两帧之间间隔太短从站把两帧当成一帧。解决方法是增加帧间隔或者在发送前清空接收缓冲区。数据被截断串口读取的时候没有读完整帧。解决方法是根据预期的响应长度精确读取而不是用readline或者readall。干扰导致数据错误现场电磁干扰严重数据在传输过程中被改变。解决方法是使用屏蔽双绞线、增加终端电阻、降低波特率。我个人的经验是如果CRC错误是偶发的大概率是干扰或者帧间隔问题如果是必现的大概率是字节序或者帧结构问题。5.3 响应超时从站地址和寄存器地址要确认响应超时是指发送请求后在超时时间内没有收到任何数据。除了物理层问题外最常见的原因是从站地址不对每个Modbus设备都有一个从站地址出厂默认可能是1但现场可能被改过。如果不确定可以用广播地址0发送请求所有从站都会响应但这样会冲突只适合单个设备调试。寄存器地址不对不同厂家对寄存器地址的定义不一样。有的手册写40001实际发送的时候要减1变成0有的手册写0实际发送的时候要加1。这个只能看手册或者用调试工具试。功能码不支持有些设备只支持功能码03不支持04。如果请求的功能码不对从站可能直接不响应。5.4 数据不对解析方式要对照手册数据能读回来但值不对这种情况通常是解析方式的问题数据类型不对有的设备用有符号数有的用无符号数有的用浮点数。需要根据手册确认。缩放系数不对手册上可能写着“实际值 寄存器值 × 0.1”如果忘了乘读出来的值就会差10倍。字节序不对32位数据拆成两个寄存器时高字和低字的顺序可能不一样。寄存器偏移不对有的设备寄存器地址从0开始有的从1开始差一个偏移量。我的建议是拿到一个新设备先用Modbus调试工具比如Modbus Poll或者QModMaster手动读一次确认能读到数据并且数据是合理的然后再写代码。这样可以排除很多协议层面的问题把精力集中在代码上。5.5 多设备轮询冲突帧间隔和超时要合理设置当总线上挂多个设备时轮询的顺序和间隔就很重要了。如果帧间隔太短前一个设备的响应还没发完后一个请求就发出去了会导致总线冲突。如果超时时间太短慢速设备还没响应就超时了会导致数据丢失。我的做法是帧间隔至少50毫秒如果设备多或者响应慢可以增加到100毫秒超时时间根据设备手册设置一般200毫秒到1秒如果某个设备连续多次超时可以暂时跳过它避免拖慢整个轮询周期轮询周期不要设得太短给设备留出处理时间6. 性能优化与稳定性提升6.1 批量读取减少请求次数如果多个连续的寄存器都需要读取尽量一次性读回来而不是一个一个读。比如要读地址0到9的10个寄存器发一次请求读10个比发10次请求读1个效率高得多。Modbus协议本身支持一次读最多125个寄存器功能码03实际使用中建议不要超过50个避免响应帧太长导致传输错误。6.2 异常重试提高数据采集成功率现场环境复杂偶尔的通信失败是正常的。我的做法是每个请求失败后重试2次如果3次都失败才标记为失败。重试之间加100毫秒的延时给设备恢复的时间。这样可以显著提高数据采集的成功率尤其是对于老旧的设备。def read_with_retry(modbus, slave_id, func_code, start_addr, count, retries3): for attempt in range(retries): try: request build_read_request(slave_id, func_code, start_addr, count) modbus._send(request) response modbus._recv(3 count * 2 2) return parse_read_response(response, count) except Exception as e: if attempt retries - 1: raise time.sleep(0.1)6.3 数据缓存与断线续传如果网络不稳定采集到的数据可以先缓存在本地等网络恢复了再上传。Express Pi上可以用SQLite做本地缓存简单可靠。数据表设计一个时间戳、设备ID、寄存器地址、值采集到就插入上传成功就删除或者标记。6.4 看门狗与自动恢复长时间运行的采集程序最怕的是串口卡死或者程序崩溃。我的做法是加一个看门狗线程定期检查采集线程是否还在正常工作如果超过一定时间没有新数据就重启串口或者重启程序。Express Pi上可以用systemd的Watchdog功能也可以用Python的threading自己实现。7. 项目扩展方向这套代码跑通之后可以往几个方向扩展。一是增加更多协议支持比如Modbus TCP这样可以通过网络直接采集支持以太网的设备。二是增加数据可视化用Flask或者FastAPI搭一个简单的Web界面实时显示采集到的数据。三是接入MQTT把数据推送到消息队列方便和其他系统集成。四是增加配置管理把设备列表、寄存器地址、缩放系数这些做成配置文件不用改代码就能适配新设备。我个人在实际操作中的体会是Modbus RTU虽然协议简单但现场调试的坑一点都不少。最有效的调试方法就是先用工具确认物理层和协议层没问题再写代码。另外一定要做好异常处理和日志记录现场出问题的时候日志是唯一的线索。最后再分享一个小技巧如果手头没有Modbus调试工具可以用Python的pyserial写一个简单的收发脚本把发送和接收的原始字节打印出来对照手册分析比盲目猜测高效得多。