1. 从“闹钟”到“心脏起搏器”:为什么我们需要通用定时器?
在嵌入式开发的世界里,时间,或者说对时间的精准控制,是一切复杂行为的基础。想象一下,你正在设计一个智能家居的温控系统。它需要每隔5分钟读取一次温度传感器,如果温度超过阈值,就启动风扇;同时,它还要驱动一个LED指示灯,以1Hz的频率闪烁,表示系统正在运行;并且,它需要响应一个外部按键,在按键按下后,去抖动并执行相应操作。这些看似简单的任务——周期性执行、生成特定频率的信号、测量外部事件的时间间隔——如果只用主循环(while(1))配合延时函数来实现,代码很快就会变得臃肿、低效且难以维护。主循环会被各种延时阻塞,无法及时响应其他事件,整个系统的实时性会大打折扣。
这时,通用定时器(General-Purpose Timer)就扮演了系统中那个精准、可靠且多才多艺的“时间管家”角色。它就像微控制器内部一个独立运行的“小闹钟”或“节拍器”,完全独立于CPU核心。你只需要对它进行一番设置,告诉它:“每隔1毫秒响一次”,或者“从这个引脚上信号上升沿开始计时”,它就能在后台默默工作。时间一到或条件满足,它要么通过中断“喊”CPU来处理,要么直接指挥其他硬件(比如直接改变一个引脚的输出电平),完全不需要CPU持续操心。这释放了CPU去处理更复杂的逻辑,实现了真正的并行处理。
因此,理解通用定时器的基本原理,是迈出从“简单单片机编程”到“设计真正嵌入式系统”的关键一步。它不仅仅是配置几个寄存器那么简单,更是理解如何让硬件为你高效工作的思维转变。无论是STM32、GD32、ESP32还是其他任何主流MCU,其通用定时器的核心思想都是相通的。掌握了它,你就掌握了让嵌入式系统“活”起来,并按照精确节奏运行的核心能力。
2. 解剖一个通用定时器:核心部件是如何协同工作的?
要理解定时器如何工作,我们可以把它拆解成几个核心的功能模块。虽然不同厂商的定时器外设名称和细节略有差异,但万变不离其宗,其核心架构通常包含以下部分:
2.1 时钟源:动力的来源
定时器要“走时”,首先需要一个时钟信号作为“心跳”。这个时钟源通常有多个选择:
- 内部时钟(CK_INT):最常见的选择,通常来自系统主时钟(如AHB、APB总线时钟)经过一个预分频器(PSC)后提供。这是定时器最基础、最稳定的工作模式。
- 外部时钟模式1:定时器可以从特定的外部输入引脚(TIx)捕获外部信号的边沿,用这个边沿来驱动计数器。这常用于测量外部脉冲的频率或占空比。
- 外部时钟模式2:使用另一个定时器(Timer)的输出或特定外部触发输入(ETR)作为时钟源。这可以实现定时器之间的级联,构建更复杂的定时或计数系统。
注意:在STM32等基于ARM Cortex-M的芯片中,定时器通常挂载在APB总线下。需要特别注意,APB总线时钟可能会经过一个倍频器(通常x1或x2)后才作为定时器的实际时钟(
TIMxCLK)。在计算定时时间时,务必查阅参考手册,确认最终的定时器输入时钟频率,这是所有计算的基石。
2.2 时基单元:计时的心脏
这是定时器的核心,决定了“时间”是如何产生的。它主要由三个寄存器构成一个流水线:
- 预分频器寄存器(PSC - Prescaler Register):这是一个16位或更宽的寄存器。它的作用是对输入的时钟源进行“降速”。例如,如果定时器输入时钟(
TIMxCLK)是72MHz,你将PSC设置为71,那么实际驱动计数器的时钟频率就变成了72MHz / (71+1) = 1MHz。这里“+1”是因为分频器是从0开始计数的。PSC让你能够处理非常宽的定时范围,从纳秒级到数秒甚至更长。 - 计数器寄存器(CNT - Counter Register):这是一个16位或32位的向上/向下/中央对齐的计数器。它按照预分频后的时钟频率进行计数。每来一个时钟脉冲,CNT的值就增加1(向上计数)或减少1(向下计数)。这是时间流逝的直接体现。
- 自动重装载寄存器(ARR - Auto-Reload Register):这是一个16位或32位的寄存器,它设定了计数器的周期或上限。在向上计数模式下,当CNT的值从0递增到ARR时,产生一个“更新事件”(Update Event),并且CNT自动清零,重新开始计数。这样,一个“定时周期”就完成了。定时周期
T = (ARR + 1) * (PSC + 1) / TIMxCLK。
这三个寄存器共同构成了定时器的“时间基准”。通过灵活配置PSC和ARR,我们可以得到几乎任意想要的定时周期。例如,要产生一个1ms的定时中断,假设TIMxCLK=72MHz,我们可以令(ARR+1)*(PSC+1) = 72000。一种简单的配置是:PSC=71(分频后1MHz),ARR=999(计数1000次),这样每次计数1us,1000次正好1ms。
2.3 输入捕获与输出比较:与外界交互的双手
定时器不仅能自己数数,还能感知外部世界和控制外部世界,这是通过输入捕获和输出比较通道实现的。
- 输入捕获(Input Capture):当指定的外部输入引脚(如TI1)发生跳变(如上升沿)时,定时器会瞬间将当前计数器CNT的值“捕获”并锁存到一个专用的捕获/比较寄存器(CCRx)中。通过连续捕获两个边沿的CNT值,我们就可以计算出这个外部脉冲的周期或高电平宽度。这是测量频率、脉宽、编码器信号的基石。
- 输出比较(Output Compare):定时器会持续将计数器CNT的值与预先设定在捕获/比较寄存器(CCRx)中的值进行比较。当两者匹配时,硬件会自动根据配置做出反应,比如将对应的输出引脚(如OCx)置高、置低、翻转,或者产生中断。这是生成PWM波、单脉冲、特定频率方波的核心机制。
2.4 中断与事件生成:发出信号的“喉咙”
定时器在特定条件满足时,需要通知CPU或触发其他硬件。这通过两类机制实现:
- 中断:当发生更新事件(计数器溢出)、捕获事件、比较匹配事件等时,如果对应的中断使能位被打开,定时器就会向CPU的NVIC(嵌套向量中断控制器)发送一个中断请求。CPU会暂停当前任务,跳转到对应的中断服务函数(ISR)中执行你编写的处理代码。中断需要CPU参与。
- 事件:这是更高效的硬件级联动。当特定条件满足时(如比较匹配),定时器内部会产生一个事件信号,这个信号可以直接触发其他内置硬件模块,如ADC开始转换、DMA进行数据传输,甚至触发另一个定时器。整个过程完全由硬件完成,无需CPU干预,极大地提高了系统的实时性和效率。
3. 核心工作模式深度解析:不止是简单的“定时”
很多人初学定时器,只把它当作一个“定时中断发生器”。这大大低估了它的能力。理解其不同的工作模式,才能将其威力发挥到极致。
3.1 定时与计数模式
这是最基础的模式,但细节值得深究。
- 定时模式:以内部恒定频率时钟驱动计数器。我们的关注点在于时间间隔。通过PSC和ARR的配合,我们可以设定一个非常精确的周期性时间基准。例如,为操作系统提供系统时钟节拍(SysTick),或者为周期性传感器采样提供触发信号。
- 计数模式:计数器由外部引脚上的脉冲边沿驱动。我们的关注点在于脉冲个数。例如,连接一个光电编码器,计数器每收到一个脉冲就加一,从而直接读取旋转的角度或位移。在这种模式下,ARR往往被设置为一个很大的值(如最大值65535),我们关心的是CNT累计的数值。
实操心得:在需要极高精度的定时应用(如生成精准的PWM控制电机)中,要特别注意时钟树的配置。确保定时器的时钟源稳定,并且避免在定时器运行期间动态修改PSC或ARR,这可能导致不可预测的延迟或毛刺。如果必须修改,应使用定时器的“预装载”功能,让修改在下一个更新事件时才生效,以保证当前周期的完整性。
3.2 PWM生成模式:输出比较的终极应用
PWM(脉冲宽度调制)是控制亮度、速度、功率的万能钥匙,而通用定时器是生成PWM的硬件主力。在输出比较模式的基础上,通过设置输出比较模式为“PWM模式1或2”,并配合ARR和CCRx寄存器,就能轻松生成PWM。
- ARR决定了PWM的周期(频率)。
频率 = TIMxCLK / ((PSC+1)*(ARR+1))。 - CCRx决定了PWM的占空比。在PWM模式1下,向上计数时,当CNT < CCRx,输出有效电平(通常为高);当CNT >= CCRx,输出无效电平。因此,
占空比 = CCRx / (ARR+1)。
高级定时器还支持互补输出带死区插入,这是驱动三相电机逆变桥的关键,用于防止上下桥臂直通短路。通用定时器虽然通常没有死区插入硬件,但通过巧妙配置多个通道和输出比较模式,也能模拟实现简单的多路同步PWM。
3.3 输入捕获模式:测量时间的尺子
当需要测量一个未知信号的周期、频率或高电平宽度时,输入捕获模式大显身手。其工作流程通常如下:
- 配置捕获通道为上升沿触发。
- 第一个上升沿到来,硬件瞬间将当前CNT值存入CCR1,并可以触发一个中断。
- 在中断服务程序中,记录下CCR1的值(
Value1),并将捕获边沿改为下降沿(或使能另一个通道的下降沿捕获)。 - 下降沿到来,CNT值被存入CCR2(或另一个CCR),再次触发中断。
- 在中断中记录
Value2。那么,高电平时间 =|Value2 - Value1| * 计数周期。如果需要测周期,则再改回上升沿捕获,获取下一个Value3,周期 =|Value3 - Value1| * 计数周期。
避坑指南:输入捕获的一个经典陷阱是“计数器溢出”。如果被测脉冲宽度很长,可能在两个边沿之间,16位的CNT计数器已经溢出归零了一次或多次。如果不处理,计算出的时间就是错误的。解决方案:在捕获中断中,不仅要读取CCRx的值,还要维护一个软件计数器(如
Capture_Overflow_Count),在定时器的“更新中断”(溢出中断)中对该软件计数器加一。最终计算时间时,公式为:时间 = [ (溢出次数 * (ARR+1)) + 本次捕获值 ] * 计数周期。务必在使能捕获中断前,先使能并清空更新中断标志,以防漏计第一次溢出。
3.4 编码器接口模式:读懂旋转的艺术
这是定时器的一个“杀手级”专用模式,用于直接接口正交编码器。只需将编码器的A、B相信号接到定时器的两个特定输入通道(TI1, TI2),硬件会自动根据两路信号的相位关系,控制CNT进行向上或向下计数。
- 正转时,CNT递增。
- 反转时,CNT递减。
并且,硬件会自动处理4倍频(在A、B相的每个边沿都计数),从而将编码器的物理分辨率提高4倍。你只需要定期去读取CNT的值,就能知道位置和方向的变化量,CPU开销极低。ARR在此模式下通常设置为编码器旋转一圈对应的计数值(如线数*4),配合更新中断,可以方便地计算圈数。
4. 高级特性与实战联动:构建系统级能力
通用定时器不是孤岛,它与MCU内部其他模块的联动,能构建出更强大的系统功能。
4.1 与DMA的配合:实现“零CPU开销”的数据搬运
想象一个场景:你需要用ADC以固定的高采样率(比如1MHz)采集一段音频信号。如果每个采样点都用ADC转换完成中断来读取,CPU将疲于奔命。此时,可以用定时器触发ADC转换,同时将定时器与DMA(直接存储器访问)控制器联动。
- 配置定时器在输出比较模式(或更新事件)下产生一个触发信号(TRGO)。
- 将这个触发信号连接到ADC的触发源。
- 配置DMA,将外设地址设为ADC数据寄存器,内存地址设为你的数据缓冲区。
- 每当定时器触发一次ADC转换,ADC转换完成的数据就会通过DMA自动搬运到内存中。
整个过程,CPU只需要在开始时配置好,并在DMA传输完成中断中处理整块数据即可,实现了后台高速、连续的数据采集。
4.2 定时器级联:扩展时间与创造复杂波形
单个16位定时器,即使PSC设为最大值,其定时范围也有上限。为了获得更长的定时周期,可以将多个定时器级联。例如,将Timer1的更新事件作为Timer2的时钟源。这样,Timer1每溢出一次,Timer2才计数一次。Timer2就成了Timer1的“分频器”,两者结合可以实现天文数字级别的超长定时。
此外,通过一个主定时器(Master)触发多个从定时器(Slave)同步启动,可以生成多路严格同步的PWM波形,这在多相电机控制、数字电源等应用中至关重要。
4.3 输出比较的“单脉冲模式”
这个模式非常实用。它允许定时器在收到一个内部或外部触发信号后,产生一个宽度可编程的单次脉冲。配置步骤通常是:
- 设置定时器为单脉冲模式。
- 配置输出比较通道的极性、CCRx值(决定脉冲宽度)和ARR值。
- 使能定时器,并等待触发信号。
- 触发信号到来后,定时器启动,输出指定宽度脉冲后自动停止。
这常用于生成精确的延时脉冲,或驱动需要单次触发的外部设备。
5. 从原理到配置:一个1kHz PWM与输入捕获的综合实验
理论说得再多,不如动手实践。我们设计一个综合实验,用一个通用定时器同时实现:在通道1上输出一个1kHz、占空比50%的PWM波;同时,用通道2测量一个外部按钮按下的高电平持续时间(消抖后)。
实验设定:MCU为STM32F103,系统时钟72MHz,定时器TIM3。
5.1 步骤一:计算与规划
首先进行核心计算:
- PWM频率1kHz:周期
T = 1/1000Hz = 1ms。 - 我们希望计数器有足够的精度来调节占空比,因此让ARR尽可能大。选择计数器时钟为1MHz,则计数一次为1us。
ARR = 周期 / 计数周期 - 1 = 1ms / 1us - 1 = 999。 - 要得到1MHz的计数器时钟,需对72MHz进行72分频。
PSC = 72MHz / 1MHz - 1 = 71。 - 占空比50%:
CCR1 = (ARR + 1) * 50% - 1 = 1000 * 0.5 - 1 = 499。(注意:有些库函数或硬件设计占空比计算为CCR/(ARR+1),此时CCR直接设为500即可,需根据实际调整) - 输入捕获:我们使用通道2。计数器时钟同样是1MHz,即分辨率1us。假设我们测量按钮按下时间,预计在10ms~2s之间,16位计数器(最大值65535)对应65.535ms,可能不够。因此,必须开启更新中断来处理溢出。
5.2 步骤二:初始化代码与关键注释
以下是基于标准外设库(SPL)风格的核心初始化思路,实际开发中可能使用HAL或LL库,但原理一致。
// 变量定义 volatile uint32_t Button_Press_Start = 0; // 捕获开始时CNT的快照(含溢出) volatile uint32_t Button_Press_Width = 0; // 最终计算的脉宽 volatile uint8_t Is_Captured = 0; // 捕获完成标志 volatile uint16_t Overflow_Count = 0; // 软件溢出计数器 void TIM3_GPIO_Init(void) { // 初始化GPIO // PA6 -> TIM3_CH1 复用推挽输出 (PWM输出) // PA7 -> TIM3_CH2 浮空输入 (输入捕获) // ... GPIO配置代码 } void TIM3_Mode_Config(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; TIM_ICInitTypeDef TIM_ICInitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 时基单元配置 TIM_TimeBaseStructure.TIM_Period = 999; // ARR TIM_TimeBaseStructure.TIM_Prescaler = 71; // PSC TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; // 时钟分频,与滤波相关 TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; // 向上计数 TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); // 2. 输出比较通道1配置为PWM模式1 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 499; // CCR1值,决定占空比 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; // 有效电平为高 TIM_OC1Init(TIM3, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); // 使能预装载,防止修改时产生毛刺 // 3. 输入捕获通道2配置 TIM_ICInitStructure.TIM_Channel = TIM_Channel_2; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; // 初始为上升沿捕获 TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; // 映射到TI2 TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; // 每个边沿都捕获 TIM_ICInitStructure.TIM_ICFilter = 0x08; // 设置滤波器,防抖动。0x08代表8个时钟周期滤波 TIM_ICInit(TIM3, &TIM_ICInitStructure); // 4. 中断配置 // 使能更新中断(用于溢出计数)和捕获/比较2中断 TIM_ITConfig(TIM3, TIM_IT_Update | TIM_IT_CC2, ENABLE); // 配置NVIC NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); // 5. 启动定时器 TIM_Cmd(TIM3, ENABLE); }5.3 步骤三:中断服务函数逻辑
这是整个测量功能的核心,包含了处理计数器溢出的关键逻辑。
void TIM3_IRQHandler(void) { // 1. 处理更新中断(计数器溢出) if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { Overflow_Count++; // 软件溢出计数器加1 TIM_ClearITPendingBit(TIM3, TIM_IT_Update); } // 2. 处理捕获/比较2中断 if (TIM_GetITStatus(TIM3, TIM_IT_CC2) != RESET) { if (Is_Captured == 0) { // 第一次捕获,上升沿 // 在读取CCR2前,先记录当前的溢出状态,这是一个原子操作的关键点 uint32_t temp_overflow = Overflow_Count; uint16_t temp_ccr = TIM_GetCapture2(TIM3); // 计算真正的起始计数值(考虑溢出) Button_Press_Start = (temp_overflow << 16) | temp_ccr; // 合并为32位值 // 改变捕获边沿为下降沿 TIM_OC2PolarityConfig(TIM3, TIM_ICPolarity_Falling); Is_Captured = 1; // 标记已捕获上升沿 } else { // 第二次捕获,下降沿 uint32_t temp_overflow = Overflow_Count; uint16_t temp_ccr = TIM_GetCapture2(TIM3); uint32_t button_press_end = (temp_overflow << 16) | temp_ccr; // 计算高电平时间(单位:微秒) // 注意处理结束值可能小于开始值的情况(因为Overflow_Count可能已清零) // 这里简化处理,假设测量时间内Overflow_Count未发生回绕 Button_Press_Width = button_press_end - Button_Press_Start; // 差值即为计数值 // 转换为微秒:因为计数器时钟是1MHz,1个计数=1us // Button_Press_Width 的值就是以微秒为单位的时间 // 重置状态,准备下一次测量 TIM_OC2PolarityConfig(TIM3, TIM_ICPolarity_Rising); Is_Captured = 0; Overflow_Count = 0; // 也可以不清零,但每次计算需使用差值逻辑 } TIM_ClearITPendingBit(TIM3, TIM_IT_CC2); } }5.4 关键点分析与避坑
- 滤波器配置(TIM_ICFilter):对于机械按钮,必须配置输入滤波器(上述代码中设为0x08),这要求信号稳定8个时钟周期才被认为有效,可以滤除大部分抖动。这是硬件消抖,比软件延时消抖更可靠、更节省CPU。
- 溢出处理的原子性:在捕获中断中读取
Overflow_Count和CCRx的顺序至关重要。如果先读CCRx,再读Overflow_Count,但在这之间恰好发生了一次更新中断导致Overflow_Count增加,就会造成数据不一致(CCRx是旧的,Overflow_Count是新的)。更稳健的做法是:在进入捕获中断后,先连续读取两次Overflow_Count,如果值相同,说明读取期间没有发生更新中断,这个值是有效的;如果不同,则重新读取。或者,可以暂时关闭更新中断,读取后再打开,但会影响溢出计数的准确性。上面的简化代码假设测量时间较短,溢出计数器不会在两次读取间变化。 - 32位时间计算:我们将
Overflow_Count(软件16位)和CCRx(硬件16位)合并成一个32位的绝对时间戳,这是处理长周期测量的标准方法。计算脉宽时,直接对32位时间戳做减法。 - 预装载(Preload):对于PWM的CCR和ARR寄存器,务必使能预装载(
TIM_OCxPreloadConfig和TIM_ARRPreloadConfig)。这样,你修改的值会先写入一个缓冲寄存器,直到下次更新事件时才真正生效,防止在一个PWM周期中间改变占空比导致输出畸形脉冲。
通过这个综合实验,你将亲身体会到通用定时器如何同时、独立地处理输入和输出任务,以及如何处理实际工程中的细节问题,如消抖、溢出和原子操作。这远比单纯配置一个定时中断要复杂,但也正是这些细节,构成了稳定可靠的嵌入式系统的基石。