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 中可设置 flagsSPI_DEVICE_HALFDUPLEX 等,但更关键的是,驱动内部已对总线访问加锁。若所有访问都通过 spi_device_* 函数,则无需额外互斥锁。但 ISR 中仍不能调用这些阻塞函数,需使用 spi_device_queue_transspi_device_get_trans_result 的非阻塞版本。

// 非阻塞传输(可在任务中调用)
spi_device_queue_trans(dev, &t, portMAX_DELAY);
// 等待完成
spi_device_get_trans_result(dev, &t, portMAX_DELAY);

三、完整配置步骤

  1. 初始化 SPI 总线:使用 spi_bus_initialize 配置 SPI2 或 SPI3,设置时钟、模式等。
  2. 添加设备spi_bus_add_device 注册设备,指定 SPI 模式、时钟频率等。
  3. 创建同步原语:初始化互斥锁和队列。
  4. 编写任务与 ISR:任务中通过队列接收请求,ISR 仅发送请求。
  5. 启用中断:注册 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 的同步规则。