最近在嵌入式开发中,经常需要处理外设与内存之间的大量数据搬运。如果全靠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主要带来两大核心优势:
- 解放CPU,提升系统效率:这是DMA最根本的价值。将耗时的数据搬运工作交给DMA硬件,CPU得以处理更复杂的计算、逻辑判断或响应其他事件,极大地提高了系统的并发处理能力和整体吞吐量。
- 满足高速数据流需求:对于一些高速外设,如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 流与通道的关系
流和通道的关系是“多对一”的选择关系。一个外设(对应一个通道)的传输请求,可以由多个流中的某一个来服务。但你在配置时,需要为选定的流指定它所服务的通道。
为什么这样设计?这种设计提供了极大的灵活性。假设你的系统同时需要:
- USART1 接收数据(使用DMA)。
- ADC1 连续采样(使用DMA)。
- SPI2 发送数据(使用DMA)。
如果只有一个流,它们必须串行排队。但有了多个流,你可以将USART1_RX(通道4)分配给流2,ADC1(通道0)分配给流0,SPI2_TX(通道1)分配给流5。这样,三个外设的DMA传输可以近乎并行地进行,只要它们不使用同一个流。
配置流程的核心逻辑:
- 确定外设:我要用哪个外设进行DMA传输?(例如:USART1的接收)
- 查找通道:根据《参考手册》,找到此外设对应的DMA通道号。(例如:USART1_RX 对应 DMA2 Stream2 的 Channel 4)
- 选择流:通道号可能关联到多个流,你需要选择一个当前未被占用的流。(例如,根据手册,对于USART1_RX,只能使用DMA2的Stream2或Stream5,且通道号为4。我们选择Stream2)。
- 配置流:初始化这个流(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。我们首先进行图形化配置。
- 启用USART1:在“Pinout & Configuration”标签页中,找到USART1。将模式(Mode)设置为“Asynchronous”(异步)。引脚PA9(TX)和PA10(RX)会自动配置。
- 配置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”,因为串口接收数据寄存器地址是固定的。
- 启用USART1全局中断:在“NVIC Settings”标签页,勾选“USART1 global interrupt”。我们稍后需要用到空闲中断。
- 生成代码:点击“Generate Code”,CubeMX会为你生成初始化代码。
4.2 编写 DMA 接收管理代码
CubeMX生成了基础初始化代码(MX_USART1_UART_Init和MX_DMA_Init),但我们需要编写更上层的应用逻辑来管理DMA接收。
创建usart_dma.h和usart_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发送任意长度的数据包。
- 发送数据:在串口助手中发送
Hello DMA!。 - 自动接收:DMA会自动将字符搬运到
usart_dma_rx_buffer中。 - 检测帧结束:当你停止发送(串口助手点击一次发送按钮),总线进入空闲状态,触发空闲中断。
- 计算长度:在中断回调函数中,通过DMA计数器计算出本次接收的数据长度(这里是10个字符)。
- 通知处理:设置
usart_dma_rx_complete_flag标志位。 - 主循环处理:
USART_DMA_ProcessData函数检测到标志位,将接收到的数据原样发送回来(回显),你会在串口助手接收区看到Hello DMA!。 - 准备下一次接收:处理完成后,函数自动调用
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_DMA或USART_DMA_StartReceive。2. 在中断服务程序或回调函数末尾,务必清除对应的中断标志(如 __HAL_UART_CLEAR_IDLEFLAG)。3. 检查回调函数逻辑,确保不会因条件判断而意外跳过DMA重启。 |
| 接收数据错位或乱码 | 1. 内存或外设地址自增设置错误。 2. 数据宽度不匹配(如外设8位,内存16位)。 3. 缓冲区溢出,新数据覆盖了未处理的数据。 | 1. 确认hdma_usart1_rx.Init.PeriphInc和hdma_usart1_rx.Init.MemInc设置正确。对于外设数据寄存器,通常不自增;对于内存数组,必须自增。2. 检查 PeriphDataAlignment和MemDataAlignment,确保与实际情况一致(通常都是字节)。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_TxCpltCallback或HAL_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_DMA、HAL_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存储器的高速通信,从而更全面地掌握这项技术。