手写RTOS内核:优先级抢占与PendSV上下文切换实战

手写RTOS内核:优先级抢占与PendSV上下文切换实战
灯光师们欢迎回来。上一讲我们手搓了任务控制块和最简单的上下文切换让两个任务能“轮流”跑。但如果你真拿那套代码跑几个任务很快就会发现一个尴尬的问题任务A是个死循环任务B永远没机会执行。没有调度策略的RTOS就像没有交警的路口谁嗓门大谁先走最后全堵死。这一篇我们专门解决“任务究竟是怎么被选中上台的”这个问题。也就是说调度器到底凭什么把CPU的执行权交给某个任务而不是另一个里面的优先级算法、就绪队列、上下文切换触发时机每一环都是面试官最爱追问的硬核考点也是你从“能跑Demo”到“真正懂RTOS”的分水岭。这篇适合已经能写出简单任务创建代码、但还没理清“调度”本质的同学。我会从调度策略的底层逻辑讲起把就绪表数据结构、PendSV上下文切换的汇编细节、时钟节拍与抢占时机都过一遍最后附上我在实际调试中踩过的五个坑。看完你不仅能说清楚“任务是怎么被选中的”还能自己动手改出一个像模像样的调度内核。1. 调度这件事本质上是解决“CPU给谁用”的效率问题单核CPU同一时刻只能跑一个任务。所谓RTOS不是让多个任务“同时”执行而是把CPU时间切分成很多小段在不同任务之间快速切换让人感觉它们像在同时运行。调度器就是决定“CPU时间片分给谁”的那套算法和数据结构。1.1 没有调度器会怎样你亲手写的第一版内耗代码假设你已经实现了前几讲的任务创建和上下文切换。一个最简单的调度器长这样按顺序轮流执行每个任务。也就是Round-Robin轮转。每个任务跑固定时间比如1ms后强制切换给下一个任务。这种调度方式没有优先级概念所有任务平等。它实际运行起来有个非常明显的问题假如任务A在等待一个外部中断比如串口收到数据但它没写阻塞代码而是在循环里死等。这个时候任务A虽然什么都没干却占着CPU不放任务B只能干瞪眼。这就是轮转调度的“平庸之恶”资源利用率低。它可以把时间公平地分给所有人但没法把时间优先给“最着急的人”。所以工业级RTOS普遍不用“纯轮转”而是用“优先级抢占 可选时间片轮转”的组合策略。你要理解的核心就是任务调度器的本质是一套“如何选人”的加权排队算法。1.2 两种主流调度策略的取舍优先级抢占 vs 时间片轮转我用一张表格做一个快速对照你之后设计调度器时心里就有谱了调度策略核心规则优点缺点典型场景优先级抢占式最高优先级的就绪任务立即获得CPU实时性可控高优先级事件能及时响应低优先级任务可能饿死实现复杂度高中断事件处理、控制类系统时间片轮转同优先级任务轮流执行固定时间公平性高实现简单无优先级区分实时性差后台任务、UI刷新混合策略高优先级抢占 同优先级轮转兼顾实时性和公平性调度逻辑最复杂需要权衡绝大部分通用RTOS我的建议是如果你在写学习项目先实现“优先级抢占式”就够了。等这个跑稳了再往同优先级里加“时间片轮转”。一颗诚实的调度器种子一定是先从“抢”开始生长的。2. 就绪队列调度器手里的“候选人名单”调度器要选任务首先得知道“现在有哪些任务是可以被选的”。在RTOS内核里这靠的就绪表Ready Table。就绪表是内核中最核心的数据结构之一它的设计直接决定了调度器的查找速度。2.1 位图法就绪表用1个bit表示一个任务的就绪状态任务状态我用一张表给你盘点清楚状态含义在就绪表中举例运行态正在占用CPU是当前正在执行的任务就绪态可运行但未运行是在排队等CPU的任务阻塞态等待某事件或信号量否task在等串口接收完成挂起态被调用vTaskSuspend挂起否调试暂停的任务初始态刚创建还未启动否还没有丢进调度器就绪表最经典的做法是用位图。比如一个32优先级的内核就绪表就是一个32位变量。第n位为1代表优先级n的任务处于就绪状态。// 就绪表bit0~bit31对应优先级0~31 // 数值越大优先级越高这里定义优先级0最低31最高 volatile uint32_t ready_table;查找当前最高优先级任务理论上是扫描最高位。如果逐位for循环最坏要查32次这可不是RTOS该干的事。CM3/CM4内核有一条指令叫CLZCount Leading Zeros数从最高位开始连续0的个数一条指令就能定位到最高的1在哪。于是代码可以这么写uint8_t get_highest_ready_priority(void) { // CLZ 返回前面0的个数 // __CLZ是CMSIS提供的编译器内置函数 return 31 - __CLZ(ready_table); }这样O(1)复杂度就能找到“该选谁”。你以后看FreeRTOS的源码它会按优先级分多个链表本质上也是类似的“快速定位”思想只是表结构更复杂。位图的思路既简单又能讲清原理我建议你手搓内核时优先实现这一版。2.2 任务状态跳转是调度器的活地图光有就绪表还不够要保证状态切换不出错你必须让“任务状态机”成为一个闭环。画个表单帮你理一下当前状态触发事件新状态就绪表操作运行态任务主动调用阻塞API等待信号量阻塞态清就绪位运行态时间片耗尽时间片轮转就绪态保持就绪位不变换人选阻塞态等待的事件发生中断里给信号量就绪态置就绪位运行态更高优先级任务就绪就绪态被抢占清就绪位或者保留这里有一个很多人第一次手写内核时搞错的点被抢占的任务到底该不该清就绪位答案是不该。因为它依然是可以执行的只是没被选中而已。它只是从“运行态”被打回“就绪态”但一直停留在就绪表中。等到它重新成为最高优先级调度器直接再选中它。2.3 我的实操习惯在位图之上再造一层任务链表位图能快速定位最高优先级任务但一个优先级下可能有多个相同优先级的任务比如两个普通任务都是优先级5。位图本身只告诉优先级5有任务就绪没告诉哪个任务。这时候我会在每个优先级上挂一个任务链表就绪表中“哪个优先级有任务”用位图同优先级内部再用链表维护多个任务。这种双层设计是工业级RTOS普遍采用的方案。FreeRTOS的pxReadyTasksLists就是按优先级拆成多个链表配合uxTopReadyPriority来快速定位最高优先级。你手搓内核时可以先简化为“每个优先级只允许一个任务”把位图跑通后再扩展同优先级链表稳扎稳打。3. 优先级抢占的核心机制PendSV异常与上下文切换的“换人”细节调度器选出了“该上场的人”下一步就是真正把CPU寄存器里的“旧演员”换下来把“新演员”请上去。这一步叫上下文切换Context Switch。在Cortex-M内核上它通常由PendSV异常来完成。3.1 为什么选PendSV而不是直接在SysTick里切换你可能会问既然SysTick定时器中断里就能做调度为什么还要PendSV来切换直接把切换代码写在SysTick_Handler里不行吗实践告诉我们不行。原因有两个。第一SysTick是中断切换任务会改变栈指针和PC如果此时另一个中断正在执行比如UART中断嵌套你在SysTick里强行切走当前任务会让正在运行的中断上下文丢失这是灾难性的。第二PendSV异常被设计为“挂起后不立即执行”可以等待所有更紧急的中断处理完毕后再进行任务切换。它的优先级可以配置成最低这样它就像个“中场清洁工”等所有演员谢幕后才开始换布景。所以标准做法是检测到需要调度时不直接跳转而是软件触发PendSV// 触发PendSV异常 SCB-ICSR | SCB_ICSR_PENDSVSET_Msk;PendSV执行时一定是在所有高优先级中断都处理完后此时做上下文切换最安全。3.2 上下文切换的汇编原理一个都不许漏Cortex-M处理器在进入异常时硬件会自动压栈一部分寄存器xPSR、PC、LR、R12、R3-R0。但剩下的R4-R11硬件不帮你保存需要手动压栈。省流版切换流程进入PendSV_Handler。判断是第一次调度还是普通切换。如果是第一次特殊处理。拿出当前任务TCB里的栈顶指针PSP。手动压栈R4-R11。更新当前任务TCB中的栈顶指针。从就绪表中取出新任务的TCB。更新PSP为新任务的栈顶指针。弹出新任务手动压栈的R4-R11。利用异常返回机制自动弹出新任务的xPSR、PC、LR、R12、R3-R0。退出PendSV开始运行新任务。Cortex-M的异常返回有一个“魔法地址”。PendSV_Handler返回时LR的值如果是0xFFFFFFFD表示“返回线程模式并使用PSP”这样CPU就会切到新任务。到这里你可能会背后一凉这东西但凡漏保存一个寄存器跑起来就是各种诡异跑飞。我调试时见过任务B的温度数据串到任务A的显示里就是寄存器恢复不完整造成的。所以这句提醒送给你上下文切换不是函数调用是寄存器级别的“偷天换日”。每个寄存器的保存和恢复都必须无懈可击否则轻则数据错乱重则HardFault。4. 调度点什么时候该“换人”理解了“怎么换”下一步是“什么时候换”。这直接影响到系统的实时性和公平性。RTOS里的换人时机主要有三个大方向任务主动让出、时钟节拍强制轮转、中断唤醒高优先级任务。4.1 任务主动让出CPU最简单的调度触发如果任务本身就没啥要紧事调一个系统调用让出CPU这是最直观、最可控的。比如void task_a(void *arg) { while (1) { // 点灯 task_yield(); // 主动让出CPU } }task_yield做的事其实很简单把当前任务从运行态转为就绪态然后触发调度器重新选择最高优先级任务。如果当前任务优先级仍然最高那么让出后马上又被选中等于白切换——所以主动让出适合“同优先级任务互相谦让”的场景。4.2 时钟节拍SysTick如何扮演“定时闹钟”纯粹靠任务自觉让出CPU不现实。毕业设计里那个点灯任务它就是死循环你不给它打断它永远不会让。所以RTOS要有一个硬件定时器周期性产生中断逼调度器“重新审视”一次任务选择。这个定时器在Cortex-M上一般是SysTick。SysTick_Handler里做两件事时间片递减。如果当前任务时间片用完触发PendSV调度。void SysTick_Handler(void) { TICK_CNT; // 如果开了时间片轮转递减当前任务的时间片 if (current_task-time_slice 0) { current_task-time_slice--; if (current_task-time_slice 0) { current_task-time_slice DEFAULT_TIME_SLICE; // 把当前任务挪到同优先级就绪队列末尾如果有链表 // 然后触发PendSV SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; } } }注意一个细节SysTick中断里你通常不直接做完整上下文切换而是“申请”PendSV。刚才讲过PendSV会等更高优先级中断处理完毕才执行这样就能避免在UART中断嵌套中切任务导致现场破坏。时钟节拍的周期决定了系统的时间精度。一般取1ms即OS_TICK_HZ 1000。如果你把周期设太短比如100usSysTick中断频繁触发调度开销会很大设太长比如10ms时间片轮转不够精细。这就需要根据实际场景折中。4.3 中断唤醒高优先级任务“插队”的全过程这是优先级抢占最精髓的部分。比如现在低优先级任务A正在跑突然串口收到一帧数据触发UART中断。中断服务函数里释放了一个信号量而这个信号量正在被优先级高的任务C等待。于是任务C从阻塞态转为就绪态优先级最高。中断退出后调度器立刻选中任务C任务A被抢占。这整个过程分成三步中断触发CPU自动跳转到中断服务函数。中断服务函数里通过API释放信号量或发消息使任务C就绪。中断服务函数返回前检查是否需要调度如果需要触发PendSV中断完全退出后执行切换。这就是“中断唤醒”的本质它不是调度器主动去发现谁醒了而是被系统调用“踢了一脚”然后调度器才重新挑人。实操时有个很常见的坑中断服务函数里直接调了释放信号量的API但忘记了那个API其实是“非中断安全版本”。比如FreeRTOS里xSemaphoreGiveFromISR和xSemaphoreGive是两个函数名前者用于中断上下文后者用于任务上下文。混用轻则警告重则调度器数据被破坏。所以任何时候在中断里调用内核API先问自己一句这个API是FromISR版本吗5. 从零实现一个调度器核心可抄作业的最小代码讲了这么多理论最终还是要落到代码上。我给出一个能在STM32上跑起来的最小调度器骨架不含全部外设驱动但调度逻辑是完整的。5.1 任务控制块与就绪表定义#define MAX_TASKS 4 #define MAX_PRIORITY 32 typedef struct tcb { uint32_t *sp; // 栈指针切换时核心 uint8_t priority; // 优先级 uint8_t state; // 0 ready, 1 blocked uint32_t time_slice; // 时间片计数 struct tcb *next; // 同优先级链表简单实现可省略 } TCB; TCB task_tcbs[MAX_TASKS]; volatile TCB *current_task; volatile TCB *next_task; volatile uint32_t ready_table;5.2 找最高优先级任务TCB *get_next_task(void) { uint8_t highest_prio 31 - __CLZ(ready_table); // 遍历任务表找到对应优先级的就绪任务 for (int i 0; i MAX_TASKS; i) { if (task_tcbs[i].state 0 task_tcbs[i].priority highest_prio) { return task_tcbs[i]; } } return NULL; // 不应该发生 }这个版本是简化版适合学习。每遍历一次最多查MAX_TASKS个任务实际工程中一个优先级下可能有多个任务就需要链表。但核心思想完全相同。5.3 触发调度的通用接口void schedule(void) { next_task get_next_task(); if (next_task ! current_task) { // 触发PendSV真正切换将在中断退出前完成 SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; } }注意一个细节schedule函数里并没有直接做寄存器切换它只是“预约”了一个PendSV。这个设计的精髓是“延后到安全时机再切换”。如果你忍不住在schedule里直接切栈、改PC很快你会发现中断丢失问题。5.4 PendSV_Handler最简汇编实现__asm volatile ( .syntax unified \n .thumb \n .thumb_func \n .global PendSV_Handler \n PendSV_Handler: \n MRS R0, PSP \n CBZ R0, first_run \n // 保存上下文R4-R11压栈 \n STMDB R0!, {R4-R11} \n // 保存当前任务的SP到TCB \n LDR R1, current_task \n LDR R1, [R1] \n STR R0, [R1] \n first_run: \n // 加载新任务TCB \n LDR R0, next_task \n LDR R0, [R0] \n // 从TCB加载新任务的SP \n LDR R1, [R0] \n // 设置PSP \n MSR PSP, R1 \n // 恢复上下文 \n LDMIA R1!, {R4-R11} \n // 更新current_task next_task \n LDR R0, current_task \n LDR R1, next_task \n LDR R1, [R1] \n STR R1, [R0] \n // 异常返回切到线程模式PSP \n MOV LR, #0xFFFFFFFD \n BX LR \n );这个汇编看起来短实际写的时候全是坑。比如“first_run”标签的处理我第一次写就漏了CBZ判断结果是第一次切换时新任务的栈里没有自动压栈的xPSR、PC等寄存器直接从损坏的栈指针开始跑直接HardFault。调试这种问题你会感受到“内核崩溃”和“业务代码崩溃”完全是两码事。还有一点必须提醒不同编译器对内联汇编的语法支持不太一样。上面的写法基于GCCKeil的ARMCC语法略有差异但逻辑一样。你要是用Keil可以把这段汇编单独放到.s文件里。6. 做实验必看的5个“调度大坑”与排查心得这部分是我实际反复踩坑攒出来的经验价值比上面的代码本身高。你如果能把这些坑提前记住至少能省下一个星期的调试时间。6.1 坑一栈指针在第一次切换时是“非法”的很多初学手搓内核的人第一次任务切换直接跑飞。最常见的原因就是任务创建时初始化栈的方式不对栈指针没有指向一个包含初始xPSR/PC/LR的合法结构。第一次切换时CPU从栈里弹出的PC是0或者不是任务入口直接HardFault。排查技巧在第一次进入PendSV_Handler时打断电查看新任务的PSP值。如果PSP指向的地址内容全是0或0xFFFFFFFF基本就是初始化栈的代码写错了。你要把任务的初始xPSR一般设为0x01000000表示使用Thumb指令、初始PC任务入口、LR设为0xFFFFFFFD都装进初始栈里。6.2 坑二优先级反转比我预想的更容易发生经典三任务模型任务H高优先级等信号量任务L低优先级持有信号量任务M中优先级不停地跑CPU。如果H等L释放信号量而M抢占LH就会被M“间接饿死”。时间点运行任务事件t0LL拿到信号量开始干活t1MM就绪抢占LL被挂起t2HH就绪抢占MH等信号量阻塞t3MM继续运行L永远没机会释放信号量t4HH继续等待相当于死锁解决思路是优先级继承当H等信号量时把持有信号量的L临时提升到H的优先级让它尽快运行并释放信号量。手搓内核初期可以先不实现但你必须意识到这个问题存在。6.3 坑三时间片轮转没有真正“轮转”如果你在SysTick里只递减了时间片计数但忘了把当前任务挪到就绪队列末尾对于单优先级链表那么“轮转”永远不会发生。SysTick每次触发当前任务的时间片减到0后又被重置为满值但调度器选出的还是同一个任务其他同优先级任务永远没有机会运行。排查技巧用逻辑分析仪同时抓两个任务的GPIO翻转波形。如果两个波形一个一直输出、另一个完全没变化十有八九是时间片轮转逻辑里“队列尾部搬移”没做。6.4 坑四中断服务函数里进行阻塞操作RTOS的临界资源操作非常讲究上下文。在中断里调用一个会阻塞的API等待信号量就是拿系统内核开玩笑。中断服务函数应该极快执行不能“睡大觉”。真遇到这种情况你应该把阻塞操作丢给任务去干中断里只做最轻量级的置标志位。我见过有人把printf直接写进UART中断结果中断服务函数执行时间好几个毫秒。SysTick一中断PendSV又等不到机会执行系统卡死。排查到最后居然是“中断里调printf”这个细节。6.5 坑五调度测量居然靠printf最后是我最想吐槽的调试调度器性能别再用串口printf打时间戳。printf本身的阻塞时间比任务切换时间还长测量结果毫无意义。正确做法是把某个GPIO翻转放在你要测量的代码段前后用逻辑分析仪看脉冲宽度。我在实际项目中测调度延迟就是在PendSV入口把一个GPIO拉高退出时拉低然后用逻辑分析仪看这个高电平的时间。那才叫真实的调度开销通常几个微秒比printf靠谱一万倍。7. 写在最后调度器这条路越往深走越有意思我个人做了几年嵌入式开发回头再想“任务调度”这件事最大的感受是它不像写业务代码功能对了就行。调度器是很多任务的公共基础设施任何一点点边缘case考虑不周就会导致系统层面的随机故障。如果你正在跟着这个系列手搓操作系统我建议你按这个顺序往下走先实现优先级抢占式调度用两个不同优先级的点灯任务验证然后加入时间片轮转用两个同优先级任务轮流转最后再挑战PendSV里用逻辑分析仪观测切换波形。每一步跑通后再回头读FreeRTOS源码时你会发现自己已经能看懂它在干什么了。任务被“选中”的瞬间背后藏着的其实是一套完整的优先级、就绪表、上下文切换与中断配合机制。把这些关节打通你才算真正把RTOS的“魂”装进了自己的代码里。下一篇我会接着讲阻塞与唤醒机制的实现也就是信号量和队列到底是怎么让任务“主动睡觉”的。

最新新闻

日新闻

周新闻

月新闻