jquerybind-显卡是什么
![callstack](/uploads/image/0294.jpg)
2023年4月4日发(作者:火炬之光 修改器)
iOSCrash栈的捕获和分析
在iOS应⽤开发和线上运⾏的过程中,我们总会被反馈到各种各样的崩溃。很多崩溃通过case的描述,就能很快的重现并得到修复,但是更
多的崩溃也许这⼀辈⼦就发⽣这么⼀次,也许我们永远不知道它什么时候再会出现。
同时,就算我们捕获到⼀个Crash栈,由于版本环境等种种原因,或者发⽣崩溃的代码我们就⽆法得到它详细的源码,我们往往会对着⼀⽚
全是程序指令偏移量的Crash栈⼀脸蒙蔽。
基于以上事实,我们需要从Crash栈的捕获和分析这两个⾓度进⾏深⼊的了解。
本博客主要内容分为两部分:
OC中的Crash异常的总结和捕获⽅法
利⽤Hopper对Crash栈进⾏分析
OC中的Crash异常的总结和捕获⽅法
相对于java从设计之初就养成的⼀条exception往下流,trycatch到底的作风,在我们iOS开发过程中,oc的异常处理就是⼀个不可逾越的
障碍阻碍着程序的运⾏与调试。因为oc⼀般⽤NSError甩错误,⼀旦遇到异常,⼋成就是⾮常⾮常严重的不可挽回的错误了,并且由于oc
往下直通c层,⾥⾯发⽣的异常简直是多种多样⾮常难以准确定位和分析。因此,我们来总结⼀下常见的异常和抓取处理分析⽅式。
OCException
oc层的异常是ios开发中最最最好抓取和分析的异常了。制造⼀个典型的oc异常简直再简单不过:
NSString*str=nil;
NSDictionary*dic=@{@"key":str};
//or
NSArray*array=@[@"a",@"b",@"c"];
[arrayobjectAtIndex:5];
//or
NSAssert(false,@"OCException");
显然,分别是NSDictionary的value不能为空,和NSArray取数据越界,和最暴⼒的assert直接抛出来的异常。这些在oc层⾯由iOS库或者
各种第三⽅库或者ocruntime验证出错误⽽抛出的异常,就是oc异常了。在debug环境下,oc异常导致的崩溃log中都会输出完整的异常信
息,⽐如:***Terminatingappduetouncaughtexception‘NSInternalInconsistencyException’,reason:‘OCException’。
包括这个Exception的类名和描述,下⾯是这个异常的完整堆栈。所以就算xcode的断点停在了main.m⾥⾯,我们也可以轻易的找到异常
的位置修复问题。
另外,oc异常还有⼀个⾮常好⽤的特性是可以⽤trycatch抓住(虽然苹果并不建议这么使⽤)。例如:
@try{
NSAssert(false,@"OCException");
}@catch(NSException*exception){
NSLog(@"%@",exception);
}
就可以获取到当前抛出异常并且阻⽌异常继续往外抛导致程序崩溃。虽然苹果真的不建议这样做。对于程序真的往外抛出并且我们很难
catch到的异常,⽐如界⾯和第三⽅库中甩出来的异常,我们也有⽅式可以截获到。NSException.m这个⽂件中携带了⼀个void
NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandler*_Nullable);的函数可以注册⼀个函数来处理未被捕获的异常。虽
然⽆法阻⽌程序崩溃,但是可以取得异常进⾏⼀些准备和后续处理,使⽤起来这样:
voidHandleException(NSException*exception){
NSArray*stackArray=[exceptioncallStackSymbols];
NSString*reason=[exceptionreason];
NSString*name=[exceptionname];
NSString*exceptionInfo=[NSStringstringWithFormat:@"Exceptionreason:%@nExceptionname:%@nExceptionstack:%@",name,reason,stackArray];
NSLog(@"%@",exceptionInfo);
}
NSSetUncaughtExceptionHandler(&HandleException);
往往我们要做的,是把异常信息保存到本地,等到下次启动的时候进⾏⼀些后续处理。这些就是crash收集⼯具所做的事⼉。当然,如果妄
想在HandleException时拉界⾯的话,就算了吧,这个函数运⾏完成后马上就崩溃了。
MachException
从OC异常往底层⾛,我们看到的是Mach异常。Mach异常是FreeBSD上特有定义的⾼层异常,当然,现在⽹络上能收集到的资料都和
mac和ios开发有关。相关的源码⽹络上可以找到。看到异常定义的名称我们会感觉到异常的亲切——EXC_MASK_开头的异常呢。我们⼀
⼀来总结常见的两个Mach异常吧:
EXC_BAD_ACCESS(BadMemoryAccess)
这是最常见并且我们觉得最头疼的,内存访问错误。这种异常分为两种:
1.访问对象未初始化(SIGBUS信号)
2.访问了什么东西已经被回收掉了(SIGSEGV信号)
当然,事实上到底是怎样的错误⽐上⾯描述的复杂神秘得多,这才是这个最难处理的主要原因。
EXC_BAD_ACCESS同时也提供了辅助的异常code来帮助我们判断到底是什么错误,⽐如KERN_PROTECTION_FAILURE是指的地址⽆
权限访问,KERN_INVALID_ADDRESS是指的地址不可⽤,异常信息中还会包括具体出错的地址。也许可以获得更多的帮助呢。在debug
运⾏是打开内存管理的ZombieObjects可以获得有效的调试信息。
EXC_BAD_INSTRUCTION(IllegalInstruction)
通常通过SIGILL信号触发的异常,很明显,它是在说运⾏了⼀条⾮法的指令。往往错误是这样⼦的:
XC_BAD_INSTRUCTION(code=EXC_I386_INVOP,subcode=0x0)
虽然是这样说,都是编译器编译出来的指令怎么会有⾮法指令嘛。所以事实上遇到这样的问题往往是运⾏指令的参数不对,多半是为0即nil
了。然后我们⼜回到了空指针的问题了~。
当然,除了代码中的问题。更多的是ios开发中的⽞学问题导致的ios本⾝异常和bug,⽐如就是这样。解决这些问题,还是得⽼⽼实实的分
析堆栈猜测和分析了。
其他
其他在实际开发中有可能遇到的并不多,主要是:
_RESOURCE是指的程序到达资源上限,⽐如cpu占⽤过⾼,内存不⾜之类的。这样的问题也没法解决啦。
_GUARD是⼀些c层函数访问错误导致的异常,⽐如fopen⽂件访问错误之类的都会爆出这个。不过我们好好的oc不⽤肯定⼀般
也不会使⽤这些,所以还安好。
3.0x00000020,这些是被FreeBSD定义为⽞学异常的异常都在⾥⾯了,也提供了特殊的code来提供辅助信息。其中其实最常见的
code是0x8badf00d,是主线程阻塞时间太长,程序被os杀了。其他的遇到了就是见⿁了!
UnixSignalExceptions
从Mach异常再往上⾛追根究底,其实,所以异常发⽣的本质途径都是Unix的异常信号。
异常并不是真正的异常,但是当⼀个OC异常被抛出到最外层还没被谁捕获,程序会强⾏发送SIGABRT信号中断程序。
异常没有⽐较⽅便的捕获⽅式,不过由于它本质就是信号,所以这⼀段讲的东西也能包含处理Mach异常。
产⽣⼀个不属于Mach异常的异常信号也是⾮常⾮常简单的事⼉,⽐如:
int*i;
free(i);
总之,c层⾯,runtime或者其他东西控制程序就是通过信号,中断当然也不例外。通过不同的信号,我们也能知道很多不同的东西。在ios
开发环境中,信号枚举在sys/signal.h⽂件中,我们可以看到⼤量的Unix信号罗列其中,参考可以看到各个信号的详解。当然,我们最终关
⼼的是能否捕获这些异常信号来抓住异常和崩溃。对,⽅法是有的,这⾥提供了⼀个叫void(signal(int,void()(int)))(int);的⽅法来注册⼀
个处理函数。
这个⽅法最后吐出来的是当前的信号,没异常信息堆栈怎么办,还好,从execinfo.h中,我们可以取出当然汇编层程序的堆栈情况。这就好
办了,最后处理代码如下:
voidSignalExceptionHandler(intsignal){
NSMutableString*mstr=[[NSMutableStringalloc]init];
[mstrappendString:@"Stack:n"];
void*callstack[128];
inti,frames=backtrace(callstack,128);
char**strs=backtrace_symbols(callstack,frames);
for(i=0;i
[mstrappendFormat:@"%sn",strs[i]];
}
}
voidInstallSignalHandler(void){
signal(SIGHUP,SignalExceptionHandler);
signal(SIGINT,SignalExceptionHandler);
signal(SIGQUIT,SignalExceptionHandler);
signal(SIGABRT,SignalExceptionHandler);
signal(SIGILL,SignalExceptionHandler);
signal(SIGSEGV,SignalExceptionHandler);
signal(SIGFPE,SignalExceptionHandler);
signal(SIGBUS,SignalExceptionHandler);
signal(SIGPIPE,SignalExceptionHandler);
}
要注意的是这⾥获得的堆栈信息是,当前汇编⼦程序的offset+指令offset,要么我们需要符号表,要么我们需要反编译⼀些我们的程序来
对应代码了。
利⽤Hopper对Crash栈进⾏分析
对于上⽂已经获得的crash堆栈,⽆论是否可以通过符号表获得代码实际情况,只要我们没发看到确切的代码,都是⽆法直接通过crash栈
直接进⾏分析。特别是遇到整个crash堆栈⾥⾯完全没有⾃⼰项⽬的代码,或者虽然是我们的项⽬名下的堆栈,却是通过pod引⼊的第三⽅
库。更现实的是,为了加速代码编译或者开发者⼲脆就是闭源的,往往pod引⼊的库都是⼆进制的静态库,所以我们得到的堆栈肯定没有具
体代码⾏数,看到堆栈肯定是⽆计可施。
遇到这样的情况,我们看到的堆栈往往是:0x100072ea40x100050000+143012这样只会有堆栈指令的pc位置或者⽅法名+
offset显⽰出来的pc位置,⽽不是。这样我们需要分析代码,只有通过分析具体的汇编指令才能继续下去。
⽽Hopper这个iOS查看和半反编译⼯具正适合这件事。
准备⼯作
⾸先我们当然要下载⼀个。这个软件demo版可以直接使⽤完整功能,和Charles⼀样每次启动可以使⽤30分钟——对于我们勉强够⽤了,
动⼼了可以买买买~
另外,我们还需要找到⽤于进⾏反编译的程序。理论上,它在ipa包的/Payload//xxx即对应的编译结果,其中在本地xcode编译出
来的app在~/Library/Developer/Xcode/DerivedData下。
最后,我们当然要准备好需要的crash堆栈,另外在旁边准备⼀个科学计算器⽐较好。
另外再⽤浏览器开⼀个ARM汇编指令⼤全吧。
iOS的ARM汇编基础
虽然基本上只需要⼀丁点⼉汇编基础知识就可以开展⼯作,还是有⼀些需要知道的。
寄存器相关:⼀共有31个64位通⽤寄存器,x0~x30。其中x29是framepointer;x30是procedurelinkregister;还有sp和pc。
常⽤的汇编指令我们需要了解的主要是:
movr1,r2把r2的数据赋予r1
ldrr1,r2把r2指向的数据赋予r1
strr1,r2把r1的数据赋予r2指向的地⽅
addsub之类的运算符肯定是需要的
那⼀堆超级⿇烦的跳转判断指令
bl调⽤⼦程序
[r1,0xXXXX]这样offset的⽅法
另外oc⽅法调⽤的情况下:
idvalue=[objmethodKey1:key1andKey2:key2];
编译到c层实际调⽤是:
idvalue=objc_msgSend(obj,@selector(methodKey1:andKey2:),key,key2);
当然,c的函数对应的其实是汇编调⽤⼦函数。因此我们需要的⼊⼝参数obj,selector,key,key2…其实是通过r0,r1,r2…..传输的,特殊情况
下可能会通过堆栈传输,不过⼀般不会~。另外返回值会直接返回到r0⾥边。
嗯,知道这些就可以了。
栗⼦:⼀次完整的分析
这次我们分析的完整的崩溃堆栈是这样的:
ExceptionType:EXC_CRASH(SIGABRT)
ExceptionCodes:0x0000,0x0000
ExceptionNote:EXC_CORPSE_NOTIFY
TriggeredbyThread:8
ApplicationSpecificInformation:
abort()called
Filteredsyslog:
Nonefound
LastExceptionBacktrace:
0CoreFoundation0x18a1151b8__exceptionPreprocess+124
0x188b4c55cobjc_exception_throw+56
2CoreFoundation0x18a11c268-[NSObject(NSObject)doesNotRecognizeSelector:]+140
3CoreFoundation0x18a119270___forwarding___+916
4CoreFoundation0x18a01280c_CF_forwarding_prep_0+92
5kmall0x1004b103c0x100050000+4591676
6kmall0x1003d1ef80x100050000+3677944
7kmall0x1003d23a00x100050000+3679136
0x188f9e1fc_dispatch_call_block_and_release+24
0x188f9e1bc_dispatch_client_callout+16
0x188fac3dc_dispatch_queue_serial_drain+928
0x188fa19a4_dispatch_queue_invoke+652
0x188fac8d8_dispatch_queue_override_invoke+360
0x188fae34c_dispatch_root_queue_drain+572
0x188fae0ac_dispatch_worker_thread3+124
15libsystem_0x1891a72a0_pthread_wqthread+1288
16libsystem_0x1891a6d8cstart_wqthread+4
从堆栈的⾓度,可以看到,倒数第三层调⽤到了doesNotRecognizeSelector⽅法然后抛出了异常,结合上下⽂,可以猜想到应该是某⼀个
object存在,但是调⽤了不存在的⽅法——也许是类型错误,导致了这个崩溃的发⽣。
⽽查询后,kmall的三层均不是我们项⽬代码,⽽是闭源的第三⽅库中抛出来的错误,⽆法得到其他信息。因此现在,只有从kmall最⾼的那
⼀层,即第5层堆栈开始⼊⼿分析汇编代码了。
堆栈第⼀层
我们看到的地址是0x1004b103c0x100050000+4591676,其实就是程序的0x46103C偏移位置。直接⽤hopper打开程序进⾏反
汇编找到对应的⼦函数:
;================BEGINNINGOFPROCEDURE================
+[GuardCommonencrypt:withKey:byAlgorithm:]:
0f1cstpx29,x30,[sp,#-0x10]!;ObjectiveCImplementationdefinedat0x1009fa370(classmethod),DATAXREF=0x1009fa
0f20movx29,sp
0f24subsp,sp,#0x80
0f28subx8,x29,#0x20
0f2cmovzx9,#0x0
0f30sturx0,[x29,#-0x10]
0f34sturx1,[x29,#-0x18]
0f38sturx9,[x29,#-0x20]
0f3cmovx0,x8
0f40movx1,x2
0f44strx3,[sp,#0x40]
0f48strx4,[sp,#0x38]
0f4cblimp___stubs__objc_storeStrong
0f50subx8,x29,#0x28
0f54movzx9,#0x0
0f58sturx9,[x29,#-0x28]
0f5cldrx9,[sp,#0x40]
0f60movx0,x8
0f64movx1,x9
0f68blimp___stubs__objc_storeStrong
...
0fd0adrpx8,#0x100a64000;CODEXREF=+[GuardCommonencrypt:withKey:byAlgorithm:]+156
0fd4addx8,x8,#0x630;objc_cls_ref_GuardEncryptProcessor
0fd8ldrx8,x8
0fdcldurx9,[x29,#-0x20]
0fe0movx0,x9
0fe4strx8,[sp,#0x30]
0fe8blimp___stubs__objc_retainAutorelease
0fecadrpx8,#0x100a53000;@selector(setTitleLabelBackgroundColor:)
0ff0addx8,x8,#0x488;@selector(bytes)
0ff4ldrx1,x8
0ff8blimp___stubs__objc_msgSend
0ffcadrpx8,#0x100a52000
1000addx8,x8,#0x3a0;@selector(length)
1004ldurx9,[x29,#-0x20]
1008ldrx1,x8
100cstrx0,[sp,#0x28]
1010movx0,x9
1014blimp___stubs__objc_msgSend
1018movx2,x0
101cldurx8,[x29,#-0x28]
1020movx0,x8
1024strw2,[sp,#0x24]
1028blimp___stubs__objc_retainAutorelease
102cadrpx8,#0x100a55000;@selector(clickGoPay:)
1030addx8,x8,#0xfd0;@selector(UTF8String)
1034ldrx1,x8
1038blimp___stubs__objc_msgSend
103cldurx8,[x29,#-0x30]
这个⼦函数有点长,我先截取⼀部分看看。⾸先根据hopper部分反编译(其实是数据映射的结果),这个⼦函数对应的⽅法是+
[GuardCommonencrypt:withKey:byAlgorithm:]:。嗯,糟糕,这是⼀个第三⽅库⾥⾯的代码,并且我们找不到源码,到此为⽌我们落实
要通过分析汇编代码的⽅式来查crash了。
然后我们找到⽬标pc地址的上⼀句,是⼀句bl即调⽤⼦函数,hopper⼜很贴⼼的把ios中常见系统⼦函数给反编译告诉我们了,这是⼀句
msgSend,和我们看到堆栈预期的⼀样,调⽤了不存在的⽅法。那么我们⾸先要做的就是找到msgSend的obj和selector,他们应该在调
⽤⼦函数前被放置在了对应的x0和x1处。
往上看,x1很快就找到了。hopper也很贴⼼的把常量指向的字符串在右侧标了出来。x1是从x8加载出来的,x8指向的字符
串“UTF8String”。然后x0呢,在0x461020看到x0是从x8挪过来的,⽽那⾥x8是从[x29,#-0x28]加载出来的。那么我们接下来就是
需要关⼼[x29,#-0x28]是哪⼉来的了。
继续往上看,在⼦函数开始部分0x460f58,把原本x9的数据放⼊了[x29,#-0x28]指向的位置中,但是注意到0x460f50开始的sub最后
得到的x8也是指向的这个位置,所以我们综合看⼀下。那⼀段结束之后调⽤了objc_storeStrong⽅法,我们知道objc_storeStrong是处理
⼊参的持有问题,把⼊参数转换到另⼀个新的id上。因此考虑到分别传⼊了⼀个空的指针和⼀个x0,因此这其实是在对x8做storeStrong
初始化。
那么看到传⼊的x1即原始数据,是从哪⼉来的?在0x460f5c从[sp,#0x40]读出来的,⽽[sp,#0x40]哪⼉来的,就在上⾯⼏⾏从x3中储
存进去的,x3到此为⽌——嗯,x3不就是⼦函数的⼊参么,应该是oc⽅法的第⼆个参数吧。即+[GuardCommon
encrypt:withKey:byAlgorithm:]的key咯。
到此为⽌,我们第⼀层堆栈分析完毕,可以继续往上了。
堆栈第⼆层
然⽽,分析第⼆层我们可见的堆栈⼦程序:
;================BEGINNINGOFPROCEDURE================
-[WindFingerprintGeneratortranformToFingerprint:]:
1db0stpx29,x30,[sp,#-0x10]!;ObjectiveCImplementationdefinedat0x1009e41f8(instancemethod),DATAXREF=0x10
1db4movx29,sp
1db8subsp,sp,#0xb0
1dbcsubx8,x29,#0x30
1dc0movzx9,#0x0
1dc4adrpx10,#0x100918000
1dc8ldrx10,[x10,#0x400];___stack_chk_guard_100918400,___stack_chk_guard
1dccldrx10,x10
1dd0movx3,x10
1dd4sturx10,[x29,#-0x8]
1dd8sturx0,[x29,#-0x20]
...
1e64adrpx8,#0x100a5b000;@selector(readStream)
1e68addx8,x8,#0x270;@selector(aesKey)
1e6csturx0,[x29,#-0x40]
1e70ldurx9,[x29,#-0x20]
1e74ldrx1,x8
1e78movx0,x9
1e7cblimp___stubs__objc_msgSend
1e80movx29,x29
1e84blimp___stubs__objc_retainAutoreleasedReturnValue
1e88strx0,[sp,#0x48]
1e8ccbzx0,loc_100381e9c
1e90ldrx8,[sp,#0x48]
1e94strx8,[sp,#0x40]
1e98bloc_100381eac
loc_100381e9c:
1e9cadrpx8,#0x10092e000;CODEXREF=-[WindFingerprintGeneratortranformToFingerprint:]+220
1ea0addx8,x8,#0x390;_kAESKey
1ea4ldrx8,x8
1ea8strx8,[sp,#0x40]
loc_100381eac:
1eacldrx0,[sp,#0x40];CODEXREF=-[WindFingerprintGeneratortranformToFingerprint:]+232
1eb0blimp___stubs__objc_retain
1eb4sturx0,[x29,#-0x48]
1eb8ldrx0,[sp,#0x48]
1ebcblimp___stubs__objc_release
1ec0adrpx0,#0x10096d000;@"-(int64_t)%@;"
1ec4addx0,x0,#0xc60;@"AES"
1ec8adrpx30,#0x100a5b000;@selector(readStream)
1eccaddx30,x30,#0x278;@selector(encrypt:withKey:byAlgorithm:)
1ed0adrpx8,#0x100a64000
1ed4addx8,x8,#0x338;objc_cls_ref_GuardCommon
1ed8ldrx8,x8
1edcldurx2,[x29,#-0x40]
1ee0ldurx3,[x29,#-0x48]
1ee4ldrx1,x30
1ee8strx0,[sp,#0x38]
1eecmovx0,x8
1ef0ldrx4,[sp,#0x38]
1ef4blimp___stubs__objc_msgSend
1ef8movx29,x29
依然是⼀段分析过后的关键段落截取。⾸先看到的⽅法名-[WindFingerprintGeneratortranformToFingerprint:]:,嗯,不是可见的⽅
法,但是和刚才不同的是这是⼀个实例⽅法了,所以当前对象很重要。另外虽然⽅法没见过,WindFingerprintGenerator却是有暴露给⽤
户使⽤,所以可以找到⼀些有⽤的信息。
然后从堆栈出⼝看,嗯,果然是msgSend⽽且selector对得上,没问题。然后刚才我们注意到的是x3,那在哪⼉放进去的呢?原来是
0x381ee0⾏,从[x29,#-0x48]读取出来的。然后继续往上0x381eb4处,讲0x储存到了[x29,#-0x48]中,⽽x0⼜是从[sp,#0x40]
读取出来的。
然后上⾯这⼀段是⼀个双goto,本质上是⼀个if判断,看⼀下判断指令:cbzx0是否存在?如果存在,往下,0x381e90把[sp,#0x48]读
出来赋予了[sp,#0x40],⽽[sp,#0x48]正好⼜是x0。所以结论是如果x0存在,传给后⾯了x0的值。
另⼀个分⽀,如果x0不存在,0x381ea0开始从⼀个叫_kAESKey的静态变量读取了数据并赋予了[sp,#0x40]。
所以这⼀段其实是:
[sp,#0x40]=x0?x0:_kAESKey;
那关键其实就是x0了。考虑到后⾯的崩溃应该是对象存在但是没有⽅法,因此这⾥要么是x0不存在_kAESKey不对,要不是x0不对,我们
需要继续追踪。
这⾥往上,x0就是0x381e7c中sendMsg的返回值,其中selector是aesKey,⽽对象x0是x9从[x29,#-0x20]来的。继续往上找,
[x29,#-0x20]在0x381dd8从x0赋予,⽽这⾥是x0最早出现的位置,即当前⼦函数的obj。因此完整解释出来,就是:
[sp,#0x40]=?:_kAESKey;
诶,打住,到此为⽌。写过相关代码的同学⽴刻会发现,self,即WindFingerprintGenerator的实例的aesKey好像是暴露出来给⽤户设置
的诶。赶快去看看~~~
⾄此,这次crash分析就结束了,事实上看到的是api希望aesKey是⼀个NSString,⽽我们代码中设置成了NSNumber,由此导致的错
误。
总结
以上Crash捕获处理就可以兜底式的涵盖所有的ios应⽤异常和崩溃的情况,是⾮常有效率。⽽结合hopper帮助给⼦程序映射oc⽅法进⾏拆
分,和对常⽤oc⼦程序进⾏部分反编译之后,阅读iOS的汇编结果进⾏crash堆栈分析并不是什么困难的事情。我们可以得到很多有⽤的信
息,结合传统的crash分析⽅法和经验,可以更可靠有效的解决问题。
通过以上⼀个完整的Crash栈捕获和抓取的流程,我们可以亲⼿抓住iOS应⽤在运⾏中遇到的所有⼤⼤⼩⼩的崩溃情况,并且在⾮常劣势的
条件下,有效的对Crash进⾏分析,解决疑难杂症。
虽然通过各种第三⽅崩溃统计服务,它们可能帮助我们把以上的⼤部分⼯作都完成了。但是最好解决bug的还是我们⾃⼰啊,不知彼知⼰拿
着Crash能不⽅么~
更多推荐
callstack
发布评论