干嵌入式驱动这行,尤其是写过几年代码后你会发现,有些外设光靠CPU硬扛是扛不动的。尤其是串口、SPI、ADC这类高频数据搬运,中断风暴一来,CPU不是在存现场,就是在恢复现场,真正干活的CEU时间被榨得干干净净。我做驱动开发这几年,调DMA的经验有一大半都是从“数据乱飞”和“通道罢工”里攒出来的。这一期继续聊DMA,把原理讲透,把坑列出,顺带附上我自己实测的数据。
这篇适合几类人看:刚接触嵌入式驱动开发,对DMA还有“听说过但没用过”的模糊感;正在调串口DMA接收、SPI DMA传输或者ADC采样搬运,被数据错位/丢帧折磨的工程师;以及准备嵌入式面试、被问到“DMA为什么不是任何时候都快”这类问题时想答出点深度的人。我尽量把话说直接,不绕弯子。
1. DMA在驱动开发里的角色:不是加速器,而是替CPU扛活的人
1.1 先算一笔账:没有DMA时CPU在忙什么
很多人对DMA的第一个误解,就是觉得它能让数据“飞起来”。其实DMA不改变总线的物理带宽,它的核心价值在于让CPU腾出手。咱们算笔账你就明白了。
以最常见的串口UART接收为例,115200波特率,一个字节由起始位、8个数据位和停止位构成,总共10位。那么每秒能传 115200 / 10 = 11520 字节,换算一下就是每 86.8 微秒来一个字节。听起来不高是吧?但如果你不用DMA,每个字节都会触发一次接收中断。中断可不是免费的:进中断要压栈、读寄存器、清标志位、出中断要弹栈,再加上CPU在中断里做消息处理或者数据搬动,一个字节轻轻松松消耗掉几十个CPU周期。
这意味着什么?115200波特率下,CPU的大把时间都耗在响应中断上了。如果把波特率拉到460800甚至1M,每字节的间隔只有10微秒左右,你还得在这么短的时间内完成中断响应之外的业务逻辑,那CPU基本就满负荷空转了。我试过一个平台,1M波特率接收高速传感器数据,不开DMA的时候CPU占用率能到80%以上,开了DMA之后直接掉到10%以内。这就是DMA对系统最实在的价值:它让CPU从“数据搬运工”变成了“数据消费方”。
1.2 从寄存器视角看DMA的搬运逻辑
DMA之所以能自己搬数据,是因为它有一组完整的“自驱”寄存器。不管你是用哪个厂商的芯片,骨架都差不多:
- 源地址寄存器:告诉DMA从哪里读数据。
- 目的地址寄存器:告诉DMA把数据写到哪。
- 传输计数器:要搬多少个数据单位。
- 数据宽度:一次搬1字节、2字节还是4字节。
- 方向:外设到内存、内存到外设,还是内存到内存。
- 突发模式(Burst):一次总线操作搬多个数据单位。
DMA的工作循环非常朴素:启动之后,每个传输周期它从源地址读一次,写到目的地址,计数器减一,源和目的地址按模式自增或保持不变。计数器到零,触发完成中断。就这么一个简单的状态机,却帮CPU挡下了海量的逐字节搬运操作。
我经常跟新人打一个比方:你把DMA想象成一个快递分拣员。快递到了(外设产生数据),分拣员自己去取,规规矩矩放到传送带(内存缓冲区),攒够一批再通知老板(触发中断让CPU来处理)。没有分拣员的时候,老板就得一直站在门口,来一个快递自己搬一次。老板很忙,但干的活没什么技术含量。DMA就是那个帮你干杂活的员工。
1.3 一个关键的认知纠偏
DMA不是魔法,它也要占用系统总线。当DMA在进行大规模内存到内存搬运时,CPU访问内存的速度反而可能变慢,因为总线仲裁会给DMA让行。所以你在面试里如果有人问“DMA一定更快吗”,正确答案不是“是”,而是“不一定”。
DMA的本质是并行化:把CPU从数据搬移中释放出来,让它在DMA搬运的同时去算别的。这在实时系统里意义重大——数据流不断灌入,CPU还能稳定响应其他高优先级任务。理解了这一点,后面的选型、调优和踩坑就都有了解释的基础。
2. 串口DMA接收的设计细节与环形缓冲区
2.1 串口+DMA:嵌入式开发最常见的组合
串口是嵌入式系统里最常见的通信接口。GPS模块、蓝牙模组、4G模块、各类传感器,凡是带数据上报的,几乎都逃不开串口。但串口数据有一个特点:不定长,且经常在你不注意的时候来一包。
如果用传统中断方式,每个字节进一次中断,数据的拼包、协议头尾解析全都要塞在中断里做,代码很容易绕成一团。DMA接收天然适合这种场景:以DMA通道为单位,把一批数据连续搬到内存,CPU只需要在某一帧完成后去内存里读完整包即可。
这种组合下,驱动层真正要设计的核心不是DMA本身,而是数据怎么在内存里安全地流转。这里环形缓冲区就是最好的结构。
2.2 环形缓冲区:怎么设计才能少踩坑
环形缓冲区本质上是一块固定大小、逻辑上首尾相连的内存区域。为什么用环形?因为串口数据是源源不断的流,如果不用环形,每收一帧你都得把后面没处理的数据往前搬,搬着搬着就把CPU又搭进去了。
设计时几个关键点:
- 缓冲区大小选2的幂次方,比如256、512、1024。这样取模运算可以用掩码(size - 1)代替。
- 维护两个指针:读指针tail由消费方(主循环/解析任务)更新,写指针head由DMA中断更新。
- 判空条件:head == tail。判满条件:(head + 1) & mask == tail。留一个空位来区分满和空,这是环形缓冲区最经典的做法。
- volatile 修饰两个指针,防止编译器优化导致读写顺序错乱。
给一段我常用在MCU里的环形缓冲区基础实现:
#define RING_SIZE 512 #define RING_MASK (RING_SIZE - 1) typedef struct { uint8_t buf[RING_SIZE]; volatile uint16_t head; uint16_t tail; } ring_buf_t; int ring_write(ring_buf_t *rb, uint8_t byte) { uint16_t next = (rb->head + 1) & RING_MASK; if (next == rb->tail) { return -1; // 满了 } rb->buf[rb->head] = byte; rb->head = next; return 0; } int ring_read(ring_buf_t *rb, uint8_t *byte) { if (rb->head == rb->tail) { return -1; // 空了 } *byte = rb->buf[rb->tail]; rb->tail = (rb->tail + 1) & RING_MASK; return 0; }这段代码看着简单,但在DMA场景里,真正写入数据的是DMA硬件,而不是你手动调ring_write。DMA把串口收到的字节直接写到rb->buf里,传输过程中触发中断,中断里更新head即可。
2.3 判断“一帧完整数据已到”的三种方式
有了缓冲区,下一个问题就是——CPU什么时候知道该去处理了?换句话说,怎么判断一包数据收完了?我实际用过的方案有三类:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 空闲中断(IDLE) | UART检测到总线上有连续空闲时间,判定帧结束 | 及时性好,不依赖协议头尾 | 需要硬件支持IDLE中断 | 最常用,STM32等平台都支持 |
| 超时定时器 | 周期扫描,超过N毫秒没有新数据就当成一帧结束 | 不挑硬件,移植性强 | 实时性一般,定时器占一个资源 | 平台不支持IDLE时 |
| 周期读取计数器 | 在主循环定时读DMA剩余计数器,算出差值 | 实现最简单 | 容易漏数据,CPU占用和实时性之间难平衡 | 对实时性要求极低的场景 |
在实际工程里,我最推荐“IDLE中断 + DMA循环模式 + 环形缓冲区”的组合。实现逻辑是:DMA开着循环模式,让数据源源不断填到缓冲区;UART检测到空闲就触发一次IDLE中断,你在中断里做三件事——记录当前head的位置、计算本次新增的数据长度、置一个“有完整帧待处理”的标志。主循环看到标志再来取数据解析。这样中断开销极小,主循环只管消费,CPU负载非常可观地降下来了。
这里有个细节很多人第一次写容易翻车:DMA计数器在循环模式下是一直回绕的。你要算“新来了多少字节”,不能只看计数器剩多少,而是用上次记录的剩余值与当前剩余值做差值,再对缓冲区大小取模。写成代码就是:
uint32_t remain = DMA_GetCurrDataCounter(); uint32_t new_len = (last_remain - remain) & (RING_SIZE - 1); last_remain = remain;这个技巧能准确算出DMA在环形缓冲区里转了多少圈、新写了多少数据,同时又不需要去动DMA的计数器,避免了读写冲突。
2.4 串口DMA配置的完整步骤
以常见的MCU平台为例,我把配置串口DMA接收的步骤列一下。不同厂商函数名不一样,但流程是一样的:
- 使能UART和DMA控制器的时钟。
- 配置UART引脚、波特率、数据位/停止位/校验位,并使能UART接收DMA请求。
- 配置DMA通道:外设地址设为UART的数据寄存器DR地址;内存地址设为环形缓冲区的数组首地址;方向设为外设到内存;缓冲区大小设为环形缓冲区长度;缓存宽度按外设数据宽度设置,一般1字节。
- 使能DMA通道,让它开始循环搬运。
- 使能UART空闲中断(IDLE Interrupt)。
- 在IDLE中断回调里更新head并置帧标志,在主循环里消费数据。
配置的顺序有讲究:一定是先配好DMA通道,再开DMA传输,最后开UART接收相关的DMA请求。颠倒顺序可能导致UART先收到一两个字节但DMA还没就绪,然后这一两个字节直接进FIFO溢出丢掉了。调试时第一件事就是看DMA使能标志位有没有真正置上,而不是想当然认为代码顺序对了硬件就绪了。
3. 驱动接入DMA时最容易踩的五个坑
3.1 内存对齐问题
DMA传输对内存缓冲区地址有对齐要求,这不是“爱好”,而是硬件限制。很多DMA控制器要求源和目的地址按总线位宽对齐,比如4字节、8字节,有的平台甚至要求32字节对齐。不对齐的后果不是报错,而是传输数据错乱、总线错误,甚至直接卡死在某条指令上。
我遇到过最典型的场景:在结构体里嵌了一个数组当DMA缓冲区,结构体把数组的起始地址顶到了非对齐位置。代码编译运行都不报错,但DMA搬出来的数据时不时错几个字节,位置还不固定。排查了两天才发现是地址对齐问题。
解决方法很简单:定义缓冲区时用对齐属性明确声明。C语言里这么写:
uint8_t dma_buf[1024] __attribute__((aligned(32)));调试时也别懒,先打印缓冲区地址,确认地址取模结果是0,再怀疑别的。这几乎是DMA相关Bug的“第一嫌疑”。
3.2 Cache一致性问题
在带Cache的高性能平台上,DMA搬运和CPU读数据之间有个经典的看不见的陷阱:DMA把数据写进了DDR,但CPU读取的时候,它命中Cache拿到的是旧副本。反过来,CPU在缓冲区里准备好数据要发给外设,DMA去读DDR,读到的是没被刷回的脏数据。
这就叫Cache一致性问题。它不会在简单MCU上出现,但在干掉Cortex-A系列、或者带DCache的高端MCU平台上,只要数据量大到跨Cache Line,就很容易踩中。
解决思路有几条:
- Linux驱动里用dma_alloc_coherent申请一致内存,DMA和CPU共享同一份视图,避免一致性处理。
- 用dma_map_single配合dma_sync_single_for_cpu / dma_sync_single_for_device在传输前后做同步操作。
- 裸机/Cortex-M平台如果硬件支持,在DMA传输前后对缓冲区做Clean(刷Cache)和Invalidate(失效Cache)操作。
这个坑最阴的地方在于:小数据量测不出来,一上大数据流就间歇性出错。我的排查习惯是一旦发现“DMA数据偶尔错几个字节、位置随机、和时间强相关”,第一时间把buffer大小、地址和Cache Line对齐关系列出来,大概率能找到答案。
3.3 计数器清零与通道重启的时序
DMA通道不是随时都能改配置的。很多平台要求在修改传输计数器或源/目的地址之前,先禁能通道,修改完成后再重新使能。如果你偷懒,直接改寄存器,会出现一种很鬼畜的现象:明明配置了新的缓冲区地址,DMA却还在往旧地址搬数据,或者传输计数器清到一半又自动补了一个数。
我踩过的实际场景是调试I2C DMA时,每次传完一帧就重新配置下一帧,因为没先关通道,偶尔会出现“多搬了一次旧数据”的情况。抓I2C波形才发现DMA多发出了一个字节,这个字节还是上一次传输残留的数据。
标准操作流程是:
DMA_Channel_Disable(); DMA_SetConfig(...); // 改地址、计数器啥的 DMA_Channel_Enable();禁能和使能之间最好加个小的等待,确保硬件状态已经完全进入空闲,再写配置寄存器。这一步是很多偶发DMA异常的元凶,值得在驱动里固化成固有流程。
3.4 多通道DMA的优先级冲突
一个DMA控制器通常有多个通道。当多个通道同时请求总线时,优先级高的通道会插队,优先级低的通道可能一直等。如果你的系统里有内存到内存的大块搬运,它占着高优先级通道不放,串口DMA的接收就会被饿得慌,表现为数据传输慢、偶发超时。
优先级设计我现在的原则是:实时性强的外设,比如串口接收、ADC采集,给高优先级;内存到内存搬移、不怎么紧迫的数据复制,给低优先级。一定不要为了省心把全部通道优先级都配成最高,那样会引入新的不确定性。
另外,如果你的DMA控制器支持“通道锁定”或“FIFO模式”,优先开启FIFO可以吸收一部分总线延迟波动,让外设侧的数据不容易因为总线仲裁被拖丢。
3.5 中断里操作缓冲区导致数据撕裂
中断和主循环共享同一个DMA缓冲区时,最忌讳双方同时读写。比如主循环正从环形缓冲read半截,DMA完成中断正好把数据覆盖进来,读出来的一帧就是“撕裂”的。
我早年犯过这个错:在DMA中断里直接调ring_write往缓冲区塞数据,而主循环也在调ring_read消费。结果经常出现一帧数据头部是上一帧、尾部是这一帧的奇葩组合。排查耗时特别长。
后来固定下来的做法是:中断里只更新head指针和标志位,绝不直接在中断里往共享缓冲区里写数据(DMA硬件本身往缓冲区写是例外)。主循环负责消费数据、推进tail。这样生产者和消费者修改的不是同一块内存的同一段位置,撕裂问题自然就消失了。如果非要中断里写数据,那就把整个写过程放进临界区(关中断/加锁),但能避免还是尽量避免。
4. DMA性能实测:从传输速率到CPU占用率的量化对比
4.1 一组来之不易的实测数据
口说无凭,直接上一组我在一个中等性能MCU平台上做的实测数据。平台A:Cortex-M4核心,主频168MHz,串口1Mbps接收,数据包长度不定,平均每包64字节。平台B:同样平台,开了DMA接收 + IDLE中断,主循环只负责解析。
| 场景 | CPU占用率(粗测) | 每包数据丢包情况 |
|---|---|---|
| 1Mbps接收,无DMA,每字节中断 | 80%~90% | 偶发,波特率高时频繁 |
| 1Mbps接收,DMA+IDLE中断 | 10%~15% | 稳定连续接收不丢 |
| 115200接收,无DMA,每字节中断 | 30%左右 | 稳定 |
| 115200接收,DMA+IDLE中断 | 3%~5% | 稳定 |
测量方法不是理论推算,而是用一个GPIO在做中断处理时拉高,主循环里拉低,再用示波器测高电平的占空比。这是驱动开发里惯用的CPU负载采样法,比软件计时靠谱得多。
DMA接收带来的收益,在高波特率下是断崖式的。这组数据想说明一个结论:只要你的串口波特率超过某个阈值(我个人经验是57600),DMA就不仅是优化,而是刚需。
4.2 DMA测速的正确姿势
热搜词里有个“DMA测速”,很多人把DMA速度测出“负优化”的结果,然后怀疑自己配置有问题。实际上,大部分时候是测法不对。
常见的错误测法:
- 用几百字节的小数据块测,DMA建立传输的一次性开销占比太大,必然测出“DMA还没memcpy快”。
- 用同一个缓冲区做源和目的,数据互相踩踏,结果毫无意义。
- 计时工具精度不够,用毫秒级定时器去测微秒级的传输。
- 编译优化开着,memcpy被编译器直接优化成别的指令序列。
正确的测量姿势我总结几条:
- 数据量至少4KB起步。DMA真正体现价值的长距离搬运,小数据量它没有优势。
- 源和目的缓冲区都用独立内存,避开Cache一致性问题干扰。
- 用硬件定时器计时,分辨率至少到微秒级。或者干脆用示波器看GPIO脉宽。
- 对同一大小做至少5次测量,取中位数或平均值。
我一组实测的数据大概这样:
| 传输大小 | DMA耗时 | CPU memcpy耗时 |
|---|---|---|
| 64字节 | 约3us | 约2us |
| 4KB | 约80us | 约300us |
| 32KB | 约500us | 约2500us |
结论很明显:数据量小的时候,DMA反而慢;数据量一大,DMA的优势急剧拉大。如果你在面试里被问到这道题,核心答法就是:DMA是为了并行化CPU,不是为了比单次搬移快。它建立了传输链路,这部分固定开销在小数据量Dominates;等到传输时间远大于链路建立和时间仲裁开销,DMA把CPU解放出来干别的,系统整体吞吐就上去了。
4.3 一块DMA测速代码的骨架
void dma_speed_test(void) { uint32_t src[8192]; uint32_t dst[8192]; /* 准备数据 */ for (int i = 0; i < 8192; i++) { src[i] = i; } /* 开启硬件定时器 */ timer_start(); dma_memcpy(dst, src, sizeof(src)); uint32_t elapsed = timer_stop(); /* 校验 */ if (memcmp(src, dst, sizeof(src)) != 0) { printf("data mismatch\n"); } else { printf("dma copy %u bytes in %u us\n", (uint32_t)sizeof(src), elapsed); } }注意校验数据一致性这步不能省。很多测速代码忽略了数据校验,结果速度测出来很漂亮,数据其实全错位了。
5. 进阶场景:多通道DMA、Memory-to-Memory与cache一致性
5.1 ADC多通道连续采样与DMA
ADC连续采样是DMA的典型用武之地。比如多通道ADC采集,如果不用DMA,每次转换结束都进中断读寄存器;用DMA的话,ADC转换完成会自动把结果搬到内存,转换很多个通道后只进一次中断。
我在实际项目里用过AD7606这类多通道同步采样芯片,和主控之间走并口/SPI,数据量一次就是多通道的同步结果,用DMA搬数据几乎是必然选择。这里要注意的是缓冲区里多通道数据的排列方式。一般ADC多通道DMA会按通道号顺序排列数据,为了处理方便,可以把缓冲区定义成二维数组,第一维是采样批次,第二维是通道编号。这样读到内存里的数据可以直接按通道索引,不用自己偏移计算。
缓冲区大小也值得花心思:做阈值判断的场景,缓冲区可能只需要几批数据;做FFT等频域分析的,缓冲区大小必须覆盖一个完整窗口的采样点,而且最好是2的幂,便于后续做算法优化。
5.2 TIM DMA Burst应用
定时器触发DMA,这个组合在驱动开发里是个很高级的玩法。STM32的TIM+DMA Burst能够实现“定时器事件触发DMA搬运预置数据到定时器寄存器”,比如精确控制PWM输出波形、步进电机离散控制、多路PWM相位调制。
说白了吧:你预置一段内存,里面存着一系列控制参数,定时器每触发一次事件,DMA就自动把下一个参数送到定时器寄存器。CPU只需要最开始配置好整个表,运行起来后完全不用干预。
我做过多路PWM分段控制的项目,要求每一段PWM的占空比和频率都要变化。如果用传统方式,就得在中断里不断写寄存器,CPU被绑死。后来改成定时器+DMA Burst,把整个波形序列预置到内存数组里,DMA逐个送到CCR寄存器,CPU只是定时器期间该干嘛干嘛去。代码复杂度降低,波形切换的精确度也上了一个档次。
5.3 Memory-to-Memory 传输的正确打开方式
内存到内存的DMA,听起来最“简单粗暴”——把一个缓冲区内容搬到另一个。但它有几个容易忽略的坑:
- 部分平台DMA的循环模式不支持mem2mem,配置了也不会生效,得先看参考手册的通道能力位。
- 传输过程中源地址数据不允许被CPU改写。如果你边搬边改源缓冲区,搬出来的数据可能既有旧值又有新值,混成一团。
- 源和目的缓冲区都要做Cache一致性处理。Linux驱动里该用dma_map_single就老老实实调用。
实际用到mem2mem的场景包括:显存刷新、大块数据摆渡、双缓冲交替更新。这些场景的共同点是数据量大、不要求实时、周期性重复。小数据量的内存复制没必要用DMA,这是我一贯的主张,否则净给总线添乱。
5.4 嵌入式Linux下的DMA驱动怎么接
在Linux环境里做DMA驱动,我强烈建议通过内核的dmaengine框架来操作,而不是直接在驱动里映射寄存器操作DMA。dmaengine帮你屏蔽了不同SoC的DMA控制器差异,也使你的驱动在代码审查里更容易被接受。
基本调用流程是这样:
dma_cap_mask_t mask; dma_cap_zero(mask); dma_cap_set(DMA_SLAVE, mask); struct dma_chan *chan = dma_request_channel(mask, NULL, NULL); struct dma_slave_config cfg = {0}; cfg.direction = DMA_DEV_TO_MEM; cfg.src_addr = (phys_addr_t)uart_phys_base; cfg.src_addr_width = DMA_SLAVE_BUSWIDTH_1_BYTE; dmaengine_slave_config(chan, &cfg); struct dma_async_tx_descriptor *desc; desc = dmaengine_prep_slave_single(chan, dma_map_single(dev, buf, len, DMA_FROM_DEVICE), len, DMA_DEV_TO_MEM, 0); dmaengine_submit(desc); dma_async_issue_pending(chan);关键的坑在设备树和物理地址这一层。驱动里拿到的buf是虚拟地址,但DMA硬件需要物理地址,所以dma_map_single这一步绝对不能省,返回的dma_addr才是DMA能吃的地址。别觉得自己驱动里printf出来的buf指针很大很整齐就可以直接给寄存器用,那是虚拟地址,硬件看到的是另一套。
对于需要长期保持映射的缓冲区,申请的时候就该用dma_alloc_coherent,一次性把一致性处理好;如果只是临时传输,就用dma_map_single/sync系列做同步。这两种用法的选择,直接影响DMA数据在Cache一致性问题上的表现。
还有一个容易忽略的流程细节:描述符用完要记得用dma_unmap_single做反映射,否则内存浪费和Cache状态紊乱会在长时间运行后集中爆发。这个问题在压力测试阶段尤其明显,表现就是系统跑着跑着DMA突然不干活了。
写了这么多,我在实际调试DMA过程中最大的体会是:解决问题最快的方式,不是看一行代码,而是先画清楚数据流。源在哪、目的在哪、谁触发、谁搬运、谁消费,这张图一旦画明白,至少能避开一半以上的坑。这个习惯让我的DMA相关Bug排查速度提升了几倍。
最后再分享一个小技巧:在DMA缓冲区头部加一个固定魔数(比如0xAA55),每次解析前校验。这样DMA数据错位或者缓冲区越界时,能第一时间从魔数位置暴露问题,定位会轻松很多。做驱动开发,有些问题是工具查不出来的,得靠自己在数据结构里埋“探针”。