任务与调度器:任务控制块(TCB)、任务状态机与调度策略

各位同学,咱们今天聊点实在的。任务和调度器,说白了就是RTOS的“心脏”和“大脑”。你写的所有业务逻辑,最终都要变成一个个任务,交给调度器去管理。我刚开始用RTOS那会儿,总觉得任务就是“一个死循环里放点代码”,结果项目一复杂,各种优先级反转、死锁、任务饿死的问题全冒出来了。嗯,后来我才明白——不理解任务控制块和状态机,你写的RTOS代码就是“裸奔”。

2.1 任务控制块(TCB)——任务的“身份证”

每个任务在RTOS里都有一个专属的数据结构,叫任务控制块(Task Control Block,TCB)。你可以把它想象成任务的“身份证”加“档案袋”。调度器不关心你的任务函数里写了什么,它只认TCB。

一个典型的TCB长什么样?我直接给你看代码:

typedef struct tcb {
    uint32_t    *sp;            // 栈指针(核心!)
    uint8_t     priority;       // 任务优先级
    uint8_t     state;          // 当前状态
    uint32_t    stack_size;     // 栈大小
    uint32_t    *stack_base;    // 栈底地址
    void        (*entry)(void*);// 任务入口函数
    void        *arg;           // 入口参数
    uint32_t    delay_ticks;    // 延时计数(阻塞用)
    uint32_t    slice_remain;   // 时间片剩余计数
    struct tcb  *next;          // 链表指针
} tcb_t;

这里我重点说三个字段:

  • sp(栈指针):这是TCB里最重要的东西。任务切换时,CPU的寄存器全部压栈,然后sp被保存到TCB里。下次切回来时,直接从TCB恢复sp,CPU就知道该从哪继续执行。我在一个项目里遇到过栈溢出导致sp乱飞,结果任务切回来直接跑飞——从那以后,我每个任务都至少留20%的栈余量。
  • state(状态):任务当前处于哪个状态?就绪、运行、阻塞还是挂起?调度器只看这个字段做决策。
  • priority(优先级):决定了谁先跑。后面会细讲。
我的习惯:TCB里我还会加一个task_name字符串指针,调试时直接打印任务名,比看地址方便一万倍。你想想看,出bug时能直接说“LED_Task卡住了”,而不是“0x200001F0那个任务挂了”,多省心。

2.2 任务状态机——就绪/运行/阻塞/挂起

一个任务在它的生命周期里,会在几个状态之间跳来跳去。我画了一张图,你看一眼就明白了:

RTOS 任务状态机 就绪 (Ready) 等待CPU 运行 (Running) 正在执行 阻塞 (Blocked) 等待事件/延时 挂起 (Suspended) 人为暂停 调度器选中 时间片用完/被抢占 等待资源/延时 事件到达/延时结束 任务挂起API 任务恢复API 部分RTOS支持 💡 关键点: 同一时刻只有一个任务处于 Running 状态。就绪队列里可能有多个任务,调度器按优先级选一个跑。

这四个状态怎么理解?我拿生活中的例子给你讲:

  • 就绪(Ready):你排队等公交车。车来了(CPU空闲了),谁先上车?优先级高的先上。
  • 运行(Running):你上车了,正在坐车。CPU正在执行你的代码。
  • 阻塞(Blocked):你等红灯。任务在等某个事件(信号量、消息队列、延时到期)。事件没到,CPU不会理你。
  • 挂起(Suspended):你下车去便利店买水,车不等你。任务被主动暂停,必须由另一个任务调用恢复API才能继续。
我曾经踩过的坑:新手最容易把“阻塞”和“挂起”搞混。阻塞是任务自己主动等的(比如调了delay()),事件到了自动变就绪。挂起是别人把它“冻住”了,必须别人解冻。我在一个项目中用挂起做“任务暂停”,结果忘了恢复,那个任务就永远“消失”了——调试了两天才找到原因。

2.3 任务优先级与调度策略

调度策略,说白了就是“下一个该谁跑”的规则。RTOS里主流的有两种:抢占式调度和时间片轮转。我建议你先把这两种搞清楚,其他变种都是它们的组合。

2.3.1 抢占式调度(Preemptive Scheduling)

这是RTOS的“招牌功能”。规则很简单:高优先级任务一旦就绪,立即打断当前正在运行的低优先级任务。

你看这个例子:

// 任务A:优先级5,负责按键扫描
void task_A(void *arg) {
    while(1) {
        if (key_pressed()) {
            // 发送信号量给任务B
            sem_post(&sem);
        }
        delay(10);  // 阻塞10ms
    }
}

// 任务B:优先级10(更高),负责响应按键
void task_B(void *arg) {
    while(1) {
        sem_wait(&sem);  // 等信号量,阻塞
        // 处理按键事件
        handle_key_event();
    }
}

任务B优先级高,平时在等信号量(阻塞)。任务A检测到按键,发信号量——任务B立刻变就绪,调度器一看:B优先级比A高,直接抢!A被踢回就绪队列,B开始跑。等B处理完再次阻塞,A才能继续。

核心优势:实时性有保障。高优先级任务响应延迟只取决于上下文切换时间,通常几微秒到几十微秒。这也是RTOS敢叫“实时”的原因。

2.3.2 时间片轮转(Round-Robin)

如果多个任务优先级相同怎么办?总不能让一个任务一直跑吧?时间片轮转就是解决这个问题的。

规则:同优先级任务轮流执行,每人分一个固定长度的时间片(比如10ms)。时间片用完,任务自动让位,换下一个同优先级任务跑。

特性 抢占式调度 时间片轮转
触发条件 高优先级任务就绪 时间片耗尽
适用场景 实时性要求高的任务 同优先级、CPU密集型任务
任务切换时机 任意时刻(可抢占) 时间片边界
优先级影响 高优先级绝对优先 仅在同优先级内生效

实际项目中,我一般这样用:

  • 关键任务(如电机控制、通信协议栈)给高优先级,用抢占式。
  • 非关键任务(如LED闪烁、日志输出)给低优先级,用时间片轮转。
  • 空闲任务给最低优先级,CPU没事干时跑它——后面讲低功耗时会重点说这个。
我的建议:时间片别设太短。我见过有人设1ms,结果系统70%的时间都在做上下文切换,任务反而跑不动了。一般5-20ms比较合理,具体看你的任务切换开销。你可以用GPIO翻转+示波器量一下实际切换时间。

2.4 调度器核心流程——从TCB到任务切换

调度器到底怎么工作的?我简化一下流程,你感受一下:

  1. 触发调度:系统滴答定时器中断、任务主动让出CPU(如调用delay())、或者高优先级任务就绪。
  2. 保存现场:当前任务的CPU寄存器全部压入它的栈,然后把栈指针sp保存到TCB里。
  3. 选下一个任务:遍历就绪队列,找优先级最高的那个。如果同优先级有多个,按时间片规则选。
  4. 恢复现场:从选中任务的TCB里取出sp,然后从栈里弹出所有寄存器。
  5. 执行新任务:CPU跳转到新任务的断点处继续执行。

整个过程,从触发到新任务开始跑,通常只需要几十条汇编指令。我优化过一个调度器,把切换时间压到了3微秒以内——嗯,那是在一个72MHz的Cortex-M3上。

注意:调度器本身也是代码,它执行的时候算“系统时间”,不算在任何任务头上。所以调度器里别干耗时的事,比如打印日志、做复杂计算。我曾经见过有人在调度器里调printf(),结果任务切换一次要几百微秒——整个系统的实时性直接崩了。

好了,任务和调度器的核心概念就这些。TCB是基础,状态机是行为规则,调度策略是决策逻辑。这三样东西搞明白了,RTOS的“骨架”你就搭起来了。下一块内容,我们会把这些东西串起来,看看事件驱动机制怎么让任务之间高效通信——嗯,那是另一个有意思的话题了。