• Linux学习笔记之设备驱动篇(5)_字符设备_理论篇2


    零基础学Linux内核系列文章目录

    前置知识篇
    1. 进程
    2. 线程
    进程间通信篇
    1. IPC概述
    2. 信号
    3. 消息传递
    4. 同步
    5. 共享内存区
    编译相关篇
    1. GCC编译
    2. 静态链接与动态链接
    3. makefile入门基础
    设备驱动篇
    1. 设备驱动概述
    2. 内核模块_理论篇
    3. 内核模块_实验篇
    4. 字符设备1_基本概念
    5. 字符设备2_基本步骤


    一、前言

    本节主要介绍下字符设备抽象的原理、设置步骤以及程序框架。


    二、前置条件


    三、本文参考资料

    《 [野火]i.MX Linux开发实战指南》
    百度


    四、正文部分

    4.1 字符设备驱动程序框架

    在这里插入图片描述

    • 创建一个字符设备时
      得到一个设备号,分配设备号的途径有静态分配和动态分配;
      拿到设备的唯一ID,我们需要实现file_operation并保存到cdev中,实现cdev的初始化;
      将所做的工作告诉内核,使用cdev_add()注册cdev;
      最后创建设备节点,以便后面调用file_operation接口。

    • 注销设备时
      需释放内核中的cdev,归还申请的设备号,删除创建的设备节点。

    4.1.1 设备号的申请/归还dev_t

    Linux内核提供了两种方式来 定义 字符设备,如下所示。

    /* 常见的变量定义 */
    static struct cdev chrdev;
    
    /* 内核提供的动态分配方式,调用该函数之后,会返回一个struct cdev类型的指针,用于描述字符设备 */
    struct cdev *cdev_alloc(void);
    
    /* 从内核中 **==移除==** 某个字符设备 */
    void cdev_del(struct cdev *p)
    		p: 该函数需要将我们的字符设备结构体的地址作为实参传递进去,就可以从内核中移除该字符设备了。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    4.1.1.1 静态申请设备号

    register_chrdev_region函数用于 静态 地(即指定一个起始设备号)为一个字符设备 申请 一个或多个设备编号。

    int register_chrdev_region(dev_t from, unsigned count, const char *name)
    
    	from:dev_t类型的变量,用于指定字符设备的起始设备号,如果要注册的设备号已经被其他的设备注册了,那么就会导致注册失败。
    	
    	count:指定要申请的设备号个数,count的值不可以太大,否则会与下一个主设备号重叠。
    	
    	name:用于指定该设备的名称,我们可以在/proc/devices中看到该设备。
    	
    	返回0表示申请成功,失败则返回错误码
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    4.1.1.2 动态申请设备号

    使用register_chrdev_region函数时,都需要去查阅内核源码的Documentation/devices.txt文件, 这就十分不方便。
    因此,内核又为我们提供了一种能够 动态分配 设备编号的方式:alloc_chrdev_region。

    int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)
    
    	dev:指向dev_t类型数据的指针变量,用于存放分配到的设备编号的起始值;
    	
    	baseminor:次设备号的起始值,通常情况下,设置为0;
    	
    	count:指定要申请的设备号个数,count的值不可以太大,否则会与下一个主设备号重叠。
    	
    	name:用于指定该设备的名称,我们可以在/proc/devices中看到该设备。
    	
    	返回值: 返回0表示申请成功,失败则返回错误码
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    调用alloc_chrdev_region函数,内核会自动分配给我们一个尚未使用的主设备号。
    我们可以通过命令 “cat /proc/devices” 查询内核分配的 主设备号

    	~ # cat /proc/devices
    	Character devices:
    	  1 mem
    	  4 /dev/vc/0
    	  4 tty
    	  4 ttyS
    	  5 /dev/tty
    	  ...
    	Block devices:
    	259 blkext
    	  7 loop
    	  8 sd
    	  ...
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    当我们删除字符设备时候,我们需要把分配的设备编号交还给内核,
    对于使用register_chrdev_region函数以及alloc_chrdev_region函数分配得到的设备编号,可以使用unregister_chrdev_region函数实现该功能。(采用宏定义管理编号个数

    void unregister_chrdev_region(dev_t from, unsigned count)
    
    	from:指定需要注销的字符设备的设备编号起始值,我们一般将定义的dev_t变量作为实参。
    
    	count:指定需要注销的字符设备编号的个数,该值应与申请函数的count值相等,通常采用宏定义进行管理。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    4.1.1.3 同类申请设备号

    除了上述的两种,内核还提供了register_chrdev函数用于分配设备号。
    该函数是一个内联函数,它 不仅支持静态申请设备号,也支持动态申请设备号,并将主设备号返回

    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:用于操作该设备的函数接口指针。
    	
    	返回值: 主设备号
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    我们从以上代码中可以看到,使用register_chrdev函数向内核申请设备号,同一类字符设备(即主设备号相同),会在内核中申请了256个
    通常情况下,我们不需要用到这么多个设备,这就造成了极大的资源浪费。

    使用register函数申请的设备号,则应该使用unregister_chrdev函数进行注销。

    static inline void unregister_chrdev(unsigned int major, const char *name)
    {
    	__unregister_chrdev(major, 0, 256, name);
    }
    
    	major:指定需要释放的字符设备的主设备号,一般使用register_chrdev函数的返回值作为实参。
    
    	name:执行需要释放的字符设备的名称。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    4.1.2 初始化cdev(关联fops)

    编写一个字符设备最重要的事情,就是要实现file_operations这个结构体中的函数
    实现之后,如何将该结构体(cdev)与我们的字符设备结构体(fops)相关联呢?
    内核提供了cdev_init函数,来实现这个过程。

    void cdev_init(struct cdev *cdev, const struct file_operations *fops)
    
    	cdev:struct cdev类型的指针变量,指向需要关联的字符设备结构体;
    
    	fops:file_operations类型的结构体指针变量,一般将实现操作该设备的结构体file_operations结构体作为实参。
    
    • 1
    • 2
    • 3
    • 4
    • 5

    在这里插入图片描述

    4.1.3 设备注册和注销(cdev_add)

    cdev_add函数用于向内核的cdev_map散列表 添加一个新的字符设备,如下所示。

    int cdev_add(struct cdev *p, dev_t dev, unsigned count)
    
    	p:struct cdev类型的指针,用于指定需要添加的字符设备;
    	
    	dev:dev_t类型变量,用于指定设备的起始编号;
    	
    	count:指定注册多少个设备。
    	
    	返回值: 错误码
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    从系统中删除cdev,cdev设备将无法再打开,但任何 已经打开的cdev将保持不变, 即使在cdev_del返回后,它们的FOP仍然可以调用。

    void cdev_del(struct cdev *p)
    
    	p:struct cdev类型的指针,用于指定需要删除的字符设备;
    	
    	返回值: 无
    
    • 1
    • 2
    • 3
    • 4
    • 5

    4.1.4 设备节点创建/销毁(mknod)

    4.1.4.1 代码执行

    创建一个设备并将其注册到文件系统

    struct device *device_create(struct class *class, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...)
    
    	class:指向这个设备应该注册到的struct类的指针;
    	
    	parent:指向此新设备的父结构设备(如果有)的指针;
    	
    	devt:要添加的char设备的开发;
    	
    	drvdata:要添加到设备进行回调的数据;
    	
    	fmt:输入设备名称。
    	
    	返回值: 成功时返回 struct device 结构体指针, 错误时返回ERR_PTR().
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    删除使用device_create函数创建的设备

    void device_destroy(struct class *class, dev_t devt)
    
    	class:指向注册此设备的struct类的指针;
    	
    	devt:以前注册的设备的开发;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    4.1.4.2 命令执行

    用法:mknod 设备名 设备类型 主设备号 次设备号

    	mknod /dev/test c 2 0    (创建一个字符设备/dev/test,其主设备号为2,次设备号为0)
    
    • 1

    在这里插入图片描述
    当类型为”p”(即命名管道文件)时可不指定主设备号和次设备号,否则它们是必须指定的。

    如果主设备号和次设备号以”0x”或”0X”开头,它们会被视作十六进制数来解析;
    如果以”0”开头,则被视作八进制数;
    其余情况下被视作十进制数。

    可用的类型包括:

    1. b:创建(有缓冲的)区块特殊文件
    2. c/u:创建(没有缓冲的)字符特殊文件
    3. p:创建先进先出(FIFO)特殊文件
    4.1.4.3 mknod实现

    在这里插入图片描述
    当我们使用上述命令,创建了一个字符设备文件时,实际上就是创建了一个设备节点inode结构体,并且将该设备的设备编号记录在成员i_rdev,将成员f_fop指针指向了def_chr_fops结构体。
    这就是mknod负责的工作内容,具体代码见如下。

    mknod调用关系 (内核源码/mm/shmem.c)
    	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;
    	}
    
    
    init_special_inode函数(内核源码/fs/inode.c)
    	void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
    	{
    		inode->i_mode = mode;
    		/* 判断文件的inode类型,如果是字符设备类型,则把def_chr_fops作为该文件的操作接口,并把设备号记录在inode->i_rdev */
    		if (S_ISCHR(mode)) {
    			/* 并不是自己构造的file_operation,而是字符设备通用的def_chr_fops */
    			inode->i_fop = &def_chr_fops;
    			inode->i_rdev = rdev;
    		} else if (S_ISBLK(mode)) {
    			inode->i_fop = &def_blk_fops;
    			inode->i_rdev = rdev;
    		} else if (S_ISFIFO(mode))
    			inode->i_fop = &pipefifo_fops;
    		else if (S_ISSOCK(mode))
    			;      /* leave it no_open_fops */
    		else
    			printk(KERN_DEBUG "init_special_inode: bogus i_mode (%o) for"
    					" inode %s:%lu\n", mode, inode->i_sb->s_id,
    					inode->i_ino);
    	}
    
    • 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

    inode上的file_operation并不是自己构造的file_operation,而是字符设备 通用 的def_chr_fops, 那么自己构建的file_operation等在应用程序调用open函数之后,才会绑定在文件上。

    	inode->i_op的类型:
    		const struct inode_operations        *i_op;
    
    • 1
    • 2

    在这里插入图片描述

    4.2 open函数实现

    4.2.1 open函数大致步骤

    使用设备之前我们通常都需要调用open函数,这个函数一般用于设备专有数据的初始化,申请相关资源及进行设备的初始化等工作
    对于简单的设备而言,open函数可以不做具体的工作,在应用层通过系统调用open打开设备时, 如果打开正常,就会得到该设备的文件描述符
    之后,我们就可以通过该描述符对设备进行read和write等操作;

    open函数到底做了些什么工作?下图中列出了open函数执行的大致过程。
    在这里插入图片描述

    用户空间使用open()系统调用函数打开一个字符设备时(int fd = open(“dev/xxx”, O_RDWR))大致有以下过程:

    1. 对于上层open调用到内核时会发生一次软中断中断号是0x80,从用户空间进入到内核空间

    2. open会调用到system_call(内核函数),system_call会根据/dev/xxx设备名,去找出你要的设备号。
      system_call函数是怎么找到详细的系统调用服务例程的呢?
      ⇒ 通过系统调用号查找系统调用表sys_call_table! 软中断指令INT 0x80运行时,系统调用号会被放入 eax寄存器中,system_call函数能够读取eax寄存器获取,然后将其乘以4,生成偏移地址,然后以sys_call_table为基址。基址加上偏移地址,就能够得到详细的系统调用服务例程的地址了!然后就到了系统调用服务例程了。
      ⇒ 补充:

      • 每个系统调用都对应一个系统调用号,而系统调用号就对应内核中的相应处理函数。
      • 所有系统调用都是通过中断0x80来触发的。
      • 使用系统调用时,通过eax 寄存器将系统调用号传递到内核,系统调用的入参通过ebx、ecx……依次传递到内核
      • 和函数一样,系统调用的返回值保存在eax中,所有要从eax中取出
    3. 再调到虚拟文件系统VFS(为了上层调用到确切的硬件统一化)中的查找对应与字符设备对应 struct inode节点

    4. 调用VFS里的sys_open,sys_open会找到在驱动链表里面,遍历散列表cdev_map,根据inode节点中的cdev_t设备号找到 cdev对象
      –> struct cdev *i_cdev;
      在这里插入图片描述

    5. 创建struct file对象
      ⇒ 在linux操作系统中,每打开一次文件,Linux操作系统会在VFS层分配一个struct file结构体来描述打开的文件。
      (系统采用 一个数组来管理一个进程中的多个被打开的设备,每个文件描述符作为数组下标标识了一个设备对象

    6. 初始化struct file对象,将struct file对象中的file_operations成员指向struct cdev对象中的file_operations成员( file->fops = cdev->fops

    7. 回调file->fops->open函数
      在这里插入图片描述

    4.2.2 open函数具体调用

    总体:

    • open() --> sys_open() --> do_sys_open()
      –> get_unused_fd_flags()获取一个未被使用的文件描述符fd(即返回值)
      –> do_filp_open() --> get_empty_filp()得到一个新的file结构体
      –> 复杂的工作,如解析文件路径,查找该文件的文件节点inode等
      –> do_dentry_open()
      –> def_chr_fops -->open() 即chrdev_open函数
      –> fd_install() 完成文件描述符和文件结构体file的关联
      –> 之后我们使用对该文件描述符fd调用read、write函数, 最终都会调用file结构体对应的函数
    4.2.2.1 do_dentry_open()

    如下所示。

    	do_dentry_open函数(位于 ebf-busrer-linux/fs/open.c)
    		static int do_dentry_open(struct file *f,struct inode *inode,int (*open)(struct inode *, struct file *),const struct cred *cred)
    		{
    			……
    			/* 使用fops_get函数来获取该文件节点inode的成员变量i_fop */
    			/* 在上图中我们使用mknod创建字符设备文件时,将def_chr_fops结构体赋值给了该设备文件inode的i_fop成员 */
    			f->f_op = fops_get(inode->i_fop);
    			……
    			if (!open)
    			/* 新建的file结构体的成员f_op就指向了def_chr_fops。 */
    			open = f->f_op->open;
    			if (open) {
    				error = open(inode, f);
    				if (error)
    				goto cleanup_all;
    			}
    			……
    		}
    	
    	
    	def_chr_fops结构体(内核源码/fs/char_dev.c)
    		const struct file_operations def_chr_fops = {
    			.open = chrdev_open,
    			.llseek = noop_llseek,
    		};
    
    • 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

    可以理解为一个字符设备的通用初始化函数,根据字符设备的设备号,找到相应的字符设备,从而得到操作该设备的方法
    在这里插入图片描述

    4.2.2.2 chrdev_open()

    在Linux内核中,使用结构体cdev来描述一个字符设备。

    chrdev_open函数(内核源码/fs/char_dev.c)
    	static int chrdev_open(struct inode *inode, struct file *filp)
    	{
    		const struct file_operations *fops;
    		struct cdev *p;
    		struct cdev *new = NULL;
    		int ret = 0;
    		spin_lock(&cdev_lock);
    		
    		/* 保存了字符设备的设备编号 */
    		p = inode->i_cdev;
    		if (!p) {
    			struct kobject *kobj;
    			int idx;
    			spin_unlock(&cdev_lock);
    			/* 找到该设备文件cdev结构体的kobj成员 */
    			kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);
    			if (!kobj)
    				return -ENXIO;
    				
    			/* 得到该字符设备对应的结构体cdev */
    			/* 通过一个结构变量中一个成员的地址找到这个结构体变量的首地址。同时,将cdev结构体记录到文件节点inode中的i_cdev,便于下次打开该文件 */
    			new = container_of(kobj, struct cdev, kobj);
    			spin_lock(&cdev_lock);
    			/* Check i_cdev again in case somebody beat us to it whilewe dropped the lock.*/
    			p = inode->i_cdev;
    			if (!p) {
    				inode->i_cdev = p = new;
    				list_add(&inode->i_devices, &p->list);
    				new = NULL;
    			} else if (!cdev_get(p))
    				ret = -ENXIO;
    		} else if (!cdev_get(p))
    			ret = -ENXIO;
    		spin_unlock(&cdev_lock);
    		cdev_put(new);
    		if (ret)
    			return ret;
    	
    		ret = -ENXIO;
    		fops = fops_get(p->ops);
    		if (!fops)
    		goto out_cdev_put;
    	
    		/* 函数chrdev_open最终将该文件结构体file的成员f_op替换成了cdev对应的ops成员,并执行ops结构体中的open函数 */
    		replace_fops(filp, fops);
    		if (filp->f_op->open) {
    			ret = filp->f_op->open(inode, filp);
    			if (ret)
    			goto out_cdev_put;
    		}
    	
    		return 0;
    	
    		out_cdev_put:
    		cdev_put(p);
    		return ret;
    	}
    
    • 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

     


    五、总结

    创建一个字符设备时需要以下几步:

    1. 准备file_operation接口,必须要的有open / close,read / write等非必须
      将对底层寄存器的配置,读写操作放在文件操作接口里面,也就是实现file_operations结构体。
      几乎所有成员都是函数指针,用来实现文件的具体操作
      static const struct file_operations driver_ops={
      	.owner = THIS_MODULE,
      	.open  = driver_open,
      	.read  = driver_read,
      	.write = driver_write,
      	...
      };
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
    2. 静态 / 动态申请 cdev 内存
      struct cdev my_cdev;
      struct cdev *cdev_alloc(void);
      
      • 1
      • 2
    3. 得到一个设备号,分配设备号的途径有静态分配 / 动态分配
      dev_t dev_no;
      int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
      
      • 1
      • 2
    4. 拿到设备的唯一ID,我们需要实现file_operation并保存到cdev中,即将操作接口挂载到内核上,实现cdev的初始化
      void cdev_init(struct cdev *cdev, const struct file_operations *fops);
      
      • 1
    5. 将所做的工作告诉内核,注册cdev
      cdev_map->probe:保存并注册驱动基本对象struct cdev
      int cdev_add(struct cdev *p, dev_t dev, unsigned count);
      
      • 1
    6. 完成上述步骤后得到一个ko文件,加载该ko后,/proc/devices 目录会显示对应设备号,表示设备此时已经注册
      sudo insmod xx.ko
      cat /proc/devices
      
      • 1
      • 2
    7. 但是,此时在/dev目录下,还没有我们需要的设备节点
      最后创建设备节点,以便后面调用file_operation接口(mknod)
      sudo mknod -m 660 /dev/driver c 244 0
      ll /dev/driver
      
      • 1
      • 2
      struct device *device_create(struct class *class, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...);
      
      • 1
      创建了一个字符设备文件时,实际上就是创建了一个设备节点inode结构体,并且将该设备的设备编号记录在成员i_rdev,将成员f_fop指针指向了def_chr_fops结构体。
      inode上的file_operation并不是自己构造的file_operation,而是字符设备 通用 的def_chr_fops, 那么自己构建的file_operation等在应用程序调用open函数之后,才会绑定在文件上。
      通过主次设备号在cdev_map中找到cdev->file_operations
      把cdev->file_operations绑定到新的设备文件中
    8. 最终只需要编写对应程序去调用该设备驱动即可
      int fd = open("/dev/driver", O_RDWR);
      	if (-1 != fd)
      	{
      		ret = read(fd, read_data, 4);
      		printf("read ret = %d \n", ret);
      
      		ret = write(fd, write_data, 4);
      		printf("write ret = %d \n", ret);
      	}
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9

    如果想要打开dev下面的pin4引脚,过程是:用户态调用open(“/de/pin4”, O_RDWR),对于内核来说,上层调用open函数会触发一个软中断(系统调用专用,中断号是0x80,0x80代表发生了一个系统调用),系统进入内核态,并走到system_call,可以认为这个就是此软中断的中断服务程序入口,然后通过传递过来的系统调用号来决定调用相应的系统调用服务程序(在这里是调用VFS中的sys_open)。sys_open会在内核的驱动链表里面根据设备名和设备号查找到相关的驱动函数(每一个驱动函数是一个节点),驱动函数里面有通过寄存器操控IO口的代码,进而可以控制IO口实现相关功能

    当我们使用open函数,打开设备文件时,会根据该设备的文件的设备号找到相应的设备结构体, 从而得到了操作该设备的方法。
    也就是说如果我们要添加一个新设备驱动的话,我们需要提供

    • 一个设备号devno
    • 一个设备结构体cdev
    • 操作该设备的方法(file_operations结构体)。
      在这里插入图片描述
  • 相关阅读:
    C++医院影像科PACS源码:三维重建、检查预约、胶片打印、图像处理、测量分析等
    无人机是如何进行开发的
    代码随想录训练营 dp
    LeetCode刷题第2周小结
    一文搞懂shell脚本
    软件版本号详解
    全国大学生数学建模A题目更新中…… 欢迎订阅
    React类型检测prop-types的基本使用
    前端页面滑动问题求解
    蓝桥杯 超级胶水 答疑
  • 原文地址:https://blog.csdn.net/qq_38211182/article/details/126479779