STM32 什么情况用ADC + DMA ?(该不该用半传输中断?)

本笔记用 STM32F103C8T6 + MQ-2 烟雾传感器(单通道 ADC)做案例。
讲清楚 3 个核心问题:
① ADC + DMA 用在哪?
② 半传输中断用在哪?
③ 你的场景到底要不要开半传输中断?


一、为什么需要 ADC + DMA?

传统 ADC 读取的痛点

最常见的 ADC 读取代码长这样:

HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, 10);   // ★ 阻塞等待转换完成
uint16_t val = HAL_ADC_GetValue(&hadc1);

这个写法的问题:

  1. 每次读都要 CPU 干等HAL_ADC_PollForConversion 内部是 while 循环死等
  2. 读多次通道切换时更慢:多通道扫描时每切换一次都要等一次
  3. 在 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 撕裂

判断口诀

单缓冲区被"边写边读",且数据完整性要求高 → 用半传输中断

半传输中断的典型场景

  1. 音频流播放/录制(最经典)→ 这就是乒乓缓冲(Ping-Pong Buffer)
  2. 高速连续采集 + 实时信号处理(例如 1024 点 FFT)
  3. DMA 写 32 位结构体或多字节数据包(多字节才有撕裂风险)
  4. RAM 紧张,没法开双缓冲

四、什么情况下没必要用半传输中断?

判断口诀

数据是 16 位/8 位原子读写的 + 做了软件滤波(取均值)→ 不用半传输中断

详细理由

  1. F103 的 DMA 配置成 HalfWord(16 位)搬运

    • 写一个 uint16_t 是总线原子操作(一次搞定)
    • CPU 要么读到旧值、要么读新值,不会读到半新半旧
    • 撕裂根本不会发生
  2. 做了多点平均滤波

    • 单点的偶发跳变会被均值抹平
    • 即使理论上有微小概率撕裂,被平均后也看不出来

我的实际场景(MQ-2 烟雾传感器)

ADC 12 位 → DMA 半字搬运 → 填 adc_buf[8] → 取 4 个点平均

这种场景,半传输中断是教科书正确、工程上多余。直接读整个 8 格取平均,实测看不出差别。

方案 代码量 撕裂风险 适合场景
单缓冲 + 直接读均值 最少 极低 MQ-2 这类
单缓冲 + 半传输中断 多 2 个回调 + 1 个标志 理论 0 数据完整性敏感
DMA 双缓冲 最复杂 0 音频/视频流

五、代码示例

5.1 最简版(推荐新手):仅 DMA + 直接读平均

/* adc.c */
volatile uint16_t adc_buf[8];

void ADC_DMA_Start(void)
{
    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, 8);
}

/* MQ_2.c */
uint16_t MQ2_GetADCValue(void)
{
    uint32_t sum = 0;
    for (uint8_t i = 0; i < 8; i++) sum += adc_buf[i];
    return (uint16_t)(sum / 8);
}

5.2 完整版:DMA + 半传输中断

/* adc.c */
#define ADC_BUF_SIZE  8
volatile uint16_t adc_buf[ADC_BUF_SIZE];
volatile uint8_t  adc_half_ready = 0;   /* 0=后半就绪, 1=前半就绪 */

void ADC_DMA_Start(void)
{
    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, ADC_BUF_SIZE);
}

/* 半传输完成 - DMA 刚填完前半 */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc)
{
    if (hadc->Instance == ADC1) { adc_half_ready = 1; }
}

/* 全传输完成 - DMA 刚填完后半 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
{
    if (hadc->Instance == ADC1) { adc_half_ready = 0; }
}

/* MQ_2.c — 从 DMA 不在写的那一半读取 */
uint16_t MQ2_GetADCValue(void)
{
    uint32_t sum = 0;
    uint16_t half_idx = (adc_half_ready == 1) ? 0 : 4;
    for (uint8_t i = 0; i < 4; i++) sum += adc_buf[half_idx + i];
    return (uint16_t)(sum / 4);
}

六、几个易踩的坑

坑 1:变量忘加 volatile

凡是 DMA / 中断 / 其他任务访问的变量,必须 volatile

volatile uint16_t adc_buf[8];   /* DMA 写,CPU 读 */
volatile uint8_t  adc_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

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐