uCOS-II任务管理与调度:OS_TCB、就绪表与上下文切换
2026/9/17 12:31:41 网站建设 项目流程

简介:面向嵌入式开发初学者与进阶工程师的UCOS II任务管理与调度课件,共170页,聚焦实时操作系统内核机制的落地讲解。内容从任务控制块TCB与就绪、运行、等待、休眠、僵死五种任务状态切入,延伸至RMS、时间片轮转等调度算法及TDMA、固定优先级两种调度策略,并覆盖中断响应处理,以及信号量、互斥锁、条件变量等同步通信机制,配合示例代码帮助理解任务间的资源竞争与事件通知。课件按任务管理、调度机制、中断处理与同步通信几个模块顺序展开,脉络清晰,适合高校课程设计、嵌入式培训讲授与自学复盘,也可作为项目中任务划分、优先级设定与实时性优化的参考。整份资料为1个pptx文件,压缩包约9.8MB,页面编排完整。目前已有153人学习下载。

1. uCOS-II 任务管理与调度:从三个不同周期的 LED 说起

三个 LED 要分别以 100ms、300ms、700ms 闪烁,裸机上写三段 while 加空循环也能跑,可一旦再叠上串口收包和按键扫描,任何一处延时都会把另外两个的节奏带偏。uCOS-II 这类实时操作系统给出的解法,是把每个循环体变成独立任务,由内核按优先级抢占式调度,高优先级任务在事件到来的瞬间就能拿到 CPU。任务管理和调度正是这套机制的两块地基:任务管理回答任务是什么、怎么创建、状态怎么流转;调度器回答下一个该谁运行、上下文怎么换。把这两块讲透,任务不切换、优先级反转、栈溢出这些移植后几乎必踩的坑才有地方对号入座,也才能判断手上的项目到底该不该上 RTOS。

2. uCOS-II 任务状态机与 OS_TCB 控制块拆解

2.1 五种任务状态与转换条件

uCOS-II 里一个任务从生到死会经历五种状态,理解状态机比背 API 更重要,因为绝大多数“任务不跑”的问题都能落回到状态没流转过去。睡眠态是任务代码存在但还没被OSTaskCreate注册,OS_TCB 和任务栈都还是一块自由内存;就绪态表示任务具备运行条件,已经被挂进就绪表等待调度;运行态同一时刻只会有一个任务,它持有 CPU;等待态是任务调用OSTimeDlyOSSemPend后主动让出 CPU;中断服务态则发生在 ISR 执行期间,中断返回时会触发一次重新调度。

当前状态进入方式退出方式
睡眠态上电初始 /OSTaskDel之后OSTaskCreateOSTaskCreateExt
就绪态创建完成、延时到期、事件满足被调度器选中,或事件被清除
运行态OS_Sched选中最高优先级被抢占、主动延时、等待事件
等待态OSTimeDlyOSSemPendOSMboxPend超时到期或事件被投递
中断服务态硬件中断触发中断返回,重新走一次调度

状态之间的转换由内核函数集中控制:OSTaskCreate把睡眠态推进就绪态,OS_Sched从就绪态挑一个变运行态,OSTimeDly把运行态降级成等待态,OSTimeTick在每个时钟节拍里把等超时的任务重新拉回就绪态。这几条路径走完之后,任务的状态就是可预期的。

2.2 OS_TCB 里哪些字段真正参与调度

每个任务都有一块OS_TCB,可以理解为内核给任务建的档案。字段看着多,真正在调度里被频繁读写的其实就几个:优先级OSTCBPrio、就绪表用的链表指针OSTCBPrev/OSTCBNext、任务栈顶OSTCBStkPtr、延时计数器OSTCBDly、事件控制块指针OSTCBEventPtr,以及挂起与延时标志。其余像任务名、扩展指针属于调试和统计用途,不参与选择下一个任务。

typedef struct os_tcb { OS_STK *OSTCBStkPtr; /* 任务栈顶指针,切换时保存/恢复 SP,最关键 */ struct os_tcb *OSTCBNext; /* 就绪表双向链表的后继 */ struct os_tcb *OSTCBPrev; /* 就绪表双向链表的前驱 */ INT16U OSTCBDly; /* 延时剩余节拍,0 表示不在等待 */ INT8U OSTCBStat; /* 状态位:就绪/延时/挂起/等待事件 */ INT8U OSTCBPrio; /* 优先级,0 最高,数值越大越低 */ OS_EVENT *OSTCBEventPtr; /* 指向等待的事件控制块 */ INT8U OSTCBX; /* 优先级低 3 位,预先算好省查表 */ INT8U OSTCBY; /* 优先级高 3 位,预先算好省查表 */ INT8U OSTCBBitX; /* 1 << OSTCBX,写就绪表用 */ INT8U OSTCBBitY; /* 1 << OSTCBY,写就绪表用 */ } OS_TCB;

OSTCBX/OSTCBY/OSTCBBitX/OSTCBBitY这四个字段是典型的空间换时间:创建任务时就把优先级的位位置算好,调度和事件投递时直接位移操作,不必每次都查OSUnMapTbl。如果你的项目优先级数量很少、RAM 又紧张,可以关掉OS_TASK_STAT_EN只保留必须字段,但OSTCBStkPtrOSTCBPrioOSTCBDlyOSTCBStat一个都不能省。

2.3 优先级分配与数量上限

uCOS-II 的优先级是 0 到OS_LOWEST_PRIO,0 最高,数值越大越低。默认配置里OS_LOWEST_PRIO是 63,也就是说最多 64 个任务,其中优先级OS_LOWEST_PRIOOS_TaskIdle占用,OS_LOWEST_PRIO-1OS_TaskStat占用(开启统计任务时)。所以真正能分给用户的一般是 0 到 61。

分配顺序上我的习惯是:硬实时事件响应放 0~3,周期性控制律放 4~10,通信解析放 11~20,人机界面和日志放 30 以后,统计和空闲交给系统。同一时刻每个优先级只能有一个任务,OSTaskCreate撞到已占用的优先级会返回OS_ERR_PRIO_EXIST

提示:不要为了“看起来清楚”把优先级排得很稀疏,0、10、20、30 这样分,中间几十个数字全浪费,一旦要往中间插入新任务就没位置了。

3. OSTaskCreate 创建任务与任务栈的初始化

3.1 OSTaskCreate 与 OSTaskCreateExt 的选型

uCOS-II 提供两个创建接口。OSTaskCreate只要任务函数、参数指针、栈顶指针和优先级四个参数,代码体积小,适合大多数中小项目。OSTaskCreateExt多出任务 ID、栈底指针、栈大小、扩展指针和选项五个参数,好处是能配合OSTaskStkChk做栈使用统计、能指定OS_TASK_OPT_STK_CHKOS_TASK_OPT_STK_CLR,代价是每个任务多占几十字节 RAM 和一点代码空间。

/* 函数原型,参数含义逐一对齐 */ INT8U OSTaskCreate(void (*task)(void *pd), /* 任务入口,形式上返回 void */ void *p_arg, /* 传给任务的参数,可以是结构体指针 */ OS_STK *ptos, /* 栈顶指针,注意是"顶"不是"底" */ INT8U prio); /* 优先级,必须唯一 */ #define TASK_LED_PRIO 5 #define TASK_LED_STK 128 static OS_STK TaskLedStk[TASK_LED_STK]; void TaskLed(void *p_arg) { (void)p_arg; /* 参数不用就显式丢弃,避免编译告警 */ for (;;) { LED_TOGGLE(); OSTimeDly(100); /* 延时 100 个时钟节拍 */ } } void App_TaskCreate(void) { INT8U err = OSTaskCreate(TaskLed, (void *)0, &TaskLedStk[TASK_LED_STK - 1], /* 传栈顶地址 */ TASK_LED_PRIO); if (err != OS_ERR_NONE) { /* 创建失败常见原因:优先级重复、内存不足、在 ISR 里调用 */ while (1) { } } }

这里最容易错的就是第三个参数。uCOS-II 用的是满递减栈,栈从高地址向低地址生长,所以必须传&Stack[SIZE-1],传Stack&Stack[0]会让第一次压栈直接踩到数组外面。传参p_arg本身不做拷贝,生命周期必须覆盖任务全程,通常就传一个静态结构体地址或者(void *)0

3.2 任务栈深度怎么估算

栈深度是新手最容易给少的地方。栈里除了局部变量和函数调用帧,还要放下任务上下文:在 Cortex-M 上,OSTaskStkInit会手工压入 R4~R11、R0~R3、R12、LR、PC、xPSR 共 16 个字,也就是 64 字节的固定开销,再加上该任务所有函数调用链上的局部变量和中断嵌套时的额外压栈。

场景建议栈深度(32 位字)说明
只做 GPIO 翻转、无浮点64 ~ 128常见 LED、按键任务
printfsprintf256 以上库函数自身嵌套深,且常带缓冲
含浮点运算256 ~ 512是否开硬件 FPU 差别很大
通信协议栈解析256 ~ 384递归下降解析器要再加
中断嵌套层数多在基础值上再放大嵌套越深,压栈越多

给完初值之后不要靠猜,用OSTaskStkChk反过来看实际用量。原则是给到实测峰值的 1.5 倍以上留余量,因为不同的输入分支会走出不同的最深调用链。

3.3 开机流程与多任务启动顺序

uCOS-II 要求在OSStart之前至少创建一个任务,并且不能在中断里创建。典型顺序是:初始化时钟和OSInit,创建起始任务,最后调OSStart把 CPU 交给就绪表里优先级最高的那个。起始任务一般只负责初始化外设、创建其余任务,然后自己删掉或者降到最低优先级去做后台工作。

int main(void) { OSInit(); /* 初始化内核:就绪表、空闲任务、时钟 */ BSP_Init(); /* 时钟、串口、GPIO 等硬件初始化 */ OSTaskCreate(TaskStart, (void *)0, &TaskStartStk[TASK_START_STK - 1], TASK_START_PRIO); /* 只创建起始任务 */ OSStart(); /* 永不返回,开始多任务调度 */ return 0; }

OSStart会先找到就绪表里的最高优先级,然后调用OSStartHighRdy启动第一个任务。如果启动后卡死、第一个任务不执行,优先检查三件事:OSStartHighRdy里有没有调用OSTaskSwHook、第一个任务的栈顶地址对不对、SysTick有没有使能并正确配置成OS_TICKS_PER_SEC

4. 调度器内核:就绪表、OS_Sched 与任务切换

4.1 OSRdyGrp 与 OSRdyTbl 的位图结构

uCOS-II 的就绪表是一张位图,不是链表数组。OSRdyTbl是一个长度为 8 的字节数组,把 64 个优先级分成 8 组,每组 8 位;OSRdyGrp是一个字节,它的第 n 位为 1 表示第 n 组里有任务就绪。这样“找最高优先级就绪任务”这个操作被压缩成两次查表加一次拼接,执行时间恒定,跟就绪任务数量无关。

优先级组号 Y(高 3 位)位号 X(低 3 位)写入位置
000OSRdyTbl[0] 的 bit0
505OSRdyTbl[0] 的 bit5
1214OSRdyTbl[1] 的 bit4
3543OSRdyTbl[4] 的 bit3
6377OSRdyTbl[7] 的 bit7

置位和清零都用预先算好的OSTCBBitYOSTCBBitX做位运算,这是这套结构里最有意思的地方:把除法换成移位和查表。

4.2 OS_Sched 的查找过程

void OS_Sched(void) { INT8U y; OS_ENTER_CRITICAL(); /* 关中断,防止就绪表被改到一半 */ if ((OSIntNesting == 0) && (OSLockNesting == 0)) { y = OSUnMapTbl[OSRdyGrp]; /* 最高优先级所在组,0..7 */ OSPrioHighRdy = (INT8U)((y << 3) + OSUnMapTbl[OSRdyTbl[y]]); /* 拼出完整优先级 */ if (OSPrioHighRdy != OSPrioCur) { /* 跟当前任务不同才切 */ OSTCBHighRdy = OSTCBPrioTbl[OSPrioHighRdy]; OSCtxSwCtr++; /* 统计切换次数,调试时好用 */ OS_TASK_SW(); /* 触发上下文切换 */ } } OS_EXIT_CRITICAL(); }

OSUnMapTbl是一张 256 字节的常量表,输入一个字节,输出最低位为 1 的位号。两次查表分别解决了“哪个组有就绪任务”和“组内哪一位有就绪任务”,因为优先级数值越小越高,所以取最低位正好对应最高优先级。函数开头的两个判断是关键:OSIntNesting == 0保证不在中断里切换,中断里只置一个OSPrioHighRdy标记,等OSIntExit再统一处理;OSLockNesting == 0保证没有在临界区里强行切走,否则锁没释放就换了任务,会出大问题。

4.3 OSCtxSw 与 Cortex-M 上的上下文切换

OS_TASK_SW是个宏,在 Cortex-M 移植版里通常写成PendSV的触发;真正的切换动作发生在OSCtxSwPendSV_Handler里。切换要干的事情是:把当前任务的 CPU 寄存器压进它自己的栈,把栈顶指针写回OSTCBCur->OSTCBStkPtr;然后从OSTCBHighRdy->OSTCBStkPtr取出新任务的栈顶,把寄存器弹回去,执行返回。硬件在进异常时自动压 R0~R3、R12、LR、PC、xPSR,所以移植层只需要手工保存 R4~R11。

; Cortex-M 移植层的任务切换骨架 PendSV_Handler CPSID I ; 进入后关中断 MRS R0, PSP ; 取当前进程栈指针 CBZ R0, PendSV_NoSave ; 首次切换没有有效 PSP,跳过保存 STMFD R0!, {R4-R11} ; 手工保存硬件不自动压的寄存器 LDR R1, =OSTCBCur LDR R1, [R1] STR R0, [R1] ; 当前 SP 存回自己的 TCB PendSV_NoSave LDR R0, =OSTCBPrioHighRdy ; 取即将运行任务的 TCB LDR R1, =OSTCBCur LDR R2, [R0] STR R2, [R1] LDR R0, [R2] ; 新任务的栈顶 LDMFD R0!, {R4-R11} ; 恢复手工保存的寄存器 MSR PSP, R0 ; 写回进程栈指针 ORR LR, LR, #0x04 ; 返回后使用 PSP CPSIE I BX LR

PendSV被特意设成最低优先级异常,这样它不会打断任何还在处理中的中断,只会在所有中断都退出后执行。这也是为什么在 ISR 里调OSSemPost不会立刻切换任务:中断里只更新就绪表和OSPrioHighRdy,中断退出走OSIntExit,最后统一由PendSV完成切换。把这条时序理清楚,就能解释“在中断里发信号量,为什么任务要等一会儿才跑”这个高频疑问。

注意:OSCtxSwCtr是个很有用的观测点,把它的值打印出来,就能判断调度到底有没有在发生。系统中枢任务切不切换、一秒切多少次,全在这个计数器上。

5. 调度踩坑:优先级反转与栈溢出排查

5.1 优先级反转与互斥量

优先级反转是任务管理里最容易被问到的问题:低优先级任务拿了共享资源,高优先级任务等这个资源,中优先级任务又把低优先级任务抢走,结果高优先级任务被一个不相干的中优先级任务间接拖住。uCOS-II 的解法是互斥量OSMutexOSMutexPend时若发生反转,内核会把持有者的优先级临时抬到等待者的优先级,等释放后再恢复。

static OS_EVENT *g_share_mutex; void TaskAccessShared(void *p_arg) { INT8U err; (void)p_arg; for (;;) { OSMutexPend(g_share_mutex, 0, &err); /* 第二参数 0 表示无限等待 */ if (err == OS_ERR_NONE) { Share_Read_Write(); /* 临界区尽量短 */ OSMutexPost(g_share_mutex); } OSTimeDly(10); } }

OSSemCreate当二值信号量也能互斥,但它不带优先级继承,一旦出现三级任务竞争就会卡住。区分方法很简单:只做任务间同步用信号量,保护共享资源一律用互斥量。创建互斥量时OSMutexCreate(prio, &err)的第一个参数是优先级提升的上限,通常传OS_LOWEST_PRIO之外的一个专用值,避免提升后撞上已有任务的优先级。

5.2 用 OSTaskStkChk 定位栈溢出

栈溢出在 uCOS-II 里表现很杂:可能是任务跑飞进HardFault,也可能只是某个变量莫名被改。最实用的手段是开OS_TASK_OPT_STK_CHK | OS_TASK_OPT_STK_CLR创建任务,然后周期性调OSTaskStkChk拿实际用量。

OS_STK_DATA stk_data; INT8U err = OSTaskStkChk(TASK_LED_PRIO, &stk_data); /* stk_data.OSFree 是剩余空闲栈字节数,stk_data.OSUsed 是历史峰值 */ if (err == OS_ERR_NONE && stk_data.OSFree < 32) { LOG_WARN("task %d stack left %d bytes", TASK_LED_PRIO, stk_data.OSFree); }

统计的原理是:创建时把整块栈清零,运行一段时间后从栈底往栈顶扫,找第一个不为 0 的位置,差值就是峰值用量。所以统计任务必须真的跑过所有分支,否则峰值是偏小的。经验值是剩余不低于总栈深的 25%,低于这个数就该加栈或者削调用链。

5.3 OSTimeDly 的精度边界

OSTimeDly(n)延时的实际时长是 n 个时钟节拍,误差来自节拍本身的粒度,而不是函数实现。OS_TICKS_PER_SEC设成 100 时,一个节拍 10ms,请求 5ms 只能等到下一个节拍,实测会在 10ms 附近跳动。要让延时更准,提升节拍频率是最直接的办法,但节拍越快,OSTimeTick的中断开销越大,低优先级任务的可用时间会被挤掉。折中点是 100~1000Hz:普通控制用 100Hz 够用,需要毫秒级精度的用 1000Hz。

时间基准也要注意,OSTimeDly依赖OSTimeTick被稳定调用。SysTick 的配置如果被其他库重写,节拍就会变慢或变快,表现出来是所有延时整体拉长或缩短。排查时调OSTimeGet(&err)前后取差,跟实际挂钟时间对比,一比就能确认节拍频率到底对不对。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询