DMA+UART 环形缓冲区案例讲解

AI助手已提取文章相关产品:

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),仅供参考

您可能感兴趣的与本文相关内容

基于可靠性评估序贯蒙特卡洛模拟法的配电网可靠性评估研究(Matlab代码实现)内容概要:本文围绕“基于可靠性评估序贯蒙特卡洛模拟法的配电网可靠性评估研究”,介绍了利用Matlab代码实现配电网可靠性的仿真分析方法。重点采用序贯蒙特卡洛模拟法对配电网进行长时间段的状态抽样与统计,通过模拟系统元件的故障与修复过程,评估配电网的关键可靠性指标,如系统停电频率、停电持续时间、负荷点可靠性等。该方法能够有效处理复杂网络结构与设备时序特性,提升评估精度,适用于含分布式电源、电动汽车等新型负荷接入的现代配电网。文中提供了完整的Matlab实现代码与案例分析,便于复现和扩展应用。; 适合人群:具备电力系统基础知识和Matlab编程能力的高校研究生、科研人员及电力行业技术人员,尤其适合从事配电网规划、运行与可靠性分析相关工作的人员; 使用场景及目标:①掌握序贯蒙特卡洛模拟法在电力系统可靠性评估中的基本原理与实现流程;②学习如何通过Matlab构建配电网仿真模型并进行状态转移模拟;③应用于含新能源接入的复杂配电网可靠性定量评估与优化设计; 阅读建议:建议结合文中提供的Matlab代码逐段调试运行,理解状态抽样、故障判断、修复逻辑及指标统计的具体实现方式,同时可扩展至不同网络结构或加入更多不确定性因素进行深化研究。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值