第三章 定时器基础与应用
定时器这东西,说白了就是单片机的「心跳」。我做了这么多年嵌入式开发,可以负责任地告诉你——谁掌握了定时器,谁就掌握了嵌入式系统的节奏。今天咱们就把系统滴答定时器、通用定时器、高级定时器,还有编码器接口模式,一次性讲透。
本章核心脉络:
- SysTick:操作系统的「节拍器」
- 通用定时器:PWM输出 + 输入捕获
- 高级定时器:互补PWM + 死区时间
- 编码器接口:电机位置反馈
3.1 系统滴答定时器(SysTick)—— 操作系统的「节拍器」
SysTick 是 Cortex-M 内核自带的 24 位递减计数器。你想想看,为什么每个 ARM 芯片都有它?因为它是为操作系统量身定做的。
我个人习惯,不管跑不跑 RTOS,都会先初始化 SysTick。为什么?因为它能提供一个精准的 1ms 时基,做延时、做超时判断都特别方便。
我的经验:在裸机开发中,我经常用 SysTick 做「软件定时器池」。一个中断服务函数里轮询多个标志位,比用硬件定时器挨个配置省事多了。
SysTick 寄存器一览
| 寄存器 | 功能 | 关键位 |
|---|---|---|
| SYST_CSR | 控制与状态 | ENABLE(0)、TICKINT(1)、CLKSOURCE(2) |
| SYST_RVR | 重装载值 | 24位,决定中断周期 |
| SYST_CVR | 当前计数值 | 读清零,写清0 |
| SYST_CALIB | 校准值 | 厂家提供,一般不用 |
配置其实很简单。假设系统时钟 72MHz,想要 1ms 中断一次:
void SysTick_Init(void)
{
// 1. 关闭SysTick,确保配置期间不产生意外中断
SYST_CSR = 0x00;
// 2. 设置重装载值:72MHz / 1000 = 72000
SYST_RVR = 72000 - 1; // 注意:减1是因为从0开始计数
// 3. 清空当前计数值
SYST_CVR = 0;
// 4. 使能:使用处理器时钟 + 使能中断 + 启动定时器
SYST_CSR = 0x07; // CLKSOURCE=1, TICKINT=1, ENABLE=1
}
我曾经踩过的坑:有次把 SYST_RVR 写成了 72000,结果中断周期变成了 1.0000139ms。虽然误差很小,但在做高精度时间同步时,累积误差会越来越大。记住:重装载值 = 时钟频率 / 目标频率 - 1。
中断服务函数怎么写?
volatile uint32_t SysTick_Count = 0;
void SysTick_Handler(void)
{
SysTick_Count++;
// 这里可以放你的轮询任务
// 比如:每10ms扫描一次按键
if (SysTick_Count % 10 == 0) {
Key_Scan();
}
}
// 毫秒延时函数
void delay_ms(uint32_t ms)
{
uint32_t start = SysTick_Count;
while ((SysTick_Count - start) < ms);
}
嗯,这里要注意:SysTick_Count 一定要用 volatile 修饰,否则编译器优化后会出大问题。我见过有人 debug 时正常,release 版就死机,查了半天发现是 volatile 丢了。
3.2 通用定时器——PWM输出与输入捕获
通用定时器比 SysTick 灵活得多。它通常有 4 个独立通道,每个通道都可以配置成输出比较或输入捕获。说白了,输出比较就是定时器到了某个值就翻转引脚,输入捕获就是记录外部信号跳变时的计数值。
3.2.1 PWM 输出——让 LED 呼吸起来
PWM 的核心就三个参数:频率、占空比、极性。我建议你先把频率定下来,再调占空比。
void TIM_PWM_Init(void)
{
// 以TIM2为例,输出PA0
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
// 配置PA0为复用功能AF1(TIM2_CH1)
GPIOA->MODER |= GPIO_MODER_MODER0_1; // 复用模式
GPIOA->AFR[0] |= 0x01; // AF1
// 定时器配置
TIM2->PSC = 72 - 1; // 72MHz / 72 = 1MHz
TIM2->ARR = 1000 - 1; // 1MHz / 1000 = 1kHz PWM频率
// 通道1配置为PWM模式1
TIM2->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2; // PWM模式1
TIM2->CCMR1 |= TIM_CCMR1_OC1PE; // 预装载使能
TIM2->CCER |= TIM_CCER_CC1E; // 使能输出
// 设置占空比50%
TIM2->CCR1 = 500;
// 使能定时器
TIM2->CR1 |= TIM_CR1_CEN;
}
// 动态调整占空比
void PWM_SetDuty(uint16_t duty)
{
if (duty > 1000) duty = 1000;
TIM2->CCR1 = duty;
}
我的习惯:PWM 频率选 1kHz 以上,人眼就看不到闪烁了。做电机控制时,我一般用 10kHz~20kHz,既能避免噪音,又能保证控制精度。
3.2.2 输入捕获——测量脉冲宽度
输入捕获说白了就是「守株待兔」。定时器一直在跑,当外部信号跳变时,硬件自动把当前计数值锁存到捕获寄存器里。
volatile uint32_t IC_Value1, IC_Value2;
volatile uint8_t IC_Complete = 0;
void TIM_IC_Init(void)
{
// 配置TIM3_CH1 (PA6) 为输入捕获
RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;
TIM3->PSC = 72 - 1; // 1MHz计数
TIM3->ARR = 0xFFFF; // 最大计数值
// 通道1配置为输入捕获,上升沿触发
TIM3->CCMR1 |= TIM_CCMR1_CC1S_0; // 映射到TI1
TIM3->CCER |= TIM_CCER_CC1E; // 使能捕获
TIM3->CCER &= ~TIM_CCER_CC1P; // 上升沿
// 使能中断
TIM3->DIER |= TIM_DIER_CC1IE;
NVIC_EnableIRQ(TIM3_IRQn);
TIM3->CR1 |= TIM_CR1_CEN;
}
void TIM3_IRQHandler(void)
{
if (TIM3->SR & TIM_SR_CC1IF) {
TIM3->SR &= ~TIM_SR_CC1IF;
if (!IC_Complete) {
IC_Value1 = TIM3->CCR1; // 第一次捕获
// 切换为下降沿
TIM3->CCER |= TIM_CCER_CC1P;
IC_Complete = 1;
} else {
IC_Value2 = TIM3->CCR1; // 第二次捕获
// 切换回上升沿
TIM3->CCER &= ~TIM_CCER_CC1P;
// 计算脉宽
uint32_t pulse = (IC_Value2 > IC_Value1) ?
(IC_Value2 - IC_Value1) :
(0xFFFF - IC_Value1 + IC_Value2);
// pulse 的单位是 1us
IC_Complete = 0;
}
}
}
我曾经遇到的坑:测量低频信号时,如果计数值溢出了怎么办?我的做法是开启定时器的更新中断(UIE),在溢出中断里记录溢出次数。这样就能测量任意宽度的脉冲了。
3.3 高级定时器——互补PWM与死区时间
高级定时器是通用定时器的「Pro Max」版本。它多了什么?互补输出、死区插入、刹车功能。这些在电机控制、逆变器、开关电源里是标配。
3.3.1 互补PWM——H桥驱动的灵魂
你想想看,驱动一个 H 桥,上管和下管不能同时导通,否则就是短路。互补 PWM 就是让上下管交替导通,中间留一个「死区时间」。
void TIM1_ComplementaryPWM_Init(void)
{
// TIM1是高级定时器,挂在APB2上
RCC->APB2ENR |= RCC_APB2ENR_TIM1EN;
TIM1->PSC = 0; // 不分频
TIM1->ARR = 7200 - 1; // 10kHz PWM (72MHz/7200)
// 通道1配置为PWM模式1
TIM1->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2;
TIM1->CCMR1 |= TIM_CCMR1_OC1PE;
// 使能主输出和互补输出
TIM1->CCER |= TIM_CCER_CC1E; // CH1输出
TIM1->CCER |= TIM_CCER_CC1NE; // CH1N互补输出
// 设置死区时间
// BDTR寄存器的DTG[7:0]控制死区
// 假设系统时钟72MHz,死区时间 = DTG * 1/72MHz
TIM1->BDTR |= 72; // 死区时间 = 1us
// 使能刹车和死区寄存器
TIM1->BDTR |= TIM_BDTR_MOE; // 主输出使能
TIM1->CR1 |= TIM_CR1_CEN;
}
死区时间怎么选?
- MOSFET 驱动:一般 100ns ~ 500ns
- IGBT 驱动:需要 1us ~ 5us
- 继电器驱动:可以到 10ms
选小了会烧管子,选大了效率低。我一般留 20% 的余量。
3.3.2 刹车功能——紧急停机的保障
高级定时器还有一个很贴心的功能:刹车输入(Break Input)。当检测到故障信号时,硬件自动把 PWM 输出强制拉到安全电平。
// 配置刹车功能
TIM1->BDTR |= TIM_BDTR_BKE; // 使能刹车输入
TIM1->BDTR |= TIM_BDTR_BKP; // 刹车输入高电平有效
TIM1->BDTR |= TIM_BDTR_AOE; // 自动输出使能(刹车解除后恢复)
// 刹车时的输出状态
// 可以配置为:高阻态、强制高、强制低
TIM1->BDTR |= TIM_BDTR_OSSI; // 空闲时输出使能
TIM1->BDTR |= TIM_BDTR_OSSR; // 运行时空闲状态
我的经验:刹车功能一定要接硬件故障信号,比如过流保护、过温保护。不要只依赖软件判断,硬件刹车比软件中断快至少一个数量级。
3.4 编码器接口模式——电机的位置「眼睛」
编码器接口模式,说白了就是让定时器自动处理编码器的 A、B 相信号。你不需要写代码去判断正反转,硬件帮你算好了。
工作原理
编码器接口模式使用 TI1 和 TI2 两个输入通道。定时器根据两路信号的相位差,自动决定是加计数还是减计数。
| 信号状态 | 计数方向 | 说明 |
|---|---|---|
| A 领先 B 90° | 递增 | 正转 |
| B 领先 A 90° | 递减 | 反转 |
| A 和 B 同时变化 | ×2 或 ×4 计数 | 分辨率翻倍 |
void TIM_Encoder_Init(void)
{
// 以TIM4为例,PB6(TIM4_CH1), PB7(TIM4_CH2)
RCC->APB1ENR |= RCC_APB1ENR_TIM4EN;
TIM4->PSC = 0; // 不分频,直接计数
// 配置编码器模式:TI1和TI2都计数(×4模式)
TIM4->SMCR |= TIM_SMCR_SMS_0 | TIM_SMCR_SMS_1; // 编码器模式3
// 配置输入滤波
TIM4->CCMR1 |= TIM_CCMR1_IC1F_0 | TIM_CCMR1_IC1F_1; // 8个采样点滤波
TIM4->CCMR1 |= TIM_CCMR1_IC2F_0 | TIM_CCMR1_IC2F_1;
// 使能输入
TIM4->CCER |= TIM_CCER_CC1E;
TIM4->CCER |= TIM_CCER_CC2E;
// 设置自动重装载为最大值
TIM4->ARR = 0xFFFF;
TIM4->CR1 |= TIM_CR1_CEN;
}
// 读取编码器位置
int16_t Encoder_GetPosition(void)
{
return (int16_t)TIM4->CNT;
}
// 清零位置
void Encoder_ResetPosition(void)
{
TIM4->CNT = 0;
}
我曾经踩过的坑:编码器线太长,信号有毛刺,导致计数不准。后来加了硬件滤波(RC 低通)和软件滤波(连续采样三次取中值),问题才解决。另外,编码器接口的 CNT 寄存器是 16 位的,记得用 int16_t 读取,否则正反转会出问题。
速度测量——M法 vs T法
测速度有两种常用方法:
- M法(测频法):固定时间窗口内计数。适合高速场景。
- T法(测周法):测量两个脉冲的时间间隔。适合低速场景。
// M法测速:每100ms读取一次计数值
uint16_t last_cnt = 0;
uint16_t speed = 0;
void Speed_Measure_M_Method(void)
{
uint16_t current_cnt = TIM4->CNT;
uint16_t delta = current_cnt - last_cnt;
last_cnt = current_cnt;
// 速度 = 脉冲数 / 时间 / 编码器线数 × 60 (RPM)
// 假设编码器1000线,时间窗口100ms
speed = (delta * 600) / 1000; // 单位:RPM
}
我的建议:实际项目中,我通常把 M 法和 T 法结合起来用。高速用 M 法,低速自动切换为 T 法,这样全速域都能保证精度。
好了,定时器的核心内容就这些。从 SysTick 的系统节拍,到通用定时器的 PWM 和捕获,再到高级定时器的互补输出和编码器接口,每个模块都是工业控制里的「基本功」。你把这些吃透了,大部分电机控制、信号测量的场景都能应对。
记住我的一句话:定时器不是万能的,但没有定时器是万万不能的。多动手写代码,多拿示波器看波形,比看十遍文档都管用。