4. GPIO外设LL库驱动

定时器这东西,说白了就是单片机的“心跳节拍器”。我刚开始用STM32的时候,总觉得定时器不就是个计数器嘛,有啥好研究的?结果有一次做电机控制,因为定时器配置不对,电机转起来一顿一顿的,查了整整两天才发现是预分频系数算错了。从那以后,我对定时器就再也不敢马虎了。

这一章,咱们就把STM32的定时器资源捋清楚,然后手把手用LL库配置一个定时中断,实现精准的微秒级和毫秒级延时。

4.1 STM32定时器家族

STM32的定时器种类不少,但别被吓到。我习惯把它们分成三类:

类型 定时器实例 位数 主要特点
基本定时器 TIM6、TIM7 16位 仅定时功能,无外部引脚
通用定时器 TIM2~TIM5 16/32位 PWM、输入捕获、输出比较
高级定时器 TIM1、TIM8 16位 带死区插入的互补PWM、刹车功能

做精准延时,我一般首选基本定时器或通用定时器。为什么?因为高级定时器功能太复杂,配置起来容易踩坑。你想想看,如果只是要个延时,何必把刹车、死区这些功能都牵扯进来?

我的习惯:做延时功能,优先用TIM6或TIM7。它们结构简单,寄存器少,不容易出错。如果TIM6/TIM7被占用了,再考虑TIM2~TIM5。

4.2 定时器的工作原理

定时器的核心逻辑其实就一句话:时钟源经过预分频器分频后,驱动计数器递增,计数器达到自动重装载值时产生更新事件。

嗯,这句话有点绕。我画个图你就明白了。

定时器工作流程 时钟源 (如72MHz) 预分频器 PSC 计数器 CNT 自动重装载 ARR CNT == ARR? 更新事件 定时频率 = 时钟源频率 / (PSC + 1) / (ARR + 1) 例如:72MHz / (72-1) / (1000-1) = 1kHz,即1ms中断一次

看到这个流程,你应该能理解了吧?时钟源进来,先经过预分频器降频,然后计数器开始数数。数到ARR设定的值,就触发更新事件,计数器归零重新开始。这个更新事件,就是我们产生定时中断的源头。

4.3 LL库定时器初始化

LL库的定时器初始化,核心就两个函数:LL_TIM_Init 和 LL_TIM_EnableIT_UPDATE。我习惯把配置步骤总结成四步走:

  1. 使能定时器时钟 —— 没时钟,啥都干不了
  2. 配置预分频和重装载值 —— 决定定时周期
  3. 使能更新中断 —— 让定时器能通知CPU
  4. 使能定时器 —— 开始计数

来看一个具体的例子。假设系统时钟72MHz,我想实现1ms中断一次:

// 第一步:使能定时器时钟
LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_TIM6);

// 第二步:初始化定时器
LL_TIM_InitTypeDef TIM_InitStruct = {0};
TIM_InitStruct.Prescaler = 72 - 1;        // 72MHz / 72 = 1MHz
TIM_InitStruct.Autoreload = 1000 - 1;     // 1MHz / 1000 = 1kHz
TIM_InitStruct.CounterMode = LL_TIM_COUNTERMODE_UP;
TIM_InitStruct.ClockDivision = LL_TIM_CLOCKDIVISION_DIV1;
LL_TIM_Init(TIM6, &TIM_InitStruct);

// 第三步:使能更新中断
LL_TIM_EnableIT_UPDATE(TIM6);

// 第四步:使能定时器
LL_TIM_EnableCounter(TIM6);
关键公式:
中断频率 = 时钟频率 / (Prescaler + 1) / (Autoreload + 1)
中断周期 = 1 / 中断频率

这里有个细节要注意:Prescaler和Autoreload都要减1。为什么?因为这两个寄存器的值是从0开始计数的。比如Prescaler设为71,实际分频系数是72。我曾经见过有人直接写72,结果定时时间长了1/72,虽然误差不大,但在高精度场合就出问题了。

4.4 定时中断服务函数

定时器配置好了,中断来了,总得有人处理吧?中断服务函数就是干这个的。

// 中断服务函数
void TIM6_DAC_IRQHandler(void)
{
    // 检查更新中断标志
    if(LL_TIM_IsActiveFlag_UPDATE(TIM6))
    {
        // 清除更新中断标志
        LL_TIM_ClearFlag_UPDATE(TIM6);
        
        // 用户代码:每1ms执行一次
        // 比如:翻转LED、更新系统滴答等
    }
}

嗯,这里有个坑。中断服务函数里,一定要先清除标志位,再执行用户代码。我刚开始做的时候顺序搞反了,结果中断标志位一直没清,程序就卡在中断里出不来。你想想看,CPU一直在响应中断,主循环根本跑不动,整个系统就像死机了一样。

避坑指南:中断服务函数要短小精悍。不要在中断里做延时、打印、复杂计算。如果确实需要处理大量数据,用标志位通知主循环来处理。

4.5 实现精准延时

有了1ms的定时中断,我们就可以在上面搭一个精准延时函数了。我常用的方法是维护一个全局的毫秒计数器:

volatile uint32_t g_tick_ms = 0;

// 中断服务函数中递增
void TIM6_DAC_IRQHandler(void)
{
    if(LL_TIM_IsActiveFlag_UPDATE(TIM6))
    {
        LL_TIM_ClearFlag_UPDATE(TIM6);
        g_tick_ms++;
    }
}

// 毫秒延时
void delay_ms(uint32_t ms)
{
    uint32_t start = g_tick_ms;
    while((g_tick_ms - start) < ms);
}

// 微秒延时(忙等待方式)
void delay_us(uint32_t us)
{
    // 先停掉定时器,重新配置为微秒级
    LL_TIM_DisableCounter(TIM6);
    LL_TIM_SetPrescaler(TIM6, 72 - 1);     // 1MHz
    LL_TIM_SetAutoreload(TIM6, us - 1);
    LL_TIM_SetCounter(TIM6, 0);
    LL_TIM_EnableCounter(TIM6);
    
    while(!LL_TIM_IsActiveFlag_UPDATE(TIM6));
    
    LL_TIM_ClearFlag_UPDATE(TIM6);
    // 恢复为毫秒配置
    LL_TIM_SetAutoreload(TIM6, 1000 - 1);
}

这个微秒延时的写法,说白了就是临时改变定时器的重装载值,让它只数us个数就产生一次更新事件。虽然简单粗暴,但在很多场合够用了。

我的建议:如果项目对延时精度要求很高(比如驱动DS18B20这种时序敏感的传感器),建议用通用定时器的输入捕获模式或者DWT(数据观察点跟踪)来实现微秒级延时,精度更高。

4.6 常见问题与调试技巧

做定时器调试,我总结了几条经验:

  • 定时器不工作?先检查时钟是否使能。用调试器看RCC寄存器,确认定时器时钟已经打开。
  • 中断不触发?检查NVIC是否使能。LL库只管外设,NVIC配置要自己加:NVIC_EnableIRQ(TIM6_DAC_IRQn)。
  • 时间不准?确认系统时钟频率。很多人用HSE 8MHz外部晶振,但忘了配置PLL,结果系统跑在8MHz而不是72MHz。
  • 中断太频繁?检查Prescaler和Autoreload是否算对了。我见过有人把两个值写反了,结果中断频率高得吓人。
症状 可能原因 排查方法
定时器不计数 时钟未使能 检查RCC寄存器
中断不响应 NVIC未配置 检查NVIC使能位
时间偏慢/偏快 系统时钟配置错误 用示波器测PWM输出
程序卡死 中断标志未清除 检查清标志顺序

最后说一句,调试定时器最好的工具是什么?示波器。把某个GPIO在中断里翻转,用示波器一看,周期对不对一目了然。比你在代码里加断点调试高效多了。