STM32 什么情况用ADC + DMA ?(该不该用半传输中断?)
本笔记用 STM32F103C8T6 + MQ-2 烟雾传感器(单通道 ADC)做案例。
讲清楚 3 个核心问题:
① ADC + DMA 用在哪?
② 半传输中断用在哪?
③ 你的场景到底要不要开半传输中断?
一、为什么需要 ADC + DMA?
传统 ADC 读取的痛点
最常见的 ADC 读取代码长这样:
HAL_ADC_Start(&hadc1);HAL_ADC_PollForConversion(&hadc1,10);// ★ 阻塞等待转换完成uint16_tval=HAL_ADC_GetValue(&hadc1);这个写法的问题:
- 每次读都要 CPU 干等:
HAL_ADC_PollForConversion内部是while循环死等 - 读多次通道切换时更慢:多通道扫描时每切换一次都要等一次
- 在 FreeRTOS 任务里读,等于浪费 CPU 时间片:本来这段时间可以干别的
DMA 帮我们做了什么
DMA(Direct Memory Access)是单片机内部一个独立硬件模块,直接和外设打交道、往内存写数据,全程 0 CPU 占用。
配置好 ADC + DMA 后:
ADC 自己持续转换 → DMA 自己持续搬运 → 数据自动填到内存数组 ↓ CPU 想用的时候去数组里读就行CPU 完全不参与搬运,传感器任务可以睡大觉(osDelay(50)),醒来直接读 RAM。
二、ADC + DMA 适合什么场景?
判断口诀
数据"持续地、规则地"流动,且 CPU 不需要逐字节干预 → 适合用 DMA
典型场景
| 场景 | 为什么适合 |
|---|---|
| ADC 单通道连续采集 | 传感器值一直在变,DMA 自动填数组,CPU 读均值即可 |
| ADC 多通道扫描 | 6 个传感器轮询,DMA 一次搬 6 个值,不用一个个切通道 |
| 串口大数据收发 | GPS、ESP8266、传感器模块持续吐数据,DMA 替代中断逐字节 |
| SPI 刷大屏 / 读 SD 卡 | 115KB 的图片,DMA 搬运期间 CPU 可以并行做别的 |
| I2S 音频流 | 音频本质是连续流,DMA 循环转运天然适配 |
反例(不适合用 DMA)
- 按键扫描:偶尔的事件,不需要持续搬运
- 温度报警瞬时判断:读一次就够,没必要后台跑
- DHT11 单总线:是数字协议,根本不是 ADC
三、半传输中断是什么、用在哪?
先理解"撕裂值"问题(重要!)
DMA 在循环搬运时,CPU 跟 DMA 是并行工作的。设想这个时刻:
DMA 正在写 adc_buf[i] 这个字节 ↓ CPU 同时读 adc_buf[i] ↓ CPU 可能读到 "一半新数据 + 一半旧数据" → 撕裂值半传输中断怎么解决撕裂
把缓冲区对半切,DMA 每填一半就触发一次中断通知 CPU:
adc_buf[0..3] ←→ adc_buf[4..7] 前半 后半 时刻 A:DMA 在写前半 → 中断触发 → CPU 去读后半(稳定的) 时刻 B:DMA 在写后半 → 中断触发 → CPU 去读前半(稳定的)主程序永远只读 DMA 不在写的那一半 → 0 撕裂
判断口诀
单缓冲区被"边写边读",且数据完整性要求高 → 用半传输中断
半传输中断的典型场景
- 音频流播放/录制(最经典)→ 这就是乒乓缓冲(Ping-Pong Buffer)
- 高速连续采集 + 实时信号处理(例如 1024 点 FFT)
- DMA 写 32 位结构体或多字节数据包(多字节才有撕裂风险)
- RAM 紧张,没法开双缓冲
四、什么情况下没必要用半传输中断?
判断口诀
数据是 16 位/8 位原子读写的 + 做了软件滤波(取均值)→ 不用半传输中断
详细理由
F103 的 DMA 配置成 HalfWord(16 位)搬运:
- 写一个
uint16_t是总线原子操作(一次搞定) - CPU 要么读到旧值、要么读新值,不会读到半新半旧
- 撕裂根本不会发生
- 写一个
做了多点平均滤波:
- 单点的偶发跳变会被均值抹平
- 即使理论上有微小概率撕裂,被平均后也看不出来
我的实际场景(MQ-2 烟雾传感器)
ADC 12 位 → DMA 半字搬运 → 填 adc_buf[8] → 取 4 个点平均这种场景,半传输中断是教科书正确、工程上多余。直接读整个 8 格取平均,实测看不出差别。
| 方案 | 代码量 | 撕裂风险 | 适合场景 |
|---|---|---|---|
| 单缓冲 + 直接读均值 | 最少 | 极低 | MQ-2 这类 |
| 单缓冲 + 半传输中断 | 多 2 个回调 + 1 个标志 | 理论 0 | 数据完整性敏感 |
| DMA 双缓冲 | 最复杂 | 0 | 音频/视频流 |
五、代码示例
5.1 最简版(推荐新手):仅 DMA + 直接读平均
/* adc.c */volatileuint16_tadc_buf[8];voidADC_DMA_Start(void){HAL_ADC_Start_DMA(&hadc1,(uint32_t*)adc_buf,8);}/* MQ_2.c */uint16_tMQ2_GetADCValue(void){uint32_tsum=0;for(uint8_ti=0;i<8;i++)sum+=adc_buf[i];return(uint16_t)(sum/8);}5.2 完整版:DMA + 半传输中断
/* adc.c */#defineADC_BUF_SIZE8volatileuint16_tadc_buf[ADC_BUF_SIZE];volatileuint8_tadc_half_ready=0;/* 0=后半就绪, 1=前半就绪 */voidADC_DMA_Start(void){HAL_ADC_Start_DMA(&hadc1,(uint32_t*)adc_buf,ADC_BUF_SIZE);}/* 半传输完成 - DMA 刚填完前半 */voidHAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef*hadc){if(hadc->Instance==ADC1){adc_half_ready=1;}}/* 全传输完成 - DMA 刚填完后半 */voidHAL_ADC_ConvCpltCallback(ADC_HandleTypeDef*hadc){if(hadc->Instance==ADC1){adc_half_ready=0;}}/* MQ_2.c — 从 DMA 不在写的那一半读取 */uint16_tMQ2_GetADCValue(void){uint32_tsum=0;uint16_thalf_idx=(adc_half_ready==1)?0:4;for(uint8_ti=0;i<4;i++)sum+=adc_buf[half_idx+i];return(uint16_t)(sum/4);}六、几个易踩的坑
坑 1:变量忘加volatile
凡是 DMA / 中断 / 其他任务访问的变量,必须volatile
volatileuint16_tadc_buf[8];/* DMA 写,CPU 读 */volatileuint8_tadc_half_ready;/* 中断写,主程序读 */不加volatile,编译器优化时可能缓存到寄存器,CPU 读到永远不变的旧值。
坑 2:CubeMX 不生成启动代码
HAL_ADC_Start_DMA()不会自动加,要自己在 USER CODE 区域调用。
坑 3:clock 警告
Generate Code 时报Clock not configured警告:
- 进 Clock Configuration 页面
- 把
ADC Prescaler设/6(72/6 = 12MHz,F103 的 ADC 必须 ≤ 14MHz) - HCLK = 72 MHz(F103 上限)
坑 4:误以为半传输中断万能
- 单字节/半字原子读写 → 撕裂不会发生
- 多次采样 + 取均值 → 跳变被抹平
- 新手不要被半传输中断的"高级感"迷惑,简单场景直接读平均就够了
坑 5:DMA 跟 RTOS 调度没关系
DMA 是独立硬件,不占用 CPU,不会被任务调度打断。任务优先级、FreeRTOS tick,对 DMA 通通没影响。
七、对比总结表
| 方案 | 实现难度 | 撕裂风险 | 适用场景 |
|---|---|---|---|
HAL_ADC_PollForConversion阻塞读 | ★ 最简单 | — | 临时读一次,不在意 CPU 占用 |
| 仅 DMA + 直接读均值 | ★★ 简单 | 极低(可忽略) | ★ 单通道 ADC + 滤波推荐 |
| DMA + 半传输中断 | ★★★ 中 | 理论 0 | 数据完整性敏感、单缓冲边写边读 |
| DMA 双缓冲 | ★★★★ 难 | 0 | 音频/高速采集/实时信号处理 |
八、一句话记住
ADC + DMA 解决"持续搬运 0 CPU 占用"
半传输中断解决"单缓冲边写边读的撕裂"
单点采样 + 平均滤波的场景,两者都不必纠结,直接读数组就行
附录:volatile 和 const 的区别(顺手记一下)
| 关键字 | 本质 | 典型位置 |
|---|---|---|
| 普通变量 | 可读写 + 可优化 | RAM |
const(全局) | 只读 | 全局 const 会被编译器放进 Flash 省 RAM |
const(局部) | 只读 | 栈 RAM |
volatile | 每次从内存读,不缓存优化 | RAM(状态变量)/ 外设寄存器 |
口诀:
const的值"永远不变",volatile的值"会被外部改,每次都得重新读"。
笔记完成日期:2026-06-27
测试硬件:STM32F103C8T6 + MQ-2 烟雾传感器 + ST7789 LCD
开发环境:STM32CubeMX + Keil MDK-ARM + FreeRTOS