本文介绍一种算法,可以在运行时接受指定的频率和占空比参数,在单片机上快速计算出 STM32 定时器对应的 PWM 配置,以达到目标参数的配置。利用它可以让 STM32 单片机简单输出低精度的指定方波,不需要额外的波形发生器芯片。
剧透一下,是穷举法,但对于 16 位定时器只需要计算 次即可求出结果,远小于全部参数穷举的 次计算,不到 1 秒即可算出结果。
STM32 生成 PWM 的频率和占空比主要由以下图片中红框里面的参数决定:
- 频率由 Prescaler 和 Counter Period 决定
- 占空比由 Counter Period 和 Pulse 决定,且 Counter Period 越大,占空比的控制越精细
- 只修改红框里面的参数,其它不修改以保持程序的简单
所以就是:
- 输入 PWM 的目标频率和占空比
- 在单片机上快速计算出上面 3 个配置参数,使用这 3 个参数的定时器可以生成最接近目标频率和占空比的 PWM
计算#
我们只要知道 Counter Period 和目标占空比,就可以算出 Pulse 了,所以 Pulse 算搞定了。
然后剩下两个未知数 (Counter Period) 和 (Prescaler)。定时器时钟源频率 以及目标频率 与它们的关系是:
转一下,可以得到 和 的比例:
其实就是一条线,由于寄存器的位数是有上限的,可以通过穷举的方式计算出所有可能的组合,而且我们已经有了两个参数的比例,根据其中一个参数的值可以直接算出另一个参数,对于 16 位定时器,只需要遍历 65536 次,即可算出最准确频率和占空比对应的 和 ,这个次数对于现在的单片机来说,计算时间不到一秒钟。
在这里我们就遍历 ,然后计算出 和 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 (%) | FreqErrorWeight | DutyErrorWeight | HCLK (Hz) | Execution Time (ms) | Prescaler | Period | Pulse | Actual Frequency (Hz) | Actual Duty Cycle (%) | FreqError (%) | DutyError (%) |
---|---|---|---|---|---|---|---|---|---|---|---|---|
0.1 | 50.0 | 1.0 | 1.0 | 100000000 | 238 | 62499 | 15999 | 8000 | 0.1 | 50.0 | 0.0 | 0.0 |
1.0 | 50.0 | 1.0 | 1.0 | 100000000 | 241 | 62499 | 1599 | 800 | 1.0 | 50.0 | 0.0 | 0.0 |
100.0 | 50.0 | 1.0 | 1.0 | 100000000 | 241 | 62499 | 15 | 8 | 100.0 | 50.0 | 0.0 | 0.0 |
100000.0 | 50.0 | 1.0 | 1.0 | 100000000 | 202 | 499 | 1 | 1 | 100000.0 | 50.0 | 0.0 | 0.0 |
1000000.0 | 50.0 | 1.0 | 1.0 | 100000000 | 202 | 49 | 1 | 1 | 1000000.0 | 50.0 | 0.0 | 0.0 |
10000000.0 | 50.0 | 1.0 | 1.0 | 100000000 | 201 | 4 | 1 | 1 | 10000000.0 | 50.0 | 0.0 | 0.0 |
123456.0 | 50.0 | 1.0 | 1.0 | 100000000 | 202 | 404 | 1 | 1 | 123456.789062 | 50.0 | 0.000639 | 0.0 |
1000.0 | 1.0 | 1.0 | 1.0 | 100000000 | 242 | 1 | 49950 | 49451 | 1000.980957 | 1.001 | 0.098096 | 0.098096 |
987654.0 | 45.678 | 1.0 | 1.0 | 100000000 | 203 | 0 | 101 | 55 | 980392.1875 | 46.0784 | -0.735259 | 0.876643 |
看起来效果还是不错的!只要不是频率特别高或者占空比指定的小数位特别多,都是比较准确的。目标频率越低,占空比越容易达标。