Linux设备驱动基础03:Linux字符设备驱动

1 Linux文件系统与设备驱动

1.1 文件系统与设备驱动之间的关系

Linux中文件系统与设备驱动程序之间的关系如下图所示,

  1. 应用程序和VFS之间的接口是系统调用;VFS和文件系统以及设备文件之间的接口是file_operations结构体中的成员函数,该结构体包含对文件进行打开、关闭、读写和控制的一系列成员函数

  1. 字符设备上层没有类似磁盘的ext2等文件系统,所以字符设备的file_operations结构体需要由字符设备驱动程序提供,这正是字符设备驱动的核心

  1. 对于块设备,有两种访问方式,

① 不通过文件系统直接访问块设备(e.g. 直接操作磁盘设备文件/dev/mmcblkx)

此时块设备的file_operations结构体使用Linux内核实现的def_blk_fops

② 通过文件系统访问块设备

此时块设备的file_operations结构体由相应的文件系统提供,文件系统会将针对文件的读写转换为针对块设备原始扇区的读写,下图为ext4文件系统实现的file_operations结构体

说明:用户空间看不到设备驱动,能看到的只有和设备对应的文件

1.2 核心数据结构

1.2.1 inode结构体

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

1.2.2 file结构体

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设备结构体

1.2.3 file_operations结构体

file_operations结构体中的成员函数是字符设备驱动程序设计的主体内容,这些函数会在应用程序调用open / close / read / write等系统调用时最终被内核调用。需要注意的是,不同版本内核的file_operations结构体会有所不同

2 Linux字符设备驱动结构

2.1 cdev结构体

在Linux内核中,使用cdev结构体描述字符设备。其中关于dev和count字段的理解,详见后文分析

说明1:Linux内核的编程习惯是定义一个设备相关的结构体,该结构体包含所涉及的cdev、私有数据以及锁等信息。在chrdev_basic示例程序中,就是将cdev结构体和所持有的内存资源打包为一个设备结构体

说明2:使用cdev_alloc函数可以动态申请一个cdev结构体,该函数会对cdev结构体中的部分成员进行初始化

2.2 核心操作

2.2.1 分配注册设备号

在向系统注册字符设备之前,需要先调用register_chrdev_regionalloc_chrdev_region向系统申请设备号,其中,

  1. register_chrdev_region函数用于已知起始设备号的情况

  1. 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的哈希表

2.2.2 初始化cdev结构体

cdev_init函数用于初始化cdev结构体成员,并建立cdev结构体和file_operations结构体之间的联系

2.2.3 注册字符设备

在完成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:字符设备驱动核心数据结构之间的关系如下图所示,

2.3 示例验证

2.3.1 open/release函数实现

为了验证chrdev_basic示例程序中注册字符设备的行为,此处先给出file_operations结构体中open和release函数的实现

说明:globalmem_open函数中的inode->i_cdev就是指向与该文件节点关联的cdev结构体,而该cdev结构体又被包含在设备结构体中。因此,通过container_of宏就可以索引到相应的设备结构体。关于container_of宏的实现,可参考01. 概述 chapter 6.2

2.3.2 手动创建设备节点

  1. 加载chrdev_basic.ko,可见分配到的主设备号为234

  1. 通过cat /proc/devices命令可以验证主设备号234确实被示例驱动程序占用

  1. 此时尚未建立设备节点,需要通过mknod命令手动创建

mknod  设备节点路径 设备类型 主设备号  次设备号

  1. 运行如下测试用例,可见运行符合预期,不同的inode节点可以索引到相应的cdev结构体

2.3.3 自动创建设备节点

  1. 如果想要实现在加载驱动时自动创建设备节点,则需要依靠Linux设备驱动模型及udev机制,本节仅说明使用方法(核心是创建设备类,并在该设备类上创建设备)

下面给出class_createdevice_create的函数原型,

  1. 加载chrdev_audodev.ko,可见设备节点被自动创建,运行测试用例结果与之前相同

  1. 加载chrdev_audodev.ko后,会创建/sys/class/globalmem目录,并且创建相应的sysfs设备文件

2.3.4 多个字符设备使用一个cdev结构体

  1. 在chrdev_basic和chrdev_autodev示例程序中,都是将cdev结构体包含在设备结构体之中,因此需要使用多个cdev结构体。但是这些cdev结构体关联的file_operations结构体是相同的,因此可以改为多个设备共用一个cdev结构体,chrdev_onecdev示例程序中的相关修改如下(在chrdev_autodev示例程序基础上修改),

① 将cdev结构体移出设备结构体,将其定义为全局变量

② 在调用cdev_add函数注册字符设备时,将多个设备号与一个cdev结构体关联起来

③ 由于设备结构体中不再包含cdev结构体,所以在open函数中改为通过inode中记录的子设备号来索引相应的设备结构体

  1. 加载chrdev_onecdev.ko,可见设备节点被自动创建,运行测试用例结果与之前相同

3 file_operations结构体基础实现示例

3.1 open函数

3.1.1 函数原型与功能

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函数,此时默认文件打开成功

3.1.2 示例实现

说明:需要注意的是,一个进程中多次打开同一个设备节点,每次打开都会创建新的file结构体,并且每次都会调用到open函数。作为验证,运行如下测试用例,并且在驱动中打印file结构体指针。可见每次打开操作的inode结构体相同(表示打开的是同一个设备文件),但是file结构体不同

3.2 release函数

3.2.1 函数原型与功能

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结构体引用计数

3.2.2 示例实现

3.3 read函数

3.3.1 函数原型与功能

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表示所修饰的变量必须是非解引用的,也就是必须是一个地址值

3.3.2 示例实现

说明:copy_to_user函数

① copy_to_user函数用于完成从内核空间到用户缓冲区的拷贝,函数原型如下,

② copy_to_user函数的返回值为不能被拷贝的字节数,因此,如果完全拷贝成功,返回值为0

③ 内核空间虽然可以访问用户空间的缓冲区,但是在访问之前,一般需要先进行合法性检查,以确定传入的缓冲区确实属于用户空间

④ 如果要拷贝到用户空间的是简单类型(e.g. char / int / long),可以使用put_user函数(经常用于unlocked_ioctl函数实现)。put_user函数成功时返回0,出错时返回-EFAULT

3.4 write函数

3.4.1 函数原型与功能

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

3.4.2 示例实现

说明: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是一个存储读取数值的内核态变量,而不是指针

3.5 llseek函数

3.5.1 函数原型与功能

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函数

3.5.2 示例实现

3.5.3 read / write / llseek函数示例验证

运行如下测试用例,可见运行结果符合预期

说明1:测试用例中的lseek操作必不可少,因为write操作会修改文件的读写位置,需要lseek到文件的开始位置才能读取到write操作写入的数据

说明2:将copy_to/from_user改为memcpy会怎样?

经过验证修改后的驱动程序可以工作,但是此时不会检查用户态地址的合法性

3.6 unlocked_ioctl函数

3.6.1 函数原型与功能

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类型,可以传递值,也可以传递用户态指针

3.6.2 示例实现

说明:由于unlocked_ioctl函数可能需要在用户态和内核态之间拷贝控制命令参数,因此内核中的一些框架会对其进行封装,以便统一处理。以V4L2框架为例,由video_usercopy函数实现控制命令的处理框架

需要注意的是,这种框架的实现严格依赖内核提供的控制命令定义方式,只有正确传递控制命令参数的大小,该处理框架才能正常工作

3.6.3 示例验证

运行如下测试用例,可见运行结果符合预期

3.7 mmap函数

3.7.1 函数原型与功能

mmap函数用于将设备内存映射到进程的虚拟地址空间中

/*
  * file: 打开的设备文件
  * vma: 内核分配的虚拟存储区,也就是要映射到进程虚拟地址空间中的目标范围
  * 返回值: 成功时应返回0,出错时应返回错误码
  */
  int mmap(struct file *file, struct vm_area_struct *vma);

说明:mmap函数调用关系

从调用关系中可见,如果mmap函数未被实现,当用户进行mmap系统调用时,会返回-ENODEV

3.7.2 示例实现

说明:关于mmap系统调用的实现,可参考Linux操作系统原理与应用04:内存管理 chapter 7

3.7.3 示例验证

运行如下测试用例,可见运行结果符合预期。需要注意的是,测试用例需要对映射后的内存进行读写操作,因此映射权限必须具有读写权限,而映射模式必须是共享模式(否则strcpy写入的数据,不会真正写入globalmem设备持有的内存中)

4 misc子系统分析

4.1 概述

  1. Linux中的大部分设备驱动都是以字符设备驱动的形式存在,但是内核的字符设备驱动框架提供的功能非常简单,着重于定义VFS与设备驱动的接口。因此在实际使用中,内核的不同子系统会根据需要,在字符设备的基础上实现一个框架来抽象这类设备的共性操作(e.g. misc子系统、V4L2子系统)

  1. misc子系统是在字符设备驱动基础上构建的最简单抽象,一般用于实现无法归类的杂项设备驱动。通过分析misc子系统,可以初步理解Linux内核中驱动分层设计的思想

4.2 miscdevice结构体

在Linux内核中,使用miscdevice结构体描述misc设备,

说明:miscdevice结构体中的parent字段指向misc设备在Linux设备模型中的父设备,该父设备一般是misc设备对应的硬件设备(一般体现为Linux设备模块中的platform设备)

4.3 核心操作

4.3.1 misc子系统初始化

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使用

4.3.2 注册misc设备

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子系统核心数据结构之间的关系如下图所示,

4.4 示例实现

将上一章的示例程序修改为misc设备,需要对如下函数进行修改,

  1. 设备结构体

设备结构体中不再封装cdev结构体,而是miscdevice结构体

  1. 模块加载函数

在模块加载函数中不再注册字符设备,而是设置设备结构体中封装的miscdevice结构体,并且调用misc_register函数进行注册。可见使用misc子系统确实简化了字符设备驱动的实现

  1. 模块卸载函数

  1. open函数

在open函数实现中,通过misc_open函数传递的miscdevice结构体指针索引到设备结构体

4.5 示例验证

  1. 加载chrdev_misc.ko之后,

① /sys/class/misc/目录下生成globalmem的设备目录

② /dev目录下生成globalmem设备节点

  1. 运行如下测试用例,可见运行结果符合预期

说明:关于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字段

经过验证,该方案是可行的,

猜你喜欢

转载自blog.csdn.net/chenchengwudi/article/details/128917916
今日推荐