搞嵌入式驱动这么多年,有一个话题每次聊都会有新人踩坑、老人叹气——DMA。串口高速收发也好,多通道ADC连续采样也好,SPI搬运一大块数据也好,DMA都是绕不开的“第二双手”。但恰恰是这个看似简单的东西,配置错误、缓存不一致、回调时机没卡对,就能让你莫名其妙丢数据却又找不到原因。这一期是驱动开发经验系列的第6期,我打算把DMA这件事从头到尾讲透:它到底怎么工作、驱动里应该怎么设计、以及我这些年实测下来最值得注意的那几个坑。
这篇内容主要适合正在写BSP、外设驱动的嵌入式软件工程师,尤其是被高速串口或者多通道ADC采集折磨过的朋友。当然,如果你刚接触嵌入式Linux,想搞懂驱动里DMA和CPU之间怎么配合,也能从这篇文章里得到一套完整的排查思路。
1. 为什么说DMA是驱动开发里躲不掉的那个“搬运工”
1.1 数据搬运的三条路:轮询、中断、DMA
在DMA出现之前,CPU搬运外设数据无非两条路。
第一条是轮询。程序死等寄存器标志位,标志位置位就读一个字节。这种写法在低速场景下能跑,但稍微有点吞吐量要求就原形毕露,CPU几乎全程都在空转。第二条是中断,外设每收到一个字节就触发一次中断,CPU进去把数据搬出来。中断比轮询强得多,但在高速场景下问题同样明显,拿1Mbps的串口来算,大约10微秒就要打断一次CPU,光是进出中断和保存恢复现场的开销,就足够让整个系统的实时性变得一塌糊涂。
第三条路就是DMA(Direct Memory Access,直接存储器访问)。它的核心逻辑非常直白:外设和内存之间的数据搬运由DMA控制器接管,CPU只需要在开始时把源地址、目的地址、传输长度配置好,然后告诉它“开始搬”,等搬完了DMA再通过中断通知CPU一次。整个过程CPU基本不参与,真正实现了“你忙你的,搬完喊我”。
三个方案的差异我整理成了一张表,方便直观对比:
| 方案 | CPU参与粒度 | 实时响应 | 代码复杂度 | 典型场景 |
|---|---|---|---|---|
| 轮询 | 每个字节 | 延迟取决于循环速度 | 低 | 低速简单外设、初始化阶段 |
| 中断 | 每个字节/每个事件 | 高,但频繁打断 | 中 | 中低速串口、按键、低频信号 |
| DMA | 每整块数据传输 | 只打断一次/块 | 高 | 高速串口、SPI Flash、多通道ADC、内存拷贝 |
我见过不少工程师最开始觉得DMA配置麻烦,宁可中断里扛着,等系统一复杂就发现不对了。中断频繁触发会导致其他更高优先级任务被饿死,有时还会出现丢字节后无法追查的情况。与其到时候重构,不如从一开始就用DMA。
1.2 不是所有场景都该上DMA
这里得泼一盆冷水:DMA不是万能药,也不能为了用而用。
判断标准很简单——看数据的“批量”和“频率”。如果一次只传三五个字节,而且是偶尔传一次,DMA的优势完全发挥不出来,光初始化DMA控制器、设置描述符的开销可能比CPU直接搬还大。我见过有人把读温度传感器这种低频、小数据量的I2C通信硬做成DMA,结果状态机复杂度翻倍,收益几乎为零。
反过来,只要是持续、大批量、周期性搬运数据,DMA就非常值得。典型的有:
- 串口连续收发,尤其多个串口同时跑;
- SPI接口的Flash读写、LCD刷屏;
- 多通道ADC连续采样,比如配套AD7606这类8通道16位同步采样板卡、ads127l11这类高精度ADC,不靠DMA几乎无法保证所有通道数据同步读回;
- TIM定时器触发的DMA突发传输,用来做PWM波形输出或者周期性采集。
判断标准其实就一句话:一个字节一个字节地让CPU跑腿,值不值。值就用DMA,不值就老老实实用中断。
1.3 DMA在常见外设配置中的位置
实际项目里,DMA并不独立存在,它总是和外设的请求信号绑定在一起。串口DMA、SPI DMA、ADC DMA、TIM DMA burst,这些都是外设在特定事件发生时给DMA控制器发一个传输请求,DMA响应请求后搬一“块”数据。
有一点新手特别容易搞混:外设的数据寄存器通常只有一个,比如串口的DR寄存器、SPI的DR寄存器、ADC的DR寄存器。DMA搬运的本质,是把外设寄存器里的数据搬到内存缓冲区,或者反过来把内存数据写到外设寄存器。所以配置DMA的时候,外设端地址永远是那个固定的外设寄存器地址,而内存端地址则是你定义的缓冲区。
这也是为什么我建议把DMA看成“外设与内存之间的管道”,而非一个独立的搬运装置。理解了这个,后面配置方向、地址增量这些字段就很自然了。
2. 动手写DMA驱动之前必须拿下的几个配置决策
2.1 通道映射:不是想接哪个DMA通道就接哪个
很多人第一次用DMA,上来就设DMA通道,结果数据死活不动。原因多半是没有查芯片手册里的DMA请求映射表。
以STM32F4为例,USART1的发送和接收对应的DMA请求并不是同一个DMA控制器、也不是随便一个通道都能用。USART1_TX对应DMA2 Stream7 Channel4,USART1_RX对应DMA2 Stream2 Channel4。你如果拿DMA1去服务USART1,数据根本不会来,因为这个外设的请求线根本就没接到DMA1上。GD32、AT32、HC32F460这些国产芯片虽然寄存器命名风格各有差异,但“外设请求必须接到对应的DMA通道”这个基本原则完全一致。
所以我的第一个建议是:拿到一个不熟悉的新芯片,先翻两样东西——DMA请求映射表和外设时钟树。确认外设的DMA请求信号挂在哪个DMA控制器、哪个通道/流上,再开始写代码。这一步能省掉后面一整天的调试时间。
2.2 传输方向、数据宽度、地址增量
DMA传输方向有四种:外设到内存、内存到外设、内存到内存、外设到外设。串口接收是外设到内存,串口发送是内存到外设,而内存到内存拷贝,需要确认你用的DMA控制器是否支持,比如部分MCU的DMA1不支持内存到内存模式,但DMA2支持,选错就直接配置无效。
数据宽度是另一个常见的坑。DMA支持字节(8位)、半字(16位)、字(32位)三种宽度。外设寄存器宽度和内存缓冲区宽度必须匹配。比如ADC配置成16位采样结果,那DMA数据宽度就应该是半字,如果你配成字节,每个通道的采样值就会被拆成两个字节,后续组包全乱。反过来,外设寄存器是32位的,内存却定义成uint8_t数组,DMA就会越界写内存,这是最隐蔽也最危险的问题,轻则数据错乱,重则直接hardfault。
地址增量这个参数相对好理解。内存端缓冲区地址必须设置为递增,否则每次传输都会覆盖同一个位置。外设端地址则固定不变,因为外设寄存器永远在那里。有些驱动库默认把两端都设成递增,数据也会跑,但跑得很诡异——每搬一个字节,外设地址也加上一个偏移,搬到最后数据全写到不该去的地方了。
2.3 单次传输和循环模式:选择背后的实际考量
DMA的一次完整传输可以工作在单次模式,也可以工作在循环模式。
单次模式下,DMA从起始地址搬到指定长度后自动停止,下一次传输需要软件重新配置传输长度并重新使能。这种模式适合一次性的大块数据搬运,比如SPI读取一页Flash、串口发送一帧完整的AT指令。
循环模式则是DMA搬完指定的长度后,自动把读写地址回归到初始值,继续下一轮搬运。这个模式天然适合串口接收、ADC连续采样这种“永远不知道数据什么时候来、来多少、来多久”的场景。循环模式下,CPU可以通过读取DMA当前剩余传输计数来判断缓冲区里新到了多少数据,不需要每个字节都被打断。
我个人的经验是:接收侧优先用循环模式,发送侧优先用单次模式。因为发送侧的事务特征通常比较明确——要发一帧,发出去了,等发送完成中断;接收侧则是未知的,循环模式配合空闲中断,是所有成熟驱动项目验证过的组合。
2.4 突发传输与优先级仲裁:并发场景才见真章
突发传输(Burst)是DMA连续搬多个数据后再释放总线的一种模式,和FIFO配合使用,可以显著减少DMA对系统总线的占用次数。但突发传输有一个前提:内存地址的数据宽度和FIFO水印要匹配。如果配错了,DMA会卡在等待FIFO填充/排空的状态,数据卡在中间一动不动。
优先级仲裁这块,我多说两句。多数MCU的DMA仲裁规则是:先看软件配置的优先级,再看硬件通道号优先级。也就是说,如果你把通道A和通道B都配置成高优先级,硬件会按固定顺序仲裁,不会并行服务。很多人忽略这点,以为两个高优先级通道能同时享受优先权,实际结果是窄通道一直被另一个通道饿死。所以遇到多通道并发DMA,优先保证关键通道的软件优先级不同,否则会出现某个通道数据连续性被破坏的隐患。
另外,如果两个外设同时请求DMA,仲裁结果并不是“快”的外设一定先服务,而是由仲裁规则决定。配置优先级之前,最好把外设的实时性要求排个序,哪个绝对不能丢数据,就给它最高的DMA优先级。
3. 串口DMA驱动从初始化到稳定运行的完整实操
3.1 初始化顺序:先外设,还是先DMA,还是先使能?
很多人写DMA初始化就是照着参考代码抄,先配DMA再配外设,顺序反了也未必立刻出问题。但有一种情况会很隐蔽:DMA先使能了,而外设还没准备好,这时外设可能已经在内部产生了一个多余的请求,DMA收到这个请求就搬了一次空数据,缓冲区第一个位置被写脏。等真正有数据来的时候,你发现缓冲区开头总会多出一个无意义字节。
所以我推荐的顺序是:先初始化外设并关闭其DMA请求,再初始化DMA的描述符和缓冲区,最后同时使能外设的DMA请求和DMA通道。这样能保证外设发出的第一个有效请求,才是DMA看到的第一个请求。
3.2 接收侧:循环DMA + 空闲中断的经典组合
串口不定长接收,最经典、也最推荐的方案是循环DMA配合串口空闲中断。
思路是这样:DMA循环模式持续把串口收到的新字节搬到接收缓冲区,CPU不需要管每个字节。当串口在一段时间内没有收到新数据时,硬件会产生一个空闲中断,这时CPU进入中断处理,通过读取DMA控制器当前的剩余传输计数(NDTR/CNT寄存器),反推出这轮新到了多少字节,然后移动缓冲区头部指针并通知协议层处理。
这里有个细节非常关键:读取DMA剩余计数时,一定要用DMA提供的API去读,不能直接操作寄存器。因为有些芯片的剩余计数寄存器在读取时有锁存机制,直接读可能会拿到一个中间状态。我吃过这个亏,调试时看到数据长度偶发跳变,最后发现是读取时机不对,改用库接口后问题消失。
另一个细节是:处理完整帧之前,要等DMA把最后一个字节完全搬到内存。空闲中断产生时,最后一个字节可能还在外设数据寄存器里没搬到内存。这时候如果立刻处理缓冲区,会漏掉最后几个字节。我一般的做法是:空闲中断里先把DMA传输停掉,等确认FIFO里的数据已经全部进入内存缓冲区,再更新缓冲区头指针,最后重新启动DMA继续接收。如果不想停DMA,也可以用“延时几个时钟周期再读取NDTR”的办法,但必须确认这个延时在极端场景下也足够。
伪代码如下,用的是STM32风格的寄存器操作示意,改成GD32/AT32也非常方便:
void uart_dma_rx_isr(void) { if (UART_GetITStatus(UART_IT_IDLE)) { UART_ClearITPendingBit(UART_IT_IDLE); uint32_t remain = DMA_GetCurrDataCounter(DMA_RX_STREAM); uint16_t received = RX_BUF_SIZE - remain; if (received > 0) { // 确保DMA停稳,避免最后几个字节还在路上 DMA_Cmd(DMA_RX_STREAM, DISABLE); while (DMA_GetFlagStatus(DMA_FLAG_TC) == RESET); // 更新头指针,通知协议层 rx_info.head = (rx_info.head + received) % RX_BUF_SIZE; rx_info.available += received; // 重新装载计数并启动 DMA_SetCurrDataCounter(DMA_RX_STREAM, RX_BUF_SIZE); DMA_Cmd(DMA_RX_STREAM, ENABLE); } } }这只是伪代码框架,具体到不同芯片,寄存器名称和读写函数差别很大,但核心思路是通用的。
3.3 发送侧:一次完整的DMA发送回调链
发送侧比接收侧简单,但同样有顺序问题。
我用DMA发送一帧数据时,流程是固定的:
- 协议层把要发送的数据写入发送缓冲区;
- 调用DMA发送接口,配置内存地址为发送缓冲区地址、数据长度为帧长度;
- 启动DMA;
- 开启DMA发送完成中断;
- 在发送完成中断里清理标志,释放发送锁,并回调通知协议层“可以发下一帧了”。
有一个细节是:外设在发送最后一个字节的过程中,DMA可能已经认为传输完成了,产生“提前完成”的中断。如果此时立刻把缓冲区释放掉或者修改缓冲区内容,最后那个字节可能被破坏。稳妥的做法是,发送完成中断后再等一个额外的标志,比如串口的发送完全空闲标志(TC),确保移位寄存器里的最后一位也发出去了,再释放缓冲区。
3.4 给上层的数据接口:回调优于裸指针
驱动写多了你会发现,DMA驱动最容易被上层骂的一点,就是拿了缓冲区裸指针让上层直接啃。这样看似高效,但耦合度极高,上层一旦改数据结构,驱动层也要跟着改。
我的做法是:驱动只提供一个环形缓冲区的读接口,以及一个“新数据到达”的回调通知。上层注册回调,在回调里调用读接口取数据,驱动层完全不关心上层拿数据之后干什么。这个设计在裸机、RTOS下都通用,后面要加协议解析或者DMA双缓冲也方便扩展。
实测下来,之前用纯中断方案跑1Mbps串口,CPU占用率在60%以上,切换到循环DMA加空闲中断之后,同样速率下CPU占用率降到个位数,而且系统里其他实时任务的调度抖动明显变小。如果一直犹豫要不要上DMA,这个数据应该能让你下定决心。
4. DMA生效了没有:三种不依赖IDE的验证方法
4.1 精确测量:用GPIO翻转法量化CPU占用率
配置完DMA,光看“能收到数据”是不够的,得验证性能收益。最简单的方法是用GPIO翻转法。
在任务入口和出口各翻转一次一个GPIO,拿示波器或者逻辑分析仪看这个GPIO高电平持续的时间,那就是这段代码的实际执行时间。用同样的方法测中断处理函数,对比纯中断方案和DMA方案下中断入口到出口的耗时差了多远,一目了然。
更重要的是测主循环或高优先级任务的“喘息时间”。开启DMA后,高优先级任务里GPIO的低电平时间段应该明显变长,说明CPU空闲资源确实多出来了。如果测出来两个方案几乎没有差别,就要回头查一下:是不是DMA中断太频繁?是不是缓冲区大小配太小导致DMA频繁重新装载?
4.2 用DWT周期计数器看代码路径延迟
对Cortex-M内核,DWT(Data Watchpoint and Trace)模块里有个CYCCNT周期计数器,不用额外硬件就能精确统计代码执行了多少个时钟周期。我习惯在DMA启动函数前后读一下CYCCNT,算出差值,就能清楚知道DMA配置这个过程本身消耗了多少CPU时间。
这个方法对优化驱动代码特别有用。比如发现DMA启动函数特别慢,一般就是每次都重新计算缓冲区地址和长度、做大量防御性判断导致的,可以在初始化时就把描述符准备好,启动时只改必要字段。
4.3 数据完整性自检:用固定模式和CRC验证
性能只是其一,正确性才是底线。我推荐在调试阶段做数据完整性自检:外部设备持续发送固定模式的字节序列,比如0x00到0xFF循环,或者带CRC的帧结构。嵌入式端DMA收到的数据一边存一边做校验,跑个十几分钟,如果校验错误率是零,才说明DMA配置和缓冲区管理在长时间高负载下是稳的。
网上有人专门写DMA测速工具,其实思路大同小异:持续搬运大块数据,统计实际吞吐量和理论带宽的差距。但测速只能看带宽,完整性必须靠模式校验。两件事都得做,别省。
5. DMA驱动实战里那些藏得很深的坑
5.1 Cache一致性:带L1 Cache的内核必踩的坎
这是很多人在Cortex-M7、M33、以及更高性能内核上遇到的第一个大坑。简单说就是:CPU写过数据之后,数据可能还躺在Cache里没写回主存;而DMA访问的是主存的物理地址,它读到的还是旧数据。反过来,DMA写入了新数据,主存已经更新了,但CPU的Cache里还留着旧副本,CPU一读就拿了个旧值。
症状表现很有迷惑性:第一次数据传输是对的,第二次开始数据错乱,或者数据总是差一拍。因为第一次Cache是干净的,DMA能看到最新数据,之后Cache和主存就开始分叉了。
解决方式不能靠碰运气,必须显式做Cache维护。在配置DMA传输前,如果CPU写过这个缓冲区,要做Cache Clean,把脏数据刷回主存;在DMA传输完成后,如果CPU要读这个缓冲区,要做Cache Invalidate,丢弃旧缓存重新从内存读。
Cortex-M7/GD32H7等平台上,CMSIS提供了类似SCB_CleanDCache_by_Addr、SCB_InvalidateDCache_by_Addr的接口,用的时候注意两个点:地址必须按Cache Line对齐,长度也最好是Cache Line的整数倍。很多芯片要求缓冲区起始地址32字节对齐,有些强缓存一致性的平台甚至要求配置MPU把DMA缓冲区设为非缓存、非缓冲区域,省得每次手动维护。
5.2 缓冲区对齐与内存属性:为什么总是莫名故障
除了Cache问题,DMA缓冲区还有一个更基础的要求:对齐。多数MCU要求DMA缓冲区地址和传输数据宽度对齐,传输字(32位)时要求4字节对齐,传输半字时要求2字节对齐。有的DMA控制器还要求缓冲区起始地址满足突发长度的对齐。
如果你的缓冲区是普通结构体里的一个成员,极大概率是不对齐的。正确做法是单独定义DMA缓冲区,用编译器属性或者链接脚本段来保证对齐。比如在GCC下用__attribute__((aligned(32))),或者把DMA缓冲区放进专门的section。缓冲区的生命周期也要严格管理,DMA正在搬数据时绝对不能把这个缓冲区释放给别的任务用,否则就是典型的内存越界事故。
5.3 中断上下文里的雷区:回调函数不是随便写的
DMA传输完成中断、空闲中断、错误中断,这些都运行在中断上下文。在这个上下文里最忌讳三件事:调用阻塞延时、做大量数据处理、获取一个可能被其他中断或者任务占用的锁。
我见过最典型的死锁场景:DMA接收空闲中断里调用了协议解析函数,解析函数里又获取了一个互斥锁,而主循环那边的任务正持有这个锁在处理上一帧数据,主循环还被这个中断打断了。两边互相等,系统直接卡死。
正确做法是:中断里只做最快的工作——判断事件类型、移动缓冲区指针、设置标志位;耗时操作放到主循环或者RTOS的任务里做,比如把“新数据帧”标志置位,主循环看到标志后调用协议解析。这也是我为什么在前面反复强调回调要轻量。
5.4 关DMA的握手时序:顺序错了会残留请求
关闭DMA这个操作看起来简单,其实是个握手过程。直接调用Disable接口是不够的,因为外设可能已经发出了DMA请求,只是还没被响应。如果这时候直接关DMA,这个请求就悬空了,等到下次再使能DMA时,它可能会带着旧请求直接搬运一次,导致缓冲区被写入一次莫名其妙的数据。
我的标准操作顺序是:先关闭外设的DMA请求使能,让外设不再产生新请求;再关闭DMA通道;然后清理DMA的状态标志;最后再操作缓冲区数据。这个过程在每个芯片上的API不一样,但原则通用:外设和DMA的停止顺序不能乱,状态标志必须清理干净再重新开始。
6. 一次串口DMA偶发丢帧的完整排查链路
6.1 现象:高负载下偶发丢帧,不是每次都能复现
之前调一个项目,4路串口同时跑DMA收发,串口1作为主通道每100毫秒从外部设备收一帧128字节的数据。单路测试一切正常,4路同时跑起来之后,串口1偶发丢帧,而且没有固定规律,可能二十帧丢一帧,也可能一百帧才丢一帧。正常帧间隔小于2个字节时间,所以不是“长时间空闲”导致的帧边界判断错误。
这种偶发问题最让人头疼,因为不便于直接打断点排查。打断点本身就会改变时序,很可能你一停在断点上,问题反而不出现了。
6.2 第一波排查:排除应用层和缓冲区大小
我习惯第一步先怀疑最便宜的假设。
先检查接收缓冲区大小。接收缓冲区256字节,每帧128字节,理论上足够。但4路串口共用DMA通道,每路的缓冲区其实分配在同一个DMA控制器的不同通道上,缓冲区本身没有重叠,也不会有越界。再把应用层协议解析的代码打上时间戳,看解析一帧数据用了多久,远小于帧间隔,排除应用层处理不过来导致覆盖。
接着盯FIFO和校验和。给每一帧打上序号,统计丢掉的是哪些。发现丢帧规律跟数据内容无关,跟时间点也无关,纯粹是概率性事件。校验和偶尔报错,但报错帧往往就是丢帧的那一帧。
6.3 用逻辑分析仪对比DMA请求与空闲中断时序
到这里,我确定问题出在驱动层,但视线还不能锁定在具体环节。于是上逻辑分析仪,把串口1的RX引脚、DMA的中断输出引脚、空闲中断处理入口的GPIO引脚同时抓下来。
抓了一段时间后,终于发现规律:丢帧总是发生在串口1空闲中断触发的同时,DMA循环模式正处于“缓冲区尾部回绕”的瞬间。也就是说,如果一帧的结束刚好落在DMA计数器回绕到零、准备重新装载的那几个时钟周期附近,空闲中断里读取的剩余计数就会比真实情况多出或者少出一个字节长度的偏差。128个字节的帧,读出来的有效长度却是127或者129,协议层解析的位数就错位了,整帧自然被丢弃。
说白了,不是DMA没有搬运数据,而是我在空闲中断里用NDTR反推数据长度时,和DMA硬件自动回绕的动作赛跑,输多赢少。
6.4 根因确认:读计数和自动重载之间的竞争窗口
我换个方式验证猜测:丢帧的时间点,把读取NDTR的代码改成连续读两次,发现两次读到的值在特定瞬间确实存在1个字节的跳变。这说明我之前的读取操作正好落在DMA硬件更新计数器和自动重载之间的窗口里。
另外还发现一个关联问题:我用的方案是循环模式单缓冲,没有用DMA传输完成中断,也没有用双缓冲区切换。这意味着DMA和CPU共用同一个缓冲区,CPU在读数据的同时DMA可能正在写同一块区域,只是靠着帧间隔短才极少撞上,但一旦撞上就丢帧。
6.5 修复方案:把同步窗口彻底关死
修复分两步走。
第一步,放弃在空闲中断里直接反推长度,改为在空闲中断触发后立即停掉DMA,然后用“缓冲区总大小减去停下来的剩余计数”得出长度,最后再重新配置计数并启动DMA。这样计数读取和重新装载之间不会再出现竞争,因为DMA已经停了。
第二步,把接收缓冲区从单缓冲改成双缓冲切换机制。DMA搬满半缓冲区时触发半传输中断,搬满整个缓冲区时触发传输完成中断,CPU在中断里处理已经稳定的那一半,而不去动DMA正在写的那一半。空闲中断还是保留,用来处理“数据没搬满半缓冲但已经空闲”的短帧情况。
修改之后,逻辑分析仪上再也看不到二次读计数跳变的现象。4路串口全开,连续跑24小时,串口1没有丢一帧,CPU占用率反而进一步下降,因为DMA传输完成中断和空闲中断各司其职,处理流程更清晰了。
6.6 这次排查留下的通用经验
这次问题给我最大的教训是:DMA循环模式的“自动回绕”看着很智能,但它和CPU读计数之间天然存在竞争窗口。只要能用双缓冲或者停止-重载的方式消除这个窗口,就不要依赖“刚好读对”的运气。
遇到偶发丢数据,先别急着怀疑硬件,按照“应用层——缓冲区——DMA计数——中断时序——缓存一致性”这个顺序逐层排除,效率比自己瞎猜高得多。
最后再分享一个小技巧:DMA驱动写多了之后,你会发现真正要管的其实就三件事——描述符别配错、缓冲区别越界、同步点别混乱。这三件事都理清了,无论换哪家芯片,DMA驱动都长一个样。下一期我准备聊中断下半部与DMA回调怎么协同才能不互相踩脚,想提前琢磨的朋友可以先想一个问题:你的DMA中断里到底放了多少不该放的东西?