基于 RT-Thread 的临界区保护在中断与线程共享外设时的死锁排查实战

1. 问题背景:共享外设的并发访问

在 RT-Thread 中,外设(如 UART、SPI)常被多个线程和中断服务程序(ISR)共享。例如,一个传感器线程通过 SPI 读取数据,而一个定时器中断也需操作同一 SPI 发送状态。此时,若不加保护,数据竞争会导致错误;若保护不当,则可能死锁。

典型场景:

  • 线程 A 持有信号量 spi_lock,正在执行 SPI 事务。
  • 中断 ISR 触发,尝试获取同一个信号量(或调用 rt_sem_take 挂起线程)。
  • 线程 A 在中断返回后继续执行,但可能因中断中的操作而被阻塞,形成循环等待。

2. 死锁根因分析

2.1 中断中调用阻塞 API

RT-Thread 的中断上下文不允许调用会挂起当前线程的 API(如 rt_sem_take 带超时)。因为中断没有线程上下文,挂起操作会导致调度器状态异常。但很多开发者误用 rt_sem_take 在中断中,或使用 rt_enter_critical 不当。

2.2 优先级反转与互斥

当线程 A 持有信号量,而中断尝试获取同一信号量时,中断无法等待,只能返回错误。若中断中直接操作共享资源而不加保护,则可能破坏数据。更糟的是,若线程 A 在临界区内被中断打断,而中断又尝试获取同一把锁,则形成“死锁”假象——线程 A 永远无法释放锁,因为中断永远在等待。

2.3 实际案例复现

/* 共享 SPI 设备 */
static struct rt_spi_device *spi_dev;
static struct rt_semaphore spi_lock;

/* 线程 A:读取传感器 */
void sensor_thread_entry(void *param) {
    rt_uint8_t buf[8];
    while (1) {
        rt_sem_take(&spi_lock, RT_WAITING_FOREVER);
        /* 执行 SPI 事务 */
        rt_spi_transfer(spi_dev, buf, buf, 8);
        rt_sem_release(&spi_lock);
        rt_thread_mdelay(100);
    }
}

/* 定时器中断:更新状态 */
void timer_isr(void) {
    /* 错误做法:尝试获取信号量 */
    if (rt_sem_take(&spi_lock, 0) != RT_EOK) {
        /* 失败则跳过,但可能造成数据不一致 */
        return;
    }
    /* 操作 SPI 寄存器 */
    spi_dev->config.data_bits = 8;
    rt_sem_release(&spi_lock);
}

当线程 A 持有 spi_lock 时,定时器中断触发,rt_sem_take 返回超时,中断直接返回。但若中断中修改了 SPI 配置,而线程 A 正在传输,则数据损坏。若中断中调用 rt_sem_take 且不检查返回值,则可能进入死锁——中断永远等待,线程 A 永远无法释放(因为中断优先级高,会一直打断)。

3. 解决方案:临界区保护的正确姿势

3.1 原则:中断中只做标记,线程中处理

中断服务程序应尽量短,只设置事件标志或发送消息,实际的外设操作放在线程中。若必须直接操作外设,则使用 RT-Thread 的临界区 API,但注意不能使用信号量。

3.2 使用 rt_enter_critical / rt_exit_critical 保护中断与线程共享的代码

rt_enter_critical 会关闭调度器,但不会屏蔽中断。若中断中也要访问共享资源,则需要配合 rt_hw_interrupt_disable

正确做法:

/* 线程中:使用临界区保护 SPI 操作 */
void sensor_thread_entry(void *param) {
    rt_base_t level;
    rt_uint8_t buf[8];
    while (1) {
        level = rt_hw_interrupt_disable();  /* 屏蔽中断 */
        /* 执行 SPI 事务,此时中断不会打断 */
        rt_spi_transfer(spi_dev, buf, buf, 8);
        rt_hw_interrupt_enable(level);      /* 恢复中断 */
        rt_thread_mdelay(100);
    }
}

/* 中断中:同样屏蔽中断(但中断本身已屏蔽,无需重复) */
void timer_isr(void) {
    /* 直接操作 SPI,因为线程中已屏蔽中断,不会冲突 */
    spi_dev->config.data_bits = 8;
}

但这种方式会阻塞所有中断,影响实时性。更推荐使用信号量 + 中断中只置标志。

3.3 使用信号量 + 中断中发送事件

/* 线程中:等待事件,然后获取信号量 */
void sensor_thread_entry(void *param) {
    rt_uint8_t buf[8];
    while (1) {
        rt_sem_take(&spi_lock, RT_WAITING_FOREVER);
        /* 执行 SPI 事务 */
        rt_spi_transfer(spi_dev, buf, buf, 8);
        rt_sem_release(&spi_lock);
        rt_thread_mdelay(100);
    }
}

/* 中断中:只发送信号量,不操作外设 */
void timer_isr(void) {
    rt_sem_release(&spi_lock);  /* 唤醒等待的线程,但线程可能未持有锁 */
}

但这样会破坏互斥,因为中断释放了信号量,可能导致两个线程同时进入临界区。正确做法是使用 rt_eventrt_mq 通知线程。

3.4 最终推荐:使用互斥量 + 中断中仅置标志

static struct rt_mutex spi_mutex;
static struct rt_event spi_event;

/* 线程 A */
void sensor_thread_entry(void *param) {
    rt_uint8_t buf[8];
    while (1) {
        rt_mutex_take(&spi_mutex, RT_WAITING_FOREVER);
        /* 执行 SPI 事务 */
        rt_spi_transfer(spi_dev, buf, buf, 8);
        rt_mutex_release(&spi_mutex);
        rt_thread_mdelay(100);
    }
}

/* 中断中:发送事件,不直接操作外设 */
void timer_isr(void) {
    rt_event_send(&spi_event, 0x01);
}

/* 另一个线程等待事件并处理 */
void event_thread_entry(void *param) {
    rt_uint32_t e;
    while (1) {
        rt_event_recv(&spi_event, 0x01, RT_EVENT_FLAG_AND | RT_EVENT_FLAG_CLEAR, RT_WAITING_FOREVER, &e);
        rt_mutex_take(&spi_mutex, RT_WAITING_FOREVER);
        /* 安全操作 SPI */
        rt_mutex_release(&spi_mutex);
    }
}

4. 死锁排查实战工具

4.1 使用 RT-Thread 的 list_threadlist_sem

在控制台输入 list_thread 查看线程状态,若线程处于 suspend 状态且等待信号量,则可能死锁。list_sem 可查看信号量持有者。

4.2 开启 RT_USING_HOOKRT_DEBUG

rtconfig.h 中定义 RT_DEBUGRT_USING_HOOK,可打印调度器切换和信号量操作日志。

4.3 使用 rt_hw_interrupt_disable 的嵌套计数

在临界区中,确保 rt_enter_criticalrt_exit_critical 成对出现,否则系统会卡死。

5. 注意事项

  • 中断中严禁调用 rt_sem_take 或任何可能阻塞的 API,除非使用 RT_WAITING_NO 且不依赖返回值。
  • 临界区嵌套:使用 rt_enter_critical 时,注意嵌套层数,RT-Thread 支持嵌套,但必须配对。
  • 优先级反转:使用互斥量而非信号量,互斥量支持优先级继承,避免高优先级线程被低优先级阻塞。
  • 测试:使用 rt_thread_mdelay 模拟时序,并加入看门狗检测死锁。

6. 总结

死锁的根源在于中断与线程对共享资源的无序竞争。通过将中断中的操作简化为事件通知,并在线程中使用互斥量保护临界区,可以彻底避免死锁。排查时,善用 RT-Thread 的调试工具,并遵循“中断最小化”原则。希望本文的实战经验能帮助你在嵌入式开发中少踩坑。