首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >RTOS死锁里的隐形坑(上)

RTOS死锁里的隐形坑(上)

作者头像
不脱发的程序猿
发布2026-04-10 14:14:29
发布2026-04-10 14:14:29
1160
举报
做嵌入式开发的同行,几乎没人能逃过死锁的 “毒打”。

产品在实验室跑了一周好好的,到了现场突然宕机,复现全靠运气;仿真器挂上去,发现所有关键任务全挂在阻塞态,CPU 被低优先级任务占满,查了三天三夜,最后发现是一行不起眼的代码触发了死锁。

教科书里早就讲透了死锁的四大必要条件:互斥、持有并等待、不可剥夺、循环等待

但真正的坑,从来不是明知故犯地同时拿两把锁,而是藏在业务逻辑、RTOS 内核机制、驱动适配、中断交互里的隐形陷阱,很多你以为绝对安全的写法,恰恰是死锁的温床。

本文结合我多年嵌入式 RTOS 开发踩过的坑,从底层原理到复现场景,再到定位手段和根治方案,帮大家把坑堵在产品上线前。

1

递归锁的 “伪安全” 陷阱

很多工程师一遇到嵌套加锁的场景,就直接上递归锁,觉得 “递归锁能解决重复加锁问题,用了就不会死锁”。

这是嵌入式开发里最常见的认知误区,也是无数死锁事故的源头。

递归锁的核心能力,是允许同一任务对同一把锁多次加锁,必须对应次数释放才会真正解锁,它解决的只是单任务内的嵌套加锁死锁,完全不解决跨任务的资源竞争问题。

更致命的是,很多人因为用了递归锁,彻底放松了对锁的管控,临界区越写越长,甚至在里面加阻塞调用,最终大幅提升死锁概率。

同时,递归锁的优先级继承机制,在嵌套场景下极易踩坑。

踩坑实录 1:跨任务循环等待,递归锁完全失效

这个场景里,递归锁没有起到任何防护作用,两个任务直接满足 “持有并等待 + 循环等待”,百分百死锁。

代码语言:javascript
复制
// 错误示例:递归锁无法解决跨任务的循环等待
xSemaphoreHandle g_recursive_lock; // 递归锁
void TaskA(void *arg)
{
    while(1)
    {
        // 拿到递归锁
        xSemaphoreTakeRecursive(g_recursive_lock, portMAX_DELAY);
        // 持有锁的同时,阻塞等待信号量
        xSemaphoreTake(g_sync_sem, portMAX_DELAY);
        // 业务操作
        do_something();
        xSemaphoreGive(g_sync_sem);
        xSemaphoreGiveRecursive(g_recursive_lock);
        vTaskDelay(10);
    }
}
void TaskB(void *arg)
{
    while(1)
    {
        // 拿到信号量
        xSemaphoreTake(g_sync_sem, portMAX_DELAY);
        // 等待递归锁,此时TaskA持有锁等信号量,TaskB持有信号量等锁
        xSemaphoreTakeRecursive(g_recursive_lock, portMAX_DELAY);
        // 业务操作
        do_other_thing();
        xSemaphoreGiveRecursive(g_recursive_lock);
        xSemaphoreGive(g_sync_sem);
        vTaskDelay(10);
    }
}

踩坑实录 2:嵌套释放不匹配,导致锁永久泄漏

递归锁要求加锁和释放次数严格匹配,很多人在异常分支里只释放一次,就以为解锁了,结果锁的嵌套计数不归零,永远不会被真正释放,其他任务永久阻塞,等同于死锁。

代码语言:javascript
复制
// 错误示例:递归锁释放次数不匹配,导致锁泄漏
void sensor_data_process(void)
{
    xSemaphoreTakeRecursive(g_i2c_lock, portMAX_DELAY);
    xSemaphoreTakeRecursive(g_i2c_lock, portMAX_DELAY); // 嵌套加锁
    if(i2c_read_reg() < 0)
    {
        // 错误:异常分支只释放了一次,嵌套计数仍为1,锁未真正释放
        xSemaphoreGiveRecursive(g_i2c_lock);
        return;
    }
    xSemaphoreGiveRecursive(g_i2c_lock);
    xSemaphoreGiveRecursive(g_i2c_lock);
}

避坑方案

不滥用递归锁,只有明确的单任务嵌套加锁场景才用递归锁,普通互斥场景优先用普通互斥锁,强制自己规避嵌套加锁。

严格保证加释放配对,用 RAII 思想(C++)或代码规范保证,即使异常分支,也必须执行对应次数的释放;严禁在持有锁的情况下直接 return/break。

守住临界区底线,即使是递归锁,临界区内也绝对不能有任何阻塞调用,持有锁的时间越短越好。

2

临界区里的 “隐形阻塞调用”

这是嵌入式开发里最高发、最隐蔽的死锁坑。

很多工程师严格遵守了 “多锁按顺序获取” 的规则,却还是踩了死锁,核心原因就是:你以为的临界区里,藏着你根本没意识到的阻塞调用

持有互斥锁的同时执行阻塞操作,直接满足了死锁的 “持有并等待” 条件,相当于把自己暴露在死锁的风险里;更致命的是,这些阻塞调用往往藏在库函数、驱动接口、系统调用里,肉眼根本看不到。

RTOS 里,任何会让当前任务进入阻塞态的操作,都属于阻塞调用,包括但不限于:

  • 任务延时:vTaskDelay、osDelay等
  • 内核对象等待:互斥锁、信号量、消息队列、事件组的获取操作(即使带超时)
  • 底层驱动阻塞调用:串口、I2C、SPI 等外设的阻塞式读写
  • 内存管理:malloc/free(绝大多数 RTOS 的堆管理都带互斥锁,极端情况会阻塞)
  • 打印接口:printf、log_print等(底层串口驱动几乎都带锁和阻塞)

只要你在持有锁的情况下调用了包含上述操作的函数,就等于埋下了死锁的地雷。

踩坑实录:printf 引发的血案

这是我见过最多团队踩过的坑,业务锁和串口底层锁的顺序冲突,直接触发循环等待死锁。

代码语言:javascript
复制
// 错误示例:持有业务锁时调用printf,触发锁顺序冲突
xSemaphoreHandle g_business_lock; // 业务数据互斥锁
// 串口驱动底层自带的互斥锁,printf最终会调用HAL_UART_Transmit,先拿这把锁
xSemaphoreHandle g_uart_driver_lock;
void DataProcessTask(void *arg)
{
    while(1)
    {
        // 先拿业务锁
        xSemaphoreTake(g_business_lock, portMAX_DELAY);
        g_sensor_data = adc_collect();
        // 致命问题:printf里先拿g_uart_driver_lock,锁顺序:业务锁→串口锁
        printf("采集到的传感器数据:%.2f\r\n", g_sensor_data);
        xSemaphoreGive(g_business_lock);
        vTaskDelay(50);
    }
}
void HeartBeatTask(void *arg)
{
    while(1)
    {
        uint8_t heartbeat_buf[] = "心跳包\r\n";
        // 先拿串口锁(HAL库底层实现)
        HAL_UART_Transmit(&huart1, heartbeat_buf, sizeof(heartbeat_buf), portMAX_DELAY);
        // 再拿业务锁,锁顺序:串口锁→业务锁,和上面完全相反
        xSemaphoreTake(g_business_lock, portMAX_DELAY);
        g_heartbeat_cnt++;
        xSemaphoreGive(g_business_lock);
        vTaskDelay(1000);
    }
}

这个场景里,两个任务的锁获取顺序完全倒置,只要调度时序刚好对上,就会直接死锁,DataProcessTask 拿了业务锁,等串口锁;HeartBeatTask 拿了串口锁,等业务锁,两个任务永久阻塞。

更坑的是,很多人觉得 “我加了超时时间,拿不到锁就放弃,不会死锁”。

但只要在超时时间内,你依然持有锁,死锁的条件就依然成立;极端情况下,两个任务同时超时、同时重试,还会触发 “活锁”,系统依然无法正常运行。

避坑方案

临界区极简铁律,持有锁的临界区里,只做对共享资源的原子操作,绝对不调用任何未知实现的函数,严禁任何阻塞操作,哪怕是带超时的。

数据拷贝先行,如果需要打印、处理共享数据,先在临界区内把数据拷贝到局部变量,释放锁之后,再处理拷贝出来的数据。

提前排查函数实现,临界区内必须调用的函数,一定要追到底层实现,确认没有任何锁操作和阻塞调用,包括库函数和驱动接口。

绝对禁止在临界区内调用 malloc/free,堆操作不仅有锁,还可能有不确定的耗时,甚至碎片导致的分配失败,极易引发问题。

3

中断与任务的锁竞争

中断上下文和任务的交互,是 RTOS 开发里最容易出问题的地方,很多死锁都源于对 “中断上下文不能阻塞” 这个基础规则的漠视,甚至是一些反直觉的隐形陷阱。

坑点 1:中断里直接使用阻塞式互斥锁

这是新手最容易踩的低级错误,但每年都有无数人栽在这里。

互斥锁的加锁操作,在拿不到锁时会让任务进入阻塞态,而中断上下文不属于任何任务,根本不允许阻塞,一旦在中断里执行加锁操作,轻则直接触发 hardfault,重则导致内核调度异常,直接死锁。

甚至很多人会在中断里调用malloc、printf这类带锁的函数,本质上是同一个问题。

坑点 2:自旋锁的 “中断未关闭” 死锁

很多工程师知道中断里不能用互斥锁,于是用自旋锁保护中断和任务共享的资源,却忽略了单核场景下自旋锁的正确用法,直接触发死锁。

单核 MCU 中,任务里拿到自旋锁后,如果没有关闭中断,此时中断触发,CPU 会立即跳转到中断服务程序(ISR)执行。

如果 ISR 里也要获取这把自旋锁,就会进入无限自旋:

  • ISR 在自旋等待锁释放,占用了整个 CPU 核心;
  • 持有锁的任务被中断抢占,根本没有机会执行释放锁的操作;
  • 最终系统彻底卡死,等同于死锁。
代码语言:javascript
复制
// 错误示例:单核场景下,自旋锁未关中断,直接触发死锁
volatile int g_spin_lock = 0;
uint32_t g_shared_buf[128];
// 任务上下文
void TaskFunc(void *arg)
{
    while(1)
    {
        spin_lock(&g_spin_lock); // 拿自旋锁,但没关中断
        memcpy(g_shared_buf, tx_buf, sizeof(tx_buf));
        spin_unlock(&g_spin_lock);
        vTaskDelay(10);
    }
}
// 中断服务程序
void DMA_IRQHandler(void)
{
    spin_lock(&g_spin_lock); // 中断触发,这里拿不到锁,无限自旋
    memcpy(rx_buf, g_shared_buf, sizeof(rx_buf));
    spin_unlock(&g_spin_lock);
}

避坑方案

中断上下文铁律,中断里绝对不能执行任何可能阻塞的操作,包括互斥锁加锁、阻塞式消息队列获取、malloc、阻塞式驱动调用;仅允许执行非阻塞的信号量释放、消息队列发送操作。

单核自旋锁必须关中断,保护中断和任务共享资源的自旋锁,必须用 “关中断 + 自旋锁” 的方式,确保临界区执行过程中不会被中断打断。

代码语言:javascript
复制
// 正确示例:单核自旋锁+关中断
uint32_t int_status;
int_status = __disable_irq(); // 关中断
spin_lock(&g_spin_lock);
// 操作共享资源
spin_unlock(&g_spin_lock);
__enable_irq(int_status); // 恢复中断状态

优先用无锁设计,中断和任务之间的数据交互,优先用环形缓冲区(FIFO)、双缓冲等无锁数据结构,完全规避锁竞争,从根源上避免问题。

4

优先级反转引发的次生死锁

很多嵌入式工程师有个误区,优先级反转只会导致高优先级任务实时性变差,不会引发死锁。

但在实际产品中,优先级反转不仅会触发看门狗复位,还会间接引发连锁死锁,最终导致系统彻底僵死。

优先级反转的经典场景:

  1. 低优先级任务(L)获取了互斥锁,正在访问共享资源;
  2. 高优先级任务(H)需要获取这把锁,被阻塞,进入等待态;
  3. 此时一个中优先级任务(M)就绪,抢占了低优先级任务 L 的 CPU,开始执行;
  4. 中优先级任务 M 一直占用 CPU,低优先级任务 L 根本没机会执行,无法释放互斥锁;
  5. 最终高优先级任务 H 一直被阻塞,形成了 “高优先级等低优先级,低优先级抢不到 CPU” 的饥饿场景,和死锁的现象完全一致。

更致命的是,这种场景会引发次生死锁,如果高优先级任务 H 还持有另一把锁,而中优先级任务 M 刚好需要等待这把锁,就会形成完美的循环等待,直接触发死锁。

很多人踩这个坑的核心原因,是用二进制信号量当做互斥锁用

二进制信号量没有优先级继承机制,是优先级反转的重灾区;而 RTOS 的互斥锁,绝大多数都实现了优先级继承协议,能大幅缓解优先级反转问题。

代码语言:javascript
复制
// 错误示例:用二进制信号量做互斥,无优先级继承,触发优先级反转
xSemaphoreHandle g_bin_sem; // 二进制信号量,用于互斥(错误用法)
// 低优先级任务:优先级1
void LowPriorityTask(void *arg)
{
    while(1)
    {
        xSemaphoreTake(g_bin_sem, portMAX_DELAY);
        // 耗时较长的操作,持有锁的时间较长
        long_time_calculate();
        xSemaphoreGive(g_bin_sem);
        vTaskDelay(100);
    }
}
// 中优先级任务:优先级3
void MidPriorityTask(void *arg)
{
    while(1)
    {
        // 持续占用CPU的运算,不会主动释放CPU
        cpu_heavy_task();
        vTaskDelay(1);
    }
}
// 高优先级任务:优先级5
void HighPriorityTask(void *arg)
{
    while(1)
    {
        xSemaphoreTake(g_bin_sem, portMAX_DELAY);
        // 紧急的实时操作
        real_time_process();
        xSemaphoreGive(g_bin_sem);
        vTaskDelay(10);
    }
}

这个场景里,一旦低优先级任务拿到信号量,高优先级任务就会被阻塞;此时中优先级任务就绪,直接抢占 CPU,低优先级任务无法执行,高优先级任务永远等不到锁,系统直接卡死。

避坑方案

互斥场景必须用互斥锁,绝对不用二进制信号量,RTOS 的互斥锁自带优先级继承机制,能最大程度缓解优先级反转;二进制信号量仅用于任务间的同步,绝对不能用于互斥场景。

缩短锁持有时间,即使有优先级继承,也要尽量减少临界区的耗时,降低优先级反转的影响窗口。

避免优先级跨度太大的任务竞争同一把锁,设计上尽量让优先级相近的任务共享资源,减少高优先级和低优先级任务的锁竞争。

关键场景启用优先级天花板协议,部分 RTOS 支持优先级天花板,能彻底避免优先级反转,确保持有锁的任务优先级不低于所有可能竞争该锁的任务的最高优先级。

5

任务删除 / 终止引发的锁泄漏

这是 RTOS 开发里最容易被忽略的死锁坑,一个任务拿了互斥锁,还没来得及释放,就被其他任务删除了,或者自己异常退出了,这把锁就会被永久持有,所有需要获取这把锁的任务,都会永久阻塞,系统直接僵死。

很多新手有个错误认知,任务被删除了,它占用的资源就会自动释放。

但绝大多数 RTOS(FreeRTOS、RT-Thread、uC/OS 等)的内核对象(互斥锁、信号量、消息队列),都是全局的内核资源,不会因为持有它的任务被删除而自动释放

踩坑实录 1:任务异常退出,锁未释放

代码语言:javascript
复制
// 错误示例:异常分支直接退出,锁泄漏
void WorkerTask(void *arg)
{
    while(1)
    {
        xSemaphoreTake(g_device_lock, portMAX_DELAY);
        // 设备读取操作,失败则退出任务
        if(device_read() < 0)
        {
            // 致命错误:直接break,没有释放锁!
            break;
        }
        // 业务处理
        process_device_data();
        xSemaphoreGive(g_device_lock);
        vTaskDelay(50);
    }
    // 任务退出,锁被永久持有,其他任务全部阻塞
    vTaskDelete(NULL);
}

踩坑实录 2:跨任务删除,直接锁泄漏

这是更常见的场景,上位机下发复位指令,管理任务直接删除工作任务,完全不考虑工作任务是否持有锁。

代码语言:javascript
复制
// 错误示例:跨任务删除,导致锁泄漏
void ManagerTask(void *arg)
{
    while(1)
    {
        if(reset_cmd == 1)
        {
            // 直接删除工作任务,不管它是否持有锁
            vTaskDelete(WorkerTaskHandle);
            // 重启任务
            xTaskCreate(WorkerTask, ..., &WorkerTaskHandle);
            reset_cmd = 0;
        }
        vTaskDelay(10);
    }
}
void WorkerTask(void *arg)
{
    while(1)
    {
        xSemaphoreTake(g_bus_lock, portMAX_DELAY);
        // 阻塞式操作,此时被删除,锁永远不释放
        bus_blocking_transfer();
        xSemaphoreGive(g_bus_lock);
        vTaskDelay(10);
    }
}

避坑方案

尽量避免跨任务删除,RTOS 开发的最佳实践是 “任务自己退出自己”,不要用其他任务强制删除。

管理任务可以通过标志位、信号量通知工作任务主动退出,工作任务在退出前,必须完成所有资源的释放。

代码语言:javascript
复制
// 正确示例:通过标志位通知任务主动退出,确保资源释放
volatile uint8_t g_worker_task_exit = 0;
void ManagerTask(void *arg)
{
    if(reset_cmd == 1)
    {
        g_worker_task_exit = 1;
        // 等待任务退出,而不是强制删除
        vTaskDelay(100);
        // 重启任务
        xTaskCreate(WorkerTask, ..., &WorkerTaskHandle);
        reset_cmd = 0;
    }
}
void WorkerTask(void *arg)
{
    while(1)
    {
        if(g_worker_task_exit)
        {
            break; // 退出循环,执行清理
        }
        xSemaphoreTake(g_bus_lock, portMAX_DELAY);
        if(bus_transfer() < 0)
        {
            xSemaphoreGive(g_bus_lock); // 异常分支先释放锁
            break;
        }
        process_data();
        xSemaphoreGive(g_bus_lock);
        vTaskDelay(10);
    }
    // 任务退出前,兜底检查,确保所有锁都释放
    vTaskDelete(NULL);
}

锁的加释放放在同一层级,尽量避免在分支里释放锁,用goto或者封装函数,确保加锁和释放在同一个代码层级,即使异常退出,也能执行释放操作。

利用 RTOS 的任务清理钩子,部分 RTOS 支持任务删除钩子函数,可以在钩子函数里检查任务是否持有锁,并执行兜底释放,避免锁泄漏。

加断言防护,在任务退出的位置,加断言检查锁的持有者是否是当前任务,如果是,直接触发断言,在开发阶段就发现问题。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-03-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 美男子玩编程 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 踩坑实录 1:跨任务循环等待,递归锁完全失效
  • 踩坑实录 2:嵌套释放不匹配,导致锁永久泄漏
  • 避坑方案
  • 踩坑实录:printf 引发的血案
  • 避坑方案
  • 坑点 1:中断里直接使用阻塞式互斥锁
  • 坑点 2:自旋锁的 “中断未关闭” 死锁
  • 避坑方案
  • 避坑方案
  • 踩坑实录 1:任务异常退出,锁未释放
  • 踩坑实录 2:跨任务删除,直接锁泄漏
  • 避坑方案
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档