zoukankan      html  css  js  c++  java
  • Tiny6410 LED字符设备驱动

    1.查看用户手册

    led1、led2、led3、led4 连接的分别是 GPK4、GPK5、GPK6、GPK7

    2、查询6410芯片手册

     

    下面还需要3个步骤:

    1、设置GPIOOUTPUT

       将GPK4GPK5GPK6GPK7设置为输出output=0001

       即GPKCON019:28都配置为0001

    2、设置GPIO的数据。

       将GPKDATA4:7位赋值为0

     

    3、设置GPKUP为上拉。

       将GPKUP4:7位设置为10

    3、代码

    led_driver.c

    1. #include <linux/module.h>  /*它定义了模块的 API、类型和宏(MODULE_LICENSE、MODULE_AUTHOR等等),所有的内核模块都必须包含这个头文件。*/   
    2.     
    3. #include <linux/kernel.h>  /*使用内核信息优先级时要包含这个文件,一般在使用printk函数时使用到优先级信息*/  
    4.   
    5. #include <linux/fs.h>    
    6. #include <asm/uaccess.h> /* copy_to_user,copy_from_user */     
    7. #include <linux/pci.h>     /*readl writel*/  
    8. #include <mach/map.h>     
    9. #include <mach/regs-gpio.h>      
    10. #include <mach/gpio-bank-k.h>      
    11.   
    12.     
    13. #define LED_MAJOR   243  
    14.   
    15. #define LED_ON      1  
    16. #define LED_OFF     0  
    17. #define LED_1_ON    2  
    18. #define LED_1_OFF   3  
    19. #define LED_2_ON    4  
    20. #define LED_2_OFF   5  
    21. #define LED_3_ON    6  
    22. #define LED_3_OFF   7  
    23. #define LED_4_ON    8  
    24. #define LED_4_OFF   9  
    25.   
    26.    
    27. static int led_open (struct inode *inode,struct file *filp)    
    28.     
    29. {    
    30.     unsigned tmp;       
    31.   
    32.     tmp = readl(S3C64XX_GPKCON);      
    33.     tmp = (tmp&0x0000ffff)| 0x1111ffff;  
    34.     writel(tmp, S3C64XX_GPKCON);     
    35.   
    36.     printk("#########open###### ");    
    37.     return 0;    
    38. }    
    39.     
    40. static int led_read (struct file *filp, char __user *buf, size_t count,loff_t *f_pos)    
    41. {      
    42.     return count;    
    43. }    
    44.     
    45.     
    46. static int led_write (struct file *filp, const char __user *buf, size_t count,loff_t *f_pos)  
    47.   
    48. {    
    49.     char wbuf[10];    
    50.     unsigned tmp;       
    51.       
    52.     if(copy_from_user(wbuf,buf,count))  
    53.         return -EFAULT;    
    54.   
    55.         switch(wbuf[0])    
    56.         {    
    57.           
    58.         case LED_ON:   
    59.                 tmp = readl(S3C64XX_GPKDAT);       
    60.             tmp &= (0x0f);       
    61.             writel(tmp, S3C64XX_GPKDAT);  
    62.             printk("turn on! ");      
    63.                 break;  
    64.   
    65.         case LED_OFF:    
    66.             tmp  = readl(S3C64XX_GPKDAT);       
    67.             tmp |= (0xf0);       
    68.             writel(tmp, S3C64XX_GPKDAT);   
    69.             printk("turn off! ");     
    70.             break;             
    71.   
    72.         case LED_1_ON:    
    73.             tmp = readl(S3C64XX_GPKDAT);       
    74.             tmp &= (0xef);       
    75.             writel(tmp, S3C64XX_GPKDAT);   
    76.             printk("turn off! ");     
    77.             break;    
    78.   
    79.         case LED_1_OFF:   
    80.                 tmp = readl(S3C64XX_GPKDAT);       
    81.             tmp |= (0xf0);       
    82.             writel(tmp, S3C64XX_GPKDAT);  
    83.             printk("turn on! ");      
    84.                 break;    
    85.   
    86.         case LED_2_ON:    
    87.             tmp = readl(S3C64XX_GPKDAT);       
    88.             tmp &= (0xdf);       
    89.             writel(tmp, S3C64XX_GPKDAT);   
    90.             printk("turn off! ");     
    91.             break;    
    92.   
    93.         case LED_2_OFF:   
    94.                 tmp = readl(S3C64XX_GPKDAT);       
    95.             tmp |= (0xf0);       
    96.             writel(tmp, S3C64XX_GPKDAT);  
    97.             printk("turn on! ");      
    98.                 break;    
    99.   
    100.         case LED_3_ON:    
    101.             tmp = readl(S3C64XX_GPKDAT);       
    102.             tmp &= (0xbf);       
    103.             writel(tmp, S3C64XX_GPKDAT);   
    104.             printk("turn off! ");     
    105.             break;    
    106.   
    107.         case LED_3_OFF:   
    108.                 tmp = readl(S3C64XX_GPKDAT);       
    109.             tmp |= (0xf0);       
    110.             writel(tmp, S3C64XX_GPKDAT);  
    111.             printk("turn on! ");      
    112.                 break;    
    113.   
    114.         case LED_4_ON:    
    115.             tmp = readl(S3C64XX_GPKDAT);       
    116.             tmp &= (0x7f);       
    117.             writel(tmp, S3C64XX_GPKDAT);   
    118.             printk("turn off! ");     
    119.             break;    
    120.   
    121.         case LED_4_OFF:   
    122.                 tmp  = readl(S3C64XX_GPKDAT);       
    123.             tmp |= (0xf0);       
    124.             writel(tmp, S3C64XX_GPKDAT);  
    125.             printk("turn on! ");      
    126.                 break;    
    127.           
    128.   
    129.         default :    
    130.                 break;    
    131.         }    
    132.      return 0;    
    133. }    
    134.     
    135. int led_release (struct inode *inode, struct file *filp)    
    136. {    
    137.     printk("#########release###### ");    
    138.     return 0;    
    139. }    
    140.     
    141. struct file_operations led_fops =  
    142. {    
    143.     .owner = THIS_MODULE,    
    144.     .open = led_open,    
    145.     .read = led_read,    
    146.     .write = led_write,    
    147.     .release = led_release,    
    148. };    
    149.     
    150. int __init led_init (void)    
    151. {     
    152.     int rc;    
    153.     printk ("Test led dev ");    
    154.     rc = register_chrdev(LED_MAJOR,"led",&led_fops);   
    155.    
    156.     if (rc <0)    
    157.     {    
    158.         printk ("register %s char dev error ","led");    
    159.         return -1;    
    160.     }    
    161.     printk ("ok! ");    
    162.     return 0;    
    163. }    
    164.     
    165. void __exit led_exit (void)    
    166. {    
    167.     unregister_chrdev(LED_MAJOR,"led");    
    168.     printk ("module exit ");    
    169.     return ;    
    170. }    
    171.     
    172. module_init(led_init);    
    173. module_exit(led_exit);    


    Makefile

     

    1. obj-m := led_driver.o    
    2. KDIR :=/home/workdir/kernel/linux-2.6.38  
    3. all:    
    4.     make -C $(KDIR) M=$(shell pwd) modules    
    5. install:    
    6.     cp driver_led.ko /tftpboot/    
    7. clean:    
    8.     make -C $(KDIR) M=$(shell pwd) clean   



     

    测试文件

     

    test_led.c

    1. #include <stdio.h>    
    2. #include <sys/types.h>    
    3. #include <sys/stat.h>    
    4. #include <fcntl.h>    
    5.   
    6.   
    7. #define LED_OFF     0  
    8. #define LED_ON      1  
    9. #define LED_1_ON    2  
    10. #define LED_1_OFF   3  
    11. #define LED_2_ON    4  
    12. #define LED_2_OFF   5  
    13. #define LED_3_ON    6  
    14. #define LED_3_OFF   7  
    15. #define LED_4_ON    8  
    16. #define LED_4_OFF   9  
    17.   
    18. int main (void)    
    19. {    
    20.     int  i=0;  
    21.     int  fd;    
    22.     char buf[10]={  
    23.             LED_ON ,   LED_OFF ,  
    24.             LED_1_ON,  LED_1_OFF,  
    25.             LED_2_ON,  LED_2_OFF,  
    26.             LED_3_ON,  LED_3_OFF,  
    27.             LED_4_ON,  LED_4_OFF,             
    28.          };    
    29.   
    30.     fd = open("/dev/led",O_RDWR);    
    31.     if (fd < 0)    
    32.     {    
    33.         printf ("Open /dev/led file error ");    
    34.         return -1;    
    35.     }       
    36.   
    37.     while(i<10)    
    38.     {    
    39.         write(fd,&buf[i],4);    
    40.         sleep(1);    
    41.         i++;  
    42.     }    
    43.     close (fd);    
    44.     return 0;    
    45.     
    46. }    



    上述编译没有问题,就可以下到板子测试了。

     

    加载驱动          insmod  led_driver.ko

    创建设备文件    mknod /dev/led c 243 0  其中243要跟驱动文件中的设备号一致

    运行测试文件    ./test_led

    完成。

     

    ------------------------------------------------------------------------------------------

    参考资料:

     

    #include <linux/module.h>/*它定义了模块的 API、类型和宏(MODULE_LICENSE、MODULE_AUTHOR等等),所有的内核模块都必须包含这个头文件。/
    #include <linux/kernel.h>/*使用内核信息优先级时要包含这个文件,一般在使用printk函数时使用到优先级信息*/
    #include <linux/init.h>//头文件:module_init、module_exit等宏定义。
    #include <linux/fs.h>////struct file_operations
    #include <asm/irq.h>
    #include <mach/regs-gpio.h>// S3C2410 GPIO寄存器定义
    #include <mach/hardware.h>// s3c2410_gpio_setpin, s3c2410_gpio_cfgpin等
    #include <linux/device.h>//class_create device_create(注意,有些2.6.27以前是的可能是class_device_create,如果出现implicate 错误时,看一下这个头问题里边是哪一个),udev,自动在/dev下创建设备节点
    #include <linux/cdev.h>//字符设备节点注册,函数有cdev_init,cdev_add,cdev_del等早期的办法是register_chrdev,unregister_chrdev这种方法应避免使用。
    #define DEVICE_NAME "leds" /* 加载模式后,执行”cat /proc/devices”命令看到的设备名称 */
    #define LED_MAJOR 231 /* 主设备号 */
    /* 应用程序执行ioctl(fd, cmd, arg)时的第2个参数 */
    #define IOCTL_LED_ON 1
    #define IOCTL_LED_OFF 0
    /* 用来指定LED所用的GPIO引脚 */
    static unsigned long led_table [] =
    {
    S3C2410_GPB5,
    S3C2410_GPB6,
    S3C2410_GPB7,
    S3C2410_GPB8,
    };
    /* 用来指定GPIO引脚的功能:输出 */
    static unsigned int led_cfg_table [] =
    {
    S3C2410_GPB5_OUTP,
    S3C2410_GPB6_OUTP,
    S3C2410_GPB7_OUTP,
    S3C2410_GPB8_OUTP,
    };
    struct leds_type
    {
    struct cdev cdev;
    };
    struct leds_type *my_leds_dev;
    /* 应用程序对设备文件/dev/EmbedSky-leds执行open(...)时,
    * 就会调用EmbedSky_leds_open函数
    */
    static int EmbedSky_leds_open(struct inode *inode, struct file *file)
    {
    int i;
    for (i = 0; i < 4; i++)
    {
    // 设置GPIO引脚的功能:本驱动中LED所涉及的GPIO引脚设为输出功能
    s3c2410_gpio_cfgpin(led_table[i], led_cfg_table[i]);
    }
    return 0;
    }
    /* 应用程序对设备文件/dev/EmbedSky-leds执行ioclt(...)时,
    * 就会调用EmbedSky_leds_ioctl函数
    */
    static int EmbedSky_leds_ioctl( struct inode *inode, struct file *file, unsigned int cmd, unsigned long arg)
    {
    if (arg > 4)
    {
    return -EINVAL;
    }
    switch(cmd)
    {
    case IOCTL_LED_ON:
    // 设置指定引脚的输出电平为0
    s3c2410_gpio_setpin(led_table[arg], 0);
    return 0;
    case IOCTL_LED_OFF:
    // 设置指定引脚的输出电平为1
    s3c2410_gpio_setpin(led_table[arg], 1);
    return 0;
    default:
    return -EINVAL;
    }
    }
    /* 这个结构是字符设备驱动程序的核心
    * 当应用程序操作设备文件时所调用的open、read、write等函数,
    * 最终会调用这个结构中指定的对应函数
    */
    static struct file_operations EmbedSky_leds_fops =
    {
    .owner = THIS_MODULE, /* 这是一个宏,推向编译模块时自动创建的__this_module变量 */
    .open = EmbedSky_leds_open, 
    .ioctl = EmbedSky_leds_ioctl,
    };
    static char __initdata banner[] = "TQ2440/SKY2440 LEDS, (c) 2008,2009 www.embedsky.net/n";
    static struct class *led_class;
    /*
    * 执行“insmod EmbedSky_leds.ko”命令时就会调用这个函数
    */
    static int __init EmbedSky_leds_init(void)
    {
    int ret;
    dev_t devno=MKDEV(LED_MAJOR,0);
    printk("init led/n");
    printk(banner);
    /* 注册字符设备驱动程序
    * 参数为主设备号、设备名字、file_operations结构;
    * 这样,主设备号就和具体的file_operations结构联系起来了,
    * 操作主设备为LED_MAJOR的设备文件时,就会调用EmbedSky_leds_fops中的相关成员函数
    ret = register_chrdev_region(devno, 1,DEVICE_NAME);//获得设备编号
    my_leds_dev=kmalloc(sizeof(struct leds_type),GFP_KERNEL);
    /*这个必须有不然会在加载模块时出现Unable to handle kernel NULL pointer dereference at virtual addres 00000000 错误,这是由于在这里my_leds_dev仅仅是个指针,没有相应大小的分配内存,所以使用时会出错,,,寻找这个错误是比较麻烦的*/
    if(!my_leds_dev)
    {
    ret=-ENOMEM;
    goto fail_malloc;
    }
    memset(my_leds_dev,0,sizeof(struct leds_type));
    cdev_init(&(my_leds_dev->cdev),&EmbedSky_leds_fops);
    ret=cdev_add(&(my_leds_dev->cdev),devno,1);
    /*注意:与早期的设备注册方法不同,早期的直接register_chrdev()就可以,*/
    if(ret)printk(KERN_NOTICE"ERROR %d",ret); 
    //注册一个类,使mdev可以在"/dev/"目录下面建立设备节点
    led_class = class_create(THIS_MODULE, DEVICE_NAME);
    if(IS_ERR(led_class))
    {
    printk("Err: failed in EmbedSky-leds class. /n");
    return -1;
    }
    //创建一个设备节点,节点名为DEVICE_NAME
    device_create(led_class, NULL, MKDEV(LED_MAJOR, 0), NULL, DEVICE_NAME);
    printk(DEVICE_NAME " initialized/n");
    return 0;
    fail_malloc: unregister_chrdev_region(devno,1);
    return ret;
    }
    /*
    * 执行”rmmod EmbedSky_leds.ko”命令时就会调用这个函数
    */
    static void __exit EmbedSky_leds_exit(void)
    {
    /* 卸载驱动程序 */
    unregister_chrdev(LED_MAJOR, DEVICE_NAME);
    device_destroy(led_class, MKDEV(LED_MAJOR, 0)); //删掉设备节点
    class_destroy(led_class); //注销类
    }
    /* 这两行指定驱动程序的初始化函数和卸载函数 */
    module_init(EmbedSky_leds_init);
    module_exit(EmbedSky_leds_exit);
    /* 描述驱动程序的一些信息,不是必须的 */
    MODULE_AUTHOR("http://www.embedsky.net"); // 驱动程序的作者
    MODULE_DESCRIPTION("TQ2440/SKY2440 LED Driver"); // 一些描述信息
    MODULE_LICENSE("GPL"); // 遵循的协议
    上面代码中,led_table数组相当于对应了GPB的四个IO口的索引,通过这四个值,对这四个IO口进行相关操作。例如:
    S3C2410_GPB5 = S3C2410_GPIONO(S3C2410_GPIO_BANKB, 5)
    = S3C2410_GPIO_BANKB + 5
    = 32*1 + 5
    在s3c2410_gpio_setpin(S3C2410_GPB5,0)中,该函数首先通过S3C2410_GPB5获得GPB的虚拟地址和偏移地址,再对GPB5的GPBDAT寄存器进行操作,具体
    void s3c2410_gpio_setpin(unsigned int pin, unsigned int to)
    {
    void __iomem *base = S3C2410_GPIO_BASE(pin);
    unsigned long offs = S3C2410_GPIO_OFFSET(pin);
    unsigned long flags;
    unsigned long dat;
    local_irq_save(flags);
    dat = __raw_readl(base + 0x04);//读取GPIO的DAT数据到dat
    dat &= ~(1 << offs); //先将要设置的IO口拉低
    dat |= to << offs; //再将形参的to值赋给dat
    __raw_writel(dat, base + 0x04);//最后将DAT值写进GPIO的DAT
    local_irq_restore(flags);
    }

    上面的 函数调用了两个子函数,具体定义如下

    #define S3C2410_GPIO_BASE(pin) ((((pin) & ~31) >> 1) + S3C24XX_VA_GPIO)
    #define S3C2410_GPIO_OFFSET(pin) ((pin) & 31)
    其中S3C24XX_VA_GPIO定义如下:

    #define S3C24XX_VA_GPIO S3C2410_ADDR(0x00E00000)
    #define S3C2410_ADDR(x) (0xF0000000 + (x))
    这里S3C2410_ADDR的基地址为0xF0000000(??),也即2440所有寄存器的虚拟地址的基地址。0x00E00000表示2440的GPIO的偏移地址,也就是说其GPIO的虚拟地址首地址为0xF0E00000。

    再看看S3C2410_GPIO_BASE(pin)的定义,我们不仿把S3C2410_GPB5的值放进去计算,可以得到(S3C2410_GPB5&~31)=32。其目的就是去掉GPB的偏移值,然后再右移一位,和GPIO的虚拟地址首地址相加。因此,S3C2410_GPIO_BASE(pin)只代表了对应GPIO组的虚拟地址,如GPB的虚拟地址为10000(B)+0xF0E00000=0xF0E00010。依此类推,可以得到所有GPIO的偏移地址,具体如下表:


    BANK
    (pin&~31)
    (pin&~31)>>1
    S3C2410_GPIO_BASE(pin)

    GPA
    32*0
    0000,0000
    0x00
    0xF0E00000

    GPB
    32*1
    0010,0000
    0x10
    0xF0E00010

    GPC
    32*2
    0100,0000
    0x20
    0xF0E00020

    GPD
    32*3
    0110,0000
    0x30
    0xF0E00030

    GPE
    32*4
    1000,0000
    0x40
    0xF0E00040

    GPF
    32*5
    1010,0000
    0x50
    0xF0E00050

    GPG
    32*6
    1100,0000
    0x60
    0xF0E00060

    GPH
    32*7
    1110,0000
    0x70
    0xF0E00070


    S3C2410_GPIO_OFFSET用于获得具体GPIO的偏移地址。如GPB5,则S3C2410_GPIO_OFFSET(pin) = (pin)&31 = (32*1 + 5) & 31 = 5。有了*base和off,就可以操作具体的寄存器了。
    函数s3c2410_gpio_cfgpin()用于配置GPCON寄存器。具体代码
    void s3c2410_gpio_cfgpin(unsigned int pin, unsigned int function)
    {
    void __iomem *base = S3C2410_GPIO_BASE(pin);
    unsigned long mask;
    unsigned long con;
    unsigned long flags;
    if (pin < S3C2410_GPIO_BANKB)
    {
    mask = 1 << S3C2410_GPIO_OFFSET(pin);//GPA的寄存器只占一位
    }
    else 
    {
    mask = 3 << S3C2410_GPIO_OFFSET(pin)*2;//非GPA的寄存器占两位
    }
    local_irq_save(flags);
    con = __raw_readl(base + 0x00);//先保留GPCON的值
    con &= ~mask; //再将要设置的管脚的CON值清零
    con |= function; //然后将形参传进来的配置赋给CON
    __raw_writel(con, base + 0x00); //最后将CON值写进GPCON寄存器
    local_irq_restore(flags);
    }
    上面的LED驱动程序中,led_cfg_table数组给出了GPB相应管脚的属性设置,调用上面的函数后即设置为Output。

    到此为止,整个S3C2440的IO口操作,应该就一目了然了

    #define __raw_writel(v,a) (__chk_io_ptr(a), *(volatile unsigned int __force *)(a) = (v)) 
    # define __chk_io_ptr(x) (void)0

    __chk_io_ptr()是编译器为了更细致地检查参数的属性,用于调试,正常编译时没有作用。 
    volatile为了防止Compiler优化。

    内核中,对所有的地址都是通过虚拟地址进行访问的.因此,不能直接访问0x56000010的物理地址,如果要对0x56000010的物理地址进行访问(一般是外设寄存器),那么需要把0x56000010的物理地址映射为虚拟地址,然后对该虚拟地址进行访问就是对实际的物理地址进行访问了。 
    需要注意的是:在进行映射时,该虚拟地址需要disable cache和Write Buffer, 否则就是加了volatile也是没有用的

    这个IOREMAP的实现过程中 
    /* 
    * figure out the physical address offset in a page size 
    * PAGE_MASK = (1 << 10) 
    */ 
    offset = phys_addr &~ PAGE_MASK; 
    /* 
    * figure out physical address with page align 
    */ 
    phys_addrs &= PAGE_MASK; 
    /* 
    * get the real size with page align 
    */ 
    size = PAGE_ALIGN(last_addr) - phys_addrs; 
    下面是通过vmlist中查找以size大小的空闲块,所以从这里可以看出,已经做过了页的对齐,只以映射的大小

    杂项设备(misc device)

    杂项设备也是在嵌入式系统中用得比较多的一种设备驱动。在 Linux 内核的include/linux目录下有Miscdevice.h文件,要把自己定义的misc device从设备定义在这里。其实是因为这些字符设备不符合预先确定的字符设备范畴,所有这些设备采用主编号10 ,一起归于misc device,其实misc_register就是用主标号10调用register_chrdev()的。 

    也就是说,misc设备其实也就是特殊的字符设备。 

    字符设备(char device) 

    使用register_chrdev(LED_MAJOR,DEVICE_NAME,&dev_fops)注册字符设备驱动程序时,如果有多个设备使用该函数注册驱动程序,LED_MAJOR不能相同,否则几个设备都无法注册(我已验证)。如果模块使用该方式注册并且 LED_MAJOR为0(自动分配主设备号 ),使用insmod命令加载模块时会在终端显示分配的主设备号和次设备号,在/dev目录下建立该节点,比如设备leds,如果加载该模块时分配的主设备号和次设备号为253和0,则建立节点:mknod leds c 253 0。使用register_chrdev (LED_MAJOR,DEVICE_NAME,&dev_fops)注册字符设备驱动程序时都要手动建立节点 ,否则在应用程序无法打开该设备。

    __raw_readl和__raw_writel

    Linux对I/O的操作都定义在asm/io.h中,相应的在arm平台下,就在asm-arm/io.h中。

    #define __raw_writeb(v,a) (__chk_io_ptr(a), *(volatile unsigned char __force *)(a) = (v))

    #define __raw_writew(v,a) (__chk_io_ptr(a), *(volatile unsigned short __force *)(a) = (v))

    #define __raw_writel(v,a) (__chk_io_ptr(a), *(volatile unsigned int __force *)(a) = (v))在include/linux/compiler.h中:

    #ifdef __CHECKER__……
    extern void __chk_io_ptr(void __iomem *);
    #else……
    # define __chk_io_ptr(x) (void)0……
    #endif

    __raw_readl(a)展开是:((void)0, *(volatile unsigned int _force *)(a))。在定义了__CHECKER__的时候先调用__chk_io_ptr检查该地址,否则__chk_io_ptr什么也不做,*(volatile unsigned int _force *)(a)就是返回地址为a处的值。(void)xx的做法有时候是有用的,例如编译器打开了检查未使用的参数的时候需要将没有用到的参数这么弄一下才能编译通过。

    CPU对I/O的物理地址的编程方式有两种:一种是I/O映射,一种是内存映射。__raw_readl和__raw_writel等是原始的操作I/O的方法,由此派生出来的操作方法有:inb、outb、_memcpy_fromio、readb、writeb、ioread8、iowrite8等。

  • 相关阅读:
    [NOIp2016] 天天爱跑步
    状压DP小拼盘
    DP × KMP
    KMP算法 详解+模板
    [NOI2014] 起床困难综合症
    [洛谷P3391] 文艺平衡树 (Splay模板)
    START
    【C】单链表的实现
    【数据结构】动态顺序表
    C语言实现扫雷程序
  • 原文地址:https://www.cnblogs.com/ljf181275034/p/3219176.html
Copyright © 2011-2022 走看看