简单字符设备文件的原理以及实现
前言
这里以一个简单的字符设备文件
的源码实现为例子,做出仔细分析。
1. 头文件
以下列出的都是Linux 内核中的标准头文件,包含了各种在内核开发中常用的功能和函数。其中包括对模块初始化
、文件操作
、内存管理
以及字符设备
等关键功能的支持。
每行注释函数,分别归属于前面对应的内核库。先不介绍每个函数有什么用,后续使用再详细分析和介绍。
#include <linux/module.h> // module_init module_exit
#include <linux/init.h> // __init __exit
#include <linux/fs.h> // file_operations
#include <linux/uaccess.h> // copy_to-user copy_from_user
#include <linux/cdev.h> // cdev_init cdev_add cdev_del
#include <linux/slab.h> // kmalloc kfree
2. 全局变量定义
#define MYNMAJOR 240 // 主设备号
#define MYNAME "my_chrdev" // 设备名称
char kbuf[100]; // 定义一个缓存数组,用于内核接收数据
static struct cdev *char_dev; // 定义一个字符设备结构体
static int major; // 用于保存主设备号
kbuf
:这个数组在内核中被分配,并且在内核模块的整个生命周期中都可以访问。struct cdev
类型:一种表示字符设备的结构体,可以通过它来管理字符设备的操作。
3. 设备操作的实体函数
这些函数具有标准的划分,分为open
、release
、read
、write
方法。
用户根据具体需求实现,实际上实现功能就是字符驱动程序对应的.open
、.release
、.read
、.write
方法的功能。在结构体中建立设备文件的四大方法与设备驱动的四大方法的连接。
(坚持看下去吧,我也坚持写下来了呢)。
3.1 open方法
该函数是字符设备驱动中的"打开"函数,用于处理用户空间程序打开设备文件时的操作。方法名通常采用driver_name_open
的格式。
struct inode *inode
:代表设备文件的索引节点;struct file *file
:代表设备文件的文件描述符。
通常在该函数内实现:设备初始化、分配资源、设置状态等准备工作。
static int my_chrdev_open(struct inode *inode, struct file *file)
{
// code part for opening the device
printk(KERN_INFO "test_module_open\n");
return 0;
}
返回值为0,表示设备成功打开;返回非零,则表示设备打开错误,该错误码会传递给用户空间程序。
3.2 release方法
与open
函数对应,release
用于处理用户空间程序关闭设备文件时的操作,通常在设备文件关闭的时候执行。
在该函数内通常需要实现:清理资源、关闭设备、保存状态等功能。
static int my_chrdev_release(struct inode *inode, struct file *file)
{
// code part for release the device
printk(KERN_INFO "test_chrdev_release\n");
return 0;
}
返回值为0,表示设备成功释放;返回非零,则表示设备释放错误,该错误码会传递给用户空间程序。
3.3 write方法
这个函数的作用是将用户空间的数据写入设备的数据缓冲区,并可能触发与硬件相关的操作。
struct file *file
:这是表示打开的设备文件的文件描述符的结构体指针。const char __user *buf
:这是用户提供的缓冲区,其中的数据将被写入设备。size_t size
:这是用户要写入的字节数。loff_t *opps
:这是一个表示当前写入位置的指针,用于跟踪在文件中的写入位置。
static ssize_t my_chrdev_write(struct file *file, const char __user *buf, size_t size, loff_t *opps)
{
int ret = -1;
printk(KERN_INFO "my_chrdev_write\n");
// memset函数将缓冲区`kbuf`的内容设置为0,以便在写入新数据之前将之前的数据清楚
memset(kbuf, 0, sizeof(kbuf));
// 将用户提供的数据从用户空间的`buf`缓冲区复制到刚才定义的内核空间中的`kbuf`缓冲区中
ret = copy_from_user(kbuf, buf, size);
if (ret){
printk(KERN_ERR "copy_from_user fail...\n");
return -EINVAL;
}
printk(KERN_INFO "copy_from_user success...\n");
// real sense: we will write some code for operation the hardware, based on the above data
// coding...
return size;
}
在实际应用中,你可能需要根据写入的数据来执行一些特定的操作,比如基于数据来控制硬件设备的行为。
3.4 read 方法
这个函数的作用是从设备的数据缓冲区 kbuf 中读取数据到用户提供的缓冲区中,并更新当前读取位置,以便下一次读取。
struct file *file
:表示打开的设备文件的文件描述符的结构体指针;char __user *buf
:这是用户空间提供的缓冲区,数据将被复制到这个缓冲区;size_t size
:这是用户请求读取的字节数;loff_t *ppos
:这表示一个当前读取所在位置的指针,用于追踪在文件中的读取位置。
static ssize_t my_chrdev_read(struct file *file, char __user *buf, size_t size, loff_t *ppos)
{
int ret = -1;
printk(KERN_INFO "my_chrdev_read\n");
// 计算当前读取位置到数据末尾的剩余字节数
int remaining_bytes = strlen(kbuf) - *ppos;
// 如果剩余字节数小于等于0,表示没有剩余数据可读,函数返回0.
if (remaining_bytes <= 0)
printk(KERN_ERR "no remaining_bytes need to be read\n");
return 0;
// 预防用户读取数据字节数,超出缓冲区剩余可读数据字节数
// 因为用户可以每次设置读取多少个字节数
if (size > remaining_bytes)
size = remaining_bytes;
// 将数据从内核空间的kbyf复制到用户提供的buf缓冲区
if(copy_to_user(buf, kbuf + *ppos, size) != 0)
printk(KERN_ERR "copy_to_user fail\n");
return -EFAULT;
// 更新读取指针,使其指向下一个要读取的位置(感觉指针存储于内核当中,因为是指针,所以会同步更新)
*ppos += size;
printk(KERN_ERR "copy_to_user success...\n");
return size;
}
copy_to_user(buf, kbuf + *ppos, size)
kbuf + *ppos
: 表示将读取指针偏移到,剩余剩余未读取字节的位置首部size
: 表示从 kbuf + *ppos位置开始,需要复制size大小的字节给buf
4 设备文件结构体
该代码定义了一个file_operation
内置类型的结构体,my_modules_fops
,用于将字符设备的操作函数与字符设备驱动程序关联起来。
.owner = THIS_MODULE
:该字段用于指定模块拥有者,通常使用THIS_MODULE,表示当前模块是这个文件操作结构的;.open = my_chrdev_open
:将字符设备的打开操作open与源码中自定义的my_chrdev_open方法建立关联,当用户空间使用open系统调用时,内核会调用my_chrdev_open函数;.release = my_chrdev_release
:字符设备释放函数,将字符设备的释放操作release与源码中自定义的my_chrdev_release方法建立关联,当用户空间使用release系统调用时,内核会调用my_chrdev_release函数;.read = my_chrdev_read
:这一行将字符设备的读取操作与一个自定义的函数 my_chrdev_read 关联起来。当用户空间程序使用 read 系统调用从这个字符设备中读取数据时,内核会调用 my_chrdev_read 函数来执行读取操作。.write = my_chrdev_write
: 这一行将字符设备的写入操作与一个自定义的函数 my_chrdev_write 关联起来。当用户空间程序使用 write 系统调用向这个字符设备中写入数据时,内核会调用 my_chrdev_write 函数来执行写入操作。
static const struct file_operations my_modules_fops = {
.owner = THIS_MODULE, // means it is a driver
.open = my_chrdev_open, // build the link between real function(my_chrdev_open) with device operation api(.open)
.release = my_chrdev_release, // the same
.read = my_chrdev_read,
.write = my_chrdev_write,
};
5. 设备的初始化与销毁
5.1 chrdev_init
字符设备创建及准备工作,其中包括:
- 设备号分配
- 字符设备内存分配
- 字符设备的初始化
- 将字符设备添加到内核的字符设备列表
static int __init chrdev_init(void)
{
int ret;
int devNo; // 保存设备号(主设备号)
// 1. 像系统申请设备号
// devNo设备号地址,次设备号从0开始,1是要分配的设备号数量,CharDriver为设备名称
ret = alloc_chrdev_region(&devNo, 0, 1, "CharDriver");
if (ret < 0){
printk(KERN_ERR "Failed to allocate device number\n");
return ret;
}
// 2. 如果你想修改设备号,可以用如下两行代码
// 否则可以不需要,因为devNo已经是一个MKDEV设备号
major = MAJOR(devNo);
devNo = MKDEV(major, 0); //
// 3. 分配字符设备文件内存
// kzalloc 用于分配内核内存,并将分配的内存区域初始化为零
char_dev = kzalloc(sizeof(struct cdev), GFP_KERNEL);
if (!char_dev){
// 设备内存分配错误,则释放设备号资源
unregister_chrdev_region(devNo, 1);
return 0;
}
// 4. 初始化字符设备结构体
// 在char_dev内存中初始化字符设备结构体,并将设备文件操作my_modules_fops与之关联
// 目的是为了告诉内核,如何处理字符设备的操作
cdev_init(char_dev, &my_modules_fops);
// 5. 将此设备添加到内核的字符设备列表中
// 并告诉内核具体的主次设备号为devNo,设备数量为1
ret = cdev_add(char_dev, devNo, 1);
if (ret < 0){
unregister_chrdev_region(devNo, 1);
printk(KERN_ERR "Failed to add character device\n");
return ret;
}
printk(KERN_INFO "Character driver loaded\n");
return 0;
}
5.2 chrdev_exit
-
cdev_del(char_dev);
: 这一行代码从内核中删除之前添加的字符设备。cdev_del 函数用于删除字符设备的注册,确保不再接受对该设备的操作请求。 -
kfree(char_dev);
: 这一行代码释放之前分配的字符设备结构体 char_dev 所占用的内存。这是为了防止内存泄漏,确保在卸载模块时释放了所有分配的内存。 -
unregister_chrdev_region(MKDEV(major, 0), 1);
: 这一行代码注销之前分配的设备号。它使用 unregister_chrdev_region 函数来释放之前分配的设备号资源,参数 MKDEV(major, 0) 用于构建正确的设备号,1 表示释放一个设备号。
static void __exit chrdev_exit(void)
{
// del character divice
cdev_del(char_dev);
kfree(char_dev);
// release device number
unregister_chrdev_region(MKDEV(major, 0), 1);
printk(KERN_INFO "Character driver unloaded\n");
}
6. 模块初始化与卸载
module_init(chrdev_init);
: 这一行代码用于指定模块的初始化函数。在模块加载时,内核会调用 chrdev_init 函数,这是模块初始化的入口点。
module_exit(chrdev_exit);
: 这一行代码用于指定模块的卸载函数。在模块卸载时,内核会调用 chrdev_exit 函数,这是模块卸载的入口点。
module_init(chrdev_init);
module_exit(chrdev_exit);
MODULE_LICENSE("GPL");
7. 字符设备读写——逻辑梳理
我觉得可以从两个角度两讲:计算机的角度和用户的角度。
- 计算机视角:设备
可以理解为计算机的一些资源(打印机、显示器、GPU显卡等),而每一个设备都应当对应着一个设备驱动程序
,这在操作系统中通过两个链表进行保存的。
你每添加一个设备,就需要安装一个对应的驱动程序;同理,你每安装一个驱动程序,都需要添加一个设备。
即:操作系统是通过
驱动程序
来发出操控设备
的指令。
- 用户视角:设备文件是与设备驱动程序相关联的文件,通过与设备驱动程序进行交互,以便用户和应用程序可以访问硬件设备,而无需了解内核驱动程序的详细实现。
在Unix/Linux中,设备文件通常存在于/dev目录下,并根据设备类型和编号命名。例如,/dev/sda
可能表示系统中的第一个硬盘,/dev/ttyUSB0
可能表示USB串口设备。
重点来了
我们通过执行df -h
查看系统中磁盘的使用情况
喔吼,可以看到两个sda都是位于dev目录下。注意,我们查找的是磁盘的使用情况,而终端通过告诉我们/dev/sda1的使用情况,来进一步告诉我们磁盘使用情况。
这不就对上了吗“Linux中,一切皆文件
”。
原来所谓的皆文件,指的是这些设备资源在Linux中对应着设备文件的概念,这里直接让我悟了。(没看懂的建议反复观看这段内容)
我们操作磁盘、打印机、或者其它硬件设备,操作系统都为用户提供了一个对应的设备文件(设备节点),从而方便用户间接的操作这些硬件设备。
7.2 三者之间的关系
这里面的逻辑可以展示为如下结构图
设备文件与内核驱动程序进行绑定,这意味着它们通过内核驱动程序来实际操作硬件设备。用户和应用程序可以打开、读取、写入和关闭设备文件,而这些操作会触发相应的内核驱动程序操作。
7.3 设备文件的好处
总结:设备文件用提供一种
标准化的方式
来访问硬件设备并屏蔽底层硬件的复杂性
。
7.4 字符文件的读写(逻辑的梳理)
(1)设备驱动的注册
设备驱动三要素:
- 设备号devNo:主设备号和次设备号
- 文件操作结构体file_operation:四个系统调用对应的实体函数实现
- 字符设备结构体cdev:内存的申请,结构体的初始化,在内核中的注册
(2)装载驱动
insmod charDriver.ko # 安装编译好的字符驱动模块
module_init(chrdev_init)————>chrdev_init
(3)写操作
echo "hello Drivers!" > /dev/charDriver
- .open ———> my_chrdev_read
- .write ———> my_chrdev_write
- .release ———>my_chrdev_release
(4)读操作
cat /dev/charDriver
- .open ———> my_chrdev_read
- .read ———> my_chrdev_read
- .release ———>my_chrdev_release
(5)卸载驱动
rmmod charDriver # 卸载指定驱动模块
module_exit(chrdev_exit)————>chrdev_exit
8. 总结
感觉以前操作系统白学了,看来理解一个东西还是需要一定的时间和一定的实践。
东西比较基础,写的比较多且随意,希望有任何问题,可以留言讨论。==!