ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32定时器完全攻略:从硬件原理到HAL库实战

STM32定时器完全攻略:从硬件原理到HAL库实战 搞嵌入式开发这些年要说哪个外设最常用、最绕不开定时器绝对排第一。不管你是点个灯、采集个传感器数据、输出个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也就是ARR72正好耗时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、TIM716位纯粹的时基定时、触发DAC产生固定周期的中断做系统心跳通用定时器TIM2、TIM3、TIM4、TIM516位或32位时基、PWM输出、输入捕获、编码器接口大多数日常需求PWM、测频率、电机控制高级定时器TIM1、TIM816位通用定时器全部功能 互补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上的定时器时钟是72MHzAPB2上的也是72MHz因为APB分频系数是2定时器时钟自动倍频为72。这个信息在后面手动计算PSC和ARR时要用到。接下来选择要使用的定时器。在左边树形菜单里找到“Timers”-“TIM2”勾选“Activated”启用。以做1毫秒定时并产生中断为例在“Parameter Settings”里这样配置Prescaler填71因为PSC从0开始实际分频是72Counter Period填999也就是ARR999Internal Clock Division选不分频Counter Mode选择UpAuto-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 1Pulse填写占空比对应的比较值比如想输出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的更新时机——如果选择EnableARR的值会在发生更新事件时被真正加载进影子寄存器避免运行中修改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);这个宏会直接修改捕获/比较寄存器的值。假设ARR999CCR250那占空比就是250 / (999 1) 25%。这里注意分母是ARR1不是ARR。可能有人问为什么我用1000当Period占空比算出来怎么不对多半正是忘了这个1的细节。PWM周期怎么计算还是那个公式PWM频率 定时器时钟 / (PSC 1) / (ARR 1)。比如上面配置72MHz / 72 / 1000 1kHz也就是周期1ms。想输出50Hz的舵机控制信号呢可以这样反推72MHz / (PSC 1) 50 * (ARR 1)如果选ARR1999920位以内自然没问题那么PSC1就是72PSC71这样算出来是接近50Hz。实际上舵机信号50Hz周期20ms占空比5%~10%左右配置成PSC71、ARR19999之后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相接定时器的通道1B相接通道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如果你不是接高速信号反而容易引入振铃。占空比不对的问题除了我们前面说的ARR1陷阱之外还要注意比较值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就会变成6553516位计数器。如果直接用无符号变量接收就会得到一个巨大的正数你把数据类型改成有符号的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()函数返回的就是系统启动以来的毫秒数。想要微秒延时我经常用DWTData 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_DMACubeMX里也需要在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纳秒设置DeadTime50死区约为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”就是典型应用。原理是定时器产生一个触发信号TRGOADC硬件收到信号后自动启动一次转换不需要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、输入捕获、编码器模式。把每一步都跑通并理解透彻你的嵌入式功底就会在不知不觉中提升一大截。希望这篇文章能帮你在定时器这条路上少走一些弯路。
返回列表