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

资讯详情

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

RT-Thread调度器源码解析:优先级抢占与时间片轮转的实现原理

RT-Thread调度器源码解析:优先级抢占与时间片轮转的实现原理 1. 从“裸奔”到“有条不紊”为什么我们需要任务调度如果你写过单片机程序最开始接触的肯定是那种“超级循环”的代码结构。一个main函数里一个while(1)死循环里面依次调用各种传感器读取、数据处理、状态判断、控制输出的函数。程序就像一条单行道所有车辆任务都得排队通过。这种模式简单直接在功能单一、逻辑简单的场景下完全够用。但问题很快就来了。假设你的设备需要同时做两件事一是每100毫秒精确采集一次温度数据二是等待一个来自串口的用户指令一旦收到就立刻响应。在超级循环里如果你把采集温度的函数放在前面那么当程序执行到读取串口数据的函数时如果此时串口没有数据程序可能会卡在“等待数据”的状态比如一个while(!UART_ReceiveReady())的忙等待导致温度采集的时机被严重延迟失去了“每100毫秒”的精确性。反过来如果把串口读取放前面又可能因为等待一个很久才来的温度传感器转换完成而阻塞整个系统导致用户指令响应迟钝。这就是“裸奔”式编程的困境缺乏并发性和实时性无法保证。任何一个耗时或可能阻塞的操作都会卡住整个系统。为了解决这个问题实时操作系统应运而生。它的核心魔法就是多任务调度。RT-Thread 作为一款在国内嵌入式领域广泛使用的开源实时操作系统其调度器的设计与实现堪称是理解实时系统精髓的绝佳范本。简单来说调度器就是操作系统的“交警”和“裁判”。它管理着系统中所有准备运行的任务在RTOS中通常称为线程决定在任何一个时刻CPU应该执行哪一个线程。RT-Thread的调度算法就是这位“裁判”所遵循的一套精密、高效的比赛规则。今天我们就深入RT-Thread的源码腹地看看这位“裁判”是如何工作的它如何在资源极其有限的单片机上实现高效、公平且具备确定性的任务调度。2. 调度器的基石核心数据结构全景解析在深入调度算法之前我们必须先理解调度器所管理的“参赛选手”和“比赛场地”。RT-Thread的调度器是围绕几个核心的数据结构构建的读懂它们就读懂了调度器的半壁江山。2.1 线程控制块每个任务的“身份证”在RT-Thread中每个线程都由一个struct rt_thread结构体来描述这就是线程控制块。它包含了管理一个线程所需的全部信息就像一个完整的档案袋。struct rt_thread { /* rt_object 基础对象用于内核对象管理如名称、类型等 */ char name[RT_NAME_MAX]; /* 线程名称 */ rt_uint8_t type; /* 对象类型 */ rt_uint8_t flags; /* 标志位 */ /* 栈指针相关 */ void *sp; /* 栈指针 */ void *entry; /* 线程入口函数 */ void *parameter; /* 线程入口参数 */ void *stack_addr; /* 栈起始地址 */ rt_uint32_t stack_size; /* 栈大小 */ /* 链表节点用于将线程插入各种队列就绪、挂起、定时等 */ rt_list_t list; /* 对象列表 */ rt_list_t tlist; /* 线程列表 */ /* 优先级相关 - 调度算法的核心字段 */ rt_uint8_t current_priority; /* 当前优先级 */ rt_uint8_t init_priority; /* 初始优先级 */ rt_uint32_t number_mask; /* 优先级位图掩码 */ /* 时间片相关 - 同优先级轮转调度关键 */ rt_uint32_t init_tick; /* 线程初始时间片 */ rt_uint32_t remaining_tick; /* 线程剩余时间片 */ /* 线程状态 */ rt_uint8_t stat; /* 线程状态 */ /* 错误码 */ rt_uint8_t error; /* 线程错误代码 */ /* 线程清理函数、用户数据等 */ void (*cleanup)(struct rt_thread *thread); /* 线程退出清理函数 */ rt_uint32_t user_data; /* 用户自定义数据 */ };这里面有几个字段是调度器关注的焦点current_priority这是调度器决策的第一依据。数值越小优先级越高通常0为最高255或更低为最低。调度器永远选择就绪态中优先级最高的线程运行。remaining_tick这是同优先级轮转调度的关键。当两个或多个相同优先级的线程都就绪时它们将共享CPU时间。每个线程被赋予一个时间片如10个系统时钟滴答remaining_tick记录该线程本轮还能运行多久。当减到0时调度器会切换至同优先级就绪链表中的下一个线程。stat线程状态决定了它是否在“比赛场地”上。主要状态包括RT_THREAD_READY就绪态万事俱备只等CPU。RT_THREAD_RUNNING运行态正在占用CPU。RT_THREAD_SUSPEND挂起态因等待信号量、消息队列等资源而主动让出CPU。RT_THREAD_CLOSE关闭态线程已运行结束。tlist链表节点。这是将线程组织起来的关键。根据线程的状态和优先级它会被挂载到不同的链表中。例如所有处于就绪态的线程会根据其优先级被挂载到对应的“就绪优先级链表”中。2.2 就绪优先级链表数组分门别类的“候场区”调度器如何快速找到优先级最高的就绪线程如果遍历所有线程在任务较多时效率太低。RT-Thread采用了“就绪优先级链表数组”加“优先级位图”的经典设计。/* 最大优先级支持如256 */ #define RT_THREAD_PRIORITY_MAX 32 /* 就绪优先级链表数组每个优先级对应一个链表头 */ rt_list_t rt_thread_priority_table[RT_THREAD_PRIORITY_MAX];rt_thread_priority_table是一个数组每个元素是一个链表头。数组的下标就是优先级。如果优先级为5的线程就绪了它就会被添加到rt_thread_priority_table[5]这个链表里。这样所有相同优先级的就绪线程都被组织在同一个链表中。2.3 优先级位图快速定位的“索引表”有了按优先级分组的链表如何知道哪些优先级上有就绪线程呢RT-Thread使用了一个位图变量rt_thread_ready_priority_group。rt_uint32_t rt_thread_ready_priority_group;假设系统支持32个优先级0-31。这个32位的变量每一位对应一个优先级。如果某个优先级上有就绪线程对应的位就被置为1。例如优先级5和优先级10上有线程就绪那么rt_thread_ready_priority_group的 bit5 和 bit10 就是1。调度器寻找最高优先级就绪线程的过程就变成了一个非常高效的操作查找位图中被置1的最低位。对于32位系统这通常可以通过编译器内置指令如__builtin_clz计算前导零或高效的查表算法瞬间完成时间复杂度是O(1)。这确保了调度器在选择线程时不会因为任务增多而变慢。注意这里描述的是最常见的优先级抢占式调度配置。RT-Thread也支持其他调度方式如时间片轮转SCHED_RR或完全公平调度SCHED_CFS但需要相应的配置和条件其底层数据结构会有所不同。本文聚焦于其默认且最核心的优先级抢占调度机制。3. 调度算法的核心引擎优先级抢占与时间片轮转RT-Thread默认的调度算法是一种结合了优先级抢占和同优先级时间片轮转的混合策略。理解这两者的协同工作是理解其实时性的关键。3.1 优先级抢占高优先级任务的“紧急通道”优先级抢占是实时系统的灵魂。它的规则很简单一旦出现比当前运行线程优先级更高的线程进入就绪态调度器会立即暂停当前线程转而去执行那个更高优先级的线程。这个过程是如何在源码中触发的呢几乎无处不在。例如线程创建 (rt_thread_create/init)当一个高优先级线程被创建并启动后调度器会检查其优先级是否高于当前线程。如果是会触发线程切换。线程释放资源 (rt_sem_release,rt_mutex_release,rt_mb_send等)当一个线程释放了一个信号量、互斥量或发送了一个消息可能会唤醒一个或多个正在等待这些资源的线程。在唤醒操作的最后调度器会调用rt_schedule函数。该函数的核心逻辑之一就是检查被唤醒的线程以及所有就绪线程中优先级最高的那个是否比当前线程优先级高。如果是则标记需要切换。系统时钟中断 (SysTick_Handler)在时钟中断服务例程中会更新系统时间检查线程的时间片和睡眠延时。如果一个睡眠到期的线程或时间片耗尽的线程的优先级高于当前线程也可能触发调度。让我们看看调度函数rt_schedule()的简化逻辑void rt_schedule(void) { /* 1. 关中断保护临界区 */ rt_base_t level; level rt_hw_interrupt_disable(); /* 2. 查找最高优先级就绪线程 */ rt_uint32_t highest_ready_priority; highest_ready_priority __rt_ffs(rt_thread_ready_priority_group) - 1; /* 3. 从该优先级的就绪链表中获取第一个线程 */ rt_thread_t highest_priority_thread; highest_priority_thread rt_list_entry(rt_thread_priority_table[highest_ready_priority].next, struct rt_thread, tlist); /* 4. 判断是否需要切换 */ if (highest_priority_thread ! rt_current_thread) { /* 如果当前线程不是最高优先级线程则进行上下文切换 */ rt_current_thread highest_priority_thread; /* 触发实际的上下文切换通常由汇编实现 */ rt_hw_context_switch((rt_uint32_t *)(from_thread-sp), (rt_uint32_t *)(to_thread-sp)); } /* 5. 开中断 */ rt_hw_interrupt_enable(level); }提示__rt_ffs()是一个用于查找一个32位数中第一个被置1的位从最低位开始的函数返回的是位的位置1-32。因此需要减1来得到优先级索引。抢占式调度的意义它保证了系统对紧急事件的响应时间是可预测的。例如一个处理紧急报警的线程优先级设为5一个负责刷新屏幕的线程优先级设为20。无论刷新线程正在执行多么复杂的图形计算一旦报警线程就绪比如传感器触发CPU会立刻被抢占报警线程在极短的时间内通常是微秒级取决于上下文切换开销就能得到执行。这种确定性是实时系统的生命线。3.2 同优先级时间片轮转公平的“分时复用”如果多个线程具有相同的优先级并且都处于就绪态该怎么办这就是时间片轮转调度发挥作用的时候。每个线程在创建时都可以被赋予一个时间片值init_tick。当调度器选择了一个优先级上某个线程运行时会将其remaining_tick设置为init_tick。系统时钟每中断一次一个tick当前运行线程的remaining_tick就会减1。当remaining_tick减少到0时会发生两件事将该线程的remaining_tick重置为init_tick。将该线程从它当前优先级就绪链表的头部移动到链表的尾部。这样在同优先级就绪链表中线程就以循环队列的方式被依次调度。每个线程一次运行一个时间片实现了CPU时间的公平共享。这个过程主要在系统时钟中断处理中完成void rt_tick_increase(void) { struct rt_thread *thread; /* ... 增加系统时钟 ... */ /* 遍历所有线程不高效的做法是只处理当前运行线程和就绪链表 */ thread rt_current_thread; if (thread-remaining_tick 0) { thread-remaining_tick --; if (thread-remaining_tick 0) { /* 时间片耗尽 */ /* 1. 重置时间片 */ thread-remaining_tick thread-init_tick; /* 2. 将自己移到同优先级就绪链表末尾 */ rt_list_remove((thread-tlist)); rt_list_insert_before((rt_thread_priority_table[thread-current_priority]), (thread-tlist)); /* 3. 触发一次调度 */ rt_schedule(); } } /* ... 处理睡眠延时等 ... */ }时间片轮转的意义它防止了同优先级线程的“饿死”现象。例如两个同为优先级10的通信线程一个负责接收一个负责发送。如果没有时间片轮转先运行的接收线程如果不主动让出CPU如调用rt_thread_delay或等待资源发送线程将永远得不到执行。时间片机制强制进行了切换保证了同等重要程度的任务都能得到进展。3.3 两种策略的协同一个生动的场景假设系统中有三个线程线程A优先级5无限循环不主动放弃CPU。线程B优先级10时间片5个tick。线程C优先级10时间片5个tick。系统启动后调度器选择优先级最高的线程A运行。线程A一直运行。 此时线程B和线程C就绪了。因为它们优先级(10)低于A(5)所以不会抢占A只能在就绪队列等待。 线程A突然调用了rt_thread_delay(100)主动延时进入挂起态。 调度器发现最高优先级就绪线程变成了优先级10。它从优先级10的就绪链表中取出第一个线程假设是B运行并初始化其remaining_tick5。 B运行了5个tick后时间片耗尽。时钟中断处理程序将其移到优先级10链表的末尾并触发调度。 调度器再次从优先级10链表头取线程这次是C。C开始运行。 在C运行到第3个tick时线程A的100个tick延时结束重新进入就绪态。 由于线程A的优先级(5)高于当前运行的线程C(10)立即发生抢占。调度器保存C的上下文切换到A运行。 A继续运行直到再次主动放弃CPU。只有当A再次挂起时B和C才能继续它们未完成的轮转。这个场景清晰地展示了抢占优先于轮转。高优先级任务可以随时打断低优先级任务的执行而同优先级任务之间则公平地分享CPU时间。4. 调度器的触发时机与上下文切换调度算法决定了“选谁”而调度器需要在合适的时机“执行选择”。RT-Thread的调度是触发式的而非周期扫描式。它主要在以下时机被调用主动释放CPU线程调用rt_thread_delay(),rt_thread_suspend()或因为等待信号量、互斥量、消息队列、事件等而阻塞时会主动调用rt_schedule()。释放资源唤醒他人当线程释放一个内核对象如rt_sem_release,rt_mutex_release可能会唤醒更高优先级的等待线程此时必须调用rt_schedule()检查是否需要切换。系统时钟中断在rt_tick_increase()中处理线程延时到期、时间片耗尽最后也会调用rt_schedule()。中断处理程序退出时RT-Thread支持在中断处理函数中释放内核对象。为了防止在中断上下文进行复杂的调度它采用了“调度器锁”或“中断级线程调度”的概念。通常在中断服务例程(ISR)中只会标记一个“需要调度”的标志如rt_interrupt_nest和rt_thread_switch_interrupt_flag而在中断退出到线程模式时再检查这个标志并执行实际的rt_schedule()。上下文切换是调度器最“硬核”的部分通常由汇编语言实现因为它直接操作CPU的栈指针(SP)、程序计数器(PC)和寄存器组。其本质是保存当前线程的“现场”所有寄存器值到其自己的栈中。将当前线程的栈指针(SP)保存到其线程控制块(rt_thread-sp)。从下一个要运行线程的线程控制块中加载新的栈指针到CPU的SP。从新线程的栈中恢复其之前保存的“现场”寄存器值。执行一条中断返回指令CPU便跳转到新线程上次被切换出去时的地方继续执行。这个过程对线程来说是透明的它们感觉自己一直独占CPU只是偶尔“睡了一觉”。RT-Thread的硬件抽象层(HAL)提供了rt_hw_context_switch()和rt_hw_context_switch_interrupt()等接口由芯片移植层实现是系统能够运行在不同架构上的关键。5. 高级特性与调度策略扩展除了默认的优先级抢占调度RT-Thread的调度框架还预留了扩展性支持更复杂的调度策略。5.1 调度器钩子函数RT-Thread允许用户设置调度器钩子函数 (rt_scheduler_sethook())。这个函数会在每次发生线程切换时被调用传入切换前和切换后的线程对象。这对于系统调试、性能分析、跟踪线程执行流非常有用。例如你可以用它来记录每个线程的运行时长分析CPU使用率。5.2 完全公平调度器在较新版本的RT-Thread或某些移植版本中实验性地支持了完全公平调度器。这种调度策略的目标是让所有线程都能“公平”地获得CPU时间而不是严格按优先级。它通过维护每个线程的虚拟运行时间(vruntime)总是选择vruntime最小的线程来运行。这对于交互式或分时系统更友好但在硬实时系统中其确定性不如优先级调度。启用CFS通常需要修改系统配置并可能带来额外的计算开销如红黑树维护。在资源紧张的实时嵌入式场景中优先级抢占调度因其简单、高效、可预测仍然是绝对的主流。5.3 优先级反转与解决方案这是多任务系统尤其是使用互斥锁时的一个经典问题。假设有三个线程H高优先级、M中优先级、L低优先级。L先运行并获取了一个互斥锁。H就绪抢占L开始运行。H尝试获取同一个互斥锁但锁被L持有于是H被挂起等待。此时M就绪优先级高于L但低于H由于H在等待M开始运行。M可能长时间运行导致持有锁的L一直得不到执行无法释放锁。结果就是中等优先级的M实际上阻塞了高优先级的H。这就是优先级反转。RT-Thread的互斥量 (rt_mutex) 实现了优先级继承协议来解决这个问题。当高优先级线程H等待低优先级线程L持有的锁时系统会临时提升L的优先级到与H相同。这样当M就绪时因为L的优先级被临时提升到了H的级别高于M所以L会抢占M得以继续运行并尽快释放锁。锁释放后L的优先级恢复原样H便能立即获取锁并继续执行。这个过程在rt_mutex_take()和rt_mutex_release()的源码中有清晰的体现是构建健壮实时系统不可或缺的特性。6. 实战中的调度器配置、调试与性能考量理解了原理最终要落到实际项目。如何用好RT-Thread的调度器6.1 优先级规划的艺术优先级配置没有银弹但有一些通用原则中断处理线程如果使用赋予最高优先级确保快速响应硬件事件。关键硬实时任务如电机控制、紧急安全检测。赋予高优先级确保截止时间。软实时或周期性任务如数据采集、通信协议处理。赋予中等优先级。非实时后台任务如日志上传、非关键状态显示。赋予最低优先级。避免过多优先级等级过多的优先级会增加调度器查找开销尽管位图法很快和管理复杂度。通常8-32个优先级等级足够应对大多数应用。谨慎使用相同优先级同优先级线程依赖时间片轮转其执行顺序和时间确定性会降低。除非任务确实同等重要且可接受分时执行否则应赋予不同优先级。6.2 时间片设置的经验时间片大小 (RT_TICK_PER_SECOND和线程的init_tick) 需要权衡太小会导致频繁的线程切换上下文切换开销占比过大降低系统整体吞吐量。太大会导致同优先级线程响应迟钝看起来像“卡顿”。经验值通常设置在10ms到100ms之间是一个合理的起点。例如系统tick设置为1000Hz1ms一次那么时间片设为10-100个tick即10ms-100ms。对于交互式任务可以设小一些如20ms对于纯计算型任务可以设大一些如50ms。6.3 调试调度问题当系统出现卡顿、响应慢、死锁时调度器往往是问题的核心。使用list_thread命令在RT-Thread的finsh/msh shell中输入list_thread可以查看所有线程的状态、优先级、剩余时间片、栈使用量等。这是第一诊断工具。关注线程状态检查是否有线程长期处于SUSPEND状态等待某个资源这可能是死锁的标志。分析栈使用list_thread也会显示栈的最大使用量。如果使用率接近100%可能导致栈溢出破坏其他数据包括线程控制块引发不可预知的调度错误。使用调度钩子注册一个调度钩子打印每次切换的线程信息可以直观看到CPU时间在如何分配发现“饿死”的线程或异常频繁的切换。6.4 性能优化点减少线程数量每个线程都需要独立的栈空间和线程控制块内存以及上下文切换开销。在满足功能的前提下线程越少越好。考虑使用状态机或软件定时器来替代简单的周期性线程。精简中断服务程序ISR中应只做最紧急的处理如清除标志、读取数据然后通过释放信号量或发送消息的方式让一个高优先级的线程去做后续处理。避免在ISR中进行复杂运算或调用可能导致阻塞的API。合理使用rt_enter_critical/rt_exit_critical关中断是最强的同步原语但会破坏系统的实时性。仅在对极小段临界代码如操作调度器内部链表时使用并尽可能缩短关中断时间。理解rt_schedule的无返回值rt_schedule()函数调用后并不一定立即发生切换。它只是根据当前就绪队列的情况判断是否需要切换并在需要时设置切换标志。实际的上下文切换可能发生在随后退出临界区或中断时。不要假设调用rt_schedule()后当前线程会立刻停止。7. 从源码中获得的启示与避坑指南最后结合源码阅读分享几点深刻的体会和常见的“坑”调度器锁的误用rt_enter_critical()和rt_exit_critical()通过关中断来实现。在临界区内不仅任务调度被禁止所有中断也被屏蔽。长时间关中断会导致系统无法响应外部事件破坏实时性。务必确保临界区代码执行时间极短。时间片不是精确计时器时间片轮转是基于系统tick的。如果一个线程的时间片是10个tick它不一定精确运行10ms。因为可能在运行到第5个tick时被高优先级任务抢占等它再次运行时实际消耗的墙上时钟时间可能远多于10ms。时间片保证的是占用CPU的时间而不是开始到结束的间隔时间。优先级继承不是万能的虽然互斥量的优先级继承解决了基本的优先级反转但嵌套的锁、多个资源竞争仍可能导致复杂的死锁和延迟。设计时应尽量减少锁的持有时间并避免多个线程以不同的顺序请求多个锁。线程栈初始化魔术字在RT-Thread初始化线程栈时经常会看到对栈空间填充一些特定的值如0xdeadbeef。这不仅仅是为了好玩。在调试时通过检查这些魔术字是否被改写可以判断是否发生了栈溢出。这是一个非常实用的调试技巧。rt_schedule的调用时机在中断上下文中不能直接调用可能导致阻塞的函数也不能直接调用rt_schedule()。RT-Thread提供了rt_interrupt_enter()和rt_interrupt_leave()来标记中断上下文并在rt_interrupt_leave()中检查并执行延迟的调度请求。自己编写中断处理函数或底层驱动时需要遵循这个规范。阅读RT-Thread调度器的源码就像在观摩一位嵌入式系统设计大师的作品。它没有追求最新潮的算法而是在有限资源的约束下将经典、可靠、高效的算法实现得极其精炼和优雅。理解它不仅能让你更好地使用RT-Thread更能深刻领会实时操作系统设计的核心思想在面对其他RTOS甚至自己设计任务调度框架时都能做到心中有数游刃有余。
返回列表