ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

TIM基本定时器实战:从PSC/ARR计算到中断配置,告别delay阻塞

TIM基本定时器实战:从PSC/ARR计算到中断配置,告别delay阻塞 为了给板子上的状态灯做闪烁效果我一开始偷懒在循环里写了个delay结果整个程序直接被拖垮按键扫描不灵敏显示刷新一卡一卡按下按键半天才有反应。当时我就意识到凡是“每隔固定时间就做一次事”的场景都不应该让CPU空转等着而是应该交给硬件定时器去计时时间到了用中断通知CPU。后来我把目标锁定在TIM基本定时器上它是定时器家族里功能最单一也最好懂的那一级——不涉及输入捕获、不涉及PWM输出就是老老实实定时。也正因为这份纯粹它成了吃透定时器原理的最佳入口。这篇文章我从头写一遍TIM基本定时器的实战使用过程它在整个定时器体系里的定位、定时时间的计算方法、寄存器配置的逐行解释以及我实际调板子时踩过的坑。刚开始接触定时器的新手或者一直在用库函数封装、想弄清底层原理的朋友应该都能在这里找到点东西。1. 先看清基本定时器在“定时器家族”里的位置1.1 三种定时器各管一摊子事在常见的Cortex-M内核MCU里定时器通常按能力分成三档基本定时器、通用定时器、高级定时器。光看名字有人会误以为通用定时器能完全覆盖基本定时器的功能但实际恰恰相反——基本定时器虽然简单却有它不可替代的定位。能力项基本定时器TIM6、TIM7通用定时器TIM2~TIM5高级定时器TIM1、TIM8定时中断/更新事件支持支持支持输入捕获无支持支持输出比较/PWM无支持支持互补输出/死区/刹车无无支持外部引脚不引出引出引出从表格能看明白一件事基本定时器没有对外引脚它不跟外部信号打交道唯一干的事情就是“按内部时钟计数数到预定值产生一次更新事件”。这个特性不是缺点反而让初学阶段少了很多干扰项。等我把基本定时器的原理吃透了再去看通用定时器的捕获、比较功能会发现它们共用相同的时基结构只是多了几条旁路而已。1.2 基本定时器内部只有三个关键部件基本定时器的数据通路可以简化成一条链内部时钟 → 预分频器PSC→ 计数器CNT→ 自动重装载寄存器ARR最后CNT数到ARR的值时产生更新事件触发中断。拿生活里的场景类比预分频器就像一个减速齿轮箱把内部高速时钟“降速”后送给计数器计数器像一个里程表一格一格往上跳ARR则是你在仪表盘上画的红线里程表一走到红线位置就发出“到点了”的信号。这套类比虽然简单但对后面理解定时时间公式非常有用。1.3 为什么入门要从基本定时器开始我见过不少同学第一次学定时器就直接上通用定时器做PWM结果既要配时基、又要配输出通道、还要调占空比一个东西涉及好几组寄存器出了问题根本不知道是时基错了还是输出配置错了。基本定时器就没有这个问题不涉及引脚复用、不涉及通道映射、不需要配置输出极性只需要关心时基本身。说白了它就是一张白纸让你能把“计数溢出”这个最核心的行为看得清清楚楚。2. 定时时间怎么算从时钟树到那条要抄在便签上的公式2.1 先弄明白定时器时钟从哪来在配置任何定时器之前必须先知道给它提供时钟的源频率是多少。以一颗主频72MHz的Cortex-M内核MCU为例定时器挂在APB1总线上。这里有一个非常容易让人迷糊的点如果APB1分频系数设置为2APB1外设时钟会变成36MHz但定时器时钟并不会跟着变36MHz而是自动倍频成72MHz。原因很简单芯片设计时故意让定时器能拿到比APB1外设更高一倍的时钟就是为了保证定时器在高精度场景下依然够用。所以计算定时时间时不能拿PDF表格里的“APB136MHz”直接去算而要在心里多问一句APB1分频系数是不是2如果是定时器时钟72MHz如果APB1分频系数是1定时器时钟才是36MHz。这一步错了后面所有定时时间都会翻倍或者减半而且轻易看不出来。2.2 核心公式T (ARR1) x (PSC1) / CK_INT定时器从0向上计数每来一个计数脉冲CNT加1。配置好预分频值PSC之后实际分频系数是PSC1自动重装载值ARR参与比较时也要加1因为CNT是从0开始数到ARR的一共数了ARR1个脉冲。所以定时周期公式是T (ARR 1) * (PSC 1) / CK_INT其中CK_INT是定时器输入时钟频率。这个公式是我建议所有刚接触定时器的人抄在便签上贴在显示器旁边的东西因为后面不管是计算1ms、1s还是做微妙延时全都是在套它。2.3 实际算一组参数从1ms到1s以定时器时钟72MHz为例如果我想产生1ms的定时中断先选预分频值目的是把计数频率降到好算的量级。取PSC71则计数频率 72MHz / (711) 1MHz也就是说计数器每1微秒跳一次。再选ARR。1ms 1000微秒计数器跳1000次需要ARR999。整个计算过程可以口算PSC71ARR999。代码里写的时候要注意这两个值填的原始值是71和999不要额外再去减1因为寄存器硬件本身已经按“值1”来处理了。如果我想做1s定时同样可以保持PSC71不变计数频率依然是1MHzARR改成999999。此时定时周期(9999991) x 1us 1s。需要提醒的是基本定时器的ARR是16位寄存器最大值65535所以1s这个配置已经超出范围了。真要做1s通常把PSC加大比如PSC7199计数频率10kHzARR9999周期(99991)x0.1ms1s。权衡思路就是先确定合理计数频率再反推ARR最后检查ARR是否落在16位范围内。2.4 为什么PSC和ARR往往配成“整百整千”的数在保证定时时间正确的前提下我喜欢把计数频率配成1kHz、1MHz这种十进制友好的数值不是因为它性能更好而是因为调试时好换算。假设你在中断里加一个变量计数想看到LED每500ms翻转一次那只要算“500000次中断对应500000微秒”就行不用拿着计算器反推。这在项目后期排查问题时能省很多脑力。3. 从零配置一个1ms定时中断寄存器逐行手记3.1 标准外设库视角下的初始化代码用标准外设库配置TIM基本定时器代码量不大关键是要知道每一行到底做了什么。下面是一段完整的初始化与中断服务函数注释里写的是寄存器层面的行为#include tim6.h static volatile uint32_t g_tick_count 0; void TIM6_Init(uint16_t prescaler, uint16_t period) { // 1. 打开TIM6外设时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM6, ENABLE); // 2. 配置时基 TIM_TimeBaseInitTypeDef timebase; timebase.TIM_Prescaler prescaler; // PSC实际分频系数为 prescaler1 timebase.TIM_CounterMode TIM_CounterMode_Up; // 向上计数 timebase.TIM_Period period; // ARR自动重装载值 timebase.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM6, timebase); // 3. 使能更新中断 TIM_ITConfig(TIM6, TIM_IT_Update, ENABLE); // 4. 配置中断优先级 NVIC_InitTypeDef nvic; nvic.NVIC_IRQChannel TIM6_IRQn; nvic.NVIC_IRQChannelPreemptionPriority 1; nvic.NVIC_IRQChannelSubPriority 1; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); // 5. 启动定时器 TIM_Cmd(TIM6, ENABLE); } void TIM6_IRQHandler(void) { if (TIM_GetITStatus(TIM6, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM6, TIM_IT_Update); g_tick_count; } }第2步里有个容易忽略的细节TIM_TimeBaseInit内部会操作TIMx_ARR、TIMx_PSC这些寄存器但PSC的写入要等到更新事件产生之后才会真正生效到计数逻辑。因此很多老工程师会在初始化后加一句TIM_GenerateEvent(TIM6, TIM_EventSource_Update)也就是软件产生一次更新事件把预分频值“推”进生效寄存器避免第一次周期的计数值不对。如果是上面这段代码后续每次都走正常溢出影响不大但严谨起见建议加上。3.2volatile到底保护了什么我在代码里把g_tick_count声明成了volatile这不是玄学。中断服务函数里修改这个变量主循环里读这个变量编译器默认不知道这个变量会“被外部改变”很可能把它的值优化到寄存器里缓存导致主循环一直读到旧值。加了volatile之后编译器每次读取都老老实实从内存地址去取。这是多线程和中断编程里最容易踩、也最难用肉眼发现的问题。3.3 主循环里怎么用它判断时间有了1ms的tick计数主循环就可以去掉各种delay改成判断差值uint32_t last_time 0; while (1) { uint32_t now g_tick_count; if (now - last_time 500) // 每500ms翻转一次 { last_time now; GPIO_ToggleBits(LED_PORT, LED_PIN); } // 按键扫描、显示刷新等任务都可以放在这里不再被延时阻塞 }这里用差值判断而不是直接比较相等是为了防止主循环执行周期波动导致漏掉某一拍的判断。用无符号整数做减法还有额外好处即使tick发生回绕只要时间差不超过该类型表示范围的一半结果依然正确。3.4 HAL库对应的写法如果你用的不是标准外设库而是HAL库本质操作完全一样只是封装方式不同__HAL_RCC_TIM6_CLK_ENABLE(); htim6.Instance TIM6; htim6.Init.Prescaler 71; htim6.Init.CounterMode TIM_COUNTERMODE_UP; htim6.Init.Period 999; htim6.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim6); HAL_TIM_Base_Start_IT(htim6);中断回调是统一入口void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM6) { g_tick_count; } }HAL库依赖初始化函数里把句柄和实例绑定同时应用里必须调用HAL_TIM_Base_Start_IT才开始真正走中断。不少人配好了初始化但忘了启动现象是“寄存器看着都对就是不进中断”。这个问题我在下一章也会详细说。4. 实际调定时器时踩过的四个坑以及完整的排查思路4.1 坑一写完程序烧进去LED纹丝不动第一次调定时中断时我配置好代码、下载程序LED压根不闪。当时第一反应是定时器没跑起来于是用调试器查看CNT寄存器的值——结果发现它就是一直在变说明定时器已经工作了。接着查中断是否发生发现对应的中断标志位一直是SET但程序就是没跳到中断服务函数。排查到这一步方向转向NVIC。逐个检查NVIC配置项发现我照抄了某段通用定时器的例程中断通道写成了别的定时器的IRQ而不是TIM6_IRQn。时钟开了、定时器跑了、中断标志有了但中断请求根本没送达CPU自然不会进服务函数。这个坑给了一个教训遇到“定时器计数正常但程序不进中断”的情况第一件事是确认中断通道是否匹配自己的定时器号第二件事是确认优先级分组是否配置过。有些启动代码里没有调用NVIC_PriorityGroupConfig虽然默认也能用但不同芯片恢复的默认分组不同最好显式配置一次。4.2 坑二定时时间对不上所有时间都翻了一倍有次我把PSC配成71ARR配成999满以为会得到1ms中断用逻辑分析仪一量居然是2ms。那一刻我第一个怀疑是公式记错了又翻手册核对预分频器分频确实应该加1ARR的比较确实要加1公式没问题。后来反复看时钟树才意识到我用的这颗芯片APB1分频系数是2APB1外设时钟已经变成36MHz但定时器时钟自动倍频回了72MHz也就是说定时器时钟确实是72MHz没错。那问题出在哪我在初始化代码里先调用了外设时钟配置把APB1分频设成了2但我是在这之后才开启TIM6时钟的。这时TIM6的输入时钟按理论应该是72MHz。逻辑分析仪为什么测出2ms后来查代码发现初始化里把TIM_Prescaler写成143我是把“PSC1144”当成目标去反推的算出来就是144倍分频计数器自然变成500kHz溢出周期变成2ms。问题出在变量名太长看花了眼填值时多打了一个2。这个经历说明公式本身不难难的是“寄存器里填的值”和“实际分频倍数”之间差一的关系。PSC71实际是72分频PSC143实际是144分频。填值时多注意这一步能省很多排查时间。4.3 坑三程序看起来死机了其实是中断风暴用调试器看程序一直停在某个中断相关代码里主循环完全跑不动看起来像死机。单步跟踪发现中断服务函数每次都进而且总是在同一个位置重复进入。根因是中断标志没有清除。我最初写服务函数时只做了标志判断忘了调用清标志位的函数。TIM的更新中断标志UIF一旦置位不清除的话退出中断后标志依然有效CPU会立刻再次进入中断。于是程序永远在“进中断→退出→立刻再进中断”中循环主循环被饿死。排查这种问题有一个很管用的观察法在中断服务函数里加一个计数器这个计数器增值很快说明中断频率异常再用调试器看一眼SR寄存器的UIF位是否一直为1如果是基本就是没清标志。清标志的操作一定要放在处理逻辑之前还是之后反而无所谓重点是不要遗漏。另外有些库函数在读取标志时已经隐式清除了标志有的没有一定要以手册为准不要想当然。4.4 坑四HAL库项目里回调函数写了但永远不进来换到HAL库项目后配置看着正常回调函数也写了但始终不触发。我重新检查初始化流程发现只调用了HAL_TIM_Base_Init没有调用HAL_TIM_Base_Start_IT。HAL库的启动函数才是真正去置位CR1寄存器CEN位、并使能更新中断的开关只初始化不启动等于车子挂好了挡但不踩油门。补上启动调用后回调立刻正常涌入。此外HAL库允许多个定时器实例共用同一个HAL_TIM_PeriodElapsedCallback回调函数如果工程里同时用了TIM6和TIM7必须在回调里用句柄的Instance区分来源否则两个定时器都会触发同一段逻辑产生意料之外的联动。5. 一个定时器不够用时用定时中断撑起整个软件时间架构5.1 从“一个tick变量”到“多个软件定时器”把1ms中断跑通之后我开始思考一个问题工程里可能有LED翻转、按键消抖、超时检测、屏幕刷新每一个都要不同的时间周期总不能为每个功能开一个硬件定时器。硬件基本定时器就两个根本不够用。于是我用结构体数组做了几个“软件定时器”每个定时器只记录目标值和当前状态typedef struct { uint8_t active; uint32_t target_tick; uint32_t last_tick; } SoftTimer;在主循环里用g_tick_count去逐个检查g_tick_count - last_tick target_tick满足就执行对应回调并更新last_tick。整个软件定时器只需要一个1ms硬件节拍其余都是内存里的加减法。这也是许多轻量级操作系统的“时间片管理”雏形。5.2 时间片轮转给不同任务分配时间窗口如果任务不需要精确到微秒更简单的做法是时间片轮转。比如tick加1代表1ms过去可以约定“每个tick末位为0时执行按键扫描为5时执行显示刷新为20时执行数据记录”。这种方式不用建结构体数组只要在1ms中断里用switch (tick % 20)分发即可。需要注意的点是1ms中断服务函数里的代码要尽量短。中断里只置标志、加计数、记录时间点不要做复杂的计算、通信或者延时操作。否则中断占用时间太长会影响到主循环的实时性也可能导致下一次中断被延后产生时间抖动。5.3 共享变量与临界区保护只要涉及中断和主循环之间共享变量就必须考虑数据一致性。对于32位MCU一个32位变量的读取在硬件上通常是原子操作但如果是结构体、数组、或者大于一个机器字长的数据读写过程可能被中断打断导致主循环读到“写到一半”的数据。这种情况下可以采用临界区保护__disable_irq(); local_var shared_var; __enable_irq();或者更精确地用“关中断→取数据→恢复中断”的方式。在我自己维护的代码里凡是中断服务函数和主循环共同访问的变量我都统一加上volatile多字节数据则用临界区保护。这是一个换过教训后的习惯。5.4 利用定时器中断做时间基座后的工程体会等我把这套时间架构跑起来之后最直观的感受是主循环变清爽了没有了各种delay嵌套按键扫描不会因为等待而丢事件屏幕刷新也稳定了。1ms的定时中断就像整个系统的“心跳”所有任务都围着这个心跳排队执行。我个人现在做定时需求时习惯先写“定时周期”再反推PSC和ARR而不是随手写个值再去量波形。算完之后用逻辑分析仪或示波器确认一次实际引脚翻转周期确认无误后再继续往下做别的功能。这个习惯看起来非常基础但在项目复杂度上去之后能保证时间基准始终可靠。6. 给新手的几条实测建议一开始不要直接用最高主频配合大分频做1s长周期先用1ms级别的中断观察现象波形看得清楚公式也容易验证。尽量把第一个定时中断配置在某个空闲的GPIO上翻转用示波器或逻辑分析仪看到方波再开始“折腾”其他功能。所有中断服务函数里跟主循环共享的变量都要加volatile多字节数据记得做临界区保护不要等程序“偶尔异常”了再回来补。如果遇到中断进不去优先怀疑“中断使能位”“NVIC通道”“启动函数”这三个位置比从头到尾读一遍代码更高效。不管用什么库最终都要能自己找到对应的寄存器位置。这一行代码会不会写不重要能不能看懂它在操作哪个寄存器才重要。写到这里基本定时器的定时功能从原理到落地已经完整串了一遍。回过头看定时器的本质就是一个能自动计数、数到目标值后通知CPU的硬件工具难点从来不在配置多少行代码而在于是否真正理解PSC、ARR、时钟源三者之间的关系。把基本定时器吃透之后再去接触通用定时器的PWM输出、输入捕获你会发现底层思想都是一样的只是多了一些“旁路”和“分支”而已。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表