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

相关推荐
蓝斯4973 小时前
从.到实现一个Windows上的虚拟hid键盘设备
windows·单片机·计算机外设
熙芯XiChip4 小时前
硬实时与软实时RTOS核心区别
嵌入式硬件
伟大的前程4 小时前
unsigned int 定义的变量 在 电脑的 64 位系统中 和 32 位 cortex-m0 和 8 位的 51 单片机
单片机·嵌入式硬件
小渔村的拉线工4 小时前
1.HPM6E80 解析芯片整体工作原理和存储架构
单片机·嵌入式硬件·mcu·架构·hpm6e80
熙芯XiChip5 小时前
CAN与CANopen的本质区别
单片机
lilihewo6 小时前
Altium Designer 封装库的创建(分享pcb 3D封装库)
嵌入式硬件·51单片机
神罚天下ET7 小时前
典型arm位单片机启动流程(从上电到main.c)
c语言·arm开发·单片机
碧海银沙音频科技研究院7 小时前
蓝牙耳机使用WSDF5361锂保芯片进入船运模式实现方法
嵌入式硬件·算法
Zevalin爱灰灰8 小时前
模拟信号链电路——基于TL431的电压基准电路
单片机·嵌入式硬件
csdn杰哥9 小时前
瑞萨单片机开发教程[二十七] DTC 外部中断触发传输实验
单片机·嵌入式硬件·mongodb·瑞萨·ra6m5