1. 从调光到控速:为什么你需要了解定时器的两种玩法

大家好,我是老李,一个在嵌入式领域摸爬滚打了十多年的工程师。今天想和大家聊聊STM32里一个既基础又强大的模块——定时器。很多朋友一提到定时器,第一反应就是输出PWM波,用来调个LED亮度或者驱动个电机。这没错,但如果你只停留在“配置CubeMX,生成PWM”这一步,那可就错过了定时器一半的精彩。

我遇到过不少项目,前期用PWM模式一切顺利,但到了后期需求变更,比如要求一个定时器的几个通道输出不同频率的波形,或者需要动态、实时地改变波形参数时,就抓瞎了。这时候,如果你只知道PWM模式,可能就得额外增加硬件定时器,或者用软件模拟,既浪费资源又增加复杂度。其实,STM32的定时器早就为你准备好了更灵活的武器:输出比较模式。

你可以把PWM模式想象成一个“老实本分”的工人,你给他设定好工作节奏(频率)和每次干活的时间比例(占空比),他就会一直按这个节奏重复工作,非常稳定可靠。而输出比较模式则像一个“聪明灵活”的工人,你告诉他“下一次动作的时间点”,他就能在精确的时刻做出反应(比如翻转电平),并且每次你都可以告诉他不同的“下一次”时间。这样一来,你就能通过编程,让他走出任何你想要的复杂“舞步”(波形)。

在接下来的内容里,我不会只给你讲枯燥的理论。我们会聚焦于一个非常具体的场景:用一个RGB LED的调光动画,来驱动一个微型直流电机的多段变速控制。听起来是不是有点意思?我们将从最基础的PWM模式实现三路同频调光开始,一步步遇到需求瓶颈,然后自然过渡到使用输出比较模式,实现三路独立频率、独立占空比的复杂波形,最终模拟出电机加速、匀速、减速的平滑过程。我会手把手带你用CubeMX配置,分析关键代码,并且用逻辑分析仪抓取真实波形,让你亲眼看到两种模式的差异和效果。准备好了吗?我们开始吧。

2. 基础建设:用PWM模式实现三路同步呼吸灯

我们先从最熟悉的领域入手。假设我们手头有一个共阳极的RGB LED,我们希望实现一个“同步呼吸”效果,即红、绿、蓝三个通道同时从暗到亮,再从亮到暗,节奏完全一致。这是一个典型的、适合PWM模式的应用场景。

2.1 PWM模式的核心:ARR与CCR的二人转

PWM模式的原理其实非常直观。定时器就像一个在跑圈的数字运动员(计数器CNT),跑道一圈的长度由自动重装载寄存器ARR决定。比如你设置ARR为999,那么运动员就从0数到999,然后回到0,开始下一圈。他跑步的快慢,则由预分频器PSC来决定,PSC决定了系统时钟要除以多少再给运动员当步频。

那么PWM的高低电平由谁控制呢?就是捕获/比较寄存器CCR。你可以把它想象成跑道上的一个标记点。在PWM1模式下,当运动员跑的位置(CNT值)小于这个标记点(CCR值)时,对应的输出引脚就输出高电平(或低电平,取决于极性);超过这个标记点后,就输出相反的电平。直到他跑完一圈(CNT==ARR),清零重新开始。

所以,频率由ARR和PSC共同决定,公式是:PWM频率 = 定时器时钟源 / [(ARR+1) * (PSC+1)]。而占空比则由CCR决定,公式是:占空比 = CCR / (ARR+1)。你看,ARR同时影响着频率和占空比的分母,这就埋下了一个伏笔:改变频率会影响所有通道的占空比基准。

2.2 CubeMX配置:三路PWM只需几分钟

我们以STM32G431为例,使用TIM3定时器,它的CH1、CH2、CH3分别对应PA6、PA7、PB0引脚,正好驱动RGB LED的三个阴极。

  1. 时钟树配置:在RCC设置中,使用HSE(外部高速时钟),并通过PLL将系统时钟锁定到80MHz。确保APB1定时器时钟也是80MHz。
  2. 定时器基础配置:找到TIM3,时钟源选择“Internal Clock”。在Parameter Settings标签页:
    • Prescaler (PSC - 16 bits value):设置为79。这样定时器时钟 = 80MHz / (79+1) = 1MHz。
    • Counter Mode:选择“Up”(向上计数)。
    • Counter Period (AutoReload Register - 16 bits value):设置为999。这样PWM频率 = 1MHz / (999+1) = 1kHz。1kHz的频率对于LED调光来说非常合适,无闪烁感。
    • auto-reload preload:选择“Enable”。这能确保ARR值在更新事件发生时才被载入,防止波形产生毛刺。
  3. PWM通道配置:切换到TIM3的“Channel 1”,模式选择“PWM Generation CH1”。同样地,为Channel 2和Channel 3也选择PWM模式。关键参数在这里:
    • Pulse (16 bits value):这个值就是CCR。我们初始化为500,即50%占空比。你可以为三个通道设置不同的初始值,产生不同的初始颜色。
    • Fast Mode:禁用。
    • PWM Polarity:选择“Low”。因为我们是共阳极LED,阴极给低电平才点亮。所以当CNT<CCR时,我们希望输出低电平(有效电平)。
    • CH Polarity:保持“High”。
    • 注意:对于Channel 2和3,也需要将PWM Polarity设置为“Low”,以保持一致性。
  4. 生成代码,记得选择好你的IDE(如Keil MDK)。

2.3 代码实现与波形验证:稳定但“死板”

生成的代码非常简洁。在main.c的初始化部分之后,启动PWM输出即可:

/* 启动TIM3的三个PWM通道 */
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);

如果你想实现呼吸灯效果,可以在主循环或定时器中断里动态修改CCR值。HAL库提供了宏定义来方便操作:

// 增加红光亮度(减小CCR值,因为我们是低有效)
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, new_red_value);
// 同理修改绿光和蓝光
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, new_green_value);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, new_blue_value);

用逻辑分析仪抓取PA6、PA7、PB0三个引脚的波形,你会看到三个完美的、频率严格为1kHz的方波,只是占空比在同步变化。这就是PWM模式的优点:硬件自动生成,不占用CPU,波形纯净稳定

但是,问题来了。如果我现在想让红灯以1Hz的频率呼吸(缓慢变化),绿灯以5Hz的频率闪烁(快速变化),蓝灯常亮50%亮度。你会发现,在PWM模式下,你无法做到。因为TIM3的ARR只有一个,它决定了三个通道共同的频率。你改变ARR,所有通道的频率一起变。PWM模式就像一场集体舞,所有人的步调必须一致。

3. 需求升级:当同步PWM无法满足独立控制时

我们的场景需要进化了。现在,我们不再仅仅驱动一个RGB LED,而是要模拟一个更复杂的控制任务:用一个三通道的定时器,独立控制三个微型直流电机的转速曲线。假设这三个电机分别驱动一个玩具小车的前进、转向和抬升机构。

  • 电机A(前进):需要完成一个“缓启动-高速运行-缓停止”的完整过程,频率较低但占空比平滑变化。
  • 电机B(转向):需要高频抖动(比如200Hz)以实现小幅度快速修正,占空比在小范围波动。
  • 电机C(抬升):只需要在某个时刻触发一下,以固定占空比运行一段固定时间后停止。

显然,PWM模式在这里束手无策。我们需要每个通道都有独立的“时间表”。这就是输出比较模式大显身手的时候了。

3.1 输出比较模式的精髓:中断驱动的“日程表”

输出比较模式的核心思想从“比较-输出固定模式”变成了“比较-触发事件-由你决定接下来做什么”。在输出比较模式下,当计数器CNT的值与捕获/比较寄存器CCR的值相等时,硬件会做两件事:

  1. 根据你设定的模式(例如翻转Toggle),自动翻转对应输出引脚的电平。
  2. 产生一个“比较匹配”中断。

关键在于第二点。在这个中断里,你可以重新计算并设置下一个“匹配点”(即新的CCR值)。因为CCR是每个通道独立的,所以你完全可以为每个通道规划一套完全独立的时间序列。

举个例子,假设我们仍然使用1MHz的计数时钟(PSC=79)。我们希望通道1输出一个周期为10ms(频率100Hz),占空比为30%的PWM波。

  • 第一步:初始化CCR1为一个值,比如0。设置模式为Toggle On Match(匹配时翻转)。
  • 第二步:计数器从0开始向上计数。当CNT等于CCR1(0)时,引脚电平第一次翻转(假设从低到高),并进入中断。
  • 第三步:在中断里,我们判断当前引脚电平。如果是高电平(意味着刚刚开启),那么我们希望高电平持续 10ms * 30% = 3ms。换算成计数次数是 3000(因为1MHz时钟下,1ms=1000次)。所以,我们设置下一个匹配点为 当前CNT + 3000,即 __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, current_cnt + 3000);
  • 第四步:计数器继续跑。当CNT跑到新的CCR1(3000)时,引脚电平再次翻转(高变低),并进入中断。
  • 第五步:此时引脚为低电平,低电平应持续 10ms * 70% = 7ms,即7000次计数。设置下一个匹配点为 当前CNT + 7000
  • 如此循环往复。

你看,通过不断在中断里为CCR“续期”,我们就能用软件“编织”出任意频率和占空比的波形。而且,通道2、通道3的中断是独立的,它们可以设置完全不同的“续期”长度,从而实现独立的频率。

3.2 CubeMX配置切换:关键设置别踩坑

我们还是在TIM3上操作,将三个通道从PWM模式切换到输出比较模式。

  1. 模式选择:在TIM3的Channel 1、2、3,将模式从“PWM Generation”改为“Output Compare CHx”。
  2. 参数配置:这是容易出错的地方。
    • Pulse:这个初始CCR值可以设为0。
    • Mode必须选择“Toggle on match”。这是实现PWM波形的关键,它让硬件在匹配时自动翻转电平,我们只需要操心“下一次翻转是什么时候”。
    • Fast Mode:禁用。
    • Polarity:根据你的电路选择。我们延续之前的“Low”作为有效电平,但这里逻辑稍有不同,初始状态由Output compare preload和第一次匹配共同决定,为了简单起见,可以先保持默认,我们在代码中处理。
    • 最重要的两点
      • Output compare preload: 必须选择“Disable”。如果使能了预装载,那么你对CCR的写入会等到下次更新事件才生效,这会导致中断里设置的“下一个匹配点”严重滞后,波形完全错乱。我们必须让CCR的修改立即生效。
      • 同样,在基础配置页,auto-reload preload建议选择“Disable”,或者保持Enable但确保我们不动态修改ARR。因为ARR在输出比较模式下主要作用是提供计数器的溢出周期,我们通常将其设为最大值(如65535),让计数器自由溢出,避免频繁处理溢出中断增加复杂度。
  3. 别忘了开启定时器的全局中断(NVIC Settings中使能TIM3中断)。
  4. 生成代码。

3.3 代码实战:中断回调函数里的“时间魔法”

生成的初始化代码后,我们需要手动启动定时器和各通道的输出比较功能,并清除可能存在的旧中断标志。

// 启动定时器基础计数
HAL_TIM_Base_Start(&htim3);
// 以输出比较模式启动通道,并开启中断
HAL_TIM_OC_Start_IT(&htim3, TIM_CHANNEL_1);
HAL_TIM_OC_Start_IT(&htim3, TIM_CHANNEL_2);
HAL_TIM_OC_Start_IT(&htim3, TIM_CHANNEL_3);
// 清除中断标志位(可选,但是个好习惯)
__HAL_TIM_CLEAR_IT(&htim3, TIM_IT_CC1);
__HAL_TIM_CLEAR_IT(&htim3, TIM_IT_CC2);
__HAL_TIM_CLEAR_IT(&htim3, TIM_IT_CC3);

接下来是核心——重写输出比较中断的回调函数HAL_TIM_OC_DelayElapsedCallback。这个函数会在任何通道发生比较匹配时被调用。

// 定义三个通道的周期(脉冲数)和占空比
#define MOTOR_A_PULSE 10000 // 周期10ms -> 频率100Hz (1MHz时钟)
#define MOTOR_A_DUTY 30     // 占空比30%
#define MOTOR_B_PULSE 5000  // 周期5ms -> 频率200Hz
#define MOTOR_B_DUTY 50     // 占空比50%
#define MOTOR_C_PULSE 20000 // 周期20ms -> 频率50Hz (仅运行一段时间)
#define MOTOR_C_DUTY 80     // 占空比80%

void HAL_TIM_OC_DelayElapsedCallback(TIM_HandleTypeDef *htim)
{
    uint32_t current_cnt = __HAL_TIM_GET_COUNTER(htim); // 获取当前计数器值

    if (htim->Instance == TIM3)
    {
        switch (htim->Channel)
        {
            case HAL_TIM_ACTIVE_CHANNEL_1: // 电机A通道
            {
                if (HAL_GPIO_ReadPin(MOTOR_A_GPIO_Port, MOTOR_A_Pin) == GPIO_PIN_RESET)
                {
                    // 当前是低电平(有效),即将变高。低电平持续时间 = 周期 * (1 - 占空比)
                    __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_1,
                                          current_cnt + (MOTOR_A_PULSE * (100 - MOTOR_A_DUTY) / 100));
                }
                else
                {
                    // 当前是高电平(无效),即将变低。高电平持续时间 = 周期 * 占空比
                    __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_1,
                                          current_cnt + (MOTOR_A_PULSE * MOTOR_A_DUTY / 100));
                }
            }
            break;

            case HAL_TIM_ACTIVE_CHANNEL_2: // 电机B通道
            {
                // 逻辑同通道1,但使用独立的MOTOR_B参数
                if (HAL_GPIO_ReadPin(MOTOR_B_GPIO_Port, MOTOR_B_Pin) == GPIO_PIN_RESET)
                {
                    __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_2,
                                          current_cnt + (MOTOR_B_PULSE * (100 - MOTOR_B_DUTY) / 100));
                }
                else
                {
                    __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_2,
                                          current_cnt + (MOTOR_B_PULSE * MOTOR_B_DUTY / 100));
                }
            }
            break;

            case HAL_TIM_ACTIVE_CHANNEL_3: // 电机C通道
            {
                // 我们可以在这里添加更复杂的逻辑,比如只运行一定次数后停止
                static uint32_t run_count = 0;
                run_count++;
                if (run_count < 100) // 只运行100个周期
                {
                    if (HAL_GPIO_ReadPin(MOTOR_C_GPIO_Port, MOTOR_C_Pin) == GPIO_PIN_RESET)
                    {
                        __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_3,
                                              current_cnt + (MOTOR_C_PULSE * (100 - MOTOR_C_DUTY) / 100));
                    }
                    else
                    {
                        __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_3,
                                              current_cnt + (MOTOR_C_PULSE * MOTOR_C_DUTY / 100));
                    }
                }
                else
                {
                    // 运行次数达到,停止该通道输出(将引脚设置为无效电平并关闭中断)
                    HAL_TIM_OC_Stop_IT(htim, TIM_CHANNEL_3);
                    HAL_GPIO_WritePin(MOTOR_C_GPIO_Port, MOTOR_C_Pin, GPIO_PIN_SET); // 假设高电平无效
                }
            }
            break;

            default:
                break;
        }
    }
}

这段代码就是整个灵活波形的控制中枢。它为每个通道独立计算下一个翻转点。注意current_cnt + pulse的计算可能会溢出,但没关系,STM32的定时器计数器是32位或16位的,溢出后从0开始是硬件行为,我们比较的是“时间差”,只要这个差值计算正确,溢出不会影响波形的正确性。

4. 逻辑分析仪下的真相:对比两种模式的波形差异

理论说再多,不如实际波形有说服力。我将上述两种配置的代码分别下载到开发板,用逻辑分析仪同时抓取三个通道的信号。

PWM模式波形:三个通道的波形严格对齐,频率计测量显示均为1.000kHz。当我动态修改某个通道的CCR值时,其占空比立即改变,但频率纹丝不动。波形边缘干净利落,抖动极小,这是硬件PWM发生器的优势。

输出比较模式波形:波形图立刻变得“生动”起来。通道1(电机A)是标准的100Hz方波,占空比30%;通道2(电机B)是更密集的200Hz方波,占空比50%;通道3(电机C)则在运行一段时间后停止,变成了固定的高电平。关键测量结果:三个通道的频率值完全独立,互不影响。这正是我们想要的效果。

但是,仔细看波形细节,你会发现输出比较模式生成的波形,其翻转边沿偶尔会有微小的、纳秒级的抖动。这是因为它依赖于中断响应。虽然1MHz的计数时钟一个计数就是1微秒,中断响应延迟通常也在微秒级,在低频应用(几十到几百Hz)中影响微乎其微,但对于极高频率或对时序精度要求极其苛刻的场合(如开关电源),这种抖动是不可接受的。而PWM模式由硬件同步生成,几乎没有抖动。

5. 双模式选择策略:什么时候用什么?

经过上面的实战,我们可以清晰地总结出两种模式的选择策略,这就像为你的项目选择最合适的工具。

坚定不移选择PWM模式的情况:

  1. 多路信号严格同步:例如RGB LED调色、全桥电机驱动(上下桥臂必须互补对称)、多路DA转换的同步时钟等。
  2. 对波形纯度、实时性要求极高:比如开关电源、音频D类功放,需要绝对稳定的频率和极低的抖动。
  3. CPU资源紧张或想降低功耗:PWM完全由硬件生成,不产生中断,CPU可以休眠,功耗更低。
  4. 需求简单固定:只需要调节占空比,频率一旦设定就不需要改变。

考虑使用输出比较模式的情况:

  1. 需要单定时器产生多路独立频率:这是最核心的应用场景,就像我们的多电机控制例子。
  2. 需要生成非标准或复杂波形:比如你想产生一个占空比按正弦规律变化的PWM(SPWM),或者产生一个脉冲序列(每个脉冲宽度都不同)。你可以在中断里根据一个波形表来动态计算下一个CCR值。
  3. 需要精确的单次脉冲或脉冲延迟:输出比较模式可以非常方便地在精确的时间点产生一个边沿,常用于触发ADC采样或另一个外设。
  4. 频率或占空比需要非常频繁地动态变化:虽然PWM模式也能动态改CCR和ARR,但改ARR会影响所有通道频率。而输出比较模式每个通道的“周期”是通过两次中断的时间差实现的,改变参数更灵活独立。

在实际项目中,我经常混合使用。比如一个产品中,用TIM1的PWM模式产生高精度电机驱动信号,同时用TIM2的输出比较模式产生几个独立频率的蜂鸣器提示音和LED闪烁信号。吃透这两种模式,你就能把STM32的定时器玩出花来,面对各种波形生成需求都能游刃有余。最后提醒一点,输出比较模式频繁进入中断,要评估CPU负载,对于非常高频的信号,中断开销可能成为瓶颈,这时候就需要更高级的定时器特性(如DMA)或者换用硬件PWM加多定时器的方案了。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐