1. 为什么DMA值得单独拎出来讲一期搞嵌入式驱动开发的人迟早都会撞上DMA这堵墙。你写个串口接收用中断方式一个字节一个字节地收波特率一高就开始丢数据你写个SPI读取传感器CPU全程陪着在那等啥也干不了你做个ADC连续采样CPU被采样中断拖得喘不过气。这些场景的终极解法基本都指向同一个东西——DMA。DMA全称Direct Memory Access直接内存访问。说白了就是给外设和内存之间修一条专用通道数据搬运这活儿不用CPU亲自干了交给DMA控制器去跑。CPU只管发号施令“从哪搬到哪、搬多少”然后就可以去处理别的事情搬完了DMA再给CPU一个中断通知。这个机制在嵌入式开发里属于“不用不知道一用回不去”的典型。这一期我打算把DMA驱动开发这件事从头到尾捋一遍。从它在STM32、ESP32这类主流平台上的基本架构到串口DMA收发、SPI DMA、ADC DMA这些常见场景的具体配置再到DMA测速怎么做、测速失败怎么排查最后聊一些实际项目中踩过的坑和面试里高频出现的DMA八股。内容会偏实战代码以STM32 HAL库和寄存器操作为主但思路在别的平台上也是通的。适合看这篇的人写过裸机驱动但还没系统用过DMA的用过DMA但总是配置不对、数据对不上的准备嵌入式面试想突击DMA相关八股的以及做嵌入式Linux驱动、需要理解底层DMA映射机制的。不管你是哪种我都尽量把“为什么这么配”讲清楚而不是只丢一段代码让你抄。2. DMA的核心架构与工作原理拆解2.1 DMA控制器到底长什么样很多人用DMA就是照着例程配一遍能跑就行但一旦出问题就完全不知道从哪查。根源在于没搞清楚DMA控制器内部到底有哪些东西。以STM32的DMA控制器为例一个DMA控制器有多个通道Channel或者流Stream每个通道独立配置可以绑定到特定的外设请求上。每个通道内部有这么几个关键部件源地址寄存器、目标地址寄存器、传输数量寄存器、控制寄存器。工作时外设发出DMA请求信号DMA控制器收到请求后从源地址读一个数据单元写到目标地址然后源地址和目标地址按配置递增或保持不变传输数量减一。数量减到零产生传输完成中断。这里有个容易混淆的点DMA请求的触发源。不是所有外设都能随便触发DMA每个DMA通道能响应哪些外设请求是硬件固定死的。比如STM32F103的DMA1通道4对应USART1_TX通道5对应USART1_RX你配错了通道DMA根本不会动。所以第一步永远是查参考手册的DMA请求映射表把通道和外设的对应关系确认清楚。2.2 为什么DMA能解放CPU用生活化的类比CPU是公司老板外设是各个部门数据是文件。没有DMA的时候部门要传文件给另一个部门得老板亲自跑腿去拿、去送老板的时间全耗在跑腿上。有了DMA相当于公司配了个专职快递员老板只需要告诉快递员“从A部门拿100份文件送到B部门”快递员自己跑跑完了跟老板汇报一声。老板在这期间可以开会、做决策、处理其他事。从技术角度看DMA传输期间CPU总线并不是完全被占用的。DMA控制器通过总线矩阵访问内存和CPU的指令/数据访问存在仲裁机制。在STM32这类MCU上DMA访问的内存区域和CPU访问的内存区域如果不同比如DMA访问SRAMCPU执行Flash里的代码两者可以并行。但如果DMA和CPU同时访问同一个SRAM区域就会出现总线仲裁CPU会被短暂挂起。这就是为什么高带宽DMA传输时CPU性能会受影响的底层原因。2.3 常见DMA工作模式与选型考量DMA的工作模式直接决定了你的驱动怎么写选错了模式要么数据丢要么CPU占用高。常见的模式有这么几种模式特点适用场景注意事项单次传输传完指定数量就停一次性大数据块搬运每次都要重新配置循环模式传完自动重装载周而复始串口接收、ADC连续采样要处理好缓冲区边界外设到内存源地址固定目标递增串口RX、ADC、SPI RX源地址不递增内存到外设源地址递增目标固定串口TX、SPI TX、DAC目标地址不递增内存到内存源和目标都递增大数据块拷贝优先级要设高选型的核心逻辑是看数据流向和是否需要连续。串口接收数据是源源不断来的必须用循环模式否则收完一批就停了。串口发送是主动发一批用单次模式就行发完中断里再决定要不要发下一批。ADC连续采样也是循环模式配合半传输中断和传输完成中断做双缓冲。还有一个关键参数是优先级。当多个DMA通道同时请求时优先级高的先响应。如果串口接收和ADC采样同时用DMA串口接收优先级要设高因为串口数据来得快、缓冲区小不及时搬走就溢出了。ADC采样数据可以稍微等等。3. 串口DMA收发驱动的完整实现3.1 串口DMA接收的配置要点串口DMA接收是嵌入式项目里用得最多的场景也是最容易出问题的场景。先讲配置以STM32 HAL库为例关键步骤有这么几步。第一步在CubeMX或者手动初始化时把USART的RX引脚对应的DMA通道使能方向设为外设到内存模式设为循环数据宽度设为字节内存地址递增外设地址不递增。这里有个细节内存地址递增一定要开否则收到的数据全堆在缓冲区第一个字节上后面来的数据把前面的覆盖了。第二步调用HAL_UART_Receive_DMA()启动接收。这个函数内部会配置DMA的源地址为USART数据寄存器目标地址为你提供的缓冲区传输数量为缓冲区大小。启动之后DMA就开始默默搬运了CPU完全不用管。第三步处理接收完成和半传输中断。循环模式下DMA搬完整个缓冲区会触发传输完成中断搬到一半会触发半传输中断。利用这两个中断可以实现双缓冲前半段满了处理前半段后半段满了处理后半段。这样数据处理和DMA搬运可以并行不会因为处理数据而丢包。#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; void uart_dma_init(void) { // 启动DMA接收循环模式 HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); } // 半传输完成回调前半段数据就绪 void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { process_data(rx_buffer[0], RX_BUFFER_SIZE / 2); } } // 传输完成回调后半段数据就绪 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { process_data(rx_buffer[RX_BUFFER_SIZE / 2], RX_BUFFER_SIZE / 2); } }3.2 串口DMA发送的坑与解法串口DMA发送看起来简单把数据丢给DMA就完事了但实际用起来有几个坑。第一个坑是发送未完成就修改缓冲区。DMA发送是异步的你调用HAL_UART_Transmit_DMA()之后函数立刻返回但数据还在慢慢往外发。如果你这时候修改了发送缓冲区的内容发出去的数据就乱了。解法是要么等发送完成回调再修改要么用双缓冲一个在发、一个在准备。第二个坑是连续调用发送函数导致DMA忙。DMA发送还没完成你又调一次发送HAL库会返回HAL_BUSY。很多人的做法是加个while等待这就把DMA的异步优势给废了。正确做法是维护一个发送队列在发送完成回调里检查队列有数据就启动下一次发送。第三个坑是串口发送完成中断和DMA传输完成中断的区别。DMA传输完成只代表数据全部写进了USART数据寄存器不代表最后一个字节已经从TX引脚发出去了。如果要确保数据真正发完比如RS485切换收发方向必须等USART的TCTransmission Complete标志而不是DMA的完成标志。这个坑我在实际项目里踩过RS485方向切换早了最后一个字节被截断。3.3 空闲中断配合DMA的不定长接收方案循环模式DMA接收适合定长数据但实际串口通信中大量是不定长数据帧。这时候经典方案是DMA加串口空闲中断。原理是这样的DMA一直在循环搬运数据到缓冲区串口总线上一旦出现一个字节时间的空闲就触发空闲中断。在空闲中断里读取DMA当前已经搬运的数量通过__HAL_DMA_GET_COUNTER()就能知道这一帧收了多少字节然后处理数据、重置DMA计数器。volatile uint16_t rx_len 0; volatile uint8_t rx_flag 0; void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 停止DMA读取剩余计数 HAL_UART_DMAStop(huart1); uint16_t remain __HAL_DMA_GET_COUNTER(huart1.hdmarx); rx_len RX_BUFFER_SIZE - remain; rx_flag 1; // 重新启动DMA接收 HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); } HAL_UART_IRQHandler(huart1); }这个方案的精髓在于DMA负责搬运空闲中断负责定帧CPU只在完整帧到达时才被唤醒。实测在115200波特率下CPU占用率不到1%比中断逐字节接收低了两个数量级。注意空闲中断里重新启动DMA之前一定要先停止DMA否则计数器读取会不准。另外如果两帧数据之间没有空闲时间背靠背连续帧空闲中断不会触发这种协议就得用别的方式定帧比如固定帧头帧尾。4. SPI DMA与ADC DMA的实战配置4.1 SPI DMA全双工通信配置SPI用DMA的场景通常是高速读取传感器数据或者驱动显示屏。SPI是全双工的发送和接收同时进行所以需要两个DMA通道一个负责TX内存到外设一个负责RX外设到内存。配置要点TX通道方向内存到外设内存递增外设不递增RX通道方向外设到内存内存递增外设不递增。两个通道的传输数量要一致因为SPI每发一个字节就收一个字节。uint8_t spi_tx_buf[64]; uint8_t spi_rx_buf[64]; void spi_dma_transfer(void) { HAL_SPI_TransmitReceive_DMA(hspi1, spi_tx_buf, spi_rx_buf, 64); }这里有个细节容易被忽略SPI的DMA请求使能位。在STM32上SPI的TX和RX DMA请求是分开使能的通过SPI_CR2寄存器的TXDMAEN和RXDMAEN位控制。HAL库的HAL_SPI_TransmitReceive_DMA()内部会设置这两位但如果你自己写寄存器操作忘了设这两位DMA配得再对也不会动。还有一个坑是SPI时钟极性和相位CPOL/CPHA配错导致数据错位。这个跟DMA没关系但用DMA时数据错位更难排查因为你看不到逐字节的收发过程。建议先用阻塞方式确认SPI基本通信正常再切换到DMA。4.2 ADC多通道DMA扫描采样ADC连续采样是DMA的经典应用。配置思路ADC设为扫描模式开启多个通道DMA设为循环模式外设到内存内存递增数据宽度为半字ADC是12位用16位变量存。#define ADC_CH_NUM 4 uint16_t adc_values[ADC_CH_NUM]; void adc_dma_init(void) { HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_values, ADC_CH_NUM); }这样ADC就会按照通道顺序依次转换每转换完一个通道DMA就把结果搬到数组对应位置。四个通道转完一轮DMA计数器重装载继续下一轮。CPU完全不用干预需要数据的时候直接读数组就行。实际项目中我通常会在DMA传输完成中断里做一次数据平均或者滤波然后更新到全局变量供其他任务使用。注意ADC的DMA传输完成中断频率不要太高否则CPU光处理中断就忙不过来。如果采样率很高可以用半传输中断做双缓冲进一步降低中断频率。4.3 定时器触发DMA的进阶用法STM32的定时器可以产生DMA请求用来触发ADC采样或者DAC输出实现精确的定时控制。比如用TIM2的更新事件触发ADCADC再通过DMA搬运数据整个链路全硬件自动完成CPU完全不参与。配置顺序很重要先配定时器设好预分频和重装载值使能更新事件作为触发输出再配ADC触发源选定时器触发使能DMA最后配DMA通道。顺序错了可能触发信号丢失。这个方案在电机控制、音频采样、电源管理里用得很多。我做过一个项目用TIM1触发ADC采三相电流采样率20kHzDMA循环搬运CPU占用率几乎为零全部精力用来跑FOC算法。5. DMA测速方法与性能评估5.1 为什么要做DMA测速DMA测速不是为了炫技而是为了验证DMA配置是否正确、评估系统带宽余量、以及排查性能瓶颈。比如你配了个串口DMA接收理论上能跑到几兆波特率但实际跑起来丢包这时候就需要测一下DMA的实际搬运速率看看是DMA带宽不够还是CPU处理不过来还是外设本身速率限制。5.2 内存到内存DMA测速实操最简单的测速方法是内存到内存的DMA传输。配置一个DMA通道源地址指向一块SRAM目标地址指向另一块SRAM传输一个大数量比如64KB在传输开始和结束时分别翻转一个GPIO或者读取定时器计数算出耗时。#define TEST_SIZE 65536 uint8_t src_buf[TEST_SIZE]; uint8_t dst_buf[TEST_SIZE]; void dma_memcpy_speed_test(void) { uint32_t start_tick, end_tick; // 启动定时器 start_tick DWT-CYCCNT; HAL_DMA_Start(hdma_memtomem, (uint32_t)src_buf, (uint32_t)dst_buf, TEST_SIZE); HAL_DMA_PollForTransfer(hdma_memtomem, HAL_DMA_FULL_TRANSFER, 1000); end_tick DWT-CYCCNT; uint32_t cycles end_tick - start_tick; float speed (float)TEST_SIZE / cycles * SystemCoreClock / 1000000; printf(DMA speed: %.2f MB/s, cycles: %lu\n, speed, cycles); }在STM32F10372MHz上实测内存到内存DMA速率大约在30-40MB/s受限于SRAM访问速度和总线仲裁。在STM32F4168MHz上能到100MB/s以上。这个数据可以用来判断你的DMA配置是否跑在正常水平。5.3 外设DMA测速与瓶颈分析外设DMA测速更有实际意义。比如串口DMA接收测速可以用一个高速发送端持续发数据接收端用DMA接收并统计一定时间内的接收字节数。SPI DMA测速可以用SPI回环MOSI接MISO发送大量数据同时接收统计吞吐量。测速时要注意几个瓶颈点外设本身的最高速率比如串口波特率上限、DMA控制器的带宽、总线矩阵的仲裁、内存的访问速度。任何一个环节成为瓶颈整体速率就上不去。我遇到过SPI DMA速率上不去的情况最后发现是SPI时钟分频设太大了跟DMA没关系。提示测速时关闭所有不相关的中断和任务避免干扰。用DWT周期计数器比用系统滴答定时器精度高得多能精确到CPU周期级别。6. DMA常见问题排查与避坑指南6.1 DMA配置了但不工作的排查思路这是最常见的问题DMA配了半天数据就是不动。排查按这个顺序来第一确认DMA通道和外设的对应关系。查参考手册的DMA请求映射表确认你用的通道确实能响应这个外设的请求。STM32F1和F4的映射不一样换芯片了要重新查。第二确认外设的DMA请求使能位。很多外设的DMA请求是单独使能的比如USART_CR3的DMAR和DMAT位SPI_CR2的RXDMAEN和TXDMAEN位。HAL库函数内部会设但如果你自己操作寄存器很容易漏。第三确认DMA通道使能位。HAL_DMA_Start()会设EN位但如果你先配了外设再配DMA顺序反了可能外设已经产生了请求但DMA还没使能请求就丢了。第四确认中断优先级。如果DMA传输完成中断被其他高优先级中断一直抢占看起来就像没工作。用调试器看DMA的CNDTR寄存器如果数值在变说明DMA在搬只是中断没处理。6.2 数据错位、丢包的典型原因数据错位通常有几个原因内存地址递增没开数据全堆在第一个字节数据宽度配错字节配成了半字导致地址跳跃循环模式下缓冲区大小和数据处理速度不匹配处理太慢被新数据覆盖。丢包的原因更多DMA优先级太低被其他通道抢占串口接收没有用空闲中断定帧帧边界判断错误DMA传输完成中断里处理时间太长下一次传输已经开始了缓冲区太小高波特率下瞬间就满了。我踩过最坑的一次是串口DMA接收丢包查了两天才发现是DMA通道优先级设成了低而同时有个SPI DMA在高速搬运串口DMA请求一直被SPI压制。把串口DMA优先级调到最高就解决了。这个问题的隐蔽性在于低速通信时完全正常只有高速时才暴露。6.3 DMA与Cache一致性问题这个问题在带Cache的MCU上比如STM32F7、H7系列特别突出。DMA直接访问内存不经过Cache而CPU访问内存经过Cache。如果CPU写了一段数据到缓冲区数据还在Cache里没写回内存这时候启动DMA发送DMA读到的是内存里的旧数据发出去就是错的。解法是在启动DMA之前做Cache清理Clean把Cache里的数据写回内存在DMA接收完成后做Cache无效化Invalidate把内存里的新数据同步到Cache。HAL库提供了SCB_CleanDCache_by_Addr()和SCB_InvalidateDCache_by_Addr()函数。// DMA发送前清理Cache SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, tx_len); HAL_UART_Transmit_DMA(huart1, tx_buf, tx_len); // DMA接收后无效化Cache SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, rx_len); process_data(rx_buf, rx_len);注意Cache操作要按Cache行对齐地址和长度都要对齐到32字节。不对齐的Cache操作可能导致相邻数据被误清理或误无效化引发更难排查的问题。6.4 DMA测速失败代码的常见原因网上有些DMA测速代码跑不起来常见原因有这么几个DWT周期计数器没使能读出来全是零DMA内存到内存模式在某些型号上不支持比如STM32F1的DMA1不支持内存到内存得用DMA2传输数量设太大超过了DMA计数器的位宽一般是16位最大65535测速时没关中断中断处理时间算进去了结果偏大。排查测速代码先用一个小数量比如256字节确认DMA能正常传输再逐步加大。用调试器看DMA的CNDTR寄存器传输过程中数值应该递减。如果不变说明DMA根本没启动。7. DMA相关面试八股与知识延展7.1 高频面试题拆解嵌入式面试里DMA相关的八股出现频率很高我整理了几个高频问题和我自己的回答思路。问题一DMA传输过程中CPU能访问内存吗能但要看情况。如果DMA访问的内存区域和CPU访问的不是同一个比如DMA访问SRAMCPU执行Flash代码可以并行。如果访问同一块SRAM会有总线仲裁CPU可能被短暂挂起。在带Cache的MCU上CPU访问的是CacheDMA访问的是内存两者可以并行但要注意Cache一致性问题。问题二DMA和中断的区别是什么中断是CPU停下来处理事件DMA是CPU不参与数据搬运。中断适合处理低频事件DMA适合大批量数据搬运。两者经常配合使用DMA搬完数据产生中断通知CPU处理。问题三DMA传输完成中断和串口空闲中断有什么区别DMA传输完成中断是DMA搬完了指定数量的数据触发的跟数据内容无关。串口空闲中断是总线上一段时间没有新数据触发的跟数据量无关。不定长接收用空闲中断定帧定长接收用DMA完成中断。问题四如何计算DMA的最大传输速率理论上限是总线带宽实际受限于外设速率、DMA控制器带宽、内存访问速度。以STM32F103为例AHB总线72MHzDMA每次传输至少占2个总线周期读写理论最大36MB/s实测30MB/s左右。7.2 DMA在嵌入式Linux中的对应概念做嵌入式Linux驱动的同学DMA的概念是相通的但实现方式不同。Linux内核里DMA分为一致性DMA和流式DMA。一致性DMA用于长期存在的缓冲区驱动初始化时分配CPU和设备看到的内存是一致的。流式DMA用于一次性传输需要做Cache同步操作对应MCU上的Clean和Invalidate。Linux下的DMA API有dma_alloc_coherent()、dma_map_single()、dma_unmap_single()等。设备树里要配置dmas和dma-names属性指定用哪个DMA控制器和通道。跟MCU裸机开发相比Linux的DMA框架更抽象但底层原理是一样的。7.3 DMA在AI嵌入式中的角色现在嵌入式AI很火DMA在其中的作用也不小。神经网络推理需要大量数据搬运比如摄像头采集的图像数据要搬到内存权重参数要从Flash搬到SRAM中间层的特征图要在缓冲区之间倒腾。这些搬运如果全用CPU做推理速度会大打折扣。用DMA搬运CPU专心做计算整体性能提升明显。有些AI加速器比如NPU内部有专门的DMA引擎负责在外部内存和内部缓存之间搬运数据。驱动开发时要配置描述符链告诉DMA引擎从哪搬到哪、搬多少、下一个描述符在哪。这比MCU上的DMA复杂得多但核心思想还是“让数据搬运不占用计算单元”。8. 我个人的DMA调试心得调DMA这些年我最大的体会是DMA的问题九成出在配置上一成出在时序上。配置问题靠仔细查手册和寄存器能解决时序问题靠逻辑分析仪和示波器。别一上来就怀疑芯片有bug先把自己的配置逐位对一遍。另一个心得是先用阻塞方式验证外设基本功能再切DMA。很多人直接上DMA出了问题分不清是外设配置错了还是DMA配置错了。先用阻塞方式确认串口能收发、SPI能读写、ADC能采样再切DMA排查范围就小了一半。还有个小技巧在DMA传输完成中断里翻转一个GPIO用示波器看波形。这样能直观看到DMA的传输频率和间隔比打印日志方便得多。如果GPIO不翻转说明中断没进查中断使能和优先级如果翻转频率不对说明传输数量或触发源配错了。最后说个实际项目里的经验DMA缓冲区一定要用volatile修饰并且考虑对齐。编译器优化可能把缓冲区的读写优化掉volatile能防止这个问题。对齐是为了Cache操作和DMA访问效率不对齐的访问在某些平台上会触发硬件异常。这些细节在例程里往往不会体现但产品代码里必须考虑。