3. 就绪列表与调度器:数据结构与选任务机制

好,咱们接着聊。上一章我们把任务的状态切换理清楚了,这一章我带你深入看看FreeRTOS的“心脏”——就绪列表和调度器。说白了,调度器就是那个决定“下一个该谁跑”的裁判,而就绪列表就是裁判手里的花名册。

我个人习惯把就绪列表比作一个“多级抽屉柜”。每个优先级是一个抽屉,抽屉里放着一条链表,链着所有该优先级的就绪任务。调度器选任务时,先看哪个优先级最高的抽屉里有任务,再从那个抽屉里挑一个出来跑。

3.1 就绪列表的数据结构

FreeRTOS的就绪列表,核心是两个东西:优先级位图链表。这两个配合起来,效率极高。

3.1.1 优先级位图

你想想看,如果系统支持32个优先级,调度器每次选任务时,难道要从优先级0到31挨个查一遍吗?那太慢了。FreeRTOS用了一个位图来加速。

位图其实就是一个32位的整数。每一位代表一个优先级。比如bit0代表优先级0,bit1代表优先级1,以此类推。如果某个优先级上有就绪任务,对应的位就置1。

代码里是这样定义的:

/* 在 task.c 中 */
#define portMAX_8_BIT_VALUE          ((uint8_t)0xff)
#define portMAX_16_BIT_VALUE         ((uint16_t)0xffff)
#define portMAX_32_BIT_VALUE         ((uint32_t)0xffffffffUL)

/* 优先级位图变量 */
#if ( configUSE_PORT_OPTIMISED_TASK_SELECTION == 1 )
    /* 使用硬件指令优化的版本 */
    #define taskSELECT_HIGHEST_PRIORITY_TASK()    ...
#else
    /* 通用版本,使用软件查找 */
    static volatile UBaseType_t uxTopReadyPriority;  /* 记录当前最高就绪优先级 */
    static volatile UBaseType_t uxTopReadyPriority;  /* 位图变量 */
#endif

嗯,这里要注意:uxTopReadyPriority这个变量,它记录的是当前所有就绪任务中,最高的优先级是多少。调度器不用遍历所有优先级,直接看这个变量就知道该从哪个抽屉里拿任务。

核心思想:用空间换时间。一个32位的整数,就能知道所有优先级的就绪情况。查找最高优先级时,用一条指令就能找到最高位的1在哪。

3.1.2 就绪链表

每个优先级对应一条链表。链表的节点就是任务控制块(TCB)。同一个优先级的多个任务,按时间片轮转的方式挂在链表上。

数据结构是这样的:

/* 就绪列表的定义 */
typedef struct xLIST
{
    UBaseType_t uxNumberOfItems;    /* 列表中任务的数量 */
    ListItem_t * pxIndex;           /* 用于遍历的索引指针 */
    MiniListItem_t xListEnd;        /* 列表结束标记 */
} List_t;

/* 每个优先级一个列表 */
static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];

你看,pxReadyTasksLists是一个数组,数组的每个元素是一条链表。比如pxReadyTasksLists[5]就是优先级5的就绪任务链表。

我在项目中遇到过一个问题:某个高优先级任务一直占着CPU,低优先级任务饿死了。后来排查发现,是同一个优先级下多个任务没有正确使用时间片轮转。嗯,这里要提醒你,同优先级任务默认是时间片轮转的,但如果你把时间片设得太大,或者任务里加了阻塞延时,轮转效果就会打折扣。

3.2 调度器如何选任务

调度器选任务的过程,我总结为三步:查位图 → 找链表 → 取任务

3.2.1 查位图:找到最高优先级

调度器先看uxTopReadyPriority这个变量。如果它等于5,说明优先级5是当前最高的就绪优先级。

但这里有个细节:uxTopReadyPriority是怎么更新的?每次任务进入就绪态时,如果它的优先级比当前记录的uxTopReadyPriority高,就更新这个变量。同样,当任务离开就绪态时,如果那个优先级上没任务了,就要重新计算最高优先级。

重新计算最高优先级,用的是位图查找算法。通用版本是软件循环查找:

/* 通用版本:软件查找最高优先级 */
#define taskRECORD_READY_PRIORITY( uxPriority )                                 \
{                                                                               \
    if( ( uxPriority ) > uxTopReadyPriority )                                   \
    {                                                                           \
        uxTopReadyPriority = ( uxPriority );                                    \
    }                                                                           \
}

#define taskSELECT_HIGHEST_PRIORITY_TASK()                                      \
{                                                                               \
    UBaseType_t uxTopPriority = uxTopReadyPriority;                             \
    /* 从最高优先级开始,向下查找第一个非空链表 */                                \
    while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) )       \
    {                                                                           \
        --uxTopPriority;                                                        \
    }                                                                           \
    /* 从该链表中取出第一个任务 */                                               \
    pxCurrentTCB = listGET_OWNER_OF_HEAD_ENTRY(                                 \
                    &( pxReadyTasksLists[ uxTopPriority ] ) );                  \
    uxTopReadyPriority = uxTopPriority;                                         \
}

如果硬件支持CLZ(Count Leading Zeros)指令,那就更快了。ARM Cortex-M3/M4都支持,一条指令就能找到最高优先级的位。

我的建议:如果你的芯片支持CLZ指令,一定要开启configUSE_PORT_OPTIMISED_TASK_SELECTION这个宏。选任务的时间从O(n)变成O(1),实时性提升很明显。

3.2.2 找链表:取出任务控制块

找到最高优先级后,就从对应的链表中取出第一个任务。注意,这里取的是链表头部的任务。因为同优先级任务采用时间片轮转,每次调度都会把当前任务移到链表尾部,然后取新的头部任务。

代码里是这样做的:

/* 从就绪链表中取出最高优先级的任务 */
pxCurrentTCB = listGET_OWNER_OF_HEAD_ENTRY( 
                &( pxReadyTasksLists[ uxTopReadyPriority ] ) );

listGET_OWNER_OF_HEAD_ENTRY这个宏,就是获取链表头部节点所属的任务控制块。说白了,就是拿到那个任务的TCB指针。

3.2.3 切换上下文:让任务跑起来

拿到TCB指针后,调度器就调用portYIELD()或者taskYIELD()触发PendSV中断,在中断里完成上下文切换。这部分我们后面章节会详细讲,这里先记住:选任务只是第一步,真正让任务跑起来的是上下文切换。

我曾经踩过的坑:有一次我在中断服务函数里调用了taskYIELD(),结果系统直接死机了。后来发现,中断里调用taskYIELD()需要特别小心,因为中断上下文和任务上下文不一样。正确的做法是在中断里设置一个标志,然后在任务里检查这个标志再调用taskYIELD()

3.3 调度器的触发时机

调度器不是一直在跑的,它只在特定时刻被触发。我总结了一下,主要有这几个时机:

  • 任务主动让出CPU:比如调用taskYIELD()vTaskDelay()
  • 时间片用完:SysTick中断触发,检查当前任务的时间片是否用完
  • 更高优先级任务就绪:比如中断里释放了信号量,唤醒了一个高优先级任务
  • 任务被阻塞或挂起:当前任务主动进入阻塞态,调度器立刻选下一个任务

你想想看,如果调度器每时每刻都在跑,那CPU就全浪费在调度上了。所以FreeRTOS的设计是“按需调度”,只在必要的时候才触发调度。

3.4 一个完整的调度流程示例

假设系统有三个任务:

任务名 优先级 当前状态
TaskA 5 就绪
TaskB 3 就绪
TaskC 5 就绪

调度流程是这样的:

  1. 调度器查看uxTopReadyPriority,发现最高优先级是5
  2. pxReadyTasksLists[5]链表中取出头部任务
  3. 假设头部任务是TaskA,那么pxCurrentTCB指向TaskA
  4. 触发PendSV中断,保存TaskB的上下文,恢复TaskA的上下文
  5. TaskA开始运行
  6. TaskA的时间片用完后,SysTick中断触发,调度器再次运行
  7. 调度器把TaskA移到链表尾部,取出TaskC作为下一个运行任务
  8. 切换上下文,TaskC开始运行

你看,整个过程非常清晰。优先级5的两个任务轮流跑,优先级3的TaskB只能等优先级5的任务都阻塞了才有机会。

总结一下:就绪列表用位图+链表的结构,实现了O(1)时间复杂度的任务选择。调度器只在必要时触发,选任务的过程就是“查位图→找链表→取任务”三步。理解了这个,你就掌握了FreeRTOS调度的核心。

下一章,我会带你看看任务切换的具体实现——那个神秘的PendSV中断里到底发生了什么。