4. DMA原理入门
串口通信里有个老生常谈的问题——粘包。说白了,就是发送方连续发了两帧数据,接收方读出来却混在一起,分不清哪段是哪段。我早年做第一个物联网项目时就吃过这个亏,设备上报的数据全乱了,排查了整整两天才发现是粘包导致的。
要解决粘包,核心思路就一条:给数据画边界。怎么画?这章我们逐一拆解。
4.1 帧头帧尾设计——给数据贴上标签
最常见的做法是定义一套帧格式。发送方在数据前后加上固定的帧头(SOF)和帧尾(EOF),接收方根据这些标记来切分数据。
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头 | 2 | 固定值,如 0xAA 0x55 |
| 数据长度 | 1~2 | 有效载荷的字节数 |
| 有效载荷 | N | 实际传输的数据 |
| 校验 | 1~2 | CRC8/CRC16 或累加和 |
| 帧尾 | 2 | 固定值,如 0x0D 0x0A |
这里有个坑要注意:如果数据内容里恰好出现了帧头或帧尾的字节,就会导致误判。解决办法是字节填充(Byte Stuffing)——在数据中遇到帧头/帧尾时,插入一个转义字符。我曾在某个项目中忽略了这一点,结果设备偶尔会莫名其妙地丢帧,查了很久才发现是数据里有个 0xAA 把解析器搞懵了。
4.2 超时机制——给等待一个期限
光靠帧头帧尾还不够。万一接收过程中断了一帧,或者发送方只发了半个包,接收方就会一直等下去。这时候就需要超时机制。
超时判断的逻辑很简单:
- 每收到一个字节,重置定时器。
- 如果定时器溢出(比如超过 10ms 没收到新字节),就认为当前帧结束。
- 检查已收到的数据是否完整,然后交给上层处理。
超时时间怎么定?
我一般按波特率来算:假设波特率 115200,一个字节约 87μs,一帧 100 字节约 8.7ms。超时设 10~20ms 比较合理。设太短容易误判,设太长会拖慢响应。
超时机制特别适合不定长帧的场景。你想想看,如果每帧长度不固定,光靠帧头帧尾解析,万一帧尾丢了就完蛋。超时相当于给了解析器一个“放弃等待”的出口。
4.3 状态机解析——把逻辑画成图
解析粘包最优雅的方式,就是用状态机。我习惯把解析过程分成几个状态,每个状态只关心一件事。
typedef enum {
STATE_IDLE, // 空闲,等待帧头
STATE_HEADER1, // 收到第一个帧头字节
STATE_HEADER2, // 收到第二个帧头字节
STATE_LENGTH, // 接收数据长度
STATE_DATA, // 接收有效载荷
STATE_CHECKSUM, // 接收校验
STATE_FOOTER1, // 收到第一个帧尾字节
STATE_FOOTER2 // 收到第二个帧尾字节
} parser_state_t;
每个字节进来,状态机根据当前状态和字节值决定下一步动作。比如:
- IDLE 状态下收到 0xAA → 进入 HEADER1
- HEADER1 状态下收到 0x55 → 进入 HEADER2,否则回到 IDLE
- HEADER2 状态下读取长度字段 → 进入 DATA
- DATA 状态下收满指定字节 → 进入 CHECKSUM
- 校验通过后收帧尾 → 一帧解析完成,回调上层
状态机的优势在于:每个状态的处理逻辑独立,不会互相干扰。而且很容易扩展——想加个转义处理?在 DATA 状态里加个子状态就行。
4.4 环形缓冲区——让数据流动起来
解析器有了,数据往哪放?中断里直接处理?不行,中断服务程序要短平快。正确的做法是:中断只负责收数据,丢进缓冲区;主循环从缓冲区取数据,交给状态机解析。
环形缓冲区(Ring Buffer)就是为此而生的。它用两个指针——读指针和写指针——来管理数据,写满了自动回绕。
#define RING_BUF_SIZE 256
typedef struct {
uint8_t buffer[RING_BUF_SIZE];
volatile uint16_t head; // 写指针
volatile uint16_t tail; // 读指针
} ring_buffer_t;
// 写入一个字节
int ring_write(ring_buffer_t *rb, uint8_t data) {
uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
if (next == rb->tail) {
return -1; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next;
return 0;
}
// 读取一个字节
int ring_read(ring_buffer_t *rb, uint8_t *data) {
if (rb->head == rb->tail) {
return -1; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
return 0;
}
为什么用环形缓冲区?
- 中断和主循环之间不需要加锁(单生产者单消费者模型)
- 内存利用率高,不会因为碎片浪费空间
- 读写操作都是 O(1),性能稳定
我建议缓冲区大小设为 2 的幂次方,比如 256、512。这样取模运算可以用位运算替代(head & (SIZE-1)),速度更快。嗯,这个优化在低端 MCU 上效果很明显。
4.5 完整解析流程——把碎片拼成拼图
把上面几块拼起来,一个完整的串口解包流程就出来了:
- 中断接收:UART 中断每收到一个字节,调用
ring_write()存入环形缓冲区。 - 主循环轮询:主循环不断调用
ring_read()取出字节。 - 状态机解析:每个字节喂给状态机,状态机根据当前状态和字节值更新状态。
- 超时检测:启动一个硬件定时器,每次收到字节就复位。超时后强制结束当前帧。
- 帧完成回调:状态机进入完成状态后,调用上层回调函数,把解析好的帧数据传过去。
这套架构我用了很多年,从 8 位单片机到 ARM Cortex-M 系列,基本没怎么改过。你想想看,只要把帧格式定义清楚,状态机写严谨,环形缓冲区大小配够,串口通信的可靠性就能上一个台阶。
最后提醒一句:别忘了处理异常情况。比如缓冲区溢出怎么办?校验失败怎么恢复?超时后残留数据要不要清空?这些边缘情况才是决定系统稳定性的关键。我曾经在一个工业项目中忽略了缓冲区溢出处理,结果设备连续运行 72 小时后死机了——缓冲区被写爆,写指针覆盖了读指针,数据全乱套了。