DMA技术全解析:从工作原理到串口、ADC实战及避坑指南

发布时间:2026/9/7 9:08:20
DMA技术全解析:从工作原理到串口、ADC实战及避坑指南 搞嵌入式这些年DMA算是我又爱又恨的一个外设。爱的是它真能把CPU从重复的数据搬运里解放出来UART收发、ADC采样、SPI刷屏这些高频操作配置好DMA之后CPU几乎可以撒手不管恨的是DMA一旦出问题排查起来往往比常规中断要难得多数据错位、传输不完整、标志位不清、模式配置错误各种疑难杂症见一次头大一次。这次借着一篇汇总笔记的契机我把DMA的工作流程、传输模式、典型应用场景和自己实际调试中踩过的坑一起梳理一遍覆盖串口DMA不定长接收、ADC多通道采样、FreeModbus移植、UFS DMA、分布式DMA等话题希望能给正在调DMA或者准备入坑DMA的朋友一点参考。文章里的内容既包含原理层面的拆解也有可以直接抄作业的代码示例和避坑经验。如果你只是刚接触DMA建议从头顺序读一遍如果已经在项目里被某个DMA问题卡住了可以直接跳到对应章节查问题每一部分都尽量做到了独立成文。1. DMA到底在解决什么问题1.1 没有DMA之前CPU在干什么在讲DMA之前先说一个反常识的场景一个波特率115200的串口满载接收数据每秒大约能收到11520字节。如果用传统中断方式逐字节接收每来一个字节CPU就要进一次中断把数据从数据寄存器搬到内存缓冲区。11520次中断/秒看起来不多但每次中断都要压栈、跳转、处理、出栈还要处理各种标志位整体开销至少上百个CPU周期。如果这个系统里同时还有CAN总线通信、ADC连续采样、PWM波形控制、显示刷新、Modbus协议解析你会发现CPU大部分时间都花在把数据从A搬到B这种毫无技术含量的操作上。真正需要CPU做的数据处理、状态机切换、控制算法反而被挤占了运行时间。中断方式的另外一个问题在于实时性。高频率的数据流到达时如果中断响应不及时数据寄存器可能会被新数据覆盖造成丢字节。为了不丢数据工程师只能把中断优先级调高、关掉其他中断或者把缓冲区做得很大这又引入了资源浪费和系统响应变慢的问题。1.2 DMA的核心思路搬运由专用硬件完成DMADirect Memory Access直接存储器访问的思路很简单数据搬运这件事交给一块专门的硬件电路去做。CPU只需要告诉DMA从哪里搬、搬到哪、搬多少、什么时候搬然后就可以去忙别的。数据搬运完成后DMA会通过中断等方式通知CPU处理结果。这个设计思路跟公司里请行政专员帮忙跑腿一样。你不需要自己下楼去取快递、打印文件、寄邮件只需要在OA系统里提交一个流程行政专员会替你把事情办完办完后再通知你结果。你在这段时间里可以继续做自己的核心工作。放到嵌入式系统里DMA要处理的事情通常分为三类外设到内存串口收到数据、ADC转换完成、SPI从机收到数据DMA自动把这些数据存进指定的RAM缓冲区。内存到外设需要发送的数据提前放到RAM里DMA自动把数据搬到串口/SPI/I2C的数据寄存器由外设发送出去。内存到内存把RAM里的一块数据复制到另一块地址比如图像数据搬移、协议数据组装。无论是哪一类DMA的参与方式都是配置好后自动运行CPU只需要在启动时设置一次参数剩下的搬运工作完全由硬件完成。这也是DMA和中断之间最本质的区别中断模式里CPU是搬运工DMA模式里CPU是管理者。2. DMA工作流程拆解一次传输的全生命周期2.1 一次DMA传输的四个阶段以串口接收为例一次完整的DMA传输分为四个阶段配置阶段CPU设置DMA通道、选择数据方向外设到内存、设定外设地址和内存地址、搬运数据长度、数据宽度以及传输模式单次/循环。以STM32 HAL库为例使用HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE)启动一次串口DMA接收。触发阶段DMA的搬运由外设请求信号触发。当串口收到一个字节数据寄存器有数据时硬件会产生一个DMA请求信号。DMA控制器接收到请求后开始执行一次搬运。这个过程完全由硬件完成不需要CPU干预。搬运阶段DMA从外设数据寄存器读取数据外设地址写入内存缓冲区内存地址。每搬运一个数据单元内部的计数寄存器NDTR/CNDTR减一。当计数不为0且外设继续产生请求时DMA会一直搬运下去。结束阶段当计数寄存器递减到0说明本次配置的所有数据搬运完成DMA会置位传输完成标志如果使能了中断还会触发DMA传输完成中断。CPU在中断回调里可以进行数据处理或者重新启动下一次传输。这个流程里有一个容易被忽略的规定转运次数必须事先设置好。DMA不是一直搬到缓冲区满为止而是搬够指定数量就停下来。2.2 请求与响应DMA如何感知外设有数据了DMA和外设之间打通数据通路的关键在于请求信号。不同外设的DMA请求来源不同但机制类似串口USART/UART接收时RXNE接收数据寄存器非空置位时产生DMA请求发送时TXE发送数据寄存器空置位时产生DMA请求。ADC每次转换完成数据寄存器有效时产生DMA请求。SPI接收缓冲区非空或者发送缓冲区为空时产生请求。定时器定时器更新事件、比较事件可以触发DMA请求。正是因为DMA由外设请求驱动所以天然适合外设随时产生数据、DMA随时搬运的场景。CPU不需要轮询外设状态数据到了硬件会自动搬走。需要注意的是DMA请求和中断请求是两个独立通路。外设产生DMA请求时不一定产生中断反过来也是。实际项目中经常外设中断关闭、DMA中断打开依靠DMA完成中断来通知CPU。这种组合的优点是每个数据单元搬运不打断CPU只有整批数据搬运完成时才通知CPU处理。2.3 聊聊连续请求和大块搬运我在搜索引擎的联想词里看到了dma continuous requests这个关键词实际调试中经常遇到的外设连续产生DMA请求本质上是外设持续产生数据DMA要不停搬运。串口全双工高速收发、ADC连续采样、麦克风音频采集都属于这种场景。连续请求场景下有两个配置细节内存地址要开启递增Memory Increment否则每次搬运都覆盖同一个地址。多数项目会使用循环模式Circular Mode这样DMA缓冲区转满后自动从头开始持续搬运最新数据CPU可以随时从缓冲区读取数据。大块搬运时还要注意数据宽度匹配。外设数据寄存器的宽度和内存数据宽度最好一致。串口数据寄存器通常是8位ADC数据寄存器是16位或32位如果外设和内存数据宽度不一致DMA控制器会做字节/半字/字的拼接或拆分效率会下降有些DMA甚至不支持不匹配的宽度配置。实测下来固定使用相同宽度是最省心的做法。3. 传输模式与配置选型别只会用默认值3.1 按数据方向分外设到内存、内存到外设、内存到内存DMA的数据方向决定了外设地址、内存地址如何递增以及谁作为请求源外设到内存Peripheral-to-MemoryP2M典型场景是串口接收、ADC采样、SPI接收。外设地址固定数据寄存器内存地址递增。内存到外设Memory-to-PeripheralM2P典型场景是串口发送、SPI发送、DAC输出。内存地址递增外设地址固定。内存到内存Memory-to-MemoryM2M典型场景是RAM内数据块复制。源地址和目的地址都递增方向设置为内存到内存。注意M2M模式下DMA不需要外设请求配置完成后立即开始搬运直到搬完为止。选择方向时最容易犯的错是把外设地址配反。比如串口接收时外设地址写成发送数据寄存器数据根本进不了缓冲区。建议每次配置后都打印一次寄存器状态确认PeriphBaseAddress指向的是外设的数据寄存器而不是控制状态寄存器。HAL库中__HAL_LINKDMA宏绑定外设句柄的DMA通道时也要确认绑定的方向对应的handle字段如hdmarx和hdmatx没有搞混。3.2 按搬运行为分单次、循环、突发与散聚DMA的行为模式直接决定数据到达缓冲区后的表现单次模式Normal Mode搬运完配置的数据个数后停止。适用于一帧一帧的不定长数据处理每帧数据搬运完成后必须重新启动。循环模式Circular Mode搬完后自动重新从缓冲区头开始持续搬运。适用于音频流、连续采样的数据采集CPU可以从缓冲区任意偏移取数。突发模式Burst Mode一次DMA请求搬运连续多个数据如4个、8个、16个。突发模式可以提高总线利用率适合大数据块搬运。但配置突发模式时内存地址、数据宽度必须满足对齐要求否则会产生总线错误。散聚模式Scatter-Gather通过链表描述符描述多块不连续内存一次DMA传输可以依次搬运多块数据。常见于高性能DMA控制器和Linux DMA引擎框架MCU中较少但在UFS/网络控制器中很常见。实际选型建议确定性通信如Modbus RTU帧、传感器上报用单次模式连续采集如音频、电机电流采样用循环模式需要极致性能时再考虑突发和散聚。不要一上来就配置循环模式虽然循环模式写起来方便但如果CPU处理速度跟不上可能出现缓冲区被新数据覆盖的问题。3.3 DMA中断半传输中断、全传输中断、错误中断DMA中断里最有用的三个标志位分别对应三个时机半传输中断Half Transfer搬运到一半时触发。常用于双缓冲处理前半缓冲区被DMA填充时CPU可以处理后一半缓冲区里的旧数据反之亦然。全传输中断Transfer Complete全部搬运完成时触发。单次模式下表示一帧数据完整到达可以开始解析。传输错误中断Transfer Error总线错误、配置错误时触发。调试时非常重要建议开发阶段始终打开。在STM32 HAL库中对应的是HAL_DMA_IRQHandler分发的三个回调void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart); void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart); void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart);实际项目中如果同时使用DMA半满和全满中断要特别小心回调函数里的重入问题。中断里不要做耗时操作只做数据标记和轻量逻辑处理具体解析放到主循环里完成。4. 串口DMA实操不定长接收、发送等待与FreeModbus移植4.1 串口DMA接收不定长数据的正确姿势空闲中断串口DMA接收的经典难题不是定长数据而是不定长数据。DMA搬运的前提是知道要搬多少可实际通信中一帧数据长度通常是未知的。这时候最常用的方案是DMA负责把所有收到的数据存进缓冲区真正负责帧结束判断的是串口空闲中断IDLE Line Interrupt。空闲中断的作用是当串口接收线上持续一段时间没有新数据到达就触发一次中断。这个空闲时间通常是一个字节的传输时间正好用来判断一帧数据可能结束了。实现思路DMA始终开启接收模式缓冲区设置为足够大的固定长度比如256字节或512字节。串口空闲中断打开一旦触发说明一帧数据结束。在空闲中断处理函数里读取DMA计数寄存器CNDTR用缓冲区长度 - 剩余计数算出当前实际收到了多少字节。重置DMA计数准备接收下一帧。以STM32 HAL库为例配置步骤// 启动DMA接收缓冲区255字节 HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); // 使能空闲中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);中断处理void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (len 0) { frame_len len; frame_ready 1; } HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); } HAL_UART_IRQHandler(huart1); }这里有几个细节必须注意第一__HAL_UART_CLEAR_IDLEFLAG在部分HAL版本中是通过读SR再读DR来清除的。如果在中断里先清标志再使用__HAL_DMA_GET_COUNTER读取的是当前实时计数值没有问题。但如果代码写成先读计数再清标志期间又来了新数据计数就会不对。第二调用HAL_UART_Receive_DMA重新启动时如果上一次DMA已经传输完成缓冲区填满直接调用可能会因为DMA通道还在忙碌状态而失败。稳妥的做法是先调用一次HAL_UART_AbortReceive或者先HAL_DMA_Abort再重新启动。第三空闲中断触发的时机比真实帧间隔略晚但只要DMA一直开着数据不会丢。所以解析协议时可以在帧尾追加一个超时校验避免把连续的半帧误判成一帧。4.2 串口DMA发送需要等待上一轮发完吗这个问题是我在搜索联想词里见到的说明很多工程师在实际使用中都被这个细节困扰过。直接给结论在单次模式下DMA发送必须等待上一轮传输完成才能启动下一轮。原理很简单。DMA控制器里的NDTR/CNDTR寄存器是每次配置后递减的计数器。如果上一次传输还没结束数据还没搬完你直接修改NDTR寄存器的值DMA控制器会把当前剩余计数覆盖成新长度。后果就是旧数据搬运到一半突然改变目标长度新数据和旧数据混在一起线上发出的是完全错乱的内容。正确做法有三种方法一轮询DMA状态。while (__HAL_DMA_GET_FLAG(hdma_usart1_tx, DMA_FLAG_TC1) RESET) { // 等待传输完成 } HAL_UART_Transmit_DMA(huart1, tx_buffer, len);方法二在DMA发送完成中断里置标志位主函数发送前检查标志位。方法三使用HAL库的状态查询。if (HAL_UART_GetState(huart1) HAL_UART_STATE_READY) { HAL_UART_Transmit_DMA(huart1, tx_buffer, len); }除了DMA自身的传输完成串口发送还要特别看另外一个标志USART的TC位发送完成位。DMA把数据搬到串口数据寄存器后串口移位寄存器还要把数据一位一位地发出去。DMA的传输完成和线上真正发完不是同一个时刻。如果使用RS485之类的半双工总线需要等串口TC置位后才能把发送模式切换成接收模式否则最后一个字节会被切掉一半。所以RS485场景的正确顺序是HAL_UART_Transmit_DMA(huart1, tx_buffer, len); // 在UART TC中断里而非DMA完成中断里切换485方向 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC)) { __HAL_UART_CLEAR_TCFLAG(huart1); RS485_DIR_RX(); } }这套流程我在多个项目里验证过能可靠避免最后一个字节被截断的问题。4.3 PY32F003使用串口DMA接收通讯数据的参考实现有朋友在搜索里提到PY32F003的串口DMA接收我基于STM32 HAL库风格整理了一份最小参考实现。PY32F003是M0内核外设结构和STM32F0系列很接近所以这份代码稍作修改即可移植到其他F0/M0系列。关键初始化代码// 串口句柄和DMA句柄 UART_HandleTypeDef huart1; DMA_HandleTypeDef hdma_usart1_rx; uint8_t uart1_rx_buf[128]; volatile uint8_t uart1_frame_ok 0; volatile uint16_t uart1_frame_len 0; void MX_USART1_UART_Init(void) { huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart1); } void HAL_UART_MspInit(UART_HandleTypeDef* uartHandle) { GPIO_InitTypeDef GPIO_InitStruct {0}; if (uartHandle-Instance USART1) { __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_DMA_CLK_ENABLE(); // PA2 TX, PA3 RX按原理图调整 GPIO_InitStruct.Pin GPIO_PIN_2; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); GPIO_InitStruct.Pin GPIO_PIN_3; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_NOPULL; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // DMA接收通道 hdma_usart1_rx.Instance DMA1_Channel1; 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_NORMAL; hdma_usart1_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_usart1_rx); __HAL_LINKDMA(uartHandle, hdmarx, hdma_usart1_rx); HAL_NVIC_SetPriority(USART1_IRQn, 0, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); // 注意M0系列DMA中断向量可能合并 HAL_NVIC_SetPriority(DMA1_Channel1_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA1_Channel1_IRQn); } }接收启动和中断处理void uart1_rx_dma_start(void) { uart1_frame_ok 0; uart1_frame_len 0; HAL_UART_Receive_DMA(huart1, uart1_rx_buf, sizeof(uart1_rx_buf)); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); } void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uart1_frame_len sizeof(uart1_rx_buf) - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (uart1_frame_len 0) { uart1_frame_ok 1; } HAL_UART_Receive_DMA(huart1, uart1_rx_buf, sizeof(uart1_rx_buf)); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); } HAL_UART_IRQHandler(huart1); } void DMA1_Channel1_IRQHandler(void) { HAL_DMA_IRQHandler(hdma_usart1_rx); }主循环里处理if (uart1_frame_ok) { uart1_frame_ok 0; process_uart1_frame(uart1_rx_buf, uart1_frame_len); }PY32F003的DMA中断向量要注意不同型号可能把多个通道的中断合并到一个入口。用CubeMX生成工程时会自动处理好手写代码时需要查阅数据手册确认中断服务函数名。4.4 FreeModbus DMA移植中必须处理的三个问题把FreeModbus协议栈和DMA结合起来使用是很多工业设备的常见需求。FreeModbus默认的串口移植是逐字节中断换成DMA后主要需要处理三个问题。问题一帧结束判断。Modbus RTU协议要求帧与帧之间至少有3.5个字符时间的静默间隔。逐字节中断模式里FreeModbus用定时器T35来判断帧是否结束改用DMA接收后UART空闲中断可以承担这个职责。但要注意空闲中断的触发时间通常大于3.5字符时间在高速率如115200以上下某些硬件空闲中断的响应可能不够精准。稳妥的做法是保留T35定时器在空闲中断触发后启动T35定时定时超时后再认为帧结束。问题二接收数据如何交给协议栈。FreeModbus在eMBPortSerialInit里注册了接收回调pvMBFrameStartCur和prvvUARTRxISR。使用DMA时不再逐字节触发prvvUARTRxISR而是在DMA空闲中断判断帧结束后直接把缓冲区数据和长度交给pvMBFrameStartCur对应的处理函数。这样协议栈的解析逻辑不需要改动只是数据来源从逐字节中断变成了DMA整帧交付。问题三发送方向切换。如果硬件使用RS485发送完成后必须切换为接收模式。前面说过一定要在USART的TC标志置位后再切换方向而不能在DMA完成中断里切换。FreeModbus的xMBPortSerialPutByte和发送完成的处理逻辑需要同步修改否则会出现发完最后一个字节后总线方向还停留在发送模式导致收不到从站回复或者总线冲突。5. ADC多通道DMA采样与数据对齐5.1 STM32 HAL库ADC单通道DMA多次采样配置ADC连续采样配合DMA的使用频率非常高比如电机电流检测、电池电压监测、传感器数据采集。用HAL库配置ADC单通道DMA多次采样的核心代码如下ADC_HandleTypeDef hadc1; DMA_HandleTypeDef hdma_adc1; uint16_t adc_buf[128]; void MX_ADC1_Init(void) { hadc1.Instance ADC1; hadc1.Init.ClockPrescaler ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode DISABLE; hadc1.Init.ContinuousConvMode ENABLE; hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion 1; 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); hdma_adc1.Instance DMA1_Channel1; hdma_adc1.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc DMA_PINC_DISABLE; hdma_adc1.Init.MemInc DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode DMA_CIRCULAR; hdma_adc1.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_adc1); __HAL_LINKDMA(hadc1, DMA_Handle, hdma_adc1); } void adc_start_dma(uint16_t *buffer, uint16_t length) { HAL_ADC_Start_DMA(hadc1, (uint32_t *)buffer, length); }启动后ADC会按照配置的采样周期持续转换DMA自动把每次转换结果依次写入adc_buf。写入128个数据后DMA会循环回到缓冲区开头继续覆盖写入。使用循环模式时CPU可以通过DMA半满/全满中断分批读取void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 后64个数据已经就绪可以在这里做均值滤波 process_adc_data(adc_buf[64], 64); } void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { // 前64个数据已经就绪 process_adc_data(adc_buf[0], 64); }这样CPU和DMA可以并行工作DMA往缓冲区后半段写数据时CPU处理前半段DMA写前半段时CPU处理后半段。这个机制就是常说的双缓冲Ping-Pong思想。5.2 多通道扫描模式下数据错位的坑ADC多通道DMA采样比单通道复杂最常见的问题是数据错位。比如配置了CH0、CH1、CH2三个通道按顺序扫描DMA缓冲区里期望的是CH0数据、CH1数据、CH2数据、CH0数据、CH1数据、CH2数据……这样循环排列但实测读出来可能是CH2数据、CH0数据、CH1数据CH2数据……。造成错位的原因通常是启动时序问题。ADC在上电瞬间或者某个通道转换尚未结束时DMA已经开始搬运或者DMA缓冲区的起始位置和ADC序列的起始通道没有对齐。解决办法保证DMA缓冲区长度是通道数的整数倍。比如3通道扫描缓冲区长度必须是3的倍数如300、600否则数据序列会在缓冲区末尾和开头之间错位。开启扫描模式后等待启动稳定再读取首包数据。某些芯片上刚启动扫描时第一次转换结果不可靠。使用序列序号定位起点。在DMA完成中断里通过当前缓冲区索引对通道数取模来确定哪个数据对应哪个通道不要把通道顺序写死。还有一点多通道扫描时关闭连续转换和循环组合不当也会出问题。如果使用连续转换 循环模式必须确认扫描序列和DMA缓冲区对齐否则CH0可能不是从索引0开始。调试时最有效的办法是给CH0接一个固定电压然后看缓冲区里哪个位置的数据始终等于该电压值通过这个方式确定通道映射关系。6. DMA性能测速与进阶应用6.1 嵌入式里怎么给DMA测速DMA测速这个词有两种理解一是测试DMA搬运数据的实际吞吐量二是测试有DMA参与的整条数据链路如存储、网卡的性能。嵌入式MCU里前者可以通过内存到内存搬运来测试。测试思路把一块固定大小的数据从源地址搬运到目的地址用SysTick或DWT时基统计搬运耗时然后计算带宽。以STM32为例#define TEST_SIZE (32 * 1024) // 32KB uint32_t src_buf[TEST_SIZE / 4]; uint32_t dst_buf[TEST_SIZE / 4]; void dma_bandwidth_test(void) { // 使用DMA内存到内存模式搬运32KB HAL_DMA_Start(hdma_mem2mem, (uint32_t)src_buf, (uint32_t)dst_buf, TEST_SIZE / 4); DWT-CYCCNT 0; // 轮询等待传输完成 while (__HAL_DMA_GET_FLAG(hdma_mem2mem, DMA_FLAG_TC1) RESET) {} uint32_t cycles DWT-CYCCNT; float time_us (float)cycles / HAL_RCC_GetSysClockFreq() * 1000000.0f; float bandwidth TEST_SIZE / time_us; // MB/s }实测下来不同芯片DMA带宽差异很大。一个72MHz主频的M3内核内存到内存DMA搬运32KB数据耗时大约几十微秒到上百微秒带宽在几十MB/s量级。如果发现带宽明显偏低先检查DMA时钟配置是否开启、总线是否被频繁抢占、数据宽度是否按32位配置。测速还有一个实用场景验证不同缓冲区大小下的DMA效率。DMA批量搬运比逐字节搬效率高缓冲区越大启动DMA的固定开销占比越低。实际项目中如果需要频繁发送小数据包可以考虑把多个小数据包拼接到一个大缓冲区再统一发送能明显降低DMA启动次数。6.2 UFS DMA从MCU到高性能存储UFSUniversal Flash Storage通用闪存存储是目前主流智能手机、平板电脑使用的高性能存储接口标准。UFS主机控制器内部有一个专门的DMA引擎负责在主机内存和UFS设备之间搬运数据。UFS DMA的核心机制是PRDTPhysical Region Descriptor Table物理区域描述符表。主机内存中的数据缓冲区不一定连续可能是分散在多个物理页里。DMA引擎通过PRDT把这些分散的区域串联起来一次命令就能完成整块数据的搬运不需要CPU逐个区域复制。这和MCU里Scatter-Gather的思想完全一致只是规模更大、性能要求更高。在Linux的UFS驱动中SCSI命令和UFS命令如READ/WRITE通过UTP描述符组织DMA引擎负责将命令描述符、PRDT、数据缓冲区的内容按协议格式搬运到UFS控制器。大文件读写时CPU占用率很低就是因为数据搬运全部由UFS DMA完成。UFS DMA对我们嵌入式工程师的启发是如果遇到性能瓶颈优先考虑用硬件描述符链表组织内存而不是手动拼包。如果能用DMA的多缓冲区链表功能把分散数据一次性搬运比CPU逐块拼包高效得多。6.3 分布式DMA多核时代的思路分布式DMA也是搜索词里的一个方向。传统的MCU方案中整个芯片通常只有一个DMA控制器所有外设共用。外设多了以后DMA通道资源会变得紧张高优先级外设长时间占用通道会阻塞低优先级外设。多核SoC和高端处理器上的做法是分布式DMA每个子系统、每个外设集群都有自己的DMA引擎各自独立处理本区域的数据搬运。比如网卡发一个队列一个DMA存储控制器一个DMA显示控制器一个DMA彼此不抢占。这样设计的好处是扩展性好外设增加时不必担心中央DMA通道不够。隔离性好某个DMA引擎出错不影响其他引擎。功耗管理灵活不用的DMA引擎可以单独关电。在嵌入式项目里即使是单核MCU也可以借鉴分布式DMA的思路把高频的外设如UART、ADC、定时器分别绑定到固定DMA通道低频外设共享剩余通道避免通道互相抢占导致的数据延迟。7. DMA疑难杂症速查表这里整理了一些我实际项目中踩过、也在社区里反复看到的DMA问题方便按图索骥排查。现象可能原因排查/解决DMA传输完成后数据不完整配置的传输长度比实际数据短缓冲区被其他代码覆盖核对NDTR初始值检查缓冲区是否溢出、是否有指针越界串口DMA接收的数据全为0外设地址配成状态寄存器DMA方向配反打印DMA通道寄存器确认外设地址指向数据寄存器检查方向配置DMA发送数据卡住不再发送上一轮传输未完成就重新配置UART TC标志未清发送前查询DMA状态或等TC标志置位后再启动缓冲区数据被新数据覆盖循环模式下CPU处理速度跟不上改用双缓冲半满中断增大缓冲区ADC多通道数据错位缓冲区长度不是通道数整数倍启动时序问题缓冲区长度取通道数公倍数首包丢弃用固定电压定位通道DMA传输到一半触发错误中断总线对齐问题内存地址无效外设时钟未开启检查突发模式下地址对齐检查DMA和外设时钟使能使用HAL_UART_Receive_DMA返回HAL_BUSY上一次DMA传输还未完成或状态未复位调用HAL_UART_AbortReceive后再重新启动FreeModbus用DMA后协议解析一帧拆成多段空闲中断判断帧结束时机不对配合T35定时器在空闲中断后再等一段稳定时间确认帧结束额外分享两个比较玄学但真实存在的坑第一个是缓冲区地址对齐问题。部分DMA控制器对内存地址有对齐要求如4字节、8字节对齐配置缓冲区时如果不注意DMA访问会出现总线错误或性能严重下降。定义全局数组时可以使用__attribute__((aligned(4)))来保证对齐。第二个是缓存一致性问题。在带D-Cache的高性能MCU如带Cortex-M7或A系列核上CPU写入DMA缓冲区后数据可能还停留在Cache里没有写回内存此时启动DMA发送发送出去的是旧数据。解决方法是使用SCB_CleanDCache_by_Addr清理Cache或者在配置DMA时使用非Cacheable内存区域。这个坑在没有Cache的M0/M3上不会出现但一旦换到M7核平台几乎必然遇到。我个人的经验是DMA出问题的时候别急着怀疑DMA本身。先确认外设是否真的产生了DMA请求再确认DMA通道是否使能再检查地址和长度配置最后才去深挖中断回调的时序逻辑。大部分问题都出在外设与DMA的配合细节上而不是DMA核心逻辑本身。希望这篇汇总笔记能帮各位少走一些弯路。