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);

挂起操作的核心逻辑:

  1. 检查目标任务是否处于 TASK_RUNNING 或 TASK_READY 状态。
  2. 如果是,将其状态改为 TASK_SUSPENDED,并从就绪队列中移除。
  3. 如果挂起的是当前正在运行的任务,立即触发一次任务调度。

这里有个细节:任务在挂起状态下,如果它之前正在等待某个事件(比如延时到期),这个等待要不要取消?我个人建议取消。因为挂起意味着任务“失联”了,等它恢复时,原来的等待条件可能已经变了。

经验之谈: 我在做低功耗项目时,经常用挂起/恢复来控制任务的运行时机。比如,系统进入省电模式前,挂起所有非关键任务;唤醒后,再逐个恢复。这样比动态创建/删除任务要高效得多。

4.4 任务恢复:精准“唤醒”

恢复接口和挂起是对称的:

int TaskResume(TaskHandle handle);

恢复时要注意:

  • 只能恢复处于 TASK_SUSPENDED 状态的任务。
  • 恢复后,任务进入 TASK_READY 状态,加入就绪队列。
  • 如果恢复的任务优先级比当前运行任务高,要不要立即抢占?这取决于你的调度策略。我一般会触发一次调度检查,让高优先级任务尽快运行。

还有一个容易被忽略的点:嵌套挂起。有些调度器支持“挂起计数”,即任务被挂起多次,需要恢复同样次数才能真正唤醒。我个人不太推荐这种设计,太容易出错了。你想想看,如果任务A挂起了任务B两次,但只恢复了一次,任务B就永远醒不过来了。

4.5 API设计对比总结

下面这个表格,是我整理的不同调度器在API设计上的常见差异:

特性 方案A(我推荐的) 方案B(不推荐的) 原因
栈分配方式 调用者分配 调度器内部malloc 调用者分配更灵活,避免堆碎片
优先级数值 越小优先级越高 越大优先级越高 与中断优先级习惯一致
挂起计数 不支持 支持 嵌套挂起容易导致任务永久挂起
删除自身 允许,延迟回收 不允许 允许自身删除更符合实际需求
恢复后抢占 立即检查 等待下次调度 立即检查能保证实时性

4.6 核心API关系图

下面这张图,展示了四个API之间的状态转换关系:

调度器核心API状态转换图 调度器核心API状态转换图 TaskCreate TASK_READY 就绪态 TASK_RUNNING 运行态 TASK_SUSPENDED 挂起态 TASK_DELETED 删除态 创建成功 调度 时间片用完 TaskSuspend TaskSuspend TaskResume TaskDelete TaskDelete TaskDelete 说明:实线箭头表示API调用导致的状态转换。注意,只有TASK_READY和TASK_RUNNING状态的任务可以被挂起。 TASK_DELETED是终态,任务一旦进入删除态,TCB和栈资源由调度器或调用者负责回收。

4.7 避坑指南与最佳实践

最后,我把自己这些年踩过的坑总结成几条原则,希望能帮你少走弯路:

避坑1:不要在中断服务函数中调用任务创建/删除。 我曾经在一个项目中,ISR里调用了TaskCreate,结果因为创建过程中需要申请内存,而内存分配函数不可重入,直接导致系统崩溃。如果非要在中断里操作,建议用消息队列把请求发给一个专门的管理任务。
避坑2:任务删除后,要确保所有引用该任务的指针都被清理。 比如,其他任务可能保存了该任务的句柄,删除后这些句柄就成了野指针。我一般会在TCB里加一个“魔法数”字段,每次访问句柄前校验一下,防止操作已删除的任务。
最佳实践: 设计API时,尽量让每个接口的语义“原子化”。比如,TaskSuspend只做挂起,不要在里面偷偷做资源回收。这样上层逻辑清晰,也方便做单元测试。