Linux 设备驱动中必须解决的一个问题是多个进程对共享资源的并发访问,并发访问会导致竞态。
并发(concurrency)指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(race conditions)
一 产生并发的场合:
1. 对称多处理器(SMP)的多个 CPU
2. 单 CPU 内进程与抢占它的进程
3. 中断(硬中断、软中断、Tasklet、底半部)与进程之间
二 解决方法:
解决竞态问题的途径是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。
访问共享资源的代码区域称为临界区(critical sections),临界区需要以某种互斥机制加以保护。
中断屏蔽、原子操作、自旋锁和信号量等是 Linux 设备驱动中可采用的互斥途径
1. 中断屏蔽
local_irq_disable() //屏蔽中断
...
critical section //临界区
...
local_irq_enable() //开中断
local_irq_disable()和 local_irq_enable()都只能禁止和使能本 CPU 内的中断,因此,并不能解决 SMP 多 CPU 引发的竞态。
因此,单独使用中断屏蔽通常不是一种值得推荐的避免竞态的方法,它适宜与自旋锁联合使用。
2. 原子操作
Linux 内核提供了一系列函数来实现内核中的原子操作,这些函数又分为两类,分别针对位和整型变量进行原子操作。它们的共同点是在任何情况下操作都是原子的,内核代码可以安全地调用它们而不被打断。位和整型变量原子操作都依赖底层CPU 的原子操作来实现,因此所有这些函数都与 CPU 架构密切相关。
整型原子操作:
void atomic_add(int i, atomic_t *v); //原子变量增加 i
void atomic_sub(int i, atomic_t *v); //原子变量减少 i
void atomic_inc(atomic_t *v); //原子变量增加 1
void atomic_dec(atomic_t *v); //原子变量减少 1
位原子操作:
void set_bit(nr, void *addr);
void clear_bit(nr, void *addr);
void change_bit(nr, void *addr);
3.自旋锁
理解自旋锁最简单的方法是把它作为一个变量看待,该变量把一个临界区或者标记为“我当前在运行,请稍等一会”或者标记为“我当前不在运行,可以被使用”。
如果 A 执行单元首先进入例程,它将持有自旋锁;当 B 执行单元试图进入同一个例程时,将获知自旋锁已被持有,需等到 A 执行单元释放后才能进入。
A) 定义自旋锁
spinlock_t spin;
B) 初始化自旋锁
spin_lock_init(lock);该宏用于动态初始化自旋锁 lock
C) 获得自旋锁
spin_lock(lock)
该宏用于获得自旋锁 lock,如果能够立即获得锁,它就马上返回,否则,它将自旋在那里,直到该自旋锁的保持者释放;
spin_trylock(lock)
该宏尝试获得自旋锁 lock,如果能立即获得锁,它获得锁并返回真,否则立即返回假,实际上不再“在原地打转”;
D) 释放自旋锁
spin_unlock(lock)
该宏释放自旋锁 lock,它与 spin_trylock 或 spin_lock 配对使用。
对于自旋锁还有其他的三种变型:读写自旋锁,顺序自旋锁, RCU(Read-Copy Update,读-拷贝-更新),
4. 信号量
信号量(semaphore)是用于保护临界区的一种常用方法,它的使用方式和自旋锁类似。与自旋锁相同,只有得到信号量的进程才能执行临界区代码。
但是,与自旋锁不同的是,当获取不到信号量时,进程不会原地打转而是进入休眠等待状态。
A) 定义信号量
下列代码定义名称为 sem 的信号量。
struct semaphore sem;
B) 初始化信号量
void sema_init (struct semaphore *sem, int val);
该函数初始化信号量,并设置信号量 sem 的值为 val。尽管信号量可以被初始化为大于 1 的值从而成为一个计数信号量,但是它通常不被这样使用。
void init_MUTEX(struct semaphore *sem); // 在2.6.25以后的内核版本中,已经被废除了,需要使用sema_init()进行初始化,并同时引用#include <linux/semaphore.h>文件
该函数用于初始化一个用于互斥的信号量,它把信号量 sem 的值设置为 1,等同于sema_init (struct semaphore *sem, 1)。
C) 获取信号量
void down(struct semaphore * sem);
该函数用于获得信号量 sem,它会导致睡眠,因此不能在中断上下文使用。
int down_interruptible(struct semaphore * sem);
该函数功能与 down()类似,不同之处为,因为 down()而进入睡眠状态的进程不能被信号打断,而因为 down_interruptible()而进入睡眠状态的进程能被信号打断,信号也会导致该函数返回,这时候函数的返回值非 0。
int down_trylock(struct semaphore * sem);
该函数尝试获得信号量 sem,如果能够立刻获得,它就获得该信号量并返回 0,否则,返回非 0 值。它不会导致调用者睡眠,可以在中断上下文使用。
在使用 down_interruptible()获取信号量时,对返回值一般会进行检查,如果非 0,通常立即返回-ERESTARTSYS,如:
if (down_interruptible(&sem))
{
return - ERESTARTSYS;
}
D) 释放信号量
void up(struct semaphore * sem);
该函数释放信号量 sem,唤醒等待者。
三 自旋锁 vs 信号量选用原则
1. 当锁不能被获取时,使用信号量的开销是进程上下文切换时间 Tsw,使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定)Tcs,
若Tcs 比较小,应使用自旋锁,若 Tcs 很大,应使用信号量。
2. 信号量所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区。
因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。
3. 信号量存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在信号量和自旋锁之间只能选择自旋锁。
当然,如果一定要使用信号量,则只能通过 down_trylock()方式进行,不能获取就立即返回以避免阻塞。
四 完善globalmem驱动
现在回过来再看globalmem驱动的代码,就会发现在读写函数中,都有与用户空间进行交互的函数,可能会产生数据发生异常变化的问题,
所以需要有互斥手段来解决竞态问题,有因为在驱动中调用了copy_from_user()、copy_to_user()这些可能导致阻塞的函数,所以不能选用自旋锁,需要选择信号量。
修改后的代码如下,注意:需要在头文件中引用:#include <linux/semaphore.h>
<span style="font-size:14px;">/*globalmem 设备结构体*/
struct globalmem_dev
{
struct cdev cdev; /*cdev 结构体*/
unsigned char mem[GLOBALMEM_SIZE]; /*全局内存*/
struct semaphore sem; /* 并发控制用的信号量 -> 由于该驱动设备中包含了copy_from_user()、copy_to_user()这些可能导致阻塞的函数,所以不能选用自旋锁,而选择信号量 */
};</span>
读函数, 写函数, ioctl函数
<span style="font-size:14px;">/* ioctl 设备控制函数 */
static int globalmem_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
struct globalmem_dev *dev = filp->private_data;/*获得设备结构体指针*/
switch (cmd)
{
case MEM_CLEAR:
if (down_interruptible(&dev->sem)) //获得信号量,进程处于sleep状态,但是还可以响应中断
{
return - ERESTARTSYS;
}
memset(dev->mem, 0, GLOBALMEM_SIZE);
//内存空间清除完成后,释放信号量
up(&dev->sem); //释放信号量
printk(KERN_EMERG "globalmem is set to zero\n");
break;
default:
return - EINVAL;
}
return 0;
}
/*读函数*/
static ssize_t globalmem_read(struct file *filp, char __user *buf, size_t size, loff_t *ppos)
{
unsigned long p = *ppos;
unsigned int count = size;
int ret = 0;
struct globalmem_dev *dev = filp->private_data; /*获得设备结构体指针*/
/*分析和获取有效的写长度*/
if (p > GLOBALMEM_SIZE)
return count ? - ENXIO: 0;
if (count > GLOBALMEM_SIZE - p)
count = GLOBALMEM_SIZE - p;
if (down_interruptible(&dev->sem)) //获得信号量,进程处于sleep状态,但是还可以响应中断
{
return - ERESTARTSYS;
}
/*内核空间→用户空间*/
if (copy_to_user(buf, (void*)(dev->mem + p), count))
{
ret = - EFAULT;
}
else
{
*ppos += count;
ret = count;
printk(KERN_EMERG "read %d bytes(s) from %d\n", count, p);
}
//读取数据全部完成后,释放信号量
up(&dev->sem); //释放信号量
return ret;
}
/*写函数*/
static ssize_t globalmem_write(struct file *filp, const char __user *buf, size_t size, loff_t *ppos)
{
unsigned long p = *ppos;
unsigned int count = size;
int ret = 0;
struct globalmem_dev *dev = filp->private_data; /*获得设备结构体指针*/
/*分析和获取有效的写长度*/
if (p >= GLOBALMEM_SIZE)
return count ? - ENXIO: 0;
if (count > GLOBALMEM_SIZE - p)
count = GLOBALMEM_SIZE - p;
if (down_interruptible(&dev->sem)) //获得信号量,进程处于sleep状态,但是还可以响应中断
{
return - ERESTARTSYS;
}
/*用户空间→内核空间*/
if (copy_from_user(dev->mem + p, buf, count))
{
ret = - EFAULT;
}
else
{
*ppos += count;
ret = count;
printk(KERN_EMERG "written %d bytes(s) from %d\n", count, p);
}
//写入数据全部完成后,释放信号量
up(&dev->sem); //释放信号量
return ret;
}</span>
<span style="font-size:14px;">/*设备驱动模块加载函数*/
int globalmem_init(void)
{
//debug
printk(KERN_EMERG "%s*********Start*******%d \n", __FUNCTION__, __LINE__);
int result;
dev_t devno = MKDEV(globalmem_major, 0);
...
memset(globalmem_devp, 0, sizeof(struct globalmem_dev));
globalmem_setup_cdev(globalmem_devp, 0);
sema_init(&globalmem_devp->sem,1); /* 初始化信号量 */
return 0;
fail_malloc: unregister_chrdev_region(devno, 1);
return result;
}</span>
五 总结
并发和竞态广泛存在,中断屏蔽、原子操作、自旋锁和信号量都是解决并发问题的机制。中断屏蔽很少单独被使用,原子操作只能针对整数进行,因此自旋锁和信号量应用最为广泛。
自旋锁会导致死循环,锁定期间不允许阻塞,因此要求锁定的临界区小。信号量允许临界区阻塞,可以适用于临界区大的情况。
读写自旋锁和读写信号量分别是放宽了条件的自旋锁和信号量,它们允许多个执行单元对共享资源的并发读。