3、Modbus协议详解(上):Modbus RTU帧结构、功能码分类(01-06)、CRC校验计算
各位工程师朋友,咱们今天来啃一块硬骨头——Modbus RTU。说实话,这协议我用了快二十年,从最早的8位单片机到现在的ARM Cortex-M,几乎每个工业项目都离不开它。你想想看,一个1980年代诞生的协议,到现在还是工业通信的标配,这本身就说明了很多问题。
3.1 Modbus RTU帧结构——别被那些字节吓到
Modbus RTU的帧结构其实特别简单。我刚开始接触时,总觉得协议栈很神秘,后来拆开一看,就四个部分:地址码、功能码、数据区、校验码。说白了,就是一个带地址的信封。
咱们直接看格式:
| 地址码 (1字节) | 功能码 (1字节) | 数据区 (N字节) | CRC校验 (2字节) |
这里有个关键点——字节序。Modbus RTU采用大端模式,也就是高位字节在前。我在项目中遇到过好几次,新手工程师把CRC高低字节搞反了,结果通信死活不通。嗯,这里要注意:CRC校验码是低字节在前,高字节在后,跟数据区的字节序是反的。
帧结构要点:
- 地址码:0x01-0xF7(1-247),0x00是广播地址
- 功能码:1字节,决定操作类型
- 数据区:长度可变,最大253字节
- CRC:2字节,循环冗余校验
帧与帧之间需要至少3.5个字符时间的静默间隔。这个时间怎么算?以9600波特率为例,一个字符约1ms,3.5个字符就是3.5ms。我曾经在一个项目中,因为定时器精度不够,导致帧间隔判断出错,最后换了晶振才解决。
3.2 功能码分类(01-06)——最常用的六个操作
Modbus定义了数十种功能码,但实际项目中,80%的场景只用到了01到06。我个人习惯把这六个功能码分成两组:位操作和字操作。
| 功能码 | 名称 | 操作对象 | 读写类型 |
|---|---|---|---|
| 0x01 | 读取线圈状态 | 位 | 只读 |
| 0x02 | 读取离散输入状态 | 位 | 只读 |
| 0x03 | 读取保持寄存器 | 字(16位) | 读写 |
| 0x04 | 读取输入寄存器 | 字(16位) | 只读 |
| 0x05 | 写单个线圈 | 位 | 只写 |
| 0x06 | 写单个寄存器 | 字(16位) | 只写 |
3.2.1 功能码01:读取线圈状态
这个功能码用来读取从设备的DO(数字量输出)。请求帧格式很简单:
从站地址 + 0x01 + 起始地址(2字节) + 线圈数量(2字节) + CRC(2字节)
举个例子,读取地址为0x01的从站,从线圈地址0x0000开始的10个线圈:
请求:01 01 00 00 00 0A BC 0D
响应:01 01 02 CD 01 ??
响应中的数据区,每个字节代表8个线圈的状态。CD(0xCD)二进制是1100 1101,对应线圈0-7的状态。这里要注意:最低位对应起始地址。我见过有人把位序搞反,结果读出来的数据全是反的。
3.2.2 功能码02:读取离散输入状态
这个跟01很像,区别在于读取的是DI(数字量输入),是只读的。你想想看,传感器信号、按钮状态这些,都是用02来读的。
实战经验: 我在一个水处理项目中,用02功能码读取液位开关状态。当时遇到一个问题:多个从站同时上报数据时,总线冲突导致数据错乱。解决方案是在每个从站程序中加入随机延时,避免同时响应。
3.2.3 功能码03:读取保持寄存器
这是最常用的功能码,没有之一。保持寄存器是16位的,可以读写。模拟量采集、参数设置、状态读取,全用它。
请求:01 03 00 00 00 02 C4 0B
响应:01 03 04 00 0A 00 14 ??
这个例子读取了两个寄存器,返回的数据是0x000A(10)和0x0014(20)。注意:每个寄存器占2字节,所以响应中的数据长度是寄存器数量的两倍。
3.2.4 功能码04:读取输入寄存器
04和03的区别在于:04读取的是只读的输入寄存器,通常用于读取模拟量输入(AI)。比如温度传感器、压力变送器的原始值。
避坑指南: 我曾经在一个项目中,用03功能码去读AI模块,结果返回的数据全是0。查了半天才发现,AI模块只支持04功能码。所以,一定要先看设备手册,别想当然。
3.2.5 功能码05:写单个线圈
写单个线圈,用来控制DO输出。数据区只有2字节:0xFF00表示ON,0x0000表示OFF。其他值都是非法的。
请求:01 05 00 00 FF 00 8C 3A // 打开线圈0
响应:01 05 00 00 FF 00 8C 3A // 回显,与请求相同
这里有个细节:响应帧和请求帧完全一样。这是Modbus的设计特点,叫"回显确认"。我刚开始做开发时,以为响应会返回线圈状态,结果发现直接回显请求帧,省事。
3.2.6 功能码06:写单个寄存器
写单个保持寄存器,用来设置参数。比如设定PID的给定值、修改设备地址等。
请求:01 06 00 01 00 64 48 0A // 向地址0x0001写入100
响应:01 06 00 01 00 64 48 0A // 回显
3.3 CRC校验计算——别怕,有现成算法
CRC校验是Modbus RTU的保命符。没有它,数据在传输过程中被干扰了都不知道。CRC-16/MODBUS的生成多项式是:x^16 + x^15 + x^2 + 1,也就是0x8005。
计算步骤其实不复杂:
- 初始化CRC寄存器为0xFFFF
- 对每个字节,与CRC低字节异或
- 右移一位,如果移出的位是1,则与0xA001异或
- 重复8次,处理完所有字节
- 最终结果取反,得到CRC校验码
直接上代码,这是我用了多年的查表法实现:
// CRC-16/MODBUS 查表法实现
uint16_t crc16_modbus(uint8_t *data, uint16_t len)
{
uint16_t crc = 0xFFFF;
uint16_t i;
while (len--)
{
crc ^= *data++;
for (i = 0; i < 8; i++)
{
if (crc & 0x0001)
crc = (crc >> 1) ^ 0xA001;
else
crc >>= 1;
}
}
return crc;
}
性能优化: 如果MCU资源紧张,可以预生成256字节的查表表,把内层循环换成查表操作。我在一个STM32F103的项目中,用查表法把CRC计算时间从200us降到了20us,效果很明显。
验证一下:发送01 03 00 00 00 01,CRC应该是0x840A。你可以用上面的代码算算看。
3.4 实战中的那些坑
最后分享几个我踩过的坑:
- 地址冲突:两个从站设成同一个地址,总线直接瘫痪。解决办法是每个设备出厂时设置唯一地址,或者用拨码开关。
- 波特率不匹配:主站设9600,从站设19200,通信时有时无。我建议在设备上电时做一次自动波特率检测。
- CRC计算错误:很多人把CRC的高低位搞反。记住:低字节在前,高字节在后。
- 帧间隔不够:3.5字符时间的间隔必须严格遵守,否则从站会把两帧当成一帧处理。
好了,这一章就到这里。下一章咱们继续聊功能码07-16,以及Modbus TCP和RTU的互转。有什么问题,欢迎在评论区交流。