目录
- 免责声明
- I. Motivation
- II. Solution
- S1 - 主次设备号
- S2 - 设备驱动程序
- S3 - 字符设备驱动程序
- III. Result
免责声明
「Tech初见」系列的文章,是本人第一次接触的话题
对所谓真理的理解暂时可能还不到位,避免不了会出现令人嗤鼻的谬论
所以,看看就好,借鉴一下,别全信,也别较真。当然,文章中不正确的地方,欢迎意见评论,我会及时研判和进行下一步的纠偏
I. Motivation
类 Unix OS 都是基于文件概念的,文件是由字节序列而构成的信息载体。所以,我们可以把 I/O 设备当作设备文件( device file )这种特殊文件来处理
例如,用同一 write()
系统调用既可以向普通文件中写入数据,也可以通过向 /dev/lp0 设备文件中写入数据,从而把数据发往打印机
根据设备驱动程序的基本特性,设备文件可以分为两种:字符和块,
- 字符设备的数据一般是不支持随机访问的,例如,声卡、键盘和鼠标;但是也可以随机访问,但具体要看数据到底在设备内的位置
- 块设备是支持随机访问的,例如,硬盘
我们为什么要写关于设备驱动程序呢?主要就是为了操控插入的外接设备,具体怎么做?下面将详细展开
II. Solution
在讲解如何编写设备驱动程序之前,我们还需了解一些设备与设备驱动程序之间的常识,其中第一个,即是主次设备号
S1 - 主次设备号
一般而言,设备标识符是由设备文件的类型(字符 OR 块)和一对参数组成的
第一个参数是主设备号( major number ),它标识了设备的类型。通常,具有相同主设备号和类型的所有设备文件共享相同的文件操作集合,因为它们是由同一个设备驱动程序处理的
第二个参数是次设备号( minor number ),它标识了主设备号相同的设备组中的一个特定设备。例如,由相同的磁盘控制器管理的一组磁盘具有相同的主设备号和不同的次设备号
而 mknod
系统调用是用来创建设备文件的,其参数有设备文件名、设备类型、主设备号及次设备号,用法如下,
$ mknod 名称 类型 主设备号 次设备号
类型 b 是块设备,类型 c 是字符设备,举个例子,
$ mknod /dev/lpl c 2 0
并且设备文件通常包含在 /dev 目录中,例如下表,
设备名 | 类型 | 主设备号 | 次设备号 | 说明 |
---|---|---|---|---|
/dev/hda | 块设备 | 3 | 0 | 第一个 IDE 磁盘 |
/dev/hda2 | 块设备 | 3 | 2 | 第一个 IDE 磁盘上的第二个主分区 |
/dev/ttyp0 | 字符设备 | 3 | 0 | 终端 |
/dev/console | 字符设备 | 5 | 1 | 控制台 |
/dev/lpl | 字符设备 | 6 | 1 | 并口打印机 |
主设备号可以理解成为设备组的概念,即相同主设备号意味着它们是同一组内的设备,同一组内则意味着它们的设备类型相同;而次设备号是组内的具体编号,比如 /dev/hda 和 /dev/hda2
在早期的 Linux 版本中,设备文件的主设备号和次设备号都是 8 位长,所以,最多只能有 65536 个块设备文件和 65536 个字符设备文件( 65536 = 2 16 2^{16} 216 )。在早期是足够的,但现在是远远不够用
主设备号和次设备号拼在一起是 16 位,前 8 位管着主设备号的分配,有 2 8 = 256 2^{8} = 256 28=256 种选择,即主设备可能是 0 ~ 255;后 8 位管着次设备号的分配,也有 2 8 = 256 2^{8} = 256 28=256 种选择,即次设备号的范围也在 0 ~ 255 之内。那么,排列组合计算得到,共有 256 ∗ 256 = 65536 256*256 = 65536 256∗256=65536 种选择
从 Linux 2.6 之后就扩充了主次设备号的位数,主设备号从原先的 8 位扩展到 12 位,次设备号也从原先的 8 位扩展到 20 位,并将两参数合并成一个 32 位的 dev_t 变量。通过宏 MAJOR
和宏 MINOR
可以从 dev_t 中提取主次设备号;且宏 MKDEV
可以将主次设备号合并成一个 dev_t 值
记住,主设备号和次设备号的组合可以确定一个内核所支持的逻辑设备文件!
通过 mknode
系统调用为设备创建创建 inode 索引节点,其中最重要的就是给该设备申请一对主次设备号
内核的做法,即是从数组 chrdevs 中寻找一个可用的主次设备号,具体如何选择,暂时不用去纠结
设备号分配好了之后,我们就可以根据设备号定位到该设备了。以后我们编写的设备驱动程序就可以指明我这个程序是专门用来操纵某个设备的,其中的 “某个” 体现在设备号上
S2 - 设备驱动程序
由于每个设备都有一个唯一的 I/O 控制器,因此就有唯一的命令和唯一的状态信息,所以大部分 I/O 设备都有自己的驱动程序
设备驱动程序并不仅仅由实现设备文件操作的函数组成,在使用设备驱动程序之前,要先注册,然后才能投入工作
注册设备驱动程序时,内核会寻找可能由该驱动程序处理但尚未获得支持的硬件设备,主要通过相关的总线类型描述符 bus_type 的 match 方法以及 device_driver 对象的 probe 方法
如果探测到可被驱动程序处理的硬件设备,内核会分配一个设备对象,然后调用 device_register()
函数把设备插入设备驱动程序模型中
注册设备驱动程序主要是以便用户态程序能通过相应的设备文件使用它
而初始化设备驱动程序可能会发生在最后使用到的时刻,且初始化意味着会分配给其宝贵的系统资源
S3 - 字符设备驱动程序
字符设备驱动程序是由一个 cdev 结构描述的,声明在 <linux/cdev.h> 头文件里,
类型 | 字段 | 说明 |
---|---|---|
struct kobject | kobj | 内嵌的 kobject |
struct module* | owner | 指向实现驱动程序模块的指针 |
struct file_operations | ops | 指向设备驱动程序文件操作表的指针 |
struct list_head | list | 与字符设备文件对应的索引节点链表的头 |
dev_t | dev | 给设备驱动程序所分配的初始主设备号和次设备号 |
unsigned int | count | 次设备号的个数 |
cdev_alloc()
函数动态地分配 cdev 描述符,并初始化内嵌的 kobject,在引用计数器值为 0 的时候自动释放该描述符,声明如下,
struct cdev *cdev_alloc(void);
仅仅返回一个已经分配好的 cdev,然后再通过 register_chrdev_region()
或 alloc_chrdev_region()
去向内核申请设备号,声明如下,
int register_chrdev_region(dev_t from, unsigned count, const char* name);
它是指定设备号的,即静态注册, from
是注册的设备的起始设备编号,例如,宏 MKDEV(100, 0) 表示起始的主设备号为 100,起始的次设备号为 0;count
表示连续注册的次设备编号的个数,例如,count
为 100 表示 0 ~ 99 的次设备号都要绑定在同一 file_operations 操作方法的结构体上;*name
表示字符设备名称
int alloc_chrdev_region(dev_t* dev, unsigned baseminor, unsigned count, const char* name);
它是不指定设备号的,由内核动态分配,*dev
是存储 cdev 主次编号的变量地址;baseminor
是次设备的起始编号;count
和 name
与之前相同
待申请到设备号之后,再通过 cdev_add()
向设备驱动程序模型(可以理解成管理驱动程序的一张大表)中注册已初始化的 cdev 描述符,定义如下,
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
{int error;p->dev = dev;p->count = count;error = kobj_map(cdev_map, dev, count, NULL, exact_match, exact_lock, p);if (error)return error;kobject_get(p->kobj.parent);return 0;
}
它需要的是我们想要增加至模型的 cdev 描述符以及它申请下来的主次设备号,再加上和该 cdev 同类型的设备个数
OR 可以通过 register_chrdev()
直接分配一个固定的设备号范围,该范围包含唯一一个主设备号以及 0 ~ 255 的次设备号。这样的话,一步到位,不需要再调用 cdev_add()
注册驱动
每个设备文件( I/O 设备)是有一个固定且唯一的主次设备号的,比如终端设备 /dev/ttyp0,它的主设备号为 3,次设备号为 0
下次如果有人向内核设备驱动程序模型中注册了主设备号为 3 的设备驱动程序,那么该驱动程序则有权利管理终端设备 /dev/ttyp0,因为 /dev/ttyp0 的主设备号为 3,和设备驱动程序的主设备号相同
另外,cdev_init()
用于初始化 cdev,并建立 cdev 和 file_operations 之间的连接,定义如下,
void cdev_init(struct cdev *cdev, const struct file_operations *fops)
{memset(cdev, 0, sizeof *cdev);INIT_LIST_HEAD(&cdev->list);kobject_init(&cdev->kobj, &ktype_cdev_default);cdev->ops = fops;
}
dev_del()
是从内核设备驱动程序模型中删除掉指定的 cdev 描述符,完成字符设备的注销工作,声明如下,
void cdev_del(struct cdev*);
大概就是将创建的 cdev 描述符释放掉,要传入描述符指针
在创建好 cdev 描述符之后,就可以向设备驱动程序模型中注册该设备,即在哈希表 cdev_map 中打上链接,大概是下面这种情形,
其中,哈希表的类型 kobj_map 声明如下,
struct kobj_map {struct probe {struct probe *next; dev_t dev; /* 设备号 */unsigned long range; /* 次设备号的数量 */struct module *owner; /* 驱动模块 */kobj_probe_t *get;int (*lock)(dev_t, void *);void *data; /* 用来指向 cdev 描述符 */} *probes[255];struct mutex *lock;
};
还有的就是,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 (*open) (struct inode *, struct file *);int (*release) (struct inode *, struct file *);......
} __randomize_layout;
都是些函数指针,即回调函数,类似于 OOP 的虚函数一样,可以重载,重新定义其行为
通过 kzalloc()
来给 cdev 结构体分配空间,这个功能就是应用层的 malloc()
,与之不同的,即是在内核中写法,kzalloc()
会调用 kmalloc()
,它的定义在 <linux/slab.h> 中,
/*** kzalloc - allocate memory. The memory is set to zero.* @size: how many bytes of memory are required.* @flags: the type of memory to allocate (see kmalloc).*/
static inline void *kzalloc(size_t size, gfp_t flags)
{return kmalloc(size, flags | __GFP_ZERO);
}
flags
一般标记为 GFP_KERNEL,表示它是内核态的正常内存,可能会因为进程的休眠状态,而被换出。暂时知道这么多,我觉得已经可以够了,
/* 初始化函数 */
static int __init my_cdev_init(void)
{int i;printk(KERN_INFO "my_cdev_init\n");if(devmajor) { /* 按照已指定的主次设备号来 */devid = MKDEV(devmajor, devminor);register_chrdev_region(devid, ndevs, devname);} else { /* 没有指定,则动态分配 */alloc_chrdev_region(&devid, devminor, ndevs, devname);devmajor = MAJOR(devid);}devp = kzalloc(sizeof(struct my_cdev)*ndevs, GFP_KERNEL);if(!devp) {printk(KERN_WARNING "kzalloc struct my_cdev failed");return -1;}return 0;
}
以及配套的 kfree()
,它与应用层的 free()
相同,在模块退出函数中释放设备实体,
/* 卸载函数 */
static void __exit my_cdev_exit(void)
{int i;printk(KERN_INFO "my_cdev_exit\n");for(i=0; i<ndevs; i++) /* 释放设备实体 */cdev_del(&devp[i].cdev);kfree(devp);
}
并通过 unregister_chrdev_region()
函数将其从设备驱动程序模型中移除,其定义如下,
void unregister_chrdev_region(dev_t from, unsigned count);
from
和 count
的含义跟注册时相同,不再展开赘述
下面就可以正式的编码工作了,其实很简单,我们首先会自定义一个特定类型的字符设备,
struct my_cdev {struct cdev cdev;char c; /* 体现字符设备的特性 */
};
其中的 struct cdev 包含在 <linux/cdev.h> 中,因为字符设备的不同,我们需要根据自己的设备特定数据结构,在 struct my_cdev 中我就简单增加了一个 char 字段,以示字符设备特性
之后,我们就可以定义一下设备号、设备描述符、设备名以及设备个数等重要信息了,
dev_t devid;
struct my_cdev* devp; /* 具体的设备描述符,在使用到的时候会动态分配 */int devmajor = 0; /* 默认是不指定主设备号的 请求内核动态分配 */
int devminor = 0;
int ndevs = 2; /* 设备组内实体的个数 */
const char* devname = "my_cdev";
另外,我们还需要完善 struct file_operations 里面的重定向操作,字符设备最常见也是最基本的打开、关闭、读和写文件,
int my_open(struct inode *inode, struct file *filp){printk(KERN_INFO "open my_cdev%d %d\n", iminor(inode), MINOR(inode->i_cdev->dev));return 0;
}ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos){printk(KERN_INFO "read my_dev\n");return 0;
}ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos){printk(KERN_INFO "write my_dev\n");return count;
}int my_release(struct inode *inode, struct file *filp){printk(KERN_INFO "release my_dev\n");return 0;
}struct file_operations my_ops = {.owner = THIS_MODULE,.read = my_read,.write = my_write,.open = my_open,.release = my_release,
};
定义完这些关键的数据结构和操作之后,就可以在驱动程序加载函数中申请设备号,分配、初始化和注册设备描述符了,
/* 初始化函数 */
static int __init my_cdev_init(void)
{int i;printk(KERN_INFO "my_cdev_init\n");if(devmajor) { /* 按照已指定的主次设备号来 */devid = MKDEV(devmajor, devminor);register_chrdev_region(devid, ndevs, devname);} else { /* 没有指定,则动态分配 */alloc_chrdev_region(&devid, devminor, ndevs, devname);devmajor = MAJOR(devid);}devp = kzalloc(sizeof(struct my_cdev)*ndevs, GFP_KERNEL);if(!devp) {printk(KERN_WARNING "kzalloc struct my_cdev failed");return -1;}for(i=0; i<ndevs; i++) { /* 初始化设备实体,并将其添加至设备驱动程序模型中 */cdev_init(&devp[i].cdev, &my_ops);devp[i].cdev.owner = THIS_MODULE;cdev_add(&devp[i].cdev, devid, ndevs);}return 0;
}
不熟悉驱动程序加载和卸载流程,可以移步至 「Tech初见」Linux驱动之hellodriver。我想说的是,流程在之前已经讲解的差不多了,我就不再赘述了
其中,需要注意的是分配设备描述符时,需要用 kzalloc()
替代应用层的 malloc()
,它的定义在本小节中也讲过
在卸载时也需要将该设备驱动程序从模型中剔除,而且要释放内存,
/* 卸载函数 */
static void __exit my_cdev_exit(void)
{int i;printk(KERN_INFO "my_cdev_exit\n");for(i=0; i<ndevs; i++) /* 释放设备实体,并将其从设备驱动程序模型中移除 */cdev_del(&devp[i].cdev);kfree(devp);unregister_chrdev_region(devid, ndevs);
}
同样,用 kfree()
替换 free()
完成内存释放工作,unregister_chrdev_region()
向设备驱动程序模型归还设备号。看一下完整的代码,
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/list.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/slab.h>struct my_cdev {struct cdev cdev;char c; /* 体现字符设备的特性 */
};dev_t devid;
struct my_cdev* devp; /* 具体的设备描述符,在使用到的时候会动态分配 */int devmajor = 0; /* 默认是不指定主设备号的 请求内核动态分配 */
int devminor = 0;
int ndevs = 2; /* 设备组内实体的个数 */
const char* devname = "my_cdev";int my_open(struct inode *inode, struct file *filp){printk(KERN_INFO "open my_cdev%d %d\n", iminor(inode), MINOR(inode->i_cdev->dev));return 0;
}ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos){printk(KERN_INFO "read my_dev\n");return 0;
}ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos){printk(KERN_INFO "write my_dev\n");return count;
}int my_release(struct inode *inode, struct file *filp){printk(KERN_INFO "release my_dev\n");return 0;
}struct file_operations my_ops = {.owner = THIS_MODULE,.read = my_read,.write = my_write,.open = my_open,.release = my_release,
};/* 初始化函数 */
static int __init my_cdev_init(void)
{int i;printk(KERN_INFO "my_cdev_init\n");if(devmajor) { /* 按照已指定的主次设备号来 */devid = MKDEV(devmajor, devminor);register_chrdev_region(devid, ndevs, devname);} else { /* 没有指定,则动态分配 */alloc_chrdev_region(&devid, devminor, ndevs, devname);devmajor = MAJOR(devid);}devp = kzalloc(sizeof(struct my_cdev)*ndevs, GFP_KERNEL);if(!devp) {printk(KERN_WARNING "kzalloc struct my_cdev failed");return -1;}for(i=0; i<ndevs; i++) { /* 初始化设备实体,并将其添加至设备驱动程序模型中 */cdev_init(&devp[i].cdev, &my_ops);devp[i].cdev.owner = THIS_MODULE;cdev_add(&devp[i].cdev, devid, ndevs);}return 0;
}/* 卸载函数 */
static void __exit my_cdev_exit(void)
{int i;printk(KERN_INFO "my_cdev_exit\n");for(i=0; i<ndevs; i++) /* 释放设备实体,并将其从设备驱动程序模型中移除 */cdev_del(&devp[i].cdev);kfree(devp);unregister_chrdev_region(devid, ndevs);
}/* 用宏来指定初始化函数和卸载函数 */
module_init(my_cdev_init);
module_exit(my_cdev_exit);/* 描述性定义 */
MODULE_LICENSE("GPL"); /* 许可协议 */
MODULE_AUTHOR("jeffrey wood"); /* 作者 */
MODULE_VERSION("V0.1"); /* 版本号 */
III. Result
在 /home/lighthouse/test-linuxdriver/chrdev 目录下,键入 make
命令编译程序,
lighthouse@VM-0-9-ubuntu:~/test-linuxdriver/chrdev$ make
make -C /lib/modules/5.4.0-126-generic/build M=/home/lighthouse/test-linuxdriver/chrdev modules
make[1]: Entering directory '/usr/src/linux-headers-5.4.0-126-generic'CC [M] /home/lighthouse/test-linuxdriver/chrdev/chrdev.oBuilding modules, stage 2.MODPOST 1 modulesCC [M] /home/lighthouse/test-linuxdriver/chrdev/chrdev.mod.oLD [M] /home/lighthouse/test-linuxdriver/chrdev/chrdev.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.4.0-126-generic'
一般来说,正常是这样的,最后生成了 .ko 文件,这就是我们需要的驱动。键入,
sudo insmod hello_driver.ko
挂载驱动程序后,可以在另一个终端键入,
cat /proc/devices
查看该驱动程序向内核申请到的设备号,因为是动态分配,所以每一次的设备号可能不尽相同,
Character devices:1 mem...7 vcs...241 my_cdev...254 gpiochip
可以看到该驱动程序的主设备号为 241,这意味着主设备号为 241 的字符设备,以后归该驱动程序管
此时,如果通过 mknod
创建 241 主设备号的设备节点,该驱动程序是可以 handle 一些常规的操作的,例如,cat 和 echo,即读取 OR 写入该设备文件,
$ sudo mknod /dev/my_cdev0 c 241 0
$ sudo mknod /dev/my_cdev1 c 241 1
我们可以顺利地建立驱动程序和设备文件之间的联系,只要主设备号相同就可以。可以先查看一下创建的两个设备节点,
$ ls /dev/my_cdev* -l
crw-r--r-- 1 root root 241, 0 Jun 8 07:29 /dev/my_cdev0
crw-r--r-- 1 root root 241, 1 Jun 8 07:29 /dev/my_cdev1
我们会看到,这两个设备暂时的权限不太高,可以通过 chmod
提高权限,
$ sudo chmod 777 /dev/my_cdev*
之后的权限就是可读可写可执行,就像这样,
$ ls -l /dev/my_cdev*
crwxrwxrwx 1 root root 241, 0 Jun 8 07:29 /dev/my_cdev0
crwxrwxrwx 1 root root 241, 1 Jun 8 07:29 /dev/my_cdev1
之后就可以为所欲为了,可以尝试向 /dev/my_cdev0 中写入一串字符,
$ echo "hello /dev/my_cdev0"> /dev/my_cdev0
在设备驱动程序的终端,键入 dmesg
查看输出信息,
[3547862.828252] my_cdev_init
[3547967.474518] open my_cdev0 0
[3547967.474531] write my_dev
[3547967.474536] release my_dev
我们会看见,程序是先打开 my_cdev0 设备文件,然后进行写入操作,待写好之后关闭文件。同样 more /dev/my_cdev0 会看到我们的驱动程序确实去查看了设备文件的内容了,
[3548889.243786] open my_cdev0 0
[3548889.243794] read my_dev
[3548889.243797] release my_dev
最后,可以通过 rmmod
卸载模块,
sudo rmmod hello_driver
可以通过 rm
删除 mknod
创建的设备节点,
$ sudo rm -rf /dev/my_cdev*
另外,如果 mknod
创建的设备节点的主设备号不是 241,那么将无法对其进行读写执行操作,键入,
$ more /dev/my_cdev0
会发现操纵不了该设备文件,原因是没有相应的驱动程序,
cat: /dev/my_cdev0: No such device or address