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 如何选择?一张图说清楚
下面这张图是我自己总结的锁选择决策流程,每次写驱动前我都会过一遍:
嗯,这张图基本覆盖了日常开发中90%的场景。剩下的10%,比如RCU(读-拷贝-更新)、顺序锁,属于高级用法,等后面章节再聊。
4.7 避坑总结
最后,我把这些年踩过的坑整理一下:
我曾经犯过的错:
- 在自旋锁里调用
copy_to_user()—— 这个函数可能触发缺页异常导致睡眠,直接死锁。 - 中断处理函数里用
mutex_lock()—— 互斥体不能用在中断上下文,应该用自旋锁。 - 忘记用
spin_lock_irqsave()—— 进程上下文和中断上下文共享数据时,只用spin_lock()是不够的,中断随时可能打断你。
我的建议:写驱动时,先画清楚“谁在访问什么资源”。把并发路径理清了,再选锁。别一上来就加锁,加多了反而容易出问题。
好了,这一章的内容就到这里。并发控制是驱动开发的基石,理解透了,后面写复杂驱动才不会手忙脚乱。