七、Linux驱动之并发控制

本节部分参考宋宝华的Linux设备驱动开发详解

1. 基本概念

1.1 Linux并发相关基础概念

    Linux设备驱动中必须要解决的一个问题是多个进程对共享的资源的并发访问,并发的访问会导致竞态。
    (1) 并发(concurrency):并发指的是多个执行单元同时、并发被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(race condition);
    (2) 竞态(race condition) :竞态简单的说就是两个或两个以上的进程同时访问一个资源,同时引起资源的错误;
    (3) 临界区(Critical Section):每个进程中访问临界资源的那段代码称为临界区;
    (4) 临界资源 :一次仅允许一个进程使用的资源称为临界资源;多道程序系统中存在许多进程,它们共享各种资源,然而有很多资源一次只能供一个进程使用;
    在宏观上并行或者真正意义上的并行(这里为什么是宏观意义的并行呢?我们应该知道“时间片”这个概念,微观上还是串行的,所以这里称为宏观上的并行),可能会导致竞争;类似两条十字交叉的道路上运行的车。当他们同一时刻要经过共同的资源(交叉点)的时候,如果没有交通信号灯,就可能出现混乱。在linux系统中也有可能存在这种情况。

1.2 解决竞态问题的途径

     解决竞态问题的途径最重要的是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。Linux 设备中提供了可采用的互斥途径来避免这种竞争。主要有原子操作,信号量,自旋锁。
    原子锁:原子操作不可能被其他的任务给调开,一切(包括中断),针对单个变量。
    自旋锁:使用忙等待锁来确保互斥锁的一种特别方法,针对是临界区。
    信号量:包括一个变量及对它进行的两个原语操作,此变量就称之为信号量,针对是临界区。

2. 并发处理详细操作

2.1 原子操作

    原子操作,顾名思义,就是说像原子一样不可再细分不可被中途打断。一个操作是原子操作,意思就是说这个操作是以原子的方式被执行,要一口气执行完,执行过程不能够被OS的其他行为打断,是一个整体的过程,在其执行过程中,OS的其它行为是插不进来的。原子操作可以保证对一个整型数据的修改是排他性的。Linux内核提供了一系列函数来实现内核中的原子操作,这些函数又分为两类,分别针对整型变量进行原子操作。

2.1.1 整型原子操作

(1) 设置原子变量的值

void atomic_set(atomic_t *v, int i); /* 设置原子变量的值为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); /* 原子变量增加1 */
void atomic_dec(atomic_t *v); /* 原子变量减少1 */

(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返回true,否则返回false。
(6) 操作并返回

int atomic_add_return(int i, atomic_t *v);
int atomic_sub_return(int i, atomic_t *v);
int atomic_inc_return(atomic_t *v);
int atomic_dec_return(atomic_t *v);

    上述操作对原子变量进行加/减和自增/自减操作,并返回新的值。

2.1.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);

    上述test_and_xxx_bit(nr, void*addr) 操作等同于执行test_bit(nr, void*addr) 后再执行xxx_bit(nr, void*addr)

2.1.3 原子操作示例代码

    第一步:在驱动程序中首先定义并初始化一个原子变量:

static atomic_t canopen=ATOMIC_INIT(1);  //定义原子变量canopen并初始化为1

    第二步:增加驱动程序里的open函数和close函数里对原子变量的操作:

static int test_open(struct inode *inode, struct file *file)
{  
    if (!atomic_dec_and_test(&canopen))    //对原子变量canopen执行自减操作后测试其是否为0
	{
	    atomic_inc(&canopen);    //对原子变量canopen执行加1操作
	    return -EBUSY;
	}
	return 0;
}
int test_close(struct inode *inode, struct file *file)
{	
    atomic_inc(&canopen);
    return 0;
}

    当第一个应用程序打开该驱动时,canopen为1,自减为0,atomic_dec_and_test返回真,取反为假,if条件不成立,打开驱动成功。当第二个应用程序想要再次打开该驱动时,canopen为0,自减为-1,atomic_dec_and_test返回假,取反为真,if条件成立,再对canopen加1操作,返回错误,打开驱动失败,这样就实现了同一时刻驱动只能有一个使用者。

2.2 自旋锁

    自旋锁(Spin Lock)是一种典型的对临界资源进行互斥访问的手段,其名称来源于它的工作方式。为了获得一个自旋锁,在某CPU上运行的代码需先执行一个原子操作,该操作测试并设置(Test-AndSet)某个内存变量。由于它是原子操作,所以在该操作完成之前其他执行单元不可能访问这个内存变量。如果测试结果表明锁已经空闲, 则程序获得这个自旋锁并继续执行; 如果测试结果表明锁仍被占用,程序将在一个小的循环内重复这个“测试并设置”操作,即进行所谓的“自旋”,通俗地说就是“在原地打转”。当自旋锁的持有者通过重置该变量释放这个自旋锁后,某个等待的“测试并设置”操作向其调用者报告锁已释放。

2.2.1 定义自旋锁

spinlock_t lock;

2.2.2 初始化自旋锁

spin_lock_init(lock);

2.2.3 获得自旋锁

spin_lock(lock);

    该宏用于获得自旋锁lock,如果能够立即获得锁,它就马上返回,否则,它将在那里自旋,直到该自旋锁的保持者释放。

2.2.4 释放自旋锁

spin_unlock(lock);

    该宏释放自旋锁lock, 它与spin_trylockspin_lock配对使用。

2.2.5 自旋锁使用方法

/* 定义一个自旋锁*/
spinlock_t lock;
spin_lock_init(&lock);
spin_lock (&lock) ; /* 获取自旋锁, 保护临界区 */
. . ./* 临界区*/
spin_unlock (&lock) ; /* 解锁*/

    尽管用了自旋锁可以保证临界区不受别的CPU和本CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候, 还可能受到中断和底半部(BH,稍后的章节会介绍)的影响。为了防止这种影响,就需要用到自旋锁的衍生。spin_lock() /spin_unlock()是自旋锁机制的基础,它们和关中断local_irq_disable()/开中断local_irq_enable() 、关底半部local_bh_disable()/开底半部local_bh_enable()、关中断并保存状态字local_irq_save()/开中断并恢复状态字local_irq_restore()结合就形成了整套自旋锁机制,关系如下:

spin_lock_irq() = spin_lock() + local_irq_disable()
spin_unlock_irq() = spin_unlock() + local_irq_enable()
spin_lock_irqsave() = spin_lock() + local_irq_save()
spin_unlock_irqrestore() = spin_unlock() + local_irq_restore()
spin_lock_bh() = spin_lock() + local_bh_disable()
spin_unlock_bh() = spin_unlock() + local_bh_enable()

    在多核编程的时候, 如果进程和中断可能访问同一片临界资源,我们一般需要在进程上下文中调用spin_lock_irqsave() /spin_unlock_irqrestore(),在中断上下文中调用spin_lock()/spin_unlock()。例如,在CPU0上,无论是进程上下文,还是中断上下文获得了自旋锁,此后,如果CPU1无论是进程上下文, 还是中断上下文, 想获得同一自旋锁,都必须忙等待,这避免一切核间并发的可能性。同时,由于每个核的进程上下文持有锁的时候用的是spin_lock_irqsave(),所以该核上的中断是不可能进入的,这避免了核内并发的可能性。
    驱动工程师应谨慎使用自旋锁, 而且在使用中还要特别注意如下几个问题:
    (1) 自旋锁实际上是忙等锁,当锁不可用时,CPU一直循环执行“测试并设置”该锁直到可用而取得该锁,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。因此,只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。当临界区很大,或有共享设备的时候,需要较长时间占用锁,使用自旋锁会降低系统的性能。
    (2) 自旋锁可能导致系统死锁。引发这个问题最常见的情况是递归使用一个自旋锁,即如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁,则该CPU将死锁。
    (3) 在自旋锁锁定期间不能调用可能引起进程调度的函数。如果进程获得自旋锁之后再阻塞,如调用copy_from_user() 、 copy_to_user() 、kmalloc() 和msleep()等函数,则可能导致内核的崩溃。
    (4) 在单核情况下编程的时候,也应该认为自己的CPU是多核的,驱动特别强调跨平台的概念。比如,在单CPU的情况下,若中断和进程可能访问同一临界区,进程里调用spin_lock_irqsave()是安全的,在中断里其实不调用spin_lock()也没有问题, 因为spin_lock_irqsave()可以保证这个CPU的中断服务程序不可能执行。但是,若CPU变多核,spin_lock_irqsave()不能屏蔽另外一个核的中断,所以另外一个核就可能造成并发问题。因此,无论如何,我们在中断服务程序里也应该调用spin_lock() 。

2.2.6 自旋锁示例代码

    第一步:在驱动程序中首先定义并初始化一个自旋锁:

spinlock_t lock;     //定义一个自旋锁
spin_lock_init(lock);    //初始化自旋锁

    第二步:增加驱动程序里的open函数和close函数里对自旋锁的操作:

int open_count = 0;/* 定义文件打开次数计数*/
static int test_open(struct inode *inode, struct file *file)
{  
    ...
    spinlock(&lock);
    if (open_count)      /* 如果已经打开*/
    {    
        spin_unlock(&lock);
        return -EBUSY;
    }
    open_count++;        /* 增加使用计数*/
    spin_unlock(&lock);
    ...
    return 0;
}

int test_close(struct inode *inode, struct file *file)
{	
    ...
    spinlock(&lock);
    open_count--;        /* 减少使用计数*/
    spin_unlock(&lock);
    return 0;
}

2.3 互斥体

2.3.1 定义互斥体

struct mutex my_mutex;

2.3.2 初始化互斥体

mutex_init(&my_mutex);

2.3.3 获取互斥体

void mutex_lock(struct mutex *lock);

    该函数用于获得mutex, 它会导致睡眠, 因此不能在中断上下文中使用。

int mutex_lock_interruptible(struct mutex *lock);

    该函数功能与mutex_lock()类似,不同之处为mutex_lock()进入睡眠状态的进程不能被信号打断,而mutex_lock_interruptible()进入睡眠状态的进程能被信号打断,信号也会导致该函数返回,这时候函数的返回值非0。

int mutex_trylock(struct mutex *lock);

    mutex_trylock()用于尝试获得mutex,获取不到mutex时不会引起进程睡眠。

2.3.4 释放互斥体

void mutex_unlock(struct mutex *lock);

2.3.5 使用互斥体方法

struct mutex my_mutex; /* 定义mutex */
mutex_init(&my_mutex); /* 初始化mutex */
mutex_lock(&my_mutex); /* 获取mutex */
... /* 临界资源*/
mutex_unlock(&my_mutex); /* 释放mutex */

2.3.6 互斥体与自旋锁的选择

    自旋锁互斥体都是解决互斥问题的基本手段,面对特定的情况,有如下3条原则:
    (1) 当锁不能被获取到时,使用互斥体的开销是进程上下文切换时间,使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定)。若临界区比较小,宜使用自旋锁,若临界区很大,应使用互斥体。
    (2) 互斥体所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区。因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。
    (3) 互斥体存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在互斥体和自旋锁之间只能选择自旋锁。当然,如果一定要使用互斥体,则只能通过mutex_trylock()方式进行,不能获取就立即返回以避免阻塞。   

2.4 信号量

    信号量作为定义临界区使用时,可以允许多个线程同时进入临界区;当获取不到信号量时, 进程不会原地打转而是进入休眠等待状态;信号量是操作系统中最典型的用于同步和互斥的手段,信号量的值可以是0、1或者n。

2.4.1 定义信号量

struct semaphore sem;    //定义名称为sem的信号量

2.4.2 初始化信号量

void sema_init(struct semaphore *sem, int val);    //该函数初始化信号量,并设置信号量sem的值为val。

2.4.3 获得信号量

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值。它不会导致调用者睡眠, 可以在中断上下文中使用。

2.3.4 释放信号量

void up(struct semaphore * sem);    //释放信号量sem, 唤醒等待者。

 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值