STM32 串口 + DMA 收发:IDLE 中断这套写法,才算真的稳

串口这东西,入门的时候都觉得简单:HAL_UART_Transmit 一发,HAL_UART_Receive 一收,能通就行。真到项目里,波特率拉到 115200 甚至 921600,或者要长时间稳定收一帧不定长的协议数据,问题就来了——主循环被卡住、字节莫名其妙丢、DMA 开着开着就不进中断了。

我自己最早也是用查询 + RXNE 中断一个字节一个字节收,后来被 DMA 救了。这篇把我踩过的坑和最终稳定下来的一套写法讲清楚,代码基于 HAL 库,STM32F4 / H7 都能直接用(个别点我会单独标出来)。

串口为什么非得上 DMA

说白了就三件事:

  • CPU 被串口绑架。115200 下每 ~87µs 来一个字节,用 RXNE 中断的话,中断频率高到离谱,主循环基本干不了别的事。
  • 高速下中断收不过来就丢。波特率越高,字节间隔越短,中间只要来个更高优先级的中断,RX 寄存器就被覆盖,数据直接没。
  • DMA 干的事是外设和内存之间直接搬数据,CPU 只需要在"一帧收完"或者"缓冲区满"的时候被通知一次。省下来的时间干啥都行。

最稳的接收玩法:IDLE 中断 + DMA

原理很直白:串口线空闲(连续一个字节时间没有新数据)就触发 IDLE 中断,基本等于告诉你"一帧发完了"。配合 DMA 把数据搬进 buffer,收到 IDLE 时你就能拿到完整一帧,长度也知道。

老写法要自己开 IDLE 中断,再拿 DMA 的剩余计数(NDTR)去算收到了多少字节,麻烦还容易错。现在 HAL 直接给了 HAL_UARTEx_ReceiveToIdle_DMA,把这套封装好了,用就完事:

#define RX_BUF_SIZE 256
uint8_t rx_buf[RX_BUF_SIZE];

// 启动:DMA 搬运 + IDLE 中断一起开
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, RX_BUF_SIZE);
// 关掉半传输中断,少进几次无意义的回调
__HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);

收满或者触发 IDLE,都会进 HAL_UARTEx_RxEventCallbackSize 就是这次收到的字节数:

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1) {
        // Size 是本次实际收到的字节数
        process_frame(rx_buf, Size);

        // ★ 关键坑:必须重新武装,否则只收一帧就再也不进了
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, RX_BUF_SIZE);
        __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);
    }
}

有个细节要提一下:上面这种"在回调里就地重新武装"是最快的写法,但如果你在回调里还干重活(比如解析协议),新数据可能覆盖掉没处理完的旧数据。更稳的做法是把数据 memcpy 出来,或者置一个 ready 标志丢给主循环,处理完再重新武装——后面完整例程用的是后者。

发送也别用阻塞

发送用 DMA,意义不在快,在于不卡主循环

HAL_UART_Transmit 是阻塞的,发 1KB 在 115200 下要 ~90ms,主循环直接卡死。换成 DMA,函数立刻返回,发完进 HAL_UART_TxCpltCallback

void uart_send(const uint8_t *data, uint16_t len)
{
    // 等上一次 DMA 发送完成(简单做法,量产可换信号量)
    while (huart1.gState != HAL_UART_STATE_READY) {
        // 空转
    }
    HAL_UART_Transmit_DMA(&huart1, (uint8_t *)data, len);
}

真正会坑你的几个点

这几个是我实打实翻过车的,按出现频率排:

1. 缓冲区覆盖
RX_BUF 要能装下最长的一帧。如果装不下,DMA 循环覆盖,旧数据直接没。定长协议就按最大帧长开;不定长且可能连续来,上双缓冲或者 ring buffer,别硬扛。

2. H7 的 Cache 一致性
如果你用 STM32H7,DMA 写内存是不走 CPU cache 的,CPU 读到的可能是旧值。读之前先Invalidate:

SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, RX_BUF_SIZE);

发送前反过来 Clean 一下:

SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, tx_len);

F1 / F4 没 cache,不用管这茬。

3. 半传输中断太吵
DMA 默认会开 HT(半传输)中断,频繁进回调。如果只是收帧,像上面那样 __HAL_DMA_DISABLE_IT(..., DMA_IT_HT) 关掉,只关心 IDLE / 全满就行。

4. 重新武装的时机
前面说了,回调里就地 re-arm 最快,但若处理慢会覆盖;挪到主循环处理再 re-arm,代价是 re-arm 之前这段时间 DMA 仍在写同一块 buffer。连续高速数据流建议直接上 ring buffer 或双缓冲(circular + 自己管两个半区),别在这两个方案里将就。

5. 波特率与时钟
高波特率(>1M)对时钟精度敏感。Cube 里时钟树配不对,会偶发帧错误(溢出 / 噪声标志)。先拿示波器或逻辑分析仪看波形,别上来就怀疑代码。

完整例程骨架

把上面拼起来,一个能直接跑的 main.c 骨架大致是这样(Init 部分省略了 Cube 自动生成的 DMA 句柄,重点看收发逻辑):

#include "main.h"

UART_HandleTypeDef huart1;
DMA_HandleTypeDef hdma_usart1_rx;
DMA_HandleTypeDef hdma_usart1_tx;

#define RX_BUF_SIZE 256
uint8_t rx_buf[RX_BUF_SIZE];
volatile uint8_t  rx_ready = 0;
volatile uint16_t rx_len   = 0;

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_USART1_UART_Init();   // Cube 生成,里面会绑定 hdma_usart1_rx/tx

    // 启动 DMA + IDLE 接收
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, RX_BUF_SIZE);
    __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);

    while (1) {
        if (rx_ready) {
            rx_ready = 0;
            // 在主循环里处理,不在中断里干重活
            process_frame(rx_buf, rx_len);
            // 处理完再重新武装
            HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, RX_BUF_SIZE);
            __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);
        }
        // 主循环现在空出来了,该干啥干啥
    }
}

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1) {
        rx_len   = Size;
        rx_ready = 1;   // 通知主循环,具体活儿留给主循环
    }
}

void uart_send(const uint8_t *data, uint16_t len)
{
    while (huart1.gState != HAL_UART_STATE_READY) {
        // 等上一次发送完成
    }
    HAL_UART_Transmit_DMA(&huart1, (uint8_t *)data, len);
}

收尾

这套 IDLE + DMA 的写法,我在项目里跑了一年多,115200 长连、偶发突发大数据包,没再丢过。核心就三件事:DMA 搬数据、IDLE 判定帧边界、回调里记得重新武装。剩下的坑基本都出在 cache、缓冲区大小和重新武装时机这三处,对着改就行。

如果你们的协议是固定长度,或者走 Modbus 这类主从帧,其实还可以用定时器超时判帧,比纯 IDLE 更稳——这个以后有空再写。

代码基于 STM32 HAL 库,F4 / H7 通用;H7 记得处理 Cache 那两行。CubeMX 里记得把 USART1 的 DMA 接收设成 Normal(不是 Circular),否则重武装逻辑会和循环模式打架。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值