Linux 字符设备驱动的编写
作者:解琛
时间:2020 年 8 月 17 日
一、Linux 设备分类
Linux 中,根据设备的类型可以分为三类:字符设备、块设备和网络设备。
- 字符设备:应用程序按字节/字符来读写数据,通常不支持随机存取。我们常用的键盘、串口都是字符设备。
- 块设备:应用程序可以随机访问设备数据。典型的块设备有硬盘、SD卡、闪存等,应用程序 可以寻址磁盘上的任何位置,并由此读取数据。此外,数据的读写只能以块的倍数进行。
- 网络设备:是一种特殊设备,它并不存在于/dev下面,主要用于网络数据的收发。
二、open()
在使用 C 库中 open() 函数打开设备文件时,执行了以下操作。
mknod [选项] 设备名 设备类型 主设备号 次设备号
设备文件通常在开机启动时自动创建的,可以使用命令 mknod 来创建一个新的设备文件。
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;
......
}
} else
shmem_free_inode(sb);
return inode;
}
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;
}
....
}
使用 mknod 命令,创建了一个字符设备文件时,实际上就是创建了一个设备节点 inode 结构体,并且将该设备的设备编号记录在成员 i_rdev,将成员 f_op 指针指向了 def_chr_fops 结构体。
命令 mknod 最终会调用 init_special_inode 函数。
使用的 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 函数。
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;
}
……
}
在该函数的实现中,使用 fops_get 函数来获取该文件节点 inode 的成员变量 i_fop。
使用 mknod 创建字符设备文件时,将 def_chr_fops 结构体赋值给了该设备文件 inode 的 i_fop 成员。
到了这里,新建的 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 可以理解为一个字符设备的通用初始化函数,根据字符设备的设备号,找到相应的字符设备,从而得到操作该设备的方法。
最后,调用上图的fd_install函数,完成文件描述符和文件结构体 file 的关联。
之后使用对该文件描述符 fd 调用 read、write 函数,最终都会调用 file 结构体对应的函数,实际上也就是调用 cdev 结构体中 ops 结构体内的相关函数。
总结以下,当使用 open 函数,打开设备文件时:会根据该设备的文件的设备号找到相应的设备结构体,从而得到了操作该设备的方法。
也就是说如果要添加一个新设备的话,需要提供:
- 一个设备号;
- 一个设备结构体;
- 一组操作该设备的方法(file_operations结构体)。
三、数据结构
3.1 struct file_operations
file_operations 结构体中包含了操作文件的一系列函数指针。
/* file_operations 结构体(位于 内核源码/include/linux/fs.h 文件) */
struct file_operations {
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:用于读取设备中的数据,并返回成功读取的字节数;
- file:类型指针变量;
- __user*:类型的数据缓冲区,__user用于修饰变量,表明该变量所在的地址空间是用户空间的。内核模块不能直接使用该数据,需要使用 copy_to_user 函数来进行操作;
- size_t:类型变量指定读取的数据大小;
- write:用于向设备写入数据,并返回成功写入的字节数;
- unlocked_ioctl:提供设备执行相关控制命令的实现方法,它对应于应用程序的 fcntl 函数以及 ioctl 函数。在 kernel 3.0 中已经完全删除了 struct file_operations 中的 ioctl 函数指针;
- open:设备驱动第一个被执行的函数,一般用于硬件的初始化。如果该成员被设置为 NULL,则表示这个设备的打开操作永远成功;
- release:当 file 结构体被释放时,将会调用该函数。与 open 函数相反,该函数可以用于释放。
/* 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 )
static inline long copy_to_user( void __user *to,
const void *from,
unsigned long n )
使用 read 和 write 函数时,需要使用 copy_to_user 函数以及 copy_from_user 函数来进行数据访问,写入 / 读取成功函数返回 0,失败则会返回未被拷贝的字节数。
- to:指定目标地址,也就是数据存放的地址;
- from:指定源地址,也就是数据的来源;
- n:指定写入/读取数据的字节数。
3.2 struct 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 struct cdev
内核用 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;
};
- kobj:内核数据对象,用于管理该结构体。obj_lookup 函数中从 cdev_map 中得到该成员,由该成员便可以得到相应的字符设备结构体;
- owner:指向了关联该设备的内核模块,实际上就是关联了驱动程序,通常设置为 THIS_MODULE;
- ops:该结构体中最重要的一部分,也是我们实现字符设备驱动的关键一步,用于存放所有操作该设备的函数指针;
- list:实现一个链表,用于包含与该结构体对应的字符设备文件 inode 的成员 i_devices 的链表;
- dev:记录了字符设备的设备号;
- count:记录了与该字符设备使用的次设备号的个数;
四、字符设备驱动程序框架
Linux 给开发者提供了一个基本的框架,如果你不按照这个框架写驱动,那么编写的驱动程序就不能被内核所接纳的。
4.1 初始化字符设备
/* 法一 */
static struct cdev chrdev;
/* 法二 */
struct cdev *cdev_alloc(void);
- 第一种方式:就是我们常见的变量定义;
- 第二种方式:是内核提供的动态分配方式,调用该函数之后,会返回一个struct cdev类型的指针,用于描述字符设备。
4.2 移除字符设备
void cdev_del(struct cdev *p)
4.3 分配设备号
Linux 的各种设备都以文件的形式存放在 /dev 目录下,为了管理这些设备,系统为各个设备进行编号。
每个设备号又分为主设备号和次设备号。
- 主设备号用来 区分不同种类的设备,如USB,tty等;
- 次设备号用来区分同一类型的多个设备,如tty0,tty1等。
内核提供了一种数据类型:dev_t,用于记录设备编号。
该数据类型实际上是一个无符号 32 位整型,其中的 12 位用于表示主设备号,剩余的 20 位则用于表示次设备号。
内核将一部分主设备号分配给了一些常见的设备。在内核源码的 Documentation/devices.txt 文件中可以找到这些设备以及这部分设备占据的主设备号。
- 记录了当前内核所占据的所有字符设备的主设备号,通过检查这一列的内容,便可以知道当前的主设备号是否被内核占用;
- 记录了设备的类型,主要分为块设备(block)以及字符设备(char);
- 记录了每个次设备号对应的设备;
- 对每个设备的概述。
创建一个新的字符设备之前,需要为新的字符设备注册一个新的设备号,内核提供了三种方式,来完成这项工作。
4.3.1 register_chrdev_region
register_chrdev_region 函数用于静态地为一个字符设备申请一个或多个设备编号。
该函数在分配成功时,会返回0;失败则会返回相应的错误码。
/* (位于 内核源码/fs/char_dev.c) */
int register_chrdev_region(dev_t from, unsigned count, const char *name)
- from:dev_t 类型的变量,用于指定字符设备的起始设备号;
- count:指定要申请的设备号个数;
- name:用于指定该设备的名称,可以在 /proc/devices 中看到该设备。
register_chrdev_region 函数使用时需要指定一个设备编号,Linux 内核为我们提供了生成设备号的宏定义。
/* 合成设备号 MKDEV(位于 内核源码/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))
- MKDEV:用于将主设备号和次设备号合成一个设备号,主设备可以通过查阅内核源码的 Documentation/devices.txt 文件,而次设备号通常是从编号 0 开始;
- MAJOR:根据设备的设备号来获取设备的主设备号;
- MINOR:根据设备的设备号来获取设备的次设备号。
4.3.2 alloc_chrdev_region
调用 alloc_chrdev_region函数,内核会自动分配给我们一个尚未使用的主设备号。
通过命令 cat /proc/devices 查询内核分配的主设备号。
/* (位于 内核源码/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:用于指定该设备的名称,可以在 /proc/devices 中看到该设备。
4.3.3 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 个,通常情况下,开发者不需要用到这么多个设备,这就造成了极大的资源浪费。
4.4 注销设备号
4.4.1 unregister_chrdev_region
当删除字符设备时候,需要把分配的设备编号交还给内核,对于使用 register_chrdev_region 函数以及 alloc_chrdev_region 函数分配得到的设备编号,可以使用 unregister_chrdev_region 函数实现该功能。
/* (位于 内核源码/fs/char_dev.c) */
void unregister_chrdev_region(dev_t from, unsigned count)
- from:指定需要注销的字符设备的设备编号起始值,一般将定义的 dev_t 变量作为实参;
- count:指定需要注销的字符设备编号的个数,该值应与申请函数的 count 值相等,通常采用宏定义进行管理。
4.4.2 unregister_chrdev
使用 register_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:执行需要释放的字