ESP32 多核 FreeRTOS 下任务与中断共享 SPI 总线时的死锁规避策略
👁 2 阅读 · 2026-08-27 · 嵌入式
在 ESP32 双核 FreeRTOS 环境中,SPI 总线常被多个任务和中断服务程序共享,若不加保护极易引发死锁或数据错乱。本文深入剖析多核并发访问 SPI 的竞争条件,结合 ESP-IDF 的 SPI 驱动模型,提出基于互斥锁、队列及中断安全 API 的完整规避策略,并给出可运行的代码示例与调试技巧,帮助开发者构建稳定可靠的嵌入式系统。
# 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 的同步规则。