从PWM到频率计:用STM32定时器实现多通道信号分析(含误差优化技巧)

从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进行图形化配置可以极大简化初始化过程。

  1. 时钟树配置:确保系统时钟(SYSCLK)和APB1总线时钟(PCLK1)正确设置。对于STM32F103,在72MHz系统时钟下,如果APB1预分频器不为1,定时器时钟会倍频。TIM4挂在APB1上,其时钟通常是PCLK1的2倍(即72MHz)。这是我们计算的基础。
  2. TIM4配置
    • 时钟源:内部时钟。
    • 预分频器:这是一个需要仔细斟酌的参数。它决定了计数器累加的“快慢”。预分频值设为 719,这样计数器时钟 = 72MHz / (719+1) = 100kHz。这意味着计数器每10微秒(0.00001秒)累加一次。
    • 计数模式:向上计数。
    • 自动重载值:设为最大值 65535(0xFFFF),以获得最长的单次计数周期。
    • 开启通道2与通道3:均设置为“输入捕获直连模式”。
    • 极性:都选择“上升沿”。
    • 输入分频器:不分频(每次检测都捕获)。
    • 滤波器:根据信号噪声情况设置,如果信号干净可以设为0(无滤波)。
  3. 中断配置
    • 必须使能 TIM4全局中断。这用于处理定时器溢出(更新)事件。
    • 通道2和通道3的捕获/比较中断会自动在代码中通过回调函数处理,无需在此单独使能NVIC。
  4. 生成代码:配置完成后,生成初始化代码。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计数误差:这是定时器测量的固有误差。因为我们只能在计数器整数步长上捕获边沿,真实边沿可能发生在两个计数时钟之间,导致最多±1个计数周期的误差。
  2. 信号抖动与噪声:待测信号本身可能存在边沿抖动。
  3. 中断响应延迟:从边沿触发到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%)适用场景分析
711 MHz65.535 ms15.26 Hz10 kHz适合中高频测量,低频精度差。
719100 kHz655.35 ms1.526 Hz1 kHz平衡之选,覆盖1.5Hz~1kHz,精度与范围兼顾。
719910 kHz6.5535 s0.1526 Hz100 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 HzCH21.98 ~ 2.021.998 ~ 2.002< ±0.1%低频下,原始值已有较好精度,优化后波动显著减小。
50 HzCH249.7 ~ 50.349.97 ~ 50.03< ±0.06%中频段是优势区间,误差极小。
500 HzCH2495 ~ 505498.5 ~ 501.5< ±0.3%接近1kHz,±1误差影响增大,优化后稳定性提升明显。
1200 HzCH21180 ~ 12201195 ~ 1205< ±0.42%已达理论边界,优化算法有效抑制了野值,结果可信。
10 Hz / 100 HzCH2/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位定时器可以极大扩展无溢出测量的频率下限,简化溢出处理逻辑。

整个项目做下来,最深的体会是,嵌入式开发中“够用”和“最优”之间往往需要反复权衡。就像这个频率计,最初只追求功能实现,测量结果跳得厉害;后来加了移动平均,瞬间稳定了不少;如果再深入下去,动态调整预分频、加入数字滤波算法,甚至可以做成一个自适应量程的通用频率计模块。但很多时候,项目进度和资源限制让我们停在“够用”这一步。理解原理的意义就在于,当“够用”变得“不够用”时,你能清楚地知道该从哪个方向去优化,而不是推倒重来。

「LLM那些事」系列第 4 篇《上下文窗口的边界》,文章连接:https://blog.csdn.net/houwenjin/article/details/163999753。 演示什么:在「预测」Sheet 的黄色格子里输入一句话(默认「来泡一杯」),四个「模型」——分别只统计最后 1 / 2 / 3 / 4 个字的 n-gram 查表——同时预测下一个字。同一个输入,看的上下文越长,候选越少、预测越确定: ┌────────────────┬──────────┬───────────────┬──────┐ │ 只看最后几个字 │ 用的前缀 │ 候选下一字数 │ 预测 │ ├────────────────┼──────────┼───────────────┼──────┤ │ 1 个 │ 杯 │ 3(茶/子/水) │ 模糊 │ ├────────────────┼──────────┼───────────────┼──────┤ │ 2 个 │ 一杯 │ 2(茶/水) │ 收窄 │ ├────────────────┼──────────┼───────────────┼──────┤ │ 3 个 │ 泡一杯 │ 1(茶) │ 确定 │ ├────────────────┼──────────┼───────────────┼──────┤ │ 4 个 │ 来泡一杯 │ 1(茶) │ 确定 │ └────────────────┴──────────┴───────────────┴──────┘
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值