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-31pcName:任务名字符串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);
删除任务时,内核会:
- 把任务从就绪队列移除
- 释放栈空间
- 把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 最低
- 系统永远运行就绪队列中优先级最高的任务
- 如果高优先级任务就绪,立即抢占当前运行的低优先级任务
- 同优先级任务采用时间片轮转
调度决策流程如下:
优先级反转?你想想看,低优先级任务拿了锁,高优先级任务等锁,中优先级任务抢了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 函数。这些都是我用实际加班换来的教训。