字符设备驱动 (二)

1 开发环境

  • Linux Kernel 4.18.0
  • QEMU 5.2.0-vexpress
  • Source Insight 3.5

2 并发控制

  • linux设备驱动中存在多个进程对资源共享并发访问.因此需要对驱动并发控制进行深入分析.

2.1 基本概念

  • 并发(Concurrency):多个执行单元同时、并行被执行.
  • 竞态(Race Conditions):并发的执行单元对共享资源的访问则很容易导致竞态.
  • 临界区(Critical Section):每个进程中访问临界资源的那段代码称为临界区.
  • 临界资源(Critical Resource) :一次仅允许一个进程使用的资源称为临界资源.
  • 中断上下文:硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境。
  • 进程上下文:一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。

2.2 并发和竞态出现场景

  • SMP对称多处理(真实并发)
    SMP(Symmetrical Multi-Processing)的多个CPU 可能在, CPU与CPU之间,进程与进程之间,中断与中断之间出现竞态.

  • 单CPU内进程与抢占它的进程(伪并发).
    Linux内核支持抢占调度,一个进程在内核执行的时候可能耗完了自己的时间片(timeslice),内核调度另一个进程。或者进程被另一个高优先级的进程打断。

  • 中断和进程之间
    中断可以打断正在执行的进程,如果中断处理函数程序访问进程正在访问的资源,则竞态也会发生.

  • Note: SMP是真正的并行以外,其他的都是单核上的“宏观并行,微观串行”

  • 解决竞态问题的途径是保证对临界区的互斥访问,即一个执行单元在访问临界区的时候,其他的执行单元被禁止访问。

2.3 编译乱序和执行乱序

2.3.1 编译乱序

  • 编译乱序是编译器的行为, 现代的高性能编译器在目标码优化上都具备对指令进行乱序优化的能力.
  • 编译器可以对访存的指令进行乱序,减少逻辑上不必要的访存,以及尽量提高Cache命中率和CPU的Load/Store单元的工作效率.
  • 解决编译乱序问题,需要通过barrier()编译屏障, 阻挡编译器的优化, 保证屏障前的语句和屏障后的语句顺序不乱.
    例如,CODE A 编译后, 在 CODE B 之前.
#define barrier() __asm__ __volatile__("" : : : "memory")
CODE A;
barrier();
CODE B;

2.3.2 执行乱序

  • 执行乱序则是处理器运行时的行为,即后发射的指令还是可能先执行完,这是处理器的**乱序执行(Out-of-Order Execution)**策略.
  • 处理器为了解决多核间一个核的内存行为对另外一个核可见的问题,引入了一些内存屏障的指令
  • DMB(数据内存屏障): 在DMB之后的显示内存访问执行前,保证所有在DMB指令之前的内存访问完成.
    保证 CODE A 执行完后,才会执行 CODE B
CODE A
DMB
CODE B
  • DSB(数据同步屏障):等待所有在DSB指令之前的指令完成(位于此指令前的所有显式内存访问均完成,位于此指令前的所有缓存、跳转预测和TLB维护操作全部完成)
  • ISB(指令同步屏障):清空流水线,使得所有ISB之后执行的指令都是从缓存或内存中获得的。
  • DSB 和DMB区别在于:DMB可以继续执行之后的指令,只要这条指令不是内存访问指令; DSB不管它后面的什么指令,都会强迫CPU等待它之前的指令执行完毕
  • ISB不仅做了DSB所做的事情,还将流水线清空,于是他们的重量级排序可以是:ISB>DSB>DMB。
  • Linux内核的自旋锁、互斥体等互斥逻辑,需要用到上述指令:在请求获得锁时,调用屏障指令;在解锁时,也需要调用屏障指令

2.4 并发控制方式

2.4.1 中断屏蔽

  • CPU一般都具备屏蔽中断和打开中断的功能,其可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生.

  • 中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于Linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也得以避免.

  • 驱动编程中不值得推荐,驱动通常需要考虑跨平台特点而不假定自己在单核上运行.

  • local_irq_disable()和local_irq_enable()都只能禁止和使能本CPU内的中断,因此并不能解决SMP多CPU引发的竞态

local_irq_disable()
Critical Section
local_irq_enable()
  • local_irq_save()和local_irq_restore()除了禁止和使能中断的操作以外,还保存目前CPU的中断位信息.
local_irq_save(flags)
Critical Section
local_irq_restore(flags)
  • local_bh_disable()和local_bh_enable()禁止和使能中断的底半部

2.4.2 原子操作

  • 原子操作可以保证对一个整型数据的修改是exclusive。Linux内核提供了对位和整型变量进行原子操作的函数。
  • ARM处理器底层使用LDREXSTREX指令 TODO(local and global monitor)
typedef struct {
	int counter;
} atomic_t;

//定义一个整型原子变量,并初始化为1
atomic_t v = ATOMIC_INIT(1);
//原子变量自减1
atomic_dec(&v);
//原子变量自加1
atomic_inc(&v);
//读取原子变量的值
atomic_read(&v);
//原子变量自减1,并与0比较,如果为0则返回true,否则返回false
atomic_dec_and_test(&v);
  • 使用原子变量使设备只能被一个进程打开
atomic_t v = ATOMIC_INIT(1);
int eric_open(struct inode *inode, struct file *filp)
{
	......
	if(!atomic_dec_and_test(&v)){
	    atomic_inc(&v);
	    return -EBUSY;	//设备已被打开,返回
	}
	......
	return 0;			//设备打开成功
}
int eric_release(struct inode *inode, struct file *filp)
{
	......
 	atomic_inc(&v);		//释放设备
	......
	return 0;
}

2.4.3 自旋锁(Spin Lock)

2.4.3.1 基本定义
typedef struct {
	u32 slock;
} arch_spinlock_t;
typedef struct raw_spinlock {
	arch_spinlock_t raw_lock;
} raw_spinlock_t;
typedef struct spinlock {
		struct raw_spinlock rlock;
} spinlock_t;

spinlock_t lock
spin_lock_init(&lock)
spin_lock(&lock)
Critical Section
spin_unlock(&lock)
  • 自旋锁主要针对SMP或单CPU但内核可抢占的情况,对于单CPU和内核不支持抢占的系统,自旋锁退化为空操作.
  • 在多核SMP的情况下,任何一个核拿到了自旋锁,该核上的抢占调度也暂时禁止了,但是没有禁止另外一个核的抢占调度。为什么会禁止抢占调度?,如下代码中调用preempt_disable() 关闭抢占调度.
spin_lock()-->raw_spin_lock()-->__raw_spin_lock()
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
	preempt_disable();		//关闭抢占调度
	spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
	LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
  • 尽管使用自旋锁可以保证临界区不受别的CPU和本CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候,还可能受到中断和底半部(BH)的影响

  • 在多核编程的时候,如果进程和中断访问同一片临界资源,一般需要在进程上下文中调用spin_lock_irqsave()/spin_unlock_irqrestore(),在中断上下文中调用spin_lock()/spin_unlock()

  • 如何避免核间和核内并发
    在CPU0上,无论是进程上下文,还是中断上下文获得了自旋锁,此后,如果CPU1无论是进程上下文,还是中断上下文,想获得同一自旋锁,都必须忙等待,这避免一切核间并发的可能性。
    由于每个核的进程上下文持有锁的时候用的是spin_lock_irqsave(),所以该核上的中断是不可能进入的,这避免了核内并发的可能性。

2.4.3.2 使用注意事项(TODO)
  • 只有在占用锁的时间极短的情况下,使用自旋锁才是合理的
  • 自旋锁可能导致系统死锁。引发这个问题最常见的情况是递归使用一个自旋锁, 一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁
  • 在自旋锁锁定期间不能调用可能引起进程调度的函数
  • 在单核情况下编程时,也应该认为自己的CPU是多核的,驱动特别强调跨平台的概念。在单CPU的情况下,若中断和进程可能访问同一临界区,进程里调用spin_lock_irqsave()是安全的,在中断里其实不调用spin_lock()也没有问题,因为spin_lock_irqsave()可以保证这个CPU的中断服务程序不可能执行。但是,若CPU变成多核,spin_lock_irqsave()不能屏蔽另外一个核的中断,所以另外一个核就可能造成并发问题。因此,无论如何,在中断服务程序里也应该调用spin_lock()
2.4.3.3 使用自旋锁使设备只能被一个进程打开
int count = 0;
int eric_open(struct inode *inode, struct file *filp)
{
	......
	spin_lock(&lock);
	if(count){
	    spin_unlock(&lock);
	    return -EBUSY;	//设备已被打开,返回
	}
	......
	count++;
	spin_unlock(&lock);
	return 0;			//设备打开成功
}
int eric_release(struct inode *inode, struct file *filp)
{
	......
 	spin_lock(&lock);		//释放设备
 	count--;
 	spin_unlock(&lock);
	......
	return 0;
}

2.4.4 读写自旋锁(TODO)

2.4.5 顺序锁(TODO)

2.4.6 读-复制-更新(RCU)(TODO)

2.4.7 完成量(TODO)

2.4.8 信号量(semaphore)

  • 信号量的值可以是0、1或者n,允许调用它的线程进入睡眠状态,试图获得某一信号量的进程会导致对处理器拥有权的丧失,也即出现进程的切换.
  • 信号量与操作系统中的经典概念PV操作对应。P(S):①将信号量S的值减1,即S=S-1;②如果S≥0,则该进程继续执行;否则该进程设置为等待状态,排入等待队列。V(S):①将信号量S的值加1,即S=S+1;②如果S<=0,唤醒队列中等待信号量的进程;否则该进程继续执行.
  • s.value>=0时,s.queue(等待状态)为空;s.value<0时,s.value的绝对值为s.queue(等待状态)中等待进程的个数

网友: 将信号量视为夜总会里的蹦蹦跳跳。一次有大量的人员被允许进入俱乐部。如果俱乐部已满,则不允许任何人进入,但是一旦一个人离开,另一个人可能会进入

struct semaphore {
	//lock是个自旋锁变量,用于实现对信号量的另一个成员count的原子操作
	raw_spinlock_t		lock;
	//无符号整型变量count用于表示通过该信号量允许进入临界区的执行路径的个数 
	unsigned int		count;
	//wait_list用于管理所有在该信号量上睡眠的进程,无法获得该信号量的进程将进入睡眠状态
	struct list_head	wait_list;
};
//sema_init函数来初始化该信号量
static inline void sema_init(struct semaphore *sem, int val);
//驱动中常用
int down_interruptible(struct semaphore *sem);
void up(struct semaphore *sem);

2.4.9 互斥锁(mutex)

  • 互斥锁是一种特殊的信号量,这种情况下信号量的count值为1.
//mutex定义和初始化
DEFINE_MUTEX(mutexname); //=struct mutex mutexname; mutex_init(mutexname);
void mutex_lock(struct mutex *lock);
void mutex_unlock(struct mutex *lock);
  • 互斥锁是进程级的,用于多个进程之间对资源的互斥,虽然也是在内核中,但是该内核执行路径是以进程的身份,代表进程来争夺资源的。如果竞争失败,会发生进程上下文切换,当前进程进入睡眠状态,CPU将运行其他进程。
  • 当锁不能被获取到时,使用互斥锁的开销是进程上下文切换时间,使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定)。若临界区比较小,宜使用自旋锁,若临界区很大,应使用互斥锁
  • 互斥体所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区
  • 互斥体存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在互斥体和自旋锁之间只能选择自旋锁
Linux 中断上下文为什么不允许睡眠 (TODO)
  • Linux的调度是针对线程级别的,中断上下文不与某个线程关联,没有上下文,不需要睡眠调度;
  • 由于中断上下文没有与之关联的进程上下文,如果因为睡眠被调度走了,那就没有办法调度回来。(schedule里pick_next_task的时候,只能选中下一个task,但中断上下文不是一个task)

2.5 增加并发控制后的字符设备驱动 (TODO)

3 设备驱动阻塞与非阻塞I/O

3.1 基本概念

  • 阻塞操作:指进程在执行设备操作时,若不能获得资源,则挂起进程直到满足可操作的条件后再进行操作。被挂起的进程进入睡眠状态,被从调度器的运行队列移走,直到等待的条件被满足。
  • 非阻塞操作:指进程在不能进行设备操作时,并不挂起,它要么放弃,要么不停地查询,直至可以进行操作为止。

3.2 I/O操作模式

  • 根据不同的需求和使用场景,Linux内核支持不同的I/O操作模式,称为设备的I/O模型,这些模型根据同步与异步、阻塞与非阻塞可以划分为四大类:同步阻塞I/O同步非阻塞I/O异步阻塞I/O异步非阻塞I/O

3.2.1 同步阻塞I/O

  • 应用程序执行一个系统调用对设备进行read/write操作,这种操作会阻塞应用程序直到设备完成read/write操作。设备驱动程序需要实现file_operations对象中的read和write方法。
  • 【另一种理解】当应用程序分别以同步非阻塞I/O模式进行read()、write()等系统调用时,若设备的资源不能获取时,应在设备驱动的xxx_read()、xxx_write()等操作中将进程阻塞直到资源可以获取,此后,应用程序的read()、write()等调用才返回,整个过程仍然进行了正确的设备访问,用户并没有感知到;
    在这里插入图片描述

3.2.2 同步非阻塞I/O

  • 应用程序分别以同步非阻塞I/O模式进行read()、write()等系统调用时,若设备的资源不能获取时,应在设备驱动的xxx_read()、xxx_write()等操作应立即返回,read()、write()等系统调用也随即被返回,应用程序收到-EAGAIN返回值,应用程序需要不停地查询。
    在这里插入图片描述

3.2.3 异步阻塞I/O

  • 这种模式的I/O操作并不是阻塞在设备的读写操作本身,而是阻塞在某一组设备文件的描述符上,当其中的某些描述符上代表的设备对读写操作已经就绪时,阻塞状态将被解除,用户程序随后可以对这些描述符代表的设备进行读写操作。Linux的字符设备驱动程序需要实现file_operations对象中的poll方法以支持这种I/O模式。
  • 【例子】如果一个应用程序需要监控多个I/O设备,那在访问一个I/O设备进入睡眠后,就不能做其他事情了, 例如一个应用程序监控鼠标事件和键盘事件,并且读取摄像头数据,同步阻塞I/O和同步非阻塞I/O就无法满足要求.
    在这里插入图片描述

3.2.4 异步非阻塞I/O

  • 在这种I/O操作模式下,读写操作会立即返回,应用程序继续工作,应用程序的读写请求将被放入一个请求队列中由设备在后台异步完成,当设备完成了本次的读写操作时,将通过信号或者回调函数的方式通知用户程序。
  • Linux系统的设备中,块设备和网络设备的I/O模型属于异步非阻塞型。
  • 对于字符设备而言,极少有驱动程序需要去实现这种模式的I/O操作。
    在这里插入图片描述

3.2.5 异步非阻塞I/O和同步阻塞I/O

在这里插入图片描述

  • 但区别在于 Signal Driver IO 是在数据可读后就通过 SIGIO 信号通知应用程序数据可读了,之后由应用程序来实际读取数据,拷贝数据到 User Space。而 AIO 是注册一个读任务后,直到读任务真的完全完成后才会通知应用层。

  • 个人觉得这四大类:同步阻塞I/O同步非阻塞I/O异步阻塞I/O异步非阻塞I/O 还需要好好想想, 同步阻塞和异步阻塞I/O并没有体现出来名字差距.

  • 我所理解的同步和异步. 两个小偷A和B,一墙之隔,同步的状态是 A将东西抛给B,B接到后,告诉A请继续扔;异步的状态是A只管将东西抛到墙外, 然后告诉B来取.

  • A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;

  • An asynchronous I/O operation does not cause the requesting process to be blocked;

  • Linux IO模式及 select、poll、epoll详解

  • 深入学习理解 IO 多路复用

  • IO - 同步,异步,阻塞,非阻塞 (亡羊补牢篇)

3.3 应用程序设置阻塞和非阻塞方式

  • 通过open函数设置
//通过open设置应用程序以非阻塞方式访问设备
open("dev/char", O_RDWR|O_NONBLOCK);
//通过open设置应用程序以阻塞方式访问设备
open("dev/char", O_RDWR|O_NONBLOCK);
  • 通过fcntlioctl设置
fcntl(fd, F_SETFL; O_NONBLOCK);

3.4 唤醒阻塞进程(TODO)

  • 进程在以阻塞方式访问设备,设备不能获取资源时,进程将进入睡眠状态,它将CPU资源“礼让”给其他进程。因为阻塞的进程会进入睡眠状态,所以必须确保有一个地方能够唤醒休眠的进程。
  • 唤醒阻塞进程的地方最大可能发生在中断里面,因为在硬件资源获得的同时往往伴随着一个中断。
  • 在Linux驱动程序中,可以使用等待队列(Wait Queue)来实现阻塞进程的唤醒。
//等待队列结构体
struct wait_queue_head {
	spinlock_t		lock;
	struct list_head	head;
};
typedef struct wait_queue_head wait_queue_head_t;

//定义并初始化等待队列头部eric_queue_h
DECLARE_WAIT_QUEUE_HEAD(eric_queue_h) ==> wait_queue_head_t eric_queue_h; init_waitqueue_head(&eric_queue_h);

//定义等待队列元素eric_queue_node, 并设置当前tsk
DECLARE_WAITQUEUE(name, tsk)

//添加和删除等待队列元素
void add_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry);
void remove_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry);

//等待事件
//等待condition发生, wq_head队列被唤醒
wait_event(wq_head, condition)	
//wait_event()和wait_event_interruptible()的区别在于后者可以被信号打断,而前者不能
wait_event_interruptible(wq_head, condition)
//增加阻塞等待的超时时间(timeout),以jiffy为单位
//在timeout到达时,不论condition是否满足,均返回
wait_event_timeout(wq_head, condition, timeout)
//wait_event_time()和wait_event_interruptible_time()的区别在于后者可以被信号打断,而前者不能
wait_event_interruptible_timeout(wq_head, condition, timeout)

//唤醒队列
void __wake_up(struct wait_queue_head *wq_head, unsigned int mode,int nr_exclusive, void *key)
//wake_up()和wake_up_interrupt()会唤醒以eric_queue_head作为等待队列头部的队列中所有的进程
//wake_up()应该与wait_event()或wait_event_timeout()成对使用, wake_up()可唤醒处于TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE的进程.
1.wake_up(&eric_queue_head);
#define wake_up(x)			__wake_up(x, TASK_NORMAL, 1, NULL)

//wake_up_interruptible()应与wait_event_interruptible()或wait_event_interruptible_timeout()成对使用, wake_up_interruptible()只能唤醒处于TASK_INTERRUPTIBLE的进程.
2.wake_up_interrupt(&eric_queue_head);
#define wake_up_interruptible(x)	__wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)

//在等待队列上睡眠(TODO)

4 参考书籍

  • Linux 设备驱动开发详解-基于最新的 Linux 4.0内核(宋宝华编著)
  • 深入Linux设备驱动程序内核机制(陈学松著)
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值