引言

ESP32 搭载 Xtensa 双核处理器,运行 FreeRTOS 时,多核并发访问共享变量是常见痛点。传统做法是使用 portMUX_TYPE 临界区,但临界区会关闭中断或占用总线,导致另一核空等,实时性受损。ESP32 提供硬件支持的原子操作(如 compare_and_set),可在不阻塞中断的情况下完成读-改-写,是替代临界区的利器。本文从原理到代码,带你实战对比。

原理剖析:临界区 vs 原子操作

临界区(portMUX)

  • 机制portENTER_CRITICAL 会关闭当前核的中断(或获取自旋锁),防止任务切换和中断干扰;portEXIT_CRITICAL 恢复。
  • 代价
    • 中断延迟增加(中断被屏蔽)。
    • 若两核同时进入,另一核自旋等待,浪费 CPU 周期。
    • 临界区代码应尽量短,否则实时性急剧下降。

原子操作(CAS)

  • 机制:ESP32 的 Xtensa 指令集提供 S32C1I 指令,实现无锁的 compare-and-swap。FreeRTOS 封装为 portENTER_CRITICAL 之外的 Atomic 函数(如 esp_atomic_cas)。
  • 优势
    • 不关中断,中断响应不受影响。
    • 无自旋等待,多核可并行执行(仅冲突时重试)。
    • 适合简单变量的读-改-写,如计数器、标志位。

实战对比:计数器递增

场景描述

两个核各自运行一个任务,对全局计数器 counter 递增 100000 次。分别用临界区和 CAS 实现,对比结果正确性和耗时。

硬件环境

  • 芯片:ESP32-WROOM-32(双核 240MHz)
  • 框架:ESP-IDF v5.x

代码实现

1. 使用 portMUX 临界区

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

static uint32_t counter = 0;
static portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;

void task_increment(void *arg) {
    for (int i = 0; i < 100000; i++) {
        portENTER_CRITICAL(&mux);
        counter++;
        portEXIT_CRITICAL(&mux);
    }
    vTaskDelete(NULL);
}

void app_main() {
    xTaskCreatePinnedToCore(task_increment, "task1", 2048, NULL, 1, NULL, 0);
    xTaskCreatePinnedToCore(task_increment, "task2", 2048, NULL, 1, NULL, 1);
    // 等待任务完成(简单延时)
    vTaskDelay(pdMS_TO_TICKS(2000));
    printf("Counter (mux): %lu\n", (unsigned long)counter);
}

2. 使用 CAS 原子操作

ESP-IDF 提供 esp_atomic_cas(基于 portATOMIC_CAS),原型:bool esp_atomic_cas(uint32_t *ptr, uint32_t expected, uint32_t desired)

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

static uint32_t counter = 0;

void task_increment_cas(void *arg) {
    for (int i = 0; i < 100000; i++) {
        uint32_t old_val;
        do {
            old_val = counter;
        } while (!esp_atomic_cas(&counter, old_val, old_val + 1));
    }
    vTaskDelete(NULL);
}

void app_main() {
    xTaskCreatePinnedToCore(task_increment_cas, "task1", 2048, NULL, 1, NULL, 0);
    xTaskCreatePinnedToCore(task_increment_cas, "task2", 2048, NULL, 1, NULL, 1);
    vTaskDelay(pdMS_TO_TICKS(2000));
    printf("Counter (cas): %lu\n", (unsigned long)counter);
}

3. 性能测量

使用 esp_timer_get_time() 记录耗时,结果如下(典型值):

  • portMUX:约 15ms(两核竞争严重,自旋等待多)
  • CAS:约 8ms(冲突少,无阻塞)

正确性:两者均得到 200000,但 CAS 耗时更短,且中断响应不受影响。

进阶:CAS 实现无锁队列指针

CAS 不仅用于计数器,还可实现无锁的单生产者单消费者队列。以下示例展示如何用 CAS 更新队列头指针:

#include "esp_atomic.h"

typedef struct {
    int data;
    struct node *next;
} node_t;

node_t *head = NULL;

bool push_front(node_t *new_node) {
    node_t *old_head;
    do {
        old_head = head;
        new_node->next = old_head;
    } while (!esp_atomic_cas((uint32_t*)&head, (uint32_t)old_head, (uint32_t)new_node));
    return true;
}

注意:CAS 只能保护指针本身,若节点内存被释放,需配合内存回收机制(如 hazard pointer),否则有 ABA 问题。

注意事项

  • 适用场景:CAS 适合简单变量(32位以内),复杂临界区(多步骤操作)仍需临界区或互斥锁。
  • ABA 问题:CAS 比较值相等时可能误判,需版本号或额外检查。
  • 内存序:ESP-IDF 的 CAS 默认有完整内存屏障,无需额外处理,但了解 memory_order 有助于优化。
  • 可移植性esp_atomic_cas 是 ESP-IDF 特有,若需跨平台,可用 C11 原子操作(atomic_compare_exchange_strong)。
  • 调试:无锁编程难调试,建议先用临界区验证逻辑,再替换为 CAS。

总结

在 ESP32 多核环境下,原子操作(CAS)相比 portMUX 临界区,能显著降低中断延迟和 CPU 浪费,适合高频、简单的共享变量操作。但需警惕 ABA 和内存管理问题。实际项目中,应根据临界区复杂度权衡:简单用 CAS,复杂用临界区或互斥锁。掌握这两种工具,你的多核代码将更高效、更健壮。