第四章 定时器与时间管理:GUI动画的脉搏

做嵌入式GUI动画,说白了就是跟时间打交道。你想想看,一个按钮从按下到弹起,60帧的平滑过渡,背后全靠定时器在撑。我早年做第一版GUI时,直接用HAL_Delay()做延时,结果动画一卡一卡的,触摸响应也迟钝。后来才明白——时间管理没做好,动画就是扯淡。

这一章,咱们就把定时器这件事彻底讲透。从硬件配置到软件实现,再到微秒级的高精度计时,一步步来。

核心观点:GUI动画的刷新节奏,完全取决于定时器的精度和调度方式。选错定时器,动画必卡。

定时器与时间管理知识体系 定时器与时间管理 硬件定时器配置 软件定时器实现 时间片轮转 高精度计时(us级) TIM基础配置 中断优先级 链表管理 回调机制 任务调度 帧同步 DWT/定时器捕获 硬件定时器提供精准时钟源,软件定时器实现灵活调度 时间片轮转保证多任务公平,高精度计时满足微秒级需求

4.1 硬件定时器配置:选对工具才能干好活

硬件定时器是MCU内部的外设,靠独立的时钟源计数。我习惯用STM32的通用定时器(TIM2-TIM5)做GUI动画的基准时钟。为什么?因为它们有16位或32位的自动重装载寄存器,可以产生很宽的定时范围。

配置一个定时器,核心就三步:

  1. 时钟源选择:内部时钟(CK_INT)还是外部时钟?GUI场景一般用内部时钟就够了。
  2. 预分频器设置:把主频降到你需要的计数频率。比如主频72MHz,预分频7199,计数频率就是10kHz。
  3. 自动重装载值:决定定时周期。计数频率10kHz,重装载值9999,就是1秒中断一次。

我的习惯:做GUI动画时,我会把定时器中断频率设在1kHz(1ms周期)。这个频率够用,也不会给CPU太大负担。你想想看,60fps的动画,每帧16.7ms,1ms的精度完全够。

代码示例(基于STM32 HAL库):

// 定时器初始化 - 1ms中断
void GUI_Timer_Init(void)
{
    TIM_HandleTypeDef htim2;
    htim2.Instance = TIM2;
    htim2.Init.Prescaler = 7199;      // 72MHz / (7199+1) = 10kHz
    htim2.Init.Period = 9;            // 10kHz / (9+1) = 1kHz = 1ms
    htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
    htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
    HAL_TIM_Base_Init(&htim2);
    HAL_TIM_Base_Start_IT(&htim2);   // 启动中断
}

注意:中断服务函数里别干重活!我曾经在TIM中断里直接做LCD刷屏,结果中断嵌套把系统搞崩了。中断里只该做标记或计数,真正的渲染工作放到主循环或任务里做。

4.2 软件定时器实现:灵活才是王道

硬件定时器数量有限,一个项目里可能就两三个。但GUI动画需要的定时任务可多了——按钮闪烁、进度条滚动、页面切换动画……这时候就得靠软件定时器。

软件定时器的本质,就是用一个硬件定时器作为“心跳”,然后管理一组定时任务。我常用的实现方式有两种:

方式 原理 优点 缺点
轮询方式 主循环中检查每个定时器的计数值 实现简单,无中断开销 精度受主循环影响
中断驱动方式 硬件定时器中断中更新所有软件定时器 精度高,响应快 中断处理要快,否则影响其他中断

我个人偏爱中断驱动方式。GUI动画对时间精度要求高,轮询方式在主循环被阻塞时(比如刷大图),定时器就停了。

下面是一个简单的软件定时器实现:

// 软件定时器结构体
typedef struct {
    uint32_t count;      // 当前计数值
    uint32_t reload;     // 重装载值
    void (*callback)(void); // 回调函数
    uint8_t enable;      // 使能标志
} SoftTimer_t;

SoftTimer_t g_timers[10]; // 最多10个软件定时器

// 1ms中断中调用
void SoftTimer_Update(void)
{
    for (int i = 0; i < 10; i++) {
        if (g_timers[i].enable) {
            g_timers[i].count++;
            if (g_timers[i].count >= g_timers[i].reload) {
                g_timers[i].count = 0;
                if (g_timers[i].callback) {
                    g_timers[i].callback(); // 执行回调
                }
            }
        }
    }
}

// 启动一个定时器
void SoftTimer_Start(int id, uint32_t ms, void (*cb)(void))
{
    g_timers[id].count = 0;
    g_timers[id].reload = ms;  // 假设心跳是1ms
    g_timers[id].callback = cb;
    g_timers[id].enable = 1;
}

关键点:回调函数里也不能做耗时操作。我一般只在回调里设置一个标志位,或者更新一个动画状态机的状态。真正的渲染工作,放到主循环里统一处理。

4.3 时间片轮转:让每个动画都有机会

做复杂GUI时,你可能同时有多个动画在跑:一个按钮在呼吸,一个进度条在滚动,一个页面在滑动。如果某个动画霸占CPU,其他的就卡住了。

时间片轮转就是解决这个问题的。说白了,就是把CPU时间切成小片,每个任务轮流用。我习惯把时间片设为5ms或10ms,这样人眼感觉不到延迟,但每个任务都能得到公平的执行机会。

实现思路:

  • 用一个全局变量记录当前时间片
  • 每个任务分配一个时间片ID
  • 在定时器中断中切换时间片
  • 主循环中根据当前时间片执行对应任务
#define TIME_SLICE_MS 5
#define TASK_COUNT    4

volatile uint8_t current_slice = 0;

// 1ms中断中调用
void TimeSlice_Update(void)
{
    static uint8_t counter = 0;
    counter++;
    if (counter >= TIME_SLICE_MS) {
        counter = 0;
        current_slice = (current_slice + 1) % TASK_COUNT;
    }
}

// 主循环中
void Main_Loop(void)
{
    while (1) {
        switch (current_slice) {
            case 0: Task_ButtonAnimation(); break;
            case 1: Task_ProgressBar(); break;
            case 2: Task_PageSlide(); break;
            case 3: Task_TouchScan(); break;
        }
    }
}

避坑指南:我曾经把时间片设得太小(1ms),结果任务切换太频繁,上下文开销反而拖慢了整体性能。后来改成5ms,效果好了很多。记住,时间片不是越小越好,要看你任务的复杂度。

4.4 高精度计时(us级):动画流畅的终极武器

有些场景需要微秒级的计时。比如驱动WS2812灯带,或者做音频相关的动画同步。这时候1ms的精度就不够了。

实现微秒级计时,常用的方法有:

方法 精度 实现难度 适用场景
DWT循环计数 1个时钟周期 低 短时间精确延时
定时器输入捕获 1个时钟周期 中 测量外部信号脉宽
定时器PWM模式 1个时钟周期 中 产生精确波形

我最常用的是DWT(Data Watchpoint and Trace)模块。Cortex-M3/M4内核都有这个外设,它有一个32位的循环计数器,每个时钟周期加1。用它做微秒延时,既简单又精准。

// DWT初始化
void DWT_Init(void)
{
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}

// 微秒延时
void delay_us(uint32_t us)
{
    uint32_t start = DWT->CYCCNT;
    uint32_t ticks = us * (SystemCoreClock / 1000000);
    while ((DWT->CYCCNT - start) < ticks);
}

// 微秒级时间戳获取
uint32_t get_us(void)
{
    return DWT->CYCCNT / (SystemCoreClock / 1000000);
}

我的经验:用DWT做微秒延时,要注意两点。第一,DWT计数器是32位的,72MHz主频下大约60秒会溢出一次。第二,不要在中断里用delay_us,除非你确定中断不会嵌套。我曾经在中断里调了delay_us(100),结果另一个高优先级中断进来,延时就不准了。

对于GUI动画来说,微秒级计时主要用在两个地方:

  • 帧同步:精确控制每帧的刷新时间,避免画面撕裂
  • 性能测量:测量某个渲染操作耗时,找出性能瓶颈

举个例子,测量绘制一个圆角矩形的耗时:

uint32_t start = get_us();
Draw_RoundRect(10, 10, 100, 50, 5, RED);
uint32_t elapsed = get_us() - start;
printf("绘制圆角矩形耗时: %d us\n", elapsed);

有了这个数据,你就能知道哪些渲染操作需要优化。我当年就是靠这个发现,软件渲染圆角矩形比硬件加速慢了10倍,果断换了方案。

总结一下:硬件定时器是骨架,提供精准的时钟源;软件定时器是血肉,实现灵活的调度;时间片轮转是神经系统,保证多任务公平;高精度计时是眼睛,让你看清每一微秒的性能细节。四者配合,才能做出流畅的嵌入式GUI动画。