3、任务管理与调度机制:任务控制块(TCB)数据结构、任务创建/删除/挂起/恢复API、优先级抢占调度算法、任务切换的现场保护与恢复(PendSV)

任务管理,说白了就是内核怎么管好你那一堆并发跑的任务。我刚开始接触LiteOS-M时,觉得任务调度不就是轮着来嘛,后来踩了几个坑才明白——任务控制块、优先级抢占、现场保护,每一个都是硬骨头。

这一章,咱们就把任务管理的底裤扒干净。

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

每个任务在内核里都有一个对应的数据结构,叫任务控制块(Task Control Block,TCB)。内核不认你的函数名,只认TCB。说白了,TCB就是任务在操作系统里的“身份证”。

LiteOS-M的TCB定义在 los_task.h 里,核心字段如下:

字段 类型 说明
taskId UINT32 任务唯一ID,内核通过它索引任务
taskStatus UINT16 任务状态(就绪/阻塞/挂起/运行)
priority UINT16 任务优先级,数值越小优先级越高
stackPointer UINT32 * 当前栈指针,任务切换时保存现场用
topOfStack UINT32 * 栈顶地址,初始化时设定
stackSize UINT32 栈大小(字节)
taskName CHAR * 任务名称,调试用
timeSlice UINT32 时间片(同优先级轮转时使用)
entryFunc TSK_ENTRY_FUNC 任务入口函数指针

关键点:TCB 中的 stackPointer 是任务切换的核心。每次切换,CPU 都把当前寄存器的值压栈,然后更新这个指针。我见过有人手动修改 stackPointer 导致栈回溯全乱,嗯,别这么干。

3.2 任务创建与删除——让任务“生”与“死”

任务创建,内核会做三件事:分配TCB、分配栈空间、初始化栈帧。删除则反过来,回收资源。

3.2.1 任务创建 API:LOS_TaskCreate

UINT32 LOS_TaskCreate(UINT32 *taskId, TSK_INIT_PARAM_S *taskInitParam);

TSK_INIT_PARAM_S 结构体需要你填这些:

  • pfnTaskEntry:任务函数入口
  • uwStackSize:栈大小,单位是字节
  • usTaskPrio:优先级,范围 0-31
  • pcName:任务名字符串
  • uwArg:传给任务函数的参数

举个例子:

UINT32 g_taskId;
TSK_INIT_PARAM_S taskParam = {0};

taskParam.pfnTaskEntry = (TSK_ENTRY_FUNC)MyTaskFunc;
taskParam.uwStackSize  = 0x1000;   // 4KB 栈
taskParam.usTaskPrio   = 5;        // 优先级 5
taskParam.pcName       = "MyTask";
taskParam.uwArg        = 0;

UINT32 ret = LOS_TaskCreate(&g_taskId, &taskParam);
if (ret != LOS_OK) {
    // 创建失败,处理错误
}

我的习惯:栈大小别拍脑袋定。我一般先给 0x1000(4KB),跑起来后用 LOS_TaskStackCheck 看实际使用量,再微调。给大了浪费内存,给小了直接栈溢出——系统静默崩溃,你查都查不到。

3.2.2 任务删除 API:LOS_TaskDelete

UINT32 LOS_TaskDelete(UINT32 taskId);

删除任务时,内核会:

  1. 把任务从就绪队列移除
  2. 释放栈空间
  3. 把TCB标记为未使用

注意:千万不要在中断服务函数里调用 LOS_TaskDelete 删除自己或别的任务。我曾经在 ISR 里删了一个持有互斥锁的任务,结果其他任务死锁,整个系统卡死。删除操作必须在任务上下文中执行。

3.3 任务挂起与恢复——让任务“睡”与“醒”

挂起和恢复,比创建删除更常用。挂起是把任务从就绪队列摘下来,恢复是再挂回去。

3.3.1 挂起 API:LOS_TaskSuspend

UINT32 LOS_TaskSuspend(UINT32 taskId);

调用后,任务进入挂起状态(OS_TASK_STATUS_SUSPEND),不再参与调度。

3.3.2 恢复 API:LOS_TaskResume

UINT32 LOS_TaskResume(UINT32 taskId);

恢复后,任务重新回到就绪队列,等待调度。

避坑指南:我曾经在调试一个传感器驱动时,挂起了一个任务后忘了恢复,结果那个任务永远不跑了。更坑的是,它占着某个资源,其他任务也在等。最后用 LOS_TaskInfoGet 查任务状态才发现是挂起状态。所以,挂起和恢复一定要成对使用,最好用状态机管理。

3.4 优先级抢占调度算法——谁优先级高谁先跑

LiteOS-M 用的是基于优先级的抢占式调度。什么意思?

  • 每个任务有一个优先级(0-31),0 最高,31 最低
  • 系统永远运行就绪队列中优先级最高的任务
  • 如果高优先级任务就绪,立即抢占当前运行的低优先级任务
  • 同优先级任务采用时间片轮转

调度决策流程如下:

优先级抢占调度决策流程 任务就绪事件 检查就绪队列 有更高优先级任务? 无更高优先级任务 触发任务切换 继续当前任务 PendSV 现场切换

优先级反转?你想想看,低优先级任务拿了锁,高优先级任务等锁,中优先级任务抢了CPU——高优先级反而跑不了。LiteOS-M 支持优先级继承协议,但需要你手动开启。我建议在涉及共享资源的任务中,把 LOSCFG_KERNEL_PRIO_INHERITANCE 打开。

3.5 任务切换的现场保护与恢复(PendSV)

任务切换,说白了就是“保存当前任务的状态,恢复下一个任务的状态”。这个操作在 ARM Cortex-M 上是通过 PendSV 异常实现的。

3.5.1 为什么用 PendSV?

你想想看,如果直接在 SysTick 里做切换,万一 SysTick 抢了某个高优先级中断,那实时性就崩了。PendSV 是可悬起的异常,优先级可以设到最低。这样:

  • SysTick 只负责标记“需要切换”
  • 真正的切换在 PendSV 里执行
  • PendSV 优先级最低,等所有中断处理完才执行

3.5.2 现场保护的内容

一次完整的任务切换,需要保存和恢复以下寄存器:

寄存器组 保存方式 说明
R0-R3, R12, LR, PC, xPSR 硬件自动压栈 进入异常时 CPU 自动压入当前栈
R4-R11 软件手动压栈 PendSV 处理函数中手动压栈
PSP(进程栈指针) 更新 TCB 中的 stackPointer 指向当前任务栈顶

3.5.3 PendSV 处理流程

LiteOS-M 的 PendSV 处理函数核心逻辑:

__asm void PendSV_Handler(void)
{
    IMPORT  g_stLosTask      // 当前任务 TCB
    IMPORT  g_stNewTask      // 新任务 TCB

    // 1. 保存当前任务上下文(R4-R11)
    MRS     R0, PSP          // 获取当前进程栈指针
    STMFD   R0!, {R4-R11}    // 手动压栈 R4-R11

    // 2. 更新当前任务的 stackPointer
    LDR     R1, =g_stLosTask
    LDR     R1, [R1]
    STR     R0, [R1]         // 保存栈指针到 TCB

    // 3. 切换 TCB 指针
    LDR     R0, =g_stNewTask
    LDR     R0, [R0]
    LDR     R1, =g_stLosTask
    STR     R0, [R1]

    // 4. 恢复新任务上下文
    LDR     R0, [R0]         // 获取新任务的栈指针
    LDMFD   R0!, {R4-R11}    // 弹出 R4-R11
    MSR     PSP, R0          // 更新 PSP

    // 5. 返回,硬件自动恢复 R0-R3, R12, LR, PC, xPSR
    BX      LR
}

关键点:注意看,硬件自动压栈和软件手动压栈是分开的。硬件在进入 PendSV 时已经压了 R0-R3、R12、LR、PC、xPSR,软件只需要压 R4-R11。返回时,硬件自动恢复那 8 个寄存器。这个分工是 Cortex-M 设计好的,别搞混。

3.5.4 现场恢复的陷阱

我遇到过一个问题:任务切换后,新任务的 LR 值不对,导致函数返回时跑飞了。查了半天才发现,是 PendSV 返回时用了 BX LR,但 LR 在进入 PendSV 时被硬件改成了 EXC_RETURN。正确的做法是:

  • 不要手动修改 PendSV 里的 LR
  • 确保新任务的栈帧里保存了正确的 LR 值
  • 返回时用 BX LR,硬件会自动判断返回模式

重要:如果你在 PendSV 里调用了 C 函数,LR 会被覆盖!所以 PendSV 处理函数要么纯汇编,要么在汇编里先保存 LR。我习惯把 PendSV 写成纯汇编,一个 C 函数都不调,稳得很。

3.6 小结

任务管理这块,说白了就是三板斧:TCB 管身份,API 管生死,PendSV 管切换。优先级抢占调度是 LiteOS-M 的调度核心,理解它你才能写出不卡死的系统。

嗯,这一章的内容就到这。记住:TCB 别乱改,挂起恢复要成对,PendSV 里别调 C 函数。这些都是我用实际加班换来的教训。