任务状态
任务有四大状态:
- Running:占用 CPU 执行中
- Ready:随时可运行,等待调度中
- Blocked:主动放弃 CPU,等待某事件(延时、信号量、队列)
- Suspended:单纯停止,不等待任何事件
状态转换规则:
- 创建后会进入 Ready 状态;
- 由调度器控制在 Ready ↔ Running 之间转换;
- Running 中调用阻塞 API 会进入 Blocked,阻塞解除会回到 Ready;
- 任意状态下调用
vTaskSuspend都会进入 Suspended,Resume后会回到 Ready,不管进入前是不是在 Blocked。
任务调度的三大规则
- 高优先级任务不放弃,低优先级永远无法执行;
- 高优先级任务一旦就绪,立即抢占运行,不会等到低优先级任务跑完;
- 多个同最高优先级任务,会由调度器基于时间片轮转控制交叉运行。
调度的核心数据结构
调度的核心数据结构是链表,包括:
- 就绪链表数组
pxReadyTasksLists[configMAX_PRIORITIES]:第 N 项存放优先级为 N 的就绪/运行态任务链表。每个任务创建时会生成 TCB,作为节点挂入对应优先级的链表。 - 全局指针
pxCurrentTCB:始终指向当前运行任务。调度时内核会修改它来选中下一个该跑的任务,然后依靠 PendSV 异常保存/恢复现场完成任务切换。PendSV 异常是 Cortex-M 上专门用于上下文切换的低优先级中断。 - 延时链表
DelayTaskList:保存阻塞延时中的任务。 - 挂起链表:保存 Suspended 任务。
所以任务的状态切换,本质上是 TCB 节点在不同链表间的搬运:
vTaskDelay:把 TCB 从就绪链表移动到DelayTaskList,并记录待阻塞的 Tick 数量。vTaskSuspend:把 TCB 移动到挂起链表。
启动顺序
创建任务时,会插入对应优先级的就绪链表,并判断新建任务是不是有更高的优先级。如果确实优先级更高,就更新 pxCurrentTCB 指向它。
因此,如果同优先级的多个任务依次创建,pxCurrentTCB 会指向最后创建的任务。
启动调度器时,还会创建优先级为 0 的空闲任务。
空闲任务与任务退出
任务函数不能直接 return:因为创建任务时会把 LR 寄存器伪造为指向 prvTaskExitError。若任务函数自然返回,会跳到 prvTaskExitError,随后会关闭中断 + 死循环,整个系统会死机。正确的退出方式是调用 vTaskDelete 自杀或他杀。
空闲任务会负责回收被删除任务的资源(TCB、栈):因为自杀的任务无法自己清理自己的遗留资源,只能由空闲任务代劳;而他杀的任务会由负责杀死的任务完成资源清理。
空闲任务是启动调度器时自动创建的,永远处于就绪状态,永不阻塞。它不但肩负回收资源的任务,还因为调度器必须要有任务正在执行——当没有任务可执行的时候,就会执行空闲任务。但这个需求不来源于调度器,而来源于 CPU:因为 CPU 不能停下来,它永远都在取指令、执行指令。
因此,如果高优先级的任务长期占用 CPU 并频繁创建、删除任务,空闲任务得不到调度,就有资源无法回收被耗尽的风险。也正因如此,不推荐反复创建和删除任务:这会反复申请堆内存,长期运行可能会分配失败。
较好的习惯是:
- 使用事件驱动 + 需要延时时主动阻塞让出 CPU,让空闲任务有机会执行;
- 配置启用空闲任务钩子函数,空闲任务每次调用都可以统计状态或者进入低功耗模式。
调度流程
系统可以配置 Tick 中断,通过宏 configTICK_RATE_HZ 决定,一般配置为 1ms 一次。
每次 Tick 中断都会做三件事:
- 累加系统计数,用作时钟基准;
- 检查
DelayTaskList,把阻塞时间到的任务移回就绪链表; - 发起调度:从高优先级向低优先级遍历就绪链表,找到第一个非空链表,靠链表内的 Index 成员取出下一个任务运行。
因为有调度优先级的存在:
vTaskDelay只能保证任务会在大于等于某个 Tick 之后才被执行;vTaskDelayUntil也只能保证最早变为 Ready 的时间,不能保证何时执行(比如有更高优先级的任务在被调度)。
需要注意的是,Tick 只是决定了基于时间的调度颗粒度,但调度不是只靠 Tick 触发的。事件驱动的调度可以不等待 Tick,比如高优先级任务就绪后的抢占,或者高优先级的按键 GPIO 中断。所以就算设置了更大的 Tick 间隔(比如 10ms)也不会让 CPU 变慢,只会让调度时间片变大。