LV06-03-chrdev-02-字符设备驱动框架
本文主要是字符设备驱动——字符设备框架的相关笔记,若笔记中有错误或者不合适的地方,欢迎批评指正😃。
点击查看使用工具及版本
PC端开发环境 | Windows | Windows11 |
Ubuntu | Ubuntu20.04.2的64位版本 | |
VMware® Workstation 17 Pro | 17.6.0 build-24238078 | |
终端软件 | MobaXterm(Professional Edition v23.0 Build 5042 (license)) | |
Win32DiskImager | Win32DiskImager v1.0 | |
Linux开发板环境 | Linux开发板 | 正点原子 i.MX6ULL Linux 阿尔法开发板 |
uboot | NXP官方提供的uboot,使用的uboot版本为U-Boot 2019.04 | |
linux内核 | linux-4.19.71(NXP官方提供) |
点击查看本文参考资料
分类 | 网址 | 说明 |
官方网站 | https://www.arm.com/ | ARM官方网站,在这里我们可以找到Cotex-Mx以及ARMVx的一些文档 |
https://www.nxp.com.cn/ | NXP官方网站 | |
https://www.nxpic.org.cn/ | NXP 官方社区 | |
https://u-boot.readthedocs.io/en/latest/ | u-boot官网 | |
https://www.kernel.org/ | linux内核官网 |
点击查看相关文件下载
分类 | 网址 | 说明 |
NXP | https://github.com/nxp-imx | NXP imx开发资源GitHub组织,里边会有u-boot和linux内核的仓库 |
nxp-imx/linux-imx/releases/tag/v4.19.71 | NXP linux内核仓库tags中的v4.19.71 | |
nxp-imx/uboot-imx/releases/tag/rel_imx_4.19.35_1.1.0 | NXP u-boot仓库tags中的rel_imx_4.19.35_1.1.0 | |
I.MX6ULL | i.MX 6ULL Applications Processors for Industrial Products | I.MX6ULL 芯片手册(datasheet,可以在线查看) |
i.MX 6ULL Applications ProcessorReference Manual | I.MX6ULL 参考手册(下载后才能查看,需要登录NXP官网) | |
Source Code | https://elixir.bootlin.com/linux/latest/source | linux kernel源码 |
https://elixir.bootlin.com/u-boot/latest/source | uboot源码 |
一、字符设备驱动框架
可以先看一张思维导图:
我们创建一个字符设备的时候,首先要的到一个设备号,分配设备号的途径有静态分配和动态分配; 拿到设备的唯一ID,我们需要实现file_operation并保存到cdev中,实现cdev的初始化; 然后我们需要将我们所做的工作告诉内核,使用cdev_add()注册cdev; 最后我们还需要创建设备节点,以便我们后面调用file_operation接口。
注销设备时我们需释放内核中的cdev,归还申请的设备号,删除创建的设备节点。
二、驱动框架解析
1. 设备号
1.1 设备号的申请
在 Linux 系统中每一个设备都有相应的设备号,通过该设备号查找对应的设备,从而进行 之后的文件操作。设备号有主设备号与次设备号之分,主设备号用来表示一个特定的驱动,次设备号用来管理下面的设备。
在 Linux 驱动中可以使用以下两种方法进行设备号的申请:
(1)register_chrdev_region()函数静态申请。
(2)alloc_chrdev_region()函数动态申请。
1.1.1 静态申请
register_chrdev_region()函数声明在fs.h - include/linux/fs.h - register_chrdev_region,定义在char_dev.c - fs/char_dev.c - register_chrdev_region:
1 | /** |
register_chrdev_region函数用于静态地为一个字符设备申请一个或多个设备编号。
参数:
- from:dev_t类型的变量,用于指定字符设备的起始设备号,如果要注册的设备号已经被其他的设备注册了,那么就会导致注册失败。
- count:指定要申请的设备号个数,count的值不可以太大,否则会与下一个主设备号重叠。
- name:用于指定该设备的名称,我们可以在/proc/devices中看到该设备。
返回值: 返回0表示申请成功,失败则返回错误码。
(1)关于设备编号,我们可以去devices.txt - Documentation/admin-guide/devices.txt中看一下,里面提供了主设备号以及次设备号的一些参考,比如devices.txt - Documentation/admin-guide/devices.txt - 200 char:
1
2
3
4
5
6
7 >200 char Veritas VxVM configuration interface
0 = /dev/vx/config Configuration access node
1 = /dev/vx/trace Volume i/o trace access node
2 = /dev/vx/iod Volume i/o daemon access node
3 = /dev/vx/info Volume information access node
4 = /dev/vx/task Volume tasks access node
5 = /dev/vx/taskmon Volume tasks monitor daemon(2)我们静态申请设备号的时候可以使用以下宏生成设备号:
1
2
3
4
5
6 >
>
>
>
>
1.1.2 动态申请
alloc_chrdev_region()函数声明在fs.h - include/linux/fs.h - alloc_chrdev_region,定义在char_dev.c - fs/char_dev.c - alloc_chrdev_region:
1 | /** |
调用alloc_chrdev_region函数,内核会自动分配给我们一个尚未使用的主设备号。 我们可以通过命令“cat /proc/devices”查询内核分配的主设备号。
参数:
- dev:指向dev_t类型数据的指针变量,用于存放分配到的设备编号的起始值;
- baseminor:次设备号的起始值,通常情况下,设置为0;
- count、name:同register_chrdev_region类型,用于指定需要分配的设备编号的个数以及设备的名称。
返回值: 返回0表示申请成功,失败则返回错误码
1.1.3 register_chrdev函数
除了上述的两种,内核还提供了register_chrdev函数用于分配设备号。该函数是一个内联函数,它不 仅支持静态申请设备号,也支持动态申请设备号,并将主设备号返回,函数定义在fs.h - include/linux/fs.h - register_chrdev:
1 | static inline int register_chrdev(unsigned int major, const char *name, |
参数:
- major:用于指定要申请的字符设备的主设备号,等价于register_chrdev_region函数,当设置为0时,内核会自动分配一个未使用的主设备号。
- name:用于指定字符设备的名称
- fops:用于操作该设备的函数接口指针。
返回值: 主设备号。
我们从函数定义中可以看到,使用register_chrdev函数向内核申请设备号,同一类字 符设备(即主设备号相同),会在内核中申请了256个,通常情况下,我们不需要用到这么多个设备,这就造成了极大的资源浪费。所以一般也不用这个函数。
1.2 设备号的释放
1.2.1 unregister_chrdev_region函数
当我们删除字符设备时候,我们需要把分配的设备编号交还给内核,对于使用register_chrdev_region函数 以及alloc_chrdev_region函数分配得到的设备编号,可以使用unregister_chrdev_region函数实现该功能。该函数声明在fs.h - include/linux/fs.h - unregister_chrdev_region,定义在char_dev.c - fs/char_dev.c - unregister_chrdev_region
1 | /** |
参数:
- from:指定需要注销的字符设备的设备编号起始值,我们一般将定义的dev_t变量作为实参。
- count:指定需要注销的字符设备编号的个数,该值应与申请函数的count值相等,通常采用宏定义进行管理。
返回值: 无
1.2.2 unregister_chrdev函数
使用register_chrdev函数申请的设备号,则应该使用unregister_chrdev函数进行注销。这也是一个内联函数,定义在fs.h - include/linux/fs.h - unregister_chrdev:
1 | static inline void unregister_chrdev(unsigned int major, const char *name) |
参数:
- major:指定需要释放的字符设备的主设备号,一般使用register_chrdev函数的返回值作为实参。
- name:执行需要释放的字符设备的名称。
返回值: 无
2. 字符设备
2.1 字符设备的定义
Linux内核提供了两种方式来定义字符设备,如下所示:
1 | //第一种方式 |
第一种方式,就是我们常见的变量定义;第二种方式,是内核提供的动态分配方式,调用该函数之 后,会返回一个struct cdev类型的指针,用于描述字符设备。struct cdev结构体定义在cdev.h - include/linux/cdev.h - struct cdev:
1 | struct cdev { |
cdev_alloc函数定义在char_dev.c - fs/char_dev.c - cdev_alloc:
1 | /** |
2.2 字符设备的初始化
2.2.1 cdev_init函数
前面我们已经提到过了,编写一个字符设备最重要的事情,就是要实现file_operations这个结构体中的函数。 实现之后,如何将该结构体与我们的字符设备结构体相关联呢?内核提供了cdev_init函数,来实现这个过程。cdev_init函数定义在char_dev.c - fs/char_dev.c - cdev_init:
1 | /** |
函数参数和返回值如下:
参数:
- cdev:struct cdev类型的指针变量,指向需要关联的字符设备结构体;
- fops:file_operations类型的结构体指针变量,一般将实现操作该设备的结构体file_operations结构体作为实参。
返回值: 无
2.2.2 使用实例
1 | static struct cdev g_cdev_dev; // 定义cdev结构体类型的变量g_cdev_dev |
2.3 字符设备的注册
cdev_add函数用于向内核的cdev_map散列表添加一个新的字符设备,它定义在char_dev.c - fs/char_dev.c - cdev_add:
1 | /** |
参数:
- p:struct cdev类型的指针,用于指定需要添加的字符设备;
- dev:dev_t类型变量,用于指定设备的起始编号;
- count:指定注册多少个设备。
返回值: 错误码
2.4 字符设备的注销
字符设备删除所用到的函数为cdev_del(),它定义在char_dev.c - fs/char_dev.c - cdev_del:
1 | /** |
参数:
- p:struct cdev类型的指针,用于指定需要删除的字符设备;
返回值: 无
从系统中删除cdev,cdev设备将无法再打开,但任何已经打开的cdev将保持不变, 即使在cdev_del返回后,它们的FOP仍然可以调用。
3. 设备节点
在 Linux 操作系统中一切皆文件,设备访问也是通过文件的方式来进行的,对于用来进行 设备访问的文件称之为设备节点,设备节点被创建在/dev 目录下,将内核中注册的设备与用户 层进行链接,这样应用程序才能对设备进行访问。
根据设备节点的创建方式不同,分为了手动创建设备节点和自动创建设备节点。
3.1 手动创建设备节点
3.1.1 命令格式
当向内核注册好设备后,可以使用mknod命令创建设备节点。
1 | mknod 设备名 设备类型 主设备号 次设备号 |
设备名就是我们要创建的节点名称,比如
/dev/driver_test
。设备类型,就是这个设备是字符设备还是块设备还是网络设备。当类型为”p”时可不指定主设备号和次设备号,否则它们是必须指定的。 如果主设备号和次设备号以”0x”或”0X”开头,它们会被视作十六进制数来解析;如果以”0”开头,则被视作八进制数; 其余情况下被视作十进制数。可用的类型包括:
b 创建(有缓冲的)区块特殊文件
c, u 创建(没有缓冲的)字符特殊文件
p 创建先进先出(FIFO)特殊文件
注意:根文件系统需要支持这个mknod命令才行。
3.1.2 mknod流程
mknod命令最终会调用内核中的函数完成设备节点的创建。
当我们使用上述命令,创建了一个字符设备文件时,实际上就是创建了一个设备节点inode结构体, 并且将该设备的设备编号记录在成员i_rdev,将成员f_op指针指向了def_chr_fops结构体。 这就是mknod负责的工作内容,具体看这个shmem.c - mm/shmem.c - shmem_get_inode函数:
1 | static struct inode *shmem_get_inode(struct super_block *sb, const struct inode *dir, |
mknod命令最终执行init_special_inode函数这个函数定义在inode.c - fs/inode.c - init_special_inode:
1 | void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev) |
第4 - 17 行判断文件的inode类型,如果是字符设备类型,则把def_chr_fops作为该文件的操作接口,并把设备号记录在inode→i_rdev。
Tips:inode上的file_operation并不是自己构造的file_operation,而是字符设备通用的def_chr_fops, 那么自己构建的file_operation等在应用程序调用open函数之后,才会绑定在文件上。
3.1.3 使用实例
上面我们看到手动创建设备节点需要知道设备的主设备号和次设备号,我们只是创建了一个字符设备,申请了设备号,只能在这个/proc/devices
文件中看到主设备号。所以要是手动创建设备节点的话,我们需要在申请设备号后打印出来为设备分配的主设备号和次设备号。知道了主设备号和次设备号的时候,我们可以这样创建设备节点:
1 | mkmod /dev/chrdev_node c 246 0 |
3.2 自动创建设备节点
3.2.1 linux热拔插机制
能不能让系统自动创建设备节点?除了使用mknod命令手动创建设备节点,还可以利用linux的udev、mdev机制,这就涉及到linux的热拔插机制了。
Linux的热插拔支持是一个连接底层硬件、内核空间和用户空间程序的机制,且一直在变化。而设备文件系统有devfs、mdev、udev这三种。
在对待设备文件这块,Linux改变了几次策略。在Linux早期,设备文件仅仅是是一些带有适当的属性集的普通文件,它由mknod命令创建,文件存放在/dev目录下。后来,采用了devfs, 一个基于内核的动态设备文件系统,他首次出现在2.3.46内核中。Mandrake,Gentoo等Linux分发版本采用了这种方式。devfs创建 的设备文件是动态的。但是devfs有一些严重的限制,从2.6.13版本后移走了。目前取代他的是udev(PC机上的linux中)和mdev(嵌入式linux系统)。
我们的ARM开发板上移植的busybox一般都有mdev机制,mdev是busybox自带的一个简化版的udev。mdev也是使用uevent机制处理热插拔问题的用户空间程序。
mdev是基于uevent_helper机制的,它在系统启动时修改了内核中的uevnet_helper变量(通过写 /proc/sys/kernel/hotplug ),值为“/sbin/mdev”。这样内核产生uevent时会调用uevent_helper所指的用户级程序,也就是mdev,来执行相应的热拔插动作。mdev使用的uevent_helper机制实现简单,适合用在嵌入式系统中。
那么就可以使用mdev机制来自动创建设备节点。文件系统里,在哪里设置了mdev机制?在etc/init.d/rcS文件里有一句:
1 | echo /sbin/mdev > /proc/sys/kernel/hotplug |
要是没有的话,可以加上。
3.2.2 udev简介
udev是基于netlink机制的通过监听内核发送的uevent来执行相应的热拔插动作,它能根据系统中硬件设备的状态动态的更新设备文件,包括设备文件的创建,删除,权限等。它能根据系统中硬件设备的状态动态的更新设备文件,包括设备文件的创建,删除,权限等。udev 的初始化脚本在系统启动时创建设备节点,并且当插入新设备——加入驱动模块——在sysfs上注册新的数据后,udev会创新新的设备节点。
udev 是一个工作在用户空间的工具,它必须有内核中的sysfs和tmpfs支持,sysfs 为udev 提供设备入口和uevent 通道,tmpfs 为udev 设备文件提供存放空间。
注意,udev 是通过对内核产生的设备文件修改,或增加别名的方式来达到自定义设备文件的目的。但是,udev 是用户模式程序,其不会更改内核行为。也就是说,内核仍然会创建sda,sdb等设备文件,而udev可根据设备的唯一信息来区分不同的设备,并产生新的设备文件(或链接)。
udev 通过在 sysfs 的 /class/ 和/block/ 目录树中查找一个称为 dev 的文件,以确定所创建的设备节点文件的主次设备号。所以要使用udev,驱动必须为设备在sysfs中创建类接口及其dev属性文件,方法和sculld模块中创建dev属性相同。
基本工作原理如下:
当系统内核发现系统中添加或者删除了某个新的设备时,内核检测到后会产生一个hotplug event并查找 /proc/sys/kernel/hotplug 去找出管理设备连接的用户空间程序。若udev已经启动,内核会通知udev去检测sysfs中关于这个新设备的信息并创建设备节点。udev就会去执行udevd,以便让udevd可以产生或者删除硬件的设备文件。
接着udevd会通过libsysfs读取sys文件系统,以便取得该硬件设备的信息(如/dev/tty0,在 /sys/class/tty/tty0/dev 存放的是”4:0”,即/dev/tty0的主次设备号);然后再向namedev查询该外部设备的设备文件信息,例如文件的名称、权限等。最后,udevd就依据上述的结果,在/dev/目录中自动建立该外部设备的设备文件,同时在/etc/udev/rules.d下检查有无针对该设备的使用权限。
当设备插入或移除时,hotplug机制会让内核会通过netlink socket通讯(内核调用kobject_uevent函数发送netlink message给用户空间,该功能由内核的统一设备模型里的子系统这一层实现)向用户传递一个事件的发生,udevd通过标准的socket机制,创建socket连接来获取内核广播的uevent事件 并解析这些uevent事件。
运行udevd以后,使用udevtrigger的时候,会把内核中已存在的设备的节点创建出来,其具体过程为:udevtrigger通过向/sysfs 文件系统下现有设备的uevent节点写”add”字符串,从而触发uevent事件,使得udevd能够接收到这些事件,并创建buildin的设备驱动的设备节点连同任何已insmod的模块的设备节点。
大概就先了解到这里,后续有必要的话再详细学习。
3.2.3 相关函数
前面大概了解了udev,其实设备文件的自动创建就是利用 udev(mdev)机制来实现,多数情况下采用自动创建设备节点 的方式。
udev(mdev)可以检测系统中硬件设备状态,可以根据系统中硬件设备状态来创建或者 删除设备文件。在驱动中首先使用 class_create()函数对 class 进行创建,这个类存放于 /sys/class/ 目录下,之后使用 device_create() 函数创建相应的设备,在进行模块加载时,用户空间中的 udev 会自动响应 device_create()函数,寻找对应的类从而创建设备节点。
3.2.3.1 类的创建
class_create()函数用于创建一个类,它定义在device.h - include/linux/device.h - class_create:
1 | /* This is a #define to keep the compiler from merging different |
该函数用于动态创建设备的逻辑类,并完成部分字段的初始化,然后将其添加进 Linux 内核系统。
参数:
- owner:struct module 结构体类型的指针,指向函数即将创建的这个 struct class 的模块。 一般赋值为 THIS_MODULE。
- name:char 类型的指针,代表即将创建的 struct class 变量的名字。 这里的名字将会在
/sys/class
中出现。
返回值:struct class * 类型的结构体。
3.2.3.2 类的销毁
class_destroy()函数用于销毁创建的类,它定义在class.c - drivers/base/class.c - class_destroy:
1 | /** |
用于删除设备的逻辑类,即从 Linux 内核系统中删除设备的逻辑类。
参数:
- cls:要销毁的类的指针。
返回值:无
3.2.3.3 设备节点创建
device_create()函数用于创建一个设备并将其注册到文件系统,它定义在core.c - drivers/base/core.c - device_create:
1 | /** |
它会在 class 类中下创建一个设备属性文件,udev 会自动识别从而进行设备节点的创建。
参数:
- class:指向这个设备应该注册到的struct类的指针;
- parent:指向此新设备的父结构设备(如果有)的指针;如果没有就指定为 NULL。
- devt:要添加的char设备的设备号;
- drvdata:要添加到设备进行回调的数据;没有则指定为 NULL。
- fmt:设备名称,这里的名称将会在
/dev/
下显示。
返回值: 成功时返回 struct device 结构体指针, 错误时返回ERR_PTR().
3.2.3.4 设备节点的销毁
删除使用device_create函数创建的设备的时候使用device_destroy()函数,它定义在core.c - drivers/base/core.c - device_create:
1 | /** |
这个函数删除 class 类中的设备属性文件,udev 会自动识别从而进行设备节点的删除。
参数:
- class:指定所要销毁的设备所从属的类。
- devt:以前注册的设备的设备号;
返回值: 无
三、open函数
1. 过程简介
使用设备之前我们通常都需要调用open函数,这个函数一般用于设备专有数据的初始化,申请相关资源及进行设备的初始化等工作, 对于简单的设备而言,open函数可以不做具体的工作,你在应用层通过系统调用open打开设备时, 如果打开正常,就会得到该设备的文件描述符,之后,我们就可以通过该描述符对设备进行read和write等操作; open函数到底做了些什么工作?下图中列出了open函数执行的大致过程。
用户空间使用open()系统调用函数打开一个字符设备时(int fd = open(“dev/xxx”, O_RDWR))大致有以下过程:
- 在虚拟文件系统VFS中的查找对应与字符设备对应 struct inode节点
- 遍历散列表cdev_map,根据inod节点中的 cdev_t设备号找到cdev对象
- 创建struct file对象(系统采用一个数组来管理一个进程中的多个被打开的设备,每个文件秒速符作为数组下标标识了一个设备对象)
- 初始化struct file对象,将 struct file对象中的 file_operations成员指向 struct cdev对象中的 file_operations成员(file→fops = cdev→fops)
- 回调file→fops→open函数。
2. 过程分析
我们使用的open函数在内核中对应的是sys_open函数,sys_open函数又会调用do_sys_open函数。在do_sys_open函数中, 首先调用函数get_unused_fd_flags来获取一个未被使用的文件描述符fd,该文件描述符就是我们最终通过open函数得到的值。 紧接着,又调用了do_filp_open函数,该函数通过调用函数get_empty_filp得到一个新的file结构体,之后的代码做了许多复杂的工作, 如解析文件路径,查找该文件的文件节点inode等,直接来到了函数do_dentry_open函数,这个函数定义在open.c - fs/open.c - do_dentry_open:
1 | static int do_dentry_open(struct file *f, |
- 第 6 行:使用fops_get函数来获取该文件节点inode的成员变量i_fop,在前面我们使用mknod创建字符设备文件时,将def_chr_fops结构体赋值给了该设备文件inode的i_fop成员。
- 第 13 行:到了这里,我们新建的file结构体的成员f_op就指向了def_chr_fops。
def_chr_fops结构体定义在char_dev.c - fs/char_dev.c - def_chr_fops:
1 | /* |
最终,会执行def_chr_fops中的open函数,也就是chrdev_open函数,可以理解为一个字符设备的通用初始化函数,根据字符设备的设备号, 找到相应的字符设备,从而得到操作该设备的方法:
chrdev_open函数定义在char_dev.c - fs/char_dev.c - chrdev_open:
1 | /* |
在Linux内核中,使用结构体cdev来描述一个字符设备。
- 第 12 行:inode→i_rdev中保存了字符设备的设备编号,
- 第 17 行:通过函数kobj_lookup函数便可以找到该设备文件cdev结构体的kobj成员,
- 第 20 行:再通过函数container_of便可以得到该字符设备对应的结构体cdev。函数container_of的作用就是通过一个结构变量中一个成员的地址找到这个结构体变量的首地址。同时,将cdev结构体记录到文件节点inode中的i_cdev,便于下次打开该文件。
- 第 43 ~ 48 行:函数chrdev_open最终将该文件结构体file的成员f_op替换成了cdev对应的ops成员,并执行ops结构体中的open函数。
最后,调用上图的fd_install函数,完成文件描述符和文件结构体file的关联,之后我们使用对该文件描述符fd调用read、write函数, 最终都会调用file结构体对应的函数,实际上也就是调用cdev结构体中ops结构体内的相关函数。
3. 总结
总结一下整个过程,当我们使用open函数,打开设备文件时,会根据该设备的文件的设备号找到相应的设备结构体, 从而得到了操作该设备的方法。也就是说如果我们要添加一个新设备的话,我们需要提供一个设备号, 一个设备结构体以及操作该设备的方法(file_operations结构体)。
参考资料: