ESP32 多核 FreeRTOS 下任务与中断共享 SPI 总线时的死锁规避策略
一、问题背景与死锁成因
ESP32 集成两个 Xtensa LX6 核心,FreeRTOS 默认将任务调度到双核上运行。当多个任务(如传感器读取、显示刷新)和中断服务程序(ISR)同时访问 SPI 总线时,若未正确同步,会产生两类典型问题:
- 数据竞争:SPI 外设寄存器被并发写,导致传输帧错乱。
- 死锁:任务持有 SPI 锁后,在等待某个信号量时被 ISR 抢占,而 ISR 又试图获取同一 SPI 锁,造成循环等待。
ESP-IDF 的 SPI 驱动提供线程安全接口(如 spi_device_polling_transmit),但 ISR 中不能直接调用阻塞型 API,否则会触发看门狗或死锁。
二、核心规避策略
1. 互斥锁 + 临界区保护
对于短小、非阻塞的 SPI 操作,使用 FreeRTOS 互斥锁(SemaphoreHandle_t)保护共享总线。注意:互斥锁具有优先级继承机制,可避免优先级反转。
// 创建互斥锁
SemaphoreHandle_t spi_mutex = xSemaphoreCreateMutex();
// 任务中安全访问 SPI
void task_spi_read(void *param) {
if (xSemaphoreTake(spi_mutex, portMAX_DELAY) == pdTRUE) {
// 执行 SPI 传输(非阻塞或短阻塞)
spi_device_polling_transmit(dev, &t);
xSemaphoreGive(spi_mutex);
}
}
2. 队列 + 中断延迟处理
ISR 中无法获取互斥锁,应通过队列将 SPI 请求发送到高优先级任务,由任务统一处理。这符合“中断只做标记,任务做实际工作”的原则。
// 定义请求结构体
typedef struct {
uint8_t reg;
uint8_t data;
} spi_request_t;
QueueHandle_t spi_queue;
// ISR 中发送请求(使用 FromISR 版本)
void IRAM_ATTR spi_isr_handler(void *arg) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
spi_request_t req = {.reg = 0x10, .data = 0x55};
xQueueSendFromISR(spi_queue, &req, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 任务中处理队列
void spi_task(void *param) {
spi_request_t req;
while (1) {
if (xQueueReceive(spi_queue, &req, portMAX_DELAY)) {
xSemaphoreTake(spi_mutex, portMAX_DELAY);
// 执行 SPI 传输
spi_device_polling_transmit(dev, &t);
xSemaphoreGive(spi_mutex);
}
}
}
3. 使用 SPI 驱动自带锁
ESP-IDF 的 spi_device_interface_config_t 中可设置 flags 为 SPI_DEVICE_HALFDUPLEX 等,但更关键的是,驱动内部已对总线访问加锁。若所有访问都通过 spi_device_* 函数,则无需额外互斥锁。但 ISR 中仍不能调用这些阻塞函数,需使用 spi_device_queue_trans 和 spi_device_get_trans_result 的非阻塞版本。
// 非阻塞传输(可在任务中调用)
spi_device_queue_trans(dev, &t, portMAX_DELAY);
// 等待完成
spi_device_get_trans_result(dev, &t, portMAX_DELAY);
三、完整配置步骤
-
初始化 SPI 总线:使用
spi_bus_initialize配置 SPI2 或 SPI3,设置时钟、模式等。 -
添加设备:
spi_bus_add_device注册设备,指定 SPI 模式、时钟频率等。 - 创建同步原语:初始化互斥锁和队列。
- 编写任务与 ISR:任务中通过队列接收请求,ISR 仅发送请求。
- 启用中断:注册 SPI 中断处理函数,并在其中发送队列消息。
四、完整代码示例
以下代码演示了双核环境下,两个任务和一个定时器中断共享 SPI 总线的安全实现。
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"
#include "driver/spi_master.h"
#define SPI_HOST SPI2_HOST
SemaphoreHandle_t spi_mutex;
QueueHandle_t spi_queue;
spi_device_handle_t spi_dev;
// SPI 初始化
void spi_init(void) {
spi_bus_config_t bus_cfg = {
.mosi_io_num = 23,
.miso_io_num = 19,
.sclk_io_num = 18,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 4096,
};
spi_bus_initialize(SPI_HOST, &bus_cfg, SPI_DMA_CH_AUTO);
spi_device_interface_config_t dev_cfg = {
.mode = 0,
.clock_speed_hz = 1 * 1000 * 1000,
.spics_io_num = 5,
.queue_size = 7,
};
spi_bus_add_device(SPI_HOST, &dev_cfg, &spi_dev);
spi_mutex = xSemaphoreCreateMutex();
spi_queue = xQueueCreate(10, sizeof(uint32_t)); // 假设传输数据为32位
}
// 任务1:读取传感器
void sensor_task(void *arg) {
uint32_t data = 0x12345678;
while (1) {
if (xSemaphoreTake(spi_mutex, pdMS_TO_TICKS(100))) {
spi_transaction_t t = {
.length = 32,
.tx_buffer = &data,
.rx_buffer = NULL,
};
spi_device_polling_transmit(spi_dev, &t);
xSemaphoreGive(spi_mutex);
}
vTaskDelay(pdMS_TO_TICKS(500));
}
}
// 任务2:显示刷新
void display_task(void *arg) {
uint32_t frame = 0xDEADBEEF;
while (1) {
if (xSemaphoreTake(spi_mutex, pdMS_TO_TICKS(100))) {
spi_transaction_t t = {
.length = 32,
.tx_buffer = &frame,
.rx_buffer = NULL,
};
spi_device_polling_transmit(spi_dev, &t);
xSemaphoreGive(spi_mutex);
}
vTaskDelay(pdMS_TO_TICKS(200));
}
}
// 定时器中断(模拟外部事件)
void timer_isr(void *arg) {
uint32_t event = 0x00000001;
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xQueueSendFromISR(spi_queue, &event, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 队列处理任务
void spi_queue_task(void *arg) {
uint32_t evt;
while (1) {
if (xQueueReceive(spi_queue, &evt, portMAX_DELAY)) {
if (xSemaphoreTake(spi_mutex, portMAX_DELAY)) {
spi_transaction_t t = {
.length = 32,
.tx_buffer = &evt,
.rx_buffer = NULL,
};
spi_device_polling_transmit(spi_dev, &t);
xSemaphoreGive(spi_mutex);
}
}
}
}
void app_main(void) {
spi_init();
xTaskCreatePinnedToCore(sensor_task, "sensor", 4096, NULL, 2, NULL, 0);
xTaskCreatePinnedToCore(display_task, "display", 4096, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(spi_queue_task, "spi_q", 4096, NULL, 3, NULL, 0);
// 假设定时器中断已配置并调用 timer_isr
}
五、注意事项与调试技巧
-
避免在 ISR 中调用阻塞 API:如
spi_device_polling_transmit,否则会触发断言或死锁。 - 合理设置任务优先级:队列处理任务应高于普通任务,但低于中断优先级。
-
使用 IRAM 安全函数:ISR 中调用的函数需放在 IRAM(如
IRAM_ATTR),避免 flash 访问冲突。 -
监控死锁:使用
xSemaphoreTake的超时参数,并打印错误日志。 - 性能考量:互斥锁会引入上下文切换开销,对于高频 SPI 操作,可考虑使用 DMA 和双缓冲。
六、总结
ESP32 多核环境下共享 SPI 总线的死锁规避,核心在于“中断不碰锁,任务用锁,队列做桥”。通过互斥锁保护短操作,队列将中断请求延迟到任务上下文,并利用 ESP-IDF 驱动自带的线程安全特性,即可有效避免死锁与数据竞争。开发者应根据实际场景选择策略,并始终遵循 FreeRTOS 的同步规则。