子程序调用编程序例子_iOS 如何抓取线程的“方法调用栈”?

场景: 在一些 “性能监控” 的工具中,在检测到App主线程卡顿的时候,可以通过子线程抓取当前时刻所有线程的方法调用堆栈(保存卡顿现场),并在合适的时机(WiFi环境&网络环境较好的时候)把堆栈信息上传到我们的服务端。服务端将堆栈信息过滤分析后,交给客户端做优化处理。 这样,就能较好的提高用户的体验,并及时发现线上环境下的问题。 同时,也可以及时发现问题,及时优化我们的代码质量和执行效率。 (一个比较好的开发循环)

61de87b1d31a3b73547edd176ddda9ef.png


那么,在App发生卡顿时候,我们该如何抓取方法调用栈呢?堆栈信息又是什么样的呢?
本文将通过一个具体的 demo ,阐述如何进行抓栈操作。
在此之前,首先要感谢我偶像@bestswifter的博客:《获取任意线程调用栈的那些事》,对我有很大的启发与帮助。
接下来,进入我们今天的正题:

  1. 什么是调用栈?
  2. 如何抓取线程当前的调用栈?
  3. 如何符号化解析?
  4. 一些特殊的调用栈
  5. (补充)如何检测App卡顿?

一、什么是调用栈?调用栈(call stack):是计算机科学中存储有关正在运行的子程序的消息的栈。—— 维基百科
在我们程序运行中,通常存在一个函数调用另一个函数的情况。
例如,在某个线程中,调用了 func A。在 func A 执行过程中,调用了 func B
那么,在计算机程序底层需要做哪些事呢?

  1. 转移控制 :暂停 func A ,并开始执行 func B,并在 func B执行完后,再回到 func A 继续执行。
  2. 转移数据func A 要能把参数传递给 func B,并且 func B如果有返回值的话,要把返回值还给 func A
  3. 分配和释放内存 :在 func B 开始执行时,给需要用到局部变量分配内存。在 func B 执行完后,释放这部分内存。

举个例子, 我声明了两个函数:foobar。 同时,在函数foo中调用了函数bar

- (void)foo { [self bar]; } - (void)bar { NSLog(@"QiShare"); }


在模拟器(x86)下,会转换成如下汇编:

QiStackFrameLogger`-[ViewController foo]:
    0x105a1f0d0 <+0>:  pushq  %rbp
    0x105a1f0d1 <+1>:  movq   %rsp, %rbp
    0x105a1f0d4 <+4>:  subq   $0x10, %rsp
    0x105a1f0d8 <+8>:  movq   %rdi, -0x8(%rbp)
    0x105a1f0dc <+12>: movq   %rsi, -0x10(%rbp)
    0x105a1f0e0 <+16>: movq   -0x8(%rbp), %rax
    0x105a1f0e4 <+20>: movq   0x64a5(%rip), %rsi        ; "bar"
    0x105a1f0eb <+27>: movq   %rax, %rdi
    0x105a1f0ee <+30>: callq  *0x3f1c(%rip)             ; (void *)0x00007fff50ad3400: objc_msgSend
->  0x105a1f0f4 <+36>: addq   $0x10, %rsp
    0x105a1f0f8 <+40>: popq   %rbp
    0x105a1f0f9 <+41>: retq   
QiStackFrameLogger`-[ViewController bar]:
    0x105a1f100 <+0>:  pushq  %rbp
    0x105a1f101 <+1>:  movq   %rsp, %rbp
    0x105a1f104 <+4>:  subq   $0x10, %rsp
    0x105a1f108 <+8>:  leaq   0x3f61(%rip), %rax        ; @"QiShare"
    0x105a1f10f <+15>: movq   %rdi, -0x8(%rbp)
    0x105a1f113 <+19>: movq   %rsi, -0x10(%rbp)
->  0x105a1f117 <+23>: movq   %rax, %rdi
    0x105a1f11a <+26>: movb   $0x0, %al
    0x105a1f11c <+28>: callq  0x105a20cd4               ; symbol stub for: NSLog
    0x105a1f121 <+33>: jmp    0x105a1f121               ; <+33> at ViewController.m:24:5

在我的真机(arm64)下,会转换成如下汇编:

QiStackFrameLogger`-[ViewController foo]:
    0x10443833c <+0>:  sub    sp, sp, #0x20             ; =0x20 
    0x104438340 <+4>:  stp    x29, x30, [sp, #0x10]
    0x104438344 <+8>:  add    x29, sp, #0x10            ; =0x10 
    0x104438348 <+12>: adrp   x8, 9
    0x10443834c <+16>: add    x8, x8, #0x5a8            ; =0x5a8 
    0x104438350 <+20>: str    x0, [sp, #0x8]
    0x104438354 <+24>: str    x1, [sp]
    0x104438358 <+28>: ldr    x9, [sp, #0x8]
    0x10443835c <+32>: ldr    x1, [x8]
    0x104438360 <+36>: mov    x0, x9
    0x104438364 <+40>: bl     0x10443a0ac               ; symbol stub for: objc_msgSend
->  0x104438368 <+44>: ldp    x29, x30, [sp, #0x10]
    0x10443836c <+48>: add    sp, sp, #0x20             ; =0x20 
    0x104438370 <+52>: ret    
QiStackFrameLogger`-[ViewController bar]:
    0x104438374 <+0>:  sub    sp, sp, #0x20             ; =0x20 
    0x104438378 <+4>:  stp    x29, x30, [sp, #0x10]
    0x10443837c <+8>:  add    x29, sp, #0x10            ; =0x10 
    0x104438380 <+12>: str    x0, [sp, #0x8]
    0x104438384 <+16>: str    x1, [sp]
->  0x104438388 <+20>: adrp   x0, 4
    0x10443838c <+24>: add    x0, x0, #0x58             ; =0x58 
    0x104438390 <+28>: bl     0x104439fe0               ; symbol stub for: NSLog
    0x104438394 <+32>: b      0x104438394               ; <+32> at ViewController.m:24:5


再转换成更直观的图解,就变成了这样:

2788d0215e549d1c6a153a19c4dd4fe7.png


目前,绝大部分iOS设备都是基于arm64架构的(iPhone 5s及之后发布的所有设备)。
通过查询 arm的官方文档 ,我们可以得知:

fe4929dc3ee9f3370c4e5dad8077678c.png

二、如何抓取线程当前的调用栈?
刚才,我们已经知道了通过fp就能找到上一级函数的地址。
通过不停的找上一级fp就能找到当前所有方法调用栈的地址。(回溯法)

Talk is easy, show me code.
  • 第一步:
    首先,我们声明一个结构体,用来存储链式的栈指针信息。(sp+fp
// 栈帧结构体:
typedef struct QiStackFrameEntry {
    const struct QiStackFrameEntry *const previouts; //!< 上一个栈帧
    const uintptr_t return_address;                  //!< 当前栈帧的地址
} QiStackFrameEntry;


没错,是个链表。

  • 第二步:
    取出 thread 里的 machine context
_STRUCT_MCONTEXT machineContext; // 先声明一个context,再从thread中取出context
if(![self qi_fillThreadStateFrom:thread intoMachineContext:&machineContext]) {
    return [NSString stringWithFormat:@"Fail to get machineContext from thread: %un", thread];
}

具体实现:

/*!
 @brief 将machineContext从thread中提取出来
 @param thread 当前线程
 @param machineContext 所要赋值的machineContext
 @return 是否获取成功
 */
+ (BOOL) qi_fillThreadStateFrom:(thread_t) thread intoMachineContext:(_STRUCT_MCONTEXT *)machineContext {
    mach_msg_type_number_t state_count = Qi_THREAD_STATE_COUNT;
    kern_return_t kr = thread_get_state(thread, Qi_THREAD_STATE, (thread_state_t)&machineContext->__ss, &state_count);
    return kr == KERN_SUCCESS;
}
  • 第三步:
    获取machineContext里,在栈帧的指针地址。
    再通过fp的回溯,将所有的方法地址保存在backtraceBuffer数组中。
    直到找到最底层,没有上一级地址就break
uintptr_t backtraceBuffer[50];
int i = 0;
NSMutableString *resultString = [[NSMutableString alloc] initWithFormat:@"Backtrace of Thread %u:n", thread];

const uintptr_t instructionAddress = qi_mach_instructionAddress(&machineContext);
backtraceBuffer[i++] = instructionAddress;

uintptr_t linkRegister = qi_mach_linkRegister(&machineContext);
if (linkRegister) {
    backtraceBuffer[i++] = linkRegister;
}

if (instructionAddress == 0) {
    return @"Fail to get instructionAddress.";
}

QiStackFrameEntry frame = {0};
const uintptr_t framePointer = qi_mach_framePointer(&machineContext);
if (framePointer == 0 || qi_mach_copyMem((void *)framePointer, &frame, sizeof(frame)) != KERN_SUCCESS) {
    return @"Fail to get frame pointer";
}

// 对frame进行赋值
for (; i<50; i++) {
    backtraceBuffer[i] = frame.return_address; // 把当前的地址保存
    if (backtraceBuffer[i] == 0 || frame.previouts == 0 || qi_mach_copyMem(frame.previouts, &frame, sizeof(frame)) != KERN_SUCCESS) {
        break; // 找到原始帧,就break
    }
}


这样,backtraceBuffer这个数组中,就存了当前时刻线程的方法调用地址(fp的集合)
backtraceBuffer这个数组,目前只是一堆方法的地址。
我们并不知道它具体指的是哪个方法?
那就需要接下来的 “符号化解析” 操作。
将每个地址与对应符号名(函数/方法名)一一对应上。


三、如何符号化解析?
我们通过回溯帧指针(fp),就能拿到线程下的所有函数调用地址。
我们怎么把地址与对应的符号(函数/方法名)对应上呢?
这就需要符号化解析步骤。
符号化解析:“地址” => “符号”

  • 预备:
    这次不用我们自己声明了,系统帮我们准备好了结构体dl_info
    专门用来存储当前的符号信息。

e8439ba7c271e22a1a209967923c066a.png
  • 第一步:
    根据backtraceBuffer数组的大小,声明一个同样大小的dl_info[]数组来存符号信息。

ba5bcb05adaadaed860d803a4b71d5b2.png
  • 第二步:
    通过address找到符号所在的image
    下面的方法,可以拿到对应imageindex(编号)。
 // 找出address所对应的image编号
uint32_t qi_getImageIndexContainingAddress(const uintptr_t address) {
    const uint32_t imageCount = _dyld_image_count(); // dyld中image的个数
    const struct mach_header *header = 0;
    
    for (uint32_t i = 0; i < imageCount; i++) {
        header = _dyld_get_image_header(i);
        if (header != NULL) {
            // 在提供的address范围内,寻找segment command
            uintptr_t addressWSlide = address - (uintptr_t)_dyld_get_image_vmaddr_slide(i); //!< ASLR
            uintptr_t cmdPointer = qi_firstCmdAfterHeader(header);
            if (cmdPointer == 0) {
                continue;
            }
            for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
                const struct load_command *loadCmd = (struct load_command*)cmdPointer;
                if (loadCmd->cmd == LC_SEGMENT) {
                    const struct segment_command *segCmd = (struct segment_command*)cmdPointer;
                    if (addressWSlide >= segCmd->vmaddr && addressWSlide < segCmd->vmaddr + segCmd->vmsize) {
                        // 命中!
                        return i;
                    }
                }
                else if (loadCmd->cmd == LC_SEGMENT_64) {
                    const struct segment_command_64 *segCmd = (struct segment_command_64*)cmdPointer;
                    if (addressWSlide >= segCmd->vmaddr && addressWSlide < segCmd->vmaddr + segCmd->vmsize) {
                        // 命中!
                        return i;
                    }
                }
                cmdPointer += loadCmd->cmdsize;
            }
        }
    }
    
    return UINT_MAX; // 没找到就返回UINT_MAX
}
  • 第三步:
    我们拿到了address所对应的imageindex
    我们就可以通过一些系统方法与计算,得到header、虚拟内存地址、ASLR偏移量(安全性考虑,为了防黑客入侵。iOS 5Android 4后引入)。
    以及,比较关键的segmentBase(通过 baseAddress + ASLR 得到)。
const struct mach_header *header = _dyld_get_image_header(index); // 根据index找到header
const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(index); //image虚拟内存地址
const uintptr_t addressWithSlide = address - imageVMAddrSlide; // ASLR偏移量
const uintptr_t segmentBase = qi_getSegmentBaseAddressOfImageIndex(index) + imageVMAddrSlide; // segmentBase是根据index + ASLR得到的
if (segmentBase == 0) {
    return false;
}

info->dli_fname = _dyld_get_image_name(index);
info->dli_fbase = (void *)header;
  • 第四步:
    通过查找符号表,找到对应的符号,并赋值给dl_info数组。
 // 查找符号表,找到对应的符号
const Qi_NLIST* bestMatch = NULL;
uintptr_t bestDistace = ULONG_MAX;
uintptr_t cmdPointer = qi_firstCmdAfterHeader(header);
if (cmdPointer == 0) {
    return false;
}
for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
    const struct load_command* loadCmd = (struct load_command*)cmdPointer;
    if (loadCmd->cmd == LC_SYMTAB) {
        const struct symtab_command *symtabCmd = (struct symtab_command*)cmdPointer;
        const Qi_NLIST* symbolTable = (Qi_NLIST*)(segmentBase + symtabCmd->symoff);
        const uintptr_t stringTable = segmentBase + symtabCmd->stroff;
        
        /*
         *
         struct symtab_command {
             uint32_t    cmd;        / LC_SYMTAB /
             uint32_t    cmdsize;    / sizeof(struct symtab_command) /
             uint32_t    symoff;     / symbol table offset 符号表偏移 /
             uint32_t    nsyms;      / number of symbol table entries 符号表条目的数量 /
             uint32_t    stroff;     / string table offset 字符串表偏移 /
             uint32_t    strsize;    / string table size in bytes 字符串表的大小(以字节为单位) /
         };
         */
        
        for (uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {
            // 如果n_value为0,则该符号引用一个外部对象。
            if (symbolTable[iSym].n_value != 0) {
                uintptr_t symbolBase = symbolTable[iSym].n_value;
                uintptr_t currentDistance = addressWithSlide - symbolBase;
                if ((addressWithSlide >= symbolBase) && (currentDistance <= bestDistace)) {
                    bestMatch = symbolTable + iSym;
                    bestDistace = currentDistance;
                }
            }
        }
        if (bestMatch != NULL) {
            info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddrSlide);
            info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx);
            if (*info->dli_sname == '_') {
                info->dli_sname++;
            }
            //如果所有的符号都被删除,就会发生这种情况。
            if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) {
                info->dli_sname = NULL;
            }
            break;
        }
    }
    cmdPointer += loadCmd->cmdsize;
}
  • 第五步:
    遍历backtraceBuffer数组,并把符号信息赋值dl_info数组。
 // 符号化:将backtraceBuffer(地址数组)转成symbolsBuffer(符号数组)。
void qi_symbolicate(const uintptr_t* const backtraceBuffer,
                    Dl_info* const symbolsBuffer,
                    const int numEntries,
                    const int skippedEntries) {
    int i = 0;
    
    if(!skippedEntries && i < numEntries) {
        qi_dladdr(backtraceBuffer[i], &symbolsBuffer[i]);
        i++;
    }
    
    for (; i < numEntries; i++) {
        qi_dladdr(CALL_INSTRUCTION_FROM_RETURN_ADDRESS(backtraceBuffer[i]), &symbolsBuffer[i]); //!< 通过回溯得到的栈帧,找到对应的符号名。
    }
}
  • 小结:
    符号化解析,完整代码如下:
 #pragma mark - Symbolicate

// 符号化:将backtraceBuffer(地址数组)转成symbolsBuffer(符号数组)。
void qi_symbolicate(const uintptr_t* const backtraceBuffer,
                    Dl_info* const symbolsBuffer,
                    const int numEntries,
                    const int skippedEntries) {
    int i = 0;
    
    if(!skippedEntries && i < numEntries) {
        qi_dladdr(backtraceBuffer[i], &symbolsBuffer[i]);
        i++;
    }
    
    for (; i < numEntries; i++) {
        qi_dladdr(CALL_INSTRUCTION_FROM_RETURN_ADDRESS(backtraceBuffer[i]), &symbolsBuffer[i]); //!< 通过回溯得到的栈帧,找到对应的符号名。
    }
}

// 通过address得到当前函数info信息,包括:dli_fname、dli_fbase、dli_saddr、dli_sname.
bool qi_dladdr(const uintptr_t address, Dl_info* const info) {
    info->dli_fname = NULL;
    info->dli_fbase = NULL;
    info->dli_saddr = NULL;
    info->dli_sname = NULL;
    
    const uint32_t index = qi_getImageIndexContainingAddress(address); // 根据地址找到image中的index。
    if (index == UINT_MAX) {
        return false; // 没找到就返回UINT_MAX
    }
    
    /*
     Header
     ------------------
     Load commands
     Segment command 1 -------------|
     Segment command 2              |
     ------------------             |
     Data                           |
     Section 1 data |segment 1 <----|
     Section 2 data |          <----|
     Section 3 data |          <----|
     Section 4 data |segment 2
     Section 5 data |
     ...            |
     Section n data |
     */
    /*----------Mach Header---------*/
    const struct mach_header *header = _dyld_get_image_header(index); // 根据index找到header
    const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(index); //image虚拟内存地址
    const uintptr_t addressWithSlide = address - imageVMAddrSlide; // ASLR偏移量
    const uintptr_t segmentBase = qi_getSegmentBaseAddressOfImageIndex(index) + imageVMAddrSlide; // segmentBase是根据index + ASLR得到的
    if (segmentBase == 0) {
        return false;
    }
    
    info->dli_fname = _dyld_get_image_name(index);
    info->dli_fbase = (void *)header;
    
    // 查找符号表,找到对应的符号
    const Qi_NLIST* bestMatch = NULL;
    uintptr_t bestDistace = ULONG_MAX;
    uintptr_t cmdPointer = qi_firstCmdAfterHeader(header);
    if (cmdPointer == 0) {
        return false;
    }
    for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
        const struct load_command* loadCmd = (struct load_command*)cmdPointer;
        if (loadCmd->cmd == LC_SYMTAB) {
            const struct symtab_command *symtabCmd = (struct symtab_command*)cmdPointer;
            const Qi_NLIST* symbolTable = (Qi_NLIST*)(segmentBase + symtabCmd->symoff);
            const uintptr_t stringTable = segmentBase + symtabCmd->stroff;
            
            /*
             *
             struct symtab_command {
                 uint32_t    cmd;        / LC_SYMTAB /
                 uint32_t    cmdsize;    / sizeof(struct symtab_command) /
                 uint32_t    symoff;     / symbol table offset 符号表偏移 /
                 uint32_t    nsyms;      / number of symbol table entries 符号表条目的数量 /
                 uint32_t    stroff;     / string table offset 字符串表偏移 /
                 uint32_t    strsize;    / string table size in bytes 字符串表的大小(以字节为单位) /
             };
             */
            
            for (uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {
                // 如果n_value为0,则该符号引用一个外部对象。
                if (symbolTable[iSym].n_value != 0) {
                    uintptr_t symbolBase = symbolTable[iSym].n_value;
                    uintptr_t currentDistance = addressWithSlide - symbolBase;
                    if ((addressWithSlide >= symbolBase) && (currentDistance <= bestDistace)) {
                        bestMatch = symbolTable + iSym;
                        bestDistace = currentDistance;
                    }
                }
            }
            if (bestMatch != NULL) {
                info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddrSlide);
                info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx);
                if (*info->dli_sname == '_') {
                    info->dli_sname++;
                }
                //如果所有的符号都被删除,就会发生这种情况。
                if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) {
                    info->dli_sname = NULL;
                }
                break;
            }
        }
        cmdPointer += loadCmd->cmdsize;
    }
    return true;
}

四、一些特殊的调用栈
看似,我们的抓取方案和抓栈策略都无懈可击。
但在release环境中,由于编译器帮我们做了优化,有一些特殊的调用栈是抓不到的。
1. 尾调用
尾调用优化的本质,是 “栈帧” 的复用。
因此,每次压栈都会复用原来的栈帧。
这时候,我们抓到的堆栈永远只有最下层的栈,而中间的调用栈全都丢失了。PS:关于尾调用优化,我之前实习的时候写了一篇博客。可供参考:《iOS objc_msgSend尾调用优化详解》
2. 函数内联
这个也比较好理解,因为内联函数会在编译时期展开。
直接复制代码块,从而节省了调用函数带来的额外时间开支。
并且,有的编译器会自动帮我们把一些逻辑简单的函数优化为内联函数。
因此,被编译器优化成内联函数的函数,我们也是没有办法抓到调用栈的。

补:关于如何检测App卡顿?
可参考我之前写的博客:《iOS 性能监控(二)—— 主线程卡顿监控》。
我们能感知到的App卡顿,是由于主线程出现卡顿,造成UI更新不及时,从而发生丢帧等情况。(正常情况下,iPhone的屏幕都是60fps,即一秒刷新60次。)
那么,目前比较好的监控方案就是利用runloop原理去监控App状态,
方案如下:

  • 第一步:开启一个子线程,并打开子线程的runloop,让该子线程常驻在App中。
  • 第二步:创建一个RunloopObserverRunloop观察者),将RunloopObserver添加到主线程runloopcommonModes下观察。同时,子线程的runloop开始监听。
  • 第三步:每当主线程runloop的状态发生变化时,就会通知该RunloopObserver。并通过发GCD信号量保证同步操作。同时,子线程的runloop持续监听。
  • 第四步:当主线程的runloop的状态长时间卡在BeforeSourcesAfterWaiting时,就代表当前主线程卡顿。
  • 第五步:检测到卡顿,抓栈,保留现场。 同时,将调用栈信息保存在本地,在合适的时机上报服务端。

500a20c3716ed798cf17477ed5037a29.png

0c808c6ac273c8c0e15c44bdc45e43b5.png

Q1:为什么是主线程的 CommonModes
主线程的runloop有DefaultModeUITrackingModeUIInitializationModeGSEventReceiveModeCommonModes
其中,CommonModesDefaultModeUITrackingMode的集合。
正常情况,也是在这两个mode下切换。Q2:为什么是BeforeSourcesAfterWaiting这两个状态?
这就要说到runloop的执行顺序,BeforeSources之后,主要是处理Source0事件(响应UIEvent)。如果卡在这个状态过久,说明当前App无法响应点击事件。AfterWaiting之后,说明当前线程刚从休眠中唤醒,准备执行timer事件。但又卡在这个状态,没有去执行。也能说明当前App卡顿。
这里,感谢“松的冬天”在评论区的留言与解答:
runloop的执行流程,因为真正做事情的通知就是这两个其他的通知后边都是紧跟着别的通知BeforeSources,会阻塞的并不一定是通知后紧跟着的那一件事,比如结束休眠后紧跟着的是处理timer,接下来的处理GCD Async To Main Queue,接下来是处理Source1。其真正的原因是各种要处理的事情阻止了runloop进入休眠,如果不休眠就会卡顿。PS:更详细监控方案过程,可查看我之前写的博客。可供参考:《iOS 性能监控(二)—— 主线程卡顿监控》。
源码:
GitHub地址:QiStackFrameLogger

参考与致谢:
1.《获取任意线程调用栈的那些事》—— bestswifter
2.《iOS开发高手课》—— 戴铭老师
3.《调用栈》—— 维基百科
4.《Call Stack(调用栈)是什么?》—— 知乎
5.《Virtual Memory(虚拟内存)是什么?》
6.《arm64官方文档》

推荐 :

如果你想一起进阶,不妨添加一下交流群1012951431

面试题资料或者相关学习资料都在群文件中 进群即可下载!

470cea7c5ae98683b0eb0f7ffa616afe.png
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值