4、定时器与PWM:系统滴答定时器、通用定时器配置、PWM输出与呼吸灯、输入捕获与频率测量
定时器这东西,可以说是嵌入式系统的「心跳」。没有它,你的单片机就像个没戴手表的程序员——完全不知道时间过了多久。我刚开始学嵌入式那会儿,总觉得定时器不就是个计数器嘛,有啥好学的?直到做第一个带PWM的项目,电机死活转不起来,我才老老实实回去啃手册。
这一章,咱们就把定时器这块硬骨头啃下来。从系统滴答定时器开始,再到通用定时器,最后玩转PWM和输入捕获。嗯,内容不少,但都是实战干货。
4.1 系统滴答定时器(SysTick)
SysTick是Cortex-M内核自带的定时器,说白了就是个24位的递减计数器。它最大的好处是——所有Cortex-M芯片都有,代码移植性极强。
核心特点:
- 24位递减计数器,从设定值减到0
- 自动重装载,无需手动干预
- 可产生中断,用于操作系统的心跳
- 时钟源可选:内核时钟或外部参考时钟
我个人习惯用SysTick做延时函数和任务调度的时间基准。你想想看,如果每个项目都去配一个通用定时器做延时,那多浪费资源?
// SysTick配置示例——1ms中断一次
void SysTick_Init(uint32_t systemClockHz) {
// 重装载值 = 系统时钟 / 1000
uint32_t reload = systemClockHz / 1000;
// 清空当前值
SysTick->VAL = 0;
// 配置重装载寄存器
SysTick->LOAD = reload - 1;
// 使能SysTick,使用内核时钟,使能中断
SysTick->CTRL = (1 << 0) | // ENABLE
(1 << 1) | // TICKINT
(1 << 2); // CLKSOURCE
}
// 全局滴答计数
volatile uint32_t sysTickUptime = 0;
void SysTick_Handler(void) {
sysTickUptime++;
}
// 毫秒延时
void delay_ms(uint32_t ms) {
uint32_t start = sysTickUptime;
while ((sysTickUptime - start) < ms);
}
避坑指南:我曾经在配置SysTick时忘了减1,结果每次中断都少了一个时钟周期。72MHz下跑一天,误差能到好几秒。记住:LOAD寄存器的值是重装载值减1,因为计数器是从LOAD到0,共LOAD+1个周期。
4.2 通用定时器配置
通用定时器比SysTick复杂多了。以STM32为例,它有TIM2-TIM5这些16位/32位定时器。功能包括:基本定时、PWM输出、输入捕获、编码器接口等。
配置通用定时器,我一般按这个步骤来:
- 使能时钟——定时器挂在APB1或APB2总线上
- 配置预分频器——决定计数时钟频率
- 配置自动重装载值——决定定时周期
- 配置计数模式——向上、向下、中央对齐
- 使能中断或DMA——如果需要的话
- 使能计数器——开始干活
// 通用定时器配置示例——TIM2,1秒中断一次
void TIM2_Init(void) {
// 1. 使能TIM2时钟
RCC->APB1ENR |= (1 << 0);
// 2. 配置预分频器:72MHz / 7200 = 10kHz
TIM2->PSC = 7200 - 1;
// 3. 自动重装载值:10kHz / 10000 = 1Hz
TIM2->ARR = 10000 - 1;
// 4. 清空计数器
TIM2->CNT = 0;
// 5. 使能更新中断
TIM2->DIER |= (1 << 0);
// 6. 配置NVIC
NVIC_EnableIRQ(TIM2_IRQn);
NVIC_SetPriority(TIM2_IRQn, 1);
// 7. 使能计数器
TIM2->CR1 |= (1 << 0);
}
void TIM2_IRQHandler(void) {
if (TIM2->SR & (1 << 0)) {
TIM2->SR &= ~(1 << 0); // 清除中断标志
// 用户代码:每秒执行一次
}
}
注意:预分频器和自动重装载值都要减1!这是新手最容易犯的错。另外,修改ARR寄存器时,如果计数器正在运行,要等更新事件发生后新值才生效。我建议在修改前先停止计数器。
4.3 PWM输出与呼吸灯
PWM,脉冲宽度调制,说白了就是通过调节高电平的占空比来控制平均电压。呼吸灯是PWM最经典的入门实验——LED从暗到亮,再从亮到暗,像呼吸一样。
配置PWM输出,核心是设置捕获/比较寄存器(CCR)。CCR的值决定了占空比:
- CCR = 0 → 占空比0%,LED灭
- CCR = ARR/2 → 占空比50%,LED半亮
- CCR = ARR → 占空比100%,LED全亮
// PWM配置示例——TIM3通道1,PA6输出
void PWM_Init(void) {
// 1. 使能GPIO和定时器时钟
RCC->APB1ENR |= (1 << 1); // TIM3
RCC->APB2ENR |= (1 << 2); // GPIOA
// 2. 配置PA6为复用推挽输出
GPIOA->CRL &= ~(0xF << 24);
GPIOA->CRL |= (0xB << 24); // 复用推挽输出,50MHz
// 3. 配置TIM3
TIM3->PSC = 72 - 1; // 72MHz/72 = 1MHz
TIM3->ARR = 1000 - 1; // PWM频率 = 1MHz/1000 = 1kHz
// 4. 配置通道1为PWM模式1
TIM3->CCMR1 |= (6 << 4); // OC1M = 110 (PWM模式1)
TIM3->CCMR1 |= (1 << 3); // OC1PE = 1 (预装载使能)
TIM3->CCER |= (1 << 0); // CC1E = 1 (使能输出)
// 5. 使能自动重装载预装载
TIM3->CR1 |= (1 << 7);
// 6. 使能计数器
TIM3->CR1 |= (1 << 0);
}
// 设置占空比
void PWM_SetDuty(uint16_t duty) {
if (duty > 1000) duty = 1000;
TIM3->CCR1 = duty;
}
// 呼吸灯效果
void BreathLight(void) {
static uint8_t direction = 0;
static uint16_t duty = 0;
if (direction == 0) {
duty++;
if (duty >= 1000) direction = 1;
} else {
duty--;
if (duty <= 0) direction = 0;
}
PWM_SetDuty(duty);
delay_ms(2); // 调整呼吸速度
}
实战经验:我在做智能家居面板时,呼吸灯频率选的是1kHz。太低了会闪烁,太高了MOS管开关损耗大。另外,呼吸曲线用线性变化其实不太自然,人眼对亮度的感知是对数的。想做得更逼真?试试指数曲线:duty = (uint16_t)(1000.0f * (1.0f - exp(-t / tau)))。
4.4 输入捕获与频率测量
输入捕获,就是测量外部信号的频率或脉宽。原理很简单:定时器检测到上升沿或下降沿时,把当前计数器的值「捕获」到CCR寄存器里。两次捕获的差值,乘以时钟周期,就是时间间隔。
频率测量的两种常用方法:
| 方法 | 原理 | 适用场景 | 精度 |
|---|---|---|---|
| 测频法 | 固定闸门时间,计脉冲数 | 高频信号(>1kHz) | 闸门时间越长越准 |
| 测周法 | 测量一个周期的时间 | 低频信号(<1kHz) | 周期越长越准 |
我个人偏爱测周法,因为它响应快,适合实时控制。来看看代码:
// 输入捕获配置——TIM4通道1,测量频率
volatile uint32_t cap1 = 0, cap2 = 0;
volatile uint8_t capFlag = 0;
void TIM4_InputCapture_Init(void) {
// 1. 时钟使能
RCC->APB1ENR |= (1 << 2); // TIM4
RCC->APB2ENR |= (1 << 3); // GPIOB
// 2. 配置PB6为浮空输入
GPIOB->CRL &= ~(0xF << 24);
GPIOB->CRL |= (0x4 << 24); // 浮空输入
// 3. 配置TIM4时基
TIM4->PSC = 72 - 1; // 1MHz计数时钟
TIM4->ARR = 0xFFFF; // 最大重装载
// 4. 配置通道1为输入捕获,上升沿触发
TIM4->CCMR1 |= (1 << 0); // CC1S = 01 (输入到TI1)
TIM4->CCMR1 &= ~(3 << 4); // IC1F = 000 (无滤波)
TIM4->CCER |= (1 << 0); // CC1E = 1, 上升沿
// 5. 使能捕获中断
TIM4->DIER |= (1 << 1); // CC1IE
// 6. NVIC配置
NVIC_EnableIRQ(TIM4_IRQn);
NVIC_SetPriority(TIM4_IRQn, 2);
// 7. 使能计数器
TIM4->CR1 |= (1 << 0);
}
void TIM4_IRQHandler(void) {
if (TIM4->SR & (1 << 1)) {
TIM4->SR &= ~(1 << 1);
static uint32_t lastCap = 0;
uint32_t currentCap = TIM4->CCR1;
// 计算周期(考虑溢出)
uint32_t period;
if (currentCap >= lastCap) {
period = currentCap - lastCap;
} else {
period = (0xFFFF - lastCap) + currentCap + 1;
}
lastCap = currentCap;
// 频率 = 1MHz / period
if (period > 0) {
uint32_t freq = 1000000 / period;
// 用户代码:处理频率值
}
}
}
踩坑记录:我曾经在测量50Hz交流信号时,发现频率值跳变厉害。查了半天,原来是信号有毛刺,触发了多次捕获。解决办法是开启输入滤波——TIMx_CCMR1里的ICF位。对于50Hz信号,设置ICF=4(8个采样点)基本就能滤掉噪声。另外,别忘了处理计数器溢出,否则低频信号测出来全是错的。
4.5 本章知识体系
说了这么多,咱们用一张图把定时器的知识体系串起来。这张SVG图展示了从系统滴答到高级应用的完整脉络:
这张图把定时器的四个核心模块串在了一起。SysTick是基础,通用定时器是主力,PWM和输入捕获是两大应用方向。实际项目中,我经常把SysTick做时间基准,通用定时器做PWM驱动电机,再用另一个定时器做输入捕获测转速——一个芯片搞定闭环控制。
核心要点回顾:
- SysTick:24位递减,适合做延时和OS心跳
- 通用定时器:16/32位,功能丰富,配置时注意PSC和ARR都要减1
- PWM:通过CCR控制占空比,呼吸灯用指数曲线更自然
- 输入捕获:高频用测频法,低频用测周法,别忘了滤波和溢出处理
定时器这块,说白了就是「数数」——数时钟周期,数脉冲个数,数时间间隔。把数数这件事玩明白了,嵌入式系统的时间控制就掌握了七八成。剩下的,就是在项目中不断踩坑、填坑、积累经验。