上一章节我们了解到什么是内核模块,模块的加载卸载详细过程以及内核模块的使用等内容。本章,我们将学习驱动相关的概念,理解字符设备驱动程序的基本框架,并从源码上分析字符设备驱动实现和管理。主要内容有如下五点:
linux 是文件型系统,所有硬件都会在对应的目录(/dev) 下面用相应的文件表示。在windows 系统中,设备大家很好理解,像硬盘,磁盘指的是实实在在硬件。而在文件系统的linux 下面,都有对于文件与这些设备关联的,访问这些文件就可以访问实际硬件。像访问文件那样去操作硬件设备,一切都会简单很多,不需要再调用以前com,prt 等接口了。直接读文件,写文件就可以向设备发送、接收数据。按照读写存储数据方式,我们可以把设备分为以下几种:字符设备、块设备和网络设备。
字符设备: 指应用程序按字节/字符来读写数据的设备。这些设备节点通常为传真、虚拟终端和串口调制解调器、键盘之类设备提供流通信服务,它通常不支持随机存取数据。字符设备在实现时,大多不使用缓存器。系统直接从设备读取/写入每一个字符。例如,键盘这种设备提供的就是一个数据流,当你敲入“cnblogs”这个字符串时,键盘驱动程序会按照和输入完全相同的顺序返回这个由七个字符组成的数据流。它们是顺序的,先返回c,最后是s。
块设备: 通常支持随机存取和寻址,并使用缓存器。操作系统为输入输出分配了缓存以存储一块数据。当程序向设备发送了读取或者写入数据的请求时,系统把数据中的每一个字符存储在适当的缓存中。当缓存被填满时,会采取适当的操作(把数据传走),而后系统清空缓存。它与字符设备不同之处就是,是否支持随机存储。字符型是流形式,逐一存储。典型的块设备有硬盘、SD卡、闪存等,应用程序可以寻址磁盘上的任何位置,并由此读取数据。此外,数据的读写只能以块的倍数进行。
块设备: 通常支持随机存取和寻址,并使用缓存器。操作系统为输入输出分配了缓存以存储一块数据。当程序向设备发送了读取或者写入数据的请求时,系统把数据中的每一个字符存储在适当的缓存中。当缓存被填满时,会采取适当的操作(把数据传走),而后系统清空缓存。它与字符设备不同之处就是,是否支持随机存储。字符型是流形式,逐一存储。典型的块设备有硬盘、SD卡、闪存等,应用程序可以寻址磁盘上的任何位置,并由此读取数据。此外,数据的读写只能以块的倍数进行。
网络设备: 是一种特殊设备,它并不存在于/dev 下面,主要用于网络数据的收发。
Linux 内核中处处体现面向对象的设计思想,为了统一形形色色的设备,Linux 系统将设备分别抽象为struct cdev, struct block_device,struct net_devce 三个对象,具体的设备都可以包含着三种对象从而继承和三种对象属性和操作,并通过各自的对象添加到相应的驱动模型中,从而进行统一的管理和操作
字符设备驱动程序适合于大多数简单的硬件设备,而且比起块设备或网络驱动更加容易理解,因此我们选择从字符设备开始,从最初的模仿,到慢慢熟悉,最终成长为驱动界的高手。
Linux 内核中将字符设备抽象成一个具体的数据结构(struct cdev), 我们可以理解为字符设备对象,cdev 记录了字符设备的相关信息(设备号、内核对象),字符设备的打开、读写、关闭等操作接口(file_operations),在我们想要添加一个字符设备时,就是将这个对象注册到内核中,通过创建一个文件(设备节点)绑定对象的cdev,当我们对这个文件进行读写操作时,就可以通过虚拟文件系统,在内核中找到这个对象及其操作接口,从而控制设备。
C 语言中没有面向对象语言的继承的语法,但是我们可以通过结构体的包含来实现继承,这种抽象提取了设备的共性,为上层提供了统一接口,使得管理和操作设备变得很容易。
在硬件层,我们可以通过查看硬件的原理图、芯片的数据手册,确定底层需要配置的寄存器,这类似于裸机开发。将对底层寄存器的配置,读写操作放在文件操作接口里面,也就是实现file_operations 结构体。
其次在驱动层,我们将文件操作接口注册到内核,内核通过内部散列表来登记记录主次设备号。
在文件系统层,新建一个文件绑定该文件操作接口,应用程序通过操作指定文件的文件操作接口来设置底层寄存器
实际上,在Linux 上写驱动程序,都是做一些“填空题”。因为Linux 给我们提供了一个基本的框架,我们只需要按照这个框架来写驱动,内核就能很好的接收并且按我们所要求的那样工作。有句成语工欲善其事, 必先利其器,在理解这个框架之前我们得花点时间来学习字符设备驱动相关概念及数据结构。
在linux 中,我们使用设备编号来表示设备,主设备号区分设备类别,次设备号标识具体的设备。cdev 结构体被内核用来记录设备号,而在使用设备时,我们通常会打开设备节点,通过设备节点的inode 结构体、file 结构体最终找到file_operations 结构体,并从file_operations 结构体中得到操作设备的具体方法。
对于字符的访问是通过文件系统的名称进行的,这些名称被称为特殊文件、设备文件,或者简单称为文件系统树的节点,Linux 根目录下有/dev 这个文件夹,专门用来存放设备中的驱动程序,我们可以使用ls -l 以列表的形式列出系统中的所有设备。其中,每一行表示一个设备,每一行的第一个字符表示设备的类型。
如下图:’c’用来标识字符设备,’b’用来标识块设备。如autofs 是一个字符设备c, 它的主设备号是10,次设备号是235;loop0 是一个块设备,它的主设备号是7,次设备号为0,同时可以看到loop0-loop3 共用一个主设备号,次设备号由0 开始递增。
一般来说,主设备号指向设备的驱动程序,次设备号指向某个具体的设备。如上图,I2C-0,I2C-1属于不同设备但是共用一套驱动程序
在内核中,dev_t 用来表示设备编号,dev_t 是一个32 位的数,其中,高12 位表示主设备号,低20 位表示次设备号。也就是理论上主设备号取值范围:0-212,次设备号0-220。实际上在内核源码中__register_chrdev_region(⋯) 函数中,major 被限定在0-CHRDEV_MAJOR_MAX,CHRDEV_MAJOR_MAX 是一个宏,值是512。在kdev_t 中,设备编号通过移位操作最终得到主/次设备号码,同样主/次设备号也可以通过位运算变成dev_t 类型的设备编号,具体实现参看上面代码MAJOR(dev)、MINOR(dev) 和MKDEV(ma,mi)。
列表1: dev_t 定义(内核源码/include/types.h)
typedef u32 __kernel_dev_t;typedef __kernel_dev_t dev_t;
列表2: 设备号相关宏(内核源码//include/linux/kdev_t.h)
#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))
内核通过一个散列表(哈希表) 来记录设备编号。哈希表由数组和链表组成,吸收数组查找快,链表增删效率高,容易拓展等优点。
以主设备号为cdev_map 编号,使用哈希函数f(major)=major%255 来计算组数下标(使用哈希函数是为了链表节点尽量平均分布在各个数组元素中,提高查询效率);主设备号冲突, 则以次设备号为比较值来排序链表节点。如下图所示,内核用struct cdev 结构体来描述一个字符设备,并通过struct kobj_map 类型的散列表cdev_map 来管理当前系统中的所有字符设备。
列表3: cdev 结构体(内核源码/include/linux/cdev.h)
struct cdev {struct kobject kobj;struct module *owner;const struct file_operations *ops;struct list_head list;dev_t dev;unsigned int count;
};
设备节点(设备文件):Linux 中设备节点是通过“mknod”命令来创建的。一个设备节点其实就是一个文件,Linux 中称为设备文件。有一点必要说明的是,在Linux 中,所有的设备访问都是通过文件的方式,一般的数据文件程序普通文件,设备节点称为设备文件。
设备节点被创建在/dev 下,是连接内核与用户层的枢纽,就是设备是接到对应哪种接口的哪个ID上。相当于硬盘的inode 一样的东西,记录了硬件设备的置和信息在Linux 中,所有设备都以文件的形式存放在/dev 目录下,都是通过文件的方式进行访问,设备节点是Linux 内核对设备的抽象,一个设备节点就是一个文件。应用程序通过一组标准化的调用执行访问设备,这些调用独立于任何特定的驱动程序。而驱动程序负责将这些标准调用映射到实际硬件的特有操作。
在驱动开发过程中,不可避免要涉及到三个重要的的内核数据结构分别包括文件操作方式(file_operations),文件描述结构体(struct file)以及inode 结构体,在我们开始阅读编写驱动程序的代码之前,有必要先了解这三个结构体。
file_operation 就是把系统调用和驱动程序关联起来的关键数据结构。这个结构的每一个成员都对应着一个系统调用。读取file_operation 中相应的函数指针,接着把控制权转交给函数指针指向的函数,从而完成了Linux 设备驱动程序的工作。
在系统内部,I/O 设备的存取操作通过特定的入口点来进行,而这组特定的入口点恰恰是由设备驱动程序提供的。通常这组设备驱动程序接口是由结构file_operations 结构体向系统说明的,它定义在ebf_buster_linux/include/linux/fs.h 中。传统上, 一个file_operation 结构或者其一个指针称为fops( 或者它的一些变体). 结构中的每个成员必须指向驱动中的函数, 这些函数实现一个特别的操作, 或者对于不支持的操作留置为NULL。当指定为NULL 指针时内核的确切的行为是每个函数不同的。
以下代码中只列出本章使用到的部分函数。
列表4: file_operations 结构体(内核源码/include/linux/fs.h)
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 *);long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);int (*open) (struct inode *, struct file *)int (*release) (struct inode *, struct file *);
};
上面,我们提到read 和write 函数时,需要使用copy_to_user 函数以及copy_from_user 函数来进行数据访问,写入/读取成功函数返回0,失败则会返回未被拷贝的字节数。
列表5: copy_to_user 和copy_from_user 函数(内核源码/include/asm-generic/uaccess.h)
static inline long copy_from_user(void *to, const void __user * from, unsigned long n)
2 static inline long copy_to_user(void __user *to, const void *from, unsigned long n)
函数参数和返回值如下:
参数
返回值
内核中用file 结构体来表示每个打开的文件,每打开一个文件,内核会创建一个结构体,并将对该文件上的操作函数传递给该结构体的成员变量f_op,当文件所有实例被关闭后,内核会释放这个结构体。如下代码中,只列出了我们本章需要了解的成员变量。
列表6: file 结构体(内核源码/include/fs.h)
struct file {
const struct file_operations *f_op;
/* needed for tty driver, and maybe others */
void *private_data;
};
VFS inode 包含文件访问权限、属主、组、大小、生成时间、访问时间、最后修改时间等信息。它是Linux 管理文件系统的最基本单位,也是文件系统连接任何子目录、文件的桥梁。内核使用inode结构体在内核内部表示一个文件。因此,它与表示一个已经打开的文件描述符的结构体(即file文件结构) 是不同的,我们可以使用多个file 文件结构表示同一个文件的多个文件描述符,但此时,所有的这些file 文件结构全部都必须只能指向一个inode 结构体。inode 结构体包含了一大堆文件相关的信息,但是就针对驱动代码来说,我们只要关心其中的两个域即可:
讲了很多次字符设备驱动程序框架,那到底什么是字符文件程序框架呢?我可以从下面的思维导图来解读内核源码。
我们创建一个字符设备的时候,首先要的到一个设备号,分配设备号的途径有静态分配和动态分配;拿到设备的唯一ID,我们需要实现file_operation 并保存到cdev 中,实现cdev 的初始化;然后我们需要将我们所做的工作告诉内核,使用cdev_add() 注册cdev;最后我们还需要创建设备节点,以便我们后面调用file_operation 接口。
注销设备时我们需释放内核中的cdev,归还申请的设备号,删除创建的设备节点。
在实现设备操作这一段,我们可以看看open 函数到底做了什么。
Linux 内核提供了两种方式来定义字符设备,如下所示。
列表7: 定义字符设备
//第一种方式
static struct cdev chrdev;
//第二种方式
struct cdev *cdev_alloc(void);
第一种方式,就是我们常见的变量定义;第二种方式,是内核提供的动态分配方式,调用该函数之后,会返回一个struct cdev 类型的指针,用于描述字符设备。
从内核中移除某个字符设备,则需要调用cdev_del 函数,如下所示。
列表8: cdev_del 函数
void cdev_del(struct cdev *p)
函数参数和返回值如下:
参数:
register_chrdev_region 函数
register_chrdev_region 函数用于静态地为一个字符设备申请一个或多个设备编号。函数原型如下所示。
列表9: register_chrdev_region 函数
int register_chrdev_region(dev_t from, unsigned count, const char *name)
函数参数和返回值如下:
参数:
返回值:返回0 表示申请成功,失败则返回错误码
alloc_chrdev_region 函数
使用register_chrdev_region 函数时,都需要去查阅内核源码的Documentation/devices.txt 文件,这就十分不方便。因此,内核又为我们提供了一种能够动态分配设备编号的方式:alloc_chrdev_region。
调用alloc_chrdev_region 函数,内核会自动分配给我们一个尚未使用的主设备号。我们可以通过命令“cat /proc/devices”查询内核分配的主设备号。
列表10: alloc_chrdev_region 函数原型
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)
函数参数和返回值如下:
参数:
unregister_chrdev_region 函数
当我们删除字符设备时候, 我们需要把分配的设备编号交还给内核, 对于使用register_chrdev_region 函数以及alloc_chrdev_region 函数分配得到的设备编号,可以使用unregister_chrdev_region 函数实现该功能。
列表11: unregister_chrdev_region 函数(内核源码/fs/char_dev.c)
void unregister_chrdev_region(dev_t from, unsigned count)
函数参数和返回值如下:
参数:
返回值: 无
register_chrdev 函数
除了上述的两种,内核还提供了register_chrdev 函数用于分配设备号。该函数是一个内联函数,它不仅支持静态申请设备号,也支持动态申请设备号,并将主设备号返回,函数原型如下所示。
列表12: register_chrdev 函数原型(内核源码/include/linux/fs.h 文件)
static inline int register_chrdev(unsigned int major, const char *name,
const struct file_operations *fops)
{return __register_chrdev(major, 0, 256, name, fops);
}
函数参数和返回值如下:
参数:
返回值: 主设备号
我们从以上代码中可以看到,使用register_chrdev 函数向内核申请设备号,同一类字符设备(即主设备号相同),会在内核中申请了256 个,通常情况下,我们不需要用到这么多个设备,这就造成了极大的资源浪费。
unregister_chrdev 函数
使用register 函数申请的设备号,则应该使用unregister_chrdev 函数进行注销。
列表13: unregister_chrdev 函数(内核源码/include/linux/fs.h)
static inline void unregister_chrdev(unsigned int major, const char *name)
{__unregister_chrdev(major, 0, 256, name);
}
函数参数和返回值如下:
参数:
major:指定需要释放的字符设备的主设备号,一般使用register_chrdev 函数的返回值作为实参。
name:执行需要释放的字符设备的名称。
返回值:无
前面我们已经提到过了,编写一个字符设备最重要的事情,就是要实现file_operations 这个结构体中的函数。实现之后,如何将该结构体与我们的字符设备结构体相关联呢?内核提供了cdev_init函数,来实现这个过程。
列表14: cdev_init 函数(内核源码/fs/char_dev.c)
void cdev_init(struct cdev *cdev, const struct file_operations *fops)
函数参数和返回值如下:
参数:
返回值: 无
cdev_add 函数用于向内核的cdev_map 散列表添加一个新的字符设备,如下所示。
列表15: cdev_add 函数(内核源码/fs/char_dev.c)
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
函数参数和返回值如下:
参数:
返回值: 错误码
从系统中删除cdev,cdev 设备将无法再打开,但任何已经打开的cdev 将保持不变,即使在cdev_del返回后,它们的FOP 仍然可以调用。
列表16: cdev_del 函数(内核源码/fs/char_dev.c)
void cdev_del(struct cdev *p)
函数参数和返回值如下:
参数:
返回值: 无
创建一个设备并将其注册到文件系统
列表17: device_create 函数(内核码/drivers/base/core.c)
struct device *device_create(struct class *class, struct device *parent,dev_t devt, void *drvdata, const char *fmt, ...)
函数参数和返回值如下:
参数:
返回值: 成功时返回struct device 结构体指针, 错误时返回ERR_PTR().
删除使用device_create 函数创建的设备
列表18: device_destroy 函数(内核源码/drivers/base/core.c)
void device_destroy(struct class *class, dev_t devt)
函数参数和返回值如下:
参数:
返回值: 无
除了使用代码创建设备节点,还可以使用mknod 命令创建设备节点。
用法:mknod 设备名设备类型主设备号次设备号
当类型为”p”时可不指定主设备号和次设备号,否则它们是必须指定的。如果主设备号和次设备号以”0x”或”0X”开头,它们会被视作十六进制数来解析;如果以”0”开头,则被视作八进制数;其余情况下被视作十进制数。可用的类型包括:
如:mkmod /dev/test c 2 0
创建一个字符设备/dev/test,其主设备号为2,次设备号为0。
当我们使用上述命令,创建了一个字符设备文件时,实际上就是创建了一个设备节点inode 结构体,并且将该设备的设备编号记录在成员i_rdev,将成员f_op 指针指向了def_chr_fops 结构体。这就是mknod 负责的工作内容,具体代码见如下。
列表19: mknod 调用关系(内核源码/mm/shmem.c)
static struct inode *shmem_get_inode(struct super_block *sb, const struct →inode *dir,
umode_t mode, dev_t dev, unsigned long flags)
{inode = new_inode(sb);if (inode) {......switch (mode & S_IFMT) {default:inode->i_op = &shmem_special_inode_operations;init_special_inode(inode, mode, dev);break;......}} elseshmem_free_inode(sb);return inode;
}
列表20: init_special_inode 函数(内核源码/fs/inode.c)
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 = &pipefifo_fops;else if (S_ISSOCK(mode)); /* leave it no_open_fops */elseprintk(KERN_DEBUG "init_special_inode: bogus i_mode (%o) for"" inode %s:%lu\n", mode, inode->i_sb->s_id,inode->i_ino);
}
inode 上的file_operation 并不是自己构造的file_operation,而是字符设备通用的def_chr_fops,那么自己构建的file_operation 等在应用程序调用open 函数之后,才会绑定在文件上。接下来我们再看open 函数到底做了什么。
使用设备之前我们通常都需要调用open 函数,这个函数一般用于设备专有数据的初始化,申请相关资源及进行设备的初始化等工作,对于简单的设备而言,open 函数可以不做具体的工作,你在应用层通过系统调用open 打开设备时,如果打开正常,就会得到该设备的文件描述符,之后,我们就可以通过该描述符对设备进行read 和write 等操作;open 函数到底做了些什么工作?下图中列出了open 函数执行的大致过程。
户空间使用open() 系统调用函数打开一个字符设备时(int fd = open(“dev/xxx”, O_RDWR)) 大致有以下过程:
我们使用的open 函数在内核中对应的是sys_open 函数,sys_open 函数又会调用do_sys_open 函数。在do_sys_open 函数中,首先调用函数get_unused_fd_flags 来获取一个未被使用的文件描述符fd,该文件描述符就是我们最终通过open 函数得到的值。紧接着,又调用了do_filp_open 函数,该函数通过调用函数get_empty_filp 得到一个新的file 结构体,之后的代码做了许多复杂的工作,如解析文件路径,查找该文件的文件节点inode 等,直接来到了函数do_dentry_open 函数,如下所示。
列表21: do_dentry_open 函数(位于ebf-busrerlinux/fs/open.c)
static int do_dentry_open(struct file *f,struct inode *inode,int (*open)(struct inode *, struct file *),const struct cred *cred)
{⋯⋯f->f_op = fops_get(inode->i_fop);⋯⋯if (!open)open = f->f_op->open;if (open) {error = open(inode, f);if (error)goto cleanup_all;}⋯⋯
}
第4 行:使用fops_get 函数来获取该文件节点inode 的成员变量i_fop,在上图中我们使用mknod创建字符设备文件时,将def_chr_fops 结构体赋值给了该设备文件inode 的i_fop 成员。
第7 行:到了这里,我们新建的file 结构体的成员f_op 就指向了def_chr_fops。
列表22: def_chr_fops 结构体(内核源码/fs/char_dev.c)
const struct file_operations def_chr_fops = {.open = chrdev_open,.llseek = noop_llseek,
};
最终,会执行def_chr_fops 中的open 函数,也就是chrdev_open 函数,可以理解为一个字符设备的通用初始化函数,根据字符设备的设备号,找到相应的字符设备,从而得到操作该设备的方法,代码实现如下。
列表23: chrdev_open 函数(内核源码/fs/char_dev.c)
static int chrdev_open(struct inode *inode, struct file *filp)
{const struct file_operations *fops;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 whilewe 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;fops = fops_get(p->ops);if (!fops)goto out_cdev_put;replace_fops(filp, fops);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;
}
在Linux 内核中,使用结构体cdev 来描述一个字符设备。
最后,调用上图的fd_install 函数,完成文件描述符和文件结构体file 的关联,之后我们使用对该文件描述符fd 调用read、write 函数,最终都会调用file 结构体对应的函数,实际上也就是调用cdev 结构体中ops 结构体内的相关函数。
总结一下整个过程,当我们使用open 函数,打开设备文件时,会根据该设备的文件的设备号找到相应的设备结构体,从而得到了操作该设备的方法。也就是说如果我们要添加一个新设备的话,我们需要提供一个设备号,一个设备结构体以及操作该设备的方法(file_operations 结构体)。
本节实验使用到STM32MP1 开发板。
本章的示例代码目录为:linux_driver/EmbedCharDev/CharDev/
结合前面所有的知识点,首先,字符设备驱动程序是以内核模块的形式存在的,、因此,使用内核模块的程序框架是毫无疑问的。紧接着,我们要向系统注册一个新的字符设备,需要这几样东西:字符设备结构体cdev,设备编号devno,以及最最最重要的操作方式结构体file_operations。
下面,我们开始编写我们自己的字符设备驱动程序。
既然我们的设备程序是以内核模块的方式存在的,那么就需要先写出一个基本的内核框架,见如下所示。
列表24: 内核模块加载函数(位于…/linux_driver/EmbedCharDev/CharDev/chrdev.c)
#define DEV_NAME "EmbedCharDev"
#define DEV_CNT (1)
#define BUFF_SIZE 128
//定义字符设备的设备号
static dev_t devno;
//定义字符设备结构体chr_dev
static struct cdev chr_dev;
static int __init chrdev_init(void)
{int ret = 0;printk("chrdev init\n");//第一步//采用动态分配的方式,获取设备编号,次设备号为0,//设备名称为EmbedCharDev,可通过命令cat /proc/devices 查看//DEV_CNT 为1,当前只申请一个设备编号ret = alloc_chrdev_region(&devno, 0, DEV_CNT, DEV_NAME);if (ret < 0) {printk("fail to alloc devno\n");goto alloc_err;
}
//第二步
//关联字符设备结构体cdev 与文件操作结构体file_operations
cdev_init(&chr_dev, &chr_dev_fops);
//第三步
//添加设备至cdev_map 散列表中
ret = cdev_add(&chr_dev, devno, DEV_CNT);
if (ret < 0) {printk("fail to add cdev\n");goto add_err;
}
return 0;add_err:
//添加设备失败时,需要注销设备号
unregister_chrdev_region(devno, DEV_CNT);
alloc_err:
return ret;
}
module_init(chrdev_init);
列表25: 内核模块卸载函数(位于…/linux_driver/EmbedCharDev/CharDev/chrdev.c)
static void __exit chrdev_exit(void)
{printk("chrdev exit\n");unregister_chrdev_region(devno, DEV_CNT);cdev_del(&chr_dev);
}
module_exit(chrdev_exit);
下面,我们开始实现字符设备最重要的部分:文件操作方式结构体file_operations,见如下所示。
列表26: file_operations 结构体(位于…/linux_driver/EmbedCharDev/CharDev/chrdev.c)
#define BUFF_SIZE 128
//数据缓冲区
static char vbuf[BUFF_SIZE];
static struct file_operations chr_dev_fops = {.owner = THIS_MODULE,.open = chr_dev_open,.release = chr_dev_release,.write = chr_dev_write,.read = chr_dev_read,
};
由于这个字符设备是一个虚拟的设备,与硬件并没有什么关联,因此,open 函数与release 直接返回0 即可,我们重点关注write 以及read 函数的实现。
列表27: chr_dev_open 函数与chr_dev_release 函数(位于…/linux_driver/EmbedCharDev/CharDev/chrdev.c)
static int chr_dev_open(struct inode *inode, struct file *filp)
{printk("\nopen\n");return 0;
}
static int chr_dev_release(struct inode *inode, struct file *filp)
{printk("\nrelease\n");return 0;
}
我们在open 函数与release 函数中打印相关的调试信息,如上方代码所示。
列表28: chr_dev_write 函数(位于…/linux_driver/EmbedCharDev/CharDev/chrdev.c)
static ssize_t chr_dev_write(struct file *filp, const char __user * buf, size_t count, loff_t *ppos)
{unsigned long p = *ppos;int ret;int tmp = count ;if (p > BUFF_SIZE)return 0;if (tmp > BUFF_SIZE - p)tmp = BUFF_SIZE - p;ret = copy_from_user(vbuf, buf, tmp);*ppos += tmp;return tmp;
}
当我们的应用程序调用write 函数,最终就调用我们的chr_dev_write 函数。
第3 行:变量p 记录了当前文件的读写位置,
第6-9 行:如果超过了数据缓冲区的大小(128 字节)的话,直接返回0。并且如果要读写的数据个数超过了数据缓冲区剩余的内容的话,则只读取剩余的内容。
第10-11 行:使用copy_from_user 从用户空间拷贝tmp 个字节的数据到数据缓冲区中,同时让文件的读写位置偏移同样的字节数。
列表29: chr_dev_read 函数(位于…/linux_driver/EmbedCharDev/CharDev/chrdev.c)
static ssize_t chr_dev_read(struct file *filp, char __user * buf, size_t count, loff_t *ppos)
{unsigned long p = *ppos;int ret;int tmp = count ;if (p >= BUFF_SIZE)return 0;if (tmp > BUFF_SIZE - p)tmp = BUFF_SIZE - p;ret = copy_to_user(buf, vbuf+p, tmp);*ppos +=tmp;return tmp;
}
同样的,当我们应用程序调用read 函数,则会执行chr_dev_read 函数的内容。该函数的实现与chr_dev_write 函数类似,区别在于,使用copy_to_user 从数据缓冲区拷贝tmp 个字节的数据到用户空间中。
下面,我们开始编写应用程序,来读写我们的字符设备,如下所示。
列表30: main.c 函数(位于…/linux_driver/EmbedCharDev/CharDev/main.c)
#include
#include
#include
#include
char *wbuf = "Hello World\n";
char rbuf[128];
int main(void)
{printf("EmbedCharDev test\n");//打开文件int fd = open("/dev/chrdev", O_RDWR);//写入数据write(fd, wbuf, strlen(wbuf));//写入完毕,关闭文件close(fd);//打开文件fd = open("/dev/chrdev", O_RDWR);//读取文件内容read(fd, rbuf, 128);//打印读取的内容printf("The content : %s", rbuf);//读取完毕,关闭文件close(fd);return 0;
}
获取内核模块源码,将配套代码/linux_driver/embedCharDev/charDev 解压到内核代码同级目录。
列表31: makefile(位于…/linux_driver/EmbedCharDev/CharDev/Makefile)
KERNEL_DIR=../../ebf_linux_kernel/build_image/buildARCH=arm
CROSS_COMPILE=arm-linux-gnueabihf-
export ARCH CROSS_COMPILEobj-m := chrdev.o
out = chrdev_testall:
$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules
$(CROSS_COMPILE)gcc -o $(out) main.c.PHONY:clean
clean:$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) cleanrm $(out)
Makefile 与此前相比,增加了编译测试程序部分。
make
编译成功后,实验目录下会生成两个名为”chrdev.ko”驱动模块文件和”chrdev_test”测试程序。
通过我们为大家编写好的Makefile,执行make,会生成chrdev.ko 文件和驱动测试程序chrdev_test,通过nfs 网络文件系统或者scp,将文件拷贝到开发板。执行以下命令:
sudo insmod chrdev.kocat /proc/devices
我们从/proc/devices 文件中,可以看到我们注册的字符设备EmbedCharDev 的主设备号为244。注意此设备号下面会用到,大家开发板根据实际情况调整
mknod /dev/chrdev c 244 0
以root 权限使用mknod 命令来创建一个新的设备chrdev,见下图。
以root 权限运行chrdev_test,测试程序,效果见下图。
实际上,我们也可以通过echo 或者cat 命令,来测试我们的设备驱动程序。
echo "EmbedCharDev test" > /dev/chrdev
如果没有获取su 的权限也可以这样使用
sudo sh -c "echo 'EmbedCharDev test' > /dev/chrdev"
cat /dev/chrdev
当我们不需要该内核模块的时候,我们可以执行以下命令:
rmmod chrdev.korm /dev/chrdev
使用命令rmmod,卸载内核模块,并且删除相应的设备文件。
在Linux 内核中,主设备号用于标识设备对应的驱动程序,告诉Linux 内核使用哪一个驱动程序为该设备服务。但是,次设备号表示了同类设备的各个设备。每个设备的功能都是不一样的。如何能够用一个驱动程序去控制各种设备呢?很明显,首先,我们可以根据次设备号,来区分各种设备;其次,就是前文提到过的file 结构体的私有数据成员private_data。我们可以通过该成员来做文章,不难想到为什么只有open 函数和close 函数的形参才有file 结构体,因为驱动程序第一个执行的是操作就是open,通过open 函数就可以控制我们想要驱动的底层硬件。
本节实验使用到STM32MP1 开发板上
下面介绍第一种实现方式,将我们的上一节程序改善一下,生成了两个设备,各自管理各自的数据缓冲区。
本章的示例代码目录为:linux_driver/EmbedCharDev/1_SupportMoreDev/
列表32: chrdev.c 修改部分(位于…/linux_driver/EmbedCharDev/1_SupportMoreDev/chrdev.c)
#define DEV_NAME "EmbedCharDev"
#define DEV_CNT (2) (1)
#define BUFF_SIZE 128
//定义字符设备的设备号
static dev_t devno;
//定义字符设备结构体chr_dev
static struct cdev chr_dev;
//数据缓冲区
static char vbuf1[BUFF_SIZE]; (2)static char vbuf2[BUFF_SIZE]; (3)
列表33: chr_dev_open 函数修改(位于…/linux_driver/EmbedCharDev/1_SupportMoreDev/chrdev.c)
static int chr_dev_open(struct inode *inode, struct file *filp)
{printk("\nopen\n ");switch (MINOR(inode->i_rdev)) {case 0 : {filp->private_data = vbuf1;break;}case 1 : {filp->private_data = vbuf2;break;}
}return 0;
}
我们知道inode 结构体中,对于设备文件的设备号会被保存到其成员i_rdev 中。
• 第4 行:在chr_dev_open 函数中,我们使用宏定义MINOR 来获取该设备文件的次设备号,使用private_data 指向各自的数据缓冲区。
• 第5-12 行:对于次设备号为0 的设备,负责管理vbuf1 的数据,对于次设备号为1 的设备,则用于管理vbuf2 的数据,这样就实现了同一个设备驱动,管理多个设备了。
接下来,我们的驱动只需要对private_data 进行读写即可。
列表34: chr_dev_write 函数(位于…/linux_driver/EmbedCharDev/1_SupportMoreDev/chrdev.c)
static ssize_t chr_dev_write(struct file *filp, const char __user * buf, size_t count, loff_t *ppos)
{unsigned long p = *ppos;int ret;char *vbuf = filp->private_data;int tmp = count ;if (p > BUFF_SIZE)return 0;if (tmp > BUFF_SIZE - p)tmp = BUFF_SIZE - p;ret = copy_from_user(vbuf, buf, tmp);*ppos += tmp;return tmp;
}
可以看到,我们的chr_dev_write 函数改动很小,只是增加了第5 行的代码,将原先vbuf 数据指向了private_data,这样的话,当我们往次设备号为0 的设备写数据时,就会往vbuf1 中写入数据。次设备号为1 的设备写数据,也是同样的道理。
列表35: chr_dev_read 函数(位于…/linux_driver/EmbedCharDev/1_SupportMoreDev/chrdev.c)
static ssize_t chr_dev_read(struct file *filp, char __user * buf, size_t count, loff_t *ppos)
{unsigned long p = *ppos;int ret;int tmp = count ;char *vbuf = filp->private_data;if (p >= BUFF_SIZE)return 0;if (tmp > BUFF_SIZE - p)tmp = BUFF_SIZE - p;ret = copy_to_user(buf, vbuf+p, tmp);*ppos +=tmp;return tmp;
}
同样的,chr_dev_read 函数也只是增加了第6 行的代码,将原先的vbuf 指向了private_data 成员。
我们回忆一下,我们前面讲到的文件节点inode 中的成员i_cdev,为了方便访问设备文件,在打开文件过程中,将对应的字符设备结构体cdev 保存到该变量中,那么我们也可以通过该变量来做文章。
本章的示例代码目录为:linux_driver/EmbedCharDev/2_SupportMoreDev/
列表36: 定义设备(位于…/linux_driver/EmbedCharDev/2_SupportMoreDev/chrdev.c)
/* 虚拟字符设备*/
struct chr_dev {
struct cdev dev;
char vbuf[BUFF_SIZE];
};
//字符设备1
static struct chr_dev vcdev1;
//字符设备2
static struct chr_dev vcdev2;
以上代码中定义了一个新的结构体struct chr_dev,它有两个结构体成员:字符设备结构体dev以及设备对应的数据缓冲区。使用新的结构体类型struct chr_dev 定义两个虚拟设备vcdev1 以及vcdev2。
列表37: chrdev_init 函数(位于…/linux_driver/EmbedCharDev/2_SupportMoreDev/chrdev.c)
static int __init chrdev_init(void)
{int ret;printk("4 chrdev init\n");ret = alloc_chrdev_region(&devno, 0, DEV_CNT, DEV_NAME);if (ret < 0)goto alloc_err;//关联第一个设备:vdev1cdev_init(&vcdev1.dev, &chr_dev_fops);ret = cdev_add(&vcdev1.dev, devno+0, 1);if (ret < 0) {printk("fail to add vcdev1 ");goto add_err1;}//关联第二个设备:vdev2cdev_init(&vcdev2.dev, &chr_dev_fops);ret = cdev_add(&vcdev2.dev, devno+1, 1);if (ret < 0) {printk("fail to add vcdev2 ");goto add_err2;}return 0;add_err2:cdev_del(&(vcdev1.dev));add_err1:unregister_chrdev_region(devno, DEV_CNT);alloc_err:return ret;
}
chrdev_init 函数的框架仍然没有什么变化。
列表38: chrdev_exit 函数(位于…/linux_driver/EmbedCharDev/2_SupportMoreDev/chrdev.c)
static void __exit chrdev_exit(void)
{printk("chrdev exit\n");unregister_chrdev_region(devno, DEV_CNT);cdev_del(&(vcdev1.dev));cdev_del(&(vcdev2.dev));
}
chrdev_exit 函数注销了申请到的设备号,使用cdev_del 移动两个虚拟设备。
列表39: chr_dev_open 以及chr_dev_release 函数(位于…/linux_driver/EmbedCharDev/2_SupportMoreDev/chrdev.c)
static int chr_dev_open(struct inode *inode, struct file *filp)
{printk("open\n");filp->private_data = container_of(inode->i_cdev, struct chr_dev, dev);return 0;
}
static int chr_dev_release(struct inode *inode, struct file *filp)
{printk("release\n");return 0;
}
我们知道inode 中的i_cdev 成员保存了对应字符设备结构体的地址,但是我们的虚拟设备是把cdev 封装起来的一个结构体,我们要如何能够得到虚拟设备的数据缓冲区呢?为此,Linux 提供了一个宏定义container_of,该宏可以根据结构体的某个成员的地址,来得到该结构体的地址。该宏需要三个参数,分别是代表结构体成员的真实地址,结构体的类型以及结构体成员的名字。在chr_dev_open 函数中,我们需要通过inode 的i_cdev 成员,来得到对应的虚拟设备结构体,并保存到文件指针filp 的私有数据成员中。假如,我们打开虚拟设备1,那么inode->i_cdev 便指向了vcdev1 的成员dev,利用container_of 宏,我们就可以得到vcdev1 结构体的地址,也就可以操作对应的数据缓冲区了。
列表40: chr_dev_write 函数(位于…/linux_driver/EmbedCharDev/2_SupportMoreDev/chrdev.c)
static ssize_t chr_dev_write(struct file *filp, const char __user * buf, size_t count, loff_t *ppos)
{unsigned long p = *ppos;int ret;//获取文件的私有数据struct chr_dev *dev = filp->private_data;char *vbuf = dev->vbuf;int tmp = count ;if (p > BUFF_SIZE)return 0;if (tmp > BUFF_SIZE - p)tmp = BUFF_SIZE - p;ret = copy_from_user(vbuf, buf, tmp);*ppos += tmp;return tmp;
对比第一种方法,实际上只是新增了第6 行代码,通过文件指针filp 的成员private_data 得到相应的虚拟设备。修改第7 行的代码,定义了char 类型的指针变量,指向对应设备的数据缓冲区。
列表41: chr_dev_read 函数(位于…/linux_driver/EmbedCharDev/2_SupportMoreDev/chrdev.c)
static ssize_t chr_dev_read(struct file *filp, char __user * buf, size_t count, loff_t *ppos)
{unsigned long p = *ppos;int ret;int tmp = count ;//获取文件的私有数据struct chr_dev *dev = filp->private_data;char *vbuf = dev->vbuf;if (p >= BUFF_SIZE)return 0;if (tmp > BUFF_SIZE - p)tmp = BUFF_SIZE - p;ret = copy_to_user(buf, vbuf+p, tmp);*ppos +=tmp;return tmp;
}
读函数,与写函数的改动部分基本一致,这里就只贴出代码,不进行讲解。
分别获取两个种方式的内核模块源码,将配套代码/linux_driver/CharDev 下1_SupportMoreDev 和2_SupportMoreDev 解压到内核代码同级目录。
至于Makefile 文件,与上一小节的相同,这里便不再罗列出来了。
在实验目录下输入如下命令来编译驱动模块:
make
编译成功后,实验目录下会分别生成驱动模块文件
通过NFS 或者SCP 将编译好的驱动模块拷贝到开发板中
下面我们使用cat 以及echo 命令,对我们的驱动程序进行测试。
insmod chrdev.komknod /dev/chrdev1 c 244 0mknod /dev/chrdev2 c 244 1
通过以上命令,加载了新的内核模块,同时创建了两个新的字符设备,分别是/dev/chrdev1和/dev/chrdev2,开始进行读写测试:
echo "hello world" > /dev/chrdev1
# 或者
sudo sh -c "echo 'hello world' > /dev/chrdev1"echo "123456" > /dev/chrdev2
# 或者
sudo sh -c "echo '123456' > /dev/chrdev2"cat /dev/chrdev1cat /dev/chrdev2
可以看到设备chrdev1 中保存了字符串“hello world”,而设备chrdev2 中保存了字符串“123456”。只需要几行代码,就可以实现一个驱动程序,控制多个设备。
总结一下,一个驱动支持多个设备的具体实现方式的重点在于如何运用file 的私有数据成员。第一种方法是通过将各自的数据缓冲区放到该成员中,在读写函数的时候,直接就可以对相应的数据缓冲区进行操作;第二种方法则是通过将我们的数据缓冲区和字符设备结构体封装到一起,由于文件结构体inode 的成员i_cdev 保存了对应字符设备结构体,使用container_of 宏便可以获得封装后的结构体的地址,进而得到相应的数据缓冲区。
到这里,字符设备驱动就已经讲解完毕了。如果你发现自己有好多不理解的地方,学完本章之后,建议重新梳理一下整个过程,有助于加深对整个字符设备驱动框架的理解。
参考资料:嵌入式Linux 驱动开发实战指南-基于STM32MP1 系列