上一章节我们了解到什么是内核模块,模块的加载卸载详细过程以及内核模块的使用等内容。 本章,我们将学习驱动相关的概念,理解字符设备驱动程序的基本框架,并从源码上分析字符设备驱动实现和管理。 主要内容有如下五点:
-
Linux设备分类;
-
字符设备的抽象,字符设备设计思路;
-
字符设备相关的概念以及数据结构,了解设备号等基本概念以及file_operations、file、inode相关数据结构;
-
字符设备驱动程序框架,例如内核是如何管理设备号的;系统关联、调用file_operation接口,open函数所涉及的知识等等。
-
设备驱动程序实验。
1. Linux设备分类
linux是文件型系统,所有硬件都会在对应的目录(/dev)下面用相应的文件表示。 在windows系统中,设备大家很好理解,像硬盘,磁盘指的是实实在在硬件。 而在文件系统的linux下面,都有对于文件与这些设备关联的,访问这些文件就可以访问实际硬件。 像访问文件那样去操作硬件设备,一切都会简单很多,不需要再调用以前com,prt等接口了。 直接读文件,写文件就可以向设备发送、接收数据。 按照读写存储数据方式,我们可以把设备分为以下几种:字符设备、块设备和网络设备。
字符设备:指应用程序按字节/字符来读写数据的设备。 这些设备节点通常为传真、虚拟终端和串口调制解调器、键盘之类设备提供流通信服务, 它通常不支持随机存取数据。字符设备在实现时,大多不使用缓存器。系统直接从设备读取/写入每一个字符。 例如,键盘这种设备提供的就是一个数据流,当你敲入“cnblogs”这个字 符串时, 键盘驱动程序会按照和输入完全相同的顺序返回这个由七个字符组成的数据流。它们是顺序的,先返回c,最后是s。
块设备:通常支持随机存取和寻址,并使用缓存器。 操作系统为输入输出分配了缓存以存储一块数据。当程序向设备发送了读取或者写入数据的请求时, 系统把数据中的每一个字符存储在适当的缓存中。当缓存被填满时,会采取适当的操作(把数据传走), 而后系统清空缓存。它与字符设备不同之处就是,是否支持随机存储。字符型是流形式,逐一存储。 典型的块设备有硬盘、SD卡、闪存等,应用程序可以寻址磁盘上的任何位置,并由此读取数据。 此外,数据的读写只能以块的倍数进行。
网络设备:是一种特殊设备,它并不存在于/dev下面,主要用于网络数据的收发。
Linux内核中处处体现面向对象的设计思想,为了统一形形色色的设备,Linux系统将设备分别抽象为struct cdev, struct block_device,struct net_devce三个对象,具体的设备都可以包含着三种对象从而继承和三种对象属性和操作, 并通过各自的对象添加到相应的驱动模型中,从而进行统一的管理和操作
字符设备驱动程序适合于大多数简单的硬件设备,而且比起块设备或网络驱动更加容易理解, 因此我们选择从字符设备开始,从最初的模仿,到慢慢熟悉,最终成长为驱动界的高手。
2. 字符设备抽象
Linux内核中将字符设备抽象成一个具体的数据结构(struct cdev),我们可以理解为字符设备对象, cdev记录了字符设备的相关信息(设备号、内核对象),字符设备的打开、读写、关闭等操作接口(file_operations), 在我们想要添加一个字符设备时,就是将这个对象注册到内核中,通过创建一个文件(设备节点)绑定对象的cdev, 当我们对这个文件进行读写操作时,就可以通过虚拟文件系统,在内核中找到这个对象及其操作接口,从而控制设备。
C语言中没有面向对象语言的继承的语法,但是我们可以通过结构体的包含来实现继承,这种抽象提取了设备的共性, 为上层提供了统一接口,使得管理和操作设备变得很容易。
在硬件层,我们可以通过查看硬件的原理图、芯片的数据手册,确定底层需要配置的寄存器,这类似于裸机开发。 将对底层寄存器的配置,读写操作放在文件操作接口里面,也就是实现file_operations结构体。
其次在驱动层,我们将文件操作接口注册到内核,内核通过内部散列表来登记记录主次设备号。
在文件系统层,新建一个文件绑定该文件操作接口,应用程序通过操作指定文件的文件操作接口来设置底层寄存器
实际上,在Linux上写驱动程序,都是做一些“填空题”。因为Linux给我们提供了一个基本的框架, 我们只需要按照这个框架来写驱动,内核就能很好的接收并且按我们所要求的那样工作。有句成语工欲善其事,必先利其器, 在理解这个框架之前我们得花点时间来学习字符设备驱动相关概念及数据结构。
3. 相关概念及数据结构
在linux中,我们使用设备编号来表示设备,主设备号区分设备类别,次设备号标识具体的设备。 cdev结构体被内核用来记录设备号,而在使用设备时,我们通常会打开设备节点,通过设备节点的inode结构体、 file结构体最终找到file_operations结构体,并从file_operations结构体中得到操作设备的具体方法。
3.1. 设备号
对于字符的访问是通过文件系统的名称进行的,这些名称被称为特殊文件、设备文件,或者简单称为文件系统树的节点, Linux根目录下有/dev这个文件夹,专门用来存放设备中的驱动程序,我们可以使用ls -l 以列表的形式列出系统中的所有设备。 其中,每一行表示一个设备,每一行的第一个字符表示设备的类型。
如下图:’c’用来标识字符设备,’b’用来标识块设备。如 autofs 是一个字符设备c, 它的主设备号是10,次设备号是235; loop0 是一个块设备,它的主设备号是7,次所备案为0,同时可以看到loop0-loop3共用一个主设备号,次设备号由0开始递增。
一般来说,主设备号指向设备的驱动程序,次设备号指向某个具体的设备。如上图,I2C-0,I2C-1属于不同设备但是共用一套驱动程序。
3.1.1. 内核中设备编号的含义
在内核中,dev_t用来表示设备编号,dev_t是一个32位的数,其中,高12位表示主设备号,低20位表示次设备号。 也就是理论上主设备号取值范围:0-2^12,次设备号0-2^20。 实际上在内核源码中__register_chrdev_region(…)函数中,major被限定在0-CHRDEV_MAJOR_MAX,CHRDEV_MAJOR_MAX是一个宏,值是512。 在dev_t中,设备编号通过移位操作最终得到主/次设备号码,同样主/次设备号也可以通过位运算变成dev_t类型的设备编号, 具体实现参看上面代码MAJOR(dev)、MINOR(dev)和MKDEV(ma,mi)。
dev_t定义 (内核源码/include/linux/types.h):
typedef u32 __kernel_dev_t;typedef __kernel_dev_t dev_t;
设备号相关宏 (内核源码/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))
-
第4-5行:内核还提供了另外两个宏定义MAJOR和MINOR,可以根据设备的设备号来获取设备的主设备号和次设备号。
-
第6行:宏定义MKDEV,用于将主设备号和次设备号合成一个设备号,主设备可以通过查阅内核源码的Documentation/admin-guide/devices.txt文件,而次设备号通常是从编号0开始。
3.1.2. cdev结构体
内核通过一个散列表(哈希表)来记录设备编号。 哈希表由数组和链表组成,吸收数组查找快,链表增删效率高,容易拓展等优点。
以主设备号为cdev_map编号,使用哈希函数f(major)=major%255来计算组数下标(使用哈希函数是为了链表节点尽量平均分布在各个数组元素中,提高查询效率); 主设备号冲突,则以次设备号为比较值来排序链表节点。 如下图所示,内核用struct cdev结构体来描述一个字符设备,并通过struct kobj_map类型的 散列表cdev_map来管理当前系统中的所有字符设备。
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;
};
-
struct kobject kobj: 内嵌的内核对象,通过它将设备统一加入到“Linux设备驱动模型”中管理(如对象的引用计数、电源管理、热插拔、生命周期、与用户通信等)。
-
struct module *owner: 字符设备驱动程序所在的内核模块对象的指针。
-
const struct file_operations *ops: 文件操作,是字符设备驱动中非常重要的数据结构,在应用程序通过文件系统(VFS)呼叫到设备设备驱动程序中实现的文件操作类函数过程中,ops起着桥梁纽带作用,VFS与文件系统及设备文件之间的接口是file_operations结构体成员函数,这个结构体包含了对文件进行打开、关闭、读写、控制等一系列成员函数。
-
struct list_head list: 用于将系统中的字符设备形成链表(这是个内核链表的一个链接因子,可以再内核很多结构体中看到这种结构的身影)。
-
dev_t dev: 字符设备的设备号,有主设备和次设备号构成。
-
unsigned int count: 属于同一主设备好的次设备号的个数,用于表示设备驱动程序控制的实际同类设备的数量。
3.2. 设备节点
设备节点(设备文件):Linux中设备节点是通过“mknod”命令来创建的。一个设备节点其实就是一个文件, Linux中称为设备文件。有一点必要说明的是,在Linux中,所有的设备访问都是通过文件的方式, 一般的数据文件程序普通文件,设备节点称为设备文件。
设备节点被创建在/dev下,是连接内核与用户层的枢纽,就是设备是接到对应哪种接口的哪个ID 上。 相当于硬盘的inode一样的东西,记录了硬件设备的位置和信息在Linux中,所有设备都以文件的形式存放在/dev目录下, 都是通过文件的方式进行访问,设备节点是Linux内核对设备的抽象,一个设备节点就是一个文件。 应用程序通过一组标准化的调用执行访问设备,这些调用独立于任何特定的驱动程序。而驱动程序负责将这些标准调用映射到实际硬件的特有操作。
3.3. 数据结构
在驱动开发过程中,不可避免要涉及到三个重要的内核数据结构分别包括文件操作方式(file_operations), 文件描述结构体(struct file)以及inode结构体,在我们开始阅读编写驱动程序的代码之前,有必要先了解这三个结构体。
3.3.1. file_operations结构体
file_operation就是把系统调用和驱动程序关联起来的关键数据结构。这个结构的每一个成员都对应着一个系统调用。 读取file_operation中相应的函数指针,接着把控制权转交给函数指针指向的函数,从而完成了Linux设备驱动程序的工作。
在系统内部,I/O设备的存取操作通过特定的入口点来进行,而这组特定的入口点恰恰是由设备驱动程序提供的。 通常这组设备驱动程序接口是由结构file_operations结构体向系统说明的,它定义在ebf_buster_linux/include/linux/fs.h中。 传统上, 一个file_operation结构或者其一个指针称为 fops( 或者它的一些变体). 结构中的每个成员必须指向驱动中的函数, 这些函数实现一个特别的操作, 或者对于不支持的操作留置为NULL。当指定为NULL指针时内核的确切的行为是每个函数不同的。
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 *);
};
-
llseek: 用于修改文件的当前读写位置,并返回偏移后的位置。参数file传入了对应的文件指针,我们可以看到以上代码中所有的函数都有该形参,通常用于读取文件的信息,如文件类型、读写权限;参数loff_t指定偏移量的大小;参数int是用于指定新位置指定成从文件的某个位置进行偏移,SEEK_SET表示从文件起始处开始偏移;SEEK_CUR表示从当前位置开始偏移;SEEK_END表示从文件结尾开始偏移。
-
read: 用于读取设备中的数据,并返回成功读取的字节数。该函数指针被设置为NULL时,会导致系统调用read函数报错,提示“非法参数”。该函数有三个参数:file类型指针变量,char__user*类型的数据缓冲区,__user用于修饰变量,表明该变量所在的地址空间是用户空间的。内核模块不能直接使用该数据,需要使用copy_to_user函数来进行操作。size_t类型变量指定读取的数据大小。
-
write: 用于向设备写入数据,并返回成功写入的字节数,write函数的参数用法与read函数类似,不过在访问__user修饰的数据缓冲区,需要使用copy_from_user函数。
-
unlocked_ioctl: 提供设备执行相关控制命令的实现方法,它对应于应用程序的fcntl函数以及ioctl函数。在 kernel 3.0 中已经完全删除了 struct file_operations 中的 ioctl 函数指针。
-
open: 设备驱动第一个被执行的函数,一般用于硬件的初始化。如果该成员被设置为NULL,则表示这个设备的打开操作永远成功。
-
release: 当file结构体被释放时,将会调用该函数。与open函数相反,该函数可以用于释放
上面,我们提到read和write函数时,需要使用copy_to_user函数以及copy_from_user函数来进行数据访问,写入/读取成 功函数返回0,失败则会返回未被拷贝的字节数。
copy_to_user和copy_from_user函数(内核源码/include/linux/uaccess.h):
static inline long copy_from_user(void *to, const void __user * from, unsigned long n)
static inline long copy_to_user(void __user *to, const void *from, unsigned long n)
函数参数和返回值如下:
参数
-
to:指定目标地址,也就是数据存放的地址,
-
from:指定源地址,也就是数据的来源。
-
n:指定写入/读取数据的字节数。
返回值
-
写入/读取数据的字节数
3.3.2. file结构体
内核中用file结构体来表示每个打开的文件,每打开一个文件,内核会创建一个结构体,并将对该文件上的操作函数传递给 该结构体的成员变量f_op,当文件所有实例被关闭后,内核会释放这个结构体。如下代码中,只列出了我们本章需要了解的成员变量。
file结构体(内核源码/include/fs.h):
struct file {
const struct file_operations *f_op;
/* needed for tty driver, and maybe others */
void *private_data;
};
-
f_op:存放与文件操作相关的一系列函数指针,如open、read、wirte等函数。
-
private_data:该指针变量只会用于设备驱动程序中,内核并不会对该成员进行操作。因此,在驱动程序中,通常用于指向描述设备的结构体。
3.3.3. inode结构体
VFS inode 包含文件访问权限、属主、组、大小、生成时间、访问时间、最后修改时间等信息。 它是Linux 管理文件系统的最基本单位,也是文件系统连接任何子目录、文件的桥梁。 内核使用inode结构体在内核内部表示一个文件。因此,它与表示一个已经打开的文件描述符的结构体(即file 文件结构)是不同的, 我们可以使用多个file文件结构表示同一个文件的多个文件描述符,但此时, 所有的这些file文件结构全部都必须只能指向一个inode结构体。 inode结构体包含了一大堆文件相关的信息,但是就针对驱动代码来说,我们只要关心其中的两个域即可:
-
dev_t i_rdev: 表示设备文件的结点,这个域实际上包含了设备号。
-
struct cdev *i_cdev: struct cdev是内核的一个内部结构,它是用来表示字符设备的,当inode结点指向一个字符设备文件时,此域为一个指向inode结构的指针。
4. 字符设备驱动程序框架
讲了很多次字符设备驱动程序框架,那到底什么是字符文件程序框架呢?我可以从下面的思维导图来解读内核源码。
我们创建一个字符设备的时候,首先要得到一个设备号,分配设备号的途径有静态分配和动态分配; 拿到设备的唯一ID,我们需要实现file_operation并保存到cdev中,实现cdev的初始化; 然后我们需要将我们所做的工作告诉内核,使用cdev_add()注册cdev; 最后我们还需要创建设备节点,以便我们后面调用file_operation接口。
注销设备时我们需释放内核中的cdev,归还申请的设备号,删除创建的设备节点。
在实现设备操作这一段,我们可以看看open函数到底做了什么。
4.1. 驱动初始化和注销
4.1.1. 设备号的申请和归还
Linux内核提供了两种方式来定义字符设备,如下所示。
//第一种方式
static struct cdev chrdev;
//第二种方式
struct cdev *cdev_alloc(void);
第一种方式,就是我们常见的变量定义;第二种方式,是内核提供的动态分配方式,调用该函数之 后,会返回一个struct cdev类型的指针,用于描述字符设备。
从内核中移除某个字符设备,则需要调用cdev_del函数,如下所示。
void cdev_del(struct cdev *p)
register_chrdev_region函数:
register_chrdev_region函数用于静态地为一个字符设备申请一个或多个设备编号。函数原型如下所示。
int register_chrdev_region(dev_t from, unsigned count, const char *name)
参数:
-
from:dev_t类型的变量,用于指定字符设备的起始设备号,如果要注册的设备号已经被其他的设备注册了,那么就会导致注册失败。
-
count:指定要申请的设备号个数,count的值不可以太大,否则会与下一个主设备号重叠。
-
name:用于指定该设备的名称,我们可以在/proc/devices中看到该设备。
返回值: 返回0表示申请成功,失败则返回错误码
alloc_chrdev_region函数:
使用register_chrdev_region函数时,都需要去查阅内核源码的Documentation/adminguide/devices.txt文件, 这就十分不方便。因此,内核又为我们提供了一种能够动态分配设备编号的方式:alloc_chrdev_region。
调用alloc_chrdev_region函数,内核会自动分配给我们一个尚未使用的主设备号。 我们可以通过命令“cat /proc/devices”查询内核分配的主设备号。
alloc_chrdev_region函数原型(内核源码/fs/char_dev.c):
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)
参数:
-
dev:指向dev_t类型数据的指针变量,用于存放分配到的设备编号的起始值;
-
baseminor:次设备号的起始值,通常情况下,设置为0;
-
count、name:同register_chrdev_region类型,用于指定需要分配的设备编号的个数以及设备的名称。
返回值: 返回0表示申请成功,失败则返回错误码
unregister_chrdev_region函数:
当我们删除字符设备时候,我们需要把分配的设备编号交还给内核,对于使用register_chrdev_region函数 以及alloc_chrdev_region函数分配得到的设备编号,可以使用unregister_chrdev_region函数实现该功能。
unregister_chrdev_region函数(内核源码/fs/char_dev.c):
void unregister_chrdev_region(dev_t from, unsigned count)
参数:
-
from:指定需要注销的字符设备的设备编号起始值,我们一般将定义的dev_t变量作为实参。
-
count:指定需要注销的字符设备编号的个数,该值应与申请函数的count值相等,通常采用宏定义进行管理。
返回值: 无
register_chrdev函数
除了上述的两种,内核还提供了register_chrdev函数用于分配设备号。该函数是一个内联函数,它不 仅支持静态申请设备号,也支持动态申请设备号,并将主设备号返回,函数原型如下所示。
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);
}
参数:
-
major:用于指定要申请的字符设备的主设备号,等价于register_chrdev_region函数,当设置为0时,内核会自动分配一个未使用的主设备号。
-
name:用于指定字符设备的名称
-
fops:用于操作该设备的函数接口指针。
返回值: 主设备号
我们从以上代码中可以看到,使用register_chrdev函数向内核申请设备号,同一类字符设备(即主设备号相同),会在内核中申请了256个,通常情况下,我们不需要用到这么多个设备,这就造成了极大的资源浪费。
unregister_chrdev函数
使用register函数申请的设备号,则应该使用unregister_chrdev函数进行注销。
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:执行需要释放的字符设备的名称。
返回值: 无
4.1.2. 初始化cdev
前面我们已经提到过了,编写一个字符设备最重要的事情,就是要实现file_operations这个结构体中的函数。 实现之后,如何将该结构体与我们的字符设备结构体相关联呢?内核提供了cdev_init函数,来实现这个过程。
cdev_init函数(内核源码/fs/char_dev.c):
void cdev_init(struct cdev *cdev, const struct file_operations *fops)
4.2. 设备注册和注销
cdev_add函数用于向内核的cdev_map散列表添加一个新的字符设备,如下所示。
cdev_add函数(内核源码/fs/char_dev.c):
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
函数参数和返回值如下:
参数:
-
p:struct cdev类型的指针,用于指定需要添加的字符设备;
-
dev:dev_t类型变量,用于指定设备的起始编号;
-
count:指定注册多少个设备。
返回值: 错误码
从系统中删除cdev,cdev设备将无法再打开,但任何已经打开的cdev将保持不变, 即使在cdev_del返回后,它们的FOP仍然可以调用。
cdev_del函数(内核源码/fs/char_dev.c):
void cdev_del(struct cdev *p)
参数:
-
p:struct cdev类型的指针,用于指定需要删除的字符设备;
返回值: 无
4.3. 设备节点的创建和销毁
创建一个设备并将其注册到文件系统,device_create函数(内核源码/drivers/base/core.c):
struct device *device_create(struct class *class, struct device *parent,dev_t devt, void *drvdata, const char *fmt, ...)
参数:
-
class:指向这个设备应该注册到的struct类的指针;
-
parent:指向此新设备的父结构设备(如果有)的指针;
-
devt:要添加的char设备的开发;
-
drvdata:要添加到设备进行回调的数据;
-
fmt:输入设备名称。
返回值: 成功时返回 struct device 结构体指针, 错误时返回ERR_PTR().
删除使用device_create函数创建的设备,device_destroy函数(内核源码/drivers/base/core.c):
void device_destroy(struct class *class, dev_t devt)
参数:
-
class:指向注册此设备的struct类的指针;
-
devt:以前注册的设备的开发;
返回值: 无
除了使用代码创建设备节点,还可以使用mknod命令创建设备节点。
用法:mknod 设备名 设备类型 主设备号 次设备号
当类型为”p”时可不指定主设备号和次设备号,否则它们是必须指定的。 如果主设备号和次设备号以”0x”或”0X”开头,它们会被视作十六进制数来解析;如果以”0”开头,则被视作八进制数; 其余情况下被视作十进制数。可用的类型包括:
-
b 创建(有缓冲的)区块特殊文件
-
c, u 创建(没有缓冲的)字符特殊文件
-
p 创建先进先出(FIFO)特殊文件
如:mkmod /dev/test c 2 0
创建一个字符设备/dev/test,其主设备号为2,次设备号为0。
当我们使用上述命令,创建了一个字符设备文件时,实际上就是创建了一个设备节点inode结构体, 并且将该设备的设备编号记录在成员i_rdev,将成员f_op指针指向了def_chr_fops结构体。 这就是mknod负责的工作内容,具体代码见如下。
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;
}
-
第10行:mknod命令最终执行init_special_inode函数
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);
}
-
第4-17行:判断文件的inode类型,如果是字符设备类型,则把def_chr_fops作为该文件的操作接口,并把设备号记录在inode->i_rdev。
inode上的file_operation并不是自己构造的file_operation,而是字符设备通用的def_chr_fops, 那么自己构建的file_operation等在应用程序调用open函数之后,才会绑定在文件上。接下来我们再看open函数到底做了什么。
5. open函数到底做了什么
使用设备之前我们通常都需要调用open函数,这个函数一般用于设备专有数据的初始化,申请相关资源及进行设备的初始化等工作, 对于简单的设备而言,open函数可以不做具体的工作,你在应用层通过系统调用open打开设备时, 如果打开正常,就会得到该设备的文件描述符,之后,我们就可以通过该描述符对设备进行read和write等操作; open函数到底做了些什么工作?下图中列出了open函数执行的大致过程。
用户空间使用open()系统调用函数打开一个字符设备时(int fd = open(“dev/xxx”, O_RDWR))大致有以下过程:
-
在虚拟文件系统VFS中的查找对应与字符设备对应 struct inode节点
-
遍历散列表cdev_map,根据inod节点中的 cdev_t设备号找到cdev对象
-
创建struct file对象(系统采用一个数组来管理一个进程中的多个被打开的设备,每个文件描述符作为数组下标标识了一个设备对象)
-
初始化struct file对象,将 struct file对象中的 file_operations成员指向 struct cdev对象中的 file_operations成员(file->fops = cdev->fops)
-
回调file->fops->open函数
我们使用的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函数,如下所示。
do_dentry_open函数(内核源码/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。
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函数,可以理解为一个字符设备的通用初始化函数,根据字符设备的设备号, 找到相应的字符设备,从而得到操作该设备的方法,代码实现如下。
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来描述一个字符设备。
-
第8行:inode->i_rdev中保存了字符设备的设备编号,
-
第13行:通过函数kobj_lookup函数便可以找到该设备文件cdev结构体的kobj成员,
-
第16行:再通过函数container_of便可以得到该字符设备对应的结构体cdev。函数container_of的作用就是通过一个结构变量中一个成员的地址找到这个结构体变量的首地址。同时,将cdev结构体记录到文件节点inode中的i_cdev,便于下次打开该文件。
-
第38-43行:函数chrdev_open最终将该文件结构体file的成员f_op替换成了cdev对应的ops成员,并执行ops结构体中的open函数。
最后,调用上图的fd_install函数,完成文件描述符和文件结构体file的关联,之后我们使用对该文件描述符fd调用read、write函数, 最终都会调用file结构体对应的函数,实际上也就是调用cdev结构体中ops结构体内的相关函数。
总结一下整个过程,当我们使用open函数,打开设备文件时,会根据该设备的文件的设备号找到相应的设备结构体, 从而得到了操作该设备的方法。也就是说如果我们要添加一个新设备的话,我们需要提供一个设备号, 一个设备结构体以及操作该设备的方法(file_operations结构体)。
6. 字符设备驱动程序实验
6.1. 硬件介绍
本节实验使用到 EBF6ULL-PRO 开发板。
6.2. 实验代码讲解
本章的示例代码目录为:linux_driver/EmbedCharDev/CharDev/
结合前面所有的知识点,首先,字符设备驱动程序是以内核模块的形式存在的,、 因此,使用内核模块的程序框架是毫无疑问的。 紧接着,我们要向系统注册一个新的字符设备,需要这几样东西:字符设备结构体cdev,设备编号devno, 以及最最最重要的操作方式结构体file_operations。
下面,我们开始编写我们自己的字符设备驱动程序。
6.2.1. 内核模块框架
既然我们的设备程序是以内核模块的方式存在的,那么就需要先写出一个基本的内核框架,见如下所示。
内核模块加载函数(位于../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_operationscdev_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);
-
第16行:使用动态分配(alloc_chrdev_region)的方式来获取设备号,指定设备的名称为“EmbedCharDev”,只申请一个设备号,并且次设备号为0。
-
第19行:这里使用C语言的goto语法,当获取失败时,直接返回对应的错误码。成功获取到设备号之后,我们还缺字符设备结构体以及文件的操作方式。
-
第23行:以上代码中使用定义变量的方式定义了一个字符设备结构体chr_dev,调用cdev_init函数将chr_dev结构体和文件操作结构体相关联,该结构体的具体实现下节见分晓。
-
第26行:最后我们只需要调用cdev_add函数将我们的字符设备添加到字符设备管理列表cdev_map即可。
-
第29行:此处也使用了goto语法,当添加设备失败的话,需要将申请的设备号注销掉,要养成一个好习惯,不要“占着茅坑不拉屎”。
模块的卸载函数就相对简单一下,只需要完成注销设备号,以及移除字符设备,如下所示。
内核模块卸载函数(位于../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);
6.2.2. 文件操作方式的实现
下面,我们开始实现字符设备最重要的部分:文件操作方式结构体file_operations,见如下所示。
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函数的实现。
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函数中打印相关的调试信息,如上方代码所示。
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个字节的数据到数据缓冲区中,同时让文件的读写位置偏移同样的字节数。
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个字节的数据到用户空间中。
6.2.3. 简单测试程序
下面,我们开始编写应用程序,来读写我们的字符设备,如下所示。
main.c函数(位于../linux_driver/EmbedCharDev/CharDev/main.c):
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
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;
}
-
第11行:以可读可写的方式打开我们创建的字符设备驱动
-
第12-15行:写入数据然后关闭
-
第17-21行:再次打开设备将数据读取出来
6.3. 实验准备
获取内核模块源码,将配套代码 linux_driver/EmbedCharDev/charDev 解压到内核代码同级目录。
6.3.1. makefile修改说明
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与此前相比,增加了编译测试程序部分。
-
第1行:该Makefile定义了变量KERNEL_DIR,来保存内核源码的目录。
-
第3-5行: 指定了工具链并导出环境变量
-
第7行:变量obj-m保存着需要编译成模块的目标文件名。
-
第8行:变量out保存着需要编译成测试程序的目标文件名。
-
第11行:’$(MAKE)modules’实际上是执行Linux顶层Makefile的伪目标modules。通过选项’-C’,可以让make工具跳转到源码目录下读取顶层Makefile。’M=$(CURDIR)’表明返回到当前目录,读取并执行当前目录的Makefile,开始编译内核模块。CURDIR是make的内嵌变量,自动设置为当前目录。
-
第12行:交叉编译工具链编译测试程序。
6.3.2. 编译命令说明
make
编译成功后,实验目录下会生成两个名为”chrdev.ko”驱动模块文件和” chrdev_test”测试程序。
6.4. 程序运行结果
编写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
可以看到 echo 的内容
当我们不需要该内核模块的时候,我们可以执行以下命令:
sudo rmmod chrdev.kosudo rm /dev/chrdev
使用命令rmmod,卸载内核模块,并且删除相应的设备文件。
7. 一个驱动支持多个设备
在Linux内核中,主设备号用于标识设备对应的驱动程序,告诉Linux内核使用哪一个驱动程序为该设备服务。但是, 次设备号表示了同类设备的各个设备。每个设备的功能都是不一样的。如何能够用一个驱动程序去控制各种设备呢? 很明显,首先,我们可以根据次设备号,来区分各种设备;其次,就是前文提到过的file结构体的私有数据成员private_data。 我们可以通过该成员来做文章,不难想到为什么只有open函数和close函数的形参才有file结构体, 因为驱动程序第一个执行的是操作就是open,通过open函数就可以控制我们想要驱动的底层硬件。
7.1. 硬件介绍
本节实验使用到 EBF6ULL-PRO 开发板上
7.2. 实验代码讲解
7.2.1. 实现方式一 管理各种的数据缓冲区
下面介绍第一种实现方式,将我们的上一节程序改善一下,生成了两个设备,各自管理各自的数据缓冲区。
本章的示例代码目录为:linux_driver/EmbedCharDev/1_SupportMoreDev/
#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)
-
第2行:修改了宏定义DEV_CNT,将原本的个数1改为2,这样的话,我们的驱动程序便可以管理两个设备。
-
第9-10行:处修改为两个数据缓冲区。
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进行读写即可。
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的设备写数据,也是同样的道理。
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成员。
7.2.2. 实现方式二 i_cdev变量
我们回忆一下,我们前面讲到的文件节点inode中的成员i_cdev,为了方便访问设备文件,在打开文件过程中, 将对应的字符设备结构体cdev保存到该变量中,那么我们也可以通过该变量来做文章。
本章的示例代码目录为:linux_driver/EmbedCharDev/2_SupportMoreDev/
定义设备(位于../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。
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函数的框架仍然没有什么变化。
-
第10、17行:在添加字符设备时,使用cdev_add依次添加。
-
第23-24行:当虚拟设备1添加失败时,直接返回的时候,只需要注销申请到的设备号即可。
-
第25-26行:若虚拟设备2添加失败,则需要把虚拟设备1移除,再将申请的设备号注销。
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移动两个虚拟设备。
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结构体的地址,也就可以操作对应的数据缓冲区了。
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类型的指针变量,指向对应设备的数据缓冲区。
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;
}
7.3. 实验准备
分别获取两个种方式的内核模块源码,将配套代码 linux_driver/CharDev下 1_SupportMoreDev和2_SupportMoreDev 解压到内核代码同级目录。
7.3.1. makefile说明
至于Makefile文件,与上一小节的相同,这里便不再罗列出来了。
7.3.2. 编译命令说明
在实验目录下输入如下命令来编译驱动模块:
make
编译成功后,实验目录下会分别生成驱动模块文件
7.4. 程序运行结果
通过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宏便可以获得封装后的结构体的地址,进而得到相应的数据缓冲区。
到这里,字符设备驱动就已经讲解完毕了。如果你发现自己有好多不理解的地方,学完本章之后,建议重新梳理一下整个过程, 有助于加深对整个字符设备驱动框架的理解。
8.字符设备驱动——点亮LED灯
通过字符设备章节的学习,我们已经了解了字符设备驱动程序的基本框架,主要是掌握如何申请及释放设备号、 添加以及注销设备,初始化、添加与删除cdev结构体,并通过cdev_init函数建立cdev和file_operations之间的关联, cdev结构体和file_operations结构体非常重要,希望大家着重掌握。
本小节我们将带领大家做一个激动人心的小实验–点亮led。 前面我们已经通过操作寄存器的方式点亮了LED,本节我们将带领大家进入点亮开发板RGB LED灯的世界, 学习一下如何在linux环境下驱动RGB LED灯。
首先我们需要明白直接操作寄存器和通过驱动程序点亮LED有什么区别。
8.1. 设备驱动的作用与本质
直接操作寄存器点亮LED和通过驱动程序点亮LED最本质的区别就是有无使用操作系统。 有操作系统的存在则大大降低了应用软件与硬件平台的耦合度,它充当了我们硬件与应用软件之间的纽带, 使得应用软件只需要调用驱动程序接口API就可以让硬件去完成要求的开发,而应用软件则不需要关心硬件到底是如何工作的。 这将大大提高我们应用程序的可移植性和开发效率。
8.1.1. 驱动的作用
设备驱动与底层硬件直接打交道,按照硬件设备的具体工作方式读写设备寄存器, 完成设备的轮询、中断处理、DMA通信,进行物理内存向虚拟内存的映射,最终使通信设备能够收发数据, 使显示设备能够显示文字和画面,使存储设备能够记录文件和数据。
在系统中没有操作系统的情况下,工程师可以根据硬件设备的特点自行定义接口,如对LED定义LightOn()、LightOff()等。 而在有操作系统的情况下,设备驱动的架构则由相应的操作系统定义,驱动工程师必须按照相应的架构设计设备驱动, 如在本次实验中必须设计file_operations的接口。这样,设备驱动才能良好地整合到操作系统的内核中。
8.1.2. 有无操作系统的区别
1)无操作系统(即裸机)时的设备驱动 也就是直接操作寄存器的方式控制硬件,在这样的系统中,虽然不存在操作系统,但是设备驱动是必须存在的。 一般情况下,对每一种设备驱动都会定义为一个软件模块,包含.h文件和.c文件,前者定义该设备驱动的数据结构并声明外部函数, 后者进行设备驱动的具体实现。其他模块需要使用这个设备的时候,只需要包含设备驱动的头文件然后调用其中的外部接口函数即可。 这在STM32的开发中很常见,也相对比较简单。
2)有操作系统时的设备驱动 反观有操作系统时,首先,驱动硬件工作的的部分仍然是必不可少的,其次,我们还需要将设备驱动融入内核。 为了实现这种融合,必须在所有的设备驱动中设计面向操作系统内核的接口,这样的接口由操作系统规定,对一类设备而言结构一致,独立于具体的设备。
由此可见,当系统中存在操作系统的时候,设备驱动变成了连接硬件和内核的桥梁。操作系统的存在势必要求设备驱动附加更多的代码和功能, 把单一的驱动变成了操作系统内与硬件交互的模块,它对外呈现为操作系统的API。
操作系统的存在究竟带来了什么好处呢?
首先操作系统完成了多任务并发; 其次操作系统为我们提供了内存管理机制,32位Linux操作系统可以让每个进程都能独立访问4GB的内存空间; 对于应用程序来说,应用程序将可使用统一的系统调用接口来访问各种设备, 通过write()、read()等函数读写文件就可以访问各种字符设备和块设备,而不用管设备的具体类型和工作方式。
8.2. 内存管理单元MMU
在linux环境直接访问物理内存是很危险的,如果用户不小心修改了内存中的数据,很有可能造成错误甚至系统崩溃。 为了解决这些问题内核便引入了MMU,
8.2.1. MMU的功能
MMU为编程提供了方便统一的内存空间抽象,其实我们的程序中所写的变量地址是虚拟内存当中的地址, 倘若处理器想要访问这个地址的时候,MMU便会将此虚拟地址(Virtual Address)翻译成实际的物理地址(Physical Address), 之后处理器才去操作实际的物理地址。MMU是一个实际的硬件,并不是一个软件程序。他的主要作用是将虚拟地址翻译成真实的物理地址同时管理和保护内存, 不同的进程有各自的虚拟地址空间,某个进程中的程序不能修改另外一个进程所使用的物理地址,以此使得进程之间互不干扰,相互隔离。 而且我们可以使用虚拟地址空间的一段连续的地址去访问物理内存当中零散的大内存缓冲区。很多实时操作系统都可以运行在无MMU的CPU中, 比如uCOS、FreeRTOS、uCLinux,以前想CPU也运行linux系统必须要该CPU具备MMU,但现在Linux也可以在不带MMU的CPU中运行了。 总体而言MMU具有如下功能:
-
保护内存: MMU给一些指定的内存块设置了读、写以及可执行的权限,这些权限存储在页表当中,MMU会检查CPU当前所处的是特权模式还是用户模式,如果和操作系统所设置的权限匹配则可以访问,如果CPU要访问一段虚拟地址,则将虚拟地址转换成物理地址,否则将产生异常,防止内存被恶意地修改。
-
提供方便统一的内存空间抽象,实现虚拟地址到物理地址的转换: CPU可以运行在虚拟的内存当中,虚拟内存一般要比实际的物理内存大很多,使得CPU可以运行比较大的应用程序。
到底什么是虚拟地址什么是物理地址?
当没有启用MMU的时候,CPU在读取指令或者访问内存时便会将地址直接输出到芯片的引脚上,此地址直接被内存接收,这段地址称为物理地址, 如下图所示。
简单地说,物理地址就是内存单元的绝对地址,好比你电脑上插着一张8G的内存条,则第一个存储单元便是物理地址0x0000, 内存条的第6个存储单元便是0x0005,无论处理器怎样处理,物理地址都是它最终的访问的目标。
当CPU开启了MMU时,CPU发出的地址将被送入到MMU,被送入到MMU的这段地址称为虚拟地址, 之后MMU会根据去访问页表地址寄存器然后去内存中找到页表(假设只有一级页表)的条目,从而翻译出实际的物理地址, 如下图所示。
对于I.MX 6ULL 这种32位处理器而言,其虚拟地址空间共有4G(2^32),一旦CPU开启了MMU, 任何时候CPU发出的地址都是虚拟地址,为了实现虚拟地址到物理地址之间的映射, MMU内部有一个专门存放页表的页表地址寄存器,该寄存器存放着页表的具体位置, 用ioremap映射一段地址意味着使用户空间的一段地址关联到设备内存上, 这使得只要程序在被分配的虚拟地址范围内进行读写操作,实际上就是对设备(寄存器)的访问。
8.2.2. TLB的作用
讲到MMU我又忍不住和大家说下TLB(Translation Lookaside Buffer)的作用。 由上面的地址转换过程可知,当只有一级页表进行地址转换的时候,CPU每次读写数据都需要访问两次内存, 第一次是访问内存中的页表,第二次是根据页表找到真正需要读写数据的内存地址; 如果使用两级了表,那么CPU每次读写数据都需要访问3次内存,这样岂不是显得非常繁琐且耗费CPU的性能,
那有什么更好的解决办法呢?答案是肯定的,为了解决这个问题,TLB便孕育而生。 在CPU传出一个虚拟地址时,MMU最先访问TLB,假设TLB中包含可以直接转换此虚拟地址的地址描述符, 则会直接使用这个地址描述符检查权限和地址转换,如果TLB中没有这个地址描述符, MMU才会去访问页表并找到地址描述符之后进行权限检查和地址转换, 然后再将这个描述符填入到TLB中以便下次使用,实际上TLB并不是很大, 那TLB被填满了怎么办呢?如果TLB被填满,则会使用round-robin算法找到一个条目并覆盖此条目。
由于MMU非常复杂,在此我们不做过于深入的了解,大家只要大概知道它的作用即可, 感兴趣的同学可以到网上查阅相关资料,对于初学者,还是建议先掌握全局,然后再深挖其中重要的细节, 千万不能在汪洋大海中迷失了方向。本小结我们主要用到的是MMU的地址转换功能,在linux环境中, 我们开启了MMU之后想要读写具体的寄存器(物理地址),就必须用到物理地址到虚拟地址的转换函数。
8.3. 地址转换函数
上面提到了物理地址到虚拟地址的转换函数。包括ioremap()地址映射和取消地址映射iounmap()函数。
8.3.1. ioremap函数
地址映射函数 (内核源码/arch/arc/mm/ioremap.c):
void __iomem *ioremap(phys_addr_t paddr, unsigned long size)
#define ioremap ioremap
参数:
-
paddr: 被映射的IO起始地址(物理地址);
-
size: 需要映射的空间大小,以字节为单位;
返回值: 一个指向__iomem类型的指针,当映射成功后便返回一段虚拟地址空间的起始地址,我们可以通过访问这段虚拟地址来实现实际物理地址的读写操作。
ioremap函数是依靠__ioremap函数来实现的,只是在__ioremap当中其最后一个要映射的I/O空间和权限有关的标志flag为0。 在使用ioremap函数将物理地址转换成虚拟地址之后,理论上我们便可以直接读写I/O内存,但是为了符合驱动的跨平台以及可移植性, 我们应该使用linux中指定的函数(如:iowrite8()、iowrite16()、iowrite32()、ioread8()、ioread16()、ioread32()等)去读写I/O内存, 而非直接通过映射后的指向虚拟地址的指针进行访问。读写I/O内存的函数如下:
unsigned int ioread8(void __iomem *addr)
unsigned int ioread16(void __iomem *addr)
unsigned int ioread32(void __iomem *addr)void iowrite8(u8 b, void __iomem *addr)
void iowrite16(u16 b, void __iomem *addr)
void iowrite32(u32 b, void __iomem *addr)
-
第1行:读取一个字节(8bit)
-
第2行:读取一个字(16bit)
-
第3行:读取一个双字(32bit)
-
第5行:写入一个字节(8bit)
-
第6行:写入一个字(16bit)
-
第7行:写入一个双字(32bit)
对于读I/O而言,他们都只有一个__iomem类型指针的参数,指向被映射后的地址,返回值为读取到的数据据; 对于写I/O而言他们都有两个参数,第一个为要写入的数据,第二个参数为要写入的地址,返回值为空。 与这些函数相似的还有writeb、writew、writel、readb、readw、readl等, 在ARM架构下,writex(readx)函数与iowritex(ioreadx)有一些区别, writex(readx)不进行端序的检查,而iowritex(ioreadx)会进行端序的检查。
说了这么多,大家可能还是不太理解,那么我们来举个栗子,比如我们需要操作RGB灯中的蓝色led中的数据寄存器, 在51或者STM32当中我们是直接看手册查找对应的寄存器,然后往寄存器相应的位写入数据0或1便可以实现LED的亮灭(假设已配置好了输出模式以及上下拉等)。 前面我们在不带linux的环境下也是用的类似的方法,但是当我们在linux环境且开启了MMU之后, 我们就要将LED灯引脚对应的数据寄存器(物理地址)映射到程序的虚拟地址空间当中,然后我们就可以像操作寄存器一样去操作我们的虚拟地址啦!其具体代码如下所示。
地址映射:
unsigned long pa_dr = 0x20A8000 + 0x00;
unsigned int __iomem *va_dr;
unsigned int val;
va_dr = ioremap(pa_dr, 4);
val = ioread32(va_dr);
val &= ~(0x01 << 19);
iowrite32(val, va_dr);
-
第1行:Address: Base address + 0h offset
-
第2行:定义一个__iomem类型的指针
-
第4行:将va_dr指针指向映射后的虚拟地址起始处,这段地址大小为4个字节
-
第5行:读取被映射后虚拟地址的的数据,此地址的数据是实际数据寄存器(物理地址)的数据
-
第7行:将蓝色LED灯引脚对应的位清零
-
第8行:把修改后的值重新写入到被映射后的虚拟地址当中,实际是往寄存器中写入了数据
8.3.2. iounmap函数
取消地址映射函数 (内核源码/arch/arc/mm/ioremap.c):
void iounmap(void *addr)
#define iounmap iounmap
参数:
-
addr: 需要取消ioremap映射之后的起始地址(虚拟地址)。
例如我们要取消一段被ioremap映射后的地址可以用下面的写法。
iounmap(va_dr); //释放掉ioremap映射之后的起始地址(虚拟地址)
8.4. 点亮LED灯实验
从第一章内核模块再到第二章字符设备驱动,从理论到实验,总算是一切准备就绪,让我们开始着手写LED的驱动代码吧。 首先我们需要一个LED字符设备结构体,它应该包含我们要操作的寄存器地址。 其次是模块的加载卸载函数,加载函数需要注册设备,卸载函数则需要释放申请的资源。 然后就是file_operations结构体以及open,write,read相关接口的实现。
8.4.1. 实验说明
8.4.1.1. 硬件介绍
本节实验使用到 EBF6ULL-PRO 开发板上的 RGB 彩灯。
8.4.1.2. 硬件原理图分析
了解RGB灯的实物后,可打开相应的原理图文档来查看硬件连接,即《野火_EBF6ULL S1 Pro 底板_V1.0_原理图》和 《野火_EBF6ULL S1 邮票孔核心板_V1.0_原理图》,具体见下图。
LED_R的阴极连接到i.MX6ULL芯片上GPIO1_IO04引脚,LED_G连接到CSI_HSYNC,LED_B连接到CSI_VSYNC。 而CSI_HSYNC和CSI_VSYNC作为摄像头的某一功能被复用为GPIO。如下表所示。
LED灯 | 原理图的标号 | 具体引脚名 | GPIO端口及引脚编号 |
---|---|---|---|
R灯 | GPIO_4 | GPIO1_IO04 | GPIO1_IO04 |
G灯 | CSI_HSYNC | CSI_HSYNC | GPIO4_IO20 |
B灯 | CSI_VSYNC | CSI_VSYNC | GPIO4_IO19 |
对于RGB灯的控制进行控制,也就是对上述GPIO的寄存器进行读写操作。可大致分为以下几个步骤:
-
使能GPIO时钟
-
设置引脚复用为GPIO
-
设置引脚属性(上下拉、速率、驱动能力)
-
控制GPIO引脚输出高低电平
8.4.1.3. 对RGB的R灯进行寄存器配置
GPIO时钟:
跟GPIO相关的时钟主要有CCM_CCGR(0~3)寄存器。查看数据手册可以知道GPIO第26-27两位控制引脚时钟。
两个bit的不同取值,设置GPIO时钟的不同属性如下:
-
00:所有模式下都关闭外设时钟
-
01:只有在运行模式下打开外设时钟
-
10:保留
-
11:除了停止模式以外,该外设时钟全程使能
CCM_CCGR1地址为 0x20C406C。 先对CCM_CCGR1寄存器的26位、27位值清空,再赋值为11(位运算,详见示例代码)。
引脚复用GPIO:
引脚复用相关的寄存器为IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO04:
关于该寄存器的配置可以见下图:
寄存器地址为0x20E006C,对该寄存器第0-3位配置为 0101时,MUX_MODE为ALT5 ,也就是该引脚复用为GPIO。
引脚属性:
寄存器为 IOMUXC_SW_PAD_CTL_PAD_GPIO1_IO04
-
HYS(bit16):用来使能迟滞比较器 。
-
PUS(bit15-bit14):用来设置上下拉电阻大小。
-
PUE(bit13):当 IO 作为输入的时候,这个位用来设置 IO 使用上下拉还是状态保持器。
-
PKE(bit12):用来使能或者禁止上下拉/状态保持器功能。
-
ODE(bit11):IO 作为输出的时候,此位用来禁止或者使能开漏输出。
-
SPEED(bit7-bit6):当 IO 用作输出的时候,此位用来设置 IO 速度。
-
DSE(bit5-bit3):当 IO 用作输出的时候用来设置 IO 的驱动能力。
-
SRE(bit0):设置压摆率
寄存器地址为0x20E02F8,对该寄存器写入0x1F838,也就是二进制 1 1111 1000 0011 1000,对比上图了解引脚的属性。
输出电平:
- 0:输入 - 1:输出
硬件原理以及寄存器配置到此为止,更多硬件上的信息可以查看原理图和芯片手册。
8.4.2. 代码讲解
本章的示例代码目录为:linux_driver/led_cdev/
8.4.2.1. 编写LED字符设备结构体且初始化
struct led_chrdev {struct cdev dev;unsigned int __iomem *va_dr;unsigned int __iomem *va_gdir;unsigned int __iomem *va_iomuxc_mux;unsigned int __iomem *va_ccm_ccgrx;unsigned int __iomem *va_iomux_pad;unsigned long pa_dr;unsigned long pa_gdir;unsigned long pa_iomuxc_mux;unsigned long pa_ccm_ccgrx;unsigned long pa_iomux_pad;unsigned int led_pin;unsigned int clock_offset;
};static struct led_chrdev led_cdev[DEV_CNT] = {{.pa_dr = 0x0209C000,.pa_gdir = 0x0209C004,.pa_iomuxc_mux =0x20E006C,.pa_ccm_ccgrx = 0x20C406C,.pa_iomux_pad =0x20E02F8,.led_pin = 4,.clock_offset = 26},{.pa_dr = 0x20A8000,.pa_gdir = 0x20A8004,.pa_iomuxc_mux =0x20E01E0,.pa_ccm_ccgrx = 0x20C4074,.pa_iomux_pad =0x20E046C,.led_pin = 20,.clock_offset = 12},{.pa_dr = 0x20A8000,.pa_gdir = 0x20A8004,.pa_iomuxc_mux =0x20E01DC,.pa_ccm_ccgrx = 0x20C4074,.pa_iomux_pad =0x20E0468,.led_pin = 19,.clock_offset = 12},
};
在上面的代码中我们定义了一个RGB灯的结构体,并且定义且初始化了一个RGB灯的结构体数组, 因为我们开发板上面共有3个RGB灯,所以代码中DEV_CNT为3。 在初始化结构体的时候我们以“.”+“变量名字”的形式来访问且初始化结构体变量的, 初始化结构体变量的时候要以“,”隔开,使用这种方式简单明了,方便管理数据结构中的成员。
-
第2行:描述一个字符设备的结构体
-
第3行:数据寄存器虚拟地址指针
-
第4行:输入输出方向寄存器虚拟地址指针
-
第5行:端口复用寄存器虚拟地址指针
-
第6行:时钟寄存器虚拟地址指针
-
第7行:电气属性寄存器虚拟地址指针
-
第9行:装载数据寄存器(物理地址)的变量
-
第10行:装载输出方向寄存器(物理地址)的变量
-
第11行:装载端口复用寄存器(物理地址)的变量
-
第12行:装载时钟寄存器(物理地址)的变量
-
第13行:装载电气属性寄存器(物理地址)的变量
-
第15行:LED的引脚
-
第16行:时钟偏移地址(相对于CCM_CCGRx)
-
第20-22行:初始化红灯结构体成员变量
-
第23-25行:初始化绿灯结构体成员变量
-
第26-28行:初始化蓝灯结构体成员变量
8.4.2.2. 内核RGB模块的加载和卸载函数
第一部分为内核RGB模块的加载函数,其主要完成了以下任务:
-
调用alloc_chrdev_region()函数向系统动态申请一个未被占用的设备号,使用alloc_chrdev_region()相比较于register_chrdev_region()的好处在于不必自己费时间去查看那些是未被占用的设备号,避免了设备号重复问题;
-
调用class_create()函数创建一个RGB灯的设备类;
-
分别给三个LED建立其对应的字符设备结构体cdev和led_chrdev_fops的关联,并且初始化字符设备结构体,最后注册并创建设备。
第二部分为内核RGB模块的卸载函数,其主要完成了以下任务:
-
调用device_destroy()函数用于从linux内核系统设备驱动程序模型中移除一个设备,并删除/sys/devices/virtual目录下对应的设备目录及/dev/目录下对应的设备文件;
-
调用cdev_del()函数来释放散列表中的对象以及cdev结构本身;
-
释放被占用的设备号以及删除设备类。
从下面代码中我们可以看出这三个LED都使用的同一个主设备号,只是他们的次设备号有所区别而已。
内核RGB模块的加载和卸载函数:
static __init int led_chrdev_init(void)
{int i = 0;dev_t cur_dev;printk("led chrdev init\n");alloc_chrdev_region(&devno, 0, DEV_CNT, DEV_NAME);led_chrdev_class = class_create(THIS_MODULE, "led_chrdev");for (; i < DEV_CNT; i++) {cdev_init(&led_cdev[i].dev, &led_chrdev_fops);led_cdev[i].dev.owner = THIS_MODULE;cur_dev = MKDEV(MAJOR(devno), MINOR(devno) + i);cdev_add(&led_cdev[i].dev, cur_dev, 1);device_create(led_chrdev_class, NULL, cur_dev, NULL,DEV_NAME "%d", i);}return 0;
}
module_init(led_chrdev_init);static __exit void led_chrdev_exit(void)
{int i;dev_t cur_dev;printk("led chrdev exit\n");for (i = 0; i < DEV_CNT; i++) {cur_dev = MKDEV(MAJOR(devno), MINOR(devno) + i);device_destroy(led_chrdev_class, cur_dev);cdev_del(&led_cdev[i].dev);}unregister_chrdev_region(devno, DEV_CNT);class_destroy(led_chrdev_class);
}
module_exit(led_chrdev_exit);
-
第5行:向动态申请一个设备号
-
第6行:创建设备类
-
第8行:绑定led_cdev与led_chrdev_fops
-
第11行:注册设备
-
第15行:创建设备
-
第19行:模块加载
-
第25行:计算出设备号
-
第26行:删除设备
-
第29行:注销设备
-
第30行:释放被占用的设备号
-
第32行:模块卸载
8.4.2.3. file_operations结构体成员函数的实现
file_operations中open函数的实现:
/* 打开RGB LED设备函数 */
static int led_chrdev_open(struct inode *inode, struct file *filp)
{unsigned int val = 0;struct led_chrdev *led_cdev =(struct led_chrdev *)container_of(inode->i_cdev, struct led_chrdev,dev);filp->private_data = led_cdev;printk("open\n");/* 实现地址映射 */led_cdev->va_dr = ioremap(led_cdev->pa_dr, 4); //,数据寄存器映射,将led_cdev->va_dr指针指向映射后的虚拟地址起始处,这段地址大小为4个字节led_cdev->va_gdir = ioremap(led_cdev->pa_gdir, 4); //方向寄存器映射led_cdev->va_iomuxc_mux = ioremap(led_cdev->pa_iomuxc_mux, 4); //端口复用功能寄存器映射led_cdev->va_ccm_ccgrx = ioremap(led_cdev->pa_ccm_ccgrx, 4); //时钟控制寄存器映射led_cdev->va_iomux_pad = ioremap(led_cdev->pa_iomux_pad, 4); //电气属性配置寄存器映射/* 配置寄存器 */val = ioread32(led_cdev->va_ccm_ccgrx); //间接读取寄存器中的数据val &= ~(3 << led_cdev->clock_offset);val |= (3 << led_cdev->clock_offset); //置位对应的时钟位iowrite32(val, led_cdev->va_ccm_ccgrx); //重新将数据写入寄存器iowrite32(5, led_cdev->va_iomuxc_mux); //复用位普通I/O口iowrite32(0x1F838, led_cdev->va_iomux_pad);val = ioread32(led_cdev->va_gdir);val &= ~(1 << led_cdev->led_pin);val |= (1 << led_cdev->led_pin);iowrite32(val, led_cdev->va_gdir); //配置位输出模式val = ioread32(led_cdev->va_dr);val |= (0x01 << led_cdev->led_pin);iowrite32(val, led_cdev->va_dr); //输出高电平return 0;
}
-
第4行:通过led_chrdev结构变量中dev成员的地址找到这个结构体变量的首地址
-
第5行:把文件的私有数据private_data指向设备结构体led_cdev
-
第9-13行:实现地址映射
-
第15-21行:配置寄存器
file_operations中open函数的实现函数很重要,下面我们来详细分析一下该函数具体做了哪些工作。
1、container_of()函数:
在Linux驱动编程当中我们会经常和container_of()这个函数打交道,所以特意拿出来和大家分享一下,其实这个函数功能不多, 但是如果单靠自己去阅读内核源代码分析,那可能非常难以理解,编写内核源代码的大牛随便两行代码都会让我们看的云深不知处, 分析内核源代码需要我们有很好的知识积累以及技术沉淀。 下面我简单跟大家讲解一下container_of()函数的大致工作内容,其宏定义实现如下所示: