linux设备驱动开发之设备驱动的并发控制

并发和竞态

  1. 什么是并发呢?

并发(Concurrency)指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(Race Conditions)

  1. 并行与并发
    对于单核CPU来说,不存在并行的现象,是属于“宏观并行,微观串行”。那么对于单核CPU来说,这种并发为什么可以造成竞态呢?归根结底是,我们编写的代码块判断不是单周期的,是多周期的,当多个执行单元执行到同一片代码块时,常常因为前后的条件改变导致改变了执行单元的逻辑初衷。
    当然,对于SMP多核CPU来说,是存在真正的并行的,不同的CPU是可以同时在同一代码块中的同一条指令上工作的。这个时候,是会造成竞态的。

  2. 如何处理由并发带来的竞态
    通常情况下,我们会在有可能造成并发的资源上做文章。例如我们会在访问共享资源区时,采用中断屏蔽、原子操作、自旋锁、信号量、互斥体等互斥机制,来保护这个共享缓冲区访问的专一性。

编译乱序和执行乱序

编译乱序

  1. 编译乱序就是编译器编译的代码顺序,并不是严格按照初始代码编写的逻辑一样。

  2. 为什么会有编译乱序这种现象呢?

    现代的高性能编译器在目标码优化上都具备对指令进行乱序优化的能力。编译器可以对访存的指令进行乱序,减少逻辑上不必要的访存,以及尽量提高Cache命中率和CPU的Load/Store单元的工作效率。因此在打开编译器优化以后,看到生成的汇编码并没有严格按照代码的逻辑顺序,这是正常的.

  3. 怎样预防编译乱序呢?我们可以在编写代码的时候设置屏障(barrier),编译器会识别这个barrier,对于barrier前后的代码不会前后顺序颠倒。

// include/linux/compiler-gcc.h
/* Optimization barrier */
/* The "volatile" is due to gcc bugs */
#define barrier() __asm__ __volatile__("": : :"memory")

执行乱序

  1. 执行乱序的现象和原因

在处理器上执行时,后发射的指令还是可能先执行完,这是处理器的“乱序执行(Out-of-Order Execution)”策略。高级的CPU可以根据自己缓存的组织特性,将访存指令重新排序执行。连续地址的访问可能会先执行,因为这样缓存命中率高。有的还允许访存的非阻塞,即如果前面一条访存指令因为缓存不命中,造成长延时的存储访问时,后面的访存指令可以先执行,以便从缓存中取数。因此,即使是从汇编上看顺序正确的指令,其执行的顺序也是不可预知的。

  1. 预防执行乱序的手段
  • DMB(数据内存屏障):在DMB之后的显式内存访问执行前,保证所有在DMB指令之前的内存访问完成;
  • DSB(数据同步屏障):等待所有在DSB指令之前的指令完成(位于此指令前的所有显式内存访问均完成,位于此指令前的所有缓存、跳转预测和TLB维护操作全部完成);
  • ISB(指令同步屏障):Flush流水线,使得所有ISB之后执行的指令都是从缓存或内存中获得的。

中断屏蔽

中断屏蔽使用方法

// 屏蔽中断 
local_irq_disable()
. . .
critical section  // 临界区
. . .
// 开中断
local_irq_enable()

上面那个关中断、开中断的源码在下面:

// arch/arm/include/asm/irqflags.h
static inline void arch_local_irq_enable(void)
 35 {
 36         asm volatile(
 37                 "       cpsie i                 @ arch_local_ir    q_enable"
 38                 :
 39                 :
 40                 : "memory", "cc");
 41 }

从上面可以看出:对于ARM处理器而言,其底层的实现是屏蔽ARM CPSR的I位。

原子操作

原子操作是用汇编代码写的,目的就是确保在访问原子操作的整形变量时,具有唯一排它性。这个原子操作也为后面的信号量、互斥锁等互斥机制提供了原始的依赖。这个依赖的核心就是原子操作内部的LDREX、STREX这两条指令(对于arm处理器来说)。

LDREX和STREX

  1. 什么是ldrex?它是一个32位的arm操作指令,功能是从指定内存中加载数据到寄存器中(loads data from memory).
  2. 什么是strex?它是一个32位的arm操作指令,功能是条件存储数据到指定的内存中(performs a conditional store to memory)

它们的使用方法:

LDREX{cond} Rt, [Rn {, #offset}]
STREX{cond} Rd, Rt, [Rn {, #offset}]
cond
	// cond是一个可选择的条件码
    is an optional condition code.
Rd
	// 目标寄存器,用作返回状态用
    is the destination register for the returned status.
Rt
	// 装载或存储寄存器
    is the register to load or store.
Rt2
	// 第二寄存器,用来进行双字节加载或存储
    is the second register for doubleword loads or stores.
Rn
	// 装载地址的寄存器
    is the register on which the memory address is based.
offset
	// 装载地址的偏移量,如果省略,偏移量默认为0
    is an optional offset applied to the value in Rn. offset is permitted only in Thumb-2 instructions. If offset is omitted, an offset of 0 is assumed.

ldrex的注意事项:

  • If the physical address has the Shared TLB attribute, LDREX tags the physical address as exclusive access for the current processor, and clears any exclusive access tag for this processor for any other physical address.
  • Otherwise, it tags the fact that the executing processor has an outstanding tagged physical address.

strex的注意事项:

  • If the physical address does not have the Shared TLB attribute, and the executing processor has an outstanding tagged physical address, the store takes place, the tag is cleared, and the value 0 is returned in Rd.
  • If the physical address does not have the Shared TLB attribute, and the executing processor does not have an outstanding tagged physical address, the store does not take place, and the value 1 is returned in Rd.
  • If the physical address has the Shared TLB attribute, and the physical address is tagged as exclusive access for the executing processor, the store takes place, the tag is cleared, and the value 0 is returned in Rd.
  • If the physical address has the Shared TLB attribute, and the physical address is not tagged as exclusive access for the executing processor, the store does not take place, and the value 1 is returned in Rd.

上面的注意事项我直接从arm编译工具链的说明文档里摘抄的。使用strex的核心就是它在把数据存储到内存中的时候是有上面那些约束条件的。这个约束条件的存在使得代码在多处理器或共享缓冲区中运行时得到了保护的可能。
然后我们会运用上面指令的特性完成我们的原子操作。

分析atomic_add

// arch/arm/include/asm/atomic.h
/*
 * ARMv6 UP and SMP safe atomic ops.  We use load exclusive and
 * store exclusive to ensure that these are atomic.  We may loop
 * to ensure that the update happens.
 */
static inline void atomic_add(int i, atomic_t *v)
{
	unsigned long tmp;
	int result;

	__asm__ __volatile__("@ atomic_add\n"
"1:	ldrex	%0, [%3]\n" #相当于 result = v->counter
"	add	%0, %0, %4\n" #相当于 result += i
"	strex	%1, %0, [%3]\n" # 关键代码,这里会尝试将result的值存储到v->counter的内存上,如果存储成功,则返回的tmp等于0,否则等于1.
"	teq	%1, #0\n"#判断tmp是否等于0
"	bne	1b"#如果不等于0,说明没有存储成功,继续进入1的标签开始执行,在我们看来就是堵塞了的现象;如果等于0,说明存储成功,继续向下执行
	: "=&r" (result), "=&r" (tmp), "+Qo" (v->counter)
	: "r" (&v->counter), "Ir" (i)
	: "cc");
}

上面就是原子操作中加函数的实现部分,其他递减函数类似。

自旋锁

自旋锁的目的是当在SMP多核处理器或可抢占内核中起到作用:保护共享资源访问时的唯一排它性。
一般情况下我们使用自旋锁的用法:

// 定义一个自旋锁
spinlock_t lock;
// 初始化这个自旋锁
spin_lock_init(&lock);
// 获取自旋锁
spin_lock (&lock) ;
. . ./* 临界区 */
// 释放自旋锁
spin_unlock (&lock) ;

现在我们来看下自旋锁为啥可以保证处理器访问共享资源时的唯一排它性。

spinlock_t结构体

// 4.0.0-040000-generic:include/linux/spinlock_types.h
typedef struct spinlock {
 65         union {
 66                 struct raw_spinlock rlock; // 生的自旋锁结构体,就是不容易直接被编程者使用的结构体
 67 
 68 #ifdef CONFIG_DEBUG_LOCK_ALLOC
 69 # define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map))
 70                 struct {
 71                         u8 __padding[LOCK_PADSIZE];
 72                         struct lockdep_map dep_map;
 73                 };
 74 #endif
 75         };
 76 } spinlock_t;

spin_lock_init接口

// 4.0.0-040000-generic:include/linux/spinlock.h
#define spin_lock_init(_lock)				\
do {							\
	spinlock_check(_lock);				\
	raw_spin_lock_init(&(_lock)->rlock);		\
} while (0)
// 在这里只是检查这个自旋锁参数的有效性,是否为空
static inline raw_spinlock_t *spinlock_check(spinlock_t *lock)
{
	return &lock->rlock;
}

从上面的代码我们可以看出,自旋锁在初始化时,会首先检查传入参数的有效性,然后再调用生的自旋锁初始化接口进行初始化。那么生的自旋锁初始化的流程是怎样的呢?这里好复杂,先不讨论。接下来我们再分析spin_lock这个接口。

spin_lock接口

// 4.0.0-040000-generic:include/linux/spinlock.h
static inline void spin_lock(spinlock_t *lock)
{
	// 调用生的自旋锁获取锁接口
	raw_spin_lock(&lock->rlock);
}
// 对这个接口进行二次封装
#define raw_spin_lock(lock)	_raw_spin_lock(lock)

// 4.0.0-040000-generic:include/linux/spinlock_api_up.h
// 再次封装
#define _raw_spin_lock(lock)			__LOCK(lock)

// 加锁操作,调用__acquire接口获取锁,
// 这个__acquire是怎样处理的呢,我们下面有分析
#define ___LOCK(lock) \
  do { __acquire(lock); (void)(lock); } while (0)
  
  // 核心加锁代码,这里调用了可抢占内核标志位失能函数,
  // 然后再进行加锁操作
#define __LOCK(lock) \
  do { preempt_disable(); ___LOCK(lock); } while (0)

那么preempt_disable是怎么实现的呢?我们接着分析

// 4.0.0-040000-generic:include/linux/preempt.h
#define preempt_disable() \
do { \
	preempt_count_inc(); \
	barrier(); \
} while (0)
// preempt计数是将标志位进行加1操作
// 上面的barrier接口起到内存屏障的作用,
// 保证barrier前后的执行代码的顺序与编写代码顺序逻辑一致
// 这里主要避免乱序执行现象
#define preempt_count_inc() preempt_count_add(1)
#define __preempt_count_inc() __preempt_count_add(1)

现在我们再来分析__preempt_count_add函数。

// 4.0.0-040000-generic:include/asm-generic/preempt.h
static __always_inline void __preempt_count_add(int val)
{
	*preempt_count_ptr() += val;
}
static __always_inline int *preempt_count_ptr(void)
{
	return &current_thread_info()->preempt_count;
}

// 4.0.0-040000-generic:arch/arm/include/asm/thread_info.h
static inline struct thread_info *current_thread_info(void) __attribute_const__;
struct thread_info {
	unsigned long		flags;		/* low level flags */
	// 这里是可抢占内核标志位的解释关键,
	// 这里可以发现当preempt_count为0时表示使能可用,
	// preempt_count小于0时表示bug了,
	// preempt_count大于0时表示失能,内核目前不可抢占
	int			preempt_count;	/* 0 => preemptable, <0 => bug */
	mm_segment_t		addr_limit;	/* address limit */
	struct task_struct	*task;		/* main task structure */
	struct exec_domain	*exec_domain;	/* execution domain */
	__u32			cpu;		/* cpu */
	__u32			cpu_domain;	/* cpu domain */
	struct cpu_context_save	cpu_context;	/* cpu context */
	__u32			syscall;	/* syscall number */
	__u8			used_cp[16];	/* thread used copro */
	unsigned long		tp_value[2];	/* TLS registers */
#ifdef CONFIG_CRUNCH
	struct crunch_state	crunchstate;
#endif
	union fp_state		fpstate __attribute__((aligned(8)));
	union vfp_state		vfpstate;
#ifdef CONFIG_ARM_THUMBEE
	unsigned long		thumbee_state;	/* ThumbEE Handler Base register */
#endif
	struct restart_block	restart_block;
};

所以preempt_count_add(1)这个加1的操作其实就是失能内核的可抢占功能。从这里我们可以发现,自旋锁加锁时会把内核的可抢占功能关掉。下面我们再分析__acquire获取锁的操作

// 4.0.0-040000-generic:include/linux/compiler.h
# define __acquires(x)	__attribute__((context(x,0,1)))
# define __releases(x)	__attribute__((context(x,1,0)))
# define __acquire(x)	__context__(x,1)
# define __release(x)	__context__(x,-1)

这段宏定义是啥意思呢?其实当我们获得锁的时候,是通过Sparse这个工具生成的锁。具体可以查看维基百科中对Sparse工具的介绍Sparse工具介绍。Sparse它是内核编码的的一种静态代码检查工具,防止像加锁释放锁这种严格的闭环操作不能得到正确执行。至于sparse怎样获得锁的,我猜测还是要借助ldrex、strex这种唯一排他的指令来进行原子操作,当然这是对于arm架构来说。

spin_unlock接口

释放操作与加锁操作有一定的对应关系,这里可以不用再细说

#define __UNLOCK(lock) \
  do { preempt_enable(); ___UNLOCK(lock); } while (0)

上面的释放操作最终会调用这个解锁操作,里面继续使能内核的可抢占功能,然后释放锁,释放具体锁的操作同样也是由Sparse工具生成的代码。

自旋锁使用

spin_lock_irq() = spin_lock() + local_irq_disable()
spin_unlock_irq() = spin_unlock() + local_irq_enable()
spin_lock_irqsave() = spin_lock() + local_irq_save()
spin_unlock_irqrestore() = spin_unlock() + local_irq_restore()
spin_lock_bh() = spin_lock() + local_bh_disable()spin_unlock_bh() = spin_unlock() + local_bh_enable()

上述自旋锁关系是摘抄自宋宝华老师写的书,详情可以参考linux驱动开发-宋宝华。

  1. 自旋锁在获取锁后,进行临界区操作期间,最好不要调用有关可以造成内核调度的接口。因为通过上面的分析我们可以发现自旋锁在锁住期间,只是暂时禁止了内核的可抢占调度,如果在锁住期间发生调度的话,很容易造成死锁。
  2. 读写自旋锁。就是允许多个进程读同一片内存共享区,允许最多一个进程写同一片内存共享区。
  3. 顺序自旋锁。进一步对读写自旋锁进行优化,就是允许在写任务进行的可以进行读任务,在读任务的时候可以进行写任务。
  4. 读-复制-更新操作。RCU(read-copy-update),它不同于自旋锁。使用RCU的读端没有锁、内存屏障、原子指令类的开销,几乎可以认为是直接读(只是简单地标明读开始和读结束),而RCU的写执行单元在访问它的共享资源前首先复制一个副本,然后对副本进行修改,最后使用一个回调机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据,这个时机就是所有引用该数据的CPU都退出对共享数据读操作的时候。等待适当时机的这一时期称为宽限期(Grace Period)。

信号量

互斥体

完成量

globalmem字符驱动中添加互斥锁

在这里插入图片描述

原始文件在上一章中有写。

总结

  1. 并发处理中的核心指令为ldrex、strex,对于arm架构而言。
  2. 并发控制指令使用可以参考内核中已有的使用案例。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值