基于 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_event 或 rt_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_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 的调试工具,并遵循“中断最小化”原则。希望本文的实战经验能帮助你在嵌入式开发中少踩坑。