嵌入式Linux中字符型驱动程序的基本框架
在“嵌入式Linux中内核模块的基本框架”一文中,已经构建好了内核模块的基本框架结构,现在在该框架的基础上进一步扩展,就可以形成Linux下的字符型设备驱动基本框架,下面就详细进行讨论。
在Linux系统中,设备驱动共分为三种类型,即字符型、块型和网络型。字符型设备以字节为最小操作单位,最为常见,其数据的读写具有顺序性。块型主要用在需要大量数据传输的地方(如硬盘、SD卡等),其数据的读写具有随机性。网络型则是一种较为通用的形式。这里只讨论最基本的字符型设备驱动。
整个Linux系统被划分为两个空间,即用户空间(User space)和内核空间(Kernel space)。为了确保操作系统的健壮性,应用空间与内核空间不能直接通信,即应用程序的错误不会导致内核的崩溃。但如此一来,应用程序如何来控制设备呢?驱动程序属于内核态程序,它本身并不执行,只是提供了一系列接口,而应用程序间接调用这些接口,这样就能实现对设备的控制了。一般驱动程序只提供控制设备的方法,但具体使用什么方法,如何使用这些方法,则由应用程序来决定。
接下来给出一个字符型设备驱动的基本框架,它提供了open、write、release三个函数接口。当应用程序调用相应的函数时,会触发驱动程序打印出相应的信息。下面给出的是驱动程序的代码,把它保存为chr_drv.c文件。
#include <linux/module.h> #include <linux/init.h> #include <linux/kernel.h> #include <linux/cdev.h> #include <linux/fs.h> #include <linux/uaccess.h> struct cdev drv_cdev; //定义一个字符型结构体 dev_t dev_id; //定义一个设备号变量 struct class *drv_class; //定义一个类 struct device *drv_device; //定义一个设备 //实现open函数,为file_oprations结构体成员函数 static int drv_open(struct inode *inode, struct file *filp) { printk("The driver is opened!\n"); return 0; } //实现write函数,为file_oprations结构体成员函数 static ssize_t drv_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt) { unsigned char val; unsigned long n; n = copy_from_user(&val, buf, cnt); //从应用空间获取值 printk("The driver got %d from app!\n", val); return cnt; } //实现release函数,为file_oprations结构体函数 static int drv_release(struct inode *inode, struct file *filp) { printk("The driver is released!\n"); return 0; } //填充一个file_oprations类型的结构体,名为drv_fops,包含上述声明的成员函数 static struct file_operations drv_fops = { .owner = THIS_MODULE, .open = drv_open, //指定open函数成员 .write = drv_write, //指定write函数成员 .release = drv_release, //指定release函数成员 }; //初始化函数(即入口函数) static int __init drv_init(void) { if(alloc_chrdev_region(&dev_id, 0, 1, "chr_drv") < 0)//动态申请一个设备号并注册到内核 { printk("Couldn't alloc_chrdev_region!\n"); return -EFAULT; } drv_cdev.owner = THIS_MODULE; //邦定模块自身 cdev_init(&drv_cdev, &drv_fops); //绑定前面声明的file_oprations类型的结构体到字符设备 if(cdev_add(&drv_cdev, dev_id, 1) < 0) //填充上面申请到的主设备号到字符设备 { printk("Couldn't add chrdev!\n"); return -EFAULT; } drv_class = class_create(THIS_MODULE, "chr_drv"); //创建一个类 drv_device = device_create(drv_class, NULL, dev_id, NULL, "chr_drv"); //根据创建的类生成一个设备节点文件 printk("Drv initted!\n"); return 0; } //退出函数(即出口函数) static void __exit drv_exit(void) { cdev_del(&drv_cdev); //删除字符设备 unregister_chrdev_region(dev_id, 1); //释放主设备号 device_destroy(drv_class, dev_id); //销毁设备节点 class_destroy(drv_class); //销毁类 printk("Drv exited!\n"); } module_init(drv_init); module_exit(drv_exit); MODULE_LICENSE("GPL");
同样,驱动程序属于内核态,编译时需要目标平台的内核源码,同时要配套一个Makefile文件,其内容如下。
KERNEL_DIR=/opt/ebf_linux_kernel_mp157_depth1/build_image/build ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- export ARCH CROSS_COMPILE obj-m := chr_drv.o all: $(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules clean: $(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean
执行make命令进行编译,完成后找到一个名为chr_drv.ko的文件,它就是驱动程序的内核模块文件。接下来还需要编写一个应用程序来调用它,应用程序代码如下,把它保存为app.c文件。
#include <stdio.h> #include<stdlib.h> #include <fcntl.h> #include <string.h> #include <unistd.h> int main(int argc, char *argv[]) { int fd; unsigned char val; val = strtol(argv[1], NULL , 10); //把输入的值转成整型并赋值给val fd = open("/dev/chr_drv", O_RDWR); //打开设备节点 if( fd < 0 ) printf("Can`t open file!\n"); write(fd, &val, 1); //把值写入设备节点 printf("Waiting for 3 seconds......\n"); sleep(3); //延时3秒 close(fd); //关闭设备节点 return 0; }
把应用程序进行交叉编译,执行arm-linux-gnueabihf-gcc app.c -o app,成功后会生成app可执行文件。把它和前面生成的驱动模块文件chr_drv.ko一同拷贝到NFS共享目录下,在开发板的挂载目录找到驱动文件,并执行insmod chr_drv.ko,把它插入内核,会在终端上打印出一句“Drv initted!”。接着执行应用程序,输入./app 123并回车。会看到先打印出“The driver is opened!",紧接着打印出"The driver got 123 from app!",然后打印"Waiting for 3 seconds......",等待3秒钟,最后打印"The driver is released!"。实验完成后可移出驱动模块,执行rmmod chr_drv,驱动模块将被移出Linux内核,终端上会打印一句"Drv exited!"。整个执行过程如下图所示。
下面就来详细解释一下以上驱动模块代码的工作原理。
首先来看头文件,除了在”嵌入式Linux中内核模块的基本框架“中包含的三个头文件外,本例还需要包含以下三个头文件。
#include <linux/cdev.h>:包含cdev结构体的定义。
#include <linux/fs.h>:包含file_operations结构体的定义。
#include <linux/uaccess.h>:包含copy_from_user函数的定义。
接下来声明了几个变量,其中struct cdev drv_cdev声明了一个cdev字符结构体变量drv_cdev,unsigned int dev_id声明了一个主设备号存储变量dev_id,而struct class *drv_class以及struct device *drv_device则分别声明了一个类指针drv_class和一个设备指针drv_device。
由于Linux内核是通过C语言编写的,它并不具备面向对象的功能,但在内核态程序中又需要使用到类似面向对象的方式,所以大量使用了结构体和指针。Linux通过结构体来描述设备驱动的类型,其中,用struct cdev来描述字符型,用struct blok_device来描述块型,用struct net_device来描述网络型。它们分别表示了三种设备的抽象,也可以认为是三种“对象”。
在字符型设备驱动中,有四个非常重要的结构体,分别是struct cdev、struct file_oprations、struct inode和struct file。
struct cdev结构体记录字符设备的相关信息(如设备号dev_t、内核对象struct kobject等)以及字符设备的打开、读写、关闭等操作接口(struct file_operations)。在新创建一个字符设备时,就是把这样一个cdev对象注册到Linux内核中,并通过创建一个设备节点文件来绑定该对象。在应用程序对这个文件进行读写操作时,通过虚拟文件系统(VFS)的支持就可以在Linux内核中找到这个对象及其操作的接口,最终达到操控设备的目的。其实,在Linux系统中“一切皆文件”,每个硬件设备都是以文件的形式来呈现的。应用程序通过操作这类文件,就能间接地操控硬件设备了。硬件设备所对应的这类文件被称为设备节点文件,它是一种特殊的文件,除了文件名外,还包含有主次设备号等信息。设备节点文件统一放在Linux的“/dev”目录下。
在Linux系统中,使用设备号(包含主、次)来关联设备,并不使用设备名称来关联,所以设备号对于驱动来说非常重要。 内核通过一个散列表(哈希表)来记录设备号,使用struct cdev结构体来描述一个字符设备,并通过struct kobj_map类型的散列表cdev_map来管理当前系统中所有的字符设备。设备号是一个32的无符号数(在Linux中定为dev_t类型),其中,高12位用于表征设备的类型,称为主设备号,低20位用于表征设备的数量,称为次设备号。虽然理论上主设备号具有2的12次方个,但在实际的Linux系统中,主设备号被限定在了512个以内。在进行驱动设计时,所使用的主设备号不能与原Linux系统中已经被使用了的设备号相冲突,查看内核中已使用的设备号可执行“cat /proc/devices”命令进行。驱动程序提供给应用程序来调用,应用程序通过访问驱动程序的设备节点文件来实现与内核的信息传递。设备节点文件可通过命令“mknod /dev/xxx c 240 1”这样的方式来手动建立,其中xxx为设备节点文件名,不能和已有的同名,c表示字符型,240为指定的主设备号,不能冲突,1为次设备号,文件要创建在Linux的“/dev”目录下。设备节点文件也可以由于Linux系统动态创建,这在后面再进行讨论。
struct file_oprations结构体包含有字符设备的操作方式(操作接口)。该结构体的内部成员都是指针函数,每一个函数都规定了一个特定的操作方式。下面给出file_oprations结构体的全部内容(4.19内核版本)。
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 *); int (*iterate) (struct file *, struct dir_context *); int (*iterate_shared) (struct file *, struct dir_context *); __poll_t (*poll) (struct file *, struct poll_table_struct *); long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); long (*compat_ioctl) (struct file *, unsigned int, unsigned long); int (*mmap) (struct file *, struct vm_area_struct *); unsigned long mmap_supported_flags; int (*open) (struct inode *, struct file *); int (*flush) (struct file *, fl_owner_t id); int (*release) (struct inode *, struct file *); int (*fsync) (struct file *, loff_t, loff_t, int datasync); int (*fasync) (int, struct file *, int); int (*lock) (struct file *, int, struct file_lock *); ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int); unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long); int (*check_flags)(int); int (*flock) (struct file *, int, struct file_lock *); ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int); ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int); int (*setlease)(struct file *, long, struct file_lock **, void **); long (*fallocate)(struct file *file, int mode, loff_t offset, loff_t len); void (*show_fdinfo)(struct seq_file *m, struct file *f); #ifndef CONFIG_MMU unsigned (*mmap_capabilities)(struct file *); #endif ssize_t (*copy_file_range)(struct file *, loff_t, struct file *, loff_t, size_t, unsigned int); int (*clone_file_range)(struct file *, loff_t, struct file *, loff_t, u64); int (*dedupe_file_range)(struct file *, loff_t, struct file *, loff_t, u64); int (*fadvise)(struct file *, loff_t, loff_t, int); }
该结构体的内容可能会随着Linux内核版本的不同而稍有区别。它所列出的成员函数,在实际应用时,只需要挑选需要的函数来进行填充(具体实现)即可,并需要全部实现。如上前面的驱动代码中,就只挑选了4个成员来实现,如下所示。
static struct file_operations drv_fops = { .owner = THIS_MODULE, .open = drv_open, //指定open函数成员 .write = drv_write, //指定write函数成员 .release = drv_release, //指定release函数成员 };
未被选到的函数会被内核初始化为NULL。其中,第一个成员owner必须要有,它指向模块本身,目的是为了避免模块在使用时被移出内核而引发错误,其他成员按需要来选择。由于本例只实现打开文件(设备节点文件,后同)、写入文件和关闭文件(释放)三个功能,所以只选择了open、write、release三个成员,此外,成员的描述方法为:.成员名称=函数名称。以"."为开头,再加上成员名称,这是gcc编译器所支持的方式。后面的函数名称可自定义,但其整个函数的结构不能改变。也就是说,你是从Linux内核提供的file_oprations结构体中去挑选函数,而不是自己定义函数,所以函数必须是file_oprations结构体规定的形式。例如代码中的open函数,它在file_oprations结构体中的形式是:int (*open) (struct inode *, struct file *),所以实际实现的函数是:static int drv_open(struct inode *inode, struct file *filp) 。除了函数名称外,其他的结构(形参、返回值)都要一样。函数名称已经在上面的声明中以“.open = drv_open”的形式告知了内核,所以内核是知道的,其他的就不能改变了。此外,static修饰是自己加入的,原因前面已经说过了,为了限定函数的范围,这里并不影响。而在函数的形参中,还需要把具体的名称确定一下(file_oprations结构体中只规定了形参的类型,并没有规定具体的参数名称)。其他成员函数的实现也一样,可自行分析。另外需要注意一点,在代码的顺序上,应该先实现函数再声明file_oprations结构体,否则会出现函数未定义的错误。最后总结一下,驱动程序对硬件设备的操作方法(即动作),都被固定在了file_oprations结构体中,在实现时,只需要挑选需要的动作并填充它就可以了。当应用程序调用用户空间的open、write、close函数时,就会触发内核空间对应的函数open、write、release的执行(注意,release函数是应用程序在执行最后一个close函数时才触发执行的)。
struct inode结构体是文件的一个抽象,它是Linux管理文件系统的最基本单位,也是文件系统连接到任何子目录和文件的桥梁,内核使用inode结构体在其内部表示一个文件。其实从上面的file_oprations结构体成员函数中,可以看到open和release函数的形参里面就包含有这个struct inode结构体。在Linux内核的虚拟文件系统(VFS)中,一个dentry(目录项)通常包含一个指向inode的指针。inode是文件系统中的对象,例如常规文件、目录、FIFO(命名管道)等。inode可以存在于磁盘上(用于块设备文件系统)或内存中(用于伪文件系统)。当需要时,磁盘上的inode会被复制到内存中,并且对inode的更改会被写回磁盘。在inode结构体中,设备文件的设备号保存在成员i_rdev 中。当应用程序在用户空间使用open函数打开一个字符设备节点文件时,系统会在虚拟文件系统VFS中查找与字符设备对应的struct inode节点,然后遍历散列表cdev_map(哈希表),并根据inod节点中的cdev_t设备号找到cdev对象,从而确定绑定在cdev中的file_oprations结构体,以获得相应的操作接口。
struct file结构体表示每个打开的文件,每打开一个文件,内核都会创建一个该结构体,并将该文件的操作函数(file_oprations结构体)传递给该结构体的成员变量f_op,当文件的所有实例被关闭后,内核才会释放这个结构体。可以认为,struct file结构体是内核空间虚拟文件系统中的inod结构体映射到用户空间来的一个具体实现。可以有多个file文件结构表示同一个文件的多个文件描述符,但所有的这些file文件结构全部都必须只能指向同一个inode结构体。从上面的file_oprations结构体成员函数中,可以看到很多接口函数的形参里面都包含有这个struct file结构体。
前面提到的手动创建一个字符型设备节点的命令“mknod /dev/xxx c 240 1”,其实就是创建了一个设备节点的inode结构体,并且将该设备的设备号(主设备号240和次设备号1合并)记录在其成员i_rdev中,同时把其成员i_fop指针指向一个名为def_chr_fops的通用结构体。当应用程序在用户空间调用open函数之后,会创建一个struct file对象,并把该对象中的成员f_op指向刚才的def_chr_fops结构体,此时,struct cdev对象中的file_operations成员就被绑定到了struct file对象的f_op成员中,最后通过回调file->fops->open函数,就完成了打开文件(初始化)的工作。 需要注意的是,应用程序中必须先执行open函数来打开设备节点文件,这相当于初始化,只有在open成功之后,才“打通”了应用空间与内核空间的交流。初始化之后,接下来只需要在用户空间调用相关的操作函数,在内核空间就会同时调用相应函数去执行。明白了这一点,上面的驱动程序也就不难理解了。
在上面的应用程序中,使用了三个系统调用函数(API),分别是open、write和close。当应用程序调用open函数时,在内核空间的驱动程序中,同样就执行了drv_open函数(file_oprations成员函数,后同),同样,应用程序调用write时,驱动执行drv_write函数,应用程序调用close函数时,驱动执行drv_release函数。需要强调,在一般情况下,只有当应用程序调用最后一个close函数之后,驱动程序才会执行release函数(因为应用程序中可能多次打开文件,必须等最后一个文件关闭之后,内核才会去释放)。驱动程序的其他过程同“嵌入式Linux中内核模块的基本框架”一文中讨论的一样,即当系统执行insmod(或modprobe)命令时,进入宏module_init所指向的入口函数(本例中为drv_init函数),系统执行rmmod命令时,进入宏module_exit所指向的出口函数(本例中为drv_exit函数)。
在驱动程序的入口函数中,分别进行了设备号申请和注册、cdev结构体初始化及注册、新创建类并依此创建一个设备,最后打印出一句“Drv initted!”结束。 下面依次来讨论。
首先通过alloc_chrdev_region(&dev_id, 0, 1, "chr_drv")函数来向Linux内核动态申请一个主设备号,该主设备号由内核从255倒着向前查寻,直到一个没有用到的主设备号,就把它存入变量dev_id中。第二个参数0是指定的次设备号,第三个参数1是表示申请的数量为1,最后一个参数是一个字符串(设备名称),如果申请成功,它将会出现在文件/proc/devices中(可以cat一下看看)。动态申请设备号的优势在于不会与系统原有的设备号冲突。另外,如果想要静态规定设备号,可通过调用函数register_chrdev_region(dev_id, 1, "chr_drv")来进行,但在调用前要先给设备号赋值,执行dev_id=MKDEV(243, 0)。MKDEV是一个宏,它的作用是把主设备号和次设备号拼接成一个32位的整型数。
然后,执行一句drv_cdev.owner = THIS_MODUL以邦定模块自身,目的是为了防止该驱动模块在使用期间被移出内核。接着通过cdev_init(&drv_cdev, &drv_fops)函数来把cdev结构体中的file_oprations成员邦定一个已经定义好的该类型结构体drv_fops(见代码中的tatic struct file_operations drv_fops部分)。这就意味着把drv_fops中的open、write和release三个接口告诉了cdev结构体。最后,通过cdev_add(&drv_cdev, dev_id, 1)函数把cdev结构体中的设备号与刚才动态申请到的设备号进行邦定,并把该cdev结构体注册进内核。上述步骤如果成功了,那就意味着内核“知道”了有一个名为chr_drv的字符型设备,其主设备号为243(假设系统动态分配给了243),次设备号为0,它能够进行open、write、release等操作。
最后,还需要动态创建一个设备节点文件,为用户空间提供操作接口(注:前面讨论过的是使用命令“mknod /dev/xxx c 240 1”手动创建设备节点文件,这里是自动动态创建)。创建设备节点先要为它创建一个类,通用调用函数class_create(THIS_MODULE, "chr_drv")来实现。成功后会创建一个名为chr_drv的类(可在/sys/class目录下看到),然后再通过调用device_create(drv_class, NULL, dev_id, NULL, "chr_drv")函数来创建一个名为chr_drv的设备节点文件。在文件创建过程中引用了刚才创建的类,并指定了前面动态申请到的设备号。创建成功后,会在/dev目录看到chr_drv文件,这就是驱动模块提供给用户空间的文件接口。
在驱动程序的出口函数中,分别对前面注册过的内容进行释放(删除)。先调用cdev_del(&drv_cdev函数来删除字符设备,再调用unregister_chrdev_region(dev_id, 1)函数来释放设备号,然后调用device_destroy(drv_class, dev_id)函数来销毁设备节点,再调用class_destroy(drv_class)函数来销毁类,最后打印一句"Drv exited!"结束。要注意释放的顺序,与申请时刚好倒过来。
本例只用来说明一个驱动程序的基本框架,因此没有其他多余的操作内容。在drv_open函数被执行时,只打印了一句“The driver is opened!”,在drv_write函数被执行时,根据应用程序传来的值,打印一句“The driver got %d from app!”(%d为应用程序传来的具体值),在drv_release被执行时,打印了一句“The driver is released!”。这里需要说明的是,在drv_write函数中,调用了一个名为copy_from_user的函数,与它配套的还有一个copy_to_user函数。他们用于在内核空间与用户空间交换变量值。下面就把上面驱动代码中的drv_write部分单独拿出来看一下,如下。
static ssize_t drv_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt) { unsigned char val; unsigned long n; n = copy_from_user(&val, buf, cnt); //从应用空间获取值 printk("The driver got %d from app!\r\n", val); return 0; }
观察上面的copy_from_user(&val, buf, cnt)函数,它的第一个参数是变量val的地址,意思是把从用户空间传过来的数值写入到该变量中。第二参数buf是drv_write函数中的第二个参数,它前面有__user的修饰,说明是用户空间的变量。也就是说,变量的值是从buf中拷贝到val中的。第三个参数cnt也是drv_write函数中的第三个参数,它表明变量的长度(即有几个字节)。再看一下应用程序app.c中调用write函数的部分,为一句write(fd, &val, 1),它的第一个参数fd是打开设备节点文件的返回值(文件描述符),即要写入的对象。第二个参数是变量val的地址(用户空间的变量),用于存放要写入fd的数据。第三个参数为1,表示写入变量的数据长度为一个字节。两个write函数配套来看就明白了,应用程序先把val赋一个值(假设为1),然后把1作为一个字节(8位)写入到已打开的设备节点fd中。驱动则从buf中获取一个字节的数据,其内容为1,然后把1拷贝到变量val中去。这里要注意,无论是用户空间的val还是内核空间的val,都声明成unsigned char型,即一个字节的容量,所以写入和获取时的长度都为1。从drv_write函数的第一个参数可以看到,它是struct file结构体,表示一个打开的文件。这里之所以能实现变量的传递,其根本上是依赖于这个file结构体进行的。
最后来看应用程序app.c就简单多了。首先从参数argv[1](即应用程序的第一个参数)中获取一个数值,并转换成整型存入变量val中。 然后调用了用户空间的open函数,打开驱动程序创建好的、位于/dev目录下的设备节点文件chr_drv,然后调用write函数把刚才val中的值写入已打开的设备节点,随后打印出一句“Waiting for 3 seconds......”,然后调用3秒的延时,最后调用close函数结束整个过程。
执行应用程序“./app 123”,先调用open函数,驱动程序打印“The driver is opened!”,随后调用write函数,驱动程序打印“The driver got 123 from app!”,然后进入3秒延时,延时结束后,调用close函数,驱动程序打印“The driver is released!”。整个过程非常明了。
在驱动模块chr_drv.ko插入内核后,可查看一下与之相关的一些信息,如下图所示。
上图中,第一个为插入的模块信息,第二个为生成的设备节点文件,第三个为新建的类,第四个为内核动态分配的主设备号和模块名称。
最后说明一下,在进行设备注册时,有人可能会使用register_chrdev函数,而不用上面提到的函数。实际上register_chrdev函数只是做了进一步的封装,查看它的原型就知道,它实际上包含了上面讨论过的函数。虽然使用register_chrdev函数表面看起来比较精简,但实际上它对每一个主设备号都分配了255个次设备号,造成了很大的资源浪费,所以并不建议使用。