从PWM到频率计:用STM32定时器实现多通道信号分析(含误差优化技巧)
在工业控制、自动化测试和嵌入式系统开发中,我们常常需要同时监测多路外部信号的频率。无论是电机转速传感器输出的脉冲,还是编码器反馈的方波,甚至是通信线路上的载波信号,快速、准确地获取其频率信息都是实现闭环控制或状态监测的关键。传统的做法可能是为每一路信号单独配置一个定时器,或者采用外部专用频率计芯片,但这无疑会增加系统成本和复杂度。
实际上,STM32系列微控制器内置的通用定时器,其输入捕获功能本身就为频率测量提供了硬件级的支持。更巧妙的是,通过合理的配置,单个定时器可以复用多个输入通道,实现多路信号的同步捕获与频率计算。这不仅能节省宝贵的定时器资源,还能确保多路测量之间的时间基准完全一致,对于需要同步分析的场景尤为重要。
今天,我们就深入探讨如何利用STM32的TIM4定时器,复用其CH2和CH3两个通道,构建一个稳定可靠的双路频率计。我们将从最基础的定时器工作原理讲起,逐步深入到多通道配置、测量范围计算、误差来源分析,并最终给出一种基于移动平均滤波的软件优化方案,实测可在2Hz到1.2kHz的宽频带内获得令人满意的精度。无论你是正在开发多轴运动控制平台,还是需要搭建一个小型信号分析仪,这篇文章提供的思路和代码都将为你节省大量摸索时间。
1. 理解核心:定时器输入捕获与频率测量原理
要构建一个频率计,我们首先得弄清楚频率测量的本质。频率 f 定义为周期性事件在单位时间内重复的次数,其倒数就是周期 T。因此,测量频率本质上就是测量信号的周期。
STM32的通用定时器在此扮演了高精度“秒表”的角色。它内部有一个计数器,在固定的时钟驱动下不断累加。输入捕获单元则像是一个敏锐的“触发器”和“记录员”。当被监测的GPIO引脚上发生指定的电平时序跳变(例如上升沿)时,输入捕获单元会立刻“冻结”当前计数器的值,并将其存入一个专用的捕获/比较寄存器(CCR)中。通过记录连续两个上升沿到来时计数器的值,我们就能计算出这两个事件之间的时间间隔,也就是信号的周期。
这里有一个关键公式:
信号周期 T = (计数器差值 * 计数器时钟周期)
信号频率 f = 1 / T
其中,计数器差值就是第二次捕获值减去第一次捕获值。计数器时钟周期则由系统时钟经过预分频器(Prescaler)分频后得到。
为什么需要处理溢出? STM32的定时器计数器位数是有限的(通常是16位,最大值65535)。当信号频率很低,周期很长时,计数器可能在两个上升沿之间从0累加到最大值(溢出)后重新从0开始。如果我们简单地将第二次捕获值减去第一次捕获值,就会得到错误的结果。因此,在软件设计中,我们必须维护一个“溢出计数器”,在定时器发生溢出更新事件时将其加一。计算周期时,需要将溢出次数乘以计数器的最大值,再加上两次捕获值的差值。
多通道复用的奥秘 STM32的一个定时器通常有多个独立的输入捕获通道(如CH1, CH2, CH3, CH4)。每个通道都有自己独立的CCR寄存器和边沿检测电路。这意味着,只要将不同的外部信号连接到这些通道对应的GPIO引脚上,并配置好各自的捕获极性,同一个定时器的计数器就能为所有通道提供统一的时间基准。当任一通道的信号边沿到来时,只会触发该通道自己的捕获事件和中断,不会干扰其他通道。这正是我们实现多通道同步测量的硬件基础。
2. 硬件与软件环境搭建
在开始编码之前,我们需要准备好开发环境并完成基本的硬件连接与软件配置。
硬件准备
- 主控芯片:本文以STM32F103C8T6(蓝色药丸核心板)为例,其TIM4是一个16位通用定时器,拥有CH1-CH4四个通道。
- 信号源:需要两路独立的、待测的方波信号。可以使用另外两个定时器产生PWM作为信号源进行测试,也可以使用信号发生器。
- 连接方式:
- 将第一路待测信号连接至 PB7 (TIM4_CH2)。
- 将第二路待测信号连接至 PB8 (TIM4_CH3)。
- 确保共地。
STM32CubeIDE配置 使用STM32CubeMX进行图形化配置可以极大简化初始化过程。
- 时钟树配置:确保系统时钟(SYSCLK)和APB1总线时钟(PCLK1)正确设置。对于STM32F103,在72MHz系统时钟下,如果APB1预分频器不为1,定时器时钟会倍频。TIM4挂在APB1上,其时钟通常是PCLK1的2倍(即72MHz)。这是我们计算的基础。
- TIM4配置:
- 时钟源:内部时钟。
- 预分频器:这是一个需要仔细斟酌的参数。它决定了计数器累加的“快慢”。预分频值设为
719,这样计数器时钟 = 72MHz / (719+1) = 100kHz。这意味着计数器每10微秒(0.00001秒)累加一次。 - 计数模式:向上计数。
- 自动重载值:设为最大值
65535(0xFFFF),以获得最长的单次计数周期。 - 开启通道2与通道3:均设置为“输入捕获直连模式”。
- 极性:都选择“上升沿”。
- 输入分频器:不分频(每次检测都捕获)。
- 滤波器:根据信号噪声情况设置,如果信号干净可以设为0(无滤波)。
- 中断配置:
- 必须使能 TIM4全局中断。这用于处理定时器溢出(更新)事件。
- 通道2和通道3的捕获/比较中断会自动在代码中通过回调函数处理,无需在此单独使能NVIC。
- 生成代码:配置完成后,生成初始化代码。CubeIDE会自动生成
htim4句柄以及基本的中断服务程序框架。
核心数据结构设计 为了清晰管理每个通道的测量状态和数据,我们定义一个结构体。这个设计比使用全局变量数组更清晰,也便于扩展。
typedef struct {
uint32_t timer_base_freq; // 定时器计数时钟频率,单位Hz
uint8_t is_captured; // 是否已成功捕获到第一个边沿
uint16_t last_capture_value; // 上一次捕获的计数器值
uint16_t overflow_count; // 从第一次捕获到当前捕获期间的溢出次数
uint16_t period_buffer[BUFFER_SIZE]; // 周期值环形缓冲区
uint8_t buffer_index; // 环形缓冲区当前写入位置
float calculated_frequency; // 计算得到的频率值
} FrequencyMeter_Channel;
这个结构体为每个测量通道保存了所有必要的信息:从定时器基础频率到历史周期数据。使用环形缓冲区是为了后续实现移动平均滤波。
3. 双通道捕获与溢出处理的代码实现
配置完成后,我们需要编写中断服务程序中的核心逻辑。这里主要涉及两个中断回调函数:一个是捕获比较中断,另一个是更新(溢出)中断。
捕获比较中断回调函数 当配置的GPIO引脚上出现上升沿时,硬件会触发此中断。
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM4) {
uint32_t current_capture;
FrequencyMeter_Channel *target_channel;
// 判断是哪个通道触发了中断
if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_CC2) != RESET) {
__HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_CC2);
current_capture = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
target_channel = &freq_ch2; // 指向通道2的数据结构
}
else if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_CC3) != RESET) {
__HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_CC3);
current_capture = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_3);
target_channel = &freq_ch3; // 指向通道3的数据结构
}
else {
return; // 非目标通道,直接返回
}
// 核心捕获逻辑
if (target_channel->is_captured == 0) {
// 第一次捕获,只记录值,不计算周期
target_channel->last_capture_value = current_capture;
target_channel->is_captured = 1;
target_channel->overflow_count = 0; // 重置溢出计数
} else {
// 第二次及之后的捕获,计算周期
uint32_t period_ticks;
if (target_channel->overflow_count == 0) {
// 无溢出,简单相减
period_ticks = current_capture - target_channel->last_capture_value;
} else {
// 有溢出,计算总计数
period_ticks = (uint32_t)target_channel->overflow_count * 65536UL;
period_ticks += current_capture;
period_ticks -= target_channel->last_capture_value;
}
// 将周期值存入环形缓冲区
target_channel->period_buffer[target_channel->buffer_index] = period_ticks;
target_channel->buffer_index = (target_channel->buffer_index + 1) % BUFFER_SIZE;
// 为下一次计算做准备
target_channel->last_capture_value = current_capture;
target_channel->overflow_count = 0;
}
}
}
注意:在中断回调函数中,我们通过检查标志位而非
htim->Channel来判断触发通道,这是因为HAL库的机制可能在某些情况下导致通道判断不准确,直接检查标志位更为可靠。
更新(溢出)中断回调函数 当计数器从65535回到0时,会触发此中断。我们需要在这里为每个通道记录溢出事件。
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM4) {
// 只有当一个通道已经开始捕获(即is_captured为1)时,其溢出计数才有效
if (freq_ch2.is_captured) {
freq_ch2.overflow_count++;
// 防止溢出计数变量本身溢出(虽然概率极低)
if (freq_ch2.overflow_count == 0xFF) freq_ch2.overflow_count = 0xFE;
}
if (freq_ch3.is_captured) {
freq_ch3.overflow_count++;
if (freq_ch3.overflow_count == 0xFF) freq_ch3.overflow_count = 0xFE;
}
}
}
主循环中的频率计算 为了避免在中断中做复杂的浮点运算(如求平均值),我们选择在主循环中定期计算频率。这通常通过一个任务或定时器来触发。
void Calculate_Frequency(FrequencyMeter_Channel *ch)
{
if (ch->is_captured == 0) {
ch->calculated_frequency = 0.0f;
return;
}
uint32_t sum_ticks = 0;
uint8_t valid_samples = 0;
// 计算环形缓冲区中所有有效样本的总和
for (int i = 0; i < BUFFER_SIZE; i++) {
if (ch->period_buffer[i] != 0) { // 0表示该位置尚未有数据
sum_ticks += ch->period_buffer[i];
valid_samples++;
}
}
if (valid_samples > 0) {
float avg_period_ticks = (float)sum_ticks / valid_samples;
// 频率 = 定时器时钟频率 / 平均计数值
ch->calculated_frequency = (float)ch->timer_base_freq / avg_period_ticks;
} else {
ch->calculated_frequency = 0.0f;
}
}
// 在主循环或1秒定时器中调用
Calculate_Frequency(&freq_ch2);
Calculate_Frequency(&freq_ch3);
printf("CH2: %.2f Hz, CH3: %.2f Hz\r\n", freq_ch2.calculated_frequency, freq_ch3.calculated_frequency);
4. 精度提升:预分频器设置与误差优化实战
直接使用上述基础代码进行测量,你会发现结果存在波动,尤其是在低频和高频段。接下来,我们深入分析误差来源并实施优化策略。
误差来源分析与预分频器选择 测量误差主要来自几个方面:
- ±1计数误差:这是定时器测量的固有误差。因为我们只能在计数器整数步长上捕获边沿,真实边沿可能发生在两个计数时钟之间,导致最多±1个计数周期的误差。
- 信号抖动与噪声:待测信号本身可能存在边沿抖动。
- 中断响应延迟:从边沿触发到CPU读取CCR值之间存在微小的、不固定的延迟,但这个误差通常远小于±1计数误差。
其中,±1计数误差是影响相对精度的主要因素。 其影响为:
相对误差 ≈ (±1 个计数) / (信号周期对应的总计数)
这意味着,信号周期对应的计数值越大,±1误差带来的相对误差就越小。因此,对于低频信号,我们希望计数器“走得慢”一点,让一个周期内包含尽可能多的计数,从而稀释误差。这通过增大预分频器的值来实现。
但矛盾在于,计数器“走得太慢”会限制我们能测量的最高频率。因为计数器最大值是固定的(65535),时钟越慢,计数器计满一个周期所需的时间就越长,如果信号周期比这个时间还短,就会发生溢出,导致测量失败或需要更复杂的多溢出处理。
因此,预分频器的设置是在测量范围和测量精度之间进行权衡。
量化分析:如何确定最佳预分频值?
假设系统时钟为72MHz,TIM4时钟为72MHz,我们通过预分频器PSC将其分频。
- 计数器时钟频率
F_cnt = 72MHz / (PSC + 1) - 计数器计满一个周期的时间
T_max = 65535 / F_cnt - 可无溢出测量的最低频率
F_min = 1 / T_max - 考虑±1误差,在目标精度下的最高可测频率。例如,要求±1误差导致的频率误差小于1%,那么要求信号周期计数值
N > 100。因此,F_max_for_1% = F_cnt / 100。
我们可以制作一个表格来直观展示不同预分频值下的性能:
| 预分频值 (PSC) | 计数器时钟 (F_cnt) | 最大周期时间 (T_max) | 理论最低频率 (F_min) | 1%精度下的最高频率 (F_max_for_1%) | 适用场景分析 |
|---|---|---|---|---|---|
| 71 | 1 MHz | 65.535 ms | 15.26 Hz | 10 kHz | 适合中高频测量,低频精度差。 |
| 719 | 100 kHz | 655.35 ms | 1.526 Hz | 1 kHz | 平衡之选,覆盖1.5Hz~1kHz,精度与范围兼顾。 |
| 7199 | 10 kHz | 6.5535 s | 0.1526 Hz | 100 Hz | 适合超低频测量,高频能力弱。 |
根据我们的目标场景(2Hz-1.2kHz),选择 PSC=719(计数器时钟100kHz)是一个合理的起点。此时,对于2Hz信号,周期为0.5秒,对应计数值为50000,±1误差带来的频率误差仅为0.002%。对于1.2kHz信号,周期约0.833ms,对应计数值83,±1误差约为1.2%,已接近我们设定的误差边界。这就需要软件算法来进一步平滑。
软件滤波:移动平均算法优化
为了抑制随机误差和抖动,我们在Calculate_Frequency函数中已经引入了环形缓冲区求平均。但这是一种简单的全局平均,对突变响应慢。移动平均(Moving Average)是更优的选择。我们修改数据结构,实现一个真正的移动平均滤波器。
#define MOVING_AVG_WINDOW 16 // 移动平均窗口大小
typedef struct {
// ... 其他成员保持不变 ...
uint16_t period_buffer[MOVING_AVG_WINDOW]; // 改为固定窗口
uint8_t buffer_is_full; // 缓冲区是否已填满
uint32_t running_sum; // 窗口内数据的运行总和,避免每次循环求和
} FrequencyMeter_Channel;
// 修改捕获中断中的存储逻辑
void store_period_sample(FrequencyMeter_Channel *ch, uint32_t period_ticks)
{
// 减去即将被覆盖的旧数据
if (ch->buffer_is_full) {
ch->running_sum -= ch->period_buffer[ch->buffer_index];
}
// 加入新数据
ch->period_buffer[ch->buffer_index] = period_ticks;
ch->running_sum += period_ticks;
// 更新索引和满标志
ch->buffer_index = (ch->buffer_index + 1) % MOVING_AVG_WINDOW;
if (ch->buffer_index == 0) {
ch->buffer_is_full = 1;
}
}
// 修改频率计算函数
void Calculate_Frequency_MA(FrequencyMeter_Channel *ch)
{
if (ch->is_captured == 0) {
ch->calculated_frequency = 0.0f;
return;
}
uint8_t valid_samples = ch->buffer_is_full ? MOVING_AVG_WINDOW : ch->buffer_index;
if (valid_samples == 0) {
ch->calculated_frequency = 0.0f;
return;
}
float avg_period_ticks = (float)ch->running_sum / valid_samples;
ch->calculated_frequency = (float)ch->timer_base_freq / avg_period_ticks;
}
移动平均算法在保持计算复杂度的同时,能持续输出经过平滑的结果,对周期性噪声和随机抖动有很好的抑制效果。窗口大小MOVING_AVG_WINDOW的选择需要权衡:窗口越大,平滑效果越好,但对频率变化的响应越慢;窗口越小,响应快,但平滑效果差。对于工业场景中频率变化不剧烈的信号,选择8或16是常见的。
动态预分频切换的进阶思路 对于需要覆盖更宽频率范围(如0.1Hz到100kHz)的应用,可以考虑动态切换预分频器。思路是:先用一个较小的预分频值(高时钟)进行快速测量,得到一个粗略频率。如果频率很低,粗略测量得到的计数值很大(说明精度足够),则维持当前设置;如果频率较高,粗略测量得到的计数值很小(比如小于1000),则自动切换到更大的预分频值(降低时钟),重新测量以提高精度。这需要在软件中实现状态机和定时器的动态重配置,虽然复杂,但能实现宽范围高精度测量。
5. 实测验证与性能评估
理论分析和代码实现之后,必须通过实际测量来验证系统的性能。我搭建了一个简单的测试平台:使用STM32的TIM2和TIM3分别产生两路独立可调的PWM信号,作为待测信号源输入到TIM4的CH2和CH3。通过串口将TIM4计算出的频率值发送到电脑,与信号源设定的频率进行对比。
测试条件
- MCU: STM32F103C8T6 @ 72MHz
- TIM4配置: PSC=719, ARR=65535, 计数器时钟=100kHz
- 移动平均窗口: 16
- 测量结果输出周期: 500ms
- 信号源: TIM2和TIM3产生的PWM,精度作为参考基准。
实测数据对比
下表展示了在不同频率点下,优化前后测量结果的稳定性对比(单位:Hz)。原始值指单次捕获计算的结果,优化值指经过16点移动平均滤波后的结果。
| 设定频率 | 通道 | 原始值 (波动范围) | 优化值 (波动范围) | 相对误差 (优化后) | 观察结论 |
|---|---|---|---|---|---|
| 2 Hz | CH2 | 1.98 ~ 2.02 | 1.998 ~ 2.002 | < ±0.1% | 低频下,原始值已有较好精度,优化后波动显著减小。 |
| 50 Hz | CH2 | 49.7 ~ 50.3 | 49.97 ~ 50.03 | < ±0.06% | 中频段是优势区间,误差极小。 |
| 500 Hz | CH2 | 495 ~ 505 | 498.5 ~ 501.5 | < ±0.3% | 接近1kHz,±1误差影响增大,优化后稳定性提升明显。 |
| 1200 Hz | CH2 | 1180 ~ 1220 | 1195 ~ 1205 | < ±0.42% | 已达理论边界,优化算法有效抑制了野值,结果可信。 |
| 10 Hz / 100 Hz | CH2/CH3 | 独立波动 | 独立稳定 | 均< ±0.15% | 双通道同时工作,互不干扰,性能一致。 |
从测试数据可以看出,在2Hz到1.2kHz的范围内,经过移动平均优化后的双通道频率计表现稳定,相对误差基本控制在0.5%以内,完全满足大多数工业监测场景的需求。双通道独立运行,测量结果互不影响,证明了硬件资源复用的有效性。
资源占用与优化建议 在完成核心功能后,审视一下系统资源占用情况是很好的习惯。我们的方案主要占用:
- 1个16位定时器 (TIM4):实现了双路测量。
- 3个中断:1个更新中断,2个捕获中断。中断频率取决于被测信号频率。对于1.2kHz的信号,每个通道每秒产生2400次中断(上升沿和下降沿各一次,如果我们测量占空比),这对Cortex-M3内核来说完全在轻松应对的范围内。
- 少量RAM:两个通道的数据结构,加上16个
uint16_t的缓冲区,总共不到100字节。 - CPU时间:主循环中简单的平均计算,负担极轻。
如果你的应用对实时性要求极高,或者需要测量更多路信号,还可以考虑以下优化方向:
- 使用DMA传输捕获值:将输入捕获配置为DMA模式,让硬件自动将CCR寄存器的值搬运到指定的内存数组中,完全解放CPU,避免高频中断。这对于测量高频信号或通道数很多时特别有效。
- 使用定时器的从模式:对于周期非常稳定的信号,可以配置定时器为“复位模式”,让输入信号直接复位计数器,然后通过读取特定时刻的计数器值来间接计算频率,可以减少软件计算量。
- 32位定时器:如果芯片支持(如STM32F4/F7系列的某些定时器),使用32位定时器可以极大扩展无溢出测量的频率下限,简化溢出处理逻辑。
整个项目做下来,最深的体会是,嵌入式开发中“够用”和“最优”之间往往需要反复权衡。就像这个频率计,最初只追求功能实现,测量结果跳得厉害;后来加了移动平均,瞬间稳定了不少;如果再深入下去,动态调整预分频、加入数字滤波算法,甚至可以做成一个自适应量程的通用频率计模块。但很多时候,项目进度和资源限制让我们停在“够用”这一步。理解原理的意义就在于,当“够用”变得“不够用”时,你能清楚地知道该从哪个方向去优化,而不是推倒重来。
&spm=1001.2101.3001.5002&articleId=149857204&d=1&t=3&u=51e021ef8a5245a598c64355d8c551fd)
246

被折叠的 条评论
为什么被折叠?



