跳到主要内容

第6节 FreeRTOS

重要提示:关于开发板的兼容性

本教程的核心逻辑适用于所有 ESP32 开发板,但所有操作步骤均以 微雪 ESP32-S3-Zero 迷你开发板 为例进行讲解。如果您使用其他型号的开发板,请根据实际情况修改相应设置。

本节介绍 FreeRTOS 的相关概念,并介绍如何在 ESP-IDF 中使用其常用 API。

FreeRTOS 是一个开源的实时操作系统(RTOS)内核,作为组件集成到 ESP-IDF 中。所有 ESP-IDF 应用程序和大部分组件都基于 FreeRTOS 编写。

需要注意,ESP-IDF 并非直接使用原生 FreeRTOS,而是在其基础上进行了大量定制和优化,特别是对多核(SMP)架构的支持,这一实现被称为 IDF FreeRTOS。虽然也可以通过配置启用 Amazon SMP FreeRTOS(官方 SMP 实现),但该实现目前处于试验/测试状态,默认并未采用。

IDF FreeRTOS 在 ESP32 上的核心特性包括:

  • 多任务与实时性:支持任务优先级、任务间通信(如队列、信号量、事件组)、软件定时器等,满足物联网常见实时需求。
  • SMP 支持:IDF FreeRTOS 针对双核(最多支持两个核)优化,任务既可指定运行在特定核(核亲和性),也可在任意核调度执行。
  • 针对 ESP 硬件的适配:结合 ESP 芯片的对称内存、原子操作、跨核中断等硬件特性,实现高效的多核调度与同步。

1. FreeRTOS 基本概念

FreeRTOS 多任务

FreeRTOS 在 ESP-IDF 中通过任务(Task)机制实现多任务。每个任务本质上是一个独立的执行线程,开发者可以通过 API 创建、删除和管理多个任务。这些任务由 FreeRTOS 内核调度,按照优先级和时间片轮转等策略在 CPU 上运行,实现“多任务”效果。

1.1 任务状态

FreeRTOS 将线程称为“任务(Task)”,每个任务都实现为一个 C 函数,通常包含一个无限循环。

FreeRTOS 任务

任务状态:任务在任何时候都可以处于四种状态之一:

  • 运行态 (Running):当前正在被 CPU 执行。
  • 就绪态 (Ready):已准备好执行,但 CPU 正在执行其他任务。
  • 阻塞态 (Blocked):正在等待某个事件(比如外设或者超时),不消耗 CPU 时间。
  • 挂起态 (Suspended):被无限期阻塞,直到被主动恢复。

1.2 任务调度

FreeRTOS 如何选择切换到哪个任务?其调度可概括为:具有时间分片和固定优先级的抢占式调度器。

在原生 FreeRTOS 中,调度器的三个核心特性为:

  • 固定优先级(fixed priority):总是选择处于就绪态的最高优先级任务运行。
  • 时间分片/轮询(round robin):当最高优先级存在多个就绪任务时,按轮转依次运行。
  • 抢占(preemptive):当更高优先级任务变为就绪时,立即切换到该任务。

在 ESP32、ESP32-S3、ESP32-P4 等双核芯片上,ESP-IDF 采用了支持对称多处理(SMP)的 IDF FreeRTOS 实现。SMP 下调度还需考虑:

  • 核亲和性:每个任务可以设置亲和性,指定运行于核 0、核 1 或任意核。每个核独立调度,只能选择“在该核上可运行”的最高优先级就绪任务。任务能否在某核上运行,需满足亲和性以及它当前是否已在另一个核心上运行。因此,即使有多个核和多个最高优先级的就绪任务,也不一定每个核都能运行一个最高优先级任务。
  • 时间分片:由于亲和性或任务已在另一核运行,无法实现完美轮转。IDF FreeRTOS 调度器会将刚被选中运行的任务移至队尾,并总是从队头开始搜索可运行任务;必要时会跳过某些任务,甚至去查找并运行一个较低优先级的可运行任务。这种策略确保同优先级任务在足够的滴答后最终都能获得运行时间。
  • 抢占:当更高优先级任务变为就绪且可在多个核上运行时,调度器只会抢占一个核,并总是优先选择当前核(即触发该就绪事件的核)。

2. 示例:任务管理

注意

本示例代码只能在具有多核心的 ESP32 芯片上运行。

本示例演示如何在 ESP-IDF 的 FreeRTOS 中创建两个任务并固定到不同核心,利用周期性延时驱动循环,展示任务的挂起/恢复与删除,帮助理解 FreeRTOS 多任务调度与任务间基本协作。

2.1 示例代码

  1. 创建一个项目。如果不清楚如何操作,请参考 从模板创建项目

  2. 将以下代码复制到 main/main.c 中:

    #include <stdio.h>

    #include "freertos/FreeRTOS.h"
    #include "freertos/task.h"

    TaskHandle_t myTaskHandleA = NULL;
    TaskHandle_t myTaskHandleB = NULL;

    void Demo_Task_A(void *arg)
    {

    int count = 0;
    while (1)
    {
    count++;
    printf("Demo_Task_A printing...%d\n", count);
    if (count == 10)
    {
    // 恢复任务 B 的运行
    printf("Demo_Task_A resumed Demo_Task_B!\n");
    vTaskResume(myTaskHandleB);
    }
    vTaskDelay(pdMS_TO_TICKS(1000));
    }
    }

    void Demo_Task_B(void *arg)
    {
    int count = 0;
    while (1)
    {
    count++;
    printf("Demo_Task_B printing...%d\n", count);
    if (count == 5)
    {
    // 挂起自身:调度器将不再调度本任务,直到被其他任务 vTaskResume
    printf("Demo_Task_B is suspended itself!\n");
    vTaskSuspend(NULL);
    }
    if (count == 10)
    {
    // 自删除:退出任务;堆栈等资源由 Idle 任务回收
    printf("Demo_Task_B is deleted itself!\n");
    vTaskDelete(NULL);
    }
    vTaskDelay(pdMS_TO_TICKS(1000));
    }
    }

    void app_main(void)
    {
    // 创建任务并固定到指定核心;栈大小 4096 字节;两个任务同优先级 10
    // A 固定到核心 0,B 固定到核心 1(在双核上可并行运行)
    xTaskCreatePinnedToCore(Demo_Task_A, "Demo_Task_A", 4096, NULL, 10, &myTaskHandleA, 0);
    xTaskCreatePinnedToCore(Demo_Task_B, "Demo_Task_B", 4096, NULL, 10, &myTaskHandleB, 1);
    }

2.2 构建并烧录代码

  1. 配置烧录选项

    首先,在构建和烧录之前,请务必检查并设置正确的目标设备、串口和烧录方式。参考 第 2 节 运行示例 - 1.3 配置项目

    VS Code 工具栏

  2. 点击 VS Code 一键构建烧录监视图标 一键自动依次执行构建、烧录和监视这三个步骤。

  3. 烧录完成后,串口监视器会开始打印信息。

    备注

    在多任务环境下 printf 输出可能交错,这是正常现象。

    Demo_Task_A printing...1
    Demo_Task_B printing...1
    Demo_Task_A printing...2
    Demo_Task_B printing...2
    Demo_Task_A printing...3
    Demo_Task_B printing...3
    Demo_Task_A printing...4
    Demo_Task_B printing...4
    Demo_Task_A printing...5
    Demo_Task_B printing...5
    Demo_Task_B is suspended itself!
    Demo_Task_A printing...6
    Demo_Task_A printing...7
    Demo_Task_A printing...8
    Demo_Task_A printing...9
    Demo_Task_A printing...10
    Demo_Task_A resumed Demo_Task_B!
    Demo_Task_A printing...11
    Demo_Task_B printing...6
    Demo_Task_A printing...12
    Demo_Task_B printing...7
    Demo_Task_A printing...13
    Demo_Task_B printing...8
    Demo_Task_A printing...14
    Demo_Task_B printing...9
    Demo_Task_A printing...15
    Demo_Task_B printing...10
    Demo_Task_B is deleted itself!
    Demo_Task_A printing...16
    Demo_Task_A printing...17
    Demo_Task_A printing...18
    Demo_Task_A printing...19
    ...

2.3 代码解析

  • 包含头文件

    #include <stdio.h>
    #include "freertos/FreeRTOS.h"
    #include "freertos/task.h"
    • stdio.h: C 标准输入输出库,我们使用其中的 printf 函数向控制台打印任务运行信息。
    • freertos/FreeRTOS.hfreertos/task.h: 提供 FreeRTOS 核心功能和任务管理相关的 API,例如任务的创建、删除、挂起、恢复和延时等。
  • 定义任务句柄

    TaskHandle_t myTaskHandleA = NULL;
    TaskHandle_t myTaskHandleB = NULL;
    • TaskHandle_t 是 FreeRTOS 中用于唯一标识一个任务的类型。
    • 我们定义了两个全局的句柄变量 myTaskHandleAmyTaskHandleB,分别用于保存任务 A 和任务 B 的句柄。
    • 将句柄设为全局变量,是为了让一个任务能够引用并操作另一个任务,例如在本例中,任务 A 需要使用任务 B 的句柄来恢复它。
  • 任务 A (Demo_Task_A)

    void Demo_Task_A(void *arg)
    {
    int count = 0;
    while (1)
    {
    count++;
    printf("Demo_Task_A printing...%d\n", count);
    if (count == 10)
    {
    // 恢复任务 B 的运行
    printf("Demo_Task_A resumed Demo_Task_B!\n");
    vTaskResume(myTaskHandleB);
    }
    vTaskDelay(pdMS_TO_TICKS(1000));
    }
    }
    • 这是一个无限循环的任务函数,每秒钟执行一次计数和打印操作。
    • vTaskDelay(pdMS_TO_TICKS(1000)): 使任务 A 阻塞(暂停)1000 毫秒,将 CPU 时间让给其他任务。
    • if (count == 10)...: 当任务 A 的计数达到 10 时,让任务 B 恢复运行。
    • vTaskResume(myTaskHandleB): 调用此函数并传入任务 B 的句柄,可以使处于挂起状态的任务 B 恢复运行。
  • 任务 B (Demo_Task_B)

    void Demo_Task_B(void *arg)
    {
    int count = 0;
    while (1)
    {
    count++;
    printf("Demo_Task_B printing...%d\n", count);
    if (count == 5)
    {
    // 挂起自身:调度器将不再调度本任务,直到被其他任务 vTaskResume
    printf("Demo_Task_B is suspended itself!\n");
    vTaskSuspend(NULL);
    }
    if (count == 10)
    {
    // 自删除:退出任务;堆栈等资源由 Idle 任务回收
    printf("Demo_Task_B is deleted itself!\n");
    vTaskDelete(NULL);
    }
    vTaskDelay(pdMS_TO_TICKS(1000));
    }
    }
    • 此任务同样每秒计数并打印。
    • if (count == 5)...: 当任务 B 的计数达到 5 时,它将调用 vTaskSuspend(NULL)
      • vTaskSuspend(): 用于挂起任务。被挂起的任务将不会再被调度器分配 CPU 时间,进入休眠状态。
      • 参数传入 NULL 表示挂起任务自身。
    • if (count == 10)...: 在被任务 A 恢复后,当其计数达到 10 时,它将调用 vTaskDelete(NULL)
      • vTaskDelete(): 用于删除任务。释放其占用的内存(任务控制块和栈)。
      • 参数传入 NULL 表示删除任务自身。
      • 注意: 一个 RTOS 任务函数不应 return,应通过 vTaskDelete(NULL) 正确退出任务。
  • 主函数 (app_main)

    void app_main(void)
    {
    // 创建任务并固定到指定核心;栈大小 4096 字节;两个任务同优先级 10
    // A 固定到核心 0,B 固定到核心 1(在双核上可并行运行)
    xTaskCreatePinnedToCore(Demo_Task_A, "Demo_Task_A", 4096, NULL, 10, &myTaskHandleA, 0);
    xTaskCreatePinnedToCore(Demo_Task_B, "Demo_Task_B", 4096, NULL, 10, &myTaskHandleB, 1);
    }
    • void app_main(void) 是 ESP-IDF 应用程序的入口点。ESP-IDF 会自动启动 FreeRTOS,用户必须定义一个 void app_main(void) 函数作为用户应用程序的入口点,并在 ESP-IDF 启动时被自动调用。
    • xTaskCreatePinnedToCore(): 这是 ESP-IDF 提供的 FreeRTOS API,用于创建一个任务并将其“固定”到特定的 CPU 核心上运行。
      • Demo_Task_A / Demo_Task_B: 任务要执行的函数。
      • "Demo_Task_A" / "Demo_Task_B": 任务的描述性名称。
      • 4096: 分配给任务的栈空间大小,单位为字节。
      • NULL: 传递给任务函数的参数,本例中未使用。
      • 10: 任务的优先级。数值越大,优先级越高。优先级不能超过 configMAX_PRIORITIES - 1
      • &myTaskHandleA / &myTaskHandleB: 传入任务句柄变量的地址,函数成功创建任务后,会将句柄存入此变量。
      • 0 / 1: 指定任务运行的核心 ID。任务 A 在核心 0 运行,任务 B 在核心 1 运行。如果传入 tskNO_AFFINITY,则调度器可以在任意核心上运行该任务。

3. 参考链接