Android平台Native代码的崩溃捕获机制及实现

6 篇文章 0 订阅

本文地址:http://blog.csdn.net/mba16c35/article/details/54178067

思路主要来源于这篇文章:http://blog.httrack.com/blog/2013/08/23/catching-posix-signals-on-android/

这篇文章的实现在这个地址代码但是还要对5.0以上做一些适配。

比较出名的Google Breakpad也提供了跨平台捕获native崩溃信息的功能,但是这个库太大太复杂了。而coffeecatch这个库我编译出来才22k,代码量也少,改动起来也很容易。

一、信号机制

我们知道,函数运行在用户态,当遇到系统调用、中断或是异常的情况时,程序会进入内核态。信号涉及到了这两种状态之间的转换,过程可以先看一下下面的示意图:

1.信号的接收

接收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中,同时向进程发送一个中断,使其陷入内核态。

注意,此时信号还只是在队列中,对进程来说暂时是不知道有信号到来的。

2.信号的检测

进程陷入内核态后,有两种场景会对信号进行检测:

  • 进程从内核态返回到用户态前进行信号检测
  • 进程在内核态中,从睡眠状态被唤醒的时候进行信号检测

当发现有新信号时,便会进入下一步,信号的处理。

3.信号的处理

信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,并且修改指令寄存器(eip)将其指向信号处理函数。

接下来进程返回到用户态中,执行相应的信号处理函数。

信号处理函数执行完成后,还需要返回内核态,检查是否还有其它信号未处理。如果所有信号都处理完成,就会将内核栈恢复(从用户栈的备份拷贝回来),同时恢复指令寄存器(eip)将其指向中断前的运行位置,最后回到用户态继续执行进程。

至此,一个完整的信号处理流程便结束了,如果同时有多个信号到达,上面的处理流程会在第2步和第3步骤间重复进行。

所以第一步就是要用信号处理函数捕获到native crash(SIGSEGV, SIGBUS等)。在posix系统,可以用sigaction():

struct sigaction sa;
struct sigaction sa_old;
memset(&sa, 0, sizeof(sa));
sigemptyset(&sa.sa_mask);
sa.sa_sigaction = my_handler;
sa.sa_flags = SA_SIGINFO;
if (sigaction(sig, &sa, &sa_old) == 0) {
  ...
}


二、如何处理堆栈溢出

一个错误来源是堆栈溢出。当栈满了(太多次递归,栈上太多对象),系统会在同一个已经满了的栈上调用SIGSEGV的信号处理函数,又再一次引起同样的信号。幸运的是,你可以使用sigaltstack在任意线程注册一个可选的栈,保留一下在紧急情况下使用的空间。(系统会在危险情况下把栈指针指向这个地方,使你得以在一个新的栈上运行信号处理函数)

stack_t stack;
memset(&stack, 0, sizeof(stack));
/* Reserver the system default stack size. We don't need that much by the way. */
stack.ss_size = SIGSTKSZ;
stack.ss_sp = malloc(stack.ss_size);
stack.ss_flags = 0;
/* Install alternate stack size. Be sure the memory region is valid until you revert it. */
if (stack.ss_sp != NULL && sigaltstack(&stack, NULL) == 0) {
  ...
}

三、兼容旧的信号处理函数

我们在java虚拟机上运行,某些信号可能在之前已经被安装过信号处理函数。例如,SIGSEGV经常用于处理NullPointerException

或者作为普通的JIT处理(可执行的物理页标记了“NO ACCESS”的保护,对任何一处非法操作都会唤起JIT编译器的信号处理函数)。所以,你必须先调用旧的信号处理函数,以防把上下文环境搞乱。旧的信号处理函数要么不进行处理直接返回,要么调用abort()(这样我们就有最后一次机会通过SIGABRT的信号处理函数处理这个信号,所以在捕获SIGABRT的信号处理函数里边,我们是最后才调用旧的信号处理函数)

static void my_handler(const int code, siginfo_t *const si, void *const sc) {
  /* Call previous handler. */
  old_handler.sa_sigaction(code, si, sc);
...
}

四、多线程环境

我们运行在一个多线程进程环境,我们不想捕获不属于我们的其他线程的crash。可以用pthread_getspecific得到一个线程相关的上下文来解决这个问题。不过,pthread_getspecific不是异步信号安全的函数,如果在信号处理函数中使用,可能有不可预知的问题。

static void my_handler(const int code, siginfo_t *const si, void *const sc) {
  /* Call previous handler. */
  old_handler.sa_sigaction(code, si, sc);

  /* Get thread-specific context. */
  my_struct *s = (my_struct*) pthread_getspecific(my_thread_var);
  if (s != NULL) {
    ...
  } 
...
}

五、打印native堆栈

1. 相对地址

我们要收集crash的基本信息,特别是出错的地址。

sigaction回调函数的第三个参数是一个指向ucontext_t的指针,ucontext_t收集了寄存器数值(还有各种处理器特定的信息)。在x86-64架构,pc值是存在uc_mcontext.gregs[REG_RIP];在arm架构,则是uc_mcontext.arm_pc。不过在Android上,ucontext_t结构体没有在任何系统头文件定义,所以要自己去引入一份定义。另外还要找到当前pc在哪个二进制文件上运行,并且找到该文件加载在内存的起始地址,因为一个随机的运行地址对调试是没有用的。linux系统下的dladdr()函数可以获得这个信息(即最近这个地址的符号,以及可以用于计算相对偏移地址的模块的起始地址)。

(另外,你也可以通过读取linux上的/proc/self/maps,检查各个模块加载在内存的地址范围,也可以获得起始地址)

Dl_info info;
if (dladdr(addr, &info) != 0 && info.dli_fname != NULL) {
  void * const nearest = info.dli_saddr;
  const uintptr_t addr_relative =
    ((uintptr_t) addr - (uintptr_t) info.dli_fbase);
  ...
}

2. 5.0以下使用libcorkscrew

为了获得在crash上下文提供堆栈信息,我们调用Android系统上的libcorkscrew库。这个库只在4.1.1以上,5.0以下的系统存在,可以通过动态加载(dlopen和dlsym())解决这个问题。为了获得crash堆栈,引入函数unwind_backtrace_signal_arch和acquire_my_map_info_list()。

/*
* Describes a single frame of a backtrace.
*/
typedef struct {
    uintptr_t absolute_pc; /* absolute PC offset */
    uintptr_t stack_top; /* top of stack for this frame */
    size_t stack_size; /* size of this stack frame */
} backtrace_frame_t;

ssize_t unwind_backtrace_signal_arch(siginfo_t* siginfo, void* sigcontext,
        const map_info_t* map_info_list,
        backtrace_frame_t* backtrace, size_t ignore_depth, size_t max_depth);

3. 5.0以上使用libunwind

那对5.0以上的系统怎么办呢?

5.0以上的系统使用了libunwind来代替libcorkscrew,coffee的作者实现如下:

/* Use libunwind to get a backtrace inside a signal handler.
   Will only return a non-zero code on Android >= 5 (with libunwind.so
   being shipped) */
#ifdef USE_LIBUNWIND
static ssize_t coffeecatch_unwind_signal(siginfo_t* si, void* sc, 
                                         void** frames,
                                         size_t ignore_depth,
                                         size_t max_depth) {
  void *libunwind = dlopen("libunwind.so", RTLD_LAZY | RTLD_LOCAL);
  if (libunwind != NULL) {
    int (*backtrace)(void **buffer, int size) =
      dlsym(libunwind, "unw_backtrace");
    if (backtrace != NULL) {
      int nb = backtrace(frames, max_depth);
      if (nb > 0) {
      }
      return nb;
    } else {
      DEBUG(print("symbols not found in libunwind.so\n"));
    }
    dlclose(libunwind);
  } else {
    DEBUG(print("libunwind.so could not be loaded\n"));
  }
  return -1;
}
#endif

4.直接使用libunwind的unw_backtrace只能打印调用者堆栈,需要额外处理

解决方法来自:

https://lightinginthedark.wordpress.com/2015/05/15/native-stack-traces-on-android-lollipop-with-libunwind/

在我的测试中这样调用libunwind只能打印出调用coffeecatch_unwind_signal函数的调用者的堆栈,而不是crash的堆栈。因为在Android系统上我们并不在一个真正的信号处理函数里。ART已经提前安装了信号处理程序并hook了sigaction()。

void handler(int signo, siginfo_t *const info, void *const reserved);

在arm和x86上信号处理程序的第三个参数reserved,都是ucontext_t结构体,包含了libunwind所需的信息。但是在Android的信号处理程序中,libunwind使用这个reserved指针填充unw_tdep_context_t的方式不正确。

我们先看下libunwind是大概怎么获得堆栈的。

//Nice simple way to get the stack trace from normal code.
//    Not for use in signal handlers
std::string getStackTrace(){
int stepCode;
char funcName[1024];
unw_cursor_t cursor;
unw_context_t uc;
unw_word_t ip,sp,offp;
std::stringstream ss;
 
unw_getcontext(&uc);这里有问题
int result=unw_init_local(&cursor, &uc);
if(!result){
if(result == -UNW_EBADREG){
//register needed wasn't accessible
}
}
//intentionally skip the first frame since
//that's this function
while((stepCode = unw_step(&cursor)) > 0){
if (unw_is_signal_frame (&cursor)){
ss << "signal frame: ";
}
unw_get_reg(&cursor, UNW_REG_IP, &ip);
unw_get_reg(&cursor, UNW_REG_SP, &sp);
unw_get_proc_name(&cursor, funcName, 1024, &offp);
char* realName = abi::__cxa_demangle(funcName,0,0,0);
ss << "pc = " << std::hex << ((void*)ip);
ss << " sp = " << std::hex << ((void*)sp);
ss << " : " <<(realName != NULL ? realName : funcName);
ss << " + " << std::hex <<((void*)offp) << '\n';
}
return ss.str();
}unw_getcontext(&uc);这里有问题
int result=unw_init_local(&cursor, &uc);
if(!result){
if(result == -UNW_EBADREG){
//register needed wasn't accessible
}
}
//intentionally skip the first frame since
//that's this function
while((stepCode = unw_step(&cursor)) > 0){
if (unw_is_signal_frame (&cursor)){
ss << "signal frame: ";
}
unw_get_reg(&cursor, UNW_REG_IP, &ip);
unw_get_reg(&cursor, UNW_REG_SP, &sp);
unw_get_proc_name(&cursor, funcName, 1024, &offp);
char* realName = abi::__cxa_demangle(funcName,0,0,0);
ss << "pc = " << std::hex << ((void*)ip);
ss << " sp = " << std::hex << ((void*)sp);
ss << " : " <<(realName != NULL ? realName : funcName);
ss << " + " << std::hex <<((void*)offp) << '\n';
}
return ss.str();
}

可以通过

git clone "https://android.googlesource.com/platform/external/libunwind"

获得libunwind的源码,如果只想编译arm架构的版本

out/target/product/generic/system/lib/libunwind.so

可以自己写一个Android.mk来编译,然后就可以像上面那样调用libunwind中的函数。

问题出在unw_getcontext这个函数,所以我们要重写这个函数。如果内核的信号接口变了,下面的代码也可能有问题,不过在我的测试中是可行的。

unw_context_t uc;
//platform specific voodoo to build a context for libunwind
#if defined(__arm__)
//cast/extract the necessary structures
ucontext_t* context = (ucontext_t*)reserved;
unw_tdep_context_t *unw_ctx = (unw_tdep_context_t*)&uc;
sigcontext* sig_ctx = &context->uc_mcontext;
//we need to store all the general purpose registers so that libunwind can resolve
//    the stack correctly, so we read them from the sigcontext into the unw_context
unw_ctx->regs[UNW_ARM_R0] = sig_ctx->arm_r0;
unw_ctx->regs[UNW_ARM_R1] = sig_ctx->arm_r1;
unw_ctx->regs[UNW_ARM_R2] = sig_ctx->arm_r2;
unw_ctx->regs[UNW_ARM_R3] = sig_ctx->arm_r3;
unw_ctx->regs[UNW_ARM_R4] = sig_ctx->arm_r4;
unw_ctx->regs[UNW_ARM_R5] = sig_ctx->arm_r5;
unw_ctx->regs[UNW_ARM_R6] = sig_ctx->arm_r6;
unw_ctx->regs[UNW_ARM_R7] = sig_ctx->arm_r7;
unw_ctx->regs[UNW_ARM_R8] = sig_ctx->arm_r8;
unw_ctx->regs[UNW_ARM_R9] = sig_ctx->arm_r9;
unw_ctx->regs[UNW_ARM_R10] = sig_ctx->arm_r10;
unw_ctx->regs[UNW_ARM_R11] = sig_ctx->arm_fp;
unw_ctx->regs[UNW_ARM_R12] = sig_ctx->arm_ip;
unw_ctx->regs[UNW_ARM_R13] = sig_ctx->arm_sp;
unw_ctx->regs[UNW_ARM_R14] = sig_ctx->arm_lr;
unw_ctx->regs[UNW_ARM_R15] = sig_ctx->arm_pc;
//s << "base pc: 0x" << std::hex << sig_ctx->arm_pc << std::endl;
logstr("base pc: ");
logptr((void*)sig_ctx->arm_pc);
logstr("\n");
#elif defined(__i386__)
ucontext_t* context = (ucontext_t*)reserved;
//on x86 libunwind just uses the ucontext_t directly
uc = *((unw_context_t*)context);
#else
//We don't have platform specific voodoo for whatever we were built for
//    just call libunwind and hope it can jump out of the signal stack on it's own
unw_getcontext(&uc);
#endif


用我们自己的实现替换掉libunwind中的那个unw_getcontext函数,然后就用我们自己填充context调用unw_init_local,接着循环调用unw_step,就可以在5.0以上打印出native的crash堆栈了。

然后使用get_backtrace_symbols函数去获得堆栈中的函数符号和demangle函数名

(关于demangle,可以参看这里:http://hipercomer.blog.51cto.com/4415661/855223)

/*
* Describes the symbols associated with a backtrace frame.
*/
typedef struct {
    uintptr_t relative_pc; /* relative frame PC offset from the start of the library, or the absolute PC if the library is unknown */
    uintptr_t relative_symbol_addr; /* relative offset of the symbol from the start of the library or 0 if the library is unknown */
    char* map_name; /* executable or library name, or NULL if unknown */
    char* symbol_name; /* symbol name, or NULL if unknown */
    char* demangled_name; /* demangled symbol name, or NULL if unknown */
} backtrace_symbol_t;

/*
* Gets the symbols for each frame of a backtrace.
* The symbols array must be big enough to hold one symbol record per frame.
* The symbols must later be freed using free_backtrace_symbols.
*/
void get_backtrace_symbols(const backtrace_frame_t* backtrace, size_t frames,
        backtrace_symbol_t* backtrace_symbols);

C++支持的函数重载功能是需要Name Mangling技术的最直接的例子,C++还有很多其他的地方需要Name Mangling,如namespace, class, template等等

举个例子:
int rect_area(int x1,int x2,int y1,int y2);
这个函数Name Mangling之后是_Z9rect_areaiiii

C++语言中规定 :以下划线并紧挨着大写字母开头或者以两个下划线开头的标识符都是C++语言中保留的标示符。所以_Z9rect_areaiiii是保留的标识符,g++编译的目标文件中的符号使用_Z开头(C99标准)。
接下来的部分和网络协议很类似。9表示接下来的要表示的一个字符串对象的长度(现在知道为什么不让用数字作为标识符的开头了吧?)所以rect_area这九个字符就作为函数的名称被识别出来了。
接下来的每个小写字母表示参数的类型,i表示int类型。小写字母的数量表示函数的参数列表中参数的数量。
所以,在符号中集成了用于区分不同重载函数的足够的语义信息。
如果要在C语言中调用C++中的函数该怎么做?这时候可以使用C++的关键字extern “C”。

六、获得java堆栈

如果想产生一个带jni crash堆栈的RuntimeException,同时解开java栈帧,需要将这些信息都传回java虚拟机,而不是调用回调函数。唯一办法就是用setjmp存储退出程序的正确位置(实际上是sigsetjmp,因为我们要恢复一些被屏蔽的信号),然后在信号处理函数里直接跳到这个位置。不过这又是一个非async-signal-safe的函数。

进程捕捉到信号并对其处理时,进程正在执行的正常指令序列就被信号处理函数临时中断,它首先执行该信号处理程序中的指令。如果crash在malloc()的过程中发生(那就意味空闲块的链表已经被破坏了),你可能引起另一个SIGSEGV,甚至是死锁,那就需要用户手动杀死进程了。

因此,alarm是第一个操作,以防死锁发生(alarm()是async-signal-safe的,那我们就可以自己杀死自己)

可以参考http://man7.org/linux/man-pages/man7/signal.7.html,signal 的手册把async-signal-safe的函数都列出来的,没有列入的大多数函数是不可重入的

static void my_handler(const int code, siginfo_t *const si, void *const sc) {
  /* Call previous handler. */
  old_handler.sa_sigaction(code, si, sc);

  /* Trigger a time bomb. */
  (void) alarm(30);

  /* Get thread-specific context. */
  my_struct *s = (my_struct*) pthread_getspecific(my_thread_var);
  if (s != NULL) {
    /* Store crash context for later. */
    s->code = code;
    s->si = *si;
    s->uc = *(ucontext_t*) sc;

    /* Jump back to initial location. */
    siglongjmp(t->ctx, -1);
  }
...
}


七、结果展示

FATAL EXCEPTION: AsyncTask #5
java.lang.RuntimeException: An error occured while executing doInBackground()
  at android.os.AsyncTask$3.done(AsyncTask.java:299)
  at java.util.concurrent.FutureTask.finishCompletion(FutureTask.java:352)
  at java.util.concurrent.FutureTask.setException(FutureTask.java:219)
  at java.util.concurrent.FutureTask.run(FutureTask.java:239)
  at android.os.AsyncTask$SerialExecutor$1.run(AsyncTask.java:230)
  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1080)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:573)
  at java.lang.Thread.run(Thread.java:841)
Caused by: java.lang.Error: signal 11 (Address not mapped to object) at address 0x42 [at libhttrack.so:0xa024]
  at com.httrack.android.jni.HTTrackLib.main(Native Method)
  at com.httrack.android.HTTrackActivity$Runner.runInternal(HTTrackActivity.java:998)
  at com.httrack.android.HTTrackActivity$Runner.doInBackground(HTTrackActivity.java:919)
  at com.httrack.android.HTTrackActivity$Runner.doInBackground(HTTrackActivity.java:1)
  at android.os.AsyncTask$2.call(AsyncTask.java:287)
  at java.util.concurrent.FutureTask.run(FutureTask.java:234)
  ... 4 more
Caused by: java.lang.Error: signal 11 (Address not mapped to object) at address 0x42 [at libhttrack.so:0xa024]
  at data.app_lib.com_httrack_android_2.libhttrack_so.0xa024(Native Method)
  at data.app_lib.com_httrack_android_2.libhttrack_so.0x705fc(hts_main2:0x8f74:0)
  at data.app_lib.com_httrack_android_2.libhtslibjni_so.0x4cc8(HTTrackLib_main:0xf8:0)
  at data.app_lib.com_httrack_android_2.libhtslibjni_so.0x52d8(Java_com_httrack_android_jni_HTTrackLib_main:0x64:0)
  at system.lib.libdvm_so.0x1dc4c(dvmPlatformInvoke:0x70:0)
  at system.lib.libdvm_so.0x4dcab(dvmCallJNIMethod(unsigned int const*, JValue*, Method const*, Thread*):0x18a:0)
  at system.lib.libdvm_so.0x385e1(dvmCheckCallJNIMethod(unsigned int const*, JValue*, Method const*, Thread*):0x8:0)
  at system.lib.libdvm_so.0x4f699(dvmResolveNativeMethod(unsigned int const*, JValue*, Method const*, Thread*):0xb8:0)
  at system.lib.libdvm_so.0x27060(Native Method)
  at system.lib.libdvm_so.0x2b580(dvmInterpret(Thread*, Method const*, JValue*):0xb8:0)
  at system.lib.libdvm_so.0x5fcbd(dvmCallMethodV(Thread*, Method const*, Object*, bool, JValue*, std::__va_list):0x124:0)
  at system.lib.libdvm_so.0x5fce7(dvmCallMethod(Thread*, Method const*, Object*, JValue*, ...):0x14:0)
  at system.lib.libdvm_so.0x54a6f(Native Method)
  at system.lib.libc_so.0xca58(__thread_entry:0x48:0)
  at system.lib.libc_so.0xcbd4(pthread_create:0xd0:0)


利用addr2line和堆栈中的相对地址就可以得到文件名和行号。当然前提是你要有一个debug版本、带有符号表的二进制文件。

还有另一个办法:构建带上所有debug信息,包括行号和宏信息的版本(-g3),把debug section分离到另外一个文件(比如一个.dbg文件)。最后通过

.gnu_debuglink

elf section告诉gdb或者addr2line这个.so有一个调试相关的文件.dbg

# copy all debugging sections to dbg file
objcopy --only-keep-debug mylib.so mylib.dbg
# strip debug sections
objcopy --strip-debug mylib.so
# wipe any existing ELF .gnu_debuglink section if any
objcopy --remove-section .gnu_debuglink mylib.so
# set the .gnu_debuglink to the dbg file
objcopy --add-gnu-debuglink=mylib.dbg mylib.so

保留.dbg文件就可以用来debug了

cd /build-archives/httrack/armv7/3.47.99.35
./toolchains/arm-linux-androideabi-4.7/prebuilt/linux-x86_64/bin/arm-linux-androideabi-addr2line -C -f -e libhttrack.so 0xa024
fourty_two
src/htscoremain.c:111

最后要确保所有库都是用-funwind-tables 编译选项编译的,这样才能带上解开栈帧的必要信息(可以在Android.mk里加上LOCAL_CFLAGS := -funwind-tables)(-funwind-tables不会使库增大很多大小)

使用的方法如下:

/** The potentially dangerous function. **/
jint call_dangerous_function(JNIEnv* env, jobject object) {
  // ... do dangerous things!
  return 42;
}

/** Protected function stub. **/
void foo_protected(JNIEnv* env, jobject object, jint *retcode) {
  /* Try to call 'call_dangerous_function', and raise proper Java Error upon 
   * fatal error (SEGV, etc.). **/
  COFFEE_TRY_JNI(env, *retcode = call_dangerous_function(env, object));
}

/** Regular JNI entry point. **/
jint Java_com_example_android_MyNative_foo(JNIEnv* env, jobject object) {
  jint retcode = 0;
  foo_protected(env, object, &retcode);
  return retcode;
}

  • 4
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 37
    评论
Android 应用程序的 Java 层中捕获 Native 异常可以通过以下两种方式来实现: 1. 使用 try-catch 块捕获异常 在调用 Native 方法时,放置 try-catch 块来捕获异常。用于抓住 Native 层的异常,然后通过 logcat 输出日志信息。 例如: ``` try { // 调用 Native 方法 } catch (Throwable e) { Log.e(TAG, "Native method threw an exception: " + e.getMessage()); e.printStackTrace(); } ``` 在 catch 块中,使用 Log.e 输出一个错误日志,并使用 printStackTrace() 打印堆栈跟踪信息。 2. 通过设置 UncaughtExceptionHandler 在应用程序的 Application 类中设置 UncaughtExceptionHandler,从而捕获 Native 异常。这个方法将捕获所有未经捕获和处理的异常。 例如: ``` public class MyApplication extends Application { @Override public void onCreate() { super.onCreate(); Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException(Thread thread, Throwable ex) { Log.e(TAG, "Uncaught Exception: " + ex.getMessage()); ex.printStackTrace(); // 在这里执行你的处理逻辑 // 例如重启应用程序等等 } }); } } ``` 在 UncaughtExceptionHandler 的 uncaughtException() 方法中,使用 Log.e 输出一个错误日志,并使用 printStackTrace() 打印堆栈跟踪信息。在这个方法中,还可以执行一些处理逻辑,例如重启应用程序等等。 这两种方式可以结合使用,以便完全捕获和处理 Native 异常。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

陆业聪

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

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

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

打赏作者

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

抵扣说明:

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

余额充值