24、通信协议设计:协议分层原则、头部与载荷的设计、状态机在协议中的应用
通信协议,说白了就是两台设备之间约定好的「对话规则」。
我做了这么多年嵌入式系统,见过太多因为协议设计不合理导致的坑。有的协议太死板,扩展性差;有的协议太随意,解析起来一团乱麻。今天我们就来聊聊,怎么设计一套靠谱的通信协议。
协议分层原则:别把所有逻辑塞一层
分层,是解决复杂问题的第一把钥匙。你想想看,TCP/IP 为什么能统治互联网?就是因为它把问题拆成了四层。
我在设计一个物联网网关项目时,一开始把数据打包、校验、重传、应用逻辑全写在一个函数里。结果呢?改一个重传策略,整个协议栈都得重新测试。后来我老老实实分了层,世界清净了。
常见的分层原则是这样的:
- 物理层:管电压、管脚、波特率。别让上层操心是 RS232 还是 RS485。
- 链路层:管帧同步、CRC 校验、地址过滤。保证「这一帧」是完整的。
- 网络层:管路由、分包、重组。如果不需要路由,这层可以省略。
- 传输层:管可靠传输、流控、重传。保证「这一条消息」能到。
- 应用层:管业务逻辑。比如温度值怎么编码、命令怎么解释。
核心原则:每一层只关心自己的事,层与层之间通过标准接口交互。这样你换掉物理层(比如从串口换成蓝牙),上层代码一行都不用改。
头部与载荷的设计:别把鸡蛋放一个篮子里
一个协议帧,通常分为头部(Header)和载荷(Payload)。头部是「信封」,载荷是「信」。设计得好,解析效率高;设计得烂,CPU 全浪费在拆包上。
我习惯这样设计头部:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 起始标识 | 2 | 比如 0xAA 0x55,用于帧同步 |
| 版本号 | 1 | 协议版本,方便向后兼容 |
| 消息类型 | 1 | 命令、数据、应答、心跳等 |
| 载荷长度 | 2 | 告诉接收方要读多少字节 |
| 序列号 | 2 | 用于去重和应答匹配 |
| 校验和 | 2 | CRC16,覆盖头部+载荷 |
这里有个坑:载荷长度字段一定要放在头部靠前的位置。为什么?因为接收方只有拿到长度,才知道要申请多大的缓冲区。我曾经见过一个协议,长度字段放在头部末尾,结果接收方得先解析完整个头部才能知道载荷多大——这不是脱裤子放屁吗?
我的经验:载荷长度字段建议用 2 字节,最大支持 65535 字节。如果不够,说明你的协议该考虑分包了。另外,起始标识用 2 字节的魔数(Magic Number),可以有效降低误同步的概率。
状态机在协议中的应用:让协议「活」起来
协议解析,本质上就是一个状态机。你想想看,接收方从空闲状态开始,收到起始标识后进入「接收头部」状态,头部收完进入「接收载荷」状态,载荷收完进入「校验」状态……
我早期做的一个项目,协议解析用的是 if-else 嵌套,大概有 200 行。后来加了一个重传功能,改得我头皮发麻。换成状态机之后,每个状态独立处理,逻辑清晰得像一张白纸。
一个典型的协议解析状态机长这样:
enum State {
STATE_IDLE,
STATE_RECV_HEADER,
STATE_RECV_PAYLOAD,
STATE_CHECKSUM,
STATE_DISPATCH
};
void protocol_parse(uint8_t byte) {
switch (state) {
case STATE_IDLE:
if (byte == 0xAA) {
state = STATE_RECV_HEADER;
header_buf[0] = byte;
header_index = 1;
}
break;
case STATE_RECV_HEADER:
header_buf[header_index++] = byte;
if (header_index == HEADER_LEN) {
payload_len = (header_buf[4] << 8) | header_buf[5];
state = STATE_RECV_PAYLOAD;
payload_index = 0;
}
break;
case STATE_RECV_PAYLOAD:
payload_buf[payload_index++] = byte;
if (payload_index == payload_len) {
state = STATE_CHECKSUM;
}
break;
case STATE_CHECKSUM:
// 校验 CRC
if (crc_ok) {
state = STATE_DISPATCH;
} else {
state = STATE_IDLE; // 校验失败,丢弃
}
break;
case STATE_DISPATCH:
// 交给上层处理
dispatch_message(header_buf, payload_buf);
state = STATE_IDLE;
break;
}
}
注意:状态机一定要有超时处理。如果接收过程中突然断线,状态机卡在 STATE_RECV_PAYLOAD 里,后续数据全乱套。我一般会在每个状态加一个超时计数器,超时后强制回到 STATE_IDLE。
知识体系结构图
下面这张图,概括了本章的核心逻辑:
嗯,到这里,通信协议设计的基本框架就讲完了。分层让你不纠结,头部设计让你不踩坑,状态机让你不混乱。这三板斧用好了,大部分协议场景都能应付。
一句话总结:好的协议设计,是让发送方和接收方都「舒服」——发送方打包不费劲,接收方解析不迷糊。