嵌入式DMA实战:通道与流详解及USART不定长接收实现
发布时间:2026/8/4 17:03:58
分类:文化教育
浏览:1234

最近在嵌入式开发中经常需要处理外设与内存之间的大量数据搬运。如果全靠CPU来“手动”搬运不仅效率低下还会严重占用CPU资源导致系统整体性能下降。这时一个强大的“搬运工”——DMADirect Memory Access直接存储器访问就显得至关重要。本文将以一个典型的嵌入式场景为例深入剖析DMA特别是其通道Channel和流Stream的概念并提供一个从理论到实战的完整指南。无论你是刚接触STM32等MCU的新手还是希望优化现有项目性能的开发者都能从中获得可直接复用的代码和清晰的配置思路。1. DMA 核心概念与价值在深入细节之前我们首先要理解DMA到底是什么以及它为何如此重要。1.1 什么是 DMADMA即直接存储器访问是一种允许特定硬件子系统外设直接读写系统内存而无需中央处理器CPU介入的技术。你可以把它想象成一个高效的“快递员”。没有DMACPU搬运CPU需要从源地址如串口接收缓冲区读取一个字节再写入到目标地址如内存数组然后循环此过程。在此期间CPU无法执行其他任务。有DMADMA搬运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分配给流2ADC1通道0分配给流0SPI2_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流对于F4USART1_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 结果说明将代码编译下载到开发板后你可以使用串口调试助手如XCOMPutty等向板子的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 CircularNormal模式单次模式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存储器的高速通信从而更全面地掌握这项技术。