第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 在 ESP-IDF 中通过任务(Task)机制实现多任务。每个任务本质上是一个独立的执行线程,开发者可以通过 API 创建、删除和管理多个任务。这些任务由 FreeRTOS 内核调度,按照优先级和时间片轮转等策略在 CPU 上运行,实现“多任务”效果。
1.1 任务状态
FreeRTOS 将线程称为“任务(Task)”,每个任务都实现为一个 C 函数,通常包含一个无限循环。
任务状态:任务在任何时候都可以处于四种状态之一:
- 运行态 (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 示例代码
-
创建一个项目。如果不清楚如何操作,请参考 从模板创建项目。
-
将以下代码复制到 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 构建并烧录代码
-
配置烧录选项
首先,在构建和烧录之前,请务必检查并设置正确的目标设备、串口和烧录方式。参考 第 2 节 运行示例 - 1.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.h
和freertos/task.h
: 提供 FreeRTOS 核心功能和任务管理相关的 API,例如任务的创建、删除、挂起、恢复和延时等。
-
定义任务句柄
TaskHandle_t myTaskHandleA = NULL;
TaskHandle_t myTaskHandleB = NULL;TaskHandle_t
是 FreeRTOS 中用于唯一标识一个任务的类型。- 我们定义了两个全局的句柄变量
myTaskHandleA
和myTaskHandleB
,分别用于保存任务 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
,则调度器可以在任意核心上运行该任务。