ESP32双核下Arduino loopTask与自定义FreeRTOS任务共享SPI总线的加锁实践

一、为什么需要加锁?

ESP32拥有两个Xtensa LX6核心(Core0和Core1)。Arduino框架默认将loopTask固定在Core1上运行,而用户通过xTaskCreatePinnedToCore创建的任务可以指定运行核心。当两个任务同时调用SPI.transfer()或操作同一SPI外设(如TFT屏、SD卡、RFID模块)时,会出现以下问题:

  • 总线时序错乱:SPI是全双工同步总线,一次传输由多个字节组成。若任务A在传输中途被任务B抢占,B插入自己的传输,A接收到的数据将完全错位。
  • 片选信号冲突:多个设备共享SCK/MOSI/MISO时,若片选(CS)控制不当,会导致多个从设备同时驱动MISO线,造成短路风险。
  • 外设状态机混乱:许多SPI设备(如SD卡)依赖命令-响应的严格时序,交叉访问会破坏内部状态机。

因此,任何可能被多个任务并发调用的SPI访问都必须用互斥量保护。

二、FreeRTOS互斥量原理

FreeRTOS提供互斥量(Mutex)作为二进制信号量的变体,具有优先级继承机制:当低优先级任务持有锁时,若高优先级任务尝试获取锁,低优先级任务的优先级会被临时提升到与高优先级任务相同,避免优先级反转。

关键API:

SemaphoreHandle_t xSemaphoreCreateMutex(void);          // 创建互斥量
BaseType_t xSemaphoreTake(SemaphoreHandle_t xMutex,     // 获取锁
                          TickType_t xTicksToWait);
BaseType_t xSemaphoreGive(SemaphoreHandle_t xMutex);    // 释放锁

在Arduino环境中,loopTask本身就是一个FreeRTOS任务,因此同样可以使用上述API。

三、配置步骤

步骤1:定义全局互斥量句柄

在setup()之前定义,确保所有任务可见:

SemaphoreHandle_t spiMutex = NULL;

步骤2:在setup()中创建互斥量

void setup() {
  Serial.begin(115200);
  spiMutex = xSemaphoreCreateMutex();
  if (spiMutex == NULL) {
    Serial.println("Mutex create failed!");
    while(1);
  }
  // 初始化SPI总线(以VSPI为例)
  SPI.begin(18, 19, 23, 5); // SCK, MISO, MOSI, SS
  // 创建自定义任务,固定到Core0
  xTaskCreatePinnedToCore(
    spiTask,        // 任务函数
    "SPI_Task",     // 任务名
    4096,           // 栈大小
    NULL,           // 参数
    1,              // 优先级
    NULL,           // 任务句柄
    0               // 核心0
  );
}

步骤3:封装带锁的SPI访问函数

避免在每个调用点重复写take/give,封装成函数更安全:

void spiTransferWithLock(uint8_t *txBuf, uint8_t *rxBuf, size_t len) {
  if (xSemaphoreTake(spiMutex, portMAX_DELAY) == pdTRUE) {
    SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));
    for (size_t i = 0; i < len; i++) {
      rxBuf[i] = SPI.transfer(txBuf[i]);
    }
    SPI.endTransaction();
    xSemaphoreGive(spiMutex);
  } else {
    Serial.println("SPI lock timeout!");
  }
}

步骤4:在loopTask和自定义任务中统一调用

// 自定义任务:每500ms读取一次SPI传感器
void spiTask(void *pvParameters) {
  uint8_t tx[4] = {0x01, 0x02, 0x03, 0x04};
  uint8_t rx[4];
  while (1) {
    spiTransferWithLock(tx, rx, 4);
    Serial.printf("Task RX: %02X %02X %02X %02X\n", rx[0], rx[1], rx[2], rx[3]);
    vTaskDelay(pdMS_TO_TICKS(500));
  }
}

// Arduino loopTask:每200ms写一次SPI显示屏
void loop() {
  uint8_t tx[2] = {0xA0, 0x55};
  uint8_t rx[2];
  spiTransferWithLock(tx, rx, 2);
  delay(200);
}

四、完整代码示例

#include <SPI.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>

SemaphoreHandle_t spiMutex = NULL;

void spiTransferWithLock(uint8_t *txBuf, uint8_t *rxBuf, size_t len) {
  if (xSemaphoreTake(spiMutex, portMAX_DELAY) == pdTRUE) {
    SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));
    for (size_t i = 0; i < len; i++) {
      rxBuf[i] = SPI.transfer(txBuf[i]);
    }
    SPI.endTransaction();
    xSemaphoreGive(spiMutex);
  }
}

void spiTask(void *pvParameters) {
  uint8_t tx[4] = {0x01, 0x02, 0x03, 0x04};
  uint8_t rx[4];
  while (1) {
    spiTransferWithLock(tx, rx, 4);
    Serial.printf("Task RX: %02X %02X %02X %02X\n", rx[0], rx[1], rx[2], rx[3]);
    vTaskDelay(pdMS_TO_TICKS(500));
  }
}

void setup() {
  Serial.begin(115200);
  spiMutex = xSemaphoreCreateMutex();
  if (spiMutex == NULL) {
    Serial.println("Mutex create failed!");
    while(1);
  }
  SPI.begin(18, 19, 23, 5);
  xTaskCreatePinnedToCore(spiTask, "SPI_Task", 4096, NULL, 1, NULL, 0);
}

void loop() {
  uint8_t tx[2] = {0xA0, 0x55};
  uint8_t rx[2];
  spiTransferWithLock(tx, rx, 2);
  delay(200);
}

五、注意事项与避坑指南

  • 锁的粒度要小:只保护SPI传输本身,不要在持锁期间调用delay()或等待其他资源,否则会阻塞另一个核心。
  • 避免在中断中使用互斥量:FreeRTOS互斥量不能在ISR中获取,若需在中断中触发SPI传输,应使用任务通知或队列将请求转发到任务上下文。
  • SPI.beginTransaction与锁的关系:beginTransaction本身不是线程安全的,必须放在锁内调用。
  • 不同SPI外设使用不同锁:若系统有多个SPI总线(VSPI和HSPI),应为每条总线分别创建互斥量,避免不必要的串行化。
  • 超时处理:xSemaphoreTake的等待时间建议用portMAX_DELAY(死等)或合理超时(如100ms),超时后应记录错误而非静默失败。
  • 栈大小检查:自定义任务栈建议不低于4096字节,SPI驱动和Serial.printf会消耗较多栈空间。
  • 优先级设置:自定义任务优先级不宜过高,避免长时间抢占loopTask导致Arduino核心功能(如WiFi、串口事件)响应迟缓。

通过以上方案,可确保ESP32双核环境下SPI总线的访问严格串行化,彻底消除竞态问题,为多任务嵌入式系统提供可靠的总线共享基础。