在前文中,我们已经知道了如何编写一个简单的字符设备驱动。本文将在此基础上为这个字符设备驱动增加阻塞功能。不过在此之前,我们会先做一些准备工作。
阻塞和非阻塞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命令写入数据时,后台运行的读进程就会读出数据,否则读进程一直阻塞。此外,如果你愿意的话,还可以分别编写一个读写进程的程序进行测试。