搞单片机的人,应该都有过被定时器溢出时间折磨的经历。我见过不少初学者,把一个经典51工程里的定时器初始化代码整个挪到中颖的板子上,结果LED闪烁频率和预期差了十万八千里,调了半天也不知道是哪里出了问题。表面上看是寄存器名字对不上,实际上更底层的原因是对“定时器溢出计算公式”的理解没有落到实处。
三星、现代、中颖、合泰、松翰这些厂商的单片机,虽然内核架构、寄存器命名、配置流程差异很大,但定时器溢出时间的计算逻辑本质上是相通的,核心公式就一个。你只要能把这个公式盘明白,再把各个型号的分频、位数、计数方向这三个变量对上号,几乎所有的定时器配置都能在纸上先算出结果,而不是靠“抄代码+碰运气”。这篇文章就是把这些公式和推算过程完整拆开,配合实际数据,让你看完就能自己动手算。
1. 定时器溢出公式的核心逻辑与应用价值
1.1 一个公式吃透所有单片机的定时器
先把最核心的公式亮出来:
T_overflow = Counts × Prediv / Fosc其中:
- T_overflow:定时器从装载初值到发生溢出中断的总时间,单位是秒;
- Fosc:单片机的系统时钟频率,单位是Hz;
- Prediv:定时器的预分频系数;
- Counts:从初值到溢出边界之间实际经过的计数脉冲个数。
这个公式看着简单,但背后的逻辑值得多说一句。定时器本质上就是一个“数脉冲的计数器”,它自己不产生时间,时间是通过脉冲个数乘以单个脉冲周期换算出来的。预分频器把系统时钟先降频,得到定时器的工作时钟;定时器每收到一个脉冲,内部计数寄存器就加一或者减一;当计数到达边界时,溢出标志置位,触发中断或者事件。
所以整个时间链条是:
系统时钟 → 预分频 → 定时器计数脉冲 → 到达边界 → 溢出再往细里说,Counts 怎么算,取决于计数方向:
- 递增计数器:从初值往满量程方向数,溢出边界是 (2^{bits}),因此 Counts = (2^{bits} - InitValue)。
- 递减计数器:从初值往零方向数,溢出边界是 0,因此 Counts = InitValue。
很多人在这一步栽跟头,就是因为把递增计数器的公式硬套到递减计数器上。比如某款芯片的定时器是16位递减计数,初值写65506,实际等效于递增计数器的初值写成“65536 - 65506 = 30”,两者相差非常大。动手前第一件事一定是查手册确认计数方向,而不是先抄代码。
1.2 这个公式到底能解决什么问题
搞懂这个公式,不是单纯为了应付一道计算题,而是为了支撑实际工程里的很多场景:
- 精确延时:毫秒级延时、微秒级延时、系统 tick 的基准;
- PWM 输出:PWM 的频率本质上就是定时器的溢出频率,改初值就是改频率;
- 串口波特率:51 系列单片机经常用定时器 1 的溢出率来生成波特率,懂公式才能自己配;
- 信号捕获:测量外部脉冲宽度时,需要把定时器的计数频率和计数值换算成时间;
- 任务调度:RTOS 的时基或者裸机的前后台调度,都依赖稳定且可计算的定时器溢出周期。
不管是三星、现代、中颖、合泰还是松翰,只要你拿到一块新板子,第一件事就是看数据手册里的时钟树、定时器位数、预分频器范围,然后用这个公式把目标时间反推成初值。这套方法适用于全部场景,比对着例程硬猜可靠得多。
2. 五大品牌单片机的定时器计算差异与实例
2.1 三星单片机:多产品线下的定时器计算套路
三星的单片机产品线很杂,8位、16位、32位都有,但做控制类项目时常见的还是8位的型号。这类芯片里的定时器有8位也有16位,预分频器的配置方式也和经典51不完全一样,但公式里的变量没有变。
假设 Fosc = 8MHz,预分频系数 = 8,则定时器工作时钟频率为 1MHz,每个计数脉冲周期是 1us。
如果用的是16位递增定时器,目标溢出时间为 1ms,那么:
Counts = 1ms / 1us = 1000 InitValue = 65536 - 1000 = 64536如果分配的是8位定时器,同样的目标时间就没法直接做,因为 1000 超出了 256 的范围。这时必须调整预分频。把预分频系数改成 64,定时器时钟变成 125kHz,单脉冲周期变成 8us,那么:
Counts = 1ms / 8us = 125 InitValue = 256 - 125 = 131这个调整思路很重要。很多人在8位定时器上硬算初值得出负数,然后以为是寄存器设置错误,其实原因很简单:分频太小,计数范围不够,需要在“分辨率”和“溢出周期”之间做取舍。
2.2 现代单片机:51兼容内核的定时器直接套经典公式
现代的单片机产品线以8位 MCU 为主,其中不少型号的内核兼容经典 8051 指令集,定时器 0、定时器 1 的结构和经典51基本一致,标准工作模式下直接套用51的算法完全可行。
经典51的定时器在没有额外配置时默认是 12 分频,也就是说系统时钟 12MHz 时,定时器实际工作时钟是 1MHz,也就是每 1us 计数一次。
以 16 位模式产生 10ms 溢出为例:
Counts = 10ms / 1us = 10000 InitValue = 65536 - 10000 = 55536 = 0xD8F0于是:
TH0 = 0xD8; TL0 = 0xF0;如果型号支持 1T 模式,即不分频,那定时器工作频率就是 12MHz,单脉冲周期约 0.0833us,同样 10ms 需要计数 120000 次,已经超出 16 位定时器范围,所以必须依赖预分频或者改用系统 tick 方式。正因为这些隐藏分频的存在,同一段代码在不同型号上跑出来的时间会完全不同。
2.3 中颖单片机:8051内核的定时器模式和初值计算
中颖的 SH79F 系列在内核上和 8051 高度兼容,定时器 0、1 的四种工作模式依然是那套经典框架。很多做 51 开发的人切到中颖之后觉得熟悉,但如果对模式和初值计算不够熟练,还是容易把时间配错。
四种模式对应的位数要记清楚:
| 工作模式 | 定时器结构 | 最大计数值 |
|---|---|---|
| 模式0 | 13位(TH高8位 + TL低5位) | 8192 |
| 模式1 | 16位 | 65536 |
| 模式2 | 8位自动重载 | 256 |
| 模式3 | 拆成两个独立8位定时器 | 256 和 256 |
模式0 很容易被当成 16 位用,因为 TH 和 TL 两个寄存器看上去很对称,实际上 TL 只有低 5 位参与计数,初值写法完全不同。我建议初学阶段尽量用模式1,16位计数范围宽,计算直观,不容易踩 13 位低 5 位的坑。
以 12MHz、12分频、模式1、50ms 为例:
机器周期 = 12 / 12MHz = 1us Counts = 50ms / 1us = 50000 InitValue = 65536 - 50000 = 15536 = 0x3CB0代码:
void Timer0_Init(void) { TMOD = 0x01; // 定时器0,16位模式 TH0 = 0x3C; TL0 = 0xB0; ET0 = 1; EA = 1; TR0 = 1; }中颖的某些增强型型号支持 1T/12T 切换,如果工程是从老代码搬过来的,启动代码里没配置这个功能位,实测时间会相差 12 倍。遇到“时间翻倍再翻倍”的诡异现象,先怀疑这个分频位。
2.4 合泰单片机:精简指令集下的定时器与预分频搭配
合泰的 HT66F 系列是精简指令集架构,指令系统和 8051 完全不一样,定时器的寄存器名字虽然变了,但计算逻辑依然绕不开“计数个数 × 计数周期”。
合泰常见定时器有 8 位和 16 位版本,预分频器可选项比较多,这给设计带来不少弹性,也让初值的推算多了一个变量。
比如 Fosc = 4MHz,预分频 = 2,定时器工作频率就是 2MHz,单脉冲周期 0.5us。如果目标是 250us:
Counts = 250us / 0.5us = 50016 位定时器直接写初值:
InitValue = 65536 - 500 = 65036但如果对应的是 8 位定时器,500 又超范围了,把预分频调到 8,定时器工作频率变成 500kHz,单脉冲周期 2us:
Counts = 250us / 2us = 125 InitValue = 256 - 125 = 131合泰的配置过程通常需要先选择时钟源和分频位,再选择计数模式和中断使能,最后写初值。手册里的寄存器表看多了容易晕,但记住自己只需要关注“分频系数”和“计数范围”这两个点就够了。
2.5 松翰单片机:递减计数方式带来的公式差异
松翰的 SN8P 系列同样是精简指令集 8 位单片机,在很多小家电、电动工具、灯控方案里用得很多。松翰的定时器有一个必须注意的细节:不少型号的定时器采用递减计数方式。
递减计数器的初值含义和递增计数器正好相反。初值设为 400,计数器会从 400 往下数,数到 0 产生一次事件,中间经过 400 个计数脉冲。所以 Counts 不再是“满量程减初值”,而是直接等于初值。
举例:Fosc = 4MHz,预分频 = 1,定时器工作频率 4MHz,单脉冲周期 0.25us。目标时间 100us:
Counts = 100us / 0.25us = 400如果是 8 位递减计数器,400 超范围,需要把预分频调大。预分频 = 8 时,单脉冲周期变成 2us:
Counts = 100us / 2us = 50 初值 = 50同样是“目标时间”,同一个芯片的递增定时器和递减定时器,初值写法一个用 (2^{bits} - Counts),一个直接写 Counts,混用的话误差直接会差出一个满量程。拿到松翰的芯片先翻数据手册里的“Up/Down Counter”段落,这个动作不能省。
3. 从目标时间反推预分频系数和初值的方法
3.1 反推初值的完整数学过程
在实际项目中,很少有人直接给一个初值问“溢出时间是多少”,更多时候是反过来的:“我要一个 20ms 的溢出,初值应该写多少?”
反推过程可以拆成四步:
- 确认系统时钟 Fosc;
- 查手册确认定时器位数 bits 和计数方向;
- 根据目标时间选择预分频系数 Prediv;
- 用以下公式计算初值:
- 递增计数器:
Counts = round(T_target × Fosc / Prediv) InitValue = 2^bits - Counts- 递减计数器:
Counts = round(T_target × Fosc / Prediv) InitValue = Counts这里的 round 代表取整。如果 Counts 不是整数,必然存在量化误差,这个误差在后面的设计中要接受,不能假装它不存在。
以 16 位递增定时器为例,Fosc = 6MHz,预分频 = 12,目标 50ms:
定时器工作频率 = 6MHz / 12 = 500kHz 单脉冲周期 = 2us Counts = 50ms / 2us = 25000 InitValue = 65536 - 25000 = 40536 = 0x9E583.2 预分频系数选择的两个约束条件
预分频不是随便选的,选择前先列两个检查项。
第一个约束是计数范围。8 位定时器最多容纳 256 个计数脉冲,16 位最多容纳 65536 个。如果算出来的 Counts 超出了这个范围,就必须调大预分频,把 Counts 压回合法区间。
第二个约束是量化误差。预分频选得越大,单脉冲周期越长,计数值的整数取整误差相对更容易被放大。比如目标时间是 100.5 个脉冲周期,如果单脉冲周期是 10us,取整后误差是 0.5 个周期,也就是 5us;如果单脉冲周期是 1us,取整后误差只有 0.5us。所以在满足计数范围的前提下,预分频选小一点,分辨率更高,误差也更小。
实际操作中我的选择顺序是:先列出所有可用的预分频系数,从最小开始逐个试,算出 Counts,看是否在范围内,再比较误差。不要一上来就选个大分频,误差大得离谱。
3.3 为什么“照抄现成初始化代码”最危险
每个厂商的定时器外设都有自己的“脾气”。同样是 16 位定时器,有的芯片系统时钟不分频直接进定时器,有的默认 12 分频,有的支持 1T/12T 切换,有的定时器分频位在系统时钟控制寄存器里不在定时器控制寄存器里。照抄现成代码等于默认这些隐藏参数都一致,只要有一个不对,时间就偏了。
而且很多教程代码里的初值是针对固定的晶振频率算出来的。比如经典 11.0592MHz 晶振下的 50ms 初值是 0x4C00,这个数字在 12MHz 晶振下完全无效。哪怕只差 1MHz,初值也得重算:
- 12MHz 时,50ms 对应初值 0x3CB0;
- 11.0592MHz 时,机器周期约 1.085us,Counts 约 46080,初值约 0x34E8。
两个初值差距非常明显。凡是“从别处复制来的定时器配置”,我都建议先重新算一遍再烧录。
4. 用溢出公式理解PWM频率、串口波特率和系统延时
4.1 PWM频率本质上就是溢出频率
定时器溢出一次的时间是 T_overflow,PWM 的频率就是 1 / T_overflow。当然,很多单片机有专门的 PWM 外设,周期可以通过周期寄存器直接设置,不需要非用基本定时器溢出不可,但如果你了解基本定时器的溢出频率怎么算,再看 PWM 周期寄存器时思路是相通的。
举个例子:16 位定时器,Fosc = 12MHz,预分频 = 12,定时器时钟 1MHz,目标 PWM 频率 1kHz:
周期 = 1ms Counts = 1ms / 1us = 1000 InitValue = 65536 - 1000 = 64536如果 PWM 外设的周期寄存器是向下计数模式,那周期寄存器直接写 1000 就可以了。可见不管外设名字怎么变,“周期时间 = 计数值 × 计数时钟周期”这个关系永远成立。
4.2 串口波特率:定时器1溢出公式的最好练习
51 系列单片机里,串口波特率经常由定时器 1 的溢出率决定,公式是:
波特率 = 定时器溢出率 / 16 // SMOD = 0 时 波特率 = 定时器溢出率 / 32 // SMOD = 1 时经典例子:晶振 11.0592MHz,12 分频,定时器 1 工作在模式 2(8位自动重载),TH1 = 0xFD。
TH1 = 0xFD = 253 Counts = 256 - 253 = 3 定时器1溢出率 = 11.0592MHz / 12 / 3 = 307200Hz SMOD = 0 时,波特率 = 307200 / 32 = 9600这就是 9600 波特率的由来。很多人只知道“9600 波特率对应 TH1 = 0xFD”,但不知道背后的溢出率计算。一旦换成别的晶振或者别的单片机,这个对应关系就不成立了,必须用公式重算。如果你能把定时器溢出公式玩到这个层面,配置串口时心里会特别踏实。
4.3 用自动重载定时器做稳定系统延时
做裸机系统时,我习惯用一个自动重载定时器做系统 tick,然后靠 tick 计数实现不同长度的延时。这样可以避免用多重循环做软件延时把 CPU 全占死。
以 1ms 系统 tick 为例,Fosc = 12MHz,12分频,16位自动重载:
Counts = 1ms / 1us = 1000 InitValue = 65536 - 1000 = 64536 TH = 0xFC TL = 0x18中断服务函数里只做一个标志位递增,不干活:
volatile unsigned int tick_count = 0; void Timer0_ISR(void) interrupt 1 { tick_count++; }主循环需要延时 20ms 时,记录当前 tick,然后等待 tick 增加 20。这种方案时间基准稳定,不会因为 CPU 执行其他任务而明显漂移,配合状态机写业务逻辑也很顺手。
5. 定时器溢出调试中的常见问题与避坑经验
5.1 实测和计算差很大的四个高频原因
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 实测时间是计算的12倍 | 系统时钟分频模式选错 | 查芯片手册里的时钟树和分频位 |
| 实测时间差不多但慢慢漂移 | 递增/递减方向搞反,或自动重载未开启 | 确认计数方向,检查自动重载配置 |
| 初值写进去后完全乱跳 | 定时器位数理解错 | 确认是8位还是16位,模式0别当16位用 |
| 溢出频率太高导致中断丢失 | 分频过小,中断处理不过来 | 调大分频或降低溢出频率 |
这里要特别强调“自动重载”这个开关。递增定时器如果没开自动重载,第一次溢出时间按初值计算没问题,但溢出后计数器回到 0,第二次溢出的时间就是满量程计数周期,远大于第一次。表现出来就是定时时间“一次对,后面全不对”。
5.2 量化误差不能当作不存在
前面提到的 Counts 取整,必然带来误差。比如目标需要 Counts = 100.5,最后写入 100,单次溢出误差是 0.5 个计数周期。如果是 1us 的计数周期,误差 0.5us 看起来很小,但系统跑 1 小时产生 360 万次溢出,误差会累积到 1.8 秒左右。对于长时间运行的系统计时来说,这个累积量是不能忽略的。
解决思路有两个:一是尽量选让 Counts 接近整数的分频系数;二是用高精度频率晶振,配合重新计算初值,把每次误差压到足够小。就经验来说,精确定时场景优先选择 11.0592MHz 或 12MHz 这种和波特率、时间基准配合友好的晶振,能给后续开发省不少精力。
5.3 中断服务程序的耗时会影响溢出精度
定时器溢出后,CPU 要跳进中断服务函数执行代码。这条中断路径占用的时间虽然短,但如果中断程序里写了太多逻辑,下一次溢出请求就会被挂起,导致事件响应延迟。特别是在多优先级中断系统里,如果其他高优先级中断持续占 CPU,定时器中断会被拉得更晚。
我的做法是:中断服务函数里只做标记、置位、计数等极短操作,所有耗时逻辑放到主循环里处理。这样既能保证定时器事件的及时性,也能让主循环有机会处理更多业务。
5.4 我习惯用的三个验证手段
算完初值,写进代码之后,一定要实测验证。我不太相信“看起来差不多”,因为定时器这种东西,误差是分毫必争的。
第一个办法是 GPIO 翻转法。在中断服务函数里翻转一个空闲 IO 口,然后用示波器量这个 IO 口的频率。如果配置的是 1ms 中断,看到的翻转周期应该是 2ms,也就是 500Hz。这个方法简单直接,不需要额外设备。
第二个办法是逻辑分析仪长时间采样。逻辑分析仪可以连续记录几十秒的波形,适合观察有没有间歇性丢中断或者溢出时间不均匀的问题。
第三个办法是计数法。不开中断实时打印,而是在主循环里定时读定时器计数寄存器的值,通过计数差值反算实际的溢出周期。这种方法适合没有示波器的场景,精度低一些,但定位方向性问题非常有效。
6. 不同品牌单片机定时器配置的对照与复用
6.1 五家厂商定时器计算要素对照表
为了方便以后查用,我把五家厂商在定时器计算时需要确认的要素汇总成一张表,照着这个表逐项确认,基本不会漏东西。
| 品牌 | 典型内核/系列 | 计数方向 | 定时器位数(常见) | 分频方式 | 核心注意点 |
|---|---|---|---|---|---|
| 三星 | S3F系列等 | 以递增为主 | 8位/16位 | 寄存器预分频 | 必须确认实际位数和分频位 |
| 现代 | 8051兼容系列 | 递增 | 16位为主 | 经典51的12分频 | 分频模式可能可选 |
| 中颖 | SH79F系列 | 递增 | 13位/16位/8位 | 经典51模式 | 模式0低5位坑,确认1T/12T |
| 合泰 | HT66F系列 | 递增 | 8位/16位 | 预分频可调 | 寄存器命名与51不同 |
| 松翰 | SN8P系列 | 部分型号递减 | 8位/16位 | 预分频可调 | 递减计数时初值就是计数值 |
这张表只覆盖了常见的系列,具体型号还是要以数据手册为准。把“时钟频率、位数、方向、分频、目标时间”这五个参数写在纸上,整个过程会清晰很多。
6.2 一个让我记忆深刻的移植事故
有一次我在一块中颖芯片上做项目,原来的代码是基于某款 51 内核芯片写的,定时器部分我直接照搬了初值。结果上电之后,整个系统的时间基准全部错乱,串口输出的数据整包乱码,LED 闪烁频率也完全不对。
排查下来发现,原工程用的系统时钟是外部晶振 22.1184MHz,而新板子默认跑内部 RC 振荡器,频率约 12MHz。虽然定时器初始化代码里改过 TH0、TL0,但定时器初值对应的目标时间完全变了,加上串口波特率也没有重新计算,整个系统自然全线崩溃。
从那以后,我养成了一个习惯:拿到任何一块新板子,第一件事就是确认主时钟频率来源,第二件事就是跑一个最小定时器测试程序,通过 GPIO 翻转用示波器实测频率。这两个动作做好,后续的工作基本不会被时间基准坑到。
定时器溢出计算这件事,本质上就是“时钟、分频、计数值”三个变量的算术题。公式不难,难的是在每一片新平台上老老实实确认参数,然后把理论计算和实测结果对上。你只要养成这个习惯,不管以后换三星、现代、中颖、合泰还是松翰,定时器的坑都会少踩一大半。