4、数据链路层(Modbus RTU):Modbus RTU帧格式、地址域、功能码、CRC16校验算法实现
好,咱们进入数据链路层。这一层在电池化成设备通信里,说白了就是Modbus RTU的天下。你想想看,化成柜里几十上百个通道,每个通道都是一个从机,主机(通常是上位机或者PLC)要跟它们挨个对话,总得有个规矩吧?Modbus RTU就是这个规矩。
我个人习惯把数据链路层比作「信封」。应用层的数据(比如你要读电压、写电流)是信纸,数据链路层负责把它装进信封,写上收件人地址(地址域)、信的类型(功能码),最后再贴个防伪标签(CRC校验)。这样,从机收到信封后,先撕开看看防伪标签对不对,再决定要不要读信。
4.1 Modbus RTU帧格式
先看帧结构。一个完整的Modbus RTU帧长这样:
| 地址域 | 功能码 | 数据域 | CRC16校验 |
| 1字节 | 1字节 | N字节 | 2字节 |
嗯,这里要注意:整个帧是连续发送的,字节之间没有间隔。如果两个字节之间的空闲时间超过1.5个字符时间,从机就认为帧结束了。这个时间参数在9600波特率下大约是1.5ms,我建议你实际项目中留点余量,设成2ms比较稳妥。
我曾经在一个项目中吃过这个亏。当时从机老是丢帧,查了半天发现是主机发送时字节间隔偶尔超过1.5个字符时间。后来我把从机的超时判断放宽到3.5个字符时间,问题就解决了。所以你看,协议是死的,工程是活的。
4.2 地址域
地址域占1个字节,取值范围0~247。其中:
- 0x00:广播地址,所有从机都要接收,但不回复
- 0x01~0xF7:单播地址,每个从机一个唯一地址
- 0xF8~0xFF:保留地址,别用
在化成设备里,我一般这样分配地址:
| 设备类型 | 地址范围 | 说明 |
|---|---|---|
| 化成柜主控 | 0x01 | 通常固定 |
| 通道板1 | 0x11~0x1F | 每个通道板16个通道 |
| 通道板2 | 0x21~0x2F | 同上 |
| 温度采集模块 | 0x30~0x3F | 独立模块 |
为什么这么分?因为化成柜的通道板通常按机柜位置排列,地址跟物理位置对应,现场排查故障时一眼就能看出来是哪个板子出了问题。这个习惯我用了七八年,一直没出过乱子。
4.3 功能码
功能码决定了你要干什么。化成设备里常用的就这几个:
| 功能码 | 名称 | 用途 |
|---|---|---|
| 0x03 | 读保持寄存器 | 读取电压、电流、温度等实时数据 |
| 0x06 | 写单个寄存器 | 设置单通道参数(如目标电压) |
| 0x10 | 写多个寄存器 | 批量设置参数(如工步序列) |
| 0x17 | 读/写多个寄存器 | 同时读写,用于实时控制 |
我个人最常用的是0x03和0x06。0x10虽然能批量写,但化成设备里每个通道的参数往往独立变化,用0x06更灵活。0x17这个功能码比较特殊,它允许你在一个帧里同时读写,我一般在做恒流恒压切换时用它,能减少一次握手时间。
举个例子,读通道1的电压:
主机发送:01 03 00 00 00 01 84 0A
从机回复:01 03 02 0B B8 7A 3F
解释一下:
01:地址,通道板103:读保持寄存器00 00:起始寄存器地址(电压寄存器偏移0)00 01:读取1个寄存器84 0A:CRC校验
从机回复的0B B8就是电压值,换算成十进制是3000,如果分辨率是1mV,那就是3.000V。嗯,这里要注意:Modbus是大端模式,高字节在前。
4.4 CRC16校验算法实现
CRC16是Modbus RTU的防伪标签。没有它,帧里任何一个字节出错,从机都可能执行错误的指令。在化成设备这种强干扰环境(大电流、变频器、继电器),CRC就是保命符。
标准Modbus RTU用的是CRC-16/MODBUS,多项式是0x8005,初始值0xFFFF。我直接给你一个我用了多年的C语言实现:
uint16_t crc16_modbus(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 >>= 1;
crc ^= 0xA001; // 0x8005的反转
}
else
{
crc >>= 1;
}
}
}
return crc;
}
这个算法是逐字节计算的,在STM32这类MCU上跑,9600波特率下完全够用。如果你用的是更高性能的芯片,可以用查表法,速度能快10倍以上。但我个人习惯用这个逐字节版本,因为代码短,移植方便,而且不容易出错。
关键点:CRC校验结果要低字节在前发送。比如计算出的CRC是0x0A84,发送顺序是84 0A。这个顺序跟Modbus协议里其他字段(大端)正好相反,很多新手在这里栽跟头。
我的调试技巧:写一个测试函数,用已知数据验证CRC结果。比如数据01 03 00 00 00 01,CRC应该是84 0A。把这个写进单元测试,每次改代码后跑一遍,能省下大量排查时间。
我曾经踩过的坑:有一次从机偶尔回复错误帧,查了三天,最后发现是CRC计算时把地址域也算进去了两次。原来我在发送函数里手动添加了地址域,CRC函数里又自动加了一次。所以,写代码时一定要明确:CRC计算的范围是从地址域开始,到数据域结束,不包括CRC本身。
4.5 实际项目中的避坑指南
最后,分享几个我在化成设备项目中积累的经验:
- 地址冲突检测:上电时主机发广播帧,所有从机回复自己的地址。如果同一个地址有多个回复,说明地址冲突,系统报警。这个机制我建议每个项目都加上。
- 功能码扩展:标准Modbus功能码不够用时,可以用0x65~0x6F这些用户自定义码。我在一个项目中用0x65做「批量校准」,省了不少事。
- CRC性能:如果MCU主频低(比如8MHz的51单片机),CRC计算可能成为瓶颈。这时候可以用查表法,或者把CRC计算放到中断里做。
- 帧超时处理:从机收到不完整帧时,不要立即回复异常,等3.5个字符时间后再判断。我曾经见过一个从机,收到半个帧就回复异常,结果把主机搞懵了。
好了,数据链路层就讲到这里。下一章咱们进入网络层,看看Modbus TCP是怎么在以太网上跑的。说实话,TCP比RTU简单多了,因为CRC和地址都由底层协议帮你搞定了。但RTU的功底必须扎实,因为化成设备里90%的通信还是走RS485的RTU模式。