STM32 零基础可移植教程 15:ADC 多通道扫描,读取三路 PWM 的平均电压

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 从另一块板子接过来,那两块板子必须共地。

再强调两个安全点:

  1. ADC 输入电压不要超过 VDDA,一般也就是不要超过 3.3V。

  2. 这一篇的 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 自测都可以作为基础。

如果你重新建工程,也可以按第一篇的流程走:

  1. 选择芯片型号;

  2. SYS -> Debug 设置为 Serial Wire

  3. 时钟先按你前面工程已经跑通的配置;

  4. Project Manager 选择 MDK-ARM

  5. 生成 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% 对应的平均电压,就说明方向是对的。

编译、下载和验证

代码加完后:

  1. Keil 编译;

  2. 下载程序;

  3. 打开串口助手;

  4. 复位开发板;

  5. 查看输出。

串口助手设置:

bash 复制代码
115200
8 数据位
1 停止位
无校验

验证时建议按这个顺序来:

  1. 先不接三根杜邦线,看 ADC 输出是否比较乱或接近某个悬空值;

  2. 接上 PWM_OUT0 -> ADC_IN0,看 CH0 是否变化;

  3. 再接 PWM_OUT1 -> ADC_IN1,看 CH1 是否变化;

  4. 最后接 PWM_OUT2 -> ADC_IN2,看 CH2 是否变化;

  5. 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.hAPP_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 通道顺序三者没有对上

按这个顺序查:

  1. 看三根线怎么接;

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

  3. APP_TEST_PWM_CH0/1/2

  4. App_TestPWMMulti_SetAllDuty() 里三个参数。

5. 编译报 hadc1 未定义

说明你的工程里 ADC 句柄不叫 hadc1,或者没有开启 ADC1。

解决方法:

  1. 回 CubeMX 确认 ADC1 已开启;

  2. adc.c 里实际生成的是 hadc1 还是别的名字;

  3. 如果不是 hadc1,修改:

bash 复制代码
#define APP_ADC_SCAN_HANDLE hadc1

6. 编译报 htim3 未定义

说明你的 PWM 不是 TIM3,或者没有开启 TIM3。

解决方法:

  1. 回 CubeMX 确认 TIM PWM 已配置;

  2. tim.c 里实际生成的是 htim3htim2 还是 htim4

  3. 修改:

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 不再每个点都傻等。

相关推荐
踏着七彩祥云的小丑1 小时前
嵌入式测试学习第 26 天:SPI通信协议基础、主从模式、速度特点
单片机·嵌入式硬件
湉湉家的小虎子1 小时前
【科普贴】浅谈UFS接口——偏硬件解析
驱动开发·嵌入式硬件·fpga开发·硬件工程
hai3152475432 小时前
# FiveOS V5.0 交付(终极合成器版 · 物理合规修正)
人工智能·stm32·单片机·嵌入式硬件·神经网络
搁浅小泽2 小时前
外部导线用接线端子&正常工作&非正常工作
嵌入式硬件
嵌入式ZYXC2 小时前
第6章:通信接口的硬件特性——为什么你的UART乱码、I2C死锁、SPI干扰大?
stm32·单片机·嵌入式硬件·物联网·智能硬件
天天爱吃肉82182 小时前
【汽车研发测试工程师|Python自动化实测全套脚本(CAN解析+数据处理+自动出报告)】
大数据·python·功能测试·嵌入式硬件·汽车
三佛科技-134163842122 小时前
AIP8P005B 与FT60E112A(8位I/O型单片机)对比分析,FT60E112A能否兼容替代AIP8P005B?
单片机·嵌入式硬件·物联网·智能家居·pcb工艺
fffzd3 小时前
STM32:串口--轮询模式
stm32·单片机·嵌入式硬件·串口·hal库·轮询模式
municornm3 小时前
单片机IO不够?ULN2003A救急方案
单片机·嵌入式硬件