通信协议基础:Modbus RTU/TCP协议详解
各位同学,咱们今天聊点实在的。做储能设备远程监控,通信协议是绕不开的坎。我最早接触Modbus是在一个光伏电站项目里,当时设备死活连不上,折腾了两天,最后发现是寄存器地址写错了。嗯,从那以后,我对协议细节再也不敢马虎。
说白了,Modbus就是工业设备之间的“普通话”。不管你是PLC、逆变器还是BMS(电池管理系统),大家约定好格式,数据就能流通。今天咱们重点拆解Modbus RTU和Modbus TCP,把数据帧结构、寄存器映射、CRC校验这些硬骨头啃下来。
核心要点:Modbus是应用层协议,RTU走串口(RS-232/485),TCP走以太网。底层不同,但数据模型完全一样。
一、Modbus RTU vs Modbus TCP:到底差在哪?
我经常被问到:“老师,RTU和TCP选哪个?”我的回答是:看现场环境。RTU适合长距离、抗干扰要求高的场景,比如储能站里电表、温控器这些老设备。TCP则适合局域网内高速通信,比如你用一个上位机同时采集几十台设备的数据。
咱们直接看对比表,一目了然:
| 特性 | Modbus RTU | Modbus TCP |
|---|---|---|
| 物理层 | RS-232 / RS-485 | 以太网(RJ45) |
| 传输速率 | 9600~115200 bps | 10/100 Mbps |
| 最大节点数 | 32(RS-485) | 理论上无限制 |
| 数据帧格式 | 地址+功能码+数据+CRC | MBAP头+功能码+数据 |
| 校验方式 | CRC-16(必须) | TCP/IP协议栈保证 |
| 典型应用 | 电表、温控器、老旧PLC | 上位机、网关、新式控制器 |
你想想看,RTU多了一个CRC校验,因为串口通信容易受干扰。TCP呢,底层有TCP/IP协议栈帮你保证数据完整性,所以MBAP头里没有CRC字段。这个区别,很多新手会忽略。
二、数据帧结构:手把手拆解
咱们先看RTU帧。我习惯把它分成四段:
| 地址码(1字节) | 功能码(1字节) | 数据区(N字节) | CRC校验(2字节) |
举个例子,你要读取储能逆变器的直流电压(寄存器地址0x3100):
发送:01 03 31 00 00 01 84 0A
解析:
01 → 设备地址(逆变器)
03 → 功能码(读保持寄存器)
31 00 → 起始寄存器地址(高字节在前)
00 01 → 读取1个寄存器
84 0A → CRC校验(低字节在前)
收到响应:
01 03 02 0B B8 7C 8D
解析:
01 → 设备地址
03 → 功能码
02 → 数据字节数(2字节)
0B B8 → 寄存器值(0x0BB8 = 3000,表示300.0V)
7C 8D → CRC校验
我的小技巧:调试时先用串口助手抓包,看CRC对不对。我曾经遇到一个设备,CRC计算方式是大端模式,结果我按小端算,死活对不上。后来翻手册才发现,有些厂家会魔改CRC顺序。
再来看TCP帧。它比RTU多了个MBAP头:
| 事务ID(2字节) | 协议ID(2字节) | 长度(2字节) | 单元ID(1字节) | 功能码(1字节) | 数据区(N字节) |
事务ID用于匹配请求和响应,协议ID固定为0x0000。长度字段表示从单元ID开始到帧尾的字节数。单元ID其实就是RTU里的设备地址,为了兼容而保留。
同样的读电压操作,TCP帧长这样:
00 01 00 00 00 06 01 03 31 00 00 01
解析:
00 01 → 事务ID
00 00 → 协议ID
00 06 → 后面还有6字节
01 → 单元ID
03 → 功能码
31 00 → 起始地址
00 01 → 读取数量
看到了吗?TCP帧里没有CRC,因为TCP/IP协议栈已经做了校验。但代价是帧头多了7个字节,传输效率略低。
二、寄存器映射表设计:储能系统的“地图”
寄存器映射表,说白了就是一张“地址-含义”对照表。没有它,你读回来的数据就是一堆乱码。我在设计储能系统时,通常把寄存器分成四类:
- 线圈(Coil):可读可写,1位。用于控制继电器、开关。功能码01/05/15。
- 离散输入(Discrete Input):只读,1位。用于读取状态,比如断路器分合。功能码02。
- 输入寄存器(Input Register):只读,16位。用于读取模拟量,比如温度、电压。功能码04。
- 保持寄存器(Holding Register):可读可写,16位。用于设置参数,比如充放电功率。功能码03/06/16。
下面是我给一个储能BMS设计的寄存器映射表示例:
| 寄存器地址 | 数据类型 | 读写属性 | 描述 | 单位/范围 |
|---|---|---|---|---|
| 0x0000 | Uint16 | 只读 | 电池组总电压 | 0.1V,如3000=300.0V |
| 0x0001 | Uint16 | 只读 | 电池组总电流 | 0.1A,如500=50.0A |
| 0x0002 | Uint16 | 只读 | SOC(荷电状态) | 0~1000,对应0.0%~100.0% |
| 0x0010 | Uint16 | 读写 | 充放电功率设定值 | 1W,如5000=5000W |
| 0x0011 | Uint16 | 读写 | 运行模式 | 0=待机,1=充电,2=放电 |
| 0x0020 | Uint16 | 只读 | 最高单体电压 | 0.001V,如4200=4.200V |
| 0x0021 | Uint16 | 只读 | 最低单体电压 | 0.001V,如4100=4.100V |
| 0x0030 | Uint16 | 只读 | 最高温度 | 0.1℃,如350=35.0℃ |
| 0x0031 | Uint16 | 只读 | 最低温度 | 0.1℃,如280=28.0℃ |
注意:寄存器地址通常从0开始,但有些设备手册会写“40001”这种PLC地址。那是Modicon的遗留习惯,实际通信时要把40001减去40001得到0x0000。别搞混了,我见过有人因为这个错位,读回来的数据全是0。
三、CRC校验原理:别被它吓到
CRC(循环冗余校验)是RTU协议的“守护神”。它的原理不复杂,就是多项式除法。咱们用标准CRC-16(多项式0x8005)来算。
我直接给一个C语言实现,你在单片机上也能跑:
uint16_t crc16_modbus(uint8_t *data, uint16_t len) {
uint16_t crc = 0xFFFF;
for (uint16_t i = 0; i < len; i++) {
crc ^= data[i];
for (uint8_t j = 0; j < 8; j++) {
if (crc & 0x0001) {
crc = (crc >> 1) ^ 0xA001;
} else {
crc = crc >> 1;
}
}
}
return crc;
}
注意,Modbus的CRC是低字节在前。比如计算结果是0x0A84,发送时先发0x84,再发0x0A。这个顺序搞反了,设备就不认你。
避坑指南:我曾经在一个项目中,用上位机给设备写参数,CRC算对了,但设备就是不响应。后来发现,设备要求CRC高字节在前。嗯,有些非标设备会改CRC顺序。所以拿到新设备,第一件事就是看手册里的CRC示例。
四、知识体系总览
为了让你对本章内容有个整体印象,我画了一张图。它把Modbus RTU/TCP的核心知识点串起来了:
这张图把RTU和TCP的差异、寄存器分类都标出来了。你把它存下来,以后做项目时对照着看,思路会清晰很多。
五、实战中的几个坑
最后,我分享几个实战中容易踩的坑:
- 波特率不匹配:RTU通信时,所有设备的波特率、数据位、停止位、校验位必须一致。我遇到过现场有人把9600和19200混用,结果数据全是乱码。
- 地址冲突:同一个总线上,不能有两个相同地址的设备。否则数据会碰撞,谁也读不到正确值。
- 寄存器地址偏移:有些设备手册写的是“40001”,实际通信地址是0x0000。记得做减法。
- CRC计算错误:尤其是大小端顺序,一定要看手册示例。
- TCP连接超时:Modbus TCP默认端口是502。如果设备没响应,先ping一下IP,再检查防火墙是不是把502端口封了。
总结一下:Modbus协议不难,但细节决定成败。寄存器映射表是系统的“地图”,CRC是数据的“保险”。把这两样搞明白,你的储能监控系统就稳了一半。
好了,这一章就到这里。下一章咱们会讲如何用Python写一个Modbus采集程序,到时候我会带着你一行一行敲代码。