4. 调度器核心API设计:任务创建、任务删除、任务挂起、任务恢复
调度器的API,说白了就是你和内核打交道的接口。任务怎么加进来?怎么踢出去?怎么让它暂停?怎么让它继续跑?这些操作如果设计得不好,后面写应用代码时会非常痛苦。我见过不少团队,调度器内核写得挺漂亮,结果API一塌糊涂,开发者用起来一头雾水。
这一章,我们就来把这四个核心API掰开揉碎讲清楚。我会结合自己踩过的坑,告诉你每个接口背后要注意什么。
4.1 任务创建:让任务“活”起来
任务创建是调度器最基础的API。没有它,其他都是空谈。我个人习惯把任务创建接口设计成下面这样:
typedef enum {
TASK_READY, // 就绪态
TASK_RUNNING, // 运行态
TASK_BLOCKED, // 阻塞态
TASK_SUSPENDED, // 挂起态
TASK_DELETED // 删除态
} TaskState;
typedef struct {
uint32_t id; // 任务ID
TaskState state; // 当前状态
void (*entry)(void*); // 入口函数
void* param; // 入口参数
uint32_t *stack; // 栈指针
uint32_t stack_size; // 栈大小
uint32_t priority; // 优先级
uint32_t *sp; // 当前栈顶(上下文保存用)
} TCB;
int TaskCreate(TaskHandle *handle,
void (*entry)(void*),
void *param,
uint32_t *stack,
uint32_t stack_size,
uint32_t priority);
嗯,这里要注意几个关键点:
- 栈由谁分配? 我建议让调用者自己分配栈空间。为什么?因为有些场景下任务栈需要放在特定内存区域(比如内部SRAM或外部SDRAM),调度器统一分配反而限制了灵活性。
- 优先级怎么定? 数值越小优先级越高,还是反过来?我个人偏好“数值越小优先级越高”,因为和中断优先级的设计思路一致,不容易搞混。
- 返回值怎么设计? 返回0表示成功,负数表示错误码。我在项目中遇到过有人把返回值设计成布尔值,结果调试时根本不知道哪里出了问题。
小技巧: 任务创建时,最好在TCB里记录一下创建时间戳或序号。这样调试时能快速定位“哪个任务是什么时候创建的”,对排查资源泄漏问题很有帮助。
4.2 任务删除:优雅地“退场”
任务删除比创建要复杂得多。你想想看,一个任务可能正在使用某个互斥锁,或者正在操作一个共享缓冲区,这时候你直接把它删了,系统就乱套了。
int TaskDelete(TaskHandle handle);
接口看起来很简单,但内部实现要考虑几个问题:
- 能不能删除自己? 可以。任务调用
TaskDelete(NULL)或TaskDelete(当前任务句柄)来结束自己。这时候调度器要标记该任务为TASK_DELETED,然后在下次调度时切走,不再调度它。 - 能不能删除别人? 可以,但要小心。我曾经遇到过一个场景:任务A删除了任务B,但任务B正持有一个信号量,结果导致任务A后续申请信号量时永远拿不到——死锁了。
- 资源回收怎么办? 任务删除后,它占用的栈空间、TCB内存要不要释放?如果栈是调用者分配的,调度器不应该碰;如果TCB是动态分配的,调度器要负责回收。
避坑指南: 我曾经在一个项目里,任务删除后没有清理就绪队列中的节点,导致调度器在遍历就绪队列时访问了野指针。从那以后,我强制要求:任务删除必须同时从所有队列(就绪队列、阻塞队列、挂起队列)中移除该任务。
4.3 任务挂起:让任务“睡个安稳觉”
任务挂起和任务阻塞有什么区别?很多人搞混了。阻塞是因为任务在等某个资源(比如信号量、消息队列),一旦资源可用,内核会自动把它唤醒。而挂起是“人为”让任务暂停,必须由另一个任务显式调用恢复接口才能唤醒。
int TaskSuspend(TaskHandle handle);
挂起操作的核心逻辑:
- 检查目标任务是否处于
TASK_RUNNING或TASK_READY状态。 - 如果是,将其状态改为
TASK_SUSPENDED,并从就绪队列中移除。 - 如果挂起的是当前正在运行的任务,立即触发一次任务调度。
这里有个细节:任务在挂起状态下,如果它之前正在等待某个事件(比如延时到期),这个等待要不要取消?我个人建议取消。因为挂起意味着任务“失联”了,等它恢复时,原来的等待条件可能已经变了。
经验之谈: 我在做低功耗项目时,经常用挂起/恢复来控制任务的运行时机。比如,系统进入省电模式前,挂起所有非关键任务;唤醒后,再逐个恢复。这样比动态创建/删除任务要高效得多。
4.4 任务恢复:精准“唤醒”
恢复接口和挂起是对称的:
int TaskResume(TaskHandle handle);
恢复时要注意:
- 只能恢复处于
TASK_SUSPENDED状态的任务。 - 恢复后,任务进入
TASK_READY状态,加入就绪队列。 - 如果恢复的任务优先级比当前运行任务高,要不要立即抢占?这取决于你的调度策略。我一般会触发一次调度检查,让高优先级任务尽快运行。
还有一个容易被忽略的点:嵌套挂起。有些调度器支持“挂起计数”,即任务被挂起多次,需要恢复同样次数才能真正唤醒。我个人不太推荐这种设计,太容易出错了。你想想看,如果任务A挂起了任务B两次,但只恢复了一次,任务B就永远醒不过来了。
4.5 API设计对比总结
下面这个表格,是我整理的不同调度器在API设计上的常见差异:
| 特性 | 方案A(我推荐的) | 方案B(不推荐的) | 原因 |
|---|---|---|---|
| 栈分配方式 | 调用者分配 | 调度器内部malloc | 调用者分配更灵活,避免堆碎片 |
| 优先级数值 | 越小优先级越高 | 越大优先级越高 | 与中断优先级习惯一致 |
| 挂起计数 | 不支持 | 支持 | 嵌套挂起容易导致任务永久挂起 |
| 删除自身 | 允许,延迟回收 | 不允许 | 允许自身删除更符合实际需求 |
| 恢复后抢占 | 立即检查 | 等待下次调度 | 立即检查能保证实时性 |
4.6 核心API关系图
下面这张图,展示了四个API之间的状态转换关系:
4.7 避坑指南与最佳实践
最后,我把自己这些年踩过的坑总结成几条原则,希望能帮你少走弯路:
避坑1:不要在中断服务函数中调用任务创建/删除。 我曾经在一个项目中,ISR里调用了TaskCreate,结果因为创建过程中需要申请内存,而内存分配函数不可重入,直接导致系统崩溃。如果非要在中断里操作,建议用消息队列把请求发给一个专门的管理任务。
避坑2:任务删除后,要确保所有引用该任务的指针都被清理。 比如,其他任务可能保存了该任务的句柄,删除后这些句柄就成了野指针。我一般会在TCB里加一个“魔法数”字段,每次访问句柄前校验一下,防止操作已删除的任务。
最佳实践: 设计API时,尽量让每个接口的语义“原子化”。比如,TaskSuspend只做挂起,不要在里面偷偷做资源回收。这样上层逻辑清晰,也方便做单元测试。