目录:

  • 1.字符设备驱动简介
  • 2.字符设备驱动开发步骤
    • 2.1. 驱动模块的加载与卸载
    • 2.2. Makefile的编写
    • 2.3.字符设备的注册与注销
      • 2.3.1.设备号的组成
      • 2.3.2.设备号的分配
    • 2.4.具体操作函数的实现
      • 2.4.1.进行打开和关闭操作
      • 2.4.2.对chrdev进行读写操作
  • 3.具体程序的实现
    • 3.1.驱动程序的编写
    • 3.2.应用程序的编写
    • 3.3. 运行测试

1.字符设备驱动简介

字符设备是Linux中最基本的一类设备驱动,我们常见的点灯、按键、IIC、SPI、LCD等等都是通过字符设备驱动框架来进行开发的。字符设备驱动是通过一个一个字节流的方式来进行读写操作设备,读写数据是分先后顺序的。
通过空间划分的方式来说,Linux系统中分为用户空间和内核空间,用户空间和内核空间是不能随意进行数据传输的,必须通过特殊的API函数来进行传输,这些特殊的API函数大部分就是通过C库中的API函数来进行用户空间和内核空间的数据传输,至于这些C库中的API函数是如何陷入到内核空间中的,这一点我目前也不太懂,等以后懂了再写文章来解释这一过程。
通过一个例子来说明,就是如果你去银行取钱,这个钱就是具体的设备;如果你想要取到钱,就要去银行去取钱,银行就是Linux操作系统;银行的金库我们普通人是不能随便进出的,所以银行的金库就是Linux系统的内核空间;我们在的营业大厅就是Linux系统的用户空间;如果你想从营业大厅到银行金库取到钱(这一过程就是从Linux的用户空间来访问内核空间),就要通过营业员来进行业务的操作,这个营业员就是C库中的API函数;通过这样一个过程,我们就可以成功从银行取到钱,也就是能从用户空间来访问内核空间的数据。
同样,通过内核空间来编写的程序,并且编译过后生成一个.ko文件并最终挂载到zImage中就是嵌入式的驱动开发;通过用户空间来编写的程序,并且编译过后生成一个应用程序,就是嵌入式的应用开发。
那么具体是怎么来进行数据交换的呢,具体就是,在应用程序中通过调用open,close,read,write等函数来对应C库中的具体函数,然后通过对应的库函数进入系统内核进行调用驱动函数中的open,close,read,write等函数来实现具体的硬件设备操作。应用程序对驱动程序的调用流程如下图所示。

在Linux中一切皆文件,驱动加载成功以后会在/dev目录下生成一个相应的文件,文件名就是我们注册的时候起的文件名(这个文件名可以自己定义)。用户空间中的应用程序通过对这个名为“/dev/xxx(xxx)就是文件名”的操作来进行对内核空间中驱动程序的调用,实现对设备的使用。

以open函数为例,当应用程序中调用open函数以后,应用程序中的open函数就会对应C库中的open函数,然后陷入内核中,进行open函数的系统调用,最后调用驱动的open函数来实现驱动程序中的相关操作。具体流程图如下图所示。

2.字符设备驱动开发步骤

我们学习stm32的时候,不管是用寄存器的方法还是库函数的方法,都是通过配置相应的寄存器来使用板子的具体外设的。在Linux驱动开发中,我们也需要配置每个功能的相关寄存器。但是,在我们配置相关寄存器之前,都需要进行驱动框架的搭建,只有满足这样一个驱动框架,系统才能识别出你编写的驱动程序,然后我们才能进行相关寄存器的配置,最后使能相关设备。所以说,学习Linux驱动开发的重点是学习其驱动开发框架。

2.1. 驱动模块的加载与卸载

Linux驱动有两种的运行方式,一种方式就是通过将驱动编译进Linux内核的方式,就是我们在配置defconfig生成.config或者通过图像化配置界面(命令为make menuconfig)来进行配置的,通过将驱动编译进内核生成zImage来使驱动运行,但是这种方法的缺点就是每次修改一些参数都要重新编译内核,编译过内核的人都知道,编译内核少则十几分钟,多则半个小时,所以这种方法来使驱动运行是非常不方便的。
由于我们不能每次都编译内核,所以我们可以采用下面的这种方式,就是将每个驱动都加载成模块,在Linux内核启动以后,我们通过命令来将这个模块挂载到内核中,使驱动运行。这样如果我们修改了驱动程序中的某段代码,我们也没有必要来编译内核,只需要重新编译驱动程序生成模块,就可以重新挂载和卸载。

模块有挂载和卸载两种操作,那我们在驱动程序编写的过程中自然也会对应两种操作函数,模块的加载和卸载的注册函数如下:

module_init(xxx_init); //注册模块加载函数module_exit(xxx_exit); //注册模块卸载函数

其中,module_init是用来向内核挂载一个模块的函数,xxx_init就是需要进行注册的具体函数,我们可以在xxx_init中来实现具体的一系列的初始化的功能;module_exit是用来向内核卸载一个模块的函数,xxx_exit就是进行卸载的具体函数,在这个函数里面,需要进行一些收尾的工作,比如说,注销字符设备驱动、关灯等等操作。

当内核加载以后,肯定要通过一些命令,让编译完成的模块挂载到内核中。当一个模块第一次放到/rootfs/lib/modules/4.1.15中的时候,需要使用depmod命令来实现第一次的挂载;然后通过modprobe xxx.ko的方式来挂载具体的模块,一旦模块挂载到内核中,上面的xxx_init就会执行;当需要卸载模块的时候,就使用rmmod xxx.ko命令来卸载相关的模块,一旦模块从内核中卸载,就会xxx_exit函数就会执行,来进行后续的收尾工作。

既然讲到了加载函数和卸载函数,我们就在这个地方直接来写这个函数的代码。
我们首先建立三个文件,chrdev.c,chrdevAPP.c和Makefile,我们通过chrdev.c来编写驱动程序,通过chrdevAPP.c来编写应用程序,通过Makefile来编写批量编译文件。

所以我们就先在chrdev.c函数中来编写模块加载函数和模块卸载函数。(相关的头文件自己抄一下)

#include #include #include #include #include #include static int __init chrdev_init(void) //模块挂载以后运行的初始化函数{return 0;}static void __exit chrdev_exit(void) //模块卸载以后运行的退出函数{}module_init(chrdev_init); //注册模块加载函数module_exit(chrdev_exit); //注册模块卸载函数MODULE_LICENSE("GPL");//添加许可证信息MODULE_AUTHOR("kk");//添加模块作者信息

上面的MODULE_LICENSE是必须要写的,不然会报错,MODULE_AUTHOR是用来记录模块编写作者的,可写可不写。

2.2. Makefile的编写

当我们搭建好具体的加载函数和卸载函数以后,我们就可以进行编译来验证我们写的代码是否正确,那么编译代码就是要通过Makefile来进行编译,所以我们就来写具体的makefile的内容。由于我目前对makefile的语法没有掌握的太深,因此只能抄一下原子哥的代码。

KERNELDIR := /home/kk/Linux/imx6ull/kernel/kk_kernel/linux_nxpCURRENT_PATH := $(shell pwd)obj-m := chrdev.obuild: kernel_moduleskernel_modules:$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) modulesclean:$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) clean

第一行KERNELDIR是表示开发板所使用的Linux内核源码的目录,使用绝对的路径,这个需要根据自己的情况来编写。
下面一行中的CURRENT_PATH表示当前路径,直接通过运行命令“pwd”命令来获取当前所处的路径。
下一行的obj-m表示将chrdev.c编译成chrdev.ko,所以我们如果要修改驱动文件的话,就直接修改这一行来生成不同的.ko文件。
后面的代码就是用来执行具体的编译命令和清理命令。

在Makefile文件编写完成后,我们可以在终端使用make命令,可以看到生成的.ko文件,说明程序编写正常

再使用make clean命令可以将编译后生成的文件进行清理

2.3.字符设备的注册与注销

对于字符设备驱动而言,在驱动模块加载成功以后,也需要向内核注册字符设备,同样,卸载驱动模块的时候也需要注销掉字符设备,注册字符设备和注销字符设备同样是通过内核向外提供的API函数来进行注册和注销的,所以驱动开发的一切过程都是在一系列的框架下来进行的(说白了就是,所有的操作都有套路可言)。
字符设备的注册和注销函数原型如下:

static inline int register_chrdev(unsigned int major, const char *name,const struct file_operations *fops)static inline void unregister_chrdev(unsigned int major, const char *name)

register_chrdev函数用于注册字符设备,此函数一共有三个参数,这三个参数的含义如下:
major:主设备号,Linux下每个设备都有一个设备号,设备号分为主设备号和次设备号。
name:设备名字,这个设备名字就是我们以后要在“/dev/name”,来进行操作的设备的标记。
fops:结构体file_operations类型指针,指向设备的操作函数集合变量。这个file_operations中包含了open,write,read,release等内容,应用程序就是通过代码中的open,write等函数来通过C库陷入到内核中,来操作file_operations中的对应函数来进行对设备的操作的.

unregister_chrdev函数是用户用于注销字符设备的函数,此函数有两个参数,这两个参数的含义如下:
major:主设备号,要注销的设备对应的主设备号。
name:要注销的设备对应的设备名。

一般来讲,字符设备的注册在驱动模块的入口函数chrdev_init函数中进行,字符设备的注销在驱动模块的出口函数chrdev_exit中进行。

2.3.1.设备号的组成

为了方便管理,Linux中的每个设备都有一个设备号,设备号由主设备号和次设备号两个部分组成,主设备号表示某一个具体的驱动,比如说IIC驱动,次设备号表示使用这个设备驱动的各个设备,比如说IIC设备下有MPU6050这个具体的设备,所以用主设备号和次设备号就能准确的锁定某个设备。

Linux中提供了一个名为dev_t的数据类型来表示设备号。dev_t其实就是unsigned int类型的,是一个32位的数据类型。这32位的数据构成了主设备号和次设备号两个部分,32位中的高12位为主设备号,低20位为次设备号。

2.3.2.设备号的分配

静态分配设备号

我们进行字符设备注册的时候需要向内核申请设备号,所谓的静态分配,就是我们人为指定一个设备号,然后进行申请,我们前面使用的register_chrdev函数就是使用静态的方法来向内核申请一个设备号的。但是要注意,向内核申请的设备号不能和现有的设备冲突,所以我们需要提前查看一下原来的设备中有哪些设备号已经被占用。当我们进入rootfs中以后,可以用cat/proc/devices来查看哪些设备号已经被占用了。


我们可以看到,在这个里面,有很多的块设备和字符设备都已经占用了一些设备号,所以我们在静态申请的时候就不能用这些设备号。

动态分配设备号

前面的静态分配设备号的过程很容易引起设备号的冲突的问题,所以就有了动态分配设备号的方法,在注册字符设备之前先动态申请一个设备号,然后系统就会自动给分配一个没有用的设备号,这样就避免了冲突的存在。动态申请设备号的函数如下:

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)

此函数有4个参数:
dev:用于保存申请到的设备号,需要用传址的方式来进行操作。
baseminor:次设备号的起始地址,alloc_chrdev_region可以连续申请一系列的次设备号,这些设备号的主设备号相同,但是次设备号不同,次设备号以baseminor为起始地址开始递增。一般来讲baseminor为0,也就是说次设备号从0开始。
count:要申请的次设备号的数量,一般我们做实验的时候都只申请一个次设备号。
name:设备名字,这个设备名字和前面register_chrdev中的name的作用相同。

同样,在注销字符设备以后也要释放掉设备号,设备号释放的函数如下:

void unregister_chrdev_region(dev_t from, unsigned count)

此函数有2个参数:
from:要释放的设备号。
count:表示从form开始,要释放的设备数量,一般都是申请一个,释放一个。

那么我们从这个地方来补充一下字符设备注册和注销的代码。

#include #include #include #include #include #include #define CHRDEV_NAME "chrdev" //注册的设备的名字#define CHRDEV_MAJOR249 //注册的主设备号/*open,read,write,release函数函数原型就是这样,我们照着这个函数原型写就好了,后面会用到函数的部分参数*/static int chrdev_open(struct inode* inode,struct file *filp){return 0;}static ssize_t chrdev_read(struct file *filp,char __user* buf,size_t cnt,loff_t* offt){return 0;}static ssize_t chrdev_write(struct file* filp,const char __user* buf,size_t cnt,loff_t* offt){return 0;}static int chrdev_release(struct inode* inode,struct file *filp){return 0;}static struct file_operations chrdev_fops = {.owner = THIS_MODULE, //.owner,一般都使用THIS_MODULE,具体我也不明白,但是都是使用THIS_MODULE.open = chrdev_open,//open函数,对应应用程序中的open函数.read = chrdev_read,//read函数,对应应用程序中的read函数.write = chrdev_write,//write函数,对应应用程序中的write函数.release = chrdev_release,//relaese函数,对应应用程序中的release函数};static int __init chrdev_init(void) //模块挂载以后运行的初始化函数{int ret = 0;ret = register_chrdev(CHRDEV_MAJOR,CHRDEV_NAME,&chrdev_fops);//注册字符设备驱动if(ret < 0){/*如果注册字符设备驱动的返回值小于0,则字符设备注册失败,如果等于0则注册成功*/printk("chrdev driber register failed!!\r\n");return -1;}printk("chrdev init success!!\r\n");//如果注册成功,打印出一行注册成功的信息return 0;}static void __exit chrdev_exit(void) //模块卸载以后运行的退出函数{unregister_chrdev(CHRDEV_MAJOR,CHRDEV_NAME);//注销字符设备驱动printk("chrdev exit success!!\r\n");}module_init(chrdev_init); //注册模块加载函数module_exit(chrdev_exit); //注册模块卸载函数MODULE_LICENSE("GPL");//添加许可证信息MODULE_AUTHOR("kk");//添加模块作者信息

补充完上面的代码以后,就基本上将字符设备驱动开发的框架搭建好了,以后每次都需要搭建这样一个框架。

当然,我们也可以看到在静态申请设备号的时候,我们只用宏定义了主设备号,但是没有定义次设备号,这就涉及到用register_chrdev进行字符设备注册的一个问题:我们每次用这个函数注册的时候都会将一个主设备号下的所有次设备号都用,比如说我们现在设备的主设备号为249,那么就会将249下面的所有的次设备号都用掉,这样就会造成大量的浪费,这个我们在后续使用新的字符设备注册方法的时候可以解决。

2.4.具体操作函数的实现

2.4.1.进行打开和关闭操作

设备打开和关闭是最基本的要求,所有的设备都得提供打开和关闭的功能,所以我们都需要实现file_operations中的open和release这两个函数,这两个函数也对应了应用程序中的open函数和release函数,我们可以从open函数和release函数中做一些事情,当应用程序中调用open函数的时候,可以初始化相关的参数,在release函数中也可以释放掉一些变量。

2.4.2.对chrdev进行读写操作

假设chrdev这个设备控制着一段缓冲区,那么应用程序需要通过read函数和write函数对chrdev这个缓冲区进行读写操作。那么用户空间的应用程序就是通过file_operations中的相关函数来实现对设备缓冲区的操作,也可以实现对设备的驱动。

3.具体程序的实现

我们此次实现的目标就是通过应用程序读取驱动程序中的一串字符串,然后再屏幕上进行打印;然后通过应用程序向驱动程序中写入一串字符串,在屏幕上打印出来。

3.1.驱动程序的编写

#include #include #include #include #include #include #define CHRDEV_NAME "chrdev" //注册的设备的名字#define CHRDEV_MAJOR249 //注册的主设备号static char readbuf[100]; //读缓冲区static char writebuf[100];//写缓冲区static char kerneldata[] = {"kernel data!"}; //应用程序从驱动程序中读取的内容/*open,read,write,release函数函数原型就是这样,我们照着这个函数原型写就好了,后面会用到函数的部分参数*/static int chrdev_open(struct inode* inode,struct file *filp){return 0;}/*read函数就是用户空间从内核空间读取,是从用户空间来考虑的,所以驱动程序的read函数其实是向用户空间进行write*/static ssize_t chrdev_read(struct file *filp,char __user* buf,size_t cnt,loff_t* offt){int retvalue = 0;memcpy(readbuf,kerneldata,sizeof(kerneldata));//通过memcpy将读取内容拷贝到读缓冲区,这一步其实就是多了一个函数的用法。retvalue = copy_to_user(buf,readbuf,cnt);//使用copy_to_user将内核空间的数据拷贝到用户空间,其中的buf就是向用户空间传递的内容/*如果copy_to_user的返回值是0,则说明发送成功,如果返回值是其他值,说明传输失败*/if(retvalue == 0) {printk("kernel senddata ok!\r\n");}else{printk("kernel senddata failed!\r\n");}return 0;}/*write函数就是用户空间向内核空间写数据,所以驱动程序的write函数其实是从用户空间进行read*/static ssize_t chrdev_write(struct file* filp,const char __user* buf,size_t cnt,loff_t* offt){int retvalue = 0;/*retvalue的值的处理和上面read函数的处理方法相同*/retvalue = copy_from_user(writebuf,buf,cnt);if(retvalue == 0){printk("kernel recevdada:%s\r\n",writebuf);}else{printk("kernel recevdata failed!\r\n");}return 0;}static int chrdev_release(struct inode* inode,struct file *filp){return 0;}static struct file_operations chrdev_fops = {.owner = THIS_MODULE, //.owner,一般都使用THIS_MODULE,具体我也不明白,但是都是使用THIS_MODULE.open = chrdev_open,//open函数,对应应用程序中的open函数.read = chrdev_read,//read函数,对应应用程序中的read函数.write = chrdev_write,//write函数,对应应用程序中的write函数.release = chrdev_release,//relaese函数,对应应用程序中的release函数};static int __init chrdev_init(void) //模块挂载以后运行的初始化函数{int ret = 0;ret = register_chrdev(CHRDEV_MAJOR,CHRDEV_NAME,&chrdev_fops);//注册字符设备驱动if(ret < 0){/*如果注册字符设备驱动的返回值小于0,则字符设备注册失败,如果等于0则注册成功*/printk("chrdev driber register failed!!\r\n");return -1;}printk("chrdev init success!!\r\n");//如果注册成功,打印出一行注册成功的信息return 0;}static void __exit chrdev_exit(void) //模块卸载以后运行的退出函数{unregister_chrdev(CHRDEV_MAJOR,CHRDEV_NAME);//注销字符设备驱动printk("chrdev exit success!!\r\n");}module_init(chrdev_init); //注册模块加载函数module_exit(chrdev_exit); //注册模块卸载函数MODULE_LICENSE("GPL");//添加许可证信息MODULE_AUTHOR("kk");//添加模块作者信息

3.2.应用程序的编写

在应用程序中我们肯定也是需要用到open,write,read,release函数来实现对内核空间的访问。

  1. open函数
    open函数的原型如下:
int open(const char *pathname, int flags)

使用open函数可以打开驱动程序中的open函数,从而可以使用其中的资源来进行相关的配置操作,open函数有两个参数:
pathname:要打开的设备或者文件名。
flags:文件的打开模式,以下三种模式必选其一:
O_RDONLY 只读模式
O_WRONLY 只写模式
O_RDWR 读写模式
除了这三种模式外,我们还有其他的可选模式,其他的如果后面用到我们再进行介绍。

返回值:如果文件打开成功的话,返回值就是文件描述符,文件描述符在后面的read等函数中,都有非常重要的作用;如果文件打开失败的话,返回值就为负数,这样就可以判断文件是否打开正常。

  1. read函数
    read函数的原型如下:
ssize_t read(int fd, void *buf, size_t count)

通过read函数来读取驱动程序中的相关变量,read函数有三个参数,函数参数的定义如下:
fd:要读取的文件的文件描述符,读取之前要用open函数来打开文件,open函数打开文件成功后会得到文件的文件描述符。
buf:数据读取到此buf中。
count:要读取的数据长度,也就是字节数。

返回值:读取成功的话就返回读取到的字节数:如果返回0就表示读取到了文件末尾了如果返回负数就表示读取失败。

  1. write函数
    write函数的原型如下:
ssize_t write(int fd, const void *buf, size_t count)

write函数的各个参数的含义和返回值的信息和read函数是相同的,只不过一个是从内核空间中读取数据,另一个向内核空间中写数据,这里就不再赘述。

  1. close函数
    close函数的原型如下:
int close(int fd);

close函数的作用是用来关闭打开的文件,close函数有两个参数,具体的参数含义如下:
fd:要关闭的文件描述符。

返回值:0表示关闭成功,负值表示关闭失败

下面是具体的应用程序代码:

#include "stdio.h"#include "unistd.h"#include "sys/types.h"#include "sys/stat.h"#include "fcntl.h"#include "stdlib.h"#include "string.h"static char usrdata[] = {"user data!"}; //用户空间的数据/* *@description: main主程序 *@param - argc : argv数组的元素个数 *@param - argv : main函数所要用到的具体的参数 *@return : 0 成功;其他:失败*/int main(int argc,char *argv[]){int fd,retvalue;//定义文件描述符和返回值变量char *filename; //用来存放打开后的文件名char readbuf[100],writebuf[100]; //用来定义读取的缓存区和写入的缓存区/* * 判断应用程序使用是否输入的三个参数,需要使用./chrdevAPP /dev/chrdev 1 或 ./chrdevAPP /dev/chrdev 0来进行读写操作 * 如果使用的是1,则向内核中写入数据;如果输入为0,则从内核读取数据 */if(argc != 3){printf("ERROR usage!\r\n");return -1;}filename = argv[1];//使用命令的时候,/dev/chrdev就是文件名,文件名保存在argv[1]中,将argv[1]的参数赋值filenamefd = open(filename,O_RDWR);//使用读写方式打开驱动文件if(fd < 0)//错误处理{printf("can't open file %s\r\n",filename);return -1;}if(atoi(argv[2]) == 0)//如果输入为0,那么就从内核中读取数据,atoi是一个关键字,输入的1或者0是字符串形式的,可以通过atoi将字符串形式转换成数字形式{retvalue = read(fd,readbuf,50);if(retvalue < 0){printf("read file %s failed!\r\n",filename);}else{ printf("read data:%s\r\n",readbuf);}}if(atoi(argv[2]) == 1)//向内核空间中写入数据{memcpy(writebuf,usrdata,sizeof(usrdata));retvalue = write(fd,writebuf,50);if(retvalue < 0){printf("write file %s failed!\r\n",filename);}}retvalue = close(fd); //close函数,如果要关闭某个文件,就直接用close函数关闭文件描述符即可if(retvalue < 0){printf("can't close file %s\r\n",filename);return -1;}return 0;}

在编写完应用程序代码以后,就可以通过交叉编译工具来编译应用程序,具体的命令是:
arm-linux-gnueabihf-gcc chrdevAPP.c -o chrdevAPP
通过上述命令就可以将chrdevAPP.c程序编译成chrdevAPP应用程序,就可以通过./来使用这个应用程序。

3.3. 运行测试

将编译完成的chrdev.ko和chrdevAPP文件拷贝到/lib/modules/4.1.15,命令为:
sudo cp chrdev.ko chrdevAPP /home/kk/Linux/nfs/rootfs/lib/modules/4.1.15/ -f

开发板上电以后进入/lib/modules/4.1.15后,首先使用depmod来挂载模块,然后通过modprobe chrdev.ko来挂载模块,
然后通过lsmod可以看到已经挂载的模块:

可以看到,在使用modprobe成功挂载模块以后,会打印出提示信息,然后通过lsmod查看已经挂载的模块以后,就可以看到已经挂载好了模块。

挂载好模块以后,使用 cat /proc/devices命令查看系统中的所有设备

可以看到在249号设备号下,成功添加了chrdev设备

在上述工作完成以后,需要手动创建设备节点,应用程序就是通过操作这个设备节点来完成具体设备的操作的。注册完设备以后为什么还有手动创建设备节点呢,原因如下:在Linux中,设备节点是用来与设备进行交互的一种方式。尽管设备已经被注册到系统中,但是节点并不会自动创建。因此,需要手动创建节点,以便用户可以与该设备进行交互。
通过以下命令来手动创建设备节点
mknod /dev/chrdev c 249 0
其中“mknod”是创建节点命令,“/dev/chrdev”是要创建的节点文件,“c”表示这是个字符设备,“200”是设备的主设备号,“0”是设备的次设备号。

完成以上的内容以后,就可以进行设备的操作测试了。
输入以下命令
./chrdevAPP /dev/chrdev 1,结果如下所示:

就可以看到内核从用户空间接收到了user data数据

输入以下命令
./chrdevAP /dev/chrdev 0,结果如下所示:

就可以看到用户空间接收到了内核的kernel data数据。

然后卸载驱动,使用rmmod chrdev.ko命令卸载,结果如下所示:

显示卸载成功。

到此为止,整个字符设备驱动开发,用register_chrdev的方式就全部完成了,由于本人也是学习者,有很多的东西做的也不是特别好,也恳请各位大佬批评指正!!!