串口这东西,入门的时候都觉得简单: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_RxEventCallback,Size 就是这次收到的字节数:
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),否则重武装逻辑会和循环模式打架。

3万+

被折叠的 条评论
为什么被折叠?



