4. DMA原理入门

串口通信里有个老生常谈的问题——粘包。说白了,就是发送方连续发了两帧数据,接收方读出来却混在一起,分不清哪段是哪段。我早年做第一个物联网项目时就吃过这个亏,设备上报的数据全乱了,排查了整整两天才发现是粘包导致的。

要解决粘包,核心思路就一条:给数据画边界。怎么画?这章我们逐一拆解。

4.1 帧头帧尾设计——给数据贴上标签

最常见的做法是定义一套帧格式。发送方在数据前后加上固定的帧头(SOF)和帧尾(EOF),接收方根据这些标记来切分数据。

字段 长度(字节) 说明
帧头 2 固定值,如 0xAA 0x55
数据长度 1~2 有效载荷的字节数
有效载荷 N 实际传输的数据
校验 1~2 CRC8/CRC16 或累加和
帧尾 2 固定值,如 0x0D 0x0A
我的习惯:帧头用 0xAA 0x55 这种交替位模式,能有效避免与数据内容混淆。帧尾用 0x0D 0x0A(回车换行),调试时用串口助手直接看文本也方便。

这里有个坑要注意:如果数据内容里恰好出现了帧头或帧尾的字节,就会导致误判。解决办法是字节填充(Byte Stuffing)——在数据中遇到帧头/帧尾时,插入一个转义字符。我曾在某个项目中忽略了这一点,结果设备偶尔会莫名其妙地丢帧,查了很久才发现是数据里有个 0xAA 把解析器搞懵了。

4.2 超时机制——给等待一个期限

光靠帧头帧尾还不够。万一接收过程中断了一帧,或者发送方只发了半个包,接收方就会一直等下去。这时候就需要超时机制。

超时判断的逻辑很简单:

  1. 每收到一个字节,重置定时器。
  2. 如果定时器溢出(比如超过 10ms 没收到新字节),就认为当前帧结束。
  3. 检查已收到的数据是否完整,然后交给上层处理。

超时时间怎么定?

我一般按波特率来算:假设波特率 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 状态里加个子状态就行。

我曾经踩过的坑:状态机里忘记处理异常跳转。比如在 DATA 状态中突然收到一个帧头字节,应该立即重置状态重新同步,而不是继续等当前帧。否则一旦数据错位,后面所有帧都会解析失败。
串口解包状态机流程图 IDLE HEADER1 HEADER2 LENGTH DATA CHECKSUM FOOTER 帧完成 → 回调 收到 0xAA 收到 0x55 非 0x55 收到 0xAA(重新同步) 关键设计原则 每个状态只处理一个字节 异常跳转必须覆盖所有分支

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 完整解析流程——把碎片拼成拼图

把上面几块拼起来,一个完整的串口解包流程就出来了:

  1. 中断接收:UART 中断每收到一个字节,调用 ring_write() 存入环形缓冲区。
  2. 主循环轮询:主循环不断调用 ring_read() 取出字节。
  3. 状态机解析:每个字节喂给状态机,状态机根据当前状态和字节值更新状态。
  4. 超时检测:启动一个硬件定时器,每次收到字节就复位。超时后强制结束当前帧。
  5. 帧完成回调:状态机进入完成状态后,调用上层回调函数,把解析好的帧数据传过去。
我的经验:调试阶段可以在状态机里加一个调试输出,打印每个状态切换的日志。这样一旦解析出错,看日志就能定位到是哪个状态出了问题,比瞎猜快得多。

这套架构我用了很多年,从 8 位单片机到 ARM Cortex-M 系列,基本没怎么改过。你想想看,只要把帧格式定义清楚,状态机写严谨,环形缓冲区大小配够,串口通信的可靠性就能上一个台阶。

最后提醒一句:别忘了处理异常情况。比如缓冲区溢出怎么办?校验失败怎么恢复?超时后残留数据要不要清空?这些边缘情况才是决定系统稳定性的关键。我曾经在一个工业项目中忽略了缓冲区溢出处理,结果设备连续运行 72 小时后死机了——缓冲区被写爆,写指针覆盖了读指针,数据全乱套了。