Linux中文件系统与设备驱动程序之间的关系如下图所示,
应用程序和VFS之间的接口是系统调用;VFS和文件系统以及设备文件之间的接口是file_operations结构体中的成员函数,该结构体包含对文件进行打开、关闭、读写和控制的一系列成员函数
字符设备上层没有类似磁盘的ext2等文件系统,所以字符设备的file_operations结构体需要由字符设备驱动程序提供,这正是字符设备驱动的核心
对于块设备,有两种访问方式,
① 不通过文件系统直接访问块设备(e.g. 直接操作磁盘设备文件/dev/mmcblkx)
此时块设备的file_operations结构体使用Linux内核实现的def_blk_fops
② 通过文件系统访问块设备
此时块设备的file_operations结构体由相应的文件系统提供,文件系统会将针对文件的读写转换为针对块设备原始扇区的读写,下图为ext4文件系统实现的file_operations结构体
说明:用户空间看不到设备驱动,能看到的只有和设备对应的文件
inode结构体是Linux管理文件系统的最基本单位,其中包含了文件节点的各种信息,
说明1:关于设备号
① 如果与inode节点对应的文件是设备文件(字符设备或块设备),则会使用i_rdev字段记录设备号
② 设备号类型为dev_t,共32位。Linux内核设备号分为主设备号和次设备号,前者为dev_t的高12位,后者为dev_t的低20位。主设备号是与驱动对应的概念,同一类设备一般使用相同的主设备号;因为同一驱动可以支持多个同类设备,因此使用次设备号来标识同类的不同设备(序号一般从0开始)
③ Linux内核提供了如下3个宏用于构成和分解设备号,
通过imajor和iminor函数则可以从inode中获取主次设备号,
④ 通过ls -l命令可以查看设备文件的主次设备号
⑤ 使用cat /proc/devices命令可以查看系统中已经使用的字符设备和块设备的主设备号
⑥ 内核源码中的Documents/admin-guide/devices.txt文件描述了Linux设备号的分配情况
devices.txt
说明2:关于__randomize_layout宏
在定义inode结构体类型时,使用了__randomize_layout宏进行修饰,这是GCC的一个plugin提供的特性。该特性可以对结构体进行混淆。在编译时,结构体中的数据不会按照声明顺序存储,而是根据函数名以及随机化种子打乱存储顺序,目的是防止黑客程序计算出结构体中关键值的偏移并进行修改
相关内容可参考randomize layout
file结构体代表一个打开的文件,系统中每个打开的文件在内核空间都会有一个对应的file结构体。file结构体由内核在打开文件时创建,并传递给在文件上进行操作的任何函数(也就是file_operations结构体中的成员函数);在文件的所有实例都关闭后(以进程为单位),内核会释放file结构体
说明1:进程打开文件的file结构体会记录在TCB的file_struct结构体中,不同进程打开同一个文件,会生成各自的file结构体
其中分配file结构体的操作如下图所示,
说明2:关于文件标志f_flags
文件标志f_flags可以由如下宏位或构成,可以在调用open函数打开文件时设置,也可以通过fcntl函数设置
说明3:关于private_data字段
私有数据指针private_data在设备驱动中被广泛使用,大多数情况下用于指向设备驱动自定义的设备结构体。例如在misc子系统中,就将private_data字段指向misc子系统定义的miscdevice设备结构体
file_operations结构体中的成员函数是字符设备驱动程序设计的主体内容,这些函数会在应用程序调用open / close / read / write等系统调用时最终被内核调用。需要注意的是,不同版本内核的file_operations结构体会有所不同
在Linux内核中,使用cdev结构体描述字符设备。其中关于dev和count字段的理解,详见后文分析
说明1:Linux内核的编程习惯是定义一个设备相关的结构体,该结构体包含所涉及的cdev、私有数据以及锁等信息。在chrdev_basic示例程序中,就是将cdev结构体和所持有的内存资源打包为一个设备结构体
说明2:使用cdev_alloc函数可以动态申请一个cdev结构体,该函数会对cdev结构体中的部分成员进行初始化
在向系统注册字符设备之前,需要先调用register_chrdev_region或alloc_chrdev_region向系统申请设备号,其中,
register_chrdev_region函数用于已知起始设备号的情况
alloc_chrdev_region函数用于设备号未知,向系统动态申请未被占用的设备号的情况
说明1:关于连续注册分配设备号的个数count
① 上述2个函数中的参数count均表示要连续注册/分配设备号的个数,此处是指次设备号的连续
② 假设注册分配的主设备号为A,次设备号为B,那么将会连续注册分配从MKDEV(A, B)到MKDEV(A, B + count - 1)的设备号
说明2:返回指针函数如何返回错误码?
上述2个函数都会调用__register_chrdev_region函数实际分配设备号,而该函数的返回值为structchar_device_struct *类型。如果在失败时只是返回NULL,则只能体现失败,不能说明失败的具体原因。因此Linux内核提供了如下3个函数,用于实现在返回指针类型的函数中返回错误码
说明3:chrdev_basic示例程序通过模块参数控制是使用指定的主设备号,还是向系统动态申请设备号
说明4:注销设备号通过unregister_chrdev_region函数实现
说明5:__register_chrdev_region函数分析
通过对__register_chrdev_region函数的分析,可以理解字符设备有效主设备号的个数以及对主设备号的组织方式
从__register_chrdev_region函数的实现可见,
① 虽然设备号中的高12位表示主设备号,但是内核支持的字符设备最大主设备号为511
② 字符设备的主设备号被组织为255个entry的哈希表
说明6:与字符设备类似,内核支持的块设备最大主设备号也是511,并且也被组织为255个entry的哈希表
cdev_init函数用于初始化cdev结构体成员,并建立cdev结构体和file_operations结构体之间的联系
在完成cdev结构体的初始化之后,就可以调用cdev_add函数向系统中注册字符设备
说明1:关于count参数
① 此处count字段的含义与分配注册设备号时的count参数是类似的,一个cdev结构体可以支持多个主设备号相同的字符设备,count字段就表示一个cdev结构体可以支持的次设备号连续的设备个数
② 假设注册的cdev结构体的主设备号为A,次设备号为B,那么该cdev结构体可以支持设备号从MKDEV(A, B)到MKDEV(A, B + count -1)的字符设备
③ 这种"支持"体现在应用程序打开上述设备号对应的设备文件时(他们对应不同的inode结构体),会索引到相同的cdev结构体
说明2:由于chrdev_basic示例程序中每个设备结构体都包含一个cdev结构体,因此需要将这些cdev结构体逐个注册到系统中
说明3:注销字符设备通过cdev_del函数实现
说明4:Linux内核还提供了register_chrdev函数,该函数可以一次性完成注册设备号、分配cdev结构体、初始化cdev结构体和注册字符设备,而且是将指定主设备号下的所有256个次设备都关联到一个cdev结构体
通过register_chrdev函数注册的字符设备可以通过unregister_chrdev函数注销,
说明5:字符设备驱动的结构如下图所示,
说明6:字符设备驱动核心数据结构之间的关系如下图所示,
为了验证chrdev_basic示例程序中注册字符设备的行为,此处先给出file_operations结构体中open和release函数的实现
说明:globalmem_open函数中的inode->i_cdev就是指向与该文件节点关联的cdev结构体,而该cdev结构体又被包含在设备结构体中。因此,通过container_of宏就可以索引到相应的设备结构体。关于container_of宏的实现,可参考01. 概述 chapter 6.2
加载chrdev_basic.ko,可见分配到的主设备号为234
通过cat /proc/devices命令可以验证主设备号234确实被示例驱动程序占用
此时尚未建立设备节点,需要通过mknod命令手动创建
mknod 设备节点路径 设备类型 主设备号 次设备号
运行如下测试用例,可见运行符合预期,不同的inode节点可以索引到相应的cdev结构体
如果想要实现在加载驱动时自动创建设备节点,则需要依靠Linux设备驱动模型及udev机制,本节仅说明使用方法(核心是创建设备类,并在该设备类上创建设备)
下面给出class_create和device_create的函数原型,
加载chrdev_audodev.ko,可见设备节点被自动创建,运行测试用例结果与之前相同
加载chrdev_audodev.ko后,会创建/sys/class/globalmem目录,并且创建相应的sysfs设备文件
在chrdev_basic和chrdev_autodev示例程序中,都是将cdev结构体包含在设备结构体之中,因此需要使用多个cdev结构体。但是这些cdev结构体关联的file_operations结构体是相同的,因此可以改为多个设备共用一个cdev结构体,chrdev_onecdev示例程序中的相关修改如下(在chrdev_autodev示例程序基础上修改),
① 将cdev结构体移出设备结构体,将其定义为全局变量
② 在调用cdev_add函数注册字符设备时,将多个设备号与一个cdev结构体关联起来
③ 由于设备结构体中不再包含cdev结构体,所以在open函数中改为通过inode中记录的子设备号来索引相应的设备结构体
加载chrdev_onecdev.ko,可见设备节点被自动创建,运行测试用例结果与之前相同
open函数用于打开设备文件,通常在open函数中进行设备和数据结构的初始化
/*
* inode: 与设备文件关联的inode结构体
* file: 打开设备文件后创建的file结构体
* 返回值: 成功应返回0,出错应返回错误码
*/
int open(struct inode *inode, struct file *file);
说明1:inode结构体代表文件系统中的一个文件(此处是字符设备文件),file结构体代表一个打开的文件,二者在chrdev_open函数中实现关联
结合上文,设备驱动向内核注册cdev结构体,并且标识他所关联的设备号;打开设备文件时,内核通过inode结构体记录的设备号索引相应的cdev结构体
其中,chrdev_open函数是内核默认的字符设备打开函数,在该函数中会调用驱动中注册的file_operations.open函数
说明2:如果打开的设备节点没有和cdev结构体关联,根据上文分析,应该会返回-ENXTO。下面先手动创建/dev/globalmem2设备节点,该节点并没有关联的cdev结构体,因此打开失败
说明3:从chrdev_open函数中可见,如果字符设备驱动不实现open函数,此时默认文件打开成功
说明:需要注意的是,一个进程中多次打开同一个设备节点,每次打开都会创建新的file结构体,并且每次都会调用到open函数。作为验证,运行如下测试用例,并且在驱动中打印file结构体指针。可见每次打开操作的inode结构体相同(表示打开的是同一个设备文件),但是file结构体不同
release函数用于关闭设备文件,通常在release函数中释放资源,关闭设备
/*
* inode: 与设备文件关联的inode结构体
* file: 打开的设备文件
* 返回值: 可以始终返回0,因为内核不会判断release函数的返回值
* 注意区分open系统调用和此处release函数的返回值
*/
int release(struct inode *inode, struct file *file);
说明1:只有当file结构体的引用计数f_count为0时,才会调用到release函数
说明2:关于file结构体引用计数维护
内核提供了get_file函数用于增加file结构体引用计数,fput函数用于减少file结构体引用计数
read函数用于从设备中读取数据
/*
* file: 打开的设备文件
* buf: 存储读取数据的用户态buffer
* size: 要读取的数据长度
* ppos: 指向当前读写位置的指针
* 返回值:成功时应返回读取到的字节数,出错时应返回错误码
*/
ssize_t read(struct file * file, char __user *buf, size_t size, loff_t *ppos)
说明1:read函数调用关系
从调用关系中可见,如果read函数未被实现,当用户进行read系统调用时,会返回-EINVAL
说明2:__user宏
① address_space表示的空间如下,
0:内核空间
1:用户空间
2:IO空间
3:CPU空间
② noderef表示所修饰的变量必须是非解引用的,也就是必须是一个地址值
说明:copy_to_user函数
① copy_to_user函数用于完成从内核空间到用户缓冲区的拷贝,函数原型如下,
② copy_to_user函数的返回值为不能被拷贝的字节数,因此,如果完全拷贝成功,返回值为0
③ 内核空间虽然可以访问用户空间的缓冲区,但是在访问之前,一般需要先进行合法性检查,以确定传入的缓冲区确实属于用户空间
④ 如果要拷贝到用户空间的是简单类型(e.g. char / int / long),可以使用put_user函数(经常用于unlocked_ioctl函数实现)。put_user函数成功时返回0,出错时返回-EFAULT
write函数用于向设备发送数据
/*
* file: 打开的设备文件
* buf: 存储写入数据的用户态buffer
* size: 要写入的数据长度
* ppos: 指向当前读写位置的指针
* 返回值:成功时应返回写入的字节数,出错时应返回错误码
*/
ssize_t write(struct file *file, const char __user *buf, size_t size, loff_t
*ppos);
说明:write函数调用关系
从调用关系中可见,如果write函数未被实现,当用户进行write系统调用时,会返回-EINVAL
说明:copy_form_user函数
① copy_from_user函数用于完成从用户缓冲区到内核空间的拷贝,函数原型如下,
② copy_from_user函数的返回值为不能被拷贝的字节数,因此,如果完全拷贝成功,返回值为0
③ copy_from_user也会对用户空间的缓冲区进行合法性检查
④ 如果要拷贝到内核空间的是简单类型(e.g. char / int / long),可以使用get_user函数(也是经常用于unlocked_ioctl函数实现)。get_user函数成功时返回0,出错时返回-EFAULT
需要特别注意的是,传递给get_user的x是一个存储读取数值的内核态变量,而不是指针
llseek函数用于修改文件的当前读写位置,并返回新的读写位置
/*
* file: 打开的设备文件
* offset: 文件偏移量
* orig: 文件偏移量计算基准,
* 可以是SEEK_SET(基于文件开头) / SEEK_CUR(基于文件当前位置) / SEEK_END(基于文件结尾)
* 返回值: 成功时应返回新的文件读写位置,出错时应返回错误码
*/
loff_t llseek(struct file *file, loff_t offset, int orig);
说明1:llseek函数调用关系
从调用关系中可见,如果llseek函数未被实现,当用户进行lseek系统调用时,最终会调用no_llseek函数,该函数会返回-ESPIPE,即该设备不支持seek操作
说明2:对于不支持seek操作的设备文件(e.g. pipe文件),有如下2种处理方式,
① 在设备驱动的open函数中调用noseekable_open函数,清除file.f_mode中的FMODE_LSEEK标志
② 在设备驱动中将file_operations.llseek函数指针设置为no_llseek函数
运行如下测试用例,可见运行结果符合预期
说明1:测试用例中的lseek操作必不可少,因为write操作会修改文件的读写位置,需要lseek到文件的开始位置才能读取到write操作写入的数据
说明2:将copy_to/from_user改为memcpy会怎样?
经过验证修改后的驱动程序可以工作,但是此时不会检查用户态地址的合法性
unlocked_ioctl函数用于提供设备控制命令
/*
* file: 打开的设备文件
* cmd: 控制命令
* arg: 对应于控制命令的参数
* 返回值: 成功时应返回0,出错时应返回错误码
*/
long unlocked_ioctl(struct file *file, unsigned int cmd, unsigned long arg);
说明1:unlocked_ioctl函数调用关系
① 从调用关系中可见,如果unlocked_ioctl函数未被实现,当用户进行ioctl系统调用时,会返回-ENOTTY
② 函数名之所以称作unlocked_ioctl,是因为在调用过程中内核不会持有锁。如果需要锁机制,需要在设备驱动注册的unlocked_ioctl函数中实现
说明2:控制命令的构成
① 从使用的角度,控制命令只需要在设备驱动和应用程序之间约定好即可,但是简单的命令定义方式会导致不同的设备驱动拥有相同的命令码
② 传递控制命令的参数cmd本质上是一个4B整数,Linux内核建议以如下方式构建控制命令,并提供了相应的宏定义。其中数据传送的方式是从应用程序角度来看的,即_IO(无数据传输)、_IOCR(读)、_IOCW(写)和_IOCWR(双向)
③ 使用_IOR / _IOW / _IOWR宏定义控制命令时,size字段只需要传递相关参数类型即可,参数尺寸将由这些宏内部调用的_IOC_TYPECHECK宏计算
说明3:从do_vfs_ioctl函数可见,内核中预定义了一些控制命令(e.g.FIOCLEX)。如果设备驱动中包含了与预定义命令一样的命令码,这些命令会作为预定义命令被内核处理,而不是被设备驱动处理
说明4:关于控制命令参数
控制命令参数为insigned long类型,可以传递值,也可以传递用户态指针
说明:由于unlocked_ioctl函数可能需要在用户态和内核态之间拷贝控制命令参数,因此内核中的一些框架会对其进行封装,以便统一处理。以V4L2框架为例,由video_usercopy函数实现控制命令的处理框架
需要注意的是,这种框架的实现严格依赖内核提供的控制命令定义方式,只有正确传递控制命令参数的大小,该处理框架才能正常工作
运行如下测试用例,可见运行结果符合预期
mmap函数用于将设备内存映射到进程的虚拟地址空间中
/*
* file: 打开的设备文件
* vma: 内核分配的虚拟存储区,也就是要映射到进程虚拟地址空间中的目标范围
* 返回值: 成功时应返回0,出错时应返回错误码
*/
int mmap(struct file *file, struct vm_area_struct *vma);
说明:mmap函数调用关系
从调用关系中可见,如果mmap函数未被实现,当用户进行mmap系统调用时,会返回-ENODEV
说明:关于mmap系统调用的实现,可参考Linux操作系统原理与应用04:内存管理 chapter 7
运行如下测试用例,可见运行结果符合预期。需要注意的是,测试用例需要对映射后的内存进行读写操作,因此映射权限必须具有读写权限,而映射模式必须是共享模式(否则strcpy写入的数据,不会真正写入globalmem设备持有的内存中)
Linux中的大部分设备驱动都是以字符设备驱动的形式存在,但是内核的字符设备驱动框架提供的功能非常简单,着重于定义VFS与设备驱动的接口。因此在实际使用中,内核的不同子系统会根据需要,在字符设备的基础上实现一个框架来抽象这类设备的共性操作(e.g. misc子系统、V4L2子系统)
misc子系统是在字符设备驱动基础上构建的最简单抽象,一般用于实现无法归类的杂项设备驱动。通过分析misc子系统,可以初步理解Linux内核中驱动分层设计的思想
在Linux内核中,使用miscdevice结构体描述misc设备,
说明:miscdevice结构体中的parent字段指向misc设备在Linux设备模型中的父设备,该父设备一般是misc设备对应的硬件设备(一般体现为Linux设备模块中的platform设备)
misc_init函数为misc子系统的初始化函数,该函数以subsys_initcall(".initcall4.init")等级在系统初始化过程中被调用
说明1:misc_init函数中注册了/proc/misc文件,读取该文件可以获得当前系统中注册的misc设备
说明2:misc设备的主设备号为10,并且一个cdev结构体关联了从MKDEV(10, 0)到MKDEV(10, 255)的设备节点
通过misc子系统实现字符设备驱动,就可以避免为驱动定义一个专门的主设备号,从而减少对主设备号资源的浪费
说明3:misc_fops结构体是所有misc设备共用的设备文件操作函数集,其中只实现了open和llseek函数。之所以可以这么做,是因为后续会使用各个misc设备自己的file_operations结构体替换file结构体中的f_op指针指向,详见后文分析
说明4:misc_devnode设备注册在class结构体中,共Linux设备驱动模型devtmpfs使用
misc_register函数用于向内核注册misc设备,并且利用Linux设备模块自动创建设备节点
说明1:misc_register函数是在misc类上创建设备,因此每个注册的设备会以一个目录的形式出现在/sys/class/misc/目录下
说明2:注册misc设备实例
在Linux内核的watchdog子系统中,当要注册的watchdog_device.id为0时,会注册watchdog_miscdev设备
说明3:注销misc设备通过misc_deregister函数实现
4.3.3 打开misc设备
打开misc设备节点时,会调用misc子系统注册的misc_open函数
说明1:虽然与misc子系统注册的cdev结构体关联的file_operations结构体中只实现了open和llseek函数,但是misc_open函数中将file结构体的f_op指针指向了miscdevice结构体中注册的file_operations结构体,所以可以通过新指向的file_operations结构体支持其他在设备文件上进行的系统调用
说明2:每次打开misc设备的open系统调用都会调用到misc_open函数,因为该函数所在的misc_fops结构体与misc子系统注册的cdev结构体关联
说明3:misc子系统核心数据结构之间的关系如下图所示,
将上一章的示例程序修改为misc设备,需要对如下函数进行修改,
设备结构体
设备结构体中不再封装cdev结构体,而是miscdevice结构体
模块加载函数
在模块加载函数中不再注册字符设备,而是设置设备结构体中封装的miscdevice结构体,并且调用misc_register函数进行注册。可见使用misc子系统确实简化了字符设备驱动的实现
模块卸载函数
open函数
在open函数实现中,通过misc_open函数传递的miscdevice结构体指针索引到设备结构体
加载chrdev_misc.ko之后,
① /sys/class/misc/目录下生成globalmem的设备目录
② /dev目录下生成globalmem设备节点
运行如下测试用例,可见运行结果符合预期
说明:关于miscdevice结构体name字段的设置
① 上述示例实现中,2个miscdevice结构体的name字段是共用一个字符数组设置的
这点虽然在创建misc设备节点的过程中没有问题,但是在通过cat /proc/misc命令列出当前系统中的misc设备时,会触发段错误
② 之所以会发生段错误,是因为设置miscdevice结构体name字段的字符数组是在模块加载函数的栈中,这片内存会在模块加载函数执行完成后被释放。因此当cat /proc/misc命令要访问这段内存时,会触发段错误
③ 其实结合cat /proc/misc命令的打印方式,2个miscdevice结构体共用一个字符数组也是不合理的
④ 改为使用字符串字面值设置miscdevice结构体的name字段之后,cat /proc/misc命令可正常执行(当然也可以改为使用全局数组来设置name字段)
⑤ 这种直接使用字符串字面值设置name字段的方式比较缺乏灵活性,一种可行的方案是将存储misc设备的name的字符数组定义在设备结构体中。然后在模块加载函数中设置设备结构体中的字符数组,并使用该数组设置miscdevice结构体的name字段
经过验证,该方案是可行的,