定时器原理深度解析:从51到STM32,避开PWM占空比与中断陷阱
2026/9/4 17:24:37 网站建设 项目流程

定时器这东西,我接触得越深越觉得它是个“大坑套小坑”的活儿。单看一篇手册,你以为自己懂了“计数、比较、中断”这几个词,一上板子却发现要么定时不准、要么中断不进、要么占空比死活到不了100%。我最早从51的T0/T1开始玩,后来切到STM32的TIM、再到Linux下的高精度定时器,中间踩过的坑堆起来比我写过的代码行数还多。所以今天这篇,我想换一个角度,不谈某个芯片的某个寄存器怎么配,而是把定时器的原理、工作模式和那些“看上去正常但实际有毛病”的细节一次性讲透。内容以51、STM32、555、SysTick和软件定时器为主线,能帮你把底层逻辑串起来,遇到问题的时候不至于只能靠试。

1. 定时器的底层逻辑:其实就是一个“数数”的计数器

先说一个最容易被人忽略的事实:无论叫什么名字——51的定时器、STM32的TIM、GD32的Timer、ARM的SysTick、Linux的hrtimer,甚至桌面软件里用setTimeout实现的延时,它们的内核只有一个,就是“数数”。

1.1 一个时钟源,一个计数器,一个比较器

所有定时器都离不开三个关键角色:

  • 时钟源:决定你“数一下”要花多长时间。它可能来自芯片内部的RC振荡器、外部晶振分频后的时钟,也可能是外部引脚输入的脉冲。
  • 计数器:一个不断累加(或递减)的寄存器,比如51的TH0/TL0,STM32的CNT。它每来一个时钟边沿就加1或减1。
  • 比较器/溢出逻辑:计数器累加到某个目标值时,触发一个动作(中断、翻转引脚、清0、重装载等)。

注意,这个“数数”用生活比喻最好理解:你手里有个秒表,每秒钟秒针跳一格,这就是时钟源;秒表上显示的数字就是计数器;你设定“跑到第10秒时闹铃响”,这个“10”就是比较值或溢出值。

为什么理解这个底层逻辑很重要?因为后续再复杂的模式——PWM输出、输入捕获、编码器接口、多定时器同步——都是在这个“数数”的基础上加各种外设功能而来的。

1.2 分频器:从高频时钟“数”出低频时间

芯片的主频往往很高,比如STM32F103的TIM挂载时钟是72MHz。如果直接用72MHz去数数,计数器从0加到65535只需要不到1ms,你想定一个10ms的周期就非常困难,因为周期=计数次数/时钟频率,65535次撑死了也只有0.91ms。

所以定时器前面加了一个预分频器(Prescaler)。它就是把时钟先除以一个系数,得到稍慢的时钟。这个系数在STM32里由PSC寄存器决定,分频后的频率为:

定时器时钟频率 = 输入时钟 / (PSC + 1)

注意,几乎所有的定时器分频寄存器,配置值都要加1才是实际分频系数。因为寄存器从0开始,写0表示不分频(即除以1),写71就是除以72。

如果你用STM32F103,想要定时1ms,主频72MHz,目标周期为1ms,那计算思路是:

定时器输入 = 72MHz / 7200 = 10kHz,此时CNT加1耗时0.1ms 再计100次 = 10ms?不对,10kHz下1次是0.1ms,100次就是10ms,这是错的

这里我就不写错例了,直接给出正确姿势:

选择 PSC = 71,分频后 = 72MHz / 72 = 1MHz,CNT加1耗时 1us 若目标定时 1ms,则 ARR = 1000 - 1 = 999

为什么是减1?因为计数器是从0开始数到999,这一共是1000个时钟周期,正好1ms。这种“寄存器值=期望次数-1”的规则,在整个嵌入式体系中到处可见(PWM占空比、比较器阈值、捕获值运算都要小心这个-1)。

1.3 和“时间”的对应关系:周期与频率的换算

很多时候我们习惯用“微妙、毫秒”来思考,但定时器寄存器只认“时钟周期数”。中间桥接的就是频率和周期换算:

时间 = 计数值 / 定时器时钟频率

反过来:

计数值 = 时间 * 定时器时钟频率

比如GD32F450的定时器,它的时钟树里有一个RCU_TIMER_PSC_MUL2配置,意思是定时器时钟可以倍频到系统时钟的2倍。这时候你算PSC就不是按72MHz算,而是按144MHz算。很多从STM32转到GD32的人第一个坑就在这里——沿用旧的分频系数,结果时间快了正好一倍。

所以在配任何定时器之前,第一件事不是抄例程,而是确认当前芯片的定时器时钟到底是多少,再谈分频、装载值。

2. 单片机定时器的核心工作模式拆解

有了“数数”的基础框架,再看工作模式就轻松多了。以STM32的通用定时器为例,它的几种模式基本涵盖了MCU定时器绝大多数应用场景。

2.1 定时模式:最基础也是最容易出错的

定时模式就是把计数器配置为“数到某个值就溢出/更新”,并触发中断。最初学51的时候,很多人用的是工作方式1(16位定时),初始化就是填TMODTH0TL0,然后TR0=1开跑,中断里记得重装初值。如果懒一点直接进中断不重装,第二次定时时间会变成从0开始跑到65535,误差大得离谱。

到了STM32,定时模式更灵活,因为自动重装载寄存器(ARR)帮你做了重装的事:

  • 计数器CNT从0向上递增;
  • 当CNT == ARR时,产生更新事件,CNT自动清零,然后继续从头开始;
  • 你可以选择在更新事件时进入中断。

就这么简单的一个流程,却有3个常见问题:

问题1:清中断标志的时机错了。HAL库里中断回调是HAL_TIM_PeriodElapsedCallback,但如果用了中断却没有及时清标志,下一次更新事件永远进不来,表现为“只有第一次中断”。其实STM32的更新中断标志UIF需要软件清零,HAL库会在HAL_TIM_IRQHandler里自动处理,但如果你自己写寄存器版,漏了清SRUIF位,就是只能进一次中断。

问题2:CNT计数到ARR时不进中断。看热词里有“定时器cnt加到car为什么没有产生中断”,这个问题我专门排查过一次,根因通常是:

  • 开了定时器,但没在NVIC里使能对应的中断通道;
  • 更新中断没有在DIER寄存器里使能(也就是TIM_IT_Update);
  • 中断回调里提前把更新标志位清了,导致HAL_TIM_IRQHandler判断不到标志。

排查思路很简单:第一步,直接在中断服务函数里打个断点或翻转个GPIO,看能不能进;第二步,查NVIC和DIER;第三步,再看定时器是否有其他中断优先级把更新中断抢占了。多数情况第一步就能发现问题。

问题3:定时周期“差不多但不对”。比如你算好了1ms,实测却是1.1ms。这种偏差的来源一是系统时钟本身的精度(HSE晶振、PLL配置),二是分了频之后计数粒度不够。较好做法是用逻辑分析仪或示波器实测一个引脚翻转周期,反推实际定时器时钟,再微调PSC或者ARR。

2.2 计数模式:数外部脉冲,别忘滤波

计数模式和定时模式的区别只有一个:它数的不是内部时钟,而是外部引脚的脉冲。同样一个TIM,可以配置为外部时钟模式1(数TIx引脚)或者模式2(数内部触发输入)。这常用来做测速、测频率。

实际项目里我比较多用的是输入捕获模式来测频率。它其实可以算作“计数器+捕获寄存器”的组合拳:配置定时器为输入捕获,第一个上升沿到来时把CNT值锁存到捕获寄存器,第二个上升沿再来一次,两次捕获值之差就是信号的周期(在CNT不溢出的前提下)。用这个差值除以定时器时钟频率,就能得到信号频率。

STM32定时器捕获测频率的具体思路:

1. 选择输入通道,比如TIM2_CH1(PA0) 2. 配置为上升沿捕获,预分频PSC设置合理值 3. 使能捕获中断(或者DMA) 4. 在中断里读两次CCR1值,相减得到周期计数值 5. 频率 = 定时器时钟 / 周期计数值

需要注意两点。第一,如果被测信号频率很低(比如10Hz),计数器很容易溢出,这时需要开启从模式复位(把另一个定时器或内部事件配置为复位源),或者把定时器配置为外部时钟模式+周期测量寄存器法,让计数器更“耐数”。第二,输入引脚上最好加滤波,尤其是电机换向这种干扰多的环境,不滤波的话,毛刺也能触发捕获,测量结果跳得厉害。STM32的输入滤波器(ICF位)可以配置为“连续N个有效电平才确认边沿”,实践下来比单纯靠软件消抖靠谱得多。

2.3 PWM模式:从原理到100%占空比的坑

PWM输出的本质是:计数器循环运行,输出引脚在一个比较值处翻转或置位,到溢出时又复位。它的两个关键量是:

  • 周期:由ARR决定;
  • 占空比:由比较寄存器CCR决定。

拿向上计数、PWM模式1来说,当CNT < CCR时输出有效电平,当CNT >= CCR时输出无效电平。也就是说,CCR = ARR时,理论上输出一直是高电平(占空比100%)

但热词里有一个非常典型的场景:“STM32F103定时器输出PWM占空比到不了100%”。我一开始也很纳闷,明明算好了CCR = ARR,示波器测出来却有很窄的低电平毛刺,或者干脆最高只能到99.x%。

后来排查发现原因有好几层:

  • 第一层,很多例程用的PWM频率较高(比如20kHz),你设置的CCR是1000,ARR也是1000,但CCR再怎么说也需要一个时钟周期来比较,计数器到达ARR时可能直接触发更新事件,随后的那个周期如果配置成PWM模式1,输出会在下一次匹配时立即拉低,导致实际上达不到绝对100%。
  • 第二层,如果你用了互补输出和死区插入(高级定时器TIM1/TIM8),死区时间会强制插入一段低电平,即使CCR=ARR,也不能输出连续的100%高电平。

解决思路也分两路。要是用高级定时器,试一下把死区设成0,并检查CCER里的极性配置;要是用通用定时器还遇到这个问题,就用逻辑分析仪抓波形看是否有个窄脉冲,有就说明是边界比较时序问题,可以考虑把输出极性反过来、或者把计数模式改成中心对齐模式来绕开。

2.4 定时器的“一专多能”:一个定时器能否同时收PWM和发PWM

热词里有人问“一个定时器能不能同时接收PWM数据和输出PWM波形”。这个问题要分两个层面看:

  • 如果你的MCU定时器有多个独立通道,比如TIM3有CH1~CH4,那完全可以让CH1输出PWM,CH2配置为输入捕获来测外部PWM频率/占空比。因为通道之间相对独立,可以同时工作。
  • 但如果你说的是某个通道既要输出又要采集同频率的PWM,那就有问题。比如你希望用同一通道既输出PWM,又测自己输出的PWM频率,绝大多数MCU是不推荐这么接的,因为内部反馈信号和外部输入信号可能冲突。

实操中我常用做法是:用定时器A的CH1输出PWM给设备,再用同一个定时器的CH2做输入捕获,间接检测设备是否有异常(比如堵转导致反电动势频率变化)。这种方案能省一个定时器,逻辑上也没有太多麻烦。

2.5 多个定时器如何同步启动

多定时器同步,最常见的场景是电机控制里需要两路或多路PWM严格同频同相,或者需要同时启动多个定时器保证时间基准一致。

常规办法有几种:

  • 主从模式:把某个定时器设为主模式,把它的更新事件或触发输出(TRGO)连到另一个定时器的从模式输入端。例如TIM1的更新事件作为TRGO,接到TIM2的从模式(ITR0),令TIM2在接收到触发时启动。这样只要TIM1一开始跑,TIM2也就跟着跑,误差在同一个时钟域内可以忽略。
  • 外部统一触发:多个定时器都配置为“外部触发从模式”,然后共用一个外部信号启动。这个方法的好处是启动时刻完全一致,适合做多通道同步采集。
  • 软件同时置位:最土的办法,把几个定时器的CR1寄存器中的CEN位通过一次写操作同时置1。在Cortex-M内核上这不是原子操作(除非你关中断),所以启动时间可能会有几个周期的偏差,低速应用可以凑合,高速PWM同步就不太行了。

我自己的体会是:宁可多用一条内部触发连线去搞主从同步,也别偷懒用软件启动。因为电机驱动这类场合对PWM相位一致性要求高,软件方式抖一下,电流波形就容易出现低频振荡。

3. 那些“不算MCU定时器”的定时器

如果只聊单片机,这个题目就太窄了。工程里你一定会碰到SysTick、555这类经典芯片、以及Linux/Windows下的软件定时器,它们的原理可以互相印证。

3.1 SysTick:一个和操作系统强绑定的定时器

SysTick是Cortex-M内核自带的定时器,它不依附于某个外设,所以哪怕你换不同厂家的STM32或GD32,SysTick的基础代码基本通用。它是一个24位递减计数器,计数到0时可以触发异常(中断),并且可以自动重装载。

它有两大典型用法:

  • 提供RTOS的心跳时钟:FreeRTOS的xPortSysTickHandler就是挂在SysTick中断里。此时SysTick的配置频率和系统时钟节拍(通常1000Hz或100Hz)直接挂钩。有人曾问“SysTick的时钟源是什么”,答案在CoreDebug或SysTick控制寄存器里:可以是处理器时钟(HCLK),也可以是HCLK/8,具体看芯片实现。在部分低功耗场景,为了让SysTick计数更稳定,有人会选择HCLK/8,但这样时间分辨率降低,实时性要求高的系统一般不用。
  • 提供裸机延时:如果你不想用定时器TIM,拿SysTick做delay是最快的。经典实现为:
void delay_us(uint32_t us) { SysTick->LOAD = us * (SystemCoreClock / 1000000) - 1; SysTick->VAL = 0; SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk; while (!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk)); SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk; }

实际上这个写法对us比较大时并不严谨,因为SysTick是24位,SystemCoreClock如果是72MHz,那么最大值只能定约233ms;超过这个值的延时就不能一次性LOAD。所以长时间延时你得循环多次,或者拆成ms级循环再补us。很多人在网上下载的延时函数一改主频就失效,就是因为没算LOAD溢出。

我个人建议:如果你只做裸机延时,直接用DWT(数据观察点与跟踪单元)里的CYCCNT做us级延时更好用,它是32位的,也不用和操作系统抢SysTick。这个思路比较冷门,但实测下来非常稳。

3.2 555定时器:硬件定时器的老祖宗

别看555是个1971年的老芯片,它的内部结构放到今天依然是理解“比较器+触发器”的最佳教材。555内部有三个5kΩ电阻分压,组成两个阈值电压(1/3 VCC、2/3 VCC),两个比较器,一个SR锁存器,一个放电三极管。

它最经典的是多谐振荡器(无稳态模式),用来产生方波:

  • 电容通过R1和R2充电,电压升到2/3 VCC时比较器翻转,输出低电平,内部放电管导通,电容经R2放电;
  • 电压降到1/3 VCC时再次翻转,输出高电平,放电管截止,又开始充电。

周期公式是很多电子入门课必背的:

T_high = 0.693 * (R1 + R2) * C T_low = 0.693 * R2 * C

注意,如果R2为0,T_low就是0,但正常工作一般不这么接,因为会过流损坏放电管。555定时器产生方波的频率范围宽、电路极度简化,我在教学板或快速原型里仍然经常用。它给嵌入式工程师的启发是:定时不一定非要用程序,硬件定时器天然不受程序跑飞、中断阻塞的影响。

3.3 Linux下的定时器与Cron表达式:从内核态到用户态

到了Linux,定时器的概念更偏向“调度”而非“硬件计数”。早期Linux内核用“时间轮”(timer wheel)维护大量定时器,现在还有高精度定时器(hrtimer)。用户态程序则常用timerfdPOSIX Timerepoll配合来做一个“精准到毫秒”的定时循环。

Linux C定时器的经典做法至少有三种:

  • setitimer:发送SIGALRM信号,周期触发。优点是简单,缺点是信号处理函数里能干的事有限,精度一般。
  • timerfd_create+poll/epoll:把定时器抽象成一个文件描述符,到期后fd可读。这种非常适合写事件驱动的服务,不会破坏现有的事件循环,也可以精确到纳秒级别(取决于内核配置和硬件)。
  • POSIX Timertimer_create:更灵活,可以选择信号通知或者线程通知,精度高,但API比前两个复杂。

如果你的项目是在业务系统里写“每分钟执行一次报表生成”,那直接用Cron表达式最省事。Cron表达式的核心是6~7个字段,分别表示秒、分、时、日、月、周(年可选),前端做定时组件时最常见的问题是时区下一次执行时间计算。如果把Cron解析放在前端,要注意new Date()的时区偏移,否则在UTC+8环境下很容易差8小时。我见过不止一个定时任务组件,部署到海外服务器上就偏了,最后发现是前端把本地时间转成字符串解析时,时区被吞了。

从原理上讲,Linux的软件定时器跟STM32定时器没有本质不同,都是“一个任务队列,每次tick时检查哪些任务到期”。区别只在于:MCU的tick来自硬件中断,Linux的tick来自内核时钟节拍或者高精度事件。理解了这一层,你在任何平台上写定时器都不会慌。

3.4 桌面端“定时器失效”的坑:以Windows锁屏定时器为例

热词里有一条“windows锁屏定时器失效”,这个很有代表性。Windows下如果你用SetTimer,它依赖消息循环,锁屏后系统为了省电可能会挂起或降低计时精度;如果用CreateWaitableTimer,它理论上不受锁屏影响,但如果你没有设置“唤醒”权限或者系统进入睡眠状态,依然会不准。

解决办法通常有两种:

  • 用多媒体定时器timeSetEvent(高精度)或者CreateWaitableTimer,并在电源管理里阻止系统进入睡眠,或者注册PowerSettingNotification在睡眠后恢复定时。
  • 把定时逻辑放到服务程序里,服务运行在Session 0,不受当前用户锁屏影响。

严格来说,这不是定时器原理的问题,而是操作系统的电源管理介入。嵌入式工程师做上位机的时候经常忽略这一点,以为自己程序写得没问题,半夜跑定时任务总是对不上点,最终发现是笔记本合盖睡过去了。

4. 常用定时器配置实操:手把手走一遍

前面讲了一大堆原理,接下来给几个能直接抄作业的配置思路,覆盖51、STM32/GD32和C#环境下的“精准定时”。

4.1 51定时器:从工作方式1到方式2

51单片机的定时器有两个,T0和T1,都是16位的。常见工作方式:

  • 方式0:13位计数器(老古董,基本不用);
  • 方式1:16位计数器;
  • 方式2:8位自动重装载。

方式2在串口波特率里用得很多,因为它可以自动重装初值,不需要中断里额外赋值。方式1则是通用定时的主流。

写一个经典“定时1ms中断翻转LED”的初始化:

void Timer0_Init(void) { TMOD &= 0xF0; // 清除T0的配置位 TMOD |= 0x01; // T0工作方式1,16位定时 TH0 = (65536 - 1000) / 256; // 12MHz晶振,12分频后1MHz,1ms计1000次 TL0 = (65536 - 1000) % 256; ET0 = 1; EA = 1; TR0 = 1; } void Timer0_ISR(void) interrupt 1 { TH0 = (65536 - 1000) / 256; // 重装初值 TL0 = (65536 - 1000) % 256; LED = !LED; }

注意这里有个细节:如果中断里重装初值后,CNT已经从0开始跑了一会儿(进中断也是要时间的,中断入口要压栈),这会导致时间略微偏长。高精度场合建议用“提前重装+修正”的办法,或者干脆换成自动重装载的方式2,但方式2最多只能装8位初值,对于1000us来说不够。

4.2 STM32标准库/HAL库配定时器中断

HAL库的套路比较固定,拿TIM3做1ms更新中断来说:

TIM_HandleTypeDef htim3; void MX_TIM3_Init(void) { htim3.Instance = TIM3; htim3.Init.Prescaler = 72 - 1; // 72MHz/72 = 1MHz htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 1000 - 1; // 1MHz,1000次 = 1ms htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim3); }

关键点在于,HAL_TIM_BASE_MSP_INIT回调里面会帮你做GPIO时钟、中断优先级、NVIC的配置。很多人忘写这个回调,结果定时器初始化成功后,中断就是进不去。检查办法:在HAL_TIM_Base_Start_IT(&htim3)之后,看htim3->State是否正常,再看NVIC里TIM3中断是否Enable。

更新中断回调不要写在IRQHandler里,要写在:

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { // 你的定时逻辑 } }

4.3 GD32的定时器配置差异

GD32F450最近用的人多,它的定时器和STM32神似,但时钟树有差异。热词里的rcu_timer_clock_prescaler_config(RCU_TIMER_PSC_MUL2)就是把TIMER时钟倍频到2 * APBx时钟。

配置步骤大致是:

rcu_periph_clock_enable(RCU_TIMER1); rcu_timer_clock_prescaler_config(RCU_TIMER_PSC_MUL2); // 定时器时钟倍频2倍 timer_deinit(TIMER1); timer_parameter_struct initpara; initpara.prescaler = 144 - 1; // 144MHz/144 = 1MHz initpara.period = 1000 - 1; // 1ms initpara.alignedmode = TIMER_COUNTER_EDGE; initpara.counterdirection = TIMER_COUNTER_UP; initpara.clockfilter = 0; timer_init(TIMER1, &initpara); timer_update_event_enable(TIMER1); // 注意GD32必须显式使能更新事件 timer_interrupt_enable(TIMER1, TIMER_INT_FLAG_UP); nvic_irq_enable(TIMER1_IRQn, 1, 0); timer_enable(TIMER1);

如果你在GD32上套用STM32的PSC值,因为GD32默认定时器时钟可能已经是倍频后的,时间会比预期快一倍。所以拿到一款新MCU,最先看时钟树图,确认定时器所在总线频率以及能否倍频,再写分频系数。

4.4 用C#做1ms精准定时

上位机里做高精度定时,很多人直接用System.Windows.Forms.Timer,但它依赖UI消息循环,精度只有约15ms,哪怕你设置Interval=1,实测也可能跳到15ms甚至更高。要做到1ms级别,得用System.Threading.TimerStopwatch+Thread.Sleep混合。

我的做法是用Stopwatch自旋等待:

var sw = System.Diagnostics.Stopwatch.StartNew(); while (sw.ElapsedMilliseconds < 1) { } // 这段空转在Release下才有意义,Debug下会被优化和调试器干扰

但纯自旋会占满一个CPU核,不适合多任务环境。更稳的是用CreateWaitableTimer或者timeBeginPeriod(1)提升系统定时分辨率。Win32 APItimeBeginPeriod可以把系统最小定时器周期调到1ms,但用完必须timeEndPeriod还原,否则系统整体功耗上升。

[DllImport("winmm.dll")] static extern uint timeBeginPeriod(uint uMilliseconds); [DllImport("winmm.dll")] static extern uint timeEndPeriod(uint uMilliseconds);

注意:这个API只影响系统定时器分辨率,不影响线程调度延迟。如果你在一个忙碌的UI线程里调用定时器,照样可能卡顿。要精准,建议把定时逻辑放独立线程或者后台任务里。

4.5 用Cron表达式组件,前端定时别踩时区

前端定时组件最流行的方案是cron-parsercronstrue这类库。开发一个定时任务管理页面时,我的习惯是后端统一存储Cron表达式和预期时区,前端只负责展示和编辑,不做任何本地时间转换。为什么?因为不同浏览器、不同操作系统的时区处理方式有差异,你没法保证每个用户的本机时间都正确。

如果非要前端做“下一次执行时间”的计算,记住一点:始终用UTC毫秒数做运算,展示时才转本地时间。千万不要用new Date('2024-01-01 00:00:00')这种字符串解析,因为不同浏览器对不带时区的ISO字符串解析规则不同,轻则差8小时,重则直接返回Invalid Date。

5. 常见问题与排查技巧实录

下面这些案例,每一个都是我在实际项目里真正遇到的,整理成速查表供参考。

5.1 定时器CNT已经等于ARR,为什么中断就是不进?

我先一步步复现排查过程。假设使用STM32F103,TIM2配置为向上计数,ARR=999,开了更新中断。

第一步,检查定时器是否真的跑起来了:

while(1) { uint16_t cnt = TIM2->CNT; }

看CNT是否在变化。如果CNT一直不动,说明时钟没来,查RCC里TIM2时钟是否使能,或分频配置是否把时钟设成了0。

第二步,检查SR寄存器里UIF位是否在定期置1。如果置1说明比较/溢出已经发生,只是没有进中断,那就是NVIC或DIER的问题。

第三步,检查中断服务函数是否存在,并且在启动文件里正确命名。我见过不少“换了芯片型号,启动文件的IRQHandler名称对不上”导致程序HardFault的案例。

5.2 定时器输出PWM,占空比到不了100%

排除法排序如下:

  • 先看CCR是否真的等于ARR,中间有没有被其他代码改动;
  • 再看输出极性,CCER里的CC1P位是否配置成高电平有效;
  • 检查是否开启了死区,高级定时器尤其明显;
  • 最后用示波器看最小低电平时间。如果低电平只有几十ns,大概率是边界比较时序问题,可考虑把PWM频率降低,或者改计数模式(中心对齐模式下CCR=ARR更容易输出满占空比)。

5.3 一个定时器能同时读PWM、写PWM吗

如果你指的是“同一个定时器的不同通道”,完全可以。比如TIM4的CH1输出PWM,CH2做输入捕获。要注意的是,输入捕获和输出比较共用CCR寄存器的一部分资源,具体到某个定时器型号可能有限制,需要查手册确认捕获/比较通道是否支持同时独立工作。STM32F1系列通用定时器一般是支持的,只要你不把同一个通道配成既输出又输入。

5.4 三个定时器为什么不能同时启动

很多人以为只要在同一行代码里写三个Enable就能同步,但在Cortex-M里,三次写TIMx->CR1是三条汇编指令,中间完全可以被中断打断。如果中断里又恰好操作了定时器,同步性就破坏了。

想要严格同步,优先用主从模式:TIM1为主,其它TIM为从,触发源设为ITR。或者用一个外部GPIO统一触发。这样无论代码执行到哪里,硬件上都是同一时刻启动。

5.5 锁屏后定时器失效、Linux下定时器不准

Windows锁屏失效的原因前面提到了,主要和电源管理相关;Linux下定时器不准则可能是内核CONFIG_HZ配置过低(如100Hz,精度10ms),或者系统负载太高导致进程调度延迟。高精度场景要么用timerfd+epoll,要么考虑实时线程+clock_nanosleep(TIMER_ABSTIME)。我用clock_nanosleep的绝对时间模式做过周期任务,抖动明显比相对时间模式小,因为它不会累积误差。

6. 写在最后的经验和扩展

定时器这个主题,越细究越发现它连接了硬件和软件的很多底层机制。我个人在实际操作中的体会是:遇到定时不准、中断不进、占空比不对,先不要怀疑编译器,先拿示波器/逻辑分析仪量引脚,看看硬件行为到底是什么。软件层面的“我以为”常常是最大的坑,而波形不会说谎。

另外,如果手头有带FPU和硬件定时器的新MCU,建议从第一天就花半小时确认三件事:定时器时钟源、分频寄存器是否要加1、中断标志是硬件自动清还是要软件清。这三件事能拦住80%的定时器问题。

最后分享一个小技巧:在调试PWM或定时器时,永远先让定时器输出一个已知频率的方波(比如1kHz),用示波器确认精确无误后,再去改复杂参数。这样一旦后面波形异常,你能判断是配置问题还是负载问题,而不是像无头苍蝇一样来回试寄存器值。

如果你看完这篇,能对定时器的“数数”本质和各大平台的差异有个整体把握,以后再换任何一款芯片,配置定时器时心里都会更有底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询