先把CPU从数据搬运里解放出来——这是我做DMA初始化最大的体会。很多人觉得DMA初始化就是照着参考代码配几个寄存器,能跑就行。但等到外设速率一提,或者传输长度一变,莫名丢数据、死等中断、总线冲突全来了,回头排查才发现根子都在初始化阶段埋下了。这篇文章把我梳理的DMA初始化流程、每个配置项背后的硬件行为、以及实测中踩过的坑完整写出来,适合刚接触嵌入式的开发者,也适合那些DMA用的不少但没系统整理过初始化逻辑的老手。
1. 为什么DMA初始化值得单独写一篇:它解决的问题比想象中大
1.1 CPU中断搬运数据,实际开销在哪
先看一个真实的场景。某项目里,我做一块数据采集板,两路ADC连续采样,采样率提升到几百k的时候,CPU占用率直接冲到80%以上。进中断看了下,中断服务函数里没有什么复杂计算,绝大部分时间消耗在从外设数据寄存器往内存数组拷贝数据上。一次拷贝不过几个字节,但采样的频率高,中断连续不断触发,CPU根本没有时间去做业务逻辑。
这就是典型的"数据搬运"占用了CPU时间。搬运本身不产生价值,但对于没有DMA的系统,这就是唯一的办法。打个比方,一个本来要做焊接的工人,每天被叫去仓库搬货,搬完货回来刚拿起焊枪又得去搬下一趟,产量自然上不去。
DMA的作用就是把"搬货"这个活单独分出去,雇一个专职搬运工,工人专心焊接。搬运工只负责一件事:把数据从指定地点搬到另一个指定地点,搬完通知一声,继续搬下一趟。
1.2 DMA的简化模型和类比
DMA控制器的工作模型,用一句话概括是:"从哪来,到哪去,搬多少,怎么知道搬完。"
对应到硬件上就是四个核心要素:
- 源地址(Source Address):数据从哪个寄存器或内存地址搬出。
- 目的地址(Destination Address):数据搬到哪个地址。
- 传输长度(Transfer Length):一共搬多少个数据单元。
- 传输状态反馈(Status/Interrupt):一次搬运是搬完了还是一半,要不要通知CPU。
初始化DMA,本质上就是把这四件事告诉控制器,然后再回答一个问题:什么时候开始搬。
这个模型很简单,但实际初始化时容易出错的地方恰恰都在细节上:地址是外设寄存器还是内存;长度单位是字节还是字;传完是产生中断还是置一个标志位;外设什么时候发出DMA请求。这些细节处理不好,模型再简单也会翻车。
1.3 并不是只有大批量传输才值得用DMA
很多开发者有一个误区,觉得数据量小的传输不值得用DMA,初始化DMA的代码比中断拷贝还多,纯属浪费。
实际上初始化是一次性的成本,而中断搬运是每次传输都有的成本。一旦使能了DMA,后续外设每准备好一个数据,硬件自动搬运,CPU全程不参与。对于采样、通信这类持续性、周期性产生的数据流,哪怕每次只有一个数据单元,累计节省的中断开销也非常可观。
我实际测试过同样一个外设,用中断搬运和DMA搬运两种方式跑同样的采样率,DMA方案的CPU占用率从60%多降到5%以下。所以判断是否值得用DMA,不是看单次传输大小,而是看传输频率和持续时长。
2. 初始化之前,先回答三个问题
2.1 请求线:外设怎么告诉DMA"我有数据了"
DMA不会凭空开始搬运,它需要一个"开工信号"。这个信号一般由外设发出,叫作DMA请求。比如串口收到一个字节,会产生一个请求;ADC转换完成,也会产生一个请求。DMA控制器收到请求后,就按预先配置好的源地址、目的地址执行一次搬运。
这个机制在初始化时对应的问题就是:外设请求线有没有正确连到DMA通道上。
不同芯片的DMA控制器通道数量有限,比如有些是8个通道,有些是16个通道,每个通道可以接受多个外设的请求,但同一时刻只能服务一个外设。初始化之前必须查清楚目标外设的DMA请求映射到了哪个通道,这个信息一般在外设参考手册的DMA请求映射表里。
如果不小心把串口的DMA请求配到了ADC的通道上,结果是数据来源混乱,串口收到的数据被当成ADC采样值存进内存,程序不会报错,但数据完全是错的。这类问题的排查成本极高,因为看起来哪哪都是正常的,只有数据不对。
2.2 目标内存:Buffer地址与对齐的约束
第二个问题更隐蔽:数据搬到哪里的内存去。
看起来这是最简单的问题——定义一个数组,把地址填进寄存器不就行了。实际上这里有一个关键约束,很多芯片要求DMA访问的目标内存地址满足对齐要求,常见的是4字节对齐,有的还要求按突发传输长度对齐。
假设传输长度配置为32字节突发,Buffer首地址如果不是4字节对齐,轻则传输性能下降,重则产生总线错误。我做过一次试验,把一个数组首地址故意改成不对齐的值,DMA配置看上去没问题,但跑起来之后部分数据块错位,接收端解出来的帧全是乱的。排查了很久才发现是Buffer对齐问题。
Buffer的设计另一个容易被忽略的点,是DMA控制器访问的是物理地址还是虚拟地址。在带操作系统的平台上,内存管理单元(MMU)会把虚拟地址映射到物理地址,而DMA控制器不经过MMU,直接访问物理内存。如果直接把应用层的虚拟地址填进DMA寄存器,而虚拟地址和物理地址不一致,搬运的数据就会写到一个不存在的映射或错误区域。
好在裸机开发或者单芯片开发里,一般没有MMU,Buffer地址往往既是虚拟地址也是物理地址,问题不大。但一旦上了嵌入式Linux这类带虚拟内存的系统,就需要特别注意。
2.3 传输完成怎么通知:中断、轮询还是回调
第三个问题是运行时策略:一次DMA传输完成后,CPU怎么知道?
最常见的选择是中断通知。配置DMA通道使能传输完成中断,传输完成后,DMA控制器产生一个中断,中断服务函数里读取接收到的数据,或者填充下一轮要发送的数据。这是大部分外设DMA方案的标准做法。
还有一种是轮询。DMA传输完成后会在状态寄存器里置一个标志位,主循环里不断读取这个标志位,发现置位了再处理数据。这种方式省掉了中断的开销,但要求主循环足够及时地去查标志,适合对实时性要求不高、数据量又不大的场景。
第三种是用DMA的传输完成触发另一个外设动作,典型场景是内存到内存批量拷贝完成后,自动触发另一个硬件事务,全程不需要CPU介入。这种属于比较高级的链式传输玩法,初始化时要额外配置硬件联动关系。
这三个问题其实是初始化设计的顶层决策,先想清楚"谁发起搬运、搬到哪、搬完怎么知道",再去看具体的寄存器配置,顺序别搞反。
3. 初始化流程的分步拆解
3.1 使能控制器时钟:一切操作的前提
DMA控制器在多数芯片里也是一个外设,使用前必须先把它的时钟打开。这一步最容易让人忽略,因为配置DMA寄存器本身并不会报错,但如果DMA控制器的时钟没有使能,寄存器写进去可能直接被忽略,或者读写时总线挂住。
我见过最典型的翻车案例是:代码里配置了一整套DMA通道参数,逻辑完全正确,但DMA就是不工作,外设的数据也不搬到Buffer里。后来翻初始化代码才发现,整段程序少了一句使能DMA控制器时钟的调用。
正确姿势是先把外设时钟树理清楚。有些芯片的DMA控制器还有独立的"等待时钟"和"接口时钟"两个域,需要分别使能。这一步配置不到位,后面所有操作都等于对着空气说话。
3.2 配置传输的关键参数:从字段到含义
时钟就绪之后,开始配置通道的参数。下面这段是通用风格的初始化代码,不同平台寄存器名可能有差异,但逻辑是相通的:
void dma_init(dma_channel_config_t *cfg) { // 1. 使能DMA控制器时钟 dma_clock_enable(DMA_BASE, cfg->dma_id); // 2. 清空通道原始状态,避免残留配置影响 dma_channel_disable(cfg->channel); dma_channel_clear_flags(cfg->channel); // 3. 配置源地址、目的地址、传输方向 dma_channel_set_src_addr(cfg->channel, cfg->src_addr); dma_channel_set_dst_addr(cfg->channel, cfg->dst_addr); dma_channel_set_transfer_dir(cfg->channel, cfg->dir); // 4. 配置数据宽度和突发长度 dma_channel_set_data_width(cfg->channel, cfg->data_width); dma_channel_set_burst_len(cfg->channel, cfg->burst_len); // 5. 配置循环模式和传输长度 dma_channel_set_circular(cfg->channel, cfg->circular_en); dma_channel_set_transfer_len(cfg->channel, cfg->transfer_len); // 6. 配置中断和事件通知 dma_channel_set_irq_enable(cfg->channel, cfg->irq_type, cfg->irq_enable); // 7. 使能DMA通道(等待外设请求触发) dma_channel_enable(cfg->channel); }拆开来看,每个配置项都有它的讲究。
数据传输方向决定源地址和目的地址的角色。方向就三种:读外设(外设到内存)、写外设(内存到外设)、内存到内存。很多芯片的时钟配置要求写和读方向使用不同的时钟边缘,配置错会导致搬运一次、丢掉一次,数据量减半。
数据宽度决定一次搬运的数据单元有多大,8位、16位还是32位。源端和目的端的数据宽度可以不一致,DMA控制器内部会做对齐转换,但转换的效率低于相同宽度。初始化时尽量把外设寄存器宽度和内存Buffer宽度配置成一致,省去转换开销。
突发长度是一次连续搬运多少个数据单元。突发越长,总线占用时间越集中,传输效率越高,但对总线的占用也会影响其他外设的访问。这里是典型的性能与公平性的权衡。
循环模式和传输长度也是一对需要配合的参数。循环模式下,DMA每次搬完设定的长度后,源地址和目的地址自动回绕到初值,适合周期采样的场景;非循环模式搬完一次就停住,需要重新初始化才能开始下一轮,适合一次性数据传输。
3.3 把Buffer地址翻译成硬件能懂的地址
配置源地址和目的地址的代码看起来简单,但这里有几个坑。
第一个是地址应指向寄存器还是指向Buffer。外设到内存方向,源地址填外设的数据寄存器地址,目的地址填内存Buffer首地址。很多人把这两个填反了或者都填成Buffer地址,DMA配起来也是"成功"的,但搬运的数据完全不是预期的内容。
第二个是地址对应的数据类型。数据宽度如果是32位,填地址时应确保地址值是4字节对齐的;如果是16位,至少2字节对齐。不对齐的地址在某些芯片上还能搬运,但会在搬运中途产生总线错误或者数据错位的现象。
第三个是Buffer长度和传输长度的关系。有些DMA控制器会校验传输长度不能超过Buffer实际容量。超过的部分属于越界访问,可能覆盖掉相邻的变量、链表节点、返回地址,程序运行一段时间后神秘崩溃。这个问题在初始化里不报错,但在运行时可能产生很严重的内存破坏。
我给自己定的规矩是:所有参与DMA的Buffer,定义时都用关键字强制对齐,比如按32字节对齐,这样既能满足4字节对齐的要求,也为将来的突发传输优化留出空间。
3.4 使能通道与外设DMA请求:最后的临门一脚
通道参数全部配好后,最后一步是打开通道使能位,让DMA控制器准备好响应外设的请求。
但这里还有一个很多人忽略的步骤:外设自己也要使能DMA请求功能。也就是说,光使能DMA通道不够,外设侧还要把"DMA请求发送"打开,两者都准备好后,外设每产生一次数据,才会真正启动一次搬运。
以串口为例,接收DMA的完整配置是:串口外设使能DMA接收请求,DMA通道使能,通道的源地址指向串口接收数据寄存器,目的地址指向内存Buffer。两个使能位缺一不可。
使能的先后顺序也影响结果。如果先使能外设DMA请求,再使能DMA通道,此时外设可能已经产生过一次请求但没人处理,请求会悬空或丢失,第一帧数据就少了。反过来,先使能DMA通道,再使能外设请求,就能保证外设的第一个请求到达时DMA已经在线等待。
4. 初始化顺序的硬约束:先配通道还是先配外设
4.1 一个常见的翻车现场
有一次排查问题,现象是初始化之后第一次DMA传输正常,后续全部失灵。看代码,逻辑上没毛病:开时钟、配通道、使能通道、使能外设请求,一气呵成。但数据的第二帧、第三帧就是不来。
后来用示波器去抓外设的DMA请求信号,发现请求一直在发,但DMA通道就是无响应。原因在于代码里外设DMA请求的使能位写在了DMA通道使能之前,外设启动后立刻发出了一次请求,而当时DMA通道还没有进入使能状态,这一次请求被丢弃,外设侧的硬件状态机出现异常,后续请求再也没有正常触发。
这个问题的严重性在于:它只在第一次上电时出现,正常复位后或者断点调试时又恢复工作,非常难复现。最终定位到初始化顺序上。
4.2 推荐顺序和背后的原因
初始化的推荐顺序,按下面的表格执行可以避免大多数顺序引发的故障:
| 步骤 | 操作 | 原因 |
|---|---|---|
| 1 | 使能DMA控制器和外设的时钟 | 没有时钟,后续寄存器操作全部无效 |
| 2 | 复位DMA通道和外设DMA逻辑 | 清除上电时不确定的残留状态 |
| 3 | 配置DMA通道参数(地址、方向、宽度等) | 让DMA控制器做好执行准备 |
| 4 | 使能DMA通道 | 通道进入就绪状态,等待请求 |
| 5 | 使能外设DMA请求 | 外设请求发出时DMA已经在线 |
| 6 | 按需使能DMA中断 | 中断要在DMA已经就绪后再使能,避免误触发 |
这个顺序的核心逻辑是:让下游先就绪,再让上游开启流量。DMA通道是下游,外设请求是上游。如果上游先放水,下游闸门没开,水就白流了。
中断使能放在最后,也是为了避免一个隐蔽问题:通道使能后的极短时间内,某些DMA控制器会产生一次虚假的传输完成事件,如果中断在此之前就已使能,中断服务函数会在没有任何数据的情况下被调用一次,访问一个还没准备好内容的Buffer。
4.3 DMA中断的优先级与嵌套
最后一步配置中断时,还需要考虑优先级和嵌套的问题。DMA传输完成中断是典型的"时间紧迫型"中断,因为下一次搬运可能马上又要开始。如果中断优先级配置过低,被其他中断长时间抢占,Buffer里的数据可能在下一次搬运开始前没被及时读取,造成覆盖。
我一般把DMA传输完成中断的优先级设置到仅次于时钟或者同等紧急的外设中断,并且关闭不必要的中断嵌套。信号量大、实时性高的场景里,用关嵌套的DMA中断比允许嵌套更稳定,因为嵌套本身也只是增加不确定的执行时延。
5. 实测调了整晚的5个坑
5.1 不对齐的Buffer:数据错位与效率暴跌
前面提到过Buffer对齐问题,这里说一个具体的实测案例。某外设接收DMA,Buffer定义成普通数组,编译器默认对齐到4字节,看起来没问题。某次重构代码时,把Buffer包进了一个结构体,结构体前边又加了一个小字段,Buffer首地址变成2字节对齐。模块功能照常工作,但实测传输速度掉了近三成。
原因在于DMA控制器内部针对对齐地址做了优化,不对齐时它只能退化为更小的数据单元搬运,突发传输实际不产生。这类性能问题在功能调试阶段不容易发现,数据多了、速度要求高了才暴露。
处理方式是在定义Buffer时显式指定对齐属性,比如:
uint8_t rx_buffer[512] __attribute__((aligned(32)));这个办法通用性强,不管编译器怎么排布上下文的变量,Buffer的首地址都会被顶到32字节对齐。也可以在内存分配时选择按对齐值取整分配,但显式声明最不容易出错。
5.2 缓存一致性问题:CPU改了,DMA看不到
这个坑在带缓存的处理器上特别隐蔽。CPU写了一个Buffer准备发出去,DMA搬运时搬的却是旧数据;或者DMA把新数据写进Buffer,CPU读的时候读到的还是老内容。
原因是CPU和DMA各自有各自的"视线"。CPU写数据时,数据先停留在缓存里,还没有真正落到物理内存;DMA不看缓存,它直接访问物理内存,当然看不到CPU刚写的内容。反过来,DMA把数据写进物理内存后,CPU之后再读时发现缓存里还是旧值,CPU读到的不是最新数据。
处理方案是在关键位置执行缓存同步操作:
- DMA发送前:clean操作,把CPU缓存里的数据冲刷到物理内存。
- DMA接收后:invalidate操作,让CPU缓存里的旧内容失效,强制从物理内存重新读取。
缓存同步操作的具体API在不同平台有不同名字,但原理是一样的。做实时通信的时候,我倾向于把DMA相关的Buffer标记为"非缓存"(uncached),这样CPU和DMA访问的都是物理内存,天然一致,代价是CPU对这块Buffer的访问稍慢,但在通信场景里是完全可接受的。
5.3 半传输中断和传输完成标志的误判
很多DMA控制器支持"半传输中断",也就是当传输长度的一半完成时,触发一次中断。这个特性在双缓冲方案里非常有用:前半段数据传输完成的瞬间,CPU可以立刻去处理前半段的数据,同时后半段还在进行。
但半传输事件在初始化和处理时有一个细节:半传输对应的标志和传输完成标志不是同一个位,必须分开处理。我踩过的一次坑是中断服务函数里只判断了传输完成标志,结果半传输中断触发后,标志位一直没人清除,DMA控制器以为中断未被处理,后续所有中断全部被阻塞,整个通道死掉。
处理办法是初始化时就明确要监听哪些事件,并且回调函数里逐一判断、逐一清标志。等传输完成事件和半传输事件都需要处理时,优先清半传输标志,让后半程传输不受影响,再处理数据的搬运逻辑。
5.4 复用冲突:两个外设抢同一个通道
DMA通道资源是有限的,初始化时如果没查映射关系,两个外设的请求可能在同一个通道上"撞车"。表现是:设备A单独跑没问题,设备B单独跑也没问题,两个同时使能时,B的数据频繁丢失。
排查这个问题的第一步就是查芯片手册里的DMA请求映射表,确认A和B是不是映射到了同一个通道。如果是,就需要把其中一个通道的资源重新分配,或者流控级别上做轮换。
但有时候冲突不是显式的,而是优先级导致的隐性饿死。DMA通道本身有优先级设置,两个通道都请求总线,优先级高的总是抢到,优先级低的长期得不到服务。表现上看两个外设都在工作,数据也都在搬,但优先级低的那一路明显会丢数据、延迟大。
处理方式是根据外设的重要程度合理分配优先级,而不是默认全部相同。对实时性要求高的周期性外设,优先级应该高一些;对于允许延迟的批量外设,优先级低一些,保证系统的整体公平。
5.5 复位后残留配置导致的"幽灵传输"
最后一个坑和上电/复位状态有关。有些DMA控制器在系统复位后,通道的使能位、地址寄存器可能保留着上一次的值,而不是全清零。这时如果初始化代码里没有先做通道复位和标志清除,直接配置新参数,可能产生一种非常诡异的现象:系统上电后DMA立刻就搬运了一段内存数据,但对应的外设根本没有发出过请求。
这个问题的本质是上次传输的"残余配置"被误认为有效。初始化代码里,使能通道之前,先执行一次完整的通道软复位,把地址寄存器清零、传输长度清零、中断标志全部清除,再开始配置新的参数。
我之前排查一个偶发的"首帧乱码"问题,查了整整两天,后来发现复位后DMA通道的残留传输长度不是零,DMA通道一使能就立刻用残留的长度执行了一次搬运,这次搬运的数据叠加到了新配置的传输上。加上通道复位这一步之后,问题彻底消失。
这个经验也给我养成了一个习惯:每次DMA初始化之前,不管是什么状态,都先无条件执行一次通道复位和标志清除,成本极低,收益极高。
DMA初始化这个活,表面看是配寄存器,实际是在和硬件的工作机制打交道。最后的顺序、对齐、请求映射、缓存同步,每一个都对应DMA控制器内部的具体行为。希望这篇流程拆解和坑点记录,能让你在配置自己项目的DMA时少走几步弯路。