STM32 零基础可移植教程 15:ADC 多通道扫描,读取三路 PWM 的平均电压
上一篇我们做了 ADC 单通道采样。
为了不外接电位器,我们用开发板自己的 PWM 输出当测试信号:
bash
PWM 输出 -> 杜邦线接回 ADC 输入 -> ADC 读取平均电压
这一篇继续沿着这个思路往前走一步:
bash
用三路 PWM 输出不同占空比
ADC 按顺序扫描三个输入通道
串口打印三路 raw 和三路平均电压
也就是做一个小小的"自测平台"。
你后面真正接传感器的时候,本质上只是把这三路 PWM 测试信号换成:
bash
电位器输出
光敏电阻分压
NTC 温度传感器分压
电流采样放大输出
其他模拟传感器输出
ADC 多通道扫描的思路不变。
不过开头先把一个容易误会的点讲清楚。
先说清楚:PWM 输出脚不能同时当 ADC 输入脚
你说开发板上有三个 GPIO 可以输出 PWM,那我们就用这三个 GPIO 作为测试信号源。
但这里不是让 ADC "直接读取这个 PWM 引脚自身"。
一个 STM32 引脚在同一时刻通常只能工作在一种主要模式下:
bash
要么配置成 TIM PWM 输出
要么配置成 ADC 模拟输入
如果某个引脚已经被 CubeMX 配成 TIMx_CHy PWM Generation,它就不是 ADC 输入模式。
所以本篇实验的连接方式是:
bash
PWM_OUT0 -> ADC_IN0
PWM_OUT1 -> ADC_IN1
PWM_OUT2 -> ADC_IN2
中间用三根杜邦线接起来。
比如你可以这样理解:
bash
三个 PWM 引脚负责"造信号"
三个 ADC 引脚负责"读信号"
这就像你拿一台信号发生器输出波形,再用万用表或示波器去测它。只不过这里信号发生器也是 STM32 自己做的。
本篇目标
最终现象:
bash
三路 PWM 同时输出:
PWM0 = 25%
PWM1 = 50%
PWM2 = 75%
ADC 扫描三路输入,并通过串口打印:
CH0 raw=..., mv=...
CH1 raw=..., mv=...
CH2 raw=..., mv=...
如果参考电压按 3.3V 估算,平均电压大约是:
|
PWM 占空比
|
理论平均电压
|
| --- | --- |
|
25%
|
约 825 mV
|
|
50%
|
约 1650 mV
|
|
75%
|
约 2475 mV
|
本篇用到的外设:
bash
TIM PWM Output
ADC Regular Scan
USART printf
本篇先不做 DMA。
多通道扫描先用轮询方式读,等你把"Rank 顺序"和"扫描结果怎么对应通道"弄明白后,下一篇再把 DMA 加进来。
准备工作
你需要准备:
|
项目
|
说明
|
| --- | --- |
|
STM32 开发板
|
任意带 ADC 和定时器 PWM 的 STM32 都可以
|
|
下载器
|
ST-LINK/V2 或板载 ST-LINK
|
|
杜邦线
|
至少 3 根,用来把 PWM 输出接回 ADC 输入
|
|
串口工具
|
用来看 ADC 打印结果
|
|
原理图
|
确认哪些 PWM 引脚和 ADC 引脚能引出来
|
如果你用的是 STM32F103ZET6,常见 ADC 输入可以选:
bash
PA0 / ADC1_IN0
PA1 / ADC1_IN1
PA2 / ADC1_IN2
PA3 / ADC1_IN3
PA4 / ADC1_IN4
PA5 / ADC1_IN5
PA6 / ADC1_IN6
PA7 / ADC1_IN7
PB0 / ADC1_IN8
PB1 / ADC1_IN9
PC0 ~ PC5 / ADC1_IN10 ~ IN15
但是注意:
bash
如果 PB0 被你拿去输出 PWM,就不要同时把 PB0 配成 ADC 输入。
它可以输出 PWM,也可以做 ADC 输入,但不要在这个实验里同时承担两个角色。
本篇示例推荐用这种思路:
|
角色
|
示例引脚
|
说明
|
| --- | --- | --- |
|
PWM_OUT0
|
TIM3_CH1 对应引脚
|
输出 25% 占空比 PWM
|
|
PWM_OUT1
|
TIM3_CH2 对应引脚
|
输出 50% 占空比 PWM
|
|
PWM_OUT2
|
TIM3_CH3 对应引脚
|
输出 75% 占空比 PWM
|
|
ADC_IN0
|
PA1 / ADC1_IN1
|
读取 PWM_OUT0
|
|
ADC_IN1
|
PA2 / ADC1_IN2
|
读取 PWM_OUT1
|
|
ADC_IN2
|
PA3 / ADC1_IN3
|
读取 PWM_OUT2
|
如果你开发板上刚好能引出来的三路 PWM 是:
bash
PB5 / TIM3_CH2
PB0 / TIM3_CH3
PB1 / TIM3_CH4
也可以用。正文后面的移植说明会告诉你怎么改通道宏。

硬件连接
本篇需要 3 根信号线。
假设你选:
bash
PWM_OUT0 -> PA1 / ADC1_IN1
PWM_OUT1 -> PA2 / ADC1_IN2
PWM_OUT2 -> PA3 / ADC1_IN3
那么接线就是:
|
PWM 输出端
|
ADC 输入端
|
| --- | --- |
|
PWM_OUT0
|
PA1
|
|
PWM_OUT1
|
PA2
|
|
PWM_OUT2
|
PA3
|
如果 PWM 输出和 ADC 输入都在同一块开发板上,一般不用额外接 GND,因为它们本来就共地。
如果你把 PWM 从另一块板子接过来,那两块板子必须共地。
再强调两个安全点:
-
ADC 输入电压不要超过 VDDA,一般也就是不要超过 3.3V。
-
这一篇的 PWM 输出最好也是 3.3V 逻辑,不要把 5V PWM 直接打到 STM32 ADC。

ADC 多通道扫描到底在扫什么
单通道 ADC 的思路很简单:
bash
启动 ADC -> 等转换完成 -> 取一个值
多通道扫描是在这个基础上多了一个顺序。
比如我们配置 3 个 Regular Channel:
bash
Rank 1: ADC_CHANNEL_1
Rank 2: ADC_CHANNEL_2
Rank 3: ADC_CHANNEL_3
那么每次启动 ADC 后,它会按这个顺序转换:
bash
先采 ADC_CHANNEL_1
再采 ADC_CHANNEL_2
再采 ADC_CHANNEL_3
代码里连续取 3 次值:
bash
第 1 次 配置为 CH1 → 读到的结果 → adc.raw[0]
第 2 次 配置为 CH2 → 读到的结果 → adc.raw[1]
第 3 次 配置为 CH3 → 读到的结果 → adc.raw[2]
补充背景 :如果你在网上搜索 ADC 多通道扫描教程,大部分会告诉你"启动一次扫描,逐通道 PollForConversion"。这个思路本身没问题,但它依赖 ADC 的 EOCS 位(每通道独立 EOC)。STM32F103 硬件上没有 EOCS 位------扫描模式下 EOC 只产生一次,而且数据寄存器只保留最后一个通道的值。因此本篇代码采用"临时关扫描→逐个配置通道→单次转换"的方式来兼容 F103,同时也兼容有 EOCS 的新系列 MCU。
为什么还要平均
和上一篇一样,PWM 不是稳定电压。
比如 50% 占空比的 PWM:
bash
一半时间是高电平
一半时间是低电平
ADC 某一次采样如果刚好落在高电平,raw 可能接近 4095。
如果刚好落在低电平,raw 可能接近 0。
所以我们不看单次结果,而是连续采很多次,然后求平均。
本篇示例里主循环这样调用:
bash
App_ADCScan_ReadAverage(&adc, 128u);
意思是:
bash
连续扫描 128 轮
每一轮都读 3 个通道
最后分别算出 CH0/CH1/CH2 的平均 raw 和平均电压
这样读到的数值就会更接近 PWM 占空比对应的平均电压。
CubeMX 配置步骤
1. 复制上一篇 ADC 工程
建议从上一篇工程复制一份,改名为:
bash
15_adc_multi_channel
这样前面的串口打印、ADC 单通道、PWM 自测都可以作为基础。
如果你重新建工程,也可以按第一篇的流程走:
-
选择芯片型号;
-
SYS -> Debug设置为Serial Wire; -
时钟先按你前面工程已经跑通的配置;
-
Project Manager 选择
MDK-ARM; -
生成 Keil 工程。

2. 配置三路 PWM 输出
找到你开发板能引出来的三路 PWM。
本篇代码默认假设三路 PWM 来自同一个定时器:
bash
TIM3_CH1
TIM3_CH2
TIM3_CH3
在 CubeMX 中把对应通道配置为:
bash
PWM Generation CHx
推荐先把 PWM 频率设成 1 kHz。
如果 TIM3 的计数时钟配置成 1 MHz,可以这样设:
|
参数
|
值
|
含义
|
| --- | --- | --- |
|
Prescaler
| 定时器时钟/1MHz - 1 |
比如定时器时钟 72 MHz 填 71,8 MHz 填 7
|
|
Counter Period
| 1000 - 1 |
1 MHz 数 1000 次,得到 1 kHz
|
|
Pulse
|
先填 500
|
初始 50% 占空比,代码里后面会改
|
时钟来源要看你自己的配置 :如果用的是 HSI(8 MHz)且 APB1 分频为 1,定时器时钟就是 8 MHz,Prescaler 填
7得到 1 MHz。如果用的是 PLL 72 MHz,Prescaler 填71。去 CubeMX 的 Clock Configuration 页面确认你的定时器实际时钟频率。
如果你的板子上三路 PWM 是:
bash
PB5 / TIM3_CH2
PB0 / TIM3_CH3
PB1 / TIM3_CH4
那就配置 TIM3 的 CH2、CH3、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

3. 配置三个 ADC 输入通道
选择 3 个 ADC 输入脚。
本篇示例用:
bash
PA1 / ADC1_IN1
PA2 / ADC1_IN2
PA3 / ADC1_IN3
在 CubeMX Pinout 页面,把这三个引脚配置成 ADC 输入。
然后进入 ADC1 参数页面,重点看这些配置:
|
配置项
|
推荐值
|
说明
|
| --- | --- | --- |
|
Scan Conversion Mode
|
Enable
|
开启多通道扫描
|
|
Continuous Conversion Mode
|
Disable
|
本篇用软件每秒启动一次
|
|
Discontinuous Conversion Mode
|
Disable
|
新手先别开
|
|
External Trigger Conversion Source
|
Regular Conversion launched by software
|
软件启动转换
|
|
Data Alignment
|
Right alignment
|
右对齐,常用
|
|
Number Of Conversion
|
3
|
一轮扫描 3 个通道
|
注意 :如果你的 CubeMX 中有 "End Of Conversion Selection" 选项,它对应 EOCS 位。但 STM32F103 没有这个位------F103 在扫描模式下,EOC 标志要等全部通道转换完成后才置位一次。F4/H7/G0 等新系列才有逐通道 EOC 的功能。本篇代码采用了一种对所有系列都兼容的方式来解决这个问题。
Regular Conversion Ranks 推荐这样排:
|
Rank
|
Channel
|
对应接线
|
| --- | --- | --- |
|
Rank 1
|
ADC_CHANNEL_1
|
PWM_OUT0 -> PA1
|
|
Rank 2
|
ADC_CHANNEL_2
|
PWM_OUT1 -> PA2
|
|
Rank 3
|
ADC_CHANNEL_3
|
PWM_OUT2 -> PA3
|
Sampling Time 可以先选长一点,比如:
bash
55.5 Cycles
或者:
bash
71.5 Cycles
这里不是追求速度,而是先让读数更稳一点。


4. 配置 USART 串口打印
这篇需要串口看结果。
如果你已经完成第 07 篇 USART printf,就继续用原来的 USART。
常见配置:
bash
Mode: Asynchronous
Baud Rate: 115200
Word Length: 8 Bits
Parity: None
Stop Bits: 1

5. 生成 Keil 工程
点击:
bash
GENERATE CODE
打开 Keil,先编译一次。
如果 CubeMX 刚生成的工程都编译不过,先不要加我们自己的代码,先处理基础工程问题。
Keil 工程生成和编译
打开 Keil 后,先编译:
bash
Build / F7
确认输出里没有错误:
bash
0 Error(s)
然后再添加本篇的 .h/.c 文件。
本篇新建 4 个文件:
bash
Core/Inc/app_adc_scan.h
Core/Src/app_adc_scan.c
Core/Inc/app_test_pwm_multi.h
Core/Src/app_test_pwm_multi.c
其中:
|
文件
|
作用
|
| --- | --- |
| app_test_pwm_multi.h/.c |
负责启动三路 PWM,并设置 25%、50%、75% 占空比
|
| app_adc_scan.h/.c |
负责 ADC 三通道扫描,并计算平均 raw 和 mV
|
如果你手动新建 .c 文件,记得在 Keil 工程树里添加:
bash
Add Existing Files to Group 'Application/User/Core'
把下面两个 .c 文件加进去:
bash
Core/Src/app_adc_scan.c
Core/Src/app_test_pwm_multi.c

完整代码
1. 新建 Core/Inc/app_adc_scan.h
bash
#ifndef APP_ADC_SCAN_H
#define APP_ADC_SCAN_H
#include "main.h"
#include <stdint.h>
#define APP_ADC_SCAN_CHANNEL_COUNT 3u
/*
* 通道映射宏:告诉扫描模块数组下标 0/1/2 分别对应哪个 ADC 通道。
* 默认值需要和 CubeMX 里配置的 Rank 1/2/3 保持一致。
* 如果你的 Rank 顺序不同,修改这里的宏即可。
*/
#ifndef APP_ADC_SCAN_CH0
#define APP_ADC_SCAN_CH0 ADC_CHANNEL_1
#endif
#ifndef APP_ADC_SCAN_CH1
#define APP_ADC_SCAN_CH1 ADC_CHANNEL_2
#endif
#ifndef APP_ADC_SCAN_CH2
#define APP_ADC_SCAN_CH2 ADC_CHANNEL_3
#endif
typedef struct
{
uint32_t raw[APP_ADC_SCAN_CHANNEL_COUNT];
uint32_t voltage_mv[APP_ADC_SCAN_CHANNEL_COUNT];
} App_ADCScan_Result;
void App_ADCScan_Init(void);
HAL_StatusTypeDef App_ADCScan_Read(App_ADCScan_Result *result);
HAL_StatusTypeDef App_ADCScan_ReadAverage(App_ADCScan_Result *result, uint16_t sample_count);
#endif
2. 新建 Core/Src/app_adc_scan.c
bash
#include "app_adc_scan.h"
#ifndef APP_ADC_SCAN_HANDLE
#define APP_ADC_SCAN_HANDLE hadc1
#endif
#ifndef APP_ADC_SCAN_VREF_MV
#define APP_ADC_SCAN_VREF_MV 3300u
#endif
#ifndef APP_ADC_SCAN_MAX_RAW
#define APP_ADC_SCAN_MAX_RAW 4095u
#endif
#ifndef APP_ADC_SCAN_POLL_TIMEOUT_MS
#define APP_ADC_SCAN_POLL_TIMEOUT_MS 10u
#endif
#ifndef APP_ADC_SCAN_ENABLE_CALIBRATION
#define APP_ADC_SCAN_ENABLE_CALIBRATION 0u
#endif
extern ADC_HandleTypeDef APP_ADC_SCAN_HANDLE;
/*
* 通道数组:数组下标 0/1/2 对应的 ADC 通道号。
* 顺序必须和 CubeMX 里 Rank 1/2/3 保持一致。
*/
static const uint32_t s_channel[APP_ADC_SCAN_CHANNEL_COUNT] = {
APP_ADC_SCAN_CH0,
APP_ADC_SCAN_CH1,
APP_ADC_SCAN_CH2
};
static uint32_t App_ADCScan_RawToVoltageMv(uint32_t raw)
{
return (raw * APP_ADC_SCAN_VREF_MV) / APP_ADC_SCAN_MAX_RAW;
}
void App_ADCScan_Init(void)
{
#if APP_ADC_SCAN_ENABLE_CALIBRATION
(void)HAL_ADCEx_Calibration_Start(&APP_ADC_SCAN_HANDLE);
#endif
}
/*
* 读取一轮扫描结果(三个通道各一次)。
*
* 为什么不用"启动一次扫描 + 逐通道 PollForConversion"的方式?
*
* STM32F103 在扫描模式下,EOC 标志要等全部通道转换完成后才置位一次,
* 而且 ADC_DR 数据寄存器只保留最后一个通道的值,前两个通道的值会被覆盖。
* (STM32F4 / H7 等新系列有 EOCS 位可以改成逐通道置 EOC,但 F103 没有。)
*
* 所以这里采用"临时关闭扫描模式 → 逐个通道单次转换 → 恢复扫描模式"的
* 方式来绕开 F103 的硬件限制。这比加 DMA 简单,适合轮询场景。
*/
HAL_StatusTypeDef App_ADCScan_Read(App_ADCScan_Result *result)
{
HAL_StatusTypeDef status = HAL_OK;
ADC_HandleTypeDef *hadc = &APP_ADC_SCAN_HANDLE;
ADC_ChannelConfTypeDef sConfig = {0};
uint32_t saved_scan;
uint32_t saved_sqr1_l;
uint8_t i;
if (result == NULL)
{
return HAL_ERROR;
}
/* 保存原始扫描模式配置 */
saved_scan = READ_BIT(hadc->Instance->CR1, ADC_CR1_SCAN);
saved_sqr1_l = READ_BIT(hadc->Instance->SQR1, ADC_SQR1_L);
/* 临时改为单通道模式:关闭扫描 + 只转换 1 个通道 */
CLEAR_BIT(hadc->Instance->CR1, ADC_CR1_SCAN);
MODIFY_REG(hadc->Instance->SQR1, ADC_SQR1_L, 0u);
sConfig.Rank = ADC_REGULAR_RANK_1;
sConfig.SamplingTime = ADC_SAMPLETIME_55CYCLES_5;
for (i = 0u; i < APP_ADC_SCAN_CHANNEL_COUNT; i++)
{
sConfig.Channel = s_channel[i];
if (HAL_ADC_ConfigChannel(hadc, &sConfig) != HAL_OK)
{
status = HAL_ERROR;
break;
}
status = HAL_ADC_Start(hadc);
if (status != HAL_OK) break;
status = HAL_ADC_PollForConversion(hadc, APP_ADC_SCAN_POLL_TIMEOUT_MS);
if (status == HAL_OK)
{
result->raw[i] = HAL_ADC_GetValue(hadc);
result->voltage_mv[i] = App_ADCScan_RawToVoltageMv(result->raw[i]);
}
HAL_ADC_Stop(hadc);
if (status != HAL_OK) break;
}
/* 恢复原始扫描模式配置(下一篇文章加 DMA 时会用到) */
MODIFY_REG(hadc->Instance->SQR1, ADC_SQR1_L, saved_sqr1_l);
if (saved_scan)
{
SET_BIT(hadc->Instance->CR1, ADC_CR1_SCAN);
}
return status;
}
HAL_StatusTypeDef App_ADCScan_ReadAverage(App_ADCScan_Result *result, uint16_t sample_count)
{
App_ADCScan_Result one_result;
uint64_t sum[APP_ADC_SCAN_CHANNEL_COUNT] = {0u};
uint16_t n;
uint8_t i;
HAL_StatusTypeDef status;
if ((result == NULL) || (sample_count == 0u))
{
return HAL_ERROR;
}
for (n = 0u; n < sample_count; n++)
{
status = App_ADCScan_Read(&one_result);
if (status != HAL_OK)
{
return status;
}
for (i = 0u; i < APP_ADC_SCAN_CHANNEL_COUNT; i++)
{
sum[i] += one_result.raw[i];
}
}
for (i = 0u; i < APP_ADC_SCAN_CHANNEL_COUNT; i++)
{
result->raw[i] = (uint32_t)(sum[i] / sample_count);
result->voltage_mv[i] = App_ADCScan_RawToVoltageMv(result->raw[i]);
}
return HAL_OK;
}
这段代码的关键逻辑:
bash
/* 保存原始扫描配置 → 临时关扫描 → 逐个通道读 → 恢复扫描配置 */
saved_scan = READ_BIT(hadc->Instance->CR1, ADC_CR1_SCAN);
saved_sqr1_l = READ_BIT(hadc->Instance->SQR1, ADC_SQR1_L);
CLEAR_BIT(hadc->Instance->CR1, ADC_CR1_SCAN); // 临时关闭扫描
MODIFY_REG(hadc->Instance->SQR1, ADC_SQR1_L, 0u); // 只转换 1 个通道
for (i = 0; i < 3; i++)
{
HAL_ADC_ConfigChannel(...); // 换成当前通道
HAL_ADC_Start(...); // 启动单次转换
HAL_ADC_PollForConversion(...);// 等待转换完成
result->raw[i] = HAL_ADC_GetValue(...); // 读取
HAL_ADC_Stop(...); // 停掉 ADC
}
MODIFY_REG(..., saved_sqr1_l); // 恢复 L 字段
if (saved_scan) SET_BIT(..., ADC_CR1_SCAN); // 恢复扫描模式
为什么不能"启动一次扫描,逐通道 PollForConversion"?
STM32F103 的 ADC 在扫描模式下,EOC 标志只在全部通道转换完成 后产生一次,而且数据寄存器 只保留最后一个通道的值。F4/H7/G0 等新系列可以通过 EOCS 位改成逐通道产生 EOC,但 F103 硬件上根本没有这个位。
所以这里用了"借道"的方式:CubeMX 里保持扫描模式配置不动(下一篇加 DMA 时直接复用),读取时临时关掉扫描,逐个通道单次转换,读完恢复原样。
CubeMX 里 ADC 仍然配成:
bash
Scan Conversion Mode = Enable
Number Of Conversion = 3
3. 新建 Core/Inc/app_test_pwm_multi.h
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_GenerateEvent(&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);
}
默认三路占空比是:
bash
CH0 = 250/1000 = 25%
CH1 = 500/1000 = 50%
CH2 = 750/1000 = 75%
如果你想改成 10%、40%、90%,可以在初始化后调用:
bash
App_TestPWMMulti_SetAllDuty(100u, 400u, 900u);
main.c 调用方式
1. 添加头文件
找到 main.c 顶部的 USER CODE BEGIN Includes:
bash
/* USER CODE BEGIN Includes */
#include "app_adc_scan.h"
#include "app_test_pwm_multi.h"
#include <stdio.h>
/* USER CODE END Includes */
如果你前面已经完成第 07 篇 printf() 串口重定向,这里就可以直接用 printf()。
2. 初始化外设后启动 PWM 和 ADC
确认 CubeMX 已生成类似代码:
bash
MX_GPIO_Init();
MX_ADC1_Init();
MX_TIM3_Init();
MX_USART1_UART_Init();
然后在 USER CODE BEGIN 2 中添加:
bash
/* USER CODE BEGIN 2 */
App_ADCScan_Init();
App_TestPWMMulti_Init();
App_TestPWMMulti_Start();
printf("\r\nADC multi-channel scan test\r\n");
printf("PWM0=25%%, PWM1=50%%, PWM2=75%%\r\n");
/* USER CODE END 2 */
为什么要先 MX_TIM3_Init(),再 App_TestPWMMulti_Init()?
因为 CubeMX 生成的 MX_TIM3_Init() 会先把定时器底层参数配置好,我们自己的函数是在这个基础上改 ARR、CCR 和启动 PWM。
为什么要先 MX_ADC1_Init(),再 App_ADCScan_Init()?
因为 ADC 的通道、Rank、扫描模式都由 CubeMX 初始化,我们自己的代码只负责启动转换和读取结果。
3. while 循环里读取并打印
在 USER CODE BEGIN 3 中添加:
bash
/* USER CODE BEGIN 3 */
App_ADCScan_Result adc;
if (App_ADCScan_ReadAverage(&adc, 128u) == HAL_OK)
{
printf("CH0 raw=%lu, mv=%lu | CH1 raw=%lu, mv=%lu | CH2 raw=%lu, mv=%lu\r\n",
adc.raw[0], adc.voltage_mv[0],
adc.raw[1], adc.voltage_mv[1],
adc.raw[2], adc.voltage_mv[2]);
}
else
{
printf("ADC scan failed\r\n");
}
HAL_Delay(1000);
/* USER CODE END 3 */
正常输出大概类似:
bash
ADC multi-channel scan test
PWM0=25%, PWM1=50%, PWM2=75%
CH0 raw=1020, mv=822 | CH1 raw=2048, mv=1650 | CH2 raw=3068, mv=2473
实际数值不需要完全一样。
只要大致满足:
bash
CH0 < CH1 < CH2
并且接近 25%、50%、75% 对应的平均电压,就说明方向是对的。
编译、下载和验证
代码加完后:
-
Keil 编译;
-
下载程序;
-
打开串口助手;
-
复位开发板;
-
查看输出。
串口助手设置:
bash
115200
8 数据位
1 停止位
无校验
验证时建议按这个顺序来:
-
先不接三根杜邦线,看 ADC 输出是否比较乱或接近某个悬空值;
-
接上 PWM_OUT0 -> ADC_IN0,看 CH0 是否变化;
-
再接 PWM_OUT1 -> ADC_IN1,看 CH1 是否变化;
-
最后接 PWM_OUT2 -> ADC_IN2,看 CH2 是否变化;
-
改
App_TestPWMMulti_SetAllDuty()的三个占空比,看三路电压顺序是否跟着变。
这样排查比三根线一次全接上更稳。

移植到其他板子的修改点
这篇的移植点比前几篇多一点,但规律很清楚。
|
要改的地方
|
为什么要改
|
在哪里改
|
| --- | --- | --- |
|
PWM 输出引脚
|
不同开发板能引出的 PWM 脚不同
|
CubeMX TIM PWM 通道
|
|
PWM 通道宏
|
代码默认 TIM3 CH1/CH2/CH3
| app_test_pwm_multi.c
的 APP_TEST_PWM_CH0/1/2
|
|
PWM 定时器实例
|
可能不是 htim3
| APP_TEST_PWM_MULTI_HANDLE |
|
PWM 计数时钟
|
影响 ARR 和频率计算
| APP_TEST_PWM_MULTI_COUNTER_CLK_HZ |
|
ADC 输入引脚
|
不同板子的模拟输入脚不同
|
CubeMX ADC Channel
|
|
ADC Rank 顺序
|
决定数组下标和通道对应关系
|
CubeMX ADC Regular Rank,以及 app_adc_scan.h 的 APP_ADC_SCAN_CH0/1/2
|
|
ADC 实例
|
可能不是 hadc1
| APP_ADC_SCAN_HANDLE |
|
ADC 参考电压
|
VDDA 不一定刚好 3300 mV
| APP_ADC_SCAN_VREF_MV |
|
ADC 分辨率
|
F1 常见 12 位,别的系列可配 10/12/16 位
| APP_ADC_SCAN_MAX_RAW |
如果你的三路 PWM 是 PB5/PB0/PB1,并且它们对应:
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
那 CubeMX ADC Rank 就保持:
bash
Rank 1 = ADC_CHANNEL_1
Rank 2 = ADC_CHANNEL_2
Rank 3 = ADC_CHANNEL_3
记住这个对应关系:
bash
adc.raw[0] -> Rank 1
adc.raw[1] -> Rank 2
adc.raw[2] -> Rank 3
不是数组下标天然等于 ADC 通道号,而是你在 CubeMX 里排了什么 Rank,它就按什么顺序出来。
常见问题排查
1. 三个通道读数都差不多
优先检查:
|
检查项
|
说明
|
| --- | --- |
|
三根线是否真的接上
|
PWM_OUT0/1/2 是否分别接到 ADC_IN0/1/2
|
|
ADC Rank 是否都配成同一个 Channel
|
很多新手复制配置时会配重复
|
|
PWM 三路占空比是否真的不同
|
看代码是否调用了 App_TestPWMMulti_SetAllDuty(250, 500, 750)
|
|
PWM 是否启动了三路
| HAL_TIM_PWM_Start()
是否三个通道都调用成功
|
2. 某一路一直是 0
常见原因:
-
这一路 PWM 没启动;
-
杜邦线没接好;
-
ADC Rank 选错通道;
-
这个 ADC 引脚被板子上其他电路拉低;
-
代码里通道宏写错,比如实际是 CH4,但宏还写 CH3。
可以把这一路 PWM 接到另一组 ADC 输入上试一下,判断是输出问题还是输入问题。
3. 某一路一直接近 4095
优先检查:
-
这个 ADC 输入是不是被接到了 3.3V;
-
PWM 是否被设置成 100% 占空比;
-
ADC 引脚有没有被配置成模拟输入;
-
是不是接线接错,把 3.3V 当成 PWM 输出接进去了。
4. 输出顺序和预期反了
比如你以为:
bash
CH0 = 25%
CH1 = 50%
CH2 = 75%
结果串口看到:
bash
CH0 最大
CH1 中间
CH2 最小
这大概率不是 ADC 算错,而是:
bash
Rank 顺序、接线顺序、PWM 通道顺序三者没有对上
按这个顺序查:
-
看三根线怎么接;
-
看 CubeMX ADC Rank 1/2/3;
-
看
APP_TEST_PWM_CH0/1/2; -
看
App_TestPWMMulti_SetAllDuty()里三个参数。
5. 编译报 hadc1 未定义
说明你的工程里 ADC 句柄不叫 hadc1,或者没有开启 ADC1。
解决方法:
-
回 CubeMX 确认 ADC1 已开启;
-
看
adc.c里实际生成的是hadc1还是别的名字; -
如果不是
hadc1,修改:
bash
#define APP_ADC_SCAN_HANDLE hadc1
6. 编译报 htim3 未定义
说明你的 PWM 不是 TIM3,或者没有开启 TIM3。
解决方法:
-
回 CubeMX 确认 TIM PWM 已配置;
-
看
tim.c里实际生成的是htim3、htim2还是htim4; -
修改:
bash
#define APP_TEST_PWM_MULTI_HANDLE htim3
7. 串口没有输出
这一般不是 ADC 问题。
按第 07 篇的思路排查:
-
USART 是否配置成 Asynchronous;
-
TX/RX/GND 是否接对;
-
波特率是否 115200;
-
printf()是否已经重定向; -
Keil 是否勾选 MicroLIB;
-
MX_USARTx_UART_Init()是否在printf()之前调用。
8. 数值跳动比较明显
这是读 PWM 的正常现象之一。
可以尝试:
-
增大平均次数,比如
128改成256; -
提高 PWM 频率;
-
增加 ADC Sampling Time;
-
如果以后接真实模拟量,前端加 RC 滤波;
-
下一篇用 DMA 连续采样,让数据更稳定、更省 CPU。
本篇小结
这一篇我们完成了 ADC 多通道扫描的入门实验。
你现在应该知道:
-
PWM 输出脚和 ADC 输入脚要分开理解;
-
本篇用三路 PWM 只是为了不外接传感器,给 ADC 提供可控测试信号;
-
ADC 多通道扫描靠 Rank 顺序决定转换顺序;
-
adc.raw[0]对应 Rank 1,不一定天然对应 ADC_CHANNEL_0; -
读取 PWM 平均电压时,不要盯着单次 ADC 值,要做多次采样平均;
-
换板子时重点改 PWM 通道、ADC Rank、句柄名、参考电压和引脚接线。
这篇跑通后,你已经具备了读多个模拟量的基础。
下一篇我们继续升级:
STM32 DMA 入门:ADC 连续采样为什么不用 CPU 一直搬。
到时候我们会把这篇的三路 ADC 扫描结果交给 DMA 自动搬到数组里,CPU 不再每个点都傻等。