7.camera驱动06-自己实现v4l2驱动-虚拟摄像头

1. 框架分层

实际上的v4l2框架:

在这里插入图片描述

v4l2本质是还是一个字符设备驱动,有自己的fops。

每注册一个video_device都会以次设备号为下标放到v4l2层的一个数组里。

应用调用open函数时,v4l2会根据次设备号找到对应的video_device,进而调用video_device对应的fops。

2. 注册v4l2_dev和video_device

(1) 注册platform_device和platform_driver,也并不是一定要这样做,只是大家都这样做,那也就跟着做了

(2) 注册v4l2_dev

v4l2_device_register(&pdev->dev,&myvivi->v4l2_dev);

(3) 分配一个video_device

定义到大结构体

struct vivi {
	struct video_device 	vid_cap_dev;
    ...
};

或者调用以下函数分配

video_device_alloc();

(4) 初始化video_device

	struct video_device *vfd;
	vfd = &myvivi->vid_cap_dev;
	snprintf(vfd->name, sizeof(vfd->name),
		 "myvivi-00-vid-cap");
	vfd->fops = &myvivi_fops;
	vfd->ioctl_ops = &myvivi_ioctl_ops;
	vfd->device_caps = myvivi->vid_cap_caps;
	vfd->release = video_device_release_empty;
	vfd->v4l2_dev = &myvivi->v4l2_dev;
	vfd->queue = &myvivi->vb_vid_cap_q;
	vfd->lock = &myvivi->mutex;
	video_set_drvdata(vfd, myvivi);
  • 第5行,这里的fops最终会被v4l2层调用;

  • 第6行,ioctl_ops就是我们要实现的重点,上层与底层的交互主要是ioctl,当然也比较多,这里实现必不可少的11个,如下:

static const struct v4l2_ioctl_ops myvivi_ioctl_ops = {
//表示它是一个摄像头设备
.vidioc_querycap = myvivi_vidoc_querycap,

//用于列举、获取、测试、设置摄像头的数据格式
.vidioc_enum_fmt_vid_cap = myvivi_vidioc_enum_fmt_vid_cap,
.vidioc_g_fmt_vid_cap = myvivi_vidioc_g_fmt_vid_cap,
.vidioc_try_fmt_vid_cap = myvivi_vidioc_try_fmt_vid_cap,
.vidioc_s_fmt_vid_cap = myvivi_vidioc_s_fmt_vid_cap,

//缓冲区操作: 申请/查询/放入/取出队列
.vidioc_reqbufs = vb2_ioctl_reqbufs,
.vidioc_querybuf = vb2_ioctl_querybuf,
.vidioc_qbuf =vb2_ioctl_qbuf,
.vidioc_dqbuf = vb2_ioctl_dqbuf,

//启动/停止
.vidioc_streamon = vb2_ioctl_streamon,
.vidioc_streamoff = vb2_ioctl_streamoff,

};

ps:使用xawtv打开,需要加另外两个函数“vidioc_g_fbuf”和“vidioc_s_fbuf”,可以给个空函数,否则不能出图。

常用ioctl接口命令一览:

ioctl接口命令解析
VIDIOC_REQBUFS请求系统分配缓冲区
VIDIOC_QUERYBUF查询映射缓冲区
VIDIOC_QUERYCAP查询驱动功能
VIDIOC_ENUM_FMT获取当前驱动支持的视频格式
VIDIOC_S_FMT设置当前驱动的频捕获格式
VIDIOC_G_FMT读取当前驱动的频捕获格式
VIDIOC_TRY_FMT验证该格式驱动是否支持,不会改变当前设置
VIDIOC_CROPCAP查询驱动的修剪能力
VIDIOC_S_CROP设置视频信号的矩形边框
VIDIOC_G_CROP读取视频信号的矩形边框
VIDIOC_QBUF把缓存放回队列
VIDIOC_DQBUF捕获好视频的缓冲区出队
VIDIOC_STREAMON开始捕获
VIDIOC_STREAMOFF关闭捕获
VIDIOC_QUERYSTD检查当前视频设备支持的标准,例如PAL或NTSC
  • 第7行,表示这个video_device有哪些能力,V4L2_CAP_VIDEO_CAPTURE表示支持图像获取,V4L2_CAP_STREAMING表示可以通过streaming方式获取图像,还有一种通过读写方式获取图像,那就是V4L2_CAP_READWRITE。

  • 第8行,release函数必须实现,否则注册会报错,可以是个空函数;

  • 第12行,设置video_device的私有数据为myvivi。

(5) 注册video_device

ret = video_register_device(myvivi->vdev,VFL_TYPE_GRABBER,-1);
  • 第1个参数,就是video_device指针;

  • 第2个参数,注册的类型,VFL_TYPE_GRABBER就是注册video,会生成/dev/videoX; 注册v4l2_subdev也会调用这个函数,但是它的类型是VFL_TYPE_SUBDEV,生成/dev/v4l2-subdevX;

  • 第3个参数,就是“/dev/videoX”中的“X”,-1表示自动生成,从0开始。

3. 实现ioctl_ops

3.1 VIDIOC_QUERYCAP 查询设备能力

static int myvivi_vidoc_querycap(struct file *file,void *priv,
					struct v4l2_capability *cap) {
	struct vivi *vind = video_drvdata(file);

	strcpy(cap->driver, "myvivi");
	strcpy(cap->card, "myvivi");
	snprintf(cap->bus_info, sizeof(cap->bus_info),
			"platform:%s", vind->v4l2_dev.name);

	cap->capabilities = vind->vid_cap_caps | V4L2_CAP_DEVICE_CAPS;
	return 0;
}

一般我们只关心 capabilities 成员,比如V4L2_CAP_VIDEO_CAPTURE 具有视频捕获能力。

第10行,vind->vid_cap_caps为:

myvivi->vid_cap_caps = 	V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_VIDEO_OVERLAY | V4L2_CAP_STREAMING;

3.2 VIDIOC_ENUM_FMT 枚举(查询)设备支持的视频格式

static int myvivi_vidioc_enum_fmt_vid_cap(struct file *file, void *priv,
					struct v4l2_fmtdesc *f){
	const struct myvivi_fmt *fmt;

	if (f->index >= 1)
		return -EINVAL;

	fmt = &myvivi_formats;

	f->pixelformat = fmt->fourcc;
	return 0;
}

一般一个设备支持多种视频格式,但是这里为了简单,只支持一种。

  • 第5~6行,由于应用层并不知道设备支持多少种格式,因此通过index从0开始尝试;这里当index大于等于1时就错误返回,表示只支持一种格式;

  • 第8~9行,myvivi_formats就是我们定义的格式,如下

    struct myvivi_fmt myvivi_formats = {
    	//.fourcc   = V4L2_PIX_FMT_YUYV,
    	//.bit_depth = 16,
    	//.buffers = 1,
    	//.colorspace = V4L2_COLORSPACE_SRGB,
    	
    	.fourcc   = V4L2_PIX_FMT_RGB565, /* gggbbbbb rrrrrggg */
    	.bit_depth = 16,
    	.buffers = 1,
    	.colorspace = V4L2_COLORSPACE_SRGB,
    };
    

    常用的格式是V4L2_PIX_FMT_YUYV,但是我们要模拟数据输入,YUV比较难处理,所以使用V4L2_PIX_FMT_RGB565;

    另外补充YUV数据格式相关知识:

    ​ YUV 4:4:4, YUV 4:2:2, YUV 4:2:0, YUV 4:1:1, 换种叫法

    ​ YCrCb 4:4:4, YCrCb 4:2:2, YCrCb 4:2:0, YCrCb 4:1:1

    ​ Y表示亮度,U和V表示色度

    (1) YUV 4:4:4

    YUV 4:4:4意思就是4个像素里的数据有4个Y, 4个U, 4个V。YUV三个信道的抽样率相同,因此在生成的图像里,每个象素的三个分量信息完整(每个分量通常8比特),经过8比特量化之后,未经压缩的每个像素占用3个字节。

    下面的四个像素为: [Y0 U0 V0] [Y1 U1 V1] [Y2 U2 V2] [Y3 U3 V3]

    存放的码流为: Y0 U0 V0 Y1 U1 V1 Y2 U2 V2 Y3 U3 V3

    一个像素大小为:3 * 8* 4 / 4 = 24 bit

    (2) YUV 4:2:2

    意思就是相邻的4个像素里有4个Y, 2个U, 2个V。每个色差信道的抽样率是亮度信道的一半,所以水平方向的色度抽样率只是4:4:4的一半。对非压缩的8比特量化的图像来说,每个由两个水平方向相邻的像素组成的宏像素需要占用4字节内存。

    下面的四个像素为:[Y0 U0 V0] [Y1 U1 V1] [Y2 U2 V2] [Y3 U3 V3]

    存放的码流为:Y0 U0 Y1 V1 Y2 U2 Y3 V3

    映射出像素点为:[Y0 U0 V1] [Y1 U0 V1] [Y2 U2 V3] [Y3 U2 V3]

    一个像素大小为:(4 * 8 + 2 * 8 + 2 * 8) / 4 = 16 bit

    (3) YUV 4:1:1

    4:1:1的色度抽样,是在水平方向上对色度进行4:1抽样。对于低端用户和消费类产品这仍然是可以接受的。对非压缩的8比特量化的视频来说,每个由4个水平方向相邻的像素组成的宏像素需要占用6字节内存。

    下面的四个像素为: [Y0 U0 V0] [Y1 U1 V1] [Y2 U2 V2] [Y3 U3 V3]

    存放的码流为: Y0 U0 Y1 Y2 V2 Y3

    映射出像素点为:[Y0 U0 V2] [Y1 U0 V2] [Y2 U0 V2] [Y3 U0 V2]

    一个像素大小为:(4 * 8 + 1 * 8 + 1 * 8) / 4 = 12 bit

    (4)YUV4:2:0

    4:2:0并不意味着只有Y,Cb而没有Cr分量。它指得是对每行扫描线来说,只有一种色度分量以2:1的抽样率存储。相邻的扫描行存储不同的色度分量,也就是说,如果一行是4:2:0的话,下一行就是4:0:2,再下一行是4:2:0…以此类推。对每个色度分量来说,水平方向和竖直方向的抽样率都是2:1,所以可以说色度的抽样率是4:1。对非压缩的8比特量化的视频来说,每个由2x2个2行2列相邻的像素组成的宏像素需要占用6字节内存。

    下面八个像素为:[Y0 U0 V0] [Y1 U1 V1] [Y2 U2 V2] [Y3 U3 V3]

    [Y5 U5 V5] [Y6 U6 V6] [Y7U7 V7] [Y8 U8 V8]

    存放的码流为:Y0 U0 Y1 Y2 U2 Y3 Y5 V5 Y6 Y7 V7 Y8

    映射出的像素点为:[Y0 U0 V5] [Y1 U0 V5] [Y2 U2 V7] [Y3 U2 V7]

    [Y5 U0 V5] [Y6 U0 V5] [Y7U2 V7] [Y8 U2 V7]

    一个像素大小为:(4 * 8 + 2 * 8) / 4 = 12bit

3.3 VIDIOC_G_FMT 获得设置好的视频格式

static int myvivi_vidioc_g_fmt_vid_cap(struct file *file, void *priv,
					struct v4l2_format *f) {
	struct v4l2_pix_format *pix = &f->fmt.pix;
	struct vivi *vind = video_drvdata(file);
	
	pix->width = vind->fmt_cap_rect.width;
	pix->height = vind->fmt_cap_rect.height;
	pix->pixelformat = vind->fmt_cap->fourcc;
	pix->field = V4L2_FIELD_NONE;
	pix->colorspace = vind->fmt_cap->colorspace;
	pix->bytesperline = vind->bytesperline;
	pix->sizeimage = pix->bytesperline * pix->width;
	return 0;
}

将格式返回。

3.4 VIDIOC_S_FMT 设置视频格式

static int myvivi_vidioc_try_fmt_vid_cap(struct file *file,void *priv, 
			struct v4l2_format *f) {
	struct v4l2_pix_format *pix = &f->fmt.pix;
	struct vivi *vind = video_drvdata(file);
	u32 w;
	u32 bit_depth;
	v4l_bound_align_image(&pix->width, MIN_WIDTH, MAX_WIDTH, 2,
                  &pix->height, MIN_HEIGHT, MAX_HEIGHT, 0, 0);
	
	pix->pixelformat = vind->fmt_cap->fourcc;
	pix->field = V4L2_FIELD_NONE;
	pix->colorspace = vind->fmt_cap->colorspace;
	w = pix->width;
	bit_depth = vind->fmt_cap->bit_depth;
	pix->bytesperline = (w * bit_depth) >> 3;   
	pix->sizeimage = pix->bytesperline * pix->width;
	return 0;
}

static int myvivi_vidioc_s_fmt_vid_cap(struct file *file, void *priv,
					struct v4l2_format *f) {
	struct v4l2_pix_format *pix = &f->fmt.pix;
	struct vivi *vind = video_drvdata(file);
	
	int ret = myvivi_vidioc_try_fmt_vid_cap(file, priv, f);
    if (ret < 0) {
		printk(KERN_ERR"try format error!!!\n");
        return ret;
	}
	
	vind->fmt_cap_rect.width = pix->width;
	vind->fmt_cap_rect.height = pix->height;
	vind->bytesperline = pix->bytesperline;
	
	return 0;
}

设置宽高,视频格式等。

3.5 VIDIOC_REQBUFS 请求在内核空间分配视频缓冲区

.vidioc_reqbufs 			= vb2_ioctl_reqbufs,

vb2_ioctl_reqbufs() 会调用到vb2_core_reqbufs(), 接下来调用关系:

int vb2_core_reqbufs(struct vb2_queue *q, enum vb2_memory memory,unsigned int *count)
    call_qop(q, queue_setup, q, &num_buffers, &num_planes, plane_sizes, q->alloc_devs);
	allocated_buffers = __vb2_queue_alloc(q, memory, num_buffers, num_planes, plane_sizes);
  • 第2行,会回调到myvivi的vid_cap_queue_setup(), 计算buffer大小和个数;
  • 第3行,分配内存,num_buffers由应用决定,plane_sizes是个数组,myvivi设置只有1个plane,所以分配内存大小为:num_buffers * 1 * plane_sizes[0];

3.6 VIDIOC_QUERYBUF 查询分配好的 buffer 信息

.vidioc_querybuf 			= vb2_ioctl_querybuf,

vb2_ioctl_querybuf() --> vb2_querybuf() 查询所分配的缓冲区,根据应用设置的index获得缓冲区的使用状态、在内核空间的偏移地址、长度等等信息,然后应用程序根据这些信息使用mmap把内核空间地址映射到用户空间。

myvivi这里的mmap使用核心层的vb2_fop_mmap()。

3.7 VIDIOC_QBUF 将缓存放入队列中

.vidioc_qbuf 				= vb2_ioctl_qbuf,

关键调用:

vb2_ioctl_qbuf()
	vb2_qbuf(vdev->queue, p);
		vb2_core_qbuf(q, b->index, b);
			ret = __buf_prepare(vb, pb);
				ret = __qbuf_mmap(vb, pb);
					call_vb_qop(vb, buf_prepare, vb);//回调到myvivi的buf_prepare
					
			list_add_tail(&vb->queued_entry, &q->queued_list);//加入queued_list尾
		
			__enqueue_in_driver(vb);
				call_void_vb_qop(vb, buf_queue, vb);//回调到myvivi的buf_queue

myvivi的buf_queue()会将vb加入到本地的链表(vid_cap_active)尾部,填充数据时,从这个链表头取vb。

3.8 VIDIOC_STREAMON 开始捕获

.vidioc_streamon 			= vb2_ioctl_streamon,

关键调用:

vb2_ioctl_streamon
	vb2_streamon(vdev->queue, i);
		vb2_core_streamon(q, type);
			ret = vb2_start_streaming(q);
				ret = call_qop(q, start_streaming, q,
					atomic_read(&q->owned_by_drv_count));//回调到myvivi的start_streaming

myvivi的start_streaming()会启动定时器,开始产生虚拟数据,如下。

static int vid_cap_start_streaming(struct vb2_queue *vq, unsigned count) {
	struct vivi *vind = vb2_get_drv_priv(vq);
	timer_setup(&vind->timer, myvivi_timer_function, 0);
	vind->timer.expires = jiffies + HZ/2;
	add_timer(&vind->timer);
	return 0;
}

3.9 VIDIOC_DQBUF

.vidioc_dqbuf 				= vb2_ioctl_dqbuf,

关键调用:

vb2_ioctl_dqbuf
	vb2_dqbuf(vdev->queue, p, file->f_flags & O_NONBLOCK);
		ret = vb2_core_dqbuf(q, NULL, b, nonblocking);
			ret = __vb2_get_done_vb(q, &vb, pb, nonblocking);
				//从done_list的链表头取出vb
				*vb = list_first_entry(&q->done_list, struct vb2_buffer, done_entry);
				list_del(&(*vb)->done_entry);//从done_list中删除
			//将取出的vb信息给到应用,应用从对应的buffer中取数据
			call_void_bufop(q, fill_user_buffer, vb, pb);
			list_del(&vb->queued_entry);

3.10 VIDIOC_STREAMOFF 关闭捕获

.vidioc_streamoff 			= vb2_ioctl_streamoff,

关键调用:

vb2_ioctl_streamoff
	vb2_streamoff(vdev->queue, i);
		vb2_core_streamoff(q, type);
			__vb2_queue_cancel(q);
				call_void_qop(q, stop_streaming, q);//回调到myvivi的stop_streaming

myvivi的stop_streaming()会停止定时器,停止产生数据。

4. videobuf2

videobuf2是嵌入到v4l2子系统,以供驱动与用户空间提供数据申请与交互的接口集合,它实现了包括buffer分配,根据状态可以入队出队的控制流。

使用过程:

  • (1) 调用vb2_queue_init 初始化队列 q ,如下:

    	q = &myvivi->vb_vid_cap_q;
    	q->type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    	q->io_modes = VB2_MMAP | VB2_USERPTR | VB2_DMABUF | VB2_READ;
    	q->drv_priv = myvivi;
    	q->buf_struct_size = sizeof(struct myvivi_buffer);
    	q->ops = &myvivi_vid_cap_qops;
    	q->mem_ops = &vb2_vmalloc_memops;
    	q->lock = &myvivi->mutex;
    	q->timestamp_flags = V4L2_BUF_FLAG_TIMESTAMP_MONOTONIC;
    	q->dev = myvivi->v4l2_dev.dev;
    	ret = vb2_queue_init(q);
    

    第6行的ops就是上面ioctl操作buffer的回调函数,定义如下:

    const struct vb2_ops myvivi_vid_cap_qops = {
    	.queue_setup		= vid_cap_queue_setup,
    	.buf_prepare		= vid_cap_buf_prepare,
    	.buf_finish			= vid_cap_buf_finish,
    	.buf_queue			= vid_cap_buf_queue,
    	.start_streaming	= vid_cap_start_streaming,
    	.stop_streaming		= vid_cap_stop_streaming,
    };
    
  • (2) 调用reqbuf 时候会根据请求分配内存, 将vb加入到q->bufs数组,并且回调q->ops.queue_setup, queue_setup就是vid_cap_queue_setup,定义如下:

    // vb2 核心层 vb2_reqbufs 中调用它,确定申请缓冲区的大小
    static int vid_cap_queue_setup(struct vb2_queue *vq,
    		       unsigned *nbuffers, unsigned *nplanes,
    		       unsigned sizes[], struct device *alloc_devs[]){
    	struct vivi *vind = vb2_get_drv_priv(vq);
    	unsigned buffers = vind->fmt_cap->buffers;
    	
    	printk("width = %d \n",vind->fmt_cap_rect.width);
        printk("height = %d \n",vind->fmt_cap_rect.height);
        printk("pixelsize = %d \n",vind->bytesperline);
    	printk("buffers = %d \n",buffers);
    	
    	sizes[0] = vind->bytesperline * vind->fmt_cap_rect.height;
    	
    	if (vq->num_buffers + *nbuffers < 2)
    		*nbuffers = 2 - vq->num_buffers;
    	*nplanes = buffers;
    	printk("%s: count=%d\n", __func__, *nbuffers);
    	
    	return 0;
    }
    
  • (3) 调用querybuf时候,根据信息(v4l2_buffer)返回q->bufs中对应的vb2_buffer的信息(v4l2_buffer);

  • (4) mmap上面信息对应的 vb空间到用户空间

  • (5) 调用qbuf 时,将对应的vb2_buffer ( vivi_bufer->list )添加到 q->queued_list 队列中,并且回调q->ops.buf_prepare, buf_prepare就是vid_cap_buf_prepare,如下:

    //APP调用ioctl VIDIOC_QBUF时导致此函数被调用
    static int vid_cap_buf_prepare(struct vb2_buffer *vb){
    	struct vivi *vind = vb2_get_drv_priv(vb->vb2_queue);
    	unsigned long size;
    	size = vind->bytesperline * vind->fmt_cap_rect.height;;
    	if (vb2_plane_size(vb, 0) < size) {
    		printk(KERN_ERR"%s data will not fit into plane (%lu < %lu)\n",
    				__func__ ,vb2_plane_size(vb, 0), size);
    		return -EINVAL;
    	}
    	vb2_set_plane_payload(vb, 0, size);
    	return 0;
    }
    

    还会回调q->ops.buf_queue, buf_queue就是vid_cap_buf_queue,如下:

    static void vid_cap_buf_queue(struct vb2_buffer *vb) {
    	struct vb2_v4l2_buffer *vbuf = to_vb2_v4l2_buffer(vb);
    	struct vivi *vind = vb2_get_drv_priv(vb->vb2_queue);
    	struct myvivi_buffer *buf = container_of(vbuf, struct myvivi_buffer, vb);
    
    	spin_lock(&vind->slock);
    	//把buf放入本地一个队列尾部,定时器处理函数就可以从本地队列取出vb
    	list_add_tail(&buf->list, &vind->vid_cap_active);
    	spin_unlock(&vind->slock);
    }
    
  • (6) 使用select 调用poll 休眠等待 q->done_list 有数据

  • (7) 数据存放完成后 调用vb2_buffer_done函数,即将上面有数据的vb2_buffer放入q->done_list中,然后唤醒上面poll休眠的进程。定时器处理函数如下:

    static void myvivi_timer_function(struct timer_list *t){
    	struct vivi *vind = container_of(t, struct vivi, timer);
        struct myvivi_buffer *vid_cap_buf = NULL;
    	char *vbuf;
    	
    	if (!list_empty(&vind->vid_cap_active)) {
    		vid_cap_buf = list_entry(vind->vid_cap_active.next, struct myvivi_buffer, list);
    		if(vid_cap_buf->vb.vb2_buf.state != VB2_BUF_STATE_ACTIVE) {
    			printk(KERN_ERR"buffer no active,error!!!\n");
    			return;
    		}
    		list_del(&vid_cap_buf->list);
    	}else {
    		printk("No active queue to serve\n");
            goto out;
    	}
        
    	//取buf
    	vbuf = vb2_plane_vaddr(&vid_cap_buf->vb.vb2_buf, 0);
    	printk("bytesperline=%d\n",vind->bytesperline);
    	
    	//填充数据
    	memset(vbuf,0xff,vind->bytesperline * vind->fmt_cap_rect.height);
    	fillbuff(vbuf,vind->bytesperline,vind->fmt_cap_rect.height);
    	
        // 它干两个工作,把buffer 挂入done_list 另一个唤醒应用层序,让它dqbuf
        vb2_buffer_done(&vid_cap_buf->vb.vb2_buf, VB2_BUF_STATE_DONE);
        
    out:
        //修改timer的超时时间 : 30fps, 1秒里有30帧数据,每1/30 秒产生一帧数据
        mod_timer(&vind->timer, jiffies + HZ/30);
    }
    

    到此可以看出,入队/出队的流程如下:

在这里插入图片描述

  • (8) poll唤醒后会调用dqbuf将q->done_list 中的vb2_buffer提出来后,将此vb2的信息(v4l2_buffer)返回

  • (9) 应用程序得到buffer信息后,就去对应的mmap后的用户空间中读数据。

关于videobuf2的其他介绍看:

5.总结

纸上得来终觉浅,绝知此事要躬行,理解一个系统框架最有效的方法莫过于亲手去实现一遍,虽然有点费劲。
测试平台:Ubuntu 16.04
Makefile可能要根据不同ubuntu版本修改,参考7.camera驱动06-自己实现v4l2驱动-准备
测试命令:
$ sudo modprobe vivid
$ sudo rmmod vivid
$ sudo insmod myvivi.ko
$ xawtv
附上代码链接:https://github.com/stksss/myV4L2, 分支是MYVIVI, 到第8个提交就完成了以下效果:
在这里插入图片描述

  • 7
    点赞
  • 39
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值