docs/zh_CN/api-reference/system/freertos_idf.rst
:link_to_translation:en:[English]
本文档介绍了 ESP-IDF 框架内的 FreeRTOS 双核 SMP 实现,包含以下小节:
.. contents:: 目录 :depth: 2
.. ---------------------------------------------------- Overview -------------------------------------------------------
原始 FreeRTOS(下文称 Vanilla FreeRTOS)是一款小巧高效的实时操作系统,适用于许多单核 MCU 和 SoC。但为了支持双核 ESP 芯片,如 ESP32、ESP32-S3、ESP32-P4,ESP-IDF 特别提供了支持双核对称多处理 (SMP) 的 FreeRTOS 实现(下文称 IDF FreeRTOS)。
IDF FreeRTOS 源代码基于 Vanilla FreeRTOS v10.5.1,但内核行为和 API 都有重大修改,以支持双核 SMP。不过用户也可以启用 :ref:CONFIG_FREERTOS_UNICORE 选项,将 IDF FreeRTOS 配置为支持单核,详情请参阅 :ref:freertos-idf-single-core。
.. note::
本文档假定读者已具备 Vanilla FreeRTOS 的必要背景知识,即了解其特性、行为和 API 用法。如需了解背景知识,请参阅 Vanilla FreeRTOS 文档 <https://www.freertos.org/index.html>_。
.. -------------------------------------------- Symmetric Multiprocessing ----------------------------------------------
基本概念 ^^^^^^^^
对称多处理是一种计算架构,其中,两个及以上相同的 CPU 核连接到单个共享的主内存,并由单个操作系统控制。SMP 系统通常具有以下特点:
与单核或非对称多处理系统相比,SMP 系统的主要优势在于:
尽管 SMP 系统支持线程切换核,但在某些情况下,线程必须或应该仅在特定核上运行。因此,在 SMP 系统中,线程也具备核亲和性,指定线程在哪个特定核上运行。
ESP 芯片上的 SMP ^^^^^^^^^^^^^^^^
ESP32、ESP32-S3、ESP32-P4 和 ESP32-H4 等 ESP 芯片是双核 SMP SoC,具有以下硬件特性以支持 SMP:
具有两个完全相同的核,分别称为核 0 和核 1。代码段无论在哪个核上运行,都有相同的执行效果。
具有对称内存(除了少数例外情况)。
跨核中断支持由一个核触发另一个核上的中断,这使得核间可以互相发送信号,如请求在另一个核上进行上下文切换。
.. note::
在 ESP-IDF 中,核 0 和核 1 有时分别又被称为 ``PRO_CPU`` 和 ``APP_CPU``。别名 ``PRO_CPU`` 和 ``APP_CPU`` 反映了典型 ESP-IDF 应用程序使用这两个 CPU 的方式。负责处理 Wi-Fi 或蓝牙等协议相关处理程序的任务通常会分配给核 0,因此称核 0 为 ``PRO_CPU``;而处理应用程序其余部分的任务会分配给核 1,因此称核 1 为 ``APP_CPU``。
.. ------------------------------------------------------ Tasks --------------------------------------------------------
创建任务 ^^^^^^^^
Vanilla FreeRTOS 提供以下用于创建任务的函数:
xTaskCreate 创建任务时,任务内存动态分配。xTaskCreateStatic 创建任务时,任务内存静态分配,即由用户提供。然而,在 SMP 系统中,任务需要分配到特定核。因此,ESP-IDF 提供了 Vanilla FreeRTOS 任务创建函数的 ...PinnedToCore() 版本:
xTaskCreatePinnedToCore 可以创建具有特定核亲和性的任务,任务内存动态分配。xTaskCreateStaticPinnedToCore 可以创建具有特定核亲和性的任务,任务内存静态分配,即由用户提供。不同于普通的任务创建函数 API,...PinnedToCore() 版本的任务创建函数 API 有额外的 xCoreID 参数,用于指定所创建任务的核亲和性。核亲和性的有效值包括:
0:将创建的任务分配给核 01:将创建的任务分配给核 1tskNO_AFFINITY:支持任务在两个核上运行注意,IDF FreeRTOS 仍支持普通的任务创建函数,但这些标准函数已经过调整,会内部调用其 ...PinnedToCore() 版本,同时将核亲和性设置为 tskNO_AFFINITY。
.. note::
IDF FreeRTOS 还更改了任务创建函数中的 ulStackDepth 参数。在 Vanilla FreeRTOS 中,任务堆栈的大小以字为单位指定,而在 IDF FreeRTOS 中,任务堆栈的大小以字节为单位指定。
执行任务 ^^^^^^^^
IDF FreeRTOS 中任务的结构与 Vanilla FreeRTOS 相同。具体而言,IDF FreeRTOS 任务:
删除任务 ^^^^^^^^
调用 :cpp:func:vTaskDelete 可以在 Vanilla FreeRTOS 中删除任务。该函数可用于删除其他任务,若任务句柄为 NULL 则删除当前运行任务。如果删除的任务是当前正在运行的任务时,任务的内存释放有时会委托给空闲任务执行。
IDF FreeRTOS 提供了同样的 :cpp:func:vTaskDelete 函数。然而,IDF FreeRTOS 是一个双核系统,因此调用 :cpp:func:vTaskDelete 时,行为上会与 Vanilla FreeRTOS 有以下差异:
请避免删除正在另一个核上运行的任务,否则由于无法确定该任务正在执行的操作,可能会导致难以预料的行为,例如:
请尽可能自己设计应用程序,确保在调用 :cpp:func:vTaskDelete 时,删除的任务处于已知状态。例如:
vTaskDelete(NULL) 自行删除。vTaskSuspend 将自己置于挂起状态。.. --------------------------------------------------- Scheduling ------------------------------------------------------
对 Vanilla FreeRTOS 调度器最确切的描述是 具有时间分片和固定优先级的抢占式调度器,这意味着:
IDF FreeRTOS 调度器支持相同的调度特性,即固定优先级、抢占和时间分片,但也存在细微的行为差异。
固定优先级 ^^^^^^^^^^
在 Vanilla FreeRTOS 中,当调度器选择要运行的新任务时,往往会选择当前优先级最高的就绪任务。而在 IDF FreeRTOS 中,每个核都独立地调度要运行的任务。当特定核选择一个任务时,该核会选择优先级最高且可以在该核上运行的就绪状态任务。满足以下条件时,任务可以在核上运行:
但是,两个具有最高优先级的就绪任务不一定始终由调度器运行,因为还需考虑到任务的核亲和性。例如,给定以下任务:
经过调度后,任务 A 将在核 0 上运行,任务 C 将在核 1 上运行。即使任务 B 是第二优先级任务,也不会被执行。
抢占 ^^^^
在 Vanilla FreeRTOS 中,如果优先级更高的任务已准备好执行,调度器可以抢占当前正在运行的任务。同样,在 IDF FreeRTOS 任务中,如果调度器确定一个优先级更高的任务可以在某个核上运行,那么调度器可以单独抢占各个核。
但在某些情况下,一个优先级更高的就绪任务可以在多个核上运行。此时,调度器只会抢占一个核。即便当前有多个核可以抢占,调度器总是优先选择当前核。换句话说,如果优先级更高的就绪任务未分配,并且其优先级高于两个核的当前优先级,调度器将始终选择抢占当前核。例如,给定以下任务:
经过调度后,任务 A 将在核 0 上运行,任务 C 将抢占任务 B,因为调度器总是优先选择当前核。
时间分片 ^^^^^^^^
Vanilla FreeRTOS 实现了时间分片,这意味着如果当前优先级最高的就绪任务包含多个就绪任务,调度器会在这些任务间轮转定期切换。
然而,在 IDF FreeRTOS 中,由于以下原因,特定任务可能无法在特定核上运行,因此无法实现完美的轮转时间分片:
因此,当核在所有就绪状态任务中搜索寻找要运行的任务时,可能需要跳过同一优先级列表中的一些任务,或者降低优先级,以找到可以运行的就绪状态任务。
IDF FreeRTOS 调度器会确保已选择运行的任务置于列表末尾,为同一优先级的就绪状态任务实现最佳轮转时间分片。这样,在下一次调度迭代(即,下一个滴答中断或让步)中,未经选择的任务优先级会更高。
以下示例展示了最佳轮转时间分片的实操。假设:
有四个相同优先级的就绪状态任务 AX、B0、C1 和 D1,其中:
A、B、C、D。X 表示未分配。任务列表始终从头开始搜索
起始状态,尚未选择要运行的就绪状态任务。
.. code-block:: none
Head [ AX , B0 , C1 , D0 ] Tail
核 0 有一个滴答中断,搜索要运行的任务。选择任务 A,并将其移至列表末尾。
.. code-block:: none
Core 0 ─┐
▼
Head [ AX , B0 , C1 , D0 ] Tail
[0]
Head [ B0 , C1 , D0 , AX ] Tail
核 1 有一个滴答中断,搜索要运行的任务。由于亲和性不兼容,任务 B 无法运行,因此核 1 跳到任务 C。选择任务 C,并将其移至列表末尾。
.. code-block:: none
Core 1 ──────┐
▼ [0]
Head [ B0 , C1 , D0 , AX ] Tail
[0] [1]
Head [ B0 , D0 , AX , C1 ] Tail
核 0 有另一个滴答中断,搜索要运行的任务。选择任务 B,并将其移至列表末尾。
.. code-block:: none
Core 0 ─┐
▼ [1]
Head [ B0 , D0 , AX , C1 ] Tail
[1] [0]
Head [ D0 , AX , C1 , B0 ] Tail
核 1 有另一个滴答中断,搜索要运行的任务。由于亲和性不兼容,任务 D 无法运行,因此核 1 跳到任务 A。选择任务 A,并将其移至列表末尾。
.. code-block:: none
Core 1 ──────┐
▼ [0]
Head [ D0 , AX , C1 , B0 ] Tail
[0] [1]
Head [ D0 , C1 , B0 , AX ] Tail
在使用最佳轮转时间分片时需注意:
时钟中断 ^^^^^^^^
Vanilla FreeRTOS 要求定期发生滴答中断,滴答中断有以下作用:
在 IDF FreeRTOS 中,每个核都会接收到定期中断,并独立运行滴答中断。每个核上的滴答中断周期相同,但可能不同步。然而,上述滴答中断任务不会由所有核同时执行,具体而言:
.. note::
在 IDF FreeRTOS 中,核 0 是负责时间计数的唯一核。因此,任何阻止核 0 增加滴答计数的情况,例如暂停核 0 上的调度器,都会导致整个调度器的时间计数滞后。
空闲任务 ^^^^^^^^
启动调度器时,Vanilla FreeRTOS 会隐式创建一个优先级为 0 的空闲任务。当没有其他任务准备运行时,空闲任务运行并有以下作用:
而 IDF FreeRTOS 为每个核单独创建了一个固定的空闲任务。每个核上的空闲任务起到与其 Vanilla FreeRTOS 对应任务相同的作用。
调度器挂起 ^^^^^^^^^^
Vanilla FreeRTOS 支持调用 :cpp:func:vTaskSuspendAll 挂起调度器,调用 :cpp:func:xTaskResumeAll 恢复调度器。调度器挂起时:
调度器恢复时,:cpp:func:xTaskResumeAll 会补上所有丢失的时钟计数,并解除超时任务的阻塞。
在 IDF FreeRTOS 中,无法在多个核上同时挂起调度器。因此,在特定核上(如核 A)调用 :cpp:func:vTaskSuspendAll 时:
在特定核(如核 A)上调用 :cpp:func:xTaskResumeAll 时:
.. warning::
IDF FreeRTOS 上的调度器挂起仅暂停特定核上的调度,因此调度器挂起 不能 确保访问共享数据时任务互斥。如果需要互斥,请使用适当的锁定机制,如互斥锁或自旋锁。
.. ------------------------------------------------ Critical Sections --------------------------------------------------
禁用中断 ^^^^^^^^
Vanilla FreeRTOS 支持通过调用 :c:macro:taskDISABLE_INTERRUPTS 和 :c:macro:taskENABLE_INTERRUPTS 分别禁用和启用中断。IDF FreeRTOS 提供了相同的 API,但中断只能在当前核上禁用或启用。
在 Vanilla FreeRTOS 以及其他普通单核系统中,禁用中断可以有效实现互斥,但在 SMP 系统中,禁用中断并不能确保实现互斥,而应使用有自旋锁的临界区以实现互斥。
API 变更 ^^^^^^^^
Vanilla FreeRTOS 通过禁用中断实现临界区 (Critical Section),以防止在临界区内发生抢占式上下文切换和中断服务,确保进入临界区的任务或 ISR 是访问共享资源的唯一实体。Vanilla FreeRTOS 中的临界区提供以下 API:
taskENTER_CRITICAL() 通过禁用中断进入临界区taskEXIT_CRITICAL() 通过重新启用中断退出临界区taskENTER_CRITICAL_FROM_ISR() 通过禁用中断嵌套从 ISR 进入临界区taskEXIT_CRITICAL_FROM_ISR() 通过重新启用中断嵌套从 ISR 退出临界区然而,在 SMP 系统中,仅禁用中断并不能构成临界区,因为存在其他核意味着共享资源仍可以同时访问。因此,IDF FreeRTOS 中的临界区是使用自旋锁实现的。为适应自旋锁,IDF FreeRTOS 中的临界区 API 包含一个额外的自旋锁参数,具体如下:
portMUX_TYPE (请勿与 FreeRTOS 互斥混淆)taskENTER_CRITICAL(&spinlock) 从任务上下文进入临界区taskEXIT_CRITICAL(&spinlock) 从任务上下文退出临界区taskENTER_CRITICAL_ISR(&spinlock) 从中断上下文进入临界区taskEXIT_CRITICAL_ISR(&spinlock) 从中断上下文退出临界区.. note::
临界区 API 可以递归调用,即可以嵌套使用临界区。只要退出临界区的次数与进入的次数相同,多次递归进入临界区就是有效的。但是,由于临界区可以针对不同的自旋锁,因此在递归进入临界区时,应注意避免死锁。
自旋锁可以静态或动态分配。因此,提供了静态和动态初始化自旋锁的宏,如以下代码片段所示。
静态分配自旋锁并使用 portMUX_INITIALIZER_UNLOCKED 初始化:
.. code:: c
// 静态分配并初始化自旋锁
static portMUX_TYPE my_spinlock = portMUX_INITIALIZER_UNLOCKED;
void some_function(void)
{
taskENTER_CRITICAL(&my_spinlock);
// 此时已处于临界区
taskEXIT_CRITICAL(&my_spinlock);
}
动态分配自旋锁并使用 portMUX_INITIALIZE() 初始化:
.. code:: c
// 动态分配自旋锁
portMUX_TYPE *my_spinlock = malloc(sizeof(portMUX_TYPE));
// 动态初始化自旋锁
portMUX_INITIALIZE(my_spinlock);
...
taskENTER_CRITICAL(my_spinlock);
// 访问资源
taskEXIT_CRITICAL(my_spinlock);
实现 ^^^^
IDF FreeRTOS 中,特定核进入和退出临界区的过程如下:
对于 taskENTER_CRITICAL(&spinlock) 或 taskENTER_CRITICAL_ISR(&spinlock)
#. 核禁用其中断或中断嵌套,直到达到 configMAX_SYSCALL_INTERRUPT_PRIORITY。
#. 接着,核使用原子比较和设置指令在自旋锁上自旋,直到获取锁。当核能够将锁的所有者值设置为核的 ID 时,就获得了锁。
#. 一旦获取了自旋锁,函数返回。剩余的临界区部分将在禁用中断或中断嵌套的情况下运行。
对于 taskEXIT_CRITICAL(&spinlock) 或 taskEXIT_CRITICAL_ISR(&spinlock)
#. 核通过清除自旋锁的所有者值释放自旋锁。 #. 核重新启用中断或中断嵌套。
限制与注意事项 ^^^^^^^^^^^^^^
由于在临界区内禁用了中断或中断嵌套,产生了多个关于在临界区内可执行操作的限制,请牢记以下操作限制和注意事项:
临界区应尽可能短
不应在临界区内调用 FreeRTOS API
不应在临界区内调用任何阻塞或让出函数
.. ------------------------------------------------------ Misc ---------------------------------------------------------
.. only:: SOC_CPU_HAS_FPU
使用浮点
^^^^^^^^
通常情况下,当发生上下文切换时:
- 核寄存器的当前状态保存到要切出的任务栈中
- 核寄存器的先前保存状态从要切入的任务栈中加载
然而,IDF FreeRTOS 为核的浮点运算单元 (FPU) 寄存器实现了延迟上下文切换。换句话说,当在特定核上(如核 0)发生上下文切换时,核的 FPU 寄存器状态不会立即保存到要被切出的任务的堆栈中(如任务 A)。FPU 的寄存器在发生以下情况前将保持不变:
- 另一个任务(如任务 B)在同一核上运行并使用 FPU,这将触发异常,将 FPU 寄存器保存到任务 A 的堆栈中。
- 任务 A 重新调度到同一核并继续执行。在这种情况下,不需要保存和恢复 FPU 的寄存器。
然而,由于任务并未分配给某一核,可以随意调度(如任务 A 切换到核 1),因此很难实现跨核复制和恢复 FPU 寄存器状态。因此,当任务在其执行流程中用 ``float`` 类型使用 FPU 时,IDF FreeRTOS 会自动将任务分配给当前正在运行的核,确保所有使用 FPU 的任务始终在特定核上运行。
此外,请注意,由于 FPU 寄存器状态与特定任务相关联,IDF FreeRTOS 默认不支持在中断上下文中使用 FPU。
.. only:: esp32
.. note::
如需在 ISR 例程中使用 ``float`` 类型,请参考配置选项:ref:`CONFIG_FREERTOS_FPU_IN_ISR`。
.. note::
具有 FPU 的 ESP 芯片不支持双精度浮点运算 ``double`` 的硬件加速。``double`` 通过软件实现,因此比起 ``float`` 类型,``double`` 操作可能消耗更多 CPU 时间。
.. -------------------------------------------------- Single Core -----------------------------------------------------
.. _freertos-idf-single-core:
单核模式 ^^^^^^^^
尽管 IDF FreeRTOS 是为双核 SMP 专门设计的,但也可通过启用 :ref:CONFIG_FREERTOS_UNICORE 选项,将 IDF FreeRTOS 配置为支持单核。
对于 ESP32-S2 和 ESP32-C3 等单核芯片,:ref:CONFIG_FREERTOS_UNICORE 选项始终启用。对于 ESP32 和 ESP32-S3 等多核芯片也可以设置 :ref:CONFIG_FREERTOS_UNICORE,对于多核目标(如 ESP32 和 ESP32-S3),也可以设置 :ref:CONFIG_FREERTOS_UNICORE,但启用该选项后应用仅在核 0 上运行。
在单核模式下,IDF FreeRTOS 与 Vanilla FreeRTOS 完全相同,因此无需考虑前文提到的对内核行为的 SMP 更改。因此,在单核模式下构建 IDF FreeRTOS 具有以下特点:
在单核模式下仍可调用 SMP API,这些 API 仍然保持公开,以便为单核和多核构建源代码,而无需调用不同的 API 集。不过,SMP API 在单核模式下不会展示任何 SMP 行为,因此实际上等同于其对应的单核模式 API。例如:
...ForCore(..., BaseType_t xCoreID) SMP API 将只接受 0 为 xCoreID 的有效值。...PinnedToCore() 任务创建 API 将直接忽略 xCoreID 核亲和参数。.. ------------------------------------------------- API References ----------------------------------------------------
本节介绍了 FreeRTOS 类型、函数和宏,均从 FreeRTOS 头文件自动生成。
任务 API ^^^^^^^^
.. include-build-file:: inc/task.inc
队列 API ^^^^^^^^^
.. include-build-file:: inc/queue.inc
信号量 API ^^^^^^^^^^
.. include-build-file:: inc/semphr.inc
定时器 API ^^^^^^^^^^
.. include-build-file:: inc/timers.inc
事件组 API ^^^^^^^^^^
.. include-build-file:: inc/event_groups.inc
流缓冲区 API ^^^^^^^^^^^^
.. include-build-file:: inc/stream_buffer.inc
消息缓冲区 API ^^^^^^^^^^^^^^
.. include-build-file:: inc/message_buffer.inc