定时器到底在数什么:STM32 的时间基准从哪里来

经典三问

Q: 定时器是什么?
A: 定时器是芯片里一只独立计数的“秒表”。它借用芯片提供的节拍数数,数到设定的数字后,提醒程序“规定的时间到了”。它自己不产生时间,只负责把已有的节拍数成可以使用的时间。

Q: 为什么需要硬件定时器?
A: while (1) 每转一圈要花多长时间,取决于里面塞了多少代码。代码一多,这一圈就变长,靠循环次数等时间必然会漂。硬件定时器由独立计数器按固定节拍运行,即使 CPU 正在执行别的代码,它也继续计时,所以能提供稳定、可计算的时间刻度。

Q: 定时器常见使用场景?
A: 让 LED 按固定节奏闪烁、让按键隔一段时间再确认、让 OLED 定期刷新,以及给需要固定周期的功能提供时间基准。


前面按键控制 LED 时,主循环只需要反复检查“现在有没有按下”。但如果想让 LED 每隔一段时间自动翻转,或者让 OLED 每隔一段时间刷新一次,程序就必须知道“上次动作以后到底过去了多久”。这个问题和 C 语言里的循环次数不是一回事:循环次数只是程序自己走了多少步,毫秒则是现实世界真正流逝的时间。

最容易想到的办法,是在两次动作之间跑空循环:

volatile uint32_t i;

for (i = 0; i < 50000; i++)
{
}
LED_Toggle();

50000 不是时间。芯片主频(可以先理解为芯片运行的快慢)、编译器对代码的优化方式和主循环里其他代码一变化,空循环所花的时间就会变化;而且 CPU 在空循环里什么也做不了。把 OLED 的:

OLED_ShowNum(0, 0, press_count, 5, OLED_6X8);
OLED_Update();

不加判断地塞进 while (1) 也一样,屏幕会被不停重复刷新,按键和其他任务只能和这段传输争抢 CPU。

我们真正想表达的是:

如果距离上次刷新已经过去 100ms,才刷新一次 OLED

谁来提供这把“100ms 的尺子”?这就是定时器要解决的第一件事。

定时器的价值还不止是让灯闪得好看。假设程序一边等 LED 的下一次翻转,一边还要检查按键。用空循环等待时,CPU 被困在等待代码里,按键可能要等很久才被看到;把等待数字改大或改小,也只能靠猜。定时器把“什么时候到了”交给芯片里的独立计数器,主循环在这段时间里仍然可以做别的事。这样,等待有了单位,多个动作也能共用同一把时间尺。

这一节先只解决一个问题:让程序拥有稳定的 1ms 时间刻度。其他外设和它们的用途,等真正用到时再介绍。

先把目标写清楚

假设我们想让 LED 每 500ms 翻转一次,同时让 OLED 每 100ms 显示一次计数值。下面这段代码没有任何时间判断:

while (1)
{
    LED_Toggle();
    OLED_ShowNum(0, 0, press_count, 5, OLED_6X8);
    OLED_Update();
}

LED 会快到看起来像不亮,OLED 也会被反复发送整屏数据。主循环一圈究竟花多长时间,取决于当前代码和编译结果;它只是“重复执行”,并没有表达 500ms 或 100ms。

我们真正要写下的是两条明确的规则:LED 距离上次翻转满 500ms 才翻转,OLED 距离上次刷新满 100ms 才刷新。两个动作之间,主循环仍然可以检查按键。定时器提供这把共同的时间尺,业务代码只在时间到了时执行动作。

while (1) 的周期不固定,定时器的计数不会等你

这件事值得单独拎出来,因为它决定了后面所有“每隔多久做一次”的写法。

while (1) 本身没有一个由硬件保证的固定周期。它一圈到底花多久,等于这一圈里所有代码执行时间的总和。假设现在循环里只有:

while (1)
{
    LED_Toggle();
}

这一圈很短,LED 会在很短的时间内翻转很多次。再把按键读取、OLED 刷新、计算和其他函数塞进来:

while (1)
{
    Key_DetectPressEdge();
    OLED_ShowNum(0, 0, press_count, 5, OLED_6X8);
    OLED_Update();
    LED_Toggle();
}

上面那句 Key_DetectPressEdge() 就是按键那一套接口里的边沿检测,这里只有一只按键,所以名字里不带 A / B:

uint8_t Key_DetectPressEdge(void)
{
    static uint8_t was_pressed = 0;
    uint8_t is_pressed = (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == Bit_RESET);
    uint8_t event;

    event = (uint8_t)(is_pressed && !was_pressed);
    was_pressed = is_pressed;

    return event;
}

这一圈立刻变长,而且每次执行 OLED 传输、分支判断和函数调用的时间还可能不同。于是“循环跑了 100 圈”没有固定含义:在代码少的时候可能很快,在代码多的时候可能慢很多。只要往 while (1) 里再塞一段代码,原来凭循环次数估出来的时间就变了。这是纯软件循环的硬伤,不是把循环变量换个名字就能解决的。

定时器的计数走的是另一条路。它拿到自己的输入时钟后,按照 PSC 和 ARR 数数;CPU 进入主循环、执行普通函数,甚至正在执行定时器中断时,计数器都在继续走。到了设定的计数值,硬件就记下一次更新事件,再向 CPU 提出中断请求。CPU 什么时候有空处理这个请求,和“定时器什么时候数到头”是两件事;处理请求不会让背后的计数器停下来。

这也是定时器比空循环准确的原因:空循环是在等 CPU 自己数完,定时器是在让硬件替 CPU 数。主循环里后来增加一段代码,不会改变定时器已经算好的 1ms;它只可能让 CPU 晚一点处理“1ms 到了”的通知。

但“硬件继续计时”并不等于“中断里的代码可以无限变长”。看一个极端情况:

定时器周期:1us
中断处理时间:3us

第一次中断还没执行完,定时器已经又到了下一次甚至下几次更新时间。更新标志会继续保持待处理,CPU 一退出中断就会再次进入同一个中断;如果每次处理都不短于下一次触发的间隔,CPU 几乎没有机会回到 while (1),程序看起来就像“卡死在定时器里”。

更极端的是 72MHz 的芯片:1us 只有约 72 个 CPU 时钟周期。中断的进入、保存现场、判断标志、清除标志、退出,本身就要消耗周期。1us 不是“越精确越好”,而是一个很容易把 CPU 挤满的危险周期。

所以定时器有一条必须记住的边界:计时周期可以很短,中断处理必须更短,而且要给主循环留下时间。 中断里通常只做记数、置标志这类短动作;LED、按键和 OLED 的实际工作放回主循环,再由时间判断函数决定什么时候执行。

TIM1 里面那只看不见的计数器

这里选用芯片里编号为 TIM1 的一只定时器。TIM 是定时器(Timer)的缩写,末尾的 1 只是编号。把它想成一个从 0 开始数数的计数器就够了:它每收到一次计数时钟,就加一;加到设定上限后回到 0,并产生一次“更新事件”。

这里有三个最常碰到的量:

名字 在标准库结构体里的成员 它管什么
定时器输入时钟 不直接写进结构体 计数器最开始拿到的时钟频率
预分频值 PSC TIM_Prescaler 把输入时钟放慢多少倍
自动重装值 ARR TIM_Period 数到多大时回到 0,产生更新事件

STM32 的计数从 0 开始,这件事会让第一次算参数的人很容易少算一拍。

  • PSC = 7199 时,真正的分频系数是 7199 + 1 = 7200。
  • ARR = 9 时,计数器实际经历 0 到 9,一共 9 + 1 = 10 次计数。

向上计数时,更新频率可以直接这样算:

更新频率 = 定时器输入时钟 / (PSC + 1) / (ARR + 1)
更新周期 = 1 / 更新频率

下面把 TIM1 配成 1ms:

timer.TIM_Prescaler = 7200 - 1;
timer.TIM_CounterMode = TIM_CounterMode_Up;
timer.TIM_Period = 10 - 1;

当前示例让芯片按 72MHz 的节拍运行。这里可以先把这个数字理解成“芯片内部每秒提供 7200 万次基本节拍”;TIM1 的输入时钟也按 72MHz 计算,再经过 PSC 除以 7200。这个输入时钟来自芯片内部一条叫作 APB2 的时钟线路;这里先把 APB2 当作“把时钟送到 TIM1 的路”即可:

72,000,000Hz / 7200 = 10,000Hz

计数器现在每秒只加一万次,隔 0.1ms 加一次。它从 0 数到 9,一共数 10 次:

10,000Hz / 10 = 1,000Hz

每秒 1000 次更新,就是每次 1ms。

这里把 7200 - 1、10 - 1 写出来,是为了让你以后改参数时有迹可循。以后你要改成 10ms,最直接的方式是保留 PSC,把 ARR 改为 100 - 1:

72MHz / 7200 / 100 = 100Hz = 10ms

也可以反过来改 PSC。数学上都可以,工程上我一般会先把计数时钟整理成好算的频率,再用 ARR 决定周期。比如这里的 10kHz 很直观:10 个计数是 1ms,100 个计数是 10ms。参数不是只有一个标准答案,重点是你改完后能一眼重新算出来。

先确认时钟,再按公式算。 上面的 72MHz 不是所有工程都自动成立。时钟树配置、APB 分频,以及 APB 分频不为 1 时定时器时钟可能被倍频,都会影响结果。拿别人的 PSC=7199, ARR=9 直接贴进工程,和把别人的尺子拿来量自己的板子差不多,能不能对得上得先看刻度。

初始化:不是神秘仪式,是把这块外设交到我们手上

初始化常被写成一长串固定模板,复制几次以后更容易忘记每一步在干什么。把下面这段拆开看,它其实只是在回答几个很具体的问题:TIM1 有没有电?它按什么速度数?数到哪里算一轮?更新事件要不要通知 CPU?CPU 收到后跳到哪个函数?

void Timer_Init1ms(void)
{
    TIM_TimeBaseInitTypeDef timer;
    NVIC_InitTypeDef nvic;

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE);
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);

    timer.TIM_Prescaler = 7200 - 1;
    timer.TIM_CounterMode = TIM_CounterMode_Up;
    timer.TIM_Period = 10 - 1;
    timer.TIM_ClockDivision = TIM_CKD_DIV1;
    timer.TIM_RepetitionCounter = 0;
    TIM_TimeBaseInit(TIM1, &timer);

    TIM_ClearFlag(TIM1, TIM_FLAG_Update);
    TIM_ITConfig(TIM1, TIM_IT_Update, ENABLE);

    nvic.NVIC_IRQChannel = TIM1_UP_IRQn;
    nvic.NVIC_IRQChannelPreemptionPriority = 1;
    nvic.NVIC_IRQChannelSubPriority = 1;
    nvic.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&nvic);

    TIM_Cmd(TIM1, ENABLE);
}

第一句 RCC_APB2PeriphClockCmd() 是开 TIM1 的时钟。和 GPIO 一样,外设没拿到时钟,后面写进去的配置不会自己活过来。

第二句 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2) 定的是中断优先级怎么分组:哪几位算抢占优先级、哪几位算子优先级。整个工程设一次就够,和时钟一起放在这里。

TIM_TimeBaseInitTypeDef 是一张配置单,不是定时器本身。我们把 PSC、ARR、计数方向等内容填在这张单子上,再交给:

TIM_TimeBaseInit(TIM1, &timer);

这句函数会把配置写进 TIM1 的硬件寄存器。寄存器究竟是哪几位被修改,留给后面的寄存器系列去拆;现在把它当成一个边界清楚的黑盒就好:输入是 TIM1 和配置单,输出是一个按指定规则计数的定时器。

TIM_ClearFlag(TIM1, TIM_FLAG_Update) 是开中断之前先把更新标志清一次。参数写完,计数器就已经按新规则跑起来了,可能已经举过一次“更新事件发生了”的牌子;先把牌子放下,TIM_ITConfig 打开的才是干净的第一次中断。

TIM_ITConfig(TIM1, TIM_IT_Update, ENABLE) 只是允许 TIM1 发出“我数完一轮了”的中断请求。请求还要交给 NVIC(芯片里统一管理中断的部分),CPU 才知道该跳去处理 TIM1_UP_IRQHandler()。最后的 TIM_Cmd(TIM1, ENABLE) 才是按下开始键。少了它,前面的参数都还在,计数器却没有开始数。

TIM_ClockDivision 和 TIM_RepetitionCounter 是不是也必须懂?

这两个成员常常让人盯着模板发愣。

TIM_ClockDivision = TIM_CKD_DIV1 不是 PSC 的第二个分频器。做普通的周期计时和更新中断时,保持这个默认值就行,暂时不用再给它安排别的任务。

TIM_RepetitionCounter 是高级定时器才真正用得上的重复计数器。TIM1 正好具备它,但现在填 0:每次数完一轮就产生更新事件。把它写在这里,是标准库这张配置单本来就有这个格子;做 1ms 节拍时不需要再套一层周期。

更新中断里,只留下一个很短的动作

TIM1 每 1ms 进一次中断。中断像有人敲门:CPU 会暂时放下主循环,先执行中断函数,处理完再回去。

static volatile uint32_t system_ms = 0;

void TIM1_UP_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET)
    {
        TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
        system_ms++;
    }
}

system_ms 前面的 volatile 是告诉编译器:这个变量会在你看不见的地方变化,不要自作主张把它一直缓存起来。主循环读它,中断改它,正是这种情况。

清除中断标志也不能省。TIM1 已经举起“更新事件发生了”的牌子,TIM_ClearITPendingBit() 才是把牌子放下。忘了这句,中断会不断因为同一件事再次触发,主循环像被堵在门外,灯和 OLED 的表现都会变得奇怪。

这个 1ms 中断里只记时间,不直接刷 OLED、打印串口,也不做很长的计算。1ms 后下一次中断还会来,前一次没处理完就会开始挤占别的事情。中断里记时或置一个标志,主循环里做真正的业务,事情多起来时仍然容易看清每一步。

向上、向下、中心对齐:都在数,只是走法不同

对当前这个 1ms 节拍,TIM_CounterMode_Up 最容易看:0 -> ARR -> 0,到顶产生更新事件。

标准库还给出了几种常见模式:

模式 计数器怎么走 更常在哪里碰到
TIM_CounterMode_Up 0 一直加到 ARR,再回 0 普通定时、周期中断
TIM_CounterMode_Down 从 ARR 减到 0,再装回 ARR 需要倒着计数的时序
TIM_CounterMode_CenterAligned1/2/3 从 0 加到 ARR,再减回 0 需要对称时序的输出

中心对齐不是“更高级的向上计数”,只是它让计数器来回走。一个完整来回比单程更长,更新事件出现的时机也会变,不能把向上计数时算出的 ARR 原封不动搬过去。现在做 1ms 系统节拍,选向上计数最容易计算;需要其他走法时,再按实际波形和时序重新计算。

通用、基本、高级定时器,差别不在名字好不好听

定时器不全是一种。对这块 STM32F103C8T6 来说,TIM2、TIM3、TIM4 是通用定时器,TIM1 是高级定时器;其他 STM32 型号还可能提供只负责计时的基本定时器,数量也可能不同。具体有哪些,始终以手里芯片的数据手册为准。

  • 基本定时器更像专职报时员,适合只要周期更新事件的场合。
  • 通用定时器除了周期计时,还能把计数结果用于测量外部信号,或按计数节奏改变输出。
  • 高级定时器在通用定时器的基础上,多了一些面向特殊输出时序和故障保护的功能。F103C8T6 的 TIM1 属于这一类,但在这里只把它当作普通的周期计时器使用。

不同类型的定时器,能做的事情和数量可能不同。给 LED、按键、OLED 提供 1ms 节拍时,先确认手里的芯片有一只可用的定时器,再把“按设定节奏产生更新事件”这件事做稳即可。

留几个可以把它玩坏的实验

这次不需要额外接线,继续用 OLED 和 LED 就能看见定时器的脚步。每次只改一处,重新编译烧录,再把改动改回来;不要一次同时动 PSC、ARR 和任务周期,不然现象变了也不知道是谁造成的。

实验一:让 OLED 显示 system_ms

烧录后盯着 OLED 上的 system_ms:它每次刷新增加的大小,大概就是 while 循环一次的大小。

实验二:同一个 1ms,改两组 PSC/ARR

先保持总除数不变,把:

timer.TIM_Prescaler = 7200 - 1;
timer.TIM_Period = 10 - 1;

改为:

timer.TIM_Prescaler = 720 - 1;
timer.TIM_Period = 100 - 1;

仍然是 72MHz / 720 / 100 = 1kHz。OLED 上的时间变化应该没有本质区别。再把第二行改成 101 - 1,就会变成约 1.01ms 一次;这点差距短时间不明显,等它多跑一会儿,和手机计时器一对就能看出慢慢落后。

实验三:少减一,看见 STM32 的“从零开始”

把 timer.TIM_Period = 10 - 1; 临时改成:

timer.TIM_Period = 10;

计数器会经历 0 到 10,共 11 次,更新周期变成 1.1ms。不是 1ms。改 PSC 时也一样:寄存器里写的是“减一后的值”,这就是公式总带 +1 的原因。

实验四:把 OLED 强行塞进 1ms 中断

临时把下面这类代码放进 TIM1_UP_IRQHandler():

    OLED_ShowNum(36, 0, system_ms, 10, OLED_6X8);
    OLED_Update();

或者在定时器里面加长时间延时。

实验五:关掉清标志这句

在副本中临时注释:

TIM_ClearITPendingBit(TIM1, TIM_IT_Update);

这类实验的具体表现会随库实现和调试环境变化,但结果不会正常。只要中断标志还挂着,CPU 就会反复被同一个 TIM1 更新请求拉进中断。

最后橘猫说

定时器带来的不只是“让一个灯每隔一秒翻转”。TIM1 让程序有了一把共同的时间尺:按键检查、LED 翻转和 OLED 刷新都有明确的时间单位,主循环也不用困在空循环里等待。定时器负责数,主循环负责做事,中断负责把“时间到了”这件事留下来。

以后会遇到更多依赖时间的功能:有的需要按固定节奏改变输出,有的需要测量外部信号间隔,有的需要统计一段时间内发生了多少次变化。名字会变,里面始终是同一只计数器:拿到时钟,按规则数数,在指定时刻产生事件。

现在已经可以用这把时间尺写出可解释的 LED、按键和 OLED 程序。后面每接触一种新的外设,都可以先问一句:它需要什么样的时间,谁来提供这段时间。

posted @ 2026-09-28 19:16  Zw-awa  阅读(51)  评论(0)    收藏  举报