上一章内容中,我们简单梳理了字符设备驱动程序的相关知识以及编写一个字符设备驱动程序的大致流程,本章内容中,将根据上一章中讲述的内容,完成一个字符设备驱动程序的编写及用户程序对字符设备的访问。
字符设备驱动程序
在这个示例中,我们将创建一个名为 “hello_device” 的字符设备,并为其编写驱动。在该设备上,我们暂时只为其实现了 open 和 close 方法。在该代码中,我们将使用动态注册设备号及自动创建设备文件节点的方式,来编写我们的驱动程序。
源码
首先我们新建 HelloDevice.c 文件,并写入以下代码:
1 |
|
代码分析
头文件
代码的第 1-7 行引入了我们编写字符设备驱动程序所需的头文件。其中,linux/init.h、linux/module.h、linux/kernel.h 三个头文件是我们编写内核模块程序所必需的三个头文件,这点在前面的文章中已有叙述。linux/cdev.h、linux/types.h、linux/fs.h 三个头文件是编写字符设备驱动程序所必须的,我们在编写字符设备驱动程序是所使用到的相关结构体及函数都定义在这三个头文件中。
其中:
linux/cdev.h 中定义了 struct cdev 结构体及与之相关的函数;
linux/types.h 中定义了设备号的数据类型;
linux/fs.h* 中定义了 struct file_operations 结构体及对设备号的操作函数等。
开源协议
代码的第 8 行,我们设定了模块所遵循的开源协议。因为我们使用的是自动创建设备节点文件的方式,而该方式又包含在 GPL 协议中,所以我们的模块必须声明遵循该协议。
全局变量
代码的第 10-13 行中,我们定义了 4 个全局变量:
1 | static dev_t hello_device_num = 0; |
其中,hello_device_num 用来保存我们的设备号。因为在这个示例程序中,我们只定义了一个字符设备,所以 hello_device_num 保存的就是我该设备的设备号。如果我们申请了多个设备,因为这些设备的主设备号一般都是相同的,次设备号一般从 0 依次递增,因此我们只需要用这个变量来记录第一个设备号和我们的设备数量,就能够通过这一个设备号变量来获取到其他设备的设备号;
phello_device_cdev 变量是一个指针,指向了一个 struct cdev 的数据结构,内核使用该结构体来表示一个设备;
phello_device_class 变量是一个指向 struct class 数据结构的指针,为我们的字符设备创建一个类,我们的字符设备便属于这个类别中。主要用来内核使用 udev 为从这个类中获取信息自动创建设备节点;
phello_device 变量是一个指向 struce device 数据结构的指针,用来告诉内核使用 udev 自动为我们的设备创建设备节点。
其实在代码的第 25-31 行还定义了一个 hello_device_fops 变量,将会放在后续进行说明。
以上定义的几个变量都是全局变量,这是因为前面四个变量都需要向内核进行申请某些资源,因此在模块的退出函数中,应当对这些资源进行释放。为了能够在初始化函数及退出函数中都能访问到这些变量,所以将其定义为全局变量。而将 hello_device_fops 变量定义为全局变量只是习惯,也可以将其在使用的时候进行定义成局部变量,不过一般我们都遵循习惯将其定义为全局变量。
文件操作
在代码的第 15-31 行,定义了我们对字符设备所编写的文件操作。
在该示例中,我们实现了两个函数:hello_device_open、hello_device_close。
我们在这两个函数中并没有做过多的事情,只是打印了函数的名称和从 inode 中获取的主设备号和次设备号。
在代码的第 25-31 行中,定义了一个 struct file_operations 类型变量 hello_device_fops ,并将我们所定义的hello_device_open、hello_device_close 两个函数赋值给 hello_device_fops 的 open 字段和 release,相当于对其进行了一个绑定,这样,在用户程序对设备进行操作的时候,就会调用到这两个函数。并且,在第 26 行,将 ownr 字段的值设置为 THIS_MODULE,表明该变量属于这个模块。
对于其它未实现的文件操作,我们将 hello_device_fops 对应的字段设置为 NULL;
模块初始化
在代码的第 33 - 88 行,就是我们模块初始化的内容。
首先,在代码的第 34 行,定义了一个 int 型变量:ret,这时因为我们在后续调用内核提供的函数的时候,这些函数可能会调用失败,这时,我们需要一个变量来接收各个函数的返回值用以校验函数是否执行成功,并在失败的时候保存函数返回的错误码。
动态申请设备号
在代码的第 36 行,使用 alloc_chrdev_region 函数向内核动态申请一个设备号,申请到的设备号将保存在先前定义的 hello_device_num 变量中,函数执行的返回值将保存在变量 ret 中。
接下来,通过判断 ret 的值来判断函数的执行是否成功,并对错误情况进行处理。当函数调用失败,进行错误处理时,打印了当前调用失败的函数名称,并使用 goto 语句跳转到第 85 行的 failed_register_chrdev_region 标签处。在此处,由于我们尚未成功地申请到任何资源,于是不用进行资源的释放,只需返回 ret 变量。此时,ret 中保存的是 alloc_chrdev_region 函数失败时返回的错误码。于是,当模块加载失败时,我们就可以通过 dmesg 信息确定是哪个函数调用失败了,并从模块的初始化函数的返回值,判断错误码是什么,方便我们进行问题排查。
创建于初始化 cdev
在代码的第 42-56 行,就是我们创建并初始化 cdev 的代码。
在第 42 行,使用 cdev_alloc 函数向内核申请一个 cdev 结构,并将返回指针保存在变量 phello_device_cdev 中。接下来通过判断 phello_device_cdev 变量的值是否为 NULL 来判断 cdev_alloc 函数是否成功执行了。在错误处理中,打印出现错误的函数名称,并跳转到第 81 行的 failed_cdev_alloc 标签中。
当 cdev_alloc 函数执行错误并跳转到 82 行时,因为此时程序肯定已经成功调用 alloc_chrdev_region 函数申请到了设备号,于是我们在第 81 行的错误处理中,就要释放已经申请到的设备号资源。后续的错误处理同理,不再对错误处理进行详细解释。
第 48 行,将 phello_device_cdev 的 owner 字段设置为 THIS_MODULE。
第 50 行,调用 cdev_init 函数将 phello_device_cdev 的 ops 字段的值初始化为我们前面定义的 hello_device_fops 变量。当然,这一步也可以通过直接赋值的方式来实现:
1 | phello_device_cdev->ops = hello_device_fops; |
接下来的第 52-56 行,使用 cdev_add 函数将我们申请到的 cdev 结构添加到系统中,并与申请到的设备号进行绑定。
创建类
第 58-62 行,使用 class_create 函数创建了一个名为 hello_device_class 的类,这个函数会在 /sys/class 目录下创建一个节点,其中包含属于该类的的所有设备。
创建设备节点文件
在 64-68 行中,使用 device_create 函数,会在 /dev 目录下创建一个属于 hello_device_class 的名为 hello_device 的设备节点文件,我们可以通过该节点文件来访这个设备。
结果返回
当程序能够成功运行到代码的第 70-71 行,说明前面所申请的全部资源已经成功申请到了,此时在该处打印一条初始化成功与设备号信息,并返回代表初始化成功的返回值。
错误处理
代码的第73 - 86 行,是程序初始化时的错误处理部分。该部分使用 goto 标签的形式来定义了不同错误的处理办法,使用这种方式能够保证在初始化过程中出错部分之前所申请的资源的正确释放。
模块退出
代码的第 89-107 行是程序的退出函数。在该函数中,按照 “先申请,后释放” 的顺序将初始化函数中所申请的资源全部释放掉。使用这种顺序进行释放的原因是:在初始化函数中进行资源申请时,某些后申请的资源可能依赖于先申请的资源,为了维护这种依赖关系,在释放的时候要先释放后申请的资源。
如使用 device_create 申请 device 结构时需要使用先通过 alloc_chrdev_region 申请到的设备号和通过 class_create 申请到的类,如果在使用 device_destroy 释放 device 之前将 phello_device_class 或 hello_device_num 释放掉,那么执行 device_destroy 函数就有可能出错。
对于申请到的资源,在模块退出函数中并不清楚哪些资源是否成功申请到了,或者在模块运行过程中,哪些资源被释放掉。因此在对资源进行释放的时候,首先要判断该资源是否还存在,然后再决定是否调用释放函数对其进行释放。
模块出入口声明
代码的第 109-110 行,使用 module_init 和 module_exit 分别声明了模块的初始化和退出函数。
编译与加载
代码编写完成之后,就可以将其编译并加载进系统中了。
首先要编写编译所使用的 Makefile 文件:
1 | ifneq ($(KERNELRELEASE),) |
编译字符设备驱动程序代码所使用的 Makefile 文件格式同编译模块程序一样,只需要在第 2 行中的 “obj-m :=” 语句后的目标根据代码文件的名称进行修改即可。
使用 make 命令进行编译:
编译完成后使用 insmod 命令将编译生成的模块加载进系统中。将模块加载进系统后,使用 dmesg 命令查看 dmesg 信息:
1 | sudo insmod HelloDevice.ko |
可以看到,我们在初始化函数结束时的 printk 函数成功执行,打印出了初始化成功及设备号信息,说明模块已经成功初始化并加载进系统中。
验证
此时,回顾上一章节中的内容,查看一下系统中的信息,对上一章中的内容作一下验证。
cdev_add 函数将在 /proc/devices 文件中中添加设备的记录,查看该文件:
1 | cat /proc/devices |
可以看到,在 /proc/devices 文件中已有 “236 hello_device” 这条记录,代表着 hello_device 的主设备号为 236,这鱼我们代码中第 70 行的执行结果一致。且该条记录中的设备名称与使用 alloc_chrdev_region 函数时传入的名称 “hello_device” 一致。
class_create 函数会在 /sys/class 下创建类节点:
1 | ls /sys/class/ |
/sys/class 目录下已经有了与调用 class_create 函数时传入的类名 “hello_device_class” 一致的节点。
device_create 会在 /dev 目录下创建设备节点文件:
1 | ls /dev/ |
可以看到,在 /dev 目录下已经有了名为 “hello_device” 的节点文件,且该文件的文件名与使用 device_create 函数时传入的设备节点文件名称 “hello_device” 一致。
有了设备节点文件,就能够编写用户程序对设备进行访问。
用户程序
源码
新建 HelloDeviceTest.c 文件,输入以下代码:
1 |
|
代码分析
用户程序代码其实很简单,主要就是调用 open 函数以来打开一个文件的形式来打开一个设备,它返回一个整型变量,如果返回值小于 0 ,表明打开文件出现错误,否则返回一个大于 0 的数,代表文件的描述符,后续对设备的操作都是通过对文件描述符的控制来实现的。函数原型如下:
1 | __fortify_function int open (const char *__path, int __oflag, ...) |
open 函数接收两个参数,第一个参数 __path 为设备节点文件的路径,第二个参数 __oflag 为对打开的文件的权限控制字,常用的值及含义如下:
O_RDONLY :只读打开。
O_WRONLY :只写打开。
O_RDWR :读、写打开。
O_APPEND :每次写时都加到文件的尾端。
O_CREAT :若此文件不存在则创建它。使用此选择项时,需同时说明第三个参数 mode,用其说明该新文件的存取许可权位。
O_EXCL :如果同时指定了O_CREAT,而文件已经存在,则出错。这可测试一个文件是否存在,如果不存在则创建此文件成为一个原子操作。
O_TRUNC :如果此文件存在,而且为只读或只写成功打开,则将其长度截短为0。
O_NOCTTY : 如果 __path 参数指的是终端设备,则不将此设备分配作为此进程的控制终端。
O_NONBLOCK :如果 __path 指的是一个 FIFO、一个块特殊文件或一个字符特殊文件,则此选择项为此文件的本次打开操作和后续的 I/O操作设置非阻塞方式。
O_SYNC :使每次 write 都等到物理 I/O 操作完成。
注:这些控制字都是通过“或”符号来组合使用,如:O_RDWR |O_CREAT ,代表打开一个文件进行读写,若该文件不存在,则创建该文件
编译并运行
使用 gcc 对程序编译并运行该程序:
1 | gcc HelloDeviceTest.c -o HelloDeviceTest |
通过程序打印的信息可以看到 open 函数和 close 函数都调用成功了。
此时再看一下 dmesg 信息:
发现多了两行信息,而这两行信息正是我们的驱动程序中所实现的 hello_device_open 函数与 hello_device_close 函数中打印的信息,验证了前面所说,对设备文件所进行的操作实际调用的是为设备所编写的 file_operations 各字段指向的函数。
卸载模块
使用 rmmod 来卸载已经加载进系统的字符设备驱动程序并查看 dmesg 信息:
1 | sudo rmmod |
可以看到卸载模块后,dmesg 中已经多出了驱动程序的退出函数中所打印的内容,证明模块中的资源已经全部释放并正确退出。
此时,再查看 /dev 目录:
1 | ls /dev/ |
/dev 目录中的设备节点文件已经被删除了。
查看 /sys/class 目录中的内容:
1 | ls /sys/class/ |
hello_device_class 也被删除了。
查看 /proc/devices 中的内容
1 | cat /proc/devices |
可以看到,原本的 “236 hello_device” 记录也被删除了。
手动创建设备节点文件
上面的代码中,我们借助了内核自动创建设备文件节点的方式来创建设备节点文件并对其进行访问,下面我们将使用手动创建设备文件节点的方式来创建设备节点文件并对其进行访问。
字符设备驱动程序代码
首先,修改我们在上面所编写的字符设备驱动程序,删除自动创建设备节点文件的部分:
1 |
|
与最初的代码不同的是,这份代码删除了 phello_device_class 变量和 phello_device 变量的定义,在模块的初始化函数中删去了 class_create 函数和 cdev_add 函数的调用及错误处理,模块的退出函数中删去了对于phello_device_class 变量和 phello_device 的释放。其他内容与最初的代码相同,在此不在赘述。
编译后重新加载模块,并查看 dmesg 信息:
根据 dmesg 信息可以判断模块已经成功加载。
手动创建设备节点文件
模块成功加载后,查看 /proc/devices 文件:
1 | cat /proc/devices |
可以在该文件中看到申请到的设备的设备号及名称的记录:236 hello_device。
查看 /sys/class 目录中的内容:
1 | ls /sys/class/ |
可以看到并没有新的类出现。
查看 /dev 目录:
1 | ls /dev/ |
/dev 目录中也没有出现新的设备节点文件。
接下来,使用 mknod 命令创建设备节点文件(为了与 /proc/devices 中的记录作区分,将节点的名称起为 HelloChrDevice,当然也可以与记录中的名称 HelloDevice 相同,只要设备号一致,设备节点文件的名称是否与 /proc/devices 中相同无关紧要):
1 | sudo mknod /dev/HelloChrDevice c 236 0 |
其中,设备号可以从模块加载后的 dmesg 中查询,也可从 /proc/devices 文件中查询。
查看 /dev 目录:
1 | ls /dev/ |
可以看到,新的节点已经添加完成。
字符设备访问
因为我们为字符设备创建了一个其他的名称,因此用户程序需要稍作修改,将 open 函数中的路径参数修改为新的节点文件的路径:
1 |
|
编译运行并查看 dmesg 信息:
1 | gcc HelloDeviceTest.c -o HelloDeviceTest |
可以看到,设备被成功打开和关闭了。
现在,我们来做一个实验,即然可以手动创建设备节点程序,那么我们能否为同一个设备创建两个节点文件并同时进行访问呢?
为设备创建另一个设备节点文件:HelloChrDevice2:
1 | sudo mknod /dev/HelloChrDevice2 c 236 0 |
可以看到,并没有报错,那么我们对其进行访问试一试:
修改用户程序代码:
1 |
|
编译并运行:
程序照常运行,那么同时访问呢?
1 |
|
编译并运行:
从 HelloDeviceTest 程序的输出和 dmesg 信息来看,同时打开两个设备节点文件是可以的,并且 open 函数返回的 fd 变量的值不一样。
删除节点
现在将模块卸载并查看 dmesg 信息:
1 | sudo rmmod HelloDevice |
根据 dmesg 信息,可以看到模块已经成功被卸载了。、
查看 /proc/devices 内容:
1 | cat /proc/devices |
可以看到, /proc/devices 中已经删除了设备的记录。
查看 /dev 目录:
1 | ls /dev/ |
可以看到,手动创建的设备节点文件并没有随着模块的卸载而被删除掉。那么此时还能对其进行访问吗?
运行用户程序:
1 | sudo ./HelloDeviceTest |
从用户程序的输出信息可以看到,虽然设备节点文件依然存在,但此时已经无法对其进行访问。
所以手动创建的节点并不会随着模块的退出而自动删除,需要使用手动删除的方式对其进行删除;
1 | sudo rm -f /dev/HelloChrDevice |
One More Thing
手动删除自动创建的节点
那么,能否用手动删除设备节点文件的方式来删除自动创建出的设备节点文件呢?
我们先修改一下最初的设备驱动程序代码:
1 |
|
在模块的释放函数中,对资源的释放的代码进行修改,当判断某个资源不存在的时候打印该变量为空的信息。
编译并将其加载进系统:
1 | make |
根据 dmesg 信息,可以看到模块已经成功执行了初始化。
查看 /proc/devices:
1 | cat /proc/devices |
查看 /dev 目录:
1 | ls /dev/ |
接下来,手动删除 /dev/hello_device 节点文件:
1 | sudo rm -f /dev/hello_device |
可以看到,自动创建的设备节点文件是可以通过手动删除的。
那么将自动创建的设备节点文件手动删除之后对模块会有什么影响呢?
查看 dmesg 信息:
dmesg 信息中并没有针对手动删除设备文件节点输出新的信息。
查看 /sys/class/ 目录:
1 | ls /sys/class/ |
hello_device_class 类仍然存在。
查看 /proc/devices:
1 | cat /proc/devices |
设备的记录也仍然存在。
卸载模块:
1 | sudo rmmod |
根据 dmesg 信息可以看到,模块的退出函数正常执行完毕,且并没有产生额外的信息。由此可以看出,通过手动的方式删除设备文件节点,并不会对模块内部造成任何影响,也不会释放模块内部的某些资源。
模块卸载时对手动创建的节点的处理
那么,自动创建的设备节点文件在模块被卸载时会自动删除 /dev 下的节点,系统又是通过什么方式来删除节点的呢?
我们知道,在使用自动创建设备文件节点的方式是调用 device_create 函数,在该函数中传入了设备的设备号和要创建的设备节点文件名称,那么在使用 device_destroy 函数删除节点时,传入的是设备号,那么模块退出时删除的设备文件节点是够是通过设备号进行关联的呢?将自动创建的节点删除后再通过手动创建出的同名设备节点文件有是否会被识别出来进行删除呢?
加载模块并产看 /dev 目录:
1 | sudo insmod HelloDevice.ko |
删除自动创建的节点 /dev/hello_device:
1 | sudo rm -f /dev/hello_device |
手动创建一个同名节点 hello_device 和一个不同名的节点 hello_device1:
1 | sudo mknod /dev/hello_device c 236 0 |
修改用户程序的 open 函数的节点路径,编译并运行:
用户程序依旧能够对其正常访问。
卸载模块:
可以看到,卸载模块后,手动创建出的两个节点均未被删除,这里猜测是在使用自动创建节点的守候,系统会将节点与 class 进行某种关联。自动删除节点的时候不仅仅是通过设备号来删除节点的,还依靠了这种关联关系。而手动删除后破坏了这种关联,虽然不会对模块功能造成什么破坏,但是手动创建的节点并不会与 class 关联起来。因此模块在退出的时候无法找到与 class 关联的节点,于是就无法删除手动创建出的设备文件节点。