0%

STM32——定时器入门:定时器就是计数器

从上一篇到这篇

上一篇我们研究了单片机的”心脏与动脉”——时钟源与时钟树。当时埋了个伏笔:时钟树上 APB1 定时器分支经过预分频 + 倍频,给定时器送来了 72MHz 的时钟。

为什么定时器值得单独一条分支、还专门配一个倍频器把频率”补”回 72MHz?从本篇开始,我们就来研究单片机中另一个非常重要的概念——定时器 TIM。

如果你以为定时器的作用是”设定一个时间,时间到了执行程序”——恭喜你猜对了一半,但也正是被”定时器”这个名字误导了。请记住这句贯穿后续好几期的话:

定时器就是计数器。

身为计数器,通过不同的”计数姿势”的巧妙组合,定时器除了实现普通的定时功能,还可以捕获脉冲宽度、计算 PWM 占空比、输出 PWM 波形、编码器计数……功能非常丰富。

定时器家族:三类定时器

STM32F103 系列最多有 8 个定时器(TIM1~TIM8),按功能强弱分三类:

类别 成员 功能
基本定时器 TIM6、TIM7 功能最弱,只有简单的定时功能
通用定时器 TIM2~TIM5 在基本定时器基础上扩展,功能丰富,是学习重点
高级定时器 TIM1、TIM8 最强,多了刹车信号、输入捕获、互补输出等工业电机功能

高级定时器的那些电机功能需要提前学习许多电机知识,当前阶段我们把它当通用定时器用即可。

⚠️ 注意:我们使用的 STM32F103C8T6 上只有 TIM1~TIM4(1 个高级 + 3 个通用),并没有基本定时器。不过没关系——通用/高级定时器都是从基本定时器的结构上扩展出来的,包含基本定时器的全部功能,所以把任意一个通用定时器当基本定时器用就行。本集我们用 TIM4。

定时器就是计数器

计时原理:恒定频率方波 + 计数器

如何计时?其实非常简单:一个恒定频率的方波信号 + 一个寄存器,每当方波的上升沿到达,寄存器存储的数就 +1,就能完成计时。

1
2
3
4
5
6
7
8
        ┌──┐  ┌──┐  ┌──┐
方波 ────┘ └──┘ └──┘ └── ... 100Hz = 每秒 100 个脉冲
↑ ↑ ↑ ↑
│ │ │ └─ 计数到 100 → 我们知道过了一秒
│ │ └─ 3
│ └─ 2
└─ 1
计数器: 0 → 1 → 2 → 3 → ... → 100

恒定频率的方波从哪来?就是上篇时钟树讲过的:APB1 定时器分支,72MHz。也就是计数器每数到 7200 万,就是一秒。

可惜计数器寄存器只有 16 位,最多数 65536 个数——72MHz 下 0.0009 秒就数满了,这太少了。所以定时器在计数器前加了一个预分频器。

预分频器:给方波”减速”

预分频器本质也是个计数器(通过计数来分频):

预分频器设置值 行为 等效分频
0 不分频,脉冲原样输出 ÷1
1 数 0、1,每 2 个脉冲输出 1 个 ÷2
2 数 0、1、2,每 3 个脉冲输出 1 个 ÷3

规律:想要 N 分频,就设置 N-1(因为从 0 开始计数)。

预分频器也是 16 位,最多 65536 分频。72MHz ÷ 65536 ≈ 1098Hz,即最多可以定时 59 秒多——对于绝大多数需要高精度定时的任务足够用了。

💡 想要更长时间的定时?定时器可以串联——一个定时器作为另一个的输入时钟。两个定时器组合可以实现 8124 年(当然,晶振存在误差,长时间定时会有累积误差,追求极致精确的话需要卫星授时校准等方案)。

自动重装载计数器:实现”定时”

有了预分频器和计数器,我们已经能”计时”(读计数器值知道过了多久),但怎么实现”每 1 秒提醒我发送一次数据“这种”定时”功能?

靠第三个部件——自动重装载计数器(ARR):

  • 它实时监控计数器的值是否与自己相同
  • 当计数器数到与它相同 → 把计数器清零,重新开始计数
  • 同时可根据设置触发一次更新中断,通知 STM32”时间到了”

和预分频器一样,因为从 0 开始计数,想定时 M 个脉冲就设置 M-1。于是计数器从 0 数到 M-1,共 M 个数,然后重置并触发中断。

1
2
3
4
┌────────────┐    ┌────────────┐    ┌────────────┐
│ 预分频器 PSC │──►│ 计数器 CNT │──►│重装载计数器ARR│──► 相等时:
│ ÷7200 │ │ 0~9999 │ │ = 9999 │ ① 计数器清零重新计数
└────────────┘ └────────────┘ └────────────┘ ② 触发更新中断

数据流全链路:从 72MHz 到每秒一次的串口数据

理解了三个部件,把本集项目的完整数据流串起来——从时钟树到串口助手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
APB1 定时器分支 72MHz (来自上篇的时钟树, HSE 8MHz ×PLL9)
│
▼
┌────────────────┐ ÷7200 (设置 7200-1) ┌────────────────┐
│ 预分频器 PSC │ ──────────────────────► │ 10kHz 脉冲 │
│ (幕后: 影子寄存器)│ 每7200个脉冲输出1个 │ 每秒10000个脉冲 │
└────────────────┘ └───────┬────────┘
│
▼
┌────────────────┐
│ 计数器 CNT (16位)│
│ 0→1→2→…→9999 │
└───────┬────────┘
│ 实时比较
▼
┌────────────────┐
│ 重装载 ARR=9999 │──相等→ 清零 + 更新中断
│ (10000-1) │
└────────────────┘
│
▼
HAL_TIM_PeriodElapsedCallback (中断回调)
│
▼
HAL_UART_Transmit_IT 中断发送
│
▼
串口助手: 每秒收到一次数据 ✅

关键计算:72MHz ÷ 7200 = 10kHz(每秒 10000 个脉冲),计数 10000 次 = 正好 1 秒。

CubeMX 配置

① 基础配置(老流程)

  • 创建工程(STM32F103C8T6),打开串口2(USART2,115200),并打开串口中断(后面用中断发送)
  • RCC 配置页打开 HSE(Crystal/Ceramic Resonator)
  • Clock Configuration 页 HCLK 改为 72MHz
  • 注意时钟树页面:APB1 定时器分支已经是 72MHz(APB1 ÷2 = 36MHz,定时器自动倍频 ×2 = 72MHz)——上一篇的知识在这里直接落地

② TIM4 定时器配置

打开 Timers → TIM4,把 Clock Source 选为 Internal Clock(内部时钟,即来自 APB1 的 72MHz),就算打开了定时器:

image-20260806214008514

配置项 填的值 说明
Prescaler(预分频器) 7200-1 想 7200 分频就填 7199(N-1)
Counter Mode Up 向上计数(默认)
Counter Period(自动重装载) 10000-1 想定时 1 秒就填 9999(M-1)

72MHz ÷ 7200 = 10kHz,数 10000 次 = 1 秒,每 1 秒触发一次更新中断。

③ 打开更新中断

在 TIM4 配置页切到 NVIC Settings 选项卡,勾选 TIM4 global interrupt:

image-20260806214104527

保存并生成代码。打开 main.c,可以看到 main 函数调用了一个新的初始化函数 MX_TIM4_Init()。

核心代码

1. 启动定时器(main 函数 USER CODE 2)

1
2
3
/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start_IT(&htim4); // 启动 TIM4,并使能更新中断
/* USER CODE END 2 */

2. 更新中断回调(USER CODE 0)

定时器的”时间到”是通过更新中断通知的——每次计数器数到与重装载值相同、被清零重新计数时触发。中断发生后要执行什么,靠重写回调函数:

1
2
3
4
5
6
7
8
9
10
/* USER CODE BEGIN 0 */
char data[] = "😊😂🤣😍😘🙌"; // 要发送的数据(随便示意一下)
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim == &htim4) // 好习惯:先确认是本定时器的中断
{
HAL_UART_Transmit_IT(&huart2, data, strlen(data)); // 串口中断发送
}
}
/* USER CODE END 0 */

💡 这个回调函数是 HAL 库定义的弱函数(weak),默认空实现,我们在自己的代码里重写即可。它会被所有定时器(TIM1~TIM4)共用,所以第一个动作就是判断是哪个定时器——htim == &htim4。

代码讲解

HAL 函数 作用 本项目中参数
HAL_TIM_Base_Start_IT 启动定时器(Base=基本用法),并使能更新中断(IT=Interrupt) &htim4 定时器句柄指针
HAL_TIM_PeriodElapsedCallback 更新中断回调(重写) 无参数,靠 htim 判断来源
HAL_UART_Transmit_IT 串口中断发送(非阻塞) &huart2、data、strlen(data)

另外,定时器还有一些名称全大写、以双下划线开头的函数——这类在 HAL 库中其实是宏定义,比较偏底层,倾向于直接操作寄存器。可以用它们随时读写定时器的核心部件,方便调试或动态改参数:

  • __HAL_TIM_GET_COUNTER / __HAL_TIM_SET_COUNTER:获取/设置计数器的值
  • __HAL_TIM_GET_AUTORELOAD / __HAL_TIM_SET_AUTORELOAD:获取/设置重装载寄存器的值
  • __HAL_TIM_SET_PRESCALER:设置预分频器的值

可以自己设计实验试试这些函数。

解读初始化代码:MX_TIM4_Init

CubeMX 生成的定时器初始化代码,逐行解析(这是本集必做的深度解读章节):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void MX_TIM4_Init(void)
{
TIM_ClockConfigTypeDef sClockSourceConfig = {0}; // 时钟源配置结构体
TIM_MasterConfigTypeDef sMasterConfig = {0}; // 主模式配置结构体

htim4.Instance = TIM4; // 指定外设: TIM4
htim4.Init.Prescaler = 7200-1; // 预分频: ÷7200 (对应CubeMX Prescaler)
htim4.Init.CounterMode = TIM_COUNTERMODE_UP; // 计数模式: 向上计数
htim4.Init.Period = 10000-1; // 自动重装载: 数到9999清零 (对应Counter Period)
htim4.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; // 时钟分频: 用于输入滤波, 定时功能用不到
htim4.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; // 重装载影子寄存器: 关闭
if (HAL_TIM_Base_Init(&htim4) != HAL_OK)
{
Error_Handler();
}

/* 时钟源: 内部时钟 (APB1定时器分支 72MHz) */
sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
if (HAL_TIM_ConfigClockSource(&htim4, &sClockSourceConfig) != HAL_OK)
{
Error_Handler();
}

/* 主模式配置: 本集用不到, 保持默认 */
sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
if (HAL_TIMEx_MasterConfigSynchronization(&htim4, &sMasterConfig) != HAL_OK)
{
Error_Handler();
}
}

各配置项与 CubeMX / 寄存器的对应关系

代码配置 对应 CubeMX 设置 对应寄存器 本项目值
Init.Prescaler Prescaler TIM_PSC 7200-1
Init.CounterMode Counter Mode TIM_CR1.DIR UP
Init.Period Counter Period TIM_ARR 10000-1
Init.ClockDivision Clock Division TIM_CR1.CKD ÷1
AutoReloadPreload auto-reload preload TIM_CR1.ARPE DISABLE
ClockSource = INTERNAL Clock Source TIM_SMCR.SMS 内部时钟

MSP 初始化:时钟使能与中断

定时器初始化的另一半在 HAL_TIM_Base_MspInit() 里(MSP = MCU Support Package,即”与 MCU 相关的部分”:时钟、引脚、中断):

1
2
3
4
5
6
7
8
9
void HAL_TIM_Base_MspInit(TIM_HandleTypeDef* tim_baseHandle)
{
if (tim_baseHandle->Instance == TIM4)
{
__HAL_RCC_TIM4_CLK_ENABLE(); // ① 打开 TIM4 的外设时钟(挂钟!)
HAL_NVIC_SetPriority(TIM4_IRQn, 0, 0); // ② 设置 TIM4 中断优先级: 抢占0 子0
HAL_NVIC_EnableIRQ(TIM4_IRQn); // ③ 使能 TIM4 中断
}
}

三步缺一不可:不打开外设时钟(挂钟),定时器根本不工作;不设置优先级、不使能中断,更新中断永远不会触发。

中断链路

1
2
3
4
5
6
7
8
9
10
计数器数到 ARR → 硬件置位更新标志 → 触发 TIM4_IRQn
│
▼
TIM4_IRQHandler (stm32f1xx_it.c, 中断服务函数)
│
▼
HAL_TIM_IRQHandler(&htim4) (HAL库, 判断中断来源)
│
▼
HAL_TIM_PeriodElapsedCallback(&htim4) (我们重写的回调)

和串口中断的套路一模一样:中断服务函数 → HAL 通用处理函数 → 我们重写的回调。

影子寄存器机制:幕后老板与一线员工

前面讲预分频器和重装载时,代码里有两个容易被忽略的设置,它们背后是定时器一个精巧的设计——影子寄存器。

预分频器:真正工作在分频一线的,其实是预分频寄存器的影子寄存器,而预分频寄存器本人更像”运筹帷幄的幕后老板”。我们通过 __HAL_TIM_SET_PRESCALER 设置的新预分频值,会等到当前计数周期结束(计数器数到重装载值、重新从零开始)时才传递给影子寄存器生效——简而言之,新值下个计数周期才生效。

自动重装载计数器:同样有影子寄存器,作用相同(新值下个周期生效)。不同之处在于——重装载的影子机制可以开关(AutoReloadPreload,默认关闭):

  • 关闭(本项目):改 ARR 立即写进寄存器,但注意——如果把重装载值调小到小于当前计数器的值,它们会”错过”,计数器一路数到最大值 65535 才会触发更新
  • 开启(CubeMX 里勾选 auto-reload preload):新值在影子寄存器里等下个周期,绝对安全

有了影子寄存器机制,就可以随时修改预分频器/重装载寄存器,而不必担心影响当前的计数周期。

实操步骤

1. CubeMX 配置

1
2
3
4
5
6
7
① 串口2: Mode → Asynchronous, 波特率 115200, NVIC 勾选 USART2 global interrupt
② RCC → HSE → Crystal/Ceramic Resonator
③ Clock Configuration → HCLK = 72MHz → 回车 → OK
④ Timers → TIM4 → Clock Source = Internal Clock
Prescaler = 7200-1, Counter Period = 10000-1
⑤ NVIC Settings → 勾选 TIM4 global interrupt
⑥ 保存生成代码

2. 写代码

1
2
3
4
① USER CODE 2:  HAL_TIM_Base_Start_IT(&htim4) 启动定时器
② USER CODE 0: 重写 HAL_TIM_PeriodElapsedCallback, 判断 htim==&htim4 后
HAL_UART_Transmit_IT(&huart2, data, strlen(data))
③ 头文件: #include <string.h> (strlen)

3. 编译下载 & 查看效果

编译下载,连上串口线,打开波特律动串口助手连接串口。刚下载完程序会慢几拍,按下板上的复位键让程序重新开始运行。

效果展示

https://www.bilibili.com/video/BV1Cnup68E8Z/?vd_source=5a111e8d49f70e532d36ddb73be3d0aa

串口助手每秒收到一次数据(emoji)

踩坑记录

1. 中断发送和主循环发送会”打架”

HAL_UART_Transmit_IT 是非阻塞发送。如果中断里的发送和主循环里的发送间隔太小,上次的数据还没发完,下次发送就把缓冲区覆盖掉了。所以发送逻辑尽量集中在一处——本项目把发送全部放在定时器中断回调里,主循环只留给自己的业务逻辑。

总结

复习结构图

1
2
3
4
5
 时钟源 (内部时钟 72MHz)
│
▼
预分频器 PSC ──► 计数器 CNT ──► 重装载 ARR ──► 更新中断
(÷7200) (0~9999) (=9999)

本集要点

要点 内容
定时器就是计数器 一切功能的本质
N-1 原则 预分频想 N 分频填 N-1;重装载想定时 M 个脉冲填 M-1
三个核心寄存器 PSC(预分频)、CNT(计数器)、ARR(重装载)
更新中断 计数器数到 ARR → 清零 → 触发中断 → 回调发数据
三步配置 勾 Internal Clock + 设 PSC/ARR + 开 NVIC 中断
两个函数 HAL_TIM_Base_Start_IT 启动、HAL_TIM_PeriodElapsedCallback 回调
影子寄存器 预分频/重装载新值下个周期生效;重装载可开关

本期只用了基本定时器功能。接下来的几期会在这张结构图的基础上不断扩展——万变不离其宗,定时器就是计数器。

参考资源