3. Modbus协议基础:Modbus RTU/ASCII/TCP帧结构、功能码详解(03/06/16/23)、地址映射规则

各位同学,咱们今天聊聊Modbus。说实话,这玩意儿在工业通信里,就像空气一样无处不在。你随便拆开一台逆变器、PLC、或者智能电表,十有八九都能看到它的影子。我最早接触Modbus是在做光伏逆变器监控的时候,那时候刚入行,被各种协议栈搞得头大。后来发现,Modbus其实很朴实,说白了就是一套“你问我答”的规矩。

3.1 三种传输模式:RTU、ASCII、TCP

Modbus有三种“方言”:RTU、ASCII和TCP。你可能会问,为什么搞这么多种?嗯,历史原因和场景不同。

3.1.1 Modbus RTU

这是工业现场最常用的模式。数据用二进制传输,紧凑、高效。我个人的习惯是,只要串口通信,优先选RTU。

  • 帧结构:地址码(1字节) + 功能码(1字节) + 数据(N字节) + CRC校验(2字节)
  • 特点:连续传输,字节间无间隔。如果间隔超过3.5个字符时间,从站就认为帧结束了。
  • :我曾经遇到过一个问题,MCU主频太低,导致字节间间隔超标,从站老是丢帧。后来把串口中断优先级调高才解决。
RTU帧示例(读取保持寄存器):
请求:01 03 00 00 00 02 C4 0B
响应:01 03 04 00 0A 00 14 7A 7B

解释:地址01,功能码03,起始地址0000,读取2个寄存器。响应返回4字节数据(0x000A=10, 0x0014=20)。

3.1.2 Modbus ASCII

这个模式用ASCII字符表示数据,可读性强,但效率低。说白了,就是把每个字节拆成两个十六进制字符发送。比如0x1A就发'1'和'A'。

  • 帧结构:起始符':' + 地址(2字符) + 功能码(2字符) + 数据(N字符) + LRC校验(2字符) + 结束符CR/LF
  • 特点:肉眼能看懂,调试方便。但传输效率只有RTU的一半。
  • 适用场景:老设备、或者通信链路质量差(比如无线数传电台)的时候。我有个项目用ASCII模式,因为RTU的CRC老是被干扰算错。
小技巧:调试阶段可以用ASCII模式抓日志,上线前再切回RTU。省得你拿逻辑分析仪去数二进制。

3.1.3 Modbus TCP

这是跑在以太网上的版本。去掉了CRC校验(因为TCP/IP协议栈自己会校验),加了一个MBAP报文头。

  • 帧结构:MBAP头(7字节) + 功能码(1字节) + 数据(N字节)
  • MBAP头包含:事务处理标识(2字节)、协议标识(2字节,固定0x0000)、长度(2字节)、单元标识(1字节)
  • 注意:单元标识相当于RTU里的地址码。但TCP模式下,一个IP端口只能连一个设备,所以单元标识很多时候填0xFF(广播)或者固定值。

我记得有一次,客户说逆变器通过以太网连不上。我查了半天,发现是MBAP头里的长度字段算错了。这个长度是从单元标识开始算的,不包括前面的6个字节。嗯,细节决定成败。

3.2 功能码详解:03、06、16、23

功能码就是“指令”。你告诉从机你想干什么。咱们逆变器开发中,最常用的就这四个。

3.2.1 功能码03:读取保持寄存器

这是最常用的功能码,没有之一。用来读取逆变器的运行参数,比如电压、电流、功率、温度等。

  • 请求格式:地址 + 0x03 + 起始地址(2字节) + 寄存器数量(2字节) + CRC
  • 响应格式:地址 + 0x03 + 字节数(1字节) + 数据(N字节) + CRC
  • 限制:一次最多读取125个寄存器(250字节数据)。为什么?因为响应里的字节数只有1字节,最大255,减去1个功能码和1个字节数,剩下253字节,除以2就是126个寄存器。但协议规定上限125,留点余量。
避坑指南:我曾经遇到过从机返回的数据长度不对。比如你请求读2个寄存器,它返回了6个字节。这时候你的解析程序一定要做长度校验,否则会越界。

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

用来设置单个参数,比如修改逆变器的输出功率、启动/停止指令等。

  • 请求格式:地址 + 0x06 + 寄存器地址(2字节) + 数据值(2字节) + CRC
  • 响应格式:与请求完全一样(回显)。这是Modbus的一个特点——写操作成功后,从机会把请求原封不动地返回。
  • 注意:写操作一定要确认响应。我见过有人发了写指令就不管了,结果设备根本没收到。

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

批量写参数。比如你要一次性设置逆变器的PID参数,或者更新一组时间配置。

  • 请求格式:地址 + 0x10 + 起始地址(2字节) + 寄存器数量(2字节) + 字节数(1字节) + 数据(N字节) + CRC
  • 响应格式:地址 + 0x10 + 起始地址(2字节) + 寄存器数量(2字节) + CRC
  • 限制:一次最多写123个寄存器。因为请求里的字节数最大255,减去1个功能码、1个地址、4个地址/数量字段,剩下249字节,除以2就是124个。协议规定上限123。
代码示例(C语言,写多个寄存器):
uint8_t req[] = {
    0x01,       // 地址
    0x10,       // 功能码16
    0x00, 0x10, // 起始地址 0x0010
    0x00, 0x03, // 写3个寄存器
    0x06,       // 字节数 = 3*2 = 6
    0x00, 0x64, // 数据1: 100
    0x00, 0xC8, // 数据2: 200
    0x01, 0x2C, // 数据3: 300
    0x00, 0x00  // CRC占位
};

3.2.4 功能码23:读写多个寄存器

这个功能码比较特殊,它在一个事务里同时完成读和写。你想想看,什么场景需要这样?

举个例子:你要读取逆变器的当前功率,同时设置一个目标功率。如果用03+06两个指令,中间可能有延迟,导致读到的功率不是设置后的值。用23功能码,就能保证原子性。

  • 请求格式:地址 + 0x17 + 读起始地址(2字节) + 读数量(2字节) + 写起始地址(2字节) + 写数量(2字节) + 写字节数(1字节) + 写数据(N字节) + CRC
  • 响应格式:地址 + 0x17 + 读字节数(1字节) + 读数据(N字节) + CRC
  • 注意:不是所有从机都支持这个功能码。我建议你在开发前先看设备手册,别想当然。

3.3 地址映射规则

地址映射,说白了就是“哪个寄存器存了什么数据”。这是协议设计中最容易出乱子的地方。我见过两个团队因为地址映射表没对齐,联调了三天。

3.3.1 寄存器地址 vs 数据地址

Modbus协议里,寄存器地址是16位的,范围0x0000~0xFFFF。但很多设备手册里写的是“数据地址”,比如40001、30001这种。这是PLC时代的遗留习惯。

  • 4xxxx:保持寄存器(可读可写),对应功能码03/06/16/23
  • 3xxxx:输入寄存器(只读),对应功能码04
  • 0xxxx:线圈(可读可写),对应功能码01/05/15
  • 1xxxx:离散输入(只读),对应功能码02

注意:40001对应的实际协议地址是0x0000,40002对应0x0001,以此类推。这个偏移量一定要搞清楚。我有个同事,把40001直接当协议地址发了,结果读回来的数据全是错的。

3.3.2 数据格式

寄存器是16位的。但逆变器的参数可能是32位浮点数、或者16位整数。怎么放?

数据类型 占用寄存器数 字节序 说明
uint16 1 大端 高字节在前,低字节在后
int16 1 大端 补码表示
uint32 2 大端或小端 看设备定义,常见的是大端(Motorola)
float32 2 IEEE754 注意字节序,有的设备用ABCD,有的用CDAB
我的习惯:在地址映射表里,明确标注每个寄存器的数据类型和字节序。比如“0x0010: 输出功率, float32, 大端, 单位W”。这样后期维护的人不会骂你。

3.3.3 地址规划建议

做逆变器通信协议时,我建议按功能分区规划地址:

  • 0x0000~0x00FF:设备信息(型号、版本、序列号)
  • 0x0100~0x01FF:运行参数(电压、电流、功率、频率)
  • 0x0200~0x02FF:状态信息(运行状态、故障码、告警)
  • 0x0300~0x03FF:控制参数(启停、功率设定、模式切换)
  • 0x0400~0x04FF:配置参数(通信参数、保护阈值)

这样规划的好处是,读设备信息时只需要读一小段,不用全量读取。而且扩展方便,加新功能时不会跟旧地址冲突。

避坑指南:我曾经在一个项目里,把读和写的地址混在一起。结果写操作不小心覆盖了只读参数,导致设备重启。后来我强制规定:只读参数和可写参数必须分区间,中间留空地址做隔离。

好了,Modbus协议基础就讲到这里。下一节咱们聊聊怎么在逆变器上实际移植Modbus协议栈,包括定时器配置、状态机设计、以及异常处理。到时候我会拿一个实际项目的代码来拆解,保证你听完就能上手。