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

第一章

本教程修改自赵磊的网上的一系列教程.本人觉得该系列教程写的非常不错.以风趣幽默的语言将块驱动写的非常详细,对于入门教程,应该属于一份经典了. 本人在这对此系列教程对细微的修改,仅针对Linux 2.6.36版本.并编译运行成功. 该教程所有版权仍归作者赵磊所有,本人只做适当修改.  

第1章

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

|                 写一个块设备驱动           

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

| 作者:赵磊                                    

| 网名:OstrichFly、飞翔的鸵鸟           

| email: zhaoleidd@hotmail.com           

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

| 文章版权归原作者所有。                  

| 大家可以自由转载这篇文章,但原版权信息必须保留。  

| 如需用于商业用途,请务必与原作者联系,若因未取得  

| 授权而收起的版权争议,由侵权者自行负责。

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

同样是读书,读小说可以行云流水,读完后心情舒畅,意犹未尽;读电脑书却举步艰难,读完后目光呆滞,也是意犹未尽,只不过未尽的是痛苦的回忆。

研究证明,痛苦的记忆比快乐的更难忘记,因此电脑书中的内容比小说记得持久。

而这套教程的目的是要打破这种状况,以至于读者在忘记小说内容忘记本文。

在这套教程中,我们通过写一个建立在内存中的块设备驱动,来学习linux内核和相关设备驱动知识。

选择写块设备驱动的原因是:

1:容易上手

2:可以牵连出更多的内核知识

3:像本文这样的块设备驱动教程不多,所以需要一个

好吧,扯淡到此结束,我们开始写了。

本章的目的用尽可能最简单的方法写出一个能用的块设备驱动。

所谓的能用,是指我们可以对这个驱动生成的块设备进行mkfs,mount和读写文件。

为了尽可能简单,这个驱动的规模不是1000行,也不是500行,而是100行以内。

这里插一句,我们不打算在这里介绍如何写模块,理由是介绍的文章已经满天飞舞了。

如果你能看得懂、并且成功地编译、运行了这段代码,我们认为你已经达到了本教程的入学资格,

当然,如果你不幸的卡在这段代码中,那么请等到搞定它以后再往下看:

mod.c:

static int __init initialization_function(void)
{
return 0;
}
static void __exit cleanup_function(void)
{
}
//注册模块加载卸载函数
module_init(initialization_function);			//指定模块加载函数
module_exit(cleanup_function);			//指定模块卸载函数
//模块信息及许可证
MODULE_AUTHOR("LvApp");				//作者
MODULE_LICENSE("Dual BSD/GPL");		//许可证
MODULE_DESCRIPTION("A simple block module");	 //描述
MODULE_ALIAS("block");					//别名

Makefile:

#your use kernel_path
KERNEL_PATH = "/LvApp/linux-2.6.36.2-v1.05"
#kernel modules
obj-m += 文件名
#Specify flags for the module compilation.
#EXTRA_CFLAGS=-g -O0
buile:kernel_modules
kernel_modules:
	make -C $(KERNEL_PATH) M=$(shell pwd) modules
clean:
	make -C $(KERNEL_PATH) M=$(shell pwd) clean

好了,这里我们假定你已经搞定上面的最简单的模块了,懂得什么是看模块,以及简单模块的编写、编译、加载和卸载。

还有就是,什么是块设备,什么是块设备驱动,这个也请自行google吧,因为我们已经迫不及待要写完程序下课。

为了建立一个可用的块设备,我们需要做......1件事情:

1:用add_disk()函数向系统中添加这个块设备

   添加一个全局的

   static struct gendisk *g_blkdev_disk;

   然后申明模块的入口和出口:

   module_init(initialization_function); //指定模块加载函数

   module_exit(cleanup_function); //指定模块卸载函数

   然后在入口处添加这个设备、出口处私房这个设备:

static int __init initialization_function(void)
{
	add_disk(g_blkdev_disk);
	return 0;
}
static void __exit cleanup_function(void)
{
	del_gendisk(g_blkdev_disk);	 //->add_disk
}

当然,在添加设备之前我们需要申请这个设备的资源,这用到了alloc_disk()函数,因此模块入口函数initialization_function(void)应该是:

static int __init initialization_function(void)
{
	g_blkdev_disk = alloc_disk(1);
	if(NULL == g_blkdev_disk){
		ret = -ENOMEM;
		goto err_alloc_disk;
	}
	add_disk(g_blkdev_disk);
	return 0;
err_alloc_disk:
	return ret;
}

还有别忘了在卸载模块的代码中也加一个行清理函数:

  put_disk(g_blkdev_disk);

还有就是,设备有关的属性也是需要设置的,因此在alloc_disk()和add_disk()之间我们需要:

        strcpy(g_blkdev_disk->disk_name, BLK_DISK_NAME);

        g_blkdev_disk->major  = ?1;

        g_blkdev_disk->first_minor  = 0;

        g_blkdev_disk->fops  = ?2;

        g_blkdev_disk->queue  = ?3;

        set_capacity(g_blkdev_disk,  ?4);

BLK_DISK_NAME其实是这个块设备的名称,为了绅士一些,我们把它定义成宏了:

#define BLK_DISK_NAME  "block_name"

这里又引出了4个问号。(天哪,是不是有种受骗的感觉,像是陪老婆去做头发)

第1个问号:

  每个设备需要对应的主、从驱动号。

  我们的设备当然也需要,但很明显我不是脑科医生,因此跟写linux的那帮疯子不熟,得不到预先为我保留的设备号。

  还有一种方法是使用动态分配的设备号,但在这一章中我们希望尽可能做得简单,因此也不采用这种方法。(在最后的代码中,采用了动态分配.详细请参阅代码)

  那么我们采用的是:抢别人的设备号。

  我们手头没有AK47,因此不敢干的太轰轰烈烈,而偷偷摸摸的事情倒是可以考虑的。

  柿子要捡软的捏,而我们试图找出一个不怎么用得上的设备,然后抢他的ID。

  打开linux/include/linux/major.h,把所有的设备一个个看下来,我们觉得最胜任被抢设备号的家伙非COMPAQ_SMART2_XXX莫属。

  第一因为它不强势,基本不会被用到,因此也不会造成冲突;第二因为它有钱,从COMPAQ_SMART2_MAJOR到COMPAQ_SMART2_MAJOR7有那8个之多的设备号可以被抢,不过瘾的话还有它妹妹:COMPAQ_CISS_MAJOR~COMPAQ_CISS_MAJOR7。

  为了让抢劫显得绅士一些,我们在外面又定义一个宏:

  #define SIMP_BLKDEV_DEVICEMAJOR         COMPAQ_SMART2_MAJOR

  然后在?1的位置填上SIMP_BLKDEV_DEVICEMAJOR。

第2个问号:

  gendisk结构需要设置fops指针,虽然我们用不到,但该设还是要设的。

  好吧,就设个空得给它:

  在全局部分添加:  

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

  然后把?2的位置填上&fop。

第3个问号:

  这个比较麻烦一些。

  首先介绍请求队列的概念。对大多数块设备来说,系统会把对块设备的访问需求用bio和bio_vec表示,然后提交给通用块层。

  通用块层为了减少块设备在寻道时损失的时间,使用I/O调度器对这些访问需求进行排序,以尽可能提高块设备效率。

  关于I/O调度器在本章中不打算进行深入的讲解,但我们必须知道的是:

  1:I/O调度器把排序后的访问需求通过request_queue结构传递给块设备驱动程序处理

  2:我们的驱动程序需要设置一个request_queue结构

  申请request_queue结构的函数是blk_init_queue(),而调用blk_init_queue()函数时需要传入一个函数的地址,这个函数担负着处理对块设备数据的请求。

  因此我们需要做的就是:

  1:实现一个static void blkdev_do_request(struct request_queue *q)函数。

  2:加入一个全局变量,指向块设备需要的请求队列:

     static struct request_queue *g_blkdev_queue;

  3:在加载模块时用blkdev_do_request函数的地址作参数调用blk_init_queue()初始化一个请求队列:   

g_blkdev_queue = blk_init_queue(blkdev_do_request,NULL);
if(NULL == g_blkdev_queue){
	ret = -ENOMEM;
	goto err_init_queue;
}

  4:卸载模块时把simp_blkdev_queue还回去:

     blk_cleanup_queue(g_blkdev_queue);

  5:在?3的位置填上g_blkdev_queue。

第4个问号:

  这个还好,比前面的简单多了,这里需要设置块设备的大小。

  块设备的大小使用扇区作为单位设置,而扇区的大小默认是512字节。

  当然,在把字节为单位的大小转换为以扇区为单位时,我们需要除以512,或者右移9位可能更快一些。

  同样,我们试图把这一步也做得绅士一些,因此使用宏定义了块设备的大小,目前我们定为16M:

  #define SIMP_BLKDEV_BYTES         (16*1024*1024)

  然后在?4的位置填上SIMP_BLKDEV_BYTES>>9。

看到这里,是不是有种身陷茫茫大海的无助感?并且一波未平,一波又起,在搞定这4个问号的同时,居然又引入了blkdev_do_request函数!

当然,如果在身陷茫茫波涛中时你认为到处都是海,因此绝望,那么恭喜你可以不必挨到65岁再退休;

反之,如果你认为到处都是没有三聚氰胺鲜鱼,并且随便哪个方向都是岸时,那么也恭喜你,你可以活着回来继续享受身为纳税人的荣誉。

为了理清思路,我们把目前为止涉及到的代码整理出来:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/genhd.h>			//add_disk
#include <linux/blkdev.h>			//struct block_device_operations
#define _DEBUG_
#define BLK_DISK_NAME 					"block_name"
#define SIMP_BLKDEV_DEVICEMAJOR       	COMPAQ_SMART2_MAJOR
#define SIMP_BLKDEV_BYTES        			(16*1024*1024)
static struct gendisk *g_blkdev_disk;
static struct request_queue *g_blkdev_queue;
struct block_device_operations fop = {
	.owner = THIS_MODULE,
};
static void blkdev_do_request(struct request_queue *q)
{
        
}
static int __init initialization_function(void)
{
	int ret = 0;
	printk(KERN_WARNING "blk_init_queue\n");
	g_blkdev_queue = blk_init_queue(blkdev_do_request,NULL);
	if(NULL == g_blkdev_queue){
		ret = -ENOMEM;
		goto err_init_queue;
	}
	printk(KERN_WARNING "alloc_disk\n");
	g_blkdev_disk = alloc_disk(1);
	if(NULL == g_blkdev_disk){
		ret = -ENOMEM;
		goto err_alloc_disk;
	}
	strcpy(g_blkdev_disk->disk_name,BLK_DISK_NAME);
	g_blkdev_disk->major 	 = SIMP_BLKDEV_DEVICEMAJOR;
	g_blkdev_disk->first_minor 	= 0;
	g_blkdev_disk->fops 	 = &fop;
	g_blkdev_disk->queue 	 = g_blkdev_queue;
	add_disk(g_blkdev_disk);
#ifdef _DEBUG_
	printk(KERN_WARNING "ok\n");
#endif
	return ret;
err_alloc_disk:
	blk_cleanup_queue(g_blkdev_queue);
return ret;
}
static void __exit cleanup_function(void)
{
	del_gendisk(g_blkdev_disk);				//->add_disk
	put_disk(g_blkdev_disk);					//->alloc_disk
	blk_cleanup_queue(g_blkdev_queue);		//->blk_init_queue
}
//注册模块加载卸载函数
module_init(initialization_function);		//指定模块加载函数
module_exit(cleanup_function);			//指定模块卸载函数
//模块信息及许可证
MODULE_AUTHOR("LvApp");				//作者
MODULE_LICENSE("Dual BSD/GPL");		//许可证
MODULE_DESCRIPTION("A simple block module");	 //描述
MODULE_ALIAS("block");					//别名

剩下部分的不多了,真的不多了。请相信我,因为我不在质监局上班。

我写的文章诚实可靠,并且不拿你纳税的钱。

我们还有一个最重要的函数需要实现,就是负责处理块设备请求的blkdev_do_requestuest()。

首先我们看看究竟把块设备的数据以什么方式放在内存中。

毕竟这是在第1章,因此我们将使用最simple的方式实现,也就是,数组。

我们在全局代码中定义:

unsigned char blkdev_data[SIMP_BLKDEV_BYTES];

对驱动程序来说,这个数组看起来大了一些,如果不幸被懂行的人看到,将100%遭到最无情、最严重的鄙视。

而我们却从极少数公仆那里学到了最有效的应对之策,那就是:无视他,然后把他定为成“不明真相的群众”。

然后我们着手实现blkdev_do_requestuest。

这里介绍blk_fetch_request()函数,原型是:

struct request *blk_fetch_request(struct request_queue *q)

用来从一个请求队列中拿出一条请求(其实严格来说,拿出的可能是请求中的一段)。

随后的处理请求本质上是根据rq_data_dir(req)返回的该请求的方向(读/写),把块设备中的数据装入req->buffer、或是把req->buffer中的数据写入块设备。

刚才已经提及了与request结构相关的rq_data_dir()宏和.buffer成员,其他几个相关的结构成员和函数是:

blk_rq_pos(req):请求的开始磁道

blk_rq_cur_sectors(req):请求磁道数

__blk_end_request_cur(req, err):结束一个请求,第2个参数表示请求处理结果,成功时设定为1,失败时设置为0或者错误号。

因此我们的blkdev_do_requestuest()函数为:

static void blkdev_do_request(struct request_queue *q)
{
        struct request *req;
        req = blk_fetch_request(q);
        while ( NULL != req ) {
        	 int err = 0;
                	if ((blk_rq_pos(req) + blk_rq_cur_sectors(req)) << 9 
			> SIMP_BLKDEV_BYTES) {
                        printk(KERN_ERR BLK_DISK_NAME
                                ": bad request: block=%llu, count=%u\n",
                                (unsigned long long)blk_rq_pos(req),
                                blk_rq_cur_sectors(req));
			err = -EIO;
			goto done;
                	}
                	switch (rq_data_dir(req)) {
                	case READ:
                        memcpy(req->buffer,
                               blkdev_data + (blk_rq_pos(req) << 9),
                               blk_rq_cur_sectors(req) << 9);
                        break;
                	case WRITE:
                    	memcpy(blkdev_data + (blk_rq_pos(req) << 9),
                               	req->buffer, 
	 			blk_rq_cur_sectors(req) << 9);
                        break;
                	default:
                        break;
                	}
done:
	if(!__blk_end_request_cur(req, err))
		req = blk_fetch_request(q);              
        }
}

函数使用blk_fetch_request(q)遍历struct request_queue *q中使用struct request *req表示的每一段,首先判断这个请求是否超过了我们的块设备的最大容量,

然后根据请求的方向rq_data_dir(req)进行相应的请求处理。由于我们使用的是指简单的数组,因此请求处理仅仅是2条memcpy。

memcpy中也牵涉到了扇区号到线性地址的转换操作,我想对坚持到这里的读者来说,这个操作应该不需要进一步解释了。

编码到此结束,然后我们试试这个程序:

首先编译:

# make

make -C /lib/modules/2.6.18-53.el5/build SUBDIRS=/root/test/simp_blkdev/simp_blkdev_step1 modules

make[1]: Entering directory `/usr/src/kernels/2.6.18-53.el5-i686'

  CC [M]  /root/test/simp_blkdev/simp_blkdev_step1/simp_blkdev.o

  Building modules, stage 2.

  MODPOST

  CC      /root/test/simp_blkdev/simp_blkdev_step1/simp_blkdev.mod.o

  LD [M]  /root/test/simp_blkdev/simp_blkdev_step1/simp_blkdev.ko

make[1]: Leaving directory `/usr/src/kernels/2.6.18-53.el5-i686'

#

加载模块

# insmod simp_blkdev.ko

#

用lsmod看看。

这里我们注意到,该模块的Used by为0,因为它既没有被其他模块使用,也没有被mount。

# lsmod

Module                  Size  Used by

simp_blkdev         16784008  0

...

#

如果当前系统支持udev,在调用add_disk()函数时即插即用机制会自动为我们在/dev/目录下建立设备文件。

设备文件的名称为我们在gendisk.disk_name中设置的simp_blkdev,主、从设备号也是我们在程序中设定的72和0。

如果当前系统不支持udev,那么很不幸,你需要自己用mknod /dev/simp_blkdev  b 72 0来创建设备文件了。

# ls -l /dev/simp_blkdev

brw-r----- 1 root disk 72, 0 11-10 18:13 /dev/simp_blkdev

#

在块设备中创建文件系统,这里我们创建常用的ext3。

当然,作为通用的块设备,创建其他类型的文件系统也没问题。

# mkfs.ext3 /dev/simp_blkdev

mke2fs 1.39 (29-May-2006)

Filesystem label=

OS type: Linux

Block size=1024 (log=0)

Fragment size=1024 (log=0)

4096 inodes, 16384 blocks

819 blocks (5.00%) reserved for the super user

First data block=1

Maximum filesystem blocks=16777216

2 block groups

8192 blocks per group, 8192 fragments per group

2048 inodes per group

Superblock backups stored on blocks:

        8193

Writing inode tables: done

Creating journal (1024 blocks): done

Writing superblocks and filesystem accounting information: done

This filesystem will be automatically checked every 38 mounts or

180 days, whichever comes first.  Use tune2fs -c or -i to override.

#

如果这是第一次使用,建议创建一个目录用来mount这个设备中的文件系统。

当然,这不是必需的。如果你对mount之类的用法很熟,你完全能够自己决定在这里干什么,甚至把这个设备mount成root。

# mkdir -p /mnt/temp1

#

把建立好文件系统的块设备mount到刚才建立的目录中

# mount /dev/simp_blkdev /mnt/temp1

#

看看现在的mount表

# mount

...

/dev/simp_blkdev on /mnt/temp1 type ext3 (rw)

#

看看现在的模块引用计数,从刚才的0变成1了,

原因是我们mount了。

# lsmod

Module                  Size  Used by

simp_blkdev         16784008  1

...

#

看看文件系统的内容,有个mkfs时自动建立的lost+found目录。

# ls /mnt/temp1

lost+found

#

随便拷点东西进去

# cp /etc/init.d/* /mnt/temp1

#

再看看

# ls /mnt/temp1

acpid           conman          functions  irqbalance    mdmpd           NetworkManagerDispatcher  rdisc     sendmail        winbind

anacron         cpuspeed      gpm        kdump         messagebus      nfs       readahead_early  setroubleshoot  wpa_supplicant

bluetooth       frecord           irda       mdmonitor     NetworkManager  psacct     saslauthd        vncserver

#

现在这个块设备的使用情况是

# df

文件系统               1K-块        已用     可用 已用% 挂载点

...

/dev/simp_blkdev         15863      1440     13604  10% /mnt/temp1

#

再全删了玩玩

# rm -rf /mnt/temp1/*

#

看看删完了没有

# ls /mnt/temp1

#

好了,大概玩够了,我们把文件系统umount掉

# umount /mnt/temp1

#

模块的引用计数应该还原成0了吧

# lsmod

Module                  Size  Used by

simp_blkdev         16784008  0

...

#

最后一步,移除模块

# rmmod simp_blkdev

#

这是这部教程的第1章,不好意思的是,内容比预期还是难了一些。

当初还有一种考虑是在本章中仅仅实现一个写了就丢的块设备驱动,也就是说,对这个块设备的操作只能到mkfs这一部,而不能继续mount,因为刚才写的数据全被扔了。

或者更简单些,仅仅写一个hello world的模块。

但最后还是写成了现在这样没,因为我觉得拿出一个真正可用的块设备驱动程序对读者来说更有成就感。

无论如何,本章是一个开始,而你,已经跨入了学习块设备驱动教室的大门,或者通俗来说,上了贼船。

而在后续的章节中,我们将陆续完善对这个程序,通过追加或者强化这个程序,来学习与块设备有关、或与块设备无关但与linux有关的方方面面。

总之,我希望通过这部教程,起码让读者学到有用的知识,或者更进一步,引导读者对linux的兴趣,甚至领悟学习一切科学所需要的钻研精神。

作为第一章的结尾,引用我在另一篇文章中的序言:

谨以此文向读者示范什么叫做严谨的研究。

呼唤踏实的治学态度,反对浮躁的论坛风气。

--OstrichFly

<未完,待续>

附上完整代码:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/genhd.h>	 //add_disk
#include <linux/blkdev.h>	 //struct block_device_operations
#define _DEBUG_
#define BLK_DISK_NAME 					"block_name"
#define SIMP_BLKDEV_DEVICEMAJOR        COMPAQ_SMART2_MAJOR
#define SIMP_BLKDEV_BYTES        			(16*1024*1024)
static int MAJOR_NR = 0;
static struct gendisk *g_blkdev_disk;
static struct request_queue *g_blkdev_queue;
unsigned char blkdev_data[SIMP_BLKDEV_BYTES];
struct block_device_operations fop = {
.owner = THIS_MODULE,
};
static void blkdev_do_request(struct request_queue *q)
{
        struct request *req;
        req = blk_fetch_request(q);
        while ( NULL != req ) {
        	 int err = 0;
                	if ((blk_rq_pos(req) + blk_rq_cur_sectors(req)) << 9 
			> SIMP_BLKDEV_BYTES) {
                        printk(KERN_ERR BLK_DISK_NAME
                                ": bad request: block=%llu, count=%u\n",
                                (unsigned long long)blk_rq_pos(req),
                                blk_rq_cur_sectors(req));
				err = -EIO;
				goto done;
                	}
                	switch (rq_data_dir(req)) {
                	case READ:
                        memcpy(req->buffer,
                               blkdev_data + (blk_rq_pos(req) << 9),
                               blk_rq_cur_sectors(req) << 9);
                        break;
                	case WRITE:
                    	memcpy(blkdev_data + (blk_rq_pos(req) << 9),
                               		req->buffer, 
 					blk_rq_cur_sectors(req) << 9);
                        break;
                	default:
                        break;
                	}
done:
	if(!__blk_end_request_cur(req, err))
		req = blk_fetch_request(q);              
        }
}
static int __init initialization_function(void)
{
	int ret = 0;
	printk(KERN_WARNING "register_blkdev\n");
	MAJOR_NR = register_blkdev(0, BLK_DISK_NAME);
	if(MAJOR_NR < 0)
	{
		return -1;
	}
	printk(KERN_WARNING "blk_init_queue\n");
	g_blkdev_queue = blk_init_queue(blkdev_do_request,NULL);
	if(NULL == g_blkdev_queue){
		ret = -ENOMEM;
		goto err_init_queue;
	}
	printk(KERN_WARNING "alloc_disk\n");
	g_blkdev_disk = alloc_disk(1);
	if(NULL == g_blkdev_disk){
		ret = -ENOMEM;
		goto err_alloc_disk;
	}
	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, SIMP_BLKDEV_BYTES>>9);
	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_init_queue:
	unregister_blkdev(MAJOR_NR, BLK_DISK_NAME);
	return ret;
}
static void __exit cleanup_function(void)
{
	del_gendisk(g_blkdev_disk);							//->add_disk
	put_disk(g_blkdev_disk);								//->alloc_disk
	blk_cleanup_queue(g_blkdev_queue);					//->blk_init_queue
	unregister_blkdev(MAJOR_NR, BLK_DISK_NAME);	//->register_blkdev
}
//注册模块加载卸载函数
module_init(initialization_function);		//指定模块加载函数
module_exit(cleanup_function);			//指定模块卸载函数
//模块信息及许可证
MODULE_AUTHOR("LvApp");				//作者
MODULE_LICENSE("Dual BSD/GPL");	 //许可证
MODULE_DESCRIPTION("A simple block module");		//描述
MODULE_ALIAS("block");								//别名

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

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 11
    评论
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值