1. 简介
字符设备(character device)是一种按字符方式进行数据传输的设备。与块设备不同,字符设备不以块为单位读写数据,而是以字符为单位进行操作。常见的字符设备包括串口、键盘、鼠标、以及一些传感器等。
字符设备驱动(character device driver)是操作系统内核中用于管理和控制字符设备的软件组件。它提供了一组操作接口,使得用户空间的程序可以通过系统调用与字符设备进行交互。字符设备驱动的主要功能包括初始化设备、管理设备的输入输出操作、处理设备的中断以及提供对设备的访问权限。
为什么要实现驱动程序,驱动到底有什么用???
首先需要明白一个概念:操作系统通常将其运行模式分为两种:内核模式(Kernel Mode)和用户模式(User Mode)。在内核模式下,代码可以直接访问硬件和内存,拥有更高的权限;而用户模式下的代码只能通过系统调用间接访问硬件和受保护的系统资源。
所以如果允许用户空间的应用程序直接访问硬件,这会带来极大的安全隐患。恶意程序可能会直接操作硬件,导致系统崩溃或安全漏洞。驱动程序在内核空间运行,受操作系统的严格控制,可以防止未经授权的硬件访问。
除此之外还有以下作用:
硬件抽象:不同硬件厂商的设备有不同的接口和操作方法。驱动程序提供了一个硬件抽象层,将这些差异屏蔽起来,使得操作系统和应用程序可以通过统一的接口访问不同的硬件设备。
统一接口,代码解耦:操作系统提供了一组标准化的接口(如 open
, read
, write
, ioctl
等)供用户空间应用程序调用。这些接口通过驱动程序实现,不管底层的具体硬件如何变化,用户程序可以依旧不变地调用这些接口。
性能优化:在用户空间直接操作硬件通常需要频繁的上下文切换(从用户模式切换到内核模式)。这种切换是昂贵的操作,会增加系统开销。驱动程序在内核空间执行,减少了这种开销,提高了性能。
实时性要求:某些硬件设备(如实时控制系统中的传感器和致动器)需要对外界变化做出非常快速的反应。驱动程序在内核空间执行,能够在需要时迅速响应硬件中断,而用户空间程序无法直接处理中断,无法满足实时性的要求。
资源管理: 内核需要管理系统中的所有硬件资源,包括设备文件、内存、I/O 端口、中断等。驱动程序是内核管理这些资源的关键模块。
硬件依赖:驱动程序通常是硬件相关的,为特定的硬件设备编写。操作系统内核通过这些驱动程序支持不同的硬件设备。
模块化设计:驱动程序允许操作系统模块化地设计和实现硬件支持。新的硬件支持可以通过加载新的驱动程序模块来添加,而不需要改变内核或用户空间程序。
可扩展性:驱动程序可以随着硬件的更新和功能的扩展而进行独立的开发和升级,而无需对操作系统内核本身进行大量修改。
等等;
2. 字符设备驱动实现过程及分析
字符设备驱动实现过程:
1. 确定主设备号
2. 定义实现自己的file_operations结构体(核心)
3. 实现入口函数,在入口函数中注册驱动程序
4. 实现出口函数,在出口函数中卸载驱动程序
5. 其他,如创建设备节点
以一个最简单的字符驱动程序为例详细解释驱动实现过程,实现一个hello.drv驱动程序,应用程序可以往驱动程序写字符串,也可从驱动程序中读取字符串;实例驱动程序实现过程:
1. 确定主设备号
static int major =0;//定义一个设备号,由内核分配
//定义主设备号: major 是一个整数变量,用于存储字符设备的主设备号(major number)。
//初始化为 0: 当 major 被初始化为 0 时,这表示驱动程序希望由内核自动分配一个主设备号,而不是手动指定一个固定的设备号。
主设备号 (Major Number): 用于标识驱动程序类型,告诉内核使用哪个驱动程序来处理设备的 I/O 操作。
-
2. 定义实现自己的file_operations结构体(核心)
-
/* 定义自己的file_operations结构体*/ static struct file_operations hello_drv = { .owner = THIS_MODULE, //这是一个指向当前模块的指针,THIS_MODULE 是一个宏,它表示当前加载的内核模块。 .open = hello_drv_open,//设备文件的打开操作,对应于用户空间调用 open() 系统调用时内核调用的函数。 .read = hello_drv_read,//设备文件的读取操作,对应于用户空间调用 read() 系统调用时内核调用的函数。 .write = hello_drv_write,//设备文件的写入操作,对应于用户空间调用 write() 系统调用时内核调用的函数。 .release = hello_drv_close,//设备文件的关闭操作,对应于用户空间调用 close() 系统调用时内核调用的函数。 };
什么是file_operations结构体?
file_operations
结构体是 Linux 内核中用来描述字符设备驱动操作的一组函数指针。每个指针指向一个函数,这些函数定义了与设备交互时所需的各种操作。通过实现这些函数,开发者可以定制字符设备驱动程序的行为。原型如下;
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 *);
unsigned int (*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 *);
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);
ssize_t (*dedupe_file_range)(struct file *, u64, u64, struct file *,
u64);
};
为什么字符设备驱动程序的核心是实现一个file_operations结构体?
因为在 Linux 系统中,设备驱动程序需要与用户空间进行交互,而这种交互通常是通过文件操作接口来实现的。在 Linux 中,设备通常被抽象为文件,用户空间的程序通过系统调用(如 open
、read
、write
、close
等)与这些“文件”进行交互。file_operations
结构体就是这种交互的桥梁。详细解释如下;
一切皆文件的设计哲学:
在 Linux 中,几乎所有的东西都被抽象为文件,包括常规文件、设备、管道、套接字等。这种设计使得各种操作都可以通过统一的文件 I/O 接口来完成,如 open
、read
、write
、close
等系统调用。当你在用户空间打开一个文件(包括字符设备文件)时,系统会为该文件创建一个 struct file
结构体,表示文件的具体实例。这个结构体保存了文件的状态信息,如文件的当前偏移量、访问模式等。
file_operations
结构体的重要性:
file_operations
结构体是 Linux 字符设备驱动程序的核心。它包含了一组函数指针,这些指针指向的函数实现了字符设备的各种操作,如打开、关闭、读写等。
当用户空间通过文件操作接口访问设备时,内核会调用 struct file_operations
中对应的函数。例如,当用户调用 read()
读取设备时,内核会调用 file_operations
结构体中的 .read
函数指针对应的函数,完成数据读取操作。
struct file
与 struct file_operations
的关系:
每当用户空间打开一个文件时,会得到一个整数的文件句柄,每一个文件句柄在内核中会有一个“struct file”结构体与之对应。内核会创建一个 struct file
结构体,这个结构体代表了用户对该文件的一次具体操作。struct file
结构体中有一个指向 struct file_operations
的指针 f_op
。
f_op
指向的 file_operations
结构体由驱动程序提供,它定义了该设备的具体操作方法。当用户对该文件执行操作(如读写)时,内核会通过 f_op
找到相应的操作函数并调用。
为什么要实现 file_operations
:
实现 file_operations
结构体是为了让内核知道如何处理来自用户空间的操作请求。通过实现 file_operations
结构体中的函数,驱动程序开发者可以控制设备的行为。
例如,当用户空间程序打开设备文件时,内核会调用 file_operations
中的 .open
函数;当用户读取设备时,内核会调用 .read
函数。每个函数都可以根据具体设备的需求来定制实现,从而使得设备能够按照预期的方式工作。
3. 填充file_operations中的函数
对于本示例只需要实现open、write、read、release函数即可
/*实现对应的open/read/write等函数,填入file_operations结构体 */
static ssize_t hello_drv_read(struct file *file, char __user *buf, size_t size, loff_t *offset)
{
int err;
printk("%s %s line %d\n",__FILE__,__FUNCTION__,__LINE__);//在 Linux 内核中用于记录消息;__FILE__:该宏扩展为当前源文件的名称,表示为字符串文字。__FUNCTION__:该宏扩展为当前函数的名称,表示为字符串文字。__LINE__:该宏扩展为源文件中的当前行号。
err=copy_to_user(buf, kernel_buf, MIN(1024,size));//从kernel_buf拷贝到用户空间
return MIN(1024,size);
}
static ssize_t hello_drv_write (struct file *file, const char __user *buf, size_t size, loff_t *offset)
{
int err;
printk("%s %s line %d\n",__FILE__,__FUNCTION__,__LINE__);
err=copy_from_user(kernel_buf, buf, MIN(1024,size));//从用户空间拷贝到kernel_buf
return MIN(1024,size);
}
static int hello_drv_open (struct inode *node, struct file *file)
{
printk("%s %s line %d\n",__FILE__,__FUNCTION__,__LINE__);
return 0;
}
static int hello_drv_close (struct inode *node, struct file *file)
{
printk("%s %s line %d\n",__FILE__,__FUNCTION__,__LINE__);
return 0;
}
本实例驱动程序是实现应用层和驱动层之间进行字符串数据的交互,所以在"hello_drv_read"中用于将内核空间的数据拷贝到用户空间,由于在 Linux 内核中,内存被分为用户空间(user space)和内核空间(kernel space),用户空间用于普通用户程序运行,而内核空间用于操作系统核心部分运行。为了保证安全性和隔离,直接访问内核空间和用户空间的内存是不被允许的。因此,需要专门的函数来进行安全的数据复制。copy_to_user将数据从内核空间复制到用户空间;
在hello_drv_write 中实现将用户空间数据复制到内核空间,需要调用copy_from_user函数;
由于本实例程序比较简单在hello_drv_open 中只提供接口不需要实现其他功能;
4. 实现入口函数
/* 把file_operations结构体告诉内核:注册驱动程序 */
/* 谁来注册驱动程序啊?得有一个入口函数:安装驱动程序时,就会去调用这个入口函数 */
static int __init hello_init (void)//入口函数,在入口函数中将file_operations结构体告诉内核
{
int err;
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
major=register_chrdev(0,"hello",&hello_drv);//注册驱动程序,返回主设备号
hello_class = class_create(THIS_MODULE, "hello_class");
err = PTR_ERR(hello_class);
if (IS_ERR(hello_class))
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
unregister_chrdev(major, "hello");
return -1;
}
device_create(hello_class, NULL, MKDEV(major, 0), NULL, "hello"); /* /dev/hello */
return 0;
}
什么是字符设备驱动程序中的入口函数?
在 Linux 内核驱动开发中,入口函数(通常以 __init
宏修饰)是驱动模块初始化的起点。这个函数的主要作用是完成驱动程序的初始化工作,例如注册字符设备、创建设备节点、分配资源等。
在上面我们已经实现的file_operations结构体,并且已经填充相应的函数,
我们实现的file_operations怎么告诉内核?我们在应用层使用驱动程序,需要设备节点,设备节点怎么得来?
所以需要一个入口函数,这些工作都在入口函数中实现。在入口函数中我们需要把file_operations 结构体告诉内核,调用register_chrdev函数注册字符设备;返回主设备号。
major = register_chrdev(0, "hello", &hello_drv);
//register_chrdev:这是一个用于注册字符设备的函数。它会向内核注册一个字符设备并返回分配的主设备号(major number)。该函数需要三个参数:
//第一个参数 0 表示让内核自动分配一个可用的主设备号。
//第二个参数 "hello" 是设备的名称。
//第三个参数 &hello_drv 是指向 file_operations 结构体的指针,用于定义字符设备的操作函数。
创建设备类和设备节点,
hello_class = class_create(THIS_MODULE, "hello_class");
device_create(hello_class, NULL, MKDEV(major, 0), NULL, "hello");
//class_create:创建一个设备类,用于设备节点的管理。设备类是一个逻辑分组,可以将相似的设备组织在一起。
//device_create:在 /dev 目录下创建一个设备节点(如 /dev/hello),用户空间的程序可以通过这个设备节点与内核中的字符设备交互。
5. 实现出口函数
有入口函数必要要有出口函数,在入口函数中,注册驱动、创建设备类和设备节点。那么在出口函数中需要卸载驱动,销毁设备节点;
在 Linux 驱动开发中,出口函数(通常以 __exit
宏修饰)用于在卸载驱动程序时执行清理操作,释放在入口函数中分配的资源。出口函数是保证驱动程序安全卸载和系统稳定的关键部分。
/* 6. 有入口函数就应该有出口函数:卸载驱动程序时,就会去调用这个出口函数 */
static void __exit hello_exie (void)
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
device_destroy(hello_class, MKDEV(major, 0));
class_destroy(hello_class);
unregister_chrdev(major,"hello");//主设备号和名字
}
在上述我们实现了入口函数和出口函数,那么怎么证明我们实现的就是入口函数、出口函数呢?
需要通过宏定义来定义入口函数、出口函数;
module_init(hello_init);//module_init 宏用于指定驱动模块的入口函数,也就是当模块被加载到内核时第一个被执行的函数。
module_exit(hello_exie);//module_exit 宏用于指定驱动模块的出口函数,即当模块从内核中卸载时(例如使用 rmmod 命令),将会执行的函数。
MODULE_LICENSE("GPL");//MODULE_LICENSE 宏用于指定模块的许可证类型。这对于模块的合法性和内核的合规性非常重要。许可证声明告诉内核模块的版权和使用条款。
这里 "GPL"
表示模块是遵循 GNU General Public License(通用公共许可证)的。这是一个开源许可证,要求任何链接到 Linux 内核的模块(例如驱动程序)必须是开源的,并且发布时需要提供源码。
3. 实例驱动程序源码
通过上述步骤解释,阅读实例整个驱动源码
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/errno.h>
#include <linux/miscdevice.h>
#include <linux/kernel.h>
#include <linux/major.h>
#include <linux/mutex.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/stat.h>
#include <linux/init.h>
#include <linux/device.h>
#include <linux/tty.h>
#include <linux/kmod.h>
#include <linux/gfp.h>
/* 1. 确定主设备号 */
static int major =0;//定义一个设备号,由内核分配
static char kernel_buf [1024];
#define MIN(a,b) (a<b ? a:b)
static struct class *hello_class;
/* 3. 实现对应的open/read/write等函数,填入file_operations结构体*/
static ssize_t hello_drv_read(struct file *file, char __user *buf, size_t size, loff_t *offset)
{
int err;
printk("%s %s line %d\n",__FILE__,__FUNCTION__,__LINE__);//在 Linux 内核中用于记录消息;__FILE__:该宏扩展为当前源文件的名称,表示为字符串文字。__FUNCTION__:该宏扩展为当前函数的名称,表示为字符串文字。__LINE__:该宏扩展为源文件中的当前行号。
err=copy_to_user(buf, kernel_buf, MIN(1024,size));//从kernel_buf拷贝到用户空间
return MIN(1024,size);
}
static ssize_t hello_drv_write (struct file *file, const char __user *buf, size_t size, loff_t *offset)
{
int err;
printk("%s %s line %d\n",__FILE__,__FUNCTION__,__LINE__);
err=copy_from_user(kernel_buf, buf, MIN(1024,size));//从用户空间拷贝到kernel_buf
return MIN(1024,size);
}
static int hello_drv_open (struct inode *node, struct file *file)
{
printk("%s %s line %d\n",__FILE__,__FUNCTION__,__LINE__);
return 0;
}
static int hello_drv_close (struct inode *node, struct file *file)
{
printk("%s %s line %d\n",__FILE__,__FUNCTION__,__LINE__);
return 0;
}
/* 2. 定义自己的file_operations结构体 */
static struct file_operations hello_drv =
{
.owner = THIS_MODULE,
.open = hello_drv_open,
.read = hello_drv_read,
.write = hello_drv_write,
.release = hello_drv_close,
};
/* 4. 把file_operations结构体告诉内核:注册驱动程序 */
/* 5. 谁来注册驱动程序啊?得有一个入口函数:安装驱动程序时,就会去调用这个入口函数 */
static int __init hello_init (void)//入口函数,在入口函数中将file_operations结构体告诉内核
{
int err;
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
major=register_chrdev(0,"hello",&hello_drv);//注册驱动程序,返回主设备号
hello_class = class_create(THIS_MODULE, "hello_class");
err = PTR_ERR(hello_class);
if (IS_ERR(hello_class))
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
unregister_chrdev(major, "hello");
return -1;
}
device_create(hello_class, NULL, MKDEV(major, 0), NULL, "hello"); /* /dev/hello */
return 0;
}
/* 6. 有入口函数就应该有出口函数:卸载驱动程序时,就会去调用这个出口函数 */
static void __exit hello_exie (void)
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
device_destroy(hello_class, MKDEV(major, 0));
class_destroy(hello_class);
unregister_chrdev(major,"hello");//主设备号和名字
}
/* 7. 其他完善:提供设备信息,自动创建设备节点 */
module_init(hello_init);
module_exit(hello_exie);
MODULE_LICENSE("GPL");
阅读这个驱动程序首先从入口函数开始,入口函数中主要工作是调用register_chrdev()函数
向内核注册了一个file_operations结构体:hello_drv,这是字符设备驱动程序的核心;
file_operations结构体由我们自己实现,对于本实例比较简单仅仅提供了read、open、write、release成员。应用程序调用read、open、write、close时就会导致这些成员函数被调用;
file_operations结构体hello_drv中的成员函数都比较简单,大多数只是打印而已。要注意的是,驱动程序和应用程序之间传递数据要使用 copy_from_user/copy_to_user函数。
4. 测试
1. 实现应用程序
驱动程序仅仅提供对硬件的抽象和操作接口,想实际测试和使用这些接口需要编写用户空间的应用程序。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
/*
* ./hello_drv_test -w abc
* ./hello_drv_test -r
*/
int main(int argc, char **argv)
{
int fd;
char buf[1024];
int len;
/* 1. 判断参数 */
if (argc < 2)
{
printf("Usage: %s -w <string>\n", argv[0]);
printf(" %s -r\n", argv[0]);
return -1;
}
/* 2. 打开文件 */
fd = open("/dev/hello", O_RDWR);
if (fd == -1)
{
printf("can not open file /dev/hello\n");
return -1;
}
/* 3. 写文件或读文件 */
if ((0 == strcmp(argv[1], "-w")) && (argc == 3))
{
len = strlen(argv[2]) + 1;
len = len < 1024 ? len : 1024;
write(fd, argv[2], len);
}
else
{
len = read(fd, buf, 1024);
buf[1023] = '\0';
printf("APP read : %s\n", buf);
}
close(fd);
return 0;
}
应用程序比较简单,只需要调用open打开字符设备节点,然后调用用read、write函数读、写数据即可;
2. 编写管理驱动程序
驱动程序中包含了很多头文件,这些头文件来自内核,不同的ARM板它的某些头文件可能不同。所以编译驱动程序时,需要指定板子所用的内核的源码路径。
要编译哪个文件?这也需要指定,设置obj-m变量即可
怎么把.c文件编译为驱动程序.ko?这要借助内核的顶层Makefile。
本驱动程序的Makefile内容如下:
# 1. 使用不同的开发板内核时, 一定要修改KERN_DIR
# 2. KERN_DIR中的内核要事先配置、编译, 为了能编译内核, 要先设置下列环境变量:
# 2.1 ARCH, 比如: export ARCH=arm64
# 2.2 CROSS_COMPILE, 比如: export CROSS_COMPILE=aarch64-linux-gnu-
# 2.3 PATH, 比如: export PATH=$PATH:/home/book/100ask_roc-rk3399-pc/ToolChain-6.3.1/gcc-linaro-6.3.1-2017.05-x86_64_aarch64-linux-gnu/bin
# 注意: 不同的开发板不同的编译器上述3个环境变量不一定相同,
# 请参考各开发板的高级用户使用手册
KERN_DIR = /home/book/100ask_imx6ull-sdk/Linux-4.9.88
all:
make -C $(KERN_DIR) M=`pwd` modules
$(CROSS_COMPILE)gcc -o hello_drv_test hello_drv_test.c
clean:
make -C $(KERN_DIR) M=`pwd` modules clean
rm -rf modules.order
rm -f hello_drv_test
obj-m += hello_drv.o
先设置好交叉编译工具链,编译好你的板子所用的内核,然后修改Makefile 指定内核源码路径,最后即可执行make命令编译驱动程序和测试程序。
3. 测试驱动程序
make命令编译后有多个文件
将hello_drv.ko和测试程序hello_drv_test复制到ARM板上;
先insmod安装驱动程序,因为我们在入口函数中创建了设备节点,安装驱动程序后在/dev目录下会自动生成设备节点;
安装驱动程序后就可直接执行应用程序;