objc_msgSend之方法快速与慢速查找

在上篇Runtime学习之objc_msgSend分析中我们简单的介绍了下对于Runtime的理解,以及objc_msgSend消息转发的流程做了简单介绍,那么今天我们就整体的做一次流程的分析和梳理,方便后续的学习总结

一.快速查找方法流程分析

1._objc_msgSend

	ENTRY _objc_msgSend
	UNWIND _objc_msgSend, NoFrame

	cmp	p0, #0			// nil check and tagged pointer check
#if SUPPORT_TAGGED_POINTERS
	b.le	LNilOrTagged		//  (MSB tagged pointer looks negative)
#else
	b.eq	LReturnZero
#endif
	ldr	p13, [x0]		// p13 = isa
	GetClassFromIsa_p16 p13, 1, x0	// p16 = class
LGetIsaDone:
	// calls imp or objc_msgSend_uncached
	CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached

汇编解析:

  • 1.cmp p0, #0 判断p0(消息接受者)是否存在,不存在则重新开始执行objc_msgSend

  • 2.ldr p13, [x0]通过p13isa

  • 3.GetClassFromIsa_p16 p13, 1, x0通过isaclass也就是p16

2.CacheLookup (方法的查找过程)

// NORMAL, _objc_msgSend, __objc_msgSend_uncached
.macro CacheLookup Mode, Function, MissLabelDynamic, MissLabelConstant
	//   requirements:
	//
	//   GETIMP:
	//     The cache-miss is just returning NULL (setting x0 to 0)
	//缓存未命中返回nil
	//
	//   NORMAL and LOOKUP:
	//   - x0 contains the receiver  接收器
	//   - x1 contains the selector  选择器
	//   - x16 contains the isa		 isa
	//   - other registers are set as per calling conventions
	//

	mov	x15, x16			// stash the original isa
LLookupStart\Function:
	// p1 = SEL, p16 = isa
#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16_BIG_ADDRS
	ldr	p10, [x16, #CACHE]				// p10 = mask|buckets
	lsr	p11, p10, #48			// p11 = mask
	and	p10, p10, #0xffffffffffff	// p10 = buckets
	and	w12, w1, w11			// x12 = _cmd & mask
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
	ldr	p11, [x16, #CACHE]			// p11 = mask|buckets

//为了方便查看 换行分开

#if CONFIG_USE_PREOPT_CACHES

//获取buckets
#if __has_feature(ptrauth_calls)
	tbnz	p11, #0, LLookupPreopt\Function
	and	p10, p11, #0x0000ffffffffffff	// p10 = buckets
#else
	and	p10, p11, #0x0000fffffffffffe	// p10 = buckets
	tbnz	p11, #0, LLookupPreopt\Function
#endif
	eor	p12, p1, p1, LSR #7
	and	p12, p12, p11, LSR #48		// x12 = (_cmd ^ (_cmd >> 7)) & mask


#else
	and	p10, p11, #0x0000ffffffffffff	// p10 = buckets
	and	p12, p1, p11, LSR #48		// x12 = _cmd & mask
#endif // CONFIG_USE_PREOPT_CACHES

#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4
//#define CACHE            (2 * __SIZEOF_POINTER__)
	ldr	p11, [x16, #CACHE]				// p11 = mask|buckets
	and	p10, p11, #~0xf			// p10 = buckets
	and	p11, p11, #0xf			// p11 = maskShift
	mov	p12, #0xffff
	lsr	p11, p12, p11			// p11 = mask = 0xffff >> p11
	and	p12, p1, p11			// x12 = _cmd & mask
#else
#error Unsupported cache mask storage for ARM64.
#endif

	add	p13, p10, p12, LSL #(1+PTRSHIFT)
						// p13 = buckets + ((_cmd & mask) << (1+PTRSHIFT))

						// do {
1:	ldp	p17, p9, [x13], #-BUCKET_SIZE	//     {imp, sel} = *bucket--
	cmp	p9, p1				//     if (sel != _cmd) {
	b.ne	3f				//         scan more
						//     } else {
2:	CacheHit \Mode				// hit:    call or return imp
						//     }
3:	cbz	p9, \MissLabelDynamic		//     if (sel == 0) goto Miss;
	cmp	p13, p10			// } while (bucket >= buckets)
	b.hs	1b

	// wrap-around:
	//   p10 = first bucket
	//   p11 = mask (and maybe other bits on LP64)
	//   p12 = _cmd & mask
	//
	// A full cache can happen with CACHE_ALLOW_FULL_UTILIZATION.
	// So stop when we circle back to the first probed bucket
	// rather than when hitting the first bucket again.
	//
	// Note that we might probe the initial bucket twice
	// when the first probed slot is the last entry.


#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16_BIG_ADDRS
	add	p13, p10, w11, UXTW #(1+PTRSHIFT)
						// p13 = buckets + (mask << 1+PTRSHIFT)
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
	add	p13, p10, p11, LSR #(48 - (1+PTRSHIFT))
						// p13 = buckets + ((mask & _cmd) << 1+PTRSHIFT)
						// see comment about maskZeroBits
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4
	add	p13, p10, p11, LSL #(1+PTRSHIFT)
						// p13 = buckets + ((mask & _cmd) << 1+PTRSHIFT)
#else
#error Unsupported cache mask storage for ARM64.
#endif
	add	p12, p10, p12, LSL #(1+PTRSHIFT)
						// p12 = first probed bucket

						// do {
4:	ldp	p17, p9, [x13], #-BUCKET_SIZE	//     {imp, sel} = *bucket--
	cmp	p9, p1				//     if (sel == _cmd)
	b.eq	2b				//         goto hit
	cmp	p9, #0				// } while (sel != 0 &&
	ccmp	p13, p12, #0, ne		//     bucket > first_probed)
	b.hi	4b

LLookupEnd\Function:
LLookupRecover\Function:
	b	\MissLabelDynamic

#if CONFIG_USE_PREOPT_CACHES
#if CACHE_MASK_STORAGE != CACHE_MASK_STORAGE_HIGH_16
#error config unsupported
#endif
LLookupPreopt\Function:
#if __has_feature(ptrauth_calls)
	and	p10, p11, #0x007ffffffffffffe	// p10 = buckets
	autdb	x10, x16			// auth as early as possible
#endif

	// x12 = (_cmd - first_shared_cache_sel)
	adrp	x9, _MagicSelRef@PAGE
	ldr	p9, [x9, _MagicSelRef@PAGEOFF]
	sub	p12, p1, p9

	// w9  = ((_cmd - first_shared_cache_sel) >> hash_shift & hash_mask)
#if __has_feature(ptrauth_calls)
	// bits 63..60 of x11 are the number of bits in hash_mask
	// bits 59..55 of x11 is hash_shift

	lsr	x17, x11, #55			// w17 = (hash_shift, ...)
	lsr	w9, w12, w17			// >>= shift

	lsr	x17, x11, #60			// w17 = mask_bits
	mov	x11, #0x7fff
	lsr	x11, x11, x17			// p11 = mask (0x7fff >> mask_bits)
	and	x9, x9, x11			// &= mask
#else
	// bits 63..53 of x11 is hash_mask
	// bits 52..48 of x11 is hash_shift
	lsr	x17, x11, #48			// w17 = (hash_shift, hash_mask)
	lsr	w9, w12, w17			// >>= shift
	and	x9, x9, x11, LSR #53		// &=  mask
#endif

	ldr	x17, [x10, x9, LSL #3]		// x17 == sel_offs | (imp_offs << 32)
	cmp	x12, w17, uxtw

.if \Mode == GETIMP
	b.ne	\MissLabelConstant		// cache miss
	sub	x0, x16, x17, LSR #32		// imp = isa - imp_offs
	SignAsImp x0
	ret
.else
	b.ne	5f				// cache miss
	sub	x17, x16, x17, LSR #32		// imp = isa - imp_offs
.if \Mode == NORMAL
	br	x17
.elseif \Mode == LOOKUP
	orr x16, x16, #3 // for instrumentation, note that we hit a constant cache
	SignAsImp x17
	ret
.else
.abort  unhandled mode \Mode
.endif

5:	ldursw	x9, [x10, #-8]			// offset -8 is the fallback offset
	add	x16, x16, x9			// compute the fallback isa
	b	LLookupStart\Function		// lookup again with a new isa
.endif
#endif // CONFIG_USE_PREOPT_CACHES

.endmacro

这里的汇编代码很多,并且有很多条件判断,几个重要部分我都空了几行,方便查看,接下来还是捡重点解析:

  • 1.ldr p11, [x16, #CACHE],这里源码有注释CACHE 16字节,也就是通过isa内存平移获取cache,然后cache的首地址不就是 (bucket_t * )

  • 2.接下来的操作and p10, p11, #0x0000ffffffffffff以及and p12, p1, p11, LSR #48,说白了就是去掩码的过程,从而获取真正的bucktes

  • 3.add p13, p10, p12, LSL #(1+PTRSHIFT)这一步其实可以看作是源码中前面几步的简写,也就是去除掩码后bucket的内存平移,p13 = buckets + ((mask & _cmd) << 1+PTRSHIFT)就是((mask & _cmd) << 4),相当于buckets平移index*16字节

  • 4.ldp p17, p9, [x13]p17 p9分别取bucketimp,sel,下面的意思就是进行对比p9p1,如果相同就找到了对应的方法,返回对应imp,走CacheHit

  • 5.如果不相同,cbz p9 向前查找下一个bucket,一直循环下去,知道找到对应的方法

  • 6.但是如果查找完了呢,结合旁边的注释goto Miss,也就是说会走MissLabelDynamic,根据前面传入的参数可以知道,他就是函数 _objc_msgSend_uncached

注意:这里有个宏定义CacheHit,我们分别看一下怎么走的

CacheHit 汇编

// CacheHit: x17 = cached IMP, x10 = address of buckets, x1 = SEL, x16 = isa
.macro CacheHit
.if $0 == NORMAL
	TailCallCachedImp x17, x10, x1, x16	// authenticate and call imp
.elseif $0 == GETIMP
	mov	p0, p17
	cbz	p0, 9f			// don't ptrauth a nil imp
	AuthAndResignAsIMP x0, x10, x1, x16	// authenticate imp and re-sign as IMP
9:	ret				// return IMP
.elseif $0 == LOOKUP
	// No nil check for ptrauth: the caller would crash anyway when they
	// jump to a nil IMP. We don't care if that jump also fails ptrauth.
	AuthAndResignAsIMP x17, x10, x1, x16	// authenticate imp and re-sign as IMP
	cmp	x16, x15
	cinc	x16, x16, ne			// x16 += 1 when x15 != x16 (for instrumentation ; fallback to the parent class)
	ret				// return imp via x17
.else
.abort oops
.endif
.endmacro

TailCallCachedImp 汇编

.macro TailCallCachedImp
	// $0 = cached imp, $1 = address of cached imp, $2 = SEL, $3 = isa
// x17 = cached IMP, x10 = address of buckets, x1 = SEL, x16 = isa
// x17 ^ 类 = 编码 imp
// call imp objc_msgSend (sel -> imp) 没有找到 
	eor	$0, $0, $3
	br	$0
.endmacro
  • 1.首先如果**$0==NORMAL**,NORMAL为0,前面有定义,就会执行TailCallCachedImp x17, x10, x1, x16
  • 2.结合TailCallCachedImp 汇编代码对应参数可以看出这是在编码查找imp,并且返回imp的过程,结合汇编代码和注释可以看出,最终是返回了x17 也就是imp

3.快速查找流程梳理

  • 1.判断recevier(消息接受者) 是否存在

  • 2.通过recevier获取isa,通过isa获取class(p16)

  • 3.通过class首地址内存平移获取cache(bucket mask)

  • 4.bucket掩码获取bucket

  • 5.mask掩码获取mask

  • 6.insert 哈希函数 (mask_t)(value & mask)

  • 7.第一次查找通过index

  • 8.bucket + index 在整个缓存里面内存平移获取的第几个bucket

  • 9.通过bucket获取imp,sel(p17,p9)

  • 10.sel比较 sel == _cmd ->cacheHit ->imp^isa = imp(br) 调用imp

  • 11.不相等,通过bucketindex- - 再次平移 直到缓存命中

  • 12.死循环遍历

  • 13.如果完全找不到 MissLabelDynamic
    就是走__objc_msgSend_uncached ,也就是方法的慢速查找

二.慢速查找流程分析

1.__objc_msgSend_uncached分析

__objc_msgSend_uncached前面的汇编代码如下

.endmacro

	STATIC_ENTRY __objc_msgSend_uncached
	UNWIND __objc_msgSend_uncached, FrameWithNoSaves

	// THIS IS NOT A CALLABLE C FUNCTION
	// Out-of-band p15 is the class to search
	
	MethodTableLookup
	TailCallFunctionPointer x17
/**
这是TailCallFunctionPointer内容
.macro TailCallFunctionPointer
	// $0 = function pointer value
	br	$0
.endmacro
**/
	END_ENTRY __objc_msgSend_uncached


	STATIC_ENTRY __objc_msgLookup_uncached
	UNWIND __objc_msgLookup_uncached, FrameWithNoSaves

	// THIS IS NOT A CALLABLE C FUNCTION
	// Out-of-band p15 is the class to search
//可以发现 MethodTableLookup才是重点
	MethodTableLookup
	ret

	END_ENTRY __objc_msgLookup_uncached

可以看出MethodTableLookup是关键点,继续查看

.macro MethodTableLookup
	
	SAVE_REGS MSGSEND

	// lookUpImpOrForward(obj, sel, cls, LOOKUP_INITIALIZE | LOOKUP_RESOLVER)
	// receiver and selector already in x0 and x1
	mov	x2, x16
	mov	x3, #3
	bl	_lookUpImpOrForward

	// IMP in x0
	mov	x17, x0

	RESTORE_REGS MSGSEND

.endmacro

可以看出最终imp存在x0寄存器,x0寄存器是默认返回值的寄存器,所以_lookUpImpOrForward里面应该会有imp查找得结果,但是你会发现_lookUpImpOrForward搜不到对应的汇编代码,原来从这里开始就走回了C函数了哦

IMP lookUpImpOrForward(id inst, SEL sel, Class cls, int behavior)
{
    const IMP forward_imp = (IMP)_objc_msgForward_impcache;
    IMP imp = nil;
    Class curClass;

    runtimeLock.assertUnlocked();

    if (slowpath(!cls->isInitialized())) {
        behavior |= LOOKUP_NOCACHE;
    }
    runtimeLock.lock();
	//判断类是否已经注册,注册后会加入allocatedClasses表中。
    checkIsKnownClass(cls);
	//获取实现类 以及父类 元类
    cls = realizeAndInitializeIfNeeded_locked(inst, cls, behavior & LOOKUP_INITIALIZE);

    runtimeLock.assertLocked();
    curClass = cls;

    for (unsigned attempts = unreasonableClassCount();;) {
        if (curClass->cache.isConstantOptimizedCache(/* strict */true)) {
#if CONFIG_USE_PREOPT_CACHES
            imp = cache_getImp(curClass, sel);
            if (imp) goto done_unlock;
            curClass = curClass->cache.preoptFallbackClass();
#endif
        } else {
            // curClass method list.
            Method meth = getMethodNoSuper_nolock(curClass, sel);
            if (meth) {
                imp = meth->imp(false);
                goto done;
            }

            if (slowpath((curClass = curClass->getSuperclass()) == nil)) {
                // No implementation found, and method resolver didn't help.
                // Use forwarding.
                imp = forward_imp;
                break;
            }
        }

        // Halt if there is a cycle in the superclass chain.
        if (slowpath(--attempts == 0)) {
            _objc_fatal("Memory corruption in class list.");
        }

        // Superclass cache.
        // 父类 快速 -> 慢速 ->
        imp = cache_getImp(curClass, sel);
        if (slowpath(imp == forward_imp)) {
            // Found a forward:: entry in a superclass.
            // Stop searching, but don't cache yet; call method
            // resolver for this class first.
            break;
        }
        if (fastpath(imp)) {
            // Found the method in a superclass. Cache it in this class.
            goto done;
        }
    }
    if (slowpath(behavior & LOOKUP_RESOLVER)) {
        behavior ^= LOOKUP_RESOLVER;
        //动态方法决议
        return resolveMethod_locked(inst, sel, cls, behavior);
    }

 done:
    if (fastpath((behavior & LOOKUP_NOCACHE) == 0)) {
#if CONFIG_USE_PREOPT_CACHES
        while (cls->cache.isConstantOptimizedCache(/* strict */true)) {
            cls = cls->cache.preoptFallbackClass();
        }
#endif
        log_and_fill_cache(cls, imp, sel, inst, curClass);
    }
 done_unlock:
 //解锁
    runtimeLock.unlock();
    if (slowpath((behavior & LOOKUP_NIL) && imp == forward_imp)) {
        return nil;
    }
    return imp;
}

其实在前面的篇章cache结构分析讲解中有提到过这个流程,但是没有具体分析,后续会详细补充,下面我们来逐步分析总结上面的流程

2.慢速查找流程梳理

  • 1.checkIsKnownClass,判断类是否已经注册,注册后会存入allocatedClasses

  • 2.realizeAndInitializeIfNeeded_locked,初始化实现类,以及父类和元类

  • 3.curClass->cache.isConstantOptimizedCache,查找共享缓存是否存在imp,有就直接返回跳转done_unlock(一般不会走步骤2,因为毕竟之前已经快速查找过)

  • 4.getMethodNoSuper_nolock(curClass, sel)在当前类中查找imp,如果有跳转done

  • 5.如果没有curClass = curClass->getSuperclass()获取父类,并判断是否存在,如果不存在直接返回forward_imp

  • 6.imp = cache_getImp(curClass,sel)快速查找父类方法,如果返回的是查找结果是forward_imp,也就是没找到,直接跳出循环,如果找到了,跳转done

  • 7.判断slowpath(behavior & LOOKUP_RESOLVER,执行resolveMethod_locked,按照注释的意思是找不到实现,请尝试一次方法解析程序。也就是后续会提到的动态方法决议

  • 8.done里面执行了函数log_and_fill_cache,而该函数里面执行的是cls->cache.insert(sel, imp, receiver),方法的插入!!!也就是说找到了正确的imp后,就会返回imp,并且插入缓存cache中,以便下次快速查找

cache_getImp汇编

	STATIC_ENTRY _cache_getImp

	GetClassFromIsa_p16 p0, 0
	CacheLookup GETIMP, _cache_getImp, LGetImpMissDynamic, LGetImpMissConstant

LGetImpMissDynamic:
	mov	p0, #0
	ret

LGetImpMissConstant:
	mov	p0, p2
	ret

	END_ENTRY _cache_getImp

相信大家不陌生,这就是前面讲到的快速查找流程的汇编。

注意: 结合前面第6步骤拆开解析: curClass是当前类的父类,而CacheLookup也就是前面说的通过一系列地址偏移操作查找imp得过程,找到了impCacheHit,没找到就是走MissLabelDynamic,而此时的MissLabelDynamic对应的参数是cache_getImp里面的LGetImpMissDynamic,这结合汇编代码来看就是返回nil,也就意味着前面的第7,8步骤就不会走了,而是重新开始循环再往父类查找,直到没有父类也就是走到了根类(如:NSobject)

关于 behavior 说明(借鉴某大佬😄)

/* method lookup */
enum {
    LOOKUP_INITIALIZE = 1,//控制是否去进行类的初始化。有值初始化,没有不初始化
    LOOKUP_RESOLVER = 2,//是否进行动态方法决议。有值决议,没有值不决议
    LOOKUP_NIL = 4,//是否进行forward。有值不进行,没有值进行
    LOOKUP_NOCACHE = 8,//是否插入缓存。有值不插入缓存,没有值插入
};

3.二分查找算法

前面我们提到了一个函数getMethodNoSuper_nolock是查找imp得关键,我们一步步进去查看

getMethodNoSuper_nolock(Class cls, SEL sel)
{
    runtimeLock.assertLocked();

    ASSERT(cls->isRealized());
    // fixme nil cls? 
    // fixme nil sel?

    auto const methods = cls->data()->methods();
    for (auto mlists = methods.beginLists(),
              end = methods.endLists();
         mlists != end;
         ++mlists)
    {
        // <rdar://problem/46904873> getMethodNoSuper_nolock is the hottest
        // caller of search_method_list, inlining it turns
        // getMethodNoSuper_nolock into a frame-less function and eliminates
        // any store from this codepath.
        method_t *m = search_method_list_inline(*mlists, sel);
        if (m) return m;
    }

    return nil;
}

进入search_method_list_inline

search_method_list_inline(const method_list_t *mlist, SEL sel)
{
	//修复method_list
    int methodListIsFixedUp = mlist->isFixedUp();
    //判断method_list的有序性
    int methodListHasExpectedSize = mlist->isExpectedSize();
    
    if (fastpath(methodListIsFixedUp && methodListHasExpectedSize)) {
    	//有序且method_list修复好 进行二分查找
        return findMethodInSortedMethodList(sel, mlist);
    } else {
        // Linear search of unsorted method list
        //未排序方法列表的线性搜索  无序列表
        if (auto *m = findMethodInUnsortedMethodList(sel, mlist))
            return m;
    }

#if DEBUG
    // sanity-check negative results
    if (mlist->isFixedUp()) {
        for (auto& meth : *mlist) {
            if (meth.name() == sel) {
                _objc_fatal("linear search worked when binary search did not");
            }
        }
    }
#endif

    return nil;
}

那么通过我们不断进去查看一下,就找到了下面的算法核心,也就是二分查找算法,如下

findMethodInSortedMethodList(SEL key, const method_list_t *list, const getNameFunc &getName)
{
    ASSERT(list);

    auto first = list->begin();
    auto base = first;
    decltype(first) probe;

    uintptr_t keyValue = (uintptr_t)key;
    uint32_t count;
    // 1000 - 0100
    // 8 - 1 = 7 >> 0111 -> 0011 3 >> 1 == 1
    // 0 + 4 = 4
    // 5 - 8
    // 6-7
    for (count = list->count; count != 0; count >>= 1) {
        probe = base + (count >> 1);
        
        uintptr_t probeValue = (uintptr_t)getName(probe);
        
        if (keyValue == probeValue) {
            // `probe` is a match.
            // Rewind looking for the *first* occurrence of this value.
            // This is required for correct category overrides.
            while (probe > first && keyValue == (uintptr_t)getName((probe - 1))) {
                probe--;
            }
            return &*probe;
        }
        
        if (keyValue > probeValue) {
            base = probe + 1;
            count--;
        }
    }
    
    return nil;
}

4.整理算法流程

  • 1.count方法数量,base相当于一个基数初始0,用于中间运算的,keyValue就是把sel强转类型便于作比较,probeValue用于当前要对比的keyValue,通过probe转换

  • 2.举例假设count=8,而最终的sel是第6个,循环条件count非零就执行,每次循环count >>= 1也就是count右移1位

  • 3.取第一个对比的probe 进行base + (count >> 1)也就是4

  • 4.因为keyValue > probeValue,6>4成立,base5count7

  • 5.count执行条件右移一位结果为3probebase + (count >> 1)也就是5+3>>1,为6

  • 6.因为keyValue == probeValue成立,返回 (&*probe),也就是method_t

可以看出整体走下来,只是走了2次循环判断,大大减少了循环次数,当然大家也可以自己举例尝试

注意:在找到正确keyValue时,里面有个循环判断,通过翻译可以看出,是为了判断分类是否存在该sel,如果有返回分类的sel

总结:本篇大致分析了快速方法查找和慢速查找得流程,至于在上一篇章中说到为什么cache查找要使用汇编?主要是下面几点:

  • 1.汇编更接近机器语言,执行速度快。为了快速找到方法,优化方法查找时间。
  • 2.消息发送参数是未知参数(比如可变参数),c参数必须明确,汇编相对能够更加动态化。
  • 3.更安全,寄存器储存,稳的一匹。

当然里面也还有一些疑问,后续会一一补充,同时也提到了如果两种方法都没有找到对应的方法,会怎么做呢?请看下一篇章动态方法决议

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值