LINUX设备驱动程序笔记(三)字符设备驱动程序

      <一>.主设备号和次设备号
       对字符设备的访问时通过文件系统内的设备名称进行的。那些设备名称简单称之为文件系统树的节点,它们通常位于/dev目录。字符设备驱动程序的设备文件可通过ls -l命令输出的第一列中的‘c‘来识别。块设备同样位于/dev下,由字符‘b‘标识
crw-rw----  1 root root    253,   0 2013-09-11 20:33 usbmon0
crw-rw----  1 root root    253,   1 2013-09-11 20:33 usbmon1
crw-rw----  1 root root    253,   2 2013-09-11 20:33 usbmon2
brw-rw----  1 root disk      8,   0 2013-09-11 20:33 sda
brw-rw----  1 root disk      8,   1 2013-09-11 20:34 sda1
brw-rw----  1 root disk      8,   2 2013-09-11 20:33 sda2
       主设备号标识设备对应的驱动程序,现代的Linux内核允许多个驱动程序共享主设备号,但大多数设备仍然按照"一个主设备对应一个驱动程序"的原则组织。
       次设备号由内核使用,用于正确确定设备文件所指的设备。可以通过次设备号获得一个指向内核设备的直接指针,也可将次设备号当做设备本地数组的索引。不管用哪种方式,处理知道次设备号用来指向驱动程序所实现的设备之外,内核本身基本不关心关于次设备号的任何其他信息。
       1.设备标号的内部表达
       在内核中,dev_t类型(在<linux/types.h>中定义)用来保存设备编号----包括主设备号和次设备号。要获得dev_t的主设备号和次设备号,要使用<linux/kdev_t.h>中定义的宏MAJOR/MINOR:MAJOR(dev_t dev); MINOR(dev_t dev);相反,如果需要将主设备号与次设备号转换为dev_t类型,则使用MKDEV(int major, int minor);
       2.分配和释放设备编号
      在建立一个字符设备前,首先要做的就是获得一个或者多个设备编号。完成该工作的必要函数在<linux/fs.h>中定义:
int register_chrdev_region(dev_t first, unsigned int count, char *name);
       a.first是要分配的设备编号范围的起始值,first的此设备号经常被置为0,但对该函数来讲并不是必须的。
       b.count是所请求的连续设备编号的个数。
       c.name是和该编号范围关联的设备名称,它将出现在/proc/devices和sysfs中。
       d.register_chrdev_region的返回值在分配成功时为0,在错误情况下,将返回一个负的错误码,并且不能使用所请求的编号区域。

       

      如果不知道设备将要使用哪些主设备号,就要使用alloc_chrdev_region动态分配设备编号,

int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, char *name);
      a.dev是仅用于输出的参数,在成功调用后将保存已分配范围的第一个编号。
      b.firstminor应该是要使用的被请求的第一个次设备号,它通常是0。 
      c.count和name参数与register_chrdev_region函数式一样的。
     不论采用哪种方法分配设备编号,都应该在不再使用它们时释放这些设备编号,设备编号的释放需要使用下面的函数void unregister_chrdev_region(dev_t first, unsigned int count);通常我们在清除函数中调用nregister_chrdev_region函数。
       在用户空间程序可以访问上述设备编号之前,驱动程序需要将设备编号和内部函数连接起来,这些内部函数用来实现设备的操作。
       3.动态分配主设备号一部分主设备号已经静态地分配给了大部分常见设备。在内核源码树的Documentation/devicex.txt文件中可以找到这些设备的清单。对于一个驱动程序,讲义不要随便选择一个当前未使用的设备号作为主设备号,而应该使用动态分配机制获取主设备号。
        动态分配的缺点是:由于分配的主设备号不能保证始终一致,所以无法预先创建设备节点。为了加载一个使用动态主设备号的设备驱动程序,对insmod的调用可替换为一个简单的脚本,该脚本在调用insmod之后读取/proc/devices以获得新分配的主设备号,然后创建对应的设备文件。分配主设备号的最佳方式是:默认采用动态分配,同时保留在加载甚至是编译时指定主设备号的余地。


       <二> 一些重要的数据结构
        大部分基本的驱动程序操作涉及到三个重要的内核数据结构,分别是file_operations、file和inode。
        1.文件操作
         file_operations结构用来建立连接设备编号和驱动程序操作,该结构定义在<linux/fs.h>中,其中包含了一组函数指针。每个打开的文件(后面提到的file)和一组函数关联。驱动程序的操作主要用来实现系统调用,命名为open、read等,可以认为文件时一个“对象”,而操作它的函数式“方法”。file_operations结构或者指向这类结构的指针称为fops。这个结构中的每个字段都必须指向驱动程序中实现特定操作的函数,对于不支持的操作,对应的字段可置为NULL值。对于各个函数而言,如果对应字段被赋为NULL指针,那么内核的具体处理行为不尽相同。
       在file_operations里,有许多参数包含有__user字符串,它其实是一种形式的文档而已,表明指针式一个用户空间地址,因此,不能被直接引用。
        struct module *owner:指向“拥有”该结构的模块的指针。内核使用这个字段以避免在模块的操作正在被使用时卸载该模块。该成员被初始化为THIS_MODULE,它是定义在<linux/module.h>中
        int (*open)(struct inode *, struct file *):这是对设备文件的第一个操作,然而却不要求驱动程序一定要声明一个相
应的方法。如果这个入口为NULL,设备的打开操作永远成功,但系统不会通知驱动程序。
        int (*release)(struct inode *, struct file *):当file结构被释放时,将调用这个操作。与open相仿,也可将release设
置为NULL,release并不是在进程每次调用close时都会被调用。只要file结构被共享,release就会等到所有的副本都关闭之后才会得到调用。如果需要关闭任意一个副本时刷新那些待处理的数据,则应事先flush方法。
        int (*flush)(struct file *):对flush操作的调用发生在进程关闭设备文件描述符副本的时候,它应该执行设备上尚未完结的操作。如果flush被置为NULL,内核将简单忽略用户程序程序的请求。
        unsigned int (*poll)(struct file *, struct poll_table_struct *):poll方法是poll/epoll和select这三个系统调用的后端实现。poll方法应该返回一个位掩码,用来指出非阻塞的读取或写入是否可能,并且也会向内核提供调用进程置于休眠状态直到I/O变为可能时的信息。如果驱动程序将poll方法定义为NULL,则设备会被认为既可读也可写,并且不会被阻塞。
        ssize_t (*read)(struct file *, char __user *, size_t, lofft_t *):用来从设备中读取数据。该函数指针被赋予NULL时,将导致read系统调用出错并返回-EINVAL。函数返回非负值表示成功读取的字节数。
        ssize_t (*write)(struct file *, const char __user *, size_t, loff_t):向设备发送数据,如果没有这个函数,write
系统调用会向程序返回一个-EINVAL,如果返回值非负,则表示成功写入的字节数。
        2.file结构
        在<linux/fs.h>中定义的struct file是设备驱动程序所使用的第二个重要的数据结构。注意,file结构与用户空间程序中的FILE没有任何关联。FILE在C库中定义且不会出现在内核代码中,而struct file是一个内核结构,不会出现在用户程序中。
        file结构代表一个打开的文件,它由内核在open时创建,并传递给在该文件上进行操作的所有函数,知道最后的close函数。在文件的所有实例都被关闭之后,内核会释放这个数据结构。struct file中最重要的成员罗列如下:
mode_t f_mode:文件模式,它通过FMODE_READ和FMODE_WRITE位来标识文件是否可读或可写,由于内核在调用驱动程序的read和write前已经检查了访问权限,所以不必为这两个方法检查权限。在没有获得对应访问权限而打开文件的情况下,对文件的读写操作将被内核拒绝,驱动程序无需为此而作额外的判断。
        unsigned int f_flags:文件标志,如O_RDONLY/O_NONBLOCK/O_SYNC。为了检查用户请求是否是非阻塞式的操作,驱动程序需要检查O_NONBLOCK标志,而其他标志很少用到。注意,检查读/写权限应该查看f_mode而不是f_flags。所有这些标志都定义在<linux/fcntl.h>中loff_t f_pos:当前的读/写位置。loff_t是一个64位的数,如果驱动程序需要知道文件中的当前位置,可以读取这个值,但不要去修改它。read/write会使用它们接收到的最后那个指针参数来更新这个位置,而不是直接对file->f_pos进行操作。这个规则的一个例外是llseek方法,该方法的目的本身就是修改文件位置。
       struct file_operations *f_op:与文件相关的操作。内核在执行open操作时对这个指针赋值,以后需要处理这些操作时读取这个指针。file->f_op中的值决不会为方便引用而保存起来,也就是说,我们可以在任何需要的时候修改文件的关联操作,在返回给调用者之后,新的操作方法就会立即生效。
       void *private_data:open系统调用在调用驱动程序的open方法前将这个指针置为NULL。驱动程序可以将这个字段用于任何目的或者忽略这个字段。
       3.inode结构
      内核用inode结构在内部表示文件,因此它和file结构不同,后者表示打开的文件描述符。对单个文件,可能会有许多个表示打开的文件描述符的file结构,但他们都指向单个inode结构。该结构中只有两个字段对编写驱动程序有用:
       dev_t i_rdev:对表示设备文件的inode结构,该字段包含了真正的设备编号
       struct cdev *i_cdev:struct cdev是表示字符设备的内核的内部结构。当inode指向一个字符设备文件时,该字段包好了指向struct cdev结构的指针。为了防止内核版本的升级带来的不兼容问题,一般不直接使用i_rdev,而是使用下面的宏获得设备号:
        unsigned int iminor(struct inode *inode);  unsigned int imajor(struct inode *inode);


       <三>字符设备的注册
       在内核调用设备的操作之前,必须分配并注册一个或多个struct cdev结构。为此,必须包含<linux/cdev.h>,其中定义了该结构及相关的辅助函数:
       分配、初始化struct cdev结构的两种方式:
struct cdev *my_cdev = cdev_allo();
void cdev_init(struct cdev *cdev, struct file_operations *fops);
my_cdev->ops = &my_fops;
        还有一个struct cdev的字段需要初始化,和file_operations结构类似,struct cdev也有一个所有者字段,应被设为THIS_MODULE。
        在cdev结构设置好之后,最后的步骤就是通过下面的调用告诉内核该结构的信息:
int cdev_add(struct cdev *dev, dev_t num, unsigned int count);

        dev是cdev结构,num是该设备对应的第一个设备编号,count是应该和该设备关联的设备编号的数量,count经常取1。在使用cdev_add时,需要注意:

a.这个调用可能会失败。如果它返回一个负的错误满,则设备不会被添加到系统中。

b.只要cdev_add返回了,设备的操作就会被内核调用。因此,在驱动程序还没有完全准备好处理设备上的操作时,就不能调用cdev_add。

         要从系统中移除一个字符设备,做如下调用:void cdev_del(struct cdev *dev);将dev传递给cdev_del函数之后,就不应再访问cdev结构了。


早起的办法:

       注册字符设备驱动程序的经典方式:int register_chrdev(unsigned int major, const char *name, struct file_operations *fops);如果使用register_chrdev函数,将自己的设备从系统移除的正确函数是:int unregister_chrdev(unsigned int major, const char *name);


      <四>open和release
      1.open方法:open方法提供给驱动程序以初始化的能力,从而为以后的操作完成初始化做准备。在大部分驱动程序中,open应完成如下工作:

     a.检查设备特定的错误 

     b.如果设备是首次打开,则对其进行初始化 

     c.如果有必要,更新f_op指针 

     d.分配并填写置于filp->private_data里的数据结构

     首先要做的是确定要打开的具体设备,open方法原型是:int (*open)(struct inode *inode,struct file *filp);其中的inode参数在其i_cdev字段中包含了我们所需要的信息,即我们先前设置的cdev结构。唯一的问题是,我们通常不需要cdev结构本身,而是希望得到包含cdev结构的scull_dev结构。通过定义在<linux/kernel.h>中的container_of宏实现:container_of(pointer, container_type, container_field);这个宏需要一个container_field字段的指针,该字段包含在container_type类型的结构,然后返回包含该字段的结构指针。
struct scull_dev *dev; /*device information*/
dev = container_of(inode->i_cdev, struct scull_dev, cdev);
filp->private_data = dev; /*for other methods*/
      另一个确定要打开的设备的方法是:检查保存在inode结构体中的次设备号。
       2.release方法:release方法的作用正好与open相反,这个设备方法都应该完成下面的任务:

       a.释放由open分配的、保存在filp->private_data中的所有内容。

       b.在最后一侧关闭操作时关闭设备


      <五>read 和 write
read和write方法完成的任务相似,即拷贝数据到应用程序空间,或反过来从应用程序空间拷贝数据。
ssize_t read(struct file *filp, char __user *buff, size_t conut, loff_t *offp);
ssize_t write(struct file *filp, char __user *buff, size_t conut, loff_t *offp);
       参数filp是文件指针,参数count是请求传输的数据长度,参数buff是指向用户空间的缓冲区,这个缓冲区或者保护要写入的数据,或者是一个存放读入数据的空缓冲区。最后的offp是一个指向"long offset type"对象的指针,这个对象指明用户在文件中进行存取操作的位置。
      需要指出,read和write方法的buff参数是用户空间的指针,因此,内核代码不能直接引用其中的内容。出现这种限制的原因有如下几个:
      a.随着驱动程序所运行的架构的不同或者内核配置的不同,在内核模式中运行时,用户空间的指针可能是无效的。该地址可能根本无法被映射到内核空间,或者可能指向某些随机数据。
      b.即使该指针在内核空间中代表相同的东西,但用户空间的内存是分页的,而在系统调用被调用时,涉及到的内存可能根本不在RAM中。对用户空间内存的直接引用将导致页错误,而这对内核代码来说是不允许的发生的,其结果可能是一个"oops",它将导致调用该系统调用的进程的死亡。
      c.我们讨论的指针可能由用户程序提供,而该程序可能存在缺陷或者是个恶意程序。如果驱动程序盲目引用用户提供的指针,将导致系统出现打开的后门,从而允许用户空间程序访问或覆盖系统中的内存。如果读者不打算因为自己的驱动程序而危及用户系统的安全性,则永远不要直接引用用户空间指针。
       read和write代码要做的就是在用户地址空间和内核地址空间之间进行整段数据的拷贝。read和write方法的实现核心在:
unsigned long copy_to_user(void __user *to, const void *from, unsigned long count);
unsigned long copy_from_user(void __user *to, const void *from, unsigned long count);

       这两个函数的作用并不限于在内核空间和用户空间之间拷贝数据,它们还检查用户空间的指针是否有效。如果指针无效,就不会进行数据拷贝;另一方面,如果在拷贝过程中遇到无效地址,则仅仅会复制部分数据。在这两种情况下,返回值还需要拷贝的内存数量值。至于实际的设备方法,read方法的任务是从设备拷贝数据到用户空间,而write方法则是从用户空间拷贝数据到设备上。每次read或write系统调用都会请求一定数目的字节传输,不过驱动程序也并不限制小数据量的传输。无论传输多少数据,都应更新*offp所表示的文件位置,以便反应在新系统调用成功完成之后当前的文件位置。出错时,read和write方法都返回一个负值,大于等于0的返回值告诉调用程序成功传输了多少字节。如果在正确传输部分数据之后发生了错误,则返回值必须是成功传输的字节数。尽管内核函数通过返回负值来表示错误,而且返回值表明了错误的类型,但运行在用户空间的程序看到的时钟是作为返回值的-1。


      1.read方法:
      调用程序对read的返回值解释如下:
      a.如果返回值等于传递给read系统调用的count参数,则说明所请求的字节数传输成功完成了。
      b.如果返回值是正的,但是比count小,则说明只有部分数据成功传送。这种情况因设备的不同可能有许多原因。大部分情况下,程序会重新读数据。
      c.如果返回值为0,则表示已经到达了文件尾。
      d.负值意味着发生了错误,该值指明了发生了什么错误,错误码在<linux/errno.h>中定义。


      2.write方法:

     与read类似,根据如下返回值规则,write也能传输少于请求的数据量:
     a.如果返回值等于count,则完成了所请求数目的字节传送
     b.如果返回值是正的,但小于count,则只传输了部分数据。程序可能再次试图写入余下的数据。
     c.如果值为0,意味着什么也没写入,这个结果不是错误,而且也没有理由返回一个错误码。

     d.赋值意味着发生了错误,与read相同,有效的错误码定义在<linux/errno.h>中。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。