一、RT-Thread驱动线程安全的由来

做嵌入式开发的同学都懂,设备驱动里的硬件资源(比如串口、I2C、SPI)是所有线程共享的,多个线程同时操作同一个硬件,就像两个人抢同一个快递柜,肯定会出乱子——比如串口数据发一半被打断,读传感器数据和打日志混在一起,根本分不清是谁的内容。所以必须用“锁”来保护这些共享资源,这就是线程安全设计的核心。

1.1 全局锁的简单方案

最容易想到的方法就是给整个驱动加一把大锁,相当于把共享资源“锁死”,任何线程要操作都得先等拿到这把锁,用完再放开。这种方案的优点是简单到不用动脑,新手上手快,几乎不会出低级的冲突问题。 我写了个实际的代码例子,直接看就行:

// 技术栈:RT-Thread C语言
#include <rtthread.h>
#include "drv_uart.h" // 底层UART驱动头文件

/* 全局锁:给整个UART驱动加唯一的锁 */
static rt_mutex_t uart_global_lock = RT_NULL;

/* 初始化驱动和锁 */
int uart_driver_init(void) {
    // 创建全局锁,优先级继承模式(RT-Thread的安全特性)
    uart_global_lock = rt_mutex_create("uart_global", RT_IPC_FLAG_PRIO);
    if (uart_global_lock == RT_NULL) {
        rt_kprintf("全局锁创建失败!\n");
        return -1;
    }
    return 0;
}

/* 用全局锁发数据的函数 */
int uart_send_global(uint8_t *data, uint16_t len) {
    // 拿锁,最多等10个时钟周期,避免一直卡着
    if (rt_mutex_take(uart_global_lock, rt_tick_from_millisecond(10)) != RT_EOK) {
        rt_kprintf("拿全局锁超时,发数据失败!\n");
        return -1;
    }
    // 实际的UART发送操作
    int ret = uart_hw_send(data, len);
    // 必须主动放锁,不然其他线程永远拿不到
    rt_mutex_release(uart_global_lock);
    return ret;
}

二、全局锁的致命误区

全局锁虽然简单,但坑特别多,大部分新手都会在这里踩坑。我举个真实场景:假设你的设备有两个线程,一个是高优先级的温湿度线程(优先级5),每秒读一次数据通过UART发给云端;另一个是低优先级的日志线程(优先级2),每100ms打一次系统运行日志,两个线程都用同一个UART。 这时候问题来了:如果日志线程刚好拿到全局锁,要打印一条10ms的日志,那高优先级的温湿度线程就得等10ms,本来1秒发一次数据,现在可能变成1.01秒,更糟的是如果有优先级更高的线程(优先级6)要操作UART,也得等这把锁,就会出现“优先级翻转”——高优先级线程被低优先级线程卡住,整个系统的实时性直接报废。

2.1 全局锁的优缺点总结

  • 优点:实现简单,不用考虑资源拆分,新手能快速写出可用代码。
  • 缺点:并发性能极差,一个线程占锁时所有线程都得等,还容易引发优先级翻转,高负载下系统直接卡成PPT。

三、细粒度锁的正确打开方式

既然全局锁太粗,那我们就把锁拆成“更细的粒度”——比如UART的发送和接收是两个独立的操作,用两把锁;如果是SPI驱动同时接Flash和LCD,就各用各的锁,这样两个操作可以同时进行,不会互相阻塞,性能直接上来。 还是用RT-Thread的代码举例,和全局锁对比,你就能看懂:

// 技术栈:RT-Thread C语言
#include <rtthread.h>
#include "drv_uart.h"

/* 细粒度锁:发送和接收各用一把锁,拆分资源 */
static rt_mutex_t uart_send_lock = RT_NULL; // 发送专用锁
static rt_mutex_t uart_recv_lock = RT_NULL; // 接收专用锁

/* 初始化两个锁 */
int uart_driver_init(void) {
    uart_send_lock = rt_mutex_create("uart_send", RT_IPC_FLAG_PRIO);
    uart_recv_lock = rt_mutex_create("uart_recv", RT_IPC_FLAG_PRIO);
    if (uart_send_lock == RT_NULL || uart_recv_lock == RT_NULL) {
        rt_kprintf("细粒度锁创建失败!\n");
        return -1;
    }
    return 0;
}

/* 发数据:只拿发送锁,不影响接收 */
int uart_send_fine(uint8_t *data, uint16_t len) {
    if (rt_mutex_take(uart_send_lock, RT_WAITING_FOREVER) != RT_EOK) {
        rt_kprintf("发送锁获取失败!\n");
        return -1;
    }
    int ret = uart_hw_send(data, len);
    rt_mutex_release(uart_send_lock);
    return ret;
}

/* 收数据:只拿接收锁,和发送并行 */
int uart_recv_fine(uint8_t *buf, uint16_t len) {
    if (rt_mutex_take(uart_recv_lock, RT_WAITING_FOREVER) != RT_EOK) {
        rt_kprintf("接收锁获取失败!\n");
        return -1;
    }
    int ret = uart_hw_recv(buf, len);
    rt_mutex_release(uart_recv_lock);
    return ret;
}

你看,现在发送和接收可以同时操作UART,不会互相等待,性能比全局锁提升了好几倍,适合高负载的产品,比如带多路传感器的工业设备、需要同时处理显示和通信的智能设备。

3.1 细粒度锁的坑

但细粒度锁也不是万能的,比如拆得太细会有“死锁”风险——假设你写了一个函数既要发送又要接收,先拿了发送锁,再拿接收锁,结果另一个线程先拿了接收锁,再等发送锁,就会互相卡死。所以拆锁的时候一定要理清楚资源的依赖关系,避免交叉拿锁。

四、两种锁的场景取舍

那到底什么时候用全局锁,什么时候用细粒度锁?我给你整理了实际的判断标准:

  • 选全局锁:驱动逻辑超级简单(比如只有一个线程访问某个GPIO)、资源访问频率极低(比如只有初始化的时候用一次)、产品对性能要求不高,这时候无脑用全局锁,省事儿。
  • 选细粒度锁:驱动有多个独立的子资源(比如SPI接两个不同设备)、高并发场景(比如每秒要发几十次数据)、产品要求实时性高,这时候必须拆锁,不然系统根本跑不动。

五、线程安全设计的注意事项

不管用哪种锁,都要记住几个关键点:

  1. 锁的持有时间越短越好:锁里只能放“拿锁→操作资源→放锁”这几步,绝对不能在锁里加延时、打大量日志,不然占锁太久,其他线程都得等。
  2. 别乱用锁嵌套:如果一定要用多个锁,必须严格按顺序拿,比如先拿A锁再拿B锁,不能反过来,避免死锁。
  3. 用RT-Thread的优先级继承锁:RT-Thread的mutex默认是带优先级继承的,低优先级线程拿锁时,高优先级线程会自动提升优先级,不会被其他低优先级线程抢占,彻底避免优先级翻转,这个特性一定要用,别自己写死锁检测。

六、总结

很多嵌入式开发者刚接触RT-Thread的时候,都会随便用全局锁,结果产品跑起来卡、数据乱;但反过来为了高性能乱用细粒度锁,又容易出死锁。核心逻辑其实很简单:根据资源的独立性和访问频率选锁的粒度,不盲目追求简单,也不盲目追求性能。全局锁是“新手友好的备选方案”,细粒度锁是“高负载场景的优化方案”,选对了,驱动的线程安全问题就解决了大半。