1. 从“定时”到“中断”:为什么STM32的定时器如此重要?
如果你刚开始接触STM32,可能会觉得定时器(Timer)这个概念有点抽象,不就是个“闹钟”吗?但当你真正用它来驱动LED闪烁、测量脉冲宽度、生成PWM控制电机,甚至作为整个系统的时间基准时,你就会发现,它是STM32微控制器里最核心、最灵活的外设之一,没有“之一”。今天我们不谈那些复杂的PWM输出、输入捕获,就从最基础,也最常用的定时器定时中断和它的外部时钟源配置讲起。这就像是学武功先扎马步,马步扎稳了,后面的擒拿格斗、刀枪剑戟才能耍得开。
很多新手在配置定时器中断时,常常会遇到两个困惑:第一,计算出来的定时时间总是不准,或者根本不是自己想要的;第二,看到手册里“外部时钟模式1”、“外部时钟模式2”、“ETR引脚”这些名词就头大,不知道它们到底用在什么场景。这篇笔记,我就结合自己踩过的坑,把这两个核心问题掰开揉碎了讲清楚。我们的目标很简单:让你不仅能照着代码把定时中断跑起来,更能彻底理解每一个配置参数背后的意义,做到知其然,更知其所以然。这样,下次当你需要用一个外部信号来精准控制定时器时,就知道该怎么下手了。
2. 定时器定时中断的核心原理与配置“三步法”
定时器中断的本质,是让定时器在设定的时间到达时,产生一个中断信号,通知CPU:“时间到了,快来处理事情!”。这样CPU就不用一直傻傻地等着(即“轮询”),可以先去执行其他任务,等“闹钟”响了再过来。在STM32中,实现这个功能主要依赖于三个核心部件:时基单元、自动重装载寄存器和中断使能控制。
2.1 时基单元:定时器的“心脏”
时基单元是定时器计时的基础,主要由三部分组成:
- 计数器寄存器:这是核心,一个向上或向下计数的变量。
- 预分频器:负责对输入的时钟源进行分频。假设系统给定时器的时钟是72MHz,这个频率太高,直接计数的话,计数器瞬间就溢出了。预分频器的作用就是把这个高频时钟“降速”。比如设置预分频值为7199,那么分频后的实际计数时钟频率就是
72MHz / (7199+1) = 10KHz。这里的“+1”是因为预分频器是一个N+1的分频器,配置值PSC代表分频系数减一。 - 自动重装载寄存器:这是定时的“目标值”。计数器从0开始,每来一个时钟脉冲就加1,当它的值等于ARR的值时,就代表一个定时周期到了,此时会产生一个“更新事件”,并且计数器清零(在向上计数模式下),重新开始。
定时时间的计算公式是:定时时间 = (ARR + 1) * (PSC + 1) / TIMx_CLK
其中TIMx_CLK是定时器的输入时钟频率。这个公式是理解一切定时器应用的基石。举个例子,我们要实现一个1秒的定时中断,假设TIMx_CLK = 72MHz。
- 我们先确定
PSC,目的是让计数器每计一次数的时间是一个整毫秒或整微秒,方便计算。比如,我们让分频后的时钟为1MHz(即每微秒计数一次),那么PSC = 72MHz / 1MHz - 1 = 71。 - 然后计算
ARR:1秒 = 1,000,000微秒。所以ARR = 1000000 - 1 = 999999。 这样,计数器从0数到999999,正好是1,000,000微秒,即1秒。
注意:ARR和PSC都是16位或32位寄存器,有最大值限制(如0xFFFF)。当计算出的ARR或PSC值超过寄存器范围时,就需要调整思路,比如增大PSC来降低计数频率,从而减小ARR的值。
2.2 配置定时中断的“三步法”实操
以STM32F103C8T6的通用定时器TIM2为例,使用标准外设库。配置流程可以固化成一个清晰的“三步法”:
第一步:开启时钟和初始化时基任何外设使用前,必须先开启它的时钟。TIM2挂载在APB1总线下。
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct; TIM_TimeBaseInitStruct.TIM_Period = 9999; // ARR值,决定周期 TIM_TimeBaseInitStruct.TIM_Prescaler = 7199; // PSC值,72M/(7199+1)=10KHz TIM_TimeBaseInitStruct.TIM_ClockDivision = TIM_CKD_DIV1; // 时钟分频,与数字滤波器相关,通常设为DIV1 TIM_TimeBaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up; // 向上计数模式 TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStruct);这段代码配置了一个10KHz的计数时钟,ARR为9999,所以定时周期为(9999+1) / 10KHz = 1秒。
第二步:配置中断并开启光有时基还不够,需要明确让定时器在“更新事件”(即定时时间到)时产生中断。
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); // 使能更新中断接着,需要配置NVIC,让CPU能够接收并处理这个中断。
NVIC_InitTypeDef NVIC_InitStruct; NVIC_InitStruct.NVIC_IRQChannel = TIM2_IRQn; // 中断通道 NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1; // 抢占优先级 NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1; // 子优先级 NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStruct);优先级数字越小,优先级越高。抢占优先级高的可以打断正在执行的抢占优先级低的中断。
第三步:编写中断服务函数并启动定时器中断服务函数的名字是固定的,在启动文件startup_stm32f10x_xx.s中有定义。
void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { // 检查是否是更新中断 // 这里是你的中断处理代码,比如翻转一个LED灯 GPIO_WriteBit(GPIOA, GPIO_Pin_0, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_0))); TIM_ClearITPendingBit(TIM2, TIM_IT_Update); // !!!千万记得清除中断标志位!!! } }最后,在主函数中启动定时器:
TIM_Cmd(TIM2, ENABLE); // 使能定时器,计数器开始运行实操心得:最容易忘记的就是清除中断标志位。如果不清除,CPU会认为中断一直存在,导致连续不断地进入中断服务函数,程序就“卡死”在中断里了。
TIM_ClearITPendingBit这句代码务必写上。
3. 定时器外部时钟模式深度解析:何时用?怎么选?
前面我们用的时钟源,都是来自内部的APB总线时钟。但STM32的定时器强大之处在于,它可以从外部引脚获取时钟信号,让定时器的计数节奏完全由一个外部信号来决定。这在很多场景下非常有用,比如:
- 频率测量:让定时器对外部脉冲计数。
- 精确定时:使用一个比内部晶振更精准的外部时钟源。
- 事件同步:让内部定时操作与外部事件同步。
STM32的定时器主要支持两种外部时钟模式,很多人容易混淆,这里彻底讲清楚。
3.1 外部时钟模式1:最常用的“信号计数器”
这是最直观、最常用的模式。其信号路径是:外部引脚 -> 边沿检测器 -> 触发控制器 -> 定时器计数器。
配置要点:
- 时钟源选择:必须将定时器的从模式控制器配置为“外部时钟模式1”。
TIM_ETRClockMode2Config(TIM2, TIM_ExtTRGPSC_OFF, TIM_ExtTRGPolarity_NonInverted, 0); // 或者使用更通用的函数,先配置ETR,再设置触发源 TIM_ETRConfig(TIM2, TIM_ExtTRGPSC_OFF, TIM_ExtTRGPolarity_NonInverted, 0); TIM_SelectInputTrigger(TIM2, TIM_TS_ETRF); // 选择ETR作为触发源 TIM_SelectSlaveMode(TIM2, TIM_SlaveMode_External1); // 设置从模式为外部时钟模式1 - 引脚映射:不同的定时器,其外部触发输入引脚是固定的。例如TIM2的ETR引脚是PA0(部分型号可能是PA15,需查数据手册)。你需要将该引脚配置为复用功能,并使能对应GPIO的时钟。
- 滤波器与分频:
TIM_ETRConfig函数中的参数可以设置对输入信号的预分频和滤波。这对于消抖或处理高频信号非常关键。
工作过程:一旦配置好,外部引脚(如PA0)上的每一个上升沿(或下降沿,可配置)都会使定时器的计数器加1。此时,定时器的计数频率完全等于外部信号的频率。自动重装载寄存器ARR仍然有效,当计数器计到ARR值时,同样会产生更新事件和中断。
典型应用:旋转编码器脉冲计数、光电门通过物体计数。你不需要关心外部信号的具体频率,只需要知道“来了多少个脉冲”。
3.2 外部时钟模式2:被忽略的“门控触发器”
模式2的路径不同:外部引脚 -> 从模式控制器 -> 使能/禁用定时器计数器。
配置要点:
TIM_SelectInputTrigger(TIM2, TIM_TS_TI1F_ED); // 选择TI1边沿检测作为触发源 TIM_SelectSlaveMode(TIM2, TIM_SlaveMode_Trigger); // 从模式设为触发模式 TIM_SelectMasterSlaveMode(TIM2, TIM_MasterSlaveMode_Enable); TIM_ETRConfig(TIM2, TIM_ExtTRGPSC_OFF, TIM_ExtTRGPolarity_NonInverted, 0); // 通常不使用ETR // 注意:模式2通常使用TI1或TI2作为触发源,而非ETR。更常见的用法是使用通道1或2的输入作为触发源,实现门控。例如,设置TI1为触发源,从模式为门控模式:
TIM_ICInitTypeDef TIM_ICInitStructure; TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; // 高电平时计数 TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x00; TIM_ICInit(TIM2, &TIM_ICInitStructure); TIM_SelectInputTrigger(TIM2, TIM_TS_TI1FP1); // 选择滤波后的TI1作为触发源 TIM_SelectSlaveMode(TIM2, TIM_SlaveMode_Gated); // 从模式设为门控模式 TIM_Cmd(TIM2, ENABLE);工作过程:在这种模式下,外部引脚的电平控制着定时器计数器的启停。例如,当TI1引脚为高电平时,定时器正常计数;当TI1变为低电平时,定时器暂停计数。ARR依然有效。
典型应用:测量一个高电平脉冲的宽度。你可以启动定时器,用内部高速时钟计数,然后用一个外部引脚的高电平作为门控信号。高电平期间计数器的增加值,就代表了脉冲宽度。这在红外遥控接收、PWM输入测量等场景非常有用。
3.3 模式1 vs 模式2:核心区别与选型指南
为了更直观地理解,我们用一个表格来对比:
| 特性 | 外部时钟模式1 | 外部时钟模式2 |
|---|---|---|
| 核心功能 | 将外部信号直接作为计数时钟 | 用外部信号控制内部时钟的计数启停 |
| 计数时钟源 | 外部引脚上的边沿信号本身 | 定时器内部的时钟(如72MHz) |
| 计数器动作 | 每个有效边沿,计数器+1 | 内部时钟每个周期,计数器+1(仅在门控信号有效时) |
| 依赖ARR | 是,计到ARR产生更新 | 是,计到ARR产生更新 |
| 典型应用 | 频率较低的事件计数、转速测量 | 高精度脉冲宽度测量、信号门控 |
| 类比 | 人手动按计数器:按一下,数字加一。 | 用开关控制电表:开关打开,电表走字;开关关闭,电表停止。 |
选型口诀:要数外部发生了多少次,用模式1;要测外部信号持续了多久,用模式2。
4. 实战:用外部时钟模式1实现按键长按与短按识别
理解了原理,我们通过一个综合案例来巩固。目标:使用TIM2的外部时钟模式1,将一个外部按键信号作为时钟源。通过计算定时器中断发生的次数(即按键按下的次数),来区分短按(按下立即松开)和长按(按下保持超过1秒)。
硬件连接:按键一端接GND,另一端接PA0(TIM2_ETR)和一个上拉电阻到3.3V。默认PA0为高电平,按下为低电平。
软件设计思路:
- 将PA0配置为TIM2的ETR引脚,采用外部时钟模式1,下降沿有效(因为按键按下是下降沿)。
- 配置定时器的ARR为一个较大的值(比如65535),我们并不真的指望它溢出,而是禁用更新中断。
- 开启另一个定时器(如TIM3),用内部时钟设置一个1ms的中断。
- 在TIM3的1ms中断里,去读取TIM2计数器的值。这个值就代表了从上次读取到现在,按键按下的次数(下降沿次数)。
- 根据这个次数的变化来判断按键状态。
关键代码实现:
// 1. 初始化TIM2为外部时钟模式1 void TIM2_ExternalClock_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure; // 开启时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 配置PA0为复用功能推挽输出(实际上作为ETR输入) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 外部上拉,配置为浮空输入亦可 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 时基初始化,ARR设大,PSC为0不分频 TIM_TimeBaseInitStructure.TIM_Period = 0xFFFF; TIM_TimeBaseInitStructure.TIM_Prescaler = 0; TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure); // 配置外部触发模式 TIM_ETRClockMode2Config(TIM2, TIM_ExtTRGPSC_OFF, TIM_ExtTRGPolarity_Inverted, 0); // 下降沿有效 // 注意:这里使用TIM_ETRClockMode2Config,但第二个参数选择的是时钟分频,对于模式1,通常用TIM_ETRConfig配合从模式设置更清晰。 // 更推荐的清晰配置方式: TIM_ETRConfig(TIM2, TIM_ExtTRGPSC_OFF, TIM_ExtTRGPolarity_Inverted, 0); // ETR下降沿有效,无分频滤波 TIM_SelectInputTrigger(TIM2, TIM_TS_ETRF); // 选择ETR作为触发源 TIM_SelectSlaveMode(TIM2, TIM_SlaveMode_External1); // 明确设置为外部时钟模式1 TIM_Cmd(TIM2, ENABLE); // 启动TIM2 } // 2. 初始化一个1ms中断的TIM3,用于采样TIM2的计数值 volatile uint32_t g_last_counter = 0; volatile uint32_t g_press_duration_ms = 0; volatile uint8_t g_key_state = 0; // 0:释放,1:短按,2:长按 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { uint32_t current_counter = TIM_GetCounter(TIM2); // 如果当前计数器值大于上次的值,说明期间有按键动作(下降沿) if (current_counter != g_last_counter) { g_press_duration_ms = 0; // 有新的边沿,重置持续时间,重新计时 g_last_counter = current_counter; } else { // 计数器没变,说明按键状态稳定 // 假设按键按下时,PA0为低电平。这里需要结合GPIO读取来判断是按下还是释放 // 简化逻辑:如果检测到过一次边沿变化(g_last_counter更新过),且PA0持续为低 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_RESET) { g_press_duration_ms++; if (g_press_duration_ms > 1000) { // 超过1000ms即1秒 g_key_state = 2; // 长按 } } else { // 按键释放 if (g_press_duration_ms > 0 && g_press_duration_ms <= 1000) { g_key_state = 1; // 短按 } g_press_duration_ms = 0; } } TIM_ClearITPendingBit(TIM3, TIM_IT_Update); } }代码解析与避坑:
- 我们使用了两个定时器:TIM2作为“事件计数器”,TIM3作为“状态采样器”。
TIM_GetCounter(TIM2)直接读取TIM2计数器的当前值。由于TIM2的时钟是外部按键信号,所以这个值就是按键按下的累计次数(下降沿数)。- 在TIM3的中断里,我们比较本次和上次的计数值。如果不同,说明按键被按过,我们开始计时(
g_press_duration_ms)。如果相同,且按键引脚仍是低电平,则累加计时,判断是否达到长按阈值。 - 关键点:直接读取计数器值来判断事件发生,比在外部中断里处理更高效,尤其适合可能频繁触发或需要计数的场景。而且,这种方式避免了在外部中断服务函数中做延时判断,是更优的设计。
踩坑记录:在这个案例中,最大的坑在于按键抖动。机械按键在按下和释放的瞬间会产生多个边沿,导致TIM2计数器在极短时间内增加多次,误判为多次短按。解决方法是在
TIM_ETRConfig函数中启用滤波器。例如,将最后一个参数(滤波器长度)设置为一个非零值(如0x0F),可以滤除短时间内的抖动毛刺。硬件上也可以在按键两端并联一个小电容(如104)。