4、STM32基础外设开发(定时器):SysTick系统滴答、基本定时器与PWM输出、输入捕获测频率、看门狗应用
定时器这东西,说简单也简单,说复杂能写一本书。我做了这么多年嵌入式,几乎每个项目都离不开它。从简单的延时,到复杂的电机控制,定时器就是MCU的「心跳」。今天咱们就把STM32的定时器家族捋一遍,重点讲几个最常用的场景。
4.1 SysTick系统滴答定时器
SysTick是内核自带的24位递减计数器。说白了,它就是给操作系统做心跳用的。但即便你不用RTOS,它也是个宝贝。
我个人的习惯:任何项目上来第一件事,先把SysTick配好,用来做软件延时和超时管理。你想想看,有了它,你就不用再写那种傻等的delay()函数了。
核心要点:SysTick的时钟源可以选择AHB时钟或AHB/8。配置时注意这个细节,不然你的延时时间会差8倍。
配置代码其实很简单:
// 配置SysTick,1ms中断一次
// 假设系统时钟72MHz,选择AHB/8作为时钟源
// 那么计数频率 = 72MHz / 8 = 9MHz
// 重装载值 = 9MHz / 1000 = 9000 - 1
void SysTick_Init(void)
{
// 关闭SysTick
SysTick->CTRL = 0;
// 设置重装载值
SysTick->LOAD = 9000 - 1;
// 清空当前值
SysTick->VAL = 0;
// 选择AHB/8时钟源,使能中断,使能计数器
SysTick->CTRL = 0x07; // 位2:时钟源,位1:中断使能,位0:使能
}
// 全局变量,用于计时
volatile uint32_t SysTick_Count = 0;
// SysTick中断服务函数
void SysTick_Handler(void)
{
SysTick_Count++;
}
// 毫秒延时函数
void delay_ms(uint32_t ms)
{
uint32_t start = SysTick_Count;
while((SysTick_Count - start) < ms);
}
我的经验:用SysTick_Count - start这种方式做延时,可以避免溢出问题。因为无符号数减法在C语言里是循环的,哪怕计数器溢出了,结果也是对的。这个技巧我在很多项目里都用过,百试不爽。
4.2 基本定时器
基本定时器(TIM6、TIM7)是最简单的定时器。它只有向上计数功能,没有输入输出通道。但别小看它,我经常用它来触发ADC采样或者做精确延时。
为什么不用SysTick?因为SysTick被系统占用了,或者你需要多个独立的定时器。这时候基本定时器就派上用场了。
配置步骤:
- 使能定时器时钟
- 设置预分频器(PSC)和自动重装载值(ARR)
- 使能更新中断(如果需要)
- 使能计数器
// 配置TIM6,定时100ms
// 假设APB1时钟 = 36MHz
// 预分频:36000 - 1,得到1kHz的计数频率
// 重装载值:100 - 1,得到100ms中断
void TIM6_Init(void)
{
// 使能TIM6时钟
RCC->APB1ENR |= RCC_APB1ENR_TIM6EN;
// 设置预分频器
TIM6->PSC = 36000 - 1; // 36MHz / 36000 = 1kHz
// 设置自动重装载值
TIM6->ARR = 100 - 1; // 1kHz / 100 = 10Hz,即100ms
// 清除更新标志
TIM6->SR = 0;
// 使能更新中断
TIM6->DIER |= TIM_DIER_UIE;
// 使能计数器
TIM6->CR1 |= TIM_CR1_CEN;
// 配置NVIC
NVIC_EnableIRQ(TIM6_IRQn);
NVIC_SetPriority(TIM6_IRQn, 1);
}
void TIM6_IRQHandler(void)
{
if(TIM6->SR & TIM_SR_UIF)
{
TIM6->SR = ~TIM_SR_UIF; // 清除中断标志
// 用户代码:比如翻转LED
}
}
我曾经踩过的坑:APB1时钟分频系数会影响定时器的时钟。如果APB1预分频系数不是1,那么定时器时钟会是APB1的两倍。这个在手册里有写,但很容易忽略。我当年调试一个PWM输出,频率死活不对,查了半天才发现是这个原因。
4.3 PWM输出
PWM输出是通用定时器的拿手好戏。呼吸灯、电机调速、舵机控制,都离不开它。STM32的定时器可以同时输出多路PWM,而且占空比可以独立调节。
配置要点:
- 选择定时器的某个通道作为PWM输出
- 配置输出比较模式为PWM1或PWM2
- 设置占空比(通过CCR寄存器)
- 使能输出
// 配置TIM2的通道1(PA0)输出PWM
// 频率:1kHz,占空比:50%
void PWM_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
TIM_OCInitTypeDef TIM_OCInitStruct;
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct;
// 使能时钟
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
// 配置PA0为复用推挽输出
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStruct);
// 定时器基础配置
TIM_TimeBaseInitStruct.TIM_Prescaler = 72 - 1; // 72MHz / 72 = 1MHz
TIM_TimeBaseInitStruct.TIM_Period = 1000 - 1; // 1MHz / 1000 = 1kHz
TIM_TimeBaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStruct);
// PWM模式配置
TIM_OCInitStruct.TIM_OCMode = TIM_OCMode_PWM1;
TIM_OCInitStruct.TIM_OutputState = TIM_OutputState_Enable;
TIM_OCInitStruct.TIM_Pulse = 500; // 占空比50%
TIM_OCInitStruct.TIM_OCPolarity = TIM_OCPolarity_High;
TIM_OC1Init(TIM2, &TIM_OCInitStruct);
// 使能定时器
TIM_Cmd(TIM2, ENABLE);
}
// 动态修改占空比
void PWM_SetDuty(uint16_t duty)
{
TIM_SetCompare1(TIM2, duty);
}
我建议:做电机控制时,PWM频率选在10kHz-20kHz比较合适。太低会有噪音,太高MOS管开关损耗大。呼吸灯的话,100Hz左右就够用了,人眼看不出闪烁。
4.4 输入捕获测频率
输入捕获可以测量外部信号的频率和脉宽。原理很简单:捕获上升沿或下降沿时,把计数器的值存到CCR寄存器里。两次捕获的差值,就是信号的周期。
测频率的步骤:
- 配置定时器的输入捕获通道
- 设置捕获边沿(上升沿或下降沿)
- 使能捕获中断
- 在中断里读取CCR值,计算频率
// 使用TIM3的通道1(PA6)测量输入频率
volatile uint32_t Capture_Value1 = 0;
volatile uint32_t Capture_Value2 = 0;
volatile uint8_t Capture_Complete = 0;
void InputCapture_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
TIM_ICInitTypeDef TIM_ICInitStruct;
// 使能时钟
RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
// 配置PA6为浮空输入
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStruct);
// 定时器基础配置
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct;
TIM_TimeBaseInitStruct.TIM_Prescaler = 72 - 1; // 1MHz计数频率
TIM_TimeBaseInitStruct.TIM_Period = 0xFFFF; // 最大计数值
TIM_TimeBaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStruct);
// 输入捕获配置
TIM_ICInitStruct.TIM_Channel = TIM_Channel_1;
TIM_ICInitStruct.TIM_ICPolarity = TIM_ICPolarity_Rising;
TIM_ICInitStruct.TIM_ICSelection = TIM_ICSelection_DirectTI;
TIM_ICInitStruct.TIM_ICPrescaler = TIM_ICPSC_DIV1;
TIM_ICInitStruct.TIM_ICFilter = 0;
TIM_ICInit(TIM3, &TIM_ICInitStruct);
// 使能捕获中断
TIM_ITConfig(TIM3, TIM_IT_CC1, ENABLE);
NVIC_EnableIRQ(TIM3_IRQn);
// 使能定时器
TIM_Cmd(TIM3, ENABLE);
}
void TIM3_IRQHandler(void)
{
if(TIM_GetITStatus(TIM3, TIM_IT_CC1) != RESET)
{
TIM_ClearITPendingBit(TIM3, TIM_IT_CC1);
static uint8_t capture_index = 0;
if(capture_index == 0)
{
Capture_Value1 = TIM_GetCapture1(TIM3);
capture_index = 1;
}
else
{
Capture_Value2 = TIM_GetCapture1(TIM3);
capture_index = 0;
Capture_Complete = 1;
}
}
}
// 在主循环中计算频率
uint32_t Get_Frequency(void)
{
uint32_t freq = 0;
if(Capture_Complete)
{
uint32_t period = 0;
if(Capture_Value2 > Capture_Value1)
{
period = Capture_Value2 - Capture_Value1;
}
else
{
// 处理溢出情况
period = (0xFFFF - Capture_Value1) + Capture_Value2;
}
// 频率 = 计数频率 / 周期
// 计数频率 = 72MHz / 72 = 1MHz
freq = 1000000 / period;
Capture_Complete = 0;
}
return freq;
}
我曾经遇到的问题:测量低频信号时,计数器可能会溢出。比如测量50Hz的工频信号,周期是20ms,而16位计数器在1MHz下只能计65ms。如果信号周期超过这个值,就得用32位定时器或者用软件扩展计数范围。我有个项目测心跳信号,频率只有1Hz左右,当时就吃了这个亏。
4.5 看门狗应用
看门狗是嵌入式系统的最后一道防线。程序跑飞了?死循环了?看门狗会帮你复位。STM32有两种看门狗:独立看门狗(IWDG)和窗口看门狗(WWDG)。
独立看门狗:由独立的40kHz RC振荡器驱动,即使主时钟挂了也能工作。适合做系统级保护。
窗口看门狗:基于系统时钟,可以在特定时间窗口内喂狗。早了不行,晚了也不行。适合对时序要求严格的场景。
// 独立看门狗配置
// 超时时间约1秒(40kHz / 64 / 625)
void IWDG_Init(void)
{
// 写入0x5555,允许修改预分频器和重装载值
IWDG->KR = 0x5555;
// 设置预分频器为64分频
IWDG->PR = IWDG_Prescaler_64;
// 设置重装载值
IWDG->RLR = 625; // 40kHz / 64 = 625Hz, 625 / 625 = 1秒
// 等待寄存器更新
while(IWDG->SR);
// 启动看门狗
IWDG->KR = 0xCCCC;
}
// 喂狗函数
void IWDG_Feed(void)
{
IWDG->KR = 0xAAAA;
}
// 窗口看门狗配置
void WWDG_Init(void)
{
// 使能WWDG时钟
RCC->APB1ENR |= RCC_APB1ENR_WWDGEN;
// 配置WWDG
// 计数器值:0x7F,窗口值:0x5F
// 超时时间 = (4096 * 8 * (0x7F - 0x3F)) / 36MHz ≈ 5.8ms
WWDG->CFR = (0x5F << 8) | WWDG_CFR_WDGTB1; // 窗口值,分频系数8
WWDG->CR = 0x7F | WWDG_CR_WDGA; // 计数器值,使能看门狗
// 使能提前唤醒中断
WWDG->CFR |= WWDG_CFR_EWI;
NVIC_EnableIRQ(WWDG_IRQn);
}
void WWDG_Feed(void)
{
WWDG->CR = 0x7F; // 喂狗,计数器回到0x7F
}
void WWDG_IRQHandler(void)
{
// 提前唤醒中断,在复位前做紧急处理
WWDG->SR = 0; // 清除中断标志
// 比如保存重要数据到Flash
}
我的经验:看门狗的喂狗位置很有讲究。我一般会在主循环的末尾喂狗,确保所有任务都执行了一遍。不要在中断里喂狗,因为如果主循环卡死了,中断可能还在正常运行,看门狗就形同虚设了。
4.6 实际项目中的选择建议
| 应用场景 | 推荐定时器 | 理由 |
|---|---|---|
| 系统心跳、软件延时 | SysTick | 内核自带,配置简单 |
| 触发ADC采样 | 基本定时器 | 独立运行,不影响其他功能 |
| 电机控制、呼吸灯 | 通用定时器PWM | 多通道输出,占空比可调 |
| 测量外部信号频率 | 通用定时器输入捕获 | 硬件捕获,精度高 |
| 系统异常保护 | 独立看门狗 | 独立时钟,可靠性高 |
| 时序严格的应用 | 窗口看门狗 | 可检测程序执行时序异常 |
嗯,定时器这部分内容确实不少。但说白了,你只要把SysTick、PWM输出、输入捕获和看门狗这四个场景吃透了,大部分项目都能应付。我当年刚开始做物联网终端时,就靠这几个功能撑起了整个项目。记住一点:定时器配置前,先搞清楚时钟树,不然你算出来的时间全是错的。
下一章咱们开始讲通信接口,UART、I2C、SPI这些,都是物联网终端和外界打交道的「嘴巴」,到时候见。