linux驱动开发(7)-互斥与同步

Linux内核为设备驱动程序等内核模块提供的互斥与同步的内核机制。如果运行的系统中自始至终只有一个执行路径,那么无须考虑互斥与同步的问题,然而不幸的是,现代的Linux系统不只支持多进程而且支持多处理器,在这样的环境下,当多个执行路径并发执行时确保对共享资源的访问安全是驱动程序员不得不面对的问题。概括地说,互斥是指对资源的排他性访问,而同步则要对进程执行的先后顺序做出妥善的安排。因为程序的并发执行而导致的竞态是Linux内核中一个非常复杂的方面。对于设备的驱动程序开发者而言,熟悉Linux内核提供的并发互斥的处理机制相当重要。所谓竞态,简而言之,就是多个执行路径有可能对同一资源进行操作时可能导致的资源数据紊乱的行为。我们把对共享的资源进行访问的代码片段称为临界区(critical section)​,而把导致出现多个执行路径的因素称为并发源。

我们首先考察Linux系统中并发执行的来源,然后逐一讨论内核为保证对资源的互斥访问所提供的机制的原理以及各自的应用场景。最后讨论Linux内核为保证各个执行路径之间的先后顺序所提供的同步机制。大部分的篇幅都将用来讨论与互斥相关的东西。

并发的来源

当我们说并发时,是指可能导致对共享资源的访问出现竞争状态的若干执行路径,不一定是指严格的时间意义上的并发执行。Linux系统下并发的来源主要有:中断处理路径 :当系统正在执行当前进程时,发生了中断,中断处理函数和被中断的进程之间形成的并发,在单处理器中,虽然中断处理函数的执行路径与被中断的进程之间不是真正严格意义上的并发,然而中断处理函数和被中断进程之间却可能形成竞态。软中断的执行也可归结到这种类型的并发中。调度器的可抢占性 :在单处理器上,因为调度器的可抢占特性,导致的进程与进程之间的并发。这种行为非常类似多处理器系统上进程间的并发。多处理器的并发执行:多处理器系统上进程与进程之间是严格意义上的并发,每个处理器都可独自调度运行一个进程,在同一时刻有多个进程在同时运行。

local_irq_enable与local_irq_disable

在单处理器不可抢占系统中,使用local_irq_enable与local_irq_disable是消除异步并发源的有效方式,虽然驱动程序中应该避免使用这两个宏​,但是在spinlock等互斥机制中常常用到这两个宏,所以在此对它们进行介绍。local_irq_enable宏用来打开本地处理器的中断,而local_irq_disable则正好相反,用来关闭处理器的中断。这两个宏的定义如下:

c 复制代码
<include/linux/irqflags.h>
#define local_irq_enable() \
    do { trace_hardirqs_on(); raw_local_irq_enable(); } while (0)
#define local_irq_disable() \
    do { raw_local_irq_disable(); trace_hardirqs_off(); } while (0)

trace_hardirqs_on()和trace_hardirqs_off()用做调试,这里重点关注raw_local_irq_enable()和raw_local_irq_disable(),这两个宏的具体实现都依赖于处理器体系架构,不同处理器有不同的指令来启用或者关闭处理器响应外部中断的能力,比如在x86平台上,会最终利用sti和cli指令来分别设置和清除x86处理器中的FLAGS插图寄存器的IF标志,这样处理器就可以响应或者不响应外部的中断。ARM平台则使用CPSIE指令。在单处理器不可抢占系统中,如果某段代码要访问某共享资源,那么在进入临界区前使用local_irq_disable来关闭中断,这样在临界区中可保证系统不会出现异步并发源,访问完共享数据在出临界区时,再调用local_irq_enable来启用中断。local_irq_enable与local_irq_disable还有一种变体,是local_irq_save与local_irq_restore宏,定义如下:

c 复制代码
<include/linux/irqflags.h>
#define local_irq_save(flags)              \
    do{                       \
        typecheck(unsigned long,flags);   \
        raw_local_irq_save(flags);       \
        trace_hardirqs_off();           \
    } while (0)
#define local_irq_restore(flags)            \
    do{                       \
        typecheck(unsigned long,flags);   \
        if (raw_irqs_disabled_flags(flags)) { \
            raw_local_irq_restore(flags); \
            trace_hardirqs_off();       \
        }else{                \
            trace_hardirqs_on();       \
            raw_local_irq_restore(flags); \
        }                     \
    } while (0)

这两个宏相对于local_irq_enable与local_irq_disable最大的不同在于,local_irq_save会在关闭中断前,将处理器当前的标志位保存在一个unsigned long flags中,在调用local_irq_restore的时候,再将保存的flags恢复到处理器的FLAGS寄存器中。这样做的目的是,防止在一个中断关闭的环境中因为调用local_irq_disable与local_irq_enable将之前的中断响应状态破坏掉。在单处理器不可抢占系统中,使用local_irq_enable与local_irq_disable及其变体来对共享数据保护是种简单而有效的方法。但在使用时应该注意,因为local_irq_enable与local_irq_disable是通过关中断的方式进行互斥保护,所以必须确保处于两者之间的代码执行时间不能太长,否则将影响到系统的性能。

自旋锁

设计自旋锁的最初目的是在多处理器 系统中提供对共享数据的保护,其背后的核心思想是:设置一个在多处理器之间共享的全局变量锁V,并定义当V=1时为上锁状态,V=0为解锁状态。如果处理器A上的代码要进入临界区,它要先读取V的值,判断其是否为0,如果V≠0表明有其他处理器上的代码正在对共享数据进行访问,此时处理器A进入忙等待即自旋状态 ,如果V=0表明当前没有其他处理器上的代码进入临界区,此时处理器A可以访问该资源,它先把V置1(自旋锁的上锁状态)​,然后进入临界区,访问完毕离开临界区时将V置0(自旋锁的解锁状态)​。上述自旋锁的设计思想在用具体代码实现时的关键之处在于,必须确保处理器A"读取V,判断V的值与更新V"这一操作序列是个原子操作(atomic operation)​。所谓原子操作,简单地说就是执行这个操作的指令序列在处理器上执行时等同于单条指令,也即该指令序列在执行时是不可分割的。

spin_lock

不同的处理器上有不同的指令用以实现上述的原子操作,所以spin_lock的相关代码在不同体系架构上有不同的实现,下面以ARM处理器上的实现为例,分析spin_lock的行为。下面的讨论先以多处理器为主,然后再讨论spin_lock及其变体在单处理器上的演进。在给出实际源码细节之前。下面是Linux源码中提供给设备驱动程序等内核模块使用的spin_lock接口函数的定义:

c 复制代码
<include/linux/spinlock.h>
static inline void spin_lock(spinlock_t *lock)
{
    raw_spin_lock(&lock->rlock);
}

代码中的数据结构spinlock_t,就是前面提到的在多处理器之间共享的自旋锁在现实源码中的具体实现,实际上它就是个volatile unsigned int型变量:

c 复制代码
<include/linux/spinlock_types.h>
typedef struct raw_spinlock {
    volatile unsigned int  raw_lock;
} raw_spinlock_t;
typedef struct spinlock {
    union {
        struct raw_spinlock  rlock;
    };
} spinlock_t;

spin_lock函数中调用的raw_spin_lock是个宏,其实现是处理器相关的,对于ARM处理器而言,最终展开为

c 复制代码
static inline void raw_spin_lock (raw_spinlock_t *lock)
{
    preempt_disable();
    do_raw_spin_lock(lock)
}

函数首先调用preempt_disable宏,后者在定义了CONFIG_PREEMPT,也即在支持内核可抢占的调度系统中时,将关闭调度器的可抢占特性。在没有定义CONFIG_PREEMPT时, preempt_disable是个空定义,什么也不做。

真正的上锁操作发生在后面的do_raw_spin_lock函数中,、为什么raw_spin_lock要先调用preempt_disable来关闭系统的可抢占性。在一个打开了CONFIG_PREEMPT特性的Linux系统中,一个在内核态执行的路径也有可能被切换出处理器,典型地,比如当前进程正在内核态执行某一系统调用时,发生了一个外部中断,当中断处理函数返回时,因为内核的可抢占性,此时将会出现一个调度点,如果CPU的运行队列中出现了一个比当前被中断进程优先级更高的进程,那么被中断的进程将会被换出处理器,即便此时它正运行在内核态。单处理器上的这种因为内核的可抢占性所导致的两个不同进程并发执行的情形,非常类似于SMP系统上运行在不同处理器上的进程之间的并发,因此为了保护共享的资源不会受到破坏,必须在进入临界区前关闭内核的可抢占性。因为Linux内核源码试图统一自旋锁的接口代码,即不论是单处理器还是多处理器,不论内核是否配置了可抢占特性,提供给外部模块使用的相关自旋锁代码都只有一份,所以可以看到在上述的raw_spin_lock函数中加入了内核可抢占性相关的代码,即便是在没有配置内核可抢占的系统上,外部模块也都统一使用相同的spin_lock和spin_unlock接口函数。

函数接着调用do_raw_spin_lock开始真正的上锁操作(展开的嵌入汇编代码前加了行号标志L,下同)​:

c 复制代码
static inline void do_raw_spin_lock (raw_spinlock_t *lock)
{
    unsigned long tmp;
    __asm__ __volatile__(
    L1  "1:  ldrex %0,[%1]\n"
    L2  "teq %0,#0\n"
    L3  "strexeq   %0,%2,[%1]\n"
    L4  "teqeq    %0,#0\n"
    L5  "bne  1b"
        : "=&r" (tmp)
        : "r" (&lock-> raw_lock), "r" (1)
        : "cc");
    smp_mb();
}

do_raw_spin_lock函数中嵌入的汇编代码段是ARM处理器上实现自旋锁的核心代码,它通过使用ARM处理器上专门用以实现互斥访问的指令ldrex和strex来达到原子操作的目的:

● "ldrex %0, %1"相当于"tmp = lock->raw_lock",即读取自旋锁V的初始状态,放在临时变量tmp中。● "teq %0, #0"判断V(tmp变量)是否为0,如果不为0,表明此时自旋锁处于上锁状态,代码执行"bne 1b"指令,开始进入忙等待:不停地到标号1处读取自旋锁的状态,并判断是否为0。● "strexeq %0, %2, %1"这条指令是说,如果V=0(自旋锁处于解锁的状态)​,说明可以进入临界区,那么就用常量1来更新V的值,并把更新操作执行的结果放到变量tmp中。● "teqeq %0, #0"用来判断上一条指令对V的更新操作其结果tmp是否为0,如果是0则表明更新V的操作成功,此时V=1,代码可以进入临界区。如果tmp≠0,则表明更新V的操作没有成功,代码执行"bne 1b"指令进入忙等待。这里之所以要执行"teqeq %0, #0",正是要利用ldrex和strex指令来达成原子操作的目的。**strex操作具有排他性,如果同时有多个CPU尝试调用strex,只有一个会成功。**只有成功修改了互斥锁的CPU才能继续往下执行临界区,而其他的则只能继续忙等待。假设系统中有两个处理器A和B,其上运行的代码现在都通过调用spin_lock试图进入临界区。开始的时候,自旋锁V=0处于解锁状态,注意这里是真正地并发执行。当处理器A执行完L1处的指令,尚未开始执行L2时,处理器B开始执行L1,等到处理器A执行完L2准备执行L3时,处理器B执行完L1。这样会发生什么情况呢?此时在处理器A和B看来, V都是0(因为处理器B执行完L1时,处理器A还没有执行L3,因此V还没有被更新)​,这意味着它们都将以为自己可以成功获得锁而进入临界区,所以接下来它们都将试图去更新V为1。谁先更新V并不重要。strex和ldrex加入了对共享内存互斥访问的支持。在处理器A和B都使用L1处的ldrex来访问自旋锁V之后,在执行到L4时将导致只有其中一个处理器可以成功执行L4,也即成功更新V为1, tmp=0。另一个处理器将不会完成对V的更新动作,对它而言tmp=1,意味着更新动作失败,这样它将不得不执行L5进入自旋状态。如此就可以保证对自旋锁V的"读取---检测---更新"操作序列的原子性。

与spin_lock相对的是spin_unlock函数,这是一个应该在离开临界区时调用的函数,用来释放此前获得的自旋锁。其外部接口定义如下:

c 复制代码
<include/linux/spinlock.h>
static inline void spin_unlock(spinlock_t *lock)
{
    raw_spin_unlock(&lock->rlock);
}
static inline void raw_spin_unlock (raw_spinlock_t * lock)
{
    do_raw_spin_unlock(lock);
    preempt_enable();
}

函数先调用do_raw_spin_unlock做实际的解锁操作,然后调用preempt_enable()函数打开内核可抢占性,对于没有定义CONFIG_PREEMPT的系统,该宏是个空定义。do_raw_spin_unlock函数在ARM处理器上的代码如下:

c 复制代码
static inline void do_raw_spin_unlock(raw_spinlock_t * lock)
{
    smp_mb();
    __asm__ __volatile__(
    "str  %1,[%0]\n"
    :
    : "r" (&lock->lock), "r" (0)
    : "cc");
}

解锁操作比获得锁的操作要相对简单,只需更新锁变量为0即可,在ARM平台上利用单条指令str就可以完成该任务,所以代码非常简单,直接用str指令将自旋锁的状态更新为0,即解锁状态。

相关推荐
lolo大魔王8 小时前
Linux 文件系统超全面详解(原理、结构、挂载、分区、inode、日志、管理命令)
linux·运维·服务器
磊 子9 小时前
详细讲解一下epoll
linux·io·epoll·io多路复用
printfLILEI10 小时前
php中的类与对象以及反序列化
linux·开发语言·php
zyl8372110 小时前
Docker 使用手册
运维·docker·容器
古月方枘Fry11 小时前
MGRE实验
运维·服务器
叠叠乐11 小时前
redmi k90 pro max 强解BL,刷海外rom, 并刷入sukisu ultra
linux
stolentime11 小时前
FreeDomain 本地开发环境快速搭建指南
运维·服务器·网络
xiaoye-duck12 小时前
《Linux系统编程》Linux 进程间通信之管道基础解析:从匿名管道原理到基于管道的进程池实现
linux
z2005093012 小时前
【Linux学习】Linux中的进程程序替换
linux·服务器·学习