news 2026/8/4 17:03:49

嵌入式DMA实战:通道与流详解及USART不定长接收实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
嵌入式DMA实战:通道与流详解及USART不定长接收实现

最近在嵌入式开发中,经常需要处理外设与内存之间的大量数据搬运。如果全靠CPU来“手动”搬运,不仅效率低下,还会严重占用CPU资源,导致系统整体性能下降。这时,一个强大的“搬运工”——DMA(Direct Memory Access,直接存储器访问)就显得至关重要。本文将以一个典型的嵌入式场景为例,深入剖析DMA,特别是其通道(Channel)和流(Stream)的概念,并提供一个从理论到实战的完整指南。无论你是刚接触STM32等MCU的新手,还是希望优化现有项目性能的开发者,都能从中获得可直接复用的代码和清晰的配置思路。

1. DMA 核心概念与价值

在深入细节之前,我们首先要理解DMA到底是什么,以及它为何如此重要。

1.1 什么是 DMA?

DMA,即直接存储器访问,是一种允许特定硬件子系统(外设)直接读写系统内存,而无需中央处理器(CPU)介入的技术。你可以把它想象成一个高效的“快递员”。

  • 没有DMA(CPU搬运):CPU需要从源地址(如串口接收缓冲区)读取一个字节,再写入到目标地址(如内存数组),然后循环此过程。在此期间,CPU无法执行其他任务。
  • 有DMA(DMA搬运):CPU只需要告诉DMA“快递员”几个关键信息:货物在哪(源地址)、送到哪(目标地址)、有多少(数据量)。然后CPU就可以去处理其他任务了,而DMA会在后台默默完成所有数据的搬运工作。搬运完成后,DMA会通过中断通知CPU“快递已送达”。

1.2 为什么需要 DMA?

使用DMA主要带来两大核心优势:

  1. 解放CPU,提升系统效率:这是DMA最根本的价值。将耗时的数据搬运工作交给DMA硬件,CPU得以处理更复杂的计算、逻辑判断或响应其他事件,极大地提高了系统的并发处理能力和整体吞吐量。
  2. 满足高速数据流需求:对于一些高速外设,如ADC(模数转换器)、DAC(数模转换器)、高速串口(USART)、摄像头接口(DCMI)、SDIO等,它们产生或消耗数据的速度非常快。如果靠CPU来搬运,很可能因为速度跟不上而导致数据丢失或溢出。DMA专为这种高速、连续的数据流设计。

1.3 DMA 的常见应用场景

  • 串口(UART/USART)通信:自动接收一帧数据到内存缓冲区,或从内存缓冲区发送数据。
  • ADC 采样:将ADC连续转换的结果自动存储到指定的内存数组中,用于波形分析。
  • SPI/I2C 通信:与外部传感器、存储器进行大数据块传输。
  • 内存到内存的传输:快速复制、填充或移动内存中的大块数据。
  • 音频处理:从I2S接口接收音频数据流,或向DAC发送音频数据流。

理解了DMA的价值,我们接下来看看现代微控制器(如ARM Cortex-M系列)中DMA的典型架构,特别是“通道”和“流”这两个关键概念。

2. DMA 架构解析:通道 (Channel) 与流 (Stream)

在不同的芯片厂商和系列中,DMA控制器的设计略有不同。我们以意法半导体(ST)的STM32系列(尤其是F4/F7/H7等系列)为例进行讲解,其概念具有广泛的代表性。

2.1 流 (Stream)

在STM32的DMA控制器中,流(Stream)是数据传输的物理路径。你可以把它看作一条“传输管道”。一个DMA控制器通常有多个流(例如STM32F4有2个DMA控制器,每个有8个流,共16个流)。

每个流是独立的,可以配置为执行一次特定的传输任务。关键点在于:一个流在同一时间只能服务于一个传输请求

2.2 通道 (Channel)

通道(Channel)标识了传输请求的来源或目的地,即与哪个外设相连。每个流可以连接到多个可能的通道之一。例如,STM32F4的每个流可能有8个通道选项(Channel 0~7)。

  • 通道0可能对应 ADC1
  • 通道1可能对应 SPI1_TX
  • 通道2可能对应 USART1_RX
  • ...等等

通道号与外设的映射关系是芯片硬件设计时固定的,需要查阅芯片的《参考手册》(Reference Manual)中的“DMA请求映射”表格来确定。

2.3 流与通道的关系

流和通道的关系是“多对一”的选择关系。一个外设(对应一个通道)的传输请求,可以由多个流中的某一个来服务。但你在配置时,需要为选定的流指定它所服务的通道。

为什么这样设计?这种设计提供了极大的灵活性。假设你的系统同时需要:

  1. USART1 接收数据(使用DMA)。
  2. ADC1 连续采样(使用DMA)。
  3. SPI2 发送数据(使用DMA)。

如果只有一个流,它们必须串行排队。但有了多个流,你可以将USART1_RX(通道4)分配给流2,ADC1(通道0)分配给流0,SPI2_TX(通道1)分配给流5。这样,三个外设的DMA传输可以近乎并行地进行,只要它们不使用同一个流。

配置流程的核心逻辑

  1. 确定外设:我要用哪个外设进行DMA传输?(例如:USART1的接收)
  2. 查找通道:根据《参考手册》,找到此外设对应的DMA通道号。(例如:USART1_RX 对应 DMA2 Stream2 的 Channel 4)
  3. 选择流:通道号可能关联到多个流,你需要选择一个当前未被占用的流。(例如,根据手册,对于USART1_RX,只能使用DMA2的Stream2或Stream5,且通道号为4。我们选择Stream2)。
  4. 配置流:初始化这个流(Stream2),并将其通道(Channel)设置为查到的值(4)。

3. 环境准备与开发平台说明

在开始实战之前,我们需要明确开发环境。本文的示例基于以下常见环境,但核心思想和代码结构具有普适性。

  • MCU/开发板:STM32F407ZGT6(其他STM32F4/F7/H7系列类似)
  • 开发环境:STM32CubeIDE 1.10.0 或 Keil MDK
  • 固件库/框架:HAL库(硬件抽象层)
  • 示例功能:使用USART1接收不定长数据,并通过DMA将数据自动搬运到内存缓冲区,利用空闲中断(Idle Interrupt)判断一帧数据接收完成。

为什么选择HAL库?HAL库提供了标准化的API,屏蔽了底层寄存器操作的差异,让开发者更关注业务逻辑,代码可移植性更强。对于初学者和理解概念尤为友好。

项目结构预览:

Your_Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ └── usart_dma.h // 我们即将创建的头文件 │ ├── Src/ │ │ ├── main.c │ │ └── usart_dma.c // 我们即将创建的源文件 │ └── Startup/ ├── Drivers/ │ └── STM32F4xx_HAL_Driver/ └── ... (其他工程文件)

接下来,我们将一步步实现一个完整的USART DMA接收示例。

4. 完整实战:USART DMA 接收不定长数据

这是一个非常经典且实用的案例,广泛应用于串口通信协议解析(如Modbus,自定义协议等)。

4.1 使用 STM32CubeMX 进行基础配置

如果你使用STM32CubeIDE,其内置了CubeMX。我们首先进行图形化配置。

  1. 启用USART1:在“Pinout & Configuration”标签页中,找到USART1。将模式(Mode)设置为“Asynchronous”(异步)。引脚PA9(TX)和PA10(RX)会自动配置。
  2. 配置DMA
    • 在“DMA Settings”标签页,点击“Add”。
    • 选择“USART1_RX”。
    • 此时,CubeMX会自动为你选择正确的DMA流(对于F4,USART1_RX是DMA2 Stream2)和通道(Channel 4)。这验证了我们之前的手册查阅过程。
    • 将模式(Mode)设置为“Circular”(循环模式)或“Normal”(正常模式)。本例先使用“Normal”。
    • 优先级(Priority)设为“Medium”。
    • 重要:将内存地址自增(Memory Increment Address)设为“Enable”,因为我们希望数据依次存放到数组中。外设地址自增(Peripheral Increment Address)保持“Disable”,因为串口接收数据寄存器地址是固定的。
  3. 启用USART1全局中断:在“NVIC Settings”标签页,勾选“USART1 global interrupt”。我们稍后需要用到空闲中断。
  4. 生成代码:点击“Generate Code”,CubeMX会为你生成初始化代码。

4.2 编写 DMA 接收管理代码

CubeMX生成了基础初始化代码(MX_USART1_UART_InitMX_DMA_Init),但我们需要编写更上层的应用逻辑来管理DMA接收。

创建usart_dma.husart_dma.c文件。

usart_dma.h

#ifndef __USART_DMA_H #define __USART_DMA_H #include "main.h" #include "stm32f4xx_hal.h" // 定义接收缓冲区大小 #define USART_DMA_RX_BUFFER_SIZE 256 // 声明外部变量,用于在main.c或其他文件中访问 extern UART_HandleTypeDef huart1; extern DMA_HandleTypeDef hdma_usart1_rx; // 声明接收缓冲区 extern uint8_t usart_dma_rx_buffer[USART_DMA_RX_BUFFER_SIZE]; // 声明一个变量来记录接收到的一帧数据的长度 extern volatile uint16_t usart_dma_rx_len; // 声明一个标志位,表示一帧数据接收完成 extern volatile uint8_t usart_dma_rx_complete_flag; // 函数声明 void USART_DMA_Init(void); void USART_DMA_StartReceive(void); void USART_DMA_ProcessData(void); #endif /* __USART_DMA_H */

usart_dma.c

#include "usart_dma.h" // 定义全局变量 UART_HandleTypeDef huart1; DMA_HandleTypeDef hdma_usart1_rx; uint8_t usart_dma_rx_buffer[USART_DMA_RX_BUFFER_SIZE] = {0}; volatile uint16_t usart_dma_rx_len = 0; volatile uint8_t usart_dma_rx_complete_flag = 0; /** * @brief USART1 DMA 功能初始化 * @note 在main.c的初始化部分调用,位于HAL_UART_Init之后 */ void USART_DMA_Init(void) { // 使能USART1的空闲线路中断(Idle Line Interrupt) // 这是实现不定长接收的关键! __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 启动第一次DMA接收 USART_DMA_StartReceive(); } /** * @brief 启动一次DMA接收 * @note 将DMA指向接收缓冲区,并启动传输。 * 当DMA传输完成(达到设定长度)或串口产生空闲中断时,本次传输结束。 */ void USART_DMA_StartReceive(void) { // 确保DMA处于停止状态 HAL_DMA_Abort(&hdma_usart1_rx); // 清除可能存在的空闲中断标志位(先读SR,再读DR寄存器) __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 复位接收完成标志和长度 usart_dma_rx_complete_flag = 0; usart_dma_rx_len = 0; // 启动DMA接收 // 参数:UART句柄, 数据寄存器地址, 目标内存地址, 接收数据量 if(HAL_UART_Receive_DMA(&huart1, usart_dma_rx_buffer, USART_DMA_RX_BUFFER_SIZE) != HAL_OK) { // 启动失败,可以进行错误处理,例如点亮错误LED Error_Handler(); } } /** * @brief 处理接收到的数据 * @note 当 usart_dma_rx_complete_flag 被置位后,在主循环中调用此函数。 */ void USART_DMA_ProcessData(void) { if(usart_dma_rx_complete_flag) { // 清除标志,准备接收下一帧 usart_dma_rx_complete_flag = 0; // 此时,usart_dma_rx_buffer 中存储了从下标0到 usart_dma_rx_len-1 的有效数据 // usart_dma_rx_len 就是这一帧数据的长度 // 示例:将接收到的数据通过串口打印回来(回显) HAL_UART_Transmit(&huart1, usart_dma_rx_buffer, usart_dma_rx_len, 1000); // 或者,在这里进行协议解析,例如判断帧头、校验和等 // parse_protocol(usart_dma_rx_buffer, usart_dma_rx_len); // 处理完数据后,重新启动DMA接收,准备下一帧 USART_DMA_StartReceive(); } }

4.3 实现串口空闲中断回调函数

DMA负责搬运数据,但我们需要知道“一帧数据什么时候结束”。对于不定长数据,常用“空闲中断”(Idle Interrupt)来判断。当串口总线上一段时间没有新数据时,就会产生空闲中断。

我们需要重写HAL库的空闲中断回调函数。这个函数通常放在stm32f4xx_it.c中,但为了模块化,我们可以放在usart_dma.c中,并通过弱函数重定义的方式实现。

usart_dma.c末尾添加:

/** * @brief 串口空闲中断回调函数(弱函数重定义) * @param huart: UART句柄 */ void HAL_UART_IdleCallback(UART_HandleTypeDef *huart) { // 判断是否是USART1产生的中断 if(huart->Instance == USART1) { // 临时禁止DMA,防止在计算过程中数据被修改 __HAL_DMA_DISABLE(huart->hdmarx); // 计算本次接收到的数据长度 // DMA当前已经传输的数量 = 设定的总长度 - 剩余未传输的长度 usart_dma_rx_len = USART_DMA_RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx); // 如果长度大于0,说明收到了有效数据 if(usart_dma_rx_len > 0) { // 设置接收完成标志,通知主循环处理 usart_dma_rx_complete_flag = 1; } // 重新使能DMA(注意:这里需要根据实际情况决定是否立即重启) // 我们选择在数据处理完后(USART_DMA_ProcessData中)再重启DMA,所以这里不开启。 // __HAL_DMA_ENABLE(huart->hdmarx); // 清除空闲中断标志(非常重要!) __HAL_UART_CLEAR_IDLEFLAG(huart); } }

4.4 主函数集成与运行验证

最后,在main.c中集成我们的模块。

main.c (部分代码)

/* 包含头文件 */ #include "usart_dma.h" int main(void) { /* HAL库初始化、系统时钟配置等 */ HAL_Init(); SystemClock_Config(); /* 初始化所有外设 */ MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); /* ... 其他外设初始化 */ /* 初始化我们的USART DMA接收模块 */ USART_DMA_Init(); /* 主循环 */ while (1) { /* 处理其他任务... */ /* 检查并处理串口接收完成的数据 */ USART_DMA_ProcessData(); /* 可以添加延时,但注意不要阻塞太久 */ HAL_Delay(1); } }

4.5 结果说明

将代码编译下载到开发板后,你可以使用串口调试助手(如XCOM,Putty等)向板子的USART1发送任意长度的数据包。

  1. 发送数据:在串口助手中发送Hello DMA!
  2. 自动接收:DMA会自动将字符搬运到usart_dma_rx_buffer中。
  3. 检测帧结束:当你停止发送(串口助手点击一次发送按钮),总线进入空闲状态,触发空闲中断。
  4. 计算长度:在中断回调函数中,通过DMA计数器计算出本次接收的数据长度(这里是10个字符)。
  5. 通知处理:设置usart_dma_rx_complete_flag标志位。
  6. 主循环处理USART_DMA_ProcessData函数检测到标志位,将接收到的数据原样发送回来(回显),你会在串口助手接收区看到Hello DMA!
  7. 准备下一次接收:处理完成后,函数自动调用USART_DMA_StartReceive,清空状态,等待下一帧数据。

至此,一个完整的、基于DMA的USART不定长数据接收框架就搭建完成了。CPU仅在数据接收完成、产生空闲中断时被短暂打扰(执行回调函数),在数据搬运过程中是完全自由的。

5. 常见问题与排查思路

在实际使用DMA时,你可能会遇到一些问题。下面是一些常见问题的排查思路。

问题现象可能原因排查思路与解决方案
DMA根本不动,数据收不到1. DMA或外设时钟未开启。
2. DMA流/通道配置错误。
3. 外设未正确初始化或使能。
4. DMA传输未启动(HAL_UART_Receive_DMA未调用或调用失败)。
1. 检查__HAL_RCC_DMAx_CLK_ENABLE()__HAL_RCC_USARTx_CLK_ENABLE()是否被调用。
2. 核对《参考手册》的DMA请求映射表,确认Stream和Channel配置正确。
3. 使用调试器检查外设寄存器(如USART的CR1寄存器)是否已使能。
4. 检查HAL_UART_Receive_DMA的返回值,并确保缓冲区地址有效。
只能收到一次数据,之后收不到了1. 使用了“Normal”模式,且传输完成后未重新启动。
2. 中断标志未清除,导致后续中断被阻塞。
3. 在回调函数中错误地停止了DMA。
1. 在数据处理完后(如USART_DMA_ProcessData),务必重新调用HAL_UART_Receive_DMAUSART_DMA_StartReceive
2. 在中断服务程序或回调函数末尾,务必清除对应的中断标志(如__HAL_UART_CLEAR_IDLEFLAG)。
3. 检查回调函数逻辑,确保不会因条件判断而意外跳过DMA重启。
接收数据错位或乱码1. 内存或外设地址自增设置错误。
2. 数据宽度不匹配(如外设8位,内存16位)。
3. 缓冲区溢出,新数据覆盖了未处理的数据。
1. 确认hdma_usart1_rx.Init.PeriphInchdma_usart1_rx.Init.MemInc设置正确。对于外设数据寄存器,通常不自增;对于内存数组,必须自增。
2. 检查PeriphDataAlignmentMemDataAlignment,确保与实际情况一致(通常都是字节)。
3. 增大接收缓冲区,或提高数据处理速度,确保在下一帧数据到来前处理完上一帧。
空闲中断不触发1. 空闲中断未使能。
2. 总线上一直有数据(如波特率错误导致持续误码)。
3. 清除空闲中断标志的时序或方法不对。
1. 确认调用了__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)
2. 检查通信双方波特率、停止位等参数是否一致。
3. 确保按照__HAL_UART_CLEAR_IDLEFLAG(huart)的方式清除标志。
DMA传输完成中断不触发1. DMA传输完成中断未使能。
2. NVIC中断未使能。
3. 中断服务函数未实现或未正确清除中断标志。
1. 在CubeMX中配置DMA时,勾选“Transfer Complete Interrupt”。
2. 在NVIC配置中启用对应的DMA流中断。
3. 实现HAL_DMA_IRQHandler会调用的回调函数,如HAL_UART_TxCpltCallbackHAL_UART_RxCpltCallback

6. 最佳实践与工程建议

掌握了基础操作后,以下建议能帮助你在实际项目中更稳健、高效地使用DMA。

6.1 模式选择:Normal vs Circular

  • Normal模式(单次模式):DMA传输设定的数据量后自动停止。需要软件重新启动才能进行下一次传输。适用于:接收/发送已知长度的数据包,或像我们示例中那样,配合空闲中断实现不定长接收。
  • Circular模式(循环模式):DMA传输达到设定数量后,自动从头开始循环传输,永不停止。适用于:ADC连续采样、音频流等需要“双缓冲”或“环形缓冲区”的场景。你需要自己管理读写指针,防止数据被覆盖。

建议:对于串口通信这类非连续、突发性的数据流,推荐使用Normal模式+空闲中断。对于连续不断的数据流,推荐使用Circular模式+半传输/传输完成中断来实现双缓冲。

6.2 缓冲区管理与数据安全

  • 缓冲区大小:根据通信协议的最大帧长度来定义缓冲区,并预留一定余量。避免定义过小导致溢出。
  • 双缓冲(Ping-Pong Buffer):对于高速连续数据,使用两个缓冲区。当DMA向缓冲区A写数据时,CPU处理缓冲区B的数据;完成后交换角色。这可以完全避免数据竞争和丢失。HAL库的Circular模式结合半传输中断(HT)和传输完成中断(TC)可以轻松实现这一点。
  • ** volatile 关键字**:在中断服务程序(ISR)和主循环之间共享的标志位(如usart_dma_rx_complete_flag)和长度变量,必须用volatile修饰,防止编译器进行错误的优化。
  • 临界区保护:在计算DMA剩余计数器、操作缓冲区指针等关键操作时,可以考虑暂时关闭全局中断(__disable_irq())或DMA,操作完成后再开启,以确保数据的原子性。

6.3 错误处理与健壮性

  • 检查HAL函数返回值:像HAL_UART_Receive_DMAHAL_UART_Transmit_DMA这样的函数都有返回值(HAL_StatusTypeDef)。务必检查其是否等于HAL_OK,并进行错误处理(如重试、记录日志、系统复位等)。
  • 实现错误回调函数:重写HAL_UART_ErrorCallback函数。当发生帧错误、噪声错误、溢出错误或DMA传输错误时,这个函数会被调用。在这里可以进行错误恢复,例如重新初始化串口和DMA。
  • 超时机制:对于等待DMA传输完成的操作,应使用超时机制,避免程序死锁。

6.4 性能考量

  • 总线仲裁:DMA和CPU都通过总线访问内存。当它们同时访问时,总线仲裁器会决定谁先使用。合理规划内存布局(如将DMA缓冲区放在SRAM中,而非慢速存储器),可以减少冲突。
  • 数据对齐:确保源地址和目标地址符合DMA和CPU的最佳访问对齐方式(如4字节对齐),可以提升传输效率。
  • 流优先级:当多个DMA流同时请求时,优先级高的先被服务。对于实时性要求高的外设(如ADC),可以设置更高的流优先级。

通过本文的梳理,我们从DMA的核心概念“通道与流”入手,逐步完成了环境搭建、代码实现、问题排查到最佳实践的完整闭环。DMA是提升嵌入式系统性能的利器,理解其工作原理并掌握其使用方法,是嵌入式开发者向中级进阶的必经之路。建议读者将示例代码在实际开发板上运行一遍,并通过调试器观察DMA寄存器和缓冲区的变化,加深理解。接下来,可以尝试将本示例扩展为Circular模式的双缓冲ADC采样,或结合SPI DMA实现与Flash存储器的高速通信,从而更全面地掌握这项技术。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/4 17:03:09

多模态AI看人熟不熟 视觉信息没帮上忙

有些产品功能,需要模型判断两个人的关系:会议软件分析参会者是不是熟人,社交产品判断该不该推荐破冰话题,客服系统识别用户和客服之间是否已经建立过沟通。过去这类判断主要靠文本和语音,现在多模态模型能同时看画面了…

作者头像 李华
网站建设 2026/8/4 17:00:27

NHSE:3步掌握《动物森友会》存档编辑的完整方案

NHSE:3步掌握《动物森友会》存档编辑的完整方案 【免费下载链接】NHSE Animal Crossing: New Horizons save editor 项目地址: https://gitcode.com/gh_mirrors/nh/NHSE 还在为《集合啦!动物森友会》中漫长的收集过程而烦恼吗?想要快速…

作者头像 李华
网站建设 2026/8/4 16:59:37

Java内部类深度解析:类型、原理与实战应用

1. Java内部类全景解析 在Java开发中,内部类就像瑞士军刀的隐藏工具层——看似不起眼,却能在关键时刻解决特定场景下的棘手问题。作为从JDK 1.1就存在的语言特性,内部类在事件处理、线程封装、数据结构优化等场景中展现出不可替代的价值。但许…

作者头像 李华
网站建设 2026/8/4 16:52:32

Python调用OpenAI兼容接口:指数退避重试与熔断降级实现

问题现象 在Python 3.10环境中,使用requests库调用OpenAI兼容的chat/completions接口时,经常遇到以下异常: requests.exceptions.ConnectionError:偶发网络抖动导致连接中断,整个采集任务直接失败。requests.exception…

作者头像 李华