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。
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;
}
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字符空闲时间,或者自定义协议的$和;。
- 长度字段:在帧头后面加一个字节表示数据长度,接收端根据长度来截取。
- 超时判帧:如果超过一定时间没收到新字节,就认为一帧结束了。
嗯,协议设计这块,说白了就是「约定」和「容错」的平衡。约定得越细,容错就越强。但也不能太复杂,否则解析起来费劲。我的建议是:先跑通,再优化。别一开始就想设计一个完美的协议,那是不可能的。