49、RTOS任务管理:任务创建、调度、同步,FreeRTOS源码分析

RTOS,说白了就是让单片机学会「多线程思考」。我早年做裸机开发时,一个while循环里塞满各种标志位,代码改起来跟拆炸弹似的。后来切到FreeRTOS,才明白什么叫「任务即函数,调度即人生」。

今天咱们就掰开揉碎,聊聊FreeRTOS的任务管理。我会带着你从API使用一路杀到源码实现,中间穿插几个我踩过的坑。

任务创建:从函数到线程的蜕变

任务在FreeRTOS里,本质上就是一个永不返回的C函数。你想想看,一个while(1)循环,加上适当的阻塞,就成了一个独立的任务。

void vTask1(void *pvParameters)
{
    while(1)
    {
        // 干活
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

创建任务的API是xTaskCreate()。它的原型长这样:

BaseType_t xTaskCreate(
    TaskFunction_t pvTaskCode,    // 任务函数指针
    const char * const pcName,    // 任务名(调试用)
    configSTACK_DEPTH_TYPE usStackDepth, // 栈深度,单位是字
    void *pvParameters,           // 参数
    UBaseType_t uxPriority,       // 优先级
    TaskHandle_t *pxCreatedTask   // 任务句柄
);

这里有个坑——栈深度。我见过新手把栈设成32,结果任务一跑就崩。为什么?因为任务切换时要保存上下文,浮点寄存器一压栈,32个字根本不够用。

我曾经在STM32F4上调试一个任务,栈深度设了128,结果跑着跑着就HardFault。查了两天才发现,任务里用了一个递归函数,栈溢出了。后来我养成了习惯:每个任务至少给256字的栈,复杂任务给512以上。

任务调度:谁先跑,谁后跑?

FreeRTOS用的是抢占式调度,加上时间片轮转。说白了就是:高优先级的任务随时可以打断低优先级的;同优先级的任务,大家轮流跑一个时间片。

调度器的核心数据结构是就绪列表。每个优先级对应一个链表,链着所有处于就绪态的任务。

// FreeRTOS源码中的就绪列表
PRIVILEGED_DATA static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];

调度发生时,系统从最高优先级的链表里取出第一个任务,切换过去。这个过程叫上下文切换

上下文切换干了三件事:

  • 保存当前任务的寄存器到它的栈里
  • 找到下一个要运行的任务
  • 从新任务的栈里恢复寄存器

嗯,这里要注意。上下文切换是由PendSV中断触发的。为什么不用SysTick直接切?因为SysTick优先级太高,如果在临界区里被打断,数据就乱套了。PendSV是「最低优先级中断」,等所有高优先级中断处理完了,它才干活。

我个人习惯:在任务里尽量少用taskYIELD()主动让出CPU。除非你明确知道下一个该谁跑,否则让调度器自己决定,反而更高效。

任务同步:信号量与互斥量

多任务最头疼的问题就是共享资源。两个任务同时写一个全局变量,结果就是数据错乱。

FreeRTOS提供了几种同步机制:

机制 适用场景 特点
二值信号量 任务与中断同步 只能取0或1,适合「事件发生」通知
计数信号量 资源池管理 可以计数,适合「还有几个空位」
互斥量 保护共享资源 带优先级继承,防止优先级反转
队列 任务间数据传递 先进先出,支持多生产者多消费者

我重点说说互斥量。它和信号量最大的区别是:谁拿了谁释放。而且互斥量有优先级继承机制。

什么是优先级反转?举个例子:

  • 任务A(高优先级)和任务C(低优先级)共享一个互斥量
  • 任务C先拿到了互斥量,然后被任务B(中优先级)抢占了
  • 任务A想拿互斥量,但被任务C占着,而任务C又被任务B抢占了
  • 结果:高优先级的A,被中优先级的B「反转」了

互斥量的优先级继承,就是当高优先级任务等互斥量时,临时把持有者的优先级提升到和自己一样。这样任务C就能尽快跑完,释放互斥量。

避坑指南:中断服务函数里绝对不要用互斥量。因为互斥量的优先级继承需要任务上下文,中断里没有。中断里只能用信号量或队列的FromISR版本。

FreeRTOS源码分析:任务切换的幕后

咱们看看调度器到底怎么工作的。核心函数是vTaskSwitchContext()

void vTaskSwitchContext( void )
{
    if( uxSchedulerSuspended != 0 )
    {
        // 调度器被挂起,标记需要切换
        xYieldPending = pdTRUE;
        return;
    }

    // 找到最高优先级的就绪任务
    taskSELECT_HIGHEST_PRIORITY_TASK();
}

taskSELECT_HIGHEST_PRIORITY_TASK()是个宏,展开后:

#define taskSELECT_HIGHEST_PRIORITY_TASK() \
{ \
    UBaseType_t uxTopPriority = uxTopReadyPriority; \
    // 从就绪列表中找到第一个非空链表 \
    while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) ) \
    { \
        uxTopPriority--; \
    } \
    // 取出链表头部的任务控制块 \
    pxCurrentTCB = listGET_OWNER_OF_HEAD_ENTRY( &( pxReadyTasksLists[ uxTopPriority ] ) ); \
}

你看,它从最高优先级往下找,找到第一个有任务的优先级,然后取出链表头的任务。这就是「最高优先级任务先运行」的实现。

那时间片轮转呢?在xTaskIncrementTick()里:

if( xConstTickCount == xNextTaskUnblockTime )
{
    // 检查是否有阻塞超时的任务,移到就绪列表
    prvCheckTasksWaitingTermination();
    prvCheckDelayedTasks();
}

// 时间片轮转:如果当前任务跑完了一个时间片
#if ( configUSE_TIME_SLICING == 1 )
{
    if( listCURRENT_LIST_LENGTH( &( pxReadyTasksLists[ pxCurrentTCB->uxPriority ] ) ) > 1 )
    {
        // 把当前任务移到链表尾部,让下一个同优先级任务跑
        taskYIELD();
    }
}
#endif

嗯,这里有个细节。时间片轮转只在同优先级有多个任务时才触发。如果当前优先级只有一个任务,它就一直跑,不会被切走。

我在项目中遇到过:一个通信任务优先级设得比较高,里面有个while循环在等数据。结果它把CPU占死了,低优先级的界面任务根本跑不起来。后来我给通信任务加了个vTaskDelay(1),问题解决。记住:高优先级任务一定要有阻塞点,否则低优先级任务会「饿死」。

任务状态机:从生到死

每个任务在FreeRTOS里都有四种状态:

  • 运行态:正在使用CPU
  • 就绪态:能跑,但CPU被别的任务占着
  • 阻塞态:在等某个事件(延时、信号量、队列)
  • 挂起态:被vTaskSuspend()暂停,只能由vTaskResume()恢复

状态转换图我画了个SVG,你一看就明白:

运行态 就绪态 阻塞态 挂起态 时间片/抢占 调度选中 等待事件 事件到达 vTaskSuspend vTaskResume vTaskSuspend FreeRTOS 任务状态转换图 箭头方向表示状态转换,文字标注触发条件

你看,任务在四个状态间流转。核心思想就是:能跑的任务排队等CPU,不能跑的任务去睡觉

任务控制块:任务的身份证

每个任务在FreeRTOS里都有一个TCB_t结构体,里面存着任务的所有信息:

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的寄存器就保存在这个栈里。切换回来时,从栈里恢复。

我当年看源码时,最震撼的就是这个设计——用栈来保存任务上下文。每个任务有自己的栈,切换时只需要改pxTopOfStackPSP(进程栈指针),效率极高。

我个人习惯:调试时给每个任务起个有意义的名字,比如"CommTask"、"SensorTask"。这样在调试器里看任务列表,一眼就知道哪个任务在跑。别用"Task1"、"Task2",三个月后你自己都忘了哪个是哪个。

实战建议:任务划分的黄金法则

任务怎么划分?我总结了几条经验:

  1. 按功能划分:一个功能一个任务。比如通信、显示、传感器采集各一个任务。
  2. 按实时性划分:实时性要求高的给高优先级,比如电机控制;实时性低的给低优先级,比如日志打印。
  3. 按触发源划分:周期性任务用vTaskDelayUntil(),事件驱动型任务用信号量或队列等待。
  4. 不要过度拆分:任务太多,上下文切换开销就大。一般单片机系统,5-10个任务就够了。

我曾经接手过一个项目,里面建了30多个任务。每个任务就干一点点活,然后切走。结果CPU大部分时间都在切任务,真正干活的时间不到20%。后来我合并成8个任务,性能直接翻倍。

记住:任务不是越多越好,够用就行


好了,关于FreeRTOS的任务管理,咱们就聊到这儿。从任务创建到调度机制,从同步手段到源码实现,核心就一句话:任务就是函数,调度就是排队,同步就是等信号。你把这些想明白了,RTOS就不再神秘。