ESP32 多核任务调度下 I2C 总线竞争导致的死锁排查与 FreeRTOS 临界区嵌套修复
👁 2 阅读 · 2026-08-27 · 嵌入式
在 ESP32 双核 FreeRTOS 环境中,多任务并发访问共享 I2C 总线时,若未正确使用临界区保护,极易引发总线竞争、数据错乱甚至死锁。本文以实际项目为例,深入剖析一次由临界区嵌套导致的死锁问题,从现象、根因到修复,完整演示排查思路与 FreeRTOS 临界区嵌套的正确用法,帮助开发者规避同类陷阱。
# ESP32 多核任务调度下 I2C 总线竞争导致的死锁排查与 FreeRTOS 临界区嵌套修复
## 一、问题背景
在 ESP32 双核(PRO_CPU 和 APP_CPU)上运行 FreeRTOS,多个任务(如传感器采集、OLED 显示、日志输出)需要共享同一路 I2C 总线。为保护总线,开发者通常使用互斥锁(Mutex)或临界区(Critical Section)。然而,若在临界区内调用带阻塞的 I2C 操作,或临界区嵌套不当,会导致系统死锁或任务卡死。
## 二、现象描述
- 系统运行数分钟后,OLED 显示停止刷新,传感器数据不再更新。
- 通过串口监视器发现,任务 `sensor_task` 和 `display_task` 均卡在 `i2c_master_write_to_device()` 调用上。
- 使用 `vTaskList()` 查看任务状态,发现两个任务均处于 `Blocked` 状态,且等待的信号量计数为 0。
## 三、初步排查思路
1. **确认 I2C 驱动配置**:检查 `i2c_param_config()` 和 `i2c_driver_install()` 是否正确,总线速率、上拉电阻等是否合理。
2. **检查任务优先级**:确认两个任务的优先级是否合理,避免优先级反转。
3. **审查临界区使用**:重点检查所有访问 I2C 的代码路径,是否在临界区内调用了可能阻塞的函数。
## 四、根因定位
通过代码审查,发现 `sensor_task` 中使用了 `taskENTER_CRITICAL()` 保护 I2C 操作,而 `display_task` 中则使用了 `taskENTER_CRITICAL()` 嵌套保护。问题出在以下代码片段:
```c
// sensor_task 中
void read_sensor(void) {
taskENTER_CRITICAL(&spinlock);
i2c_master_write_to_device(I2C_NUM_0, SENSOR_ADDR, cmd, len, 100);
i2c_master_read_from_device(I2C_NUM_0, SENSOR_ADDR, data, len, 100);
taskEXIT_CRITICAL(&spinlock);
}
// display_task 中
void update_display(void) {
taskENTER_CRITICAL(&spinlock);
taskENTER_CRITICAL(&spinlock); // 错误:重复进入同一临界区
i2c_master_write_to_device(I2C_NUM_0, OLED_ADDR, buf, len, 100);
taskEXIT_CRITICAL(&spinlock);
taskEXIT_CRITICAL(&spinlock);
}
```
**问题分析**:
- `taskENTER_CRITICAL()` 在单核上会关闭中断,在双核上会获取一个全局互斥锁(spinlock)。重复进入同一临界区会导致嵌套计数错误,在双核环境下可能造成死锁。
- 更严重的是,`i2c_master_write_to_device()` 内部会调用 `vTaskDelay()` 或等待事件组,属于阻塞操作。在临界区内调用阻塞函数,会长时间占用 CPU 或锁,导致其他任务无法获得总线。
## 五、修复方案
### 1. 使用互斥锁替代临界区
对于 I2C 这类慢速外设,应使用 FreeRTOS 互斥锁(Mutex)而非临界区,因为互斥锁允许任务在等待时让出 CPU,且支持优先级继承。
```c
SemaphoreHandle_t i2c_mutex;
void init_i2c_mutex(void) {
i2c_mutex = xSemaphoreCreateMutex();
}
void read_sensor(void) {
if (xSemaphoreTake(i2c_mutex, portMAX_DELAY) == pdTRUE) {
i2c_master_write_to_device(I2C_NUM_0, SENSOR_ADDR, cmd, len, 100);
i2c_master_read_from_device(I2C_NUM_0, SENSOR_ADDR, data, len, 100);
xSemaphoreGive(i2c_mutex);
}
}
void update_display(void) {
if (xSemaphoreTake(i2c_mutex, portMAX_DELAY) == pdTRUE) {
i2c_master_write_to_device(I2C_NUM_0, OLED_ADDR, buf, len, 100);
xSemaphoreGive(i2c_mutex);
}
}
```
### 2. 若必须使用临界区,注意嵌套规则
FreeRTOS 允许临界区嵌套,但必须成对调用,且嵌套深度不能超过 `configMAX_SYSCALL_INTERRUPT_PRIORITY` 限制。在 ESP32 上,推荐使用 `portENTER_CRITICAL()` 和 `portEXIT_CRITICAL()`,并确保嵌套层级正确。
```c
void safe_i2c_operation(void) {
portENTER_CRITICAL(&spinlock);
// 短小、非阻塞的 I2C 操作(如寄存器读写)
portEXIT_CRITICAL(&spinlock);
}
```
### 3. 避免在临界区内调用阻塞 API
`i2c_master_write_to_device()` 带有超时参数,内部会等待 I2C 硬件事件,属于阻塞调用。应将其置于临界区之外,或使用非阻塞的 I2C 驱动接口(如 `i2c_master_start()` 等)。
## 六、完整示例代码
以下是一个基于互斥锁的 I2C 共享示例,适用于 ESP32 双核环境:
```c
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "driver/i2c.h"
SemaphoreHandle_t i2c_mutex;
#define I2C_MASTER_SCL_IO 22
#define I2C_MASTER_SDA_IO 21
#define I2C_MASTER_NUM 0
#define I2C_MASTER_FREQ_HZ 100000
void i2c_master_init(void) {
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = I2C_MASTER_SDA_IO,
.scl_io_num = I2C_MASTER_SCL_IO,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master.clk_speed = I2C_MASTER_FREQ_HZ,
};
i2c_param_config(I2C_MASTER_NUM, &conf);
i2c_driver_install(I2C_MASTER_NUM, conf.mode, 0, 0, 0);
i2c_mutex = xSemaphoreCreateMutex();
}
void sensor_task(void *arg) {
uint8_t cmd = 0x10;
uint8_t data[2];
while (1) {
if (xSemaphoreTake(i2c_mutex, portMAX_DELAY)) {
i2c_master_write_to_device(I2C_MASTER_NUM, 0x50, &cmd, 1, 100);
i2c_master_read_from_device(I2C_MASTER_NUM, 0x50, data, 2, 100);
xSemaphoreGive(i2c_mutex);
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void display_task(void *arg) {
uint8_t buf[8] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
while (1) {
if (xSemaphoreTake(i2c_mutex, portMAX_DELAY)) {
i2c_master_write_to_device(I2C_MASTER_NUM, 0x3C, buf, sizeof(buf), 100);
xSemaphoreGive(i2c_mutex);
}
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void app_main(void) {
i2c_master_init();
xTaskCreatePinnedToCore(sensor_task, "sensor", 2048, NULL, 2, NULL, 0);
xTaskCreatePinnedToCore(display_task, "display", 2048, NULL, 2, NULL, 1);
}
```
## 七、注意事项
- **临界区与互斥锁的选择**:临界区用于保护极短代码段(如变量自增),互斥锁用于保护可能阻塞的操作。I2C 操作必须使用互斥锁。
- **嵌套临界区**:若确需嵌套,务必保证 `ENTER` 和 `EXIT` 严格配对,且不要在临界区内调用任何可能阻塞的 API。
- **双核调度**:ESP32 双核下,`taskENTER_CRITICAL()` 会获取全局 spinlock,若一个核在临界区内阻塞,另一个核会自旋等待,导致性能下降甚至死锁。
- **使用 `vTaskList()` 调试**:当任务卡死时,通过 `vTaskList()` 查看任务状态和等待的信号量,能快速定位问题。
- **考虑 I2C 驱动本身的线程安全**:ESP-IDF 的 I2C 驱动在 `CONFIG_FREERTOS_UNICORE` 下可能默认非线程安全,务必自行加锁。
## 八、总结
本次死锁源于对 FreeRTOS 临界区机制的误用,将阻塞的 I2C 操作置于临界区内,且错误嵌套。通过改用互斥锁并规范临界区使用,系统稳定运行。在 ESP32 多核环境下,共享资源保护必须谨慎设计,优先使用互斥锁,避免临界区嵌套和阻塞调用,才能确保实时性和可靠性。