嵌入式开发里定时器/计数器可以说是我打交道最多的外设没有之一。很多时候我把项目拆开来看核心功能无非就是“在正确的时间做正确的事”什么时间翻转电平、什么时间采集传感器、什么时间发送数据背后基本都躲不开定时器。如果是计数场景比如测速、计量脉冲个数、读取编码器位置那就得靠计数器模式。老读者可能知道我在这个系列前面已经写过不少外设但定时器/计数器始终值得单独拿一篇来重点聊因为它既是基本功也是很多同学从裸机思维迈向复杂系统设计时的一道分水岭。这篇内容适合正在学嵌入式、刚接触单片机定时器外设的开发者也适合已经会点灯但搞不明白预分频和自动重装载关系的朋友。我会从定时器/计数器到底是什么、有哪几种核心工作模式、寄存器怎么配、实际调试中会踩哪些坑这四个层面展开尽量把原理和实操揉在一起讲保证你能拿去直接对着自己的板子验证。如果你手头有一块常见的32位开发板这篇文章的效果会更好因为我会连配置代码的思路都讲清楚而不是只给一段寄存器操作。1. 定时器/计数器到底能干什么先想清楚这几点1.1 为什么几乎所有嵌入式项目里都有定时器说一句有点绝对的话只要一个嵌入式系统需要“和时间沾边”的功能就离不开定时器。比如LED闪烁你可以用延时函数做但延时函数会让CPU一直空转期间什么都干不了而定时器中断方式可以让CPU在计数到达某个值时自动跳进中断服务函数平时该干嘛干嘛。再比如电机转速控制你需要输出周期和占空比都可调的PWM波这个波形的时基就是由定时器提供的。还有通信协议里常用的超时判断比如等待一个外部事件如果100ms内没等到就报错这也需要定时器来计时。从工程角度看定时器/计数器解决的核心问题是“精准的时间基准”和“脉冲事件的统计”。CPU的主频再高靠软件循环数指令周期来计时总是不可靠的因为中断、分支预测、编译器优化都会引入不确定性。而定时器是硬件电路独立工作的只要时钟源稳定计数器递增或递减的速度就是确定的中断触发时刻也是确定的。这就是为什么我们说定时器让嵌入式系统从“轮询”进化到“事件驱动”实时性和CPU利用率都得到了质的提升。1.2 定时器和计数器到底是不是同一个东西我经常被问到定时器就是计数器吗严格说它们本质上是同一个硬件单元区别只在于计数脉冲的来源。如果计数脉冲来自芯片内部的固定频率时钟那它计的是“时间”所以叫定时器如果计数脉冲来自外部引脚上的输入信号比如光电编码器、流量计脉冲、按键按下的电平变化那它计的是“事件次数”所以叫计数器。这个差别在实际编程里非常关键。你在配置寄存器时要决定时钟源选内部时钟还是外部时钟一旦选错定时器的表现会完全不符合预期。举个例子我用某款常用32位MCU做转速测量把霍尔传感器的输出接到定时器的外部时钟输入引脚配置成外部时钟模式计数器每来一个脉冲就加一然后配合一个固定时间窗口比如1秒窗口结束读取计数值乘以60就能得到转速。如果这时候我误配成内部时钟计数器跑得飞快值根本不可控整个测量结果就是一团乱麻。另外很多定时器还支持“外部触发控制”也就是不只有内部时钟和外部时钟两种选择还能让外部信号决定定时器何时启动、何时停止。这类功能在做精准同步采样时很有用比如让定时器在收到外部触发信号后延迟一段时间再启动ADC采样。理解到这一层你就算真正抓住定时器/计数器的骨架了。2. 核心工作模式我把这四个搞懂后面写代码顺手多了2.1 定时模式与自动重装载最基础也最常用的组合定时器最基本的用法就是让它从0开始每个时钟脉冲加一加到我们设定的值就产生溢出事件然后继续下一个周期。这个过程里有两个关键参数预分频器和自动重装载值。预分频器的作用是把输入时钟先除以一个系数降低计数频率自动重装载值则决定计数器要数到多少才算一个周期。打个比方就像你用秒表给运动员计时预分频器相当于把“每秒钟滴答1000下”的精密时钟调成“每秒钟滴答1下”自动重装载值就是你要等多少滴答才按一次暂停键。实际项目中我常常把预分频器和自动重装载值配合起来凑出一个精确的中断频率。假设芯片时钟是72MHz我想产生1kHz的中断那可以先分频到1MHz再设自动重装载值为999这样计数器每数1000下到1ms触发一次。这里有两点值得注意第一自动重装载寄存器存的是“计数值减一”因为计数器是从0开始数的第二预分频器同样有这个问题实际分频系数等于寄存器值加1。很多人第一次调定时器算出频率总差一点点多半就是忘了这个加一。自动重装载模式下计数器到达设定值后会自动清零重新开始所以中断是周期性的。如果关掉自动重装载定时器就变成“单次模式”数到设定值后停止适合做一次性延时或者定时关机这类功能。我在做电池供电设备时经常用单次模式让系统定时唤醒一次处理完数据再睡回去既省电又简单。2.2 PWM输出与死区补偿驱动电机和调光都靠它PWM也就是脉宽调制本质上是让定时器输出一个频率固定、高电平时间可调的方波。普通模式下定时器的计数器和某个通道的比较寄存器不断进行比较计数值小于比较值时输出高电平大于时输出低电平这样占空比就由比较值控制。频率则由周期决定。我最早用PWM调LED亮度只改一个比较寄存器的值亮度就能从0平滑变到100%非常直观。但如果你要驱动电机或做电源控制仅仅能输出PWM还不够还要考虑死区时间。死区是指互补输出的两个通道在切换时刻意插入一段两边都关闭的时间防止上桥臂和下桥臂同时导通造成短路。这个时间通常几微秒需要用定时器的死区发生器来配置。我有一次调试H桥电路没设置死区结果电机一转就产生很大的尖峰电流后来把死区时间调到1微秒波形立刻干净了。死区时间不是越长越好太长了会降低输出效率具体取多少要看功率器件的关断延迟数据手册上一般都有推荐值。PWM模式还有一个容易忽略的点极性配置。同样是输出占空比为50%的方波你可以让高电平在计数器上升阶段输出也可以在下降阶段输出。如果外部电路是高电平使能极性设反了轻则电机反转重则烧驱动板。我调试陌生板卡时会先用示波器确认输出波形极性再去排查后面的问题这习惯帮我省了不少事。2.3 输入捕获与脉宽测量测频率、测占空比都能搞定输入捕获模式是定时器用来“读外部信号”的利器。原理是当外部引脚出现指定的边沿上升沿或下降沿时定时器会把当前的计数值锁存到捕获寄存器里同时触发一个捕获事件。你只需要记录两次捕获事件的计数值之差再除以计数频率就能算出两个边沿之间的时间。用这个功能测PWM波形的频率和占空比非常好用。比如测一个遥控信号的脉宽先等上升沿捕获记录计数值再等下降沿捕获记下差值这就是高电平持续时间再等下一个上升沿算出一个完整周期高电平时间除以周期就是占空比。整个过程全部由硬件完成CPU不需要不停检查引脚电平精度远高于轮询采样。我做过一个无人机遥控接收机解析程序PPM信号脉宽精度要求很高用输入捕获配合DMA批量读取捕获值实测误差能控制在几十纳秒级别。输入捕获还有个进阶玩法捕获时顺便触发中断在中断里切换捕获边沿。比如先捕获上升沿中断里把边沿改成下降沿这样就能连续测量多个脉冲的宽度。不过要注意中断执行需要时间如果信号频率很高中断里做的事就不能太多否则会漏掉捕获。这时候可以考虑用DMA把捕获值批量搬到内存等一批数据接收完再统一处理。2.4 编码器接口模式电机位置和转速测量省心方案很多主流MCU的定时器还集成编码器接口模式专门用来读取正交编码器的A、B两路信号。正交编码器输出两路相位差90度的方波正转时A相超前B相反转时B相超前A相。定时器的编码器模式可以自动根据两路信号的边沿组合判断方向并让计数器随着转动而递增或递减。这个功能让电机测速变得非常简单只需要把A、B两路接到定时器的两个输入通道配置好模式计数器就自动跟随电机位置了。我在做轮式机器人的时候左右轮各装一个带编码器的直流电机用两个定时器的编码器模式读取位置再用PID控制转速。编码器模式省去了外部方向判断电路固件里也不需要额外处理旋转方向直接从计数器的符号就能知道电机是正转还是反转。这里有一个关键配置计数范围。编码器计数可以选择在0到设定最大值之间循环也可以到顶后溢出回绕。需要记录圈数或者绝对位置时我更习惯用循环计数同时在定时器更新中断里维护一个“圈数变量”把计数器回绕的次数累计下来这样就能获得很大范围的绝对位置。3. 寄存器配置的完整实操我以常见32位定时器为例带你走一遍3.1 初始化流程与时钟使能顺序错了会直接卡死做定时器编程第一步不是直接写控制寄存器而是打开定时器外设的时钟。大部分MCU的外设时钟默认是关闭的这是为了省电。如果你没使能时钟就去访问定时器寄存器读出来的值往往是0写进去也不会生效甚至可能触发硬件错误。时钟使能通常在芯片的时钟管理寄存器里操作一般只需一行代码。我看到不少新手在初始化顺序上栽跟头先配置GPIO再配置定时器结果定时器怎么都不工作最后发现只是忘了开定时器时钟。第二步是配置定时器的时基。时基子单元一般包括预分频寄存器、自动重装载寄存器、计数方向控制位和时钟分频控制。你要做的是先把定时器关闭然后在清零状态下写入这些参数最后再启动。千万不要在定时器正在计数的时候随意修改预分频和自动重装载值虽然有些芯片支持影子寄存器在更新事件时再装载但如果你没搞清楚触发时机很容易出现第一个周期异常。第三步是配置中断或DMA。如果只是软件轮询标志位那不用开中断但绝大多数实际场景需要定时器自动通知CPU所以要使能中断源并编写中断服务函数。中断服务函数里要记得清除中断标志位否则会一直重复进入中断。这是我见过最频繁的低级错误各位一定要养成“进中断第一件事查标志处理完清标志”的习惯。3.2 预分频与自动重装值的计算精度和范围要一起考虑预分频器和自动重装载值并不是随便填两个数就能用的。工程上你会遇到两类需求要么已知期望的中断频率算寄存器值要么已知寄存器的分频和重装值算实际频率。前者的标准步骤是先确定定时器输入时钟然后选一个合适的预分频系数让计数频率至少比期望频率大一个数量级最后用期望频率倒推自动重装值。我举个例子芯片定时器输入时钟是80MHz我想要一个100Hz的中断也就是10ms周期。如果直接把预分频设为79输入被分成1MHz那么自动重装值就是9999实际中断频率就是100Hz。为什么分母是80而不是79因为寄存器值是减一后的结果。同样的自动重装值9999表示计数10000次。这套“寄存器值期望值-1”的规则简直贯穿整个定时器编程记牢了能省很多排查时间。但精度和范围有时候会互相打架。如果你需要非常精细的时间分辨率比如纳秒级就必须用小的预分频值这会导致计数频率很高自动重装值也会很大。如果定时器寄存器只有16位最大只能数到65535那它能表示的最大周期就受限。比如计数频率1MHz16位计数器最大周期约65.535毫秒想定时1秒就装不下了。这时有两个解决办法一是在中断里用软件变量累加每次中断加1到了一定次数再触发真正的业务逻辑二是换用32位定时器。我倾向于第一种因为很多低成本芯片只有16位定时器但软件累加方案完全能满足大部分需求。3.3 中断与DMA联动让定时器成为系统的“节拍器”定时器中断是整个系统的节拍器但如果在中断里写太多代码系统的实时性就会被破坏。一个常见的设计原则是中断服务函数里只做“置标志位、读寄存器、数据搬运”这类轻量操作把复杂处理放到主循环或更高优先级的任务里。比如用定时器做1ms系统心跳中断里只对一个全局变量加1主循环去检查这个变量是否到达某个阈值。这样即使中断频率很高CPU也能从容应对。DMA和定时器配合能进一步释放CPU。以PWM输出为例如果你想输出一段正弦波查表数据可以配置定时器的更新事件触发DMA把内存中预先算好的正弦表按顺序搬运到比较寄存器里不需要CPU逐个修改DMA搬完后再产生传输完成中断。这样CPU只需要在初始化时启动DMA后续就不用管了。另一个典型是采样ADC完成信号触发定时器捕获或DMA搬运实现定时采样MCU的负载可以压得非常低。不过DMA和定时器配合的坑也很多。DMA通道的触发源要和定时器事件对应正确外设地址增减方向要设置对数据宽度要和寄存器长度匹配。我在一次音频播放项目中用DMA把正弦表搬到PWM比较寄存器结果一直没声音排查半天发现是DMA传输宽度设成了8位而比较寄存器是16位每次只写了一部分。把宽度改成16位后声音立刻出来了。这类问题光靠读程序很难发现必须对照寄存器手册逐项核查。4. 实战中的坑与排查技巧这些都是我打着瞌睡调出来的经验4.1 “定时不准”的典型原因别一上来就怀疑硬件定时器输出频率和计算值不一致是非常高频的问题。如果你确认预分频和自动重装都算对了频率还是不对我建议按下面的顺序排查。先看时钟树定时器输入时钟未必等于CPU主频很多芯片的APB总线分频器会和定时器时钟有倍频关系。我就见过一个项目CPU主频从外部晶振倍频而来APB预分频为1时定时器时钟等于PCLK但APB预分频大于1时定时器时钟会变成PCLK的两倍如果没注意这一点所有时间都偏了一倍。其次检查预分频值有没有在运行时被意外修改。有些调试器在暂停程序时会修改外设寄存器导致频率变化这种问题特别隐蔽。我的做法是把定时器参数定义成常量在初始化时一次性写入调试过程里不要用手动修改寄存器的方式来偷懒。最后检查中断响应延迟。如果定时器中断被更高优先级的中断长时间阻塞中断标志置位了但CPU进不了服务函数实际触发周期就会变长。这在极端负载下可能造成时间漂移。解决方案是把定时器中断优先级调高一点或者改为使用硬件事件触发DMA减少CPU参与环节。4.2 定时器冲突与资源分配芯片就那么多定时器怎么省着用一个项目里往往有多个定时器需求系统心跳要一个电机PWM要一个测速编码器要一个串口超时要一个。但芯片定时器资源是有限的拿常见的MCU来说可能只有两三个通用定时器加若干个基本定时器。如何分配这些资源很考验规划能力。我通常会把“固定周期不可中断”的功能放在基本定时器上比如系统心跳把需要多个通道输出或输入捕获的复杂功能放在通用定时器上。PWM输出和编码器输入通常各占一个完整定时器因为他俩都不太方便和其他功能共享。如果你有多路PWM但每路频率相同可以用同一个定时器的不同通道这样只需配一次时基。如果频率都不同那基本就得单独占用定时器了。还有一个小技巧如果你只需要一个“较长延时”不一定非用硬件定时器可以用系统滴答定时器或者RTC来兜底把宝贵的通用定时器留给真正需要精确捕获输出的场景。4.3 数据手册与调试建议不会查手册永远调不好定时器我见过很多人遇到定时器配置问题就网上搜代码搜到一段类似的就复制改改。这个方法偶尔能跑通但一旦出现和网上教程不同型号的芯片就完全抓瞎。嵌入式开发最核心的能力其实是查数据手册。定时器章节通常有寄存器描述、时序图、示例配置和应用笔记哪怕你没记住寄存器名也能对着手册快速查到每一位的含义。调试定时器时有一个工具比示波器还基础就是逻辑分析仪。有些定时器的PWM输出频率很低示波器看波形没问题但频率稍高或者占空比有微小变化时逻辑分析仪可以直观看到每一路信号的时间关系。如果条件允许可以把定时器的更新事件映射到一个空闲的引脚上在示波器上看中断的实际周期这一步能迅速判断到底是配置算错了还是别的外设干扰。我做定时器调试时通常会在初始化后先让定时器“裸跑”用示波器确认波形没问题再接入中断和业务代码排错范围会缩小非常多。5. 最后再分享一个我自己比较受用的配置思路5.1 从需求倒推参数先写注释再写代码很多新手配置定时器喜欢先抄初始化代码再去调参数这样容易把参数的意义搞混。我现在习惯在写代码前先把需求写清楚比如“需要一个1ms中断用于系统心跳需要一个20kHz PWM用于驱动蜂鸣器占空比范围0到100%”。然后根据这些描述在代码注释里列出输入时钟频率、目标频率、预分频寄存器值、自动重装值一切确认后再动手配置。这个习惯帮我避免了很多低级错误。因为一旦你先把计算过程写出来就会发现“目标频率1000Hz输入时钟72MHz分频到1MHz重装值999”这种推导是清晰且可验证的。相反如果你直接对着寄存器文档填数字就算当时对了过两个星期回来看代码自己也看不懂为什么填这个值。可维护性在这种“一次配置长期运行”的外设初始化代码里非常重要。5.2 多用定时器的回调机制和抽象层别把固件写成一坨如果你做的项目只有一个源文件那定时器怎么配置都无所谓。但项目一大比如需要有驱动层、中间层和应用层定时器就不能每个模块各配一套寄存器了。我一般会把每个定时器的初始化、中断处理、回调注册做成一整套小型驱动接口。上层模块只需要调用“注册一个1ms回调函数”或者“设置PWM占空比”这种接口完全不用关心底层是哪个定时器、哪个通道、哪个寄存器。这么做的好处非常明显切换芯片平台时应用层代码几乎不用改只要把驱动层重新实现一遍即可。而且调试时可以根据回调函数的执行频率来判断系统调度是否正确不用到处翻寄存器代码。当然抽象层不是越厚越好对于非常简单的小项目直接操作寄存器反而更直接高效。我建议宁可在工程结构上多斟酌也不要一上来就把所有外设都包一层复杂框架。5.3 以真实项目经验收尾定时器是基础更是系统思维的起点我在实际项目中体会最深的一点是定时器/计数器并不只是“一个外设”它很像嵌入式系统的时间骨架。你把定时器设计好了系统的心跳就稳了PWM输出、采样节拍、通信超时、电机闭环控制全都跟着稳了。相反如果定时器配置混乱、中断拥堵、资源抢夺那上层功能写得再漂亮也会出各种偶发问题。我踩过的坑里“定时不准”和“中断卡死”算是两个重点基本都是时钟树没吃透、中断里干活太多、寄存器位没查清这几类原因。如果这篇文章能帮你少走一点弯路我自己的踩坑也算值得了。最后建议大家动手写一个综合练习用定时器输出一路PWM同时用另一个定时器输入捕获测量这路PWM的频率和占空比再把测量结果通过串口打印出来。这个练习虽然代码量不大但覆盖了定时器的定时、比较输出、捕获输入、中断、串口交互几乎所有基础知识点做完一遍你对定时器的理解就扎实了。