一、为什么DMA+空闲中断如此流行
在STM32开发中,接收不定长串口数据是高频需求。传统逐字节中断方式CPU开销大,而HAL库提供的HAL_UARTEx_ReceiveToIdle_DMA()(或早期手动组合DMA+IDLE中断)能一次性把数据搬进内存,空闲中断再通知“一帧结束”,兼顾效率与实时性。
典型用法如下:
uint8_t rx_buf[256];
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf));
在回调中处理:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1) {
process_frame(rx_buf, Size);
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf));
}
}
看起来很美,但隐患就藏在“重新启动接收”与“处理数据”的时序里。
二、溢出风险从何而来
2.1 单缓冲区的致命窗口
上述代码中,rx_buf只有一份。回调里先process_frame()再重启DMA。若处理耗时较长,而对方持续发数据,DMA此时处于停止状态,新数据直接丢失(ORE溢出)。
更隐蔽的是:若在回调中先重启DMA、后处理数据,DMA会立刻向同一块rx_buf写入新数据,覆盖尚未处理完的旧帧,造成数据错乱。
2.2 半传输与空闲中断的竞争
当一帧长度超过缓冲区一半时,DMA半传输中断与空闲中断可能交错触发。若回调中未区分事件类型,容易重复处理或漏处理。
2.3 高波特率下的“来不及”
115200bps下每字节约87µs,1KB数据仅约87ms。若主循环或回调中有阻塞操作(如打印日志、Flash擦写),溢出几乎必然发生。
结论:单缓冲区方案只适合“低速、短帧、处理快”的场景,工程中必须改造。
三、环形缓冲改造思路
核心思想:DMA只负责把数据搬进一个足够大的环形缓冲区,空闲中断只更新写指针,数据处理与接收解耦。
- DMA目标地址始终指向环形缓冲区中的“当前写位置”,避免覆盖未读数据。
- 空闲中断到来时,根据DMA剩余计数(
__HAL_DMA_GET_COUNTER)计算本帧长度,推进写指针。 - 应用层从读指针取数据,读写指针分离,天然支持生产者-消费者模型。
为简化实现,这里采用“双缓冲+环形队列”的折中方案:DMA在两个半区交替接收,空闲中断将整帧拷贝进软件环形队列,再立即重启DMA。这样既避免覆盖,又保留HAL的易用性。
四、CubeMX配置步骤
- 使能USART1,模式Asynchronous,波特率115200。
- 在NVIC中使能USART1全局中断。
- 在DMA Settings中添加USART1_RX,模式Circular(循环模式),优先级Medium。
- 生成代码后,在
main.c中调用初始化接收函数。
注意:使用
HAL_UARTEx_ReceiveToIdle_DMA时,DMA建议配置为Circular,HAL会自动处理半传输/传输完成事件。
五、完整代码示例
5.1 环形队列定义
#define RING_SIZE 1024
typedef struct {
uint8_t buf[RING_SIZE];
volatile uint16_t head; // 写指针
volatile uint16_t tail; // 读指针
} ring_t;
static ring_t g_ring;
static uint8_t g_dma_buf[256];
static inline uint16_t ring_used(void)
{
return (g_ring.head - g_ring.tail) & (RING_SIZE - 1);
}
static bool ring_push(const uint8_t *data, uint16_t len)
{
if (len > RING_SIZE - 1 - ring_used()) return false;
for (uint16_t i = 0; i < len; i++) {
g_ring.buf[g_ring.head] = data[i];
g_ring.head = (g_ring.head + 1) & (RING_SIZE - 1);
}
return true;
}
uint16_t ring_pop(uint8_t *out, uint16_t max_len)
{
uint16_t n = 0;
while (n < max_len && g_ring.tail != g_ring.head) {
out[n++] = g_ring.buf[g_ring.tail];
g_ring.tail = (g_ring.tail + 1) & (RING_SIZE - 1);
}
return n;
}
5.2 启动接收与回调
void uart_rx_start(void)
{
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_dma_buf, sizeof(g_dma_buf));
}
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance != USART1) return;
/* 先入队,再重启DMA,避免覆盖 */
if (!ring_push(g_dma_buf, Size)) {
/* 队列满:记录溢出计数,丢弃本帧 */
g_overflow_cnt++;
}
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_dma_buf, sizeof(g_dma_buf));
}
5.3 应用层读取
void app_poll(void)
{
uint8_t tmp[64];
uint16_t n;
while ((n = ring_pop(tmp, sizeof(tmp))) > 0) {
parse_protocol(tmp, n); // 非阻塞解析
}
}
六、关键注意事项
- 回调中不做耗时操作:只做入队和重启DMA,解析放到主循环。
- 队列容量要留余量:按“最大帧长 × 突发帧数”估算,建议≥2倍。
-
读写指针用
volatile:回调与主循环共享,编译器优化可能出错。 - 单生产者单消费者无需关中断:若多任务访问,需加临界区保护。
-
溢出要可观测:增加
g_overflow_cnt,便于现场定位。 -
半传输事件:
HAL_UARTEx_RxEventCallback会区分HAL_UART_RXEVENT_HT与IDLE,本方案统一按Size入队即可,但需确认HAL版本行为一致。 -
DMA循环模式与重启:循环模式下重复调用
ReceiveToIdle_DMA是安全的,HAL会重置计数器。
七、总结
DMA+空闲中断是STM32串口接收的利器,但单缓冲区方案在突发数据下存在覆盖与丢包风险。通过引入环形队列,把“接收”与“处理”解耦,并让DMA只写入临时缓冲、回调快速入队,可显著提升系统鲁棒性。改造后代码结构清晰、易于扩展,适合作为产品级串口框架的基础。
暂无评论,来抢沙发~