1. Linux设备分类
- 字符设备: 指应用程序按字节/字符来读写数据的设备。通常为传真、虚拟终端和串口调制解调器、键盘之类设备提供流通信服务,通常不支持随机存取数据。字符设备在实现时大多不使用缓存器。系统直接从设备读/写每一个字符。
- 块设备: 通常支持随机存取和寻址,并使用缓存器。操作系统为输入输出分配了缓存以存储一块数据。当程序向设备发送读或写数据的请求时,系统把数据中的每一个字符存储在适当的缓存中。当缓存填满时,会采取适当的操作(把数据传走),而后系统清空缓存。与字符设备不同的是,是否支持随机存储。字符型是流形式,逐一存储。典型的块设备有硬盘、 SD卡、闪存等,应用程序可以寻址磁盘上的任何位置,并由此读取数据。此外,数据的读写只能以块的倍数进行。
- 网络设备: 是一种特殊设备,它并不存在于/dev 下面,主要用于网络数据的收发。
Linux 内核中处处体现面向对象的设计思想,为了统一形形色色的设备, Linux 系统将设备抽象为 struct cdev, struct block_device,struct net_devce 三个对象,具体的设备都可以包含着三种对象从而继承和三种对象属性和操作,并通过各自的对象添加到相应的驱动模型中,从而进行统一的管理和操作
2. 字符设备抽象
Linux 内核将字符设备抽象成一个数据结构 (struct cdev), 字符设备对象cdev 记录了字符设备的相关信息(设备号、内核对象),字符设备的打开、读写、关闭等操作接口(file_operations),在我们想要添加一个字符设备时,就是将这个对象注册到内核中,通过创建一个文件(设备节点)绑定对象的 cdev,当我们对这个文件进行读写操作时,就可以通过虚拟文件系统,在内核中找到这个对象及其操作接口,从而控制设备。
- 硬件层,通过查看硬件的原理图、芯片的数据手册,确定底层需要配置的寄存器,这类似于裸机开发。将对底层寄存器的配置,读写操作放在文件操作接口里面,也就是实现file_operations 结构体。
- 驱动层,将文件操作接口注册到内核,内核通过内部散列表来登记记录主次设备号。在文件系统层,新建一个文件绑定该文件操作接口,应用程序通过操作指定文件的文件操作接口来设置底层寄存器
3. 相关概念及数据结构
linux 中使用设备编号来表示设备,主设备号区分设备类别,次设备号标识具体的设备。cdev 结构体被内核用来记录设备号,在使用设备时通常会打开设备节点,通过设备节点的 inode 结构体、 file 结构体最终找到 file_operations 结构体,并从 file_operations 结构体中得到操作设备的具体方法
3.1 设备号
对于字符的访问是通过文件系统的名称进行的,这些名称被称为特殊文件、设备文件, Linux 根目录下有/dev 这个文件夹,专门用来存放设备中的驱动程序,使用 ls -l /dev以列表的形式列出系统中的所有设备。其中,每一行表示一个设备,每一行的第一个字符表示设备的类型。
如图:’ c’用来标识字符设备,’ b’用来标识块设备。如 autofs 是一个字符设备 c, 它的主设备号是 10,次设备号是 235; loop0 是一个块设备,它的主设备号是 7,次所备案为 0,同时可以看到 loop0-loop3 共用一个主设备号,次设备号由 0 开始递增 ,一般来说,主设备号指向设备的驱动程序,次设备号指向某个具体的设备。如图, I2C-0, I2C-1属于不同设备但是共用一套驱动程序
-
内核中设备编号的含义
typedef u32 __kernel_dev_t; typedef __kernel_dev_t dev_t; /* 表示设备号 */ #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))/* 将主设备号和次设备号合成一个设备号 */
dev_t 是一个 32 位的数表示设备编号,高12位表示主设备号,低20位表示次设备号。理论上主设备号取值范围: 0-2^12,次设备号 0-2^20。实际上在内核源码中 __register_chrdev_region(… ) 函数中, major 被限定在 0-CHRDEV_MAJOR_MAX,CHRDEV_MAJOR_MAX 是一个宏值是 512。在 kdev_t 中,设备编号通过移位操作最终得到主/次设备号码,同样主/次设备号也可以通过位运算变成 dev_t 类型的设备编号,具体实现参看上面代码 MAJOR(dev)、 MINOR(dev) 和 MKDEV(ma,mi)。
-
cdev 结构体
struct cdev { struct kobject kobj; /* 内嵌的内核对象,将设备统一加入到“Linux 设备驱动模型”中管理 */ struct module *owner; /* 字符设备驱动程序所在的内核模块对象的指针。 */ const struct file_operations *ops; /* 文件操作,ops在应用程序通过文件系统(VFS)呼叫到设备设备驱动程序中实现的文件操作类函数过程中起桥梁纽带作用, VFS 与文件系统及设备文件之间的接口是 file_operations 结构体成员函数,这个结构体包含了对文件进行打开、关闭、读写、控制等一系列成员函数。 */ struct list_head list; /* 用于将系统中的字符设备形成链表 */ dev_t dev; /* 字符设备的设备号,有主设备和次设备号构成 */ unsigned int count; /* 属于同一主设备号的次设备号的个数,表示设备驱动程序控制的实际同类设备的数量。 */ };
内核通过一个散列表 (哈希表) 记录设备编号。哈希表由数组和链表组成。以主设备号为 cdev_map 编号,使用哈希函数 f(major)=major%255 来计算组数下标 (使用哈希函数是为了链表节点尽量平均分布在各个数组元素中,提高查询效率);主设备号冲突, 则以次设备号为比较值来排序链表节点。如下图所示,内核用 struct cdev 结构体来描述一个字符设备,并通过struct kobj_map 类型的散列表 cdev_map 来管理当前系统中的所有字符设备。
内核中确实有一个 chrdevs 数组 :
static struct char_device_struct { struct char_device_struct *next; unsigned int major; unsigned int baseminor; int minorct; char name[64]; struct cdev *cdev; /* will die */ } *chrdevs[CHRDEV_MAJOR_HASH_SIZE];
访问它的时候,并不是直接使用主设备号 major 来确定数组项,而是使用如下函数来确定数组项:
/* index in the above */ static inline int major_to_index(unsigned major) { return major % CHRDEV_MAJOR_HASH_SIZE; }
-
file_operations 结构体
file_operation是关联系统调用和驱动程序的关键数据结构。结构的每一个成员都对应一个系统调用。读取 file_operation 中相应的函数指针,接着把控制权转交给函数指针指向的函数,从而完成了 Linux 设备驱动程序的工作。
提到 read 和 write 函数时,需要使用 opy_to_user以及copy_from_user来进行数据访问,写入/读取成功函数返回 0,失败则会返回未被拷贝的字节数。
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)
-
file结构体
内核中用 file 结构体来表示每个打开的文件,每打开一个文件,内核会创建一个结构体,并将对该文件上的操作函数传递给该结构体的成员变量 f_op,当文件所有实例被关闭后,内核会释放这个结构体。
struct file { const struct file_operations *f_op; /* needed for tty driver, and maybe others */ void *private_data; };
4. 框架
- 确定主设备号,也可以让内核分配
- 定义自己的 file_operations 结构体
- 实现对应的 drv_open/drv_read/drv_write 等函数,填入 file_operations 结构体
- 把 file_operations 结构体告诉内核: register_chrdev
- 得有一个入口函数:安装驱动程序时,就会去调用这个入口函数
- 有入口函数就应该有出口函数:卸载驱动程序时,出口函数调用unregister_chrdev
- 其他完善:提供设备信息,自动创建设备节点: class_create,device_create
5. open 函数到底做了什么
使用设备前通需要调用 open 函数,用于设备专有数据的初始化,申请相关资源及进行设备的初始化等工作,对简单的设备而言,open函数可不做具体的工作,在应用层通过系统调用 open 打开设备时,打开正常会得到该设备的文件描述符,之后可以通过该描述符对设备进行 read 和 write 等操作;
使用 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 函数
6. 补充知识
6.1 module_init/module_exit 的实现
驱动刚需的入口函数,出口函数如下
module_init(hello_init);
module_exit(hello_exit);
驱动程序可被编进内核,也可被编译为ko文件后手工加载。 对于两种形式,“ module_init/module_exit” 宏是不一样的。 在内核文件“ include\linux\module.h”中可以看到这 2 个宏:
#ifndef MODULE
#define module_init(x) __initcall(x);
#define module_exit(x) __exitcall(x);
#else /* MODULE */
#define module_init(initfn) \
/* Each module must use one module_init(). */
static inline initcall_t __inittest(void) \
{ return initfn; } \
int init_module(void) __attribute__((alias(#initfn)));
/* This is only required if you want to be unloadable. */
#define module_exit(exitfn) \
static inline exitcall_t __exittest(void) \
{ return exitfn; } \
void cleanup_module(void) __attribute__((alias(#exitfn)));
#endif
编译驱动程序时,我们执行“make modules”这样的命令,它在编译c文件时会定义宏 MODULE,在编译内核时,并不会定义宏 MODULE。所以, “ module_init/module_exit”这 2 个宏在驱动程序被编进内核时,如上面代码中第 3、 4 行那样定义;在驱动程序被编译为 ko 文件时,如上面代码中第 11~19 行那样定义。
6.2 register_chrdev 的内部实现
static inline int register_chrdev(unsigned int major, const char *name, const struct file_operations *fops)
{
return __register_chrdev(major, 0, 256, name, fops);
}
int __register_chrdev(unsigned int major, unsigned int baseminor, unsigned int count, const char *name, const struct file_operations *fops)
{
struct char_device_struct *cd;
struct cdev *cdev;
int err = -ENOMEM;
cd = __register_chrdev_region(major, baseminor, count, name);
cdev = cdev_alloc();
cdev->owner = fops->owner;
cdev->ops = fops;
kobject_set_name(&cdev->kobj, "%s", name);
err = cdev_add(cdev, MKDEV(cd->major, baseminor), count);
}
调用__register_chrdev_region 函数来“注册字符设备的区域”,它仅仅是查看设备号(major, baseminor)到(major, baseminor+count-1)有没有被占用,如果未被占用的话,就使用这块区域。