LV005-字符设备简介
一、字符设备驱动简介
1. linux 设备分类
linux 是文件型系统,所有硬件都会在对应的目录(/dev)下面用相应的文件表示。 在 windows 系统中,设备很好理解,像硬盘,磁盘指的是实实在在硬件。 而在文件系统的 linux 下面,都有对于文件与这些设备关联的,访问这些文件就可以访问实际硬件。 像访问文件那样去操作硬件设备,一切都会简单很多,不需要再调用以前 com,prt 等接口了。 直接读文件,写文件就可以向设备发送、接收数据。 按照读写存储数据方式,我们可以把设备分为以下几种:字符设备、块设备和网络设备。
字符设备: 指应用程序按字节/字符来读写数据的设备。 这些设备节点通常为传真、虚拟终端和串口调制解调器、键盘之类设备提供流通信服务, 它通常不支持随机存取数据。字符设备在实现时,大多不使用缓存器。系统直接从设备读取/写入每一个字符。 例如,键盘这种设备提供的就是一个数据流,当你敲入“cnblogs”这个字 符串时, 键盘驱动程序会按照和输入完全相同的顺序返回这个由七个字符组成的数据流。它们是顺序的,先返回 c,最后是 s。
块设备: 通常支持随机存取和寻址,并使用缓存器。 操作系统为输入输出分配了缓存以存储一块数据。当程序向设备发送了读取或者写入数据的请求时, 系统把数据中的每一个字符存储在适当的缓存中。当缓存被填满时,会采取适当的操作(把数据传走), 而后系统清空缓存。它与字符设备不同之处就是,是否支持随机存储。字符型是流形式,逐一存储。 典型的块设备有硬盘、SD 卡、闪存等,应用程序可以寻址磁盘上的任何位置,并由此读取数据。 此外,数据的读写只能以块的倍数进行。
网络设备: 是一种特殊设备,它并不存在于/dev 下面,主要用于网络数据的收发。
Linux 内核中处处体现面向对象的设计思想,为了统一形形色色的设备,Linux 系统将设备分别抽象为 struct cdev, struct block_device,struct net_devce 三个对象,具体的设备都可以包含着三种对象从而继承和三种对象属性和操作, 并通过各自的对象添加到相应的驱动模型中,从而进行统一的管理和操作
字符设备驱动程序适合于大多数简单的硬件设备,而且比起块设备或网络驱动更加容易理解, 因此我们选择从字符设备开始,从最初的模仿,到慢慢熟悉。
2. 驱动程序的调用
我们先来简单的了解一下 Linux 下的应用程序是如何调用驱动程序的, Linux 应用程序对驱动程序的调用如下图:

在 Linux 中一切皆为文件,驱动加载成功以后会在“/dev”目录下生成一个相应的文件,应用程序通过对这个名为“/dev/xxx” (xxx 是具体的驱动文件名字)的文件进行相应的操作即可实现对硬件的操作。
比如现在有个叫做/dev/led 的驱动文件,此文件是 led 灯的驱动文件。应用程序使用 open 函数来打开文件/dev/led,使用完成以后使用 close 函数关闭/dev/led 这个文件。 open 和 close 就是打开和关闭 led 驱动的函数,如果要点亮或关闭 led,那么就使用 write 函数来操作,也就是向此驱动写入数据,这个数据就是要关闭还是要打开 led 的控制参数。如果要获取 led 灯的状态,就用 read 函数从驱动中读取相应的状态。
应用程序运行在用户空间,而 Linux 驱动属于内核的一部分,因此驱动运行于内核空间。当我们在用户空间想要实现对内核的操作,比如使用 open 函数打开/dev/led 这个驱动,因为用户空间不能直接对内核进行操作,因此必须使用一个叫做“系统调用”的方法来实现从用户空间“陷入” 到内核空间,这样才能实现对底层驱动的操作。 open、 close、 write 和 read 等这些函数是由 C 库提供的,在 Linux 系统中,系统调用作为 C 库的一部分。当我们调用 open 函数的时候流程

C 库如何通过系统调用“陷入” 到内核空间?这个有点复杂,可以参考这里理解一下:《01 嵌入式开发/02IMX6ULL 平台/LV03-应用开发/LV01-01-应用编程基本概念-01-基础知识.md》以及以下资料:
- Linux 系统调用(syscall)原理 - Gityuan 博客 | 袁辉辉的技术博客
- linux-insides-zh/SysCall/README.md at master · hust-open-atom-club/linux-insides-zh
这里就先不详细去说了。
3. 字符设备的抽象
Linux 内核中将字符设备抽象成一个具体的数据结构(struct cdev), 我们可以理解为字符设备对象, cdev 记录了字符设备的相关信息(设备号、内核对象),字符设备的打开、读写、关闭等操作接口(file_operations), 在我们想要添加一个字符设备时,就是将这个对象注册到内核中,通过创建一个文件(设备节点)绑定对象的 cdev, 当我们对这个文件进行读写操作时,就可以通过虚拟文件系统,在内核中找到这个对象及其操作接口,从而控制设备。
语言中没有面向对象语言的继承的语法,但是我们可以通过结构体的包含来实现继承,这种抽象提取了设备的共性, 为上层提供了统一接口,使得管理和操作设备变得很容易。

在硬件层,我们可以通过查看硬件的原理图、芯片的数据手册,确定底层需要配置的寄存器,这类似于裸机开发。 将对底层寄存器的配置,读写操作放在文件操作接口里面,也就是实现 file_operations 结构体。
其次在驱动层,我们将文件操作接口注册到内核,内核通过内部散列表来登记记录主次设备号。
在文件系统层,新建一个文件绑定该文件操作接口,应用程序通过操作指定文件的文件操作接口来设置底层寄存器。
实际上,在 Linux 上写驱动程序,都是做一些“填空题”。因为 Linux 给我们提供了一个基本的框架, 我们只需要按照这个框架来写驱动,内核就能很好的接收并且按我们所要求的那样工作。
二、相关概念及数据结构
在 linux 中,我们使用设备编号来表示设备,主设备号区分设备类别,次设备号标识具体的设备。 cdev 结构体被内核用来记录设备号,而在使用设备时,我们通常会打开设备节点,通过设备节点的 inode 结构体、 file 结构体最终找到 file_operations 结构体,并从 file_operations 结构体中得到操作设备的具体方法。
1. 设备号
1.1 什么是设备号?
对于字符的访问是通过文件系统的名称进行的,这些名称被称为特殊文件、设备文件,或者简单称为文件系统树的节点, Linux 根目录下有/dev 这个文件夹,专门用来存放设备中的驱动程序,我们可以使用 ls -l 以列表的形式列出系统中的所有设备。 其中,每一行表示一个设备,每一行的第一个字符表示设备的类型。
如下所示,’c’用来标识字符设备,’b’用来标识块设备。如 autofs 是一个字符设备 c, 它的主设备号是 10,次设备号是 235; loop0 是一个块设备,它的主设备号是 7,次设备号为 0,同时可以看到 loop0 - loop3 共用一个主设备号,次设备号由 0 开始递增。
root@alpha-imx6ull:~# ls -alh /dev
total 4K
#...... 主设备号 次设备号
crw-rw---- 1 root root 10, 235 Jan 1 00:00 autofs
#......
crw-rw---- 1 root root 89, 0 Jan 1 00:00 i2c-0
crw-rw---- 1 root root 89, 1 Jan 1 00:00 i2c-1
#......
brw-rw---- 1 root root 7, 0 Jan 1 00:00 loop0
brw-rw---- 1 root root 7, 1 Jan 1 00:00 loop1
brw-rw---- 1 root root 7, 2 Jan 1 00:00 loop2
brw-rw---- 1 root root 7, 3 Jan 1 00:00 loop3
#......
brw-rw---- 1 root root 1, 0 Jan 1 00:00 ram0
brw-rw---- 1 root root 1, 1 Jan 1 00:00 ram1
brw-rw---- 1 root root 1, 10 Jan 1 00:00 ram10
brw-rw---- 1 root root 1, 11 Jan 1 00:00 ram11
brw-rw---- 1 root root 1, 12 Jan 1 00:00 ram12
brw-rw---- 1 root root 1, 13 Jan 1 00:00 ram13
brw-rw---- 1 root root 1, 14 Jan 1 00:00 ram14
#......一般来说,主设备号指向设备的驱动程序,次设备号指向某个具体的设备。例如 I2C-0,I2C-1 属于不同设备但是共用一套驱动程序。
1.2 设备编号的含义
在内核中,dev_t 用来表示设备编号,dev_t 是一个 32 位的数,其中,高 12 位表示主设备号,低 20 位表示次设备号。 也就是理论上主设备号取值范围:0-2^12,次设备号 0-2^20 。 实际上在内核源码中__register_chrdev_region(…)函数中,major 被限定在 0 - CHRDEV_MAJOR_MAX ,CHRDEV_MAJOR_MAX 是一个宏,值是 512。 dev_t 定义在 types.h - include/linux/types.h - dev_t:
typedef u32 __kernel_dev_t;
typedef __kernel_dev_t dev_t;在 kdev_t 中,设备编号通过移位操作最终得到主/次设备号码,同样主/次设备号也可以通过位运算变成 dev_t 类型的设备编号。具体可以看这几个函数:kdev_t.h - include/linux/kdev_t.h
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))MAJOR 和 MINOR,可以根据设备的设备号来获取设备的主设备号和次设备号。宏定义 MKDEV,用于将主设备号和次设备号合成一个设备号,主设备可以通过查阅内核源码的 devices.txt - Documentation/admin-guide/devices.txt 文件,而次设备号通常是从编号 0 开始。
1.3 cdev 结构体
内核通过一个散列表(哈希表)来记录设备编号。 哈希表由数组和链表组成,吸收数组查找快,链表增删效率高,容易拓展等优点。
以主设备号为 cdev_map 编号,使用哈希函数 f(major)= major%255 来计算组数下标(使用哈希函数是为了链表节点尽量平均分布在各个数组元素中,提高查询效率); 主设备号冲突, 则以次设备号为比较值来排序链表节点。 如下图所示,内核用 struct cdev 结构体来描述一个字符设备,并通过 struct kobj_map 类型的 散列表 cdev_map 来管理当前系统中的所有字符设备。

struct cdev 结构体定义在 cdev.h - include/linux/cdev.h - struct cdev:
struct cdev {
struct kobject kobj;
struct module *owner;
const struct file_operations *ops;
struct list_head list;
dev_t dev;
unsigned int count;
} __randomize_layout;- struct kobject kobj: 内嵌的内核对象,通过它将设备统一加入到“Linux 设备驱动模型”中管理(如对象的引用计数、电源管理、热插拔、生命周期、与用户通信等)。
- struct module *owner: 字符设备驱动程序所在的内核模块对象的指针。
- const struct file_operations *ops: 文件操作,是字符设备驱动中非常重要的数据结构,在应用程序通过文件系统(VFS)呼叫到设备设备驱动程序中实现的文件操作类函数过程中,ops 起着桥梁纽带作用,VFS 与文件系统及设备文件之间的接口是 file_operations 结构体成员函数,这个结构体包含了对文件进行打开、关闭、读写、控制等一系列成员函数。
- struct list_head list: 用于将系统中的字符设备形成链表(这是个内核链表的一个链接因子,可以再内核很多结构体中看到这种结构的身影)。
- dev_t dev: 字符设备的设备号,有主设备和次设备号构成。
- unsigned int count: 属于同一主设备好的次设备号的个数,用于表示设备驱动程序控制的实际同类设备的数量。
2. 设备类
2.1 什么是设备类
备驱动模型中,还有一个 抽象概念 叫做类(CLass),准确来说,叫做设备类。所谓设备类,是指提供的用户接口相似的一类设备的集合,常见的设备类的有 block、tty、input、usb 等等。
举个例子,一些年龄相仿、需要获取的知识相似的人,聚在一起学习,就构成了一个班级(Class)。这个班级可以有自己的名称(如 1307 班),但如果离开构成它的学生(device),它就没有任何存在意义。另外,班级存在的最大意义是什么呢?是由老师讲授的每一个课程!因为老师只需要讲一遍,一个班的学生都可以听到。不然的话(例如每个学生都在家学习),就要为每人请一个老师,讲授一遍。而讲的内容,大多是一样的,这就是极大的浪费。
设备模型中的 Class 所提供的功能也一样了,例如一些相似的 device(学生),需要向用户空间提供相似的接口(课程),如果每个设备的驱动都实现一遍的话,就会导致内核有大量的冗余代码,这就是极大的浪费。
设备类是一个设备的高层视图,它抽象出了底层的实现细节,从而允许用户空间使用设备所提供的功能,而不用关心设备是如何连接和工作的。设备类是用来抽象设备的共性而诞生的。类成员通常由上层代码所控制,而无需驱动的明确支持。但有些情况下驱动也需要直接处理类。
2.2 linux 系统中的 class
我们先看一下现有 Linux 系统中有关 class 的状况,我们来看一下这个/sys/class 目录:
ls /sys/class/ -alh
我们这里以 input 这个目录为例来看一下。继续深入这个 input 目录:
ls /sys/class/input/ -l
我们看到里面有 event0、event1、input0 和 input1 等软链接,他们都链接到了哪里?这里的 ../../ 是什么?event0 这些软链接位于 /sys/class/input/ 目录,往上推两级就是 /sys/,所以这里的 ../../ 其实绝对路径就是 /sys/。我们看看这些软链接对应的目录都有什么:
# event0
ls -alh /sys/class/input/event0
ls -alh /sys/devices/soc0/soc/2000000.aips-bus/20cc000.snvs/20cc000.snvs:snvs-powerkey/input/input0/event0
# input0
ls -alh /sys/class/input/input0
ls -alh /sys/devices/soc0/soc/2000000.aips-bus/20cc000.snvs/20cc000.snvs:snvs-powerkey/input/input0
发现 input class 也没做什么实实在在的事儿,它(input class)的功能,仅仅是:
(1)在 /sys/class/ 目录下,创建一个本 class 的目录(input)
(2)在本目录下,创建每一个属于该 class 的设备的符号链接,这样就可以在本 class 目录下,访问该设备的所有特性(即 attribute)
如,把“
/sys/devices/soc0/soc/2000000.aips-bus/20cc000.snvs/20cc000.snvs:snvs-powerkey/input/input0/event0”设备链接到”/sys/class/input/event0”。
(3)device 在 sysfs 的目录下,也会创建一个 subsystem 的符号链接,链接到本 class 的目录,如上图,这里的 ../../../../../../../../ 是啥?我们知道这个 subsystem 的路径为 /sys/devices/soc0/soc/2000000.aips-bus/20cc000.snvs/20cc000.snvs:snvs-powerkey/input/input0 所以从这里往上推 8 级就是 /sys

所以这里其实 subsystem 是链接到了 /sys/class/input/input0。
2.3 struct class
struct class 结构体定义在 device.h - include/linux/device.h - struct class:
struct class {
/* 名称 */
const char *name;
struct module *owner; // owner 是 class 所属的模块,虽然 class 是涉及一类设备,但也是由相应的模块注册的。比如 usb 类就是由 usb 模块注册的。
/* 属性 */
const struct attribute_group **class_groups;
const struct attribute_group **dev_groups;
/* 内部对象 */
struct kobject *dev_kobj;
// 设备发出 uevent 消息时添加环境变量用的
// 在 core.c 中的 dev_uevent()函数,其中就包含对设备所属 bus 或 class 中 dev_uevent()方法的调用,
// 只是 bus 结构中定义方法用的函数名是 uevent。
int (*dev_uevent)(struct device *dev, struct kobj_uevent_env *env);
char *(*devnode)(struct device *dev, umode_t *mode); // 返回设备节点的相对路径名,在 core.c 的 device_get_devnode()中有调用到。
/* 释放方法 */
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;
};2.3.1 name
name:设备类的名称,会在“/sys/class/”目录下体现。(实际使用的是内部 kobj 包含的动态创建的名称。)
2.3.2 dev_kobj
dev_kobj 是 struct kobject 类型,在 device 注册时,会在/sys/dev 下创建名为自己设备号的软链接。但设备不知道自己属于块设备还是字符设备,所以会请示自己所属的 class;class 就是用 dev_kobj 记录本类设备应属于的哪种设备。
我们来看一下 /sys/dev:
ls -alh /sys/dev
发现里面有两个目录,其实这里就是不同的设备类型,block 里面是块设备,char 里面是字符设备:

2.3.3 总结
这里还是有一些概念没看懂,这里大概了解一下,后面遇到了会详细再去学习。
3. 设备节点
设备节点(设备文件):Linux 中设备节点是通过“mknod”命令来创建的。一个设备节点其实就是一个文件, Linux 中称为设备文件。有一点必要说明的是,在 Linux 中,所有的设备访问都是通过文件的方式, 一般的数据文件程序普通文件,设备节点称为 设备文件。
设备节点被创建在/dev 下,是连接内核与用户层的枢纽,就是设备是接到对应哪种接口的哪个 ID 上。 相当于硬盘的 inode 一样的东西,记录了硬件设备的位置和信息在 Linux 中,所有设备都以文件的形式存放在/dev 目录下, 都是通过文件的方式进行访问,设备节点是 Linux 内核对设备的抽象,一个设备节点就是一个文件。 应用程序通过一组标准化的调用执行访问设备,这些调用独立于任何特定的驱动程序。而驱动程序负责将这些标准调用映射到实际硬件的特有操作。

4. 数据结构
在驱动开发过程中,不可避免要涉及到三个重要的的内核数据结构分别包括文件操作方式(file_operations), 文件描述结构体(struct file)以及 inode 结构体,在我们开始阅读编写驱动程序的代码之前,有必要先了解这三个结构体。
4.1 struct file_operations
file_operations 结构体定义在 fs.h - include/linux/fs.h - struct file_operations:
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
int (*iterate) (struct file *, struct dir_context *);
int (*iterate_shared) (struct file *, struct dir_context *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
unsigned long mmap_supported_flags;
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
// ......
} __randomize_layout;在系统内部,I/O 设备的存取操作通过特定的入口点来进行,而这组特定的入口点恰恰是由设备驱动程序提供的。 通常这组设备驱动程序接口是由结构 file_operations 结构体向系统说明的,它定义在 ebf_buster_linux/include/linux/fs.h 中。 传统上, 一个 file_operation 结构或者其一个指针称为 fops( 或者它的一些变体)。结构中的每个成员必须指向驱动中的函数, 这些函数实现一个特别的操作, 或者对于不支持的操作留置为 NULL。当指定为 NULL 指针时内核的确切的行为是每个函数不同的。
- llseek: 用于修改文件的当前读写位置,并返回偏移后的位置。参数 file 传入了对应的文件指针,我们可以看到以上代码中所有的函数都有该形参,通常用于读取文件的信息,如文件类型、读写权限;参数 loff_t 指定偏移量的大小;参数 int 是用于指定新位置指定成从文件的某个位置进行偏移,SEEK_SET 表示从文件起始处开始偏移;SEEK_CUR 表示从当前位置开始偏移;SEEK_END 表示从文件结尾开始偏移。
- read: 用于读取设备中的数据,并返回成功读取的字节数。该函数指针被设置为 NULL 时,会导致系统调用 read 函数报错,提示“非法参数”。该函数有三个参数:file 类型指针变量,char __user *类型的数据缓冲区,__user 用于修饰变量,表明该变量所在的地址空间是用户空间的。内核模块不能直接使用该数据,需要使用 copy_to_user 函数来进行操作。size_t 类型变量指定读取的数据大小。
- write: 用于向设备写入数据,并返回成功写入的字节数,write 函数的参数用法与 read 函数类似,不过在访问__user 修饰的数据缓冲区,需要使用 copy_from_user 函数。
- unlocked_ioctl: 提供设备执行相关控制命令的实现方法,它对应于应用程序的 fcntl 函数以及 ioctl 函数。在 kernel 3.0 中已经完全删除了 struct file_operations 中的 ioctl 函数指针。
- open: 设备驱动第一个被执行的函数,一般用于硬件的初始化。如果该成员被设置为 NULL,则表示这个设备的打开操作永远成功。
- release: 当 file 结构体被释放时,将会调用该函数。与 open 函数相反,该函数可以用于释放
4.2 struct file
内核中用 file 结构体来表示每个打开的文件,每打开一个文件,内核会创建一个结构体,并将对该文件上的操作函数传递给 该结构体的成员变量 f_op,当文件所有实例被关闭后,内核会释放这个结构体。这个结构体定义在 fs.h - include/linux/fs.h - struct file:
struct file {
// ......
const struct file_operations *f_op;
/* needed for tty driver, and maybe others */
void *private_data;
// ......
};- f_op:存放与文件操作相关的一系列函数指针,如 open、read、wirte 等函数。
- private_data:该指针变量只会用于设备驱动程序中,内核并不会对该成员进行操作。因此,在驱动程序中,通常用于指向描述设备的结构体。
4.3 struct inode
FS inode 包含文件访问权限、属主、组、大小、生成时间、访问时间、最后修改时间等信息。 它是 Linux 管理文件系统的最基本单位,也是文件系统连接任何子目录、文件的桥梁。 内核使用 inode 结构体在内核内部表示一个文件。因此,它与表示一个已经打开的文件描述符的结构体(即 file 文件结构)是不同的, 我们可以使用多个 file 文件结构表示同一个文件的多个文件描述符,但此时, 所有的这些 file 文件结构全部都必须只能指向一个 inode 结构体。 inode 结构体包含了一大堆文件相关的信息,但是就针对驱动代码来说,我们只要关心其中的两个域即可(fs.h - include/linux/fs.h - struct inode)
struct inode {
// ......
dev_t i_rdev;
// ......
const struct file_operations *i_fop; /* former -> i_op-> default_file_ops */
struct file_lock_context *i_flctx;
struct address_space i_data;
struct list_head i_devices;
union {
struct pipe_inode_info *i_pipe;
struct block_device *i_bdev;
struct cdev *i_cdev;
char *i_link;
unsigned i_dir_seq;
};
// ......
} __randomize_layout;- dev_t i_rdev: 表示设备文件的结点,这个域实际上包含了设备号。
- struct cdev *i_cdev: struct cdev 是内核的一个内部结构,它是用来表示字符设备的,当 inode 结点指向一个字符设备文件时,此域为一个指向 inode 结构的指针。
参考资料: