一、前言
在单片机、工业控制、传感器采集、PLC 通信等场景中,Modbus 是非常常见的一种通信协议。
很多刚接触 Modbus 的同学会有几个疑问:
-
Modbus 报文到底长什么样?
-
主机和从机是怎么通信的?
-
CRC 校验是干什么的?
-
C 语言里如何把数据打包成 Modbus 报文?
-
收到一帧数据后又该怎么解析?
本文结合一个简单的 ModBusKit 工具代码,带大家从小白角度理解 Modbus 的基本组包、解包和 CRC 校验流程。
二、什么是 Modbus?
Modbus 是一种常用于工业设备之间通信的协议。
简单来说,它规定了:
主机发送什么格式的数据,从机应该如何理解;
从机回复什么格式的数据,主机应该如何解析。
常见的 Modbus 类型有:
| 类型 | 说明 |
|---|---|
| Modbus RTU | 常用于串口通信,如 RS485 |
| Modbus ASCII | 使用 ASCII 字符传输,较少用 |
| Modbus TCP | 基于以太网 TCP/IP 通信 |
在单片机项目中,最常见的是 Modbus RTU。
三、Modbus RTU 基本报文格式
一个常见的 Modbus RTU 报文大致由以下几部分组成:
从机地址 + 功能码 + 数据区 + CRC校验
例如:
| 字段 | 含义 |
|---|---|
| 从机地址 | 要访问哪个设备 |
| 功能码 | 要执行什么操作,例如读寄存器、写寄存器 |
| 数据区 | 寄存器地址、数据长度、实际数据等 |
| CRC 校验 | 用于判断数据传输是否出错 |
本文代码中的 MODBUS_PacketCmd() 函数就是按照类似格式进行组包的:先写入从机地址,再写入命令码,再写入数据长度和数据内容,最后计算并追加 CRC16 校验码。
四、代码整体结构
本代码主要由两个文件组成:
/**
************************************************************
************************************************************
************************************************************
* 文件名: ModBusKit.c
*
* 作者: 张继瑞
*
* 日期: 2017-12-14
*
* 版本: V1.0
*
* 说明: ModBus协议
*
* 修改记录:
************************************************************
************************************************************
************************************************************
**/
//协议头文件
#include "ModBusKit.h"
//C库
#include <string.h>
//==========================================================
// 函数名称: MODBUS_NewBuffer
//
// 函数功能: 申请内存
//
// 入口参数: modbusPacket:包结构体
// size:大小
//
// 返回参数: 无
//
// 说明: 1.可使用动态分配来分配内存
// 2.可使用局部或全局数组来指定内存
//==========================================================
void MODBUS_NewBuffer(MODBUS_PACKET_STRUCTURE *modbusPacket, uint32 size)
{
uint32 i = 0;
if(modbusPacket->_data == NULL)
{
modbusPacket->_memFlag = MEM_FLAG_ALLOC;
modbusPacket->_data = (uint8 *)MODBUS_MallocBuffer(size);
if(modbusPacket->_data != NULL)
{
modbusPacket->_len = 0;
modbusPacket->_size = size;
for(; i < modbusPacket->_size; i++)
modbusPacket->_data[i] = 0;
}
}
else
{
modbusPacket->_memFlag = MEM_FLAG_STATIC;
for(; i < modbusPacket->_size; i++)
modbusPacket->_data[i] = 0;
modbusPacket->_len = 0;
if(modbusPacket->_size < size)
modbusPacket->_data = NULL;
}
}
//==========================================================
// 函数名称: MODBUS_DeleteBuffer
//
// 函数功能: 释放数据内存
//
// 入口参数: edpPacket:包结构体
//
// 返回参数: 无
//
// 说明: 当使用的局部或全局数组时不释放内存
//==========================================================
void MODBUS_DeleteBuffer(MODBUS_PACKET_STRUCTURE *modbusPacket)
{
if(modbusPacket->_memFlag == MEM_FLAG_ALLOC)
MODBUS_FreeBuffer(modbusPacket->_data);
modbusPacket->_data = NULL;
modbusPacket->_len = 0;
modbusPacket->_size = 0;
modbusPacket->_memFlag = MEM_FLAG_NULL;
}
//==========================================================
// 函数名称: MODBUS_CRC16
//
// 函数功能: CRC16校验
//
// 入口参数: buf:待计算的buf
// length:数据长度
//
// 返回参数: CRC16校验结果
//
// 说明:
//==========================================================
uint16 MODBUS_CRC16(uint8 *buf, uint16 length)
{
uint16 i = 0, j = 0, tmp = 0;
uint16 crc = 0xFFFF;
for(i = 0; i < length; i++)
{
crc = buf[i] ^ crc;
for(j = 0; j < 8; j++)
{
tmp = crc & 0x0001;
crc = crc >> 1;
if(tmp)
crc = crc ^ 0xA001;
}
}
return crc << 8 | crc >> 8;
}
//==========================================================
// 函数名称: MODBUS_Connect
//
// 函数功能: 登录组包
//
// 入口参数: serial:序列号
// pswd:密码
// proid:产品ID
// modbusPacket:包指针
//
// 返回参数: 0-成功 1-失败
//
// 说明:
//==========================================================
uint1 MODBUS_Connect(const int8 *serial, const int8 *pswd, const int8 *proid, MODBUS_PACKET_STRUCTURE *modbusPacket)
{
//分配内存---------------------------------------------------------------------
MODBUS_NewBuffer(modbusPacket, 52);
if(modbusPacket->_data == NULL)
return 1;
//Byte0~Byte10:报文:type,以0补齐11字节----------------------------------------
strncpy((int8 *)modbusPacket->_data + modbusPacket->_len, "type", 4);
modbusPacket->_len += 11;
//Byte11~Byte19:报文:name,以0补齐9字节----------------------------------------
strncpy((int8 *)modbusPacket->_data + modbusPacket->_len, "name", 4);
modbusPacket->_len += 9;
//Byte20~Byte31:报文:phone,以0补齐12字节-------------------------------------
strncpy((int8 *)modbusPacket->_data + modbusPacket->_len, serial, strlen(serial));
modbusPacket->_len += 12;
//Byte32~Byte40:报文:svrpwd,以0补齐9字节-------------------------------------
strncpy((int8 *)modbusPacket->_data + modbusPacket->_len, pswd, strlen(pswd));
modbusPacket->_len += 9;
//Byte41~Byte51:报文:id,以0补齐11字节-------------------------------------
strncpy((int8 *)modbusPacket->_data + modbusPacket->_len, proid, strlen(proid));
modbusPacket->_len += 11;
return 0;
}
//==========================================================
// 函数名称: MODBUS_PacketCmd
//
// 函数功能: 命令组包
//
// 入口参数: s_addr:从机地址
// m_cmd:modbus命令
// value:传感器值缓存
// value_cnt:传感器值个数
// modbusPacket:包指针
//
// 返回参数: 0-成功 1-失败
//
// 说明:
//==========================================================
_Bool MODBUS_PacketCmd(uint8 s_addr, uint8 m_cmd, uint16 *value, uint8 value_cnt, MODBUS_PACKET_STRUCTURE *modbusPacket)
{
uint8 i = 0;
uint16 crc = 0;
//分配内存---------------------------------------------------------------------
MODBUS_NewBuffer(modbusPacket, 5 + (value_cnt << 1));
if(modbusPacket->_data == NULL)
return 1;
//Byte0:从机地址--------------------------------------------------------------
modbusPacket->_data[modbusPacket->_len++] = s_addr;
//Byte1:命令码----------------------------------------------------------------
modbusPacket->_data[modbusPacket->_len++] = m_cmd;
//Byte2:数据长度(字节)--------------------------------------------------------
modbusPacket->_data[modbusPacket->_len++] = value_cnt << 1;
//Byte3~x:上传的数据----------------------------------------------------------
for(; i < value_cnt; i++)
{
modbusPacket->_data[modbusPacket->_len++] = value[i] >> 8;
modbusPacket->_data[modbusPacket->_len++] = value[i] & 0x00FF;
}
//Bytex~x+2:CRC16校验码------------------------------------------------------
crc = MODBUS_CRC16(modbusPacket->_data, modbusPacket->_len);
modbusPacket->_data[modbusPacket->_len++] = crc >> 8;
modbusPacket->_data[modbusPacket->_len++] = crc & 0x00FF;
return 0;
}
//==========================================================
// 函数名称: MODBUS_UnPacketCmd
//
// 函数功能: 命令解包
//
// 入口参数: s_addr:从机地址
// m_cmd:modbus命令
// r_addr:寄存器地址
// addr_len:读取长度
// data:原始数据
// len:数据长度
//
// 返回参数: 0-成功 其他-失败
//
// 说明:
//==========================================================
uint8 MODBUS_UnPacketCmd(uint8 *s_addr, uint8 *m_cmd, uint16 *r_addr, uint16 *addr_len, uint8 *data, uint16 len)
{
uint16 crc = 0;
if(len < 3)
return 1;
crc = MODBUS_CRC16(data, len - 2); //CRC16-高8位在前
if((data[len - 2] << 8 | data[len - 1]) == crc) //检查检验和是否正确
{
//提取从机地址--------------------------------------------------------------
*s_addr = data[0];
//提取命令码----------------------------------------------------------------
*m_cmd = data[1];
//提取寄存器地址------------------------------------------------------------
*r_addr = (uint16)data[2] << 8 | data[3];
//提取读取长度--------------------------------------------------------------
*addr_len = (uint16)data[4] << 8 | data[5];
return 0;
}
else
return 2; //CRC错误
}
//==========================================================
// 函数名称: MODBUS_PacketPing
//
// 函数功能: 心跳请求组包
//
// 入口参数: modbusPacket:包指针
//
// 返回参数: 0-成功 1-失败
//
// 说明:
//==========================================================
uint1 MODBUS_PacketPing(MODBUS_PACKET_STRUCTURE *modbusPacket)
{
MODBUS_NewBuffer(modbusPacket, 2);
if(modbusPacket->_data == NULL)
return 1;
//Byte0:0----------------------------------------------------------------------
modbusPacket->_data[modbusPacket->_len++] = 0;
//Byte1:0----------------------------------------------------------------------
modbusPacket->_data[modbusPacket->_len++] = 0;
return 0;
}
#ifndef _MODBUSKIT_H_
#define _MODBUSKIT_H_
#include "Common.h"
//=============================配置==============================
//===========可以提供RTOS的内存管理方案,也可以使用C库的=========
#include <stdlib.h>
#define MODBUS_MallocBuffer malloc
#define MODBUS_FreeBuffer free
//==========================================================
#ifndef NULL
#define NULL (void*)0
#endif
/*--------------------------------内存分配方案标志--------------------------------*/
#define MEM_FLAG_NULL 0
#define MEM_FLAG_ALLOC 1
#define MEM_FLAG_STATIC 2
typedef struct Buffer
{
uint8 *_data; //协议数据
uint32 _len; //写入的数据长度
uint32 _size; //缓存总大小
uint8 _memFlag; //内存使用的方案:0-未分配 1-使用的动态分配 2-使用的固定内存
} MODBUS_PACKET_STRUCTURE;
/*--------------------------------删包--------------------------------*/
void MODBUS_DeleteBuffer(MODBUS_PACKET_STRUCTURE *edpPacket);
/*--------------------------------登录组包--------------------------------*/
uint1 MODBUS_Connect(const int8 *serial, const int8 *pswd, const int8 *devid, MODBUS_PACKET_STRUCTURE *modbusPacket);
/*--------------------------------命令组包--------------------------------*/
_Bool MODBUS_PacketCmd(uint8 s_addr, uint8 m_cmd, uint16 *value, uint8 value_cnt, MODBUS_PACKET_STRUCTURE *modbusPacket);
/*--------------------------------命令解包--------------------------------*/
uint8 MODBUS_UnPacketCmd(uint8 *s_addr, uint8 *m_cmd, uint16 *r_addr, uint16 *addr_len, uint8 *data, uint16 len);
/*--------------------------------心跳请求组包--------------------------------*/
uint1 MODBUS_PacketPing(MODBUS_PACKET_STRUCTURE *modbusPacket);
#endif
ModBusKit.h
ModBusKit.c
其中:
| 文件 | 作用 |
|---|---|
ModBusKit.h |
定义结构体、宏、函数声明 |
ModBusKit.c |
实现 Modbus 组包、解包、CRC 等功能 |
头文件中定义了一个核心结构体:
typedef struct Buffer
{
uint8 *_data; // 协议数据
uint32 _len; // 已写入的数据长度
uint32 _size; // 缓存总大小
uint8 _memFlag; // 内存使用方式
} MODBUS_PACKET_STRUCTURE;
这个结构体可以理解为一个"数据包容器"。
它里面保存了:
-
_data:真正存放报文数据的数组 -
_len:当前已经写入了多少字节 -
_size:整个缓存区大小 -
_memFlag:这块内存是动态申请的,还是外部静态分配的
头文件中还把 malloc 和 free 封装成了宏,方便后续替换为 RTOS 的内存管理函数。
五、内存管理:申请和释放数据包缓存
1. 申请缓存:MODBUS_NewBuffer()
在组包之前,必须先准备一块缓存区,用来存储即将生成的 Modbus 报文。
代码中使用:
MODBUS_NewBuffer(modbusPacket, size);
它的作用是:
-
判断当前数据包是否已有缓存;
-
如果没有缓存,就动态申请一块内存;
-
如果已有缓存,就清空原来的数据;
-
设置
_len = 0,表示重新开始写入数据。
这个函数既支持动态内存,也支持用户自己提前准备好的静态数组。源码中通过 _memFlag 区分内存来源。
2. 释放缓存:MODBUS_DeleteBuffer()
使用完数据包之后,需要释放内存:
MODBUS_DeleteBuffer(&modbusPacket);
这个函数会判断当前内存是不是动态申请的。
如果是动态申请的,就调用 MODBUS_FreeBuffer() 释放;
如果是静态数组,就不会释放,只会清空结构体状态。
这样做可以避免误释放静态内存。
六、CRC16 校验是什么?
Modbus RTU 中,CRC16 是非常重要的一部分。
它的作用是:
判断一帧数据在传输过程中有没有出错。
比如主机发送:
01 03 00 00 00 02 CRC_H CRC_L
从机收到后,也会根据前面的数据重新计算 CRC。
如果计算出来的 CRC 和报文末尾携带的 CRC 一致,说明数据大概率没有出错;
如果不一致,说明数据在传输过程中可能被干扰了。
七、代码中的 CRC16 实现
源码中提供了:
uint16 MODBUS_CRC16(uint8 *buf, uint16 length)
核心流程如下:
-
CRC 初始值为
0xFFFF -
每次取一个字节参与异或
-
每个字节循环处理 8 位
-
如果最低位为 1,就和
0xA001异或 -
最后返回 CRC 结果
代码中最后返回的是:
return crc << 8 | crc >> 8;
也就是说,这份代码把 CRC 的高低字节做了一次交换,后续组包时按"高字节在前"的方式写入 CRC。源码注释中也写了"CRC16-高8位在前"。
这里要注意:
标准 Modbus RTU 中,CRC 通常是低字节在前、高字节在后。
但这份代码内部做了字节交换,并按照高字节在前进行比较和追加。
所以移植时一定要和自己的上位机、从机设备保持一致。
八、命令组包函数:MODBUS_PacketCmd()
这是本文最核心的函数之一。
函数原型如下:
_Bool MODBUS_PacketCmd(
uint8 s_addr,
uint8 m_cmd,
uint16 *value,
uint8 value_cnt,
MODBUS_PACKET_STRUCTURE *modbusPacket
);
参数含义:
| 参数 | 说明 |
|---|---|
s_addr |
从机地址 |
m_cmd |
Modbus 命令码 |
value |
要上传的数据数组 |
value_cnt |
数据个数,每个数据是 uint16 |
modbusPacket |
输出的数据包 |
1. 组包格式
这个函数生成的数据格式如下:
从机地址 + 命令码 + 数据字节数 + 数据内容 + CRC16
也就是:
| 字节位置 | 内容 |
|---|---|
| Byte0 | 从机地址 |
| Byte1 | 命令码 |
| Byte2 | 数据长度,单位是字节 |
| Byte3 ~ x | 数据内容 |
| 最后 2 字节 | CRC16 校验 |
源码中的处理逻辑是:
modbusPacket->_data[modbusPacket->_len++] = s_addr;
modbusPacket->_data[modbusPacket->_len++] = m_cmd;
modbusPacket->_data[modbusPacket->_len++] = value_cnt << 1;
因为每个 uint16 数据占 2 个字节,所以数据长度是:
value_cnt * 2
代码中使用了左移一位:
value_cnt << 1
效果等价于乘以 2。
2. 数据高字节在前
对于每一个 uint16 数据,代码是这样写入的:
modbusPacket->_data[modbusPacket->_len++] = value[i] >> 8;
modbusPacket->_data[modbusPacket->_len++] = value[i] & 0x00FF;
也就是说:
一个 16 位数据会被拆成两个 8 位字节,并且高字节在前,低字节在后。
例如:
value[i] = 0x1234;
写入报文后就是:
12 34
这也是很多通信协议中常见的"大端序"存储方式。
3. 追加 CRC 校验
数据写完后,函数会调用:
crc = MODBUS_CRC16(modbusPacket->_data, modbusPacket->_len);
然后把 CRC 追加到报文末尾:
modbusPacket->_data[modbusPacket->_len++] = crc >> 8;
modbusPacket->_data[modbusPacket->_len++] = crc & 0x00FF;
最终,一帧完整的 Modbus 数据就组好了。
九、命令解包函数:MODBUS_UnPacketCmd()
除了组包,实际通信中还需要解析收到的数据。
代码中提供了:
uint8 MODBUS_UnPacketCmd(
uint8 *s_addr,
uint8 *m_cmd,
uint16 *r_addr,
uint16 *addr_len,
uint8 *data,
uint16 len
);
参数含义:
| 参数 | 说明 |
|---|---|
s_addr |
解析出的从机地址 |
m_cmd |
解析出的命令码 |
r_addr |
解析出的寄存器地址 |
addr_len |
解析出的寄存器数量 |
data |
接收到的原始数据 |
len |
原始数据长度 |
1. 解包流程
函数主要做了三件事:
第一,判断数据长度是否合法:
if(len < 3)
return 1;
第二,计算 CRC:
crc = MODBUS_CRC16(data, len - 2);
第三,比较收到的 CRC 和计算出的 CRC:
if((data[len - 2] << 8 | data[len - 1]) == crc)
如果 CRC 正确,就继续解析:
*s_addr = data[0];
*m_cmd = data[1];
*r_addr = (uint16)data[2] << 8 | data[3];
*addr_len = (uint16)data[4] << 8 | data[5];
如果 CRC 错误,则返回 2。
2. 返回值说明
| 返回值 | 含义 |
|---|---|
0 |
解包成功 |
1 |
数据长度太短 |
2 |
CRC 校验失败 |
这对于调试串口通信非常有用。
如果你发现一直返回 2,大概率是以下问题:
-
CRC 高低字节顺序不一致
-
串口接收数据不完整
-
波特率或串口参数不一致
-
接收缓冲区丢数据
-
发送端和接收端协议格式不一致
十、心跳包函数:MODBUS_PacketPing()
代码中还有一个简单的心跳包函数:
uint1 MODBUS_PacketPing(MODBUS_PACKET_STRUCTURE *modbusPacket)
它申请 2 个字节的缓存,并写入:
00 00
心跳包通常用于:
-
保持连接
-
判断设备是否在线
-
定时通知服务器或主机当前设备还活着
不过这个心跳格式不是标准 Modbus RTU 的固定格式,更像是作者根据项目需求自定义的简单心跳数据。
十一、登录组包函数:MODBUS_Connect()
源码中还有一个 MODBUS_Connect() 函数:
uint1 MODBUS_Connect(
const int8 *serial,
const int8 *pswd,
const int8 *proid,
MODBUS_PACKET_STRUCTURE *modbusPacket
);
它会申请 52 字节缓存,然后依次填入:
| 字节范围 | 内容 |
|---|---|
| Byte0 ~ Byte10 | "type" |
| Byte11 ~ Byte19 | "name" |
| Byte20 ~ Byte31 | serial |
| Byte32 ~ Byte40 | pswd |
| Byte41 ~ Byte51 | proid |
这个函数看起来更像是某个项目里用于连接服务器或设备认证的自定义协议,并不是标准 Modbus RTU 的典型功能。
所以我们可以这样理解:
MODBUS_PacketCmd()和MODBUS_UnPacketCmd()更接近 Modbus 报文处理;
MODBUS_Connect()和MODBUS_PacketPing()更像是项目扩展功能。
十二、简单使用示例
假设我们要向从机地址 0x01 发送命令码 0x03,并携带两个数据:
uint16 value[2] = {0x1234, 0x5678};
MODBUS_PACKET_STRUCTURE packet = {0};
MODBUS_PacketCmd(0x01, 0x03, value, 2, &packet);
组包后,理论数据区大致为:
01 03 04 12 34 56 78 CRC_H CRC_L
含义如下:
| 数据 | 含义 |
|---|---|
01 |
从机地址 |
03 |
命令码 |
04 |
数据长度,两个 uint16,共 4 字节 |
12 34 |
第一个数据 |
56 78 |
第二个数据 |
CRC_H CRC_L |
CRC 校验 |
发送完成后,如果使用的是动态内存,需要释放:
MODBUS_DeleteBuffer(&packet);
十三、移植到单片机时需要注意什么?
1. 数据类型要提前定义好
源码中使用了:
uint8
uint16
uint32
uint1
int8
这些类型应该是在 Common.h 中定义的。
如果你的工程里没有这些类型,可以改成标准 C 类型:
#include <stdint.h>
uint8 -> uint8_t
uint16 -> uint16_t
uint32 -> uint32_t
int8 -> int8_t 或 char
2. 不建议在小内存 MCU 中频繁使用 malloc
虽然源码中默认使用:
#define MODBUS_MallocBuffer malloc
#define MODBUS_FreeBuffer free
但是在 STM32、CH32 等单片机中,频繁使用 malloc/free 可能造成内存碎片。
更推荐的方式是使用静态数组,例如:
uint8_t modbus_buf[128];
MODBUS_PACKET_STRUCTURE packet = {
._data = modbus_buf,
._len = 0,
._size = sizeof(modbus_buf),
._memFlag = MEM_FLAG_STATIC
};
这样可以减少动态内存带来的风险。
3. CRC 字节顺序一定要确认
这是 Modbus 调试中非常常见的问题。
有的设备要求:
CRC_L CRC_H
有的代码实现可能使用:
CRC_H CRC_L
本文源码中采用的是计算后交换字节,并以高字节在前的方式处理 CRC。移植时一定要结合实际设备手册确认。
4. 解包时要判断数据长度
源码中只判断了:
if(len < 3)
return 1;
但是后面会访问:
data[0] ~ data[5]
所以如果你要增强代码健壮性,建议改成:
if(len < 8)
return 1;
因为常见读寄存器请求至少包含:
从机地址 + 功能码 + 寄存器地址2字节 + 数量2字节 + CRC2字节
也就是至少 8 字节。
十四、总结
本文基于一个简单的 ModBusKit C 语言代码,梳理了 Modbus 入门中最重要的几个知识点:
-
Modbus 报文通常由从机地址、功能码、数据区和 CRC 校验组成;
-
MODBUS_PACKET_STRUCTURE用来保存一帧协议数据; -
MODBUS_NewBuffer()用于申请或清空数据缓存; -
MODBUS_DeleteBuffer()用于释放动态申请的缓存; -
MODBUS_CRC16()用于生成 CRC16 校验码; -
MODBUS_PacketCmd()用于生成一帧命令数据; -
MODBUS_UnPacketCmd()用于解析收到的 Modbus 数据; -
移植到单片机时,要重点注意数据类型、内存管理和 CRC 字节顺序。
对于刚入门的同学来说,理解 Modbus 不要一开始就死记协议文档,而是先抓住一句话:
Modbus 本质上就是按照固定格式收发一串字节。
只要搞清楚每个字节代表什么,再配合 CRC 校验,Modbus 通信就没有那么难了。