3、通信协议设计:Modbus RTU协议详解

通信协议,说白了就是设备之间交流的「语言」。你想想看,万用表采集到的电压、电流数据,怎么告诉上位机?上位机又怎么告诉万用表「你该切换量程了」?这些都得靠协议来约定。

我做过多通道数据采集系统,最头疼的就是协议设计。选错了,后面调试能让你怀疑人生。今天咱们就聊聊几种常见的协议方案,以及我踩过的那些坑。

3.1 Modbus RTU协议详解

Modbus RTU,工业现场的老大哥。几乎所有的PLC、传感器、仪表都支持它。为什么?简单、稳定、开源。

3.1.1 帧结构

Modbus RTU的帧结构很清晰,就四个部分:

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

举个例子,我要读取1号万用表的电压值:

发送:01 03 00 00 00 01 84 0A
接收:01 03 02 01 2C B9 8A

这里01是设备地址,03是功能码(读保持寄存器),00 00是起始地址,00 01是读取数量,84 0A是CRC校验。接收到的01 2C换算成十进制就是300,代表3.00V。

我的习惯:调试Modbus时,我总会在串口助手里先手动拼一帧数据发出去。确认设备能响应了,再写代码。这样能快速定位是硬件问题还是软件问题。

3.1.2 常用功能码

Modbus的功能码很多,但咱们做万用表数据采集,常用的就这几个:

  • 03 (0x03):读保持寄存器。读取电压、电流、电阻值。
  • 06 (0x06):写单个寄存器。设置量程、采样率。
  • 10 (0x10):写多个寄存器。批量配置参数。
  • 08 (0x08):诊断功能。测试通信是否正常。

我记得有一次,客户说设备偶尔会死机。排查了半天,发现是上位机用了不常用的功能码,设备固件里没处理,直接卡死了。从那以后,我设计协议时都会明确列出「支持的功能码列表」,不支持的直接返回异常码。

3.1.3 CRC校验

CRC校验是Modbus RTU的保命符。没有它,数据传错了你都不知道。

CRC-16的算法其实不复杂,但手写容易出错。我一般直接用查表法:

// CRC-16 Modbus 查表法实现
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;
}
注意:Modbus RTU的CRC是低字节在前,高字节在后。发送时先发低8位,再发高8位。我曾经在这个顺序上栽过跟头,调试了一整天。

3.2 自定义ASCII协议设计

Modbus RTU虽然好,但有时候太「重」了。比如你只需要传一个简单的温度值,用Modbus还得拼帧、算CRC,有点杀鸡用牛刀的感觉。

这时候,自定义ASCII协议就派上用场了。说白了,就是用人类可读的字符串来通信。

我设计过一个简单的协议,格式如下:

#CMD:VALUE;CHK

比如读取1号通道的电压:

发送:$READ:VOLT,1;A5
接收:$VOLT:1,3.00;7C

这里$是起始符,READ是命令,VOLT是参数,1是通道号,A5是校验和。接收端解析起来也很简单,按逗号分割字符串就行。

避坑指南:我曾经设计过一个协议,没加起始符和结束符。结果有一次数据粘包了,解析出来的全是乱码。后来老老实实加了$和;,世界清净了。

3.3 SCPI兼容命令集设计

SCPI(Standard Commands for Programmable Instruments)是仪器界的「普通话」。如果你想让你的万用表兼容主流测试软件,SCPI是必须的。

SCPI命令的格式很规范,比如:

MEASure:VOLTage:DC?  // 测量直流电压
CONFigure:VOLTage:DC 10,0.001  // 配置量程10V,分辨率1mV
SYSTem:ERRor?  // 查询错误

实现SCPI时,我建议用状态机来解析。因为SCPI命令可以简写(比如MEASure可以写成MEAS),而且大小写不敏感。

// SCPI命令解析状态机示例
typedef enum {
    SCPI_IDLE,
    SCPI_HEADER,
    SCPI_PARAM,
    SCPI_QUERY
} scpi_state_t;

void scpi_parse(char *cmd) {
    // 状态机实现...
}

我的经验:SCPI命令集虽然规范,但实现起来工作量不小。我一般只实现最常用的几个命令,比如测量、配置、查询ID。其他的用自定义协议补充。这样既兼容了标准,又保持了灵活性。

3.4 协议健壮性设计

协议设计得再好,到了现场也可能会出问题。电磁干扰、线缆松动、设备死机……什么幺蛾子都能遇到。所以,健壮性设计是重中之重。

3.4.1 超时重传

发送一帧数据后,如果设备没响应,怎么办?等多久?重试几次?

我一般这样设计:

  • 超时时间:根据设备响应速度来定。万用表一般50ms内能响应,我设100ms。
  • 重试次数:3次。超过3次还没响应,就报通信故障。
  • 退避策略:每次重试间隔递增,比如100ms、200ms、400ms。避免网络风暴。
// 超时重传伪代码
for (int retry = 0; retry < 3; retry++) {
    send_frame(data);
    if (wait_response(100 * (retry + 1))) {
        break;  // 收到响应,退出
    }
}
if (retry == 3) {
    report_error("通信超时");
}

3.4.2 帧同步

串口通信没有帧的概念,就是一串字节流。怎么从字节流里找到一帧的起始和结束?这就是帧同步要解决的问题。

我常用的方法:

  • 固定帧头帧尾:比如Modbus的3.5字符空闲时间,或者自定义协议的$和;。
  • 长度字段:在帧头后面加一个字节表示数据长度,接收端根据长度来截取。
  • 超时判帧:如果超过一定时间没收到新字节,就认为一帧结束了。
我曾经踩过的坑:有一次,我设计的协议帧头是0xAA,帧尾是0x55。结果数据里正好出现了0xAA 0x55,接收端直接误判了。后来我改成帧头用0xAA 0xBB两个字节,帧尾用0x55 0x66,误判概率大大降低。

嗯,协议设计这块,说白了就是「约定」和「容错」的平衡。约定得越细,容错就越强。但也不能太复杂,否则解析起来费劲。我的建议是:先跑通,再优化。别一开始就想设计一个完美的协议,那是不可能的。