写一个块设备驱动-第15章(最终章)

转自:http://linux.chinaunix.net/bbs/thread-1045283-14-1.html

 

+---------------------------------------------------+
|                 写一个块设备驱动                  |
+---------------------------------------------------+
| 作者:赵磊                                        |
| email: zhaoleidd@hotmail.com                      |
+---------------------------------------------------+
| 文章版权归原作者所有。                            |
| 大家可以自由转载这篇文章,但原版权信息必须保留。  |
| 如需用于商业用途,请务必与原作者联系,若因未取得  |
| 授权而收起的版权争议,由侵权者自行负责。          |
+---------------------------------------------------+

在上一章中我们对这个块设备驱动所作的更改使它具备了动态申请内存的能力,
但实际上同时也埋下一个隐患,就是数据访问冲突。

这里我们顺便唠叨一下内核开发中的同步问题。
提到数据访问同步,自然而然会使人想到多进程、多线程、加锁、解锁、
信号量、synchronized关键字等东西,然后就很头疼。
对于用户态程序,网上大量的解释数据同步概念和方法的文章给人的印象大概是:
同步很危险,编程要谨慎,
处处有机关,问题很难找。

对于第一次进行多线程时编程的人来说,感觉可能是以下两种:
一种是觉得程序中处处都会有问题,任何一条访问数据的指令都不安全,
恨不得把程序中所有的数据都加上锁,甚至打算给锁本身的数据再加个锁,
另一种是没觉得有什么困难,根本不去理什么都互斥不互斥,
就按原先的来,编出的程序居然也运行得很顺。
然后怀着这两种想法人通过不断的学习和实践掌握了数据同步的知识后认识到,
数据同步其实并不像前一种想法那样危险,也不像后一种想法那样简单。

所幸的是对于不少用户态程序来说,倒是可以不用考虑数据同步问题。
至少当我们刚开始写HelloWorld时不用去理这个麻烦。

而对于内核态代码而言,很不幸,整个儿几乎都相当于用户态的多线程。
其实事情也并非原本就是这么糟的。
在很久很久以前,山是青的,草是绿的,牛奶是能喝的,
见到老人摔跤是敢扶的,作者是纯情的,电脑也是单CPU的。
那时的内核环境很静,很美。除了中断会时不时地捣捣乱,其余的都挺诗意。
代码独个儿在跑,就像是一辆汽车在荒漠上奔驰,因为没有其他妨碍,
几乎可以毫无顾忌地访问数据,而不用考虑什么万恶的访问冲突。
唯一要考虑的从天而降的中断奥特曼,解决的方法倒也不难,禁用了中断看你还能咋的。

然后随着作者的成长,目光从书本转向了美眉,计算机也由单CPU发展成了多CPU。
内核代码的执行环境终于开始热闹起来,由于每个CPU上都在执行任务,
这些任务进入到对应的内核态时会出现多条内核指令流同时执行,
这些指令流对全局数据的访问很明显就牵涉到了同步问题,这是开端。
从那时起编程时要考虑其他CPU上的事情了。

然后随着作者的进一步成长,目光从美眉的脸转向了胸,
CPU制造商为了贯彻给程序员找麻烦的精神,搞出了乱序执行。
这一创举惊醒了多年来还在梦中的诸多程序员,原来,程序不是按程序执行的啊。
正如林高官说的:“我是交通部派来的,级别和你们市长一样高,敢跟我斗,
你们这些人算个屁呀!”原来,无职无权的平民百姓就是屁啊。
正当程序员从睡梦中惊醒还没缓过神时,编译器又跟着捣乱,
“你CPU都能乱序了,凭什么不让我乱序?”
然后热闹了,好在我们还有mb()、rmb()、wmb()、barrier()这几根救命稻草,
事情倒是没变得太糟。

然后随着作者的进一步成长,目光从美眉的胸转向了臀,
内核也从一开始时被动的为了适应多CPU而不得已半推半就支持多任务并行,
转向了主动掀起裙角管它一个还是几个CPU都去多任务了。
从技术面解释,这就是大名鼎鼎的内核抢占。
内核的程序员从此不仅要考虑其他CPU,好要提妨自个儿的CPU,
因为执行代码的CPU说不定什么时候就莫名其妙的被调度执行别的任务了。

如果以作者的成长历程为主线解释内核的演化还不至于太混乱的话,
我们还可以考虑再介绍一下spin_lock, mutex_lock, preempt_disable,
atomic_t和rcu等函数,不过作者忍住了这一冲动,还是让读者去google吧。

然后回到我们的代码,现在的代码是有问题的。
比如simp_blkdev_trans()函数中,假设2个任务同时向块设备的同一区域写数据,
而这块区域在这之前没有被写过,也就是说还没有申请内存,那么如果运气够好的话,
这两个进程可能几乎同时运行到:
this_first_page = radix_tree_lookup(&simp_blkdev_data,
                        (dsk_offset + done_cnt) >> SIMP_BLKDEV_DATASEGSHIFT);
这句,很明显这两个任务得到的this_first_page都是NULL,然后它们争先恐后的执行
if (!this_first_page)
判断,从而进入之后的alloc_pages,随后它们都会为这个块设备区域申请内存,并加入基树结构。
如果运气爆发的话,这两个任务radix_tree_insert()的代码中将有机会近乎同时越过
if (slot != NULL)
        return -EEXIST;
的最后防线,先后将新申请的内存指针赋值给基树结点。
虽然x86的多处理器对同一块内存的写操作是原子的,
这样至少不会因为这两个任务同时赋值基树指针造成指针指向莫名其妙的值,
但这仍然也解决不了我们的问题,后一个赋值操作将覆盖前一个操作的结果,
基数节点最终将指向稍后一点执行赋值操作的任务。
这两个任务最终将运行到radix_tree_insert()函数的结尾,而函数的返回值都是漂亮的0。
剩下的事情扳脚丫子大概也能想出来了,这两个任务都将自欺欺人地认为自己正确而成功地为块设备分配了内存,
而真相是其中一个任务拿走的内存却再也没有机会拿回来了。

至于解决方法嘛,当然是加锁。
只要我们让“查找基数中有没有这个节点”到“分配内存并插入这节点”的过程中没有其他任务的打搅,
就自然的解决了这个问题。

首先定义一个锁,因为是用来锁simp_blkdev_data的,
就放在static struct radix_tree_root simp_blkdev_data;后面吧:
DEFINE_MUTEX(simp_blkdev_datalock); /* protects the disk data op */

然后根据刚才的思想给对simp_blkdev_trans()函数中的simp_blkdev_datalock的操作加锁,
也就是在
this_first_page = radix_tree_lookup(&simp_blkdev_data,
        (dsk_offset + done_cnt) >> SIMP_BLKDEV_DATASEGSHIFT);
语句之前添加:
mutex_lock(&simp_blkdev_datalock);

操作结束后被忘了把锁还回去,否则下次再操作时就成死锁了,因此在
trans_done:
后面加上
mutex_unlock(&simp_blkdev_datalock);
这一行。

完成了吗?细心看看就知道还没完。
simp_blkdev_trans()函数中有一些判断异常的代码,这些代码大多是扔出一条printk就直接return的。
这样可不行,可千万别让它们临走时把锁也顺回去了。
这意味着我们要在simp_blkdev_trans()函数中的3个故障时return的代码前完成锁的释放。
因此simp_blkdev_trans()函数最后就成了这样:
static int simp_blkdev_trans(unsigned long long dsk_offset, void *buf,
                unsigned int len, int dir)
{
        unsigned int done_cnt;
        struct page *this_first_page;
        unsigned int this_off;
        unsigned int this_cnt;

        done_cnt = 0;
        while (done_cnt < len) {
                /* iterate each data segment */
                this_off = (dsk_offset + done_cnt) & ~SIMP_BLKDEV_DATASEGMASK;
                this_cnt = min(len - done_cnt,
                        (unsigned int)SIMP_BLKDEV_DATASEGSIZE - this_off);

                mutex_lock(&simp_blkdev_datalock);

                this_first_page = radix_tree_lookup(&simp_blkdev_data,
                        (dsk_offset + done_cnt) >> SIMP_BLKDEV_DATASEGSHIFT);
                if (!this_first_page) {
                        if (!dir) {
                                memset(buf + done_cnt, 0, this_cnt);
                                goto trans_done;
                        }

                        /* prepare new memory segment for write */
                        this_first_page = alloc_pages(
                                GFP_KERNEL | __GFP_ZERO | __GFP_HIGHMEM,
                                SIMP_BLKDEV_DATASEGORDER);
                        if (!this_first_page) {
                                printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                                        ": allocate page failed/n");
                                mutex_unlock(&simp_blkdev_datalock);
                                return -ENOMEM;
                        }

                        this_first_page->index = (dsk_offset + done_cnt)
                                >> SIMP_BLKDEV_DATASEGSHIFT;

                        if (IS_ERR_VALUE(radix_tree_insert(&simp_blkdev_data,
                                this_first_page->index, this_first_page))) {
                                printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                                        ": insert page to radix_tree failed"
                                        " seg=%lu/n", this_first_page->index);
                                __free_pages(this_first_page,
                                        SIMP_BLKDEV_DATASEGORDER);
                                mutex_unlock(&simp_blkdev_datalock);
                                return -EIO;
                        }
                }

                if (IS_ERR_VALUE(simp_blkdev_trans_oneseg(this_first_page,
                        this_off, buf + done_cnt, this_cnt, dir))) {
                        mutex_unlock(&simp_blkdev_datalock);
                        return -EIO;
                }
trans_done:
                mutex_unlock(&simp_blkdev_datalock);
                done_cnt += this_cnt;
        }

        return 0;
}

这个函数差不多了。
我们再看看代码中还有什么地方也对simp_blkdev_data进行操作来着,别漏掉了这些小王八蛋。
查找一下代码,我们发现free_diskmem()函数中也进行了操作。

其实从理论上说,这里不加锁是不会产生问题的,因为对内核在执行对块设备设备时,
会锁住这个设备对应的模块(天哪,又是锁,这一章和锁彪上了),
其结果是在simp_blkdev_trans()函数操作simp_blkdev_data的过程中,
该模块无法卸载,从而无法不会运行到free_diskmem()函数。

那么如果同时卸载这个模块呢,回答是也没有问题,英勇的模块锁也会搞掂这种情况。

这一章由于没有进行功能增加,就不列出修改后模块的测试经过了,
不过作为对读者的安慰,我们将列出到目前为止经历了大大小小修改后的全部模块代码。
看到这些代码,我们能历历在目的回忆出读这篇教程到现在为止所经受的全部折磨和苦难。
当然也能感受到坚持到现在所得到的知识和领悟。

对于Linux而言,甚至仅仅对于块设备驱动程序而言,这部教程揭开的也仅仅是冰山一角。
而更多的知识其实离我们很近,在google上,在代码中,在心中。
学习,是要用心,不断地去想,同时要有恒心、耐心、要细心,
人应该越学越谦虚,问题应该越学越多,这大概就是作者通过这部教程最想告诉读者的。

#include <linux/module.h>
#include <linux/blkdev.h>
#include <linux/hdreg.h>
#include <linux/version.h>

/*
* A simple block device driver based on memory
*
* Copyright 2008 -
*        Zhaolei <zhaolei@cn.fujitsu.com>
*
* Sample for using:
*   Create device file (first time only):
*     Note: If your system have udev, it can create device file for you in time
*           of lsmod and fdisk automatically.
*           Otherwise you need to create them yourself by following steps.
*     mknod /dev/simp_blkdev  b 72 0
*     mknod /dev/simp_blkdev1 b 72 1
*     mknod /dev/simp_blkdev2 b 72 2
*
*   Create dirs for test (first time only):
*     mkdir /mnt/temp1/ # first time only
*     mkdir /mnt/temp2/ # first time only
*
*   Run it:
*     make
*     insmod simp_blkdev.ko
*     # or insmod simp_blkdev.ko size=numK/M/G/T
*     fdisk /dev/simp_blkdev # create 2 patitions
*     mkfs.ext3 /dev/simp_blkdev1
*     mkfs.ext3 /dev/simp_blkdev2
*     mount /dev/simp_blkdev1 /mnt/temp1/
*     mount /dev/simp_blkdev2 /mnt/temp2/
*     # play in /mnt/temp1/ and /mnt/temp2/
*     umount /mnt/temp1/
*     umount /mnt/temp2/
*     rmmod simp_blkdev.ko
*
*/

#define SIMP_BLKDEV_DEVICEMAJOR        COMPAQ_SMART2_MAJOR
#define SIMP_BLKDEV_DISKNAME        "simp_blkdev"

#define SIMP_BLKDEV_SECTORSHIFT        (9)
#define SIMP_BLKDEV_SECTORSIZE        (1ULL<<SIMP_BLKDEV_SECTORSHIFT)
#define SIMP_BLKDEV_SECTORMASK        (~(SIMP_BLKDEV_SECTORSIZE-1))

/* usable partitions is SIMP_BLKDEV_MAXPARTITIONS - 1 */
#define SIMP_BLKDEV_MAXPARTITIONS        (64)

#define SIMP_BLKDEV_DATASEGORDER        (2)
#define SIMP_BLKDEV_DATASEGSHIFT        (PAGE_SHIFT + SIMP_BLKDEV_DATASEGORDER)
#define SIMP_BLKDEV_DATASEGSIZE                (PAGE_SIZE << SIMP_BLKDEV_DATASEGORDER)
#define SIMP_BLKDEV_DATASEGMASK                (~(SIMP_BLKDEV_DATASEGSIZE-1))

static struct request_queue *simp_blkdev_queue;
static struct gendisk *simp_blkdev_disk;

static struct radix_tree_root simp_blkdev_data;
DEFINE_MUTEX(simp_blkdev_datalock); /* protects the disk data op */

static char *simp_blkdev_param_size = "16M";
module_param_named(size, simp_blkdev_param_size, charp, S_IRUGO);

static unsigned long long simp_blkdev_bytes;

static int simp_blkdev_trans_oneseg(struct page *start_page,
                unsigned long offset, void *buf, unsigned int len, int dir)
{
        unsigned int done_cnt;
        struct page *this_page;
        unsigned int this_off;
        unsigned int this_cnt;
        void *dsk_mem;

        done_cnt = 0;
        while (done_cnt < len) {
                /* iterate each page */
                this_page = start_page + ((offset + done_cnt) >> PAGE_SHIFT);
                this_off = (offset + done_cnt) & ~PAGE_MASK;
                this_cnt = min(len - done_cnt, (unsigned int)PAGE_SIZE
                        - this_off);

                dsk_mem = kmap(this_page);
                if (!dsk_mem) {
                        printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                                ": map device page failed: %p/n", this_page);
                        return -ENOMEM;
                }
                dsk_mem += this_off;

                if (!dir)
                        memcpy(buf + done_cnt, dsk_mem, this_cnt);
                else
                        memcpy(dsk_mem, buf + done_cnt, this_cnt);

                kunmap(this_page);

                done_cnt += this_cnt;
        }

        return 0;
}

static int simp_blkdev_trans(unsigned long long dsk_offset, void *buf,
                unsigned int len, int dir)
{
        unsigned int done_cnt;
        struct page *this_first_page;
        unsigned int this_off;
        unsigned int this_cnt;

        done_cnt = 0;
        while (done_cnt < len) {
                /* iterate each data segment */
                this_off = (dsk_offset + done_cnt) & ~SIMP_BLKDEV_DATASEGMASK;
                this_cnt = min(len - done_cnt,
                        (unsigned int)SIMP_BLKDEV_DATASEGSIZE - this_off);

                mutex_lock(&simp_blkdev_datalock);

                this_first_page = radix_tree_lookup(&simp_blkdev_data,
                        (dsk_offset + done_cnt) >> SIMP_BLKDEV_DATASEGSHIFT);
                if (!this_first_page) {
                        if (!dir) {
                                memset(buf + done_cnt, 0, this_cnt);
                                goto trans_done;
                        }

                        /* prepare new memory segment for write */
                        this_first_page = alloc_pages(
                                GFP_KERNEL | __GFP_ZERO | __GFP_HIGHMEM,
                                SIMP_BLKDEV_DATASEGORDER);
                        if (!this_first_page) {
                                printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                                        ": allocate page failed/n");
                                mutex_unlock(&simp_blkdev_datalock);
                                return -ENOMEM;
                        }

                        this_first_page->index = (dsk_offset + done_cnt)
                                >> SIMP_BLKDEV_DATASEGSHIFT;

                        if (IS_ERR_VALUE(radix_tree_insert(&simp_blkdev_data,
                                this_first_page->index, this_first_page))) {
                                printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                                        ": insert page to radix_tree failed"
                                        " seg=%lu/n", this_first_page->index);
                                __free_pages(this_first_page,
                                        SIMP_BLKDEV_DATASEGORDER);
                                mutex_unlock(&simp_blkdev_datalock);
                                return -EIO;
                        }
                }

                if (IS_ERR_VALUE(simp_blkdev_trans_oneseg(this_first_page,
                        this_off, buf + done_cnt, this_cnt, dir))) {
                        mutex_unlock(&simp_blkdev_datalock);
                        return -EIO;
                }
trans_done:
                mutex_unlock(&simp_blkdev_datalock);
                done_cnt += this_cnt;
        }

        return 0;
}

static int simp_blkdev_make_request(struct request_queue *q, struct bio *bio)
{
        int dir;
        unsigned long long dsk_offset;
        struct bio_vec *bvec;
        int i;
        void *iovec_mem;

        switch (bio_rw(bio)) {
        case READ:
        case READA:
                dir = 0;
                break;
        case WRITE:
                dir = 1;
                break;
        default:
                printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                        ": unknown value of bio_rw: %lu/n", bio_rw(bio));
                goto bio_err;
        }

        if ((bio->bi_sector << SIMP_BLKDEV_SECTORSHIFT) + bio->bi_size
                > simp_blkdev_bytes) {
                printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                        ": bad request: block=%llu, count=%u/n",
                        (unsigned long long)bio->bi_sector, bio->bi_size);
                goto bio_err;
        }

        dsk_offset = bio->bi_sector << SIMP_BLKDEV_SECTORSHIFT;

        bio_for_each_segment(bvec, bio, i) {
                iovec_mem = kmap(bvec->bv_page) + bvec->bv_offset;
                if (!iovec_mem) {
                        printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                                ": map iovec page failed: %p/n", bvec->bv_page);
                        goto bio_err;
                }

                if (IS_ERR_VALUE(simp_blkdev_trans(dsk_offset, iovec_mem,
                        bvec->bv_len, dir)))
                        goto bio_err;

                kunmap(bvec->bv_page);

                dsk_offset += bvec->bv_len;
        }

#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
        bio_endio(bio, bio->bi_size, 0);
#else
        bio_endio(bio, 0);
#endif

        return 0;

bio_err:
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
        bio_endio(bio, 0, -EIO);
#else
        bio_endio(bio, -EIO);
#endif
        return 0;
}

static int simp_blkdev_getgeo(struct block_device *bdev,
                struct hd_geometry *geo)
{
        /*
         * capacity        heads        sectors        cylinders
         * 0~16M        1        1        0~32768
         * 16M~512M        1        32        1024~32768
         * 512M~16G        32        32        1024~32768
         * 16G~...        255        63        2088~...
         */
        if (simp_blkdev_bytes < 16 * 1024 * 1024) {
                geo->heads = 1;
                geo->sectors = 1;

        } else if (simp_blkdev_bytes < 512 * 1024 * 1024) {
                geo->heads = 1;
                geo->sectors = 32;
        } else if (simp_blkdev_bytes < 16ULL * 1024 * 1024 * 1024) {
                geo->heads = 32;
                geo->sectors = 32;
        } else {
                geo->heads = 255;
                geo->sectors = 63;
        }

        geo->cylinders = simp_blkdev_bytes >> SIMP_BLKDEV_SECTORSHIFT
                / geo->heads / geo->sectors;

        return 0;
}

struct block_device_operations simp_blkdev_fops = {
        .owner                = THIS_MODULE,
        .getgeo                = simp_blkdev_getgeo,
};

void free_diskmem(void)
{
        unsigned long long next_seg;
        struct page *seglist[64];
        int listcnt;
        int i;

        next_seg = 0;
        do {
                listcnt = radix_tree_gang_lookup(&simp_blkdev_data,
                        (void **)seglist, next_seg, ARRAY_SIZE(seglist));

                for (i = 0; i < listcnt; i++) {
                        next_seg = seglist[i]->index;
                        radix_tree_delete(&simp_blkdev_data, next_seg);
                        __free_pages(seglist[i], SIMP_BLKDEV_DATASEGORDER);
                }

                next_seg++;
        } while (listcnt == ARRAY_SIZE(seglist));
}

int getparam(void)
{
        char unit;
        char tailc;

        if (sscanf(simp_blkdev_param_size, "%llu%c%c", &simp_blkdev_bytes,
                &unit, &tailc) != 2) {
                return -EINVAL;
        }

        if (!simp_blkdev_bytes)
                return -EINVAL;

        switch (unit) {
        case 'g':
        case 'G':
                simp_blkdev_bytes <<= 30;
                break;
        case 'm':
        case 'M':
                simp_blkdev_bytes <<= 20;
                break;
        case 'k':
        case 'K':
                simp_blkdev_bytes <<= 10;
                break;
        case 'b':
        case 'B':
                break;
        default:
                return -EINVAL;
        }

        /* make simp_blkdev_bytes fits sector's size */
        simp_blkdev_bytes = (simp_blkdev_bytes + SIMP_BLKDEV_SECTORSIZE - 1)
                & SIMP_BLKDEV_SECTORMASK;

        return 0;
}

static int __init simp_blkdev_init(void)
{
        int ret;

        ret = getparam();
        if (IS_ERR_VALUE(ret))
                goto err_getparam;

        simp_blkdev_queue = blk_alloc_queue(GFP_KERNEL);
        if (!simp_blkdev_queue) {
                ret = -ENOMEM;
                goto err_alloc_queue;
        }
        blk_queue_make_request(simp_blkdev_queue, simp_blkdev_make_request);

        simp_blkdev_disk = alloc_disk(SIMP_BLKDEV_MAXPARTITIONS);
        if (!simp_blkdev_disk) {
                ret = -ENOMEM;
                goto err_alloc_disk;
        }

        INIT_RADIX_TREE(&simp_blkdev_data, GFP_KERNEL);

        strcpy(simp_blkdev_disk->disk_name, SIMP_BLKDEV_DISKNAME);
        simp_blkdev_disk->major = SIMP_BLKDEV_DEVICEMAJOR;
        simp_blkdev_disk->first_minor = 0;
        simp_blkdev_disk->fops = &simp_blkdev_fops;
        simp_blkdev_disk->queue = simp_blkdev_queue;
        set_capacity(simp_blkdev_disk,
                simp_blkdev_bytes >> SIMP_BLKDEV_SECTORSHIFT);
        add_disk(simp_blkdev_disk);

        return 0;

err_alloc_disk:
        blk_cleanup_queue(simp_blkdev_queue);
err_alloc_queue:
err_getparam:
        return ret;
}

static void __exit simp_blkdev_exit(void)
{
        del_gendisk(simp_blkdev_disk);
        free_diskmem();
        put_disk(simp_blkdev_disk);
        blk_cleanup_queue(simp_blkdev_queue);
}

module_init(simp_blkdev_init);
module_exit(simp_blkdev_exit);

MODULE_LICENSE("GPL");

追记:偶然看到刚才的代码首部注释,Copyright后面还是2008年。
大概是从第一章开始一直这样拷贝过来的。
这部教程从2008年11月断断续续的写到了2009年3月,终于功德圆满了。
作为作者写的第一个如此长度篇幅的教程,炸一眼瞟过来,倒也还像个样子,
看来写教程并不是太难高攀的事情,因此如果读者也时不时地有一些写起来的冲动,
就不妨开始吧: )

本章以块设备驱动程序的代码为例,说明了内核中的同步概念,
当然,在不少情况下,程序员遇到的同步问题比这里的要复杂的多,
内核中也采用了很多方法和技巧来处理同步,了解和学习这些知识,
收获的不仅是数据同步本身的解决方法,更是一种思路,
这对于更一般的程序设计都是有很大帮助的,因此有空时google一下,
总能找到自己想了解的知识。

<--全文完,赵磊出品,必属精品-->

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值