本期主题:
linux设备驱动并发
linux设备驱动并发
1.并发与竞态
并发是指多个执行单元同时、并行的被执行;
并发时,执行单元的共享资源的访问(硬件资源、软件变量等)会导致竞态;
例如两个进程都打开了串口设备,并且同时往串口发送缓存中写入不同的值,这样就会导致竞态。
2.并发的主要场景
1.对称多处理器(SMP)的多个CPU
SMP是一种共享存储的系统模型,特点是:多个CPU使用共同的系统总线,因此可以访问共同的外设和存储器。
因此 两个CPU之间的进程、两个CPU之间的中断都有并发的可能性;
2.单CPU内的进程与抢占进程
在单个CPU中,一个进程执行完成之后,有可能消耗完了自己的时间片,也可能被另外一个高优先级的进程给打断,高优先级进程与被打断的进程中之间有共同访问共享资源的可能,产生竞态。
3.中断与进程
中断可以打断正在执行的进程,所以如果中断服务程序也访问进程正在访问的共享资源,这样就有产生竞态的可能。
3.面对竞态的常用方法
1.中断屏蔽
在单CPU的范围内,可以使用中断屏蔽的方法来避免竞态,中断屏蔽可以使中断和进程之间的并发不再发生。
该方法的核心原理:
让CPU不再响应中断,对于ARM处理器而言,就是屏蔽 ARM CPSR寄存器的I位
;
但是这种方式同样存在一些问题:
- linux内核的进程调度也依赖于中断,所以在屏蔽中断后,应尽快执行完临界区代码;
- 只能屏蔽当前CPU的中断,不能解决SMP的多CPU并发,而且驱动编程一般要考虑代码的通用性,放在其他平台上跑的情况,中断屏蔽的方式放在SMP上就会有问题;
2.原子操作
原子操作可以保证对一个数据的修改是排他性的,特点:
- 原子操作具有排他性
- 原子操作依赖底层CPU的原子操作,与CPU架构密切相关,对于ARM处理器而言,底层使用LDREX和STREX指令
3.自旋锁
自旋锁(spin lock) 是一种典型的对临界资源进行互斥访问的手段,主要针对的是SMP以及单CPU但是内核可抢占的情况
,常用方法如下:
- 获取自旋锁的操作实际上是一个原子操作,这个原子操作是测试并设置某个内存变量;由于是原子操作,
在该操作完成之前,其他执行单元无法访问这个变量
; - 其他执行单元想获取自旋锁时,如果上述的 测试并设置的结果是空闲的,则代表此时锁已经空闲,那么该执行单元就会获取这个锁并继续执行;
- 如果上述测试结果是锁被占用了,则该执行单元就会在一个小的循环内重复这个测试并设置的操作,即
进行自旋,在原地打转
;
Linux中自旋锁的主要操作:
//1.定义自旋锁
spin_lock_t lock;
//2.初始化自旋锁
spin_lock_init(lock)
动态初始化自旋锁
//3.获得自旋锁
spin_lock(lock)
用于获得自旋锁,如果能立即获得锁就立马返回,否则,将在那里自锁,直到自旋锁的保持者释放
spin_trylock(lock)
尝试获得自旋锁,如果能理解获得锁就立马返回true,否则返回false,不再原地打转
//4.释放自旋锁
spin_unlock(lock)
//自旋锁的一般使用
spinlock_t lock;
spin_lock_init(&lock);
spin_lock(&lock); //获取自旋锁,保护临界区
/* 临界区代码 */
spin_unlock(&lock); //解锁
自旋锁的衍生:
- 自旋锁可以保证临界区不受别的CPU和本CPU的抢占进程打扰,但是还是有可能受到中断的影响,所以会将自旋锁和中断保护一起使用。
- spin_lock()/spin_unlock()的自旋锁机制 与 关中断并保存状态字 local_irq_save() / 开中断恢复状态字 local_irq_restore() 组合成自旋锁机制
- 我们在使用的过程中一般这样:在进程的上下文使用 spin_lock_irqsave()和 spin_unlock_irqrestore(),在中断上下文中使用 spin_lock()和 spin_unlock()
spin_lock_irqsave() = spin_lock() + local_irq_save()
spin_unlock_irqrestore() = spin_unlock() + local_irq_restore()
- 在进程上下文中使用 spin_lock_irqsave/restore,因为需要屏蔽调中断的影响
- 在中断上下文中使用 spin_lock/unlock
4.信号量
信号量(semaphore)是操作系统中典型的用于互斥和同步的手段,信号量的值可以是0、1甚至为n。信号量与操作系统的PV操作对应,具体可看这篇blog的介绍,UNIX环境编程——信号量与互斥量对比。
Linux中与信号量相关的操作主要有以下几种:
//1.定义初始化信号量
struct semaphore sem;
//2.初始化信号量
void sema_init(struct semaphore *sem, int val);
//3.获得信号量
void down(struct semaphore *sem);
//该函数用于获得信号量sem,如果没有获取到会让出cpu,导致睡眠,因此不能在中断的上下文中使用
int down_trylock(struct semaphore *sem);
//该函数尝试获取信号量sem,如果能立即获得,就获得该信号量并且返回0,否则返回非0,不会导致调用者
//睡眠,可以在中断上下文中使用
//4.释放信号量
void up(struct semaphore *sem);
//该函数释放信号量,唤醒等待者
1.信号量可以用作互斥,信号量保护临界区的方式与自旋锁类似,都是只有拿到信号量/自旋锁的进程才能够执行临界区的代码,但是不同的是,拿不到信号量的进程不会原地打转而是进入休眠等待。
2.信号量也可以用于作同步,进程P2执行down()等待信号量,进程P1执行up()释放信号量,典型的生产者/消费者
问题,可以用信号量来保证数据的同步;
5.互斥体
尽管信号量已经可以实现互斥的功能,但是Linux还有一个互斥体mutex的存在,这在linux中应用更为广泛。
常用的方式:
//1.定义mutex
struct mutex my_mutex;
//2.初始化mutex
mutex_init(&my_mutex);
//3.获取Mutex
mutex_lock(&my_mutex);
//4.临界区....
//5.释放
mutex_unlock(&my_mutex);
自旋锁和互斥体都是解决互斥问题的手段,两者属于不同层次上的互斥手段:
1.自旋锁更为底层,互斥体属于进程级别的互斥手段;
2.互斥体的原子性实现依赖于自旋锁;
重点: 可以总结一下,自旋锁和互斥体的选用原则:
- 当锁不能被获取时,使用互斥体的开销是进程的上下文切换,而自旋锁的开销是一直跑,等待获取自旋锁,所以从系统效率角度,如果临界区代码较小时,适合用自旋锁,很大时,用互斥体;
- 从系统安全的角度,如果保护的临界区中有阻塞操作,一定要用互斥体,因为进程阻塞会导致进程的切换,如果此时使用自旋锁,会导致死锁,因为切换出去的其他进程无法拿到自旋锁;
- 如果被保护的共享资源在中断的情况下使用,要用自旋锁,因为互斥体会导致睡眠,但是中断的响应又需要及时;