任务与调度器:任务控制块(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(优先级):决定了谁先跑。后面会细讲。
task_name字符串指针,调试时直接打印任务名,比看地址方便一万倍。你想想看,出bug时能直接说“LED_Task卡住了”,而不是“0x200001F0那个任务挂了”,多省心。
2.2 任务状态机——就绪/运行/阻塞/挂起
一个任务在它的生命周期里,会在几个状态之间跳来跳去。我画了一张图,你看一眼就明白了:
这四个状态怎么理解?我拿生活中的例子给你讲:
- 就绪(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才能继续。
2.3.2 时间片轮转(Round-Robin)
如果多个任务优先级相同怎么办?总不能让一个任务一直跑吧?时间片轮转就是解决这个问题的。
规则:同优先级任务轮流执行,每人分一个固定长度的时间片(比如10ms)。时间片用完,任务自动让位,换下一个同优先级任务跑。
| 特性 | 抢占式调度 | 时间片轮转 |
|---|---|---|
| 触发条件 | 高优先级任务就绪 | 时间片耗尽 |
| 适用场景 | 实时性要求高的任务 | 同优先级、CPU密集型任务 |
| 任务切换时机 | 任意时刻(可抢占) | 时间片边界 |
| 优先级影响 | 高优先级绝对优先 | 仅在同优先级内生效 |
实际项目中,我一般这样用:
- 关键任务(如电机控制、通信协议栈)给高优先级,用抢占式。
- 非关键任务(如LED闪烁、日志输出)给低优先级,用时间片轮转。
- 空闲任务给最低优先级,CPU没事干时跑它——后面讲低功耗时会重点说这个。
2.4 调度器核心流程——从TCB到任务切换
调度器到底怎么工作的?我简化一下流程,你感受一下:
- 触发调度:系统滴答定时器中断、任务主动让出CPU(如调用
delay())、或者高优先级任务就绪。 - 保存现场:当前任务的CPU寄存器全部压入它的栈,然后把栈指针sp保存到TCB里。
- 选下一个任务:遍历就绪队列,找优先级最高的那个。如果同优先级有多个,按时间片规则选。
- 恢复现场:从选中任务的TCB里取出sp,然后从栈里弹出所有寄存器。
- 执行新任务:CPU跳转到新任务的断点处继续执行。
整个过程,从触发到新任务开始跑,通常只需要几十条汇编指令。我优化过一个调度器,把切换时间压到了3微秒以内——嗯,那是在一个72MHz的Cortex-M3上。
printf(),结果任务切换一次要几百微秒——整个系统的实时性直接崩了。
好了,任务和调度器的核心概念就这些。TCB是基础,状态机是行为规则,调度策略是决策逻辑。这三样东西搞明白了,RTOS的“骨架”你就搭起来了。下一块内容,我们会把这些东西串起来,看看事件驱动机制怎么让任务之间高效通信——嗯,那是另一个有意思的话题了。