4、时间片轮询架构:超级循环的缺陷,时间片轮询思想,定时器调度器实现,任务优先级划分

好,咱们今天聊一个嵌入式开发里绕不开的话题——时间片轮询架构。

说实话,我见过太多新手工程师,甚至有些工作两三年的朋友,还在用超级循环(Super Loop)硬扛所有逻辑。不是说超级循环不能用,而是你得知道它什么时候该退休了。

4.1 超级循环的“死穴”

先说说超级循环长什么样。说白了就是 while(1) 里塞一堆函数调用,一个接一个跑。

void main(void)
{
    while(1)
    {
        Key_Scan();      // 按键扫描
        LED_Process();   // LED 显示
        UART_Process();  // 串口处理
        ADC_Process();   // ADC 采集
        // ... 还有更多
    }
}

这段代码看着挺规整对吧?但实际跑起来,问题就来了。

我曾经踩过的一个坑: 有个项目用超级循环做温控器,按键扫描里加了个去抖延时,结果整个循环周期被拉长到 50ms。ADC 采集频率不够,温度控制直接震荡。查了两天才找到根儿——就是那个延时把时间轴打乱了。

超级循环的核心缺陷,我总结成三点:

  • 任务互相阻塞: 一个任务卡住,后面的全得等着。你想想看,按键扫描里如果用了 delay_ms(20),那 LED 刷新就得等 20ms。
  • 实时性无法保证: 每个任务的执行周期完全取决于前面任务的总耗时。今天代码少跑得快,明天加个功能就变慢了,不可控。
  • 扩展性差: 加一个新功能就得重新算一遍循环周期,改来改去容易出 bug。

所以,当你的项目里同时有按键、显示、通信、采集这些任务时,超级循环基本就撑不住了。

4.2 时间片轮询思想:把时间切成“蛋糕”

时间片轮询的思路其实很简单——把 CPU 的时间切成固定长度的小片,每个任务分一片。谁也别占着不放,大家轮流用。

我个人习惯把这个思想叫做“分时复用”。就像公司开会,每人发言 5 分钟,时间到了就换下一个人。这样谁都有机会说话,不会出现一个人讲半小时的情况。

核心原则: 每个任务必须在自己的时间片内执行完毕,不能阻塞等待。如果任务需要较长时间,就拆成多个小步骤,分多次执行。

时间片轮询和超级循环的对比,我整理了一张表:

对比项 超级循环 时间片轮询
任务调度方式 顺序执行,一个接一个 分时轮转,每个任务固定时间
实时性 差,受任务耗时影响 好,每个任务周期可控
扩展性 差,加功能需重新评估 好,新增任务只需分配时间片
代码复杂度 低 中等
适用场景 极简系统,任务少 多任务,中等复杂度

说白了,时间片轮询就是给超级循环加了个“调度器”,让系统变得有序可控。

4.3 定时器调度器实现:从理论到代码

好,理论说完了,咱们直接上代码。我常用的定时器调度器实现,核心就两个东西:一个硬件定时器产生心跳,一个任务链表管理任务。

先看定时器部分。我一般用 SysTick 或者通用定时器,设置 1ms 中断一次。

// 定时器中断服务函数,1ms 触发一次
void Timer_IRQHandler(void)
{
    static uint32_t tick = 0;
    tick++;
    // 这里只做一件事:更新系统心跳
    SystemTick = tick;
}

然后定义任务控制块(TCB):

typedef struct {
    void (*func)(void);   // 任务函数指针
    uint32_t period;      // 任务周期(单位:心跳数)
    uint32_t elapsed;     // 已过去的心跳数
    uint8_t  priority;    // 优先级(0最高)
    uint8_t  running;     // 运行标志
} Task_t;

调度器主循环长这样:

void Scheduler_Run(void)
{
    uint32_t i;
    uint32_t currentTick = SystemTick;

    for(i = 0; i < TASK_MAX; i++)
    {
        // 跳过无效任务
        if(taskList[i].func == NULL) continue;

        // 计算是否到达执行时间
        if((currentTick - taskList[i].elapsed) >= taskList[i].period)
        {
            taskList[i].elapsed = currentTick;
            taskList[i].running = 1;
            taskList[i].func();   // 执行任务
            taskList[i].running = 0;
        }
    }
}

一个小技巧: 我用 currentTick - taskList[i].elapsed 而不是直接累加,这样可以避免 tick 溢出带来的问题。32 位无符号数溢出后,减法依然能得到正确结果,这是利用了补码的特性。

注册任务也很简单:

void Task_Register(uint8_t id, void (*func)(void), uint32_t period_ms, uint8_t priority)
{
    taskList[id].func     = func;
    taskList[id].period   = period_ms;
    taskList[id].elapsed  = 0;
    taskList[id].priority = priority;
    taskList[id].running  = 0;
}

嗯,这里要注意一点:任务的执行时间不能超过它的周期。比如一个 10ms 的任务,你执行了 15ms,那下次调度就会出问题。我在项目中遇到过这种情况,最后是通过任务拆分解决的。

4.4 任务优先级划分:谁先谁后?

时间片轮询虽然公平,但有些任务确实更紧急。比如按键响应可以等 50ms,但电机堵转保护必须在 1ms 内处理。这时候就需要优先级了。

我一般把任务分成三个等级:

  • 高优先级(0-1): 紧急保护、通信超时检测。这些任务必须在最短时间内响应。
  • 中优先级(2-3): 数据采集、状态更新。周期固定,但可以容忍少量延迟。
  • 低优先级(4-5): 界面刷新、日志输出。晚几十毫秒无所谓。

调度器里加个优先级判断:

void Scheduler_Run_Priority(void)
{
    uint32_t i, j;
    uint32_t currentTick = SystemTick;
    Task_t *task;

    // 按优先级从高到低遍历
    for(j = 0; j <= PRIORITY_MAX; j++)
    {
        for(i = 0; i < TASK_MAX; i++)
        {
            task = &taskList[i];
            if(task->func == NULL) continue;
            if(task->priority != j) continue;

            if((currentTick - task->elapsed) >= task->period)
            {
                task->elapsed = currentTick;
                task->func();
            }
        }
    }
}

我曾经犯过的错: 一开始我把高优先级任务放在最前面,结果低优先级任务永远得不到执行——因为高优先级任务每次都会抢占 CPU。后来我改成“每个优先级轮询一次,高优先级先执行”,才解决了这个问题。

下面这张图展示了时间片轮询架构的整体流程:

时间片轮询调度架构 硬件定时器(1ms心跳) SystemTick 全局变量 调度器主循环 Scheduler_Run() 任务链表(按优先级排序) 高优先级(保护/超时) 中优先级(采集/状态) 低优先级(显示/日志) 周期:1-5ms 周期:10-50ms 周期:100-500ms 调度器按优先级从高到低遍历任务,到达执行周期的任务立即执行,未到达的跳过

4.5 实际项目中的避坑指南

最后分享几个我在实际项目中积累的经验:

  • 任务不要做死等: 比如用 while(!flag) 等待某个事件,这会阻塞整个调度器。改成状态机或者回调。
  • 心跳频率要合理: 1ms 是常用值,但如果你所有任务周期都是 10ms 以上,用 5ms 心跳可以减少中断开销。
  • 任务执行时间要测量: 用 GPIO 翻转 + 示波器看每个任务的执行时间,确保不超过周期的一半。
  • 临界区保护: 如果任务和中断共享变量,记得关中断保护。我习惯用 __disable_irq() 和 __enable_irq() 包裹。

总结一下: 时间片轮询架构是超级循环到 RTOS 之间的“黄金过渡方案”。它不需要复杂的任务切换和栈管理,但已经能解决大部分多任务调度问题。我个人的建议是:如果你的项目任务数在 5-15 个之间,周期要求不苛刻(1ms 以上),时间片轮询是最优解。