STM32CubeMX+DMA实现ADC多通道采集与滤波实战
发布时间:2026/9/28 5:45:51 作者:尧图编辑部 阅读量:1,286

做嵌入式这些年ADC采集算是碰得最多的外设之一。早期我习惯用中断方式每次转换结束进中断、读数据、出中断单通道这么搞倒也没啥大问题。但一旦要同时采集电压、电流、温度好几个通道中断频率一上来主循环里的任务调度就会被频繁打断——LCD刷新开始卡顿、按键响应变迟钝、就连PID控制周期都被拖累。后来我把这套逻辑换成了DMA模式ADC转换完的数据自己就跑到内存里去了CPU全程不用操心整个系统一下子清爽了很多。今天的实战主题就是 STM32CubeMX DMA 搞定 ADC 多通道采集顺便把我趟过坑之后沉淀下来的几种滤波方案一并分享出来。无论你是刚入手 STM32 的新手还是被 DMA 配置坑过、想搞懂背后原理的工程师这篇应该都能给你带来点实在的帮助。1. 为什么我推荐用DMA做ADC多通道采集1.1 中断方式其实也能用但成本太高很多朋友一开始接触多通道 ADC 采集习惯性用的是“转换完成中断”方案。ADC 每完成一次转换就拉一次中断CPU 进中断把数据从ADC_DR寄存器读出来然后再启动下一次转换。听起来没什么问题但仔细算一笔账就明白了。假设我用 3 个通道、单次采样时间 55.5 周期再加上 12.5 个周期的转换时间一个通道从启动到转换完成大约需要 68 个 ADC 时钟周期。ADC 时钟配置成 12MHz 的话一次转换耗时大概 5.7 微秒。3 个通道一轮下来大约 17 微秒。如果还要跑连续转换中断发生频率接近 60kHz。这个频率下哪怕中断服务函数里只做“读寄存器 存数组”这两步操作CPU 的有效处理时间也被吃掉了一大部分。关键是中断服务函数里不适合做复杂数据处理滤波、换算、协议打包这些活儿最终还是得回到主循环里干数据积压的问题会越来越明显。还有一个更隐蔽的问题中断响应存在延迟。如果主程序正在处理某个临界区或者一个更高优先级中断正在执行ADC 转换完成中断会排队等待。这一等下一次采样启动的时间就不固定了采样间隔抖动直接影响到信号分析的准确性。对采样时序有要求的场景这就是硬伤。1.2 DMA的角色一个不需要CPU盯着的搬运工DMA 的全称是 Direct Memory Access直译过来就是“直接存储器访问”。你可以把它想象成一个专职搬运工ADC 转换完成一个数据DMA 自动把ADC_DR寄存器里的值搬到内存指定地址搬完一个继续搬下一个全程不需要 CPU 插手。用 DMA 做 ADC 采集时整个工作流程变成了这样CPU 启动一次 DMA 传输然后该干嘛干嘛ADC 按配置好的顺序扫描各个通道转换结果被 DMA 源源不断地送到内存缓冲区等一组数据搬完了DMA 可以产生一个中断告诉 CPU“数据齐了你来处理吧”。CPU 要做的只是初始化时动动嘴数据处理时才真正干活中间这段采集过程完全不占用任何 CPU 时间。从系统架构的角度看这不是简单的“中断换成 DMA”而是把采集这个重复劳动从 CPU 手里完全剥离了。CPU 的主循环可以稳定地跑显示刷新、通信协议、控制算法采样频率再高也不会阻塞业务逻辑。1.3 三种典型场景建议直接上DMA结合我自己的项目经验下面这三种场景基本是“非 DMA 不可”多通道连续采样需要周期性刷新数据的场景。比如一个电源管理模块里要同时监控输入电压、输出电压、两路负载电流DMA 模式一轮一轮刷新数据CPU 定期取平均值就行。主循环任务繁重不能让中断反复打断的场景。比如带 GUI 刷屏、跑 Modbus 通信、做 PID 闭环控制中断频率过高会直接拖垮实时性。对采样时序一致性有要求的场景。像交流信号的过零检测、波形采样这种每次采样间隔必须均匀中断模式很难保证这一点DMA 模式配合固定的 ADC 触发源可以做到严格稳定。当然中断模式也不是一无是处。如果你只是偶尔采一次数据、通道少、转换频率低用中断反而更简单没必要为了用 DMA 而用 DMA。选型的标准就一条ADC 在不在你的关键路径上。2. STM32CubeMX一步步配置ADC多通道DMA2.1 时钟分频先算清楚别让ADC超频工作我用 STM32F103C8T6 当示例芯片来走一遍完整配置流程。新建工程选择芯片之后首先要做的是在 Clock Configuration 页面把时钟树配好。F103 的最高主频是 72MHz系统时钟走 PLL 倍频上去这个大家应该比较熟不多说。关键是看 ADC 的时钟分频。F103 的 ADC 时钟挂在 APB2 总线上APB2 的最高频率是 72MHz但 ADC 模块自己的最高工作时钟是 14MHz所以必须分频。在 CubeMX 的 Clock Configuration 界面里找到 ADC1 的时钟源如果把 PCLK2 设为 72MHzADC 时钟分频就选 6 分频得到 12MHz安全合规。也有人选 4 分频得到 18MHz超频状态下的转换结果位数会受影响不建议这么干。时钟这里我多提醒一句ADC 时钟频率直接决定了转换时间和最高采样率。12MHz 下最短采样时间 1.5 周期配置时单个通道一次转换最快需要 1.5 12.5 14 个 ADC 时钟周期约 1.17 微秒。但注意通道的输入源阻抗越高需要的采样时间越长否则采样电容充不满转换结果就会偏小这个后面滤波那节还会展开讲。2.2 ADC参数逐项设置扫描、连续、采样时间进入 Pinout Configuration在 Analog 分类下找到 ADC1先把需要采集的通道引脚勾选上。比如我接的是 IN0、IN1、IN2 三个通道就把这三个勾上。接着看 Parameter Settings 里的关键选项我挨个说Scan Conversion Mode置为 Enabled。多通道采集必须开启扫描模式否则 ADC 只会转换一个通道。Continuous Conversion Mode置为 Enabled。开启连续转换ADC 转换完一轮自动开始下一轮配合 DMA 的循环模式数据会一直刷新。Discontinuous Conversion Mode保持 Disabled。不连续模式一般用于“每次触发只转 N 个通道”的省电场景我们这里用不上。DMA Continuous Requests置为 Enabled。这个选项翻译过来是“DMA 连续请求”它决定了 ADC 转换完成后要不要持续向 DMA 发出数据传输请求。对于多通道连续采集这里必须打开否则可能只传一轮就停了。Number Of Conversion填 3因为我们用了三个通道。每个 Rank 的采样时间我建议至少选 55.5 周期输入源阻抗比较高的时候直接上 239.5 周期。采样时间越长采样电容充得越满结果越接近真实值代价是转换速度变慢。低速信号根本不在乎多出来的十几微秒优先保证精度就好。ADC 数据对齐方式右对齐。12 位分辨率下右对齐后高 4 位是 0读出来的数值直接用换算电压也方便。还有一个容易忽略的地方End of Conversion Selection 选项。默认的 EOC flag at end of sequence 表示一组序列转换完之后才置位转换结束标志这适合多通道场景如果选成 EOC flag at end of conversion每个通道转换完都会置一次标志逻辑上有点绕。我们保持默认即可。2.3 DMA参数设置模式、数据宽度、优先级ADC 参数配完接下来是最关键的 DMA 配置。在 ADC1 的 DMA Settings 选项卡下点 AddF103 的 ADC1 对应 DMA1 的 Channel1这个是硬件固定的CubeMX 会自动带出来。参数设置我逐项说明一下Mode 选 Circular循环模式。循环模式的特点是 DMA 搬运完指定数量的数据后地址自动回到起始位置继续搬运下一轮。这样 ADC 连续转换DMA 连续搬运数据实时更新CPU 只需要在回调里取数就行。Direction 选 Peripheral To Memory方向是外设到内存。ADC 是外设内存缓冲区是目的地这个方向别选反。Peripheral Address 和 Memory Address 一般不用手填CubeMX 根据外设和用户代码里的缓冲区自动处理。Data Width 这里外设端和内存端都选 Half Word也就是 16 位。因为 F103 的 ADC 是 12 位分辨率一个转换结果正好占 16 位用 Half Word 最省内存数据也最规整。Priority 建议选 High。DMA 有多个通道同时工作时优先级高的通道优先被服务。ADC 数据有实时性要求给高优先级合适。配置完保存工程点击 Generate Code 生成代码工具链选 MDK-ARM 或者 STM32CubeIDE 都行。2.4 生成代码后的目录检查生成完工程后我习惯打开看一下目录结构。核心文件是adc.c和dma.c初始化函数MX_ADC1_Init()和MX_DMA_Init()会自动生成。务必留意一行代码HAL_ADC_Start_DMA()的真实调用时机。CubeMX 生成的代码里DMA 初始化在main()函数中MX_DMA_Init()调用ADC 初始化是MX_ADC1_Init()整个顺序都不要动如果先初始化 ADC 再初始化 DMA某些系列芯片会出问题。还有个细节DMA 中断的优先级在stm32f1xx_it.c里默认没有配置需要你在 NVIC 设置里手动打开 DMA1 Channel1 中断否则采集完成回调函数根本不会触发。CubeMX 的 NVIC 页面里勾上 DMA1 channel1 global interrupt重新生成代码即可。这一步经常被漏掉。3. 代码实现启动一次采集剩下的交给DMA3.1 定义缓冲区并启动DMA采集打开main.c在用户代码区定义一个数组用作 DMA 缓冲区/* USER CODE BEGIN PV */ #define ADC_CH_NUM 3 uint16_t adc_buf[ADC_CH_NUM]; volatile uint8_t adc_data_ready 0; /* USER CODE END PV */注意我定义的是uint16_t类型数组因为 ADC 结果是 12 位一个元素占 16 位。DMA 缓冲区的大小等于通道数这个数组会被 DMA 按顺序填充adc_buf[0]对应第一个使能的通道adc_buf[1]对应第二个以此类推。如果你在 CubeMX 里把通道顺序调整了数组下标和通道的对应关系也要跟着变。然后在main()函数的用户代码区域启动采集/* USER CODE BEGIN 2 */ HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buf, ADC_CH_NUM); /* USER CODE END 2 */这里有个细节容易踩坑F1 系列 HAL 库中HAL_ADC_Start_DMA的第二个参数类型是uint32_t*而我们传入的是uint16_t*数组所以要强转。很多朋友编译报警其实就是这里类型不匹配强转一下就好。第三个参数是 DMA 传输的数据个数这里传ADC_CH_NUM也就是 3DMA 每搬运 3 个数据就完成一轮传输。启动之后整个采集链路就自动跑起来了。ADC 连续转换DMA 自动搬运CPU 在主循环里该干啥干啥。3.2 回调函数里的数据搬运和标志DMA 每搬完一轮数据会触发一次传输完成中断。HAL 库封装好的回调函数是HAL_ADC_ConvCpltCallback我们需要重写它。直接在main.c的用户代码区添加/* USER CODE BEGIN 4 */ void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc-Instance ADC1) { adc_data_ready 1; } } /* USER CODE END 4 */主循环里判断标志再处理数据while (1) { if (adc_data_ready) { adc_data_ready 0; // 到这里adc_buf[0]~adc_buf[2] 就是一组完整的采集数据 process_adc_data(); } }这里我特别提醒一个时机问题HAL_ADC_ConvCpltCallback是在中断上下文执行的函数体里面不要做耗时操作。像电压换算、滑动窗口滤波这种计算放在主循环里去处理回调里只置个标志位最多做一次数组拷贝把数据快照下来避免主循环处理期间 DMA 又刷新了缓冲区导致数据一致性出问题。想更进一步降低中断频率的话可以把 DMA 缓冲区放大比如定义成uint16_t adc_buf[ADC_CH_NUM * 16]DMA 一次搬完 48 个数据才触发一次回调这样回调频率变成原来的 1/16对 CPU 的打扰更小。代价是数据实时性变差适合对实时性要求不高的监控类场景。3.3 从原始值到真实电压换算与多通道对齐ADC 原始值是一个 0~4095 的整数12 位分辨率下要把它换算成真实的物理电压公式很简单电压 原始值 × 参考电压 ÷ 4096参考电压在 F103 上默认就是 VDDA通常是 3.3V。写成代码float voltage (float)adc_buf[0] * 3.3f / 4096.0f;有一个非常常见的认知误区12 位 ADC 的最大数字是 4095为什么除数是 4096因为 ADC 的满量程对应参考电压本身数值范围是 0~4095一共 4096 个刻度。如果除以 4095算出来会偏大一丢丢。这种偏差单独看无所谓但做校准表时能明显感觉到不整齐。统一用 4096 做除数。多通道对齐的问题说白了就一句话数组下标和 CubeMX 里配置的通道扫描顺序一一对应。你在 CubeMX 的 Rank 列表里看到的顺序就是 DMA 往缓冲区写数据的顺序。如果先配了 Rank 1 是 IN2那么adc_buf[0]就是 IN2 的数据。很多人数据“串了”基本都是配置顺序和代码用到的地方没对上。4. 附赠干货我在项目里实际用过的几种ADC滤波手段4.1 滑动窗口平均滤波实时性与平滑度的平衡ADC 采集的原始数据通常带一定噪声尤其是用开关电源供电时纹波会直接耦合到采样值上。最简单的处理办法是算术平均攒够 N 个点求平均。但算术平均有个问题必须等攒齐 N 个点才能输出一个结果实时性不好。我在实际项目里用得最多的是滑动窗口平均滤波也叫环形缓冲平均滤波。它的思路是维护一个固定长度的窗口每次进来一个新数据就把窗口里最老的数据踢掉再对当前窗口里的所有数据求平均。这样每来一个有效采样点就能立刻得到一个平滑后的输出实时性和平滑效果兼顾。#define WIN_SIZE 16 static uint16_t s_window[WIN_SIZE]; static uint8_t s_index 0; static uint32_t s_sum 0; uint16_t sliding_window_filter(uint16_t sample) { s_sum - s_window[s_index]; // 减掉最老的数据 s_sum sample; // 加上新数据 s_window[s_index] sample; // 覆盖最老的位置 s_index (s_index 1) % WIN_SIZE; return (uint16_t)(s_sum / WIN_SIZE); }这段代码有几个点值得说一下。s_sum用的是 32 位无符号整数16 个 12 位 ADC 的最大值是 16 × 4095 65520用 16 位整形会溢出这个坑我踩过一次窗口一长结果就完全不对了。循环取模操作(s_index 1) % WIN_SIZE在编译器优化后效率很高也可以换成位运算(s_index 1) (WIN_SIZE - 1)前提是窗口大小是 2 的幂。滑动窗口的窗口大小怎么定我的经验是信号变化慢、噪声频率高的场景窗口选大一些比如 32信号变化快、需要及时响应的场景窗口选小一些比如 8 或 16。窗口越大延迟越大响应越迟钝这是物理规律没法绕过。4.2 限幅滤波先剔除“一眼假”的突变值滑动窗口平均能防随机噪声但对付不了突然冒出来的超大尖峰。比如电机启动瞬间产生的电磁干扰可能让 ADC 值瞬间跳变几百甚至上千的数值。这种突变数据如果混进滑动窗口里即使只有一个点也会把平均值带偏一大截。限幅滤波的思路很简单如果当前采样值和上一次有效值之间的差值超过了一个阈值就认为这次采样是异常数据直接用上一次的值替代或者干脆丢弃。#define LIMIT_DELTA 50 uint16_t last_valid_value 0; uint16_t limit_filter(uint16_t sample) { uint16_t delta; if (sample last_valid_value) delta sample - last_valid_value; else delta last_valid_value - sample; if (delta LIMIT_DELTA) return last_valid_value; // 判定为尖峰用上次的值顶替 last_valid_value sample; return sample; }阈值LIMIT_DELTA怎么定要看信号的物理意义。比如温度传感器正常变化速率每分钟最多几度ADC 值每毫秒跳变超过 50 就非常可疑但如果采的是电流信号动态响应很快阈值定 50 可能把正常的变化也当成尖峰给滤掉了。这个阈值没有标准答案必须实测数据的波动范围再定。我习惯把限幅滤波放在最前面先做粗筛剔除明显异常值然后再进滑动窗口做平滑。两级串联的效果比任何单一滤波都稳。4.3 中值去极值平均解决随机尖峰如果数据里的随机尖峰比较多而且你期望的输出值要更“稳”可以考虑中值滤波和去极值平均的组合方案也就是经典的去极值平均滤波。思路是采集 N 个点把这 N 个点排序去掉一个最大值和一个最小值剩下的 N-2 个点求平均。这样既不放过真实的信号变化又能把偶然出现的脉冲干扰剔除掉。#define MED_SIZE 9 uint16_t median_avg_filter(uint16_t* raw_buf) { uint16_t sort_buf[MED_SIZE]; memcpy(sort_buf, raw_buf, MED_SIZE * sizeof(uint16_t)); // 选择排序数据量小效率无所谓 for (uint8_t i 0; i MED_SIZE - 1; i) { uint8_t min_idx i; for (uint8_t j i 1; j MED_SIZE; j) { if (sort_buf[j] sort_buf[min_idx]) min_idx j; } if (min_idx ! i) { uint16_t tmp sort_buf[i]; sort_buf[i] sort_buf[min_idx]; sort_buf[min_idx] tmp; } } uint32_t sum 0; for (uint8_t i 1; i MED_SIZE - 1; i) sum sort_buf[i]; return (uint16_t)(sum / (MED_SIZE - 2)); }注意几个点sort_buf是局部数组大小为 9 时栈占用大约 18 字节在 STM32 上毫无压力但如果你把MED_SIZE改成 100就得考虑栈空间够不够了。还有这个函数需要攒够 9 个采样点才输出一个结果输出频率是采样频率的 1/9实际使用时要评估实时性能不能接受。三种滤波方法做个简单对比滤波方法实时性抗尖峰能力平滑效果计算开销滑动窗口平均高弱中等很低限幅滤波极高强无极低中值去极值平均低强好中等实际项目里我常用组合先限幅再滑动窗口偶尔数据质量特别差时上中值去极值。4.4 别小看硬件滤波软件解决不了的问题要靠它软件滤波能处理很多噪声但它治标不治本。有些问题比如采样值整体系统性偏低、波动范围很大软件再怎么算都救不回来。这时候要先回头检查硬件常见问题有这么几类采样时间太短输入源阻抗高时采样电容没充满就开始转换结果必然偏小。F103 的采样时间配置到 239.5 周期能明显改善这个问题。参考电压不稳VDDA 上如果直接接了一堆负载参考电压本身就在波动ADC 结果当然跟着跳。解决办法是把模拟电源和数字电源分开用磁珠或电感隔离靠近 VDDA 引脚放一个 100nF 和 4.7uF 的去耦电容。输入信号带宽太宽如果输入信号本身带高频噪声ADC 的采样保持电路会把混叠信号采进来。可以在 ADC 引脚前端加一个 RC 低通滤波比如 100Ω 串 100nF 电容到地截止频率大约 16kHz够滤掉大部分开关噪声。地回路问题模拟地和数字地没有单点连接时数字电路的开关噪声会通过地平面窜进模拟回路。PCB 布局上要保证模拟信号的回流路径尽量短ADC 引脚远离高频时钟线和 PWM 输出线。顺便提一嘴现在不少新型号的 MCU 已经内置了硬件滤波机制比如部分 GD32H7 系列自带 ADC 硬件滤波单元配置好寄存器后 CPU 拿到手的直接就是滤波后的结果软件压力小很多。选型的时候如果明确知道要用在噪声环境里优先考虑这类带硬滤支持的型号能省不少事。5. 常见问题与排查技巧实录建议收藏5.1 多通道数据错位、对不上号现象adc_buf[0]读到的不像是第一个通道的数据整体好像错了一位。最常见的原因是 CubeMX 里通道扫描顺序和代码里读取的顺序不一致。比如你在 CubeMX 里按 Rank 1 IN2、Rank 2 IN0、Rank 3 IN1 配置那么 DMA 填充顺序就是 IN2、IN0、IN1adc_buf[0]对应 IN2 而不是你脑补的 IN0。排查方法很简单回到 CubeMX 的 ADC 配置页对照 Rank 顺序改代码注释或者在初始化结构体里看Channel字段的排列顺序。另一个容易被忽略的原因是 DMA 传输还没开始第一次中断的数据是历史残留。一般启动 DMA 后第一次回调里的数据可能不完全对位稳妥的做法是初始化完成后先丢弃前几组数据HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buf, ADC_CH_NUM); // 等待几组数据刷新后再开始使用 for (int i 0; i 5; i) { while (!adc_data_ready); adc_data_ready 0; }5.2 第一次采样值明显偏大或偏小初次上电后ADC 内部采样电容还没有完成充电第一次转换结果往往会偏离真实值。如果连续转换已经开启了一般只有第一轮数据受影响。最简单的方案是启动后延时几毫秒再取值或者干脆丢弃第一次转换结果。代码层面我在启动 DMA 后加一段空等待HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buf, ADC_CH_NUM); HAL_Delay(5); // 等采样电容充电 adc_data_ready 0; // 清掉可能已经置位的标志这行HAL_Delay(5)不会影响采集链路但能把第一轮“不干净”的数据挡在门外。5.3 采样值一直跳软件滤波救不回来如果滤波参数已经调得够重了数据还是在跳基本就是硬件问题。排查优先级我自己是这样的先给一个固定电位给 ADC 输入比如直接用跳线把输入引脚接到 GND 或 VCC看读值是否稳定。如果固定输入还跳就是 ADC 配置或电源问题如果固定输入稳定那问题出在输入信号链路本身。接着检查配置ADC 时钟分频是否正确是否超频采样时间是否太短。再查电源万用表量一下 VDDA 是不是稳定 3.3V有没有明显纹波。最后查布线输入信号线有没有和 PWM 线、时钟线平行走线过长。按这个顺序查下来绝大多数“软件救不回来”的跳动问题都能定位到根因。5.4 DMA只进一次中断之后再也不触发这个现象非常经典程序跑起来第一次回调进了后面就再也没反应。先说原因DMA 工作在 Normal 模式而不是 Circular 模式时搬完指定数量数据就停了不会再自动搬运。如果 CubeMX 里 DMA Mode 忘记选 Circular那就会“只跑一轮就罢工”。检查 DMA 配置改成 Circular重新生成代码就能解决。还有一种情况是Continuous Conversion Mode没有打开。ADC 转完最后一通道后不启动新一轮转换DMA 自然没有新数据可搬。把 Continuous Conversion Mode 置为 Enabled 即可。还有一种更隐蔽的原因CubeMX 生成的 DMA 中断没有正确使能。如果你在主函数里手动重开 DMAHAL_ADC_Stop_DMA(hadc1); HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buf, ADC_CH_NUM);注意 Stop 和 Start 之间要确保 DMA 中断标志已经清掉最好在回调里不调用 Stop保持循环模式一直跑从根上避掉这个问题。5.5 快速排查速查表现象优先检查项处理办法无任何数据DMA 中断未使能NVIC 中打开 DMA1 Channel1 全局中断只进一次中断DMA Mode 非 Circular修改为 Circular数据全部为 0缓冲区地址不对/通道未使能检查 DMA 缓冲区是否在 SRAM引脚是否配置为模拟输入数据错位通道顺序和代码读取顺序不符对照 CubeMX Rank 顺序调整代码首个值异常采样电容首次充电启动后延时 5ms 或丢弃第一轮数据数值整体偏小采样时间过短采样时间提高到 55.5 或 239.5 周期数值跳动剧烈硬件电源/地噪声加强滤波电容分离模拟地和数字地F1 平台 DMA 不工作缓冲区放在 CCM RAM将缓冲区放在普通 SRAM禁止使用__attribute__((at(0x10000000)))最后再分享一个我自己的使用习惯软件滤波只是兜底手段不要一上来就把滤波参数调得很重那样只会掩盖硬件问题。先让硬件指标尽量做好——供电干净、地回路短、采样时间给够这时候哪怕不滤波数据质量也基本过得去。滤波只是在这个基础上再压掉最后一点波动而已。另外等这套 DMA 采集链路跑顺了有一个特别值得继续做的扩展把 DMA 缓冲区开大配合HAL_ADC_ConvHalfCpltCallback实现“乒乓缓冲”前半段在填的时候处理后半段后半段在填的时候处理前半段这样采样和处理可以真正做到完全并行。当然这就不是今天这篇能说完的了等你把 DMA 的基本功练扎实自然就会发现它还有更多可玩的余地。