4. 设备驱动中的并发控制:自旋锁、互斥锁、信号量、原子操作在驱动中的应用场景
在 Linux 设备驱动开发中,并发访问是导致内核崩溃、数据损坏和死锁的头号隐患。多个进程、中断处理程序(上半部与下半部)、SMP 多核 CPU 同时访问共享资源时,必须使用同步机制来保证操作的原子性和数据一致性。本章深入剖析自旋锁、互斥锁、信号量和原子操作四大并发控制原语,明确各自的适用场景、性能特征与使用禁忌。
4.1 并发控制全景图
下图展示了 Linux 内核中四种主要并发控制机制在驱动中的典型应用层级与决策路径。
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,它能在运行时检测潜在的死锁、锁顺序反转和递归加锁。
============================================= 的警告信息,并标注可能的死锁路径。
下图展示了驱动中典型的死锁形成过程,以及 lockdep 的检测机制。
4.8 驱动移植中的并发适配检查清单
在将驱动从单核/非抢占内核移植到 SMP/抢占式内核时,必须逐项检查以下并发相关点:
- 全局变量和静态变量:所有可写全局/静态变量是否都有锁保护?
- 硬件寄存器访问:MMIO 读写是否在临界区内?是否使用了
readl_relaxed()/writel_relaxed()等带内存屏障的变体? - 中断处理程序:是否使用了
spin_lock_irqsave()而非spin_lock()? - tasklet / workqueue:与进程上下文共享的数据是否使用了
spin_lock_bh()? - ioctl 路径:用户空间传入的缓冲区指针是否在锁外进行
copy_from_user()? - 定时器回调:定时器函数运行在软中断上下文,不能使用互斥锁。
- 设备移除路径:在
remove()函数中释放锁时,确保没有其他 CPU 仍在临界区内。
本章详细介绍了 Linux 驱动开发中四种核心并发控制机制的原理、API、适用场景和移植注意事项。掌握这些原语是编写健壮、可移植驱动的基石。在实际工程中,应结合 lockdep 工具和代码审查,确保并发安全。