参考:
ESP-IDF官方文档
浮点
以前一直使用基于STM32的FreeRTOS开发代码,在刚接触到ESP-IDF时,就想沿用RTOS来编写代码,主要是为了能有任务管理,消息队列、信号量等的同步机制,在刚接触的时候,也遇到了一些问题,记录整理一下
FreeRTOS 针对不同硬件平台的深度定制(如 ESP32 双核架构)会导致显著差异。基于 ESP-IDF 框架,简单解析其 FreeRTOS 实现与原生版本的差异,涵盖双核调度、内存管理、浮点运算等核心模块。
在ESP-IDF中使用FreeRTOS
FreeRTOS 以组件的形式集成到 ESP-IDF 中。因此,所有的 ESP-IDF 应用程序(包括Espressif官方例程)及多种 ESP-IDF 组件都基于 FreeRTOS 编写。FreeRTOS 内核已移植到 ESP 芯片的所有 CPU 架构(即 Xtensa 和 RISC-V)中
- STM32的开发,移植依赖:
- 需手动移植FreeRTOS内核(如复制FreeRTOSConfig.h、调整启动代码),或通过STM32CubeMX生成基础代码
- 内存分配、中断优先级等需开发者手动配置
- ESP-IDF中的FreeRTOS
- FreeRTOS作为ESP-IDF的核心组件已预配置,无需移植,通过menuconfig(idf.py menuconfig)可图形化配置内核参数(如任务栈大小、调度策略)
- 编译工具链:基于CMake构建系统,FreeRTOS源码位于components/freertos目录,无需手动管理
注意:这是个“特殊组件”,无需在CMakeLists中添加组件,代码直接包含头文件即可开始使用
任务创建的差异
在STM32中,使用标准FreeRTOS API创建任务,任务自动分配到单核:
xTaskCreate(task_function, "Task", 128, NULL, 2, NULL);
ESP-IDF要求显式指定任务运行的核心,避免双核竞争:
// 显式绑定到Core 0
xTaskCreatePinnedToCore(task_function, "Task", 4096, NULL, 2, NULL, 0);
// 不绑定核心(由调度器自动分配)
xTaskCreatePinnedToCore(task_function, "Task", 4096, NULL, 2, NULL, tskNO_AFFINITY);
这里需注意xTaskCreatePinnedToCore函数的最后一个参数,如果是tskNO_AFFINITY,则代表核心由系统自动分配,在单核的型号上使用这个或者指定核心0
关键区别:
- 栈大小要求:ESP-IDF建议默认任务栈为4096字节(STM32通常为128-512字),原因包括:双核任务可能涉及复杂调用链(如WiFi/BT协议栈)。ESP-IDF的调试功能(如堆栈溢出检测)需要预留空间。
- 任务删除风险:禁止直接删除另一个核心上的任务,需通过IPC通知目标核心自行清理。
- 栈单位,有字节和字两种
官方文档:
例如STM32中的FreeRTOS创建函数时对于参数栈
的说明
这段注释的含义如下:
usStackDepth 参数指定的是任务栈可以容纳的 变量(或字)的数量,而不是直接的字节数。
实际分配的栈内存大小取决于你所使用的 STM32 微控制器的 字长 (word size)。
Cortex-M3、Cortex-M4、Cortex-M7 : 通常是 32 位 架构,这意味着一个字是 4 个字节。
如果 usStackDepth 设置为 128,则实际栈大小为 128 * 4 = 512 字节
如果 usStackDepth 设置为 512,则实际栈大小为 512 * 4 = 2048 字节
临界区保护
在STM32中创建任务时,常见一种作法是在STM32CubeMX中配置好一个“start”任务,这个任务专门用来创建各种任务
在代码初始化阶段建议使用临界区
void initCreateTask(void const *argument)
{
taskENTER_CRITICAL(); // 进入临界区
/* 打印相关信息 */
/* 初始化 */
/* 创建任务 */
xTaskCreate((TaskFunction_t)appLedTask,
(const char *)"appLed",
(uint16_t)128,
(void *)NULL,
(UBaseType_t)3,
(TaskHandle_t *)NULL);
vTaskDelete(NULL);
taskEXIT_CRITICAL(); // 退出临界区
}
临界区是指一段需要不被中断打断执行的代码。在 initCreateTask 函数中,进入临界区的主要作用是为了保护任务创建和自我删除的过程,确保这些关键操作的原子性,避免在执行过程中被其他任务或中断打断,从而保证系统的稳定性和可靠性
通过进入临界区,可以禁用中断和上下文切换,确保 xTaskCreate 函数能够完整地执行完毕,从而保证新任务的正确创建
需要注意的是,临界区应该尽可能短,避免长时间禁用中断,否则会影响系统的实时性和响应性。在这个例子中,任务创建和删除通常都是相对较快的操作,因此使用临界区是合理的
ESP-IDF中的临界区
查看官方源码,taskENTER_CRITICAL
宏定义相比原生FreeRTOS多了一个参数
单核临界区仍可用taskENTER_CRITICAL(),但多核操作(以S3型号为例)需使用自旋锁:
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
taskENTER_CRITICAL(&mux);
taskEXIT_CRITICAL(&mux);
原理分析:
- 原生FreeRTOS单核临界区原理:禁用中断
在单核的FreeRTOS系统中,taskENTER_CRITICAL() 宏通常通过禁用全局中断来实现临界区的保护。当执行 taskENTER_CRITICAL() 时,处理器会停止响应外部中断。这意味着在 taskEXIT_CRITICAL() 重新使能中断之前,任何中断服务例程(ISR)都无法执行,从而保证了临界区内的代码不会被中断打断。由于只有一个核心,禁用全局中断就能有效地防止其他任务(通过上下文切换)或中断同时访问共享资源。 - ESP-IDF多核临界区面临的挑战:单核禁用不足
当系统拥有多个核心(例如ESP32 S3),单靠禁用某个核心上的中断已经无法保证临界区的安全。这是因为:其他核心仍然可以运行: 即使在一个核心上禁用了中断,其他核心仍然可以独立地执行代码,包括访问相同的共享资源。
为了在多核环境下保护共享资源,需要一种能够跨越不同核心的同步机制。
- ESP-IDF的解决方案:引入自旋锁 (Spinlock)
为了解决多核环境下的临界区保护问题,ESP-IDF在 taskENTER_CRITICAL() 宏中引入了一个参数,这个参数实际上是一个指向 portMUX_TYPE 结构体的指针。portMUX_TYPE 在ESP-IDF中被用作实现自旋锁的数据结构。
自旋锁的工作原理:
尝试获取锁: 当一个核心执行 taskENTER_CRITICAL(&mux) 时,它会尝试获取与 mux 关联的自旋锁。
锁已被持有: 如果该自旋锁已经被另一个核心持有,那么当前核心不会立即进入睡眠或执行其他任务,而是会**不断地循环检查(“自旋”)**锁是否已经被释放。这个循环会消耗一定的CPU资源,但避免了上下文切换的开销。
锁被释放: 当持有锁的核心执行 taskEXIT_CRITICAL(&mux) 时,它会释放该自旋锁。
获取锁成功: 正在自旋等待的某个核心会检测到锁已被释放,并成功获取该锁,然后可以安全地执行临界区内的代码。
内存管理的特殊设计
ESP-IDF在内存管理方面采用了与传统的单区域堆(如STM32中常见的 heap_4.c)不同的设计,主要是为了更好地利用其目标平台(通常是ESP32系列芯片)上不同类型的内存,并提供更灵活的内存分配策略
1. 为什么需要多区域堆?
ESP32等芯片通常配备了多种类型的内存,它们在特性、速度和用途上有所不同:
1.内部RAM (IRAM & DRAM):
IRAM (Instruction RAM): 主要用于存储可执行代码。它通常与CPU的指令总线直接相连,具有最高的访问速度,对于程序性能至关重要。某些内部RAM也可以作为数据RAM使用。
DRAM (Data RAM): 主要用于存储程序运行时的数据,如变量、堆栈等。它也具有较高的访问速度。在某些ESP32芯片中,IRAM和DRAM可能在物理上是同一块内存,但通过地址映射和访问控制进行区分。
- 外部 PSRAM (Pseudo-Static RAM):
这是一种外部连接的伪静态RAM芯片,通常容量比内部RAM大得多。
它的访问速度通常比内部RAM慢,并且可能需要通过特定的接口(如SPI)进行访问。
PSRAM的主要用途是扩展可用内存空间,用于存储较大的数据结构、缓冲区、图像、音频数据等。
如下图,是一款常见的串行闪存芯片,通常与ESP芯片使用SPI接口连接
可以在menuconfig中对PSRAM进行配置
如果像STM32那样使用单一的内存池来管理所有这些不同特性的内存,可能会带来以下问题:
- 性能瓶颈: 将需要高速访问的代码或数据与速度较慢的外部PSRAM混合在一起,可能会影响程序的整体性能。
- 内存类型限制: 某些操作可能对内存类型有特定的要求。例如,某些DMA操作可能只能在特定的内部RAM区域进行。单一内存池难以保证分配到的内存满足这些要求。
- 碎片化: 不同速度和特性的内存混合管理,更容易导致内存碎片化问题,使得无法分配到满足特定大小和特性的连续内存块。
2. ESP-IDF 多区域堆的核心思想
ESP-IDF的多区域堆管理的核心思想是将不同类型的内存视为独立的区域进行管理。每个区域都有其自身的特性和分配策略。当应用程序需要分配内存时,可以根据其需求(例如,是否需要高速访问、是否需要可执行、是否需要位于外部PSRAM等)来选择从哪个或哪些区域进行分配。
3. 内存分区详解
ESP-IDF通常会管理以下几个主要的内存区域:
- Internal IRAM: 用于存储可执行代码。通过 MALLOC_CAP_EXEC 能力进行分配。
- Internal DRAM: 用于存储数据。通常是默认的分配区域。可以通过 MALLOC_CAP_INTERNAL 和 MALLOC_CAP_8BIT 或 MALLOC_CAP_32BIT 等能力进行更细致的控制。
- External PSRAM (如果存在): 用于扩展数据存储空间。通过 MALLOC_CAP_SPIRAM 能力进行分配。
例如我使用LVGL时,可以设置开辟双缓存
lv_color_t* pBuff1 = heap_caps_malloc(buffSize * sizeof(lv_color_t), MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
lv_color_t* pBuff2 = heap_caps_malloc(buffSize * sizeof(lv_color_t), MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
LVGL需要频繁地读写显示缓冲区的数据,以便将图像渲染到屏幕上。内部RAM(DRAM)的访问速度远快于外部PSRAM。将显示缓冲区分配在内部RAM中可以显著提高LVGL的渲染性能,保证更流畅的用户界面体验。内部RAM的访问延迟通常更稳定和可预测,这对于需要实时渲染的图形界面至关重要
4. 能力 (Capabilities) 的概念
ESP-IDF引入了“能力 (Capabilities)”的概念。能力是描述内存区域特性的标志。在分配内存时,应用程序可以指定所需的内存能力,内存分配器会尝试从具有这些能力的内存区域中分配内存。
常见的内存能力包括:
- MALLOC_CAP_EXEC: 分配的内存可以用于执行代码(通常是IRAM)。
- MALLOC_CAP_INTERNAL: 分配的内存是内部RAM(IRAM或DRAM)。
- MALLOC_CAP_SPIRAM: 分配的内存是外部PSRAM。
- MALLOC_CAP_DMA: 分配的内存适合用于DMA(直接内存访问)操作,通常需要特定的对齐和内部属性。
- MALLOC_CAP_8BIT: 分配的内存可以按字节访问。
- MALLOC_CAP_32BIT: 分配的内存需要32位对齐,这对于某些处理器架构和操作是必需的。
5. API 扩展:heap_caps_malloc 原理
heap_caps_malloc(size, capabilities) 是ESP-IDF提供的用于按能力分配内存的关键API。其工作原理如下:
- 接收请求: 当应用程序调用 heap_caps_malloc 并传入所需的内存大小和能力掩码时,内存分配器会接收到这个请求。
- 匹配内存区域: 分配器会遍历其管理的各个内存区域,并检查每个区域是否满足所有指定的能力要求。例如,如果请求了 MALLOC_CAP_SPIRAM,则只有外部PSRAM区域会被考虑。如果请求了 MALLOC_CAP_EXEC,则只有内部IRAM区域会被考虑。可以同时指定多个能力,例如 MALLOC_CAP_INTERNAL | MALLOC_CAP_32BIT 表示需要内部RAM且32位对齐的内存。
- 在匹配区域中分配: 一旦找到满足所有能力的内存区域,分配器就会在该区域内尝试分配指定大小的内存块。这通常会使用该区域特定的内存分配算法(例如,FreeRTOS的堆管理算法的变种)。
- 返回结果: 如果成功分配到内存,heap_caps_malloc 会返回指向该内存块的指针。如果没有任何内存区域满足所有指定的能力要求,或者在满足要求的区域内没有足够的连续空闲内存,则会返回 NULL。
浮点运算
在ESP-IDF中,若任务涉及浮点运算(如DSP计算、矩阵操作等),必须显式绑定到特定核心(Core 0或1),这一限制与ESP32的硬件架构和FreeRTOS的上下文管理策略密切相关。以下从硬件设计、操作系统实现两个层面分析其原理。
1. 浮点单元 (FPU) 及其上下文
FPU 的作用: 浮点单元(Floating-Point Unit,FPU)是处理器中专门用于执行浮点数运算(如加减乘除、平方根、三角函数等)的硬件组件。相比于使用整数运算来模拟浮点运算,FPU能够显著提高浮点运算的效率和精度。
FPU 上下文: 当一个任务使用FPU进行浮点运算时,会将一些中间结果和状态存储在FPU内部的寄存器中。这些寄存器的值构成了该任务的FPU上下文。就像CPU的通用寄存器需要在任务切换时保存和恢复一样,FPU寄存器的值也需要在任务切换时进行管理,以保证任务在下次恢复执行时能够正确地继续进行浮点运算。
例如S3的datasheet(注意是单精度)
ESP32芯片的每个核心(Core 0和Core 1)均配备独立的浮点运算单元(FPU),但其FPU寄存器组是核心私有的资源
- Core 0的FPU寄存器与Core 1的FPU寄存器物理隔离,无法直接共享或同步。
- 当任务在某个核心上执行浮点指令时,FPU寄存器中会存储临时计算结果(如32个单精度浮点寄存器S0-S31)。
若任务跨核迁移(例如从Core 0切换到Core 1),新核心的FPU寄存器处于未定义状态,导致以下问题:
- 数据丢失:原核心FPU寄存器中的计算结果无法自动同步到新核心
- 上下文污染:新核心可能残留其他任务的浮点数据,导致当前任务计算结果错误。
2. 任务上下文切换与 FPU 上下文
在多任务操作系统中,当调度器决定从一个正在运行的任务切换到另一个任务时,需要保存当前任务的执行状态(即上下文),以便稍后能够恢复执行。这个上下文通常包括:
- 程序计数器 (PC)
- 堆栈指针 (SP)
- 通用寄存器的值
- 以及如果任务使用了FPU,还包括FPU寄存器的值。
3. ESP32 的 FPU 上下文切换限制
在ESP-IDF的FreeRTOS实现中,上下文切换仅针对当前核心,未设计跨核FPU状态同步。具体表现为:
单核上下文管理:
当任务在同一核心挂起和恢复时,FreeRTOS会自动保存和恢复其FPU寄存器(通过portSAVE_FPU_REGS和portRESTORE_FPU_REGS宏)。
跨核迁移的缺失:
若任务从Core 0迁移到Core 1,FreeRTOS不会将Core 0的FPU寄存器复制到Core 1,导致Core 1的FPU寄存器未被正确初始化。
官方文档说明:
4. ESP-IDF 的解决方案:强制核心绑定
为了规避这个问题,ESP-IDF采取了强制核心绑定的策略:如果一个任务中涉及到浮点运算,那么必须使用 xTaskCreatePinnedToCore 函数将其绑定到一个固定的核心(Core 0 或 Core 1)。
// 绑定浮点任务到Core 0,确保FPU上下文一致性
xTaskCreatePinnedToCore(float_task, "FloatTask", 4096, NULL, 3, NULL, 0);
原理: 通过将任务绑定到特定的核心,可以保证该任务始终在该核心上运行,不会发生跨核心的迁移,实现FPU上下文隔离,每个核心独立管理其FPU寄存器,任务仅在绑定核心内运行,保证FPU状态始终有效
5. 为什么单核临界区不涉及 FPU 问题?
之前提到的单核临界区使用 taskENTER_CRITICAL()。在单核系统中,由于只有一个核心,任务不会真正地并行执行,任务切换通常发生在中断返回或者显式调用调度器函数时。在临界区内,中断是被禁用的,因此不会发生任务切换。即使发生了任务切换(在临界区外),也总是在同一个核心上进行,因此FPU上下文的保存和恢复机制在单核环境下通常能够正常工作。
自动创建的任务
ESP-IDF自动创建的系统任务详解:空闲任务、定时器、APP_MAIN、IPC、ESP定时器服务
在ESP-IDF中,FreeRTOS内核在启动时会自动创建多个系统任务,这些任务负责底层资源管理、调度协调和外设驱动支持
官方文档:
在代码运行后,通过打印相关信息可验证这点
vTaskList(buffer);
// 获取任务列表(需启用CONFIG_FREERTOS_USE_TRACE_FACILITY等宏定义)
APP_MAIN任务(应用入口任务)
-
功能:
- 用户代码入口:执行app_main()函数,初始化用户任务和外设。
- 生命周期管理:app_main()退出后,此任务自动删除。
-
优先级:1(与定时器服务任务同级)。
-
双核行为:
- 仅Core 0运行:app_main()始终在Core 0执行。
- 任务创建建议:在app_main()中启动双核任务,避免核间竞争。
-
栈大小:默认栈为3584字节(可配置CONFIG_MAIN_TASK_STACK_SIZE),复杂初始化需扩容。
IPC任务(Inter-Processor Call Task)
- 功能:核间通信:处理esp_ipc_call()请求,确保函数在指定核心原子执行。
- 资源共享:协调双核对硬件外设(如SPI、I2C)的访问
- 优先级:24(最高优先级,确保实时响应)
- 双核行为:
- 每个核心一个IPC任务:Core 0和Core 1各运行一个实例。
- 命令传递:通过共享内存和中断通知目标核心执行函数。
- 原子性保证:IPC函数应短小且无阻塞
- 用户任务优先级若≥24,可能阻塞IPC导致死锁。
总结
显式指定任务运行核心(避免使用tskNO_AFFINITY)
-
避免跨核上下文切换的开销
-
ESP32的每个核心(Core 0和Core 1)拥有独立的缓存和硬件资源(如FPU)。若任务允许跨核迁移(tskNO_AFFINITY),可能会导致以下问题:
-
FPU状态同步失败:浮点任务跨核运行时,FPU寄存器状态无法自动同步,可能导致计算错误。
-
缓存一致性开销:跨核切换时需刷新缓存,增加延迟。
-
-
资源隔离与冲突规避
- 部分硬件外设(如WiFi/BT射频模块)的驱动和中断默认绑定到Core 0。若任务随意跨核运行,可能导致核间资源竞争(如SPI总线访问冲突)。
-
注意事项
- 负载均衡:若任务均绑定到固定核心,需手动平衡两核的负载。例如,避免将多个高优先级任务集中到同一核心。