linux设备驱动中的并发控制

在linux设备驱动中必须解决的一个问题是多个进程对互斥资源的并发访问,并发的访问会导致竞态。

1.1 并发与竟态

并发指多个执行单元同时、并发的被执行,而并发的执行单元对共享资源的访问很容易导致竟态。在linux内核中,主要的竟态发生在以下几种情况。

1. 对称多处理器(SMP)的多个CPU

SMP是一种紧耦合、共享存储的系统模型,其特点是多个CPU使用共同的系统总线,因此可以访问共同的存储器和外设。

2. 单CPU内进程与抢占它的进程

在linux2.6以后的内核支持内核抢占调度,一个进程在内核执行的时候可能消耗完了自己的时间片,也可能被更高优先级的进程打断,进程与抢占它的进程访问共享资源的情况类似SMP的多个CPU。

3. 中断(硬中断、软中断、Tasklet、底半部)与进程之间

中断可以打断正在执行的进程,如果中断服务程序访问进程正在访问的资源,则静态也会发生。如果支持中断嵌套则高优先级的能够打断低优先级的中断,其也能引发竟态。linux 2.6.35之后,就取消了中断的嵌套。

解决竟态问题的途径是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元访问互斥资源时其他执行单元被禁止访问。访问共享资源的代码区域称为临界区,临界区需要被某种互斥机制加以保护。linux设备驱动中可采用的互斥途径有:中断屏蔽、原子操作、自旋锁、互斥体等。

2 编译乱序和执行乱序

理解linux内核锁机制,还需要理解编译器和处理器的特点。比如下面的一段代码,写端申请一个新的struct foo结构体并初始化其中的成员变量,之后把结构体的地址赋值给全局gp指针:
struct foo{
    int a;
    int b;
    int c;
};

struct foo *gp = NULL;

p = kmalloc(sizeof(*p),GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;
gp = p;
写端如果做如下处理,程序的运行可能不符合预期:
p = gp;
if(p != NULL)
    do_something_with(p->a,p->b,p->c);
有两种可能的原因会造成程序的出错,一种可能性是编译乱序、另一种可能性是执行乱序。
关于编译乱序,C语言顺序的 “p->a = 1; p->b = 2; p->c = 3; gp = p”的指令顺序可能是gp的赋值指令发生在a、b、c的赋值之前。现代的高性能编译器在目标码优化上都具备对指令进行乱序优化的能力。编译器可以对访存指令进行乱序,减少逻辑上不必要的访存,以及提高Cache命中率和CPU的Load/Store单元的工作效率。因此在打开编译器优化后,看到生成的汇编并没有严格的按照代码逻辑顺序,这是正常的情况。
解决编译乱序的问题,我们可以在代码中设置barrier()屏障,这个屏障可以阻挡编译器的优化。对于编译器而言设置屏障可以保证语句的顺序。

关于编译乱序的问题,C语言volatile关键字的作用比较弱,它最多的只是解决内存访问行为的合并,对C编译器而言,volatile是暗示除了当前的执行线索以外,其他的执行线索也可能改变某内存,所以其含义是“易变的”。另外,volatile也不具备保护临界资源的作用。
编译乱序是编译器的行为,而执行乱序则是处理器运行时的行为。执行乱序是指即便编译的二进制指令的顺序是按照“p->a = 1; p->b = 2; p->c = 3; gp = p”排放,在处理器上执行时,后发射的指令还是可能先执行完,这是处理器的乱序执行策略。高级的处理器可以根据自己缓存的组织特性,将访存指令重新排序执行。连续地址访问可能会先执行,因为这样缓存命中率高。有的还允许访存的非阻塞,即如果前面一条访存指令因为访存不命中,造成长延时的存储访问时,后面的访存指令可以先执行,以便从访存中取数。因此,即使从汇编上看顺序是正确的指令,其执行的顺序也是不可预知的。
对于大多数体系结构而言,尽管每隔CPU都是乱序执行的,但是这种乱序对于单核的程序的执行是不可见的,因为单个CPU在碰到依赖点的时候会等待,所以程序员可能感觉不到这个乱序的过程。但是这个依赖点等待的过程,在SMP处理器里面对于其他核是不可见的。
处理器为了解决多核间一个核的内存行为对另外一个核的可见的问题,引入了一些内存屏障的指令。ARM处理器的屏障指令包括:
DMB(数据内存屏障):在DMB之后的显式内存访问执行前,保证所有在DMB指令之前的内存访问完成;
DSB(数据同步屏障):等待所有在DSB指令之前的指令完成(位于此指令前所有显示内存访问均完成,位于此指令前的所有缓存、跳转预测和TLB维护操作全部完成);
ISB(指令同步屏障):Flush流水线,使得所有ISB之后执行的指令都从缓存或内存中获得

3. 中断屏蔽

在单CPU范围内避免静态的一种简单而有效的方式是进入临界区之前屏蔽系统的中断,但是在驱动程序编程中不值得推荐,驱动通常需要靠考虑平台特点而不假设自己在单核上运行。CPU一般都具备屏蔽中断和打开中断的功能,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生。具体而言,中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间也得以避免。对于ARM处理器而言,其底层的实现是屏蔽ARM CPSR的I位。

local_irq_disable()、ocal_irq_enable(),只能禁止和使能本CPU的中断。因此,不能解决SMP多CPU引发的竟态。单独使用中断屏蔽通常不是一种值得推荐的避免竟态的方法。
local_irq_save(flags)、local_irq_restore(flags),除了能够屏蔽中断外还能保存目前CPU的中断位信息,local_irq_restore(flags)的功能和local_irq_save(flags)的功能相反。对于ARM处理器就谁保存和恢复CPSR。
local_bh_disable()、local_bh_enable(),屏蔽和使能底半部中断。顶半部中断完成的一般是紧急的硬件操作,底半部中断执行的大部分耗时的操作并可以被新的中断打断。

4. 原子操作

原子操作可以保证对一个整形数据的修改是排他性的。linux内核提供了一系列的函数实现内核的原子操作,这些函数又分为两类,分别针对位和整型变量进行原子操作。

4.1 整型原子操作

1. 设置原子变量的值
void atomic_set(atomic_t *v,int i); /*设置原子变量v的值为i*/
atomic_t v = ATOMIC_INIT(0) ; /*定义元自变量v并初始化为0*/
2. 获取原子变量的值
atomic_read(atomic_t *v);  /*返回原子变量的值*/
3. 原子变量的加/减
void atomic_add(int i,atomic_t *v);  /*原子变量增加i*/
void atomic_sub(int i,atomic_t *v);  /*原子变量减少i*/
4. 原子变量自增/自减
void atomic_inc(atomic_t *v);
vois atomic_dec(atomic_t *v);
5. 操作并测试
int atomic_inc_and_test(atomic_t *v);
int atomic_dec_and_test(atomic_t *v);
int atomic_sub_and_test(int i,atomic_t *v);
上述操作对原子变量执行自增、自减和减操作后,测试其结果是否为0,为0返回真,非0返回假。
6. 操作并返回
int atomic_add_return(int i,atomic_t *v);
int atomic_inc_and_return(atomic_t *v);
int atomic_dec_and_return(atomic_t *v);
int atomic_sub_and_return(int i,atomic_t *v);
上述操作对原子变量执行自增、自减和减操作后,返回其操作结果。

4.2 位原子操作

1. 设置位
void set_bit(nr,void *addr);
将addr地址的第nr位设置为1。
2. 清除位
void clear_bit(nr,void *addr;
将addr地址的第nr位设置为0。
3. 改变位
void change_bit(nr,void *addr);
将addr地址的第nr位进行取反。
4. 测试位
test_bit(nr,void *addr);
返回addr地址的第nr位。
5. 测试并操作位
int test_and_set_bit(nr, void *addr);
int test_and_clear_bit(nr, void *addr);
int test_and_change_bit(nr, void *addr);

5. 自旋锁

自旋锁(spin lock)是一种典型对临界资源资源进行互斥访问的手段。自旋锁就是相当与对一个标志变量的测试,当一个进程持有该锁时就把该变量置为真,此时另外一个进程要申请该锁时检测到该变量为真则做空操作不停的检测该变量。当进程完成对互斥资源访问后则释放该锁(实际就是把该变量置为假),此时另外一个进程检测到该变量为假则停止空操作获得临界资源继续向下执行。所有进程对变量的操作都是原子的。

在ARM体系结构下,自旋锁的实现借助了ldrex指令、strex指令、ARM处理器内存屏障指令dmd、wfe指令和sev指令。既要保持排他性又要处理好内存屏障。

5.1 自旋锁的使用

Linux中自旋锁相关的操作主要有以下4中。
1. 定义自旋锁
spinlock_t lock;
2. 初始化自旋锁
spin_lock_init(lock); //该宏用于动态初始化自旋锁lock
3. 获取自旋锁
spin_lock(lock);
该宏用于获取自旋锁lock,如果能够立即获得该锁,则立即返回,否则,他将一直在哪里自旋,直到持有则释放。
spin_trylock(lock);
该宏用于获取自旋锁lock,如果能够立即获得,则立即获得并返回true,否则立即返回flase。
4. spin_unlock(lock);
该宏用于释放自旋锁lock。

自旋锁主要针对SMP或单CPU但内核支持抢占的情况,对于单CPU不支持内核抢占的系统,自旋锁将退化为空操作。在单CPU和内核可抢占的系统中,自旋锁持有期间内核的抢占将被禁止。在多核SMP的情况下,任何一个核拿到自旋锁,该核的抢占调度将被禁止,但是没有禁止另外一个核的抢占调度。
尽管用了自旋锁可以保证临界区不受别的CPU和本CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候,还可能受到中断和底半部中断的影响。为了防止这种影响,就产生了几种自旋锁的衍生版。关中断local_irq_disable()/开中断local_irq_enable(),关底半部中断local_bh_disable()/开底半部中断local_bh_enable(),关中断并保存状态字local_irq_save()/开中断并恢复状态字local_irq_restore(),这些函数是为了避免中断的突然进入对系统造成的伤害。

应该谨慎使用自旋锁并在使用时注意以下几个问题。
1. 自旋锁实际是忙等待,因此只有在占用锁极端的时间使用否则会降低系统的性能。
2. 自旋锁可能会造成系统的死锁。如一个已经获取自旋锁的CPU想要再次获取这个自旋锁。
3. 自旋锁锁定期间不能调用能够引起进程调度的函数。
4. 在单核编程时也应认为自己的CPU是多核的,驱动特别强调跨平台的概念。

5.2 读写自旋锁

读写自旋锁是比自旋锁颗粒度更小的锁机制,读写自选锁在读时多个进程能同时读,在写时最多只能有一个进程写。读写不能同时进行。
1. 定义和初始化读写自旋锁
rwlock_t my_rwlock;
rwlock_init(&my_rwlock); //动态初始化
2. 读锁定
void read_lock(rwlock_t *lock);
void read_lock_irqsave(rwlock_t *lock,unsigned int flags);
void read_lock_irq(rwlock_t *lock);
void read_lock_bh(rwlock_t *lock);
3. 读解锁
void read_unlock(rwlock_t *lock);
void read_unlock_irqsave(rwlock_t *lock);
void read_unlock_irq(rwlock_t *lock);
void read_lock_bh(rwlock_t *lock);
4. 写锁定
void write_lock(rwlock_t *lock);
void write_lock_irqsave(rwlock_t *lock,unsigned int flags);
void write_lock_irq(rwlock_t *lock);
void write_lock_bh(rwlock_t *lock);
int write_trylock(rwlock *lock);
5. 写解锁
void write_unlock(rwlock_t *lock);
void write_unlock_irqsave(rwlock_t *lock);
void write_unlock_irq(rwlock_t *lock);
void write_lock_bh(rwlock_t *lock);

5.3 顺序锁(写时能读,写写互斥)

顺序锁(seqlock)是对读写锁的一种优化,若使用顺序锁,读执行单元不会被写执行单元阻塞,即读执行单元再写执行单元对顺序锁保护的共享资源进行写操作时仍然可以继续读,也就是说读时不必等待写操作结束。写执行单元也不必等读执行单元结束时再去写。但是写写仍然是互斥的。如果都执行单元在执行时已经发生了写操作,那么读执行单元必须重新读这样才能确保数据的完整性。

在Linux内核中,写执行单元涉及的顺序锁操作。
1. 获取顺序锁
void write_seqlock(seqlock_t *sl);
int write_tryseqlock(seqlock_t *sl);
void write_seqlock_irqsave(lock,flags);
void write_seqlock_irq(lock);
void write_seqlock_bh(lock);
2. 释放顺序锁
void write_sequnlock(seqlock_t *sl);
void write_sequnlock_irqsave(lock,flags);
void write_sequnlock_irq(lock);
void write_sequnlock_bh(lock);

在Linux内核中,读执行单元涉及的顺序锁操作。
1. 读开始
unsigned read_seqbegin(const seqlock_t *s1);
read_seqbegin_irqsave(lock,flags);
读执行单元在对被顺序锁s1保护的共享资源进行访问前需要调用该函数,返回顺序锁s1的当前序号。
2. 重读
int read_seqretry(const seqlock_t *s1,unsigned iv);
read_seqretry_irqrestore(lock,iv,flags);
检查读期间是否有写操作,若有则重新读。

6 信号量

信号量是操作系统中最经典的用于同步和互斥的手段,信号量的值可以是0、1或N。信号量与操作系统中的经典概念PV操作对应。
P(S): 1. 如果信号量S的值大于零,该进程继续执行。
         2. 如果S的值为零,将该进程设置为等待状态,排入信号量的等待队列,知道V操作唤醒它。

Linux中与信号相关的操作主要有以下几种。
1. 定义信号量
struct semaphore sem;
2. 初始化信号量
void sema_init(struct semaphore *sem, int val); //初始化信号量,并设置sem的值。
3. 获得信号量
void down(struct semaphore *sem); //获取信号量,它会导致进程睡眠,因此不能在中断上下文中使用。
int down_interruptible(struct semaphore *sem);//进入睡眠的进程能够被中断打断,信号也会导致该函数返回,此时返回值为非0.
int down_trylock(struct semaphore *sem);//尝试获得信号量sem,如果能够立刻获得将立即获得并返回0,否则返回非0。他不会使调用者进程休眠,可以用于中断上下文。
4. 释放信号量
void up(struct semaphore *sem);//释放信号量,唤醒等待者。

7. 互斥体

1.定义并初始化互斥体
struct mutex my_mutex;  //定义互斥体
mutex_init(&my_nutex);  //初始化互斥体
2. 获取互斥体
void mutex_lock(struct mutex *lock); //将引起调用进程的睡眠
int mutex_lock_interruptible(struct mutex *lock); //调用进程不会睡眠
int mutex_trylock(struct mutex *lock); //尝试获取lock,获取不到不会引起调用进程的睡眠
3. 释放互斥体
void mutex_unlock(struct mutex *lock); 


互斥体和自旋锁属于不同层次上的互斥手段,前者依赖于后者。互斥体为了保证互斥结构存取的原子性,需要借助自旋锁来完成。所以自旋锁属于更底层的手段。

互斥体是进程级别,用于多个进程间对互斥资源的访问。代表进程来争夺资源,如果竞争失败,会发生进程上下文的切换,当前进程进入休眠状态,CPU将运行其他进程。进程切换的花销很大,只有当进程长时间占用资源时,使用互斥体比较好。当访问临界资源时间比较短时使用自旋锁比较好。

自旋锁和互斥体使用的三原则:
1. 当锁不能被获取时,使用互斥体的开销是进程上下文切换的时间,使用自旋锁的开销是等待获取自旋锁的时间。若临界资源比较小益于使用自旋锁,若临界资源比较大益于使用互斥体。 
2. 互斥体所保护的临界资源可能包含引起阻塞的代码,而自旋锁绝对要避免用来保护包括这样代码的临界资源。因为阻塞意味着要进行进程的切换,如果进程被切换出去,另一个进程企图获取本自旋锁,死锁就会发生。
3. 互斥体存在进程上下文,如果要保护的共享资源要在中断或者软中断情况下使用,则互斥体和自旋锁之间只能选择自旋锁。如果一定要使用互斥体则要通过mutex_trylock(),不能获取就立即返回不能阻塞进程。

 8. 完成量

完成量用于一个执行单元等待另一个执行单元结束。

1. 定义完成量
struct completion my_completion;
2. 初始化完成量
init_completion(&my_completion);
reinit_completion(&my_completion);
3. 等待完成量
void wait_for_completion(struct completion *c) ;
4. 唤醒完成量
void completion(struct completion *c); //只唤醒一个等待的完成量
void completion_all(struct completion *c) ; //释放所有等待同一个完成量的执行单元







你可能感兴趣的:(linux设备驱动中的并发控制,linux驱动程序设计)