Linux 是一个多任务操作系统,肯定会存在多个任务共同操作同一段内存或设备的情况,多个任务甚至中断都能访问的资源叫做共享资源,就和共享单车一样。在驱动开发中要注意对共享资源的保护,也就是要处理对共享资源的并发访问。
原子操作
原子操作指的是不能再进一步分割的操作,一般情况下原子操作用于变量或位操作,比如下载要对无符号整型变量 a 赋值为 3,对于 C 语言来说很简单只要一句话就可以了,但是在汇编中,需要借助寄存器R0、R1 等来完成赋值操作。在运行多句语句的情况下,若两个线程同时访问,会很容易出问题。而要解决这个问题就要保证多行汇编指令作为一个整体运行,也就是作为一个原子存在。
Linux 内核提供了一组原子操作的 API 函数来完成此功能,Linux 内核提供了两组原子操作 API 函数,一组是对整形变量进行操作的,一组是对位进行操作的,我们接下来看一下这些 API 函数。
原子整形操作 API 函数
Linux 内核定义了叫做 atomic_t 的结构体来完成整形数据的原子操作,在使用中用原子变量来替代整形变量,此结构体定义在 include/linux/types.h 文件中,定义如下
1 | typedef struct { |
如果要使用原子操作 API 函数,首先要定义一个 atomic_t 变量,如下所示:
atomic_t a = ATOMIC_INIT(0); // 定义原子变量 a 并赋值为 0
原子变量有了,接下来就是对原子变量进行操作,比如读、写、增加、减少等等,Linux内核提供了大量的原子操作 API 函数,如下表所示:
| 函数 | 描述 |
|---|---|
| ATOMIC_INIT(int i) | 定义原子变量时对其进行初始化 |
| int atomic_read(atomic_t *v) | 读取v的值并返回 |
| void atomic_set(atomic_t *v, int i) | 向 v 写入 i 值 |
| void atomic_add(int i,atomic_t *v) | 给 v 加上 i 值 |
| void atomic_sub(int i, atomic_t *v) | 从 v 减去 i 值 |
| void atomic_inc(atomic_t *v) | 给 v 加 1,也就是自增。 |
| void atomic_dec(atomic_t *v) | 从 v 减 1,也就是自减。 |
| int atomic_dec_return(atomic_t *v) | 从 v 减 1,并且返回 v 的值。 |
| int atomic_inc_return(atomic_t *v) | 给 v 加 1,并且返回 v 的值。 |
| int atomic_sub_and_test(int i, atomic_t *v) | 从 v 减 i,如果结果为 0 就返回真,否则返回假。 |
| int atomic_dec_and_test(atomic_t *v) | 从 v 减 1,如果结果为 0 就返回真,否则返回假。 |
| int atomic_inc_and_test(atomic_t *v) | 给 v 加 1,如果结果为 0 就返回真,否则返回假。 |
| int atomic_add_negative(int i, atomic_t *v) | 给 v 加 i,如果结果为负就返回真,否则返回假。 |
如果使用 64 位的 SOC 的话,就要用到 64 位的原子变量。Linux 内核也定义了 64 位原子结构体,如下所示
typedef struct {
s64 counter;
} atomic64_t;
原子位操作 API 函数
位操作也是很常用的操作,Linux 内核也提供了一系列的原子位操作 API 函数,只不过原子位操作不像原子整形变量那样有个 atomic_t 的数据结构,原子位操作是直接对内存进行操作,API 函数如下表:
| 函数 | 说明 |
|---|---|
| void set_bit(int nr, void *p) | 将 p 地址的第 nr 位设置为 1。 |
| void clear_bit(int nr, void *p) | 将 p 地址的第 nr 位清零。 |
| void change_bit(int nr, void *p) | 将 p 地址的第 nr 位进行翻转。 |
| int test_bit(int nr, void *p) | 获取 p 地址的第 nr 位的值。 |
| int test_and_set_bit(int nr, void *p) | 将 p 地址的第 nr 位设置为 1,并返回 nr 位原来的值。 |
| int test_and_clear_bit(int nr, void *p) | 将 p 地址的第 nr 位清零,并返回 nr 位原来的值。 |
| int test_and_change_bit(int nr, void *p) | 将 p 地址的第 nr 位翻转,并返回 nr 位原来的值。 |
自旋锁
原子操作只能对整型变量或者位进行保护,但是在实际情况下不只有整型变量或位为临界区,比如设备结构体变量就不是整型变量。我们需要自旋锁来确保只有一个线程访问此结构体变量。
当一个线程要访问某个共享资源的时候首先要获取相应的锁,锁只能被一个线程持有,只要此线程不释放持有的锁,那么其他的线程就不能获取此锁。对于自旋锁而言,如果自旋锁正在被 线程 A 持有,线程 B 想要获取自旋锁,那么线程 B 就会处于忙循环-旋转-等待状态,线程 B 不会进入休眠状态或者说去做其他的处理,而是会一直等待获取到自旋锁。从这里就可以看到自旋锁的缺点:等待自旋锁的线程会一直处于自旋状态,这样会浪费性能,所以自旋锁的持有时间不能太长。所以自旋锁适合短时期的轻量加锁。
Linux 内核使用结构体 spinlock_t 表示自旋锁,结构体定义如下
1 | typedef struct spinlock { |
在使用自选锁之前,先定义一个变量,然后就可以使用相应的 API 函数来操作自旋锁。最基本的自旋锁 API 函数如下表所示:
| 函数 | 描述 |
|---|---|
| DEFINE_SPINLOCK(spinlock_t lock) | 定义并初始化一个自旋锁变量。 |
| int spin_lock_init(spinlock_t *lock) | 初始化自旋锁。 |
| void spin_lock(spinlock_t *lock) | 获取指定的自旋锁,也叫做加锁。 |
| void spin_unlock(spinlock_t *lock) | 释放指定的自旋锁。 |
| int spin_trylock(spinlock_t *lock) | 尝试获取指定的自旋锁,如果没有获取到就返回 0。 |
| int spin_is_locked(spinlock_t *lock) | 检查指定的自旋锁是否被锁定,如果已经被锁定返回非0,否则返回0 |
上表的自旋锁 API 函数适用于 SMP 或支持抢占的单 CPU 下线程之间的并发访问,也就是线程与线程之间。被自旋锁保护的临界区一定不能调用任何能够引起睡眠或阻塞的 API 函数,否则会导致死锁发生。
上面说了,这些 API 函数用于线程之间的并发访问,在一个线程上锁后,如果此时有中断也要获取锁,中断和线程则会你不让我,我不让你。又会导致死锁现象发生因此,在上锁之前啊,需要关闭本地中断,Linux 内核也提供了相应的 API 函数,如下表所示
| 函数 | 描述 |
|---|---|
| void spin_lock_irq(spinlock_t *lock) | 禁止本地中断,并获取自旋锁。 |
| void spin_unlock_irq(spinlock_t *lock) | 激活本地中断,并释放自旋锁。 |
| void spin_lock_irqsave(spinlock_t *lock, unsigned long flags) | 保存中断状态,禁止本地中断,并获取自旋锁。 |
| void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags) | 将中断状态恢复到之前的状态,并激活本地中断,释放自旋锁。 |
使用spin_lock_irq/spin_unlock_irq 的时候需要用户能够确定加锁之前的中断状态,但实际上内核很庞大,我们很难确定中断状态,因此我们推荐使用spin_lock_irqsave 和 spin_unlock_irqrestore,因为这一组函数会保存中断状态,在释放锁的时候会恢复中断状态。一般在线程中使用这两个函数,在中断中使用 spin_lock/spin_unlock。下半部(BH)也会竞争共享资源,如果要在这里面使用自旋锁,可以使用如下 API 函数
| 函数 | 描述 |
|---|---|
| void spin_lock_bh(spinlock_t *lock) | 关闭下半部,并获取自旋锁。 |
| void spin_unlock_bh(spinlock_t *lock) | 打开下半部,并释放自旋锁。 |
信号量
在 Linux 内核中,信号量是一种同步机制,用于控制多个线程(或进程)对共享资源的访问。先看最朴素的模型。假设一个资源只有一个线程能用,但是两个进程同时想用,于是引入一个二值信号量 a = 1,逻辑就变成这样:
进程A:P() -> semaphore-- -> 使用资源 -> V() -> semaphore++
进程B:P() -> 如果 semaphore == 0 就等待
于是资源就被排队访问。值得注意的是,相比于自旋锁,信号量可以使线程进入休眠状态,提高处理器的使用效率,但是信号量的开销要比自旋锁大。我们来总结一下信号量的特点:
①、因为信号量可以使等待资源线程进入休眠状态,因此适用于那些占用资源比较久的场合。
②、信号量不可以用于中断,因为信号量会引起休眠,中断不能休眠。
③、如果共享资源的持有时间比较短,那就不适合使用信号量了,因为频繁的休眠、切换线程引起的开销要远大于信号量带来的那点优势。
信号量的定义如下,其中 count 表示当前还允许多少个线程进入临界区。
1 | struct semaphore { |
常用的 API 函数如下表
| 函数 | 描述 |
|---|---|
| DEFINE_SEMAPHORE(name) | 定义一个信号量,并且设置信号量的值为 1。 |
| void sema_init(struct semaphore *sem, int val) | 初始化信号量 sem,设置信号量值为 val。 |
| void down(struct semaphore *sem) | 获取信号量,因为会导致休眠,因此不能在中断中使用。 |
| int down_trylock(struct semaphore *sem) | 尝试获取信号量,如果能获取到信号量就获取,并且返回 0。如果不能返回非 0,并且不会进入休眠。 |
| int down_interruptible(struct semaphore *sem) | 获取信号量,和 down 类似,只是使用 down 进入休眠状态的线程不能被信号打断,而使用此函数进入休眠以后是可以被信号打断的。 |
| void up(struct semaphore *sem) | 释放信号量。 |
信号量的使用如下:
1 | struct semaphore sem; |
互斥体
互斥体是一种用来保证同一时间只有一个线程访问共享资源的同步机制。在我们编写 Linux 驱动的时候遇到需要互斥访问的地方建议使用 mutex。Linux 内核使用 mutex 结构体表示互斥体,定义如下:
1 | struct mutex { |
在使用 mutex 之前需要先定义一个 mutex 变量,在使用 mutex 的时候需要注意以下几点:
①、mutex 可以导致休眠,不要在中断中使用 mutex,中断只能用自旋锁。
②、和信号量一样,mutex 保护的临界区可以调用引起阻塞的 API 函数。
③、因为一次只有一个线程可以持有 mutex,因此,必须由 mutex 的持有者释放 mutex。并且 mutex 不能递归上锁和解锁。
| 函数 | 描述 |
|---|---|
| DEFINE_MUTEX(name) | 定义并初始化一个 mutex 变量。 |
| void mutex_init(struct mutex *lock) | 初始化 mutex。 |
| void mutex_lock(struct mutex *lock) | 获取 mutex,也就是给 mutex 上锁。如果获取不到就进入休眠。 |
| void mutex_unlock(struct mutex *lock) | 释放 mutex,也就是给 mutex 解锁。 |
| int mutex_trylock(struct mutex *lock) | 尝试获取 mutex,如果成功就返回 1,如果失败就返回 0。 |
| int mutex_is_locked(struct mutex *lock) | 判断 mutex 是否被获取,如果是的话就返回 1,否则返回 0。 |
| int mutex_lock_interruptible(struct mutex *lock) | 使用此函数获取 mutex 失败进入休眠以后可以被信号打断。 |
互斥体的使用如下
1 | struct mutex lock; |