DMA + UART 环形缓冲区实战:让串口通信不再“卡主线程”
你有没有遇到过这样的场景?
调试一个嵌入式系统,UART 接收传感器数据,每秒几百条。一开始用中断方式处理——每来一个字节就进一次中断,结果发现主循环越来越慢,RTOS 任务开始掉帧,甚至看门狗都触发了复位。
“这不科学啊,我只开了个串口……”
可现实就是这么残酷:
115200 波特率下,平均每 87 微秒就会来一个字节
。如果每个中断花 20μs 处理,CPU 就得把近四分之一的时间花在“搬运数据”这种低级事务上。
更别提突发流量时的丢包问题。设备发了一堆 JSON 数据包,结果粘在一起、拆得七零八落,解析直接崩溃。
这不是代码写得不好,而是架构选型出了问题。
好消息是,现代 MCU 早就为我们准备了解药: DMA + UART + 环形缓冲区 。这套组合拳,能把串口从“性能黑洞”变成“静默管道”,几乎不打扰 CPU,还能稳稳接住高速数据流。
今天我们就以 STM32 为例,彻底讲清楚这个嵌入式开发中的“必杀技”。不是照搬手册,而是从工程实践的角度,告诉你它为什么有效、怎么落地、有哪些坑要避开。
为什么传统中断模式撑不住高负载?
先别急着上 DMA,我们得明白——到底是什么让普通中断接收成了瓶颈。
设想一下,UART 收到数据,硬件拉高中断线,CPU 停下手头工作,保存上下文,跳转到中断服务函数(ISR),读一个字节,存进缓冲区,退出中断,恢复现场……这一套流程下来,哪怕再快也得几十个周期。
而当数据密集到来时:
- 中断频繁发生(比如每 87μs 一次)
- 上下文切换开销累积成山
- 主程序得不到足够运行时间
- 最终表现为:系统卡顿、响应延迟、任务失步
更要命的是,MCU 的 NVIC 并不能“合并”连续的 UART 中断。每一个字节都是独立事件,哪怕你什么都不做,它也会一次次把你拽进 ISR。
🤯 想象你在开会,每过一分钟就有个人敲门说:“老板,刚来了封邮件。”
即使内容只是“测试测试”,你也得暂停会议去应付。
这就是传统串口中断的真实写照。
所以,根本出路不是优化 ISR,而是 减少进入 ISR 的次数 。
理想情况是什么?
最好整个数据包过来之后,只通知你一次:“嘿,一帧数据收完了,来取吧。”
这就引出了我们的第一个关键角色: IDLE Line Detection(空闲线检测) 。
IDLE 中断:让“每一帧结束”成为唯一通知点
UART 是异步通信,没有时钟线同步,但它有一个隐含的时间特征: 帧与帧之间通常存在短暂的总线空闲期 。
比如两个 Modbus 报文之间隔 3.5 个字符时间;或者 JSON 包之间有个换行或延时。这段时间里,RX 引脚保持高电平(空闲态)。
STM32 的 UART 控制器可以检测这个状态变化:一旦发现 RX 在持续接收后突然变为空闲,就会置位
IDLE
标志,并触发中断——前提是开启了
UART_IT_IDLE
。
这意味着什么?
👉 我们不再关心“来了几个字节”,只关心“一整段数据是否收完” 。
配合 DMA 使用,效果爆炸:
- DMA 负责默默把所有收到的字节搬进内存
- 当数据流暂停,IDLE 中断被触发
- 此时再去检查 DMA 已经搬了多少数据,就知道完整的一帧有多长
这样一来,无论这一帧是 10 字节还是 1000 字节, CPU 只被打扰一次 。
是不是比每字节中断高效太多了?
但这里有个前提:你得知道 DMA 到底搬了多长的数据。而这就依赖于另一个重要机制——DMA 的“剩余数据寄存器”。
DMA 如何告诉我们“已经收了多少字节”?
在 STM32 HAL 库中,当我们调用:
uint8_t dma_rx_buffer[256];
HAL_UART_Receive_DMA(&huart1, dma_rx_buffer, 256);
背后发生了什么?
DMA 被配置为从 UART 的数据寄存器(通常是
USARTx_DR
)向
dma_rx_buffer
搬运数据,每次 UART 收到一个字节,DMA 自动触发一次传输。
同时,DMA 控制器内部有一个 NDTR(Number of Data Register) 寄存器,初始值设为 256,每完成一次传输就减 1。
也就是说, 当前已接收的字节数 = 初始长度 - NDTR 当前值 。
例如,如果 NDTR 显示还剩 240 个未传输,则说明已经有 16 个字节被写入缓冲区。
⚠️ 注意:NDTR 表示的是“还要传多少”,不是“已经传了多少”。
所以在 IDLE 中断里,我们可以这样计算有效数据长度:
void UART_IDLE_IRQHandler(void) {
uint16_t current_counter;
// 先清除 IDLE 标志:必须先读 SR,再读 DR
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
// 获取当前 DMA 尚未传输的数据量
current_counter = __HAL_DMA_GET_COUNTER(huart1.hdmarx);
// 实际接收到的数据长度
uint16_t received_len = BUFFER_SIZE - current_counter;
// 将这段数据交给环形缓冲区管理模块
ring_buffer_write(&g_rx_ring_buf, dma_rx_buffer, received_len);
// 重启 DMA 接收(重新加载计数器)
__HAL_DMA_DISABLE(huart1.hdmarx);
huart1.hdmarx->Instance->NDTR = BUFFER_SIZE;
__HAL_DMA_ENABLE(huart1.hdmarx);
}
}
看到没?整个过程非常干净利落:
- 不需要逐字节复制
- 不需要维护复杂的状态机
- 只需在“帧结束”时抓一次快照,然后批量移交数据
而且因为用了 DMA 循环模式(Circular Mode),即使中间有短时间无法处理,也不会丢数据——新的字节会继续往缓冲区里填,直到溢出为止。
说到这儿,就得谈谈那个经典的搭档了: 环形缓冲区 。
环形缓冲区:生产者-消费者的完美桥梁
DMA 是“生产者”,它不断往缓冲区塞数据;你的主程序是“消费者”,需要从中取出并解析协议。
两者节奏完全不同:DMA 可能瞬间灌进来上百字节,而主程序可能每隔几毫秒才检查一次是否有新数据。
如果没有中间缓存,要么丢数据,要么阻塞生产者。
环形缓冲区(Ring Buffer)正是为此而生。
它的核心思想很简单:一块固定大小的数组,首尾相连,用两个指针追踪位置:
-
head:下一个写入的位置(由生产者更新) -
tail:下一个读取的位置(由消费者更新)
当
head == tail
,说明为空;当
(head + 1) % size == tail
,说明满(保留一个空位防混淆)。
但在实际应用中,我们常做一些优化:
✅ 使用 2 的幂大小 + 位掩码替代模运算
#define RING_BUFFER_SIZE 512 // 必须是 2^n
#define RING_BUFFER_MASK (RING_BUFFER_SIZE - 1)
head = (head + 1) & RING_BUFFER_MASK; // 比 % 快得多!
这对 Cortex-M 系列尤其重要,因为除法指令很慢,而位运算是单周期。
✅ volatile 关键字保护多上下文访问
由于
head
可能在中断/DMA 上下文中被修改,
tail
在主循环中被修改,必须声明为
volatile
,防止编译器优化导致读取旧值。
typedef struct {
uint8_t buffer[RING_BUFFER_SIZE];
volatile uint16_t head;
volatile uint16_t tail;
} ring_buffer_t;
✅ 提供安全的 API 接口
不要让使用者直接操作指针。封装成标准 FIFO 接口:
int ring_buffer_put(ring_buffer_t *rb, uint8_t byte) {
uint16_t next_head = (rb->head + 1) & RING_BUFFER_MASK;
if (next_head == rb->tail) return -1; // 已满
rb->buffer[rb->head] = byte;
rb->head = next_head;
return 0;
}
int ring_buffer_get(ring_buffer_t *rb, uint8_t *byte) {
if (rb->head == rb->tail) return -1; // 为空
*byte = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) & RING_BUFFER_MASK;
return 0;
}
uint16_t ring_buffer_available(ring_buffer_t *rb) {
return (rb->head - rb->tail) & RING_BUFFER_MASK;
}
这些接口保证了线程(或中断)安全的前提是: 只有一个生产者和一个消费者 。
如果你在 RTOS 下使用多个任务读取,就需要额外加锁(如信号量),但大多数情况下,串口数据由单一任务处理即可。
实战整合:构建完整的 DMA+UART 接收链路
现在我们把所有零件组装起来。
假设目标平台是 STM32H743,使用 UART1,波特率 115200,希望实现稳定接收外部设备发送的 JSON 数据包。
第一步:定义全局资源
#define RX_BUFFER_SIZE 256
#define RING_BUFFER_SIZE 1024
// DMA 直接写入的物理缓冲区
uint8_t dma_rx_buffer[RX_BUFFER_SIZE];
// 环形缓冲区(逻辑层)
ring_buffer_t g_uart_ring_buf;
第二步:初始化 UART 和 DMA
使用 CubeMX 或手写初始化代码,确保开启以下功能:
- UART1 时钟使能
- GPIO 配置为 AF7_USART1
- 波特率设置为 115200
- 数据格式 8-N-1
- 开启 DMA 接收通道(DMA1_Stream0 或对应通道)
- 启用 UART 空闲中断(IDLE IE)
关键代码片段:
// 启动 DMA 接收(循环模式)
HAL_UART_Receive_DMA(&huart1, dma_rx_buffer, RX_BUFFER_SIZE);
// 必须手动开启 IDLE 中断(HAL 不自动开)
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
注意:HAL 默认不会打开
UART_IT_IDLE
,一定要手动启用!
第三步:编写 IDLE 中断处理函数
void USART1_IRQHandler(void) {
uint16_t remaining;
uint16_t received;
// 检查是否是 IDLE 中断
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) &&
__HAL_UART_GET_IT_SOURCE(&huart1, UART_IT_IDLE)) {
// 清除 IDLE 标志:顺序不能错!
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
// 获取当前 DMA 剩余计数值
remaining = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);
received = RX_BUFFER_SIZE - remaining;
// 批量写入环形缓冲区
for (int i = 0; i < received; i++) {
ring_buffer_put(&g_uart_ring_buf, dma_rx_buffer[i]);
}
// 重启 DMA(重新装填计数器)
__HAL_DMA_DISABLE(&hdma_usart1_rx);
huart1.hdmarx->Instance->NDTR = RX_BUFFER_SIZE;
__HAL_DMA_ENABLE(&hdma_usart1_rx);
}
// 其他中断类型(如错误)也可在此处理
}
💡 小技巧:有些开发者喜欢用双缓冲(Double Buffer)+ 半传输中断(HT)来进一步提升效率,但对于大多数应用场景,单缓冲 + IDLE 中断已足够简洁高效。
第四步:主循环中消费数据
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
// 初始化环形缓冲区
memset(&g_uart_ring_buf, 0, sizeof(g_uart_ring_buf));
// 启动 DMA 接收
HAL_UART_Receive_DMA(&huart1, dma_rx_buffer, RX_BUFFER_SIZE);
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
while (1) {
// 检查是否有足够数据构成一个包(比如以 '\n' 结尾)
if (ring_buffer_available(&g_uart_ring_buf) > 0) {
static uint8_t temp_buf[256];
int len = 0;
// 临时提取数据进行分析
while (len < 255 && ring_buffer_available(&g_uart_ring_buf)) {
uint8_t c;
if (ring_buffer_get(&g_uart_ring_buf, &c) == 0) {
temp_buf[len++] = c;
if (c == '\n') break; // 发现完整行
}
}
if (len > 0) {
temp_buf[len] = '\0';
parse_json_or_command(temp_buf, len); // 协议解析
}
}
// 其他任务...
osDelay(1); // 如果用了 RTOS
}
}
你看,主循环完全不受干扰,只在有数据时才去处理,且一次处理一整块。
常见陷阱与避坑指南 💣
这套方案虽强,但也有一些容易踩的雷区:
❌ 1. 忘记清除 IDLE 标志,导致中断反复触发
这是最常见 bug!
IDLE 标志一旦被置起,除非手动清除,否则会一直触发中断。
而且清除顺序必须是: 先读 SR,再读 DR 。
HAL 提供了宏
__HAL_UART_CLEAR_IDLEFLAG()
,内部已经做了正确操作:
#define __HAL_UART_CLEAR_IDLEFLAG(__HANDLE__) do {\
__IO uint32_t tmpreg = 0x00U;\
tmpreg = (__HANDLE__)->Instance->SR;\
tmpreg = (__HANDLE__)->Instance->DR;\
UNUSED(tmpreg);\
} while(0)
千万别自己随便读个寄存器糊弄过去。
❌ 2. 缓冲区太小,导致高频突发数据溢出
虽然 DMA + RingBuf 能扛一阵子,但终究不是无限缓存。
举个例子:你设了个 256 字节的 DMA 缓冲区,设备突然连发 500 字节,中间无停顿。那么前 256 字节会被正常接收,后 256 字节呢?
答案是: 覆盖前面的数据 !因为是循环模式。
等到 IDLE 中断触发时,NDTR 显示剩余 0,你以为收到了整整 256 字节,但实际上可能是最后半包 + 前半包的混合体,协议解析必然失败。
📌 解决办法:
- 增大 DMA 缓冲区(建议 ≥ 最大报文长度)
- 或者改用双缓冲模式(Memory-to-Memory 双缓冲切换)
- 更激进的做法:使用带 FIFO 的专用通信协处理器(少见)
❌ 3. Cache 一致性问题(仅适用于 M7/M4F 等带缓存的芯片)
如果你的 MCU 有 D-Cache(如 STM32H7、F7、F429),并且 DMA 缓冲区位于可缓存区域,可能会出现:
“明明 DMA 写了数据,但我读出来却是旧的?”
原因在于:CPU 从 cache 读,而 DMA 写的是真实内存。
解决方案有两个方向:
方案 A:将 DMA 缓冲区放在非缓存区(推荐)
通过链接脚本或 MPU 设置一块 Non-cacheable 内存区域:
/* 在 .ld 文件中定义 */
.DMA_Buffers (NOLOAD) : {
_dmabuffers_start = .;
. = . + 1K;
_dmabuffers_end = .;
} > RAM_D2 /* H7 上的 D2 域支持 NONCACHEABLE */
然后分配
dma_rx_buffer
到该区域。
方案 B:手动维护 Cache 一致性
在 IDLE 中断中加入无效化操作:
SCB_InvalidateDCache_by_Addr((uint32_t*)dma_rx_buffer, RX_BUFFER_SIZE);
⚠️ 注意:只能 invalid(清 cache),不能 clean(写回),因为我们不希望 CPU 的脏数据污染 DMA 写入的内容。
❌ 4. 在中断中做耗时操作,破坏实时性
有些人图省事,在 IDLE 中断里直接调用
parse_packet()
或
printf()
,殊不知这些函数可能涉及内存分配、锁、浮点运算……
后果就是:中断执行太久,其他外设响应延迟,甚至引发 HardFault。
✅ 正确做法:中断只做“移交数据”和“发信号”两件事:
- 把数据放进 ring buffer
- 设置标志位,或给 RTOS 任务发信号量 / 消息队列
真正的解析留给主任务去做。
发送也可以用 DMA?当然!
别忘了,DMA 不仅能收,还能发。
当你需要发送大量数据(比如固件升级、日志导出、图像传输),同样可以用 DMA 减轻负担。
uint8_t tx_data[] = "Hello World\n";
HAL_UART_Transmit_DMA(&huart1, tx_data, sizeof(tx_data));
发送完成后会触发
TC
(Transmission Complete)中断,可以在回调中释放缓冲区或启动下一轮发送。
如果你想实现全双工流水线,还可以为 TX 也配一个环形缓冲区,实现“后台自动发送”的效果。
在 RTOS 下如何做得更好?
FreeRTOS、ThreadX、Zephyr 等实时系统下,这套机制还能进一步升华。
✅ 用消息队列通知任务
不在主循环轮询,而是让 IDLE 中断直接唤醒处理任务:
// 全局定义
TaskHandle_t uart_task_handle;
QueueHandle_t uart_data_queue;
// 在中断中发送事件
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xQueueSendFromISR(uart_data_queue, &received_packet_info, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
✅ 使用静态内存避免动态分配
嵌入式环境慎用
malloc/free
。环形缓冲区内存应静态分配:
static uint8_t s_ring_buffer_storage[1024];
static ring_buffer_t s_ring_buf = {
.buffer = s_ring_buffer_storage,
.head = 0,
.tail = 0
};
✅ 加入超时机制防死锁
万一对方设备断线,没有 IDLE 中断怎么办?
可以加一个定时器监控:如果超过一定时间没收到新数据,强制触发一次“假 IDLE”,处理已有缓存。
它真的适用于所有场景吗?
当然不是。任何技术都有适用边界。
✅
适合场景
:
- 高频、小包、间歇性数据流(如传感器、遥测)
- 协议自带帧间隔(Modbus RTU、NMEA、自定义文本协议)
- 对 CPU 占用敏感的系统(如音视频处理、AI 推理)
❌
不适合场景
:
- 数据连续不断、无空闲间隙(如音频流、加密隧道)
- 波特率极低(此时中断成本本身就不高)
- 无法控制对端通信行为(对方不停发,永远不 idle)
对于连续流,你需要考虑其他策略,比如:
- 定时采样 DMA 计数器(每 10ms 查一次)
- 使用 DMA 半传输中断(HT)+ 全传输中断(TC)交替触发
- 或干脆放弃 IDLE,改用协议层解析驱动消费
写到最后:这不仅仅是个“串口技巧”
表面上看,这只是解决了一个 UART 接收的问题。
但深入思考你会发现,这是一种典型的 异步解耦设计范式 :
- 生产者 (DMA)专注采集
- 缓冲层 (Ring Buffer)吸收波动
- 消费者 (Main Task)按节奏处理
- 事件驱动 (IDLE IRQ)作为协调信号
这套模式广泛存在于各种高性能系统中:
- 网络协议栈中的 sk_buff 队列
- Linux tty 子系统的 line discipline
- 音频系统的 audio buffer pipeline
- 工业 PLC 的 I/O 扫描机制
掌握它,不只是为了少写几个中断,更是理解如何构建 高吞吐、低延迟、抗抖动 的嵌入式系统的钥匙。
下次当你面对 SPI 接收 ADC 数据、I2S 播放音频、SDIO 读写 SD 卡时,不妨问问自己:
“我能用 DMA + RingBuf + 触发中断 的方式重构它吗?”
往往,答案是肯定的。 🔑
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
1万+

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



