4、Modbus协议详解(下):Modbus TCP帧结构、MBAP报文头、多寄存器读写(功能码15-16)、实际工程中的常见问题。
好,咱们接着聊。上一节我们把Modbus RTU和ASCII扒了个底朝天,这一节轮到TCP上场了。说实话,现在新上的项目,十有八九都是走TCP/IP网络。为什么?布线方便、带宽大、还能和办公网融合。但便利背后,坑也不少。
我个人习惯,拿到一个新协议,先看它的“信封”长什么样。Modbus TCP的“信封”叫MBAP报文头,这是和RTU最根本的区别。
4.1 MBAP报文头:Modbus TCP的“身份证”
RTU协议里,报文靠3.5个字符的静默时间来分割。TCP是流式协议,数据像水管里的水一样源源不断,你怎么知道哪一段是一个完整的请求?
答案就在MBAP头里。它固定7个字节,放在每个Modbus报文的最前面。我画个简表你就明白了:
| 字段 | 长度(字节) | 描述 | 我的一点经验 |
|---|---|---|---|
| 事务处理标识符 | 2 | 用于匹配请求和响应。客户端每发一个请求,这个值加1。 | 很多设备不检查这个,但我建议你严格递增,否则调试时抓包会疯掉。 |
| 协议标识符 | 2 | 固定为0x0000,表示Modbus协议。 | 嗯,这里要注意,如果收到非0值,直接丢弃。 |
| 长度 | 2 | 后续字节的总数(从单元标识符开始算起)。 | 这是解析的关键。长度不对,后面全乱套。 |
| 单元标识符 | 1 | 相当于RTU里的从站地址。用于网关路由。 | 直连时通常设为0x01或0xFF。 |
你看,有了这个长度字段,TCP粘包的问题就迎刃而解了。你想想看,接收方先收7个字节,解析出长度,再收那么多字节,一个完整的报文就到手了。
核心要点:Modbus TCP去掉了CRC校验,因为TCP/IP协议栈自己会保证数据完整性。但这也意味着,如果你的网络环境很差(丢包、乱序),应用层是不知道的。我曾经在一条老旧的工业以太网上吃过这个亏,数据偶尔错一个位,CRC在RTU里能发现,TCP里就悄无声息地过去了。后来我强制在应用层加了个累加和校验,才彻底解决。
4.2 功能码15:写多个线圈(Force Multiple Coils)
功能码15,说白了就是一次写一堆开关量。比如你要同时控制10个继电器,用功能码05一个一个写太慢了,用15一次搞定。
请求帧结构是这样的:
从站地址 | 功能码(0x0F) | 起始地址(2字节) | 线圈数量(2字节) | 字节计数(1字节) | 数据(N字节)
这里有个容易踩的坑:线圈数量是按位算的,但数据是按字节打包的。比如你要写20个线圈,数据区需要3个字节(20位 = 2.5字节,向上取整)。
我举个例子:
请求:01 0F 00 00 00 14 03 CD 01 00
解释:
- 01:从站地址
- 0F:功能码15
- 00 00:起始地址(从0号线圈开始)
- 00 14:写20个线圈(十进制20)
- 03:数据区有3个字节
- CD 01 00:二进制是 1100 1101 0000 0001 0000 0000
注意:线圈0对应第一个字节的bit0,线圈1对应bit1...以此类推
响应很简单,就是原样返回请求的前6个字节(从站地址、功能码、起始地址、线圈数量)。
避坑指南:我曾经遇到一个PLC,写线圈数量必须是8的倍数,否则报异常码02(非法数据地址)。查了半天手册才发现,它内部是按字节处理的。所以,写之前最好看看设备手册,确认是否支持任意数量的线圈写入。
4.3 功能码16:写多个寄存器(Preset Multiple Registers)
功能码16和功能码15是对应的,只不过操作对象从线圈换成了保持寄存器。这是工程中最常用的写操作,没有之一。
请求帧结构:
从站地址 | 功能码(0x10) | 起始地址(2字节) | 寄存器数量(2字节) | 字节计数(1字节) | 数据(N字节)
注意:每个寄存器占2个字节,所以字节计数 = 寄存器数量 × 2。
看个实际例子:
请求:01 10 00 01 00 02 04 00 0A 00 14
解释:
- 01:从站地址
- 10:功能码16
- 00 01:从地址1的寄存器开始写
- 00 02:写2个寄存器
- 04:数据区有4个字节
- 00 0A:第一个寄存器写10(十进制)
- 00 14:第二个寄存器写20(十进制)
响应帧:
01 10 00 01 00 02
和功能码15一样,响应只返回起始地址和数量,不返回具体数据。
我的小技巧:写多个寄存器时,尽量保持寄存器地址连续。有些设备虽然支持非连续地址的批量写,但内部处理会很慢,甚至导致总线超时。我习惯把需要批量写的寄存器规划在连续的地址段里,这样一次16功能码搞定,效率最高。
4.4 实际工程中的常见问题
做Modbus TCP项目,来来去去就那么几个坑。我帮你总结一下:
4.4.1 连接管理问题
Modbus TCP本身是无状态的。客户端发一个请求,服务器回一个响应,然后就完了。但TCP连接是有状态的。很多新手会犯一个错:每个请求都新建一个Socket,用完就关。这在Windows上问题不大,但在Linux上,频繁的TIME_WAIT状态会耗尽端口资源。
我建议:复用连接。一个客户端对一个服务器,只建立一个TCP连接,所有请求都走这个连接。除非检测到连接断开,才重连。
4.4.2 超时处理
Modbus协议本身没有定义超时时间。但实际工程中,你必须设置。设多少合适?
- 响应超时:我一般设500ms到3秒,取决于网络延迟和设备响应速度。
- 帧间隔超时:TCP里不需要,因为MBAP头已经定义了帧边界。
但要注意:不要设得太短。我曾经在一个项目里把超时设成200ms,结果现场有台老PLC偶尔响应慢一点,就频繁报超时,整个系统都在重试。后来改成1秒,世界清净了。
4.4.3 字节序问题
Modbus协议规定:多字节数据采用大端序(Big-Endian)。也就是高字节在前,低字节在后。
但很多嵌入式设备、PLC的CPU是小端序(Little-Endian)。这就需要在软件里做转换。我见过最离谱的Bug,是一个工程师忘了做转换,结果写进去的数值大了256倍。
记住:在x86、ARM等常见平台上,发送前要调用htons()、htonl()转换,接收后调用ntohs()、ntohl()还原。别偷懒,每个多字节字段都要处理。
4.4.4 异常码处理
Modbus协议定义了一套异常码。当服务器无法处理请求时,会返回异常响应。格式是:
从站地址 | 功能码(最高位置1) | 异常码
常见的异常码有:
- 01:非法功能码——设备不支持这个功能。
- 02:非法数据地址——你访问的寄存器不存在。
- 03:非法数据值——数据超出范围(比如写0xFFFF到只接受0-100的寄存器)。
- 04:从站设备故障——设备内部出问题了。
我建议:客户端一定要处理异常码,不能只当没看见。很多系统出问题,就是因为异常码被忽略了,客户端还在傻等正常响应。
4.4.5 广播与多主站
Modbus TCP支持广播吗?严格来说,不支持。因为广播地址(0x00)在TCP里没有意义——你总不能把数据包发给整个网段的所有设备吧?
那多主站呢?多个客户端同时访问一个服务器?技术上可行,但我强烈不建议这么做。两个客户端同时写同一个寄存器,数据会乱掉。如果非要这么做,必须在应用层实现互斥机制,比如用“令牌”或者“锁寄存器”。
好了,Modbus协议的核心内容,到这儿就讲完了。下一节我们聊聊Modbus的进阶话题——如何用C语言实现一个轻量级的Modbus协议栈。到时候我会把我自己写的一个小库拿出来,一行一行地讲给你听。