一、什么是同步与互斥
互斥与同步机制是计算机系统中,用于控制程序对共享资源(临界区)的访问,以保证临界区的数据正确性。
------------------------------------------------------------------------------------
同步,又称直接制约关系,是指多个线程(或进程)为了合作完成任务,必须严格按照规定的 某种先后次
序来运行。
例如,线程 T2 中的语句 y 要使用线程 T1 中的语句 x 的运行结果,所以只有当语句 x 执行完成之后
语句 y 才可以执行。我们可以使用信号量进行同步:
semaphore S=0; // 初始化信号量
T1() {
...
x; // 语句x
V(S); // 告诉线程T2,语句x已经完成
...
}
T2() {
...
P(S); // 检查语句x是否运行完成
y; // 检查无误,运行y语句
...
}
------------------------------------------------------------------------------------
互斥,又称间接制约关系,是指系统中的某些共享资源,一次只允许一个线程访问。当一个线程正在访问该
临界资源时,其它线程必须等待。
例如,打印机就是一种临界资源,而访问打印机的代码片段称为临界区,故每次只允许一个线程进入临界区。
—— 我们同样可以使用信号量解决互斥问题,只需把临界区置于 P(S) 和 V(S) 之间,即可实现两线程对临
界资源的互斥访问。
semaphore S=1; // 初始化信号量
T1() {
...
P(S);
线程T1的临界区; // 访问临界资源
V(S);
...
}
T2() {
...
P(S);
线程T2的临界区; // 访问临界资源
V(S);
...
}
------------------------------------------------------------------------------------
同步:强调的是顺序性
互斥:强调的是排他性
竞态:多个执行单元同时被执行,处理的是同一个资源,就会导致竞态
导致竞态原因:
1.多进程同时访问操作临界资源(进程和抢占它的进程之间会导致竞态)
例如,当进程在访问某个临界资源的时候发生内核态抢占,随后进入了高优先级
的进程,如果该进程也访问了同一临界资源,那么就会造成进程与进程之间的并发。
2.进程和中断
例如,当进程在访问某个临界资源的时候发生了中断,随后进入中断处理程序,
如果在中断处理程序中,也访问了该临界资源。虽然不是严格意义上的并发,但
是也会造成了对该资源的竞态。
3.多处理器
例如,多处理器系统上的进程与进程之间是严格意义上的并发,每个处理器都可
以独自调度运行一个进程,在同一时刻有多个进程在同时运行 。
4.软中断和tasklet
例如,软终端在硬中断后会调度,中断进程并且如果此时该终端处理函数访问统一临界资源
就会产生竞态
二、解决竞态的具体方法
内存屏障:
介绍: https://zhuanlan.zhihu.com/p/505956490
定义: ./asm-generic/barrier.h
总结: 主要是高速CPU刷新最新数据、减伤编译优化,明确流程使其能完全符合我们的思路运行
(待补充)
屏蔽中断(单处理器不可抢占系统)
中断屏蔽;local_irq_disable() 临界区; 使能中断;local_irq_enable()
由前面可以知道,对于单处理器不可抢占系统来说,系统并发源主要是中断处理。
因此在进行临界资源访问时,进行禁用/使能中断即可以达到消除异步并发源的目的。
Linux系统中提供了两个宏local_irq_enable与 local_irq_disable来使能和禁用中断。
在linux系统中,使用这两个宏来开关中断的方式进行保护时,要确保处于两者之间的代码执行时间不能太长,
否则将影响到系统的性能。(不能及时响应外部中断)
要求:临界区代码执行时间足够短。
注意: 中断屏蔽函数,只能屏蔽本CPU的中断
自旋锁
应用背景:自旋锁的最初设计目的是在多处理器系统中提供对共享数据的保护。
自旋锁的设计思想:在多处理器之间设置一个全局变量V,表示锁。并定义当V=1时为锁定状态,V=0时为解锁状态。自旋锁同步机制是针对多处理器设计的,属于忙等机制。自旋锁机制只允许唯一的一个执行路径持有自旋锁。如果处理器A上的代码要进入临界区,就先读取V的值。如果V!=0说明是锁定状态,表明有其他处理器的代码正在对共享数据进行访问,那么此时处理器A进入忙等状态(自旋);如果V=0,表明当前没有其他处理器上的代码进入临界区,此时处理器A可以访问该临界资源。然后把V设置为1,再进入临界区,访问完毕后离开临界区时将V设置为0。
注意:必须要确保处理器A“读取V,半段V的值与更新V”这一操作是一个原子操作。所谓的原子操作是指,一旦开始执行,就不可中断直至执行结束。
自旋锁的分类:
普通自旋锁
自旋锁相关函数声明和数据类型定义,在linux/spinlock.h中自旋锁数据类型:spinlock_tspin_lock_init(spinlock_t *lock) 功能:初始化自旋锁 参数: @lock 自旋锁结构体指针void spin_lock(spinlock_t *lock) 功能:自旋锁上锁 参数: @lock 自旋锁结构体指针
int spin_trylock(spinlock_t *lock) 功能:自旋锁上锁 参数: @lock 自旋锁结构体指针 特点:如果上锁失败,错误返回
void spin_unlock(spinlock_t *lock) 功能:自旋锁解锁 参数: @lock 自旋锁结构体指针
void flags_init(int *flags,int val)
{
*flags = val;
}
int flags_sub_and_test(int *flags)
{
(*flags)‐‐;
if(*flags == 0){
return 1;
}else{
return 0;
}
}
void flags_add(int *flags,int val)
{
*flags+=val;
}
int demo_open(struct inode *inode, struct file *filp)
{
spin_lock(&(my_cdev‐>spin));
if(!flags_sub_and_test(&(my_cdev‐>flags))){
flags_add(&(my_cdev‐>flags),1);
spin_unlock(&(my_cdev‐>spin));
return ‐EBUSY;
}
spin_unlock(&(my_cdev‐>spin));
printk("‐‐‐devfile_minor:%d‐‐‐\n",MINOR(inode‐>i_rdev));
printk(KERN_DEBUG "‐‐‐%s‐‐‐%s‐‐‐%d‐‐‐\n",__FILE__,__func__,__LINE__);
return 0;
}
int demo_release(struct inode *inode, struct file *filp)
{
spin_lock(&(my_cdev‐>spin));
flags_add(&(my_cdev‐>flags),1);
spin_unlock(&(my_cdev‐>spin));
printk(KERN_DEBUG "‐‐‐%s‐‐‐%s‐‐‐%d‐‐‐\n",__FILE__,__func__,__LINE__);
return 0;
}
int __init demo_init(void)
{
//...
flags_init(&(my_cdev‐>flags),1);
spin_lock_init(&(my_cdev‐>spin));
return 0;
}
自旋锁的变种
在前面讨论spin_lock很好的解决了多处理器之间的并发问题。但是如果考虑如下一个应用场景:处理器上的当前进程A要对某一全局性链表g_list进行操作,所以在操作前调用了spin_lock获取锁,然后再进入临界区。如果在临界区代码当中,进程A所在的处理器上发生了一个外部硬件中断,那么这个时候系统必须暂停当前进程A的执行转入到中断处理程序当中。假如中断处理程序当中也要操作g_list,由于它是共享资源,在操作前必须要获取到锁才能进行访问。因此当中断处理程序试图调用spin_lock获取锁时,由于该锁已经被进程A持有,中断处理程序将会进入忙等状态(自旋)。从而就会出现大问题了:中断程序由于无法获得锁,处于忙等(自旋)状态无法返回;由于中断处理程序无法返回,进程A也处于没有执行完的状态,不会释放锁。因此这样导致了系统的死锁。即spin_lock对存在中断源的情况是存在缺陷的,因此引入了它的变种。
定义: 在linux/spinlock.h中
spin_lock_irq(lock)
spin_unlock_irq(lock)
相比于前面的普通自旋锁,它在上锁前增加了禁用中断的功能,在解锁后,使能了中断。
读写自旋锁rwlock
应用背景:前面说的普通自旋锁spin_lock类的函数在进入临界区时,对临界区中的操作行为不细分。只要是访问共享资源,就执行加锁操作。但是有时候,比如某些临界区的代码只是去读这些共享的数据,并不会改写,如果采用spin_lock()函数,就意味着,任意时刻只能有一个进程可以读取这些共享数据。如果系统中有大量对这些共享资源的读操作,很明显spin_lock将会降低系统的性能。因此提出了读写自旋锁rwlock的概念。对照普通自旋锁,读写自旋锁允许多个读者进程同时进入临界区,交错访问同一个临界资源,提高了系统的并发能力,提升了系统的吞吐量。
读写自旋锁有数据结构rwlock_t来表示。定义在./include/linux/spinlock_types.h中
读写自旋锁的接口函数:
DEFINE_RWLOCK(lock) //声明读写自旋锁lock,并初始化为未锁定状态
write_lock(lock) //以写方式锁定,若成功则返回,否则循环等待
write_unlock(lock) //解除写方式的锁定,重设为未锁定状态
read_lock(lock) //以读方式锁定,若成功则返回,否则循环等待
read_unlock(lock) //解除读方式的锁定,重设为未锁定状态
读写自旋锁的工作原理:
对于读写自旋锁rwlock,它允许任意数量的读取者同时进入临界区,但写入者必须进行互斥访问。一个进程要进行读,必须要先检查是否有进程正在写入,如果有,则自旋(忙等),否则获得锁。一个进程要进程写,必须要先检查是否有进程正在读取或者写入,如果有,则自旋(忙等)否则获得锁。即读写自旋锁的应用规则如下:
(1)如果当前有进程正在写,那么其他进程就不能读也不能写。
(2)如果当前有进程正在读,那么其他程序可以读,但是不能写。
顺序自旋锁seqlock
应用背景:顺序自旋锁主要用于解决自旋锁同步机制中,在拥有大量读者进程时,写进程由于长时间无法持有锁而被饿死的情况,其主要思想是:为写进程提高更高的优先级,在写锁定请求出现时,立即满足写锁定的请求,无论此时是否有读进程正在访问临界资源。但是新的写锁定请求不会,也不能抢占已有写进程的写锁定。
顺序锁的设计思想:对某一共享数据读取时不加锁,写的时候加锁。为了保证读取的过程中不会因为写入者的出现导致该共享数据的更新,需要在读取者和写入者之间引入一个整形变量,称为顺序值sequence。读取者在开始读取前读取该sequence,在读取后再重新读取该值,如果与之前读取到的值不一致,则说明本次读取操作过程中发生了数据更新,读取操作无效。因此要求写入者在开始写入的时候更新。
顺序自旋锁由数据结构seqlock_t表示,定义在src/include/linux/seqlcok.h
顺序自旋锁访问接口函数:
seqlock_init(seqlock) //初始化为未锁定状态
read_seqbegin()、read_seqretry() //保证数据的一致性
write_seqlock(lock) //尝试以写锁定方式锁定顺序锁
write_sequnlock(lock) //解除对顺序锁的写方式锁定,重设为未锁定状态。
顺序自旋锁的工作原理:写进程不会被读进程阻塞,也就是,写进程对被顺序自旋锁保护的临界资源进行访问时,立即锁定并完成更新工作,而不必等待读进程完成读访问。但是写进程与写进程之间仍是互斥的,如果有写进程在进行写操作,其他写进程必须循环等待,直到前一个写进程释放了自旋锁。顺序自旋锁要求被保护的共享资源不包含有指针,因为写进程可能使得指针失效,如果读进程正要访问该指针,将会出错。同时,如果读者在读操作期间,写进程已经发生了写操作,那么读者必须重新读取数据,以便确保得到的数据是完整的。
原子操作
对变量的操作,一次性完成,中间不能被打断。 要完成原子操作,必须使用到汇编代码,C嵌套汇编的形式实现。 汇编代码和体系架构相关。 <asm/atomic.h>
原子变量的数据类型:
typedef struct {
int counter;
} atomic_t;
atomic_set(atomic_t *v,int i) //设置原子变量值
void atomic_add(int i, atomic_t *v) //原子变量加i
int atomic_add_return(int i, atomic_t *v) //对原子变量进行加操作,操作后,返回 加完以后的值
void atomic_sub(int i, atomic_t *v) //原子变量减i
int atomic_sub_return(int i, atomic_t *v) //对原子变量进行减操作,操作后,返回 加完以后的值
#define atomic_inc(v) atomic_add(1, v) //自加
#define atomic_dec(v) atomic_sub(1, v) //自减
#define atomic_inc(v) atomic_add(1, v) //自加
#define atomic_dec(v) atomic_sub(1, v) //自减
#define atomic_inc_and_test(v) (atomic_add_return(1, v) == 0) //自加并测 试,和0进行比较
#define atomic_dec_and_test(v) (atomic_sub_return(1, v) == 0) //自减并测 试,和0进行比较
#define atomic_inc(v) atomic_add(1, v) //自加
#define atomic_dec(v) atomic_sub(1, v) //自减
#define atomic_inc_and_test(v) (atomic_add_return(1, v) == 0) //自加并测 试,和0进行比较
#define atomic_dec_and_test(v) (atomic_sub_return(1, v) == 0) //自减并测 试,和0进行比较
原子操作相关的代码:
1 int demo_open(struct inode *inode, struct file *filp)
{
if(!atomic_dec_and_test(&(my_cdev‐>ato))){
atomic_inc(&(my_cdev‐>ato));
return ‐EBUSY;
}
printk("‐‐‐devfile_minor:%d‐‐‐\n",MINOR(inode‐>i_rdev));
printk(KERN_DEBUG "‐‐‐%s‐‐‐%s‐‐‐%d‐‐‐\n",__FILE__,__func__,__LINE__);
return 0;
}
int demo_release(struct inode *inode, struct file *filp)
{
atomic_inc(&(my_cdev‐>ato));
printk(KERN_DEBUG "‐‐‐%s‐‐‐%s‐‐‐%d‐‐‐\n",__FILE__,__func__,__LINE__);
return 0;
}
int __init demo_init(void)
{
//...
atomic_set(&(my_cdev‐>ato),1);
//...
return 0;
}
互斥体
定义在<linux/mutex.h>
互斥体的数据类型: struct mutex
mutex_init(struct mutex *mutex)// 功能:初始化互斥体
// 参数: @mutex 互斥体结构体指针
void mutex_lock(struct mutex *lock) //上锁
void mutex_unlock(struct mutex *lock) //解锁
操作:
1.初始化
2.找到临界资源和临界区
3.使用互斥体来保护临界区
互斥体上锁 —>>> 互斥体上锁失败,会导致应用层进程休眠 等待临界区 互斥体解锁
信号量
信号量的操作本质就是PV操作(加减操作),如果信号量为0,执行减操作失败,会导 致应用层进程休眠。 定义:<linux/semaphore.h>
信号量数据类型: struct semaphore
#define DEFINE_SEMAPHORE(name);//定义并初始化信号量,name 信号量变 量名
// 功能:初始化信号量 参数: @sem 信号量结构体指针 @val 初始化信号量的值
void sema_init(struct semaphore *sem, int val);
void down(struct semaphore *sem);//功能:信号量减操作,减失败后进程休眠等(不可中断休眠态)
int down_interruptible(struct semaphore *sem);//功能:信号量减操作,减失败后进程休眠等(可中断休眠态)
int down_trylock(struct semaphore *sem); //功能:信号量减操作,减操作失败,报错返回
void up(struct semaphore *sem); //功能:信号量加操作
RCU
RCU概念:RCU全称是Read-Copy-Update(读/写-复制-更新),是linux内核中提供的一种免锁的同步机制。RCU与前面讨论过的读写自旋锁rwlock,读写信号量rwsem,顺序锁一样,它也适用于读取者、写入者共存的系统。但是不同的是,RCU中的读取和写入操作无须考虑两者之间的互斥问题。但是写入者之间的互斥还是要考虑的。
RCU原理:简单地说,是将读取者和写入者要访问的共享数据放在一个指针p中,读取者通过p来访问其中的数据,而读取者则通过修改p来更新数据。要实现免锁,读写双方必须要遵守一定的规则。
读取者的操作(RCU临界区)
对于读取者来说,如果要访问共享数据。首先要调用rcu_read_lock和rcu_read_unlock函数构建读者侧的临界区(read-side critical section),然后再临界区中获得指向共享数据区的指针,实际的读取操作就是对该指针的引用。
读取者要遵守的规则是:(1)对指针的引用必须要在临界区中完成,离开临界区之后不应该出现任何形式的对该指针的引用。(2)在临界区内的代码不应该导致任何形式的进程切换(一般要关掉内核抢占,中断可以不关)。
写入者的操作
对于写入者来说,要写入数据,首先要重新分配一个新的内存空间做作为共享数据区。然后将老数据区内的数据复制到新数据区,并根据需要修改新数据区,最后用新数据区指针替换掉老数据区的指针。写入者在替换掉共享区的指针后,老指针指向的共享数据区所在的空间还不能马上释放(原因后面再说明)。写入者需要和内核共同协作,在确定所有对老指针的引用都结束后才可以释放老指针指向的内存空间。为此,写入者要做的操作是调用call_rcu函数向内核注册一个回调函数,内核在确定所有对老指针的引用都结束时会调用该回调函数,回调函数的功能主要是释放老指针指向的内存空间。Call_rcu函数的原型如下:
Void call_rcu(struct rcu_head *head,void (*func)(struct rcu_head *rcu));
内核确定没有读取者对老指针的引用是基于以下条件的:系统中所有处理器上都至少发生了一次进程切换。因为所有可能对共享数据区指针的不一致引用一定是发生在读取者的RCU临界区,而且临界区一定不能发生进程切换。所以如果在CPU上发生了一次进程切换切换,那么所有对老指针的引用都会结束,之后读取者再进入RCU临界区看到的都将是新指针。
老指针不能马上释放的原因:这是因为系统中爱可能存在对老指针的引用,者主要发生在以下两种情况:(1)一是在单处理器范围看,假设读取者在进入RCU临界区后,刚获得共享区的指针之后发生了一个中断,如果写入者恰好是中断处理函数中的行为,那么当中断返回后,被中断进程RCU临界区中继续执行时,将会继续引用老指针。(2)另一个可能是在多处理器系统,当处理器A上的一个读取者进入RCU临界区并获得共享数据区中的指针后,在其还没来得及引用该指针时,处理器B上的一个写入者更新了指向共享数据区的指针,这样处理器A上的读取者也饿将引用到老指针。
RCU特点:由前面的讨论可以知道,RCU实质上是对读取者与写入者自旋锁rwlock的一种优化。RCU的可以让多个读取者和写入者同时工作。但是RCU的写入者操作开销就比较大。在驱动程序中一般比较少用。
为了在代码中使用RCU,所有RCU相关的操作都应该使用内核提供的RCU API函数,以确保RCU机制的正确使用,这些API主要集中在指针和链表的操作。
下面是一个RCU的典型用法范例:
//假设struct shared_data是一个在读取者和写入者之间共享的受保护数据
Struct shared_data{
Int a;
Int b;
Struct rcu_head rcu;
};
//读取者侧的代码
Static void demo_reader(struct shared_data *ptr)
{
Struct shared_data *p=NULL;
Rcu_read_lock();
P=rcu_dereference(ptr);
If(p)
Do_something_withp(p);
Rcu_read_unlock();
}
//写入者侧的代码
Static void demo_del_oldptr(struct rcu_head *rh) //回调函数
{
Struct shared_data *p=container_of(rh,struct shared_data,rcu);
Kfree(p);
}
Static void demo_writer(struct shared_data *ptr)
{
Struct shared_data *new_ptr=kmalloc(…);
…
New_ptr->a=10;
New_ptr->b=20;
Rcu_assign_pointer(ptr,new_ptr);//用新指针更新老指针
Call_rcu(ptr->rcu,demo_del_oldptr); //向内核注册回调函数,用于删除老指针指向的内存空间
}
完成接口completion
内核编程中常见的一种模式是,在当前线程之外初始化某个活动,然后等待该活动的结束。这个活动可能是,创建一个新的内核线程或者新的用户空间进程、对一个已有进程的某个请求,或者某种类型的硬件动作,等等。在这种情况下,我们可以使用信号量来同步这两个任务。然而,内核中提供了另外一种机制——completion接口。Completion是一种轻量级的机制,他允许一个线程告诉另一个线程某个工作已经完成。
定义在: ./include/linux/completion.h
结构与初始化
Completion在内核中的实现基于等待队列(关于等待队列理论知识在前面的文章中有介绍),completion结构很简单:
struct completion {
unsigned int done;/*用于同步的原子量*/
wait_queue_head_t wait;/*等待事件队列*/
};
和信号量一样,初始化分为静态初始化和动态初始化两种情况: 静态初始化:
#define COMPLETION_INITIALIZER(work) \
{ 0, __WAIT_QUEUE_HEAD_INITIALIZER((work).wait) }
#define DECLARE_COMPLETION(work) \
struct completion work = COMPLETION_INITIALIZER(work)
动态初始化:
static inline void init_completion(struct completion *x)
{
x->done = 0;
init_waitqueue_head(&x->wait);
}
可见,两种初始化都将用于同步的done原子量置位了0,后面我们会看到,该变量在wait相关函数中减一,在complete系列函数中加一。
实现
同步函数一般都成对出现,completion也不例外,我们看看最基本的两个complete和wait_for_completion函数的实现。 wait_for_completion最终由下面函数实现:
static inline long __sched
do_wait_for_common(struct completion *x, long timeout, int state)
{
if (!x->done) {
DECLARE_WAITQUEUE(wait, current);
wait.flags |= WQ_FLAG_EXCLUSIVE;
__add_wait_queue_tail(&x->wait, &wait);
do {
if (signal_pending_state(state, current)) {
timeout = -ERESTARTSYS;
break;
}
__set_current_state(state);
spin_unlock_irq(&x->wait.lock);
timeout = schedule_timeout(timeout);
spin_lock_irq(&x->wait.lock);
} while (!x->done && timeout);
__remove_wait_queue(&x->wait, &wait);
if (!x->done)
return timeout;
}
x->done--;
return timeout ?: 1;
}
wait_for_completion最终由下面函数实现:
static inline long __sched
do_wait_for_common(struct completion *x, long timeout, int state)
{
if (!x->done) {
DECLARE_WAITQUEUE(wait, current);
wait.flags |= WQ_FLAG_EXCLUSIVE;
__add_wait_queue_tail(&x->wait, &wait);
do {
if (signal_pending_state(state, current)) {
timeout = -ERESTARTSYS;
break;
}
__set_current_state(state);
spin_unlock_irq(&x->wait.lock);
timeout = schedule_timeout(timeout);
spin_lock_irq(&x->wait.lock);
} while (!x->done && timeout);
__remove_wait_queue(&x->wait, &wait);
if (!x->done)
return timeout;
}
x->done--;
return timeout ?: 1;
}
而complete实现如下:
void complete(struct completion *x)
{
unsigned long flags;
spin_lock_irqsave(&x->wait.lock, flags);
x->done++;
__wake_up_common(&x->wait, TASK_NORMAL, 1, 0, NULL);
spin_unlock_irqrestore(&x->wait.lock, flags);
}
不看内核实现的源代码我们也能想到他的实现,不外乎在wait函数中循环等待done变为可用(正),而另一边的complete函数为唤醒函数,当然是将done加一,唤醒待处理的函数。是的,从上面的代码看到,和我们想的一样。内核也是这样做的。
运用
运用LDD3中的例子:
#include <linux/module.h>
#include <linux/init.h>
#include <linux/sched.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/types.h>
#include <linux/completion.h>
MODULE_LICENSE("GPL");
static int complete_major=250;
DECLARE_COMPLETION(comp);
ssize_t complete_read(struct file *filp,char __user *buf,size_t count,loff_t *pos)
{
printk(KERN_ERR "process %i (%s) going to sleep\n",current->pid,current->comm);
wait_for_completion(&comp);
printk(KERN_ERR "awoken %i (%s)\n",current->pid,current->comm);
return 0;
}
ssize_t complete_write(struct file *filp,const char __user *buf,size_t count,loff_t *pos)
{
printk(KERN_ERR "process %i (%s) awakening the readers...\n",current->pid,current->comm);
complete(&comp);
return count;
}
struct file_operations complete_fops={
.owner=THIS_MODULE,
.read=complete_read,
.write=complete_write,
};
int complete_init(void)
{
int result;
result=register_chrdev(complete_major,"complete",&complete_fops);
if(result<0)
return result;
if(complete_major==0)
complete_major=result;
return 0;
}
void complete_cleanup(void)
{
unregister_chrdev(complete_major,"complete");
}
module_init(complete_init);
module_exit(complete_cleanup);
测试步骤:
mknod /dev/complete创建complete节点,在linux上驱动程序需要手动创建文件节点。
insmod complete.ko 插入驱动模块,这里要注意的是,因为我们的代码中是手动分配的设备号,很可能被系统已经使用了,所以如果出现这种情况,查看/proc/devices文件。找一个没有被使用的设备号。
cat /dev/complete 用于读该设备,调用设备的读函数
打开另一个终端输入 echo “hello” > /dev/complete 该命令用于写入该设备。
后续还会不断补充;