4. 设备驱动中的并发控制:自旋锁、互斥锁、信号量、原子操作在驱动中的应用场景

在 Linux 设备驱动开发中,并发访问是导致内核崩溃、数据损坏和死锁的头号隐患。多个进程、中断处理程序(上半部与下半部)、SMP 多核 CPU 同时访问共享资源时,必须使用同步机制来保证操作的原子性和数据一致性。本章深入剖析自旋锁、互斥锁、信号量和原子操作四大并发控制原语,明确各自的适用场景、性能特征与使用禁忌。

核心原则:选择同步机制时,必须考虑上下文(进程上下文 vs 中断上下文)、持有时间(短 vs 长)、睡眠需求(允许 vs 不允许)以及实时性要求。

4.1 并发控制全景图

下图展示了 Linux 内核中四种主要并发控制机制在驱动中的典型应用层级与决策路径。

Linux 驱动并发控制机制决策树 共享资源访问 中断上下文 进程上下文 自旋锁 (spin_lock) 原子操作 (atomic_t) 互斥锁 (mutex) 信号量 (semaphore) 自旋锁 (可睡眠场景慎用) 决策要点: • 中断上下文 → 只能使用自旋锁或原子操作(不能睡眠) • 进程上下文 → 优先使用互斥锁(持有时间长、允许睡眠)

4.2 自旋锁(Spinlock)

自旋锁是最轻量级的锁机制,适用于短临界区和中断上下文。当锁被持有时,等待的 CPU 会原地循环(自旋)直到锁释放,不会引起进程调度。

4.2.1 核心 API 与变体

API 变体 适用场景
spin_lock() 普通自旋锁 进程上下文,无中断竞争
spin_lock_irq() 关闭本地中断 + 自旋锁 进程上下文与中断处理程序共享数据
spin_lock_irqsave() 保存中断状态 + 关闭中断 + 自旋锁 嵌套中断或不确定中断状态时使用
spin_lock_bh() 关闭软中断 + 自旋锁 进程上下文与 tasklet/软中断共享数据

4.2.2 典型驱动应用场景

  • 硬件寄存器保护:多个 CPU 同时操作同一组 MMIO 寄存器时,使用自旋锁保证寄存器读写原子性。
  • 中断处理程序与进程上下文共享:如网卡驱动中,中断处理程序更新接收描述符环,进程上下文(如 ioctl)读取统计信息。
  • 全局标志位或链表操作:对设备状态标志、等待队列头、工作队列等轻量级数据结构进行保护。
自旋锁使用禁忌:临界区不能包含可能引起睡眠的操作(如 kmalloc(GFP_KERNEL)、copy_from_user()、mutex_lock())。自旋锁持有时间应控制在几十微秒以内,否则浪费 CPU 周期。

4.2.3 代码示例:中断上下文使用自旋锁

struct my_device {
    spinlock_t lock;
    int shared_counter;
    int irq;
};

/* 中断处理程序 */
static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
    struct my_device *dev = (struct my_device *)dev_id;
    unsigned long flags;

    spin_lock_irqsave(&dev->lock, flags);
    dev->shared_counter++;  /* 临界区:更新共享计数器 */
    spin_unlock_irqrestore(&dev->lock, flags);

    return IRQ_HANDLED;
}

/* 进程上下文读取 */
static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off)
{
    struct my_device *dev = filp->private_data;
    unsigned long flags;
    int val;

    spin_lock_irqsave(&dev->lock, flags);
    val = dev->shared_counter;
    spin_unlock_irqrestore(&dev->lock, flags);

    /* 将值拷贝到用户空间(此时已解锁,可以安全睡眠) */
    if (copy_to_user(buf, &val, sizeof(val)))
        return -EFAULT;
    return sizeof(val);
}

4.3 互斥锁(Mutex)

互斥锁是进程上下文中最推荐的睡眠锁。当锁被持有时,等待的进程会进入睡眠状态,让出 CPU,因此持有时间可以较长(毫秒级甚至更久)。

4.3.1 互斥锁 vs 自旋锁对比

特性 互斥锁 (mutex) 自旋锁 (spinlock)
等待行为 进程睡眠,让出 CPU CPU 原地自旋,不调度
中断上下文使用 禁止(会触发内核调度异常) 允许(需配合 irq 变体)
持有时间 无严格限制(可长可短) 必须极短(微秒级)
递归加锁 不支持(同一进程重复加锁会死锁) 不支持
优先级继承 支持(防止优先级反转) 不支持

4.3.2 典型驱动应用场景

  • 设备打开/关闭控制:确保同一时刻只有一个进程打开设备节点进行配置。
  • 大块数据传输:DMA 缓冲区映射、SG 列表构建等耗时操作。
  • 用户空间数据拷贝:copy_from_user() / copy_to_user() 前后保护共享数据结构。
  • 硬件初始化序列:设备复位、固件加载等需要等待硬件响应的操作。

4.3.3 代码示例:互斥锁保护设备打开计数

struct my_device {
    struct mutex open_lock;
    int open_count;
};

static int my_open(struct inode *inode, struct file *filp)
{
    struct my_device *dev = container_of(inode->i_cdev, struct my_device, cdev);

    mutex_lock(&dev->open_lock);
    if (dev->open_count > 0) {
        mutex_unlock(&dev->open_lock);
        return -EBUSY;  /* 设备已被占用 */
    }
    dev->open_count++;
    mutex_unlock(&dev->open_lock);

    filp->private_data = dev;
    return 0;
}

static int my_release(struct inode *inode, struct file *filp)
{
    struct my_device *dev = filp->private_data;

    mutex_lock(&dev->open_lock);
    dev->open_count--;
    mutex_unlock(&dev->open_lock);
    return 0;
}

4.4 信号量(Semaphore)

信号量是一种更通用的同步原语,允许多个进程同时访问有限数量的资源(计数信号量),或用于生产者-消费者模型中的通知机制。在 Linux 内核中,信号量已逐渐被互斥锁和完成量取代,但在某些场景下仍有不可替代的作用。

4.4.1 信号量类型与 API

类型 初始化 行为
二值信号量 sema_init(&sem, 1) 等同于互斥锁(但无优先级继承)
计数信号量 sema_init(&sem, N) 允许最多 N 个进程同时进入临界区
互斥信号量 init_MUTEX(&sem)(旧 API) 已废弃,建议使用 mutex

4.4.2 典型驱动应用场景

  • 有限硬件资源池:如硬件加密引擎只有 4 个通道,使用计数信号量限制并发访问数。
  • 生产者-消费者同步:驱动中的环形缓冲区,生产者(中断处理程序)通过 up() 唤醒消费者(内核线程)。
  • 驱动初始化顺序控制:多个子模块需要按特定顺序完成初始化时,使用信号量做同步屏障。
经验建议:新驱动开发优先使用 mutex 而非 semaphore,除非明确需要计数功能或与旧代码兼容。互斥锁在调试、性能、优先级继承方面都优于二值信号量。

4.4.3 代码示例:计数信号量控制硬件通道

struct hw_accelerator {
    struct semaphore chan_sem;
    int channels[4];  /* 4 个硬件通道 */
};

/* 申请一个硬件通道 */
int hw_chan_acquire(struct hw_accelerator *acc)
{
    /* 如果无可用通道,进程会在此睡眠 */
    if (down_interruptible(&acc->chan_sem))
        return -ERESTARTSYS;  /* 被信号中断 */

    /* 查找空闲通道(临界区) */
    for (int i = 0; i < 4; i++) {
        if (acc->channels[i] == 0) {
            acc->channels[i] = 1;
            return i;  /* 返回通道号 */
        }
    }
    /* 理论上不会到达这里 */
    up(&acc->chan_sem);
    return -ENOENT;
}

/* 释放硬件通道 */
void hw_chan_release(struct hw_accelerator *acc, int chan_id)
{
    acc->channels[chan_id] = 0;
    up(&acc->chan_sem);  /* 唤醒等待的进程 */
}

4.5 原子操作(Atomic Operations)

原子操作是最轻量级的并发保护手段,不需要锁结构,直接由 CPU 指令保证操作的不可分割性。适用于对整数、位标志等简单数据类型的操作。

4.5.1 原子操作分类

类别 API 示例 说明
原子整数 atomic_t 32 位原子整数操作
原子长整数 atomic64_t 64 位原子整数操作(ARM64/x86_64)
原子位操作 set_bit(), clear_bit(), test_and_set_bit() 对内存中的位进行原子置位/清除/测试

4.5.2 典型驱动应用场景

  • 引用计数:设备模块使用计数、缓冲区引用计数,使用 atomic_inc() / atomic_dec_and_test()。
  • 设备状态标志:使用 set_bit() 和 test_bit() 管理设备打开、暂停、错误等状态。
  • 统计计数器:中断次数、错误计数、数据包计数等性能统计,无需锁保护。
  • 无锁环形缓冲区:生产者-消费者模型中,使用原子操作更新读写指针。

4.5.3 代码示例:原子操作实现引用计数

struct my_device {
    atomic_t refcount;
    /* 其他成员 */
};

struct my_device *my_device_create(void)
{
    struct my_device *dev = kzalloc(sizeof(*dev), GFP_KERNEL);
    if (!dev)
        return ERR_PTR(-ENOMEM);
    atomic_set(&dev->refcount, 1);  /* 初始引用计数为 1 */
    return dev;
}

struct my_device *my_device_get(struct my_device *dev)
{
    if (dev)
        atomic_inc(&dev->refcount);  /* 增加引用计数 */
    return dev;
}

void my_device_put(struct my_device *dev)
{
    if (dev && atomic_dec_and_test(&dev->refcount)) {
        /* 引用计数降为 0,释放设备 */
        kfree(dev);
    }
}

4.6 四种机制综合对比与选型指南

下表从多个维度对比四种并发控制机制,帮助开发者在实际驱动移植中快速做出正确选择。

维度 自旋锁 互斥锁 信号量 原子操作
上下文限制 中断/进程均可 仅进程上下文 仅进程上下文 中断/进程均可
是否睡眠 否(自旋) 是(睡眠) 是(睡眠) 否(CPU 指令)
临界区大小 极短(< 25 μs) 无限制 无限制 单条指令
适用数据类型 任意共享数据 任意共享数据 资源计数/同步 整数/位标志
性能开销 低(无调度) 中(有调度) 中(有调度) 极低
调试难度 高(死锁难排查) 中(lockdep 工具) 高(无优先级继承) 低
驱动移植实战口诀:
中断上下文用自旋,进程上下文用互斥;
短小精悍用自旋,长时操作用互斥;
简单计数用原子,资源池控用信号量。

4.7 死锁预防与调试技巧

并发控制使用不当会导致死锁,这是驱动开发中最棘手的 bug 之一。以下是驱动移植中必须遵守的死锁预防规则:

  • 锁顺序规则:如果必须获取多个锁,所有代码路径必须按照相同的顺序获取,否则可能形成循环等待。
  • 避免嵌套锁:尽量设计为单锁保护,减少锁嵌套层级。
  • 中断上下文锁保护:在中断处理程序中获取锁时,必须使用 spin_lock_irqsave(),防止进程上下文持有锁时被中断打断导致死锁。
  • 使用 lockdep:内核配置 CONFIG_PROVE_LOCKING 开启 lockdep,它能在运行时检测潜在的死锁、锁顺序反转和递归加锁。
常见死锁场景:进程 A 持有锁 L1 并等待锁 L2,进程 B 持有锁 L2 并等待锁 L1。两个进程互相等待,永远无法继续。lockdep 会在内核日志中输出类似 ============================================= 的警告信息,并标注可能的死锁路径。

下图展示了驱动中典型的死锁形成过程,以及 lockdep 的检测机制。

死锁形成与 lockdep 检测示意图 进程 A 进程 B 持有锁 L1 持有锁 L2 等待 L2 等待 L1 lockdep 检测到锁依赖循环 L1 → L2 → L1 形成环形等待,输出死锁警告

4.8 驱动移植中的并发适配检查清单

在将驱动从单核/非抢占内核移植到 SMP/抢占式内核时,必须逐项检查以下并发相关点:

  1. 全局变量和静态变量:所有可写全局/静态变量是否都有锁保护?
  2. 硬件寄存器访问:MMIO 读写是否在临界区内?是否使用了 readl_relaxed() / writel_relaxed() 等带内存屏障的变体?
  3. 中断处理程序:是否使用了 spin_lock_irqsave() 而非 spin_lock()?
  4. tasklet / workqueue:与进程上下文共享的数据是否使用了 spin_lock_bh()?
  5. ioctl 路径:用户空间传入的缓冲区指针是否在锁外进行 copy_from_user()?
  6. 定时器回调:定时器函数运行在软中断上下文,不能使用互斥锁。
  7. 设备移除路径:在 remove() 函数中释放锁时,确保没有其他 CPU 仍在临界区内。
移植经验:从 UP(单处理器)移植到 SMP 时,最隐蔽的 bug 是“看起来不需要锁”的变量。例如,一个只被中断处理程序修改、被进程上下文读取的变量,在 UP 上可能不会出问题,但在 SMP 上会因为缓存一致性导致进程读到过期的值。必须使用自旋锁或原子操作来保证可见性。

本章详细介绍了 Linux 驱动开发中四种核心并发控制机制的原理、API、适用场景和移植注意事项。掌握这些原语是编写健壮、可移植驱动的基石。在实际工程中,应结合 lockdep 工具和代码审查,确保并发安全。