在前文中,我们已经知道了如何编写一个简单的字符设备驱动。本文将在此基础上为这个字符设备驱动增加阻塞功能。不过在此之前,我们会先做一些准备工作。
阻塞和非阻塞I/O
阻塞和非阻塞I/O是设备访问内核的两种不同的模式。进程以阻塞方式访问设备并对其进行操作时,如果不能及时获得I/O资源则会被挂起,直到满足可操作的条件后才进行相应的操作。这个被挂起的进程会进入睡眠状态,并被移至某个等待队列;当条件满足时候,会被移出这个等待队列。这里所说的等待队列以及相关操作在上文已说明,在此不再赘述。非阻塞方式是指进程在不能进行设备操作时并不被挂起,它要么放弃操作,要么不停进行查询,直至可以进行相关的设备操作为止。
我们现在已经知道,用户空间中的应用程序通过read()和write()等统一的系统调用来访问设备文件。而这些系统调用函数最终则会调用设备驱动中的XXX_read()和XXX_write()函数。因此,如果我们在设备驱动中实现了阻塞功能(具体会落实到某个操作函数),当应用程序进程不能及时获得设备资源时就会将该进程阻塞到资源可访问为止。那么XXX_read()和XXX_write()等函数也就不会立即返回,read()和write()等系统调用也就不会立即返回。整个阻塞-唤醒的过程用户是无法感知到的。从用户的角度来看,他们会认为直接就可以对此设备进行操作。
相反,如果设备驱动中的操作函数是非阻塞的,那么当设备资源不可用时,设备驱动中的XXX_read()和XXX_write()等函数会立即返回,那么read()和write()等系统调用也会立即返回。从用户角度来看,此时访问设备文件就出错了。
支持阻塞操作的字符设备驱动
接下来要分析的这个字符设备驱动同样使用一个全局的字符串数组globalmem来存储字符数据。XXX_read()负责将内核空间的数据(此处即为globalmem中的字符串)拷贝到用户空间,实现用户空间对设备文件的读操作;XXX_write()负责将用户空间的数据拷贝到内核空间,实现用户空间对该设备文件的写操作。另外,为了更好的演示本文所述的阻塞操作,我们对这个字符串数组globalmem进行这样的限制:当它为空时,读进程不能进行读操作;当它为满的时候,写进程不能进行写操作。当读了count字节的数据后,还要将globalmem中这些被读的数据移出这个全局数组。
如果你理解了前面那个最基本的字符设备驱动的话,除了上述的不同外,基本上没有什么地方你看不懂的。这个举例的完整代码在这里。
static char globalmem[BUF_NUM]; static wait_queue_head_t rdwait; static wait_queue_head_t wrwait; static struct semaphore mutex; static int len; ssize_t myblock_read(struct file*,char*,size_t count,loff_t*); ssize_t myblock_write(struct file*,char*,size_t count,loff_t*); struct file_operations fops= { .read=myblock_read, .write=myblock_write, }; static int __init myblock_init(void) { int ret; printk("myblock module is working..\n"); ret=register_chrdev(MAJOR_NUM,"edsionte_block_cdev",&fops); if(ret<0) { printk("register failed..\n"); return 0; } else { printk("register success..\n"); } init_MUTEX(&mutex); init_waitqueue_head(&rdwait); init_waitqueue_head(&wrwait); return 0; }
在内核模块加载函数中,先申请字符设备号;再初始化互斥信号量mutex;最后分别初始化了读等待队列头和写等待队列头。另外定义了一个全局变量len来记录当前globalmem中实际的字节数,而BUF_NUM则是最大长度。
在读函数中,我们先创建一个代表当前进程的等待队列结点wait,并把它加入到读等待队列当中。但这并不意味着当前进程就已经完全睡眠了,还需要调度函数的调度。我们前面已经说过,当共享数据区的数据长度为0时,就应该阻塞该进程。因此,在循环中,首先将当前进程的状态设置TASK_INTERRUPTIBLE。然后利用schedule函数进行重新调度,此时,读进程才会真正的睡眠,直至被写进程唤醒。在睡眠途中,如果用户给读进程发送了信号,那么也会唤醒睡眠的进程。
当共享数据区有数据时,会将count字节的数据拷贝到用户空间,并且唤醒正在睡眠的写进程。当上述工作完成后,会将当前进程从读等待队列中移除,并且将当前进程的状态设置为TASK_RUNNING。
关于从全局缓冲区移出已读数据,这里要特别说明一下。这里利用了memcpy函数将以(globalmem+count)开始的(len-count)字节的数据移动到缓冲区最开始的地方。
另外,在上述操作过程中,还加入了互斥信号量防止多个进程同时访问共享数据len和globalmem。
ssize_t myblock_read(struct file*fp,char*buf,size_t count,loff_t*offp) { int ret; DECLARE_WAITQUEUE(wait,current); down(&mutex); add_wait_queue(&rdwait,&wait); while(len==0) { __set_current_state(TASK_INTERRUPTIBLE); up(&mutex); schedule(); if(signal_pending(current)) { ret=-1; goto signal_out; } down(&mutex); } if(count>len) { count=len; } if(copy_to_user(buf,globalmem,count)==0) { memcpy(globalmem,globalmem+count,len-count); len-=count; printk("read %d bytes\n",count); wake_up_interruptible(&wrwait); ret=count; } else { ret=-1; goto copy_err_out; } copy_err_out:up(&mutex); signal_out:remove_wait_queue(&rdwait,&wait); set_current_state(TASK_RUNNING); return ret; }
在写函数中,如果检测到globalmem当前的长度是BUF_NUM,则阻塞当前的进程;否则,从用户空间将数据拷贝到内核空间。写函数的控制流程大致与读函数相同,只不过对应的等待队列是写等待队列。
ssize_t myblock_write(struct file*fp,char*buf,size_t count,loff_t*offp) { int ret; DECLARE_WAITQUEUE(wait,current); down(&mutex); add_wait_queue(&wrwait,&wait); while(len==BUF_NUM) { __set_current_state(TASK_INTERRUPTIBLE); up(&mutex); schedule(); if(signal_pending(current)) { ret=-1; goto signal_out; } down(&mutex); } if(count>(BUF_NUM-len)) { count=BUF_NUM-len; } if(copy_from_user(globalmem+len,buf,count)==0) { len=len+count; printk("written %d bytes\n",count); wake_up_interruptible(&rdwait); ret=count; } else { ret=-1; goto COPY_ERR_OUT; } signal_out:up(&mutex); COPY_ERR_OUT:remove_wait_queue(&wrwait,&wait); set_current_state(TASK_RUNNING); return ret; }
上述就是支持阻塞模式的字符设备驱动。关于上述程序更多的解释如下:
1.两种睡眠。当读进程读数据时,如果发现写进程正在访问临界区,那么它会因为不能获得互斥信号量而阻塞;而当读进程获得信号量后,如果当前globalfifo的数据数为0,则会阻塞。这种阻塞是由我们在设备驱动中实现的。
2.两种唤醒。当写进程离开临界区并释放信号量时,读进程会因信号量被释放而唤醒;当写进程往globalfifo中写入了数据时,读进程会被写进程中的唤醒函数所唤醒。特别的,如果读进程是以轻度睡眠方式睡眠的,那么用户可以通过发送信号而唤醒睡眠的读进程。
3.唤醒后如何执行。无论因哪种方式而睡眠,当读进程被唤醒后,均顺序执行接下来的代码。
4.down操作和add_wait_queue操作交换。在原程序中,读进程先获取信号量,再将读进程对应的等待队列项添加到读等待队列中。如果交换,当读进程的等待队列项加入到等待队列后,它可能又会因未获得信号量而阻塞。
5.up操作和remove_wait_queue操作交换。这两个操作分别对应标号out和out2。如果读进程从内核空间向用户空间拷贝数据失败时,就会跳转到out。因为读进程是在获得信号量后才拷贝数据的,因此必须先释放信号量,再将读进程对应的等待队列项移出读等待队列。而当读进程因信号而被唤醒时,则直接跳转到out2。此时读进程并没有获得信号量,因此只需要移出队列操作即可。如果交换上述两个操作,读进程移出等待队列时还未释放互斥信号量,那么写进程就不能写。而当读进程因信号而唤醒时,读进程并没有获得信号量,却还要释放信号量。
通过下述方法,你就可以体验到以阻塞方式访问设备文件。
1.make编译文件,并插入到内核;
2.创建设备文件结点:sudo mknod /dev/blockcdev c major_num 0;
3.修改设备文件权限:sudo chmod 777 /dev/blockcdev;
4.终端输入:cat /dev/blockcdev&;即从字符设备文件中读数据,并让这个读进程在后台执行,可通过ps命令查看到这个进程;
5.中断继续输入:echo ‘I like eating..’ > /dev/blockcdev;即向字符设备文件中写入数据;
通过上述的步骤,可以看到,每当利用echo命令写入数据时,后台运行的读进程就会读出数据,否则读进程一直阻塞。此外,如果你愿意的话,还可以分别编写一个读写进程的程序进行测试。
对这个程序的透彻理解,请看globalfifo精彩问答:http://blog.csdn.net/sfrysh/archive/2010/08/13/5809926.aspx
[回复一下]
siling 回复:
5月 7th, 2011 at 22:22
@clj, clj的说法,你说的那个我也看过原帖,是chinaunix上面的,并没有这个解释的清楚。。。
[回复一下]
先回复一下clj的说法,你说的那个我也看过原帖,是chinaunix上面的,并没有这个解释的清楚。。。
不过我还是要问楼主,我有一个问题,就是xxx_read()在调用schedule(); 函数后睡眠,如果用户程序没有使用如ctrl +c的
中断信号的话,1.是不是应该在xxx_write函数中被唤醒,可是问题就在这,在xxx_write函数中调用weak_up后,由于唤醒了
read函数,那么是不是write函数被打断了,程序直接开始运行if(signal_pending(current)) 开始的代码,而write函数后面
的代码就不运行了2,或者是write仍运行,直到write函数执行完,才运行read函数,不过如果是这样的话,我还有一个问题
,在weak_up后,我的理解是read进程应该是重新进入就绪态任务列表中,可是当write函数执行完后,又没有进行schedule()
的任务调度(我没有看到这样的代码,是不是操作系统后台自动进行(当一个进程执行完,系统会自动进行调度?)),如果
不是我上面所说的那样,那么read函数又是如何又重新或得运行了呢?
最后一个问题:这个set_current_state(TASK_RUNNING有何用处,我的理解是如果能够从while()中退出来,那么当前进程
就是RUNNING,如果是这样的话,为何还要重新设置呢?
不知道我的问题是不是太多了,不过我确实不理解,谢谢了。。。。
[回复一下]
先回复一下clj的说法,你说的那个我也看过原帖,是chinaunix上面的,并没有这个解释的清楚。。。
[回复一下]
hello!
[回复一下]
楼主:不好意思,昨天由于我发送的时候返回来信息时没有任何提示,导致我发送了好几遍,并且给你的邮箱也发送了,真的很抱歉,不过我还是希望你能给我解答一下我说的问题,我相信,也有很多我这样的linux驱动爱好者会碰到这些问题,搜索到最后也会搜素到这里,希望你能给我解答一下,方便大家能过继续学习下去,谢谢
[回复一下]
edsionte 回复:
5月 8th, 2011 at 21:46
@gududesiling, 最近比较忙,所以没能及时回复,见谅。
1.内核中的进程分为多个状态,每个状态的进程对应一个链表。
TASK_RUNNING是可运行状态,即操作系统原理中的就绪状态和运行状态的集合。
起始linux内核中运行状态的进程并不需要用链表来组织,因为每个CPU上任何时刻只有一个进程
处于运行,其他为就绪。
因此唤醒函数只是说将该进程从等待队列上删除,再加到就绪队列末尾。
而至于这个进程何时被执行则有内核的进程调度机制管理。
2.set_current_state只是改变进程的状态,因此还需要调度函数的重新调度。
3.程序中调用schedule()可以让调度程序立马调度一次,由于读/写进程之前被设置为阻塞,因此
读/写进程就立马阻塞了。
我没有直接回答你的问题,不过你可以就此回答继续探索一下。
关于这些问题你可以参考《linux内核设计与实现》相关章节。
[回复一下]
gududesiling 回复:
5月 8th, 2011 at 23:21
@edsionte, 你好,谢谢你的回答,
不过你的回答应该是回应了我猜测的第2中情况是正确的,也就是程序会把write_xx函数执行完,然后内核会自动调度,然后执行read_xx阻塞后的代码(此时调用read_xx的进程应该是running状态了吧)。可是关于最后一个TASK_RUNNING状态疑惑,你没有直接说明,我还是不是很理解,为啥要重新设置呢?按我的理解应该是read函数能从while()中退出(也就是说现在已经唤醒,并且调度已经调度到了这个read_xx进程),证明此时是内核调度调用到了,并且程序从while()退出到设置TASK_RUNNING状态之前的这段copy数据的代码,进程不是在执行么?难道这个不是说明当前进程是running么,如果不是running,那如何会执行这一段的程序(如果说是进程已经设置为阻塞但由于还没来得及schedule()(因此才会执行这段代码),那么我说调度之后有没有重新设置成阻塞态的代码,那么也就不存在来得及之说了。。。)。我说的很拉杂,不知道不知道你能不能理解,希望你能给我讲解一下?
[回复一下]
edsionte 回复:
5月 11th, 2011 at 10:10
@gududesiling, 你的想法没错,从while中出来是应该处于running状态了,可是你必须
设置他的状态,因为进while后状态设备为阻塞了。进程的状态必须让内核知道,即便
我们认为它“应该”是running了,可是内核不知道阿。你必须有设备状态这个动作。
具体如何设备,你可以参考这个函数的源码。
[回复一下]
好的,将的太漂亮了,我理解了,谢谢,呵呵
[回复一下]
edsionte 回复:
5月 12th, 2011 at 14:33
@gududesiling, 感谢您的支持。
[回复一下]