从上一篇到这篇
上一篇我们研究了单片机的”心脏与动脉”——时钟源与时钟树。当时埋了个伏笔:时钟树上 APB1 定时器分支经过预分频 + 倍频,给定时器送来了 72MHz 的时钟。
为什么定时器值得单独一条分支、还专门配一个倍频器把频率”补”回 72MHz?从本篇开始,我们就来研究单片机中另一个非常重要的概念——定时器 TIM。
如果你以为定时器的作用是”设定一个时间,时间到了执行程序”——恭喜你猜对了一半,但也正是被”定时器”这个名字误导了。请记住这句贯穿后续好几期的话:
定时器就是计数器。
身为计数器,通过不同的”计数姿势”的巧妙组合,定时器除了实现普通的定时功能,还可以捕获脉冲宽度、计算 PWM 占空比、输出 PWM 波形、编码器计数……功能非常丰富。
定时器家族:三类定时器
STM32F103 系列最多有 8 个定时器(TIM1~TIM8),按功能强弱分三类:
| 类别 | 成员 | 功能 |
|---|---|---|
| 基本定时器 | TIM6、TIM7 | 功能最弱,只有简单的定时功能 |
| 通用定时器 | TIM2~TIM5 | 在基本定时器基础上扩展,功能丰富,是学习重点 |
| 高级定时器 | TIM1、TIM8 | 最强,多了刹车信号、输入捕获、互补输出等工业电机功能 |
高级定时器的那些电机功能需要提前学习许多电机知识,当前阶段我们把它当通用定时器用即可。
⚠️ 注意:我们使用的 STM32F103C8T6 上只有 TIM1~TIM4(1 个高级 + 3 个通用),并没有基本定时器。不过没关系——通用/高级定时器都是从基本定时器的结构上扩展出来的,包含基本定时器的全部功能,所以把任意一个通用定时器当基本定时器用就行。本集我们用 TIM4。
定时器就是计数器
计时原理:恒定频率方波 + 计数器
如何计时?其实非常简单:一个恒定频率的方波信号 + 一个寄存器,每当方波的上升沿到达,寄存器存储的数就 +1,就能完成计时。
1 | ┌──┐ ┌──┐ ┌──┐ |
恒定频率的方波从哪来?就是上篇时钟树讲过的: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 | ┌────────────┐ ┌────────────┐ ┌────────────┐ |
数据流全链路:从 72MHz 到每秒一次的串口数据
理解了三个部件,把本集项目的完整数据流串起来——从时钟树到串口助手:
1 | APB1 定时器分支 72MHz (来自上篇的时钟树, HSE 8MHz ×PLL9) |
关键计算: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),就算打开了定时器:
| 配置项 | 填的值 | 说明 |
|---|---|---|
| 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:
保存并生成代码。打开 main.c,可以看到 main 函数调用了一个新的初始化函数 MX_TIM4_Init()。
核心代码
1. 启动定时器(main 函数 USER CODE 2)
1 | /* USER CODE BEGIN 2 */ |
2. 更新中断回调(USER CODE 0)
定时器的”时间到”是通过更新中断通知的——每次计数器数到与重装载值相同、被清零重新计数时触发。中断发生后要执行什么,靠重写回调函数:
1 | /* USER CODE BEGIN 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 | void MX_TIM4_Init(void) |
各配置项与 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 | void HAL_TIM_Base_MspInit(TIM_HandleTypeDef* tim_baseHandle) |
三步缺一不可:不打开外设时钟(挂钟),定时器根本不工作;不设置优先级、不使能中断,更新中断永远不会触发。
中断链路
1 | 计数器数到 ARR → 硬件置位更新标志 → 触发 TIM4_IRQn |
和串口中断的套路一模一样:中断服务函数 → HAL 通用处理函数 → 我们重写的回调。
影子寄存器机制:幕后老板与一线员工
前面讲预分频器和重装载时,代码里有两个容易被忽略的设置,它们背后是定时器一个精巧的设计——影子寄存器。
预分频器:真正工作在分频一线的,其实是预分频寄存器的影子寄存器,而预分频寄存器本人更像”运筹帷幄的幕后老板”。我们通过 __HAL_TIM_SET_PRESCALER 设置的新预分频值,会等到当前计数周期结束(计数器数到重装载值、重新从零开始)时才传递给影子寄存器生效——简而言之,新值下个计数周期才生效。
自动重装载计数器:同样有影子寄存器,作用相同(新值下个周期生效)。不同之处在于——重装载的影子机制可以开关(AutoReloadPreload,默认关闭):
- 关闭(本项目):改 ARR 立即写进寄存器,但注意——如果把重装载值调小到小于当前计数器的值,它们会”错过”,计数器一路数到最大值 65535 才会触发更新
- 开启(CubeMX 里勾选 auto-reload preload):新值在影子寄存器里等下个周期,绝对安全
有了影子寄存器机制,就可以随时修改预分频器/重装载寄存器,而不必担心影响当前的计数周期。
实操步骤
1. CubeMX 配置
1 | ① 串口2: Mode → Asynchronous, 波特率 115200, NVIC 勾选 USART2 global interrupt |
2. 写代码
1 | ① USER CODE 2: HAL_TIM_Base_Start_IT(&htim4) 启动定时器 |
3. 编译下载 & 查看效果
编译下载,连上串口线,打开波特律动串口助手连接串口。刚下载完程序会慢几拍,按下板上的复位键让程序重新开始运行。
效果展示
https://www.bilibili.com/video/BV1Cnup68E8Z/?vd_source=5a111e8d49f70e532d36ddb73be3d0aa
串口助手每秒收到一次数据(emoji)
踩坑记录
1. 中断发送和主循环发送会”打架”
HAL_UART_Transmit_IT 是非阻塞发送。如果中断里的发送和主循环里的发送间隔太小,上次的数据还没发完,下次发送就把缓冲区覆盖掉了。所以发送逻辑尽量集中在一处——本项目把发送全部放在定时器中断回调里,主循环只留给自己的业务逻辑。
总结
复习结构图
1 | 时钟源 (内部时钟 72MHz) |
本集要点
| 要点 | 内容 |
|---|---|
| 定时器就是计数器 | 一切功能的本质 |
| 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 回调 |
| 影子寄存器 | 预分频/重装载新值下个周期生效;重装载可开关 |
本期只用了基本定时器功能。接下来的几期会在这张结构图的基础上不断扩展——万变不离其宗,定时器就是计数器。
参考资源
- 视频:【STM32】第16集 动画告诉你, STM32的定时器到底怎么回事(keysking)
- STM32 中文参考手册:定时器章节(TIM1-8 寄存器详解)
- 上一篇:STM32——浅析时钟源与时钟树理论知识

