字符设备驱动

x33g5p2x  于2022-05-07 转载在 其他  
字(12.4k)|赞(0)|评价(0)|浏览(295)

linux根据驱动程序实现的模型框架将设备的驱动分为了三类:

  1. 字符设备驱动:以字节流为单位顺序读写,不能随机访问。如,帧缓冲
    ( framebuffer)驱动,声卡,串口等。
  2. 块设备驱动:以固定大小的块(block) 为单位读写,可以随机访问。
  3. 网络设备驱动:网络接口是一个能够和其他主机交换数据的设备。接口通常是一个硬件设备,但也可能是个纯软件设备,比如回环(loopback)接口。网络接口由内核中的网络子系统驱动,负责发送和接收数据包。
    以上驱动程序的分类是按照驱动的模型框架进行的,在现实生活中,有的设备很难被严格界定是字符设备还是块设备。甚至有的设备同时具有两类驱动,如MTD (存储技术设备,如闪存)。一个设备的驱动属于上述三类中的哪一类, 还要看具体的使用场合和最终的用途。

字符设备驱动基础

在正式学习字符设备驱动之前,我们来看看相关的基础知识。

在类UNIX系统中,有一个众所周知的说法,即“一切皆文件”,当然网络设备是一个例外。这就意味着设备最终也会体现为一个文件,应用程序要对设备进行访问,最终就会转化为对文件的访问,这样做的好处是统一了 对上层的接口。

设备文件通常位于/dev目录下,使用下面的命令可以看到很多设备文件及其相关的信息。
ls -l dev

在上面列出的信息中,前面的字母“b”表示是块设备,“c” 表示是字符设备。比如sda、sda1, sda2、 sda5就是块设备,实际上这些设备是笔者的Ubuntu主机上的一个硬盘和这个硬盘上的三个分区,其中sda表示的是整个硬盘,而sdal1、sda2、 sda5分别是三个分区。tty0、 tty1 就是终端设备shell 程序使用这些设备来同用户进行交互

从上面的打印信息来看,设备文件和普通文件有很多相似之处,都有相应的权限、所属的用户和组、修改时间和名字。但是设备文件会比普通文件多出两个数字,这两个数字分别是主设备号和次设备号。这两个号是设备在内核中的身份或标志,是内核区分不同设备的唯一信息。通常内核用主设备号区别一类设备,次设备号用于区分同一类设备的不同个体或不同分区。而路径名则是用户层用于区别设备信息的。

通过mknod命令来创建一个设备文件

mknod /dev/vser0 c 256 0
ls -li /dev/vser0

mknod是make node的缩写,就是创建一个节点(设备文件)。

在linux系统中,一个节点代表一个文件,创建一个文件最主要的工作就是分配一个新的节点。
包含节点号的分配(节点号在系统中是唯一的,可以区分不同的文件)。

如上面的命令的结果会出现
126695 crw-r--r-- 1 root root 256, 0 Jul 13 10:03 /dev/vser0

这里的126695就是节点号
然后初始化这个节点的(文件模式 crw-r--r-- 、访问时间 Jul 13 10:03 、用户ID 1 、组ID 13等信息
如果是设备文件需要初始化好设备号

再将这个初始化好的节点放入磁盘,还需要在文件所在目录下添加一个目录项,目录项中包含了前面分配的节点号和文件的名字,然后写入磁盘。存在磁盘上的这个节点用一个结构封装。

下面用extr2文件系统为例:
在linux3.14内核文件中

struct ext2_inode {
	__le16	i_mode;		/* File mode */
	__le16	i_uid;		/* Low 16 bits of Owner Uid */
	__le32	i_size;		/* Size in bytes */
	__le32	i_atime;	/* Access time */
	__le32	i_ctime;	/* Creation time */
	__le32	i_mtime;	/* Modification time */
	__le32	i_dtime;	/* Deletion Time */
	__le16	i_gid;		/* Low 16 bits of Group Id */
	__le16	i_links_count;	/* Links count */
	__le32	i_blocks;	/* Blocks count */
	__le32	i_flags;	/* File flags */
	.....
	__le32	i_block[EXT2_N_BLOCKS];/* Pointers to blocks */
	......
};

可以从这里清楚的看出一个node节点的结构体包含的数值,前面说的节点的(文件模式 crw-r–r-- 、访问时间 Jul 13 10:03 、用户ID 1 、组ID 13等信息)都有

另外,对于i_blocks

  1. 如果是普通文件,则这个数组存放的是真正的文件数据所在的块号(看成对文件的索引,所以ext2文件是按照索引的方式找的);
  2. 如果是设备文件,这个数组主要存放设备的主次设备号

可以看下面代码:

static int __ext2_write_inode(struct inode *inode, int do_sync)
{
	struct ext2_inode * raw_inode = ext2_get_inode(sb, ino, &bh);
	.......
	raw_inode->i_mode = cpu_to_le16(inode->i_mode);
	.......
	if (S_ISCHR(inode->i_mode) || S_ISBLK(inode->i_mode)) {
		if (old_valid_dev(inode->i_rdev)) {
			raw_inode->i_block[0] =
				cpu_to_le32(old_encode_dev(inode->i_rdev));
			raw_inode->i_block[1] = 0;
		} else {
			raw_inode->i_block[0] = 0;
			raw_inode->i_block[1] =
				cpu_to_le32(new_encode_dev(inode->i_rdev));
			raw_inode->i_block[2] = 0;
		}
	}
	......
}

这个struct ext2_inode * raw_inode = ext2_get_inode(sb, ino, &bh); 获得了一个要写入磁盘的ext2_inode结构,并初始化了部分成员。

这个

if (S_ISCHR(inode->i_mode) || S_ISBLK(inode->i_mode)) {
		if (old_valid_dev(inode->i_rdev)) {
			raw_inode->i_block[0] =
				cpu_to_le32(old_encode_dev(inode->i_rdev));
			raw_inode->i_block[1] = 0;
		} else {
			raw_inode->i_block[0] = 0;
			raw_inode->i_block[1] =
				cpu_to_le32(new_encode_dev(inode->i_rdev));
			raw_inode->i_block[2] = 0;
		}
	}

判断了设备的类型,如果是字符设备或块设备,那么将设备号写入i_ block 的前2个或前3个元素,其中ionde的i_dev 成员就是设备号。而这里的inode是存在于内存中的节点,是涉及文件操作的一个非常关键的数据结构

关于该结构我们之后还要讨论,这里只需要知道写入磁盘中的ext2_ inode 结构内的成员基本上都是靠存在于内存中的inode中对应的成员初始化的即可,其中就包含了这里讲的设备号。之前我们说过,设备号有主、次设备号之分,而这里的设备号只有一个。原因是主、次设备号的位宽有限制,可以将两个设备号合并,之后我们会看到相应的代码。

在代码raw_inode->i_mode = cpu_to_le16(inode->i_mode);我们可以看到,文件的类型也被保存在了ext2_ inode 结构中,并且写在了磁盘上。

刚才还谈到需要在文件所在目录下添加目录项,这又是怎样完成的呢?

文件目录下添加目录项

linux系统中,目录本身也是一个文件,其中保存的数据是若干个目录项,目录项的主要内容就是刚才分配的节点号和文件或子目录的名字。

在ext2中,写入磁盘的目录项数据结构如下:

上面的inode就是节点号,name成员就是文件或者子目录的名字。

具体代码实现可以参考fs/ext2/namei.c”的ext2_mknod函数.

下图说明了mknod命令在ext2文件系统上完成的工作

上面的整个过程,就是mknod命令将文件名、文件类型、和主次设备号等信息保存在磁盘上

接下来我们来讨论如何打开一个文件,这是理解上层应用程序和底层驱动程序如何建立联系的关键,也是理解字符设备驱动编写方式的关键。整个过程非常烦琐,涉及的数据结构和相关的内核知识非常多。为了便于大家理解,下面将该过程进行大量简化,并以图3.2和调用流程来进行说明。

理解以下流程:

在内核中,一个进程用一个task_ struct结构对象来表示,其中的files成员指向了一个files_ struct 结构变量,该结构中有一个fd_ array 的指针数组(用于维护打开文件的信息), 数组的每一个元素是指向file 结构的一个指针。
open 系统调用函数在内核中对应的函数是sys_ _open, sys_ open调用了do_ sys_ open,在do_ sys_ open中首先调用了getname 函数将文件名从用户空间复制到了内核空间。接着调用get__unused fd_ flags来获取一个未使用的文件描述符,要获得该描述符,其实就是搜索files_ _struct中的fd_ arrary 数组,查看哪一个元素没有被使用,然后返回其下标即可。接下来调用do_ filp. _open函数来构造一-个 file结构,并初始化里面的成员。其中最重要的是将它的f _op成员指向和设备对应的驱动程序的操作方法集合的结构file_ operations , 这个结构中的绝大多数成员都是函数指针,通过file_operations中的open函数指针可以调用驱动中实现的特定于设备的打开函数,从而完成打开的操作。do_ filp_open 函数执行成功后,调用fd_ install函数,该函数将刚才得到的文件描述符作为访问fd_ array数组的下标,让下标对应的元素指向新构造的file 结构。

最后系统调用返回到应用层,将刚才的数组下标作为打开文件的文件描述符返回。

do_ filp_ open函数包含的内容很多,是这个过程中最复杂的一部分,下面进行一下非
常简化的介绍。
do_ filp_ open 函数调用path _openat 来进行实际的打开操作,path_ openat调用get_empty_ filp 快速得到一一个file结构,再调用link. path walk来处理文件路径中除最后一个分量的前面部分。
举个例子来说,如果要打开/dev/vser0这个文件,那么link_path_ walk需要处理/dev这部分,包含根目录和dev目录。
接下来path _openat 调用do_ last来处理最后一个分量,do_ last首先调用lookup_fast 在RCU模式下来尝试快速查找,如果第一次这么做会失败,所以继续调用lookup_ open, 而lookup_ open 首先调用lookup_dcache在目录项高速缓存中进行查找,第一次这么做也会失败,所以转而调用lookup_ real, lookup_ real则在磁盘上真正开始查找最后一个分量所对应的节点,如果是ext2文件系统,则会调用ext2_ lookup, 得到inode 的编号后,ext2_ lookup 又会调用ext2_ iget从磁盘上获取之前使用mknod保存的节点信息对字符设备驱动来说,这里最重要的就是将文件类型和设备号取出并填充到了内存中的inode 结构的相关成员中
另外,通过判断文件的类型,还将inode中的f_op指针指向了def _chr_ fops, 这个结构中的
open函数指针指向了chrdev_ open, 那么自然chrdev_ open 紧接着会被调用。chrdev_ open完成的主要工作是:首先根据设备号找到添加在内核中代表字符设备的cdev (cdev 是放在cdev_ map 散列表中的,驱动加载时会构造相应的cdev并添加到这个散列表中,并且在构造这个cdev时还实现了一个操作方法集合,由cdev的ops成员指向它),找到对应的cdev对象后,用cdev关联的操作方法集合替代之前构造的file结构中的操作方法集合,然后调用edev所关联的操作方法集合中的打开函数,完成设备真正的打开操作,这也标志着do_ filp_ open 函数基本结束。

为了下一次能够快速打开文件,内核在第一次打开一个文件或 目录时都会创建一个dentry的目录项,它保存了文件名和所对应的inode 信息,所有的dentry使用散列的方式存储在目录项高速缓存中,内核在打开文件时会先在这个高速缓存中查找相应的dentry,如果找到,则可以立即获取文件所对应的inode,否则就会在磁盘上获取。对于字符设备驱动来说,设备号、cdev 和操作方法集合至关重要,内核找到路径名所对应的inode后,要和驱动建立连接,首先要做的就是根据inode中的设备号找到cdev,然后根据cdev找到关联的操作方法集合,从而调用驱动所提供的操作方法来完成对设备的具体操作。可以说,字符设备驱动的框架就是围绕着设备号、cdev 和操作方法集合来实现的
虽然设备的打开操作很烦琐,但是其他系统的调用过程就要简单很多。因为打开操
作返回了一个文件描述符,其他系统调用时都会以这个文件描述符作为参数传递给内核,
内核得到这个文件描述符后可以直接索引fd_ array,找到对应的file结构,然后调用相应
的方法。

字符设备驱动框架

cdev结构

视频:
描述字符设备使用cdev结构

struct cdev {
struct kobject kobj; //父类。
struct module *owner; //当前结构所属模块,THIS_ MODULE (当前模块)。
const struct file_ operations *ops; //设备对应操作。
struct list_head list; //内核链表,内核用来管理字符设备。
dev_ t dev;//设备编号(dev_ t) ,高12主设备号,低20位次设备号。
unsigned int count;//次设备号个数。
};

内核链表就是一个struct

设备编号 高12主设备号,低20位次设备号。

如果是-crw 以-开头是普通文件

l开头是链接文件

d开头是目录文件

b开头是块设备

还得用次设备号区分不同的设备
用主设备号区分不同的程序(文件不同)

struct file_ operations *ops; //设备对应操作

struct file_ operations {
struct module *owner; //THIS MODULE.
ssize_ t (*read) (struct file *, char__ user*, size_t, loff_t *); //对应系统调用read.
ssize_ t (*write) (struct file *, const char_user*, size_ t, loff_t *); //对应系统调用write.
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); //对应系统调用ioctl.
int (*open) (struct inode *, struct file *); //对应系统调用open.
int (*release) (struct inode *, struct file *); //对应系统调用close.
[..]
};

下面介绍一下 字符设备函数

字符设备函数

分配字符设备:struct cdev *cdev_ alloc(void); //已经初始化,内存自动释放(不用free)
初始化字符设备: void cdev_ init(struct cdev *cdev, const struct file_ operations *fops); 。
注意:除使用cdev_ alloc函数分配的字符设备以外,都可以调用该函数初始化。如初始化静态分配的字符设备。
也就是说 init初始化与调用cdev_ alloc不能同时使用

添加设备:int cdev_ aldd(struct cdev *p, dev_ t dev, unsigned count)。

参数:
p,字符设备指针。
dev,设备编号,
count,次设备号数。 子设备
返回值:成功返回0,失败返回错误码。

删除设备:void cdev_ del(struct cdev *p)。

设备编号: 设备编号是一个32无符号整数(dev _t), 高12主设备号,低20位次设备号。

主设备号用来识别驱动,次设备号用来区分不同的设备。

MKDEV(major, minor); //构造设备编号,转换为设备编号类型dev_t
//MKDEV是将主设备号和次设备号转换成dev_t类型的一个内核函数。
MAJOR(dev_ t); //取主设备号. 
MINOR(dev_ t); //取次设备号.

下面我们来试试如何实现驱动

实现字符设备驱动

通过上面我们知道了cdev结构,但是我们要将cdev构造就要将cdev架构对象添加到内核的cdev_map散列表中

注册设备号

#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>

#include <linux/fs.h>

#define VSER_MAJOR	256
#define VSER_MINOR	0
#define VSER_DEV_CNT	1   //数量1 设备号1个
#define VSER_DEV_NAME	"vser"

static int __init vser_init(void)
{
	int ret;
	dev_t dev;

	dev = MKDEV(VSER_MAJOR, VSER_MINOR);
	ret = register_chrdev_region(dev, VSER_DEV_CNT, VSER_DEV_NAME);
	if (ret)
		goto reg_err;
	return 0;

reg_err:
	return ret;
}

static void __exit vser_exit(void)
{
	
	dev_t dev;

	dev = MKDEV(VSER_MAJOR, VSER_MINOR);
	unregister_chrdev_region(dev,VSER_DEV_CNT);
}

module_init(vser_init);
module_exit(vser_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kevin Jiang <jiangxg@farsight.com.cn>");
MODULE_DESCRIPTION("A simple character device driver");
MODULE_ALIAS("virtual-serial");

在模块的初始化函数中:

#define VSER_MAJOR	256
#define VSER_MINOR	0
#define VSER_DEV_CNT	1 //数量1 设备号1个
#define VSER_DEV_NAME	"vser"

dev = MKDEV(VSER_MAJOR, VSER_MINOR);

这里用MKDEV宏将主设备号和次设备号合并成一个一个设备号dev

在内核源码中相关宏定义如下:

#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))

不难发现,该宏的作用是将主设备号左移20位和次设备号相或(12高主设备号+20低次设备号=32位设备号)

构造好设备号后

代码ret = register_chrdev_region(dev, VSER_DEV_CNT, VSER_DEV_NAME);

它调用了register_chrdev_region将构造的设备号注册到内核中,表明该设备号已经被占用,如果有其他驱动随后要注册该设备号不行

其函数原型:
int register chrdev_region(dev_t from, unsigned count, const char *name);

该函数一次可以注册多个连续的号,由count形参指定个数,由from指定起始的设备号,name用于标记主设备号的名称。该函数成功则返回0,不成功则返回负数
返回负数通常是因为要注册的设备号已经被其他的驱动抢先注册了。如果注册出错,则使用goto语句跳转到错误处理代码处执行,否则初始化函数返回0。

在卸载模块时候,已注册的号应该从内核中注销,否则再次加载该驱动时候,注册设备号操作会失败
。代码中调用unregister_chrdev_region(dev,VSER_DEV_CNT);

上面的代码再一次印证了前面所说的内容,即在模块初始化的函数中负责注册、分配内存等操作,而在模块清除函数中负责相反的操作,即注销、释放内存等操作。

以上的代码可以编译并进行测试,在Ubuntu主机上测试的步骤如下( 在ARM目标板上的测试和前面所讲的模块在ARM目标板上测试的过程类似)。

下面试试编译

make
make modules_install
sudo insmod vser.ko
或者
modprobe vser

然后要查看,先建立个文件proc,再在里面建立devices目录

主要是为了使用cat /proc/devices查看设备号

character devices如下

256号是vser,这样就相当于注册好了

使用register _chrdev_ region注册设备号的方式称为静态注册设备号,但是该方式有一个明显的缺点,就是如果两个驱动都使用了同样的设备号,那么后加载的驱动将会失败,因为设备号冲突了。为了解决这个问题,可以使用动态分配设备号的函数,其原型如下:

int alloc_chrdev_region(dev_t*dev, unsigned baseminor, unsigned count, const char*name) ;

其中,count和name形参同register_ chrdev_ region函数中相应的形参一致 。baseminor是动态分配的设备号的起始次设备号,而dev 则是分配得到的第一个设备号。 该函数成功则返回0,失败则返回负数。这样就避免了各个驱动使用相同的设备号而带来的冲突,

但是会存在另外-一个问题,那就是不能事先知道主次设备号,在使用mknod命令创建设备节点时,必须先查看/proc/devices文件才能确定主设备号(次设备号在代码中确定),也就是要求mknod命令要后于驱动加载执行,不过这个问题在新的Linux设备模型中已经得到了比较好的解决,设备节点会自动地创建和销毁,这在后面的章节会详细描述。

成功注册了设备号后,接下来应该构造并添加cdev结构对象

构造并添加cdev结构对象

#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>

#include <linux/fs.h>
#include <linux/cdev.h>

#define VSER_MAJOR	256
#define VSER_MINOR	0
#define VSER_DEV_CNT	1
#define VSER_DEV_NAME	"vser"

static struct cdev vsdev;

static struct file_operations vser_ops = {
	.owner = THIS_MODULE,
};

static int __init vser_init(void)
{
	int ret;
	dev_t dev;

	dev = MKDEV(VSER_MAJOR, VSER_MINOR);
	ret = register_chrdev_region(dev, VSER_DEV_CNT, VSER_DEV_NAME);
	if (ret)
		goto reg_err;

	cdev_init(&vsdev, &vser_ops);
	vsdev.owner = THIS_MODULE;

	ret = cdev_add(&vsdev, dev, VSER_DEV_CNT);
	if (ret)
		goto add_err;

	return 0;

add_err:
	unregister_chrdev_region(dev, VSER_DEV_CNT);
reg_err:
	return ret;
}

static void __exit vser_exit(void)
{
	
	dev_t dev;

	dev = MKDEV(VSER_MAJOR, VSER_MINOR);

	cdev_del(&vsdev);
	unregister_chrdev_region(dev, VSER_DEV_CNT);
}

module_init(vser_init);
module_exit(vser_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kevin Jiang <jiangxg@farsight.com.cn>");
MODULE_DESCRIPTION("A simple character device driver");
MODULE_ALIAS("virtual-serial");

代码static struct cdev vsdev;定义了一个struct cdev类型的全局变量vsdev。

下面

static struct file_operations vser_ops = {
	.owner = THIS_MODULE,
};

定义了一个struct file_operations类型的全局变量vser_ops

字符设备操作结构很关键

其中,vsdev表示一个具体的字符设备,而vser_ops是操作该设备的一些方法。

代码cdev_init(&vsdev, &vser_ops);调用cdev_init 初始化了vsdev中的部分成员。
另外一个最重要的操作就是将vsdev中的ops指针指向了vser_ ops,这样通过
设备号找到vsdev对象后,就能找到相关的操作方法集合,并调用其中的方法。

cdev_ init函数的原型如下,第一个参数是要初始化的cdev地址,第二个参数是设备操作方法集合的结构地址。

void cdev_init(struct cdev*cdev,const struct file_operations*fops) ;

继续代码

static struct file_operations vser_ops = {
	.owner = THIS_MODULE,
};

static int __init vser_init(void)
{
	··············
	vsdev.owner = THIS_MODULE;

将owner成员赋值为THIS_MODULE,owner是一个指向struct module类型变量的指针。
THIS_MODULE是包含驱动的模块中的struct module类型对象的地址类似于c++中的this指针
这样就能通过vsdev或者vser_fops找到对应的模块

在对前面两个对象进行访问时都要调用类似于try_module_get的函数增加模块的引用计数,因为在这两个对象使用的过程中,模块是不能被卸载的,模块被卸载的前提条件是引用计数为0。

cdev对象初始化以后,就应该添加到内核中的cdev_ map散列表中,调用的函数是cdev_ add,其函数原型如下
int cdev add(struct cdev *p, dev_ t dev, unsigned count) ;

cdev_ add 函数的主要工作是将主设备号通过对255取余,将余数作为cdev_ map数组的下标索引,然后构造一个probe对象,并让data指向要添加的cdev结构地址,然后加入到链表当中

该函数的最后一个参数count指定了被添加的cdev可以管理多少个设备。这里需要特别注意的是,参数p只指向一个cdev对象,但该对象可以同时管理多个设备,由count 的值来决定具体有多少个设备,那么cdev和设备就不是一 一对应的关系。

这样,对于一个驱动支持多个设备的情况,我们可以采用两种方法来实现,

  1. 第一种方法是为每一个设备分配一个cdev对象,每次调用cdev_add 添加一个cdev对象,直到多个cdev对象全部被添加到内核中
  2. 第二种方法是只构造一个cdev对象,但在调用cdev. add 时,指定添加的
    cdev可以管理多个设备。

这两种方法我们在后面的例子中都会看到。以上是简化的讨论,
实际的实现要复杂一些,如果要详细了解,请参考cdev_ add 的内核源码。

在初始化函数中添加了cdev对象,那么在清除函数中自然就应该删除该cdev对象,代码cdev_del(&vsdev);演示了这一操作, 实现的函数是cdev_del, 其函数原型如下。

void edev_ del (struct cdev *p);

该函数的作用就是根据cdev找到散列表中的probe,并进行删除。

在上面的例子中,cdev是静态的,我们也可以动态分配,对应的函数是cdev_alloc

struct cdev *cdev_ alloc (void);

该函数成功则返回动态分配的cdev 对象地址,失败则返回NULL。

下面试试编译加载

从上面的操作可以看到,在未加载驱动之前,使用cat命令读取/dev/vser0设备,错误信息是设备找不到,这是因为找不到和设备号对应的cdev对象。

在加载驱动后,cat命令的错误信息变成了参数无效,说明驱动工作了,只是还未实现具体的设备操作的方法。

相关文章