subsistema linux SPI

1. Información general

SPI es una abreviatura de "Serial Peripheral Interface", y es una interfaz de comunicación serial síncrona de cuatro cables, que se utiliza para conectar microcontroladores, sensores, dispositivos de memoria, el dispositivo SPI se divide en un dispositivo maestro y el dispositivo esclavo son dos, para la comunicación Las cuatro líneas de control y control son:

  • Señal de selección de chip CS
  • Señal de reloj SCK
  • Entrada de datos del dispositivo maestro MISO , pin de salida de datos del dispositivo esclavo
  • Salida de datos MOSI del dispositivo principal, desde el pin de entrada de datos del dispositivo
    porque en la mayoría de los casos, la CPU o el lado SOC generalmente funcionan en modo maestro, por lo tanto, las versiones actuales del kernel de Linux, el motor de accionamiento es solo el marco del modo maestro .

Este artículo se basa en el kernel linux-3.0.35 y el hardware imx6q

2. Conexión de hardware

Escriba la descripción de la imagen aquí

imx6q admite cuatro dispositivos esclavos SPI externos El SPI0 de ECSPI admite el modo salve, que no está implementado por el kernel, por lo que si desea utilizar este modo, debe escribir el controlador usted mismo.

El dispositivo esclavo utilizado aquí es sc16is752, un chip de spi a uart.

3. Secuencia de trabajo

Según la relación de fase entre la señal de reloj y la señal de datos, SPI tiene 4 modos de temporización de trabajo:

Escriba la descripción de la imagen aquí

Usamos CPOL para representar el estado del nivel inicial de la señal del reloj, un CPOL de 0 indica que el estado inicial de la señal del reloj es bajo y un valor de 1 indica que el nivel inicial de la señal del reloj es alto. Además, utilizamos CPHA para indicar que los datos se muestrean en ese borde del reloj. Un CPHA de 0 significa que los datos se muestrean en el primer borde del reloj, y un CPHA de 1 significa que los datos se muestrean en el segundo borde del reloj. . Utilice la combinación de CPOL y CPHA para representar el modo de trabajo actual requerido por SPI:

  • CPOL = 0, CPHA = 1 Modo 0
  • CPOL = 0, CPHA = 1 Modo 1
  • CPOL = 1, CPHA = 0 Modo 2
  • CPOL = 1, CPHA = 1 Modo 3

sc16is752 solo admite el modo 0

4. temporización de funcionamiento del sc16is752

Escriba la descripción de la imagen aquí

5. Archivos relacionados con el kernel

Aquí usamos un controlador spidev que viene con el kernel de Linux, y todos los archivos involucrados son los siguientes:

manejar:

./drivers/spi/spidev.c
./drivers/spi/spidev.h

Subsistema:

./drivers/spi/spi.c
./include/linux/spi/spi.h

capa de en medio:

./drivers/spi/spi_bitbang.c
./drivers/spi/spi_bitbang.h

Fondo:

./drivers/spi/spi_imx.c


Registro de dispositivo de plataforma:

./arch/arm/plat-mxc/devices/platform-spi_imx.c

Archivos a nivel de placa:

./arch/arm/mach-mx6/board-mx6q_sabreauto.h
./arch/arm/mach-mx6/board-mx6q_sabreauto.c

5. Modificación del kernel

El subsistema SPI se basa en el marco del dispositivo de la plataforma , por lo que los nombres del dispositivo y el controlador deben ser coherentes para que coincidan correctamente.

Paso 1: configurar el pad

La configuración de imx6 es bastante complicada, un pin se puede configurar en diferentes funciones a través de mux

static iomux_v3_cfg_t mx6q_sabreauto_pads[] = {
              .
              .
              .
        //ECSPI2
        MX6Q_PAD_DISP0_DAT19__ECSPI2_SCLK,
        MX6Q_PAD_DISP0_DAT17__ECSPI2_MISO,
        MX6Q_PAD_DISP0_DAT16__ECSPI2_MOSI,
        MX6Q_PAD_DISP0_DAT18__ECSPI2_SS0,
        MX6Q_PAD_DISP0_DAT22__GPIO_5_16, //这个引脚可以做为SC16IS752的中断输入(本文不用)
                .
                .
                .
                .

 };

Paso 2: definir la información de la plataforma

static struct spi_board_info imx6_sabresd_spi_uart[] __initdata = {
    {
        .modalias = "sc16is752", //这个别名非常重要,必须要和spidev.c驱动里的名字保持一致
        .max_speed_hz = 1000000,  //最大的速度
        .bus_num = 1,            //这里我们用的ECSPI2,下标要注意
        .chip_select = 0,        //片选,是低电平有效
        .mode = SPI_MODE_0,      //使用的SPI模式0
//      .irq = gpio_to_irq(SPI_UART_IRQ),
    },
};

Paso 3: inicializar el equipo de la plataforma

static void spi_device_init(void)
{
    spi_register_board_info(imx6_sabresd_spi_uart, ARRAY_SIZE(imx6_sabresd_spi_uart));
}

static void __init mx6_board_init(void)
{

   。。。

       /* SPI */
     imx6q_add_ecspi(0, &mx6q_sabreauto_spi1_data);
     imx6q_add_ecspi(1, &mx6q_sabreauto_spi2_data);
     imx6q_add_ecspi(2, &mx6q_sabreauto_spi3_data);
     spi_device_init();

。。。。
}

Paso 4: Impulsar los cambios

static struct spi_driver spidev_spi_driver = {
    .driver = {
        //.name =       "spidev",  
        .name =     "sc16is752", // 这里改为sc16is752,当然改spi_board_info 里的值也是一样的,只要一致
        .owner =    THIS_MODULE,
    },
    .probe =    spidev_probe,
    .remove =   __devexit_p(spidev_remove),

    /* NOTE:  suspend/resume methods are not necessary here.
     * We don't do anything except pass the requests to/from
     * the underlying controller.  The refrigerator handles
     * most issues; the controller driver handles the rest.
     */
};

Paso 5: prueba

El código de prueba del controlador spidev se proporciona en el kernel, ubicado en

spidev_test.c en el directorio ./documentation/spi

Primero puede cortocircuitar el MISO y el MOSI con unas pinzas, si ve que los datos enviados y recibidos son los mismos, el controlador ha estado funcionando normalmente. El resto es la configuración de SC16IS752.

6. Análisis de la estructura de datos clave

Si desea comprender el análisis del subsistema, debe comenzar con las siguientes estructuras:

6.1 、 spi_transfer y spi_message

La estructura spi_transfer es una transferencia spi, múltiples spi_transfers forman un spi_message

struct spi_transfer {
    const void  *tx_buf;
    void        *rx_buf;
    unsigned    len;

    dma_addr_t  tx_dma;
    dma_addr_t  rx_dma;

    unsigned    cs_change:1;
    u8      bits_per_word;
    u16     delay_usecs;
    u32     speed_hz;

    struct list_head transfer_list;-------------------------|
};                                                          |
                                                            |
struct spi_message {                                        |
    struct list_head    transfers;--------------------------|

    struct spi_device   *spi;

    unsigned        is_dma_mapped:1;

    /* completion is reported through a callback */
    void            (*complete)(void *context);
    void            *context;
    unsigned        actual_length;
    int         status;

    struct list_head    queue;
    void            *state;
};

La relación entre spi_transfer y spi_message

Función de interfaz de operación:

static inline void spi_message_init(struct spi_message *m)
static inline void spi_message_add_tail(struct spi_transfer *t, struct spi_message *m)
static inline void spi_transfer_del(struct spi_transfer *t)

6.2 、 ​​spi_master

Esta estructura representa un bus spi

struct spi_master {
    struct device   dev; //继承自device
    struct list_head list;  //spi_register_master 的时候会把spi_master注册到 spi_master_list这个双向链表上
    s16         bus_num; //第几根bus
    u16         num_chipselect; //这个spi master有几个片选信号
    u16         dma_alignment;
    u16         mode_bits;  //spi 协议的mode位
    u16         flags;

    /* lock and mutex for SPI bus locking */
    spinlock_t      bus_lock_spinlock;
    struct mutex        bus_lock_mutex;

    bool            bus_lock_flag;
    // 以下的三个接口都定义在spi_imx.c文件里
    //static int spi_imx_setup(struct spi_device *spi)
    //static int spi_imx_transfer(struct spi_device *spi,struct spi_transfer *transfer)
    //static void spi_imx_cleanup(struct spi_device *spi)
    int         (*setup)(struct spi_device *spi);
    int         (*transfer)(struct spi_device *spi,
                        struct spi_message *mesg);
    void            (*cleanup)(struct spi_device *spi);
};

6.3 、 spi_driver y spi_device

struct spi_device {
    struct device       dev;  //继承自device
    struct spi_master   *master;
    u32         max_speed_hz;
    u8          chip_select;
    u8          mode;
    u8          bits_per_word; //一个word多少个bit
    int         irq; 
    void            *controller_state;
    void            *controller_data;
    char            modalias[SPI_NAME_SIZE]; //这个会从spi_board_info的modalias域拷贝过来,驱动与设备匹配的时候会比较
}

//板级初始化的时候会根据spi_board_info ,new一个spi_device出来
struct spi_device *spi_new_device(struct spi_master *master,
                  struct spi_board_info *chip)
{
    struct spi_device   *proxy;
    int         status;

    proxy = spi_alloc_device(master);
    if (!proxy)
        return NULL;

    WARN_ON(strlen(chip->modalias) >= sizeof(proxy->modalias));

    proxy->chip_select = chip->chip_select;//片选,是低有效还是高有效
    proxy->max_speed_hz = chip->max_speed_hz; //最大频率
    proxy->mode = chip->mode; //spi操作模式
    proxy->irq = chip->irq;//中断号,我这里没有使用
    strlcpy(proxy->modalias, chip->modalias, sizeof(proxy->modalias));//拷贝别名
    proxy->dev.platform_data = (void *) chip->platform_data; //其它的数据可以通过这个指针取到
    proxy->controller_data = chip->controller_data;
    proxy->controller_state = NULL;

    status = spi_add_device(proxy);
    if (status < 0) {
        spi_dev_put(proxy);
        return NULL;
    }

    return proxy;
}

El proceso de coincidencia de spi_driver y spi_device:

¿Cómo encajan? ————— Cuando el controlador de dispositivo de plataforma de spi_imx.c está registrado

struct spi_driver {
    const struct spi_device_id *id_table;
    int         (*probe)(struct spi_device *spi); //匹配成功后会调用的函数
    int         (*remove)(struct spi_device *spi);
    void            (*shutdown)(struct spi_device *spi);
    int         (*suspend)(struct spi_device *spi, pm_message_t mesg);
    int         (*resume)(struct spi_device *spi);
    struct device_driver    driver;
};

// Inicialización del controlador de plataforma:

static struct platform_driver spi_imx_driver = {
    .driver = {
           .name = DRIVER_NAME,
           .owner = THIS_MODULE,
           },
    .id_table = spi_imx_devtype,
    .probe = spi_imx_probe,
    .remove = __devexit_p(spi_imx_remove),
};

static int __init spi_imx_init(void)
{
    return platform_driver_register(&spi_imx_driver);
}

//platform_driver_register 会调用 bus_add_driver
int bus_add_driver(struct device_driver *drv)
{
        .
        .
        .
    if (drv->bus->p->drivers_autoprobe) {
        error = driver_attach(drv); //here
        if (error)
            goto out_unregister;         
    }
        .
        .
        .
}

static int __driver_attach(struct device *dev, void *data)
{
            .
            .
            .

    if (!driver_match_device(drv, dev)) //在这里会进行匹配
        return 0;

    if (dev->parent)    /* Needed for USB */
        device_lock(dev->parent);
    device_lock(dev);
    if (!dev->driver)
        driver_probe_device(drv, dev); //匹配成功了之后再probe
    device_unlock(dev);
    if (dev->parent)
        device_unlock(dev->parent);

    return 0;
}

static int spi_match_device(struct device *dev, struct device_driver *drv)
{
    const struct spi_device *spi = to_spi_device(dev);
    const struct spi_driver *sdrv = to_spi_driver(drv);

    /* Attempt an OF style match */
    if (of_driver_match_device(dev, drv))
        return 1;

    if (sdrv->id_table)
        return !!spi_match_id(sdrv->id_table, spi);

    return strcmp(spi->modalias, drv->name) == 0; //这里最终是匹配的名字
}


int driver_probe_device(struct device_driver *drv, struct device *dev)
{
        .
        .
        .
    pm_runtime_get_noresume(dev);
    pm_runtime_barrier(dev);
    ret = really_probe(dev, drv); //here
    pm_runtime_put_sync(dev);

    return ret;
}


static int really_probe(struct device *dev, struct device_driver *drv)
{
        .
        .
        .

    if (dev->bus->probe) {
        ret = dev->bus->probe(dev);
        if (ret)
            goto probe_failed;
    } else if (drv->probe) {
        ret = drv->probe(dev); //here
        if (ret)
            goto probe_failed;
    }

        .
        .
        .
}

La función de sonda impulsada por la plataforma imx spi:

static int __devinit spi_imx_probe(struct platform_device *pdev)
{
    struct spi_imx_master *mxc_platform_info;
    struct spi_master *master;
    struct spi_imx_data *spi_imx;
    struct resource *res;
    int i, ret;

    mxc_platform_info = dev_get_platdata(&pdev->dev);
    if (!mxc_platform_info) {
        dev_err(&pdev->dev, "can't get the platform data\n");
        return -EINVAL;
    }

    master = spi_alloc_master(&pdev->dev, sizeof(struct spi_imx_data));
    if (!master)
        return -ENOMEM;

    platform_set_drvdata(pdev, master);

    master->bus_num = pdev->id;
    master->num_chipselect = mxc_platform_info->num_chipselect;

    spi_imx = spi_master_get_devdata(master);
    spi_imx->bitbang.master = spi_master_get(master);
    spi_imx->chipselect = mxc_platform_info->chipselect;

    for (i = 0; i < master->num_chipselect; i++) {
        if (spi_imx->chipselect[i] < 0)
            continue;
        ret = gpio_request(spi_imx->chipselect[i], DRIVER_NAME);
        if (ret) {
            while (i > 0) {
                i--;
                if (spi_imx->chipselect[i] >= 0)
                    gpio_free(spi_imx->chipselect[i]);
            }
            dev_err(&pdev->dev, "can't get cs gpios\n");
            goto out_master_put;
        }
    }

    //这里的函数指针初始化比较重要
    spi_imx->bitbang.chipselect = spi_imx_chipselect;
    spi_imx->bitbang.setup_transfer = spi_imx_setupxfer;
    spi_imx->bitbang.txrx_bufs = spi_imx_transfer;
    spi_imx->bitbang.master->setup = spi_imx_setup;
    spi_imx->bitbang.master->cleanup = spi_imx_cleanup;
    spi_imx->bitbang.master->mode_bits = SPI_CPOL | SPI_CPHA | SPI_CS_HIGH;

    init_completion(&spi_imx->xfer_done);

    spi_imx->devtype_data =
        spi_imx_devtype_data[pdev->id_entry->driver_data];

    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    if (!res) {
        dev_err(&pdev->dev, "can't get platform resource\n");
        ret = -ENOMEM;
        goto out_gpio_free;
    }

    if (!request_mem_region(res->start, resource_size(res), pdev->name)) {
        dev_err(&pdev->dev, "request_mem_region failed\n");
        ret = -EBUSY;
        goto out_gpio_free;
    }

    spi_imx->base = ioremap(res->start, resource_size(res));
    if (!spi_imx->base) {
        ret = -EINVAL;
        goto out_release_mem;
    }

    spi_imx->irq = platform_get_irq(pdev, 0);
    if (spi_imx->irq < 0) {
        ret = -EINVAL;
        goto out_iounmap;
    }

    /*上面我说没有用到中断,那这个中断什么鬼呢?这个中断是收发数据的SPI中断,上面说的中断是外接的从设备当数据准备好或是其它的情况的时候向IMX6输入的一个电平。*/
    ret = request_irq(spi_imx->irq, spi_imx_isr, 0, DRIVER_NAME, spi_imx);
    if (ret) {
        dev_err(&pdev->dev, "can't get irq%d: %d\n", spi_imx->irq, ret);
        goto out_iounmap;
    }

    spi_imx->clk = clk_get(&pdev->dev, NULL);
    if (IS_ERR(spi_imx->clk)) {
        dev_err(&pdev->dev, "unable to get clock\n");
        ret = PTR_ERR(spi_imx->clk);
        goto out_free_irq;
    }

    clk_enable(spi_imx->clk);
    spi_imx->spi_clk = clk_get_rate(spi_imx->clk);

    spi_imx->devtype_data.reset(spi_imx);

    spi_imx->devtype_data.intctrl(spi_imx, 0);
    ret = spi_bitbang_start(&spi_imx->bitbang);
    if (ret) {
        dev_err(&pdev->dev, "bitbang start failed with %d\n", ret);
        goto out_clk_put;
    }
    clk_disable(spi_imx->clk);

    //最后打印到这里
    dev_info(&pdev->dev, "probed\n");

    return ret;

out_clk_put:
    clk_disable(spi_imx->clk);
    clk_put(spi_imx->clk);
out_free_irq:
    free_irq(spi_imx->irq, spi_imx);
out_iounmap:
    iounmap(spi_imx->base);
out_release_mem:
    release_mem_region(res->start, resource_size(res));
out_gpio_free:
    for (i = 0; i < master->num_chipselect; i++)
        if (spi_imx->chipselect[i] >= 0)
            gpio_free(spi_imx->chipselect[i]);
out_master_put:
    spi_master_put(master);
    kfree(master);
    platform_set_drvdata(pdev, NULL);
    return ret;
}

Escriba la descripción de la imagen aquí

7. Análisis del flujo de datos

Analice de arriba a abajo cómo se transfiere un dato desde la capa de la aplicación al registro, aquí es para tomar prestado el controlador spidev traído por el propio kernel

7.1 、 prueba_spidev.c

Escriba la descripción de la imagen aquí

7.1 、 spidev.c

static int spidev_message(struct spidev_data *spidev,
        struct spi_ioc_transfer *u_xfers, unsigned n_xfers)
{
    struct spi_message  msg;
    struct spi_transfer *k_xfers;
    struct spi_transfer *k_tmp;
    struct spi_ioc_transfer *u_tmp;
    unsigned        n, total;
    u8          *buf;
    int         status = -EFAULT;

    spi_message_init(&msg); //message 初始化
    k_xfers = kcalloc(n_xfers, sizeof(*k_tmp), GFP_KERNEL);
    if (k_xfers == NULL)
        return -ENOMEM;

    buf = spidev->buffer;
    total = 0;
    for (n = n_xfers, k_tmp = k_xfers, u_tmp = u_xfers;
            n;
            n--, k_tmp++, u_tmp++) {
        k_tmp->len = u_tmp->len;

        total += k_tmp->len;
        if (total > bufsiz) {
            status = -EMSGSIZE;
            goto done;
        }

        if (u_tmp->rx_buf) {
            k_tmp->rx_buf = buf;
            if (!access_ok(VERIFY_WRITE, (u8 __user *)
                        (uintptr_t) u_tmp->rx_buf,
                        u_tmp->len))
                goto done;
        }
        if (u_tmp->tx_buf) {
            k_tmp->tx_buf = buf;
            if (copy_from_user(buf, (const u8 __user *)
                        (uintptr_t) u_tmp->tx_buf,
                    u_tmp->len))
                goto done;
        }
        buf += k_tmp->len;

        k_tmp->cs_change = !!u_tmp->cs_change;
        k_tmp->bits_per_word = u_tmp->bits_per_word;
        k_tmp->delay_usecs = u_tmp->delay_usecs;
        k_tmp->speed_hz = u_tmp->speed_hz;

        // 把transfer添加到message的链表上
        spi_message_add_tail(k_tmp, &msg);
    }
     //发送  
    status = spidev_sync(spidev, &msg);
    if (status < 0)
        goto done;

    /* copy any rx data out of bounce buffer */
    buf = spidev->buffer;
    for (n = n_xfers, u_tmp = u_xfers; n; n--, u_tmp++) {
        if (u_tmp->rx_buf) {
            if (__copy_to_user((u8 __user *)
                    (uintptr_t) u_tmp->rx_buf, buf,
                    u_tmp->len)) {
                status = -EFAULT;
                goto done;
            }
        }
        buf += u_tmp->len;
    }
    status = total;

done:
    kfree(k_xfers);
    return status;
}

7.1 、 spi.c y spi_bitbang.c

Escriba la descripción de la imagen aquí

 static void bitbang_work(struct work_struct *work)
{
    struct spi_bitbang  *bitbang =
        container_of(work, struct spi_bitbang, work);
    unsigned long       flags;

    spin_lock_irqsave(&bitbang->lock, flags);
    bitbang->busy = 1;
    while (!list_empty(&bitbang->queue)) { //遍历bitbang的队列
        struct spi_message  *m;
        struct spi_device   *spi;
        unsigned        nsecs;
        struct spi_transfer *t = NULL;
        unsigned        tmp;
        unsigned        cs_change;
        int         status;
        int         do_setup = -1;

        m = container_of(bitbang->queue.next, struct spi_message,
                queue);
        list_del_init(&m->queue);
        spin_unlock_irqrestore(&bitbang->lock, flags);

        /* FIXME this is made-up ... the correct value is known to
         * word-at-a-time bitbang code, and presumably chipselect()
         * should enforce these requirements too?
         */
        nsecs = 100;

        spi = m->spi;
        tmp = 0;
        cs_change = 1;
        status = 0;

        list_for_each_entry (t, &m->transfers, transfer_list) { //遍历message 链表上的所有transfer

            /* override speed or wordsize? */
            if (t->speed_hz || t->bits_per_word)
                do_setup = 1;

            /* init (-1) or override (1) transfer params */
            if (do_setup != 0) {
                status = bitbang->setup_transfer(spi, t); //这里对spi的接口进行配置,因为每个transfer都可以设置 bits_per_word
                if (status < 0)
                    break;
                if (do_setup == -1)
                    do_setup = 0;
            }

            /* set up default clock polarity, and activate chip;
             * this implicitly updates clock and spi modes as
             * previously recorded for this device via setup().
             * (and also deselects any other chip that might be
             * selected ...)
             */
            if (cs_change) {
                bitbang->chipselect(spi, BITBANG_CS_ACTIVE); //这里在imx里没有什么毛线用,拉低电平是通过芯片内部的硬件实现的
                ndelay(nsecs); //这个也没有用
            }
            cs_change = t->cs_change;
            if (!t->tx_buf && !t->rx_buf && t->len) {
                status = -EINVAL;
                break;
            }

            /* transfer data.  the lower level code handles any
             * new dma mappings it needs. our caller always gave
             * us dma-safe buffers.
             */
            if (t->len) {
                /* REVISIT dma API still needs a designated
                 * DMA_ADDR_INVALID; ~0 might be better.
                 */
                if (!m->is_dma_mapped)
                    t->rx_dma = t->tx_dma = 0;
                status = bitbang->txrx_bufs(spi, t); //在这里把数据发出去了,最终调用了static int spi_imx_transfer(struct spi_device *spi,
                struct spi_transfer *transfer)
            }
            if (status > 0)
                m->actual_length += status;
            if (status != t->len) {
                /* always report some kind of error */
                if (status >= 0)
                    status = -EREMOTEIO;
                break;
            }
            status = 0;

            /* protocol tweaks before next transfer */
            if (t->delay_usecs)
                udelay(t->delay_usecs);

            if (!cs_change)
                continue;
            if (t->transfer_list.next == &m->transfers)
                break;

            /* sometimes a short mid-message deselect of the chip
             * may be needed to terminate a mode or command
             */
            ndelay(nsecs);
            bitbang->chipselect(spi, BITBANG_CS_INACTIVE);
            ndelay(nsecs);
        }

        m->status = status;
        m->complete(m->context);

        /* normally deactivate chipselect ... unless no error and
         * cs_change has hinted that the next message will probably
         * be for this chip too.
         */
        if (!(status == 0 && cs_change)) {
            ndelay(nsecs);
            bitbang->chipselect(spi, BITBANG_CS_INACTIVE);
            ndelay(nsecs);
        }

        spin_lock_irqsave(&bitbang->lock, flags);
    }
    bitbang->busy = 0;
    spin_unlock_irqrestore(&bitbang->lock, flags);
}

Retraso entre la selección del chip y la transmisión de datos:

Escriba la descripción de la imagen aquí

Al final, se logra configurando el campo CSD CTL del registro ECSPIx_PERIODREG. Consulte los requisitos de temporización de SC16IS752. No es necesario configurarlo aquí. También somos decenas de nosotros.

Escriba la descripción de la imagen aquí

El empuje en el último paso es más complicado y generará una interrupción. Después de que se genera la interrupción, entonces empuje

Esto está relacionado con el proceso de operación de ECSPI

Escriba la descripción de la imagen aquí

referencia

https://blog.csdn.net/droidphone/article/details/24663659

Supongo que te gusta

Origin blog.csdn.net/amwha/article/details/80126842
Recomendado
Clasificación