前言
本篇文章带大家来学习Linux驱动开发中的同步与互斥,这两个概念是我们会经常接触到的两个概念,而且也是非常重要的,所以我们务必掌握他们的用法。
一、同步与互斥概念
1.同步(Synchronization):
同步指的是线程或进程之间的协调和顺序执行,以避免竞争条件和不一致的结果。通过同步机制,可以确保多个线程或进程按照一定的规则和顺序访问共享的资源,从而实现数据的一致性和正确性。
2.互斥(Mutual Exclusion):
互斥是一种同步机制,用于确保同一时间只有一个线程或进程能够访问共享资源。它通过对临界区(Critical Section)加锁来实现,进入临界区的线程或进程会获得独占访问权限,其他线程或进程需要等待。
二、为什么在驱动程序中需要引入同步与互斥
一个驱动程序可能被多个应用程序调用,那么这个时候就有可能会发生一些错误。所以这个时候就会引入同步和互斥,下面举几个同步互斥失败的例子:
这里我们定义了一个valid变量,当成功打开驱动时valid变量被设置为0,当有一个应用程序已经打开了这个驱动程序时,valid为0,其他应用程序再来打开时就不能打开了,返回错误。
但是在实际中Linux程序的运行状态是非常复杂的,有各种中断和线程进程之间的切换,这也就导致了同步互斥的失败。
当程序A想打开驱动程序时,运行到if (!valid)语句,这个时候valid还是1准备执行else语句了,但是这个时候程序B打断了A程序的执行也执行到了if (!valid)语句,这个时候valid的值并没有发生改变,最后的结果就是程序A和程序B都成功的打开了这个驱动程序,导致了同步和互斥的失败。
static int valid = 1; static ssize_t gpio_key_drv_open (struct inode *node, struct file *file) { if (!valid) { return -EBUSY; } else { valid = 0; } return 0; //成功 }
第一个失败的例子可能是因为程序太长导致运行时间过长导致的。那么我们在看下面这个精简后的代码例子:
这个代码看起来是少了许多但是依然避免不了问题的出现,因为需要一个变量从汇编的角度来讲都需要经过:读出,修改,写入的这三个步骤,那么当程序A在这三个步骤的任何一个步骤被程序B打断了那么就会导致同步和互斥的失败。
static int valid = 1; static ssize_t gpio_key_drv_open (struct inode *node, struct file *file) { if (--valid) { valid++; return -EBUSY; } return 0; }
前两个例子失败的原因都是因为被进程切换打断了,那么下面这个程序干脆把中断关闭了,这样就没有办法进行切换了,那这总可以保证万无一失了吧,错了这样还是会出问题的。
在多CPU的架构下,不同CPU上运行的程序还是可以同时访问这个驱动的,这样同步与互斥就又失败了。
static int valid = 1; static ssize_t gpio_key_drv_open (struct inode *node, struct file *file) { unsigned long flags; raw_local_irq_save(flags); // 关中断 if (--valid) { valid++; raw_local_irq_restore(flags); // 恢复之前的状态 return -EBUSY; } raw_local_irq_restore(flags); // 恢复之前的状态 return 0; }
三、内嵌汇编
在讲解解决问题的之前我们先讲解一下内嵌汇编的概念:
内嵌汇编(Inline Assembly)是一种在高级编程语言中插入汇编代码的技术。它允许开发者在高级语言代码中直接嵌入汇编指令,以便实现对底层硬件和特定处理器功能的直接访问和控制。在许多编程语言中,如C、C++和Rust,都提供了内嵌汇编的支持。
下面就是使用内嵌汇编来实现的一个加法函数:
int add(int a, int b) { int sum = 0; asm volatile( "add %0, %1, %2" :"=r"(sum) :"r"(a), "r"(b) ); return sum; }
下面是对汇编代码的解释:
"add %0, %1, %2"
此行代码是使用汇编指令 add 执行相加操作。%0、%1 和 %2 是占位符,用于表示操作数的位置,对应于输出(“=r”(sum))和输入(“r”(a)、“r”(b))约束。
接下来解释约束字符串:
:"=r"(sum)
这个约束表示变量 sum 是一个输出操作数,并将其分配给通用寄存器。“=r” 是约束字符串的一部分,其中 = 表示输出操作数(结果)必须放在寄存器中,r 表示使用通用寄存器。
:"r"(a), "r"(b)
这些约束表示变量 a 和 b 是输入操作数,并将它们分别分配给通用寄存器。“r” 表示使用通用寄存器。
四、原子操作
1.原子操作概念
上面失败的例子都是因为在修改valid的值的时候被改变了导致的,如果在修改valid的值时不能被打断,那么这个问题就解决了。那么怎么保证这个值在被修改的时候不被打断呢,这个时候就需要使用到原子操作了。
原子操作(Atomic Operation)是并发编程中的概念,指的是一个不可被中断的操作,要么完全执行,要么完全不执行。原子操作在多线程或多进程的并发环境下用于确保数据的一致性和避免竞争条件。
2.内核中原子变量的定义
原子变量的类型在内核中是以结构体的形式存在的:
里面有一个int类型的counter计数变量。
typedef struct { int counter; } atomic_t;
3.内核中怎么实现原子操作
1.ARMV6以下的架构
在ARMV6的架构下我们可以看到这个函数:
我们来替换一下这些宏
#define ATOMIC_OP(op, c_op, asm_op) \ static inline void atomic_##op(int i, atomic_t *v) \ { \ unsigned long flags; \ \ raw_local_irq_save(flags); \ v->counter c_op i; \ raw_local_irq_restore(flags); \ }
替换后:
可以看到在ARMV6以下的架构使用原子操作是非常简单的,只需要在对原子变量进行操作的时候关闭中断,操作完成后恢复中断即可,因为在ARMV6架构以下是不支持(SMP(多核CPU的))。
static inline void atomic_add(int i, atomic_t *v) { unsigned long flags; raw_local_irq_save(flags); v->counter += i; raw_local_irq_restore(flags); }
2.ARMV6以上的架构
在ARMV6以上的架构也是有对应的函数的:
static inline void atomic_##op(int i, atomic_t *v) \ { \ unsigned long tmp; \ int result; \ \ prefetchw(&v->counter); \ __asm__ __volatile__("@ atomic_" #op "\n" \ "1: ldrex %0, [%3]\n" \ " " #asm_op " %0, %0, %4\n" \ " strex %1, %0, [%3]\n" \ " teq %1, #0\n" \ " bne 1b" \ : "=&r" (result), "=&r" (tmp), "+Qo" (v->counter) \ : "r" (&v->counter), "Ir" (i) \ : "cc"); \ }
替换后:
static inline void atomic_add(int i, atomic_t *v) { unsigned long tmp; int result; prefetchw(&v->counter); __asm__ __volatile__("@ atomic_add\n" "1: ldrex %0, [%3]\n" " add %0, %0, %4\n" " strex %1, %0, [%3]\n" " teq %1, #0\n" " bne 1b" : "=&r" (result), "=&r" (tmp), "+Qo" (v->counter) : "r" (&v->counter), "Ir" (i) : "cc"); }
这里涉及到两个特别重要的汇编指令,这里给大家讲解一下:
ldrex和strex是ARM架构中用于实现原子操作的指令。它们用于在多处理器系统中实现无锁的读-改-写操作。
1.ldrex(Load-Exclusive)指令用于在无锁读操作期间加载内存值,并将该值存储到寄存器中。该指令指定了一个内存地址作为操作数,并使用乐观读(optimistic read)的方式来执行。如果在执行ldrex期间,没有其他处理器或设备对这个地址进行写操作,那么ldrex指令将成功,并将读取到的值存储到寄存器中。否则,如果有其他处理器或设备对该地址进行写操作,ldrex指令将失败,不会加载值到寄存器中。
2.strex(Store-Exclusive)指令用于在无锁写操作期间将值存储回内存。该指令指定了一个内存地址和一个寄存器作为操作数,并使用乐观写(optimistic write)的方式来执行。如果在执行strex期间,没有其他处理器或设备对该地址进行写操作,则strex指令将将寄存器中的值存储到该地址上,并返回成功的标志。否则,如果有其他处理器或设备对该地址进行写操作,strex指令将失败,不会将值存储到内存中,并返回失败的标志。
下面讲解一下上面的代码是怎么样实现原子操作的
在第3行,通过 ldrex 指令从内存中读取 v->counter 的当前值,并将其存储到 %0 寄存器中。
ldrex 使用乐观读(optimistic read)方式执行,如果没有其他处理器或设备对该内存地址进行写操作,读取操作将成功。
在第5行,通过 strex 指令将 %0 寄存器中的值(即相加后的结果)存储回内存地址 &v->counter。
如果在执行 strex 期间没有其他处理器或设备对 &v->counter 进行写操作,strex 操作将成功,并返回零。
如果在执行 strex 期间有其他处理器或设备对 &v->counter 进行写操作,strex 操作将失败,并返回非零值。
通过使用 ldrex 和 strex 指令的组合,可以实现无锁的原子操作。具体的实现方式如下:
1.执行 ldrex 指令读取 v->counter 的当前值。
2.执行计算操作,例如加法操作。
3.执行 strex 指令尝试将计算后的结果存储回 v->counter。
4.检查 strex 操作的结果,如果成功(返回零),则操作完成;如果失败(返回非零),则意味着有其他线程或处理器已经修改了 v->counter,需要重新尝试。
5.通过循环重试的方式,确保原子操作的完成。这样就实现了原子的加法操作,保证了对 v->counter 的并发读和写的一致性,避免了竞争条件的问题。
五、原子操作示例
原子操作函数介绍:
atomic_dec_and_test 函数是一种原子操作函数,常用于多线程编程中对计数器的递减操作,并检查其结果是否为零。其作用是原子地将指定的计数器递减1,并返回递减后的值。如果递减后的值为零,则表示计数器已经变为零。
该函数通常用于同步和控制并发操作,特别适用于实现资源引用计数的功能。以下是 atomic_dec_and_test 函数的简要说明:
函数原型:
int atomic_dec_and_test(atomic_t *v);
参数:
v:指向 atomic_t 类型变量的指针,表示要递减的计数器。
返回值:
递减后的计数器值。如果计数器递减后的值为零,返回1;否则,返回0。
函数功能:
将 v 指向的计数器递减1。
返回递减后的计数器值,并检查是否为零。
static atomic_t valid = ATOMIC_INIT(1);//将原子数据类型里面的counter值设置为1 static ssize_t gpio_key_drv_open (struct inode *node, struct file *file) { if (atomic_dec_and_test(&valid)) { return 0; } atomic_inc(&valid); return -EBUSY; }
总结
本篇文章就讲解到这里,下篇文章我们来讲解锁的概念和实现原理。