# 基于 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 实际案例复现 ```c /* 共享 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`。 正确做法: ```c /* 线程中:使用临界区保护 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 使用信号量 + 中断中发送事件 ```c /* 线程中:等待事件,然后获取信号量 */ 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_event` 或 `rt_mq` 通知线程。 ### 3.4 最终推荐:使用互斥量 + 中断中仅置标志 ```c 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_thread` 和 `list_sem` 在控制台输入 `list_thread` 查看线程状态,若线程处于 `suspend` 状态且等待信号量,则可能死锁。`list_sem` 可查看信号量持有者。 ### 4.2 开启 `RT_USING_HOOK` 和 `RT_DEBUG` 在 `rtconfig.h` 中定义 `RT_DEBUG` 和 `RT_USING_HOOK`,可打印调度器切换和信号量操作日志。 ### 4.3 使用 `rt_hw_interrupt_disable` 的嵌套计数 在临界区中,确保 `rt_enter_critical` 和 `rt_exit_critical` 成对出现,否则系统会卡死。 ## 5. 注意事项 - **中断中严禁调用 `rt_sem_take` 或任何可能阻塞的 API**,除非使用 `RT_WAITING_NO` 且不依赖返回值。 - **临界区嵌套**:使用 `rt_enter_critical` 时,注意嵌套层数,RT-Thread 支持嵌套,但必须配对。 - **优先级反转**:使用互斥量而非信号量,互斥量支持优先级继承,避免高优先级线程被低优先级阻塞。 - **测试**:使用 `rt_thread_mdelay` 模拟时序,并加入看门狗检测死锁。 ## 6. 总结 死锁的根源在于中断与线程对共享资源的无序竞争。通过将中断中的操作简化为事件通知,并在线程中使用互斥量保护临界区,可以彻底避免死锁。排查时,善用 RT-Thread 的调试工具,并遵循“中断最小化”原则。希望本文的实战经验能帮助你在嵌入式开发中少踩坑。