index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html
![]()
本文深入探讨了C++异常捕获机制及其背后的unwind原理,阐述了fbjni如何处理Java异常并转化为C++异常,进而引发与__gxx_personality_v0相关的崩溃。文章详细解析了unwind的栈展开过程,包括personality function的作用、FDE与CIE的结构,以及C++ try-catch在底层汇编中的体现。通过分析NDK版本不一致可能导致的ABI兼容性问题,作者分享了解决此类崩溃的实践经验,并强调了谨慎使用异常捕获机制的重要性。
🎯 **C++异常捕获与Java异常的桥梁:** fbjni库在Android开发中充当了Java与C++代码的桥梁。当Java层抛出异常时,fbjni会将其捕获并转化为C++异常抛出。这种转换是理解后续崩溃的关键,因为它将原本在Java虚拟机中处理的异常带入了C++运行时环境,可能引发更深层次的问题。
💥 **unwind机制与__gxx_personality_v0的关联:** C++异常处理的核心是unwind机制,它负责在异常抛出时回溯调用栈,查找并执行析构函数和catch块。__gxx_personality_v0是GCC编译器实现此机制的关键函数。当C++异常被抛出时,该函数会被调用来展开栈帧,查找合适的异常处理程序。与该函数的关联,以及其依赖的C++标准库(libc++、libc++abi、libunwind)的版本兼容性,是导致崩溃的直接原因。
🛠️ **NDK版本不一致引发的ABI兼容性问题:** 文章指出,NDK版本的变化(如从21升级到23)可能导致C++标准库(libc++_shared.so)的ABI(Application Binary Interface)发生不兼容。当不同版本的库在运行时链接时,__gxx_personality_v0等关键函数的内部数据结构或偏移量可能发生变化,从而在栈展开过程中引发内存访问越界等问题,导致程序崩溃。
💡 **堆栈信息不完整与复现难度的成因:** 异常情况下,崩溃平台的堆栈信息可能只显示一行,指向一个模糊的库(如MMKV),而真实原因却隐藏在unwind过程中。这增加了问题排查的难度。文中提到,只有在线上包或特定动态库加载场景下才能复现的问题,也与动态库的加载和符号链接的时机有关。
🚀 **实践中的问题定位与修复策略:** 文章通过实际案例,展示了如何通过分析墓碑文件、对比不同NDK版本下的编译产物、回溯源码等方法来定位由unwind机制和ABI不兼容引起的崩溃。针对MMKV动态下发加载、启动白屏等问题,作者也提供了修复思路,强调了对底层机制的深入理解是解决复杂技术问题的基础。
原创 庞仔 2025-11-12 18:30 上海

C++ 异常捕获机制能让我们像java一样通过异常来处理程序中的错误,提高代码的可读性和可维护性,虽然好用,但是也要谨慎使用。

目录
一、序言二、unwind原理 1. fbjni异常抛出 2. C++ 标准库与unwind的关系 3. C++ try catch实现 4. crash 元凶之一 5. crash 元凶之二 6. crash 平台堆栈为什么只有一行 7. Android 系统的unwind三、动态库符号链接 1. dlopen流程 2. 为什么只有线上包才能复现四、问题修复 1. MMKV动态下发加载 2. 启动白屏 3. Android 5.1 闪退五、结语 序言在需求迭代开发过程中,有测试团队的小伙伴反馈在 RN 页面 偶尔会出现 crash,且堆栈只有一行,指向了MMKV,看完之后一头雾水,为什么 RN 页面会出现 MMKV的 crash,而且堆栈为什么只有一行,其实这些都和 unwind 有直接和间接的关系。就此问题而言,虽然 crash 平台的堆栈只有一行,但是通过抓取到的墓碑文件,可以看到此问题和 __gxx_personality_v0 相关。自 RN 升级到 0.72.5 版本之后,已经是第三次出现和 __gxx_personality_v0 相关的 crash。为什么 crash 堆栈只有一行?为什么 RN 的 crash 会指向MMKV?让我们带着这些疑问来详细了解一下unwind。unwind原理为什么要了解unwind,因为三次 crash 都和 __gxx_personality_v0 息息相关,而 __gxx_personality_v0 正是unwind 进行栈展开的关键函数。只有弄懂了unwind原理,才有可能找到问题的根本原因。说起__gxx_personality_v0,就不得不提起因它而起的第一次 crash。在RN 升级 0.72.5 版本之初,是在RN Demo 中进行的,升级改造完成之后,才移植到得物 App进行测试回归的。回归测试过程中发现了几例奇怪的 crash(如下图所示),但是在 RN demo 中从未出现过。堆栈中都出现了__gxx_personality_v0,通过google了解到此函数是 unwind 回溯堆栈所用到的,为什么 fbjni 会调用到这里,难道是出现了什么异常?后来通过反复自测,发现JS通过NativeModule调用到原生方法,当原生方法调用过程中抛出了异常,就会导致这个crash。fbjni 是Facebook所开发的三方库,旨在简化和减少使用 JNI 的一些复杂性,使得开发者可以更容易地编写和维护 JNI 代码。而 RN 也是通过 fbjni 来实现 Android 侧 JNI 方法的绑定,NativeModule 的调用最终也是通过 fbjni 调用过来。正常Java抛的异常是不会中断 C++ 的代码流程,但是 fbjni 对 JNI 方法调用做了一层包装,接下我们来看下 fbjni 是如何处理 Java 侧抛出的异常。可以看到,最终 fbjni 检测到Java 异常之后,会清理掉Java异常,并抛出一个 C++的异常,这里无需担心抛出异常导致 crash,上层 RN 有try ctach 处理,并把异常交给我们自定义的 ExceptionHandler 进行处理,那这里抛出异常会直接导致 crash 呢?接下来我们来看下 crash 堆栈中的 __gxx_personality_v0。经过一番 google,__gxx_personality_v0 是一个由GNU C++ 编译器(GCC)实现的函数,主要用于支持 C++ 异常处理和堆栈展开。它是一个所谓的 personality function ,这种函数负责处理异常的抛出、捕获和堆栈展开的行为。__gxx_personality_v0 函数是 GCC 提供的一种实现,用于在 C++ 中支持异常处理机制。当 C++ 异常被抛出时,__gxx_personality_v0 被调用来处理当前的调用栈。这包括识别哪些函数应该从堆栈中展开,以便适当地调用异常处理程序。因为__gxx_personality_v0 是被 libc++_shared.so 动态库所导出的,因为得物有很多库都依赖了C++标准库,最终打包进的libc++_shared.so 是通过得物壳工程中 pickFirst 来决定的,难道是编译fbjni与使用的libc++与得物中的 libc++_shared.so 不一致导致的。因为 RN 升级后,ndk版本也发生了改变,得物使用的ndk 21,而升级后的 RN 使用的ndk 23,ndk 21 与 ndk 23 所使用 libc++_shared.so 是有所不同的,当时猜测是__gxx_personality_v0 函数中使用的某个对象结构发生了变化,因为对象结构发生变化,代码编译时访问对象属性时的偏移量也就可能发生变化,从而引发运行时内存访问越界(这里只是猜测,具体crash 原因后文中会详细介绍)。于是,就拉了fbjni的源码,将 ndk版本降级为 21 重新编译了fbjni,然后重新发布 RN 组件到得物,果然 ndk 降级为 21 之后,这个问题就不复存在了,抛出的异常能够被正常catch住。那这样的话,RN 0.72.5 版本的 hermes 引擎也是通过 ndk 23 编译的,当执行JS过程中出现异常势必也会受到影响,果然主动抛出JS异常后,hermes也有相同的问题,不得不把 hermes 源码也拉下来自行编译一份。至此,__gxx_personality_v0引发的 crash 就告一段落,RN 0.72.5 版本也在未延期的情况下正常顺利上线了。但时隔多日,crash平台又出现了相似的崩溃,堆栈仍然报在__gxx_personality_v0,不同点是之前错误信号是 SIGSEGV,而这次的信号是SIGABRT,说明是程序自己调用了abort() 函数终止了进程。为了探寻__gxx_personality_v0为何会触发 abort(),就不得不对__gxx_personality_v0有一个深入的了解,这就要从C++的 try catch 开始讲起。这里介绍三个库,分别是libc++、libc++abi、libunwind:libc++libc++是 C++标准库的一个实现,提供了对 C++ 标准库的完整支持,包括如标准容器(如std::vector、std::map)、算法、输入输出流等。特性:支持 C++11、C++14、C++17 及后续版本的标准。注重对 C++ 新特性的支持,并且设计上优化了性能和可用性。提供完整的 STL (标准模板库)实现。用途:开发 C++ 应用程序时,libc++作为标准库被直接引用,用于标准语言特性的访问。libc++abilibc++abi是与libc++相关的一个库,主要负责为 C++ 标准库提供应用二进制接口(ABI)支持,特别是在异常处理和运行时类型识别 (RTTI) 方面。特性:实现了 C++ 异常处理中的关键功能,如 __cxa_throw 和 __cxa_rethrow。提供动态类型信息的支持,例如 typeinfo 和 dynamic_cast。用途:作为libc++的底层支持库,为 C++ 应用程序提供必要的 ABI 支持。libunwindlibunwind是一个用于堆栈展开、异常处理和回调的库,提供了一种跨平台的 API 来处理 C 和 C++ 的堆栈展开。特性:允许捕获和恢复程序执行上下文,并支持函数调用栈的展开。在异常处理和调试过程中,能够访问调用栈信息。用途:主要在异常处理和调试中使用,帮助实现C++中的堆栈展开。它们三者之间的关系:libc++ 和 libc++abi :libc++依赖于libc++abi来执行一些关键功能,如处理 C++ 异常的抛出与捕获。使用libc++的C++应用程序通常会隐式依赖 libc++abi,因为后者提供了库的 ABI 方面的支持。libc++abi 和 libunwind :在处理 C++ 异常时,libc++abi可能会使用libunwind 来支持堆栈展开。当一个异常被抛出时,libunwind负责展开调用堆栈,确保所有必要的清理操作得以执行。根据C++的标准,异常抛出后如果在当前函数内没有被捕捉(catch),它就要沿着函数的调用链继续往上抛,直到走完整个调用链,或者在某个函数中找到相应的catch。如果走完调用链都没有找到相应的catch,那么std::terminate()就会被调用,这个函数默认是把程序 abort,而如果最后找到了相应的catch,就会进入该catch代码块,执行相应的操作。程序中的catch那部分代码有一个专门的名字叫作:Landing pad。从抛异常开始到执行landing pad里的代码这中间的整个过程叫作stack unwind,这个过程包含了两个阶段:从抛异常的函数开始,一帧一帧地在每个调用函数里查找landing pad。如果没有找到landing pad则把程序abort,否则,则记下landing pad的位置,再重新回到抛异常的函数那里开始,一帧一帧地清理调用链上各个函数内部的局部变量,直到landing pad所在的函数为止。
简而言之,正常情况下,stack unwind所要做的事情就是从抛出异常的函数开始,找到catch所在的函数,然后从头开始清理调用链上的已经创建了的局部变量。注:下文介绍到 unwind 实现都是基于GCC版本。异常抛出先看一下异常是如何抛出的:这是 fbjni中的一段异常抛出代码,我们来看一下反编译之后的代码:可以看到,异常抛出时会先使用__cxa_allocate_exception 来分配一个异常对象,然后再调用__cxa_throw将异常抛出,我们再来看一下__cxa_throw 实现。可以看到,最终是调用到了_Unwind_RaiseException ,而 _Unwind_RaiseException函数就是用于进行stack unwind的。栈展开它在用户执行throw时被调用,然后从当前函数开始,对调用链上每个函数幀都调用一个叫作personality routine的函数(__gxx_personality_v0),该函数由上层的语言定义及提供实现,_Unwind_RaiseException 会在内部把当前函数栈的调用现场重建,然后传给personality routine,personality routine则主要负责做两件事情:检查当前函数是否含有相应catch可以处理上面抛出的异常。清掉调用栈上的局部变量。接下来我们来看一下 _Unwind_RaiseException 的具体实现:可以看到 _Unwind_RaiseException 最终拆成两个函数,第一个函数主要来完成 phase1,而 phase2 则是放在了 _Unwind_RaiseException_Phase2,不过phase1与 phase2都是通过fs.personality 来进行栈展开的, fs.personality其实就是上文中crash堆栈中的__gxx_personality_v0,fs.personality 的来源则是通过 uw_frame_state_for,它的实现在后文中 FDE 再展开介绍。所以两个阶段主要分为查找和清理,但是最终的实现都是通过C++标准库中__gxx_personality_v0,接下来我们就看一下__gxx_personality_v0的具体实现。__gxx_personality_v0会通过 actions 参数来区分当前是 查找阶段 还是 清理阶段,根据不同阶段进行不同操作,如果是查找阶段,则通过scan_eh_tab 来读取和解析异常处理表。unwind的进行需要编译器生成一定的数据来支持,这些数据保存了与每个可能抛异常的函数相关的信息以供运行时查找,那么,编译器都保存了哪些信息呢?根据Itanium ABI的定义,主要包括以下三类:unwind table,这个表记录了与函数相关的信息,共三个字段:函数的起始地址,函数的结束地址,一个info block指针。unwind descriptor table,这个列表用于描述函数中需要unwind的区域的相关信息。语言相关的数据(language specific data area),用于上层语言内部的处理。
以上数据结构的描述来自Itanium ABI的标准定义,但在具体实现时,这些数据是怎么组织以及放到了哪里则是由编译器来决定的,对于GCC来说,所有与unwind相关的数据都放到 了.eh_frame及.gcc_except_table这两个section里面了,而且它的格式与内容和标准的定义稍稍有些不同。下图来源于网络,展示了gcc_except_table及language specific data 的格式:FDE 与 CIE刚才上文中介绍到 scan_eh_tab 是来读取和解析异常处理表,但是 scan_eh_tab 代码实现较长,这里就不详细展开介绍了,我们就着重介绍一下 scan_eh_tab 中使用到的 languageSpecificData 是从何而来。上文中介绍到_Unwind_RaiseException 函数时有提到 uw_frame_state_for,接下来我们就来介绍一下 uw_frame_state_for的实现。可以看到,uw_frame_state_for会先通过_Unwind_Find_FDE 读取出FDE,再从FDE获取到CIE,最后再通过CIE中的信息填充fs中的字段。可以看到 languageSpecificData 的地址和上文中提到 fs.personality 其实都是通过 FDE 中 CIE 来读取到的,那 FDE 和 CIE 又是什么呢?上文中有介绍到栈展开所需的栈帧信息存放在的 eh_frame 段中。这个结构和调试信息中的 debug_frame 信息是相似的,使用的都是dwarf格式的文件结构,但是两者有一个重要的区别,debug 结构的 frame 在 strip 之后就不再包含调试信息,而且调试信息默认是不会加载的内存中的,当调试器需要的时候从硬盘上读取数据。在eh_frame中包含的是一个一个的 FDE 结构,每个 FDE 结构描述了一个函数堆栈的栈帧信息,包含了最为基本的一个函数的起始地址、长度以及CIE地址。而CIE则是存储了数据对齐大小、返回地址寄存器、扩展参数字符串 等等,刚刚提到的languageSpecificData地址与 fs.personality 就是从扩展参数字符串中解析出来的。我们可以看一下GCC 源码中 FDE 与 CIE 的数据结构定义(可与后文中提到的 Clang 版本进行对比)。具体的栈展开流程已经介绍完毕了,接下来,看一下C++ 中 try catch 代码反编译之后是怎么样的。这个是 fbjni 中一处简单的try catch处理,我们看一下它反编译之后的样子。.text:000000000000D594 ; void __fastcall facebook::jni::JniException::populateWhat(const facebook::jni::JniException *this)
.text:000000000000D594 _ZNK8facebook3jni12JniException12populateWhatEv
.text:000000000000D594 ; CODE XREF: facebook::jni::JniException::what(void)+38↓p
.text:000000000000D594
.text:000000000000D594 var_A0 = -0xA0
.text:000000000000D594 var_98 = -0x98
.text:000000000000D594 var_90 = -0x90
.text:000000000000D594 var_80 = -0x80
.text:000000000000D594 var_70 = -0x70
.text:000000000000D594 var_60 = -0x60
.text:000000000000D594 var_58 = -0x58
.text:000000000000D594 var_48 = -0x48
.text:000000000000D594 var_40 = -0x40
.text:000000000000D594 var_28 = -0x28
.text:000000000000D594 var_20 = -0x20
.text:000000000000D594 var_10 = -0x10
.text:000000000000D594 var_s0 = 0
.text:000000000000D594
.text:000000000000D594 this = X0 ; const facebook::jni::JniException *
.text:000000000000D594 ; __unwind { // __gxx_personality_v0
.text:000000000000D594 SUB SP, SP, #0x70
.text:000000000000D598 STP X22, X21, [SP,#0x60+var_20]
.text:000000000000D59C STP X20, X19, [SP,#0x60+var_10]
.text:000000000000D5A0 STP X29, X30, [SP,#0x60+var_s0]
.text:000000000000D5A4 ADD X29, SP, #0x60
.text:000000000000D5A8 MRS X20, #3, c13, c0, #2
.text:000000000000D5AC LDR X8, [X20,#0x28]
.text:000000000000D5B0 MOV X19, this
.text:000000000000D5B4 this = X19 ; const facebook::jni::JniException *
.text:000000000000D5B4 STUR X8, [X29,#var_28]
.text:000000000000D5B8 ; try {
.text:000000000000D5B8 ADD X0, SP, #0x60+var_40 ; this
.text:000000000000D5BC BL ._ZN8facebook3jni11ThreadScopeC2Ev ; facebook::jni::ThreadScope::ThreadScope(void)
.text:000000000000D5BC ; } // starts at D5B8
.text:000000000000D5C0 ADD X0, this, #8
.text:000000000000D5C4 ; try {
.text:000000000000D5C4 ADD X8, SP, #0x60+var_58
.text:000000000000D5C8 BL _ZNK8facebook3jni7JObject8toStringEv ; facebook::jni::JObject::toString(void)
.text:000000000000D5C8 ; } // starts at D5C4
.text:000000000000D5CC MOV X21, this
.text:000000000000D5D0 LDRB W8, [X21,#0x10]!
.text:000000000000D5D4 TBNZ W8, #0, loc_D5E0
.text:000000000000D5D8 STRH WZR, [X21]
.text:000000000000D5DC B loc_D600
.text:000000000000D5E0 ; ---------------------------------------------------------------------------
.text:000000000000D5E0
.text:000000000000D5E0 loc_D5E0 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+40↑j
.text:000000000000D5E0 LDR X8, [this,#0x20]
.text:000000000000D5E4 STRB WZR, [X8]
.text:000000000000D5E8 LDRB W8, [this,#0x10]
.text:000000000000D5EC STR XZR, [this,#0x18]
.text:000000000000D5F0 TBZ W8, #0, loc_D600
.text:000000000000D5F4 LDR X0, [this,#0x20] ; void *
.text:000000000000D5F8 BL ._ZdlPv ; operator delete(void *)
.text:000000000000D5FC STR XZR, [this,#0x10]
.text:000000000000D600
.text:000000000000D600 loc_D600 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+48↑j
.text:000000000000D600 ; facebook::jni::JniException::populateWhat(void)+5C↑j
.text:000000000000D600 LDR X8, [SP,#0x60+var_48]
.text:000000000000D604 LDUR Q0, [SP,#0x60+var_58]
.text:000000000000D608 MOV W9, #1
.text:000000000000D60C ADD X0, SP, #0x60+var_40 ; this
.text:000000000000D610 STR X8, [X21,#0x10]
.text:000000000000D614 STR Q0, [X21]
.text:000000000000D618 STRB W9, [this,#0x28]
.text:000000000000D61C BL ._ZN8facebook3jni11ThreadScopeD2Ev ; facebook::jni::ThreadScope::~ThreadScope()
.text:000000000000D620
.text:000000000000D620 loc_D620 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+1A4↓j
.text:000000000000D620 LDR X8, [X20,#0x28]
.text:000000000000D624 LDUR X9, [X29,#var_28]
.text:000000000000D628 CMP X8, X9
.text:000000000000D62C B.NE loc_D644
.text:000000000000D630 LDP X29, X30, [SP,#0x60+var_s0]
.text:000000000000D634 LDP X20, this, [SP,#0x60+var_10]
.text:000000000000D638 LDP X22, X21, [SP,#0x60+var_20]
.text:000000000000D63C ADD SP, SP, #0x70 ; 'p'
.text:000000000000D640 RET
.text:000000000000D644 ; ---------------------------------------------------------------------------
.text:000000000000D644
.text:000000000000D644 loc_D644 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+98↑j
.text:000000000000D644 this = X19 ; const facebook::jni::JniException *
.text:000000000000D644 BL .__stack_chk_fail
.text:000000000000D648 ; ---------------------------------------------------------------------------
.text:000000000000D648 ; catch(...) // owned by D5C4
.text:000000000000D648 STR X20, [SP,#0x60+var_60]
.text:000000000000D64C MOV X20, X0
.text:000000000000D650 ADD X0, SP, #0x60+var_40 ; this
.text:000000000000D654 BL ._ZN8facebook3jni11ThreadScopeD2Ev ; facebook::jni::ThreadScope::~ThreadScope()
.text:000000000000D658 B loc_D664
.text:000000000000D65C ; ---------------------------------------------------------------------------
.text:000000000000D65C ; catch(...) // owned by D5B8
.text:000000000000D65C STR X20, [SP,#0x60+var_60]
.text:000000000000D660 MOV X20, X0
.text:000000000000D664
.text:000000000000D664 loc_D664 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+C4↑j
.text:000000000000D664 MOV X0, X20 ; void *
.text:000000000000D668 BL .__cxa_begin_catch
.text:000000000000D66C ADRP X9, #_ZN8facebook3jni12JniException25kExceptionMessageFailure_E_ptr@PAGE
.text:000000000000D670 LDR X9, [X9,#_ZN8facebook3jni12JniException25kExceptionMessageFailure_E_ptr@PAGEOFF]
.text:000000000000D674 ADD X20, this, #0x10
.text:000000000000D678 CMP X20, X9
.text:000000000000D67C B.EQ loc_D730
.text:000000000000D680 LDRB W10, [X9] ; facebook::jni::JniException::kExceptionMessageFailure_
.text:000000000000D684 LDP X11, X12, [X9,#8]
.text:000000000000D688 LDRB W8, [X20]
.text:000000000000D68C LSR X13, X10, #1
.text:000000000000D690 TST W10, #1
.text:000000000000D694 CSINC X7, X12, X9, NE ; __p_new_stuff
.text:000000000000D698 CSEL X21, X13, X11, EQ
.text:000000000000D69C TBNZ W8, #0, loc_D6B0
.text:000000000000D6A0 MOV W1, #0x16
.text:000000000000D6A4 SUBS X2, X21, X1
.text:000000000000D6A8 B.HI loc_D6C4
.text:000000000000D6AC B loc_D6D0
.text:000000000000D6B0 ; ---------------------------------------------------------------------------
.text:000000000000D6B0
.text:000000000000D6B0 loc_D6B0 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+108↑j
.text:000000000000D6B0 LDR X9, [X20]
.text:000000000000D6B4 AND X9, X9, #0xFFFFFFFFFFFFFFFE
.text:000000000000D6B8 SUB X1, X9, #1 ; __old_cap
.text:000000000000D6BC SUBS X2, X21, X1 ; __delta_cap
.text:000000000000D6C0 B.LS loc_D6D0
.text:000000000000D6C4
.text:000000000000D6C4 loc_D6C4 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+114↑j
.text:000000000000D6C4 TBNZ W8, #0, loc_D6E0
.text:000000000000D6C8 LSR X3, X8, #1
.text:000000000000D6CC B loc_D6E4
.text:000000000000D6D0 ; ---------------------------------------------------------------------------
.text:000000000000D6D0
.text:000000000000D6D0 loc_D6D0 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+118↑j
.text:000000000000D6D0 ; facebook::jni::JniException::populateWhat(void)+12C↑j
.text:000000000000D6D0 TBNZ W8, #0, loc_D6FC
.text:000000000000D6D4 ADD X22, X20, #1
.text:000000000000D6D8 CBNZ X21, loc_D704
.text:000000000000D6DC B loc_D714
.text:000000000000D6E0 ; ---------------------------------------------------------------------------
.text:000000000000D6E0
.text:000000000000D6E0 loc_D6E0 ; CODE XREF: facebook::jni::JniException::populateWhat(void):loc_D6C4↑j
.text:000000000000D6E0 LDR X3, [this,#0x18] ; __old_sz
.text:000000000000D6E4
.text:000000000000D6E4 loc_D6E4 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+138↑j
.text:000000000000D6E4 ; try { ; this
.text:000000000000D6E4 MOV X0, X20
.text:000000000000D6E8 MOV X4, XZR ; __n_copy
.text:000000000000D6EC MOV X5, X3 ; __n_del
.text:000000000000D6F0 MOV X6, X21 ; __n_add
.text:000000000000D6F4 BL _ZNSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEE21__grow_by_and_replaceEmmmmmmPKc ; std::string::__grow_by_and_replace(ulong,ulong,ulong,ulong,ulong,ulong,char const*)
.text:000000000000D6F4 ; } // starts at D6E4
.text:000000000000D6F8 B loc_D730
.text:000000000000D6FC ; ---------------------------------------------------------------------------
.text:000000000000D6FC
.text:000000000000D6FC loc_D6FC ; CODE XREF: facebook::jni::JniException::populateWhat(void):loc_D6D0↑j
.text:000000000000D6FC LDR X22, [this,#0x20]
.text:000000000000D700 CBZ X21, loc_D714
.text:000000000000D704
.text:000000000000D704 loc_D704 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+144↑j
.text:000000000000D704 MOV X0, X22 ; void *
.text:000000000000D708 MOV X1, X7 ; void *
.text:000000000000D70C MOV X2, X21 ; size_t
.text:000000000000D710 BL .memmove
.text:000000000000D714
.text:000000000000D714 loc_D714 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+148↑j
.text:000000000000D714 ; facebook::jni::JniException::populateWhat(void)+16C↑j
.text:000000000000D714 STRB WZR, [X22,X21]
.text:000000000000D718 LDRB W8, [X20]
.text:000000000000D71C TBNZ W8, #0, loc_D72C
.text:000000000000D720 LSL W8, W21, #1
.text:000000000000D724 STRB W8, [X20]
.text:000000000000D728 B loc_D730
.text:000000000000D72C ; ---------------------------------------------------------------------------
.text:000000000000D72C
.text:000000000000D72C loc_D72C ; CODE XREF: facebook::jni::JniException::populateWhat(void)+188↑j
.text:000000000000D72C STR X21, [this,#0x18]
.text:000000000000D730
.text:000000000000D730 loc_D730 ; CODE XREF: facebook::jni::JniException::populateWhat(void)+E8↑j
.text:000000000000D730 ; facebook::jni::JniException::populateWhat(void)+164↑j ...
.text:000000000000D730 ; try {
.text:000000000000D730 BL .__cxa_end_catch
.text:000000000000D734 LDR X20, [SP,#0x60+var_60]
.text:000000000000D738 B loc_D620
.text:000000000000D73C ; ---------------------------------------------------------------------------
.text:000000000000D73C ; catch(...) // owned by D6E4
.text:000000000000D73C MOV this, X0
.text:000000000000D740 BL .__cxa_end_catch
.text:000000000000D740 ; } // starts at D730
.text:000000000000D744 MOV X0, X19
.text:000000000000D748 BL __clang_call_terminate
.text:000000000000D74C ; ---------------------------------------------------------------------------
.text:000000000000D74C ; catch(...) // owned by D730
.text:000000000000D74C BL __clang_call_terminate
.text:000000000000D74C ; } // starts at D594
看到类似于; __unwind { // __gxx_personality_v0 的注释时,通常意味着该反汇编代码块与 C++ 的异常处理机制相关,它应该是通过读取 .eh_frame段来获取到对应函数的 FDE 信息从而展示出来的。可以看到,代码中设置了多个出口函数,分别对应不同情况下,处理各个局部变量的析构。当程序中有多个可能抛异常的地方时,landing pad也相应地会有多个,该函数的出口将更复杂,这也算是异常处理的一个开销。小结总的来说,当程序抛出异常之后,会经历以下流程:调用__cxa_allocate_exception函数,分配一个异常对象。调用__cxa_throw函数,这个函数会将异常对象做一些初始化。__cxa_throw() 调用Itanium ABI里的_Unwind_RaiseException() 从而开始unwind。_Unwind_RaiseException()对调用链上的函数进行unwind时,调用personality routine。如果该异常如能被处理(有相应的catch),则personality routine会依次对调用链上的函数进行清理。_Unwind_RaiseException() 将控制权转到相应的catch代码。unwind完成,用户代码继续执行。先来说一下 RN 升级到 0.72.5 版本之后触发的SIGSEGV crash,我们上文中也提到了 ndk 降级到 21 之后,这个问题就不复存在。崩溃是发生在栈展开过程中,说明可能是 eh_frame 存储的 FDE、CIE 数据结构发生了变化,导致栈帧信息在读取时出现错乱,从而导致内存访问时出现了SIGSEGV。接下来,就看一下 ndk 从21升至23之后,GCC版本是否发生了变化。通过翻阅 ndk 23 版本的 Changelog,发现一条重要线索:从 ndk 23 开始,C++ 标准库中 libunwind 从 libgcc 替换为 LLVM 中的 libunwind(也就相当于从GCC 彻底变为 clang)。LLVM与GCC对于 libunwind 都有各自的实现,而 unwind 所使用的 eh_frame 是由编译器所生成的(也就是上文中所介绍的 FDE 与 CIE),RN 升级后使用的 ndk 23,所以是由 llvm 的 clang 所生成的,但是的 libc++_shared.so 是 ndk 21 的,所以 fbjni 中的符号链接到了 GCC 中 __gxx_personality_v0 函数,最终 GCC 的 unwind 函数去处理 clang 生成 eh_frame,这势必会出现问题,两者之间的数据结构都不一样。我们看一下 LLVM 中 FDE 与 CIE 的数据结构定义。这与上文中介绍到的 GCC 版本有明显差异,所以栈展开过程中读取 eh_frame 出现了内存访问错误,导致最终的 SIGSEGV crash,本质原因还是 RN 的动态库与得物的 libc++_shared.so ndk版本不一致。上面我们介绍了ndk版本不一致导致的SIGSEGV crash,接下来,我们再来看一下crash平台上另外一个 SIGABRT crash。反编译得物 libc++_shared.so 之后,通过 crash 堆栈中的偏移量可以锁定崩溃点在这里。因为打在得物 release 包中的 libc++_shared.so 是经过符号裁剪的,于是就反编译了ndk 21 中未裁剪符号的 libc++_shared.so,通过反编译后的代码来锁定崩溃点对应的源码位置。而从源码中来看,最终对应的则是 _Unwind_SetGR。因为得物中 libc++_shared.so 是 ndk 21版本的,所以 _Unwind_SetGR 的实现是来自于 GCC 版本。而_Unwind_SetGR 的主要作用是将指定寄存器的值更新为给定的值,用来确保相关寄存器在展开期间能够恢复到正确的状态,而set_registers 正是通过修改寄存器,来跳转到对应的 catch 块。通过上面的分析,可以将最终的错误锁定到是 _Unwind_SetGR 修改寄存器出现了错误,而导致错误原因大概率就是因为dwarf_reg_size_table[index] != 8 这个判断。dwarf_reg_size_table是一个DWARF (Debugging With Attributed Record Formats) 相关的数据结构,用于描述寄存器的大小。在使用调试信息和异常处理时,这个信息很重要,特别是在处理栈展开和访问寄存器状态时。在处理异常时,dwarf_reg_size_table可以帮助编译器和调试工具决定如何正确地保存和恢复寄存器的状态,以便在异常发生时顺利展开堆栈。通过反编译后的代码,可以发现dwarf_reg_size_table在初始化时都被填充了8,正常情况下 dwarf_reg_size_table[index] != 8这个条件理论上是不可能满足的。所以只能是dwarf_reg_size_table中的数据出现了损坏,而dwarf_reg_size_table 为什么会出现损坏,我们之后的文章再娓娓道来。所以,第二次出现crash不是因为ndk 版本升级所引起的。回归正题,为什么5.64.0 版本出现了很多MMKV的 SIGSEGV crash,而且堆栈只有一行,其实这也是 unwind 所导致的,得物的 crash 采集使用的是 xCrash,我们来看一下xCrash是如何对 native crash 进行栈回溯的。xCrash 捕获到信号之后,会调用 xcc_unwind_get(),而 xcc_unwind_get()内部会判断系统版本从而调用到 xcc_unwind_clang_record()。而xcc_unwind_clang_record()最终调用到了 _Unwind_Backtrace(),这里虽然函数命名里有 clang,但是最终调用的是 libc++_shared.so 中的 _Unwind_Backtrace()。没错,他最终调用的是GCC中_Unwind_Backtrace() 而非 clang(因为得物 libc++_shared.so 是 ndk 21 编译出来的)。所幸,_Unwind_Backtrace() 下方有个 0 == self.buf_used的判断,如果 self.buf_used 等于 0,说明 _Unwind_Backtrace()失败了,就会使用 sig_pc 来记录触发 signal 所在 pc 的栈帧信息(也就是最后一个栈帧),所以crash平台上只有一行堆栈信息。我们来看一下 GCC _Unwind_Backtrace()的具体实现。有没有看到熟悉的身影uw_frame_state_for,上文中介绍过它的作用主要是从 eh_frame 中读取 FDE 与 CIE,因为MMKV是通过 ndk 25 编译的,所以它的 eh_frame 是通过 LLVM Clang 所生成的,而 LLVM 中的 FDE、CIE 与 GCC 中定义是有所差异的,所以读取解析时大概率会出错,从而导致_Unwind_Backtrace()失败(这里其实是有风险的,因为数据结构不一样,uw_frame_state_for在读取时可能会出现 SIGSEGV, 从而导致二次崩溃),最终记录的堆栈只有一行。其实,堆栈只有一行的不止是MMKV,crash 平台上 hawk 相关的一部分 crash 也是一行堆栈,这是因为 hawk 是通过 ndk 27 编译的,所以大概率也是这个原因导致的。xCrash是指望不上了,希望只能寄托给 Android 系统的 unwind了,而系统的 unwind 所使用的是 libunwindstack,虽然它也是借助 eh_frame 来进行栈展开的,但是它对于 eh_frame 的解析是存在兼容性的,所以它能够回溯出完整的堆栈。但是它回溯出的堆栈最终是记录到了 tombstone 文件,所以查看完整堆栈完全寄托于上报的 tombstone 文件。不负所望,tombstone文件确实记录了完整的堆栈。可以看到,原因是 hermes_executor.so 中抛出了一个C++ 异常,但是在栈展开过程中使用__gxx_personality_v0出现了 crash,这和上面所介绍到的第一个 crash 是相似的问题,但问题在于使用 __gxx_personality_v0为什么来自MMKV。通过堆栈,我们也找到了抛出异常的相关代码。这个异常是 RN 加载拆包文件过程中,拆包文件出现了损坏导致了配置解析出错抛出的异常,在之前版本中,这个问题都是作为JS异常上报到了 sls,但是从 5.64.0 开始,这个C++ 异常捕获不住了,直接变为了 crash,根本原因还是出在了__gxx_personality_v0。从堆栈中可以看出,__cxa_throw使用的还是 c++_shared.so 中的,但是栈展开过程中使用__gxx_personality_v0却是mmkv.so中的,上文中也介绍过 fs.personality 来自于 CIE,说明栈回溯过程中某一个栈帧的 fs.personality 指向了 mmkv.so 中的 __gxx_personality_v0,也就是某个动态库中引用的 __gxx_personality_v0符号链接到了mmkv.so(因为MMKV是已静态库的方式集成了libc++,所以也导出 libc++ 的符号)。因为拆包加载要经过了一次线程切换,切换至 RN 的JS线程,所以要使用到 fbjni 的 JNativeRunnable post 到JS线程的 MessageQueue 中,所以整个堆栈中就只有 fbjni 和 hermes_executor 的栈帧。但是 fbjni、hermes_executor 与MMKV没有什么依赖关系,为什么会链接到MMKV导出的符号,这就要了解一下Android系统中动态库的符号链接过程。动态库符号链接System.loadLibray 最终是通过 dlopen 来加载动态库的,所以要想了解符号链接的过程,就需要去分析一下 dlopen 的具体实现。dlopen 调用了do_dlopen,而 do_dlopen 调用了 find_library,然后 find_library 调用了 find_libraries,而 find_libraries 就是动态库加载主要流程。find_library_internal 首先通过 find_loaded_library_by_name 函数判断目标动态库是否已经加载,如果已经加载则直接返回对应的soinfo指针,没有加载的话则调用 load_library 继续加载流程,下面看 load_library 函数:可以看到,load_library 的结尾通过for循环将其所依赖的动态库添加至 load_tasks,然后 find_libraries 中的 load_tasks 的 for 循环再次触发 find_library_internal,直到所有依赖的动态库都 load_library。通过 load_library 完成了动态库的加载,然后结束 find_library_internal 流程,回到 find_libraries 函数,而find_libraries 函数的最后两步则是建立 local_group_roots 集合与链接符号。这里可以看到,local_group_roots 集合最终存放了 dlopen 所要加载的库,而其依赖库并没有加载其中。接下来,就是最后一步链接符号,首先是 walk_dependencies_tree,此函数会遍历 root 节点以及它所有的依赖节点,然后将其添加至 local_group 集合,而这里的 root 节点就是 dlopen 所要加载的动态库。可以看到,walk_dependencies_tree 是一个广度优先遍历的方式。然后就是 SymbolLookupList 对象的创建,而 SymbolLookupList 对象就是用来给 符号查找时提供的动态库列表,其中,global_group 是全局加载的动态库,通过 dlopen 的flag参数控制,Android中System.loadLibray默认传值是 RTLD_NOW,所以通过 System.loadLibray 加载的动态库都不是全局的,而 local_group 则是 dlopen 所要加载的库以及它所有依赖的库。其中flag有:RTLD_LAZY RTLD_NOW RTLD_GLOBAL,其含义分别为:RTLD_LAZY:在dlopen返回前,对于动态库中存在的未定义的变量(如外部变量extern,也可以是函数)不执行解析,就是不解析这个变量的地址。RTLD_NOW:与上面不同,他需要在dlopen返回前,解析出每个未定义变量的地址,如果解析不出来,在dlopen会返回NULL,错误为:: undefined symbol: xxxx.......RTLD_GLOBAL:它的含义是使得库中的解析的定义变量在随后的其它的链接库中变得可以使用。接下来就是遍历 local_group 中的动态库,进行逐一链接,如果对应的动态库已经链接过了,就不会再通过 link_image 进行链接。link_image 的实现细节就不展开介绍了,就直接看一下链接过程中的符号查找。可以看到,符号查找是遍历 lookup_list 中的动态库来进行逐一查找,如果对应符号找到了,则直接 return。而这里的lookup_list就是上面所提到的SymbolLookupList对象,里面包含了dlopen 所要加载的库以及它所有依赖的库。而 hawk 依赖了MMKV,也依赖了 yoga,而 yoga 依赖了 fbjni,而 fbjni 依赖了 libc++_shared.so。刚刚上面也提到了 SymbolLookupList 是以广度优先遍历的方式创建而来的。所以 SymbolLookupList 对象中的依赖库顺序则是 hawk、MMKV、yoga、fbjni、libc++_shared.so。上文中也可以看到,local_group 中的动态库符号链接用的是同一个 SymbolLookupList 对象,所以对于 fbjni 的符号链接,也是如此,因为MMKV与 libc++_shared.so 都有导出C++ 符号, 所以 fbjni 最终优先链接到了MMKV中的C++ 符号。crash 出现后,线下包总是复现不了(包括打包机上 tf 包),只有最终的线上的 PR 包才能复现。于是就对 System loadLibray 的日志进行了分析,发现线下包的 fbjni 加载时机是早于 hawk 的。通过排查,发现是因为 du_developer 组件的缘故,因为启动流程中,du_developer 中的 flipper 初始化要早于 hawk 的初始化,而 flipper 的动态库依赖 fbjni,致使 fbjni 提前加载。后续 hawk 初始化时,fbjni 已经加载,就不会再进行符号链接,所以 fbjni 符号链接是正常的。这也导致了线下包测试过程中没有出现过一次问题,直到线上包打出后才有小伙伴反馈。问题修复方案一:裁剪掉MMKV中的C++ 符号,MMKV本身就不应该导出C++符号,C++依赖应该使用 libc++_shared.so,如果一个库同时依赖了MMKV和 其他依赖C++ 共享库的 lib(如 fbjni),就会导致这个依赖C++ 共享库的 lib 链接到MMKV。方案二:确保和MMKV同时依赖或间接依赖的动态库(自身依赖C++ 共享库)能够提前加载,不与被依赖库同时进行链接。显然,方案二是改动最小的也是风险最小的,但它始终不是一个长久之计,后续如果又有其他库出现此问题,总不能把本该懒加载的动态库都放到应用启动流程进行提前加载。而且,它还有另外一个隐患,导致编译时错误的符号引用。就比如 hawk,hawk 也是静态库的方式集成了 libc++,对于 hawk 来说,C++ 相关的符号都应该是内部符号。但是实际编译后C++ 相关的符号却变为外部符号,这是因为编译时,MMKV作为 hawk 的依赖,编译工具以为 hawk 中使用的C++ 函数是来自于MMKV 而非 libc++,这也就导致 hawk 运行时调用 libc++ 的函数其实都是MMKV中的。因为 hawk 与MMKV的 ndk 版本不一致,也算是隐患之一。但是为了能够先解决线上问题,在 5.65.0 版本上先上线了方案二。5.65.0 版本上线后,问题确实不复存在了,但是出于后续稳定性的考量,方案一还是要做的。MMKV符号裁剪虽然很简单,但是问题在于它的影响范围比较大,因为MMKV是得物 App 进程启动加载的第一个动态库,如果出了问题,热修和启动回滚都搞不定,所以要想一个万全之策,确保线上出现问题后能够及时回滚。为了保证MMKV能够回滚,裁剪后的MMKV动态库不直接替换,而是通过 yeezy 平台进行动态下发。MMKV初始化有多个 重载方法,其中部分重载函数支持传入自定义的 LibLoader,通过 LibLoader 自实现MMKV动态库的加载。LibLoader 刚好满足我们的诉求,启动时先自行加载裁剪符号后的MMKV动态库,如果加载失败,则使用apk 内原本的MMKV动态库。功能提测后,有测试反馈启动时白屏卡死,而且还只在那一台手机上出现。起初还以为是系统问题,通过排查发现,测试的那台手机会 mmkv.so 加载时机会更早,早于 OptimizedApplication 中的 initMMKV()。通过调试发现,du_developer 组件中也有MMKV的初始化逻辑,而且它的初始化时机更早,之所以只在这一台手机出现,因为这段代码逻辑有一个判断,只有命中灰度的设备才会执行。因为我只修改了OptimizedApplication 中的 initMMKV(),所以其他地方的MMKV初始化是不会传入自定义的 LibLoader,这就导致了 du_developer 组件中逻辑会优先初始化MMKV,所以它先加载了 apk 中内置的 mmkv.so,然后走进OptimizedApplication中的 initMMKV(),加载了动态下发的 mmkv.so。为什么两次加载 会导致白屏呢?这是因为两次加载的 so 来自于不同版本的 MMKV,apk 中使用的MMKV版本是 1.3.4,而动态下发的MMKV是 1.3.13版本,那为什么两个不同版本的MMKV加载会导致白屏呢?du_developer 组件中的 MMKV 初始化完成之后,会初始化一个MMKV对象,而这个对象其实就是C++ 侧 MMKV 对象的句柄。后续OptimizedApplication中的 initMMKV() 会再次加载 mmkv.so,在 JNI_OnLoad 中会把 Java 侧的 jni 方法绑定到新加载的MMKV上。随后,其他地方又使用之前 du_developer 组件初始化的 MMKV对象进行 getString(),而此时 getString() 对应 jni 方法已经绑定到新加载的 mmkv.so 上了。MMKV的getString() 函数 会使用线程锁 m_lock 来保证线程安全,而这个 m_lock 则是MMKV对象里的属性,所以最终是新方法用到了老对象里面的 m_lock。因为两个 mmkv.so 版本不一致,升级后的MMKV中 MMKV 类结构发生了变化,因为C++ 对象最终是通过偏移量获取属性的,而这个对象结构发生了变化,通过新的类结构偏移量 从 老的对象里面取属性,取出来的属性肯定是错误的,所以此时的 m_lock 其实并不是一个锁对象,从而导致线程锁卡死在了这里。所以,引发此问题的关键因素就是MMKV两次初始化动态库加载不一致,为了解决这个问题,需要对MMKV初始化所有调用点进行统一,所以就通过 ASM 插桩的方式对所有MMKV初始化调用点进行了修改,确保MMKV每次初始化都使用相同的LibLoader。功能灰度前,又有一个测试反馈 Android 5.1 上会出现MMKV的 crash,真是屋漏偏逢连夜雨。因为 hawk 是在C++ 侧依赖了MMKV,所以在代码中直接调用 getMMKVWithID()来获取MMKV实例。而最终问题则是出现在这里,g_instanceDic 是一个空指针。g_instanceDic 是在 MMKV 的initialize() 中new出来的,而 hawk 代码在执行时,MMKV 肯定已经初始化过了,理论上 g_instanceDic 应该不可能为空指针。难道是 hawk.so 加载时,又触发了 apk 内置的 mmkv.so 的加载,导致 hawk.so 链接的符号是新加载的 mmkv.so 中的符号,因为 hawk 在使用MMKV中没有调用初始化(因为正常情况下,应用启动时MMKV 已经初始化过了),所以这里新加载的 mmkv.so 是没有经过初始化的,所以此时的 g_instanceDic 是一个空指针。那为什么 Android 5.1 上的 hawk.so 会触发apk内置的 mmkv.so再次加载呢?这其实是 Android 5.x 系统的一个缺陷,上文 dlopen 流程中其实介绍过 find_loaded_library_by_name(),当动态库加载时,会先通过find_loaded_library_by_name() 检查动态库是否已经加载过,如果已经加载过,就不会再进行加载。但是问题就出在这里,我们先来看一下Android 5.1中 find_loaded_library_by_name() 实现。当 hawk 加载触发MMKV加载时,此处 search_name 其实是 libmmkv.so,而 si->name 是什么呢?在Android 5.1 上,si->name 其实就是 load_library 传入的名字,而我们在自定义 MMKV LibLoader 时,System.load() 传入的so文件的全路径,si->name就是 动态下发的so文件的全路径。所以 find_loaded_library_by_name() 时没有匹配成功,导致 hawk 加载时又触发了 apk 内置的mmkv.so 的加载,从而导致了 g_instanceDic 空指针。接下来,我们再来看一下Android 5.x 之后的版本 find_loaded_library_by_name() 是如何实现的。可以看到,Android 5.x 之后的版本在比较时不再使用 si->name,而是使用 si->get_soname(),我们再来看一下 si->get_soname()又是从何而来。所以,最终的 soname_ 是来自于 soinfo::prelink_image() 时,从 so 文件解析出来的,我们可以看一下 mmkv.so 文件中 DT_SONAME 的对应值。DT_SONAME 的对应值正是libmmkv.so,所以在 Android 5.x 以上的系统,find_loaded_library_by_name() 执行时 soname 会匹配成功,不会导致 apk 内置的 mmkv.so 被加载,所以不会出现上文中的空指针问题。最终,通过多个版本的灰度,符号裁剪后的mmkv.so 也是在 5.74.0 版本内置到 apk 中全量上线了。结语C++ 异常捕获机制能让我们像Java 一样通过异常来处理程序中的错误,提高代码的可读性和可维护性,虽然好用,但是也要谨慎使用。异常频繁抛出可能会带来性能问题,上文中也介绍了栈展开的流程,它会带来一定的性能开销。其次,就是包体大小的影响,每处 try catch 都会生成多个 landing pad,所以对包体大小也有一定增加。如果项目中多个动态库编译所使用 ndk 版本不一致,更要谨慎使用 try catch,尤其是跨 so 之间的异常捕获,避免出现上文中所出现的问题。如果条件允许,尽量统一应用内所有动态库编译时所用的 ndk 版本。往期回顾
1. 得物TiDB升级实践2. 得物管理类目配置线上化:从业务痛点到技术实现3. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术4. RAG—Chunking策略实战|得物技术5. 告别数据无序:得物数据研发与管理平台的破局之路文 /庞仔关注得物技术,每周一、三更新技术干货要是觉得文章对你有帮助的话,欢迎评论转发点赞~未经得物技术许可严禁转载,否则依法追究法律责任。“
扫码添加小助手微信如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:阅读原文
跳转微信打开