Binder系列6 Binder传输原理之请求处理

一 Binder驱动事务读取

在上一篇文章中,我们知道 Binder 驱动生成了一个 binder_transaction 事务,并把这个事务插入到了目标进程或目标线程的 todo 队列中,并唤醒了目标进程或目标线程.那么目标端是怎么处理的呢?

先说个大概流程:Binder 驱动唤醒目标进程或目标线程后,目标线程会在读取函数中休眠等待的地方醒过来,然后到自己的 todo 队列上把这个事务摘下来,然后分析这个事务,根据事务中携带的数据信息,找到目标 Binder 实体,最后把数据传递给目标Binder实体.这个只是大概的流程,接下来我们详细分析.

1.1 目标线程被唤醒

我们知道负责与 Binder 通信的都是一个个具体的线程, 当目标进程被唤醒时,实际上唤醒的是该目标进程中的某一个 Binder 线程,这个 Binder 线程正常的情况下会通过调用 binder_thread_read() 函数来读取请求者的数据,如果暂时还没有数据传输过来的时候,就会休眠阻塞在这个函数中;而如果有数据传输过来的话,则会在休眠阻塞的地方被唤醒,然后接着处理传输过来的数据,我们来看这个函数:

static int binder_thread_read(struct binder_proc *proc,
			      struct binder_thread *thread,
			      binder_uintptr_t binder_buffer, size_t size,
			      binder_size_t *consumed, int non_block)
{
	void __user *buffer = (void __user *)(uintptr_t)binder_buffer;
	void __user *ptr = buffer + *consumed;
	void __user *end = buffer + size;

	int ret = 0;
	int wait_for_proc_work;
	........
	//检查本线程内部的todo队列中是否有待处理的事务,如果没有,那就等待处理本进程todo队列的work
	wait_for_proc_work = binder_available_for_proc_work_ilocked(thread);
	........
	thread->looper |= BINDER_LOOPER_STATE_WAITING;//设置线程looper等待标志
	........
	if (non_block) {
		........
	} else {
	    //重要函数,休眠阻塞等待,被唤醒后开始往下执行
		ret = binder_wait_for_work(thread, wait_for_proc_work);
	}
   //走到这里,证明已经被唤醒了,结束等待,需要去掉线程looper的等待状态
	thread->looper &= ~BINDER_LOOPER_STATE_WAITING;
	........
	while (1) {
		uint32_t cmd;
		struct binder_transaction_data tr;
		struct binder_work *w = NULL;
		struct list_head *list = NULL;
		struct binder_transaction *t = NULL;
		struct binder_thread *t_from;

		binder_inner_proc_lock(proc);
		//优先处理本线程内部的 todo 队列,如果为空,则处理进程的 todo 队列
		if (!binder_worklist_empty_ilocked(&thread->todo))
			list = &thread->todo;//选取线程的todo
		else if (!binder_worklist_empty_ilocked(&proc->todo) &&
			   wait_for_proc_work)
			list = &proc->todo;//选取进程的todo
		else {
			//若无数据且当前线程looper_need_return为false, 则重试
			if (ptr - buffer == 4 && !thread->looper_need_return)
				goto retry;
			break;
		}
		........
		//选取好todo队列后,开始从todo队列中摘除待处理的binder_transaction事务
		w = binder_dequeue_work_head_ilocked(list);//从list队列中取出第一项work
		if (binder_worklist_empty_ilocked(&thread->todo))
     		//如果线程的todo为空,则设置process_todo false
			thread->process_todo = false;

		switch (w->type) {//判断binder_work的类型,这里为BINDER_WORK_TRANSACTION
		case BINDER_WORK_TRANSACTION: {
			//根据work找到对应的binder_transaction事务
			t = container_of(w, struct binder_transaction, work);
		} break;
		........
		}//end switch

		if (!t)
			continue;
		//以上我们已经拿到从请求端发送过来的binder_transaction事务
		//接下来解析这个事务
		if (t->buffer->target_node) {//是否存在目标节点,这里为存在
			struct binder_node *target_node = t->buffer->target_node;
			struct binder_priority node_prio;
            //非常重要,把Binder实体的地址赋值给tr的target.ptr
			tr.target.ptr = target_node->ptr;
			//非常重要,Binder实体的地址赋值给tr的target.cookie
			tr.cookie =  target_node->cookie;
			........
			cmd = BR_TRANSACTION;//构建BR_TRANSACTION命令把tr数据返回到用户空间
		} else {
			tr.target.ptr = 0;
			tr.cookie = 0;
			cmd = BR_REPLY;
		}
		//事务的信息全部赋值给tr这个binder_transaction_data结构,用来返回到用户空间
		tr.code = t->code;
		tr.flags = t->flags;
		tr.sender_euid = from_kuid(current_user_ns(), t->sender_euid);
		t_from = binder_get_txn_from(t);//获取事务t的from源线程
		........
		tr.data_size = t->buffer->data_size;
		tr.offsets_size = t->buffer->offsets_size;
		// 使tr中的数据缓冲区和偏移数组的地址值指向事务t中的数据缓冲区和偏移数组的地址值
		tr.data.ptr.buffer = (binder_uintptr_t)
			((uintptr_t)t->buffer->data +
			binder_alloc_get_user_buffer_offset(&proc->alloc));
		tr.data.ptr.offsets = tr.data.ptr.buffer +
					ALIGN(t->buffer->data_size,
					    sizeof(void *));
     //将返回协议BR_TRANSACTION拷贝到目标线程提供的用户空间
		if (put_user(cmd, (uint32_t __user *)ptr)) {
			........
		}
		ptr += sizeof(uint32_t);
		// 将binder_transaction_data类型的tr拷贝到目标线程提供的用户空间
		if (copy_to_user(ptr, &tr, sizeof(tr))) {
			........
		}
		ptr += sizeof(tr);
		........
		//表示事务t的buffer允许目标线程在用户空间发出BC_FREE_BUFFER命令协议来释放
		t->buffer->allow_user_free = 1;
		if (cmd == BR_TRANSACTION && !(t->flags & TF_ONE_WAY)) {
			//同步操作,要等事务t处理完, 才释放缓存空间
			//同时需要对事务栈transaction_stack进行处理
			t->to_parent = thread->transaction_stack;
			t->to_thread = thread;
			thread->transaction_stack = t;
		} else {//异步操作可以直接free这个事务t了
			binder_free_transaction(t);
		}
		break;
	}
    ........
    //每次读取完事务后,都需要检查进程中的Binder线程是否够用,如果不够用需要通知新增线程
    //新增条件是:请求线程数为0,并且等待线程数也为0,并且已启动线程数不超过最大线程数
    //并且线程的状态为已经注册或已经进程循环,满足以上条件则向用户空间发送BR_SPAWN_LOOPER
    //命令,告诉用户空间创建一个新的Binder线程
	if (proc->requested_threads == 0 &&//正在请求还没有启动的线程数量
	    list_empty(&thread->proc->waiting_threads) &&//线程中空闲等待的线程数量
	    proc->requested_threads_started < proc->max_threads &&
	    (thread->looper & (BINDER_LOOPER_STATE_REGISTERED |
	     BINDER_LOOPER_STATE_ENTERED))) {
		proc->requested_threads++;//正在请求还没有启动的线程数量加1
		........
		//向用户空间发送命令BR_SPAWN_LOOPER, 创建新线程
		if (put_user(BR_SPAWN_LOOPER, (uint32_t __user *)buffer))
			return -EFAULT;
		........
	} else
		........
	return 0;
}

代码中的注释已经很详细了,简单说来就是,如果线程或进程中的 todo 队列中没有事务需要处理,binder_thread_read()函数就会进入休眠等待,否则binder_thread_read()函数就会在本线程或本进程的 todo 队列中摘下一个代表一个事务的work节点,并通过work节点找到对应的Binder事务,然后把这个事务里的数据整理成一个binder_transaction_data结构,然后通过copy_to_user()把该结构传输到用户空间。如果这个事务携带有TF_ONE_WAY标记,那么copy完后,需要把这个事务给free掉,因为已经不需要了,相反如果不是TF_ONE_WAY的话,那么就是同步操作,那么这个事务就不能被free掉,需要把这个事务链接到本线程的事务栈中,并修改这个事务相关的事务栈的信息.

还需要注意的是,在binder_thread_read()函数的最后,也就是读取完毕后,需要检查下本进程的Binder线程数量是否够用,也就是现有启动的Binder线程能否及时地响应和处理其它进程发过来的请求,主要的判断条件是本进程中 waiting_threads 的数量是否为0 ,这个变量表示本进程中空闲等待线程的数量,如果为0表示已经没有空闲线程了,那么就需要新启动一个线程,同时还需要判断开启的线程数量是否已经达到上限,如果没有达到上限那么就可以新增一个Binder线程.

以下是binder_thread_read函数中涉及到的相关函数:

获取本线程是否能够处理本进程的事务,只有本线程的todo队列为空,且事务栈transaction_stack为空的时候,才表示当先线程是空闲的,这个时候才可以处理所属进程的事务,否则的话,这个线程需要处理自己线程内部的todo队列中的事务.

static bool binder_available_for_proc_work_ilocked(struct binder_thread *thread)
{
	return !thread->transaction_stack &&
		binder_worklist_empty_ilocked(&thread->todo) &&
		(thread->looper & (BINDER_LOOPER_STATE_ENTERED |
				   BINDER_LOOPER_STATE_REGISTERED));
}

休眠等待函数 binder_wait_for_work

static int binder_wait_for_work(struct binder_thread *thread,
				bool do_proc_work)
{
	DEFINE_WAIT(wait);//建立并初始化一个等待队列项wait
	struct binder_proc *proc = thread->proc;
	int ret = 0;

	freezer_do_not_count();
	binder_inner_proc_lock(proc);
	for (;;) {//循环的作用是让线程被唤醒后再一次去检查一下condition是否满足
		prepare_to_wait(&thread->wait, &wait, TASK_INTERRUPTIBLE);//将wait添加到等待队列头中,并设置进程的状态
		if (binder_has_work_ilocked(thread, do_proc_work))//唤醒条件condition,如果满足则跳出循环,否则一直循环等待
			break;
		if (do_proc_work)//如果是在等待处理本进程的todo队列的任务
			list_add(&thread->waiting_thread_node,
				 &proc->waiting_threads);//把本线程的waiting_thread_node添加到所属进程的waiting_threads中
		binder_inner_proc_unlock(proc);
		schedule();//调用schedule(),让出cpu资源,开始休眠
		binder_inner_proc_lock(proc);
		list_del_init(&thread->waiting_thread_node);
		if (signal_pending(current)) {
			ret = -ERESTARTSYS;
			break;
		}
	}
	finish_wait(&thread->wait, &wait);//执行清理工作
	binder_inner_proc_unlock(proc);
	freezer_count();

	return ret;
}

二 Binder实体端对请求的处理

2.1 特殊Binder实体SMgr对请求的处理

服务端的Binder实体分两种情况,一种是普通的Binder实体所在的Server端,还有一种是Binder的守护进程SMgr,我们先看SMgr是如何处理的,我们回忆下在Binder系列3中介绍的 SMgr 的 binder_loop循环.

void binder_loop(struct binder_state *bs, binder_handler func)
{
    int res;
    struct binder_write_read bwr;//构造BINDER_WRITE_READ命令的数据binder_write_read
    uint32_t readbuf[32];
 
    bwr.write_size = 0;//write_size置为0表示只读不写
    bwr.write_consumed = 0;
    bwr.write_buffer = 0;
 
    readbuf[0] = BC_ENTER_LOOPER;
    binder_write(bs, readbuf, sizeof(uint32_t));
  //向Binder驱动发送BC_ENTER_LOOPER命令,通知Binder驱动本线程进入loop状态
 
    for (;;) {//无限for循环
        bwr.read_size = sizeof(readbuf);
        bwr.read_consumed = 0;
        bwr.read_buffer = (uintptr_t) readbuf;
 
        res = ioctl(bs->fd, BINDER_WRITE_READ, &bwr);//通过ioctl读取来自Binder驱动的数据
 
        if (res < 0) {
            ALOGE("binder_loop: ioctl failed (%s)\n", strerror(errno));
            break;
        }
     // 调用binder_parse函数解析读取的数据
        res = binder_parse(bs, 0, (uintptr_t) readbuf, bwr.read_consumed, func);
        if (res == 0) {
            ALOGE("binder_loop: unexpected reply?!\n");
            break;
        }
        if (res < 0) {
            ALOGE("binder_loop: io error %d %s\n", res, strerror(errno));
            break;
        }
    }
}

SMgr中的 binder_loop 函数通过ioctl和内核空间通信,我们知道当ioctl执行完上面介绍的 binder_thread_read 函数后会通过put_user和copy_to_user 分别把BR_TRANSACTION命令和binder_transaction_data数据拷贝到用户空间,通过代码分析我们知道,就是拷贝到了这个binder_loop函数中定义的binder_write_read数据的read_buffer中,也就是readbuf中,接下来就可以通过binder_parse函数来解析这些数据了,接下来的解析工作,都在binder_parse函数中,大家可以参考Binder系列3中的介绍,这里不再赘述了.

2.2 普通Binder实体所在的Server端进程对请求的处理

我们知道普通Binder实体所在的Server端进程对请求的处理与SMgr是不同的,他们是怎么处理的呢?我们以MediaPlayerService为例分析,代码如下:

int main(int argc __unused, char **argv __unused)
{
    signal(SIGPIPE, SIG_IGN);

    sp<ProcessState> proc(ProcessState::self());
    sp<IServiceManager> sm(defaultServiceManager());
    ALOGI("ServiceManager: %p", sm.get());
    InitializeIcuOrDie();
    MediaPlayerService::instantiate();//初始化
    ResourceManagerService::instantiate();
    registerExtensions();
    ProcessState::self()->startThreadPool();
    IPCThreadState::self()->joinThreadPool();
}

这个是MediaPlayerService的入口函数,我们观察ProcessState的startThreadPool函数

void ProcessState::startThreadPool()
{
    AutoMutex _l(mLock);
    if (!mThreadPoolStarted) {
        mThreadPoolStarted = true;
        spawnPooledThread(true);//调用spawnPooledThread
    }
}
void ProcessState::spawnPooledThread(bool isMain)
{
    if (mThreadPoolStarted) {
        String8 name = makeBinderThreadName();
        ALOGV("Spawning new pooled thread, name=%s\n", name.string());
        sp<Thread> t = new PoolThread(isMain);
        t->run(name.string());//生成一个PoolThread对象并调用run运行
    }
}
class PoolThread : public Thread
{
public:
    explicit PoolThread(bool isMain)
        : mIsMain(isMain)
    {
    }
    
protected:
    virtual bool threadLoop()
    {
        IPCThreadState::self()->joinThreadPool(mIsMain);//调用IPCThreadState的joinThreadPool
        return false;
    }
    
    const bool mIsMain;
};
void IPCThreadState::joinThreadPool(bool isMain)
{
    mOut.writeInt32(isMain ? BC_ENTER_LOOPER : BC_REGISTER_LOOPER);//先把BC_ENTER_LOOPER写入Binder驱动

    status_t result;
    do {
        processPendingDerefs();
        // now get the next command to be processed, waiting if necessary
        result = getAndExecuteCommand();//循环调用以从Binder驱动读取数据

        ........

        // Let this thread exit the thread pool if it is no longer
        // needed and it is not the main process thread.
        if(result == TIMED_OUT && !isMain) {
            break;
        }
    } while (result != -ECONNREFUSED && result != -EBADF);

    ........
    mOut.writeInt32(BC_EXIT_LOOPER);
    talkWithDriver(false);
}

从以上代码可知在joinThreadPool函数中,会循环调用getAndExecuteCommand函数,这个函数主要负责从Binder驱动读取数据并处理,代码如下:

status_t IPCThreadState::getAndExecuteCommand()
{
    status_t result;
    int32_t cmd;

    result = talkWithDriver();//和Binder驱动进行交互
    if (result >= NO_ERROR) {
        size_t IN = mIn.dataAvail();
        if (IN < sizeof(int32_t)) return result;
        cmd = mIn.readInt32();
        ........

        pthread_mutex_lock(&mProcess->mThreadCountLock);
        mProcess->mExecutingThreadsCount++;
        if (mProcess->mExecutingThreadsCount >= mProcess->mMaxThreads &&
                mProcess->mStarvationStartTimeMs == 0) {
            mProcess->mStarvationStartTimeMs = uptimeMillis();
        }
        pthread_mutex_unlock(&mProcess->mThreadCountLock);

        result = executeCommand(cmd);//对读取的数据进行处理

        pthread_mutex_lock(&mProcess->mThreadCountLock);
        mProcess->mExecutingThreadsCount--;
        if (mProcess->mExecutingThreadsCount < mProcess->mMaxThreads &&
                mProcess->mStarvationStartTimeMs != 0) {
            int64_t starvationTimeMs = uptimeMillis() - mProcess->mStarvationStartTimeMs;
            if (starvationTimeMs > 100) {
                ALOGE("binder thread pool (%zu threads) starved for %" PRId64 " ms",
                      mProcess->mMaxThreads, starvationTimeMs);
            }
            mProcess->mStarvationStartTimeMs = 0;
        }
        pthread_cond_broadcast(&mProcess->mThreadCountDecrement);
        pthread_mutex_unlock(&mProcess->mThreadCountLock);
    }

    return result;
}

getAndExecuteCommand 函数主要调用了2个方法,一个是 talkWithDriver 用来和 Binder 交互,主要是从Binder驱动读取数据;另外一个是 executeCommand 函数用来处理从Binder驱动读取过来的数据.

status_t IPCThreadState::talkWithDriver(bool doReceive)
{
    ........
    binder_write_read bwr;

    // Is the read buffer empty?
    const bool needRead = mIn.dataPosition() >= mIn.dataSize();
    const size_t outAvail = (!doReceive || needRead) ? mOut.dataSize() : 0;

    bwr.write_size = outAvail;
    bwr.write_buffer = (uintptr_t)mOut.data();

    // This is what we'll read.
    if (doReceive && needRead) {
        bwr.read_size = mIn.dataCapacity();
        bwr.read_buffer = (uintptr_t)mIn.data();
    } else {
        bwr.read_size = 0;
        bwr.read_buffer = 0;
    }
    ........
    // Return immediately if there is nothing to do.
    if ((bwr.write_size == 0) && (bwr.read_size == 0)) return NO_ERROR;

    bwr.write_consumed = 0;
    bwr.read_consumed = 0;
    status_t err;
    do {
        ........
        if (ioctl(mProcess->mDriverFD, BINDER_WRITE_READ, &bwr) >= 0)//调用ioctl与Binder驱动交互
            err = NO_ERROR;
        else
            err = -errno;
        ........
    } while (err == -EINTR);
    ........

    if (err >= NO_ERROR) {
        if (bwr.write_consumed > 0) {
            if (bwr.write_consumed < mOut.dataSize())
                mOut.remove(0, bwr.write_consumed);
            else
                mOut.setDataSize(0);
        }
        if (bwr.read_consumed > 0) {
            mIn.setDataSize(bwr.read_consumed);
            mIn.setDataPosition(0);
        }
        ........
        return NO_ERROR;
    }
    return err;
}

接下来看 executeCommand 函数,对命令和携带的数据进行解析和处理.

status_t IPCThreadState::executeCommand(int32_t cmd)
{
    BBinder* obj;
    RefBase::weakref_type* refs;
    status_t result = NO_ERROR;

    switch ((uint32_t)cmd) {
    ........
    case BR_TRANSACTION:
        {
            binder_transaction_data tr;
            result = mIn.read(&tr, sizeof(tr));//从mIn数据包中读取数据到tr
            ........
            Parcel buffer;
            buffer.ipcSetDataReference(
                reinterpret_cast<const uint8_t*>(tr.data.ptr.buffer),
                tr.data_size,
                reinterpret_cast<const binder_size_t*>(tr.data.ptr.offsets),
                tr.offsets_size/sizeof(binder_size_t), freeBuffer, this);
           ........
            Parcel reply;
            status_t error;
            ........
            if (tr.target.ptr) {//Binder实体的地址           
                if (reinterpret_cast<RefBase::weakref_type*>(
                        tr.target.ptr)->attemptIncStrong(this)) {
                    error = reinterpret_cast<BBinder*>(tr.cookie)->transact(tr.code, buffer,
                            &reply, tr.flags);//tr.cookie就是Binder实体的地址,然后执行transact函数
                    reinterpret_cast<BBinder*>(tr.cookie)->decStrong(this);
                } else {
                    error = UNKNOWN_TRANSACTION;
                }

            } else {
                error = the_context_object->transact(tr.code, buffer, &reply, tr.flags);
            }
            ........
            if ((tr.flags & TF_ONE_WAY) == 0) {
                LOG_ONEWAY("Sending reply to %d!", mCallingPid);
                if (error < NO_ERROR) reply.setError(error);
                sendReply(reply, 0);
            } else {
                LOG_ONEWAY("NOT sending reply to %d!", mCallingPid);
            }
            ........
        }
        break;
    ........
    default:
        ALOGE("*** BAD COMMAND %d received from Binder driver\n", cmd);
        result = UNKNOWN_ERROR;
        break;
    }
    if (result != NO_ERROR) {
        mLastError = result;
    }
    return result;
}

请注意上面代码中的reinterpret_cast<BBinder*>(tr.cookie)->transact(tr.code, buffer, &reply, tr.flags); 这里的 tr.cookie 就是Binder实体的地址,然后调用BBinder的 transact 函数.

status_t BBinder::transact(
    uint32_t code, const Parcel& data, Parcel* reply, uint32_t flags)
{
    data.setDataPosition(0);

    status_t err = NO_ERROR;
    switch (code) {
        case PING_TRANSACTION:
            reply->writeInt32(pingBinder());
            break;
        default:
            err = onTransact(code, data, reply, flags);//调用BBinder子类的onTransact方法
            break;
    }

    if (reply != NULL) {
        reply->setDataPosition(0);
    }

    return err;
}

其中最关键的一句是调用onTransact(),因为我们的Binder实体在本质上都是继承于BBinder的,而且我们一般都会重载onTransact()函数,所以上面这句onTransact()实际上调用的是具体的继承于BBinder的Binder实体的onTransact()成员函数,对于MediaPlayerService来说,它的Binder实体是 BnMediaPlayerService 如下:

class BnMediaPlayerService: public BnInterface<IMediaPlayerService>
{
public:
    virtual status_t    onTransact( uint32_t code,
                                    const Parcel& data,
                                    Parcel* reply,
                                    uint32_t flags = 0);
};

}; 

所以最终调用的是 BnMediaPlayerService 的 onTransact 函数,代码如下:

status_t BnMediaPlayerService::onTransact(
    uint32_t code, const Parcel& data, Parcel* reply, uint32_t flags)
{
    switch (code) {
        case CREATE: {
            CHECK_INTERFACE(IMediaPlayerService, data, reply);
            sp<IMediaPlayerClient> client =
                interface_cast<IMediaPlayerClient>(data.readStrongBinder());
            audio_session_t audioSessionId = (audio_session_t) data.readInt32();
            sp<IMediaPlayer> player = create(client, audioSessionId);
            reply->writeStrongBinder(IInterface::asBinder(player));
            return NO_ERROR;
        } break;
        case CREATE_MEDIA_RECORDER: {
            CHECK_INTERFACE(IMediaPlayerService, data, reply);
            const String16 opPackageName = data.readString16();
            sp<IMediaRecorder> recorder = createMediaRecorder(opPackageName);
            reply->writeStrongBinder(IInterface::asBinder(recorder));
            return NO_ERROR;
        } break;
        case CREATE_METADATA_RETRIEVER: {
            CHECK_INTERFACE(IMediaPlayerService, data, reply);
            sp<IMediaMetadataRetriever> retriever = createMetadataRetriever();
            reply->writeStrongBinder(IInterface::asBinder(retriever));
            return NO_ERROR;
        } break;
        case GET_OMX: {
            CHECK_INTERFACE(IMediaPlayerService, data, reply);
            sp<IOMX> omx = getOMX();
            reply->writeStrongBinder(IInterface::asBinder(omx));
            return NO_ERROR;
        } break;
        ........
        case GET_CODEC_LIST: {
            CHECK_INTERFACE(IMediaPlayerService, data, reply);
            sp<IMediaCodecList> mcl = getCodecList();
            reply->writeStrongBinder(IInterface::asBinder(mcl));
            return NO_ERROR;
        } break;
        default:
            return BBinder::onTransact(code, data, reply, flags);
    }
}

这个 onTransact 函数就是对具体语义进行分析了,其中的 code 就是函数的编号,根据这个值进行逐个解析,然后执行相应的操作,完成服务的远程使用.

以上就是 Binder 传输机制的接收端对数据的接收和处理的调用流程,大家需要多思考并根据代码进行验证.接下来的几篇文章会结合实例把这些操作流程再走一遍,以加深理解.如有问题和建议欢迎大家提出交流.

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值