首页 / 资讯中心 / 文章详情

DMA原理与实战:从串口接收到SPI采集的嵌入式驱动指南

DMA原理与实战:从串口接收到SPI采集的嵌入式驱动指南 ★ FEATURED ARTICLE
这期聊DMA。做嵌入式驱动开发这几年DMA是我觉得最值得早点搞明白的外设之一。先说个直白的数据串口115200波特率一个字节触发一次接收中断的话每秒会打断CPU一万多次如果用SPI以1MHz的速率去读AD7606这类多通道ADC光轮流搬寄存器就能把CPU占用拉到非常难看。数据量一大采集、通信、控制三件事就开始互相抢时间丢数据的丢数据波形抖动的抖动。DMA要解决的就是这个根子上的问题——数据搬运不占CPU执行时间CPU只管算搬砖的活交给专用硬件。这篇“嵌入式驱动开发经验”第6期我把DMA从原理、串口接收、SPI采集到TIM触发的Burst模式再到测速和踩坑完整梳理一遍。适合正在调驱动、被中断风暴和丢数据折磨的开发者参考也适合刚接触DMA、想系统理解这套机制的朋友。文章里用到的代码以STM32为主DMA的设计思路上各家MCU大同小异。把这套东西吃透换到别的平台也就是查寄存器和映射表的事。下面直接进入正题。1. 先把DMA这颗“搬运工”的原理讲透1.1 DMA到底解决了什么问题DMA全称Direct Memory Access直接存储器访问。一个外设要跟内存交换数据传统做法是让CPU一趟一趟地搬。拿最经典的串口接收举例每收到一个字节硬件会把数据放进接收寄存器然后扔一个中断给你CPU进中断、读寄存器、把数据写进内存缓冲区、退出中断一套流程跑下来几十个周期就没了。波特率低的时候还好串口一上460800、921600中断频率成倍往上翻时间全都耗在处理中断上了。更麻烦的是如果CPU正忙着跑别的更高优先级的任务中断响应不及时串口接收寄存器里又来了新数据旧数据就硬生生被覆盖丢数据就是这么来的。DMA的思路是把“搬运”这件事从CPU身上摘掉。DMA控制器本身就是一个有独立总线访问能力的硬件模块你给它配好源地址、目的地址、传输长度和触发源它就等着外设举手请求请求一到就自动搬数据搬完了再通过中断告诉你一句“干完了”。CPU从头到尾不用碰数据只需要事后处理。这就像一个装修现场业主是CPU他既要在旁边监工又被逼着自己搬砖。DMA就是那个专门雇来的搬运工你把物料清单给他他搬到指定位置再喊你验收中间你爱干嘛干嘛。要注意DMA不占CPU执行时间不代表不占总线。大多数MCU里DMA和CPU共享总线和内存控制器DMA高频搬运时会跟CPU抢总线周期只是这个抢的过程由硬件仲裁CPU的流水线不会被打断指令照常执行只有访问内存的那一瞬间可能被插入等待。带宽富余时基本无感高负载时会有一些性能下降这个心里有数就好。1.2 驱动开发中最常用的三类DMA搬运场景驱动开发里DMA的用法翻来覆去就三种方向我习惯用表格把场景和典型应用列出来查起来也方便。传输方向典型外设驱动应用场景通常配合的中断外设到内存串口、SPI、I2S、ADC、DCMI接收串口数据、采集AD7606等多通道ADC、抓取摄像头图像传输完成中断、接收空闲中断、半传输中断内存到外设串口、SPI、DAC、定时器DMA发送串口数据、DAC输出任意波形、SPI发送显示数据传输完成中断内存到内存DMA控制器自带M2M模式高速缓冲区拷贝、图像数据搬移传输完成中断从实际项目占比看串口收发和SPI采集是DMA用得最多的两个地方。内存到内存的模式在STM32上从F1到H7都支持但它不是所有场景都比memcpy快后面我会专门说。先把这三类方向搞清楚你再去翻自己板子的DMA寄存器很多配置项都能对上号方向定了、地址定了、数据宽度定了、传输模式定了剩下的就是启动和等中断。2. 串口DMA接收从丢数据到空闲中断的实战改造2.1 为什么普通中断收串口扛不住我见过很多项目刚开始用串口都是轮询或者逐字节中断跑着跑着就发现高负载下丢数据。轮询的毛病很明显——CPU要反复去查接收标志位大部分时间都在空转别的任务被拖着走。逐字节中断好一些但中断处理本身有开销加上串口波特率越来越高每字节一次中断的节奏会让系统非常紧张。尤其当你的主循环里有复杂算法、长时间Flash操作或者别的更高优先级中断时接收中断稍微晚一点响应硬件FIFO又浅新数据直接把旧数据顶掉这是做串口驱动最容易翻车的一个点。DMA接收的天然优势就在这里数据往内存里搬的过程CPU根本没参与。哪怕CPU正卡在某个临界区里出不来DMA也在按自己的节奏把串口数据写进缓冲区。只要缓冲区不被写满回绕理论上一个字节都不会丢。但DMA接收有一个绕不开的问题DMA只会傻乎乎地搬数据它不知道对方发的一帧数据什么时候结束。所以驱动里必须有一种机制来切帧。业界用下来最稳定的是“DMA循环模式串口空闲中断IDLE”。串口总线在一段时间内没有新数据、处于空闲状态时硬件会置起IDLE标志并产生中断这时候就说明对方的一帧发送完了我们在中断里读一下DMA还剩下多少计数没搬用缓冲区大小减掉剩下的就是本次新收到的数据长度。这套思路在很多MCU的串口驱动里都是标配。2.2 串口DMA驱动程序的关键配置以STM32 HAL库为例我贴一段比较典型的DMA循环接收配置。先把DMA通道设置好再挂在串口句柄上最后启动接收。static uint8_t rx_buf[1024]; DMA_HandleTypeDef hdma_usart1_rx; // 配置DMA接收通道 __HAL_DMA_DISABLE(hdma_usart1_rx); 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; // 循环模式接收数据自动回绕 HAL_DMA_Init(hdma_usart1_rx); // 把DMA挂到串口1的DMA接收句柄上 __HAL_LINKDMA(huart1, hdmarx, hdma_usart1_rx); // 启动DMA接收 HAL_UART_Receive_DMA(huart1, rx_buf, 1024);中断处理长得这样void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t len 1024 - remain; // 本次新接收的数据长度 process_rx_data(rx_buf, len); // 解析处理 } HAL_UART_IRQHandler(huart1); }这段代码有个隐藏的坑如果一帧数据正好跨过了缓冲区末尾比如缓冲区起点100、一帧数据长200数据分成前一段和后一段这直接按rx_buf首地址处理一整段就会读错。严谨的写法是判断最新数据终点有没有越过缓冲区尾如果越过了就拆成两段处理一段是缓冲区末尾到缓冲区尾另一段从缓冲区头开始。实际工程里我更推荐的做法是在DMA缓冲区之上再封装一层环形队列。DMA只负责把串口数据不断写进底层缓冲区IDLE中断里只更新一个“写指针”不去做协议解析主循环或者专门的解析任务按自己的节奏消费数据。这样即使CPU忙到几毫秒后才去处理环形队列也能把数据暂存下来不容易因为处理不及时而丢数据。2.3 接收端用DMA发送端需要改发送方式吗这个热词对应的问题我经常被问到我在设备A上用DMA接收串口数据那么设备B作为发送端是不是也要改成DMA发送结论很明确不需要。DMA接收只是接收端的内部实现方式对发送端完全透明。对方的串口发送用轮询、中断还是DMA发到总线上的都是同样的电平时序你的DMA接收端一视同仁。不过有几个点要提醒你。第一IDLE空闲中断依赖总线持续一段时间没有数据如果发送端用DMA连续不停地把一个大文件以字节流形式发过来帧与帧之间的间隔可能比空闲检测的时间还短这时候“一帧结束”的判定就失效了数据会变成一大块连续的流。第二如果你的发送端在帧中间故意拖很久不发比如发完一个字节等几百微秒再发下一个接收端可能把这个间隙误判为帧结束。这两个问题本质上属于协议时序问题不是DMA本身的锅但你要清楚自己的“切帧逻辑”依赖什么条件避免在项目里想当然。3. SPI DMA为什么采集类驱动离不开它以AD7606为例3.1 高频多通道采集的瓶颈AD7606是16位8通道同步采样ADC在电力监测、振动采集这类应用里很常见。它支持并行和SPI两种接口SPI接口节省引脚但代价是CPU读数据更忙。假设采样率50kHz、8通道全开就意味着每秒要读40万个16位数据。如果用SPI轮询方式读每读一个16位数据都要等SPI外设把数据收完CPU要一直守在旁边等标志位期间什么别的事都干不了。更要命的是AD7606的转换结果一直在更新下一次采样转换完成前如果没把本次数据读走读到的可能已经是新的转换值了你辛辛苦苦采到的数据直接被覆盖。这时候SPI DMA的优势就体现出来了。你可以把“读8通道状态字”整件事打包成一个DMA任务当ADC转换完成、BUSY引脚拉低的瞬间利用外部中断触发DMA去SPI读取指定长度的数据DMA会连续把SPI接收寄存器里的数据搬到内存缓冲区搬完再进传输完成中断做解析。整个读取过程CPU只是个旁观者只在开始和结束时各被打断一次。3.2 STM32 AD7606 SPI DMA驱动怎么搭硬件连接大致是这样AD7606的CONVST引脚用来启动一次转换BUSY引脚接到MCU的一个外部中断输入SPI接口接MCU的SPI主机。驱动流程分四步初始化SPI为主机模式数据宽度16位时钟极性和相位按AD7606手册要求设置一般是CPOL0、CPHA1。配置SPI对应的DMA接收通道方向为外设到内存内存地址指向一个长度9的16位数组8个通道数据加1个状态字。主循环里拉低再拉高CONVST启动一次转换。等待BUSY下降沿进入外部中断在中断里调用SPI DMA接收函数启动一次自动搬运DMA传输完成中断里解析数据。代码的关键片段uint16_t adc_buffer[9]; volatile uint8_t adc_done 0; // BUSY下降沿外部中断 void EXTI9_5_IRQHandler(void) { if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_8) ! RESET) { __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_8); // 启动一次SPI DMA接收自动读9个16位数据 HAL_SPI_Receive_DMA(hspi2, (uint8_t *)adc_buffer, 9); } } // DMA传输完成回调 void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI2) { adc_done 1; // 置标志主循环解析 } }这里有一个经验要提醒如果ADC的采样率很高每次BUSY下降沿都要启动一次新的DMA接收但DMA在上一次传输还没完成时是不能随便重新配置的。你必须在启动前检查DMA是否处于空闲状态或者在DMA传输完成回调里做开启下一次接收的联动不要让中断里反复启动同一个通道否则可能触发DMA配置错误或数据错乱。3.3 continuous requests和循环模式在采集里的实际作用“continuous requests”这个热词在STM32的DMA配置里很常见在CubeMX的DMA设置界面能看到一个“DMA Continuous Requests”选项。直译过来是连续请求模式它跟循环模式往往是搭配使用的。我把这个配置说人话一点普通的DMA传输搬完你设定的长度之后就会停下来外设再有请求也不管了要CPU重新启动才能继续。而当你配置了循环模式circular mode时DMA搬完一批数据后会自动把地址和计数寄存器恢复到初始值继续响应外设的下一个请求。很多MCU里这个循环模式生效后DMA就成了一个“常驻搬运工”只要外设触发它就搬从接收端看就是连续的CPU不用反复插手。这在ADC采集场景里特别好用。如果ADC由定时器触发采样定时器每个更新事件产生一次DMA请求DMA自动把ADC转换结果搬进内存缓冲区缓冲里永远是最新的一批采样数据。CPU要做的事就只剩下定期去缓冲区里取数或者等DMA半满中断、满中断时批量处理。配合循环缓冲采集系统的吞吐量能高一大截CPU负载反而降下来了。4. 进阶玩法TIM触发DMA与DMA Burst以及那个“同道”坑4.1 为什么要让定时器去指挥DMA前面讲的串口和SPI DMA触发源都是外设自己来数据了才搬。但有些场景需要DMA按一个精确的时间节奏去搬运比如DAC输出正弦波、PWM步进调光、周期性读取外部采样数据。如果让CPU在定时中断里一个点一个点地写寄存器中断响应延迟会带来抖动波形频率高了以后CPU负载也非常难看。让TIM去触发DMA的思路是定时器的更新事件本身就产生一个DMA请求DMA收到请求就搬运一个数据到目标外设的寄存器。典型例子是用TIMDMADAC输出正弦波。内存里放一张正弦波形表DMA通道配置成内存到DAC数据寄存器DMA工作模式设为循环定时器按你想要的波形频率产生更新事件每个更新事件触发DMA搬一个样本到DAC。整个过程中CPU只在最开始启动了一下定时器和DMA后面就什么都不用管了DAC持续输出稳定的正弦波波形频率和相位都能做得非常准。4.2 TIM DMA Burst到底解决了什么问题DMA单个数据搬运一次一个“数据宽度”而DMA Burst突发模式允许一次DMA请求触发后连续搬运多个数据。这个能力搬到定时器上很有意思。STM32的定时器有一组特殊寄存器可以配置DMA突发请求的起始地址DBA和传输长度DBL配合普通DMA通道一次更新事件就能把缓冲区里连续多个值分别写入定时器的不同寄存器。举个实际例子。做变频控制时经常要同时更新ARR和CCR1、CCR2也就是同时改变PWM周期和几个通道的占空比。几个寄存器在逻辑上是联动的如果CPU里一条一条地写写完ARR再写CCR1中间那几十个纳秒里PWM的输出状态可能是“新周期旧占空比”的混搭状态某些应用里会看到毛刺或波形跳变。用TIM DMA Burst定时器更新事件一来DMA把内存里排好的ARR、CCR1、CCR2的值连续写入定时器几个寄存器几乎在同一个硬件窗口内完成更新中间态被压缩到可以忽略波形切换干净利落。配置上的要点就两个一个是定时器端的DMAR寄存器设置好DBA和DBL另一个是DMA通道的传输方向为内存到外设内存数据宽度和数量要对上。第一次用的时候建议在数据缓冲区里放几个固定值用逻辑分析仪看PWM波形变化确认搬移顺序没有错位再上真正的波形数据。4.3 STM32F103的“DMA同道同通道”是什么坑有一个热词是“stm32f103c8t6 dma 同道”我猜是输入法把“同通道”打成了“同道”。F103的DMA使用上确实有一个非常典型的通道冲突问题。DMA1和DMA2各自有7个通道每个通道会被多个外设请求复用但同一时刻一个通道只能服务于一个外设请求。你在初始化时给某个通道设定了外设请求ID这个通道就归这个外设用了如果另一个外设也映射到了同一个通道并且你也把它配置启用了两边就会打架轻则数据传输错乱重则DMA通道配置被互相覆盖中断完全乱套。比如在F103里USART1_TX的DMA请求和SPI2_RX有可能是同一个DMA通道的复用映射两个外设同时开就会出问题。解决办法就两个方向一是查芯片参考手册里的DMA请求映射表给不同外设分配不同的DMA通道避开冲突二是如果外设多、通道确实不够就做分时复用用DMA的通道禁用/重新配置来实现切换但要严格控制切换时机确保上一个外设的传输完成中断到位后再切。这里我可太有感触了。F103是很多人入门的第一块板子串口DMA、SPI DMA玩熟了之后开始同时开多个外设第一个撞墙的基本都是DMA通道冲突。我的建议是动手配之前先把映射表抄在一张纸上把外设、通道、中断号全部列出来再排别直接在代码里瞎试。4.4 内存到内存DMA的适用边界内存到内存DMA是一次把一块内存的数据拷贝到另一块内存不需要外设请求启动后会自动搬完。不少人在需要高速拷贝的时候会想到它但我实际用下来它在某些场景里可能不如编译优化后的memcpy。原因是DMA搬数据也要走总线每次读写内存都有固定开销如果数据量不是很大DMA配置和启动的固定成本会摊薄收益而memcpy在编译器和CPU缓存配合下可以做字对齐批量拷贝速度并不差。只有数据量大到几百KB甚至上MB、而且CPU还要同时处理别的关键任务时DMA M2M的优势才真正体现出来。所以我的习惯是大块数据传输并且明确不想占CPU用DMA小数据里拷贝老老实实用memcpy简单可靠。在带缓存的高端MCU上内存到内存DMA还要额外考虑缓存一致性问题数据搬完之后CPU从缓存里读到可能是旧数据必须做cache invalidate这是后话。5. DMA性能测速与常见问题排查5.1 怎么正经地给DMA做一次测速“dma测速”这个热词搜出来一堆东西里面有PC端的工具也有嵌入式的测试方法。嵌入式里给DMA测速我推荐两种容易落地的方法。第一种是内存到内存DMA测速。准备两个大缓冲区把一个缓冲区的内容通过DMA搬到另一个在启动前用定时器或者ARM的DWT计数器记下时间戳等DMA传输完成中断标志置位后再记一次两者相减就是搬运时间。已知搬运字节数除以时间得到的就是实测吞吐率。DWT周期计数器的用法很多芯片都有我贴个思路uint32_t t0, t1; t0 DWT-CYCCNT; HAL_DMA_Start(hdma, src, dst, len); // 等待DMA完成标志 while (HAL_DMA_GetState(hdma) ! HAL_DMA_STATE_READY); t1 DWT-CYCCNT; // 每秒吞吐率 len字节 / ((t1 - t0) / 系统主频)第二种是外设DMA测速比如用串口DMA接收一个固定长度的文件记录整个接收耗时换算成波特率或有效吞吐率。这样测出来的数据更贴近实际应用。要注意只测DMA本身意义不大因为实际吞吐率受限于外设接口速率、FIFO深度、内存访问仲裁等最好把整个链路算进去。测速结果要分清楚理论带宽和实际吞吐的区别。DMA理论速率可以很高但实际总会低于理论值。总线竞争、内存访问等待、外设时钟频率这些都是客观限制实测低一点很正常。如果你测出来的值低到离谱那才需要怀疑配置问题。5.2 DMA常见问题速查表我把自己实际遇到过的DMA问题整理成了一张速查表遇到问题先对着表查一圈能省很多时间。现象可能原因处理建议DMA中断一直不进循环模式没打开、通道配置被覆盖、外设DMA请求没使能检查Mode是否为CIRC确认通道没有冲突打开外设侧的DMA请求位串口偶尔丢一帧IDLE中断处理太久、缓冲区太小用环形队列让主循环消费数据增大接收缓冲SPI DMA读到的数据全是FF或0SPI相位极性不对、DMA数据宽度不匹配核对AD7606等外设手册的CPOL/CPHA确认是8位还是16位宽度启用DMA后其它外设异常DMA通道与外设请求映射冲突查参考手册的DMA请求映射表换通道内存到内存拷贝结果不对数据宽度与源/目标类型不一致保证PeriphDataAlignment与MemDataAlignment匹配测速结果为零统计区间没覆盖完整传输、DMA没跑完用DMA完成中断做结束点统计总字节数还有一个容易忽略的坑是缓存一致性。带D-Cache的MCU或SoC上DMA搬运完数据后CPU从Cache里读到的可能是旧数据。遇到这种平台必须在DMA完成中断里显式做Cache的clean或invalidate操作比如Cortex-M7上的SCB_InvalidateDCache_by_Addr。这个坑在普通M3/M4上不存在但一上M7或者A系列芯片就容易翻车排查思路完全不一样。5.3 DMA“配了却不动”的排查流程“dma测速失败代码”这种词条我不太确定指的是哪套工具但在嵌入式里最常见的失败表现是DMA通道初始化没有报错程序跑起来了可是数据就是不搬。我提供一个排查顺序照着做基本能定位到问题。第一步检查外设侧的DMA请求有没有打开。这一步我踩过太多次。串口要开USART_CR3里的DMAR和DMAT位SPI要打开对应DMA请求位定时器要配置DMAR相关寄存器。很多人只配了DMA控制器外设根本没发出请求DMA当然不动。第二步确认DMA通道的使能位已经置位并且CNDTR寄存器是你要传输的长度。如果CNDTR已经是0说明传输已完成了只是你没看对标志位。第三步看DMA的中断标志和错误标志。TEIF传输错误、HTIF半传输、TCIF传输完成这些标志位都能在DMA状态寄存器里读到。如果出现了传输错误中断大概率是内存地址配置不对比如目标地址越界了。第四步用调试器或者逻辑分析仪看外设数据线上到底有没有数据。数据线上没有信号说明上游没数据DMA再牛也没东西可搬。这套流程我几乎每次调新板子的DMA都会走一遍虽然笨但有效。很多“测速失败”其实是DMA压根没在一次正确传输中跑完测速当然测不出结果。6. 几个值得单独说一嘴的经验6.1 半传输中断的妙用DMA在传输长度过半时会触发半传输中断HT这个中断在实际工程里非常好用。拿串口DMA接收举例如果配置了循环模式可以打开半传输中断和传输完成中断前半段数据满了处理前半段后半段满了处理后半段等于把缓冲区砍成两个交替使用的半区。只要处理速度够快这就是一个天然的“双缓冲”机制CPU解析数据外设继续接收互不干扰。我第一次用这个技巧是在一个4G模组的数据下载项目里模组一直往串口吐数据流量大且没有明确的帧边界。用半传输中断把缓冲区切成两段轮流消费配合DMA循环模式跑满串口带宽也不丢数据。这个思路希望能帮到正在做连续大数据流处理的朋友。6.2 DMA缓冲区大小和内存对齐DMA缓冲区的大小最好设置成2的幂比如256、1024、4096一来环形队列取余数方便二来对齐地址也更容易满足DMA外设对内存地址的要求。很多DMA外设要求目标地址按数据宽度对齐比如16位传输要求半字对齐32位传输要求字对齐。你要是不小心让缓冲区地址落在奇数地址上轻则效率下降重则直接触发传输错误。另外缓冲区尽量放在连续的内存段上。如果项目里有MPU记得给DMA缓冲区设置好MPU属性和Cache策略别让缓冲区被MPU默认规则挡在不可访问区域里。7. 一个容易忽略的协作问题DMA与中断优先级DMA传输完成中断的优先级设置很多人随手设了个一样大的就完事结果在高负载下出了各种奇奇怪怪的问题。DMA把数据搬到内存之后必须靠中断通知CPU去取数据。如果这个中断的优先级太低别的中断频繁插队DMA缓冲区的数据就会被下一轮DMA传输覆盖你取出来的是残缺数据。反过来如果DMA中断优先级太高频繁打断低优先级任务又会影响系统的整体实时性。我的做法是把DMA完成中断的优先级设得比普通外设中断高一点但低于系统最关键的实时中断比如电机控制里的PWM中断。这样既保证DMA数据能被及时消费又不干扰最紧要的控制环路。串口DMA的中断里只做“取数据推队列”的轻量动作绝不做耗时解析所有协议解析放主循环或专用任务里做这个协作节奏定好了系统才稳。从入行到现在我前前后后调过的DMA通道少说也有二十来个。说真的DMA这种外设光看手册是学不会的真正理解它是在一个“CPU忙到串口收烂数据、但DMA一帧不丢”的项目里。优先级怎么排、通道怎么选、中断怎么配合、循环缓冲怎么设计这些事亲自动手配过一遍踩过几个坑才算真正会用它。如果你现在刚开始接触DMA建议先拿一块板子做串口DMA回环实验把空闲中断和计数器的关系彻底搞明白再尝试SPI采集和TIM触发一步一步来你会发现自己对驱动开发的整个理解都会上一个台阶。
阅读完成 · 觉得有帮助?
咨询建站