引言
ESP32 搭载 Xtensa 双核处理器,运行 FreeRTOS 时,两个核心可同时执行任务。若仍沿用单核时代的“关中断”来保护临界区,不仅无法阻止另一核心的访问,还可能因中断屏蔽导致系统响应延迟。正确做法是使用硬件原子操作,如 portMUX_TYPE 或 atomic 内置函数。本文从原理到实践,带你掌握双核环境下的临界区保护。
为什么关中断在双核下失效?
- 单核原理:关中断后,当前核心不会被调度器打断,临界区天然互斥。
- 双核问题:关中断只屏蔽当前核心的中断,另一核心仍可自由执行并访问共享资源,导致数据竞争。
-
额外风险:若在关中断期间调用 FreeRTOS API(如
vTaskDelay),会触发断言或死锁,因为调度器依赖中断。
因此,双核下必须使用原子操作或自旋锁,它们基于硬件指令(如 ldrex/strex 或 atomic_compare_exchange)保证多核间的互斥。
原子操作原理与 ESP32 支持
硬件基础
ESP32 的 Xtensa 处理器提供 S32C1I 指令,用于实现原子比较交换(CAS)。ESP-IDF 将其封装为 portMUX_TYPE 和 spinlock,同时支持 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_ISR和portEXIT_CRITICAL_ISR,否则会死锁。 -
原子操作内存序:
stdatomic默认使用顺序一致性,可指定memory_order_relaxed提升性能,但需确保正确性。 -
调试技巧:使用
taskENTER_CRITICAL时,可开启CONFIG_FREERTOS_DEBUG_OC_ISR检查嵌套。
总结
在 ESP32 双核环境下,关中断不再是可靠的临界区保护手段。优先使用 C11 原子操作处理简单变量,用 portMUX_TYPE 保护复合临界区。理解硬件原子指令和自旋锁原理,能让你写出既高效又安全的嵌入式代码。记住:多核编程,互斥是设计,不是运气。