做串口调试的时候,我遇到过一个挺扎心的情况:波特率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位),CNDTR=100,那实际搬运的总字节数是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 ADC+DMA的初始化流程
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化,彻底释放CPU
5.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_Channel4,USART1_RX对应DMA1_Channel5;USART2的映射又不一样。我把几个常用映射整理了一下:
| 外设请求 | DMA1通道 |
|---|---|
| ADC1 | Channel1 |
| USART1_TX | Channel4 |
| USART1_RX | Channel5 |
| USART2_TX | Channel7 |
| USART2_RX | Channel6 |
如果配错通道,串口收发完全没反应,而且不会报任何错。检查这类问题,第一件事就是打开参考手册的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库源码,会发现很多原来觉得玄乎的东西,其实就是一套非常机械的搬运流程。