Linux设备驱动的并发控制(原子操作、自旋锁、信号量)
并发与竞态
并发 指的是多“用户”单元访问同一片共享资源(硬件资源和软件资源的全局变量、静态变量、共享存储区等),并不一定指的是时间上的并发执行。
竞态 指的是多个执行路径可能对同一资源进行操作时可能导致的资源数据紊乱的行为。竞态一般需要两个条件:[1] 存在共享资源、[2] 对共享资源存在竞争访问关系。
Linux系统并发产生的原因
多线程并发访问中断处理抢占式并发访问SMP(多核)核间并发访问
多线程并发访问
Linux跟同RTOS一样,是多任务(线程)的系统(对于单核CPU来说,多任务处理是“宏观并行,微观串行”,即在一定时间段内的程序处理可看作并行。),多线程访问是并发产生的基本原因。
中断处理
中断可以打断正在执行的进程,如果中断服务程序访问进程正在访问的资源,则并发产生。
抢占式并发式访问
Linux内核在2.6版本之后,支持抢占式处理,即调度程序可以在任意时刻抢占正在运行的线程,从而运行其他的线程。
SMP(多核)核间并发访问
SMP 是一种紧耦合、共享存储的系统模型,它的特点是多个CPU使用共同的系统总线,因此可以访问共同的外设和存储器。
在上述的并发产生的原因中,SMP是真正意义的并行,其他的都是在单核上的“宏观并行,微观串行”,但其引发的竞争问题类似。
解决竞态问题的途径是保证共享资源的互斥访问,互斥访问指的是一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。访问共享资源的代码区称为临界区(Critical Sections),代码临界区需要被加以某种互斥机制加以保护。Linux设备驱动中常见的互斥机制有:中断屏蔽、原子操作、自旋锁、信号量和互斥体等。
中断屏蔽
在单CPU处理器中避免竞态的一种简单且有效的方法是进入临界区之前屏蔽系统的中断,CPU一般具备屏蔽中断和打开中断的功能,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生。由于Linux内核的进程调度等操作都依赖于中断来实现,所以中断屏蔽将避免中断与进程之间的并发、内核抢占进程之间的并发也将避免。
中断屏蔽的使用方法:
local_irq_disable() /* 关中断 */
critical section /* 临界区 */
local_irq_enable() /* 开中断 */
中断屏蔽底层的实现跟CPU架构密切相关,对于ARM处理器,底层为屏蔽ARM CPSR寄存器的I位。对于RISC-V处理器,其底层实现是屏蔽STATUS寄存器的IE位。
\arch\arm\include\asm\irqflags.h
static inline void arch_local_irq_enable(void)
{
asm volatile(
" cpsie i @ arch_local_irq_enable"
:
:
: "memory", "cc");
}
\arch\riscv\include\asm\irqflags.h
static inline void arch_local_irq_disable(void)
{
csr_clear(CSR_STATUS, SR_IE);
}
原子操作
原子操作简介
在化学中“原子”被定义为“化学反应不可再分的基本微粒”。原子操作可理解为不可拆分的操作。原子操作可以保证指令以原子的方式执行–执行过程不被打断。原子操作用于执行轻量级、仅执行一次的操作,原子操作可以确保操作的串行化,不再需要锁进行并发访问保护。
例 :当前存在两个线程,线程A用于给变量a赋值为5,线程B用于给变量a赋值为10,高级语言通过编译会产生如下汇编语句,两线程执行顺序如下图所示:


通过上述两图可看出,在没有进行原子操作赋值时,线程B会在线程A执行的中间时刻更改相应寄存器的值,这会导致线程A的赋值错误,从而产生竞态错误。当加入原子操作赋值后,线程B的赋值会在线程A赋值并存储后进行,消除了竞态错误。
原子操作相关常用API函数
原子操作的相关实现与CPU架构息息相关,对于RISC-V处理器而言底层使用LD和SD,但是在Linux内核中提供了一系列函数来实现内核中的原子操作,用户使用原子操作只需要调用相应的函数即可完成。
Linux内核对原子操作定义了使用 atomic_t 的结构体来完成整形数据的原子操作。
\include\linux\types.h
typedef struct {
int counter;
} atomic_t;
/* 64位原子变量结构体 */
#ifdef CONFIG_64BIT
typedef struct {
s64 counter;
} atomic64_t;
#endif
| 函数 | 描述 |
|---|---|
| ATOMIC_INIT(i) | 定义原子变量的时候对其进行初始化 |
| atomic_read(const atomic_t *v) | 获取原子变量的值 |
| atomic_set(atomic_t *v, int i) | 向v写入值i |
| atomic_add(int i, atomic_t *v) | 给v加i |
| atomic_sub(int i, atomic_t *v) | 给v减i |
| atomic_inc(atomic_t *v) | 给v自加1 |
| atomic_dec(atomic_t *v) | 给v自减1 |
| atomic_add_return(int i, atomic_t *v) | 给v加i并返回v的值 |
| atomic_sub_return(int i, atomic_t *v) | 给v减i并返回v的值 |
| atomic_inc_return(atomic_t *v) | 给v自加1并返回v的值 |
| atomic_dec_return(atomic_t *v) | 给v自减1并返回v的值 |
| atomic_sub_and_test(int i, atomic_t *v) | v减i,如果结果为0返回真,否则返回假 |
| atomic_dec_and_test(atomic_t *v) | v自减1,如果结果为0返回真,否则返回假 |
| atomic_inc_and_test(atomic_t *v) | v自加1,如果结果为0返回真,否则返回假 |
| atomic_add_negative(int i, atomic_t *v) | v加i,如果结果为0返回真,否则返回假 |
| 函数 | 描述 |
|---|---|
| set_bit(int nr, volatile unsigned long *addr) | 设置地址addr中数据的第nr位 |
| clear_bit(int nr, volatile unsigned long *addr) | 清除地址addr中数据的第nr位 |
| change_bit(int nr, volatile unsigned long *addr) | 改变地址addr中数据的第nr位 |
| test_and_set_bit(int nr, volatile unsigned long *addr) | 将addr地址的数据的第nr位设置为1,并返回nr位原来的值 |
| test_and_clear_bit(int nr, volatile unsigned long *addr) | 将addr地址的数据的第nr位清除为0,并返回nr位原来的值 |
| test_and_change_bit(int nr, volatile unsigned long *addr) | 将addr地址的数据的第nr位翻转,并返回nr位原来的值 |
注意:不得自行使用 +、- 等等操作原子操作数
自旋锁
自旋锁简介
原子操作只能针对整型变量或位进行保护,当其他类型的数据需要保证原子性时便会用到 自旋锁。自旋锁(Spin Lock)是一种典型的对临界资源进行互斥访问的手段,其名称来源于它的工作方式。
当某个线程需要访问某个公共资源时首先需要获得相应的锁,这个锁只能被一个线程所持有,当这个线程不释放持有的锁时,其他线程将不能够使用此锁,并且想获得此锁的线程将会处于忙循环-旋转-等待状态,并不会进入休眠或去做其他处理。Linux内核使用spinlock_t结构体表示自旋锁,结构体定义如下:
\include\linux\spinlock_types.h
typedef struct spinlock {
union {
struct raw_spinlock rlock;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
# define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map))
struct {
u8 __padding[LOCK_PADSIZE];
struct lockdep_map dep_map;
};
#endif
};
} spinlock_t;
自旋锁常用API函数
| 函数 | 描述 |
|---|---|
| DEFINE_SPINLOCK(x) | 定义并初始化一个自旋锁变量 |
| spin_lock_init(spinlock_t *lock) | 初始化自旋锁 |
| spin_lock(spinlock_t *lock) | 获取指定自旋锁,加锁 |
| spin_unlock(spinlock_t *lock) | 释放指定自旋锁 |
| spin_trylock(spinlock_t *lock) | 尝试获取指定自旋锁,如果没有获取到,返回0 |
| spin_is_locked(spinlock_t *lock) | 检查指定的自旋锁是否被获取,如果没有返回非0,否则返回0 |
上述自旋锁API函数用于线程之间的并发访问,当自旋锁上锁至解锁中间,产生中断,中断函数也想访问共享资源,此时便需要禁用本地中断,否则将会产生死锁现象。

例:如上图所示,线程A正运行,并获取了lock锁,当线程A运行至functionA函数的时候中断产生,中断抢走CPU使用权。在中断函数中,也需要获取lock锁,此时由于线程A没有释放,这里中断将会一直自旋,等待锁有效,但中断线程执行完成前,线程A永远不会执行,就会进入死锁状态,线程A被中断信号打断,中断需要lock锁才能继续处理。
| 函数 | 描述 |
|---|---|
| spin_lock_irq(spinlock_t *lock) | 禁止本地中断,并获取自旋锁 |
| spin_unlock_irq(spinlock_t *lock) | 激活本地中断,并释放自旋锁 |
| spin_lock_irqsave(spinlock_t *lock, unsigned long f) | 保存中断状态,禁止本地中断,获取自旋锁 |
| spin_lock_irqsave(spinlock_t *lock, unsigned long f) | 将中断状态恢复至以前状态,激活本地中断,释放自旋锁 |
注意 :
- 自旋锁是不可递归的。
- 适用于 SMP 或支持抢占的单 CPU 下线程之间的并发访问,也就是用于线程与线程之间,被自旋锁保护的临界区一定不能调用任何能够引起睡眠和阻塞的 API 函数,否则的话可能会导致死锁现象的发生。
其他类型锁
- 读写自旋锁
读写自旋锁为读和写操作提供了不同的锁,一次只能允许一个写操作,也就是只能一个线程持有写锁,而且不能进行读操作。当没有写操作时允许一个或多个线程持有读锁,可以进行并发的读操作。Linux内核使用rwlock_t结构体表示读写锁,结构体定义如下:
\include\linux\rwlock_types.h
typedef struct {
arch_rwlock_t raw_lock;
#ifdef CONFIG_DEBUG_SPINLOCK
unsigned int magic, owner_cpu;
void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} rwlock_t;
读写锁操作API函数分为两部分,一部分时读锁函数,另一部分是写锁函数,如下表所示:
| 函数 | 描述 |
|---|---|
| DEFINE_RWLOCK(x) | 定义并初始化读写锁 |
| rwlock_init(lock) | 初始化读写锁 |
| read_lock(lock) | 获取读锁 |
| read_unlock(lock) | 释放读锁 |
| read_lock_irq(lock) | 禁止本地中断,并获取读锁 |
| read_unlock_irq(lock) | 打开本地中断,并释放读锁 |
| read_lock_irqsave(lock, flags) | 保存中断状态,禁止本地中断,并获取读锁 |
| read_unlock_irqrestore(lock, flags) | 将中断状态恢复到以前的状态,并激活本地中断,释放读锁 |
| read_lock_bh(lock) | 关闭下半部,并获取读锁 |
| read_unlock_bh(lock) | 打开下半部,并释放读锁 |
| write_lock(lock) | 获取写锁 |
| write_unlock(lock) | 释放写锁 |
| write_lock_irq(lock) | 禁止本地中断,并获取写锁 |
| write_unlock_irq(lock) | 打开本地中断,并释放写锁 |
| write_lock_irqsave(lock) | 打开中断状态,禁止本地中断,并获取写锁 |
| write_unlock_irqrestore(lock, flags) | 将中断状态恢复到以前的状态,并且激活本地中断,释放读锁 |
| write_unlock_bh(lock) | 关闭下半部,并获取读锁 |
| write_unlock_irqrestore(lock, flags) | 打开下半部,并释放读锁 |
- 顺序锁
顺序锁是对读写锁的一种优化,读写锁在使用时读操作和写操作不能同时进行。若使用顺序锁,读执行单元不会被写执行单元阻塞,读执行单元在写执行单元对被顺序锁保护的共享资源进行保护的共享资源进行写操作时仍然可以进行读,而不必等待写执行单元完成写操作,写执行单元不需要等待所有读执行单元完成读操作才去进行写操作。但是写执行单元与写执行单元之间仍是互斥的关系,即如果有写执行单元在进行写操作,其他执行单元必须自旋在那,直到写执行单元释放顺序锁。Linux内核使用seqlock_t结构体表示顺序锁,结构体定义如下:
\linux\seqlock.h
typedef struct seqcount {
unsigned sequence;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} seqcount_t;
| 函数 | 描述 |
|---|---|
| DEFINE_SEQLOCK(sl) | 定义并初始化顺序锁 |
| seqlock_init(sl) | 初始化顺序锁 |
| write_seqlock(seqlock_t *sl) | 获取写顺序锁 |
| write_sequnlock(seqlock_t *sl) | 释放写顺序锁 |
| write_seqlock_irq(seqlock_t *sl) | 禁止本地中断,并获取顺序锁 |
| write_sequnlock_irq(seqlock_t *sl) | 打开本地中断,并释放写顺序锁 |
| write_seqlock_irqsave(lock, flags) | 保存中断状态,禁止本地终端并获取写顺序锁 |
| write_sequnlock_irqrestore(seqlock_t *sl, unsigned long flags) | 将中断状态恢复到以前的状态,并激活本地中断,释放顺序锁 |
| write_seqlock_bh(seqlock_t *sl) | 关闭下半部,并获取写读锁 |
| write_sequnlock_bh(seqlock_t *sl) | 打开下半部,并释放写读锁 |
| read_seqbegin(const seqlock_t *sl) | 读单元访问共享资源时调用此函数,此函数返回顺序锁的顺序号 |
| read_seqretry(const seqlock_t *sl, unsigned start) | 读结束以后调用此函数检查在读的过程中有没有对资源进行写操作,如果有需要进行重新读 |
信号量
Linux内核中信号量是一种睡眠锁。如果有一个任务试图获得一个不可用的信号量时,信号量将其推进一个等待队列,让其睡眠,此时处理器将去执行其他代码,当持有的信号量可用时,处于等待队列的任务将会被唤醒并获得信号量。
特点:
- 信号量可以使等待资源线程进入休眠状态,因此适用于占用资源较久的场合。
- 信号量不能用于中断中,原因是信号量会引起休眠。
- 共享资源的持有时间较短时不适合使用信号量,因为频繁的休眠、切换线程的开销远大于信号量带来的优势。
Linux内核使用semaphore结构体表示信号量,其定义如下所示:
\include\linux\semaphore.h
struct semaphore {
raw_spinlock_t lock;
unsigned int count;
struct list_head wait_list;
};
| 函数 | 描述 |
|---|---|
| DEFINE_SEMAPHORE(name) | 定义一个信号量,并设置信号量的值为1 |
| sema_init(struct semaphore *sem, int val) | 初始化信号量sem,设置信号量的值为val |
| down(struct semaphore *sem) | 获取信号量,此操作可能会导致休眠,故不能在中断中使用 |
| down_trylock(struct semaphore *sem) | 尝试获取信号量,如果能获取到便获取,并返回0.不能则返回非0,并不会进入休眠 |
| down_interruptible(struct semaphore *sem) | 获取信号量,与down类似,不同之处在于此函数能被中断信号打断 |
| up(struct semaphore *sem) | 释放信号量 |
互斥体
互斥体是一种睡眠锁,他是一种简单的睡眠锁,其行为和 count 为 1 的信号量类似,斥体简洁高效,但是相比信号量,有更多的限制,因此对于互斥体的使用条件更加严格。
- 互斥体会导致休眠,故不能在中断中使用。
- 互斥体保护的临界区可以调用引起阻塞的API函数。
- 由于一次只能有一个线程持有互斥体,故必须由互斥体的持有者释放互斥体。互斥体不能递归上锁和解锁。
| 函数 | 描述 |
|---|---|
| DEFINE_MUTEX(mutexname) | 定义并初始化一个mutex变量 |
| mutex_init(mutex) | 初始化mutex |
| mutex_lock(struct mutex *lock) | 获取mutex,即上锁,若未能获取到,则进入休眠 |
| mutex_unlock(struct mutex *lock) | 释放mutex,即解锁 |
| mutex_trylock(struct mutex *lock) | 尝试获取mutex,成功返回1,失败返回0 |
| mutex_is_locked(struct mutex *lock) | 判断mutex是否被获取,是返回1,否返回0 |
| mutex_lock_interruptible(struct mutex *lock) | 使用此函数获取信号量失败进入休眠以后可以被信号打断 |
参考
《Linux设备驱动开发详解:基于最新的Linux4.0内核》
《【正点原子】I.MX6U嵌入式Linux驱动开发指南V1.5.pdf》
🚘
😶
🧚♀️
本文详细介绍了Linux设备驱动中并发控制的原理和方法,包括原子操作、自旋锁和信号量。并发控制主要应对多线程、中断处理、抢占式并发及SMP核间并发访问引发的竞态问题。原子操作提供了不可拆分的整型或位操作,自旋锁用于保护轻量级的互斥访问,而信号量则允许线程在资源不可用时进入休眠状态。这些机制保证了共享资源的安全访问。
&spm=1001.2101.3001.5002&articleId=125677782&d=1&t=3&u=588c3a11f4774889907f6c16453db8ed)
5万+

被折叠的 条评论
为什么被折叠?



