字符设备驱动
字符设备驱动是一种驱动编写的框架,使用设备号来标识唯一的设备,cdev
结构抽象为这个字符设备(主要包括file_operations
操作函数),以文件的形式提供给用户空间。用户最直观的感受就是通过读写操作字符设备文件(设备节点),可以对应到内核驱动实现的文件接口函数。
参考:Linux设备管理(二)_从cdev_add说起(超详细) - 知乎 (zhihu.com)
内核版本:4.1.15
1.字符设备驱动的编写流程
在应用层面,字符设备驱动提供的框架就是,驱动程序编写这实现一些file_operations
接口,比如read
等,然后在用户空间需要有一个字符设备文件作为设备节点,用户程序可以对该设备节点进行read
等操作,最终实际执行驱动中实现的函数,从而达到设备即文件,通过文件控制设备的效果。下面是应用的必要步骤:
-
设备号的分配和释放:设备号的分配采用动态分配和静态分配两种方式,可以一次申请多个,接口定义如下:
#include <linux/fs.h> int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name); int register_chrdev_region(dev_t from, unsigned count, const char *name); void unregister_chrdev_region(dev_t from, unsigned count);
-
字符设备注册和添加:我们需要初始化一个
cdev
结构,主要填充owner
和ops
域;然后将其添加到内核中:#include <linux/cdev.h> void cdev_init(struct cdev *cdev, const struct file_operations *fops); int cdev_add(struct cdev *p, dev_t dev, unsigned count); void cdev_del(struct cdev *p);
-
创建设备节点:有两种方式
-
在用户空间根据设备号
mknod
手动创建,一般创建在/dev
目录下mknod /dev/【设备名】 c 【主设备号】 【次设备号】
-
让内核的设备统一模型自己去维护。在设备统一模型中,每一个设备在进行变动的时候,会通过
kobject_uevent
上报自己的变动事件,其会往上找到一个kset
进行用户空间的上报;上报方式有netlink
和kmod
两种,这里采用kmod
,会将上报信息放到环境变量中,调用用户空间的uevent_helper
程序,这里指定为mdev
程序;mdev
程序会解析上报的事件进行不同的操作,对于add
添加操作来说,会为其在/dev
创建目录下创建对应的设备节点。因此,我们要做的是将我们的驱动维护在设备统一模型中,简单的办法就是注册一个
class
因为其是一个kset
可以上报事件,在该类中以该设备号注册一个设备,这样这个设备号就被维护在系统中的,在设备注册的时候mdev
会创建设备节点,注销的时候会删除设备节点。
-
-
另:关于字符设备的注册还有另一个老接口,但是不推荐使用:
#include <linux/fs.h> static inline int register_chrdev(unsigned int major, const char *name, const struct file_operations *fops); static inline void unregister_chrdev(unsigned int major, const char *name);
该接口有以下几个特性:
-
支持动态分配和静态分配:传入
major
为0
是采用动态分配,否则静态分配 -
没有次设备号个数的入口参数,其默认将所有次设备号分配出去,这样会造成浪费
-
函数内部已经做了关于
cdev
的事情,包括初始化和cdev_add
,所以只用掉这一个函数即可完成字符设备初始化
2.字符设备的内涵
字符设备驱动的大概框架如下,用户空间对设备节点进行open
,文件系统会找到文件对应的inode
指针,查看是否有对应的cdev
结构,如果没有,则会在cdev_map
哈希表中使用主设备号进行查找,dev
和range
进行匹配,将找到的cdev
结构赋值到inode
中,然后用inode
更新filp
的相关项,最后filp->ops
即为驱动程序定义的cdev->ops
,后期的read
、write
等操作就直接调用filp->ops
即可掉入到cdev->ops
中。
下来我们从cdev
结构以及cdev_add
入手,看看字符设备是怎样在内核中运作的。
2.1 cdev
结构
cdev
结构中保存着ops
操作函数接口,这个是其最重要的信息,其他域大都主要是用于维护和组织内核中的结构的。
"incldue/linux/cdev.h" struct cdev { struct kobject kobj; struct module *owner; const struct file_operations *ops; struct list_head list; dev_t dev; unsigned int count; }; 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 *); ... };
2.2cdev_add
这个函数是将cdev
结构注册到内核中,因此很关键,是解决cdev
在内核中如何运作的入口点。我们可以看到字符设备框架中有一个静态全局变量cdev_map
,其是一个哈希表,所说的的cdev
添加内核中就是指加入到这个哈希表中。
cdev_add
函数:
"fs/char_dev.c" // 静态全局的struct kobj_map结构,是一个哈希表,使用主设备号作为键值,dev和range进行匹配,查找其cdev结构 static struct kobj_map *cdev_map; // 主要做的是将cdev加入到cdev_map哈希表中 int cdev_add(struct cdev *p, dev_t dev, unsigned count) { int error; // cdev结构赋值 p->dev = dev; p->count = count; // 将cdev结构加入到cdev_map全局字符设备哈希表中,则后来可以通过哈希的键值找到对应的cdev结构 error = kobj_map(cdev_map, dev, count, NULL, exact_match, exact_lock, p); if (error) return error; kobject_get(p->kobj.parent); return 0; }
下面展开以下比较重点的地方:
struct kobj_map
结构:
struct kobj_map
是一个哈希映射表,其有一个长度256
的probe
的内存单元,使用拉链法解决哈希冲突;后文可以看到我们以主设备号取余作为索引可以指向对应的probe
,再在probe
这条链上匹配符合的probe
项;每个probe
可以在data
中保存存储在哈希表中的数据。定义如下:
"drivers/base/map.c" struct kobj_map { struct probe { struct probe *next; // 解决哈希冲突问题,如果一个多个设备号都映射到这个地方就以一定的顺序链到这后面 dev_t dev; // cdev_add传入的起始设备号 以该值和下面的range一起匹配匹配,若查找的设备号位于[dev, dev+range-1]之间,则返回该probe中的data unsigned long range; // cdev_add传入的count struct module *owner; kobj_probe_t *get; int (*lock)(dev_t, void *); void *data; // 哈希表中存的数据,在后面可以看到它指向我们的cdev结构,很重要 } *probes[255]; struct mutex *lock; };
kobj_map
函数:
该函数将cdev
结构存储在cdev_map
哈希表中。其中:
-
键值:主设备号,最终匹配使用设备号,看设备号是否在
probe
的[dev, dev+range-1]
区间里 -
哈希函数:主设备号
%256
-
数据:
cdev
-
解决冲突:拉链法,最终在链上使用
dev
和range
进行匹配"drivers/base/map.c" int kobj_map(struct kobj_map *domain, dev_t dev, unsigned long range, struct module *module, kobj_probe_t *probe, int (*lock)(dev_t, void *), void *data) { unsigned n = MAJOR(dev + range - 1) - MAJOR(dev) + 1; // 计算用到的主设备号有几个,多个主设备号要一次插入多个probe unsigned index = MAJOR(dev); // 起始索引 unsigned i; struct probe *p; // 所要插入的probe们 if (n > 255) n = 255; p = kmalloc_array(n, sizeof(struct probe), GFP_KERNEL); // 动态分配内存 if (p == NULL) return -ENOMEM; // 初始化每一个probe,注意p->data是传入的data,在cdev_add函数中我们可以看到该参数是cdev指针 for (i = 0; i < n; i++, p++) { p->owner = module; p->get = probe; p->lock = lock; p->dev = dev; // dev保存起始设备号 p->range = range; // range保存有几个设备,则范围为[dev, dev+range-1] p->data = data; } // 对于每个probe,将其插入到哈希的映射位置链表上 mutex_lock(domain->lock); for (i = 0, p -= n; i < n; i++, p++, index++) { struct probe **s = &domain->probes[index % 255]; while (*s && (*s)->range < range) s = &(*s)->next; p->next = *s; *s = p; } mutex_unlock(domain->lock); return 0; }
综上,cdev_add
函数的作用就很清楚了,其就是将cdev
结构添加到dev_map
全局哈希表中,使内核其他地方可以通过设备号寻找到cdev
结构,从而能够访问到cdev
结构中驱动这编写的ops
接口函数。下面我们就看看内核是怎么把对设备节点处的文件操作转变为对驱动定义的cdev->ops
的函数调用的。
2.3cdev->ops
的调用
当文件被创建的时候,内核会创建相应的inode
,字符设备是一个特殊的文件,其inode
初始化的时候的相关工作如下:
init_special_inode
函数:
可以看到如果是字符设备的话,操作函数集合被赋予def_chr_fops
"fs/inode.c" void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev) { inode->i_mode = mode; if (S_ISCHR(mode)) { // 字符设备 inode->i_fop = &def_chr_fops; // 关键,其操作函数是def_chr_fops inode->i_rdev = rdev; } else if (S_ISBLK(mode)) { inode->i_fop = &def_blk_fops; inode->i_rdev = rdev; } else if (S_ISFIFO(mode)) inode->i_fop = &pipefifo_fops; else if (S_ISSOCK(mode)) ; /* leave it no_open_fops */ else printk(KERN_DEBUG "init_special_inode: bogus i_mode (%o) for" " inode %s:%lu\n", mode, inode->i_sb->s_id, inode->i_ino); } EXPORT_SYMBOL(init_special_inode);
def_chr_fops
结构体
打开的时候调用chrdev_open
"fs/char_dev.c" const struct file_operations def_chr_fops = { .open = chrdev_open, // 注意,文件打开会调用这个函数 .llseek = noop_llseek, };
chrdev_open
函数
该函数其实就是通过字符设备文件的设备号,来去cdev_map
查找对应的ops
,赋值给filp->ops
,从而以后文件操作对应的ops
都是驱动中定义的cdev->ops
。
"fs/char_dev.c" static int chrdev_open(struct inode *inode, struct file *filp) { const struct file_operations *fops; struct cdev *p; struct cdev *new = NULL; int ret = 0; spin_lock(&cdev_lock); p = inode->i_cdev; // inode->i_cdev存储该字符设备文件对应的cdev结构体,首次进来的时候为空,找到cdev后为其赋值以后编可直接通过该域访问 if (!p) { // 首次打开字符设备文件 struct kobject *kobj; int idx; spin_unlock(&cdev_lock); kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx); // 传入设备号inode->i_rdev在哈希表中找到cdev,返回其kobj对象 if (!kobj) return -ENXIO; new = container_of(kobj, struct cdev, kobj); // 获取cdev spin_lock(&cdev_lock); /* Check i_cdev again in case somebody beat us to it while we dropped the lock. */ p = inode->i_cdev; if (!p) { inode->i_cdev = p = new; // 赋值inode->i_dev为找到的cdev,下一次就可以不用找了 list_add(&inode->i_devices, &p->list); new = NULL; } else if (!cdev_get(p)) ret = -ENXIO; } else if (!cdev_get(p)) ret = -ENXIO; spin_unlock(&cdev_lock); cdev_put(new); if (ret) return ret; ret = -ENXIO; fops = fops_get(p->ops); // 获取cdev中的ops if (!fops) goto out_cdev_put; replace_fops(filp, fops); // 用cdev中的ops替换filp中的ops!!! if (filp->f_op->open) { ret = filp->f_op->open(inode, filp); // 执行cdev->ops中的open if (ret) goto out_cdev_put; } return 0; out_cdev_put: cdev_put(p); return ret;
最后,我们看一下是怎么在cdev_map
中找到对应的cdev
的kobj
的:
kobj_lookup
函数
重点看注释的地方,发现匹配规则正如前文所说,会找到范围包含设备号的probe
,返回它的data
中的kobj
。
"drivers/base/map.c" struct kobject *kobj_lookup(struct kobj_map *domain, dev_t dev, int *index) { struct kobject *kobj; struct probe *p; unsigned long best = ~0UL; retry: mutex_lock(domain->lock); // 该循环通过主设备号找到了probes上对应的probe链表,然后遍历 for (p = domain->probes[MAJOR(dev) % 255]; p; p = p->next) { struct kobject *(*probe)(dev_t, int *, void *); struct module *owner; void *data; // 范围判断,可以看到设备号不在[dev,dev+range-1]区间的话就继续查看下一个probe,反过来就是我们要查找设备号在其范围中的probe if (p->dev > dev || p->dev + p->range - 1 < dev) continue; if (p->range - 1 >= best) break; if (!try_module_get(p->owner)) continue; owner = p->owner; data = p->data; probe = p->get; best = p->range - 1; *index = dev - p->dev; if (p->lock && p->lock(dev, data) < 0) { module_put(owner); continue; } mutex_unlock(domain->lock); kobj = probe(dev, index, data); /* Currently ->owner protects _only_ ->probe() itself. */ module_put(owner); if (kobj) return kobj; goto retry; } mutex_unlock(domain->lock); return NULL; }
3.设备号的认识
思考
之前一直没有思考过设备号究竟是什么,为什么字符设备一定要有设备号呢,而且还分什么主次设备号,今天在了解了字符设备的内涵之后,这些问题得以思考。
-
设备号是什么:主设备号作为
cdev_map
的键值,找到对应到256
项probe
中的一项;设备号(dev
range
)作为设备的唯一标识,可以从这个probe
对应的链表上匹配cdev_add
时对应的cdev
结构,是通过字符设备文件找到cdev
结构的钥匙,
-
主设备号和次设备号的意义:
之前就听过,主设备号是驱动程序的唯一标识,主设备号和次设备号的结合是设备的唯一标识,这个其实是适用于早期版本的老接口,它仅有一个
256
长度的数组,数组用主设备号作为索引,有一个cdev
结构,因而导致一个主设备号仅对应一个cdev
结构,次设备号在查找cdev
结构上根本没法出力,因而说主设备号是驱动程序的唯一标识。而因为设备节点是主次设备号确定的,因为设备号是设备的唯一标识,驱动程序编写者可以在驱动程序内部区分次设备号进行不同的操作。现在却不能说是这样的,关于我对代码的分析。我觉得,设备号是确定设备的唯一标识,因为它对应一个设备节点,这个还是一样的;而现在使用的
cdev_map
结构每一个probe
可以使用dev
和range
项来唯一的确定一次cdev_add
操作,也就是说,驱动取决于cdev_add
时候传入的cdev
结构,一样的主设备号可以多次cdev_add
进来不一样的cdev
结构,实现不一样的驱动。总结来说,主设备号只决定在cdev_map
的时候链接到哪一个项的链表中;每一次cdev_add
确定一个驱动;如果cdev_add
多个设备即cnt
大于1
,允许一个驱动对应多个设备,这里和老版本的次设备号意义相同。
设备号知识补充
设备号用于唯一的标识设备,是设备节点找到cdev
字符设备驱动的唯一途径。所以在系统中,设备号必须唯一,且接受系统的管理,使用时需要申请分配和释放。设备号在linux
中是dev_t
类型的,它是个u32
32
位无符号整型变量,因此可见设备号是有大小限制的,具体定义如下:
"include/linux/types.h" typedef __kernel_dev_t dev_t; ----typedef __u32 __kernel_dev_t;=
其分为主设备号和次设备号两部分,一般高12
位为主设备号,低20
位为次设备号,并且有一些宏用于进行设备号和主次设备号的转换,一般我们都是使用宏来进行操作,定义如下:
"include/linux/kdev_t.h" #define MINORBITS 20 #define MINORMASK ((1U << MINORBITS) - 1) #define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS)) #define MINOR(dev) ((unsigned int) ((dev) & MINORMASK)) #define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))
4.用户与内核通过文件进行交互的认识
驱动是写在内核态的,其最终是需要和用户态进行交互,接收用户态的控制,那么就需要一个接口,这个接口就是文件,文件的ops
。所以,似乎所有的驱动好像基本上都需要套上一层框架,能为其提供文件接口的框架,例如字符设备驱动,到这里我对字符设备驱动在整个系统层次中的位置好像有一点理解了,它和块设备网络设备一起构成的驱动的上一层,为驱动提供文件接口。