
做串口调试的时候我遇到过一个挺扎心的情况波特率115200一包数据256个字节用中断方式收发CPU占用率高得离谱后来换成DMA搬运同样数据量CPU几乎可以“躺平”。开始学STM32的时候我一直觉得DMA是个很玄乎的东西等到自己把寄存器和HAL库的调用链捋清楚又把几个典型实验跑通之后才意识到它可能是嵌入式开发里性价比最高的一块外设。这篇是STM32学习心得系列的DMA篇我会从基本原理讲到三个可以直接照抄的实验存储器到存储器搬运、ADC连续采样、串口收发。文末把我踩过的坑和排查思路一并交代清楚。1. 为什么需要DMA一次数据搬运暴露出的CPU瓶颈1.1 我最初用中断和轮询做数据搬运的笨办法最早做串口接收时我的第一反应是“来一个字节中断一次”。每个字节进来CPU都要停下手里的事保存现场、跳进中断服务函数、读数据寄存器、写进自己的缓冲区、再判断缓冲区满了没有。表面上看代码很直白但代价是每收一个字节CPU就要“精神分裂”一次。72MHz主频的F103听起来不低但真到了115200波特率每秒约11.5KB的数据量每一个字节触发一次中断中断进出开销加上现场保护CPU实际能留给业务逻辑的时间就少了很多。我当时用一个逻辑分析仪对比了一下同样是收发256字节中断方式下中断服务程序占了相当大的执行比例主循环被拖得像是老年机开了七八个后台APP。ADC采样更明显。如果让ADC工作在连续转换模式每次转换完都进中断去读数据寄存器高采样率下几乎就是“中断套中断”。我试过用定时器触发ADC采样采样率拉到几十千赫兹中断方式根本跟不住偶尔还会漏掉转换完成事件。那时候我还没意识到这类“从外设寄存器往内存搬数据”的活压根不应该让CPU亲自干。1.2 DMA到底替CPU做了什么DMA的全称是Direct Memory Access直接存储器访问。名字听着高级本质就是一颗“专职搬运工”硬件模块。它自己维护一套“源地址、目标地址、搬运长度”的配置启动之后它自己从源地址读数据、写进目标地址每搬完一个数据单元就把内部计数器减一。等整个批次搬完它会通过中断告诉CPU“活干完了你来验货吧。” 搬运期间CPU该干嘛干嘛完全不用管。我后来跟朋友解释DMA时用了这个类比CPU是一个仓库经理中断方式等于每来一箱货经理都得亲自推着小车跑一趟DMA则是你雇了一个固定路线的搬运工经理只需要站在收货区等搬运工干完活按铃提醒再去清点货物就行。经理省下来的时间可以处理订单、做调度、跑业务逻辑。这个类比虽然简单但把“搬数据”和“处理数据”彻底分开的思路就是理解DMA的钥匙。2. DMA工作的核心逻辑从寄存器组看一次传输怎么跑完2.1 DMA控制器里有什么以最常见的STM32F103为例DMA1一共有7个通道每个通道都有一条独立的“搬运线路”。每个通道对应一组关键寄存器CPAR外设地址、CMAR存储器地址、CNDTR传输数量、CCR控制配置。还有一个中断状态寄存器记录每个通道的传输完成TC、半传输HT、传输错误TE标志。CNDTR是整个DMA的灵魂。它保存的是“还剩多少个数据单元没搬”不是字节数。比如你设置数据宽度为半字16位CNDTR100那实际搬运的总字节数是200字节。这一点特别容易搞错我在实验里就吃过亏后面单独说。2.2 一次完整DMA传输的五个阶段拆开来看一次DMA传输大概经历这么几步配置阶段CPU设置好通道的源地址、目标地址、数据宽度、传输方向、模式把要搬运的数量写进CNDTR。请求阶段外设发出DMA请求或者软件直接启动DMA。比如USART收到一字节数据会把请求信号拉起来DMA控制器检测到之后开始干活。搬运阶段DMA从源地址读取一个数据单元写入目标地址CNDTR减一。重复搬运继续执行第3步直到CNDTR减到0。结束与通知普通模式下CNDTR归零后传输停止置位传输完成标志触发中断循环模式下CNDTR自动重载为初始值继续下一轮搬运不会停。这里普通模式和循环模式的选择直接决定实验效果。普通模式适合“一次性搬一包数据”比如把一块内存通过DMA发给串口循环模式适合“持续不断采样”的场景比如ADC连续转换DMA一边往数组里写写了旧的又写新的环形覆盖。两种模式的差异我后面实验里都会涉及。配置项普通模式循环模式传输结束后的行为停止CNDTR归零CNDTR自动重载为初值继续传输典型用途串口发一帧数据ADC连续采样、串口不定长接收缓冲是否需要重新配置才能再启动需要重新设置CNDTR不需要一直转CNDTR读取值归零后为0表示当前轮还剩多少2.3 数据宽度、方向、优先级三个必须提前想清楚的配置方向其实就三种存储器到存储器、外设到存储器、存储器到外设。存储器到存储器常见于内存拷贝外设到存储器典型是ADC采样值存入数组存储器到外设典型是把缓冲区数据发给串口。方向选错连接收什么、发送什么都对不上。数据宽度方面F103支持字节8位、半字16位、字32位。源和目的可以分别设置宽度但我强烈建议保持一致。一旦不一致比如源是32位的字目标是8位的字节DMA会按字节拆开搬运结果往往不是你想要的数据。我在实验里发现ADC数据寄存器是16位如果不小心把源宽度配成32位采集到的数据会整体错位数组里的值看起来像乱码。优先级相对简单每个通道有软件优先级极高、高、中、低当多个通道同时请求时优先级高的先响应优先级相同则通道号小的优先。普通应用里把DMA优先级拉到最高未必是好事反而可能挤压CPU访问总线的带宽。做串口和ADC时我一般用中优先级已经足够稳。3. 实验一存储器到存储器的DMA搬运验证基本原理3.1 为什么先做这个实验很多人第一个DMA实验就是串口收发结果一上来就遇到外设配置、请求映射、中断回调一堆问题出了问题分不清是DMA没配置对还是串口本身有毛病。我后来学乖了凡是接触一个新的DMA特性都先做存储器到存储器实验。它不依赖任何外设只需要两个数组代码最少出问题时范围最小非常适合拿来验证DMA本身有没有跑通。3.2 初始化与启动代码逐段解读我用HAL库演示但原理同样适用于标准库和寄存器操作。先定义两个32位数组作为实验数据#define BUFFER_SIZE 32 uint32_t srcBuffer[BUFFER_SIZE]; uint32_t dstBuffer[BUFFER_SIZE];初始化DMA句柄并启动传输DMA_HandleTypeDef hdma_memtomem; void MX_DMA_MemToMem_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_memtomem.Instance DMA1_Channel1; hdma_memtomem.Init.Direction DMA_MEMORY_TO_MEMORY; hdma_memtomem.Init.PeriphInc DMA_PINC_ENABLE; hdma_memtomem.Init.MemInc DMA_MINC_ENABLE; hdma_memtomem.Init.PeriphDataAlignment DMA_PDATAALIGN_WORD; hdma_memtomem.Init.MemDataAlignment DMA_MDATAALIGN_WORD; hdma_memtomem.Init.Mode DMA_NORMAL; hdma_memtomem.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_memtomem); } void Start_MemToMem_Test(void) { HAL_DMA_Start(hdma_memtomem, (uint32_t)srcBuffer, (uint32_t)dstBuffer, BUFFER_SIZE); }几个细节要注意存储器到存储器模式下外设地址增量必须使能DMA_PINC_ENABLE因为这里的“外设地址”实际上是一块内存的起始地址如果禁止地址增量DMA就会反复读第一个元素目标数组里全是同一个数。数据宽度我统一用了字因为两个数组都是uint32_t类型这样一次搬运一个完整元素逻辑最清晰。3.3 如何验证搬运结果光启动还不够要看搬运结果对不对。我在主循环里加了这样的验证逻辑volatile uint8_t transferComplete 0; void HAL_DMA_TC_Callback(DMA_HandleTypeDef *hdma) { if (hdma-Instance DMA1_Channel1) { transferComplete 1; } }给源数组塞满非零递增数据然后把目标数组初始化成全0启动DMA后死等传输完成标志。如果一切正常目标数组会和源数组一模一样。用调试器的Memory窗口直接看两块区域比printf还直观。我还习惯在传输过程中打印一下CNDTR的值确认它从32一路减到0。HAL库里有现成接口uint16_t remaining __HAL_DMA_GET_COUNTER(hdma_memtomem);这个值对排查“搬了一部分就停住”的问题特别管用。如果启动后CNDTR一直不变说明DMA请求根本没触发如果减到一半停了多半是源或目标地址非法、总线错误。4. 实验二ADC多通道连续采样DMA把数据自动搬进数组4.1 ADC采样频率高不适合中断读数据寄存器ADC连续转换模式下采样率一高中断方式就开始力不从心。每个转换结果都是一次中断CPU被频繁打断还会出现“上次中断还没处理完下一次转换结果已经覆盖了数据寄存器”的情况。数据丢失就是这么来的。DMA在这里特别合适ADC每次转换完成自动触发一次DMA搬运把16位采样结果搬进预先分配好的数组CPU只需要等一批数据搬完后处理一次。这就是“外设到存储器”的典型场景。4.2 ADCDMA的初始化流程ADC的DMA初始化一般放在HAL_ADC_MspInit里这样ADC初始化时会自动关联DMA。以ADC1三个通道CH0、CH1、CH2扫描连续采样为例static DMA_HandleTypeDef hdma_adc; void HAL_ADC_MspInit(ADC_HandleTypeDef* hadc) { if (hadc-Instance ADC1) { __HAL_RCC_ADC1_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); hdma_adc.Instance DMA1_Channel1; hdma_adc.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_adc.Init.PeriphInc DMA_PINC_DISABLE; hdma_adc.Init.MemInc DMA_MINC_ENABLE; hdma_adc.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_adc.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_adc.Init.Mode DMA_CIRCULAR; hdma_adc.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_adc); __HAL_LINKDMA(hadc, DMA_Handle, hdma_adc); } }ADC本身配置成扫描连续模式按顺序设置三个通道的Rank和采样时间ADC_HandleTypeDef hadc1; void MX_ADC1_Init(void) { hadc1.Instance ADC1; hadc1.Init.ScanConvMode ADC_SCAN_ENABLE; hadc1.Init.ContinuousConvMode ENABLE; hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion 3; HAL_ADC_Init(hadc1); ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel ADC_CHANNEL_0; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); sConfig.Channel ADC_CHANNEL_1; sConfig.Rank 2; HAL_ADC_ConfigChannel(hadc1, sConfig); sConfig.Channel ADC_CHANNEL_2; sConfig.Rank 3; HAL_ADC_ConfigChannel(hadc1, sConfig); }启动采样只需要一行uint16_t adc_values[3]; HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_values, 3);4.3 实际采集中的坑数据错位与半满中断第一坑是通道顺序和数组下标的关系。DMA搬运的是ADC规则组转换顺序命中的结果通道0的转换值会进adc_values[0]通道1进adc_values[1]以此类推。如果你改动了Rank顺序但没同步数组逻辑数据就错位了。模块化代码里最稳妥的办法是把通道序列定义成宏或数组和缓冲区下标一一对应。第二个坑是ADC采样时间。如果信号源内阻较大而采样时间太短采集到的值会偏低和DMA本身无关但排查时容易让人误以为是DMA数据错了。采样时间尽量选长一点我用过ADC_SAMPLETIME_239CYCLES_5读出的电压才真正平稳。第三个坑是循环模式下数组会被反复覆盖。处理高频采样时很多人用DMA半传输中断HT做一个简陋但好用的双缓冲前半段填满触发HT中断CPU处理前半段数据后半段继续填填满触发TC中断CPU处理后半段数据。这样CPU和DMA各干各的不打架。F103上HT中断回调函数是HAL_DMA_HT_Callback。HAL库还有个习惯要适应HAL_ADC_Start_DMA的第二个参数形参是uint32_t*实际传入uint16_t数组地址时需要强转。有些强迫症朋友会改成uint32_t数组再截取低16位也行但浪费一半内存。我实际测试下来直接强转uint16_t数组没有任何问题。5. 实验三串口收发DMA化彻底释放CPU5.1 先把发送改造成DMA串口发送用DMA非常直观把要发的一整包数据放到一个发送缓冲区然后告诉DMA“从这块地址搬N个字节到USART的数据寄存器”。这和中断发送最大的区别是CPU不需要一个字节一个字节地往数据寄存器里写。配置串口DMA时USART1的发送通道是DMA1_Channel4接收通道是DMA1_Channel5。在HAL_UART_MspInit里完成初始化static DMA_HandleTypeDef hdma_usart1_tx; static DMA_HandleTypeDef hdma_usart1_rx; void HAL_UART_MspInit(UART_HandleTypeDef* huart) { if (huart-Instance USART1) { __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); hdma_usart1_tx.Instance DMA1_Channel4; hdma_usart1_tx.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_usart1_tx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart1_tx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_tx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_tx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_tx.Init.Mode DMA_NORMAL; hdma_usart1_tx.Init.Priority DMA_PRIORITY_LOW; HAL_DMA_Init(hdma_usart1_tx); __HAL_LINKDMA(huart, hdmatx, hdma_usart1_tx); hdma_usart1_rx.Instance DMA1_Channel5; hdma_usart1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode DMA_CIRCULAR; hdma_usart1_rx.Init.Priority DMA_PRIORITY_LOW; HAL_DMA_Init(hdma_usart1_rx); __HAL_LINKDMA(huart, hdmarx, hdma_usart1_rx); } }发送一帧数据调用HAL_UART_Transmit_DMA(huart1, txBuffer, len);这里最大的坑是DMA发送期间不能马上修改txBuffer。因为DMA还在从这个地址读数据你改了内容发出去的数据就乱了。必须在发送完成回调里做标记等DMA读完再填新数据。发送完成回调是void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { txBusy 0; } }5.2 固定长度接收与不定长接收固定长度接收可以用HAL_UART_Receive_DMA(huart1, rxBuffer, RX_BUFFER_SIZE);。DMA会一直把串口收到的字节往rxBuffer里搬搬满固定长度触发一次接收完成回调。这种方法适合协议固定帧长的场景。但实际项目里更多是“不知道一帧数据有多长”这时候DMA固定长度接收就有点僵。配合串口空闲中断是常见解法一帧发完后总线上会有一段空闲时间串口在检测到空闲时产生中断。在空闲中断里检查“DMA已经收了几个字节”就知道这一帧的长度了。新版本HAL库提供了组合接口HAL_UARTEx_ReceiveToIdle_DMA(huart1, rxBuffer, RX_BUFFER_SIZE);启动之后一帧数据结束空闲出现时触发回调在回调里通过HAL_UARTEx_GetRxDataLength拿到当前已接收字节数然后把DMA缓冲区指针挪到下一帧起始位置继续接收。这个方法比“每收一字节进一次中断”要清爽太多。5.3 串口DMA的请求通道映射串口DMA配置里最容易踩的坑就是把通道搞错。F103上USART1_TX对应DMA1_Channel4USART1_RX对应DMA1_Channel5USART2的映射又不一样。我把几个常用映射整理了一下外设请求DMA1通道ADC1Channel1USART1_TXChannel4USART1_RXChannel5USART2_TXChannel7USART2_RXChannel6如果配错通道串口收发完全没反应而且不会报任何错。检查这类问题第一件事就是打开参考手册的DMA请求映射表核对通道号。这个细节换一颗芯片就要重新查一次千万别凭记忆硬写。6. 调试DMA时非常容易踩的坑与排查心得6.1 先确认时钟和RCC配置DMA挂载在AHB总线上初始化第一件事就是使能DMA时钟。HAL库的MX_DMA_Init一般会自动处理但如果你这颗芯片同时用了ADC、USART、SPI多个外设共用DMA1要注意初始化顺序不能互相覆盖。我遇到过的问题是某个外设的MspInit里重新初始化了同一个DMA句柄把前一个外设的链接给冲掉了。解决办法是一个外设单独一个DMA通道不要让两个外设共用同一个DMA句柄。6.2 传输数量是“次数”而不是“字节数”CNDTR的单位是数据单元数量不是字节数。数据宽度是8位时1个单元等于1字节宽度是16位时1个单元等于2字节宽度是32位时1个单元等于4字节。我曾经配了ADC采样数组长度100但把数据宽度设成字节结果DMA只搬了100字节数组后半截全是旧值。计算缓冲区大小时一定要把宽度和数量换算清楚。CNDTR本身是16位寄存器最大65535个单元超过这个值就要分批转移或者换用不同方案。6.3 用调试器判断DMA到底搬没搬排查DMA问题我的固定步骤是先在目标缓冲区打内存断点看有没有被写入再看DMA状态寄存器确认传输完成标志置位没有然后读一下CNDTR看剩余数量在不在递减。这三招基本能分清“根本没启动”还是“搬了一半卡住”。还有一种很低级但常见的坑目标缓冲区用的是局部变量栈地址不稳定或者被编译器优化掉了。给DMA用的缓冲区最好定义成全局数组或者至少加上volatile修饰。6.4 不同芯片型号的DMA差异F103和F407的DMA差异很大。F103的DMA1只有7个通道F407有两个DMA每个控制器有8个通道外设请求映射完全不一样。更麻烦的是F4系列带有数据缓存Cache的型号如F7/H7存在缓存一致性问题DMA往内存写了数据CPU读到的可能是缓存里的旧副本。遇到这类情况必须做Cache清理或无效化操作比如SCB_InvalidateDCache_by_Addr。我的经验是代码可以在F103上跑通后移植到F407但DMA这一块不能直接抄一定要重新打开目标型号参考手册把通道映射和缓冲要求重新过一遍。6.5 一个容易被忽略的时序问题DMA搬运虽然不需要CPU逐字节处理但它在总线上还是要占用带宽的。当DMA通道很多、数据量很大时会和CPU争抢AHB总线访问权。表现是CPU执行速度变慢或者某些实时性要求高的任务出现抖动。如果你发现系统卡顿但DMA本身跑得很正常试着把不重要的DMA通道优先级调低一点给CPU留出足够的总线带宽。这个优化看似不起眼但实际项目里特别有价值。最后说点实际操作中的体会第一次用DMA建议按照“内存到内存 → ADC采样 → 串口收发”的顺序练。内存到内存验证DMA本身ADC让你理解外设到存储器的自动搬运串口收发再叠加“外设请求映射”和“中断回调”两个关键点。这样每步只有一个新变量出了问题容易定位。调试时多依赖调试器的内存窗口和CNDTR寄存器别上来就printf打印本身也会干扰时序。如果你能把三个实验跑通再去翻具体型号的DMA请求映射表和HAL库源码会发现很多原来觉得玄乎的东西其实就是一套非常机械的搬运流程。