字符设备是linux内核抽象出来的一类设备,linux内核为该设备驱动程序提供了一套驱动程序编写框架,驱动程序员编写linux字符设备驱动程序时,必须按照该框架进行。
1. 字符设备的内核抽象
内核对于所有字符设备,抽象出来了一个数据结构,该数据结构的一个实体便表示内核中的一个字符设备:
--------------------------------------------------------------------------------------------
struct cdev {
struct kobject kobj; //内嵌的内核对象.
struct module *owner; //该字符设备所在的内核模块的对象指针.
const struct file_operations *ops; //该结构描述了字符设备所能实现的方法,是
//极为关键的一个结构体.
struct list_head list; //用来将已经向内核注册的所有字符设备形成链表.
dev_t dev; //字符设备的设备号,由主设备号和次设备号构成.
unsigned int count; //隶属于同一主设备号的次设备号的个数.
};
内核给出的操作struct cdev结构的接口主要有以下几个:
void cdev_init(struct cdev *, const struct file_operations *);
该函数主要对struct cdev结构体做初始化,主要工作有(1)将整个结构体清零;(2)初始化list成员使其指向自身;(3)初始化kobj成员;(4)初始化ops成员.
struct cdev *cdev_alloc(void);
该函数主要分配一个struct cdev结构,并做了cdev_init中所做的前面3步初始化工作(第四步初始化工作需要在调用cdev_alloc后,显式的做初始化即: .ops=xxx_ops).
在上面的两个初始化的函数中,我们没有看到关于owner成员、dev成员、count成员的初始化;其实,owner成员的存在体现了驱动程序与内核模块间的亲密关系,struct module是内核对于一个模块的抽象,该成员在字符设备中可以体现该设备隶属于哪个模块,在驱动程序的编写中一般由用户显式的初始化 .owner = THIS_MODULE, 该成员可以防止设备的方法正在被使用时,设备所在模块被卸载。而dev成员和count成员则在cdev_add中才会赋上有效的值。
int cdev_add(struct cdev *p, dev_t dev, unsigned count);
该函数向内核注册一个struct cdev结构,即正式通知内核由struct cdev *p代表的字符设备已经可以使用了。当然这里还需提供两个参数:(1)第一个设备号 dev,(2)和该设备关联的设备编号的数量。这两个参数直接赋值给struct cdev 的dev成员和count成员。
void cdev_del(struct cdev *p);
该函数向内核注销一个struct cdev结构,即正式通知内核由struct cdev *p代表的字符设备已经不可以使用了。
从上述的接口讨论中,我们发现对于struct cdev的初始化和注册的过程中,我们需要提供几个东西(1) struct file_operations结构指针;(2)dev设备号;(3)count次设备号个数。但是我们依旧不明白这几个值到底代表着什么,而我们又该如何去构造这些值!
2. 设备号
Linux系统中的一个设备号由主设备号和次设备号组成,linux内核用主设备号来定位对应的设备驱动程序,而次设备号则用驱动程序使用,用来标识它所管理的若干同类设备。因此,设备号(尤其是主设备号)是系统的一种资源,使用者需要时必须向内核申请注册,不再使用时应该向内核注销设备号。
设备号的内核表示是数据类型 dev_t,其内部实现可以通过下图表示:
dev_t (n+m)bits:
Major(n bits) |
Minor(m bits) |
作用一个明智的使用者,我们实在不应该去妄自猜测major和minor各占多少bit位,也不应该去猜测dev_t的实现;内核也为我们提供了几个方便操作的宏:
MAJOR(dev) / MINOR(dev) :从设备号中提取major 和minor
MKDEV(major, minor) :通过major和minor构建设备号
我们必须通过上述宏去构造和获取设备号的相关值。
在上述的讨论中说主设备号用来定位设备驱动程序,次设备号用来标识驱动程序所管理的若干同类设备。但到目前为止,我们仅仅知道在注册struct cdev结构体时需要给入参数:dev 和count,我们依旧不明白主设备号是怎么定位到设备驱动程序,次设备号又是怎么标识驱动程序所管理的若干同类设备的?我们不明白此道理,就无法构造出正确的设备号!
要回答这个问题我们必须先回答下面这个问题,处于内核空间的驱动程序是如何为用户空间的用户所使用的呢?欲知答案为何,请听下回揭晓!
3. struct file_operations – 驱动程序之枢纽
答案是通过文件!在linux中字符设备和块设备都被抽象了文件,因此用户空间对于设备的操作就是对于文件的操作,而操作的接口就是文件I/O接口,而我们可以猜想得到是对于这些I/O接口的调用最终肯定会调用的内核空间对应的驱动程序去的。那在内核空间到底是谁来接待这些用户空间的I/O接口的呢?
答案就是cdev中的struct file_operations结构体了。该结构体定义在
4.设备文件的创建
前面我们提到过,在linux系统上操作字符设备和块设备其实就是操作文件系统上的文件,而这类文件称为设备文件。但是设备文件是不会“你见或者不见,它就在那里”的!它需要我们去创建它,而且我们必须提供足够的信息。下面是在linux上创建一个字符设备的命令行。
# mknod /dev/demodev c 2 0
上述命令行中,mknod是创建一个设备文件(或者称设备节点)的命令,“/dev/demodev”是设备文件名, c表示所创建是字符设备(b则表示块设备),2和0分别表示主设备号和次设备号。从命令行的参数,我们似乎可以隐约看出设备号将是串起用户空间文件操作和内核空间对应驱动程序的一条甚至可能是唯一的一条线。为了解开这个疑问,我们先来看看,上述命令行到底在内核空间引起了什么反应,主次设备号又是如何为内核所用的。
对于任何一个已存在的文件,在内核都有一个称为struct inode的结构体(定义于include/linux/fs.h)与之对应,这是一个结构成员繁多的数据结构,但是对于字符设备而言,真正需要关心的只有以下三个成员:
dev_t i_rdev; //设备文件的设备号
struct cdev *i_cdev; //内核对于一个字符设备的抽象,正是这个结构体将用户空间看到设备//文件跟内核空间看到的字符设备关联了起来。
const struct file_operations *i_fop; //该设备文件所能拥有的所有能力。
用户空间调用了mknod命令后,内核空间会做两件事: (1)生成一个struct inode 实体;(2)为struct inode的上述成员做初始化工作。
关于对上述上个成员的初始化,我们可能会不由自主的猜测,其初始化流程应该是这样的:i_rdev设备号由mknod命令传递的值构成;剩下的关键是如何给i_cdev赋值了,因为i_cdev成员里也有一个struct file_operations *成员,而此成员正是我们构建的驱动程序的实现,因此应该可以直接赋值给inode里的i_fop。而只要能给i_cdev赋上值,i_fop就不在话下了。那i_cdev的值该从何而来呢?通过毕竟mknod传递下来的,对于驱动程序而言,除了设备号没有其他可用的信息了。如何还记得cdev_add这个函数的朋友们,应该会马上猜到了,内核应该还提供通过设备号查找i_cdev的函数。这样初始化流程就可以顺利完成了!如果你想到了上面这些,那么恭喜你,说明你在看本文的过程中已经加入了自己的思考。
不过可不能得意得那么早啊,这毕竟不过是“元芳的看法”而已,狄大人还没发话呢!哈哈……
我们还是来看看事情的真相是怎么样的吧!
在mknod的内核执行流程中的会调用一个与设备驱动程序关系密切的init_special_inode
--------------------------------------------------------------------------------------------
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;
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 = &def_fifo_fops;
else if (S_ISSOCK(mode))
inode->i_fop = &bad_sock_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);
}
从上面的代码实现(蓝色部分),我们可以看到i_rdev初始化为rdev,而rdev是由mknod中的参数构造而来的,这和我们设想的是一样的;但是接下来出乎我们意料的是i_fop却初始化为&def_chr_fops(def_chr_fops定义如下),而更出乎我们意料的是查遍了整个流程也没有看到i_cdev的初始化。这到底是怎么回事呢?事到如今,我们也是被无他法了,上面的实现明确的告诉我们,i_cdev并没有被初始化,而i_fop的初始化与设备号没有丁点关系而且初始化的值也不是我们所实现的那个struct file_operations指针,inode仅仅是保存了设备号而已啊!这可怎么办啊?我们怎么该调用到我们的驱动程序啊?朋友们请别泄气,狄大人的经验告诉我们,真相总是会水落石出的,只要我们能坚持住!哈哈!且听下回分解吧……
const struct file_operations def_chr_fops = {
.open = chrdev_open,
.llseek = noop_llseek,
};
6. 设备文件的操作
熟悉linux应用编程的朋友们应该都知道,要操作一个文件,除了该文件必须存在外,还需要先通过open系统调用去得到一个文件句柄,有了这个句柄后续的操作才能进行。对于设备文件的操作也是同样的道理。于是我们似乎又可以找到一些线索了,也许在字符设备文件的open操作中,我们能够看到我们曾经猜测的东西,它们没有在mknod中被完成而是延后到了open的时候来完成了。好吧,让我们从open系统调用开始来揭开这层层的谜团吧!
open系统调用在C库头文件中的原型如下:
int open(const char *pathname, int flags, mode_t mode);
毫无疑问open函数必须通过pathname去找到该文件对应的inode(这里假设我们的inode设备节点已经存在)。找到inode节点后,将调用inode里i_fop成员的open方法,对于字符设备而言,将调用chrdev_open函数。该函数实现如下:
--------------------------------------------------------------------------------------------
static int chrdev_open(struct inode *inode, *filp)
{
struct cdev *p;
struct cdev *new = NULL;
int ret = 0;
spin_lock(&cdev_lock);
p = inode->i_cdev;
if (!p) {
struct kobject *kobj;
int idx;
spin_unlock(&cdev_lock);
kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);
if (!kobj)
return -ENXIO;
new = container_of(kobj, struct cdev, kobj);
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;
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;
filp->f_op = fops_get(p->ops);
if (!filp->f_op)
goto out_cdev_put;
if (filp->f_op->open) {
ret = filp->f_op->open(inode, filp);
if (ret)
goto out_cdev_put;
}
return 0;
out_cdev_put:
cdev_put(p);
return ret;
}
该函数流程如下:
(1) 判断inode的i_cdev成员是否为空(据我们所知,从我们mknode开始到现在还没有谁给它赋过值,因此到目前为止还是空的)。
(2) 如果为空,将通过kobj_lookup与container_of的组合找出inode->i_rdev所对应的struct cdev结构。
(3) 将通过inode->i_rdev查找到的struct cdev结构指针赋值给inode->i_cdev(注意下次open时inode将不为空),然后将inode加入struct cdev链表中。
(4) 将inode->i_cdev中的i_fop赋值给由chrdev_open传递进来的filp的f_op成员。
(5) 如果inode->i_cdev中的i_fop不为空,则调用其指向的open方法。
以上流程是对应创建设备节点后第一次调用open的流程,该流程与我们在“设备文件的创建”一节中猜测的inode值的初始化过程还是有点出入的,对于inode->i_cdev初始化的猜测,我们是正确的,而对于inode->i_fop我们得出的结论是设备节点的inode的i_fop值从创建后一直是&def_chr_fops(.open= chrdev_open), 而struct cdev结构的i_fop指针只是赋给了代表每个打开文件的filp结构中的ops。
如果open不是创建设备节点后第一次被调用,则chrdev_open函数的执行流程是执行完(1)后直接跳过(2)、(3)两个步骤到(4)。
至此,open成功返回!
对于用户空间的open我们知道它返回一个int型的句柄,而后续的所有操作都是根据该句柄进行的,如read和write:
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
对于设备文件而言这些操作直接对应于驱动程序实现的struct file_operations里的实现!那我们现在的问题又来了,内核是怎么通过fd找到与其对应的struct file_operations的?
在用户空用文件路径名代表着一个文件,而open函数返回的fd则代表着一个打开文件的抽象,即对于一个文件可以同时存在对其进行操作的多个窗口。当然这些都必须得到内核的支持才行,因此在内核空间用一个inode结构代表一个文件,而用struct file结构代表一个打开的文件。用户空间每open一个文件,内核都会为其生成一个struct file结构。该结构的成员也不少,这里只有两个成员是我们关心的
const struct file_operations *f_op;
void *private_data;
f_op成员我们可以看到在chrdev_open被赋值了,赋的值是inode->i_cdev->ops;而private_data的作用则是用于f_op里各个方法间传递数据用的,这在驱动程序的实现过程中将会非常有用。
那内核是如何根据用户空间的fd找到其对应的struct file结构的呢?其实对于每个进程而言,内核中表示一个进程的数据结构(如struct task_struct task)里回维护一张打开的文件描述符表(task->files->fdt->fd,struct file *指针数组),在open的过程中,内核会以得到的fd为下标,将新生成的struct file的指针填入表中,即
task->files->fdt->fd[fd] = filp;
因此以fd的其他文件操作可以很轻易的找到其对应的struct file,进而调用f_op对应的方法。