序言
在前面,我们介绍了常用两种内核同步技术:自旋锁和信号量。这里我们接着介绍其他的内核同步技术。
内核同步技术
前面我们提到,信号量是基于原子操作的,它的信号初始值count是一个原子类型,下面我们就对它做详细的介绍,同时我们还会讲解其他的内核同步技术。
原子操作
原子操作是内核中比较低级的同步操作接口,它保证指令以“原子”的方式执行而不能被打断。内核中提供了两种原子操作接口,一种是原子整数操作,一种是原子位操作。
原子整数操作
原子整数操作只能操作atomic_t类型的整数数据。你也许会问为什么不是C语言里的int变量呢?在回答这个问题之前,我们先来看看它是如何定义的。atomic_t数据类型定义在<asm/atomic.h>头文件中:
// 大多数平台定义(ARM、MIPS,...)
typedef struct { volatile int counter; } atomic_t;
// x86平台定义
typedef struct { int counter; } atomic_t;
原子操作的实现都是平台相关的,直接使用系统提供的汇编语言来实现,因此使用 atomic_t数据类型可以屏蔽系统平台的差异性。另一点使用atomic_t的原因是可以阻止编译器对变量访问的优化处理(如使用别名或临时变量)。最后使用atomic_t可以确认我们不会把原子变量使用到非原子操作函数中,反之亦然。另一点需要说明原子操作只能保证操作是原子的,要么完成,要么不完成,不会有操作一半的可能,但原子操作并不能保证操作的顺序性,即它不能保证两个操作是按某个顺序完成的。如果要保证原子操作的顺序性,请使用内存屏障指令。在下一小节你就会看到内存屏障技术。
我们先来看一个使用原子操作的一个例子,我们有一个数据data,因为有多个进程在使用它,因此对它的访问必须是原子的,要么完成,要么不完成,而不会操作一半。我们使用原子操作来实现它:
#include <asm/atomic.h>
// A 进程初始化数据位0
atomic_t data;
data = ATOMIC_INIT(0);
// B进程需要设置它的数据为 6
atomic_set(&data);
// C进程每次操作会把数据增加10
atomic_add(10, &data);
// D进程每次操作会自动把数据递增1
atomic_inc(&data);
原子整数操作的完整函数列表如下:
#define ATOMIC_INIT(i);
#define atomic_read(v);
#define atomic_set(v,i);
static __inline__ void atomic_add(int i, atomic_t *v);
static __inline__ void atomic_inc(atomic_t *v);
static __inline__ void atomic_sub(int i, atomic_t *v);
static __inline__ void atomic_dec(atomic_t *v);
static __inline__ int atomic_inc_and_test(atomic_t *v)
static __inline__ int atomic_sub_and_test(int i, atomic_t *v);
static __inline__ int atomic_dec_and_test(atomic_t *v);
static __inline__ int atomic_add_negative(int i, atomic_t *v);
static __inline__ int atomic_add_return(int i, atomic_t *v);
static __inline__ int atomic_sub_return(int i, atomic_t *v);
static __inline__ int atomic_add_unless(atomic_t *v, int a, int u);
#define atomic_inc_not_zero(v) atomic_add_unless((v), 1, 0)
#define atomic_inc_return(v) (atomic_add_return(1,v))
#define atomic_dec_return(v) (atomic_sub_return(1,v))
原子位操作
Linux内核提供了原子位操作,用于操作某个内存地址的一个位。因此原子位操作函数的参数是一个内存地址和操作的位号,这些函数定义在<asm/bitops.h>头文件中,显然,他们是系统平台结构相关的。
由于原子位操作的操作对象仅仅是普通的内存地址,因此它的使用就有很大的灵活性,你可以在任何地址上使用原子位操作,以下是原子位操作的函数列表:
static inline void set_bit(int nr, volatile unsigned long * addr);
static inline void clear_bit(int nr, volatile unsigned long * addr);
static inline void clear_bit_unlock(unsigned long nr, volatile unsigned long *addr);
static inline void change_bit(int nr, volatile unsigned long * addr);
static inline int test_and_set_bit(int nr, volatile unsigned long * addr);
static inline int test_and_set_bit_lock(int nr, volatile unsigned long *addr);
static inline int test_and_clear_bit(int nr, volatile unsigned long * addr);
static inline int test_and_change_bit(int nr, volatile unsigned long* addr);
static inline int find_first_zero_bit(const unsigned long *addr, unsigned size);
int find_next_zero_bit(const unsigned long *addr, int size, int offset);;
static inline unsigned find_first_bit(const unsigned long *addr, unsigned size);
static inline unsigned long ffz(unsigned long word);
static inline int ffs(int x);
static inline int fls(int x);
需要说明的是,由于原子位操作仅仅是操作内存地址,因此原子位操作可以同非原子位操作混合使用。而且,内核也提供了与原子位操作函数相对应的非原子位函数,它在名字上仅仅是在原子位函数名前加双下划线(__)。
内存屏蔽指令
前面我们提到,原子操作只能保证操作的原子性,并不能保证操作的顺序性,这主要是由于编译器优化的结构。编译器一般会为了优化而更改C语言指令执行的顺序。因此有时程序执行的指令并不向我们在C语言中期望的那样执行。一般来说这并不会影响程序的执行结果,但对指令执行顺序有严格要求程序来说,这种优化对程序来说简直是致命的,并且你很难在后面的调试中发现这种错误。
为了解决这个问题,Linux提供了内存屏蔽指令。插入内存屏蔽指令可以保证指令前后的执行顺序不会改变。根据平台的不同,内存屏蔽指令被定义在<asm/system.h>或<asm/barrier.h>头文件中。内存屏蔽指令API定义如下:
#include <asm/system.h>
#define mb()
#define rmb()
#define wmb()
#define smp_mb()
#define smp_rmb()
#define smp_wmb()
内存屏蔽指令都是宏定义,内存屏蔽指令中,rmb是读内存屏蔽,wmb是写内存屏蔽指令,带smp_的应用于SMP内核的内存屏蔽指令。
内存屏蔽指令的使用很简单,知需要在需要屏蔽的两条指令之间加上内存屏蔽指令即可。
完成变量
我们在来看另一种同步需求,一个进程明确的需要等待另一个进程完成某个任务,如果另一个进程的任务没有完成,它就睡眠等待这个任务的完成。当另一个进程完成了这个任务,它就通知等待进程继续执行。
目前来说我们还无法用前面介绍的技术来解决这个问题。还好,内核提供了完成变量(completions)这种技术,它是基于等待队列的一种技术(关于等待队列,我们会在后面Linux的等待队列一章在详细讲解)。
完成变量对象是一个completion结构,它定义在<linux/completion.h>头文件中,显然它是一个平台无关的技术。完成变量的API也很简单,初始化、等待、完成通知,如下表:
#include <linux/completion.h>
struct completion {
unsigned int done;
wait_queue_head_t wait;
};
DECLARE_COMPLETION(work);
static inline void init_completion(struct completion *x);
void wait_for_completion(struct completion *);
int wait_for_completion_interruptible(struct completion *x);
unsigned long wait_for_completion_timeout(struct completion *x,
unsigned long timeout);
unsigned long wait_for_completion_interruptible_timeout(
struct completion *x, unsigned long timeout);
extern void complete(struct completion *);
extern void complete_all(struct completion *);
使用完成变量
使用前需要先声明并初始化完成变量。就像其他的同步技术(想想自旋锁和信号量的初始化),完成变量的初始化也分为静态和动态两种。DECLARE_COMPLETION(work)用于静态的声明并初始化完成变量,init_completion用于动态初始化完成变量。初始化完成后,需要等待的任务调用wait函数族来指定等待的条件,其中带有interruptible的函数表示等待队列是可中断的,带有timeout的函数表示等待指定的超时时间。当其他进程完成了等待时间就会调用complete()函数来通知(唤醒)等待在这个事件上的任务。
大内核锁(BKL)
如果你浏览Linux内核源代码,你会发现lock_kernel()和unlock_kernel()函数。是的,这也是一种内核同步锁,一种早期的内核锁,称为大内核锁。它阻止其他进程进入某个代码区,因此它更像是在保护代码而不是数据。
它的使用很简单:
lock_kernel();
...
unlock_kernel()
大内核锁是允许递归的,因此是一种递归锁。在大内核锁的临界区,你可以安全的睡眠,锁会被系统安全的释放,当睡眠进程被唤醒时,锁又会被系统安全的加上。
如果系统配置为支持可抢占的大内核锁,则它的实现时基于互斥信号量的,如果不是,则它的实现时基于自旋锁的。
由于内核不在推荐使用大内核锁,尽管它很简单,也不要在你的代码里使用大内核锁。
seq锁
seq锁是Linux2.6内核引入的一种内核锁,它类似于读写自旋锁。但不同于读写自旋锁,读写自旋锁不分优先级,但seq锁引入了优先级的概念,它赋予了写锁更高的优先级。因此不管seq读锁是否持有该锁,seq写锁都会被成功持有而执行写操作。这就解决的读写自旋锁写饥饿的问题。其实seq锁是基于自旋锁来实现了,它给seq锁对象增加了一个序列计数器,它的使用是这样的。在seq写锁被持有时,这个序列值会递增1。在读数据时,读进程要判断读前和读后序列号的变化以确定是否有写操作发生,如果有则重新读数据。
seq锁定义在<linux/seqlock.h>头文件中,它是平台无关的,下面时它的API接口。
#include <linux/seqlock.h>
SEQLOCK_UNLOCKED
DEFINE_SEQLOCK(x);
static inline void write_seqlock(seqlock_t *);
static inline void write_sequnlock(seqlock_t *);
static __always_inline unsigned read_seqbegin(const seqlock_t *);
static __always_inline int read_seqretry(const seqlock_t *, unsigned );
下面是一个典型的seq锁使用的例子:
// 初始化
seqlock_t seq_lock = SEQLOCK_UNLOCKED;
// 写进程写操作
write_seqlock(&seq_lock);
......
write_sequnlock(&seq_lock);
// 读进程读操作
unsigned long seq;
do {
seq = read_seqbegin(&seq_lock);
...... // 读数据
}while (read_seqretry(&seq_lock, seq));
seq锁的初始化和写操作与自旋锁类似,但读操作就完全不同了。
seq锁是写优先锁,只要没有其他写进程持有该锁,它总能被成功持有。只要有写进程持有该锁,读进程就会不断的循环读数据,直到写进程释放该锁。
后记
到现在为止我们已经介绍了内核中驱动程序常用的同步操作技术。在后面的例子中,我们就会用到内核同步技术来完善我们的“hello world”驱动程序支持多同步操作。现在来看看我们的驱动程序,它已经支持读写数据了,不过好像是少了什么功能(好好想想)?是设备控制,我们还无法通过应用程序来控制我们的“Hello World”设备。在这里我们先透露一点就是,Linux中设备的控制接口就是ioctl,在下一章我们将扩展我们的驱动来支持设备控制接口。
在前面,我们介绍了常用两种内核同步技术:自旋锁和信号量。这里我们接着介绍其他的内核同步技术。
内核同步技术
前面我们提到,信号量是基于原子操作的,它的信号初始值count是一个原子类型,下面我们就对它做详细的介绍,同时我们还会讲解其他的内核同步技术。
原子操作
原子操作是内核中比较低级的同步操作接口,它保证指令以“原子”的方式执行而不能被打断。内核中提供了两种原子操作接口,一种是原子整数操作,一种是原子位操作。
原子整数操作
原子整数操作只能操作atomic_t类型的整数数据。你也许会问为什么不是C语言里的int变量呢?在回答这个问题之前,我们先来看看它是如何定义的。atomic_t数据类型定义在<asm/atomic.h>头文件中:
// 大多数平台定义(ARM、MIPS,...)
typedef struct { volatile int counter; } atomic_t;
// x86平台定义
typedef struct { int counter; } atomic_t;
原子操作的实现都是平台相关的,直接使用系统提供的汇编语言来实现,因此使用 atomic_t数据类型可以屏蔽系统平台的差异性。另一点使用atomic_t的原因是可以阻止编译器对变量访问的优化处理(如使用别名或临时变量)。最后使用atomic_t可以确认我们不会把原子变量使用到非原子操作函数中,反之亦然。另一点需要说明原子操作只能保证操作是原子的,要么完成,要么不完成,不会有操作一半的可能,但原子操作并不能保证操作的顺序性,即它不能保证两个操作是按某个顺序完成的。如果要保证原子操作的顺序性,请使用内存屏障指令。在下一小节你就会看到内存屏障技术。
我们先来看一个使用原子操作的一个例子,我们有一个数据data,因为有多个进程在使用它,因此对它的访问必须是原子的,要么完成,要么不完成,而不会操作一半。我们使用原子操作来实现它:
#include <asm/atomic.h>
// A 进程初始化数据位0
atomic_t data;
data = ATOMIC_INIT(0);
// B进程需要设置它的数据为 6
atomic_set(&data);
// C进程每次操作会把数据增加10
atomic_add(10, &data);
// D进程每次操作会自动把数据递增1
atomic_inc(&data);
原子整数操作的完整函数列表如下:
#define ATOMIC_INIT(i);
#define atomic_read(v);
#define atomic_set(v,i);
static __inline__ void atomic_add(int i, atomic_t *v);
static __inline__ void atomic_inc(atomic_t *v);
static __inline__ void atomic_sub(int i, atomic_t *v);
static __inline__ void atomic_dec(atomic_t *v);
static __inline__ int atomic_inc_and_test(atomic_t *v)
static __inline__ int atomic_sub_and_test(int i, atomic_t *v);
static __inline__ int atomic_dec_and_test(atomic_t *v);
static __inline__ int atomic_add_negative(int i, atomic_t *v);
static __inline__ int atomic_add_return(int i, atomic_t *v);
static __inline__ int atomic_sub_return(int i, atomic_t *v);
static __inline__ int atomic_add_unless(atomic_t *v, int a, int u);
#define atomic_inc_not_zero(v) atomic_add_unless((v), 1, 0)
#define atomic_inc_return(v) (atomic_add_return(1,v))
#define atomic_dec_return(v) (atomic_sub_return(1,v))
原子位操作
Linux内核提供了原子位操作,用于操作某个内存地址的一个位。因此原子位操作函数的参数是一个内存地址和操作的位号,这些函数定义在<asm/bitops.h>头文件中,显然,他们是系统平台结构相关的。
由于原子位操作的操作对象仅仅是普通的内存地址,因此它的使用就有很大的灵活性,你可以在任何地址上使用原子位操作,以下是原子位操作的函数列表:
static inline void set_bit(int nr, volatile unsigned long * addr);
static inline void clear_bit(int nr, volatile unsigned long * addr);
static inline void clear_bit_unlock(unsigned long nr, volatile unsigned long *addr);
static inline void change_bit(int nr, volatile unsigned long * addr);
static inline int test_and_set_bit(int nr, volatile unsigned long * addr);
static inline int test_and_set_bit_lock(int nr, volatile unsigned long *addr);
static inline int test_and_clear_bit(int nr, volatile unsigned long * addr);
static inline int test_and_change_bit(int nr, volatile unsigned long* addr);
static inline int find_first_zero_bit(const unsigned long *addr, unsigned size);
int find_next_zero_bit(const unsigned long *addr, int size, int offset);;
static inline unsigned find_first_bit(const unsigned long *addr, unsigned size);
static inline unsigned long ffz(unsigned long word);
static inline int ffs(int x);
static inline int fls(int x);
需要说明的是,由于原子位操作仅仅是操作内存地址,因此原子位操作可以同非原子位操作混合使用。而且,内核也提供了与原子位操作函数相对应的非原子位函数,它在名字上仅仅是在原子位函数名前加双下划线(__)。
内存屏蔽指令
前面我们提到,原子操作只能保证操作的原子性,并不能保证操作的顺序性,这主要是由于编译器优化的结构。编译器一般会为了优化而更改C语言指令执行的顺序。因此有时程序执行的指令并不向我们在C语言中期望的那样执行。一般来说这并不会影响程序的执行结果,但对指令执行顺序有严格要求程序来说,这种优化对程序来说简直是致命的,并且你很难在后面的调试中发现这种错误。
为了解决这个问题,Linux提供了内存屏蔽指令。插入内存屏蔽指令可以保证指令前后的执行顺序不会改变。根据平台的不同,内存屏蔽指令被定义在<asm/system.h>或<asm/barrier.h>头文件中。内存屏蔽指令API定义如下:
#include <asm/system.h>
#define mb()
#define rmb()
#define wmb()
#define smp_mb()
#define smp_rmb()
#define smp_wmb()
内存屏蔽指令都是宏定义,内存屏蔽指令中,rmb是读内存屏蔽,wmb是写内存屏蔽指令,带smp_的应用于SMP内核的内存屏蔽指令。
内存屏蔽指令的使用很简单,知需要在需要屏蔽的两条指令之间加上内存屏蔽指令即可。
完成变量
我们在来看另一种同步需求,一个进程明确的需要等待另一个进程完成某个任务,如果另一个进程的任务没有完成,它就睡眠等待这个任务的完成。当另一个进程完成了这个任务,它就通知等待进程继续执行。
目前来说我们还无法用前面介绍的技术来解决这个问题。还好,内核提供了完成变量(completions)这种技术,它是基于等待队列的一种技术(关于等待队列,我们会在后面Linux的等待队列一章在详细讲解)。
完成变量对象是一个completion结构,它定义在<linux/completion.h>头文件中,显然它是一个平台无关的技术。完成变量的API也很简单,初始化、等待、完成通知,如下表:
#include <linux/completion.h>
struct completion {
unsigned int done;
wait_queue_head_t wait;
};
DECLARE_COMPLETION(work);
static inline void init_completion(struct completion *x);
void wait_for_completion(struct completion *);
int wait_for_completion_interruptible(struct completion *x);
unsigned long wait_for_completion_timeout(struct completion *x,
unsigned long timeout);
unsigned long wait_for_completion_interruptible_timeout(
struct completion *x, unsigned long timeout);
extern void complete(struct completion *);
extern void complete_all(struct completion *);
使用完成变量
使用前需要先声明并初始化完成变量。就像其他的同步技术(想想自旋锁和信号量的初始化),完成变量的初始化也分为静态和动态两种。DECLARE_COMPLETION(work)用于静态的声明并初始化完成变量,init_completion用于动态初始化完成变量。初始化完成后,需要等待的任务调用wait函数族来指定等待的条件,其中带有interruptible的函数表示等待队列是可中断的,带有timeout的函数表示等待指定的超时时间。当其他进程完成了等待时间就会调用complete()函数来通知(唤醒)等待在这个事件上的任务。
大内核锁(BKL)
如果你浏览Linux内核源代码,你会发现lock_kernel()和unlock_kernel()函数。是的,这也是一种内核同步锁,一种早期的内核锁,称为大内核锁。它阻止其他进程进入某个代码区,因此它更像是在保护代码而不是数据。
它的使用很简单:
lock_kernel();
...
unlock_kernel()
大内核锁是允许递归的,因此是一种递归锁。在大内核锁的临界区,你可以安全的睡眠,锁会被系统安全的释放,当睡眠进程被唤醒时,锁又会被系统安全的加上。
如果系统配置为支持可抢占的大内核锁,则它的实现时基于互斥信号量的,如果不是,则它的实现时基于自旋锁的。
由于内核不在推荐使用大内核锁,尽管它很简单,也不要在你的代码里使用大内核锁。
seq锁
seq锁是Linux2.6内核引入的一种内核锁,它类似于读写自旋锁。但不同于读写自旋锁,读写自旋锁不分优先级,但seq锁引入了优先级的概念,它赋予了写锁更高的优先级。因此不管seq读锁是否持有该锁,seq写锁都会被成功持有而执行写操作。这就解决的读写自旋锁写饥饿的问题。其实seq锁是基于自旋锁来实现了,它给seq锁对象增加了一个序列计数器,它的使用是这样的。在seq写锁被持有时,这个序列值会递增1。在读数据时,读进程要判断读前和读后序列号的变化以确定是否有写操作发生,如果有则重新读数据。
seq锁定义在<linux/seqlock.h>头文件中,它是平台无关的,下面时它的API接口。
#include <linux/seqlock.h>
SEQLOCK_UNLOCKED
DEFINE_SEQLOCK(x);
static inline void write_seqlock(seqlock_t *);
static inline void write_sequnlock(seqlock_t *);
static __always_inline unsigned read_seqbegin(const seqlock_t *);
static __always_inline int read_seqretry(const seqlock_t *, unsigned );
下面是一个典型的seq锁使用的例子:
// 初始化
seqlock_t seq_lock = SEQLOCK_UNLOCKED;
// 写进程写操作
write_seqlock(&seq_lock);
......
write_sequnlock(&seq_lock);
// 读进程读操作
unsigned long seq;
do {
seq = read_seqbegin(&seq_lock);
...... // 读数据
}while (read_seqretry(&seq_lock, seq));
seq锁的初始化和写操作与自旋锁类似,但读操作就完全不同了。
seq锁是写优先锁,只要没有其他写进程持有该锁,它总能被成功持有。只要有写进程持有该锁,读进程就会不断的循环读数据,直到写进程释放该锁。
后记
到现在为止我们已经介绍了内核中驱动程序常用的同步操作技术。在后面的例子中,我们就会用到内核同步技术来完善我们的“hello world”驱动程序支持多同步操作。现在来看看我们的驱动程序,它已经支持读写数据了,不过好像是少了什么功能(好好想想)?是设备控制,我们还无法通过应用程序来控制我们的“Hello World”设备。在这里我们先透露一点就是,Linux中设备的控制接口就是ioctl,在下一章我们将扩展我们的驱动来支持设备控制接口。