4. 字符设备驱动进阶:并发与竞态控制

驱动开发做到一定阶段,你会发现一个绕不开的坎——并发。

说白了,就是多个执行流同时访问同一份资源。比如两个用户程序同时调用同一个驱动里的 write(),或者一个中断来了,正好跟进程上下文抢数据。这时候如果没处理好,轻则数据错乱,重则内核崩溃。

我刚开始写驱动时,就吃过这个亏。一个简单的计数器,两个进程同时读,结果读出来全是乱数。从那以后,我养成了一个习惯:写驱动前,先想清楚哪些资源会被共享,再决定用什么锁。

4.1 竞态与临界区

先搞清楚两个概念:

  • 竞态(Race Condition):多个执行流无序访问共享资源,导致结果不可预测。
  • 临界区(Critical Section):访问共享资源的那段代码,必须互斥执行。

嵌入式Linux里的并发来源,我归纳了一下,主要有这几种:

并发来源 说明 典型场景
对称多处理(SMP) 多个CPU核同时运行 双核ARM Cortex-A72同时调用驱动
进程与进程 多个用户进程并发 两个app同时open同一个设备
进程与中断 进程上下文与中断上下文 进程正在写缓冲区,中断来了也要写
中断与中断 高优先级中断打断低优先级中断 两个外设中断共享一个全局变量

你想想看,如果临界区不加保护,后果就是:一个进程读到一半,另一个进程把数据改了。嗯,这就是典型的“脏读”。

核心原则:临界区越小越好,锁的持有时间越短越好。别在锁里做耗时的操作,比如内存分配、睡眠等待。

4.2 信号量(Semaphore)

信号量是内核里比较“重”的同步机制。它允许进程在获取不到锁时睡眠等待,所以不能在中断上下文使用。

我个人习惯把信号量想象成一个“房间计数器”:

  • 计数信号量:允许多少个人同时进房间。
  • 互斥信号量:一次只允许一个人进房间(其实就是二值信号量)。

看个例子:

#include <linux/semaphore.h>

struct semaphore my_sem;

// 初始化
sema_init(&my_sem, 1);  // 初始值为1,互斥

// 获取信号量(可睡眠)
if (down_interruptible(&my_sem)) {
    // 被信号打断,返回非0
    return -ERESTARTSYS;
}

// 临界区代码
// ...

// 释放信号量
up(&my_sem);

我的建议:能用 down_interruptible() 就别用 down()。后者会让进程进入不可中断的睡眠,万一出问题,连kill都杀不掉。我曾经在调试时遇到过这种“僵尸进程”,只能重启机器。

4.3 自旋锁(Spinlock)

自旋锁跟信号量最大的区别是:拿不到锁就原地打转,不睡眠。

所以它适合用在:

  • 临界区非常短(几微秒)
  • 中断上下文(不能睡眠)
  • SMP环境下保护共享数据

但要注意,自旋锁在单核CPU上其实没什么用——它只是关闭了内核抢占。真正发挥作用是在多核系统上。

#include <linux/spinlock.h>

spinlock_t my_lock;

// 初始化
spin_lock_init(&my_lock);

// 获取自旋锁
spin_lock(&my_lock);

// 临界区(必须短!)
// ...

// 释放自旋锁
spin_unlock(&my_lock);

避坑指南:我曾经在自旋锁保护的临界区里调用了 kmalloc(GFP_KERNEL),结果系统直接死锁。为什么?因为 kmalloc 可能触发内存回收,导致进程睡眠,而自旋锁不允许睡眠。记住:自旋锁里不能调用任何可能睡眠的函数。

如果临界区既要在进程上下文访问,又要在中断上下文访问,那就得用 spin_lock_irqsave():

unsigned long flags;

spin_lock_irqsave(&my_lock, flags);
// 临界区
spin_unlock_irqrestore(&my_lock, flags);

这个函数会保存当前中断状态并关闭本地中断,防止中断处理程序跟你抢锁。

4.4 原子操作

有些场景其实不需要锁——比如对一个整数做加减。这时候用原子操作更轻量。

原子操作由硬件保证,一条指令完成读-改-写,不会被中断打断。

#include <linux/atomic.h>

atomic_t counter = ATOMIC_INIT(0);

// 原子加1
atomic_inc(&counter);

// 原子减1
atomic_dec(&counter);

// 读取当前值
int val = atomic_read(&counter);

// 原子加并返回新值
int new = atomic_add_return(5, &counter);

我一般在驱动里用原子操作做引用计数和标志位。比如记录设备被打开了多少次:

static atomic_t open_count = ATOMIC_INIT(0);

static int my_open(struct inode *inode, struct file *file) {
    if (atomic_inc_return(&open_count) > 1) {
        atomic_dec(&open_count);
        return -EBUSY;  // 只允许一个进程打开
    }
    return 0;
}

static int my_release(struct inode *inode, struct file *file) {
    atomic_dec(&open_count);
    return 0;
}

适用场景:原子操作只适合保护简单的整数或位操作。如果临界区逻辑复杂,还是老老实实用锁。

4.5 互斥体(Mutex)

互斥体是内核里最推荐的睡眠锁。它比信号量更严格,也更安全:

  • 同一时刻只能有一个任务持有
  • 谁持有谁释放,不能跨上下文
  • 不支持递归加锁
#include <linux/mutex.h>

struct mutex my_mutex;

// 初始化
mutex_init(&my_mutex);

// 获取互斥体
mutex_lock(&my_mutex);

// 临界区
// ...

// 释放互斥体
mutex_unlock(&my_mutex);

互斥体跟信号量比,优势在哪?

特性 互斥体(Mutex) 信号量(Semaphore)
语义 严格互斥 可计数
调试支持 有(可检测死锁) 无
性能 优化更好 相对较重
推荐度 高 低(新代码尽量用mutex)

我的习惯:新写的驱动,只要临界区允许睡眠,我首选互斥体。信号量现在基本只用在需要计数同步的场景,比如生产者-消费者模型。

4.6 如何选择?一张图说清楚

下面这张图是我自己总结的锁选择决策流程,每次写驱动前我都会过一遍:

并发控制机制选择流程图 并发控制机制选择决策图 临界区需要保护? 是否在中断上下文? 是 自旋锁 否 临界区是否极短? 是 自旋锁 否 是否保护简单整数? 是 原子操作 否 互斥体(Mutex) 信号量:仅用于计数同步场景

嗯,这张图基本覆盖了日常开发中90%的场景。剩下的10%,比如RCU(读-拷贝-更新)、顺序锁,属于高级用法,等后面章节再聊。

4.7 避坑总结

最后,我把这些年踩过的坑整理一下:

我曾经犯过的错:

  • 在自旋锁里调用 copy_to_user() —— 这个函数可能触发缺页异常导致睡眠,直接死锁。
  • 中断处理函数里用 mutex_lock() —— 互斥体不能用在中断上下文,应该用自旋锁。
  • 忘记用 spin_lock_irqsave() —— 进程上下文和中断上下文共享数据时,只用 spin_lock() 是不够的,中断随时可能打断你。

我的建议:写驱动时,先画清楚“谁在访问什么资源”。把并发路径理清了,再选锁。别一上来就加锁,加多了反而容易出问题。

好了,这一章的内容就到这里。并发控制是驱动开发的基石,理解透了,后面写复杂驱动才不会手忙脚乱。