4、DMA与串口:串口DMA发送、串口DMA接收、环形缓冲区设计、空闲中断+DMA接收不定长数据

串口通信,可以说是嵌入式开发里最常用的外设了。但很多人用串口,还是老一套——中断里一个字节一个字节地收,CPU 被频繁打断,高负载下很容易丢数据。我刚开始做项目时也这样,直到有一次产品在现场频繁丢日志,排查了三天才发现是中断响应不过来。

从那以后,我养成了一个习惯:只要波特率超过 115200,或者数据量稍大,一律上 DMA。这一章,我就把串口 DMA 的几种玩法掰开揉碎讲清楚。

4.1 串口 DMA 发送:让 CPU 彻底解放

先看发送。传统方式下,你要往串口发一包数据,得在循环里等 TXE 标志位,或者进中断一个字节一个字节地塞。CPU 全程被占用,干不了别的。

DMA 发送的思路很简单:你把数据缓冲区的地址告诉 DMA,把长度告诉 DMA,然后启动传输。剩下的,DMA 自己会把数据从内存搬到串口数据寄存器,发完再通知你。

核心配置步骤:
  1. 使能 DMA2 时钟(STM32F4 系列串口 TX 通常挂在 DMA2 上)
  2. 配置 DMA 数据流、通道、外设地址(&USARTx->DR)、内存地址(你的发送缓冲区)
  3. 方向:内存到外设;增量模式:内存地址增量,外设地址固定
  4. 使能传输完成中断(可选,用于发送完毕后的回调)
  5. 调用 HAL_UART_Transmit_DMA() 启动
// 伪代码示例:串口 DMA 发送配置
void UART_DMA_Send_Config(void)
{
    __HAL_RCC_DMA2_CLK_ENABLE();

    hdma_tx.Instance = DMA2_Stream7;
    hdma_tx.Init.Channel = DMA_CHANNEL_4;
    hdma_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
    hdma_tx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_tx.Init.MemInc = DMA_MINC_ENABLE;
    hdma_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    hdma_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    hdma_tx.Init.Mode = DMA_NORMAL;
    hdma_tx.Init.Priority = DMA_PRIORITY_HIGH;

    HAL_DMA_Init(&hdma_tx);
    __HAL_LINKDMA(&huart1, hdmatx, hdma_tx);
}

// 发送一帧数据
HAL_UART_Transmit_DMA(&huart1, tx_buffer, len);
我的经验:发送完成后,记得在 HAL_UART_TxCpltCallback() 里关闭 DMA 或释放信号量。否则连续发送时,上一次还没发完,下一次又启动了,数据会乱掉。

4.2 串口 DMA 接收:环形缓冲区是标配

接收比发送麻烦一点。DMA 接收是把数据从串口 DR 寄存器自动搬到内存缓冲区。但有个问题:DMA 是连续搬运的,如果缓冲区满了,新数据会覆盖旧数据。

解决方案就是——环形缓冲区。我几乎在每个项目里都用它,简单可靠。

环形缓冲区核心要素:
  • 一个固定大小的数组(比如 512 字节)
  • 两个指针/索引:读指针(应用程序取数据用)和写指针(DMA 写入用)
  • 判空:读指针 == 写指针
  • 判满:(写指针 + 1) % 缓冲区大小 == 读指针
// 环形缓冲区结构体
typedef struct {
    uint8_t buffer[RING_BUFFER_SIZE];
    volatile uint16_t head;  // 写索引(DMA 写入位置)
    volatile uint16_t tail;  // 读索引(应用程序读取位置)
} RingBuffer_t;

// 写入一个字节
void RingBuffer_Write(RingBuffer_t *rb, uint8_t data)
{
    uint16_t next = (rb->head + 1) % RING_BUFFER_SIZE;
    if (next != rb->tail) {  // 未满
        rb->buffer[rb->head] = data;
        rb->head = next;
    }
}

// 读取一个字节
uint8_t RingBuffer_Read(RingBuffer_t *rb, uint8_t *data)
{
    if (rb->head == rb->tail) return 0;  // 空
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUFFER_SIZE;
    return 1;
}
注意:DMA 接收时,写指针由硬件自动更新,读指针由软件维护。这两个变量必须用 volatile 修饰,否则编译器优化会出问题。我曾经因为这个 bug 排查了两天,数据总是对不上。

4.3 空闲中断 + DMA 接收不定长数据

实际项目中,你永远不知道对方会发多少字节。可能是 5 个,也可能是 200 个。这时候就需要空闲中断了。

空闲中断(IDLE Interrupt)是串口的一个硬件特性:当接收线上超过一个字节的时间没有新数据到来时,就会触发空闲中断。配合 DMA,就能实现不定长接收。

实现思路:
  1. 开启串口 DMA 接收(循环模式或正常模式)
  2. 开启串口空闲中断
  3. DMA 持续把数据搬入环形缓冲区
  4. 当一帧数据发送完毕,总线空闲,触发空闲中断
  5. 在空闲中断里,计算本次接收到的数据长度,置位帧完成标志
// 空闲中断处理函数
void USART1_IRQHandler(void)
{
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);

        // 停止 DMA,计算已接收字节数
        HAL_UART_DMAStop(&huart1);
        uint16_t received = RING_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_rx);

        // 更新环形缓冲区写指针
        ring_buf.head = (ring_buf.tail + received) % RING_BUFFER_SIZE;

        // 置位帧接收完成标志
        frame_received = 1;

        // 重新启动 DMA 接收
        HAL_UART_Receive_DMA(&huart1, ring_buf.buffer, RING_BUFFER_SIZE);
    }
    HAL_UART_IRQHandler(&huart1);
}
避坑指南:我曾经在空闲中断里直接处理数据,结果导致中断服务函数执行时间过长,影响了其他中断。后来我改成在空闲中断里只置位标志位,在主循环里处理数据。记住:中断里只做最轻量的事。

4.4 三种接收模式对比

模式 CPU 占用 适用场景 缺点
轮询接收 高(持续占用) 简单调试、低波特率 阻塞,无法处理其他任务
中断接收 中(每字节中断一次) 小数据量、不定长 高波特率下中断频繁
DMA + 空闲中断 低(仅帧结束时中断) 大数据量、高波特率 实现稍复杂,需环形缓冲区

我个人强烈推荐第三种。虽然配置起来多几行代码,但带来的性能提升是实打实的。你想想看,115200 波特率下,每 86 微秒就有一个字节,如果用中断接收,CPU 每 86 微秒就要被打断一次。而用 DMA,CPU 可以连续跑几百微秒甚至几毫秒才处理一次数据。

4.5 整体架构图

下面这张图展示了串口 DMA 接收 + 空闲中断 + 环形缓冲区的完整数据流。我画这张图时特意把关键路径标出来了,方便你理解。

串口 DMA 接收 + 空闲中断 + 环形缓冲区 数据流 串口外设 USART1/2/3... DMA 控制器 自动搬运数据 环形缓冲区 head / tail 索引 DR 寄存器 内存搬运 空闲中断 总线空闲触发 中断处理函数 应用程序 置位帧完成标志 关键点 • 中断只置标志位 • 主循环处理数据 • 注意 volatile 修饰

数据从串口外设进来,DMA 自动搬运到环形缓冲区。当一帧数据结束,总线空闲,触发空闲中断。中断里计算本次接收长度、更新写指针、置位标志。主循环检测到标志后,从环形缓冲区读取数据并处理。整个过程,CPU 只在帧结束时被中断一次。

又一个坑:DMA 接收缓冲区的大小要设置合理。太小了容易溢出,太大了浪费内存。我一般根据最大帧长度乘以 2 来设置。比如最大帧 256 字节,缓冲区就设 512 字节。这样即使处理不及时,也能缓冲一帧半的数据。

好了,这一章的内容就这些。串口 DMA 这块,说白了就是用硬件搬运代替软件搬运,用帧中断代替字节中断。配置起来虽然多几行代码,但带来的性能提升是立竿见影的。你可以在自己的项目里试试,把原来的中断接收改成 DMA + 空闲中断,看看 CPU 占用率降了多少。