3. 分层设计方法论
说实话,很多嵌入式工程师一听到「协议栈」三个字,就觉得那是 Linux 内核或者大型 RTOS 才该操心的事。我早年也这么想——直到在一个 8 位 MCU 的项目里,需要让两个节点隔着 50 米电缆可靠通信。标准 TCP/IP 太重,Modbus 又不够灵活。怎么办?自己搭一个轻量级的。
这一章,我就带你走一遍自定义协议栈的核心:帧结构怎么定、校验怎么选、重传怎么做。说白了,就是让你在资源受限的环境里,也能拥有「可靠传输」的能力。
3.1 帧结构设计:别小看这个「壳」
帧结构是协议的骨架。我见过太多人上来就写数据,结果通信一乱,连哪一段是地址、哪一段是长度都分不清。我的习惯是:先画一张图,把每一字节的用途定死。
下面是我在一个工业传感器项目中用过的帧格式,你可以直接拿来改:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| SOF | 1 | 帧起始符,固定 0xAA |
| DST | 1 | 目标地址 |
| SRC | 1 | 源地址 |
| TYPE | 1 | 帧类型(数据/命令/应答) |
| LEN | 1 | 负载长度(0~64) |
| PAYLOAD | 0~64 | 实际数据 |
| CRC | 2 | CRC-16 校验 |
| EOF | 1 | 帧结束符,固定 0x55 |
你看,总共开销才 7 字节(不含负载)。对于 64 字节的负载来说,效率已经很高了。为什么用 SOF 和 EOF 两个定界符?嗯,这里要注意——单靠一个起始符,一旦数据里出现相同字节,接收端就会乱套。双定界符配合长度字段,能大幅降低误同步的概率。
// 帧结构定义(C语言)
typedef struct {
uint8_t sof; // 0xAA
uint8_t dst;
uint8_t src;
uint8_t type;
uint8_t len;
uint8_t payload[64];
uint16_t crc;
uint8_t eof; // 0x55
} __attribute__((packed)) Frame_t;
__attribute__((packed)) 能省掉结构体对齐填充,但要注意非对齐访问可能引发异常。如果你用 ARM 内核,记得使能非对齐访问支持。
3.2 校验策略:CRC 不是万能的,但没有 CRC 是万万不能的
校验这事,我踩过坑。曾经在一个无线模块项目里,为了省那 2 字节开销,用了简单的累加和校验。结果呢?数据在传输过程中发生了偶数位翻转,累加和完全没检测出来。从那以后,我再也不敢在关键链路上用累加和了。
对于轻量级协议,我推荐以下三种校验方式:
- CRC-16-CCITT:多项式 0x1021,检测能力极强,适合 64 字节以内的负载。查表法实现,在 48MHz 的 STM32 上耗时不到 10μs。
- CRC-8:如果你实在省得厉害,可以用 CRC-8。但注意,它只能检测单比特错误和部分多比特错误。
- 双重校验:帧头用 CRC-8,负载用 CRC-16。我在一个军工项目中用过这种方案,可靠性极高。
// CRC-16-CCITT 查表法实现(节选)
uint16_t crc16_ccitt(uint8_t *data, uint16_t len) {
uint16_t crc = 0xFFFF;
for (uint16_t i = 0; i < len; i++) {
crc = (crc >> 8) | (crc << 8);
crc ^= data[i];
crc ^= (crc >> 4) & 0x0F;
crc ^= (crc << 12) & 0xFFFF;
crc ^= (crc << 5) & 0xFFFF;
}
return crc;
}
3.3 重传机制:简单可靠才是王道
重传是传输层的核心。你想想看,如果发出去的数据丢了,接收端怎么知道?发端又怎么知道该不该重发?
在资源受限的嵌入式系统里,我不建议用 TCP 那种复杂的滑动窗口。我的做法是:停等协议 + 超时重传。说白了就是:发一帧,等一帧的应答,等不到就重发。
具体流程如下:
- 发送端发送数据帧,启动超时定时器。
- 接收端收到后,校验 CRC,正确则回复 ACK(确认帧),错误则丢弃。
- 发送端如果在超时时间内收到 ACK,发送下一帧;否则重传当前帧。
- 重传次数超过上限(比如 3 次),上报链路错误。
这里有个细节:ACK 帧本身也可能丢。所以接收端需要做去重——每个数据帧带一个 4 位的序列号,接收端记录上次收到的序列号,重复的就丢弃但重新回复 ACK。
// 重传状态机核心逻辑
typedef enum {
TX_IDLE,
TX_WAIT_ACK,
TX_RETRY
} TxState_t;
void protocol_tx_task(Frame_t *frame) {
static TxState_t state = TX_IDLE;
static uint8_t retry_cnt = 0;
static uint8_t seq = 0;
switch (state) {
case TX_IDLE:
frame->payload[0] = seq++; // 序列号塞入负载
uart_send(frame);
timer_start(50); // 50ms 超时
state = TX_WAIT_ACK;
break;
case TX_WAIT_ACK:
if (ack_received()) {
state = TX_IDLE;
retry_cnt = 0;
} else if (timer_expired()) {
if (++retry_cnt > 3) {
error_handler("Link down");
state = TX_IDLE;
} else {
uart_send(frame);
timer_restart();
}
}
break;
}
}
3.4 整体架构:一张图看懂协议栈
下面这张图,是我在设计协议栈时画的顶层架构。它把帧封装、校验、重传、去重这几个模块串在了一起。你照着这个结构写代码,基本不会乱。
你看,每一层各司其职。物理层只管收发字节,数据链路层负责校验和定界,网络层做帧的封装和地址路由,传输层保证可靠性。这样分层之后,哪一层出了问题,定位起来非常快。
3.5 实战中的几个坑
最后,分享几个我在项目中踩过的坑,希望能帮你省点时间:
- 字节序问题:CRC 和长度字段在多字节时,一定要统一用 Little-Endian 还是 Big-Endian。我建议统一用小端,因为大部分 MCU 都是小端模式。
- 透明传输:如果数据里出现了 0xAA 或 0x55,接收端会误判为帧头或帧尾。解决办法是字节填充——在数据中遇到定界符时,插入一个转义字节(比如 0x7D)。
- 中断 vs 轮询:接收帧时,我习惯用 DMA + 空闲中断。如果 MCU 不支持空闲中断,就用一个字节超时定时器来判断帧结束。千万别在中断里做 CRC 校验,太耗时。
- 重传风暴:如果多个节点同时重传,信道会瞬间拥塞。我的做法是加入随机退避——每次重传前,等待一个随机毫秒数(10~100ms)。