• Linux驱动开发九.内核定时器使用——2.定时器APP调用


    在上面一张我们在一个基础IO外设上加上了定时器功能,但是在设备驱动挂载完成后直接就运行定时器了,这样肯定是不行的。一定是需要一个APP程序和底层驱动进行交互。APP起码具备的功能有启动、停止定时器,修改定时器工作周期的功能。

    在前面所有的APP中我们主要用了file_operations结构体中的open、read、write和realease。其实write就可以满足我们的需求,但是今天我们通过一个新的操作来实现数据的交互——ioctrl

    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    long (*compat_ioctl) (struct file *, unsigned int, unsigned long);

    ioctl和write用法有些区别,用man来打印一下看看

     上面的是write的介绍,下面是ioctl的介绍

     具体的区别我暂时还没搞太明白,但是很明显两个函数的参数是不一样的,write是通过指针指向要修改的地方,有个参数是指针类型。而ioctl是两个int类型的参数。

    compat_ioctl 函数与 unlocked_ioctl 函数功能一样,区别在于在 64 位系统上,32 位的应用程序调用将会使用此函数。在 32 位的系统上运行 32 位的应用程序调用的是unlocked_ioctl。因为我们的I.MX6UL运行的是32位的程序,所以我们要使用的就是unlock_ioctl。

    /**
     * @brief 文件操作集合
     * 
     */
    static const struct file_operations key_fops = {
        .owner = THIS_MODULE,
        .open =  new_dev_open,
        .unlocked_ioctl = new_dev_ioctl,
    };

    unlock_ioctl函数

    先看下函数的参数

    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);

    我们可以查到ioctl的引用一直到include/uapi/asm-generic/ioctl.h这个文件中有下面的介绍

    /* ioctl command encoding: 32 bits total, command in lower 16 bits,
     * size of the parameter structure in the lower 14 bits of the
     * upper 16 bits.
     * Encoding the size of the parameter structure in the ioctl request
     * is useful for catching programs compiled with old versions
     * and to avoid overwriting user space outside the user buffer area.
     * The highest 2 bits are reserved for indicating the ``access mode''.
     * NOTE: This limits the max parameter size to 16kB -1 !
     */
    
    /*
     * The following is for compatibility across the various Linux
     * platforms.  The generic ioctl numbering scheme doesn't really enforce
     * a type field.  De facto, however, the top 8 bits of the lower 16
     * bits are indeed used as a type field, so we might just as well make
     * this explicit here.  Please be sure to use the decoding macros
     * below from now on.
     */

    第一个参数不用说了,就是文件句柄,第二个参数是命令,这个命令是有固定格式的,我们后面再讲。最后的参数可以用来传一个长整型的参数。既然第二个参数是命令,我们就可以使用switch语句来进行处理。内核中整个函数结果可以简化成这样的

    static long new_dev_ioctl(struct file *file,unsigned int cmd,unsigned long arg)
    {
        switch (cmd)
        {
        case cmd1:
            //do case1
            break;
        
        case cmd2:
            //do case2
            break;
    
        case cmd3:
            //do case3
            break;
        }
    }

    我们只需要根据不同的cmd对应的值去做相应的工作就行了。

    cmd结构

    虽然命令的数据类型是个int类型,但是这个数据时有形式要求的。这个在Documentation/ioctl/路径下的ioctl-number.txt里有详细的说明。我们可以在里面看一下内核里使用这个ioctl写的驱动样式.

     随便在内核中搜索一下ioctl的驱动文件,可以发现下面的案例

     1 #define CMD_COREB_START        _IO('b', 0)
     2 #define CMD_COREB_STOP        _IO('b', 1)
     3 #define CMD_COREB_RESET        _IO('b', 2)
     4 
     5 static long
     6 coreb_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
     7 {
     8     int ret = 0;
     9 
    10     switch (cmd) {
    11     case CMD_COREB_START:
    12         bfin_write_SYSCR(bfin_read_SYSCR() & ~0x0020);
    13         break;
    14     case CMD_COREB_STOP:
    15         bfin_write_SYSCR(bfin_read_SYSCR() | 0x0020);
    16         bfin_write_SICB_SYSCR(bfin_read_SICB_SYSCR() | 0x0080);
    17         break;
    18     case CMD_COREB_RESET:
    19         bfin_write_SICB_SYSCR(bfin_read_SICB_SYSCR() | 0x0080);
    20         break;
    21     default:
    22         ret = -EINVAL;
    23         break;
    24     }
    25 
    26     CSYNC();
    27 
    28     return ret;
    29 }

    在上面的驱动代码中,就是通过switch来实现cmd的处理的,注意cmd是开始定义的宏,这个宏是调用了一个_IO的函数。所以也就是说这个cmd的格式是有要求的。

    cmd命令格式

    Linux内核在使用ioctl函数来进行文件读写操作时,是对cmd参数有指定要求的。在内核文档ioctl/ioctl-decoding.txt里面有对其进行的详细说明

    整个cmd包含了4个部分,包括了幻数、 序数、传输方向和数据大小,有些介绍在这里我们不再做详细的说明。总之我们要构造一个cmd是很麻烦的事情,还好,Linux在include/uapi/asm-generic/ioctl.h里为我们提供了一个API生成这个cmd,我们只需要传输相应的参数就可以了

     1 #ifndef _UAPI_ASM_GENERIC_IOCTL_H
     2 #define _UAPI_ASM_GENERIC_IOCTL_H
     3 
     4 /* ioctl command encoding: 32 bits total, command in lower 16 bits,
     5  * size of the parameter structure in the lower 14 bits of the
     6  * upper 16 bits.
     7  * Encoding the size of the parameter structure in the ioctl request
     8  * is useful for catching programs compiled with old versions
     9  * and to avoid overwriting user space outside the user buffer area.
    10  * The highest 2 bits are reserved for indicating the ``access mode''.
    11  * NOTE: This limits the max parameter size to 16kB -1 !
    12  */
    13 
    14 /*
    15  * The following is for compatibility across the various Linux
    16  * platforms.  The generic ioctl numbering scheme doesn't really enforce
    17  * a type field.  De facto, however, the top 8 bits of the lower 16
    18  * bits are indeed used as a type field, so we might just as well make
    19  * this explicit here.  Please be sure to use the decoding macros
    20  * below from now on.
    21  */
    22 #define _IOC_NRBITS    8
    23 #define _IOC_TYPEBITS    8
    24 
    25 /*
    26  * Let any architecture override either of the following before
    27  * including this file.
    28  */
    29 
    30 #ifndef _IOC_SIZEBITS
    31 # define _IOC_SIZEBITS    14
    32 #endif
    33 
    34 #ifndef _IOC_DIRBITS
    35 # define _IOC_DIRBITS    2
    36 #endif
    37 
    38 #define _IOC_NRMASK    ((1 << _IOC_NRBITS)-1)
    39 #define _IOC_TYPEMASK    ((1 << _IOC_TYPEBITS)-1)
    40 #define _IOC_SIZEMASK    ((1 << _IOC_SIZEBITS)-1)
    41 #define _IOC_DIRMASK    ((1 << _IOC_DIRBITS)-1)
    42 
    43 #define _IOC_NRSHIFT    0
    44 #define _IOC_TYPESHIFT    (_IOC_NRSHIFT+_IOC_NRBITS)
    45 #define _IOC_SIZESHIFT    (_IOC_TYPESHIFT+_IOC_TYPEBITS)
    46 #define _IOC_DIRSHIFT    (_IOC_SIZESHIFT+_IOC_SIZEBITS)
    47 
    48 /*
    49  * Direction bits, which any architecture can choose to override
    50  * before including this file.
    51  */
    52 
    53 #ifndef _IOC_NONE
    54 # define _IOC_NONE    0U
    55 #endif
    56 
    57 #ifndef _IOC_WRITE
    58 # define _IOC_WRITE    1U
    59 #endif
    60 
    61 #ifndef _IOC_READ
    62 # define _IOC_READ    2U
    63 #endif
    64 
    65 #define _IOC(dir,type,nr,size) \
    66     (((dir)  << _IOC_DIRSHIFT) | \
    67      ((type) << _IOC_TYPESHIFT) | \
    68      ((nr)   << _IOC_NRSHIFT) | \
    69      ((size) << _IOC_SIZESHIFT))
    70 
    71 #ifndef __KERNEL__
    72 #define _IOC_TYPECHECK(t) (sizeof(t))
    73 #endif
    74 
    75 /* used to create numbers */
    76 #define _IO(type,nr)        _IOC(_IOC_NONE,(type),(nr),0)
    77 #define _IOR(type,nr,size)    _IOC(_IOC_READ,(type),(nr),(_IOC_TYPECHECK(size)))
    78 #define _IOW(type,nr,size)    _IOC(_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))
    79 #define _IOWR(type,nr,size)    _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))
    80 #define _IOR_BAD(type,nr,size)    _IOC(_IOC_READ,(type),(nr),sizeof(size))
    81 #define _IOW_BAD(type,nr,size)    _IOC(_IOC_WRITE,(type),(nr),sizeof(size))
    82 #define _IOWR_BAD(type,nr,size)    _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),sizeof(size))
    83 
    84 /* used to decode ioctl numbers.. */
    85 #define _IOC_DIR(nr)        (((nr) >> _IOC_DIRSHIFT) & _IOC_DIRMASK)
    86 #define _IOC_TYPE(nr)        (((nr) >> _IOC_TYPESHIFT) & _IOC_TYPEMASK)
    87 #define _IOC_NR(nr)        (((nr) >> _IOC_NRSHIFT) & _IOC_NRMASK)
    88 #define _IOC_SIZE(nr)        (((nr) >> _IOC_SIZESHIFT) & _IOC_SIZEMASK)
    89 
    90 /* ...and for the drivers/sound files... */
    91 
    92 #define IOC_IN        (_IOC_WRITE << _IOC_DIRSHIFT)
    93 #define IOC_OUT        (_IOC_READ << _IOC_DIRSHIFT)
    94 #define IOC_INOUT    ((_IOC_WRITE|_IOC_READ) << _IOC_DIRSHIFT)
    95 #define IOCSIZE_MASK    (_IOC_SIZEMASK << _IOC_SIZESHIFT)
    96 #define IOCSIZE_SHIFT    (_IOC_SIZESHIFT)
    97 
    98 #endif /* _UAPI_ASM_GENERIC_IOCTL_H */

    我们主要使用的就是滴85~88行几组宏,说白了就是将0或1左右移动最后获取到指定的cmd。

    #define _IO(type,nr)            //没有参数的命令
    #define _IOR(type,nr,size)      //从驱动中读取数据
    #define _IOW(type,nr,size)      //向驱动中写入数据
    #define _IOWR(type,nr,size)     //双向数据传输

    其中参数type就是幻数、nr是序号,size是大小,幻数也是个int点数据,可以在内核文档Documentation/ioctl/ioctl-number.txt里已经给出了参考值,可以根据需求从里面查询。

    所以我们可以把我们需要对命令按照需求声明出来。我们的APP和驱动交互主要用来实现3个功能,启动、关闭定时器及修改定时器工作频率,打开和关闭是不用传递额外的参数的,而设置周期要写入个周期,就要用_IOW了。幻数使用的0xEF

    #define CMD_CLOSE       _IO(0xEF,1)             //cmd值为1,关闭定时器
    #define CMD_OPEN        _IO(0xEF,2)             //cmd值为2,启动定时器
    #define CMD_PERIOD      _IOW(0xEF,3,int)        //cmd值为3,修改定时器工作频率

    前两个不需要传参数,就用来_IO,不用指定count,设置频率涉及到应用程序向内核写数据,就用个_IOW,参数int意思是我们要传递个int类型的数据,所以长度就是int对应的长度值。可以看出来,使用Linux提供的方法来构建cmd就简单多了,我们只需要使用定义好的宏就可以了。

    驱动修改

    下面我们就可以根据ioctl的样式修改前面的驱动。主要就是unlocked_ioctl绑定的函数构建

    static long new_dev_ioctl(struct file *file,unsigned int cmd,unsigned long arg)
    {
        int ret = 0;
        int value = 0;
        struct new_dev *dev = file->private_data;
        switch (cmd)
        {
        case CMD_CLOSE:
            del_timer_sync(&dev->timer);
            gpio_set_value(dev->gpio,1);
            break;
        
        case CMD_OPEN:
            value = atomic_read(&dev->timer_per);
            mod_timer(&dev->timer,jiffies+msecs_to_jiffies(value));
            break;
    
        case CMD_PERIOD:
            ret = copy_from_user(&value,(int *)arg,sizeof(int));  //arg是应用传递给驱动的周期值数据首地址,长度为4个字节
                if(ret<0){
                return -EFAULT;
            }
            atomic_set(&dev->timer_per,value);
            mod_timer(&dev->timer,jiffies+msecs_to_jiffies(value));
            break;
        }
    }

    驱动没什么可讲的,主要就是一个switch结构,根据APP通过ioctl操作传进来的cmd进行操作。要注意点就是第修改周期时使用了copy_from_user函数,注意传递函数时和APP里指针取值的对应。还有就是如果我们的定时器如果在运行中,是要不停读取timer_per这个变量值来设置定时器expires的,如果我们在没有停止定时器时去设置周期值,很有可能发生竞争,所以在一开始设计程序结构的时候,这个变量采用了原子变量,避免了竞争的发生。所以我前面说过,并发和竞争一定要在写代码前先想好,要不是改起来就很麻烦了!

    下面吧整个驱动的代码放出来

    /**
     * @file timer.c
     * @author your name (you@domain.com)
     * @brief 定时器测试驱动程序
     * @version 0.1
     * @date 2022-07-16
     * 
     * @copyright Copyright (c) 2022
     * 
     */
    #include <linux/module.h>
    #include <linux/kernel.h>
    #include <linux/init.h>
    #include <linux/fs.h>
    #include <linux/uaccess.h>
    #include <linux/io.h>
    #include <linux/types.h>
    #include <linux/cdev.h>
    #include <linux/device.h>
    #include <linux/of.h>
    #include <linux/of_address.h>
    #include <linux/of_irq.h>
    #include <linux/gpio.h>
    #include <linux/of_gpio.h>
    
    #include <linux/ioctl.h>
    #define DEVICE_CNT      1
    #define DEVICE_NAME    "time"
    
    
    #define CMD_CLOSE       _IO(0xEF,1)
    #define CMD_OPEN        _IO(0xEF,2)
    #define CMD_PERIOD      _IOW(0xEF,3,int)  
    
    
    struct new_dev
    {
        dev_t dev_id;
        int major;
        int minor;
        struct class *class;
        struct device *device;
        struct cdev cdev;
        struct device_node *dev_nd;
        int gpio;
        struct timer_list timer;    //定时器
        atomic_t timer_per;         //定时器周期
    };
    
    struct new_dev new_dev;
    
    static int new_dev_open(struct inode *inode, struct file *filp)
    {
        filp->private_data = &new_dev;     /* 设置私有数据 */
        return 0;
    }
    
    /**
     * @brief 文件io操作
     * 
     * @param file 
     * @param cmd 
     * @param arg 
     * @return long 
     */
    static long new_dev_ioctl(struct file *file,unsigned int cmd,unsigned long arg)
    {
        int ret = 0;
        int value = 0;
        struct new_dev *dev = file->private_data;
        switch (cmd)
        {
        case CMD_CLOSE:
            del_timer_sync(&dev->timer);
            gpio_set_value(dev->gpio,1);
            break;
        
        case CMD_OPEN:
            value = atomic_read(&dev->timer_per);
    
            mod_timer(&dev->timer,jiffies+msecs_to_jiffies(value));
    
            break;
    
        case CMD_PERIOD:
            
            ret = copy_from_user(&value,(int *)arg,sizeof(int));  //arg是应用传递给驱动的周期值数据首地址,长度为4个字节
            
            if(ret<0){
                return -EFAULT;
            }
            atomic_set(&dev->timer_per,value);
            mod_timer(&dev->timer,jiffies+msecs_to_jiffies(value));
            break;
        }
    }
    
    /**
     * @brief 文件操作集合
     * 
     */
    static const struct file_operations key_fops = {
        .owner = THIS_MODULE,
        .open =  new_dev_open,
        // .release = new_dev_release,
        .unlocked_ioctl = new_dev_ioctl,
    };
    
    
    
    
    
    //gpio设备初始化
    int beep_init(struct new_dev *dev)
    {
        int ret = 0 ;
    
        //从设备树搜索设备节点
        dev->dev_nd = of_find_node_by_path("/beep");
        if(dev->dev_nd == NULL){
            printk("no device found\r\n");
            ret = -EINVAL;
            goto fail_nd;
        }
    
        //获取beep对应GPIO
        dev->gpio = of_get_named_gpio(dev->dev_nd,"beep-gpios",0);
        printk("beep_gpio=%d\r\n",dev->gpio);
        if(dev->gpio < 0){
            printk("no GPIO found!\r\n");
            ret = -EINVAL;     //errno-base.h中定义的异常数值到34,这里从100开始使用防止冲突
            goto fail_gpio;
        }
    
        //请求GPIO
        ret = gpio_request(dev->gpio,"beep-gpio");
        if(ret){
            printk("gpio request err\r\n");
            ret = -EBUSY;
            goto fail_request;}
        
    
        ret = gpio_direction_output(dev->gpio,1);
        if(ret < 0){
            ret = -EINVAL;
            goto fail_gpioset;
        }
    
        return 0;
        fail_gpioset:
            gpio_free(dev->gpio);
        fail_request:
        fail_gpio:
        fail_nd:
            return ret;
    }
    
    
    timer_func(unsigned long arg){
        static int stat = 1;
        int value = 0;
        struct new_dev *dev = (struct new_dev*)arg;
        stat = !stat;
        gpio_set_value(dev->gpio,stat);
        value = atomic_read(&dev->timer_per);
        mod_timer(&dev->timer,jiffies + msecs_to_jiffies(value));
    }
    
    
    static int __init timer_init(void){
    
        int ret = 0; 
        unsigned int value = 500;
    
        //申请设备号
        new_dev.major = 0;
        if(new_dev.major){
            //手动指定设备号,使用指定的设备号
            new_dev.dev_id = MKDEV(new_dev.major,0);
            ret = register_chrdev_region(new_dev.dev_id,DEVICE_CNT,DEVICE_NAME);
        }
        else{
            //设备号未指定,申请设备号
            ret = alloc_chrdev_region(&new_dev.dev_id,0,DEVICE_CNT,DEVICE_NAME);
            new_dev.major = MAJOR(new_dev.dev_id);
            new_dev.minor = MINOR(new_dev.dev_id);
        }
        printk("dev id geted!\r\n");
    
        if(ret<0){
            //设备号申请异常,跳转至异常处理
            goto faile_devid;
        }
    
        //字符设备cdev初始化
        new_dev.cdev.owner = THIS_MODULE;
    
        cdev_init(&new_dev.cdev,&key_fops);                 //文件操作集合映射
    
        ret = cdev_add(&new_dev.cdev,new_dev.dev_id,DEVICE_CNT);
        if(ret<0){
            //cdev初始化异常,跳转至异常处理
            goto fail_cdev;
        }
    
        printk("chr dev inited!\r\n");
    
    
        //自动创建设备节点
        new_dev.class = class_create(THIS_MODULE,DEVICE_NAME);
        if(IS_ERR(new_dev.class)){
            //class创建异常处理
            printk("class err!\r\n");
            ret = PTR_ERR(new_dev.class);
            goto fail_class;
        }
        printk("dev class created\r\n");
        new_dev.device = device_create(new_dev.class,NULL,new_dev.dev_id,NULL,DEVICE_NAME);
        if(IS_ERR(new_dev.device)){
            //设备创建异常处理
            printk("device err!\r\n");
            ret = PTR_ERR(new_dev.device);
            goto fail_device;
        }
        printk("device created!\r\n");
    
    
        //gpio外设初始化
        ret = beep_init(&new_dev);
        if(ret<0){
            printk("gpio init err\r\n");
            goto fail_gpioinit;
        }
    
    
        //定时器初始化
        init_timer(&new_dev.timer);
        atomic_set(&new_dev.timer_per,value);
        new_dev.timer.expires = jiffies + msecs_to_jiffies(value);
        new_dev.timer.function = timer_func;
        new_dev.timer.data = (unsigned long)&new_dev;
        add_timer(&new_dev.timer);
    
    
    
        return ret;
    fail_gpioinit:
    
    fail_device:
        //device创建失败,意味着class创建成功,应该将class销毁
        printk("device create err,class destroyed\r\n");
        class_destroy(new_dev.class);
    fail_class:
        //类创建失败,意味着设备应该已经创建成功,此刻应将其释放掉
        printk("class create err,cdev del\r\n");
        cdev_del(&new_dev.cdev);
    fail_cdev:
        //cdev初始化异常,意味着设备号已经申请完成,应将其释放
        printk("cdev init err,chrdev register\r\n");
        unregister_chrdev_region(new_dev.dev_id,DEVICE_CNT);
    faile_devid:
        //设备号申请异常,由于是第一步操作,不需要进行其他处理
        printk("dev id err\r\n");
        return ret;
    }
    
    
    static void __exit timer_exit(void)
    {
    
        gpio_set_value(new_dev.gpio,1);
        del_timer(&new_dev.timer);
    
        cdev_del(&new_dev.cdev);
        unregister_chrdev_region(new_dev.dev_id,DEVICE_CNT);
    
        device_destroy(new_dev.class,new_dev.dev_id);
        class_destroy(new_dev.class);
    
        gpio_free(new_dev.gpio);
    
    }
    module_init(timer_init);
    module_exit(timer_exit);
    MODULE_LICENSE("GPL");
    MODULE_AUTHOR("ZeqiZ");
    View Code

    应用APP

    先把应用APP的代码放出来

    /**
     * @file timerAPP.c
     * @author your name (you@domain.com)
     * @brief 定时器APP测试程序
     * @version 0.1
     * @date 2022-07-16
     * 
     * @copyright Copyright (c) 2022
     * 
     */
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <unistd.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/ioctl.h>
    
    #define CMD_CLOSE       _IO(0xEF,1)
    #define CMD_OPEN        _IO(0xEF,2)
    #define CMD_PERIOD      _IOW(0xEF,3,int)  
    
    /**
     * @brief 
     * 
     * @param argc                      //参数个数 
     * @param argv                      //参数
     * @return int 
     */
    int main(int argc,char *argv[])
    {
        char *filename;                 //文件名
        filename = argv[1];             //文件名为命令行后第二个参数(索引值为1)
        int value = 0;
        int ret = 0;                    //初始化操作返回值
        int f = 0;                      //初始化文件句柄
    
        unsigned int cmd;
        unsigned int arg;
        unsigned char str[100];
    
        f = open(filename, O_RDWR);     //打开文件
        
        if(f < 0){
        printf("file open error\r\n");
        return -1;
        }
    
        while(1){
            printf("Input CMD:");
            ret = scanf("%d",&cmd);     //cmd对应
    
            if(ret !=1){
                gets(str);//防止卡死
            }
    
            if(cmd == 1){               //cmd值为1,关闭定时器
                ioctl(f,CMD_CLOSE,&arg);
            }
            else if(cmd == 2){          //cmd值为2,启动定时器
                ioctl(f,CMD_OPEN,&arg);
            } 
            else if(cmd == 3){          //cmd值为3,设置定时器周期
                printf("Input Timer Period=");
                ret = scanf("%d",&arg);
                if(ret !=1){
                    gets(str);
                }
                ioctl(f,CMD_PERIOD,&arg);
            }
            }
        
        close(f);                       //关闭文件
        return 0;
    }

    程序运行以后,先打开驱动文件,然后进入while循环等待键盘输入cmd的值。当输入1时,定时器关闭,输入2时定时器重新启动,输入3时从键盘获取新的周期值

  • 相关阅读:
    夜半随笔
    VC6.0 工程转到VS2008一些问题
    没有找到MSVCR90.dll,因此这个应用程序未能启动,重新安装应用程序可解决
    开源中国
    保存桌面图标的次序吧
    2008下,错误:fatal error C1853
    生成成功,调试时出现错误导致中断Unhandled exception at 0x41cd7fb0 in webcam.exe: 0xC0000005: Access violatio
    opencv安装可能没注意的细节
    error PRJ0003 : 生成“cl.exe”时出错
    wince对话框添加菜单
  • 原文地址:https://www.cnblogs.com/yinsedeyinse/p/16479894.html
Copyright © 2020-2023  润新知