前面内容:
1 Linux驱动—内核模块基本使用
2 Linux驱动—内核模块参数,依赖(进一步讨论)
linux根据驱动程序实现的模型框架将设备的驱动分为了三类:
以上驱动程序的分类是按照驱动的模型框架进行的,在现实生活中,有的设备很难被严格界定是字符设备还是块设备。甚至有的设备同时具有两类驱动,如MTD (存储技术设备,如闪存)。一个设备的驱动属于上述三类中的哪一类, 还要看具体的使用场合和最终的用途。
在正式学习字符设备驱动之前,我们来看看相关的基础知识。
在类UNIX系统中,有一个众所周知的说法,即“一切皆文件”,当然网络设备是一个例外。这就意味着设备最终也会体现为一个文件,应用程序要对设备进行访问,最终就会转化为对文件的访问,这样做的好处是统一了 对上层的接口。
设备文件通常位于/dev目录
下,使用下面的命令可以看到很多设备文件及其相关的信息。
ls -l dev
在上面列出的信息中,前面的字母“b”表示是块设备,“c” 表示是字符设备。
比如sda、sda1, sda2、 sda5就是块设备,实际上这些设备是笔者的Ubuntu主机上的一个硬盘和这个硬盘上的三个分区,其中sda
表示的是整个硬盘,而sdal1、sda2、 sda5分别是三个分区。tty0、 tty1 就是终端设备
,shell 程序使用这些设备来同用户进行交互。
从上面的打印信息来看,设备文件和普通文件有很多相似之处,都有相应的权限、所属的用户和组、修改时间和名字。但是设备文件会比普通文件多出两个数字,这两个数字分别是主设备号和次设备号
。这两个号是设备在内核中的身份或标志,是内核区分不同设备的唯一信息。通常内核用主设备号区别一类设备,次设备号用于区分同一类设备的不同个体或不同分区。而路径名则是用户层用于区别设备信息的。
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
,
块号
(看成对文件的索引,所以ext2文件是按照索引的方式找的);主次设备号
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结构
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 {
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
#include
#include
#include
#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 " );
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结构对象
#include
#include
#include
#include
#include
#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 " );
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和设备就不是一 一对应的关系。
这样,对于一个驱动支持多个设备的情况,我们可以采用两种方法来实现,
这两种方法我们在后面的例子中都会看到。以上是简化的讨论,
实际的实现要复杂一些,如果要详细了解,请参考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命令的错误信息变成了参数无效,说明驱动工作了,只是还未实现具体的设备操作的方法。