# 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`)保护共享总线。注意:互斥锁具有优先级继承机制,可避免优先级反转。 ```c // 创建互斥锁 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 请求发送到高优先级任务,由任务统一处理。这符合“中断只做标记,任务做实际工作”的原则。 ```c // 定义请求结构体 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` 的非阻塞版本。 ```c // 非阻塞传输(可在任务中调用) 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 总线的安全实现。 ```c #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 的同步规则。