一、问题背景:为什么不定长帧会半包、粘包

在嵌入式项目中,ESP32通过UART对接传感器、上位机或总线设备时,帧长往往不固定。若用轮询或逐字节中断接收,常见两类错误:

  • 半包:一帧数据尚未收完,程序就读取并处理,导致解析失败。
  • 粘包:两帧数据在缓冲区中连在一起,被当成一帧处理。

根因是“接收完成”的判定时机不准确。UART本身没有帧概念,只有字节流。要正确切帧,必须借助总线空闲这一物理特征:一帧结束后,线路会保持空闲电平一段时间。ESP32的UART外设支持空闲中断(UART_IDLE),配合DMA可高效判定帧边界。

二、原理:DMA环形缓冲 + 空闲中断

ESP32的UART DMA接收采用环形缓冲(ring buffer):DMA持续把RX FIFO数据搬入内存环形区,写指针循环前进。CPU只需在空闲中断中读取“当前写位置”,即可知道这一帧的结束点。

关键点:

  • 空闲中断触发条件:接收线上连续空闲超过设定阈值(ESP32以波特率周期计),说明一帧传输结束。
  • 长度快照:中断中读取DMA已写入的字节数,与上次处理位置比较,差值即本帧长度。
  • 半包避免:只有空闲中断到来才认为帧结束,避免“读到一半就处理”。
  • 粘包避免:若两帧间隔极短未触发空闲,需在应用层按协议头/长度字段二次拆分,或用超时兜底。

三、配置步骤(ESP-IDF)

  1. 配置UART参数:波特率、数据位、停止位、校验位。
  2. 安装UART驱动,开启uart_driver_install并指定RX环形缓冲大小。
  3. 使能空闲中断:uart_enable_rx_intr(UART_NUM_1, UART_INTR_IDLE)。
  4. 注册中断回调或在任务中等待事件,读取uart_get_buffered_data_len获取当前帧长。
  5. 应用层做帧解析与粘包拆分。

四、完整代码示例

#include "driver/uart.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "string.h"

#define UART_PORT      UART_NUM_1
#define UART_TX_PIN    17
#define UART_RX_PIN    16
#define BUF_SIZE       1024

static QueueHandle_t uart_queue;

// 帧解析:按协议头0xAA55 + 长度字段拆分粘包
static void parse_frame(const uint8_t *data, int len)
{
    int i = 0;
    while (i + 4 <= len) {
        if (data[i] == 0xAA && data[i+1] == 0x55) {
            uint8_t payload_len = data[i+2];
            int frame_len = 4 + payload_len + 1; // 头2+长度1+负载+校验1
            if (i + frame_len <= len) {
                // 完整帧,交给业务处理
                // handle_frame(&data[i], frame_len);
                i += frame_len;
                continue;
            } else {
                break; // 半包,等待后续数据
            }
        }
        i++; // 未找到帧头,滑动
    }
}

static void uart_rx_task(void *arg)
{
    uint8_t *buf = malloc(BUF_SIZE);
    uart_event_t event;

    while (1) {
        if (xQueueReceive(uart_queue, &event, portMAX_DELAY)) {
            if (event.type == UART_DATA || event.type == UART_IDLE) {
                size_t len = 0;
                uart_get_buffered_data_len(UART_PORT, &len);
                if (len > 0) {
                    int n = uart_read_bytes(UART_PORT, buf, len, pdMS_TO_TICKS(10));
                    if (n > 0) {
                        parse_frame(buf, n);
                    }
                }
            } else if (event.type == UART_FIFO_OVF || event.type == UART_BUFFER_FULL) {
                uart_flush_input(UART_PORT);
                xQueueReset(uart_queue);
            }
        }
    }
}

void uart_dma_idle_init(void)
{
    uart_config_t cfg = {
        .baud_rate = 115200,
        .data_bits = UART_DATA_8_BITS,
        .parity    = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
        .source_clk = UART_SCLK_DEFAULT,
    };

    uart_driver_install(UART_PORT, BUF_SIZE * 2, 0, 20, &uart_queue, 0);
    uart_param_config(UART_PORT, &cfg);
    uart_set_pin(UART_PORT, UART_TX_PIN, UART_RX_PIN,
                 UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);

    // 使能空闲中断,空闲阈值约 2 个字符时间
    uart_enable_rx_intr(UART_PORT, UART_INTR_IDLE);

    xTaskCreate(uart_rx_task, "uart_rx", 4096, NULL, 10, NULL);
}

五、避免半包与粘包的关键策略

  • 只在空闲中断处理:不要每收到一个字节就解析,等空闲中断确认帧结束。
  • 长度快照:中断中先uart_get_buffered_data_len取长度,再一次性uart_read_bytes,避免边读边写导致半包。
  • 协议层二次拆分:空闲中断只能切“物理帧”,若两帧间隔小于空闲阈值会粘包,必须在parse_frame中按帧头+长度字段拆分。
  • 超时兜底:对固定长度协议,可加软件定时器,超时未收满则丢弃并复位解析状态。
  • 环形缓冲足够大:uart_driver_install的RX缓冲应大于最大帧长×2,防止溢出。

六、注意事项

  • 空闲中断阈值与波特率相关,高波特率下阈值过小易误触发,建议实测调整。
  • uart_read_bytes带超时参数,任务中不要用portMAX_DELAY,防止阻塞。
  • 发生UART_FIFO_OVF说明处理不及时,需增大缓冲或提高任务优先级。
  • DMA与CPU共享环形缓冲,读取时建议关中断或使用驱动提供的原子接口,避免读写指针竞争。
  • 调试时可用逻辑分析仪观察帧间隔,验证空闲中断触发点是否与帧尾对齐。