STM32 零基础可移植教程 16:ADC + DMA 连续采样,为什么不用 CPU 一直搬数据

上一篇我们做了 ADC 多通道扫描。

流程大概是:

启动 ADC

等待第 1 个通道转换完成

读 CH0

等待第 2 个通道转换完成

读 CH1

等待第 3 个通道转换完成

读 CH2

这个方式很适合入门,因为每一步都看得见。

但工程里如果 ADC 采样频率高一点,通道多一点,CPU 就会很累。

比如你要一直采三路电压:

CH0、CH1、CH2、CH0、CH1、CH2、CH0、CH1、CH2...

如果每个数据都让 CPU 去等、去拿、去放进数组,那 CPU 就像站在快递柜旁边,来一个包裹拿一个包裹。

DMA 的作用就是:

ADC 转换完一个数据后,DMA 自动把数据搬到内存数组

CPU 不用每个点都盯着

这一篇我们继续沿用第 15 篇的硬件思路:

三路 PWM 输出不同占空比

三根杜邦线接回三路 ADC 输入

ADC 扫描三路输入

DMA 自动把结果搬进数组

主循环只负责算平均值和串口打印

这篇只解决一个明确目标:

用 ADC + DMA Circular 模式连续采集三路 PWM 平均电压

先不做复杂滤波,不做多任务,不做 FreeRTOS,也不把 DMA 和串口 DMA 混在一起。

先把 ADC + DMA 这条链路跑通。

本篇目标

最终现象:

PWM0 = 25%

PWM1 = 50%

PWM2 = 75%


ADC + DMA 后台连续采样

串口持续输出三路平均电压

串口大概会看到:

ADC DMA circular test

PWM0=25%, PWM1=50%, PWM2=75%

DMA avg(32 scans): CH0=825 mV, CH1=1650 mV, CH2=2475 mV

DMA avg(32 scans): CH0=824 mV, CH1=1648 mV, CH2=2473 mV

数值不需要完全一样。

只要大致满足:

CH0 < CH1 < CH2

并且接近 25%、50%、75% 对应的平均电压,就说明方向是对的。

本篇用到的外设:

TIM PWM Output

ADC Regular Scan

DMA Circular

USART printf

准备工作

你需要准备:

|
项目
|
说明
|
| — | — |
|
STM32 开发板
|
任意带 ADC、DMA、PWM 的 STM32 都可以
|
|
下载器
|
ST-LINK/V2 或板载 ST-LINK
|
|
杜邦线
|
3 根,用来把三路 PWM 接回三路 ADC
|
|
串口工具
|
用来看 DMA 采样结果
|
|
原理图
|
确认 PWM 输出脚和 ADC 输入脚
|

本篇建议直接复制第 15 篇工程。

因为第 15 篇已经有:

三路 PWM 输出

三路 ADC 输入

串口 printf

Rank 1/2/3 扫描顺序

这篇只是在 ADC 后面加 DMA。

硬件连接仍然是:

PWM_OUT0 -> ADC_IN0

PWM_OUT1 -> ADC_IN1

PWM_OUT2 -> ADC_IN2

再次提醒:

PWM 输出脚和 ADC 输入脚不是同一个脚

PWM 负责造信号,ADC 负责读信号。

图片

DMA 到底帮我们做了什么

不用 DMA 时,CPU 要做很多重复动作:

ADC 转换完成了吗?

好了,CPU 读一次 ADC 数据寄存器

放到变量里


ADC 又转换完成了吗?

好了,CPU 再读一次

再放到变量里

这个过程本身不难,但是很占 CPU 注意力。

用了 DMA 后,流程变成:

ADC 转换完成一个数据

DMA 自动从 ADC 数据寄存器搬到内存数组

搬完一个,数组下标自动往后走

搬满一半,通知 CPU

搬满一整圈,再通知 CPU

CPU 不需要每个采样点都去等。

它只需要在合适的时候处理一块已经采好的数据。

可以把 DMA 理解成一个“后台搬运工”:

ADC 负责生产数据

DMA 负责搬数据

内存数组负责存数据

CPU 负责处理一整块数据

图片

先看懂 DMA 数组里数据怎么排

这是 ADC 多通道 DMA 最容易把新手绕晕的地方。

我们配置 3 个 ADC 通道:

Rank 1 -> CH0

Rank 2 -> CH1

Rank 3 -> CH2

ADC 每扫描一轮,会产生 3 个数据:

CH0, CH1, CH2

DMA 会按转换顺序把它们放进数组。

所以数组不是这样排的:

CH0, CH0, CH0, CH0...

CH1, CH1, CH1, CH1...

CH2, CH2, CH2, CH2...

而是这样排的:

buffer[0] =1 轮 CH0

buffer[1] =1 轮 CH1

buffer[2] =1 轮 CH2


buffer[3] =2 轮 CH0

buffer[4] =2 轮 CH1

buffer[5] =2 轮 CH2


buffer[6] =3 轮 CH0

buffer[7] =3 轮 CH1

buffer[8] =3 轮 CH2

也就是:

CH0, CH1, CH2, CH0, CH1, CH2, CH0, CH1, CH2...

代码里算平均值时,就要按这个规律取:

buffer[scan * 
3
 + 
0
] -> CH0

buffer[scan * 
3
 + 
1
] -> CH1

buffer[scan * 
3
 + 
2
] -> CH2

这里的 0/1/2 对应的是 ADC Rank 顺序,不是天然对应 ADC 通道号。

图片

为什么要用 Circular 模式

DMA 常见有两种模式:

|
模式
|
含义
|
本篇是否使用
|
| — | — | — |
|
Normal
|
搬满指定长度后停止
|
暂不使用
|
|
Circular
|
搬满后从数组开头继续搬
|
使用
|

我们希望 ADC 一直采样,所以选:

DMA Circular

这样 DMA buffer 会像一个环:

前半段填满 -> 回调通知 CPU

后半段填满 -> 回调通知 CPU

再回到前半段继续填

本篇代码把数组分成两半:

前半段:32 轮扫描,每轮 3 个通道,共 96 个数据

后半段:32 轮扫描,每轮 3 个通道,共 96 个数据

总数组长度是:

3 通道 x 32 轮 x 2= 192 个数据

当 DMA 搬满前 96 个数据时,会进入:

HAL_ADC_ConvHalfCpltCallback()

当 DMA 搬满后 96 个数据时,会进入:

HAL_ADC_ConvCpltCallback()

我们的回调里不算平均值,只做一件事:

告诉主循环:有一半数据准备好了

真正的计算和 printf 放在主循环里做。

这是一个好习惯。

中断和回调里尽量少干活,主循环里再处理业务。

CubeMX 配置步骤

1. 复制第 15 篇工程

建议复制第 15 篇工程,改名为:

16_adc_dma

如果你重新建工程,也可以按前面的流程走:

  1. 选择芯片型号;

  2. SYS -> Debug 设置为 Serial Wire

  3. 配好 USART,用于 printf;

  4. 配好三路 PWM;

  5. 配好三路 ADC;

  6. 再加 DMA。

图片

2. 保留三路 PWM 输出

这部分和第 15 篇一样。

本篇代码默认三路 PWM 来自:

TIM3_CH1

TIM3_CH2

TIM3_CH3

如果你的开发板实际能用的是:

PB5 / TIM3_CH2

PB0 / TIM3_CH3

PB1 / TIM3_CH4

那就配置 TIM3 的 CH2、CH3、CH4。

代码里后面把通道宏改成:

#define APP_TEST_PWM_CH0 TIM_CHANNEL_2

#define APP_TEST_PWM_CH1 TIM_CHANNEL_3

#define APP_TEST_PWM_CH2 TIM_CHANNEL_4

PWM 推荐先保持:

频率:1 kHz

占空比:代码里设置成 25%、50%、75%

图片

3. 配置 ADC 三通道扫描

ADC 还是配置 3 个 Regular Channel。

示例:

Rank 1 -> ADC_CHANNEL_1

Rank 2 -> ADC_CHANNEL_2

Rank 3 -> ADC_CHANNEL_3

对应接线:

PWM_OUT0 -> PA1 / ADC1_IN1

PWM_OUT1 -> PA2 / ADC1_IN2

PWM_OUT2 -> PA3 / ADC1_IN3

ADC 参数推荐:

|
配置项
|
推荐值
|
说明
|
| — | — | — |
|
Scan Conversion Mode
|
Enable
|
必须开,多通道扫描
|
|
Continuous Conversion Mode
|
Enable
|
必须连续转换,配合 DMA 后台不断采样
|
|
Discontinuous Conversion Mode
|
Disable
|
新手先不要开
|
|
External Trigger Conversion Source
|
Regular Conversion launched by software
|
软件启动一次,后面连续转换
|
|
Data Alignment
|
Right alignment
|
常用右对齐
|
|
Number Of Conversion
|
3
|
一轮扫描 3 个通道
|
|
DMA Continuous Requests
|
Enable
|
如果你的 CubeMX 有这个选项,打开
|

Sampling Time 可以先选长一点:

55.5 Cycles

或者:

71.5 Cycles

采样速度不是本篇重点,先让结果稳定。

图片

4. 给 ADC 添加 DMA

进入 ADC 的 DMA Settings。

添加一个 DMA 请求。

对于 STM32F103 这类芯片,ADC1 常见对应:

DMA1 Channel1

不同芯片系列名字可能不一样,以 CubeMX 显示为准。

DMA 参数推荐:

|
配置项
|
推荐值
|
说明
|
| — | — | — |
|
Direction
|
Peripheral to Memory
|
ADC 数据寄存器搬到内存
|
|
Mode
|
Circular
|
搬满后循环继续
|
|
Peripheral Increment
|
Disable
|
ADC 数据寄存器地址不变
|
|
Memory Increment
|
Enable
|
内存数组地址要往后走
|
|
Peripheral Data Width
|
Half Word
|
ADC 12 位数据,用 16 位存
|
|
Memory Data Width
|
Half Word
|
数组用 uint16_t
|
|
Priority
|
Low / Medium
|
入门先用默认或中等
|

图片

5. 打开 DMA 中断

DMA 要触发半满和全满回调,需要 DMA 中断。

在 CubeMX 里确认:

DMA interrupt 已启用

对于 STM32F103,可能看到:

DMA1 Channel1 global interrupt

并且生成的 stm32f1xx_it.c 里应该有类似:

void DMA1_Channel1_IRQHandler(void)
{

  HAL_DMA_IRQHandler(&hdma_adc1);

}

如果没有这个中断函数,DMA 可能能搬数据,但半满/全满回调进不来。

图片

6. 确认 MX_DMA_Init 的位置

CubeMX 通常会在 main.c 里生成:

MX_GPIO_Init();

MX_DMA_Init();

MX_ADC1_Init();

MX_TIM3_Init();

MX_USART1_UART_Init();

注意:

MX_DMA_Init() 一般要在 MX_ADC1_Init() 前面

因为 ADC 初始化时会把 ADC 句柄和 DMA 句柄关联起来。

如果顺序不对,后面 HAL_ADC_Start_DMA() 可能出问题。

图片

Keil 工程生成和编译

打开 Keil 后,先编译 CubeMX 生成的工程:

Build / F7

确认没有错误:

0 Error(s)

然后添加本篇代码文件。

本篇新增 4 个文件:

Core/Inc/app_adc_dma.h

Core/Src/app_adc_dma.c

Core/Inc/app_test_pwm_multi.h

Core/Src/app_test_pwm_multi.c

app_test_pwm_multi.h/.c 和第 15 篇一样,用来输出三路 PWM。

app_adc_dma.h/.c 是本篇重点,用来启动 ADC DMA、处理半缓冲和全缓冲数据。

如果你手动新建 .c 文件,记得在 Keil 工程树里添加:

Core/Src/app_adc_dma.c

Core/Src/app_test_pwm_multi.c

图片

完整代码

1. 新建 Core/Inc/app_adc_dma.h

#ifndef APP_ADC_DMA_H

#define APP_ADC_DMA_H


#include "main.h"

#include <stdint.h>


#define APP_ADC_DMA_CHANNEL_COUNT       3u

#define APP_ADC_DMA_SCAN_COUNT_PER_BLOCK 32u


typedef
struct{

    
uint32_t
 raw[APP_ADC_DMA_CHANNEL_COUNT];

    
uint32_t
 voltage_mv[APP_ADC_DMA_CHANNEL_COUNT];

    
uint32_t
 scan_count;

} App_ADCDMA_Result;


void App_ADCDMA_Init(void)
;

HAL_StatusTypeDef App_ADCDMA_Start(void)
;

void App_ADCDMA_Stop(void)
;

uint8_t App_ADCDMA_GetResult(App_ADCDMA_Result *result)
;


#endif

这里有两个宏要看懂:

#define APP_ADC_DMA_CHANNEL_COUNT        3u

#define APP_ADC_DMA_SCAN_COUNT_PER_BLOCK 32u

意思是:

每轮扫描 3 个通道

每半个 DMA buffer 里放 32 轮扫描

所以每半块数据长度是:

3 x 32 = 96 个 ADC 数据

2. 新建 Core/Src/app_adc_dma.c

#include "app_adc_dma.h"


#ifndef APP_ADC_DMA_HANDLE

#define APP_ADC_DMA_HANDLE hadc1

#endif


#ifndef APP_ADC_DMA_VREF_MV

#define APP_ADC_DMA_VREF_MV 3300u

#endif


#ifndef APP_ADC_DMA_MAX_RAW

#define APP_ADC_DMA_MAX_RAW 4095u

#endif


#ifndef APP_ADC_DMA_ENABLE_CALIBRATION

#define APP_ADC_DMA_ENABLE_CALIBRATION 0u

#endif


#define APP_ADC_DMA_BLOCK_LENGTH \    (APP_ADC_DMA_CHANNEL_COUNT * APP_ADC_DMA_SCAN_COUNT_PER_BLOCK)


#define APP_ADC_DMA_BUFFER_LENGTH \    (APP_ADC_DMA_BLOCK_LENGTH * 2u)


extern
 ADC_HandleTypeDef APP_ADC_DMA_HANDLE;


static
uint16_t
 s_adc_dma_buffer[APP_ADC_DMA_BUFFER_LENGTH];

static
volatile
uint8_t
 s_half_ready = 
0u
;

static
volatile
uint8_t
 s_full_ready = 
0u
;


static uint32_t App_ADCDMA_RawToVoltageMv(uint32_t raw)
{

    
return
 (raw * APP_ADC_DMA_VREF_MV) / APP_ADC_DMA_MAX_RAW;

}


static void App_ADCDMA_CalcBlock(uint32_t start_index, App_ADCDMA_Result *result)
{

    
uint64_t
 sum[APP_ADC_DMA_CHANNEL_COUNT] = {
0u
};

    
uint32_t
 scan;

    
uint8_t
 ch;


    
for
 (scan = 
0u
; scan < APP_ADC_DMA_SCAN_COUNT_PER_BLOCK; scan++)

    {

        
for
 (ch = 
0u
; ch < APP_ADC_DMA_CHANNEL_COUNT; ch++)

        {

            sum[ch] += s_adc_dma_buffer[start_index +

                                        (scan * APP_ADC_DMA_CHANNEL_COUNT) +

                                        ch];

        }

    }


    
for
 (ch = 
0u
; ch < APP_ADC_DMA_CHANNEL_COUNT; ch++)

    {

        result->raw[ch] = (
uint32_t
)(sum[ch] / APP_ADC_DMA_SCAN_COUNT_PER_BLOCK);

        result->voltage_mv[ch] = App_ADCDMA_RawToVoltageMv(result->raw[ch]);

    }


    result->scan_count = APP_ADC_DMA_SCAN_COUNT_PER_BLOCK;

}


void App_ADCDMA_Init(void)
{

    s_half_ready = 
0u
;

    s_full_ready = 
0u
;


#if APP_ADC_DMA_ENABLE_CALIBRATION

    (
void
)HAL_ADCEx_Calibration_Start(&APP_ADC_DMA_HANDLE);

#endif

}


HAL_StatusTypeDef App_ADCDMA_Start(void)
{

    s_half_ready = 
0u
;

    s_full_ready = 
0u
;


    
return
 HAL_ADC_Start_DMA(&APP_ADC_DMA_HANDLE,

                             (
uint32_t
 *)s_adc_dma_buffer,

                             APP_ADC_DMA_BUFFER_LENGTH);

}


void App_ADCDMA_Stop(void)
{

    (
void
)HAL_ADC_Stop_DMA(&APP_ADC_DMA_HANDLE);

}


uint8_t App_ADCDMA_GetResult(App_ADCDMA_Result *result)
{

    
uint32_t
 start_index;

    
uint8_t
 has_block = 
0u
;


    
if
 (result == 
0
)

    {

        
return
0u
;

    }


    __disable_irq();

    
if
 (s_half_ready != 
0u
)

    {

        s_half_ready = 
0u
;

        start_index = 
0u
;

        has_block = 
1u
;

    }

    
else
if
 (s_full_ready != 
0u
)

    {

        s_full_ready = 
0u
;

        start_index = APP_ADC_DMA_BLOCK_LENGTH;

        has_block = 
1u
;

    }

    
else

    {

        start_index = 
0u
;

    }

    __enable_irq();


    
if
 (has_block == 
0u
)

    {

        
return
0u
;

    }


    App_ADCDMA_CalcBlock(start_index, result);


    
return
1u
;

}


void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{

    
if
 (hadc == &APP_ADC_DMA_HANDLE)

    {

        s_half_ready = 
1u
;

    }

}


void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{

    
if
 (hadc == &APP_ADC_DMA_HANDLE)

    {

        s_full_ready = 
1u
;

    }

}

这段代码里有几个重点。

第一,DMA buffer 是 uint16_t

static
 
uint16_t
 s_adc_dma_buffer[APP_ADC_DMA_BUFFER_LENGTH];

因为 ADC 是 12 位结果,用 16 位存比较合适。

第二,启动 DMA 时传入整个 buffer:

HAL_ADC_Start_DMA(&APP_ADC_DMA_HANDLE,

                  (
uint32_t
 *)s_adc_dma_buffer,

                  APP_ADC_DMA_BUFFER_LENGTH);

APP_ADC_DMA_BUFFER_LENGTH 是转换次数,不是字节数。

第三,回调里只置标志:

s_half_ready = 
1u
;

s_full_ready = 
1u
;

不要在回调里 printf()

也不要在回调里做很长的平均计算。

第四,App_ADCDMA_GetResult() 里用了:

__disable_irq();

...

__enable_irq();

这里是在保护 s_half_ready 和 s_full_ready 这两个标志。

因为它们会在 DMA 中断回调里被修改,也会在主循环里被读取和清零。

这个点前面“临界区”番外已经讲过,这里就是一个真实应用场景。

3. 新建 Core/Inc/app_test_pwm_multi.h

这个文件和第 15 篇一致,用来输出三路 PWM。

#ifndef APP_TEST_PWM_MULTI_H

#define APP_TEST_PWM_MULTI_H


#include "main.h"

#include <stdint.h>


#define APP_TEST_PWM_COUNT 3u


void App_TestPWMMulti_Init(void)
;

HAL_StatusTypeDef App_TestPWMMulti_Start(void)
;

void App_TestPWMMulti_Stop(void)
;

void App_TestPWMMulti_SetFrequency(uint32_t frequency_hz)
;

void App_TestPWMMulti_SetDutyPermille(uint8_t index, uint16_t duty_permille)
;

void App_TestPWMMulti_SetAllDuty(uint16_t duty0_permille,                                 uint16_t duty1_permille,                                 uint16_t duty2_permille)
;


#endif

4. 新建 Core/Src/app_test_pwm_multi.c

#include "app_test_pwm_multi.h"


#ifndef APP_TEST_PWM_MULTI_HANDLE

#define APP_TEST_PWM_MULTI_HANDLE htim3

#endif


#ifndef APP_TEST_PWM_CH0

#define APP_TEST_PWM_CH0 TIM_CHANNEL_1

#endif


#ifndef APP_TEST_PWM_CH1

#define APP_TEST_PWM_CH1 TIM_CHANNEL_2

#endif


#ifndef APP_TEST_PWM_CH2

#define APP_TEST_PWM_CH2 TIM_CHANNEL_3

#endif


#ifndef APP_TEST_PWM_MULTI_COUNTER_CLK_HZ

#define APP_TEST_PWM_MULTI_COUNTER_CLK_HZ 1000000u

#endif


#ifndef APP_TEST_PWM_MULTI_DEFAULT_FREQ_HZ

#define APP_TEST_PWM_MULTI_DEFAULT_FREQ_HZ 1000u

#endif


#ifndef APP_TEST_PWM_MULTI_MAX_ARR

#define APP_TEST_PWM_MULTI_MAX_ARR 0xFFFFu

#endif


extern
 TIM_HandleTypeDef APP_TEST_PWM_MULTI_HANDLE;


static
const
uint32_t
 s_pwm_channel[APP_TEST_PWM_COUNT] =

{

    APP_TEST_PWM_CH0,

    APP_TEST_PWM_CH1,

    APP_TEST_PWM_CH2

};


static
uint32_t
 s_period_counts = 
1000u
;

static
uint16_t
 s_duty_permille[APP_TEST_PWM_COUNT] = {
250u
, 
500u
, 
750u
};


void App_TestPWMMulti_Init(void)
{

    App_TestPWMMulti_SetFrequency(APP_TEST_PWM_MULTI_DEFAULT_FREQ_HZ);

    App_TestPWMMulti_SetAllDuty(
250u
, 
500u
, 
750u
);

}


HAL_StatusTypeDef App_TestPWMMulti_Start(void)
{

    HAL_StatusTypeDef status;

    
uint8_t
 i;


    
for
 (i = 
0u
; i < APP_TEST_PWM_COUNT; i++)

    {

        status = HAL_TIM_PWM_Start(&APP_TEST_PWM_MULTI_HANDLE, s_pwm_channel[i]);

        
if
 (status != HAL_OK)

        {

            
return
 status;

        }

    }


    
return
 HAL_OK;

}


void App_TestPWMMulti_Stop(void)
{

    
uint8_t
 i;


    
for
 (i = 
0u
; i < APP_TEST_PWM_COUNT; i++)

    {

        HAL_TIM_PWM_Stop(&APP_TEST_PWM_MULTI_HANDLE, s_pwm_channel[i]);

    }

}


void App_TestPWMMulti_SetFrequency(uint32_t frequency_hz)
{

    
uint32_t
 arr;


    
if
 (frequency_hz == 
0u
)

    {

        App_TestPWMMulti_Stop();

        
return
;

    }


    s_period_counts = APP_TEST_PWM_MULTI_COUNTER_CLK_HZ / frequency_hz;

    
if
 (s_period_counts < 
2u
)

    {

        s_period_counts = 
2u
;

    }


    arr = s_period_counts - 
1u
;

    
if
 (arr > APP_TEST_PWM_MULTI_MAX_ARR)

    {

        arr = APP_TEST_PWM_MULTI_MAX_ARR;

        s_period_counts = arr + 
1u
;

    }


    __HAL_TIM_SET_AUTORELOAD(&APP_TEST_PWM_MULTI_HANDLE, arr);

    App_TestPWMMulti_SetAllDuty(s_duty_permille[
0
], s_duty_permille[
1
], s_duty_permille[
2
]);

    __HAL_TIM_SET_COUNTER(&APP_TEST_PWM_MULTI_HANDLE, 
0u
);

    __HAL_TIM_GENERATE_EVENT(&APP_TEST_PWM_MULTI_HANDLE, TIM_EVENTSOURCE_UPDATE);

}


void App_TestPWMMulti_SetDutyPermille(uint8_t index, uint16_t duty_permille)
{

    
uint32_t
 ccr;


    
if
 (index >= APP_TEST_PWM_COUNT)

    {

        
return
;

    }


    
if
 (duty_permille > 
1000u
)

    {

        duty_permille = 
1000u
;

    }


    s_duty_permille[index] = duty_permille;

    ccr = (s_period_counts * duty_permille) / 
1000u
;


    __HAL_TIM_SET_COMPARE(&APP_TEST_PWM_MULTI_HANDLE, s_pwm_channel[index], ccr);

}


void App_TestPWMMulti_SetAllDuty(uint16_t duty0_permille,                                 uint16_t duty1_permille,                                 uint16_t duty2_permille)
{

    App_TestPWMMulti_SetDutyPermille(
0u
, duty0_permille);

    App_TestPWMMulti_SetDutyPermille(
1u
, duty1_permille);

    App_TestPWMMulti_SetDutyPermille(
2u
, duty2_permille);

}

main.c 调用方式

1. 添加头文件

在 main.c 顶部添加:

/* USER CODE BEGIN Includes */

#include "app_adc_dma.h"

#include "app_test_pwm_multi.h"

#include <stdio.h>

/* USER CODE END Includes */

2. 初始化后启动 PWM 和 ADC DMA

确认 main() 里有这些 CubeMX 初始化函数:

MX_GPIO_Init();

MX_DMA_Init();

MX_ADC1_Init();

MX_TIM3_Init();

MX_USART1_UART_Init();

然后在 USER CODE BEGIN 2 中添加:

/* USER CODE BEGIN 2 */

App_TestPWMMulti_Init();

App_TestPWMMulti_Start();


App_ADCDMA_Init();

if
 (App_ADCDMA_Start() != HAL_OK)

{

    
printf
(
"ADC DMA start failed\r\n"
);

}


printf
(
"\r\nADC DMA circular test\r\n"
);

printf
(
"PWM0=25%%, PWM1=50%%, PWM2=75%%\r\n"
);

/* USER CODE END 2 */

顺序建议是:

先启动 PWM

再启动 ADC DMA

这样 ADC 开始采样时,输入端已经有 PWM 信号。

3. while 循环里处理 DMA 数据

/* USER CODE BEGIN WHILE */

while
 (
1
)

{

/* USER CODE END WHILE */


/* USER CODE BEGIN 3 */

  App_ADCDMA_Result adc;


if
 (App_ADCDMA_GetResult(&adc) != 
0u
)

  {

      
printf
(
"DMA avg(%lu scans): CH0=%lu mV, CH1=%lu mV, CH2=%lu mV\r\n"
,

             adc.scan_count,

             adc.voltage_mv[
0
],

             adc.voltage_mv[
1
],

             adc.voltage_mv[
2
]);

  }


  HAL_Delay(
10
);

/* USER CODE END 3 */

}

这里的 HAL_Delay(10) 不是用来控制采样。

ADC 和 DMA 已经在后台采样了。

这个延时只是避免主循环空转太快。

编译、下载和验证

代码加完后:

  1. Keil 编译;

  2. 下载程序;

  3. 打开串口助手;

  4. 复位开发板;

  5. 观察输出。

图片

串口参数仍然是:

115200

8 数据位

1 停止位

无校验

正常输出类似:

ADC DMA circular test

PWM0=25%, PWM1=50%, PWM2=75%

DMA avg(32 scans): CH0=826 mV, CH1=1649 mV, CH2=2470 mV

DMA avg(32 scans): CH0=823 mV, CH1=1651 mV, CH2=2475 mV

如果你想验证三路是否真的对应,可以改:

App_TestPWMMulti_SetAllDuty(
100u
, 
400u
, 
900u
);

正常应该看到:

CH0 变小

CH1 中等

CH2 变大

移植到其他板子的修改点

|
要改的地方
|
为什么要改
|
在哪里改
|
| — | — | — |
|
PWM 输出引脚
|
不同板子引出的 PWM 脚不同
|
CubeMX TIM PWM 通道
|
|
PWM 通道宏
|
代码默认 TIM3 CH1/CH2/CH3
| app_test_pwm_multi.c |
|
PWM 定时器实例
|
可能不是 htim3
| APP_TEST_PWM_MULTI_HANDLE |
|
ADC 输入通道
|
不同板子的模拟输入脚不同
|
CubeMX ADC Channel
|
|
ADC Rank 顺序
|
决定 DMA buffer 里的数据顺序
|
CubeMX ADC Regular Rank
|
|
ADC 句柄
|
可能不是 hadc1
| APP_ADC_DMA_HANDLE |
|
DMA 通道/请求
|
不同芯片 ADC 对应 DMA 不同
|
CubeMX DMA Settings
|
|
DMA 模式
|
本篇必须 Circular
|
CubeMX DMA Mode
|
|
DMA 数据宽度
|
ADC 12 位建议 Half Word
|
CubeMX DMA Data Width
|
|
参考电压
|
VDDA 不一定刚好 3.3V
| APP_ADC_DMA_VREF_MV |
|
ADC 分辨率
|
不是所有系列都固定 12 位
| APP_ADC_DMA_MAX_RAW |

如果你的三路 PWM 是:

PB5 / TIM3_CH2

PB0 / TIM3_CH3

PB1 / TIM3_CH4

那么把 app_test_pwm_multi.c 里的通道宏改成:

#define APP_TEST_PWM_CH0 TIM_CHANNEL_2

#define APP_TEST_PWM_CH1 TIM_CHANNEL_3

#define APP_TEST_PWM_CH2 TIM_CHANNEL_4

ADC 输入仍然建议另选:

PA1 / ADC1_IN1

PA2 / ADC1_IN2

PA3 / ADC1_IN3

然后用杜邦线连接:

PB5 -> PA1

PB0 -> PA2

PB1 -> PA3

常见问题排查

1. HAL_ADC_Start_DMA() 返回错误

优先检查:

|
检查项
|
说明
|
| — | — |
|
ADC 是否开启 DMA
|
CubeMX ADC DMA Settings 里是否添加 DMA
|
|
DMA 是否初始化
| MX_DMA_Init()
 是否生成并调用
|
|
初始化顺序
| MX_DMA_Init()
 是否在 MX_ADC1_Init() 前面
|
|
ADC 句柄是否正确
|
代码默认 hadc1
|
|
DMA 句柄是否关联
| adc.c
 里是否有 __HAL_LINKDMA()
|

2. 串口只打印开头,不打印平均值

这通常说明 DMA 回调没有进来。

检查:

  • DMA Mode 是否是 Circular;

  • DMA 中断是否开启;

  • DMA1_Channel1_IRQHandler() 是否存在;

  • 中断函数里是否调用了 HAL_DMA_IRQHandler(&hdma_adc1)

  • ADC 是否 Continuous Conversion Enable;

  • 如果有 DMA Continuous Requests 选项,是否 Enable。

3. 三路数值顺序不对

这大概率是 Rank 顺序、接线顺序、PWM 通道顺序没对上。

按这个顺序查:

  1. 看 PWM_OUT0/1/2 实际是哪几个引脚;

  2. 看三根杜邦线接到哪几个 ADC 输入;

  3. 看 CubeMX ADC Rank 1/2/3;

  4. 看 APP_TEST_PWM_CH0/1/2

  5. 看 App_TestPWMMulti_SetAllDuty() 三个参数。

记住:

DMA buffer 里的第 0、1、2 个数据,对应的是 Rank 1、Rank 2、Rank 3

不是你心里以为的 PA1、PA2、PA3。

4. 编译提示回调函数重复定义

如果你的工程里别的文件已经写了:

HAL_ADC_ConvHalfCpltCallback()

HAL_ADC_ConvCpltCallback()

那再把本篇代码直接加进去,就会重复定义。

解决方法不是删掉其中一个功能,而是合并回调。

例如在已有回调里加判断:

if
 (hadc == &hadc1)

{

    
/* 调用 ADC DMA 相关处理 */

}

一个工程里同名 HAL 回调函数只能定义一次。

5. 数值跳动比较明显

可以尝试:

  • 增大 APP_ADC_DMA_SCAN_COUNT_PER_BLOCK,比如从 32 改成 64;

  • 提高 PWM 频率;

  • 增加 ADC Sampling Time;

  • 确认 PWM 输出和 ADC 输入接线牢靠;

  • 后续接真实模拟传感器时,前端加 RC 滤波。

6. 程序卡在 Error_Handler

常见原因:

  • CubeMX 配置的 ADC/DMA 不完整;

  • DMA 初始化顺序不对;

  • ADC 校准 API 不适配当前芯片;

  • PWM 或 ADC 引脚冲突;

  • HAL_ADC_Start_DMA() 返回错误后你在代码里进入了错误处理。

排查时可以先把错误打印出来,不要一上来就怀疑 DMA 原理。

7. DMA 能用,但 CPU 读取的数据偶尔怪

本篇用了半缓冲和全缓冲回调,就是为了避免 CPU 正在处理的那一半数据被 DMA 继续写。

如果你自己改成直接随便读整个 buffer,就可能读到 DMA 正在更新的数据。

入门阶段建议先保留这种结构:

半满 -> 处理前半段

全满 -> 处理后半段

等这个模式跑通了,再考虑更复杂的数据处理。

本篇小结

这一篇我们完成了 ADC + DMA 连续采样。

你现在应该知道:

  • ADC 负责转换模拟量;

  • DMA 负责把 ADC 数据自动搬到内存数组;

  • Circular 模式适合连续采样;

  • 多通道 ADC DMA 的数组顺序是 CH0, CH1, CH2, CH0, CH1, CH2...

  • 半满回调和全满回调适合通知主循环处理一块数据;

  • 回调里不要做复杂计算和 printf()

  • 主循环和中断共享标志时,可以用很短的临界区保护。

到这里,ADC 这条线已经从:

单通道轮询

走到了:

多通道扫描 + DMA 连续搬运

下一篇我们切到串口 DMA:

STM32 USART + DMA + IDLE:串口不定长接收怎么做。

前面我们已经做过串口单字节中断和一行命令,下一篇会把串口接收升级成更接近工程项目的写法。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐