前言:开始学习字符设备驱动,这里会配合对内核的了解来学习
学习字符设备前,先了解一个结构体:struct file_operations
可以看到该结构体中都是一些函数指针变量,这些函数变量这里不做介绍,我们只需要知道现实中驱动的编写多是围绕着这些函数的实现来进行的。
学习字符设备驱动,要先了解字符设备结构体,内核使用cdev结构体来描述一个字符设备。
struct cdev
{
struct kobject kobj; /* 内嵌的 kobject 对象 */
struct module *owner; /*所属模块*/
struct file_operations *ops; /*文件操作结构体*/
struct list_head list; //用来将该字符设备添加到字符设备链表
dev_t dev; /*设备号*/
unsigned int count; //次设备号的个数,用来表示当前驱动程序控制的同类设备的个数
};
1.申请空间
内核提供了一个函数,用来申请一个cdev的空间,并且对cdev进行必要的初始化:
struct cdev *cdev_alloc(void)
struct cdev *p=kmalloc(sizeof(struct cdev),GFP _ KERNEL); /*分配 cdev的内存*/
if (p) {
memset(p, 0, sizeof(struct cdev));
p->kobj.ktype = &ktype _ cdev _ dynamic;
INIT_LIST_HEAD(&p->list);
kobject_init(&p->kobj);
}
return p;
}
但是通常情况下,我们不使用这个函数,因为内核引入cdev数据结构作为字符设备的抽象,仅仅是为了满足系统对字符设备驱动的程序框架结构设计的需要,现实中一个字符设备的抽象往往要复杂的多,这种情况下的cdev一般作为一个成员嵌套到实际的数据结构中。比如:
struct my_cdev
{
int a ;
char b;
struct cdev cdev;
}
这种情况下,一般使用下面的方法,在驱动程序初始化函数开头申请内存空间:
struct my_cdev *p=kmalloc(sizeof(struct my_cdev),GFP_KERNEL);
用该函数申请内存之后,对于初始化不用做任何的操作,在之后的cdev_init中,会把之前cdev_alloc做的初始化再次做一遍。
2.字符设备初始化
cdev_init()函数用于初始化 cdev 的成员,并建立 cdev 和 file_operations 之间的连接,其源代码如代码如下:
void cdev _ init(struct cdev *cdev, struct file _ operations *fops)
{
memset(cdev, 0, sizeof *cdev);
INIT_LIST_HEAD(&cdev->list);
cdev->kobj.ktype = &ktype_cdev_default;
kobject_init(&cdev->kobj);
cdev->ops = fops; /*将传入的文件操作结构体指针赋值给 cdev 的 ops*/
}
3. 设备号的构成、分配与释放
3.1 设备号的构成
数据类型:dev_t dev ,是一个32位的无符号数。
构成 :主设备号与次设备号
主设备号:12位 是内核用来定位对应的设备驱动
次设备号:20位 是驱动用来管理若干个同类设备
随着内核的演变,主次的位有可能会发生变化,所有内核有特定的宏来操作设备号:
#define MAJOR(dev) ((unsigned int)((dev)>>MINORBITS)) //用于提取主设备号
#define MINOR(dev) ((unsigned int)((dev)&MINORMASK)) //用于提取次设备号
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi)) //用于生成设备号
3.2 设备号的分配
内核中有两种办法进行设备号的分配,
手动分配:
int register_chrdev_region(dev_t from, unsigned count, const char *name)
**功能:手动指定设备号**
参数:
@from : 设备号的起始值
@count : 设备的个数
@name : 名字 cat /proc/devices
返回值:成功返回0 失败返回错误码
自动分配:
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count,const char *name)
功能:自动分配设备号
参数:
@dev :分配好的设备号
@baseminor :起始的次设备
@count :设备的个数
@name :名字 cat /proc/devices
返回值:成功返回0 失败返回错误码
3.3 设备号的释放
作为资源,设备号在驱动卸载之后要进行归还,不管是手动还是自动分配的,都需要归还给系统。
unregister_chrdev_region(dev_t from,unsigned count);
4.字符设备的注册与注销
注册:
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
功能:注册字符设备驱动
参数:
@p :cdev结构体指针
@dev :设备号
@count:设备的个数
返回值:成功0 失败返回错误码
注销:
cdev_del(struct cdev *p);
功能:注销字符设备驱动
参数:
@p :cdev结构体指针
具体内核的实现我们这里来简单进行分析:
内核里面有一个全局的变量cdev_map,定义如下:
static struct kobj_map *cdev_map;
结构体如下:
这个全局变量,在系统启动的时候,由chrdev_init函数负责初始化,然后当我们调用注册函数的时候,系统就会根据我们的设备号中的主设备号,找到对应的probe数组中的对应项probe[i],然后初始化这些成员。这样其它模块就可以使用我们的驱动了。对于系统而言,当我们调用了cdev_add之后,就意味着一个字符设备对象已经加入到系统了。对于用户态而言,这时候就可以通过文件系统调用我们的接口了。不过文件系统时怎么调用的,我们后面会分析。
5 设备文件节点的生成
linux系统下,设备文件是种特殊的文类型,其存在的主要意义是沟通用户空间程序和内核空间驱动程序。换句话说,用户空间的程序想要使用驱动程序提供的服务,需要经过设备文件来完成,当然,如果你的驱动程序只是为了内核中的其他模块服务,则没有必要生成对应的设备文件。
手动创建一个设备节点:
sudo mknod /dev/hello(可以在任何目录下) c/b 主设备号 次设备号
sudo mknod /dev/hello c 250 0
动态创建一个设备节点:
class_create(owner, name)
功能:在/sys/class目录下创建目录
参数:
@owner: THIS_MODULE
@name :目录的名字
返回值:成功返回struct class*指针 失败返回错误码指针
ERR_PTR(); //将错误码转化为错误码指针
IS_ERR(const void *ptr) //返回指针是否是错误,如果是错误返回真,否者返回假
PTR_ERR(const void *ptr) //将错误码指针转化为错误码
struct device *device_create(struct class *class, struct device *parent,dev_t devt, void *drvdata, const char *fmt, ...)
功能:创建设备节点
参数:
@class :class结构体指针
@device :NULL
@devt :设备号
@drvdata:NULL
@... : 可变参数 "demo%d",i
返回值:成功返回struct device*指针 失败返回错误码指针
设备节点的删除:
void class_destroy(struct class *cls)
功能:删除class
参数:
@cls:class结构体指针
void device_destroy(struct class *class, dev_t devt)
功能:删除device
参数:
@class:class结构体指针
@devt:设备号
这里我们分析一下mknod手动添加一个设备文件节点的过程,完成的过程需要知晓特定文件系统的的技术细节,然而对于驱动程序员来说,没有必要知道文件系统的相关的细节,我们这里只需要关心文件系统和驱动程序之间是怎么建立关系的。
使用mknod首先会调用mknod函数,然后系统调用sys_mknod进入内核,该函数的作用如下:
1、在根文件系统根目录“”/“”下寻找dev目录对应的inode。
2、然后根据inode信息,找到dev目录在内存中的位置
3、调用dev的inode结构中的 i_op成员所指向的ext3_dir_inode_operations ,来调用该对象中的mknod的方法,这将导致ext3_mknod函数被调用。
4、ext3_mknod函数的主要作用是生成一个新的inode。之后调用一个与设备驱动程序密切相关的init_special_inode函数,其定义如下,我们仔细看这个函数,对字符设备而言,这个函数的作用就是初始化新生成的inode中的两个变量 i_fop与i_rdev,其中i_rdev保存了自用户空间传下来的设备号,i_fop指向了def_chr_fops,该结构体定义如下:
到此,设备节点的所有铺垫的工作就结束了,后面我们分析一下设备文件的打开操作。
6.字符设备文件的打开操作
这里先列出用户空间跟内核空间open函数的定义:
用户空间: init open(const char *filename,int flags,mode_t mode)
内核空间: init (*open)(struct inode *,struct file*);
这里我们简单分析一下用户空间的open是怎么调用到内核空间的open的,两者的关联是什么。
我们知道用户空间open()函数会返回一个文件描述符,我们这里用fd表示,这个fd会被之后用户空间中read(),wirte()()ioctl()使用,驱动中的读、写、io函数每个函数的第一个参数都是struct file * filp 类型的一个参数,很显然,这里内核在打开设备文件时会为fd与filp建立某些联系,其次是filp与驱动程序中的fops建立关系。
我们都知道open函数会调用系统函数sys_open进入内核,该函数的作用我们这里主要分析一下,代码就不列出来了,这里只把该函数的执行过程大概描述一下:
- 分配一个fd
- 找到文件对应的inode
- 为打开的文件分配一个struct file类型的内存空间,进程为文件操作维护一个文件描述符表,fd作为索引,file作为空间地址,会赋值给fd作为索引的表成员项,这样用户空间在后续使用fd就能找到对应的struct file类型的内存空间,进而访问里面的内容。file结构体类型如下,里面跟驱动有关系的成员我标出来了。
- 找到file内存空间之后,有啥用呢?这里就要看sys_open后续的操作了,这里会把struct file filp->f_op指向之前创建的inode结构中的inod->i_fop。上面介绍过这个成员,相当于filp->f_op = &def_chr_fops。
- 接下来会利用filp来调用def_chr_fops中的.open部分,所以chrdev_open函数就会被调用。这个函数非常重要。原型如下:
- 接下来介绍chrdev_open函数的功能:
a) 首先通过kobj_lookup函数,利用inode保存的设备号找到cdev_map链表中对应的设备new,这里就现实了设备号的作用。
b) 找到之后将filp->f_op指向设备驱动的ops,此处展示了如何将驱动中实现的ops与filp中的filp->f_op联系起来
c) 接下来函数会检查驱动程序中是否实现了open函数,
d) 如此之后,用户就可以根据使用read wirte等函数,根据fd找打对应的filp,然后调用filp中的filp->f_op成员,就相当于调用驱动中实现的对应的read,write等函数。
到此,设备文件打开流程就结束了。通过上述分析我们可以知道,设备号的关键用处,这里再次说明一下:
设备号的第一处用处:作为一个索引,保存字符设备到cdev_map链表中。
设备号的第二处用处:保存到inode结构体中。
这样当我们打开一个设备文件的时候,才能通过inode中的设备号找到对应的设备驱动,所以设备号一定不能搞错。
7.字符设备文件关闭操作
有了前面打开流程分析的基础,这里简单说一下关闭操作的流程。
用户空间关闭文件的原型为
int close(unsigned int fd);
close对应的系统调用函数为sys_close,这个函数主要的功能如下:
- 通过fd找到filp
- 接着调用filp_close函数,close的大部分工作都在这里面。
- filp_close原型代码
- filp_close函数会先判断filp->f_count变量是否为0 ,如果为0,则说明已经被之前的close关闭掉了,直接返回0就行。f_count变量代表file文件的使用计数。
- fput函数介绍,从下面的函数介绍可以知道,如果file->f_count为1,则进行真正的关闭,执行_fput()函数,_fput()函数最后会调用驱动程序中的release。可以看到使用close关闭字符设备文件,不一定调用驱动中的release函数。