4、Modbus协议深度解析:Modbus RTU与TCP帧结构、功能码详解、地址映射规则
各位同学,咱们今天来啃一块硬骨头——Modbus协议。说实话,这玩意儿在工业现场太常见了,几乎每个做自动化的人都会碰到。但很多人只是会用,真问到帧结构里每个字节是干嘛的,就说不清了。
我当年刚入行时也吃过亏。有一次调试一个变频器,怎么都读不到数据,折腾了一下午。最后发现是RTU帧的CRC校验算错了。嗯,从那以后,我就把Modbus协议从头到尾捋了一遍。今天把这些经验分享给你们。
4.1 Modbus协议家族:RTU vs TCP
Modbus协议主要有两种形态:RTU和TCP。它们的关系,说白了就是同一套“语言”跑在不同的“传输通道”上。
核心区别一句话:RTU走串口(RS-232/485),TCP走以太网。RTU有CRC校验,TCP没有(因为TCP/IP协议栈自己会做校验)。
咱们先看一张对比表,把两者的差异搞清楚:
| 对比项 | Modbus RTU | Modbus TCP |
|---|---|---|
| 物理层 | RS-232 / RS-485 | 以太网 (RJ45) |
| 传输方式 | 串行异步,半双工 | TCP/IP,全双工 |
| 帧格式 | 地址 + 功能码 + 数据 + CRC | MBAP头 + 功能码 + 数据 |
| 地址域 | 1字节,0为广播,1-247为从站 | 单元标识符(1字节),通常填0xFF |
| 校验方式 | 16位CRC校验 | 无应用层校验(依赖TCP) |
| 典型波特率 | 9600 / 19200 / 115200 bps | 10/100/1000 Mbps |
| 最大从站数 | 32个(RS-485无中继) | 理论上无限制 |
我个人习惯是:如果现场有现成的以太网,优先用TCP,调试方便。但很多老旧设备只有串口,那就必须用RTU。咱们做网关转换时,经常要把RTU转成TCP,或者反过来。
4.2 帧结构深度拆解
4.2.1 Modbus RTU帧结构
RTU的帧结构,我建议你死记硬背下来。因为调试时经常要对着报文看:
| 地址域(1B) | 功能码(1B) | 数据域(NB) | CRC校验(2B) |
举个例子,读取从站1的保持寄存器,起始地址0x0000,读取2个寄存器:
请求帧:01 03 00 00 00 02 C4 0B
响应帧:01 03 04 00 0A 00 14 3A 1B
咱们逐字节拆解:
- 01:从站地址,表示1号从站
- 03:功能码,读取保持寄存器
- 00 00:起始地址,高字节在前(大端序)
- 00 02:读取数量,2个寄存器
- C4 0B:CRC校验,低字节在前
响应帧里:
- 01:从站地址回显
- 03:功能码回显
- 04:数据字节数,4个字节(2个寄存器×2字节)
- 00 0A:第一个寄存器值,十进制10
- 00 14:第二个寄存器值,十进制20
- 3A 1B:CRC校验
实战小技巧:调试RTU时,我习惯先用串口助手抓报文,看CRC对不对。很多问题都是CRC计算错误导致的。Linux下可以用 modbus-rtu 工具加 -d 参数打印原始报文。
4.2.2 Modbus TCP帧结构
TCP帧比RTU多了一个MBAP头(Modbus Application Protocol Header),共7个字节:
| 事务标识(2B) | 协议标识(2B) | 长度(2B) | 单元标识(1B) | 功能码(1B) | 数据域(NB) |
同样的读取操作,TCP请求帧长这样:
00 01 00 00 00 06 01 03 00 00 00 02
拆解一下:
- 00 01:事务标识,用于匹配请求和响应
- 00 00:协议标识,固定为0(Modbus协议)
- 00 06:后续字节长度,6个字节(单元标识+功能码+数据)
- 01:单元标识符,相当于RTU的从站地址
- 03:功能码
- 00 00:起始地址
- 00 02:读取数量
注意:TCP帧的“长度”字段不包括MBAP头自身,只算后面的数据。这个坑我踩过——有一次写网关程序,长度算多了2个字节,结果对端一直报错。查了半天才发现是长度字段包含了单元标识符。
4.3 功能码详解
功能码是Modbus协议的核心,告诉从站你要干什么。常用的就那么几个,我列个表:
| 功能码 | 名称 | 操作对象 | 读写方向 |
|---|---|---|---|
| 01 (0x01) | 读取线圈状态 | DO(数字量输出) | 只读 |
| 02 (0x02) | 读取离散输入 | DI(数字量输入) | 只读 |
| 03 (0x03) | 读取保持寄存器 | AO(模拟量输出/参数) | 只读 |
| 04 (0x04) | 读取输入寄存器 | AI(模拟量输入) | 只读 |
| 05 (0x05) | 写单个线圈 | DO | 只写 |
| 06 (0x06) | 写单个寄存器 | AO | 只写 |
| 15 (0x0F) | 写多个线圈 | DO | 只写 |
| 16 (0x10) | 写多个寄存器 | AO | 只写 |
这里有个容易混淆的点:保持寄存器和输入寄存器的区别。保持寄存器可读可写,一般存参数、设定值;输入寄存器只读,存传感器采集的数值。我在项目中见过有人用03功能码去读输入寄存器,结果读回来的全是0——因为地址空间根本不对。
4.4 地址映射规则
地址映射是Modbus转PROFINET时最头疼的部分。Modbus的地址是“平面”的,而PROFINET的GSD文件里定义的是“结构化”的数据块。怎么把两者对应起来?
先看Modbus的地址空间划分:
| 地址范围 | 数据类型 | 对应功能码 | PLC中常见表示 |
|---|---|---|---|
| 00001 - 09999 | 线圈(DO) | 01, 05, 15 | Q区 |
| 10001 - 19999 | 离散输入(DI) | 02 | I区 |
| 30001 - 39999 | 输入寄存器(AI) | 04 | PIW |
| 40001 - 49999 | 保持寄存器(AO) | 03, 06, 16 | PQW / DB |
注意,Modbus地址是从1开始的,但协议报文里的地址是从0开始的。比如你要读40001,报文里填的地址是0x0000。这个偏移量很多人会搞错。
地址映射的核心原则:在网关配置时,把Modbus的“地址+功能码”映射到PROFINET的“槽号+子槽号+字节偏移”。说白了,就是告诉网关:Modbus的哪个数据,放到PROFINET的哪个位置。
举个例子,我在一个项目中这样配:
- Modbus保持寄存器40001-40010(10个寄存器,20字节)→ PROFINET输入数据槽1,偏移0
- Modbus线圈00001-00016(16个位,2字节)→ PROFINET输出数据槽2,偏移0
这样PLC侧读PROFINET输入数据时,就能直接拿到Modbus从站的模拟量值;写PROFINET输出数据时,就能控制Modbus从站的数字量输出。
4.5 知识体系总览
下面这张图把本章的核心逻辑串起来了,建议你保存下来对照着看:
4.6 实战中的避坑指南
最后,分享几个我在项目中踩过的坑:
坑1:CRC校验字节序搞反
我曾经在写一个RTU转TCP的网关程序时,CRC算对了,但低字节和高字节放反了。从站一直返回异常码0x03(非法数据值)。后来用串口对比工具才发现,CRC应该是 C4 0B,我写成了 0B C4。记住:RTU的CRC是低字节在前。
坑2:TCP的单元标识符乱填
有些Modbus TCP从站不检查单元标识符,填什么都行。但有些会严格校验。我遇到过一台温控器,单元标识符必须填0x01,填0xFF就报错。所以做网关时,最好把这个字段做成可配置的。
调试利器:Linux下用 modpoll 和 mbpoll 工具,可以快速测试Modbus设备。加 -v 参数能看到原始报文,比用Windows的串口助手方便多了。