尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

RTOS调度器核心原理:任务如何被选中上台?

RTOS调度器核心原理:任务如何被选中上台? 任务被选中上台谁说了算——从RTOS调度器源码拆解“选角”全过程写这个系列到第六篇我一直强调一个观点搞懂RTOS别一上来就抱着FreeRTOS几千行源码啃先自己动手“土办法”写一个把底层逻辑彻底吃透再回头去看那些商业系统你会觉得它们是锦上添花而不是天书。上一篇我们讲完了任务怎么创建、栈怎么初始化、上下文切换时寄存器怎么保存和恢复有朋友留言说这些我都懂了但系统里同时有10个任务在排队RTOS到底凭什么决定让哪个任务“上台”执行优先级一样的任务怎么轮流如果一个任务一直在跑其他任务岂不是永远没机会这一篇就专门解决这个问题。我尽量用“点灯大师”的视角把调度的核心讲透——任务被选中上台背后究竟是谁在拍板。1. 万物皆状态任务不是“在跑”就是“在等”操作系统里的任务看起来是一堆执行流并行在跑实际上在任何一个精确的时间点CPU只能执行一个任务。那其他任务去哪了它们是“暂停”的——暂停在代码的某一句话上寄存器现场全部被保存到自己的栈里等待被再次唤醒。理解调度首先要搞清楚任务的状态机。任务一生大概有四个状态运行态Running、就绪态Ready、阻塞态Blocked和挂起态Suspended。运行态很好理解任务正在占用CPU执行指令。就绪态是“万事俱备只欠CPU”——任务没有在等待任何东西只是还没轮到自己。阻塞态是任务在等某个事件比如等一个信号量、等一个队列消息、等延时结束此时任务即使有CPU也不会执行。挂起态类似阻塞是任务被主动“冻结”恢复前不参与调度一般用来做调试或临时停用任务。这四个状态里调度器真正关心的只有一个是“就绪态”。运行态只有一个它其实是“刚刚从就绪池里被选中的那个任务”。那么状态之间怎么切换当任务调用延时函数比如task_delay(1000)它就从运行态变成阻塞态此时调度器会立刻从就绪队列里选下一个任务上台。当延时结束系统在时钟节拍中断TICK里把它从阻塞队列移到就绪队列它又从阻塞态变回就绪态。就绪态排队等待直到某个时刻它被选中进入运行态。画出状态转移图的话你会发现运行态和就绪态之间的“来回切换”就是调度的核心战场。任务怎么从就绪者中最合适的那一位“上岸”下面展开讲。2. 调度器只看一件东西就绪列表2.1 就绪列表的三种形态调度器的本质是维护一张“谁现在能跑”的名单并且每次从中挑一个。这张名单在嵌入式RTOS里通常有三种实现形态。第一种叫做数组遍历。维护一个任务数组每次调度时遍历所有任务找优先级最高的就绪任务。代码简单但时间复杂度是 O(n)任务数量多了以后每次调度都要来回扫描效率不够。第二种是链表。每个任务节点按优先级从高到低排好调度时直接取链表头。插入时按优先级找插入位置时间复杂度平均是 O(n) 但比数组好维护。由于RTOS任务数量通常不多几十个链表已经足够了。第三种是位图法。每个优先级对应一个bit就绪时置1最高优先级就是最高位的那个1。配合CLZ前导零计数指令一条指令就能找到最高优先级的任务时间复杂度 O(1)。FreeRTOS就是这种算法的代表在Cortex-M内核上配合__CLZ指令效率极高。我自己的手搓RTOS第一步用的是“数组遍历”因为最容易理解和调试。等到跑通了再改成位图法性能提升立竿见影。新手可以从链表和数组开始关键是把调度逻辑跑对优化是后话。2.2 就拿“数组遍历”来推演一帧调度假设系统里就绪任务有3个任务A优先级2、任务B优先级3、任务C优先级1数字越小优先级越高。调度器执行顺序是这样系统tick中断到来保存当前任务上下文。在中断中检查哪些任务满足就绪条件延时结束、拿到信号量把它们标志位置1。退出中断前调用调度函数。调度函数遍历任务控制块数组找出优先级最高的就绪任务这里是任务A优先级2。如果任务A就是当前任务什么都不用做。如果是任务B就触发上下文切换把任务B的现场保存起来恢复任务A的现场。在这个过程中最关键的一点是调度函数本身不消耗太多时间因为它的决策必须“快、准、狠”。如果调度一次要耗时几十微秒而tick周期是1ms那系统很大一部分性能会耗在选人上。3. “选中上台”的三套拍板逻辑3.1 静态优先级决定“谁先上场”大多数RTOS默认采用静态优先级调度优先级在任务创建时定好整个生命周期不变。调度器永远选择就绪态中优先级最高的任务运行。优先级怎么定这个没有统一公式我个人的经验是实时性要求越高的任务优先级越高。比如传感器数据采集要精确到毫秒可以给高优先级显示刷新差个几十毫秒无所谓给低优先级。一个重要概念如果有高优先级任务一直就绪不阻塞低优先级任务就一直上不了台。这种现象叫“饿死”starvation。所以设计任务时要注意高优先级任务必须适时调用延时或等待事件主动让出CPU而不能写一个大死循环在里面占着不放。3.2 时间片轮转让“同级任务轮流上台”如果两个任务优先级一样怎么办难道永远只跑第一个答案是时间片轮转Round-Robin每个相同优先级的任务轮流使用CPU每次一个时间片tick的整数倍比如5ms10ms切换一次。像开会时每个人轮流发言5分钟铃一响就换下一个人。实现方式是在任务控制块里加一个时间片计数值任务每次运行一个滴答计数值减1减到0就通知调度器“该换人了”同优先级列表里下一个任务上台。这种机制保证了公平性不会出现某个任务永远拿不到CPU。这里有个容易被忽略的坑时间片轮转只对同一个优先级有效。如果高优先级任务持续就绪时间片轮转在低优先级间根本不会发生。所以大家在学的时候要理顺优先级和时间片之间的关系。3.3 动态优先级用临时提权解决“资源打架”静态优先级简单可靠但有些场景不够灵活。比如一个低优先级任务持有一把锁而这个锁是三个高优先级任务都需要的如果不做任何处理高优先级任务全部阻塞低优先级任务又因为优先级低被其他任务抢走CPU导致锁迟迟释放——这就是经典的优先级反转问题。解决思路是临时提升持有锁任务的优先级。低优先级任务因为拿了锁把它瞬时提到所有等锁任务的最高优先级上。它释放锁后再降回原来的优先级。这个叫“优先级继承”或“优先级天花板”是系统级的动态优先级机制。对一个不复杂的RTOS来说动态优先级实现起来比较费劲。我建议你一开始先老老实实用静态优先级把基本调度跑顺然后再考虑给互斥量加上优先级继承。4. 手写“选角导演”调度器的三个核心函数4.1 schedule()每次调度的决策入口这是调度器的“大脑”一次调度的目标非常明确找到最高优先级的就绪任务然后切换到它。如果放在裸机语境里调度器的灵魂就是这段代码的逻辑void schedule(void) { task_t *next_task find_highest_ready_task(); if (next_task ! current_task) { task_switch(next_task); // 保存当前任务上下文恢复next_task上下文 } }find_highest_ready_task()是调度的核心它遍历就绪列表找出优先级最高的任务并返回。这个函数要保证两点第一返回的一定是就绪态任务第二在多个相同优先级任务里选择时要按时间片逻辑来选而不是每次都选第一个。4.2 tick中断系统心跳也是调度发令枪RTOS必须有一个硬件定时器来产生周期性中断叫tick。一般设成1ms一次默认一次1个节拍。tick中断是调度系统的“心脏”它做三件事系统tick计数加1。扫描所有阻塞态任务看延时是否到期。如果到期了把任务从阻塞队列移到就绪队列。检查当前任务的时间片是否用完如果用完了标记需要调度触发上下文切换。tick中断越频繁时间精度越高但CPU开销也越大。典型RTOS的tick是1ms对大多数传感器采集、控制类应用足够了没必要追求过小的tick。4.3 临界区调度器的“免打扰模式”调度器和tick中断配合才完成了任务切换。但这里有一个血泪教训如果调度过程中来了另一个中断把正在切换的流程打断了系统就可能乱套。比如你在切换上下文的过程中tick中断一进来发现当前任务的栈指针保存了一半现场全乱了。所以调度器的核心代码必须放进临界区关中断 → 执行调度 → 开中断。临界区实现在Cortex-M上很简单就是__disable_irq()和__enable_irq()或者用BASEPRI寄存器屏蔽可屏蔽中断。但要注意临界区里别干耗时的事情只做最核心的切换逻辑否则中断响应延迟会剧增这是做嵌入式最忌讳的。更现代的RTOS比如FreeRTOS会用portDISABLE_INTERRUPTS()配合portENABLE_INTERRUPTS()代码更加规范但原理和我上面讲的完全一样。5. 一个具体调度案例tick里发生了什么我们用最简单的手搓RTOS来推演一次完整的调度过程。假设系统有两个任务task_led优先级1延时100ms亮灯翻转。task_uart优先级2延时5ms向串口发送“Hello”。初始态两个任务都被创建task_led优先级更高系统启动后先跳进task_led执行。task_led里执行task_delay(100)这个函数做了什么把当前任务的优先级对应的标志位从就绪表里清0。设置这个任务的延时剩余时间 100。调用schedule()把CPU交给优先级第二高的就绪任务此时只有task_uart就绪。上下文切换保存task_led的现场恢复task_uart的现场。task_uart执行发一条串口消息然后也执行task_delay(5)。此时两个任务都进入阻塞态就绪队列空了。schedule()发现没有就绪任务就进入空闲任务系统永远是“有人上台”的哪怕是个专职发呆的空闲任务。时间流逝每个tick到来内核都扫描阻塞队列第5个ticktask_uart延时到期内核把它从阻塞队列移到就绪队列此时就绪表里优先级2置位。调度器发现当前空闲任务的优先级比task_uart低于是切换到task_uart。task_uart发完消息再次延时又阻塞。第100个ticktask_led延时到期内核唤醒UI灯翻转。这个过程周而复始。你可能会发现调度器在切换时只会见缝插针地选“当前最高优先级就绪者”至于台上的人休息了多久、上次最低优先级等了多久它一概不管。这就是典型的“静态优先级可抢占”调度。6. 三个致命陷阱调度器最容易踩的坑6.1 前置陷阱栈溢出调度过程中每个任务需要独立的栈空间来保存寄存器现场。如果栈给得不够任务一调用多层函数就爆栈然后系统莫名其妙进入HardFault或者某个变量被莫名其妙改写。排查栈溢出需要两个工具高地址魔法数和栈最大深度记录。每个任务初始化时把栈区全部填充成一个固定值比如0xAA系统定时扫描栈空间看有多少字节被改写。如果500字节的栈空间有450字节被覆盖说明栈可能不够。整理下来我的经验是给任务分配的栈至少要比当前估算大1.5倍到2倍。6.2 裸奔陷阱任务里写死循环有朋友刚上手写RTOS直接就在任务里写了个while(1)里面没有任何延时或阻塞比如void task_uart(void *param) { while (1) { printf(hello\r\n); } }如果这个任务优先级最高它就会一直霸占CPU其他任务永远没机会执行。解决方法是必须有阻塞点延时、等待消息、等待信号量都行。你不能让一个高优先级任务无限自旋不然调度器也拿它没办法——它是“自愿”不下来的。6.3 竞态陷阱共享资源不加锁两个任务同时访问一个全局变量比如环形缓冲区index没有加保护就可能出现一个任务改写数据另一个任务同时在读。这种Bug很难复现但一旦出现数据错乱就随之而来。保护方法通常是“临界区关中断”或“信号量/互斥量”。注意临界区保护时间要尽量的短否则对中断响应的影响非常明显。在我做过的项目中就出现过临界区里放了打印函数一执行就是几十毫秒高优先级中断全被堵住整个系统差点“死机”。7. 经验扩展调度器的调试技巧7.1 用逻辑分析仪看调度我调试调度器时最直接的手段是GPIO翻转。在每个任务切换的瞬间翻转一个GPIO用逻辑分析仪看波形。这样一眼就能看出任务调度是否符合预期哪个任务在跑、跑了多长时间、切到哪个任务了。这个技巧在实际项目中比什么调试器都好用。7.2 模拟随机任务时序你可以创建一个“压力测试任务”它随机产生延时1~50 tick随机访问几个共享变量加锁随机申请释放内存如果有内存池的话。跑上几个小时看是否有异常卡死、栈溢出、数据错乱。好的调度器是抗造的。如果只是点个灯这些问题可能暴露不出来。但一旦应用变复杂你就体会到“调度器靠谱”有多么重要。8. 写在后面这期内容信息量不小但核心思想非常朴素任务是被“管”起来的CPU不是被谁抢走的而是通过tick时钟一波一波地把运行权交出去。理解了这个RTOS就不再是玄学。下一步可以继续深入的方向有三个一是如何从数组调度改到位图调度并把查找最高优先级的汇编指令用起来二是实现信号量和互斥量理解任务如何在等待和唤醒之间切换三是分析FreeRTOS的vTaskSwitchContext()函数对照自己写的代码看看它做了哪些你没考虑到的优化。等你把这些路都走一遍以后用啥RTOS心里都有底。调试调度器这事儿急不得。我建议你边看这篇边动手写哪怕把代码抄一遍跑起来看波形再回头审视收获会大得多。
返回列表