YM

Back

STM32 单片机运行时生成任意频率和占空比 PWMBlur image

本文介绍一种算法,可以在运行时接受指定的频率和占空比参数,在单片机上快速计算出 STM32 定时器对应的 PWM 配置,以达到目标参数的配置。利用它可以让 STM32 单片机简单输出低精度的指定方波,不需要额外的波形发生器芯片。

剧透一下,是穷举法,但对于 16 位定时器只需要计算 2162^{16} 次即可求出结果,远小于全部参数穷举的 2322^{32} 次计算,不到 1 秒即可算出结果。

STM32 生成 PWM 的频率和占空比主要由以下图片中红框里面的参数决定:

定时器参数设置

  • 频率由 Prescaler 和 Counter Period 决定
  • 占空比由 Counter Period 和 Pulse 决定,且 Counter Period 越大,占空比的控制越精细
  • 只修改红框里面的参数,其它不修改以保持程序的简单

所以就是:

  • 输入 PWM 的目标频率和占空比
  • 在单片机上快速计算出上面 3 个配置参数,使用这 3 个参数的定时器可以生成最接近目标频率和占空比的 PWM

计算#

我们只要知道 Counter Period 和目标占空比,就可以算出 Pulse 了,所以 Pulse 算搞定了。

然后剩下两个未知数 CPCP (Counter Period) 和 PscPsc (Prescaler)。定时器时钟源频率 FF 以及目标频率 ff 与它们的关系是:

f=F/(CP+1)/(Psc+1)f=F/(CP+1)/(Psc+1)

转一下,可以得到 CP+1CP+1Psc+1Psc+1 的比例:

CP+1PSC+1=Ff\frac{CP+1}{PSC+1}=\frac{F}{f}

其实就是一条线,由于寄存器的位数是有上限的,可以通过穷举的方式计算出所有可能的组合,而且我们已经有了两个参数的比例,根据其中一个参数的值可以直接算出另一个参数,对于 16 位定时器,只需要遍历 65536 次,即可算出最准确频率和占空比对应的 CPCPPscPsc,这个次数对于现在的单片机来说,计算时间不到一秒钟。

在这里我们就遍历 CPCP,然后计算出 PSCPSC 和 Pulse。

实操#

不多废话了,上 C 代码(HAL 库):

/*
 * Copyright (c) 2023 YM Blog
 * You can find the original article at https://blog.yanming.link
 */

#include <float.h>
#include <math.h>

#include "tim.h"
#include "main.h"

// 可以自己修改这些值,来看看效果
// 因为是示例代码,全部都写死在代码里面了
// 如果需要更快的切换参数来看效果,最好写个串口输入之类的

#define TIM_HANDLE htim10
static const uint32_t TIM_CHANNEL = TIM_CHANNEL_1;
static const float TARGET_FREQUENCY = 0.1f;
static const float TARGET_DUTY_CYCLE = 0.5f;

// 用于返回计算结果的结构体,
// 前三个字段存储了计算出来的定时器配置,
// 后三个用于存储使用这些参数配置的定时器,与目标频率和占空比的误差,可以忽略的
struct TimConfig {
    uint16_t Prescaler;
    uint16_t Period;
    uint16_t Pulse;
    float ActualFrequency;
    float ActualDutyCycle;
    float FrequencyErrorFraction;
    float DutyCycleErrorFraction;
};

/**
 * 根据给定定时器时钟源频率、目标频率和目标占空比,计算定时器配置使其输出的 PWM 参数与目标参数接近。
 * @param config 储存计算结果结构体的指针
 * @param timerBaseFreq 定时器时钟源频率
 * @param targetFrequency 目标 PWM 频率
 * @param targetDutyCycle 目标 PWM 占空比
 * @param errorFreqWeight 频率误差占比,数值越大,计算式更考虑减少频率的误差,设置为 0 则不考虑频率误差
 * @param errorDutyWeight 占空比误差占比,数值越大,计算式更考虑减少占空比的误差,设置为 0 则不考虑占空比误差
 * @return 1 为输入的参数不合法,0 为无错误
 */
static int SetupConfig(struct TimConfig *config, const float timerBaseFreq, const float targetFrequency,
                       const float targetDutyCycle, const float errorFreqWeight, const float errorDutyWeight) {
    // 检查输入参数是否合法
    if (targetDutyCycle <= 0 || targetDutyCycle >= 1 || targetFreq <= 0 || timerBaseFreq < targetFreq) {
        return 1;
    }

    // 基础频率与目标频率的比值
    const float ratio = timerBaseFreq / targetFreq;
    float minError = FLT_MAX;

    // 预分频器值
    uint16_t prescaler;
    // 以周期遍历
    for (uint32_t period = 0; period <= 0xFFFF; ++period) {
        // 周期的浮点数
        const float periodF = (float)period;

        // 预分频器值的浮点数,通过频率的比值算出来
        const float prescalerF = ratio / ((float)period + 1) - 1;

        // 处理预分频器浮点数超出范围的情况
        if (prescalerF < 0) prescaler = 0;
        else if (prescalerF > 0xFFFF) prescaler = 0xFFFF;
        else prescaler = (uint16_t)roundf(prescalerF);

        // 可以根据占空比算出 pulse 的值
        const uint16_t pulse = (uint16_t)roundf(periodF * (1 - targetDutyCycle));

        // 下面就是计算误差了,将频率或占空比与目标相差的比值加起来,
        // 然后再看看是不是历史最低误差,如果是,就将现在的参数写进结构体

        const float actualFreq = timerBaseFreq / (periodF + 1) / ((float)prescaler + 1);
        const float actualDuty = period == 0 ? 1 : (float)(period - pulse + 1) / (periodF + 1);
        const float freqErrorFraction = (actualFreq - targetFrequency) / targetFrequency;
        const float dutyErrorFraction = (actualDuty - targetDutyCycle) / targetDutyCycle;
        const float errorSum = fabsf(freqErrorFraction * errorFreqWeight) + fabsf(dutyErrorFraction * errorDutyWeight);

        if (errorSum >= minError) continue;

        minError = errorSum;
        config->Prescaler = prescaler;
        config->Period = period;
        config->Pulse = pulse;
        config->ActualFrequency = actualFreq;
        config->ActualDutyCycle = actualDuty;
        config->FrequencyErrorFraction = freqErrorFraction;
        config->DutyCycleErrorFraction = dutyErrorFraction;
    }

    return 0;
}

void App() {
    struct TimConfig config;
    // 这里偷懒,实际上好像并不是每个芯片的每个定时器频率都和 HCLK 相等的
    const float timerBaseFreq = (float)HAL_RCC_GetHCLKFreq();

    if (SetupConfig(&config, timerBaseFreq, TARGET_FREQUENCY, TARGET_DUTY_CYCLE, 1.0f, 1.0f) != 0) return;

    // 根据计算出来的结果设置定时器的参数
    __HAL_TIM_SET_PRESCALER(&TIM_HANDLE, config.Prescaler);
    __HAL_TIM_SET_AUTORELOAD(&TIM_HANDLE, config.Period);
    __HAL_TIM_SET_COMPARE(&TIM_HANDLE, TIM_CHANNEL, config.Pulse);
    HAL_TIM_PWM_Start(&TIM_HANDLE, TIM_CHANNEL);
}
c

主要计算过程在 SetupConfig 函数里面,注释基本都标的很清楚了,而且暴力求解的算法也是比较简单的。

以上代码,只需要修改文件开头的宏定义和几个常量,以及 App 函数里面的 timerBaseFreq,就可以粘贴到任何一个使用 HAL 库的 STM32 工程里面直接使用。也可以粘贴结构体定义和 SetupConfig 函数,直接在现有项目里面使用。

使用下面的配置运行起来看看效果吧!

  • 单片机: STM32F411CEU6
  • 定时器时钟频率: 100MHz

指定:

  • 目标频率: 10kHz
  • 目标占空比: 0.25

程序计算(用时 208ms):

  • Prescaler: 0
  • Period: 9998
  • Pulse: 7499
  • ActualFreq: 10001.000000
  • ActualDuty: 0.250025
  • FreqError: 0.010000%
  • DutyError: 0.010002%

输出波形

拜托,真的很准好吗!

然后再指定一下其它参数都试试看!

Frequency (Hz)Duty Cycle (%)FreqErrorWeightDutyErrorWeightHCLK (Hz)Execution Time (ms)PrescalerPeriodPulseActual Frequency (Hz)Actual Duty Cycle (%)FreqError (%)DutyError (%)
0.150.01.01.0100000000238624991599980000.150.00.00.0
1.050.01.01.01000000002416249915998001.050.00.00.0
100.050.01.01.010000000024162499158100.050.00.00.0
100000.050.01.01.010000000020249911100000.050.00.00.0
1000000.050.01.01.010000000020249111000000.050.00.00.0
10000000.050.01.01.010000000020141110000000.050.00.00.0
123456.050.01.01.010000000020240411123456.78906250.00.0006390.0
1000.01.01.01.0100000000242149950494511000.9809571.0010.0980960.098096
987654.045.6781.01.0100000000203010155980392.187546.0784-0.7352590.876643

看起来效果还是不错的!只要不是频率特别高或者占空比指定的小数位特别多,都是比较准确的。目标频率越低,占空比越容易达标。

STM32 单片机运行时生成任意频率和占空比 PWM
https://yanming.link/blog/generate-any-frequency-and-duty-cycle-pwm-on-stm32-at-runtime
Author YM
Published at November 8, 2024