3. Modbus协议基础(下):异常码定义、CRC校验原理、广播与单播机制、协议栈分层思想

好,咱们接着聊Modbus协议的下半部分。上一节我们把协议的基本框架搭起来了,这一节要深入几个关键点——异常码、CRC校验、广播单播,还有协议栈怎么分层。这些内容,说实在的,你在实际项目中几乎天天都要打交道。

3.1 异常码定义:从站说“不”的方式

你想想看,主站发了一条指令,从站不一定每次都能乖乖执行。比如你让从站读一个不存在的寄存器,或者写一个只读的线圈,这时候从站就得告诉主站:“兄弟,这事儿我办不了。” 怎么告诉?靠的就是异常码。

Modbus的异常响应帧结构很简单:功能码 + 0x80(把最高位置1),后面跟一个字节的异常码。举个例子,主站发功能码0x03读保持寄存器,从站返回0x83,后面跟着异常码02,意思就是“你要读的地址不存在”。

我刚开始做Modbus从站的时候,犯过一个低级错误——异常码返回格式不对。当时从站返回了0x03而不是0x83,主站那边直接解析成正常响应了,结果数据全乱套。排查了一下午才发现,原来是异常码的标识位没置上去。嗯,这个坑你们要注意。

常见的异常码有这么几个:

异常码 名称 含义
01 非法功能码 从站不支持这个功能码
02 非法数据地址 你要读写的地址超出了范围
03 非法数据值 数据值不合法,比如写线圈时写了0x02
04 从站设备故障 从站内部出问题了,比如硬件异常
06 从站忙 从站正在处理其他任务,没空理你
我的经验:异常码04(从站设备故障)在调试阶段特别有用。我曾经在移植协议栈时,发现从站偶尔返回04,排查后发现是某个寄存器地址映射错了,导致内存访问越界。所以,别把异常码当成“错误”,它其实是调试的好帮手。

3.2 CRC校验原理:数据完整性的守护者

Modbus RTU模式用的是CRC-16校验,多项式是0x8005。说白了,就是给每一帧数据算一个16位的校验值,接收方收到后重新算一遍,如果对不上,说明数据在传输过程中被污染了。

CRC的计算原理其实不复杂。你把数据看成一个很长的二进制数,用这个数除以多项式(模2除法),余数就是CRC值。但实际实现时,没人真去做除法,都是用查表法或者移位法。

我个人习惯用查表法,速度快,代码也简洁。下面是一个标准的CRC-16计算函数:

// CRC-16 查表法实现
uint16_t crc16(uint8_t *data, uint16_t len) {
    uint16_t crc = 0xFFFF;
    uint16_t i, j;
    
    for (i = 0; i < len; i++) {
        crc ^= data[i];
        for (j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc = crc >> 1;
            }
        }
    }
    return crc;
}

注意看,这里用的多项式是0xA001,实际上是0x8005的反转形式。Modbus协议规定CRC采用小端序传输,也就是说低字节在前,高字节在后。这一点很容易搞反,我见过不少新手把CRC字节顺序弄错,结果通信死活不通。

避坑指南:我曾经在一个项目中,CRC计算明明是对的,但主站就是不认。折腾了两天,最后发现是CRC的初始值设成了0x0000,而Modbus标准要求初始值是0xFFFF。就这一个字节的差别,让我加了两天班。所以,初始值、多项式、字节顺序,这三个参数一个都不能错。

3.3 广播与单播机制:一对多与一对一

Modbus支持两种通信模式:单播和广播。

单播就是主站给某个特定从站发指令,从站地址是1到247。从站收到后必须回复,主站通过回复来确认指令是否执行成功。这是最常用的模式,咱们平时说的“一问一答”就是它。

广播呢,主站把地址设为0,所有从站都会收到这条指令,但从站不需要回复。广播一般用于同步操作,比如让所有从站同时复位,或者同时设置某个参数。

这里有个关键点:广播只支持写操作,不支持读操作。你想想看,如果主站广播一条读指令,所有从站都回复,那总线就乱套了。所以Modbus协议规定,广播帧只能用于写单个线圈、写单个寄存器、写多个线圈、写多个寄存器这几种功能码。

我在做工业现场项目时,广播用得不多,但一旦用上就很关键。比如有一次,现场有30多个从站设备,需要同时启动某个流程。如果用单播一个一个发,时间差会导致设备不同步。用广播,一条指令下去,所有设备同时响应,效果立竿见影。

实现要点:在移植协议栈时,广播处理逻辑要单独写。因为广播帧不需要回复,所以你的协议栈在处理地址为0的帧时,应该执行完命令后直接返回,不要调用发送函数。否则从站会往总线上发数据,造成冲突。

3.4 协议栈分层思想:把复杂问题拆开

好了,前面讲了这么多细节,怎么把它们组织成一个可用的协议栈?我的答案是——分层

协议栈分层,说白了就是把不同职责的代码分开。Modbus协议栈我一般分成三层:

  • 物理层/驱动层:负责串口收发、定时器管理、GPIO控制。这一层跟硬件绑定,移植时主要改这里。
  • 协议核心层:负责帧解析、CRC校验、异常码生成、地址匹配。这一层是纯逻辑,跟硬件无关。
  • 应用层:负责处理具体的功能码,比如读线圈、写寄存器。这一层跟你的业务逻辑相关。

为什么要分层?我举个实际例子。有一次我需要把Modbus协议栈从STM32移植到GD32上,如果代码是揉成一团的,那得从头到尾改一遍。但因为我分了层,只需要改驱动层的串口和定时器代码,协议核心层和应用层一行代码都没动。两天就搞定了移植。

下面这张图是我常用的协议栈分层结构,你可以参考一下:

Modbus从站协议栈分层结构 应用层 功能码处理:读线圈、写寄存器、异常码生成 协议核心层 帧解析、CRC校验、地址匹配、广播识别 驱动层(硬件相关) 串口收发、定时器管理、GPIO控制 数据流向:驱动层 → 协议核心层 → 应用层 移植时只需修改驱动层,核心层和应用层保持不动

你看,数据从串口进来,驱动层先收完一帧数据,然后交给协议核心层。核心层做CRC校验、地址匹配,如果地址不对直接丢弃。地址匹配上了,再根据功能码分发到应用层。应用层处理完,把结果返回给核心层,核心层封装成响应帧,最后驱动层发出去。

这个流程,每一步职责都很清晰。你在写代码的时候,也要保持这种思路——每个函数只做一件事,做好一件事。

我的建议:刚开始移植协议栈时,别急着把所有功能都实现。先把驱动层的收发调通,能收到数据、能发出去,这就成功了一半。然后加上CRC校验,确保数据完整性。最后再慢慢加功能码处理。一步一步来,出问题了也好定位。

好了,这一节的内容就到这里。异常码、CRC校验、广播单播、分层思想,这四个知识点是Modbus协议栈的核心。下一节我们会开始动手写代码,从零搭建一个Modbus从站协议栈。到时候,这些理论都会变成实实在在的代码。


公众号:蓝海资料掘金营,微信deep3321