2. Modbus协议详解(上):Modbus RTU帧结构、地址映射、功能码(03/06/16)、CRC校验实战

各位同行,今天我们来聊聊Modbus RTU。说实话,这个协议在储能系统里太常见了。BMS、PCS、EMS之间通信,十有八九都在用Modbus。我最早接触它是在一个光伏电站项目上,当时被CRC校验折腾了一整天——嗯,今天咱们就把这些坑都填上。

2.1 Modbus RTU帧结构——说白了就是数据怎么打包

Modbus RTU的帧结构其实很简单。我习惯把它想象成一个快递包裹:有地址、有指令、有数据、最后还有个校验码。具体长这样:

地址域 功能码 数据域 CRC校验
1字节 1字节 N字节 2字节

举个例子,你要读取BMS的电池电压,发送的报文可能是这样的:

01 03 00 00 00 02 C4 0B

拆开来看:

  • 01 —— 从机地址,也就是BMS的ID
  • 03 —— 功能码,表示我要读保持寄存器
  • 00 00 —— 起始地址,从0号寄存器开始
  • 00 02 —— 读取2个寄存器(4字节数据)
  • C4 0B —— CRC校验,低字节在前
我的习惯:调试时先用串口助手抓一帧完整的报文,对照着看每个字节的含义。比看文档直观多了。

2.2 地址映射——寄存器到底怎么分配?

地址映射是Modbus协议里最容易出问题的地方。我见过太多项目因为地址对不上而联调失败。

Modbus定义了四种数据对象,但在储能系统里,我们最常用的是保持寄存器(Holding Register),也就是功能码03和06/16操作的对象。

数据对象 访问类型 对应功能码 地址范围
线圈(Coil) 读写 01/05/15 00001-09999
离散输入(Discrete Input) 只读 02 10001-19999
输入寄存器(Input Register) 只读 04 30001-39999
保持寄存器(Holding Register) 读写 03/06/16 40001-49999

注意,这里有个坑:协议地址和物理地址的区别。Modbus协议里,地址是从0开始的,但很多设备厂家文档里写的是40001这种PLC地址。我曾经因为没搞清楚这个,浪费了整整一个下午。

避坑指南:协议地址 = PLC地址 - 40001。比如文档说"电池电压在40010",那协议里实际地址是9(0x0009)。

2.3 功能码03/06/16——读写操作的核心

这三个功能码,基本覆盖了储能系统90%的通信需求。咱们一个一个说。

2.3.1 功能码03:读保持寄存器

这是最常用的读操作。请求帧和响应帧的结构如下:

请求帧(主机→从机):

从机地址 + 0x03 + 起始地址(2字节) + 寄存器数量(2字节) + CRC(2字节)

响应帧(从机→主机):

从机地址 + 0x03 + 数据字节数 + 数据(N字节) + CRC(2字节)

举个例子,读取BMS的SOC(假设在寄存器0x0010):

// 请求:读取地址0x0010,1个寄存器
发送:01 03 00 10 00 01 85 CF

// 响应:SOC值为60.0%(假设缩放系数10)
接收:01 03 02 02 58 B8 3A
// 数据0x0258 = 600,除以10得60.0%
经验之谈:一次读取多个寄存器效率更高。我一般一次读10-20个,减少总线负载。

2.3.2 功能码06:写单个寄存器

这个简单,就是写一个寄存器。常用于设置参数或下发指令。

请求帧:

从机地址 + 0x06 + 寄存器地址(2字节) + 数据值(2字节) + CRC(2字节)

比如设置PCS的功率目标值为500kW(假设缩放系数100):

// 写入0x01F4(500)到地址0x0020
发送:01 06 00 20 01 F4 89 AC

响应帧和请求帧完全一样,表示写入成功。

2.3.3 功能码16:写多个寄存器

这个功能码在储能系统里特别有用。比如你要同时设置充放电功率、运行模式、启停状态,用16一次搞定。

请求帧:

从机地址 + 0x10 + 起始地址(2字节) + 寄存器数量(2字节) + 字节数 + 数据(N字节) + CRC(2字节)

举个例子,同时设置PCS的运行模式(地址0x0030)和功率目标(地址0x0031):

// 模式=1(充电),功率=300kW(0x012C)
发送:01 10 00 30 00 02 04 00 01 01 2C 7A 9B
注意:写多个寄存器时,数据长度不能超过255字节。我遇到过有人一次写100个寄存器,结果报文超长被丢弃。

2.4 CRC校验实战——别让校验码坑了你

CRC校验是Modbus RTU的"守门员"。计算不对,整个报文都会被丢弃。我刚开始做的时候,经常因为CRC算错而抓狂。

Modbus RTU用的是CRC-16(Modbus),多项式是0x8005,初始值0xFFFF。计算步骤:

  1. 初始化CRC寄存器为0xFFFF
  2. 对每个字节,与CRC低字节异或
  3. 右移1位,如果移出的位是1,则与0xA001异或
  4. 重复8次
  5. 所有字节处理完后,得到CRC值
  6. 低字节在前,高字节在后

这里有个C语言实现,我一直在用:

uint16_t crc16_modbus(uint8_t *data, uint16_t len) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;  // 注意:返回的是小端格式
}
调试技巧:用在线CRC计算器验证。我习惯先算一帧简单的,比如"01 03 00 00 00 01",CRC应该是0x840A(低字节0x0A,高字节0x84)。

2.5 实战案例——读取BMS数据

咱们来个完整的例子。假设BMS从机地址0x01,要读取以下数据:

参数 寄存器地址 数据类型 缩放系数
总电压 0x0000 uint16 0.1V
总电流 0x0001 int16 0.1A
SOC 0x0002 uint16 0.1%
SOH 0x0003 uint16 0.1%

请求报文:

01 03 00 00 00 04 44 0C

响应报文:

01 03 08 0B B8 00 64 02 58 03 E8 7A 1B

解析一下:

  • 0x0BB8 = 3000 → 总电压300.0V
  • 0x0064 = 100 → 电流10.0A(正数表示充电)
  • 0x0258 = 600 → SOC 60.0%
  • 0x03E8 = 1000 → SOH 100.0%
核心要点:
  • 帧结构:地址+功能码+数据+CRC,缺一不可
  • 地址映射:搞清楚协议地址和PLC地址的区别
  • 功能码:03读、06写单个、16写多个,够用了
  • CRC校验:低字节在前,别搞反了

好了,Modbus RTU的核心内容就这些。说白了就是"怎么问、怎么答、怎么校验"。下节课咱们聊聊Modbus TCP,看看它和RTU有什么不同。


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