MMC 卡驱动分析

最近花时间研究了一下 MMC 卡驱动程序,开始在网上找了很多关于 MMC 卡驱动的分析文章,但大都是在描述各个层,这对于初学者来讲帮助并不大,所以我就打算把自己的理解写下来,希望对大家有用。个人觉得理解LINUX 内核当中 MMC/SD 卡驱动程序构架是学习 MMC 卡驱动程序的重点,只有理解了它的基本框架或流程才能真正理解一个块设备驱动程序的写法,同时才能真正理解 LINUX 设备驱动模型是如何发挥作用的。

 

一.需要的基础知识:

1.       LINUX 设备驱动的基本结构。

2.       块设备驱动程序的基本构架(相信研究过 LDD3 当中的 sbull 的人应该都不成问题,如果只是走马观花的话,那可得好好再补补了)

3.       LINUX 设备驱动模型。

二、设备模型流程图

 
 

 

.驱动程序分析

       首先,来明确一下我们需要分析的文件。下面的文件均来自 linux-2.6.24 源码,我们重点是分析驱动程序的基本构架,所以不同内核版本的差异并不是很大。 MMC/SD 卡驱动程序位于 drivers/mmc 目录下,我们只列出我们分析过程涉及到的几个文件:

Card/

       block.c

       queue.c/queue.h

core/

       bus.c/bus.h

       core.c/core.h

       host.c/host.h

       mmc.c

       mmc_ops.c/mmc_ops.h  MMC 卡来分析, SD 卡驱动程序流程类似。

host/

       s3cmci.c/s3cmci.h  S3C24XX  MMC/SD 卡控制器为例,其它类型的控制器类似。

LINUX 当中对目录的划分是很有讲究的,这些文件被分布在 3 个目录下,正好对应 MMC/SD 驱动程序的 3 个层次(关于层的划分这里浏览一下,有个概念即可,当我们分析完了后再回头来看,你会觉得很形象):

(1)       区块层

主要是按照 LINUX 块设备驱动程序的框架实现一个卡的块设备驱动,这 block.c 当中我们可以看到写一个块设备驱动程序时需要的 block_device_operations 结构体变量的定义,其中有 open/release/request 函数的实现,而queue.c 则是对内核提供的请求队列的封装,我们暂时不用深入理解它,只需要知道一个块设备需要一个请求队列就可以了。

(2)       核心层

核心层封装了 MMC/SD 卡的命令,例如存储卡的识别,设置,读写。例如不管什么卡都应该有一些识别,设置,和读写的命令,这些流程都是必须要有的,只是具体对于不同的卡会有一些各自特有的操作。 Core.c 文件是由sd.c  mmc.c 两个文件支撑的, core.c  MMC 卡、 SD 卡的共性抽象出来,它们的差别由 sd.c  sd_ops.c mmc.c  mmc_ops.c 来完成。

(3)       主机控制器层

主机控制器则是依赖于不同的平台的,例如 s3c2410 的卡控制器和 atmel 的卡控制器必定是不一样的,所以要针对不同的控制器来实现。以 s3cmci.c 为例,它首先要进行一些设置,例如中断函数注册,全能控制器等等。然后它会向 core 层注册一个主机( host ),用结构 mmc_host_ops 描述,这样核心层就可以拿着这个 host 来操作s3c24xx 的卡控制器了,而具体是 s3c24xx 的卡控制器还是 atmel 的卡控制器, core 层是不用知道的。

 

驱动程序层次图

 

       好了,对这几个目录有一个大概认识以后,我们来看几个重要的数据结构:

       struct mmc_host 用来描述卡控制器

struct mmc_card 用来描述卡

struct mmc_driver 用来描述 mmc 卡驱动

struct mmc_host_ops 用来描述卡控制器操作集,用于从主机控制器层向 core 层注册操作函数,从而将core 层与具体的主机控制器隔离。也就是说 core 要操作主机控制器,就用这个 ops 当中给的函数指针操作,不能直接调用具体主控制器的函数。

      

第一阶段:

        s3cmci_init 开始往下看

static int __init s3cmci_init(void)

{

platform_driver_register(&s3cmci_driver_2410);

}

 

 platform_driver_register 函数,根据设备模型的知识,我们知道那一定会有对应的 platform_device_register函数的,可是在哪里呢?没有看到,那是不是这个 s3cmci_driver_2410 当中给的 probe 函数就不执行了???当然不是, mci 接口一般都是硬件做好的(我认为是这样),所以在系统启动时一定会有调用 platform_device?_register 对板上的资源进行注册,如果没有这个硬件资源,那我们这个驱动也就没有用了。好,我们就假定是有mci 接口的,而且也有与 s3cmci_driver_2410 对应的硬件资源注册了,那自己就会去跑 probe 函数。来看一下s3cmci_driver_2410:

static struct platform_driver s3cmci_driver_2410 = {

       .driver.name    = "s3c2410-sdi",

       .probe            = s3cmci_probe_2410,

       .remove          = s3cmci_remove,

       .suspend  = s3cmci_suspend,

       .resume          = s3cmci_resume,

};

 

我们到 s3cmci_probe_2410 函数中看,还是干脆直接看 s3cmci_probe 算了:

static int s3cmci_probe(struct platform_device *pdev, int is2440) // 来自 /host/s3cmci.c

{

       struct mmc_host   *mmc;

       struct s3cmci_host       *host;

 

       int ret;

……

       mmc = mmc_alloc_host (sizeof(struct s3cmci_host), &pdev->dev);

       if (!mmc) {

              ret = -ENOMEM;

              goto probe_out;

       }

……

       mmc->ops     = &s3cmci_ops;

……

       ret = mmc_add_host (mmc);

       if (ret) {

              dev_err(&pdev->dev, "failed to add mmc host./n");

              goto free_dmabuf;

       }

……

       platform_set_drvdata(pdev, mmc);

       return 0;

……

}

 

这个函数很长,做的事件也很多,但我们关心的整个驱动的构架 / 流程,所以过滤掉一些细节的东西,只看 2 个最重要的函数: mmc_alloc_host  mmc_add_host 。函数命名已经很形象了,前者是申请一个 mmc_host ,而后者是添加一个 mmc_host 。中间还有一个操作,就是给 mmc  ops  成员赋上了 s3cmci_ops 这个值。申请mmc_host 当然很简单,就是申请一个结构体(我们暂且这样认为,因为他里面还做的其它事情,后面会看到),而添加又是添加到哪里去呢?看 mmc_add_host 函数:

int mmc_add_host(struct mmc_host *host) // 来自 core/host.c

{

       int err;

……

       err = device_add(&host->class_dev);

       if (err)

              return err;

 

       mmc_start_host(host);

       return 0;

}

 

很简单,就是增加了一个 device ,然后就调用 mmc_start_host 了,那就先跳过 device_add 这个动作,来看mmc_start_host:

void mmc_start_host(struct mmc_host *host) // 来自 /host/core.c

{

       mmc_power_off(host);                // 掉电一下

       mmc_detect_change(host, 0);              // ???

}

看上去只有两行代码,不过浓缩才是精华, mmc_power_off(host) 光看名子都知道是在干什么,先跳过,来看mmc_detect_change ,那么它到底干了些什么呢?看一下就知道了:

void mmc_detect_change(struct mmc_host *host, unsigned long delay)    // core/core.c

{

       mmc_schedule_delayed_work(&host->detect, delay);

}

static int mmc_schedule_delayed_work(struct delayed_work *work, unsigned long delay)

{

       return queue_delayed_work(workqueue, work, delay);

}

 

mmc_detect_change 又跳了一下,最后调用了 queue_delayed_work ,不知道这个函数功能的去查一下〈〈LDD3 〉〉和〈〈深入理解 LINUX 内核〉〉,这几个代码告诉我们在 workqueue 这个工作队列当中添加一个延迟的工作任务,而这个工作任务就是由 host->detect 来描述的,在随后的 delay  jiffies 后会有一个记录在 host->detect 里面的函数被执行,那么到这里 s3cmci_probe 这个函数算是结束了,但事情还没有完, workqueue 这个工作队列还在忙,不一会儿它就会调用 host->detect 里面那个函数,这个函数到底是哪个函数,到底是用来干什么的呢?好像没有看到, detect 包含在 host 里面,那估计是在刚才那个申请的地方设置的那个函数,回过头来看一下 mmc_alloc_host:

struct mmc_host *mmc_alloc_host(int extra, struct device *dev)  // 来自 core/host.c

{

       struct mmc_host *host;

 

       host = kzalloc(sizeof(struct mmc_host) + extra, GFP_KERNEL);

       if (!host)

              return NULL;

 

       INIT_DELAYED_WORK(&host->detect, mmc_rescan);

       return host;

}

如果你看了 queue_delayed_work 这个函数功能介绍,相信对 INIT_DELAYED_WORK 也不会陌生了吧。不废话了,来看 mmc_rescan 

// 来自 core/host.c

void mmc_rescan(struct work_struct *work)   // // 来自 core/host.c

{

       struct mmc_host *host =      container_of(work, struct mmc_host, detect.work);

       u32 ocr;

       int err;

……

       /* detect a newly inserted card */

……

       /*

         * First we search for SDIO...

         */

       err = mmc_send_io_op_cond(host, 0, &ocr);

       if (!err) {

              if (mmc_attach_sdio(host, ocr))

                     mmc_power_off(host);

              goto out;

       }

 

       /*

         * ...then normal SD...

         */

       err = mmc_send_app_op_cond(host, 0, &ocr);

       if (!err) {

              if (mmc_attach_sd(host, ocr))

                     mmc_power_off(host);

              goto out;

       }

 

       /*

         * ...and finally MMC.

         */

       err = mmc_send_op_cond(host, 0, &ocr);

       if (!err) {

              if (mmc_attach_mmc(host, ocr))

                     mmc_power_off(host);

              goto out;

       }

 

       mmc_release_host(host);

       mmc_power_off(host);

 

out:

       if (host->caps & MMC_CAP_NEEDS_POLL)

              mmc_schedule_delayed_work(&host->detect, HZ);

}

 

浏览一个这个函数,看看函数名,再看看注释,知道什么了吗?它是在检测是不是有卡插入了卡控制器,如果有卡挺入就要采取相应的行动了。这里要明白一点,我们平时用的 SD/MMC 卡就是一个卡,如果要操作它得用SD/MMC 卡控制器才行,所以可以看到有 struct mmc_card,struct mmc_host 的区分。

       到这里了,来回忆一下 s3cmci_probe 这个函数做的事情,大概就是准备一个 mmc_host 结构,然后添加一个主控制器设备到内核,最后又调用了一下 mmc_rescan 来检测是不是有卡插入了。

       如果有卡插入了还好,可以去操作卡了,那如果没有卡插入呢? mmc_rescan 不是白调用了一次吗?是啊,的确是白调用了一次。可是卡插入时为什么 PC 还是能检测到呢?看来卡检测的动作不光是在 probe 的最后一步做了一次,其它地方也有做。卡插入一般都是人为地随时去插入的,像这种情况一般都是会用中断机制去提供系统有外来侵入,然后再去采取行动。 SD/MMC 卡也的确是这样做的,找来找去,发现在 s3cmci_probe 里面注册了一个中断函数 s3cmci_irq_cd( 函数名的意思应该是 irq card detect) ,就是这个了,看看这个函数先:

static irqreturn_t s3cmci_irq_cd(int irq, void *dev_id)   // host/s3cmci.c

{

       struct s3cmci_host *host = (struct s3cmci_host *)dev_id;

 

       mmc_detect_change(host->mmc, msecs_to_jiffies(500));

 

       return IRQ_HANDLED;

}

看到这个函数想都不用想,直接跳到 mmc_rescan 里面去看就行了。前面已经知道了 mmc_rescan 里面就是在检测卡是不是插入了,既然卡随时插入我们都能检测到了,那就来看卡插入后都做了些什么动作吧。

 

第二阶段:

       mmc_rescan 里面既要检测 sd 卡,又要检测 mmc 卡的,我们就照着一个往下走,假定有个人插入了 MMC卡,那就应该走下面这几行:

       err = mmc_send_op_cond(host, 0, &ocr);

       if (!err) {

              if (mmc_attach_mmc(host, ocr))

                     mmc_power_off(host);

              goto out;

       }

mmc_send_op_cond 这个函数据说是读了一下卡的什么值,这个值是什么意义我也不清楚,这就像检测 FLASH时读 FLASH  ID 一样,网卡也是这样的,不用管这个值的意义了,只要知道它能标识是一个 MMC 卡插入就行了。如果取这个值没有错误的话就得进 mmc_attach_mmc 了:

/*

  * Starting point for MMC card init.

  */

int mmc_attach_mmc(struct mmc_host *host, u32 ocr)  // core/mmc.c

{

       int err;

……

       mmc_attach_bus_ops(host);         // 这个与总线的电源管理有关,暂时跳过

……

       /*

         * Detect and init the card.

         */

       err = mmc_init_card(host, host->ocr, NULL);

       if (err)

              goto err;

……

       mmc_release_host(host);

 

       err = mmc_add_card(host->card);

       if (err)

              goto remove_card;

 

       return 0;

 

remove_card:

……

err:

……

       return err;

}

 

还是找几个关键函数来看 mmc_init_card 从函数名来看就是初始化一个 card ,这个 card 就用 struct mmc_card结构来描述,然后又调用 mmc_add_card 将卡设备添加到了内核,先来看 mmc_init_card 都做了些什么事情:

static int mmc_init_card(struct mmc_host *host, u32 ocr,

       struct mmc_card *oldcard)

{

       struct mmc_card *card;

       int err;

       u32 cid[4];

       unsigned int max_dtr;

……

              /*

                * Allocate card structure.

                */

              card = mmc_alloc_card(host, &mmc_type);

              if (IS_ERR(card)) {

                     err = PTR_ERR(card);

                     goto err;

              }

 

              card->type = MMC_TYPE_MMC;

              card->rca = 1;

              memcpy(card->raw_cid, cid, sizeof(card->raw_cid));

……

              host->card = card;

 

       return 0;

 

free_card:

……

err:

……

       return err;

}

将与硬件操作相关的全部删掉,最后对我们有用的也就这几行了 mmc_alloc_card 申请了一个 struct mmc_card结构,然后给 card->type 赋上 MMC_TYPE_MMC ,最后将 card 又赋给了 host->card ,这和具体硬件还是挺像的,因为一个主控制器一般就插一个卡,有卡时 host->card 有值,没有卡时 host->card 自己就是 NULL 了。

       钻进 mmc_alloc_card 里面来看看:

/*

  * Allocate and initialise a new MMC card structure.

  */

struct mmc_card *mmc_alloc_card(struct mmc_host *host, struct device_type *type)

{

       struct mmc_card *card;

 

       card = kzalloc(sizeof(struct mmc_card), GFP_KERNEL);

       if (!card)

              return ERR_PTR(-ENOMEM);

 

       card->host = host;

 

       device_initialize(&card->dev);

 

       card->dev.parent = mmc_classdev(host);

       card->dev.bus = &mmc_bus_type;

       card->dev.release = mmc_release_card;

       card->dev.type = type;

 

       return card;

}

Struct mmc_card 结构里面包含了一个 struct device 结构, mmc_alloc_card 不但申请了内存,而且还填充了struct device 中的几个成员,尤其 card->dev.bus = &mmc_bus_type; 这一句要重点对待。

       申请一个 mmc_card 结构,并简单初始化后, mmc_init_card 的使命就完成了,然后再调用 mmc_add_card将这个 card 设备添加到内核。 mmc_add_card 其实很简单,就是调用 device_add  card->dev 添加到内核当中去。

       知道总线模型这个东西的人都明白,理到 device_add 里面总线就应该有动作了,具体是哪个总线呢?那就得看你调用 device_add 时送的那个 dev 里面指定的是哪个总线了,我们送的 card->dev ,那么 card->dev.bus具体指向什么呢?很明现是那个 mmc_bus_type 

static struct bus_type mmc_bus_type = {

       .name             = "mmc",

       .dev_attrs       = mmc_dev_attrs,

       .match           = mmc_bus_match,

       .uevent           = mmc_bus_uevent,

       .probe            = mmc_bus_probe,

       .remove          = mmc_bus_remove,

       .suspend  = mmc_bus_suspend,

       .resume          = mmc_bus_resume,

};

 device_add 里面,设备对应的总线会拿着你这个设备和挂在这个总线上的所有驱动程序去匹配( match ),此时会调用 match 函数,如果匹配到了就会调用总线的 probe 函数或驱动的 probe 函数,那我们看一下这里的mmc_bus_match 是如何进行匹配的:

static int mmc_bus_match(struct device *dev, struct device_driver *drv)

{

       return 1;

}

看来 match 永远都能成功,那就去执行 probe 吧:

static int mmc_bus_probe(struct device *dev)

{

       struct mmc_driver *drv = to_mmc_driver(dev->driver);

       struct mmc_card *card = dev_to_mmc_card(dev);

 

       return drv->probe(card);

}

这里就有点麻烦了,在这个函数里面又调用了一下 drv->probe() ,那这个 drv 是什么呢?上面有: struct mmc_driver *drv = to_mmc_driver(dev->driver);

match 函数总是返回 1 ,那看来只要是挂在这条总线上的 driver 都有可能跑到这里来了,事实的确也是这样的,不过好在挂在这条总线上的 driver 只有一个,它是这样定义的:

static struct mmc_driver mmc_driver = {

       .drv        = {

              .name      = "mmcblk",

       },

       .probe            = mmc_blk_probe,

       .remove          = mmc_blk_remove,

       .suspend      = mmc_blk_suspend,

       .resume          = mmc_blk_resume,

};

看到这里时, card/core/host 几个已经全部被扯进来了,边看 mmc_driver 中的几个函数,他们几个如何联系起来也就慢慢明白了。那我们继续吧。

 

第三阶段:

前面已经看到了,在总线的 probe 里面调用了 drv->probe, 而这个函数就对应的是 mmc_blk_probe ,具体这个mmc_driver 是怎么挂到 mmc_bus 上的,自己去看 mmc_blk_init() ,就几行代码,应该不难。

static int mmc_blk_probe(struct mmc_card *card) // 来自 card/block.c

{

       struct mmc_blk_data *md;

       int err;

……

       md = mmc_blk_alloc(card);

       if (IS_ERR(md))

              return PTR_ERR(md);

……

       add_disk(md->disk);

       return 0;

 

  out:

       mmc_blk_put(md);

 

       return err;

}

还是捡重要的函数看,一看到这个函数最后调用了 add_disk ,你应该可以想到些什么吧?如果你不知道我在说些什么,那我估计你没有看过 LDD3 ,或者看了也是走马观花了。我来告诉你:如果看到 add_disk ,那说明前面一定会有 alloc_disk 和初始化队列的动作,在 mmc_blk_probe 时面没有体现出来,那就看mmc_blk_alloc(card) 那一行:

static struct mmc_blk_data *mmc_blk_alloc(struct mmc_card *card)

{

       struct mmc_blk_data *md;

       int devidx, ret;

 

       devidx = find_first_zero_bit(dev_use, MMC_NUM_MINORS);

       if (devidx >= MMC_NUM_MINORS)

              return ERR_PTR(-ENOSPC);

       __set_bit(devidx, dev_use);

 

       md = kzalloc(sizeof(struct mmc_blk_data), GFP_KERNEL);

       if (!md) {

              ret = -ENOMEM;

              goto out;

       }

 

 

       /*

         * Set the read-only status based on the supported commands

         * and the write protect switch.

         */

       md->read_only = mmc_blk_readonly(card);

 

       md->disk = alloc_disk(1 << MMC_SHIFT);

       if (md->disk == NULL) {

              ret = -ENOMEM;

              goto err_kfree;

       }

 

       spin_lock_init(&md->lock);

       md->usage = 1;

 

       ret = mmc_init_queue(&md->queue, card, &md->lock);

       if (ret)

              goto err_putdisk;

 

       md->queue.issue_fn = mmc_blk_issue_rq;

       md->queue.data = md;

 

       md->disk->major   = MMC_BLOCK_MAJOR;

       md->disk->first_minor = devidx << MMC_SHIFT;

       md->disk->fops = &mmc_bdops;

       md->disk->private_data = md;

       md->disk->queue = md->queue.queue;

       md->disk->driverfs_dev = &card->dev;

 

       /*

         * As discussed on lkml, GENHD_FL_REMOVABLE should:

         *

         * - be set for removable media with permanent block devices

         * - be unset for removable block devices with permanent media

         *

         * Since MMC block devices clearly fall under the second

         * case, we do not set GENHD_FL_REMOVABLE.  Userspace

         * should use the block device creation/destruction hotplug

         * messages to tell when the card is present.

         */

 

       sprintf(md->disk->disk_name, "mmcblk%d", devidx);

 

       blk_queue_logical_block_size(md->queue.queue, 512);

 

       if (!mmc_card_sd(card) && mmc_card_blockaddr(card)) {

              /*

                * The EXT_CSD sector count is in number or 512 byte

                * sectors.

                */

              set_capacity(md->disk, card->ext_csd.sectors);

       } else {

              /*

                * The CSD capacity field is in units of read_blkbits.

                * set_capacity takes units of 512 bytes.

                */

              set_capacity(md->disk,

                     card->csd.capacity << (card->csd.read_blkbits - 9));

       }

       return md;

 

  err_putdisk:

       put_disk(md->disk);

  err_kfree:

       kfree(md);

  out:

       return ERR_PTR(ret);

}

看到这个函数的代码,我们自然就回忆起了块设备驱动的整个套路了:

1.       分配、初始化请求队列,并绑定请求队列和请求函数。

2.       分配,初始化 gendisk ,给 gendisk  major  fops  queue 等成员赋值,最后添加 gendisk 

3.       注册块设备驱动。

我们看看 MMC 卡驱动程序有没有按这个套路走,

1  mmc_init_queue 初始了队列,并将 mmc_blk_issue_rq; 函数绑定成请求函数;

2  alloc_disk 分配了 gendisk 结构,并初始化了 major  fops ,和 queue 

3 、最后调用 add_disk 将块设备加到 KERNEL 中去。

到这里虽然 mmc_blk_probe 已经结束了,但我们别停下来。记得 LDD3 上在讲 sbull 实例时说过, add_disk 的调用标志着一个块设备驱动将被激活,所以在这之前必须把其它所有准备工作全部做好,作者为什么会这样说是有理由的,因为在 add_disk 里面 kernel 会去调用你绑定到队列中的请求函数,目的是去你的块设备上读分区表。而且是在 add_disk 内部就要做的,而不是 add_disk 返回后再做,具体为什么会这样,去看 add_disk 的代码实现就知道了。

既然要调用请求函数去读,那我们就来看看请求函数: mmc_blk_issue_rq

static int mmc_blk_issue_rq(struct mmc_queue *mq, struct request *req)

{

       struct mmc_blk_data *md = mq->data;

       struct mmc_card *card = md->queue.card;

       struct mmc_blk_request brq;

       int ret = 1, disable_multi = 0;

 

       do {

 

              mmc_wait_for_req(card->host, &brq.mrq);

              /*

                * A block was successfully transferred.

                */

              spin_lock_irq(&md->lock);

              ret = __blk_end_request(req, 0, brq.data.bytes_xfered);

              spin_unlock_irq(&md->lock);

       } while (ret);

 

       return 1;

}

这个函数实在太长了,好在我们不用全部看,大部分读数据的准备代码和出错处理的代码已经被我删掉了,只要知道读数据都是在这里完成的就够了。看不懂这个函数的,拿上 LDD3 找个人少的地方,将 sbull 研究透了也就明白这个函数了。不过这个函数里涉及的东西还挺不少,“散列表”,“回弹”都在这里出现了,有时间慢慢去研究吧。

       在块设备驱动当中你只需要抓住请求队列和请求函数就可以了,具体那些 block_device_operations 里面赋值的函数可不像字符设备驱动里面那么受关注了。

 

       分析到这里, MMC/SD 卡的驱动整个构架基本也就很明析了,说简单了就是做了两件事:

1.       卡的检测;

2.       卡数据的读取。

最后再将这两个过程大概串一下:

1.       卡的检测:

S3cmci_probe(host/s3cmci.c)

       Mmc_alloc_host(core/core.c)

              Mmc_rescan(core/core.c)

                     Mmc_attach_mmc(core/mmc.c)

                            Mmc_init_card(core/mmc.c)

                            mmc_add_card(core/bus.c)

                                   device_add

                                          mmc_bus_match(core/bus.c)

                                          mmc_bus_probe(core/bus.c)

                                                 mmc_blk_probe(card/block.c)

                                                        alloc_disk/add_disk

2.       读写数据:

mmc_blk_issue_rq  card/block.c 

       mmc_wait_for_req(core/core.c)

              mmc_start_request(core/core.c)

                     host->ops->request(host, mrq)   // s3cmci  s3cmci_request

 

MMC/SD 卡的驱动分析完了,是不是有些复杂,不过这样设计的目的是为了分层,让具体平台的驱动编写更加省事。

http://blog.163.com/prevBlogPerma.do?host=cupidove&srl=100566220117611505857&mode=prev

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值