• 3、字符设备驱动框架和开发步骤


    一、Linux内核对文件的分类

    Linux的文件种类

    • 1、-:普通文件
    • 2、d:目录文件
    • 3、p:管道文件
    • 4、s:本地socket文件
    • 5、l:链接文件
    • 6、c:字符设备
    • 7、b:块设备

    Linux内核按驱动程序实现模型框架的不同,将设备分为三类

    • 1、字符设备:按字节流形式读取的设备,一般情况下按顺序访问,数据量不大,一般不设缓存
    • 2、块设备:按块进行读写的设备,最小的块大小为256字节(一个扇区),块的大小必须是扇区的整数倍。Linux块的大小一般为4096字节,随机访问,设有缓存以提高效率
    • 3、网络设备:针对网络数据收发的设备

    框图

    在这里插入图片描述

    二、设备号

    • 用于区分内核中的同类设备

    内核中用设备号来区分同类里不同的设备,设备号是一个无符号的32位整数数据类型dev_t,设备号分为两部分:

    • 1、主设备号:占高12位,用来表示驱动程序相同的一类设备
    • 2、次设备号:占低20位,用来表示被操作的具体设备

    应用程序打开一个设备时,通过设备号来查找定位内核中管理的具体设备

    MKDEV - 宏

    • 将主设备号和次设备号组合成32位的完整设备号,用法:
    dev_t id;
    int major = 251;//主设备号
    int minor = 2;//次设备号
    id = MKDEV(251, 2);
    
    • 1
    • 2
    • 3
    • 4

    MAJOR - 宏

    • 将主设备号从设备号中分离出来,用法:
    dev_t id = MKDEV(251, 2);
    int major = MAJOR(id);
    
    • 1
    • 2

    MINOR - 宏

    • 将次设备号从设备号中分离出来,用法:
    dev_t id = MKDEV(251, 2);
    int minor = MINOR(id);
    
    • 1
    • 2

    如果已知一个设备的主次设备号,应用指定好设备文件名,可以用mknod命令在/dev目录下创建代表这个设备的文件,即此后应用程序对此文件的操作就是代表对此设备的操作,用法:

    cd /dev
    mknod 设备文件名 设备种类(c位字符设备,b为块设备)	主设备号  次设备号
    
    • 1
    • 2

    在应用程序中如果要创建设备可以调用系统调用函数mknod,用法:

    int mknod(const char *pathname,mode_t mode,dev_t dev);
    pathname:带路径的设备文件名,无路径默认为当前目录,一般都创建在/dev下
    mode:文件权限或S_IFCHR/S_IFBLK
    dev:32位设备号
    
    • 1
    • 2
    • 3
    • 4

    三申请和注册设备号

    字符设备驱动的第一步是通过模块的入口函数__init向内核添加设备驱动的代码框架

    • 1、申请设备号
    • 2、定义、初始化、向内核添加代表本设备的结构体元素
    int register_chrdev_region(dev_t from, unsigned count, const char *name)
    功能:手动分配设备号,先验证设备号是否被使用,如果没有就申请该设备号
    参数:
    	from:自己指定的设备号
    	count:申请的设备数量
    	name:/proc/devices文件中与该设备对应的名字,方便用户层查询主设备号
    返回值:
    	成功为0,失败是负数,绝对值为错误码
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    int alloc_chrdev_region(dev_t *dev,unsigned baseminor,unsigned count, const char *name)
    功能:自动分配设备号,查询内核里未被占用的设备号,如果找到则占用该设备号
    参数:
    	dev:分配设备号成功后用来存放分配到的设备号
    	baseminor:起始的次设备号,一般为0
    	count:申请设备号的数量
    	name:/proc/devices文件中与该设备对应的名字,方便用户层查询主次设备号
    返回值:
    	成功后为0,失败为负数,绝对值为错误码
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 成功分配后在/proc/devices可以查看到申请到的主设备号和设备名

    • 3、释放设备号

    void unregister_chrdev_region(dev_t from, unsigned count)
    功能:释放设备号
    参数:
    	from:已经成功分配到的设备号将被释放
    	count:申请成功的设备数量
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 释放后/proc/devices路径下对应的设备文件记录消失

    四:注册字符设备

    • 1、字符设备结构体
      #include
    struct cdev
    {
    	struct kobject kobj;//表示该类型实体是一种内核对象(kernel object)
    	struct module *owner;//填THIS_MODULE,表示还字符设备属于哪个内核模块
    	const struct file_operations *ops;//指向内核空间中存放该设备的各种操作函数地址
    	struct list_head list;//链表指针域
    	dev_t dev;//设备号
    	unsigned int count;//设备数量
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    自己定义的结构体中必须要有一个成员为struct cdev cdev ,两种定义方法:

    • 1、直接定义:定义结构体全局变量

    • 2、动态申请:struct cdev * cdev_alloc()

    • 初始化

    void cdev_init(struct cdev *cdev,const struct file_operations *fops)
    功能:初始化字符设备结构体
    参数:
    	cdev:设备号
    	fops:操作函数
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 字符设备结构体与设备号绑定
    int cdev_add(struct cdev *p,dev_t dev,unsigned int count)
    功能:将指定字符设备添加到内核
    参数:
    	p:指向被添加的设备
    	dev:设备号
    	count:设备数量
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 移除字符设备
    void cdev_del(struct cdev *p)
    功能:从内核中移除一个字符设备
    参数:
    	p:设备号
    
    • 1
    • 2
    • 3
    • 4
    • 系统调用函数结构体
      #include
    struct file_operations 
    {
       struct module *owner;           //填THIS_MODULE,表示该结构体对象从属于哪个内核模块
       int (*open) (struct inode *, struct file *);	//打开设备
       int (*release) (struct inode *, struct file *);	//关闭设备
       ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);	//读设备
       ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);    //写设备
       loff_t (*llseek) (struct file *, loff_t, int);		//定位
       long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);//读写设备参数,读设备状态、控制设备
       unsigned int (*poll) (struct file *, struct poll_table_struct *);	//POLL机制,实现多路复用的支持
       int (*mmap) (struct file *, struct vm_area_struct *); //映射内核空间到用户层
       int (*fasync) (int, struct file *, int); //信号驱动
       //......
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    该对象的函数指针成员对应相应的系统调用函数,应用层通过系统调用函数来间接调用这些函数指针指向的设备驱动函数
    在这里插入图片描述
    一般定义一个struct file_operations类型的全局变量,并用自己实现的各种操作函数名对其初始化

    总结:
    字符设备开发步骤:

    • 如果设备有自己的控制数据,则定义一个包含struct cdev cdev成员的结构体(方便管理),其他成员根据设备需求。设备简单直接用struct cdev
    • 2、定义一个struct cdev的全局变量来表示该设备
    • 3、定义三个全局变量分别来表示:主设备号、次设备号、设备数量
    • 4、定义一个struct file_operations 结构体变量,其owner成员赋值为THIS_MODULE
    • 5、module init函数流程:a. 申请设备号 b. 如果是全局设备指针则动态分配代表本设备的结构体元素 c. 初始化struct cdev成员,设置struct cdev的owner成员为THIS_MODULE e. 添加字符设备到内核
    • 6、module exit函数:a. 注销设备号 b. 从内核中移除struct cdev. 如果如果是全局设备指针则释放其指向空间
    • 7、编写各个操作函数并将函数名初始化给struct file_operations结构体变量

    验证步骤:

    • 1、编写驱动代码
    • 2、make生成ko文件
    • 3、insmod内核模块
    • 4、查阅字符设备用到的设备号(主设备号):cat /proc/devices | grep 申请设备号时用的名字
    • 5、创建设备文件(设备节点) : mknod /dev/??? c 上一步查询到的主设备号 代码中指定初始次设备号
    • 6、编写app验证驱动(testmychar_app.c)
    • 7、编译运行app,dmesg命令查看内核打印信息

    示例:

    #include 
    #include 
    #include 
    #include 
    
    //设备结构体
    struct cdev cdev;
    
    //设备信息
    dev_t id;
    int major, minor;
    
    //驱动操作函数
    //打开设备
    int char_open (struct inode * inode, struct file * f)	//打开设备
    {
    
    	
    	return 0;
    }
    
    //读
    ssize_t char_read (struct file * f, char __user * user, size_t size, loff_t * loff)	//读设备
    {
    
    	return size;
    }
    
    //写
    ssize_t char_write (struct file * d , const char __user * user, size_t size, loff_t * loff)    //写设备
    {
    	
    	return size;
    }
    
    //关闭
    int char_close (struct inode * inod, struct file * f)	//关闭设备
    {
    	return 0;
    }
    
    struct file_operations fops = {
    
    	.owner = THIS_MODULE,
    	.open = char_open,
    	.release = char_close,
    	.write = char_write,
    	.read = char_read,
    };
    
    int __init init_test(void)
    {
    	int ret;
    	
    	//申请设备号(自动分配)
    	alloc_chrdev_region(&id, 0, 1, "char");
    	if(ret)
    	{
    		printk("alloc err \r\n");
    		return -EIO;
    	}
    	
    	//初始化设备结构体
    	cdev_init(&cdev, &fops);
    	
    	//设置cdev的owner成员为THIS_MODULE
    	cdev.owner = THIS_MODULE;
    
    	//添加字符设备到内核
    	cdev_add(&cdev, id, 1);
    		return 0;
    }
    
    void __exit exit_test(void)
    {
    	//移除设备
    	cdev_del(&cdev);
    
    	//注销设备号
    	unregister_chrdev_region(id, 1);
    }	
    
    MODULE_LICENSE("GPL");
    module_init(init_test);
    module_exit(exit_test);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85

    五、字符设备驱动框架解析

    设备的操作函数如果比喻为桩的话,则:

    • 驱动实现操作函数:做桩
    • insmod调用init函数:钉桩
    • 应用层通过系统调用函数间接调用这些设备的操作函数:用桩
    • rmmod调用exit函数:拔桩

    操作函数中常用的结构体

    • 1、内核中记录文件信息的结构体
    struct inode{
    	//....
    	dev_t  i_rdev;//设备号
    	struct cdev  *i_cdev;//如果是字符设备才有此成员,指向对应设备驱动程序中的加入系统的struct cdev对象
    	//....
    }
    /*
    	1、该结构体对象对应着一个实际的设备,一对一
    	2、open一个文件时,如果内核中存在该文件对应的inode对象就不再创建,不存在则创建
    	3、内核中用此类型关联到此文件的操作函数集(对设备而言就是关联到具体的驱动代码)
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 2、读写文件内容过程中用到的一些控制数据组合的对象
    struct file
    {
    	//...
    	mode_t f_mode;//不同用户的操作权限,驱动一般不用
    	loff_t f_pos;//数据位置指示器,需要控制数据开始读写位置的设备有用
    	unsignedint f_flags;//open是的第二个参数flags存放在此,驱动中常用
    	structfile_operations*f_op;//open时从struct inode中i_cdev的对应成员获得地址。驱动开发中用来协助理解工作原理,内核中使用
    	void*private_data;//本次打开文件的私有数据驱动中常用来在几个操作函数中传递数据
    	structdentry*f_dentry;//驱动中一般不用,除非需要访问对应文件的inode,用法flip->f_dentry->d_inode
        int refcnt;//引用计数,保存着该对象地址的位置个数,close时发现refcnt为0才会销毁该struct file对象
    	//...
    };
    /*
    	1、open函数被调用一次,则创建一个该对象,可以认为一个该对象对应一次指定文件的操作
    	2、open一个文件多次,每次open都会创建一个该对象
    	3、文件描述符中存放的地址指向该类型的对象
    	4、每个文件描述符都对应一个struct file对象的地址
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    字符设备驱动程序框架

    • 驱动实现端:
      在这里插入图片描述

    • 驱动使用端:
      在这里插入图片描述

    参考原理图

    在这里插入图片描述

    常用操作函数

    int (*open) (struct inode *, struct file *);	//打开设备
    /*
    	指向函数一般用来对设备进行硬件上的初始化,对于一些简单的设备该函数只需要return 0,对应open系统调用,是open系统调用函数实现过程中调用的函数,
    */
    
    int (*release) (struct inode *, struct file *);	//关闭设备
    /*
    	,指向函数一般用来对设备进行硬件上的关闭操作,对于一些简单的设备该函数只需要return 0,对应close系统调用,是close系统调用函数实现过程中调用的函数
    */
    
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);	//读设备
    /*
    	指向函数用来将设备产生的数据读到用户空间,对应read系统调用,是read系统调用函数实现过程中调用的函数
    */
    
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);    //写设备
    /*
    	指向函数用来将用户空间的数据写进设备,对应write系统调用,是write系统调用函数实现过程中调用的函数
    */
    
    loff_t (*llseek) (struct file *, loff_t, int);		//数据操作位置的定位
    /*
    	指向函数用来获取或设置设备数据的开始操作位置(位置指示器),对应lseek系统调用,是lseek系统调用函数实现过程中调用的函数
    */
    
    
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);//读写设备参数,读设备状态、控制设备
    /*
    	指向函数用来获取、设置设备一些属性或设备的工作方式等非数据读写操作,对应ioctl系统调用,是ioctl系统调用函数实现过程中调用的函数
    */
    
    unsigned int (*poll) (struct file *, struct poll_table_struct *);//POLL机制,实现对设备的多路复用方式的访问
    /*
    	指向函数用来协助多路复用机制完成对本设备可读、可写数据的监控,对应select、poll、epoll_wait系统调用,是select、poll、epoll_wait系统调用函数实现过程中调用的函数
    */
      
    int (*fasync) (int, struct file *, int); //信号驱动
    /*
    	指向函数用来创建信号驱动机制的引擎,对应fcntl系统调用的FASYNC标记设置,是fcntl系统调用函数FASYNC标记设置过程中调用的函数
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    六、读操作实现

    ssize_t xxx_read(struct file *filp, char __user *pbuf, size_t count, loff_t *ppos);
    完成功能:读取设备产生的数据
    参数:
        filp:指向open产生的struct file类型的对象,表示本次read对应的那次open
        pbuf:指向用户空间一块内存,用来保存读到的数据
        count:用户期望读取的字节数
        ppos:对于需要位置指示器控制的设备操作有用,用来指示读取的起始位置,读完后也需要变更位置指示器的指示位置
     返回值:
        本次成功读取的字节数,失败返回-1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 由于内核不能直接和用户空间进行数据传输,因此要使用函数实现:
    • 从内核中复制数据到用户空间
      #include
    unsigned long copy_to_user (void __user * to, const void * from, unsigned long n)
    成功为返回0,失败非0
    
    • 1
    • 2

    七、写操作实现

    ssize_t xxx_write (struct file *filp, const char __user *pbuf, size_t count, loff_t *ppos);  
    完成功能:向设备写入数据
    参数:
        filp:指向open产生的struct file类型的对象,表示本次write对应的那次open
        pbuf:指向用户空间一块内存,用来保存被写的数据
        count:用户期望写入的字节数
        ppos:对于需要位置指示器控制的设备操作有用,用来指示写入的起始位置,写完后也需要变更位置指示器的指示位置
     返回值:
        本次成功写入的字节数,失败返回-1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 从用户空间复制数据到内核中
      #include
    unsigned long copy_from_user (void * to, const void __user * from, unsigned long n)
    
    成功为返回0,失败非0
    
    • 1
    • 2
    • 3

    八、ioctl的实现

    • 可以已知结构体成员获得所在结构体变量的地址
    container_of(成员地址,结构体类名,成员的名称)
    
    • 1
    • ioctl

    long xxx_ioctl (struct file *filp, unsigned int cmd, unsigned long arg);
    功能:对相应设备做指定的控制操作(各种属性的设置获取等等)
    参数:
    	filp:指向open产生的struct file类型的对象,表示本次ioctl对应的那次open
    	cmd:用来表示做的是哪一个操作
        arg:和cmd配合用的参数
    返回值:成功为0,失败-1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • cmd的组成
      在这里插入图片描述
    1. dir(direction),ioctl 命令访问模式(属性数据传输方向),占据 2 bit,可以为 _IOC_NONE、_IOC_READ、_IOC_WRITE、_IOC_READ | _IOC_WRITE,分别指示了四种访问模式:无数据、读数据、写数据、读写数据;
    2. type(device type),设备类型,占据 8 bit,在一些文献中翻译为 “幻数” 或者 “魔数”,可以为任意 char 型字符,例如 
       ‘a’、’b’、’c’ 等等,其主要作用是使 ioctl 命令有唯一的设备标识;
    3. nr(number),命令编号/序数,占据 8 bit,可以为任意 unsigned char 型数据,取值范围 0~255,如果定义了多个 ioctl 命令,通常从 0 开始编号递增;
    4. size,涉及到 ioctl 函数 第三个参数 arg ,占据 13bit 或者 14bit(体系相关,arm 架构一般为 14 位),指定了 arg 的数据类型及长度,如果在驱动的 ioctl 实现中不检查,通常可以忽略该参数;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    九、创建多个次设备

    每一个具体设备(次设备不一样),必须有一个struct cdev来代表它
    并且需要以下三个操作:

    • cdev_init
    • cdev.owner赋值
    • cdev_add

    十、(多个次设备同时获取设备号、同时创建设备)在驱动中创建设备

    内核中 实现 创建 设备文件 /dev/xxx

    1、创建设备

    • 手动方式 创建设备文件
    	sudo mknod /dev/xxx  c      243      0 
    			设备节点名  字符型  主设备号 次设备号 
    	在Linux内核 3.0后 引入了 udev服务  该服务可以用于 检查
    	驱动的 设备文件添加请求, 然后添加对应的设备文件 
    
    • 1
    • 2
    • 3
    • 4
    • 自动创建设备文件

    #include

    struct class * class_create(owner, name);
    owner 所有者  THIS_MODULE 
    name  类的名字 创建之后可以看到该名字 `cat /proc/devices | grep 类的名字`
    
    返回值:  struct class *  出差 返回 错误指针
    
    • 1
    • 2
    • 3
    • 4
    • 5

    2、关联设备节点与设备文件名

    #include

    struct device *device_create(struct class *cls, struct device *parent,
    			     dev_t devt, void *drvdata,
    			     const char *fmt, ...);
    例子:
    	dev = device_create(cls, NULL, id, NULL, "char%d", 1);
    
    • 1
    • 2
    • 3
    • 4
    • 5

    3、卸载驱动时销毁设备节点

    void device_destroy(struct class *cls, dev_t devt);
    
    • 1

    4、销毁类

    void class_destroy(struct class *cls);
    
    • 1

    示例:

    #include 
    #include 
    #include 
    #include 
    #include 
    
    //设备结构体
    struct cdev cdev;
    
    //设备信息
    dev_t id;
    int major, minor;
    
    //class类型
    struct class *cls;
    
    struct device *dev;
    
    //驱动操作函数
    //打开设备
    int char_open (struct inode * inode, struct file * f)	//打开设备
    {
    
    	
    	return 0;
    }
    
    //读
    ssize_t char_read (struct file * f, char __user * user, size_t size, loff_t * loff)	//读设备
    {
    
    	return size;
    }
    
    //写
    ssize_t char_write (struct file * d , const char __user * user, size_t size, loff_t * loff)    //写设备
    {
    	
    	return size;
    }
    
    //关闭
    int char_close (struct inode * inod, struct file * f)	//关闭设备
    {
    	return 0;
    }
    
    struct file_operations fops = {
    
    	.owner = THIS_MODULE,
    	.open = char_open,
    	.release = char_close,
    	.write = char_write,
    	.read = char_read,
    };
    
    int __init init_test(void)
    {
    	int ret;
    	int i;
    	//申请设备号(自动分配)四个设备
    	alloc_chrdev_region(&id, 0, 4, "char_dev");
    	if(ret)
    	{
    		printk("alloc err \r\n");
    		return -EIO;
    	}
    	
    	//初始化设备结构体
    	cdev_init(&cdev, &fops);
    	
    	//设置cdev的owner成员为THIS_MODULE
    	cdev.owner = THIS_MODULE;
    
    	//添加字符设备到内核
    	ret = cdev_add(&cdev, id, 4);
    	if(ret)
    	{
    		printk("cdev add err \r\n");
    		goto add_err;
    	}
    	//生成设备文件
    	cls = class_create(THIS_MODULE, "char_class");
    	if(IS_ERR(cls))
    	{
    		printk("class create err\r\n");
    		goto class_err;		
    		
    	}
    	//关联设备节点和设备名  循环创建4个
    
    	for(i =0 ;i < 4; i++)
    	{
    		dev = device_create(cls, NULL, id + i, NULL, "char_dev%d", i);
    		if(IS_ERR(dev))
    		{
    			printk("device create err\r\n");
    			goto device_err;	
    		}	
    	}
    	return 0;
    
    //回滚
    device_err:
    	for(i--; i >= 0; i--)
    	{
    		device_destroy(cls, id + i);
    	}
    	//销毁class
    	class_destroy(cls);
    
    class_err:
    	//移除设备
    	cdev_del(&cdev);
    
    add_err:
    	//注销设备号
    	unregister_chrdev_region(id, 4);
    
    	return -EIO;
    }
    
    void __exit exit_test(void)
    {
    	int i;
    	//取消设备节点
    	for(i =0 ;i < 4; i++)
    	{
    		device_destroy(cls, id + i);
    	}
    	//销毁class
    	class_destroy(cls);
    
    	//移除设备
    	cdev_del(&cdev);
    
    	//注销设备号
    	unregister_chrdev_region(id, 4);
    }	
    
    MODULE_LICENSE("GPL");
    module_init(init_test);
    module_exit(exit_test);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
  • 相关阅读:
    基于Spring实现策略模式
    python项目实战——银行取款机系统(二)
    企业视频数字人有哪些应用场景
    本科毕业四年:工作,辞职,结婚,买房
    Code Review 文化人语录
    ElasticSearch三种分页对比
    Docker 容器生命周期:创建、启动、暂停与停止----从创建到停止多角度分析
    【Mysql】第3篇--数据库约束
    Node.js精进(6)——文件
    如何隐藏woocommerce 后台header,woocommerce-layout__header
  • 原文地址:https://blog.csdn.net/qq_53402930/article/details/133698510