4. 时间片轮转(软定时器):利用SysTick实现非阻塞延时与周期性任务

说实话,很多初学者刚接触单片机时,最习惯的写法就是 delay_ms(1000)。我也一样。但很快你就会发现,这种“死等”的方式,让CPU在延时期间啥也干不了。你想想看,一个主频几十兆的芯片,就为了等那几百毫秒,白白空转,是不是有点浪费?

这一章,我们就来解决这个问题。核心思路很简单:用SysTick定时器来“记账”,让CPU不再傻等。这就是软定时器的本质——时间片轮转的雏形。

4.1 为什么需要非阻塞延时?

先看一个典型场景。你要控制一个LED以500ms的周期闪烁,同时还要扫描按键。如果用阻塞延时:

while(1) {
    LED_Toggle();
    delay_ms(500);   // CPU在这里空转500ms
    if(Key_Scan() == KEY_PRESS) {
        // 按键处理
    }
}

问题很明显:按键扫描的实时性完全被破坏了。你在延时期间按了键,CPU根本不理你。我在一个智能家居项目中就吃过这个亏——用户按了开关,灯要等半秒才反应,体验极差。

非阻塞延时的核心思想是:不等待,只查询。你告诉定时器“我要等500ms”,然后继续干别的事。每隔一段时间,你去问定时器“时间到了没?”到了就执行,没到就继续忙别的。

4.2 SysTick定时器:Cortex-M内核的“心跳”

SysTick是Cortex-M内核内置的一个24位递减计数器。说白了,它就是一个硬件计时器,专门用来给操作系统或裸机程序提供时间基准。我个人习惯把它配置成1ms中断一次,这样就有了一个“毫秒级的心跳”。

SysTick关键参数:
  • 24位递减计数器,最大值16,777,215
  • 时钟源可选:内核时钟或外部参考时钟
  • 计数到0时,可产生中断
  • 自动重装载,无需手动重置

配置代码其实很简单。以STM32为例,我通常这样初始化:

// SysTick配置:1ms中断一次
// 假设系统时钟为72MHz
void SysTick_Init(void) {
    // 设置重装载值:72000000 / 1000 = 72000
    SysTick->LOAD = 72000 - 1;
    // 清零当前值
    SysTick->VAL = 0;
    // 使能SysTick,使能中断,使用内核时钟
    SysTick->CTRL = 0x07;
}

嗯,这里要注意:LOAD寄存器的值要减1,因为计数器是从LOAD值减到0,总共LOAD+1个时钟周期。我曾经在这里踩过坑,少减了1,导致时间偏差了1个时钟周期。虽然影响不大,但做精确计时时还是要严谨。

4.3 软定时器的实现:从“死等”到“查询”

有了SysTick这个1ms的“心跳”,我们就可以实现软定时器了。核心数据结构就是一个计数器变量:

// 全局变量:系统滴答计数
volatile uint32_t sysTickCount = 0;

// SysTick中断服务函数
void SysTick_Handler(void) {
    sysTickCount++;
}

// 获取当前滴答值
uint32_t GetTick(void) {
    return sysTickCount;
}

// 非阻塞延时函数
// 返回1表示时间到,返回0表示时间未到
uint8_t CheckDelay(uint32_t startTick, uint32_t delayMs) {
    return (GetTick() - startTick) >= delayMs;
}

使用方式就变成了这样:

uint32_t ledTimer = 0;
uint8_t ledState = 0;

void LED_Blink_NonBlocking(void) {
    // 启动定时器
    if(ledTimer == 0) {
        ledTimer = GetTick();
    }
    
    // 检查500ms是否到达
    if(CheckDelay(ledTimer, 500)) {
        ledTimer = GetTick();  // 重新计时
        ledState = !ledState;
        GPIO_WriteBit(LED_PORT, LED_PIN, ledState);
    }
}

// 主循环中,LED闪烁和按键扫描互不干扰
while(1) {
    LED_Blink_NonBlocking();
    Key_Scan_NonBlocking();  // 按键也是非阻塞的
    // 还可以做其他任务...
}
小技巧:使用 GetTick() - startTick 而不是 GetTick() > startTick + delayMs,可以避免定时器溢出带来的问题。因为uint32_t的减法在溢出时是自动回绕的,只要延时时间不超过49.7天(2^32毫秒),结果就是正确的。

4.4 多任务时间片轮转:让每个任务都有机会

单个软定时器只能控制一个任务。但实际项目中,我们往往需要同时管理多个周期性任务。比如:LED闪烁(500ms)、按键扫描(20ms)、温度采集(1000ms)、串口发送(100ms)。

这时候,就需要一个任务调度表来统一管理。我习惯用结构体数组来实现:

// 任务控制块
typedef struct {
    void (*taskFunc)(void);   // 任务函数指针
    uint32_t interval;        // 执行间隔(ms)
    uint32_t lastTick;        // 上次执行时间
    uint8_t enable;           // 使能标志
} Task_t;

// 任务列表
Task_t taskList[] = {
    {LED_Blink,      500, 0, 1},   // LED闪烁,500ms
    {Key_Scan,        20, 0, 1},   // 按键扫描,20ms
    {Temp_Read,     1000, 0, 1},   // 温度采集,1000ms
    {UART_Send,      100, 0, 1},   // 串口发送,100ms
};

// 任务调度器
void TaskScheduler(void) {
    uint32_t currentTick = GetTick();
    uint8_t i;
    
    for(i = 0; i < TASK_NUM; i++) {
        if(taskList[i].enable) {
            // 检查是否到达执行时间
            if(currentTick - taskList[i].lastTick >= taskList[i].interval) {
                taskList[i].lastTick = currentTick;  // 更新时间戳
                taskList[i].taskFunc();               // 执行任务
            }
        }
    }
}

// 主循环
while(1) {
    TaskScheduler();  // 所有任务都在这里被调度
}
注意:任务函数的执行时间不能超过它的调度间隔。比如按键扫描任务每20ms执行一次,如果它本身执行了30ms,就会导致下一次调度被延迟。这就是所谓的“任务超时”。我曾经在一个电机控制项目中,因为一个任务执行时间过长,导致其他任务的实时性全部崩溃。解决办法是:要么优化任务函数,要么把耗时任务拆分成多个小步骤。

4.5 时间片轮转的核心逻辑

上面这个调度器,其实就是时间片轮转的雏形。每个任务都分到了一个“时间片”,但这个时间片不是固定的CPU执行时间,而是固定的执行间隔。每个任务在自己的时间片到来时执行一次,然后让出CPU给其他任务。

我画了一张图,帮你理解这个流程:

时间片轮转调度流程 主循环开始 获取当前Tick值 遍历任务列表 时间到了? (current - last >= interval) 是 执行任务函数 更新lastTick 否 跳过,继续下一个 每个任务独立计时,互不干扰。CPU在任务间隙可以处理中断或其他紧急事件。

4.6 软定时器的进阶用法:单次定时与周期定时

上面的例子都是周期定时——任务每隔固定时间执行一次。但实际中,我们也需要单次定时:比如按下按键后,延迟100ms做消抖,然后只执行一次。

我通常这样扩展软定时器:

typedef enum {
    TIMER_ONESHOT,    // 单次定时
    TIMER_PERIODIC    // 周期定时
} TimerMode_t;

typedef struct {
    uint32_t timeout;      // 定时时长(ms)
    uint32_t startTick;    // 启动时的Tick值
    uint8_t active;        // 是否激活
    TimerMode_t mode;      // 模式
    void (*callback)(void);// 回调函数
} SoftTimer_t;

// 启动定时器
void Timer_Start(SoftTimer_t *timer, uint32_t ms, TimerMode_t mode, void (*cb)(void)) {
    timer->timeout = ms;
    timer->startTick = GetTick();
    timer->active = 1;
    timer->mode = mode;
    timer->callback = cb;
}

// 定时器处理(在主循环中调用)
void Timer_Process(SoftTimer_t *timer) {
    if(!timer->active) return;
    
    if(GetTick() - timer->startTick >= timer->timeout) {
        if(timer->callback) {
            timer->callback();
        }
        if(timer->mode == TIMER_ONESHOT) {
            timer->active = 0;  // 单次定时,自动关闭
        } else {
            timer->startTick = GetTick();  // 周期定时,重新计时
        }
    }
}
我的习惯:在项目中,我会预定义几个全局的软定时器实例,比如 SoftTimer_t debounceTimer(按键消抖)、SoftTimer_t ledTimer(LED闪烁)、SoftTimer_t commTimeout(通信超时检测)。这样代码结构清晰,也方便调试。

4.7 避坑指南:软定时器的常见问题

我在多个项目中用过这种软定时器方案,遇到过不少坑。这里列几个最常见的:

问题 现象 原因 解决方案
定时不准 实际延时比设定值长 任务执行时间过长,导致调度延迟 拆分耗时任务,或提高SysTick中断优先级
定时器溢出 长时间运行后定时异常 GetTick()回绕导致比较逻辑错误 使用差值比较(current - start),而非直接比较大小
中断冲突 SysTick中断与其它中断互相干扰 中断优先级配置不当 合理设置NVIC优先级分组,SysTick优先级设为较低值
任务饿死 某个任务长时间得不到执行 高优先级中断或任务占用CPU太久 确保每个任务执行时间远小于调度间隔

我曾经在一个工业控制器项目中,因为SysTick中断里做了太多事情(打印日志、计算复杂算法),导致主循环的调度严重滞后。后来我把所有非必要操作都移出了中断,只在中断里做sysTickCount++这一件事,问题就解决了。记住:中断服务函数要短小精悍。

4.8 小结:从软定时器到RTOS的过渡

你现在实现的这个任务调度器,其实已经具备了RTOS的雏形。你看:

  • 任务控制块(TCB):我们定义的 Task_t 结构体,就是最简单的TCB
  • 任务调度器:TaskScheduler() 函数,就是最朴素的调度算法
  • 时间片:每个任务的执行间隔,就是它的时间片
  • 上下文切换:虽然我们没有保存寄存器,但函数调用和返回本质上就是一种上下文切换

当然,真正的RTOS比这复杂得多。它要处理任务优先级、抢占式调度、信号量、消息队列、内存管理等等。但核心思想是一样的:让CPU在多个任务之间高效切换,让每个任务都感觉自己独占CPU。