引言

ESP32 搭载 Xtensa 双核处理器,运行 FreeRTOS 时,两个核心可同时执行任务。若仍沿用单核时代的“关中断”来保护临界区,不仅无法阻止另一核心的访问,还可能因中断屏蔽导致系统响应延迟。正确做法是使用硬件原子操作,如 portMUX_TYPEatomic 内置函数。本文从原理到实践,带你掌握双核环境下的临界区保护。

为什么关中断在双核下失效?

  • 单核原理:关中断后,当前核心不会被调度器打断,临界区天然互斥。
  • 双核问题:关中断只屏蔽当前核心的中断,另一核心仍可自由执行并访问共享资源,导致数据竞争。
  • 额外风险:若在关中断期间调用 FreeRTOS API(如 vTaskDelay),会触发断言或死锁,因为调度器依赖中断。

因此,双核下必须使用原子操作自旋锁,它们基于硬件指令(如 ldrex/strexatomic_compare_exchange)保证多核间的互斥。

原子操作原理与 ESP32 支持

硬件基础

ESP32 的 Xtensa 处理器提供 S32C1I 指令,用于实现原子比较交换(CAS)。ESP-IDF 将其封装为 portMUX_TYPEspinlock,同时支持 C11 标准原子操作(stdatomic.h)。

两种方案对比

| 方案 | 适用场景 | 特点 | |------|---------|------| | portMUX_TYPE | 保护共享外设寄存器或短临界区 | 基于自旋锁,可关中断,但需手动管理 | | stdatomic | 保护普通变量(如计数器、标志位) | 编译期优化,无锁,但仅适用于单变量 |

实战:用原子操作保护临界区

场景定义

假设两个核心上的任务共享一个 32 位计数器 counter,每个任务对其执行 100000 次自增。若不保护,结果将小于 200000。

方案一:使用 stdatomic(推荐)

#include <stdatomic.h>

atomic_int counter = 0;

void task_increment(void *arg) {
    for (int i = 0; i < 100000; i++) {
        atomic_fetch_add(&counter, 1);
    }
    vTaskDelete(NULL);
}

void app_main(void) {
    xTaskCreatePinnedToCore(task_increment, "task1", 2048, NULL, 5, NULL, 0);
    xTaskCreatePinnedToCore(task_increment, "task2", 2048, NULL, 5, NULL, 1);
}
  • 原理atomic_fetch_add 编译为原子指令,无需关中断,也不会阻塞其他任务。
  • 优点:代码简洁,无死锁风险,性能高。
  • 限制:仅适用于单个变量,无法保护复合操作(如“读-改-写”多变量)。

方案二:使用 portMUX_TYPE(保护复合临界区)

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

portMUX_TYPE my_mux = portMUX_INITIALIZER_UNLOCKED;
int counter = 0;

void task_increment(void *arg) {
    for (int i = 0; i < 100000; i++) {
        portENTER_CRITICAL(&my_mux);
        counter++;  // 临界区:可包含多条语句
        portEXIT_CRITICAL(&my_mux);
    }
    vTaskDelete(NULL);
}

void app_main(void) {
    xTaskCreatePinnedToCore(task_increment, "task1", 2048, NULL, 5, NULL, 0);
    xTaskCreatePinnedToCore(task_increment, "task2", 2048, NULL, 5, NULL, 1);
}
  • 原理portENTER_CRITICAL 会获取自旋锁并关当前核心中断,另一核心若尝试获取锁则自旋等待。
  • 注意:临界区代码必须短小,且不能调用可能阻塞的 API(如 vTaskDelay),否则另一核心会空转浪费 CPU。

完整示例:保护复合状态机

以下示例展示如何用原子操作保护一个包含状态和数据的结构体(使用 portMUX_TYPE):

#include <string.h>

typedef struct {
    int state;
    int data;
} shared_t;

shared_t shared;
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;

void update_shared(int new_state, int new_data) {
    portENTER_CRITICAL(&mux);
    shared.state = new_state;
    shared.data = new_data;
    portEXIT_CRITICAL(&mux);
}

void read_shared(int *state, int *data) {
    portENTER_CRITICAL(&mux);
    *state = shared.state;
    *data = shared.data;
    portEXIT_CRITICAL(&mux);
}

注意事项与常见陷阱

  • 不要混用:同一资源只能用一种保护方式,否则互斥失效。
  • 临界区长度portMUX_TYPE 临界区应控制在几十条指令内,避免长耗时操作。
  • 中断上下文:在 ISR 中应使用 portENTER_CRITICAL_ISRportEXIT_CRITICAL_ISR,否则会死锁。
  • 原子操作内存序stdatomic 默认使用顺序一致性,可指定 memory_order_relaxed 提升性能,但需确保正确性。
  • 调试技巧:使用 taskENTER_CRITICAL 时,可开启 CONFIG_FREERTOS_DEBUG_OC_ISR 检查嵌套。

总结

在 ESP32 双核环境下,关中断不再是可靠的临界区保护手段。优先使用 C11 原子操作处理简单变量,用 portMUX_TYPE 保护复合临界区。理解硬件原子指令和自旋锁原理,能让你写出既高效又安全的嵌入式代码。记住:多核编程,互斥是设计,不是运气。