ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

STM32 DMA原理与实战:从基础概念到高效数据搬运配置

STM32 DMA原理与实战:从基础概念到高效数据搬运配置 1. 从“搬运工”到“效率核心”DMA到底是什么如果你玩过STM32肯定遇到过这样的场景串口接收了一大堆数据CPU正忙着处理结果新的数据又来了CPU不得不停下手中的活赶紧去把数据从串口寄存器搬到内存里。这种“打杂”的活干多了CPU就没办法专心处理核心逻辑整个系统的实时性就下来了。DMA就是来解决这个问题的。DMA全称Direct Memory Access直接存储器访问。你可以把它想象成一个非常专业的“数据搬运工”。它的核心工作就是在CPU不参与的情况下完成外设比如ADC、串口、SPI和存储器比如SRAM、Flash之间或者存储器和存储器之间的大批量数据搬运。CPU只需要在搬运开始前告诉DMA搬运工“嘿去A仓库源地址搬100箱货数据量放到B仓库目标地址去。” 然后CPU就可以去干别的了。等DMA搬完了它会发个消息中断告诉CPU“老板活干完了。” 整个过程CPU只做了初始化和收尾的指挥工作中间繁重的体力活全由DMA包了。为什么这很重要在嵌入式实时系统中时间就是生命。比如你用ADC以1MHz的速率采集音频信号每个数据是12位2字节那么一秒钟就有2MB的数据需要搬运。如果让CPU用for循环一个个去读ADC数据寄存器再存到数组它几乎什么都别想干了。而DMA可以悄无声息地在后台完成这个搬运CPU只需要在DMA搬运完一个缓冲区比如1024个点后去处理这个缓冲区的数据即可系统响应速度和吞吐量得到质的提升。2. STM32 DMA的“工作台”与“任务单”架构与通道解析要指挥好DMA这位搬运工你得先了解它的“工作台”——也就是STM32内部的DMA控制器架构以及它怎么接活——也就是通道的概念。2.1 DMA控制器的基本架构以STM32F1系列为例它有两个DMA控制器DMA1和DMA2。每个控制器管理着多条“生产线”也就是通道Channel。DMA1有7个通道DMA2有5个通道。你可以把每个通道看作一个独立的搬运工它们可以同时执行不同的搬运任务。这些搬运工能搬什么呢它们的“客户”是各种外设。每个外设的发送TX或接收RX请求会固定地连接到某个DMA控制器的某个通道上。这个连接关系是芯片设计时硬件定死的你在数据手册的“DMA请求映射”表格里能找到。比如在STM32F103中USART1的TX请求连接在DMA1的Channel4上而ADC1的请求则连接在DMA1的Channel1上。这意味着如果你想用DMA来发送USART1的数据你必须使用DMA1的Channel4没得选。这是配置DMA时第一个要查清楚的关键点。2.2 核心工作单元DMA数据流Stream在更高级的系列如STM32F4/F7/H7中概念略有升级。这里引入了“数据流Stream”和“通道Channel”两层结构。一个DMA控制器如DMA2有多个数据流Stream0-Stream7每个数据流可以映射到不同的通道Channel0-Channel7。这里的“通道”概念更接近“请求源”比如“USART1_TX”这个请求就是一个通道。你可以将某个数据流比如Stream6配置为服务于“USART1_TX”这个通道。这样设计更灵活因为多个数据流可以服务于同一个通道通过仲裁器决定优先级但一个数据流在某一时刻只能服务一个通道。2.3 DMA的“搬运规则”传输模式与数据宽度DMA搬运不是乱搬的它遵循一套你设定的规则传输方向这是根本。有三种外设到存储器Peripheral-to-memory最常见如ADC采集数据到内存数组。存储器到外设Memory-to-peripheral也很常见如从内存数组发送数据到串口。存储器到存储器Memory-to-memoryF1系列有些型号不支持F4及以上系列支持。可以用于内存块的高效拷贝或填充。传输模式单次模式NormalDMA只搬运一次你设定的数据量然后就停止了。想再搬需要你手动重新使能DMA通道。循环模式Circular这是实现“双缓冲”或“环形缓冲区”的利器。DMA搬运完设定的数据量后会自动将地址和计数器重置为初始值然后重新开始搬运周而复始。对于连续数据流如音频采集、通信几乎是不二之选。数据宽度Data Width你需要告诉DMA源和目标地址的数据单位是字节Byte、半字Half-Word 16位还是字Word 32位。这里有个关键点源和目标的宽度可以不同但必须注意对齐和传输总量的设置。例如源是8位字节的ADC数据寄存器目标是16位半字的数组那么DMA每次读一个字节但会写入半字空间的高8位还是低8位这通常由外设的数据格式决定一般需要匹配。通常建议源和目标宽度设置为一致避免复杂问题。地址增量Increment搬运工搬完一个数据后是去下一个地址搬还是老在同一个地址搬对于存储器数组我们肯定希望地址递增。对于外设寄存器如USART1-DR它的数据寄存器就一个固定地址所以必须设置为不递增Non-incremental。3. 手把手配置从初始化结构体到代码实战理论说再多不如一行代码。我们以STM32CubeMX配合HAL库这是目前最主流的开发方式为例配置一个USART1使用DMA发送数据的场景。假设我们要循环发送一个字符串。3.1 使用STM32CubeMX图形化配置引脚与时钟配置在Pinout Configuration标签页先配置好USART1为异步通信模式Asynchronous并设置好波特率、字长等参数。STM32CubeMX会自动配置对应的TX/RX引脚。启用DMA在左侧导航栏找到DMA设置。点击Add在Request下拉菜单中选择USART1_TX。然后配置弹出的参数Mode:Normal单次或Circular循环。我们选Normal先演示。Priority: 优先级根据系统需求选择默认Low。Data Width: 都选择Byte因为串口数据是8位的。生成代码点击Project Manager设置好项目名称、路径和IDE如MDK-ARM然后点击右上角的GENERATE CODE。3.2 剖析生成的HAL库代码与初始化结构体生成了代码后我们打开main.c找到MX_DMA_Init()函数。你会发现它可能很简单甚至没有内容。这是因为HAL库把DMA的初始化整合到了外设的初始化中。真正的配置在MX_USART1_UART_Init()函数里或者更具体地说是在我们调用HAL_UART_Transmit_DMA()时动态完成的。但我们有必要理解最核心的配置结构体DMA_HandleTypeDef。这个结构体就是发给DMA搬运工的“详细任务单”。typedef struct __DMA_HandleTypeDef { void *Instance; /* 指向DMA数据流/通道寄存器基地址如DMA1_Stream6 */ DMA_InitTypeDef Init; /* DMA初始化配置结构体 */ HAL_LockTypeDef Lock; /* 锁用于多线程安全 */ __IO HAL_DMA_StateTypeDef State; /* DMA状态 */ void *Parent; /* 指向拥有此DMA句柄的外设句柄 */ void (* XferCpltCallback)( struct __DMA_HandleTypeDef * hdma); /* 传输完成回调函数 */ void (* XferHalfCpltCallback)( struct __DMA_HandleTypeDef * hdma); /* 传输过半回调函数 */ void (* XferErrorCallback)( struct __DMA_HandleTypeDef * hdma); /* 传输错误回调函数 */ __IO uint32_t ErrorCode; /* DMA错误代码 */ uint32_t StreamBaseAddress; /* 数据流基地址内部使用 */ uint32_t StreamIndex; /* 数据流索引内部使用 */ } DMA_HandleTypeDef;其中最关键的Init成员是DMA_InitTypeDef类型它包含了我们之前讨论的所有规则typedef struct { uint32_t Channel; /* 通道选择如DMA_CHANNEL_4 */ uint32_t Direction; /* 传输方向如DMA_MEMORY_TO_PERIPH */ uint32_t PeriphInc; /* 外设地址是否递增如DMA_PINC_DISABLE */ uint32_t MemInc; /* 存储器地址是否递增如DMA_MINC_ENABLE */ uint32_t PeriphDataAlignment; /* 外设数据宽度如DMA_PDATAALIGN_BYTE */ uint32_t MemDataAlignment; /* 存储器数据宽度如DMA_MDATAALIGN_BYTE */ uint32_t Mode; /* 模式如DMA_NORMAL或DMA_CIRCULAR */ uint32_t Priority; /* 优先级如DMA_PRIORITY_LOW */ } DMA_InitTypeDef;3.3 编写应用层代码在main.c的/* USER CODE BEGIN 2 */部分我们编写应用代码。/* 定义一个要发送的数据缓冲区 */ uint8_t tx_data[] Hello DMA!\r\n; /* 声明DMA和UART句柄通常CubeMX已在外部分声明 */ extern DMA_HandleTypeDef hdma_usart1_tx; extern UART_HandleTypeDef huart1; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); /* 使用DMA发送数据 */ if (HAL_UART_Transmit_DMA(huart1, tx_data, sizeof(tx_data)-1) ! HAL_OK) { Error_Handler(); } /* 此时CPU是自由的可以执行其他任务 */ while (1) { // 你的主循环代码比如闪烁LED HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); HAL_Delay(500); } } /* 传输完成回调函数需要重写 */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { /* 发送完成可以在这里做一些处理比如置位一个标志位 */ /* 注意在中断回调函数中不要做耗时操作 */ } }这段代码中HAL_UART_Transmit_DMA函数内部会完成以下几件事检查DMA状态。锁住DMA句柄。根据传入的参数UART句柄、数据地址、长度和CubeMX预先配置的DMA参数填充hdma_usart1_tx句柄的剩余字段如源地址、目标地址、数据量。配置DMA通道的寄存器CxPAR-外设地址 CxMAR-存储器地址 CxNDTR-数据量。使能DMA通道和对应的外设DMA请求这里是USART1的TX请求。 一旦使能DMA就开始工作HAL_UART_Transmit_DMA函数立即返回。数据的搬运在后台进行CPU继续执行后面的while(1)循环。当DMA搬运完所有数据会触发一个传输完成中断自动跳转到HAL_UART_TxCpltCallback回调函数。4. 进阶玩法与避坑指南环形缓冲区、存储器到存储器与常见问题掌握了基础配置我们来看看更实用的高级模式和一些我踩过的坑。4.1 循环模式与环形缓冲区上面的例子是单次发送。对于持续接收数据如串口接收不定长数据循环模式才是王道。配置时将Mode设置为DMA_CIRCULAR。// 在CubeMX中直接选择Circular模式或者在代码中配置 hdma_usart1_rx.Init.Mode DMA_CIRCULAR;假设我们为USART1_RX分配了一个接收缓冲区uint8_t rx_buf[256]并使用循环DMA模式。DMA会不停地往这个缓冲区里填数据从头填到尾然后再回到头覆盖循环往复。这时CPU面临一个问题我怎么知道DMA当前写到哪个位置了怎么读取有效数据而不覆盖未处理的数据这就需要软件配合实现“环形缓冲区”的管理。HAL库提供了一个便利的API__HAL_DMA_GET_COUNTER(hdma)。这个函数返回DMA通道当前剩余未传输的数据量CxNDTR寄存器的值。因为总传输量是已知的这里是256所以当前写入位置Write Index可以这样计算uint16_t dma_write_index RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx);CPU维护一个读索引read_index。只要write_index不等于read_index就说明有新的数据待处理。CPU读取rx_buf[read_index]的数据然后read_index。这样读和写就在这个环形的缓冲区里追逐实现了高效、无丢失的数据流处理。这是处理高速串口通信、ADC连续采集的经典模式。4.2 存储器到存储器M2M模式这个模式允许DMA在两个内存区域之间快速拷贝数据而不占用CPU。在F4及以上系列中只有某个特定的数据流如DMA2_Stream0支持M2M模式且不需要外设请求触发一旦使能立即开始传输。配置关键点Direction:DMA_MEMORY_TO_MEMORY。PeriphInc和MemInc通常都使能。因为没有外设请求所以Channel选择可以任意通常选一个未占用的。优先级通常设为DMA_PRIORITY_HIGH因为它没有外设同步越快完成越好。// 示例使用DMA2_Stream0进行内存拷贝 hdma_m2m.Instance DMA2_Stream0; hdma_m2m.Init.Channel DMA_CHANNEL_0; // 通道可任选 hdma_m2m.Init.Direction DMA_MEMORY_TO_MEMORY; hdma_m2m.Init.PeriphInc DMA_PINC_ENABLE; hdma_m2m.Init.MemInc DMA_MINC_ENABLE; hdma_m2m.Init.PeriphDataAlignment DMA_PDATAALIGN_WORD; hdma_m2m.Init.MemDataAlignment DMA_MDATAALIGN_WORD; hdma_m2m.Init.Mode DMA_NORMAL; hdma_m2m.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_m2m); // 启动传输 uint32_t src[100], dst[100]; // ... 填充src数据 ... if(HAL_DMA_Start(hdma_m2m, (uint32_t)src, (uint32_t)dst, 100) HAL_OK) { // 等待传输完成 HAL_DMA_PollForTransfer(hdma_m2m, HAL_DMA_FULL_TRANSFER, HAL_MAX_DELAY); }4.3 避坑指南那些年我踩过的DMA坑地址对齐问题这是最隐蔽的坑之一。如果源或目标地址没有按照你设置的数据宽度对齐可能会导致硬件错误HardFault。例如你设置数据宽度为Word32位那么源和目标地址都必须是4字节对齐的。对于数组可以使用编译器指令来保证对齐例如GCC的__attribute__((aligned(4)))。在HAL库中如果你传递的缓冲区地址不对齐某些函数会返回错误HAL_ERROR。缓存一致性问题D-Cache在带有数据缓存D-Cache的高性能MCU如STM32H7中这个问题极其致命。CPU操作的是缓存中的数据而DMA操作的是直接的实际内存SRAM。如果你用CPU准备了一块数据然后启动DMA将其发送出去由于数据可能还留在CPU的缓存里而没有写回内存DMA读到的就是旧数据或错误数据。反之DMA接收数据到内存后CPU去读可能读到的是缓存里的旧数据。解决方法在DMA传输的前后使用缓存维护函数SCB_CleanDCache_by_Addr清理 确保DMA能读到CPU最新数据和SCB_InvalidateDCache_by_Addr失效 确保CPU能读到DMA刚写入的数据来手动同步缓存和内存。H7系列的很多DMA异常都和这个有关。传输完成中断过早触发在存储器到外设模式如UART发送下DMA的传输完成中断标志是在DMA把最后一个数据从内存搬到外设的数据寄存器如USART-DR时置位的。但此时这个数据可能还在外设的发送移位寄存器里并没有真正在物理线路上发送完毕如果你在DMA传输完成中断里立即关闭外设或进行其他敏感操作可能会截断最后一个字节。解决方法对于UART在DMA发送完成回调函数中最好再等待一下UART本身的“发送完成”TC Transmission Complete标志位。HAL库的HAL_UART_Transmit_DMA函数内部其实已经考虑了这一点它使能了UART的TC中断并在TC中断里调用最终的完成回调。但如果你是自己配置寄存器就需要特别注意这个时序。数据量寄存器CxNDTR是只写的这是一个容易忽略的细节。你不能直接读取这个寄存器来获取剩余字节数在F1系列因为读出来的是未定义的。必须通过__HAL_DMA_GET_COUNTER这样的宏来读取它读的是另一个影子寄存器。直接操作寄存器时务必查阅参考手册。外设时钟与DMA时钟DMA和外设的时钟都必须使能。通常CubeMX会帮你做好。但如果你手动配置代码忘记开启DMA时钟__HAL_RCC_DMA1_CLK_ENABLE()DMA是根本无法工作的而且错误可能难以排查。5. 调试DMA当搬运工“罢工”时如何排查DMA一旦配置出错现象往往很“静默”——数据没过来程序也没崩溃就是功能不对。这时候需要系统性地排查。5.1 检查清单时钟确认RCC中DMA和外设的时钟都已使能。引脚与外设配置确认外设本身如USART的配置是正确的能正常工作在非DMA模式。通道/数据流映射核对数据手册确认你使用的DMA通道/数据流确实映射到了目标外设的请求上。这是硬性规定配错了DMA根本不会响应外设的请求。地址检查源地址和目标地址是否正确。特别是外设寄存器地址要使用外设实例的基地址加上数据寄存器的偏移量如(USART1-DR)。数据量与宽度检查CxNDTR设置的数据量是否匹配你的预期。检查源和目标的Data Alignment是否合理。中断与NVIC如果你使用了DMA传输完成中断确保在NVIC中使能了对应的DMA通道中断并且实现了正确的中断服务函数IRQHandler和回调函数。使能顺序标准的流程是初始化DMA - 初始化外设 - 配置外设的DMA请求如__HAL_UART_ENABLE_DMA_TX - 启动DMA传输HAL_DMA_Start_IT或外设的DMA发送函数。5.2 利用调试器现代IDE如Keil MDK IAR STM32CubeIDE的调试功能非常强大。查看寄存器在调试模式下直接查看DMA通道的寄存器组。关注CxCR控制寄存器确认EN位是否已置1使能DIR、CIRC、MINC、PINC、PSIZE、MSIZE等位是否正确。CxNDTR数据数量寄存器传输过程中会递减观察它是否在变化。CxPAR和CxMAR外设和存储器地址寄存器确认地址值是否正确。查看内存在Memory窗口查看你设定的目标内存区域看是否有数据被写入。可以配合断点在DMA传输完成中断处停下观察内存内容。使用逻辑分析仪或示波器对于外设数据如UART的TX引脚这是最直观的。查看物理引脚上是否有符合预期的波形和数据。如果DMA配置正确但引脚没输出问题可能出在外设配置或GPIO复用上。5.3 一个典型的排查案例UART DMA接收不到数据现象配置了USART1的DMA循环接收但缓冲区里始终没有数据。 排查步骤用调试器确认USART1的时钟、引脚复用已开启。确认DMA1假设是Channel5时钟已开启NVIC中断已使能。在USART1初始化后检查USART1-CR3寄存器确认DMARDMA接收使能位是否被置1。HAL库中HAL_UART_Receive_DMA函数会设置此位。在调试器中查看DMA1_Channel5的CNDTR寄存器。如果它一直不减少说明DMA没有被触发。可能原因通道映射错误USART1_RX可能不在Channel5上或者外设根本没有产生接收请求检查USART1是否真的收到了数据波特率是否匹配。如果CNDTR在减少但内存里没数据。检查CMAR存储器地址寄存器看它指向的地址是否就是你定义的缓冲区地址。检查MSIZE存储器数据宽度设置如果设成了Word但缓冲区是uint8_t类型可能会导致地址错位。在USART接收引脚上接上逻辑分析仪确认物理层确实有数据进来。DMA的调试需要耐心和条理从时钟、映射、地址、数据量这些基础项逐一核对大部分问题都能定位。把它当成一个逻辑严密的硬件模块来理解而不是一个黑盒会对你驾驭它大有裨益。
返回列表