2、FreeRTOS内核源码分析:任务控制块、就绪列表、调度器启动流程

好,咱们今天来啃一块硬骨头。任务控制块、就绪列表、调度器启动流程,这三个东西是FreeRTOS的命根子。我当年刚接触RTOS时,就是先把这三块搞透,后面再看消息队列、信号量什么的,就顺多了。

说白了,任务控制块就是任务的“身份证”,就绪列表是任务的“排队区”,调度器启动就是“发令枪”。咱们一个一个来拆。

2.1 任务控制块(TCB)—— 任务的身份证

每个任务在FreeRTOS里都有一个对应的结构体,叫tskTaskControlBlock。你创建任务时,内核就给你分配一个TCB。任务挂了,TCB就回收。我早期做无人机飞控时,遇到过任务栈溢出导致TCB被踩坏的情况,查了三天才定位到——嗯,从那以后我特别关注TCB的完整性。

TCB长什么样?核心字段就这些:

typedef struct tskTaskControlBlock {
    volatile StackType_t *pxTopOfStack;    // 栈顶指针,切换任务就靠它
    ListItem_t xStateListItem;             // 状态列表项,挂到就绪/阻塞/挂起列表
    ListItem_t xEventListItem;             // 事件列表项,用于同步
    UBaseType_t uxPriority;                // 任务优先级
    StackType_t *pxStack;                  // 栈起始地址
    char pcTaskName[configMAX_TASK_NAME_LEN]; // 任务名,调试用
    // ... 还有一堆,但核心就这些
} tskTCB;

这里我重点说两个东西:

  • pxTopOfStack:这是CPU寄存器的“快照”。每次任务切换,CPU就把当前寄存器压栈,然后更新这个指针。下次切回来,直接弹栈恢复。你想想看,没有这个指针,任务切出去就回不来了。
  • xStateListItem:这个ListItem里有个pvOwner指针,指向TCB本身。所以通过列表项,内核能反向找到任务。这是FreeRTOS里很经典的设计模式。

重要:TCB是静态分配的,还是在堆上动态分配的?取决于你的configSUPPORT_DYNAMIC_ALLOCATION配置。我建议无人机项目用静态分配,避免堆碎片导致任务创建失败——我在一次飞行测试中就吃过这个亏。

2.2 就绪列表 —— 任务的排队区

就绪列表,说白了就是所有“能跑”的任务在排队。FreeRTOS用了一个数组:

static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];

每个优先级对应一个链表。优先级0的任务挂在pxReadyTasksLists[0]上,优先级1挂在[1]上,以此类推。

为什么用数组+链表?而不是一个全局链表?

因为调度器选任务时,要从最高优先级开始找。如果是数组,直接查uxTopReadyPriority这个位图,就能知道当前最高优先级是几,然后去那个链表里取第一个任务。时间复杂度O(1)。

我举个例子:

  • 优先级5的链表里有3个任务
  • 优先级3的链表里有2个任务
  • 优先级0的链表里有5个任务

调度器一看uxTopReadyPriority是5,直接去pxReadyTasksLists[5]取第一个任务。优先级3和0的?等优先级5的任务都阻塞了再说。

个人经验:无人机飞控里,我通常把控制环路放在最高优先级(比如优先级4),传感器读取放在次高(优先级3),通信任务放在最低(优先级0)。这样即使通信卡了,飞机也不会失控。

2.3 调度器启动流程 —— 发令枪怎么响

调度器启动,入口函数是vTaskStartScheduler()。这个函数干了三件大事:

  1. 创建空闲任务:优先级0,永远不阻塞。没有其他任务可跑时,就它上。
  2. 初始化系统节拍定时器:一般是SysTick,配置成1ms中断一次。这个中断就是任务切换的“心跳”。
  3. 启动第一个任务:调用portTASK_SWITCH_HOOK或者直接跳转到最高优先级任务的栈里。

第三步最核心。我带你看看伪代码:

void vTaskStartScheduler( void ) {
    // 1. 创建空闲任务
    xIdleTaskHandle = xTaskCreate( prvIdleTask, ... );
    
    // 2. 初始化节拍定时器
    vPortSetupTimerInterrupt();
    
    // 3. 启动调度器
    if( xPortStartScheduler() ) {
        // 这里永远不会返回
        // 因为CPU已经交给任务了
    }
}

xPortStartScheduler()里做了什么?

  • 设置PendSV和SysTick的中断优先级为最低
  • 触发一次PendSV异常
  • 在PendSV处理函数里,调用vTaskSwitchContext()选出第一个任务
  • 然后恢复这个任务的寄存器,开始执行

为什么会这样?因为第一次启动时,没有“当前任务”。所以需要手动触发一次上下文切换,让调度器选一个任务出来。

注意:启动调度器之前,必须至少创建一个用户任务。否则空闲任务会立即执行,然后因为没有其他任务,系统就挂在那了。我曾经在培训新人时,看他调了半天,结果就是忘了创建任务——嗯,这种坑踩过一次就不会再犯了。

2.4 任务切换的完整链路

调度器启动后,任务切换靠什么?两个中断:

中断 触发时机 作用
SysTick 每个节拍(通常1ms) 更新时间片,触发任务切换
PendSV 被软件触发 实际执行上下文保存和恢复

SysTick中断里,调用xTaskIncrementTick(),更新任务延时、时间片。如果当前任务时间片用完,就设置一个标志,然后在PendSV里切换。

PendSV是“可挂起的系统调用”,优先级最低。这意味着它不会打断其他中断。等所有高优先级中断处理完了,才轮到PendSV做任务切换。这个设计很巧妙——保证了中断响应的实时性。

我画个流程图你感受下:

SysTick中断
    |
    v
xTaskIncrementTick()
    |-- 更新延时任务
    |-- 检查时间片
    |-- 设置切换标志
    |
    v
触发PendSV
    |
    v
PendSV处理函数
    |-- 保存当前任务寄存器到栈
    |-- 调用vTaskSwitchContext()选新任务
    |-- 恢复新任务寄存器
    |-- 返回,执行新任务

你看,整个链路很清晰。没有多余的跳转,没有复杂的算法。FreeRTOS之所以流行,就是因为它把复杂的东西做简单了。

避坑指南:我曾经在STM32F4上移植FreeRTOS时,发现SysTick中断优先级设置不对,导致任务切换卡死。后来查手册才发现,Cortex-M的 PendSV和SysTick必须设置为最低优先级,否则会死锁。这个坑,我建议你移植时第一个检查。

2.5 小结

这一章我们聊了三个核心:

  • TCB:任务的身份证,记录了栈指针、优先级、状态
  • 就绪列表:按优先级分组的链表,调度器O(1)选任务
  • 调度器启动:创建空闲任务、配置定时器、触发第一次切换

下一章,我们会深入任务创建和删除的源码。到时候你会看到,TCB是怎么分配、怎么初始化、怎么挂到就绪列表里的。嗯,那才是真正动手的地方。

公众号:蓝海资料掘金营,微信deep3321