从应用调用vivi驱动分析v4l2 -- 缓存放入队列(VIDIOC_QBUF)

Linux v4l2架构学习总链接

vivi代码

v4l2测试代码

step 5 : 设置缓存

3,将所有的缓存放入队列

struct v4l2_buffer v4l2_buffer;

for(i = 0; i < nr_bufs; i++)
{
	memset(&v4l2_buffer, 0, sizeof(struct v4l2_buffer));
	v4l2_buffer.index = i; //想要放入队列的缓存
	v4l2_buffer.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
	v4l2_buffer.memory = V4L2_MEMORY_MMAP;	

        ret = ioctl(fd, VIDIOC_QBUF, &v4l2_buffer);
        ...
}

调用vidioc_qbuf

vidioc_qbuf

-> vb2_qbuf


static int vidioc_qbuf(struct file *file, void *priv, struct v4l2_buffer *p)
{
	struct vivi_dev *dev = video_drvdata(file);
	return vb2_qbuf(&dev->vb_vidq, p);
}

int vb2_qbuf(struct vb2_queue *q, struct v4l2_buffer *b)
{
	int ret;


        /*
         * 判断q->fileio的值,默认为0
         */

	if (vb2_fileio_is_active(q)) {
		dprintk(1, "file io in progress\n");
		return -EBUSY;
	}


        /*
         * 见下面分析
         */


	ret = vb2_queue_or_prepare_buf(q, b, "qbuf");

        
        /*
         * 下面分析可知,vivi驱动返回0
         * 所以会调用vb2_core_qbuf
         */
        

	return ret ? ret : vb2_core_qbuf(q, b->index, b);
}

vidioc_qbuf

-> vb2_qbuf

      -> vb2_queue_or_prepare_buf


分析其中的vb2_queue_or_prepare_buf

static int vb2_queue_or_prepare_buf(struct vb2_queue *q, struct v4l2_buffer *b,
				    const char *opname)
{
	if (b->type != q->type) {
		dprintk(1, "%s: invalid buffer type\n", opname);
		return -EINVAL;
	}

	if (b->index >= q->num_buffers) {
		dprintk(1, "%s: buffer index out of range\n", opname);
		return -EINVAL;
	}

	if (q->bufs[b->index] == NULL) {
		/* Should never happen */
		dprintk(1, "%s: buffer is NULL\n", opname);
		return -EINVAL;
	}

	if (b->memory != q->memory) {
		dprintk(1, "%s: invalid memory type\n", opname);
		return -EINVAL;
	}


        /*
         * 忽略上面的相关判断
         * 对这个buffer cache hint 下面分析
         */


	set_buffer_cache_hints(q, q->bufs[b->index], b);

        
        /*
         * vivi驱动是单平面的视频结构
         * 所以这里会返回0
         */


	return __verify_planes_array(q->bufs[b->index], b);
}

static void set_buffer_cache_hints(struct vb2_queue *q,
				   struct vb2_buffer *vb,
				   struct v4l2_buffer *b)
{


        /*
         * 对于dma方式,prepare及finish不需要显示的使用sync
         * 对于其他方式则需要
         * 显示使用sync的意思是调用sync相关函数???
         */


	/*
	 * DMA exporter should take care of cache syncs, so we can avoid
	 * explicit ->prepare()/->finish() syncs. For other ->memory types
	 * we always need ->prepare() or/and ->finish() cache sync.
	 */
	if (q->memory == VB2_MEMORY_DMABUF) {
		vb->need_cache_sync_on_finish = 0;
		vb->need_cache_sync_on_prepare = 0;
		return;
	}

	/*
	 * Cache sync/invalidation flags are set by default in order to
	 * preserve existing behaviour for old apps/drivers.
	 */
	vb->need_cache_sync_on_prepare = 1;
	vb->need_cache_sync_on_finish = 1;


        /*
         * if的条件是! q->allow_cache_hints && q->memory == VB2_MEMORY_MMAP
         * vivi的q->allow_cache_hints值为0
         * 所以if满足
         * flags中相关标志位清空
         * 这里的b->flags数据来源于用户空间设置
         */
    

	if (!vb2_queue_allows_cache_hints(q)) {
		/*
		 * Clear buffer cache flags if queue does not support user
		 * space hints. That's to indicate to userspace that these
		 * flags won't work.
		 */
		b->flags &= ~V4L2_BUF_FLAG_NO_CACHE_INVALIDATE;
		b->flags &= ~V4L2_BUF_FLAG_NO_CACHE_CLEAN;
		return;
	}

	/*
	 * ->finish() cache sync can be avoided when queue direction is
	 * TO_DEVICE.
	 */
	if (q->dma_dir == DMA_TO_DEVICE)
		vb->need_cache_sync_on_finish = 0;

	if (b->flags & V4L2_BUF_FLAG_NO_CACHE_INVALIDATE)
		vb->need_cache_sync_on_finish = 0;

	if (b->flags & V4L2_BUF_FLAG_NO_CACHE_CLEAN)
		vb->need_cache_sync_on_prepare = 0;
}

vidioc_qbuf

-> vb2_qbuf

      -> vb2_queue_or_prepare_buf

      -> vb2_core_qbuf


分析vb2_qbuf中的vb2_core_qbuf

int vb2_core_qbuf(struct vb2_queue *q, unsigned int index, void *pb)
{
	struct vb2_buffer *vb;
	int ret;

	if (q->error) {
		dprintk(1, "fatal error occurred on queue\n");
		return -EIO;
	}

	vb = q->bufs[index];

	switch (vb->state) {


        /*
         * 前面的所有分析中只有reqbufs的时候
         * 状态设置成了VB2_BUF_STATE_DEQUEUED
         * 表示在用户空间控制
         * 所以要分析__buf_prepare这个函数
         */


	case VB2_BUF_STATE_DEQUEUED:
		ret = __buf_prepare(vb, pb);
		if (ret)
			return ret;
		break;
	case VB2_BUF_STATE_PREPARED:
		break;
	case VB2_BUF_STATE_PREPARING:
		dprintk(1, "buffer still being prepared\n");
		return -EINVAL;
	default:
		dprintk(1, "invalid buffer state %d\n", vb->state);
		return -EINVAL;
	}
        
        ...
}

 vidioc_qbuf

-> vb2_qbuf

      -> vb2_queue_or_prepare_buf

      -> vb2_core_qbuf

              -> __buf_prepare


static int __buf_prepare(struct vb2_buffer *vb, const void *pb)
{
	struct vb2_queue *q = vb->vb2_queue;
	unsigned int plane;
	int ret;

	if (q->error) {
		dprintk(1, "fatal error occurred on queue\n");
		return -EIO;
	}

	vb->state = VB2_BUF_STATE_PREPARING;

	switch (q->memory) {

        
        /*
         * 很明显vivi驱动会执行MMAP
         */


	case VB2_MEMORY_MMAP:
		ret = __prepare_mmap(vb, pb);
		break;
	case VB2_MEMORY_USERPTR:
		ret = __prepare_userptr(vb, pb);
		break;
	case VB2_MEMORY_DMABUF:
		ret = __prepare_dmabuf(vb, pb);
		break;
	default:
		WARN(1, "Invalid queue type\n");
		ret = -EINVAL;
	}
        ...

 vidioc_qbuf

-> vb2_qbuf

      -> vb2_queue_or_prepare_buf

      -> vb2_core_qbuf

              -> __buf_prepare

                       -> __prepare_mmap


static int __prepare_mmap(struct vb2_buffer *vb, const void *pb)
{
	int ret = 0;


        /*
         * pb是用户空间传入的,所以存在
         */


	if (pb)
                

                /*
                 * 变换之后 vb->vb2_queue->buf_ops->fill_vb2_buffer
                 * 对应__fill_vb2_buffer
                 */


		ret = call_bufop(vb->vb2_queue, fill_vb2_buffer,
				 vb, pb, vb->planes);


        /*
         * vb->vb2_queue->ops->buf_prepare
         * 就是vivi_video_qops.buf_prepare
         */


	return ret ? ret : call_vb_qop(vb, buf_prepare, vb);
}

 vidioc_qbuf

-> vb2_qbuf

      -> vb2_queue_or_prepare_buf

      -> vb2_core_qbuf

              -> __buf_prepare

                       -> __prepare_mmap

                                 -> call_bufop -- __fill_vb2_buffer


static int __fill_vb2_buffer(struct vb2_buffer *vb,
		const void *pb, struct vb2_plane *planes)
{
	struct vb2_queue *q = vb->vb2_queue;
	const struct v4l2_buffer *b = pb;
	struct vb2_v4l2_buffer *vbuf = to_vb2_v4l2_buffer(vb);
	unsigned int plane;
	int ret;


        /*
         * 对于capature
         * __verify_length 返回0
         */


	ret = __verify_length(vb, b);
	if (ret < 0) {
		dprintk(1, "plane parameters verification failed: %d\n", ret);
		return ret;
	}



        /*
         * 不是output不关心
         */



	if (b->field == V4L2_FIELD_ALTERNATE && q->is_output) {
		/*
		 * If the format's field is ALTERNATE, then the buffer's field
		 * should be either TOP or BOTTOM, not ALTERNATE since that
		 * makes no sense. The driver has to know whether the
		 * buffer represents a top or a bottom field in order to
		 * program any DMA correctly. Using ALTERNATE is wrong, since
		 * that just says that it is either a top or a bottom field,
		 * but not which of the two it is.
		 */
		dprintk(1, "the field is incorrectly set to ALTERNATE for an output buffer\n");
		return -EINVAL;
	}

        
	vb->timestamp = 0;
	vbuf->sequence = 0;


        /*
         * 也不是多平面视频格式
         */


	if (V4L2_TYPE_IS_MULTIPLANAR(b->type)) {
		if (b->memory == VB2_MEMORY_USERPTR) {
			for (plane = 0; plane < vb->num_planes; ++plane) {
				planes[plane].m.userptr =
					b->m.planes[plane].m.userptr;
				planes[plane].length =
					b->m.planes[plane].length;
			}
		}
		if (b->memory == VB2_MEMORY_DMABUF) {
			for (plane = 0; plane < vb->num_planes; ++plane) {
				planes[plane].m.fd =
					b->m.planes[plane].m.fd;
				planes[plane].length =
					b->m.planes[plane].length;
			}
		}

		/* Fill in driver-provided information for OUTPUT types */
		if (V4L2_TYPE_IS_OUTPUT(b->type)) {
			/*
			 * Will have to go up to b->length when API starts
			 * accepting variable number of planes.
			 *
			 * If bytesused == 0 for the output buffer, then fall
			 * back to the full buffer size. In that case
			 * userspace clearly never bothered to set it and
			 * it's a safe assumption that they really meant to
			 * use the full plane sizes.
			 *
			 * Some drivers, e.g. old codec drivers, use bytesused == 0
			 * as a way to indicate that streaming is finished.
			 * In that case, the driver should use the
			 * allow_zero_bytesused flag to keep old userspace
			 * applications working.
			 */
			for (plane = 0; plane < vb->num_planes; ++plane) {
				struct vb2_plane *pdst = &planes[plane];
				struct v4l2_plane *psrc = &b->m.planes[plane];

				if (psrc->bytesused == 0)
					vb2_warn_zero_bytesused(vb);

				if (vb->vb2_queue->allow_zero_bytesused)
					pdst->bytesused = psrc->bytesused;
				else
					pdst->bytesused = psrc->bytesused ?
						psrc->bytesused : pdst->length;
				pdst->data_offset = psrc->data_offset;
			}
		}
	} else {
		/*
		 * Single-planar buffers do not use planes array,
		 * so fill in relevant v4l2_buffer struct fields instead.
		 * In videobuf we use our internal V4l2_planes struct for
		 * single-planar buffers as well, for simplicity.
		 *
		 * If bytesused == 0 for the output buffer, then fall back
		 * to the full buffer size as that's a sensible default.
		 *
		 * Some drivers, e.g. old codec drivers, use bytesused == 0 as
		 * a way to indicate that streaming is finished. In that case,
		 * the driver should use the allow_zero_bytesused flag to keep
		 * old userspace applications working.
		 */
		if (b->memory == VB2_MEMORY_USERPTR) {
			planes[0].m.userptr = b->m.userptr;
			planes[0].length = b->length;
		}

		if (b->memory == VB2_MEMORY_DMABUF) {
			planes[0].m.fd = b->m.fd;
			planes[0].length = b->length;
		}

		if (V4L2_TYPE_IS_OUTPUT(b->type)) {
			if (b->bytesused == 0)
				vb2_warn_zero_bytesused(vb);

			if (vb->vb2_queue->allow_zero_bytesused)
				planes[0].bytesused = b->bytesused;
			else
				planes[0].bytesused = b->bytesused ?
					b->bytesused : planes[0].length;
		} else

                        
                        /*
                         * vivi只符合这里,
                         * bytesused设置为0
                         */


			planes[0].bytesused = 0;

	}

	/* Zero flags that the vb2 core handles */
	vbuf->flags = b->flags & ~V4L2_BUFFER_MASK_FLAGS;


        /*
         * 这里的if条件满足
         * 清空V4L2_BUF_FLAG_TSTAMP_SRC_MASK标志
         */

	if (!vb->vb2_queue->copy_timestamp || !V4L2_TYPE_IS_OUTPUT(b->type)) {
		/*
		 * Non-COPY timestamps and non-OUTPUT queues will get
		 * their timestamp and timestamp source flags from the
		 * queue.
		 */
		vbuf->flags &= ~V4L2_BUF_FLAG_TSTAMP_SRC_MASK;
	}

	if (V4L2_TYPE_IS_OUTPUT(b->type)) {
		/*
		 * For output buffers mask out the timecode flag:
		 * this will be handled later in vb2_qbuf().
		 * The 'field' is valid metadata for this output buffer
		 * and so that needs to be copied here.
		 */
		vbuf->flags &= ~V4L2_BUF_FLAG_TIMECODE;
		vbuf->field = b->field;
	} else {
		/* Zero any output buffer flags as this is a capture buffer */
		vbuf->flags &= ~V4L2_BUFFER_OUT_FLAGS;
		/* Zero last flag, this is a signal from driver to userspace */
		vbuf->flags &= ~V4L2_BUF_FLAG_LAST;
	}

	return 0;
}

 vidioc_qbuf

-> vb2_qbuf

      -> vb2_queue_or_prepare_buf

      -> vb2_core_qbuf

              -> __buf_prepare

                       -> __prepare_mmap

                          -> call_bufop -- __fill_vb2_buffer

                                 -> call_vb_qop -- buf_prepare


static int buffer_prepare(struct vb2_buffer *vb)
{
	struct vivi_dev *dev = vb2_get_drv_priv(vb->vb2_queue);
	struct vivi_buffer *buf = container_of(vb, struct vivi_buffer, vb);
	unsigned long size;

	BUG_ON(NULL == dev->fmt);

	/*
	 * Theses properties only change when queue is idle, see s_fmt.
	 * The below checks should not be performed here, on each
	 * buffer_prepare (i.e. on each qbuf). Most of the code in this function
	 * should thus be moved to buffer_init and s_fmt.
	 */
	if (dev->width  < 48 || dev->width  > MAX_WIDTH ||
	    dev->height < 32 || dev->height > MAX_HEIGHT)
		return -EINVAL;

	size = dev->width * dev->height * 2;


        /*
         * vb->planes[i].length == size
         * 这是之前reqbufs时候设置的
         */


	if (vb2_plane_size(vb, 0) < size) {
		dprintk(dev, 1, "%s data will not fit into plane (%lu < %lu)\n",
				__func__, vb2_plane_size(vb, 0), size);
		return -EINVAL;
	}

        
        /*
         * 展开就是 vb->planes[0].bytesused = size;
         * 终于找到bytesused设置的地方了
         * 这里只设置planes[0],而不考虑planes[1],应该是vivi知道自己是
         * 单平面的视频格式
         */


	vb2_set_plane_payload(&buf->vb, 0, size);

	buf->fmt = dev->fmt;

        
        /*
         * 这里更新vivi的colorbar相关的,不分析
         */

	precalculate_bars(dev);
	precalculate_line(dev);

	return 0;
}

回到 __buf_prepare

 vidioc_qbuf

-> vb2_qbuf

      -> vb2_queue_or_prepare_buf

      -> vb2_core_qbuf

              -> __buf_prepare


        /*
         * set_buffer_cache_hints中
         * 应用程序传递的参数flags如果没有设置 
         * V4L2_BUF_FLAG_NO_CACHE_CLEAN
         * 那么 need_cache_sync_on_prepare值就是1
         */


	if (vb->need_cache_sync_on_prepare) {
		for (plane = 0; plane < vb->num_planes; ++plane)

                        /*
                         * vb->vb2_queue->mem_ops->prepare对应
                         * vb2_vmalloc_memops.prepare
                         * 这里没有这个方法,所以跳过
                         */
			call_void_memop(vb, prepare,
					    vb->planes[plane].mem_priv);
	}

        /*
         * 更改vb state为 VB2_BUF_STATE_PREPARED
         * 表示buffer是由videobuf和驱动程序准备的缓冲区 
         */

        vb->state = VB2_BUF_STATE_PREPARED;

回到 __buf_prepare

 vidioc_qbuf

-> vb2_qbuf

      -> vb2_queue_or_prepare_buf

      -> vb2_core_qbuf

        /*
         * 注释写的很明白,buffer将一直在链表上,直到使用dqbuf出队
         * queued_count记录了链表上buffer的个数
         * 现在将状态改为VB2_BUF_STATE_QUEUED
         * 表示 在videobuf中准备了缓冲区,但在驱动程序中未准备 
         */




	/*
	 * Add to the queued buffers list, a buffer will stay on it until
	 * dequeued in dqbuf.
	 */
	list_add_tail(&vb->queued_entry, &q->queued_list);
	q->queued_count++;
	q->waiting_for_buffers = false;
	vb->state = VB2_BUF_STATE_QUEUED;


        /*
         * copy_timestamp
         * 对应 __copy_timestanp
         * 只有是输出的时候才会执行,这里分析的是输入,所以忽略
         */


	if (pb)
		call_void_bufop(q, copy_timestamp, vb, pb);

            
        /*
         * start_streaning_called是在start_streaming中设置的
         * 这里为什么会加个判断呢?
         * 会不会有一种情况,应用1已经打开了video0,在抓图
         * 我现在这个是应用2,又将video0打开了进行设置,所以出现了这种情况?
         * 这种情况我们也分析一下
         *
         * 下面分析可以看出
         * 如果在这种情况下,新加入的buffer也会被填充数据
         * 这里有个疑问,buffer中的数据会被谁拿到呢?
         * 猜测应该是当前的应用,因为关联到了offset
         * 还有个疑问,当前应该还没有开始抓图,那么当前的应用中填充了数据的buffer
         * 这个buffer还会再次填充吗?
         */
        

        /*
	 * If already streaming, give the buffer to driver for processing.
	 * If not, the buffer will be given to driver on next streamon.
	 */
	if (q->start_streaming_called)
		__enqueue_in_driver(vb);

vidioc_qbuf

-> vb2_qbuf

      -> vb2_queue_or_prepare_buf

      -> vb2_core_qbuf

            -> __enqueue_in_driver


static void __enqueue_in_driver(struct vb2_buffer *vb)
{
	struct vb2_queue *q = vb->vb2_queue;


        /*
         * 状态 VB2_BUF_STATE_ACTIVE
         * 缓冲区在驱动程序中排队并且可能在硬件操作中使用。 
         */


	vb->state = VB2_BUF_STATE_ACTIVE;
	atomic_inc(&q->owned_by_drv_count);

	trace_vb2_buf_queue(q, vb);

        /*
         * 这里对应vivi驱动的 buffer_queue
         * 这里是把所有操作该video节点的应用的buffer都传递过去
         */


	call_void_vb_qop(vb, buf_queue, vb);
}

static void buffer_queue(struct vb2_buffer *vb)
{
	struct vivi_dev *dev = vb2_get_drv_priv(vb->vb2_queue);
	struct vivi_buffer *buf = container_of(vb, struct vivi_buffer, vb);
	struct vivi_dmaqueue *vidq = &dev->vidq;
	unsigned long flags = 0;

	dprintk(dev, 1, "%s\n", __func__);

	spin_lock_irqsave(&dev->slock, flags);
        
        
        /*
         * vivi驱动中,只是将buf挂载到一个active的链表上
         */

	list_add_tail(&buf->list, &vidq->active);
    	spin_unlock_irqrestore(&dev->slock, flags);
}

static void vivi_thread_tick(struct vivi_dev *dev)
{
	struct vivi_dmaqueue *dma_q = &dev->vidq;
	struct vivi_buffer *buf;
	unsigned long flags = 0;

	dprintk(dev, 1, "Thread tick\n");

	spin_lock_irqsave(&dev->slock, flags);
	if (list_empty(&dma_q->active)) {
		dprintk(dev, 1, "No active queue to serve\n");
		goto unlock;
	}


        /*
         * vivi有个定时器,会不断的填充模拟的帧数据到buffer
         * 可以看到buffer挂载到active链表后,就会被填充数据
         */


	buf = list_entry(dma_q->active.next, struct vivi_buffer, list);
	list_del(&buf->list);

	buf->vb.timestamp = ktime_get_ns();

	/* Fill buffer */
	vivi_fillbuff(dev, buf);
	dprintk(dev, 1, "filled buffer %p\n", buf);

	vb2_buffer_done(&buf->vb, VB2_BUF_STATE_DONE);
	dprintk(dev, 2, "[%p/%d] done\n", buf, buf->vb.index);
unlock:
	spin_unlock_irqrestore(&dev->slock, flags);
}

回到 __buf_prepare

 vidioc_qbuf

-> vb2_qbuf

      -> vb2_queue_or_prepare_buf

      -> vb2_core_qbuf


        /*
         * 在之前VIDIOC_QUERYBUF的文章中我们分析过这个
         * fill_user_buffer函数
         * 对应__fill_user_buffer
         * 其中有这样的几行代码
         *  if (!q->is_output &&
       	 *      b->flags & V4L2_BUF_FLAG_DONE &&
	 *      b->flags & V4L2_BUF_FLAG_LAST)
	 *	q->last_buffer_dequeued = true;
         * 出现V4L2_BUF_FLAG_DONE这种标志的情况
         * 符合上面分析的情况
         * 就是其他应用已经打开当前应用操作的节点,并开始抓图了
         */


	/* Fill buffer information for the userspace */
	if (pb)
		call_void_bufop(q, fill_user_buffer, vb, pb);


        /*
         * 对于这里有些绕
         * 首先对于stream_on来说
         * 如果能正常抓图 q->streaming = 1 q->start_streaming_called = 1
         * 这里q->start_streaming_called = 0
         * 这里的代码和上面if(q->start_streaming_called)的情况不会同时出现
         * 说明启动的时候,因为某些原因失败了
         * 失败的原因有什么?
         * 比如下面说的没有足够的缓冲区
         * 但是为什么会没有足够的缓冲区呢?应用程序会判断的
         * 只有一种情况就是驱动程序不关心申请到的缓冲区小于最小缓冲区要求
         * 也就是min_buffers_needed
         * 这样返回给应用程序,应用程序判断没有问题,才会往下走,最后执行stream_on
         * 为了验证上面的分析
         * 追了一下代码ioctl stream_on的时候,这个后面会分析
         * vb2_core_streamon中
         * 会判断 q->queued_count >= q->min_buffers_needed,
         * 如果不符合的话 start_streaming_called = 0
         * 但是会将q->streaming = 1
         * 于是就出现了下面这种情况
         * 这有点像借用buffer
         */        


        /*
	 * If streamon has been called, and we haven't yet called
	 * start_streaming() since not enough buffers were queued, and
	 * we now have reached the minimum number of queued buffers,
	 * then we can finally call start_streaming().
	 */
	if (q->streaming && !q->start_streaming_called &&
	    q->queued_count >= q->min_buffers_needed) {
		ret = vb2_start_streaming(q);
		if (ret)
			return ret;
	}

对应的应用程序

        for (i = 0; i < req.count; ++i)
        {
                memset(&buf, 0, sizeof(buf));

                buf.type        = V4L2_BUF_TYPE_VIDEO_CAPTURE;
                buf.memory      = V4L2_MEMORY_MMAP;
                buf.index       = i;

                if (-1 == ioctl (fd, VIDIOC_QBUF, &buf))
                        printf ("VIDIOC_QBUF failed\n");

                printf("buf[%d].flags = 0x%08x\n", i, buf.flags);
        }

这里将flags打印一下,上面的分析可知

驱动中首先是flags置为VB2_BUF_STATE_QUEUED 3

后来在fill_v4l2_buffer 或上了V4L2_BUF_FLAG_TIMESTAMP_MONOTONIC  0x00002000

所以打印如下

buf[0].flags = 0x00002003
buf[1].flags = 0x00002003
buf[2].flags = 0x00002003
buf[3].flags = 0x00002003

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

dianlong_lee

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值