4.5 线程调度算法:基于位图的优先级调度

调度算法,说白了就是操作系统决定「下一个该谁跑」的规则。RT-Thread 用的是基于位图的优先级调度,这个设计非常巧妙。我第一次看源码时,说实话被那个位图操作惊艳到了——用几个整数就搞定了优先级查找,效率极高。

RT-Thread 支持 256 个优先级(0 到 255),0 最高,255 最低。每个优先级对应一个就绪队列。调度器要做的,就是从所有就绪线程中,找到优先级最高的那个。

你可能会问:「遍历 256 个优先级,不就行了?」嗯,理论上可以。但实时系统里,每次调度都要遍历,太慢了。RT-Thread 用位图来优化,核心思想是:用 32 位整数的每一位,代表一个优先级是否有就绪线程

核心数据结构

// 优先级位图,共8个32位整数,覆盖256个优先级
rt_uint32_t rt_thread_ready_priority_group;
rt_uint8_t  rt_thread_ready_table[32];

举个例子:如果优先级 5 有就绪线程,那么 rt_thread_ready_table[0] 的第 5 位就置 1。同时 rt_thread_ready_priority_group 的第 0 位也置 1(因为 table[0] 非零)。

查找最高优先级时,RT-Thread 用了一个查表法:

// 获取最高优先级的算法
register rt_ubase_t number;
number = __rt_ffs(rt_thread_ready_priority_group) - 1;
highest_priority = (number << 3) + __rt_ffs(rt_thread_ready_table[number]) - 1;

__rt_ffs 是「查找第一个置位位」的函数,返回 1~32。减去 1 就是位索引。这个函数通常用 CPU 指令实现,比如 ARM 的 CLZ 指令,一个周期就搞定。

我的经验:我在一个工业控制项目中,任务数量超过 30 个,优先级用了 20 多个。位图调度的查找时间基本恒定,不会因为任务增多而变慢。这点比链表遍历强太多了。

4.6 时间片轮转

同优先级的线程怎么调度?答案是时间片轮转。每个线程可以设置一个时间片,单位是系统滴答(tick)。默认情况下,时间片是 10 个 tick。

假设优先级 10 上有三个线程 A、B、C,时间片都是 10。调度器会这样工作:

  1. A 运行 10 个 tick,然后被换下
  2. B 运行 10 个 tick,然后被换下
  3. C 运行 10 个 tick,然后被换下
  4. 回到 A,继续循环

时间片用完后,线程会被放到就绪队列的末尾。这个操作在时钟中断里完成。

// 时间片处理(简化版)
void rt_systick_handler(void)
{
    // ... 其他处理 ...
    
    // 当前线程时间片减1
    rt_current_thread->remaining_tick--;
    
    // 时间片用完,触发调度
    if (rt_current_thread->remaining_tick == 0) {
        rt_current_thread->remaining_tick = rt_current_thread->init_tick;
        // 将当前线程移到就绪队列末尾
        rt_schedule();
    }
}

注意:时间片只对同优先级有效。如果高优先级线程就绪了,低优先级线程即使时间片没用完,也会被抢占。我曾经遇到一个 bug,就是因为把时间片设得太大,导致其他同优先级线程迟迟得不到运行。

4.7 线程切换过程详解

线程切换,是 RT-Thread 最核心的操作。说白了就是:保存当前线程的上下文,恢复目标线程的上下文。上下文包括 CPU 寄存器、栈指针、程序计数器等。

切换过程分为三步:

4.7.1 触发调度

调度可以由以下情况触发:

  • 线程主动调用 rt_thread_yield()
  • 线程被挂起(等待信号量、消息队列等)
  • 时间片用完
  • 中断退出时

4.7.2 选择下一个线程

调度器调用 rt_schedule(),通过位图找到最高优先级的就绪线程。如果当前线程还在就绪队列中,且优先级相同,则继续运行当前线程(时间片轮转时除外)。

4.7.3 上下文切换

这是最底层的操作,用汇编实现。以 ARM Cortex-M 为例:

; 上下文切换汇编代码(简化)
rt_hw_context_switch:
    ; 保存当前线程的上下文
    STMFD   SP!, {LR}        ; 保存返回地址
    STMFD   SP!, {R4-R11}    ; 保存通用寄存器
    STR     SP, [R0]         ; 保存栈指针到线程控制块
    
    ; 恢复目标线程的上下文
    LDR     SP, [R1]         ; 从线程控制块加载栈指针
    LDMFD   SP!, {R4-R11}    ; 恢复通用寄存器
    LDMFD   SP!, {PC}        ; 恢复程序计数器

嗯,这里要注意:Cortex-M 在进入中断时,硬件会自动保存 R0-R3、R12、LR、PC、xPSR。所以 PendSV 中断里的切换,只需要保存 R4-R11 即可。

切换开销:一次完整的线程切换,大约需要几十到几百个 CPU 周期。我实测过,在 72MHz 的 STM32 上,一次切换约 1-2 微秒。这个时间对大多数应用来说可以忽略。

4.8 空闲线程与钩子函数

空闲线程是 RT-Thread 自动创建的,优先级最低(255)。当没有其他线程可运行时,系统就运行空闲线程。

空闲线程主要做两件事:

  • 回收已删除线程的资源(栈、TCB 等)
  • 执行用户注册的钩子函数

钩子函数,说白了就是回调函数。你可以在空闲线程里注册一些低优先级的任务,比如:

  • 系统休眠(WFI 指令)
  • LED 闪烁指示
  • CPU 利用率统计
  • 看门狗喂狗
// 注册空闲钩子函数
static void idle_hook(void)
{
    // 进入低功耗模式
    __WFI();
}

void application_init(void)
{
    rt_thread_idle_sethook(idle_hook);
}

避坑指南:我曾经在空闲钩子里放了一个延时函数,结果系统直接卡死。为什么?因为空闲线程里不能调用任何可能导致线程挂起的函数(比如延时、等待信号量)。空闲线程必须能一直运行,否则系统就崩了。

另外,空闲线程的栈大小默认是 256 字节。如果你的钩子函数用栈比较多,记得调大。我一般设成 512 字节,留点余量。

4.8.1 CPU 利用率计算

利用空闲线程,可以很方便地计算 CPU 利用率:

static rt_uint32_t idle_count = 0;
static rt_uint32_t total_count = 0;

void idle_hook_cpu_usage(void)
{
    idle_count++;
}

void calculate_cpu_usage(void)
{
    rt_uint32_t idle_start, idle_end;
    rt_uint32_t total_start, total_end;
    
    idle_count = 0;
    total_start = rt_tick_get();
    
    rt_thread_delay(100);  // 等待100个tick
    
    total_end = rt_tick_get();
    idle_end = idle_count;
    
    // CPU利用率 = 1 - (空闲计数 / 总计数)
    rt_kprintf("CPU Usage: %d%%\n", 
        100 - (idle_end * 100 / (total_end - total_start)));
}

这个方法虽然简单,但很实用。我在好几个项目里都用它来监控系统负载,效果不错。

再次提醒:空闲钩子函数里不要调用任何阻塞 API。包括 rt_thread_delayrt_sem_takert_mb_recv 等。这些函数会让空闲线程挂起,而系统没有其他线程可运行,结果就是死锁。

好了,线程调度这块的核心内容就这些。位图调度、时间片轮转、上下文切换、空闲线程,这四个知识点串起来,就是 RT-Thread 调度器的全貌。下一章我们讲线程同步,到时候会看到信号量和互斥量是怎么和调度器配合的。