搞嵌入式开发这些年,要说哪个外设最常用、最绕不开,定时器绝对排第一。不管你是点个灯、采集个传感器数据、输出个PWM波,还是测个频率、做个编码器计数,背后几乎都有定时器的影子。我见过不少新手朋友一上来就啃《参考手册》,结果被时基单元、预分频器、计数器这些都搞晕了,其实真没必要这么痛苦。这篇文章我就用HAL库的视角,把STM32定时器的底细彻底聊透,从硬件架构到实际编码,再到我踩过的那些坑,一次给你讲明白。
这篇文章适合谁看呢?刚入门STM32、被CubeMX和HAL库API搞得一头雾水的同学,或者已经会点灯但想认真搞懂定时器原理的开发者。我会尽量少谈晦涩的寄存器位定义,多用“为什么这么做”“这样做会发生什么”的视角来帮你建立完整的知识框架。看完这篇文章,你拿到一个定时器需求,应该能很自然地知道该选哪个定时器、该怎么配置、代码该怎么写、出了问题该从哪查起。
1. 内容整体设计与思路拆解
1.1 定时器到底是什么
先说个形象的类比。你可以把定时器想象成一个带闹钟的秒表,秒表负责计时,闹钟负责到点提醒。在STM32内部,这个秒表的核心是一个不断累加的计数器,它跟随着时钟脉冲一跳一跳地增加数字。当这个数字累加到某个设定值,就会触发一系列动作——产生中断、翻转IO、启动ADC转换、或者输出一个PWM波形。
很多人学定时器死记硬背“预分频器PSC”“自动重载值ARR”这些术语,却从来不理解它们存在的意义。我换个说法你就懂了:计数器每来一个时钟脉冲就加1,但芯片的主频比如72MHz意味着每秒有7200万个脉冲,要让计数器每1毫秒加一次,就需要一个“分频器”把脉冲频率降下来。这就是PSC的作用——把72MHz分成1000份,每一份就是72kHz,这样计数器从0加到72(也就是ARR=72)正好耗时1毫秒。所以那个著名的公式就出来了:定时器频率 = 时钟频率 / (PSC + 1) / (ARR + 1),这里加1是因为硬件寄存器从0开始计数。
理解了这个,你再看HAL库里的接口,就不会感觉是在调用一堆陌生函数了。说到底,HAL库做的事情就是把设置PSC、ARR、计数模式这些寄存器操作打包成几个结构体和初始化函数,你填好参数,调用一下,剩下的交给硬件去跑。
1.2 为什么选择HAL库而不是标准库或者LL库
现在很多教程还在用标准外设库,也有不少人推崇LL库,说它更接近寄存器操作、效率更高。关于HAL库和LL库的区别,网上讨论得很多。我的看法是,从学习和项目可维护性角度,HAL库是当前最合适的选择,尤其对初学者和中小规模项目来说。
HAL库的一大优点是它在硬件细节之上做了一层非常统一的抽象。同一个API,比如HAL_TIM_PWM_Start,你用它驱动F1系列可以,换到F4、F7、L4系列,基本不需要改应用层代码。这意味着你的业务逻辑、控制算法、状态机这些核心代码可以跨芯片复用,换芯片的工程量大幅下降。LL库虽然更轻量、性能更好,但它暴露了大量寄存器细节,要求你对芯片本身有较深理解,能读寄存器手册,否则代码很难写利索。标准库则是早已停止更新的老方案,新芯片根本不支持,用它做新项目有点自找麻烦。
当然HAL库也不是没有缺点。它的函数调用层级深、状态检查多,在极端实时性要求下,定时器中断服务函数里如果调用了一堆HAL接口,中断响应时间确实会比裸寄存器操作慢。我的处理原则是:除初始化外,在中断回调函数里尽量少调用HAL库封装好的“重量级”函数,自己直接操作寄存器来做关键动作,这样既保留开发效率,也保证性能底线。
1.3 定时器选型思路
拿到一个需求,先别急着写代码,第一步一定是想清楚用哪个定时器。STM32芯片里通常不止一个定时器,它们分工不同,性能也不同。我把常见型号的定时器类别和特点列个表,你对照着选就行。
| 定时器类型 | 典型实例 | 位数 | 主要功能 | 适用场景 |
|---|---|---|---|---|
| 基本定时器 | TIM6、TIM7 | 16位 | 纯粹的时基定时、触发DAC | 产生固定周期的中断,做系统心跳 |
| 通用定时器 | TIM2、TIM3、TIM4、TIM5 | 16位或32位 | 时基、PWM输出、输入捕获、编码器接口 | 大多数日常需求,PWM、测频率、电机控制 |
| 高级定时器 | TIM1、TIM8 | 16位 | 通用定时器全部功能 + 互补PWM、死区、刹车 | 电机控制、逆变器、开关电源 |
选型要特别注意三点。第一,不同定时器挂在不同的总线(APB1或APB2)上,时钟频率不同,比如F103的APB1定时器时钟是72MHz,两者在实际配置时要注意区分。第二,TIM2和TIM5在多数STM32系列上是32位计数器,ARR可以设到42亿,适合做长时间计时;TIM3、TIM4是16位,最大也就65535,用的时候要小心溢出。第三,高级定时器的刹车输入、死区生成这些功能虽然强大,但配置复杂,如果只是输出普通PWM,杀鸡不用牛刀,用通用定时器就够了。
2. 核心细节解析与实操要点
2.1 时基单元的配置细节
进入HAL库初始化之前,必须先弄懂时基单元相关的几个关键参数,因为后面所有定时器功能都建立在这个基础上。HAL库把时基初始化封装成了TIM_Base_InitTypeDef结构体,你只需要填下面几个字段即可。
第一个是Prescaler,也就是PSC预分频器。前面说了,它的作用是把定时器输入时钟分频。这里有一个很多新手会犯的错误——直接把需要的分频系数填进去,比如想分频成1kHz,输入72MHz,就直接填72。其实硬件分频是从0开始的,所以实际分频值是PSC + 1,填71才对。另外PSC是16位的,最大只能到65535,如果需要的分频系数超过这个范围,你只能考虑用更高的定时器时钟,或者把两部分分频逻辑分开做。
第二个是Period,也就是ARR自动重载值。它决定了计数器计数到多少就产生更新事件。这里同样存在“从0开始”的陷阱:想定时1毫秒,如果PSC分频后时钟是1MHz(即每个计数周期1微秒),那Period应该填999而不是1000。对于16位定时器,Period最大是65535,算下来单次最长定时约65毫秒(以1MHz计数时钟为例),想定时更长就得在中断里做软件累加。
第三个是CounterMode,常见的是向上计数TIM_COUNTERMODE_UP。向上计数模式下,计数器从0递增到ARR,然后归零并产生更新事件,这是最简单、最常用的模式。还有向下计数和中心对齐模式,中心对齐模式用于PWM和ADC同步采样时会用到,这个后文展开说。还有ClockDivision这个参数,它控制的是数字滤波器的采样时钟分频,做输入捕获时处理抖动信号才会用到,平时用不到就填TIM_CLOCKDIVISION_DIV1。
2.2 HAL库定时器初始化函数的完整解析
HAL库的初始化函数通常分两步走:先调用HAL_TIM_Base_Init,再调用HAL_TIM_Base_Start_IT。前者配置硬件,后者把定时器跑起来。缺一不可,但很多人不知道两者为什么分开。
HAL_TIM_Base_Init做的事情包括:根据传入的句柄(htim)配置PSC、ARR等时基参数,把定时器时钟使能,然后注册中断(如果开了中断的话)。这个函数的背后还隐藏着一个重要机制——它内部会回调HAL_TIM_Base_MspInit,这个函数是__weak弱定义,需要你在自己的代码里重写。通常我们在这个函数里做GPIO时钟使能、GPIO初始化、中断优先级配置和NVIC使能。我不止一次看到有人初始化定时器没反应,查来查去发现是MspInit里忘写NVIC_EnableIRQ了。CubeMX生成代码会自动填充这个函数,但是手动建工程的话特别容易漏。
HAL_TIM_Base_Start_IT则更像是“点火开关”,调用它之后,计数器才开始运转,更新中断才会被使能。注意,这个函数只管启动,不会阻塞等待中断。中断触发后,HAL库会调用HAL_TIM_PeriodElapsedCallback这个回调函数,你把业务逻辑放在里面就行。这里还有一个细节:同样的回调函数,所有定时器共用。如果你在工程里同时用了TIM2和TIM3各自的中断,需要在回调里用htim->Instance来判断是哪个定时器触发的事件,否则不同定时器的事件会互相干扰。
2.3 中断优先级设置的几个讲究
定时器中断优先级不是随便填的。HAL库里的优先级分组设置(HAL_NVIC_SetPriorityGrouping)通常放在main函数最前面,CubeMX会自动生成,但手动建工程的话很容易忽略。如果优先级分组设置不对,后续所有中断的优先级含义就全乱套了,整个系统可能出现莫名其妙的响应延迟。
另外一个优先级问题是:定时器中断里的业务代码执行时间不能太长。我们说到底还是在中断上下文里跑代码,如果代码执行时间接近甚至超过定时周期,那下一次中断到访时上一次还没执行完,会出现中断丢失甚至“中断风暴”。一种常见的做法是:在定时器中断回调里只设置标志位、做轻量的计数累加,真正的耗时业务逻辑放到主循环while(1)里根据标志位去执行。这样既保证定时精度,也不阻塞主循环。这个设计模式在裸机开发里非常重要,我建议你从开始写定时器代码就养成这个习惯。
3. 实操过程与核心环节实现
3.1 使用CubeMX配置定时器的完整流程
我强烈建议你用STM32CubeMX来生成初始化代码,这不仅省时间,还能避免很多手动配置的低级错误。下面我以STM32F103系列芯片为例,一步步走一遍用定时器输出周期中断、同时输出PWM的配置流程。
打开CubeMX,新建工程并选好芯片型号,首先在“System Core”->“RCC”里把HSE设置为Crystal/Ceramic Resonator,这是外部高速晶振的使能,保证时钟精度。然后进入“Clock Configuration”页面,把系统主频调到最高(F103一般是72MHz)。这时候注意看“APB1 Timer Clocks”和“APB2 Timer Clocks”这两个值,APB1上的定时器时钟是72MHz,APB2上的也是72MHz(因为APB分频系数是2,定时器时钟自动倍频为72)。这个信息在后面手动计算PSC和ARR时要用到。
接下来选择要使用的定时器。在左边树形菜单里找到“Timers”->“TIM2”,勾选“Activated”启用。以做1毫秒定时并产生中断为例,在“Parameter Settings”里这样配置:Prescaler填71(因为PSC从0开始,实际分频是72),Counter Period填999(也就是ARR=999),Internal Clock Division选不分频,Counter Mode选择Up,Auto-reload preload选择Enable。这样配置的结果就是定时器时钟为72MHz / 72 = 1MHz,也就是每个计数周期1微秒,计数1000次即1毫秒产生一次更新中断。这个计算逻辑我建议你每次配置完都自己心算一遍,确认无误再生成代码。
然后切到NVIC Settings选项卡,勾选“TIM2 global interrupt”,使能定时器中断。CubeMX会自动帮你把IRQHandler和中断优先级写好。如果你还想让定时器输出PWM,可以在这里再加一个通道,比如把TIM2的Channel1设置为“PWM Generation CH1”,然后在“Parameter Settings”里配置PWM参数:Mode选择PWM mode 1,Pulse填写占空比对应的比较值,比如想输出50%占空比,就填500(也就是CCR值,对应ARR 999的一半)。Output compare preload一般也选择Enable。做完这些,CubeMX会在生成的代码里自动配置好GPIO复用功能,你不需要手动去初始化引脚。
最后点击“Project Manager”页面,设置工程名称、保存路径、工具链(比如MDK-ARM),以及“Generate Code”生成代码。CubeMX生成的是一个完整的基础工程,打开后你会发现定时器初始化代码已经整整齐齐地写在tim.c里了,唯一的任务是往用户回调函数里填自己的业务逻辑。
3.2 定时器中断的HAL库代码实现
CubeMX生成的代码框架很完整,但我们还是要把关键代码过一遍,理解每一行背后的逻辑。先看看生成的初始化函数(以TIM2为例):
/* tim.c */ void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 999; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_Base_Init(&htim2) != HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; if (HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig) != HAL_OK) { Error_Handler(); } sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig) != HAL_OK) { Error_Handler(); } }这段代码的意义是:配置PSC为71、ARR为999、向上计数模式,然后调用HAL_TIM_Base_Init完成硬件写入。注意AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE,这个寄存器位决定了ARR的更新时机——如果选择Enable,ARR的值会在发生更新事件时被真正加载进影子寄存器,避免运行中修改ARR导致计数周期瞬间跳变。对需要动态调周期(比如变频PWM)的场景,这个配置很重要。
然后看中断使能和回调。main函数里通常会有这两行(CubeMX生成的):
HAL_TIM_Base_Start_IT(&htim2);这一行代码执行后定时器2才真正开始跑。接着我们在stm32f1xx_it.c里能看到中断服务函数的入口,HAL库已经帮你跳转好:
/* stm32f1xx_it.c */ void TIM2_IRQHandler(void) { HAL_TIM_IRQHandler(&htim2); }然后你要做的是在用户代码文件里写这个回调:
/* user_code.c */ uint32_t tick_ms = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { tick_ms++; } }这样就实现了一个精确到毫秒的软件系统时钟。如果你有多个项目同时用定时器中断,别忘了在回调里区分htim->Instance。如果你需要一个更大的时基,比如1秒,就在中断回调里再累加一个计数器,比如if (tick_ms % 1000 == 0) flag_1s = 1;。这样做的好处是灵活性极高,所有时基都从同一个毫秒心跳派生出来,不会互相干扰。
3.3 PWM输出的代码实现与占空比理解
用定时器输出PWM其实比做中断还要简单,因为硬件自动翻转引脚,CPU不用管。CubeMX配置好PWM通道后,代码上只需要三步:启动PWM输出、设置占空比、必要时修改占空比。
启动PWM的函数是:
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);这个函数执行之后,对应引脚上就会输出PWM波形。默认占空比是CubeMX里“Pulse”参数设置的值(对应比较寄存器CCR)。实际应用中我们经常要动态调整占空比,HAL库提供了一个宏:
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 250);这个宏会直接修改捕获/比较寄存器的值。假设ARR=999,CCR=250,那占空比就是250 / (999 + 1) = 25%。这里注意分母是ARR+1,不是ARR。可能有人问:为什么我用1000当Period,占空比算出来怎么不对?多半正是忘了这个+1的细节。
PWM周期怎么计算?还是那个公式:PWM频率 = 定时器时钟 / (PSC + 1) / (ARR + 1)。比如上面配置72MHz / 72 / 1000 = 1kHz,也就是周期1ms。想输出50Hz的舵机控制信号呢?可以这样反推:72MHz / (PSC + 1) = 50 * (ARR + 1),如果选ARR=19999(20位以内自然没问题),那么PSC+1就是72,PSC=71,这样算出来是接近50Hz。实际上舵机信号50Hz,周期20ms,占空比5%~10%左右,配置成PSC=71、ARR=19999之后,CCR范围就是1000~2000之间,非常标准。这种反向计算能力是调试定时器必备技能。
3.4 输入捕获模式测量信号频率和脉宽
定时器不仅能输出,还能测量外部信号。之前有个朋友做毕业设计,要做转速测量,问我频率怎么读,用输入捕获模式就能办到。
输入捕获的思路是:引脚上的上升沿或者下降沿会让定时器把当前的计数器值“快照”到捕获寄存器里,并触发捕获中断。你在中断里把两次捕获的计数值相减,就能算出一个周期内计数了多少次,再结合定时器时钟频率即可换算出信号频率。以测量PWM脉冲宽度为例:配置定时器通道1为上升沿捕获,第一次捕获到上升沿记录counter1,然后配置通道为下降沿捕获(或者用双通道分别捕获上升沿和下降沿),记录counter2,脉冲宽度 = (counter2 - counter1) * (1 / 定时器计数频率)。
HAL库的实现代码大致是这样:
HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1); void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { uint32_t capture = HAL_TIM_ReadCapturedValue(&htim2, TIM_CHANNEL_1); /* 处理本次捕获值 */ } }一个常见的坑是:捕获的是计数器的“瞬时值”,如果信号频率太慢,两次捕获之间计数器可能发生了多次溢出回绕,直接相减会得到错误结果。解决办法是在捕获中断里同时检查定时器溢出标志,额外累加溢出次数,最后用溢出次数和捕获值共同计算总计数个数。我在实际项目里测过一个低速旋转编码盘输出的方波,如果不处理溢出,测出来的频率忽高忽低,加了溢出补偿后就非常稳了。
3.5 编码器模式实现电机测速
说到编码器模式,这算是通用定时器一个比较高级的功能,但HAL库用起来其实也不难。电机上装一个带A、B两相输出的增量式编码器,把A相接定时器的通道1,B相接通道2,配置定时器为编码器模式,计数器就会自动跟随电机正反转做加减计数。这样你就得到电机的位移量,再用定时器中断周期性地读取计数并除以时间间隔,就得到转速了。
CubeMX里怎么配?在TIM的“Encoder Mode”选项卡里选择“Encoder Mode TI1 and TI2”,然后把计数器模式设为根据编码器方向自动翻转(通常选择TIM_COUNTERMODE_UP加上EncoderMode自动处理方向)。HAL库里对应函数是HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL),启动后直接读取htim3.Instance->CNT就能拿到当前计数。注意读取CNT时要考虑方向性:如果编码器反转,计数值会向下减,所以数据类型要用有符号的int16_t或int32_t来读,否则会出现正的65530这种奇怪数值,我之前就被这个坑过一次。
编码器模式最大的好处是计数逻辑由硬件完成,即使CPU忙不过来,也不会丢脉冲。不过初始化时要注意两个通道的滤波器设置,编码器信号如果带毛刺,建议开启InputFilter,一般设置成几微秒的宽度就能滤除大部分干扰。
4. 常见问题与排查技巧实录
4.1 定时器不工作的排查思路
定时器最常见的问题就是初始化代码里有个地方没配对,导致整个定时器像死了一样,没有中断、没有输出、引脚无波形。遇到这种情况,我一般按下面这个顺序排查。
第一步查时钟。先去确认时钟树配置正确,尤其手动建工程时,__HAL_RCC_TIM2_CLK_ENABLE()有没有调用。如果定时器时钟没有使能,寄存器读出来全是0,初始化函数直接返回失败。CubeMX生成的代码一般没问题,但如果你是从旧工程改过来的,一定要检查有没有启用新建工程的定时器时钟。
第二步查NVIC。中断模式下,如果HAL_TIM_Base_Start_IT调了但没反应,多半是HAL_NVIC_EnableIRQ没有执行。这个代码在MspInit里,CubeMX会自动生成,但手动移植时非常容易漏。有一个小技巧:在HAL_TIM_Base_Init返回后,读一下htim2.Instance->CR1寄存器的值,看是不是0x01,如果不是,说明定时器没有真正使能,问题多半出在上游初始化。
第三步查引脚复用。PWM输出没有波形,往往不是定时器配置的问题,而是GPIO复用没有设置对。F103的TIM2_CH1默认在PA0引脚,但如果你用了重映射(Remap),引脚就变了,GPIO配置也得跟着变。CubeMX里配置好之后会自动处理,但如果你手动改过引脚分配,一定要回头检查GPIO的Alternate Function设置对不对。
第四步查中断回调。如果定时器在跑、中断也进了,但业务代码不执行,检查一下HAL_TIM_PeriodElapsedCallback里有没有根据htim->Instance做了分支判断,如果实例不匹配,回调就白白执行了。另外回调函数名拼写错误也会导致编译不出来,因为它是弱定义函数,写错了编译器不会报错,只是你的回调不会被执行,这是最隐蔽的坑之一。
4.2 定时器中断执行时间过长导致丢中断
嵌入式开发永远逃不开“时间预算”问题。定时器中断服务函数(ISR)里的代码如果执行时间超过定时器周期,就会出现中断重叠,轻则定时不准,重则系统卡死,连主循环都跑不动。
我写过这样一个案例:朋友做一个小项目,在1ms定时器中断里做浮点数运算、OLED刷新和按键扫描,结果屏幕刷新频率高的时候系统响应变得异常迟钝。他的初衷是想把这些操作定时化,没想到把定时器中断当成了“万能调度器”。正确的做法是:1ms中断里只做标志位置位和轻量计数,然后主循环里检查标志,批量处理重活。如果确实需要多个不同频率的任务,可以用一个时基源,在中断里按计数分频出多个软件定时器。比如tick_ms % 10 == 0时置1个10ms标志,tick_ms % 100 == 0时置1个100ms标志。这样整个系统的调度清晰了很多,中断响应也稳了。
另外要注意浮点运算。在没有硬件FPU的F1系列上,浮点运算在中断里做会占用大量CPU周期。我在中断回调里基本不做浮点运算,需要用小数的时候就先把整数算好,放到主循环里再转换。如果你用的是Cortex-M4/M7这种带FPU的芯片,浮点算得快很多,但中断里做除法、三角函数依然要小心,能近似就近似。
4.3 PWM输出异常波形排查记录
PWM输出有个典型问题是:波形毛刺多、占空比不对、频率漂移。毛刺大概率是电源或地线干扰,也可能是GPIO驱动能力配置过强/过弱。如果输出引脚直接驱动了长导线或者MOS管栅极,可以在驱动信号和地之间加一个小电阻(几十欧到几百欧),或者在软件里把GPIO速度等级调低。F1的GPIO输出速度有2MHz、10MHz、50MHz三档,默认CubeMX可能配置成Very High,如果你不是接高速信号,反而容易引入振铃。
占空比不对的问题,除了我们前面说的ARR+1陷阱之外,还要注意比较值CCR和ARR的关系。有些芯片手册规定CCR必须小于ARR,如果你设置的Pulse大于ARR,硬件行为可能不可预测,常见表现是占空比接近100%或完全无输出。我在代码里总会写注释标明CCR的取值范围,防止事后忘了这个约束。
频率漂移通常有两种原因:一是系统时钟本身不稳,比如外部晶振质量差、匹配电容选错,导致主频漂移;二是你在运行中修改了PSC或ARR,但修改的时机没有对齐更新事件,导致计数周期跳变。推荐的做法是:修改PSC/ARR前先关闭定时器更新中断(或者用影子寄存器机制,让修改在下一个更新事件生效),改完再重新使能。如果只是调占空比,直接调CCR通常是安全的,但也要注意比较预装载(Preload)的配置,建议开启比较预装载,这样CCR更新会在下一个周期开始才生效,不会在周期中间突然跳变。
4.4 输入捕获测量不准的常见原因
输入捕获模式下测量不准,原因往往不是捕获逻辑本身,而是你忽略了信号边缘的抖动。比如按钮产生的边沿信号在按下和释放瞬间会弹跳多次,捕获寄存器会在极短时间内被触发多次。解决方法是给捕获通道配置输入滤波,CubeMX的“Input Filter”参数可以设置成几微秒到几十微秒的滤波窗口,超出窗口宽度的毛刺会被硬件直接滤掉。另外,高阻抗输入信号建议开启内部上拉或下拉,具体看信号默认电平状态,不然浮空引脚可能反复触发沿中断。
还有一个测量精度的问题是中断响应延迟。你捕获到沿之后,代码读取捕获值是在中断回调里完成的,HAL库的HAL_TIM_IC_CaptureCallback执行时,定时器可能又前进了几个计数周期,但实际上HAL库读取的是捕获寄存器里的快照值,所以不受响应延迟影响。真正会影响精度的是你用了错误的计数频率基准——如果定时器时钟是72MHz,你按36MHz去换算,测出来的频率就会差一倍。所以遇到测量结果成倍不准的时候,先回头确认自己用的是哪个总线时钟。
4.5 编码器模式下计数值为负的问题
编码器模式下读取CNT,很多初学会遇到“读出来的值为什么是六万多”的问题。原因很简单:编码器反转时计数器向下减,从0减1就会变成65535(16位计数器)。如果直接用无符号变量接收,就会得到一个巨大的正数,你把数据类型改成有符号的int16_t,读出来的就会是-1。这个改数据类型的小操作,我见过好几个人在论坛上反复问,其实就是C语言的基础知识在嵌入式场景下的具体应用。另外,如果你需要长期累计编码器圈数,注意16位计数器只在-32768到32767之间有效,超过这个范围会溢出回绕。建议主循环里定期(比如10ms)读取计数值并做一次增量累加,用更高位宽的变量保存长期累计值,避免信息丢失。
5. 高级应用技巧与避坑指南
5.1 利用定时器实现精确延时函数
很多初学者最开始写延时都是空循环,比如for (i = 0; i < 100000; i++);,这种延时既不准确也不可移植,换一颗主频不同的芯片时间就完全变了。利用SysTick定时器(滴答定时器),可以实现一个精确到微秒级别的延时函数。SysTick是集成在Cortex-M内核里的一个简易定时器,它不占用我们前面说的TIM外设资源,非常适合做系统心跳。
HAL库本身就依赖SysTick生成毫秒时基,HAL_GetTick()函数返回的就是系统启动以来的毫秒数。想要微秒延时,我经常用DWT(Data Watchpoint and Trace)单元,它是内核里的一个周期计数器,可以精确地读取CPU执行的周期数。代码实现很简单:
void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000U); while ((DWT->CYCCNT - start) < ticks); }使用前记得先在main函数里使能DWT计数器:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;这个方案的好处是延时用的是CPU周期数,不受中断影响,精度很高。当然如果系统在高负载状态下开启了大量中断,这种“忙等”也会受到一定程度干扰,但对绝大多数裸机应用来说完全够用。这里要提醒的是,这个技巧依赖具体的Cortex-M内核,移植到其他内核时要重新确认。
5.2 使用DMA方式让定时器PWM批量更新
如果你需要让定时器输出一连串不同的PWM占空比波形(比如渐变呼吸灯、步进电机梯形加减速曲线),手动在中断里一次次改CCR会很麻烦,而且高频率下CPU忙不过来。这时候可以借助DMA,让它在定时器更新事件触发时自动从内存搬运数据到CCR寄存器,过程中完全不需要CPU参与。
HAL库的做法是用HAL_TIM_PWM_Start_DMA,CubeMX里也需要在PWM配置页打开DMA请求。比如你准备呼吸灯需要100个渐变阶梯,把100个CCR值放进一个数组,然后一次性启动DMA传输,定时器每个周期自动从数组里拿出一个值更新占空比,CPU只需要在DMA传输完成中断里再改变方向填充数组。这样做的好处是PWM输出的实时性极高,不占用CPU周期。
我实际用这个方案做过一款智能灯具的调光渐变效果,波形肉眼可见的平滑,没有任何抖动。但要注意,DMA方式意味着你不能再随时直接调CCR了,因为CCR的值会被DMA持续覆盖。想停止渐变,就要调用HAL_TIM_PWM_Stop_DMA来停掉DMA传输。还有个小坑是DMA传输的模式:如果选择Normal模式,发送完数组就停了;如果选Circular模式,会循环发送同一数组,适合做重复波形。用之前想清楚你到底需要哪种。
5.3 高级定时器的死区插入与互补输出
做电机控制或者逆变器项目的朋友,多半会用到高级定时器的互补PWM输出和死区控制。为什么需要死区?因为逆变器里桥臂上下两个功率管不能同时导通,否则会直通短路烧毁电路,所以两个PWM信号之间需要设置一个极短的“死区时间”,保证上下管切换时有一段完全关断的时间。
在HAL库中,使用高级定时器输出互补PWM的配置主要在HAL_TIMEx_PWMN_Start里体现。CubeMX配置时,在TIM1的PWM生成选择里同时勾选Channel1和Channel1N,然后设置死区时间。死区时间如何计算?它由DeadTime参数决定,具体公式取决于芯片的时钟频率和配置寄存器位,以F1为例,死区时间大约等于DeadTime值乘以定时器时钟周期。比如定时器时钟72MHz,一个周期约13.8纳秒,设置DeadTime=50,死区约为690纳秒,这对多数MOSFET驱动来说已经足够。
这部分的代码大致是:
HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1);主输出通道和互补通道同时开启后,你再使用__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, ccr_value)修改占空比,硬件会自动在互补通道上生成反相且带死区的PWM信号。使用高级定时器还有一个坑:启动PWM之前,必须调用HAL_TIM_MspPostInit或者CubeMX自动生成的HAL_TIM_PWM_MspInit里面确保输出比较通道的GPIO和互补通道的GPIO都被正确配置,不然就是只有一路输出正常,另一路完全没波形。
5.4 中心对齐模式与ADC采样的同步
中心对齐模式是个容易上手又容易踩坑的模式。普通PWM的计数器是从0向上递增到ARR然后瞬间归零,输出波形的高电平集中在周期的前半段。中心对齐模式下,计数器先递增到ARR,再递减回0,形成的PWM波形“居中”对齐,所以叫中心对齐。这种模式的优点是可以减少PWM开关瞬间对负载的冲击,对电机控制、电源转换来说效果更平顺。
在CubeMX里,把Counter Mode配置为Center Aligned Mode1即可。中心对齐模式下更新事件发生在计数器归零或计数到ARR的时刻,这一点在写ADC同步采样逻辑时非常关键。例如你需要做峰值电流采样,通常希望在PWM的中心点(即计数器计数到ARR的时刻)去采样,那你就让ADC由定时器的触发事件启动,并且触发时刻配置成“更新事件上涌”或“比较匹配事件”。我在做数字电源时就是利用这个特性,让ADC采样时刻和PWM开关时刻精确错开,从而避开开关噪声,采样出来的电压电流值稳定得多。
中心对齐模式一个常见问题:ARR值如果设置成奇数,计数周期在向上和向下的阶段会有细微的周期差异,输出波形看起来会有一点不对称。所以使用中心对齐模式时,我一般把ARR配置成偶数,保证两个半周期对称。
5.5 多个定时器级联实现长定时和同步触发
前面提到16位定时器单次最长只能定时到65毫秒左右,那需要更长定时怎么办?除了在中断里做软件累加,还可以考虑多个定时器级联。级联的意思是一个定时器的更新事件输出作为另一个定时器的时钟源,这样看起来像是两个秒表串联,第一个到点把第二个推一下。
HAL库和CubeMX都支持这种配置。比如TIM2输出TRGO事件,连接给TIM3作为外部时钟,然后TIM3的ARR可以设成1000,那么TIM3的更新周期就是TIM2周期的1000倍。这样单次中断就能定时到秒级甚至分钟级。级联的好处是硬件自动完成,CPU零负担,而且两个定时器之间的相位关系是确定的,特别适合做需要精确延时触发的应用。缺点是配置复杂,初级用户不推荐上手就用,先把单定时器玩熟了再折腾级联也不迟。
还有一种是定时器触发ADC采样。我前面讲中心对齐模式时提到的“定时器触发ADC”就是典型应用。原理是定时器产生一个触发信号(TRGO),ADC硬件收到信号后自动启动一次转换,不需要CPU干预。HAL库配置时在定时器的“Trigger Output (TRGO) Parameters”中选择更新事件或者比较事件作为触发源,然后再去ADC配置页把触发源设置为定时器,这样整个采样链路的时序就完全由硬件保证了。我用这个方式做过三相电流采样,时序误差极小,数据非常干净。
6. 其他常见应用的代码片段参考
6.1 独立看门狗与窗口看门狗的快速上手
有些朋友问定时器时会把看门狗也扯进来,因为看门狗本质上也依赖计数器。独立看门狗(IWDG)和窗口看门狗(WWDG)是用来看门的,用途和我们前面讨论的定时器有本质区别:它们的主要职责是防止程序跑飞。
独立看门狗是一个完全独立的RC振荡器驱动的计数器,一旦启动就无法关闭,必须在程序主循环里定期“喂狗”重装计数值,否则系统会被强制复位。HAL库的接口非常简单:
HAL_IWDG_Refresh(&hiwdg);把它放在主循环里即可。注意喂狗不能太晚(否则复位),也不能太早(窗口看门狗有上限),这对主循环的执行时间有严格要求。我曾经写过一个大循环里有两个耗时分支的程序,一个分支因等待传感器响应阻塞了很长时间,结果看门狗触发了复位。这提醒我们,在开启看门狗的项目里,任何可能长时间阻塞主循环的操作都要特别谨慎。
6.2 低功耗模式下定时器的使用
低功耗项目里,定时器扮演的角色也很重要。STM32的多种低功耗模式里,只有部分定时器能在停止(Stop)模式下正常工作,并且要用特定的时钟源(如LSI内部低速时钟)。很多低功耗项目会安排一个RTC闹钟唤醒,或者用LPTIM(低功耗定时器)来实现周期性唤醒。LPTIM本质上是一个超低功耗的定时器外设,在Stop模式下依然可以计数,并且唤醒中断能力比普通定时器强很多。
如果你做的是电池供电的传感器节点,想实现“每10秒醒来测一次数据然后继续睡”的逻辑,LPTIM就是个非常合适的方案。HAL库里也有对应的HAL_LPTIM_Init和HAL_LPTIM_SetOnce_Start_IT接口。这里我只提醒一点:LPTIM的时钟源、预分频器配置和普通定时器不完全一样,直接在CubeMX里选LPTIM配置就会方便很多,千万别拿普通TIM的参数套上去用。
6.3 使用定时器测速的实际案例库
最后给你一个可以“抄作业”的测速代码片段。假设我用TIM3的编码器模式读取电机编码器,主循环每10ms统计一次计数变化,换算成转速。
/* 初始化时启动编码器 */ HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL); /* 主循环或10ms周期任务里执行 */ int16_t cnt_now = (int16_t)(htim3.Instance->CNT); int16_t diff = cnt_now - cnt_last; /* 两次采样之间编码器计数差 */ cnt_last = cnt_now; /* 假设编码器线数为512线(即每圈输出512个脉冲),并且使用了4倍频 */ float rpm = (float)diff * 1000.0f / 10.0f / (512.0f * 4.0f) * 60.0f;这段代码里最关键的一点是diff必须用有符号变量。如果你直接用无符号的计数器值做减法,反向转动时结果完全错了。另外我把10ms换算成了“每秒计数变化”再除以每圈脉冲数,最终得到每分钟转速。这类代码在很多电机控制入门项目里都能直接用,你只需要把编码器线数换成你自己电机的参数即可。
7. 我的实操心得与经验总结
这篇文章写到这儿,我没打算给你一个“完美收官式”的总结,就想聊聊这些年摸爬滚打下来关于定时器的一些体会。
定时器是嵌入式世界里少数几个“值得彻底搞懂”的外设之一。它的概念不复杂,无非就是分频、计数、比较、捕获这几个动作,但组合起来的变化非常丰富。我的建议是不要只满足于“调通代码”,而是多问自己几个“为什么”:为什么这个定时器挂了这条总线?为什么这个参数要这样配?为什么改了ARR之后波形变了?把底层行为搞清楚了,以后遇到任何新的芯片、新的库,你都能很快迁移过去。
另一个体会是:调试定时器问题时,一定要学会“看波形”。逻辑分析仪或者示波器是最有力的工具。我记得早期调PWM输出时,怎么都觉得频率不对,拿示波器一量才发现CubeMX配置的时钟树和我以为的不一样。用示波器看一次波形,比自己闷头看寄存器快一百倍。
最后想跟大家说的是:HAL库只是一个工具,它方便了你快速上手,但不要把所有问题都丢给库去解决。真正出问题的时候,最终还是要靠你对芯片参考手册的理解。学会在“HAL库代码”和“寄存器手册”之间灵活切换视角,这个能力比背熟多少个API都值钱。
如果你正准备开始写自己的定时器应用,建议从最基础的定时器中断开始,点一个LED、做一个毫秒心跳,然后慢慢尝试PWM、输入捕获、编码器模式。把每一步都跑通并理解透彻,你的嵌入式功底就会在不知不觉中提升一大截。希望这篇文章能帮你在定时器这条路上少走一些弯路。