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总线的访问严格串行化,彻底消除竞态问题,为多任务嵌入式系统提供可靠的总线共享基础。
暂无评论,来抢沙发~