STM32 零基础可移植教程 16:ADC + DMA 连续采样,为什么不用 CPU 一直搬数据
上一篇我们做了 ADC 多通道扫描。
流程大概是:
bash
启动 ADC
等待第 1 个通道转换完成
读 CH0
等待第 2 个通道转换完成
读 CH1
等待第 3 个通道转换完成
读 CH2
这个方式很适合入门,因为每一步都看得见。
但工程里如果 ADC 采样频率高一点,通道多一点,CPU 就会很累。
比如你要一直采三路电压:
bash
CH0、CH1、CH2、CH0、CH1、CH2、CH0、CH1、CH2...
如果每个数据都让 CPU 去等、去拿、去放进数组,那 CPU 就像站在快递柜旁边,来一个包裹拿一个包裹。
DMA 的作用就是:
bash
ADC 转换完一个数据后,DMA 自动把数据搬到内存数组
CPU 不用每个点都盯着
这一篇我们继续沿用第 15 篇的硬件思路:
bash
三路 PWM 输出不同占空比
三根杜邦线接回三路 ADC 输入
ADC 扫描三路输入
DMA 自动把结果搬进数组
主循环只负责算平均值和串口打印
这篇只解决一个明确目标:
bash
用 ADC + DMA Circular 模式连续采集三路 PWM 平均电压
先不做复杂滤波,不做多任务,不做 FreeRTOS,也不把 DMA 和串口 DMA 混在一起。
先把 ADC + DMA 这条链路跑通。
本篇目标
最终现象:
bash
PWM0 = 25%
PWM1 = 50%
PWM2 = 75%
ADC + DMA 后台连续采样
串口持续输出三路平均电压
串口大概会看到:
bash
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
数值不需要完全一样。
只要大致满足:
bash
CH0 < CH1 < CH2
并且接近 25%、50%、75% 对应的平均电压,就说明方向是对的。
本篇用到的外设:
bash
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 篇已经有:
bash
三路 PWM 输出
三路 ADC 输入
串口 printf
Rank 1/2/3 扫描顺序
这篇只是在 ADC 后面加 DMA。
硬件连接仍然是:
bash
PWM_OUT0 -> ADC_IN0
PWM_OUT1 -> ADC_IN1
PWM_OUT2 -> ADC_IN2
再次提醒:
bash
PWM 输出脚和 ADC 输入脚不是同一个脚
PWM 负责造信号,ADC 负责读信号。

DMA 到底帮我们做了什么
不用 DMA 时,CPU 要做很多重复动作:
bash
ADC 转换完成了吗?
好了,CPU 读一次 ADC 数据寄存器
放到变量里
ADC 又转换完成了吗?
好了,CPU 再读一次
再放到变量里
这个过程本身不难,但是很占 CPU 注意力。
用了 DMA 后,流程变成:
bash
ADC 转换完成一个数据
DMA 自动从 ADC 数据寄存器搬到内存数组
搬完一个,数组下标自动往后走
搬满一半,通知 CPU
搬满一整圈,再通知 CPU
CPU 不需要每个采样点都去等。
它只需要在合适的时候处理一块已经采好的数据。
可以把 DMA 理解成一个"后台搬运工":
bash
ADC 负责生产数据
DMA 负责搬数据
内存数组负责存数据
CPU 负责处理一整块数据

先看懂 DMA 数组里数据怎么排
这是 ADC 多通道 DMA 最容易把新手绕晕的地方。
我们配置 3 个 ADC 通道:
bash
Rank 1 -> CH0
Rank 2 -> CH1
Rank 3 -> CH2
ADC 每扫描一轮,会产生 3 个数据:
bash
CH0, CH1, CH2
DMA 会按转换顺序把它们放进数组。
所以数组不是这样排的:
bash
CH0, CH0, CH0, CH0...
CH1, CH1, CH1, CH1...
CH2, CH2, CH2, CH2...
而是这样排的:
bash
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
也就是:
bash
CH0, CH1, CH2, CH0, CH1, CH2, CH0, CH1, CH2...
代码里算平均值时,就要按这个规律取:
bash
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 一直采样,所以选:
bash
DMA Circular
这样 DMA buffer 会像一个环:
bash
前半段填满 -> 回调通知 CPU
后半段填满 -> 回调通知 CPU
再回到前半段继续填
本篇代码把数组分成两半:
bash
前半段:32 轮扫描,每轮 3 个通道,共 96 个数据
后半段:32 轮扫描,每轮 3 个通道,共 96 个数据
总数组长度是:
bash
3 通道 x 32 轮 x 2 半 = 192 个数据
当 DMA 搬满前 96 个数据时,会进入:
bash
HAL_ADC_ConvHalfCpltCallback()
当 DMA 搬满后 96 个数据时,会进入:
bash
HAL_ADC_ConvCpltCallback()
我们的回调里不算平均值,只做一件事:
bash
告诉主循环:有一半数据准备好了
真正的计算和 printf 放在主循环里做。
这是一个好习惯。
中断和回调里尽量少干活,主循环里再处理业务。
CubeMX 配置步骤
1. 复制第 15 篇工程
建议复制第 15 篇工程,改名为:
bash
16_adc_dma
如果你重新建工程,也可以按前面的流程走:
-
选择芯片型号;
-
SYS -> Debug设置为Serial Wire; -
配好 USART,用于 printf;
-
配好三路 PWM;
-
配好三路 ADC;
-
再加 DMA。

2. 保留三路 PWM 输出
这部分和第 15 篇一样。
本篇代码默认三路 PWM 来自:
bash
TIM3_CH1
TIM3_CH2
TIM3_CH3
如果你的开发板实际能用的是:
bash
PB5 / TIM3_CH2
PB0 / TIM3_CH3
PB1 / TIM3_CH4
那就配置 TIM3 的 CH2、CH3、CH4。
代码里后面把通道宏改成:
bash
#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 推荐先保持:
bash
频率:1 kHz
占空比:代码里设置成 25%、50%、75%

3. 配置 ADC 三通道扫描
ADC 还是配置 3 个 Regular Channel。
示例:
bash
Rank 1 -> ADC_CHANNEL_1
Rank 2 -> ADC_CHANNEL_2
Rank 3 -> ADC_CHANNEL_3
对应接线:
bash
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 可以先选长一点:
bash
55.5 Cycles
或者:
bash
71.5 Cycles
采样速度不是本篇重点,先让结果稳定。

4. 给 ADC 添加 DMA
进入 ADC 的 DMA Settings。
添加一个 DMA 请求。
对于 STM32F103 这类芯片,ADC1 常见对应:
bash
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 里确认:
bash
DMA interrupt 已启用
对于 STM32F103,可能看到:
bash
DMA1 Channel1 global interrupt
并且生成的 stm32f1xx_it.c 里应该有类似:
bash
void DMA1_Channel1_IRQHandler(void)
{
HAL_DMA_IRQHandler(&hdma_adc1);
}
如果没有这个中断函数,DMA 可能能搬数据,但半满/全满回调进不来。

6. 确认 MX_DMA_Init 的位置
CubeMX 通常会在 main.c 里生成:
bash
MX_GPIO_Init();
MX_DMA_Init();
MX_ADC1_Init();
MX_TIM3_Init();
MX_USART1_UART_Init();
注意:
bash
MX_DMA_Init() 一般要在 MX_ADC1_Init() 前面
因为 ADC 初始化时会把 ADC 句柄和 DMA 句柄关联起来。
如果顺序不对,后面 HAL_ADC_Start_DMA() 可能出问题。

Keil 工程生成和编译
打开 Keil 后,先编译 CubeMX 生成的工程:
bash
Build / F7
确认没有错误:
bash
0 Error(s)
然后添加本篇代码文件。
本篇新增 4 个文件:
bash
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 工程树里添加:
bash
Core/Src/app_adc_dma.c
Core/Src/app_test_pwm_multi.c

完整代码
1. 新建 Core/Inc/app_adc_dma.h
bash
#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
这里有两个宏要看懂:
bash
#define APP_ADC_DMA_CHANNEL_COUNT 3u
#define APP_ADC_DMA_SCAN_COUNT_PER_BLOCK 32u
意思是:
bash
每轮扫描 3 个通道
每半个 DMA buffer 里放 32 轮扫描
所以每半块数据长度是:
bash
3 x 32 = 96 个 ADC 数据
2. 新建 Core/Src/app_adc_dma.c
bash
#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:
bash
static
uint16_t
s_adc_dma_buffer[APP_ADC_DMA_BUFFER_LENGTH];
因为 ADC 是 12 位结果,用 16 位存比较合适。
第二,启动 DMA 时传入整个 buffer:
bash
HAL_ADC_Start_DMA(&APP_ADC_DMA_HANDLE,
(
uint32_t
*)s_adc_dma_buffer,
APP_ADC_DMA_BUFFER_LENGTH);
APP_ADC_DMA_BUFFER_LENGTH 是转换次数,不是字节数。
第三,回调里只置标志:
bash
s_half_ready =
1u
;
s_full_ready =
1u
;
不要在回调里 printf()。
也不要在回调里做很长的平均计算。
第四,App_ADCDMA_GetResult() 里用了:
bash
__disable_irq();
...
__enable_irq();
这里是在保护 s_half_ready 和 s_full_ready 这两个标志。
因为它们会在 DMA 中断回调里被修改,也会在主循环里被读取和清零。
这个点前面"临界区"番外已经讲过,这里就是一个真实应用场景。
3. 新建 Core/Inc/app_test_pwm_multi.h
这个文件和第 15 篇一致,用来输出三路 PWM。
bash
#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
bash
#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 顶部添加:
bash
/* 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 初始化函数:
bash
MX_GPIO_Init();
MX_DMA_Init();
MX_ADC1_Init();
MX_TIM3_Init();
MX_USART1_UART_Init();
然后在 USER CODE BEGIN 2 中添加:
bash
/* 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 */
顺序建议是:
bash
先启动 PWM
再启动 ADC DMA
这样 ADC 开始采样时,输入端已经有 PWM 信号。
3. while 循环里处理 DMA 数据
bash
/* 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 已经在后台采样了。
这个延时只是避免主循环空转太快。
编译、下载和验证
代码加完后:
-
Keil 编译;
-
下载程序;
-
打开串口助手;
-
复位开发板;
-
观察输出。

串口参数仍然是:
bash
115200
8 数据位
1 停止位
无校验
正常输出类似:
bash
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
如果你想验证三路是否真的对应,可以改:
bash
App_TestPWMMulti_SetAllDuty(
100u
,
400u
,
900u
);
正常应该看到:
bash
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 是:
bash
PB5 / TIM3_CH2
PB0 / TIM3_CH3
PB1 / TIM3_CH4
那么把 app_test_pwm_multi.c 里的通道宏改成:
bash
#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 输入仍然建议另选:
bash
PA1 / ADC1_IN1
PA2 / ADC1_IN2
PA3 / ADC1_IN3
然后用杜邦线连接:
bash
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 通道顺序没对上。
按这个顺序查:
-
看 PWM_OUT0/1/2 实际是哪几个引脚;
-
看三根杜邦线接到哪几个 ADC 输入;
-
看 CubeMX ADC Rank 1/2/3;
-
看
APP_TEST_PWM_CH0/1/2; -
看
App_TestPWMMulti_SetAllDuty()三个参数。
记住:
bash
DMA buffer 里的第 0、1、2 个数据,对应的是 Rank 1、Rank 2、Rank 3
不是你心里以为的 PA1、PA2、PA3。
4. 编译提示回调函数重复定义
如果你的工程里别的文件已经写了:
bash
HAL_ADC_ConvHalfCpltCallback()
HAL_ADC_ConvCpltCallback()
那再把本篇代码直接加进去,就会重复定义。
解决方法不是删掉其中一个功能,而是合并回调。
例如在已有回调里加判断:
bash
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 正在更新的数据。
入门阶段建议先保留这种结构:
bash
半满 -> 处理前半段
全满 -> 处理后半段
等这个模式跑通了,再考虑更复杂的数据处理。
本篇小结
这一篇我们完成了 ADC + DMA 连续采样。
你现在应该知道:
-
ADC 负责转换模拟量;
-
DMA 负责把 ADC 数据自动搬到内存数组;
-
Circular 模式适合连续采样;
-
多通道 ADC DMA 的数组顺序是
CH0, CH1, CH2, CH0, CH1, CH2...; -
半满回调和全满回调适合通知主循环处理一块数据;
-
回调里不要做复杂计算和
printf(); -
主循环和中断共享标志时,可以用很短的临界区保护。
到这里,ADC 这条线已经从:
bash
单通道轮询
走到了:
bash
多通道扫描 + DMA 连续搬运
下一篇我们切到串口 DMA:
STM32 USART + DMA + IDLE:串口不定长接收怎么做。
前面我们已经做过串口单字节中断和一行命令,下一篇会把串口接收升级成更接近工程项目的写法。