任务与调度器:让单片机学会“多线程”思考

各位同学,欢迎来到第三章。前两章我们搭好了RTOS的骨架——临界区和链表。现在,是时候给它注入灵魂了。

这一章,我们要聊的是RTOS最核心的两个概念:任务调度器。说白了,就是让单片机看起来能“同时”干好几件事。你想想看,一个单核CPU,怎么做到一边刷屏、一边读按键、一边处理串口数据?嗯,答案就在任务切换里。

任务到底是什么?——TCB、栈、优先级

在RTOS的世界里,任务不是一个函数,而是一个“执行上下文”。我习惯把任务想象成一个独立的小人,他有自己的状态、自己的记忆、自己的优先级。

每个任务背后,站着三个核心数据结构:

  • TCB(任务控制块):任务的身份证。里面存了栈指针、优先级、状态、名字等。调度器就是靠TCB来管理所有任务的。
  • 任务栈:任务的私人记事本。函数调用、局部变量、中断现场,全记在这里。栈大小设错了,系统就崩了。我在项目中遇到过,栈设小了,任务跑着跑着就莫名其妙死机,查了两天才发现是栈溢出。
  • 优先级:任务的“插队权”。数值越小,优先级越高(有些RTOS相反,看具体实现)。高优先级的任务先跑,跑完了才轮到低优先级的。

核心要点:没有TCB,调度器就不知道任务的存在。没有栈,任务就没法恢复执行。没有优先级,调度器就不知道先服务谁。

调度器的工作原理——谁先跑,谁后跑?

调度器,就是RTOS的大脑。它决定下一个该执行哪个任务。常见的调度策略有两种:

1. 时间片轮转(Round-Robin)

每个任务轮流跑一小段时间,时间到了就换下一个。大家机会均等,谁也不占便宜。适合那些“大家差不多重要”的场景。

但说实话,我在实际项目中很少只用时间片轮转。因为嵌入式系统里,任务的重要性往往不一样。比如,电机控制任务比LED闪烁任务重要得多。你让它们平分时间?电机可能会抖。

2. 抢占式调度(Preemptive Scheduling)

这才是RTOS的杀手锏。高优先级的任务可以随时“抢”低优先级任务的CPU使用权。低优先级的任务?乖乖等着吧。

举个例子:你正在写一个低优先级的日志打印任务,突然来了一个高优先级的按键中断。抢占式调度会立刻暂停日志任务,让按键任务先跑。等按键任务处理完了,再回来继续打印日志。

避坑指南:我曾经在一个项目中,把两个任务的优先级设反了。结果高优先级的任务一直占着CPU,低优先级的任务永远得不到执行。系统看起来没死,但该干的事一件没干。嗯,这就是传说中的“优先级反转”的雏形。

创建第一个RTOS任务——vTaskCreate

理论说完了,咱们来点实际的。在FreeRTOS中,创建任务的API是vTaskCreate。它的原型长这样:

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

参数看着多,其实用起来很简单。我给你写个例子:

void vTask1(void *pvParameters)
{
    while(1)
    {
        // 任务代码
        printf("Task 1 is running\n");
        vTaskDelay(pdMS_TO_TICKS(1000)); // 延时1秒
    }
}

void vTask2(void *pvParameters)
{
    while(1)
    {
        printf("Task 2 is running\n");
        vTaskDelay(pdMS_TO_TICKS(500)); // 延时0.5秒
    }
}

void main(void)
{
    // 初始化硬件
    // ...

    // 创建两个任务
    xTaskCreate(vTask1, "Task1", 128, NULL, 1, NULL);
    xTaskCreate(vTask2, "Task2", 128, NULL, 2, NULL);

    // 启动调度器
    vTaskStartScheduler();

    // 正常情况下不会执行到这里
    while(1);
}

你看,创建任务就这么简单。两个任务,一个优先级1,一个优先级2。优先级2的任务会抢占优先级1的任务。运行起来,你会看到Task2打印的频率比Task1快一倍。

个人经验:我建议新手在创建任务时,先给每个任务分配128字的栈。跑起来后,用uxTaskGetStackHighWaterMark()看看实际用了多少。然后根据实际情况调整。别一开始就给很大,浪费内存。

体验多任务并发——眼见为实

理论再漂亮,不如亲眼看到效果。我建议你拿一块开发板,把上面的代码烧进去。打开串口助手,你会看到:

  • Task1每隔1秒打印一次
  • Task2每隔0.5秒打印一次
  • 两个任务“同时”在跑,互不干扰

这就是多任务并发的魅力。虽然底层是CPU在快速切换,但看起来就像有两个独立的“大脑”在干活。

你可能会问:“那如果两个任务同时要访问同一个全局变量怎么办?”嗯,这个问题问得好。这就是我们下一章要讲的——同步与互斥。到时候我会告诉你,怎么用信号量和互斥锁来避免数据打架。

好了,这一章的内容就到这里。记住:任务和调度器是RTOS的基石。把这两个概念吃透了,后面的内容就会轻松很多。下一章,我们聊聊任务状态和切换的细节。