linux驱动开发:驱动中的并发控制

发布于:2023-01-01 ⋅ 阅读:(179) ⋅ 点赞:(0)

目录

一种典型的竞态

内核中的并发

中断屏蔽

原子变量

自旋锁

读写锁

顺序锁

信号量

互斥量


一种典型的竞态

如果内核中有多条执行路径都要访问同一个资源,那么可能会导致数据的相互覆盖,并造成紊乱,我们称为竞态。造成竞态的根本原因就是内核中的代码对共享资源产生了并发的访问。下面我们来看一个典型的竞态例子:

假设整型变量i是驱动代码中的一个全局变量,在某个阶段执行了i++操作,而在中断服务程序也执行了i++操作那么会如何?

ldr r1,[r0]
add r1,r1,#1
str r1,[r0]

i++指令在汇编代码中可以分为上面三行汇编代码,假设执行完第一行代码这时候产生了一个硬件中断,去执行中断处理程序,进行了自加操作,将i的值变成6,中断程序返回,将寄存器的值恢复,r1寄存器的值还是刚刚取出的5,然后自加变成6,再存入变量i所在的内存,本来两次i++,结果应该为7,但现在变成了6,造成数据紊乱。

内核中的并发

1.硬件中断

2.软中断和tasklet

3.抢占内核的多进程环境

4.普通的多进程环境

5.多处理器或多核cpu

中断屏蔽

再访问共享资源之前先将中断屏蔽,然后再访问共享资源,等共享资源访问完成后再重新使能中断就能避免这种竞态的产生。但是中断屏蔽到中断重新使能之间的这段代码不宜过长,否则中断屏蔽的时间过长,将会影响系统的性能。

unsigned long flags;

local_irq_save(flags);
i++;
local_irq_restore(flags);

原子变量

如果一个变量的操作是原子性的,即不能再被分割,类似在汇编代码上也只要一条汇编指令就能完成,那么这样的变量访问就根本不需要考虑并发带来的影响,因此,内核专门提供了一种数据类型

atomic_t,用它来定义的变量为原子变量。

typedef struct{
    int counter;
}atomic_t;

原子操作是指在执行过程中不会被别的代路径所中断的操作。
 
//设置值
void atomic_set(atomic_t *v, int i);  
atomic_t v = ATOMIC_INIT(0);       

  

//原子变量自增/自减
void atomic_inc(atomic_t *v);   //+1
void atomic_dec(atomic_t *v);   //-1 

//操作并测试 (自增自减后测试是否为0)
int atomic_dec_and_test(atomic_t *v); 
int atomic_inc_and_test(atomic_t *v); 

虽然原子变量使用方便,但其本质是一个整型变量,对于非整型变量(如整个结构)就不能使用这一套方法来操作。

自旋锁

在访问共享资源之前,首先要获得自旋锁,访问完资源后解锁。其他内核执行路径如果没有竞争到锁,只能等待,所以自旋锁是一种忙等锁。

自旋锁是一种典型的对临近资源进行互斥访问的手段,使用频率高,能够适用于中断中。

头文件   linux/spinlock.h

使用自旋锁的步骤如下:

//1.定义
spinlock_t lock;     

        

//2.初始化自旋锁,使用之前必须要初始化
spin_lock_init(&lock);           

     

//3.加锁
void spin_lock(spinlock_t *lock);        //获取不到锁,原地打转
int spin_trylock(spinlock_t *lock);    //获取不到锁 立即返回,返回值为0表示成功获得自旋锁

//4.解锁
void spin_unlock(spinlock_t *lock); 

关于自旋锁还有一些重要特性和使用注意事项:

1.获得自旋锁的临界代码执行时间不宜过长,因为是忙等锁,如果时间过长,就意味着其他想要获得锁的内核执行路径会进行长时间的等待,影响系统工作效率。

2.在获得锁的期间,不能调用可能会引起进程切换的函数,因为这会增加持锁的时间,导致其他想要获取锁的代码进行更长时间的等待,更糟糕的情况是,如果新调度的进程也要获取同样的自旋锁,那么会导致死锁。

3.自旋锁是不可递归的,获得自旋锁后不能再获得,否则会因为等待一个不能获得的锁而将自己锁死

4.自旋锁可以用于中断上下文中,因为它不会引起进程的切换。

5.如果中断中也要访问共享资源,则在非中断处理代码中访问共享资源之前应该先禁止中断再获取自旋锁

看一段自旋锁使用代码:

int my_open (struct inode *pi, struct file *pf);
int my_close(struct inode *pi, struct file *pf);
static struct cdev mycdev; //字符设备

static struct file_operations myfops={
	.open = my_open,
	.release = my_close,
};
int my_open (struct inode *pi, struct file *pf)
{
	spin_lock(&lock);
	if(n==0){
		n++;
		spin_unlock(&lock);
		return 0;
	}
	else{
		spin_unlock(&lock);
		return -1;
	}
	
}
int my_close(struct inode *pi, struct file *pf)
{
	spin_lock(&lock);
	n--;
	spin_unlock(&lock);
	return 0;
}
static int mod_init(void)
{
	//与内核相关
	int ret;
	//1.分配设备号

	//2.初始化字符设备cdev
	
	//3.注册到内核
	
	spin_lock_init(&lock);
	
	//与硬件相关

}

static void mod_exit(void)
{
	。。。。
}

//3.注册
module_init(mod_init);
module_exit(mod_exit);

//4.模块信息(许可证)
MODULE_LICENSE("GPL");

读写锁

在并发的方式中,有三种并发,分别为,读-读,读-写,写-写,很显然,一般的资源的读操作并不会修改它的值,因此读和读之间是完全可以全允许并发的,但是如果使用了自旋锁,读操作也会被加锁,阻止另一个读操作,为了提高并发效率,内核提供一种允许读和读并发的锁,叫读写锁

rwlock_t lock;//定义锁

rwlock_init(&lock);//初始化读写锁

read_lock(&lock)//获取读锁

read_trylock(&lock)//获取读锁,没有获取到立即返回

read_unlock(&lock)//解开读锁

write_lock(&lock)//获取写锁

write_trylock(&lock)//获取写锁,没有获取到立即返回

write_unlock(&lock)//解开写锁

读写锁的使用和自旋锁一样也需要定义,初始化,加锁和解锁的过程。只是改变变量的值需要先获取写锁,值改变完成后再解除写锁,读操作用读锁,这样,当一个内核路径在获取变量的值时,如果有另一条执行路径也要获取变量的值,则读锁可以正常获取,但如果有一个写在进行,那么不管是写锁还是读锁都不能获取,只有当写锁解锁之后才行。

顺序锁

顺序锁更进一步,允许读和写之间的并发。为了实现这一需求,顺序锁在读时不上锁,也就意味着在读的期间允许写,但是在读之前需要读取一个顺序值,读操作完成后,再次读取顺序值,如果两者相等,说明在读的过程中没有发生过写操作,否则重新读取。当然这意味着,写操作要上锁,并且要更新顺序值。

数据类型:seqlock_t

typedef struct{

        strcut seqcount seqcount;//顺序值

        spinlock_t lock;//顺序锁

}seqlock_t;

seqlock_init(seqlock_t *sl);//初始化顺序锁

unsigned read_seqbegin(const seqlock_t *sl)//读之前获取顺序值,函数返回顺序值

unsigned read_seqretry(const seqlock_t *sl,unsigned start)//读之后验证顺序值是否发生变化,返回1表示需要重读,返回0表示读成功

write_seqlock(seqlock_t *sl);写之前加锁

write_sequnlock(seqlock_t *sl);写之后解锁

来看个例子吧:

int i=5;
unsigned long flags;
/****定义锁***/
seqlock_t lock;
/*****初始化*****/
seqlock_init(&lock);

int v;
unsigned start;
do{
/******读之前先获取顺序值*****************/
    start=read_seqbegin(&lock);
    v=i;
/******读完之后检测顺序值是否发生变化,如果是重读*****/
}while(read_seqretry(&lock,start));

/*******写之前获取顺序锁**************/
write_seqlock_irqsave(&lock,flags);
i++;
/*******写完释放锁*******************/
write_sequnlock_irqrrestore(&lock,flags);

信号量

前面所讨论的锁机制都有一个不好的弊端,那就是在锁获得期间不能调用调度器,即不能引起进程切换,但是内核中很多函数都可能出发对调度器 的调用,对于自旋锁这种,当临界代码段执行的时间比较长时,就会极大降低系统效率,所以内核提供了一种叫信号量的机制来取消这种限制,信号量也是用于保护临界资源的一种方法,区分与自旋锁,信号量获取不到时不会原地打转,而是进入休眠等待。

数据类型定义:

struct semaphore{

        raw_spinlock_t   lock;

        unsigned int        count;//记录信号量资源情况

        struct list_head   wait_list;

};

注:

count为0时信号量就不能被获取,这说明信号量可以被多个进程所持有

//定义
struct semaphore sem;

//初始化
void  sema_init(struct semaphore *sem, int val);

//获取
void  down(struct semaphore * sem); //信号量的值减1,当信号量值不为0时,可以立即获取信号量


int  down_interruptible(struct semaphore * sem); //可以被中断 ctrl + c
int  down_trylock(struct semaphore * sem);       //获取不到,立即返回

//释放
void up(struct semaphore * sem);//信号量值加1,如果有进程等待信号量,则唤醒这些进程

对于信号量的特点及其他的一些使用注意事项总结:

1.信号量可以被多个进程同时拥有,当给信号量赋值为1,信号量也称为互斥信号量,可以用来互斥。

2.如果不能获得信号量,则进程休眠,调度其他的进程执行,不会进行等待。

3.因为获取信号量可能会引起进程切换,所以不能在中断上下文中,如果要用只能使用down_trylock,不过在中断上下文中可以使用up释放信号量,从而唤醒其他进程。

4.持有信号量期间可以调用调度器,但需要注意是否会产生死锁。

5.信号量开销大,在不违背自旋锁的使用规则下,应该优先自旋锁。

下面看一段信号量的代码:

/******定义信号量******/
struct semaphore sem;
/******初始化信号量,赋值为1,用于互斥**/
sema_init(&sem,1);
/****获取信号量,如果被信号唤醒,则返回****/
if(down_interruptible(&sem))
    return -ERESTARTSYS;
/**对共享资源进行访问****/
xxxxx;
/****访问完成后,释放信号量*/
up(&sem);

互斥量

信号量还有一个缺点,在获取信号量的代码中,只要信号量为0,则进程马上休眠,但一般不会等待太长时间,信号量就可以获得,那么信号量的操作就要经历使进程先休眠再被唤醒的漫长过程。为了更智能化,内核提供了一种专门用于互斥的高效率信号量,也就是互斥量/互斥体。

//定义
struct mutex my_mutex;

//初始化

mutex_init(&my_mutex);

//获取互斥体
void inline _ _sched  mutex_lock(struct mutex *lock);  //获取不到锁,休眠

//释放互斥体
void __sched    mutex_unlock(struct mutex *lock);//访问共享资源结束后,释放互斥量
 

注意:

1.要在同一上下文对互斥量进行上锁和解锁,比如不能在读进程中上锁,也不能在写进程中解锁。

2.互斥量的上锁不能递归。

3.当持有互斥量时,不能退出进程

4.不能用于中断上下文

5.持有互斥量期间,可以调用会引起进程切换的函数。


网站公告

今日签到

点亮在社区的每一天
去签到