一步一步走进块驱动之第十五章

第十五章

本教程修改自赵磊的网上的一系列教程.本人觉得该系列教程写的非常不错.以风趣幽默的语言将块驱动写的非常详细,对于入门教程,应该属于一份经典了. 本人在这对此系列教程最后附上对Linux 2.6.36版本的代码.并编译运行成功. 该教程所有版权仍归作者赵磊所有,本人只做附录代码的添加,并为对原文修改.有不懂的地方,可以联系我 97164811@qq.com 或者给我留言  

+---------------------------------------------------+

|                 写一个块设备驱动                  

+---------------------------------------------------+

作者:赵磊                                        

| 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/init.h>
#include <linux/module.h>
#include <linux/genhd.h>		//add_disk
#include <linux/blkdev.h>		//struct block_device_operations
#include <linux/hdreg.h>

#define _DEBUG_

#define BLK_PAGE_ORDER			2
#define BLK_PAGE_SIZE			(PAGE_SIZE << BLK_PAGE_ORDER)
#define BLK_PAGE_SHIFT			(PAGE_SHIFT + BLK_PAGE_ORDER)
#define BLK_PAGE_MASK			(~(BLK_PAGE_SIZE - 1))

#define BLK_SECTOR_SHIFT		9
#define BLK_SECTOR_SIZE			(1ULL << BLK_SECTOR_SHIFT)
#define BLK_SECTOR_MASK			(~(BLK_SECTOR_SIZE - 1))

#define BLK_DISK_NAME 			"block_name"
#define BLKDEV_DEVICEMAJOR      	COMPAQ_SMART2_MAJOR
#define BLKDEV_BYTES        		(16*1024*1024)
#define MAX_PARTITIONS			64

static int MAJOR_NR = 0;

static struct gendisk *g_blkdev_disk;
static struct request_queue *g_blkdev_queue;

struct radix_tree_root blk_dev_data;
DEFINE_MUTEX(blk_radix_mutex);
static unsigned long long g_blk_size = 1;
static char*	defaule_size = "16M";

int getsize(void)
{
	char flag;
	char tail = 'N';

#ifdef _DEBUG_
	printk(KERN_WARNING "parameter = %s\n",defaule_size);
#endif		
	
	if(sscanf(defaule_size,"%llu%c%c",&g_blk_size,&flag,&tail) != 2){
		return -EINVAL;
	}
	
	if(!g_blk_size)
		return -EINVAL;
	
	switch(flag){
	case 'g':
	case 'G':
		g_blk_size <<= 30;
		break;
	case 'm':
	case 'M':
		g_blk_size <<= 20;
		break;
	case 'k':
	case 'K':
		g_blk_size <<= 10;
		break;
	}
	
	g_blk_size = (g_blk_size + BLK_SECTOR_SIZE - 1) & BLK_SECTOR_MASK;
	//此处为字节对齐.(1<<9 - 1) = 255 = 0xFF
	
#ifdef _DEBUG_
	printk(KERN_WARNING "size = %llu tail = %c\n",g_blk_size,tail);
#endif			
	return 0;
}

static int disk_get_pages(struct page **ppage,unsigned int index)
{
	int ret = 0;
	struct page *page = NULL;
	page = (void*)alloc_pages(GFP_KERNEL | __GFP_ZERO | __GFP_HIGHMEM,BLK_PAGE_ORDER);
	if(NULL == page){
		ret = -ENOMEM;
		goto err_get_page;
	}
		
	page->index = index;
	ret = radix_tree_insert(&blk_dev_data,index,(void*)page);
	if(IS_ERR_VALUE(ret)){
		ret = -EIO;
		goto err_insert;
	}
	*ppage = page;
	return 0;

err_insert:
	__free_pages(page,BLK_PAGE_ORDER);
err_get_page:
	return ret;
}

/*
	function:	blk_make_once
	description:
		该函数完成一次数据操作,将数据读/写到指定的page页面.
	parameter:	
		struct page *page,	:要读/写的页面
		unsigned int offset,	:页面的相对偏移长度
		void *iovec_men,		:真实的数据地址
		unsigned int len,		:数据长度
		int dir			:数据方向
	
*/
static int blk_make_once(struct page *page,unsigned int offset,void *iovec_men,unsigned int len,int dir)
{	
	void *dsk_mem = NULL;
	unsigned int count_current = 0;
	unsigned int count_done = 0;
	unsigned int count_offset = 0;
	struct page *this_page = NULL;
	
	while(count_done < len){
		count_offset = (offset + count_done) & ~PAGE_MASK;
		/*
			count_offset:计算当前地址到页首的偏移
		*/
		count_current = min(len - count_done,
							(unsigned int)(PAGE_SIZE - count_offset));
		/*
			count_current:计算当前传输数据的长度,若跨页,则取此页到页尾的长度
			(PAGE_SIZE - count_offset):计算当前偏移到页尾的长度
		*/
		this_page = page + ((offset + count_done) >> PAGE_SHIFT);
		/*
			this_page:获取片面,将页面映射至高端内存
		*/
		dsk_mem = kmap(this_page);
		if(!dsk_mem){
			printk(KERN_ERR BLK_DISK_NAME
					": get memory page address failed: %p\n",
					page);
			return -EIO;
		}
		
		dsk_mem += count_offset;
		
		if(!dir){
			//read
			memcpy(iovec_men + count_done,dsk_mem,count_current);
		}else{
			//write
			memcpy(dsk_mem,iovec_men + count_done,count_current);
		}
		
		kunmap(this_page);
		
		count_done += count_current;
	}

	return 0;	
}


/*
	function:	blk_make_transition
	description:
		该函数完成一次映射好的becv数据操作
	parameter:	
		unsigned long long disk_offset,	:要读写的虚拟磁盘的地址
		void *iovec_men,				:映射好的bvec操作的数据地址
		unsigned int len,				:数据长度
		int dir					:数据方向
	
*/
static int blk_make_transition(unsigned long long disk_offset,void *iovec_men,unsigned int len,int dir)
{
	unsigned int count_current = 0;
	unsigned int count_done = 0;
	unsigned int count_offset = 0;
	struct page *page = NULL;
	count_done = 0;
	while(count_done < len){
		count_offset = (disk_offset + count_done) & ~BLK_PAGE_MASK;
		/*
			count_offset:表示当前页对应页对齐的地址的偏移长度
		*/
		count_current = min(len - count_done,
						(unsigned int)(BLK_PAGE_SIZE - count_offset));
		/*
			BLK_PAGE_SIZE - count_offset:表示当前页最后能存储的长度.
		*/
		
		mutex_lock(&blk_radix_mutex);
		/*
			采用互斥锁,防止多进程同时访问至此,该页面未申请,导致多个进程同时申请同一个地址页面
			导致页面相互覆盖,造成的页面浪费.浪费的页面无法释放.
		*/
		page = (struct page*)radix_tree_lookup(&blk_dev_data,(disk_offset + count_done) >> BLK_PAGE_SHIFT);
		if(!page){
			if(!dir){
				memset(iovec_men + count_done,0,count_current);
				goto trans_done;
			}
			if(IS_ERR_VALUE(disk_get_pages(&page,(disk_offset + count_done) >> BLK_PAGE_SHIFT))){
				printk(KERN_ERR BLK_DISK_NAME
						": alloc page failed index: %llu\n",
						(disk_offset + count_done) >> BLK_PAGE_SHIFT);
				mutex_unlock(&blk_radix_mutex);
				return -EIO;
			}
		}
		//进行一次数据读写
		if(IS_ERR_VALUE(blk_make_once(page,count_offset,iovec_men + count_done,count_current,dir))){
			return -EIO;
		}
trans_done:		
		mutex_unlock(&blk_radix_mutex);
		count_done += count_current;
	}
	return 0;	
}

static int blkdev_make_request(struct request_queue *q, struct bio *bio)
{
	struct bio_vec *bvec;
	int i;
	int dir = 0;
	unsigned long long disk_offset = 0;

	if ((bio->bi_sector << BLK_SECTOR_SHIFT) + bio->bi_size > g_blk_size) {
		printk(KERN_ERR BLK_DISK_NAME
				": bad request: block=%llu, count=%u\n",
				(unsigned long long)bio->bi_sector, bio->bi_size);
		goto bio_err;
	}
	
	switch(bio_rw(bio)){
	case READ:
	case READA:
		dir = 0;
		break;
	case WRITE:
		dir = 1;
		break;
	}
	
	disk_offset = bio->bi_sector << BLK_SECTOR_SHIFT;
	
	bio_for_each_segment(bvec, bio, i) {
		void *iovec_mem;
		iovec_mem = kmap(bvec->bv_page) + bvec->bv_offset;
		
		if(!iovec_mem){
			printk(KERN_ERR BLK_DISK_NAME
					": kmap page faile: %p\n",
                    bvec->bv_page);
            goto bio_err;    
		}
		//进行一次bvec的操作
		if(IS_ERR_VALUE(blk_make_transition(disk_offset,iovec_mem,bvec->bv_len,dir))){
			kunmap(bvec->bv_page);
			goto bio_err;
		}
		kunmap(bvec->bv_page);
		disk_offset += bvec->bv_len;
	}		
	
	bio_endio(bio, 0);
	return 0;
bio_err:
	bio_endio(bio, -EIO);
	return 0;
}

int gendisk_getgeo(struct block_device *pblk_dev, struct hd_geometry *phd_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 (g_blk_size < 16 * 1024 * 1024) {
		phd_geo->heads = 1;
		phd_geo->sectors = 1;
	} else if (g_blk_size < 512 * 1024 * 1024) {
		phd_geo->heads = 1;
		phd_geo->sectors = 32;
	} else if (g_blk_size < 16ULL * 1024 * 1024 * 1024) {
		phd_geo->heads = 32;
		phd_geo->sectors = 32;
	} else {
		phd_geo->heads = 255;
		phd_geo->sectors = 63;
	}

	phd_geo->cylinders = g_blk_size >> BLK_SECTOR_SHIFT / phd_geo->heads / phd_geo->sectors;
	
	return 0;
}

struct block_device_operations fop = {
	.owner = THIS_MODULE,
	.getgeo = gendisk_getgeo,
};

void delete_diskmem(void)
{
	int i = 0;
	int page_count = 0;
	int next_count = 0;
	struct page *pagelist[64] = {NULL};
	
	page_count = 0;
	next_count = 0;
	do{
		page_count = radix_tree_gang_lookup(&blk_dev_data, (void**)pagelist,next_count,ARRAY_SIZE(pagelist));
		for(i = 0;i < page_count;i++){
			next_count = pagelist[i]->index;
			radix_tree_delete(&blk_dev_data,next_count);
			__free_pages(pagelist[i],BLK_PAGE_ORDER);
		}
		next_count++;
	}while(page_count == ARRAY_SIZE(pagelist));
}

static int __init initialization_function(void)
{
	int ret = 0;
	
	getsize();
	
	MAJOR_NR = register_blkdev(0, BLK_DISK_NAME);
	if(MAJOR_NR < 0)
	{		
		return -1;
	}
	
	g_blkdev_queue = blk_alloc_queue(GFP_KERNEL);
	if(NULL == g_blkdev_queue){
		ret = -ENOMEM;		
		goto err_alloc_queue;
	}
	
	blk_queue_make_request(g_blkdev_queue, blkdev_make_request);
	
	g_blkdev_disk = alloc_disk(MAX_PARTITIONS);
	if(NULL == g_blkdev_disk){
		ret = -ENOMEM;		
		goto err_alloc_disk;
	}
	
	INIT_RADIX_TREE(&blk_dev_data,GFP_KERNEL);
	
	strcpy(g_blkdev_disk->disk_name,BLK_DISK_NAME);
	g_blkdev_disk->major = MAJOR_NR;
	g_blkdev_disk->first_minor = 0;
	g_blkdev_disk->fops = &fop;
	g_blkdev_disk->queue = g_blkdev_queue;
	
	set_capacity(g_blkdev_disk, g_blk_size>>BLK_SECTOR_SHIFT);
	
	add_disk(g_blkdev_disk);
#ifdef _DEBUG_
	printk(KERN_WARNING "ok\n");
#endif
	return ret;
	
err_alloc_disk:
	blk_cleanup_queue(g_blkdev_queue);
err_alloc_queue:
	unregister_blkdev(MAJOR_NR, BLK_DISK_NAME);
	return ret;
}

static void __exit cleanup_function(void)
{
	del_gendisk(g_blkdev_disk);						//->add_disk
	delete_diskmem();								//->alloc_diskmem
	put_disk(g_blkdev_disk);						//->alloc_disk
	blk_cleanup_queue(g_blkdev_queue);					//->blk_init_queue
	unregister_blkdev(MAJOR_NR, BLK_DISK_NAME);
}

//注册模块加载卸载函数
module_init(initialization_function);					//指定模块加载函数
module_exit(cleanup_function);						//指定模块卸载函数

//到处函数.导出名size,对应变量defaule_size
module_param_named(size, defaule_size, charp, S_IRUGO);

//模块信息及许可证
MODULE_AUTHOR("LvApp");								//作者
MODULE_LICENSE("Dual BSD/GPL");						//许可证
MODULE_DESCRIPTION("A simple block module");				//描述
MODULE_ALIAS("block");						   //别名

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

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

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

本人是在参考教程之后修改的教程内容.如有不同.可能有遗漏没有修改.造成对读者的迷惑,在此致歉~~ 


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值