岁虚山

行有不得,反求诸己

0%

字符设备驱动程序

概述

Linux 系统将设备分为三类:字符设备块设备网络设备

三种设备的定义分别如下:

  1. 字符设备:只能一个字节一个字节的读写的设备,不能随机读取设备内存中的某一数据,读取数据需要按照先后顺序进行。字符设备是面向流的设备,常见的字符设备如鼠标、键盘、串口、控制台、LED等。
  2. 块设备:是指可以从设备的任意位置读取一定长度的数据设备。块设备如硬盘、磁盘、U盘和SD卡等存储设备。
  3. 网络设备:网络设备比较特殊,不在是对文件进行操作,而是由专门的网络接口来实现。应用程序不能直接访问网络设备驱动程序。在 /dev 目录下也没有文件来表示网络设备。

对于字符设备和块设备来说,在 /dev 目录下都有对应的设备文件,Linux 用户程序通过设备文件或叫做设备节点来使用驱动程序操作字符设备和块设备。

本章内容主要介绍编写设备文件驱动程序所涉及到的相关知识和数据结构,下一张内容会实现一个字符驱动程序,并对其进行访问。

在介绍字符设备驱动程序之前,我们首先要了解一点基础的知识,首先我们来看一下应用程序调用的流程:

应用程序调用流程

字符设备文件

对字符设备的访问是通过文件系统内的设备名称进行的,那些名称被称为特殊文件、设备文件,或者简单称之为文件系统树的节点,他们通常位于 /dev 目录。使用 ls -l /dev 命令来查看:

1
ls -l /dev

截屏2022-11-21 23.22.08

在列出的设备文件信息中,第一列的 “c” 代表着字符设备,“b” 代表着块设备。

cdev

字符设备驱动程序的大致结构如下:

字符设备驱动程序结构

Linux 中,使用 struct cdev 来描述一个设备,该结构体定义在 cdev.h 头文件中,其原型如下:

1
2
3
4
5
6
7
8
struct cdev {
struct kobject kobj; // 内嵌的内核对象
struct module *owner; // 该字符设备所在的内核模块(所有者)的对象指针,一般为 THIS_MODULE,主要用于模块计数
const struct file_operations *ops; // 该结构体描述了字符设备所能实现的操作集,如打开、关闭、读取、写入等操作
struct list_head list; // 用来将已经向内核注册的所有字符设备形成链表
dev_t dev; // 字符设备的设备号,由主设备号与次设备号构成,如果是一次申请多个设备,此设备号为第一个设备的设备号
unsigned int count; // 隶属于同一主设备号的次设备号的个数
} __randomize_layout;

在该结构体中,我们主要关心的的两个属性是 dev_t devconst struct file_operations *ops

设备号 dev_t

Linux 的设备管理是和文件系统紧密结合的,各种设备都以文件的形式存放在 /dev 目录下,称为设备文件。应用程序可以打开、关闭和读写这些设备文件,完成对设备的操作,就像操作普通的数据文件一样。

为了管理这些设备,系统为设备编了号,每个设备号又分为主设备号和次设备号。主设备号用来区分不同种类的设备,而次设备号用来区分同一类型的多个设备。对于常用设备,Linux 有约定俗成的编号,如终端类设备的主设备号是 4。

在内核中, dev_t 类型用来表示设备号,在 types.h 中定义。在当前的内核版本中, dev_t 是一个 32 位的数,前 12 位用来表示主设备号,而其余 20 位用来表示次设备号。但是我们在使用的时候要注意,我们的代码不应该对设备编号的组织作出任何假定,而应该始终使用 types.h 中的宏定义来定义设备号变量。

关于设备号常用的相关函数(宏定义)主要有 3 个:

1
2
3
MAJOR(dev_t dev);       // 获取 dev_t 的主设备号
MINOR(dev_t dev); // 获取 dev_t 的次设备号
MKDEV(ma, mi); // 将主设备号和次设备号转换为 dev_t 类型

在我们使用 ls -l /dev 来查看 /dev 目录下的设备文件时,命令的返回结果:

截屏2022-11-21 23.22.08

中,autofsbtrfs-control 便是两个字符设备,其中,日期之前的两列就是这两个设备的主次设备号,用逗号分隔。如 autofs 设备的主设备号就是 10,次设备号就是 235btrfs-control 的主设备号是 10, 次设备号是 234

通常而言,主设备号标识设备对应的驱动程序,在上述的例子中,autofsbtrfs-control 的主设备号都是 10 代表着两个设备都由驱动程序 10 来进行管理。虽然现代的 Linux 内核允许多个驱动程序共享主设备号,但是目前的大多是设备仍然按照“一个主设备号对应一个驱动程序”的原则组织。

次设备号由内核使用,用于正确确定设备文件所指的设备,即驱动程序所实现的设备。

简单点来讲,就是一个字符设备驱动程序中,可以管理多个设备。举例来说明的话,就是目前常见的有些鼠标键盘生产厂商所生产的鼠标键盘套装,这一套设备只有一个驱动程序,而这一个驱动程序可以同时管理鼠标与键盘。因此,主设备号就对应着这一套产品的驱动程序,而次设备号就指向了被这个驱动程序所管理的鼠标或键盘。

在内核中,维护着一个以主设备号为 Key 的哈希表,哈希表中的数据部分则指向与该主设备号相对应的驱动程序的指针。如果一个驱动程序管理着多个同类型的设备,则哈希表中的数据部分指向这些设备驱组成的数组。如下图所示:

设备号哈希表

通过这种方式,内核能够快速的根据主设备号找到主设备号所对应的驱动程序,或通过主设备号找到共享一个主设备号的多个设备的数组,再通过次设备号索引找到对应的子设备。

分配设备编号

在创建一个字符设备之前,我们的驱动程序首先要做的事就是获取一个或者多个设备编号。获取设备编号的方式有两种:

一种是在我们明确所需要的设备编号的前提下,主动去注册一个设备编号,所使用的函数是

1
int register_chrdev_region(dev_t first, unsigned int count, char *name);

该函数定义在 头文件中。

其中,first 参数是要分配的设备编号范围的起始值,其类型为 dev_t 类型的。在前面我们讲过,dev_t 中同时存放着主设备号和次设备号,在注册设备号时,first 的次设备号经常被置为 0,这是因为 register_chrdev_region 函数允许一次注册多个设备,其中 count 便是我们要注册的设备的数量。在注册多个设备时,这些设备号的次设备号是连续且递增的。比如我们一次注册 3 个设备,第一个设备的次设备号是 0,则第二个设备的设备号就是 1,第三个设备的次设备号就是 2。为了贯彻我们的 “一个驱动程序对应一个主设备号”的原则,以及方便后续的维护,通常将 first 参数的次设备号置为 0,那么我们注册的多个设备的次设备号就将从 0 开始。当然,这只是我们习惯上的要求,并不是一定要将次设备号置为 0 才可以。

name 参数是该所申请的编号范围内关联的设备名称,它将出现在 /proc/devicessysfs 中。

通常,我们在使用 register_chrdev_region 函数时,需要传入我们想要的设备编号,虽然我们知道 dev_t 是一个 32 位的数,前 12 位代表主设备号,后 20 位代表次设备号,但是我们最好还是使用系统提供的宏 MKDEV 来构造这个设备号。

如:我们要注册主设备号为 100 的 3 个设备,设备名称为 hello,那么规范的写法应该是:

1
2
3
int ret = 0;
dev_t dev_num = MKDEV(100, 0);
ret = register_chrdev_region(dev_num, 3, "hello");

或者:

1
2
int ret = 0;
ret = register_chrdev_region(MKDEV(100, 3), 3, "hello");

需要注意的是,和大部分内核函数一样,register_chrdev_region 函数在分配成功时会返回 0;在错误情况下,将返回一个负的错误码,此时,我们所想要注册的编号区域时不能使用的,因为没有注册成功。

在我们没有明确所要使用的设备编号的情况下,我们通常希望内核能够动态地给我们分配一些设备编号,此时用到的函数是:

1
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, char *name);

firstminor 参数为我们想要设置的第一个设备的次设备号,一般为 0。

countname 参数的含义与register_chrdev_region 中相同。

alloc_chrdev_region 会根据我们输入的 firstminor 参数与 count 参数来为我们恰当分配我们所需的设备号,并将以分配的设备号范围的第一个编号保存在 dev 参数中。

通常情况下,我们动态申请设备号时,firstminor 参数设为 0,那么假如我们申请三个设备,即 count 参数为 3,那么内核就会为我们分配一个主设备号与该主设备号下的三个从设备号。假设内核给我们分配的主设备号为 100,那么如果我们用 主设备号.从设备号 的方式来表示设备号的话,对应的三个从设备号就为:100.0100.1100.2,存放在 dev 变量中的设备号就是 100.0

该函数的常规用法举例来讲:我们要申请 3 个设备,设备名称为 hello,那么规范的写法应该是:

1
2
3
int ret;
dev_t dev_num;
ret = alloc_chrdev_region(&dev_num, 0, 3, "hello");

同样的,alloc_chrdev_region 函数在申请成功时返回 0,申请失败时返回一个负的错误码。

Linux 系统中,一部分主设备号已经静态分配给了大部分常见设备。并且,系统中可能存在了很多其他的驱动程序,占用了部分主设备号,而它们占用了哪些设备号有坑在不同的化境中不一样,我们很难确定一个设备号是否未被占用。因此,使用指定主设备号的方法 register_chrdev_region 可能会因为想要注册的设备号已经被分配给了其他驱动程序而导致注册失败,为了使我们的设备更具备通用性和适应能力,应该始终使用 alloc_chrdev_region 来动态地申请设备号。

释放设备号

无论采用哪种方法分配设备号,都要在不在使用它们时释放这些设备编号。设备编号的释放使用的下面这个函数:

1
void unregister_chrdev_region(dev_t first, unsigned int count);

first 蚕食是我们所申请到的设备号范围的第一个设备号,count 参数为设备号的个数。

通常,我们在模块的卸载函数中调用 unregister_chrdev_region 来释放我们所申请的设备号资源。

struct file_operations *ops

struct file_operations 用来连接设备与驱动程序操作,定义在 fs.h 头文件中,其原型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct file_operations {
// 模块拥有者,一般为 THIS_MODULE
struct module *owner;
// 用来修改文件读写位置,并将新位置返回,出错时返回一个负值
loff_t (*llseek) (struct file *, loff_t, int);
// 从设备中读取数据,成功时返回读取的字节数,出错返回负值(绝对值是错误码)
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
// 向设备发送数据,成功时该函数返回写入字节数。若为被实现,用户调层用write()时系统将返回
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
// 刷新设备
int (*flush) (struct file *, fl_owner_t id);
// 打开设备
int (*open) (struct inode *, struct file *);
// 关闭设备
int (*release) (struct inode *, struct file *);
...
} __randomize_layout;

Linux 中一切皆“文件”,字符设备也是这样,对于用户程序中,针对文件的常用操作,如 open()read()write(), close() 等,对字符设备也可以进行同样的操作。与常规的文件操作不同的是:文件的打开、关闭、读取、写入等操作的实现,都是由操作系统来完成的,而对于字符设备程序而言,这些操作的具体实现都需要我们自己在编写字符设备驱动程序的时候来实现。

比如说,我们的字符设备驱动程序想要响应用户程序的 open() 功能,我们就需要自己实现一个函数 dev_open() ,并将 struct file_operations 结构体中的指针int (*open) 指针指向该函数,这样,用户程序就不用关心字符设备的 open() 函数是什么,怎样实现的,用户程序只需要向打开一个文本文件一样,调用字符设备的 open() 函数就可以了,而 open() 函数实际上的实现是我们自己编写的 dev_open() 函数。

同样的,若我们的字符设备驱动程序想要提供其他的文件操作,也要自己进行实现,而对于我们没有实现的相关操作,要将 struct file_operations 中对应的函数指针设为 NULL。对各个函数而言,如果对应字段被赋值为 NULL 指针,那么内核的具体处理行为时不尽相同的。

对于 file_operations 还有两个重要的结构体需要进行说明:struct inodestruct file,这两个结构体在我们实现 file_operations 的相关操作中会用到,如file_operations 中的 openrelease 方法中就需要这两个参数。

file 文件描述符

struct file 结构体在linux/fs.h 头文件中定义,需要进行说明的是,该结构与用户空间中,我们常用的文件操作的 file 没有任何关联,strucr file 是一个内核结构,它不会出现在用户程序中,其主要定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct file {
struct path f_path;
struct inode *f_inode;
const struct file_operations *f_op;
spinlock_t f_lock;
enum rw_hint f_write_hint;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;
void *private_data;
···
} __randomize_layout

在内核中,struct file 代表着一个打开的文件(它并不仅限于设备驱动程序,系统中每个打开的文件在内核空间都有一个对应的 file 结构),是对一个文件的描述。它由内核在一个文件 open 或被新建的时候创建,并传递给在该文件上进行操作的所有函数,直到最后的 close 函数。在文件的所有实例都被关闭之后,内核会释放这个数据结构。

通俗点来讲,在 Linux 系统中,当一个文件被打开或被创建的时候,那么内核空间中就会创建一个 struct file 数据结构,这个数据结构描述着该文件的读写属性、读写位置以及读写操作等信息,对文件的所有操作都会通过 struct file 进行。

需要注意的是,如果有多个进程打开同一个文件,内核为每一个进程都创建一个 struct file 结构体,而这多个结构都同时指向了同一个文件。甚至在同一个进程中多次打开了一个文件,内核也会创建多个 struct file 结构体。

在内核源码中,struct file 要么表示为 file,或者为 filp (意指“file pointer”),注意区分一点,file 指的是 struct file 本身,而 filp 是指向这个结构体的指针。

inode 索引节点

Linux 文件系统中,每个文件都有一个 inode,即所谓的索引节点,该结构在 linux/fs.h 头文件中定义,用以描述在文件系统中该文件的属性信息。每个文件在系统中都有唯一一个 inode 节点。

当访问某个文件时,系统通过该文件的 inode 来索引到该文件,并通过它的 inode 知道这个文件是什么类型的文件(如文本文件、设备文件等)、怎样组织的、文件中存储着多少数据、所有者、时间戳等信息。

想要更清晰的了解 inode,需要先了解磁盘的存储结构以及文件在磁盘中是如何存储的。

在安装操作系统或格式化磁盘分区的时候,操作系统会把磁盘分区成两个区域:block 存储区和 inode 存储区,如下所示:

磁盘分区结构

文件是存储在硬盘上的,硬盘的最小存储单位叫做扇区 sector,每个扇区存储 512字节。操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个块 block。这种由多个扇区组成的块,是文件存取的最小单位。块的大小,最常见的是 4KB,即连续八个 sector 组成一个 block

文件中的数据便是存储在 block 区中的。但是,一个 block 一般只有 4kb ,而我们的通常所常见与使用的文件,大小远远超过 4kb,所以操作系统往往是把文件拆散,存放在多个 block 中。

那么在使用一个文件时,系统又是如何知道这个文件存放在哪个或哪些 block 中,从而从磁盘中读取数据呢?这就是 inode 的作用:系统中的每一个文件都有一个 inode ,用来存储文件的元数据,并记录这个文件中的数据是具体存放的 block 的位置。

inode 是一种数据结构,用来存储以下信息:

1、文件大小;
2、文件类型(常规文件、目录、软连接等);
3、权限(读、写、执行权限);
4、属主(所属用户);
5、属组(所属用户组);
6、链接数(有多少个文件名指向这个inode);
7、文件时间戳(创建时间、最近访问时间、最近修改时间);
8、文件内容所在Block位置。

每一个 inode 都有一个编号,系统根据 inode 编号可以快速的计算出 inode 信息在磁盘 inode 存储区的偏移,然后从中获取 inode 信息,再根据 inode 信息中记录的 block 块位置,从 block 存储区读出文件内容。也就是说,系统定位一个文件,是通过这个文件的 inode 编号来寻找文件的,但是我们对文件的操作,多是通过文件名而非 inode 编号,那么文件名又是如何与 inode 编号对应起来的呢?

这是因为,文件系统中维护着一个目录文件,这个文件中会记录文件名与 inode 编号。我们通过文件名对文件进行操作的时候,文件系统首先会从目录文件中找到找到这个文件名对应的 inode 编号,再通过 inode 编号来找到 inode 数据,然后通过 inode 的信息,找到真正存储文件数据的 block 块。因此,我们对文件进行重命名的时候,修改的只是目录文件中,inode 对应的信息,而不会修改文件的 inode 编号。

我们知道,一个文件通常存储在多个 block 中,文件系统在日常的运行中,随着文件的增删,产生大量的空隙,即在一片连续的 block 中存在空白的,未存储数据的 block,如下所示:

block 空隙

而这些空白的 block 如果不能充分利用起来,那将造成极大的磁盘空间浪费。而且如果一个文件过大,那么可能在 block 区中无法找到足够多连续的空白 block 来存储文件的数据。因此,文件系统采用了一种叫做非连续存储的方式,将文件分散的存放在不连续的 block 中。当采用这种存储方式时,文件系统会在存储数据的 block 中划分出一个很小的区域,用来存放下一个 block 的地址,形成链表一样的结构,从而找到所有存储某一文件的 block

文件索引

其实,关于 inode 我们只需要了解它与 struct file 结构的区别,后者表示打开的文件的描述符,只有当某个文件打开的时候,才会被内核创建并维护,而 inode 在内部表示文件,保存文件的元数据信息等。对于单位文件,可能会有多个表示打开的文件描述符的 struct file 结构,而 inode 值会有唯一的一个。并且,某个文件被打开时所有的 struct file 结构都指向该文件,即这个文件对应的 inode

对于编写驱动程序来讲,我们不需要关心 inode 中所包含的大量的有关文件的信息,我们只需要关心其中的两个字短即可:

dev_t i_rdev: 对表示设备文件的 inode ,该字短包含了真正的设备编号;

struct c_dev *i_cdevstruct code 是表示字符设备的内核内部结构,当 inode 指向一个字符设备文件时,该字短包含了指向 struct cdev 结构的指针。

因此,通过设备文件的 inode, 我们可以很轻松的获取到它的设备编号及字符设备的 struct c_dev 结构。但是值得注意的是,不同版本的内核,inodei_rdev 的类型可能有所不同。因此,为了编写可移植性更强的代码,内核中提供了两个宏来获取主设备号与次设备号:

1
2
unsigned int iminor(struct inode *inode);     // 获取次设备号
unsigned int imajor(struct inode *inode); // 获取主设备号

当我们想要通过 inode 结构来获取主、次设备号的时候,应该使用上面的宏,而非直接操作 i_rdev

字符设备的注册与释放

内核内部使用 struct cdev 来表示字符设备,在内核调用设备的操作之前,我们必须为我们的字符设备注册一个或多个上述结构。在 cdev.h 头文件中定义了该结构与其相关的一些函数。

申请 cdev

申请一个 struct cdev 结构所用到的函数如下:

1
struct cdev *cdev_alloc(void);

该函数在申请成功是返回一个 struct cdev 指针,在申请失败时返回 NULL。因此,在使用该函数时应该检查其返回值,判断是否申请成功。

初始化 cdev

struct cdev 结构中,有三个字段需要设置,它们分别是:

1
2
3
struct module *owner;                  // 该字符设备所在的内核模块(所有者)的对象指针,一般为 THIS_MODULE,主要用于模块计数
const struct file_operations *ops; // 该结构体描述了字符设备所能实现的操作集,如打开、关闭、读取、写入等操作
dev_t dev; // 字符设备的设备号,由主设备号与次设备号构成,如果是一次申请多个设备,此设备号为第一个设备的设备号

第一个字段 owner 代表字符模块的所有者,一般将其设置为 THIS_MODULE

第二个字段 ops 代表着该字符设备所关联的操作,有两种方法可以对其进行设置。第一种方法就是将其直接设置为我们所定义并实现的 const struct file_operations 变量的地址,另一种是使用下面的函数进行设置:

1
void cdev_init(struct cdev*cdev, struct file+operations* ops);

这两种方法都可以完成对 ops 字段的设置,任选其一即可。

第三个字段 dev 代表着设备编号,使用以下函数对其进行设置:

1
int cdev_add(struct cdev* dev, dev_t num, unsigned count);

一般来讲,一个设备对应着一个设备编号,因此往往 count 取 1,num 就是想要为该设备设置的设备编号。但是在默写情况下,会有多个设备编号对应一个特定的设备的情况,这时,count 代表着要绑定的设备编号的个数,num 为多个设备编号中的第一个,后续的 count -1 个编号则是 num 依次加 1 实现的,即该 count 个设备编号是连续的。

cdev_add 不仅起到着 “绑定” 设备编号的作用,当它调用成功时,它还会将设备添加进内核中,这时,我们的设备就 “活了”,它的操作就有可能被内核调用,因此,我们在调用该函数之前,应该先将 ownerops 进行初始化。

该函数可能会失败(虽然几乎总会成功),成功时返回 0,失败时返回一个负的错误码,此时设备不会被添加进内核中。因此,在调用该函数后需要检查它的返回值。

删除 cdev

要从系统中移除一个字符设备,应该调用以下函数:

1
void cdev_del(struct cdev *dev);

当然,要使用该函数从系统中移除一个字符设备,先决条件是在这之前已经成功地使用 cdev_add 函数将设备添加进了内核。

值得注意的是,我们调用 cdev_del 的时机应该是在需要删除字符设备(如模块的退出函数或错误处理中),该函数针对的是 cdev_alloc 函数,而非 cdev_init 函数或 cdev_add 函数。换句话说,当我们使用 cdev_alloc 函数成功注册设备后,就应该在某一步操作中使用 cdev_del 函数来删除 cdev

早期的方法

在 2.6 版本之前的内核中,可以看到不使用 struct cdev 结构来注册字符设备的方法:

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

其中 major 参数是主设备号,name 参数是驱动程序的名称,而 fops 是默认的 file_operations 结构。对该函数的调用将为给定的主设备号注册 0~255 作为次设备号,并为每一个设备创建一个默认的 struct cdev 结构。

当使用这个函数来注册字符设备的时候,那么我们的驱动程序必须能够处理所有 256 个次设备号上的 open 调用,不管它们是否真正独赢有十几设备,而且也不能使用大雨 255 的主设备号和次设备号。

对应的,使用 register_chrdev 方法注册的字符设备在删除是要使用:

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

majorname 参数必须与和使用 register_chrdev 函数注册时传入的一致,否则会调用失败。

当然,这种注册和删除字符设备的方法虽然目前系统中还保留着它们,但是这两个函数本身已经是属于过时的旧方法,有可能在未来新的内核中消失,因此在编写代码时,要注意避免使用这类过时的旧方法。

字符设备的访问

字符设备节点

前面说过,对字符设备的访问是通过文件系统内的设备名称进行的,那些名称被称为特殊文件、设备文件,或者简单称之为文件系统树的节点,他们通常位于 /dev 目录中。我们可以通过这些设备文件(节点)以正常操作文件的形式来对设备进行操作。

在字符设备成功通过 cdev_add 函数添加进系统,在 /proc/devices 文件中就已经可以查询我们的设备号和设备名称了:

cat /proc/devices

但是,此时我们的设备尚未出现在 /dev 目录下,即 /dev 目录下还没有我们的设备文件(节点),我们目前自然还无法通过设备文件(节点)来访问并操作设备文件。

要想通过 /dev 目录下的设备文件(节点)来访问设备,我们还需要在 /dev 目录下创建对应的设备文件(节点)。创建设备节点的方式有两种:手动创建和自动创建。

手动创建设备节点

手动创建设备节点使用的是系统提供的命令 mknode,其标准形式为:

1
mknod DEVNAME {b | c} MAJOR MINOR

其中,DEVNAME 是要创建的设备文件的名称,若要将设备文件放在特定的目录下,要首先使用 mkdir 命令在 /dev 目录下创建文件夹;

{b | c} 代表的设备的类型,b 是块设备,表示系统从块设备中读取数据的时候,直接从内存的buffer中读取数据,而不经过磁盘;c 是字符设备,表示字符设备文件与设备传送数据的时候是以字符的形式传送,一次传送一个字符,比如打印机、终端都是以字符的形式传送数据;

MAJORMINOR 是设备的主、次设备号,该设备号应与我们在代码中为设备申请或注册的设备号一致。成功添加进系统的设备的设备号可以在 /proc/devices 中查询到。

当然,除了上述参数,我们还可以在创建设备时设置别的选项,这时其用法为:

1
mknod operation param DEVNAME {b | c} MAJOR MINOR

其中,operation 为我们要设置的选项,paramoperation 所需的参数。选项主要包含以下几种:

-Z:设置安全的上下文;

-m:设置权限模式;

-help:显示帮助信息;

—version:显示版本信息。

对应的,要删除一个设备文件(节点),只需要同删除文件一样,使用 rm 名利即可:

1
rm -f DEVNAME

DEVNAME 是要删除的设备文件(节点)的名称,要与 mknod 时所用的文件(节点)名称一致。

举个简单的例子,假如我们在驱动程序注册或申请了一个名为 hello 的设备,其主设备号为 233,次设备号为 0,并使用 cdev_add 将其添加进系统中。那我们在编译成功并使用 insmod 将我们的驱动程序加载后,在 /proc/devices 中就应该能够看到一条这样的数据:

233 hello

此时,我们就能够使用 mknod 命令为该设备在 /dev 目录下创建设备文件(节点),如:

1
sudo mknod /dev/hello c 233 0

当然,我们还可以为该命令设置选项,如设置 660 权限:

1
sudo mknod -m 660 /dev/hello c 233 0

此时,/dev 目录下就已经创建成功了 hello 文件(节点)。

要想删除该文件(节点),只要使用:

1
sudo rm -f /dev/hello

即可删除 hello 文件(节点)。

值得注意的是,使用手动的方式在 /dev 目录下创建设备文件(节点),节点名称无需与我们代码中注册或申请设备号时传入的设备名称一样。即设备文件(节点)是通过设备号相关联的,而非设备名称。如在代码中,使用 alloc_chrdev_region 函数申请一个设备号,名称为 hello,那么在在驱动程序成功加载后, /proc/devices 中的设备名称同我们申请时的一致,即: hello。在我们使用 mknod 创建设备文件(节点)的时候,可以创建另外的名字,如 helloworld,但是主设备号要与申请到的设备号保持一致。

自动创建设备节点

内核提供了在加载模块自动在 /dev 目录下创建设备文件(节点)的机制,要想使用这种机制,我们首先需要弄明白它的运行机制和使用方式。

内核中在 linux/device/class.h 头文件中定义了 struct class 结构体,其原型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct class {
const char *name;
struct module *owner;
const struct attribute_group **class_groups;
const struct attribute_group **dev_groups;
struct kobject *dev_kobj;
int (*dev_uevent)(struct device *dev, struct kobj_uevent_env *env);
char *(*devnode)(struct device *dev, umode_t *mode);
void (*class_release)(struct class *class);
void (*dev_release)(struct device *dev);
int (*shutdown_pre)(struct device *dev);
const struct kobj_ns_type_operations *ns_type;
const void *(*namespace)(struct device *dev);
void (*get_ownership)(struct device *dev, kuid_t *uid, kgid_t *gid);
const struct dev_pm_ops *pm;
struct subsys_private *p;
};

一个 struct class 结构体类型变量对应一个类,类这个概念在 Linux 中被抽象成一种设备的集合,如 Linux 内核中的 gpiortc等类。 struct class 结构体中的属性很多,而目前我们不需要关心它的每个字段的含义,只需要知道它的用法就可以了。

内核提供了创建 struct class 结构体的函数:

1
class_create(owner, name);

该函数是定义在 linux/device/class.h 头文件中的一个宏定义,它接受两个参数:

owner :指定类的所有者是哪个模块,一般为THIS_MODULE;

name :指定要创建的类的目录名(该类名与设备名称无关,可以不与设备名一致)。

该函数返回一个 struct class 类型的指针,并在加载模块时,在 /sys/class 目录下创建与类名同名的目录。

调用 class_create 有可能会失败,因此在调用该函数时,要使用 IS_ERR 函数对返回的指针进行判断。

相对应的,释放一个 struct class 指针要使用:

1
void class_destroy(struct class *cls);

举例说明:

1
2
3
4
5
6
7
8
// 创建 hello 类
struct class *hello_class = class_create(THIS_MODULE, "hello");
if (IS_ERR(hello_class)){
// 错误处理
}

// 释放 hello 类
class_destroy(hello_class);

一旦使用 class_cerate 成创建了一个类(这个类存放于 /sys/class 目录下),就能够使用 device_create 函数来在 /dev 目录下创建相应的设备节点(文件),该函数定义在 /linux/device.h 中:

1
struct device *device_create(struct class *cls, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...);

cls 为该设备所属的类,parent 为父设备,若该设备没有父设备,则设为 NULLdev_t 是设备号;drvdate 为私有数据,代表回调函数的输入参数,若没有,可设为 NULLfmt 为设备名称,设备文件(节点)被自动创建时的文件名(节点名)。

device_create 能自动创建设备文件是依赖于 udev 这个应用程序。udev 是一种工具,它能够根据系统中的硬件设备的状态动态更新设备文件,包括设备文件的创建,删除等。在加载模块的时候。用户空间中的 udev 自动响应 device_create 函数,去 /sys/class 下寻找对应的类,从而创建设备文件(节点)。设备文件(节点)通常放在 /dev 目录下。使用 udev 后,在 /dev 目录下就只包含系统中真正存在的设备。

该函数有可能会调用失败,当调用失败时,返回的指针为 NULL,调用该函数后要检查返回值。

与之对应的,释放由 device_create 创建的设备文件(节点)所用的函数为:

1
void device_destroy(struct class *cls, dev_t devt);

参数含义与 device_create 中对应的参数相同。要注意的是,调用释放函数时传入的参数要与使用 device_create 创建时输入的参数保持一致。

值得注意的是,在自动创建设备文件(节点)时所使用的 class_createdevice_create 函数以及各自对应的释放函数 class_destroyde vice_destroy 只在遵循 GPL 协议时可以使用。因此,要使用这几个函数,我们模块的 MODULE_LICENSE 只能设置为 GPL,即:

1
MODULE_LICENSE("GPL");

一般来讲,我们在创建设备(文件)节点的时候要使用自动创建的方式,这样可以减少工作量,并能避免创建设备节点时设备名与设备不一致时的错误。

访问设备节点

前面讲过,访问设备节点其实就是对设备文件进行操作,与在普通的用户程序中对文件进行的操作别无二致。假设我们现在已经将我们的设备驱动程序加载进系统,且在 /dev 中创建了对应的节点(假设该节点的名称为:chrdev_hello),那么我们在用户程序中对该设备的访问如下:

1
2
3
4
5
6
7
int fd = 0;
fd = open("/dev/chrdev_hello", O_RDWR);
if (fd < 0){
// 文件打开失败,进行失败时的处理
}
··· // 文件操作,如:read、write 等
close(fd);

字符设备程序编写

字符设备相关结构体结构

接下来,我们来对字符设备中所涉及到的数据结构关系大致进行梳理:

结构体关系

上图中对前面讲到的几个结构体的关系大致和相关的函数进行了展示。

字符设备驱动程序编写

结合上文中的内容,我们再对编写字符设备驱动程序的大致流程进行说明。

Linux 系统中使用 struct cdev 结构体来表示字符设备,而为了初始化该结构体,又需要用到 struct dev_t 结构体代表的设备号和 struct file_operations 结构体表示的设备操作。

设备号

因此,我们需要先申请设备号,申请设备号的方式有两种:

1
2
3
4
5
6
7
// 静态注册设备号
int ret = 0;
struct dev_t hello_device_num = MKDEV(100, 0);
ret = register_chrdev_region(MAJOR(hello_device_num), 1, "hello_device");
if (ret < 0){
··· //错误处理
}

或:

1
2
3
4
5
6
7
// 动态申请设备号
int ret = 0;
struct dev_t hello_device_num = 0;
ret = alloc_chrdev_region(&hello_device_num, 1, "hello_device");
if (ret < 0){
··· //错误处理
}

文件操作

然后,再定义文件操作函数和结构体变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
int hello_device_open(struct inode *pinode, struct file *pfile){
// 设备打开操作
}
int hello_device_close(struct inode *pinode, struct file *pfile){
// 设备关闭操作
}
static const struct file_operation hello_device_ops = {
.owner = THIS_MODULE,
.open = hello_device_open,
.release = hello_device_close,
.read = NULL,
.write = NULL
};

cdev

完成了设备号和设备操作的定义之后,就可以定义 struct cdev 结构体变量并对其进行初始化:

1
2
3
4
5
6
struct cdev *phello_device_cdev = NULL;
phello_device_cdev = cdev_alloc();
if (hello_device_cdev == NULL){
// 错误处理
}
phello_device_cdev->owner = THIS_MODULE;

然后将我们定义的设备操作结构体变量与 cdev 进行绑定,即将我们实现的设备操作函数与 cdev 关联起来,这样,我们对设备进行的操作,真正执行的就是我们自己实现的操作函数。关联文件操作结构体 file_operationscdev 的方式有两种:

1
2
// 通过赋值的方式
phello_device_cdev->ops = hello_device_ops;

或:

1
2
// 通过 cdev_init 函数
cdev_init(phello_device_cdev, hello_device_ops);

接下来,要将 cdev 添加进内核,并于注册/申请到的设备号相关联:

1
ret = cdev_add(phello_device_cdev, hello_device_num, 1);

此时,当我们将我们所编写的字符设备驱动程序并成功加载进系统后,就能够在 /proc/devices 中查看到我们所添加进系统的字符设备的设备号及名称。

设备节点

为了能够使得用户程序能够访问到我们的字符设备,我们还需要在 /dev 目录下创建设备文件节点。创建设备文件节点的方式有两种:手动创建和自动创建。

手动创建

假定我们注册/申请到的主设备号为 100(可以在 /proc/devices 查到),那么使用 mknod 命令来创建设备文件节点:

1
mknod /dev/hello_device c 100 1 

自动创建

自动创建的方式首先要求我们为我们创建的设备创建一个类:

1
2
3
4
5
struct class *phello_device_class = NULL;
phello_device_class = class_create(THIS_MODULE, "hello_device_class");
if(IS_ERR(phello_device_class)){
··· // 错误处理
}

我们所创建的类会出现在 */sys/class 目录中。

再创建一个 struct device 变量:

1
2
struct device *phello_device == NULL;
phello_device = device_create(phello_device_class, NULL, hello_device_num, NULL, "hello_device");

这样,udev 程序在我们的设备驱动程序被加载进系统时,就会自动在 /dev 目录下创建名为 ”hello_device“ 的设备文件节点。

资源释放

当然,我们应该在设备驱动程序的退出函数中实现对模块申请到的各种资源进行释放。但是在前面申请个类资源的过程中,可能某些资源的申请会遇到失败的情况,这时我们在错误处理中,要对已经申请到的资源进行释放。那么在我们的释放函数中,就要先判断要释放的资源是否已经在先前的错误处理中被释放掉了。

1
2
3
4
5
6
7
8
9
10
11
12
if (phello_device != NULL){
device_destroy(phello_device_class, hello_device_num);
}
if (phello_device_class != NULL){
class_destroy(phello_device_class);
}
if (phello_device_cdev != NULL){
cdev_del(phello_device_cdev);
}
if (hello_device_num != 0){
unregister_chrdev_region(hello_device_num, 1);
}

如果我们是手动创建设备文件节点的话,要是用 rm 删除设备文件节点。

用户程序的编写

在我们的字符驱动程序被加载进系统后,且在 /dev 目录下创建了设备文件节点,那么我们在用户程序中就可以通过对设备文件节点的访问来访问我们的字符设备。

1
2
3
4
5
6
7
int fd = -1;
fd = open("/dev/hello_device", O_RDWR);
if (fd < 0){
return 0;
}
··· // 文件操作
close(fd);
-------------本文结束 感谢阅读-------------
打赏一包辣条