Linux设备驱动程序(三)——字符驱动

简介: 本章的目的是编写一个完整的字符设备驱动,我们开发一个字符驱动是因为这一类适合大部分简单硬件设备,字符驱动也比块驱动易于理解。

前言


本章的目的是编写一个完整的字符设备驱动,我们开发一个字符驱动是因为这一类适合大部分简单硬件设备,字符驱动也比块驱动易于理解。


贯穿本章,我们展示从一个真实设备驱动提取的代码片段: scull( Simple Character Utility for Loading Localities):区域装载的简单字符工具。scull 是一个字符驱动,操作一块内存区域好像它是一个设备,在本章,因为 scull 的这个特殊之处, 我们可互换地使用“设备”这个词和"scull 使用的内存区"。


scull 的优势在于它不依赖硬件,scull 只是操作一些从内核分配的内存。


一、scull 的设计


编写驱动的第一步是定义驱动将要提供给用户程序的能力(机制)。scull 源码实现下面的设备. 模块实现的每种设备都被引用做一种类型。


scull0 ~ scull3

4 个设备,每个由一个全局永久的内存区组成,全局意味着如果设备被多次打开,设备中含有的数据由所有打开它的文件描述符共享,永久意味着如果设备关闭又重新打开,数据不会丢失。它可以用惯常的命令来存取和测试,例如 cp,cat 以及 I/O 重定向。


scullpipe0 ~ scullpipe3

4 个 FIFO(先入先出)设备,行为类似管道, 一个进程读的内容来自另一个进程所写的,如果多个进程读同一个设备,它们竞争数据。scullpipe 的内部将展示阻塞读写和非阻塞读写如何实现,而不必采取中断,尽管真实的驱动使用硬件中断来同步它们的设备,阻塞和非阻塞操作的主题是重要的并且与中断处理是分开的。


scullsingle、scullpriv、sculluid、scullwuid

这些设备与 scull0 相似,但是在什么时候允许打开上有一些限制。第一个snullsingle 只允许一次一个进程使用驱动,而 scullpriv 对每个虚拟终端(或者 X 终端会话)是私有的,因为每个控制台/终端上的进程有不同的内存区。sculluid 和 scullwuid 可以多次打开,但是一次只能是一个用户;前者返回一个"设备忙"错误,如果另一个用户锁着设备,而后者实现阻塞打开。

本章涉及 scull0 到 scull3 的内部结构


二、主设备号和次设备号


对字符设备的访问是通过文件系统内的设备名称进行的,那些名称被称为文件系统的特殊文件、设备文件,或者简单称之为文件系统树的节点;它们通常位于 /dev 目录。字符驱动的特殊文件由使用 ls -l 的输出的第一列的"c"标识,块设备也出现在 /dev 中,但是它们由"b"标识。


在Linux系统上输入:ls -l /dev 观察输出。我们会发现如下面所示的文件的详细信息:

总用量 0
crw-r--r--  1 root root     10, 235  5月  9 21:44 autofs
drwxr-xr-x  2 root root         560  5月 28 22:53 block
drwxr-xr-x  2 root root          80  5月 13 14:15 bsg
crw-------  1 root root     10, 234  5月  9 21:44 btrfs-control
drwxr-xr-x  3 root root          60  5月  9 21:43 bus
lrwxrwxrwx  1 root root           3  5月 13 14:08 cdrom -> sr0
drwxr-xr-x  2 root root        3900  5月 29 11:07 char
crw--w----  1 root tty       5,   1  5月  9 21:44 console
lrwxrwxrwx  1 root root          11  5月  9 21:43 core -> /proc/kcore
drwxr-xr-x  6 root root         120  5月  9 21:44 cpu
crw-------  1 root root     10, 124  5月  9 21:44 cpu_dma_latency
crw-------  1 root root     10, 203  5月  9 21:44 cuse
drwxr-xr-x  8 root root         160  5月  9 21:44 disk
drwxr-xr-x  2 root root          60  5月  9 21:43 dma_heap
crw-rw----+ 1 root audio    14,   9  5月  9 21:44 dmmidi
drwxr-xr-x  3 root root         100  5月  9 21:44 dri
crw-------  1 root root     10, 126  5月  9 21:44 ecryptfs

在设备文件项中有 2 个数(由一个逗号分隔)在最后修改日期前面,这里通常是文件长度出现的地方,这些数字是给特殊设备的主次设备编号,上面的列表显示了一个典型系统上出现的几个设备,它们的主编号是 5、10、14, 而次编号是 1, 3, 9…。


主编号标识设备相连的驱动,例如 /dev/null 和 /dev/zero 都由驱动 1 来管理,而虚拟控制台和串口终端都由驱动 4 管理;次编号被内核用来决定引用哪个设备,依据你的驱动是如何编写的,你可以从内核得到一个你的设备的直接指针, 或者可以自己使用次编号作为本地设备数组的索引。


例如我们要操作某个设备,首先,我们要知道设备在/dev下的设备文件名。这个设备文件提供主设备号以及次设备号。然后内核通过设备文件提供的主设备找到设备驱动程序(操作设备由驱动程序实现)。最后通过主设备号和次设备构成的设备号找到正确的设备。有了操作的对象(设备)和操作的方法(驱动程序)那就可以完成了我们的要求。


一个驱动程序可以操作多个设备,所以不同的设备可以具有相同的主设备号。

因为我们在添加设备到内核的时候我们是关联设备号的,不同的设备可以具有相同的主设备号,那不同的次设备号和相同的主设备号结合就可以构成不同的设备号了,就标识了不同的设备了。


1、设备编号的内部表达


在上面主设备和次设备的介绍中我们提到设备编号,真正能标识不同的设备的是设备编号,每一个设备有一个唯一的设备编号。


在内核中,用 dev_t 类型来保存设备编号,它是一个32位的数,其中前12位用来表示主设备号,后20位用来表示次设备号。这个类型在中定义。


设备号由主设备号和次设备号构成。内核提供三个宏来实现这三个东西的转换。分别是:

MKDEV(int major, int minor)    //将主次设备号转换成 dev_t 类型
MAJOR(dev_t dev)               //获得dev_t dev中的主设备号
MINOR(dev_t dev)。             //获得dev_t dev中的次设备号

这三个宏在中定义。


2、分配和释放设备编号


内核是通过设备编号找到设备的,理所当然地要建立一个字符设备那必须要获得字符设备编号。要建立多少个字符设备就要得到多少个字符设备编号。


完成这一工作有两种方式,一种是静态获取,一种是动态获取,分别由:register_chrdev_region() 和 alloc_chrdev_region() 这两个函数实现。成功调用申请设备编号的函数后,在系统的 /proc/devices 下就会包含设备以及设备主设备号的信息。函数在中声明。字符设备不再使用时应该释放它们占用的编号。设备编号的释放使用 unregister_chrdev_region()。这三个函数的原型如下:


静态获取设备编号:

int register_chrdev_region(dev_t first, unsigned int count, char *name); 
返回: 0  成功分配.返回:负数  分配失败. 
first 是你要分配的设备编号的起始范围值. first 的次编号部分常常是 0, 但是没有特别要求. 
count 是你请求的连续设备编号的总数. 注意, 如果 count 太大, 那么所请求的范围可能和下一个主设备号重叠, 但是只要请求的编号范围可用, 一切都仍然会正确工作.。
name 是应当连接到这个编号范围的设备的名字; 

动态获取设备编号:

int alloc_chrdev_region(dev_t *dev, unsigned int firstminor,  unsigned int count, char *name); 
dev 是一个只用于输出的参数, 在成功完成调用后将保存已分配范围的第一个编号. fisetminor 是要使用的被请求的第一个次设备号,它常常是 0. 
count 和 name 参数如同给 request_chrdev_region 函数的一样。

成功调用申请设备编号的函数后,在系统的 /proc/devices下就会包含设备以及设备主设备号的信息。

释放设备编号:

void unregister_chrdev_region(dev_t first, unsigned int count);

这三个函数在中声明。


静态分配:即直接使用内核源码数的 Documentation/devices.txt 中定义的未使用的主设备号。它对应于register_chrdev_region 函数。

动态分配:即动态分配一个主设备号,它对应于 alloc_chrdev_region 函数。


3、主编号的动态分配


尽量使用动态分配的方法,这样就能在加载甚至编译模块的时候设定主设备号,大大优于静态分配。

动态分配的缺点:由于分配的主设备号不能始终保持一致,所以无法在分配设备号之前创造设备节点。 但一旦设备号被分配之后, 你可以通过 /proc/devices 来读取它。


典型的 /proc/devices 文件如下所示:

Character devices:
  1 mem
  4 /dev/vc/0
  4 tty
  4 ttyS
  5 /dev/tty
  5 /dev/console
  5 /dev/ptmx
  5 ttyprintk
  6 lp
  7 vcs
 10 misc
 13 input
 14 sound/midi
 14 sound/dmmidi
 21 sg
 29 fb
 89 i2c
 99 ppdev
108 ppp
116 alsa
128 ptm
136 pts
180 usb
189 usb_device
202 cpu/msr
203 cpu/cpuid
204 ttyMAX
226 drm
238 ipmidev
239 hidraw
240 ttyDBC
241 vfio
242 wwan_port
243 bsg
244 watchdog
245 remoteproc
246 ptp
247 pps
248 rtc
249 dma_heap
250 dax
251 dimmctl
252 ndctl
253 tpm
254 gpiochip
Block devices:
  7 loop
  8 sd
  9 md
 11 sr
 65 sd
 66 sd
 67 sd
 68 sd
 69 sd
 70 sd
 71 sd
128 sd
129 sd
130 sd
131 sd
132 sd
133 sd
134 sd
135 sd
253 device-mapper
254 mdp
259 blkext

分配主设备号最佳的方式是:默认采用动态分配,同时保留在加载甚至是编译时指定主设备号的余地,scull 的实现采用了这种方式工作;它使用一个全局变量,scull_major,用来保存所选择的设备号(也有一个用于次设备号的 scull_minor 变量)。这个变量初始化为 SCULL_MAJOR,定义在 scull.h 中,发布的源码中的 SCULL_MAJOR 的缺省值是 0,意思是"使用动态分配"。用户可以使用这个默认值或者选择某个特定的主设备号,而且既可以在编译前修改宏定义,也可以通过 insmod 命令行指定一个值给 scull_major。


下面是 scull.c 中用来获取主设备号的代码:

  int result, i;
  dev_t dev = 0;    //设备编号
/*
*申请分配设备编号,根据scull_major的值是否为0,分别采用静态分配设备编号(register_chrdev_region)
*或动态分配设备编号(alloc_chrdev_region)的方法。scull_major代表主设备号,它的值是怎么确定的呢?
*scull_init_module函数中,如果用户没有通过命令行参数给scull_major赋任意大于0的值,
*则会采用alloc_chrdev_region动态分配设备编号。如果用户给scull_major赋了一个大于0值,
*则采用register_chrdev_region静态申请设备编号。(因此这里默认动态分配)
*/
  if (scull_major) {               //scull_major在头文件中定义为0
    dev = MKDEV(scull_major, scull_minor);    //将主次设备号转换成dev_t类型  
    result = register_chrdev_region(dev, scull_nr_devs, "scull");
  } else {
    result = alloc_chrdev_region(&dev, scull_minor, scull_nr_devs,
        "scull");
    scull_major = MAJOR(dev);      //得到主设备号
  }
  if (result < 0) {                    //判断设备号是否分配成功
    printk(KERN_WARNING "scull: can't get major %d\n", scull_major);
    return result;
  }


三、一些重要的数据结构


大部分的基础性的驱动操作包括 3 个重要的内核数据结构,称为 file_operations,file 和 inode。


1、文件操作(file_operation)


我们已经为自己保留了一些设备编号来使用,但未将任何程序操作连接到这些编号上。 file_operation 结构是一个字符驱动如何建立这个连接,这个结构,定义在 ,是一个函数指针的集合。这里用 scull 的 file_operation 结构来解析:

struct file_operations scull_fops = { 
  .owner = THIS_MODULE, 
  .llseek = scull_llseek, 
  .read = scull_read, 
  .write = scull_write, 
  .ioctl = scull_ioctl, 
  .open = scull_open, 
  .release = scull_release, 
}; 

在你通读 file_operations 方法的列表时,你会注意到不少参数包含字串 __user,这种注解是一种文档形式而已,表明指针是一个用户空间地址,因此不能被直接引用。对于正常的编译,__user 没有任何效果,但是它可被外部检查软件使用来找出对用户空间地址的错误使用


struct module *owner

第一个 file_operations 成员根本不是一个操作;它是一个指向拥有这个结构的模块的指针。这个成员用来在它的操作还在被使用时阻止模块被卸载,几乎所有时间中,它被简单初始化为 THIS_MODULE, 一个在  中定义的宏。


loff_t (*llseek) (struct file *, loff_t, int);

llseek 方法用作改变文件中的当前读/写位置,并且新位置作为(正的)返回值。loff_t 参数是一个"long offset",并且就算在 32 位平台上也至少要占 64 位数据宽度。出错时返回一个负值。如果这个函数指针是 NULL,seek 调用会以潜在地无法预知的方式修改 file 结构中的位置计数器( 在"file 结构" 一节中描述)。


ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);

用来从设备中获取数据,在这个位置的一个空指针导致 read 系统调用以 -EINVAL(“Invalid argument”)失败,一个非负返回值代表了成功读取的字节数(返回值是一个 “signed size” 类型,常常是目标平台本地的整数类型)。


ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);

发送数据给设备。如果 NULL,-EINVAL 返回给调用 write 系统调用的程序,如果非负,返回值代表成功写的字节数。


int (*open) (struct inode *, struct file );

尽管这常常是对设备文件进行的第一个操作,不要求驱动声明一个对应的方法,如果这个项是 NULL,设备打开一直成功,但是你的驱动不会得到通知。


int (*release) (struct inode *, struct file *);

在文件结构被释放时引用这个操作,如同 open,release 可以为 NULL。


2、文件结构(struct file)


struct file,定义于 ,是设备驱动中第二个最重要的数据结构。文件结构代表一个打开的文件(它不特定给设备驱动;系统中每个打开的文件有一个关联的 struct file 在内核空间)。它由内核在 open 时创建,并传递给在该文件上操作的任何函数,直到最后的 close 函数。在文件的所有实例都关闭后,内核释放这个数据结构。


在内核源码中,指向 struct file 的指针常常称为 file 或者 filp(“file pointer”),我们将一直称这个指针为 filp 以避免和结构自身混淆,因此,file 指的是结构本身,而 filp 是结构指针。


struct file 的重要成员:

mode_t f_mode;

此文件模式通过 FMODE_READ、FMODE_WRITE 识别了文件为可读的、可写的,或者是二者。在open 或 ioctl 函数中可能需要检查此域以确认文件的读/写权限,你不必直接去检测读或写权限,因为在进行 open/ioctl 等操作时内核本身就需要对其权限进行检测。


loff_t f_pos;

当前读写文件的位置,loff_t 在所有平台都是 64 位。如果想知道当前文件当前位置在哪,驱动可以读取这个值,但不要去修改它的位置;read、write 会使用它们接收到的最后一个指针参数来更新文件的位置,而不是直接对 filp ->f_pos 操作。这一规则的例外是在 llseek 方法,llseek 方法的目的就是用于改变文件的位置。


unsigned int f_flags;

文件标志,如 O_RDONLY、O_NONBLOCK 以及 O_SYNC。在驱动中还可以检查 O_NONBLOCK 标志查看是否有非阻塞请求。其它的标志较少使用。特别地注意的是,读写权限的检查是使用 f_mode 而不是 f_flags 。所有的标量定义在头文件  中。


struct file_operations *f_op;

与文件相关的各种操作。当文件需要迅速进行各种操作时,内核分配这个指针作为它实现文件打开、读、写等功能的一部分。 filp->f_op 其值从未被内核保存作为下次的引用,即你可以改变与文件相关的各种操作,这种方式效率非常高。


void *private_data;

在驱动调用 open 方法之前, open 系统调用设置此指针为 NULL 值。你可以很自由的将其做为你自己需要的一些数据域或者不管它,如,你可以将其指向一个分配好的数据,但是你必须记得在 file struct 被内核销毁之前在 release 方法中释放这些数据的内存空间。 private_data 用于在系统调用期间保存各种状态信息是非常有用的。


3、inode 结构


内核使用 inode 结构体在内核内部表示一个文件。因此,它与表示一个已经打开的文件描述符的结构体(即 file 文件结构)是不同的,我们可以使用多个file 文件结构表示同一个文件的多个文件描述符,但此时,所有的这些 file 文件结构全部都必须只能指向一个 inode 结构体。


inode 结构体包含了一大堆文件相关的信息,但是就针对驱动代码来说,我们只要关心其中的两个域即可:


dev_t i_rdev;

表示设备文件的结点,这个域实际上包含了设备号。


struct cdev *i_cdev;

struct cdev 是内核的一个内部结构,它是用来表示字符设备的,当 inode 结点指向一个字符设备文件时,此域为一个指向 inode 结构的指针。


此外,内核也提供了两个宏可以从 inode 结点中获取主次设备号,宏的原型如下:

unsigned int iminor(struct inode *inode);
unsigned int imajor(struct inode *inode); 


四、字符设备的注册


内核在内部使用类型 struct cdev 的结构来代表字符设备,在内核调用你的设备操作前,你编写分配并注册一个或几个这些结构,为此,你的代码应当包含


有 2 种方法来分配和初始化一个这些结构,如果你想在运行时获得一个独立的 cdev 结构,你可以为此使用这样的代码:

struct cdev *my_cdev = cdev_alloc();
my_cdev->ops = &my_fops;

但是,偶尔你会想将 cdev 结构嵌入一个你自己的设备特定的结构;scull 这样做了,在这种情况下,你应当初始化你已经分配的结构,使用:

void cdev_init(struct cdev *cdev, struct file_operations *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 是有几个重要事情要记住,第一个是这个调用可能失败,如果它返回一个负的错误码,你的设备没有增加到系统中。 cdev_add 一返回,你的设备就是"活的"并且内核可以调用它的操作,因此,在驱动程序还没有完全准备好处理设备上的操作时,就不能调用 cdev_add。


从系统去除一个字符设备,调用:


void cdev_del(struct cdev *dev);


在将 cdev 结构传递到 cdev_del 函数之后,就不应再访问 cdev结构了。


1、scull 中的设备注册


在内部,scull 使用一个 struct scull_dev 类型的结构表示每个设备,这个结构定义为:

struct scull_dev {
  struct scull_qset *data; /* Pointer to first quantum set */
  int quantum; /* the current quantum size */
  int qset; /* the current array size */
  unsigned long size; /* amount of data stored here */
  unsigned int access_key; /* used by sculluid and scullpriv */
  struct semaphore sem; /* mutual exclusion semaphore */
  struct cdev cdev; /* Char device structure */
};

我们在遇到它们时讨论结构中的各个成员,但是现在我们关注于 cdev,我们的设备与内核接口的 struct cdev,这个结构必须初始化并且如上所述添加到系统中,处理这个任务的 scull 代码是:

static void scull_setup_cdev(struct scull_dev *dev, int index)
{
  int err, devno = MKDEV(scull_major, scull_minor + index);
  cdev_init(&dev->cdev, &scull_fops);
  dev->cdev.owner = THIS_MODULE;
  dev->cdev.ops = &scull_fops;
  err = cdev_add (&dev->cdev, devno, 1);
  /* Fail gracefully if need be */
  if (err)
  printk(KERN_NOTICE "Error %d adding scull%d", err, index);
}

因为 cdev 结构嵌在 struct scull_dev 里面,因此必须调用 cdev_init 来执行该结构的初始化。


2、早期的办法


没有更新到 2.6 内核接口的老代码,注册一个字符设备的经典方法是使用:

int register_chrdev(unsigned int major, const char *name, struct file_operations *fops);

这里,major 是设备的主编号,name 是驱动的名称(出现在 /proc/devices),fops 是缺省的 file_operations 结构。一个对 register_chrdev 的调用将为给定的主编号注册 0 - 255 作为次编号,并且为每一个建立一个缺省的 cdev 结构。


如果使用 register_chrdev,从系统中去除你的设备的正确的函数是:

int unregister_chrdev(unsigned int major, const char *name);

major 和 name 必须和传递给 register_chrdev 的相同,否则调用会失败。


五、open 和 release


1、open 方法


在大部分驱动中,open 应当进行下面的工作:


检查设备特定的错误(例如设备没准备好,或者类似的硬件错误)

如果它第一次打开,则对其进行初始化

如果必要,更新 f_op 指针

分配并填写要放进 filp->privatedata 的数据结构

open 方法的原型是:

int (*open)(struct inode *inode, struct file *filp);

其中的 inode 参数在其 i_cdev 字段中包含了我们所需要的信息,即我们先前设置的 cdev 结构。唯一的问题是,我们通常不需要 cdev 结构本身,而是希望得到包含 cdev 结构的 scull_dev 结构,可以通过定义在  中的 container_of 宏实现:

container_of(pointer, container_type, container_field);

这个宏需要一个 container_field 字段的指针,该字段包含在 container_type 类型的结构中,然后返回包含该字段的结构指针,在 scull_open 中,这个宏用来找到适当的设备结构:

struct scull_dev *dev; /* device information */
dev = container_of(inode->i_cdev, struct scull_dev, cdev);
filp->private_data = dev; /* for other methods */

一旦它找到 scull_dev 结构,scull 在文件结构的 private_data 成员中存储一个它的指针,为以后更易存取。

scull_open 的代码(稍微简化过)是:

int scull_open(struct inode *inode, struct file *filp)
{
  struct scull_dev *dev;    /*获取设备信息 */
  dev = container_of(inode->i_cdev, struct scull_dev, cdev);   //调用container_of宏,通过cdev成员得到包含该cdev的scull_dev结构
  filp->private_data = dev;    /*将得到的scull_dev结构保存在filp->private_data中,因为open结束后,后面的read,write等操作使用同一个filp变量,它们即可以从filp->private_data中直接取出scull_dev结构体来使用。*/
  /*如果scull设备文件是以只写的方式打开,则要调用scull_trim将scull设备清空。up(&dev->sem)是进行加锁解锁操作,进行互斥。*/
  if ( (filp->f_flags & O_ACCMODE) == O_WRONLY) {
    if (down_interruptible(&dev->sem))
      return -ERESTARTSYS;
    scull_trim(dev); 
    up(&dev->sem);
  }
  return 0;          /* 打开成功 */
}


2、release 方法


release 方法的作用正好与 open 相反,这个设备方法都应该完成下面的任务;


释放 open 分配在 filp->private_data 中的任何东西

在最后的 close 关闭设备

scull 的基本形式没有需要关闭的硬件,因此所需的代码量最少:

/*
*这个函数直接返回0。因为scull设备是内存设备,关闭设备时也没有什么需要特别
*理的,所以这个函数比较简单。
*/
int scull_release(struct inode *inode, struct file *filp)
{
  return 0;
}


你可能想知道当一个设备文件关闭次数超过它被打开的次数会发生什么? 答案很简单: 不是每个 close 系统调用引起调用 release 方法,只有真正释放设备数据结构的调用会调用这个方法。


六、scull 的内存使用


scull 使用的内存区,也称为一个设备,长度可变,你写的越多,它增长越多;用更短的文件以覆盖方式写设备时则会变短。


scull 驱动引入 2 个核心函数来管理 Linux 内核中的内存,这些函数定义在<linux/slab.h>,是:

void *kmalloc(size_t size, int flags);
void kfree(void *ptr);

对 kmalloc 的调用试图分配 size 字节的内存,返回值是指向那个内存的指针或者如果分配失败为 NULL,flags 参数用来描述内存应当如何分配,对于现在,我们一直使用 GFP_KERNEL。分配的内存应当用 kfree 来释放,我们不应该将非 kmalloc 返回的指针传递给 kfree,但是,将 NULL指针传递给 kfree 是合法的。


在 scull 中,每个设备都是一个指针链表,其中每个指针都指向一个 scull_qset 结构,每个这样的结构,默认的, 通过一个中间指针数组最多指向 4 兆字节,发行代码使用了一个 1000 个指针的数组,每个指针指向一个 4000 字节的区域,我们称每个内存区域为一个量子,而这个指针数组(或者它的长度)称为量子集,scull 设备和它的内存区如图所示:

选择的参数使得向 scull 中写入一个字节消耗 8000 或 12000 个字节的内存:每个量子占用 4000 个字节,而一个量子集占用 4000 或 8000 个字节(根据指针在目标平台上是用 32 位还是 64 位表示)。


为量子和量子集选择合适的值是一个策略问题而非机制问题,在 scull 中,用户可以掌管改变这些值,有几个途径:编译时间通过改变 scull.h 中的宏 SCULL_QUANTUM 和 SCULL_QSET,在模块加载时设定整数 scull_quantum 和 scull_qset,或者使用 ioctl 在运行时改变当前值和缺省值。


我们已经看到内部表示 scull 设备的 scull_dev 结构,该结构的 quantum 和 qset 字段分别代表设备的量子和量子集大小,但是,实际的数据是由另外的结构跟踪,我们称为 struct scull_qset:

struct scull_qset {
  void **data;
  struct scull_qset *next;
};

下面代码片段展示了实际中 struct scull_dev 和 struct scull_qset 是如何被用来持有数据的,sucll_trim 函数负责释放整个数据区,由 scull_open 在文件为写而打开时调用。它简单地遍历链表,并且释放它发现的任何量子和量子集:

int scull_trim(struct scull_dev *dev)
{
  struct scull_qset *next, *dptr;
  int qset = dev->qset;   /* "dev" is not-null */
  int i;
  for (dptr = dev->data; dptr; dptr = next) {   /* dev->data指向第一个量子集scull_qset,所以这个for循环每次循环处理一个scull_qset。 */
    if (dptr->data) {
      for (i = 0; i < qset; i++)        //这个for循环循环1000次,因为每个量子集有1000个量子。
         kfree(dptr->data[i]);           //每次kfree释放一个量子的内存空间。
      kfree(dptr->data);                //释放量子集数组占用的内存空间。
      dptr->data = NULL;               //将指针重新初始化为NULL。防止野指针。
    }
    next = dptr->next;                    //next指向下一个量子集。
    kfree(dptr);                          //释放scull_qset占用的内存空间。
  }
  /*
  *恢复初始状态。
  */
  dev->size = 0;
  dev->quantum = scull_quantum;
  dev->qset = scull_qset;
  dev->data = NULL;
  return 0;
}

scull_trim 也用在模块清理函数中,来归还 scull 使用的内存给系统。


七、读和写


read 和 write 方法都进行类似的任务,就是拷贝数据到应用程序空间和反过来到应用程序中拷贝数据,因此它们的原型相当相似:

ssize_t read(struct file *filp, char __user *buff, size_t count, loff_t *offp); 
ssize_t write(struct file *filp, const char __user *buff, size_t count, loff_t *offp);

对于 2 个方法:


filp 是文件指针;

count 是请求的传输数据大小;

buff 参数指向用户空间的缓存区, 这个缓冲区要么保存写入的数据,要么是一个存放新读入新数据的空缓冲区。

offp 是一个指向一个"long offset type"(长偏移量类型)对象指针,它指出用户在文件中进行存取操作的位置,返回值是一个"signed size type"有符号的尺寸类型。

需要再次强调的是,read 和 write 方法的 buff 参数是用户空间指针,因此,它不能被内核代码直接解引用。


scull 中的 read 和 write 代码要做的工作就是在用户地址空间和内核地址空间之间进行整段数据的拷贝,这个能力由下列内核函数提供的,它们拷贝一个任意的字节序列,这也是大多数 read 和 write 方法实现的核心部分:

unsigned long copy_to_user(void __user *to,const void *from,unsigned long count);
unsigned long copy_from_user(void *to,const void __user *from,unsigned long count);

这 2 个函数的作用不限于在内核空间和用户空间之间拷贝数据,它们还检查用户空间的指针是否有效,如果指针无效,就不会进行拷贝;另一方面,如果在拷贝中遇到一个无效地址,则只会复制部分数据。

下图表明了一个典型的 read 实现是如何使用其参数的:


1、read 方法


scull_read

ssize_t scull_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
  struct scull_dev *dev = filp->private_data; 
  struct scull_qset *dptr;        
  int quantum = dev->quantum, qset = dev->qset;
  int itemsize = quantum * qset;     
  int item, s_pos, q_pos, rest;
  ssize_t retval = 0;
  if (down_interruptible(&dev->sem))     //获得互斥锁
    return -ERESTARTSYS;
  if (*f_pos >= dev->size)
    goto out;
  if (*f_pos + count > dev->size)
    count = dev->size - *f_pos;
  item = (long)*f_pos / itemsize;        // item代表要读的数据起始点在哪个scull_qset中
  rest = (long)*f_pos % itemsize;        
  s_pos = rest / quantum; q_pos = rest % quantum;     //s_pos代表要读的数据起始点在哪个量子中,q_pos代表要读的数据的起始点在量子的具体哪个位置
  dptr = scull_follow(dev, item);    /*调用scull_follow函数,这个函数的第二个参数代表要读的数据在哪个scull_qset中,该函数的作用是返回item指定的scull_qset。如果scull_qset不存在,还要分配内存空间,创建指定的scull_qset。*/
  if (dptr == NULL || !dptr->data || ! dptr->data[s_pos])  //如果指定的scull_qset不存在,或者量子指针数组不存在,或者量子不存在,都退出。
    goto out; 
  /* 设置scull_read一次最多只能读一个量子 */
  if (count > quantum - q_pos)
    count = quantum - q_pos;
    /*调用copy_to_user(buf, dptr->data[s_pos] + q_pos, count)函数,将数据拷贝到用户空间。*/
  if (copy_to_user(buf, dptr->data[s_pos] + q_pos, count)) {
    retval = -EFAULT;
    goto out;
  } 
  *f_pos += count;    //读取完成后,新的文件指针位置向前移动count个字节
  retval = count;    
  out:
  up(&dev->sem);
  return retval;     //返回读取到的字节数,即count。
}

read 的返回值的解释:


如果这个值等于传递给 read 系统调用的 count 参数,请求的字节数已经被传送,这是最好的情况。

如果是正数,但是小于 count,只有部分数据被传送。这可能由于几个原因,依赖于设备,常常,应用程序重新试着读取,例如,如果你使用 fread 函数来读取,库函数重新发出系统调用直到请求的数据传送完成。

如果值为 0,到达了文件末尾(没有读取数据)。

一个负值表示有一个错误,这个值指出了什么错误,根据 <linux/errno.h>。 出错的典型返回值包括 -EINTR(被打断的系统调用)或者 -EFAULT(坏地址)。


2、write 方法


scull_write

ssize_t scull_write(struct file *filp, const char __user *buf, size_t count,loff_t *f_pos)
{
  struct scull_dev *dev = filp->private_data;
  struct scull_qset *dptr;
  int quantum = dev->quantum, qset = dev->qset;
  int itemsize = quantum * qset;
  int item, s_pos, q_pos, rest;
  ssize_t retval = -ENOMEM; 
  if (down_interruptible(&dev->sem))      //获得互斥锁
    return -ERESTARTSYS;
  item = (long)*f_pos / itemsize;         // item代表要写的数据起始点在哪个scull_qset中
  rest = (long)*f_pos % itemsize;
  s_pos = rest / quantum; q_pos = rest % quantum;   //s_pos代表要写的数据起始点在哪个量子中,q_pos代表要写的数据的起始点在量子的具体哪个位置
 /*调用scull_follow函数,这个函数的第二个参数代表要读的数据在哪个scull_qset中,
  该函数的作用是返回item指定的scull_qset。*/
  dptr = scull_follow(dev, item);
  if (dptr == NULL)
    goto out;
  if (!dptr->data) {       //如果指定的量子指针数组不存在,则分配内存空间,创建量子指针数组。
    dptr->data = kmalloc(qset * sizeof(char *), GFP_KERNEL);
    if (!dptr->data)
      goto out;
    memset(dptr->data, 0, qset * sizeof(char *));
  } 
  if (!dptr->data[s_pos]) {   //如果指定量子不存在,则分配内存空间,创建量子。
    dptr->data[s_pos] = kmalloc(quantum, GFP_KERNEL);
    if (!dptr->data[s_pos])
      goto out;
  }
  /* 限定一次最多只能写满一个量子。 */
  if (count > quantum - q_pos)
    count = quantum - q_pos;
   /*调用copy_from_user,将用户数据写到量子中。*/
  if (copy_from_user(dptr->data[s_pos]+q_pos, buf, count)) {
    retval = -EFAULT;
    goto out;
  }
  *f_pos += count;   //将文件指针后移count字节。
  retval = count;    //设置返回值为count,即写入字节数。
        /* 更新文件大小。 */
  if (dev->size < *f_pos)
    dev->size = *f_pos;
  out:
  up(&dev->sem);
  return retval;
}

与read类似,write也能传送少于要求的数据,根据返回值规则:


如果返回值等于 count,要求的字节数已被传送。

如果返回值是正值,但是小于 count,只有部分数据被传送,程序很可能再次试图写入剩下的数据。

如果值为 0,意味什么没有写入。这个结果不是一个错误,没有理由返回一个错误码;标准库会重复调用write。

一个负值表示发生一个错误,和read一样,有效的错误码是定义在 <linux/errno.h>中。


3、readv 和 writev


unix 系统很早就支持了两个可选的系统调用:readv 和 writev。这些“向量”型的函数具有一个结构数组,每个结构包含一个指向缓冲区的指针和一个长度值,readv 调用可用于将指定数量的数据依次读入每个缓冲区。writev 则是把各个缓冲区的内容搜集起来,并将它们在一次写入操作中进行输出。


向量操作的原型:

ssize_t (*readv) (struct file *filp, const struct iovec *iov, unsigned long count, loff_t *ppos);
ssize_t (*writev) (struct file *filp, const struct iovec *iov, unsigned long count, loff_t *ppos);

这里,filp 和 ppos 参数与 read 和 write 的相同,iovec 结构,定义于 <linux/uio.h>,如同;

struct iovec
{
  void __user *iov_base; __kernel_size_t iov_len;
};


每个 iovec 描述了一块要传送的数据;它开始于 iov_base (在用户空间)并且有 iov_len字节长,count 参数告诉有多少 iovec 结构,这些结构由应用程序创建,但是内核在调用驱动之前拷贝它们到内核空间。


八、使用字符设备程序的方法


前面了解了字符设备程序的一些基本操作方法,但是要使用这些方法的话,还需要给创建出来的设备分配设备节点才行,而创建设备节点又有两种方式:


1、手动创建设备节点


手动创建设备节点的话,顾名思义,是在命令行敲命令来给新创建的设备分配设备节点,命令为:

mkmod 设备名称 主设备号 次设备号

创建完设备节点后,就可以通过open、read、write方法来对设备进行操作了。


2、自动创建设备节点


自动创建设备节点,就是在代码里调用device_create()或device_register()或device_add()方法来创建设备节点,这三个方法位于<linux/device.h>头文件中,其中最常见的就是用device_create()函数来创建设备节点了,但是在之后阅读内核源码的过程中却很少见device_create()的踪影了,取而代之的是device_register()与device_add(),将device_create()函数展开不难发现:其实device_create()只是device_register()的封装,而device_register()则是device_add()的封装。



目录
相关文章
|
1月前
|
Linux 开发工具 Perl
在Linux中,有一个文件,如何删除包含“www“字样的字符?
在Linux中,如果你想删除一个文件中包含特定字样(如“www”)的所有字符或行,你可以使用多种文本处理工具来实现。以下是一些常见的方法:
41 5
|
2月前
|
Linux 开发工具 Perl
Linux命令替换目录下所有文件里有"\n"的字符为""如何操作?
【10月更文挑战第20天】Linux命令替换目录下所有文件里有"\n"的字符为""如何操作?
47 4
|
3月前
|
Linux 程序员 编译器
Linux内核驱动程序接口 【ChatGPT】
Linux内核驱动程序接口 【ChatGPT】
|
4月前
|
存储 缓存 Unix
Linux 设备驱动程序(三)(上)
Linux 设备驱动程序(三)
52 3
|
4月前
|
Linux
Linux 设备驱动程序(四)
Linux 设备驱动程序(四)
32 1
|
4月前
|
存储 数据采集 缓存
Linux 设备驱动程序(三)(中)
Linux 设备驱动程序(三)
52 1
|
3月前
|
Linux API
Linux里的高精度时间计时器(HPET)驱动 【ChatGPT】
Linux里的高精度时间计时器(HPET)驱动 【ChatGPT】
|
4月前
|
存储 缓存 安全
Linux 设备驱动程序(三)(下)
Linux 设备驱动程序(三)
41 0
|
4月前
|
安全 Linux 程序员
Linux 设备驱动程序(二)(下)
Linux 设备驱动程序(二)
33 0
|
Shell Linux Perl
在Linux命令行中进行大小写字符转换
在Linux命令行中进行大小写字符转换
在Linux命令行中进行大小写字符转换