crash 一般产生自 iOS 的微内核 Mach,然后在 BSD 层转换成 UNIX SIGABRT 信号,以标准 POSIX 信号的形式提供给用户。NSException 是使用者在处理 App 逻辑时,用编程的方法抛出。
iOS 端的 crash 分为三类:
EXC_BAD_ACCESS
在访问一个已经释放的对象或向它发送消息时,就会出现 EXC_BAD_ACCESS。
造成 EXC_BAD_ACCESS 最常见的原因,是在初始化变量时用错了所有权修饰符,这会导致对象过早地被释放。例如,一个 NSArray 属性的所有权修饰符设成了 assign 而不是 strong。
这个崩溃发生时,查看崩溃日志,往往得不到有用的栈信息。可以通过 NSZombieEnabled 来解决。
NSZombieEnabled 是一个环境变量,用来调试与内存相关的问题,跟踪对象的释放过程。启用之后,它会在对象调用 dealloc 时,也就是在引用计数降到 0,用一个僵尸实现将该对象转换成僵尸对象。僵尸对象的作用是在你向它发送消息时,它会显示一段日志并自动跳入调试器。
所以,当在应用中启用 NSZombie 而不是让应用直接崩溃时,一个错误的内存访问就会变成一条无法识别的消息发送给僵尸对象。僵尸对象会显示接收到的消息,然后跳入调试器,这样就可以查看到底哪里出了问题。 启用如图所示:
SIGSEGV
段错误信息(SIGSEGV)是操作系统产生的一个更严重的问题。
当硬件出现错误、访问不可读的内存地址或向受保护的内存地址写入数据时,就会发生这个错误。
当要读取保存在 RAM 中的数据,而该位置的 RAM 硬件有问题时,会收到 SIGSEGV,这种情况并不常见。下面两种情况更多出现:
SIGSEGV 错误调试起来更困难,而导致 SIGSEGV 的最常见原因是不正确的类型转换。要避免过度使用指针或尝试手动修改指针来读取私有数据结构。如果你那样做了,而在修改指针时没有注意内存对齐和填充问题,就会收到 SIGSEGV。
SIGBUS
总线错误信号(SIGBUG)代表无效内存访问,即访问的内存是一个无效的内存地址。
也就是说,那个地址指向的位置根本不是物理内存地址(它可能是某个硬件芯片的地址)。SIGSEGV 和 SIGBUS 都 EXC_BAD_ACCESS 的子类型。
SIGTRAP
SIGTRAP 代表陷阱信号。
它并不是一个真正的崩溃信号。它会在处理器执行 trap 指令发送。LLDB 调试器通常会处理此信号,并在指定的断点处停止运行。如果你收到了原因不明的 SIGTRAP,先清除上次的输出,然后重新进行构建通常能解决这个问题。
EXC_ARITHETIC
当要作除零运算时,应用会收到 EXC_ARITHMETIC 信号。
这个错误应该很容易解决。
SIGILL
SIGILL 代表 signal illegal instruction(非法指令信号)。
当在处理器上执行非法指令时,它就会发生。执行非法指令是指,将函数指针会给另外一个函数时,该函数指针由于某种原因是坏的,指向了一段已经释放的内存或是一个数据段。有时你收到的是 EXC_BAD_INSTRUCTION 而不是SIGILL,虽然它们是一回事,不过 EXC_* 等同于此信号不依赖体系结构。
SIGABRT
SIGABRT 代表 SIGNAL ABORT(中止信号)。
当操作系统发现不安全的情况时,它能够对这种情况进行更多的控制;必要的话,它能要求进程进行清理工作。在调试造成此信号的底层错误时,并没有什么妙招。Cocos2d 或 UIKit 等框架通常会在特定的前提条件没有满足或一些糟糕的情况出现时调用 C 函数 abort(由它来发送此信号)。
当 SIGABRT 出现时,控制台通常会输出大量的信息,说明具体哪里出错了。由于它是可控制的崩溃,所以可以在 LLDB 控制台上键入 bt
命令打印出回溯信息。
看门狗超时
这种崩溃通常比较容易分辨,因为错误码是固定的 0x8badf00d。(程序员幽默的把它读作 Ate Bad Food。)在 iOS 上,它经常出现在执行一个同步网络调用而阻塞主线程的情况。因此,永远不要进行同步网络调用。
日常开发中,可以使用的 crash 收集方式有:
本文主要讨论自定义捕获。
如果想要做 mach 异常捕获,需要注册一个异常端口,这个异常端口会对当前任务的所有线程有效,如果想要针对单个线程,可以通过 thread_set_exception_ports 注册自己的异常端口,发生异常时,首先会将异常抛给线程的异常端口,然后尝试抛给任务的异常端口,当我们捕获异常时,就可以做一些自己的工作,比如,当前堆栈收集等。
NSException 异常是 OC 代码导致的 crash。
NSException 异常和 Signal 信号异常,这两类都可以通过注册相关函数来捕获。
// 保存注册的 exception 捕获方法
NSUncaughtExceptionHandler * oldExceptionHandler;
// 自定义的 exception 异常处理
void ExceptionHandler(NSException * exception);
void RegisterExceptionHandler()
{
if(NSGetUncaughtExceptionHandler() != ExceptionHandler) {
oldExceptionHandler = NSGetUncaughtExceptionHandler();
}
NSSetUncaughtExceptionHandler(ExceptionHandler);
}
NSSetUncaughtExceptionHandler 用来做异常处理,功能非常有限。引起崩溃的大多数原因如:内存访问错误、重复释放等错误,它就无能为力了,因为这种错误它抛出的是 Signal。
同时值得注意
如果一个应用中注册了多个 crash 收集组件,必然会存在冲突问题。
这个时候,我们需要在注册之前判断是否已经注册过 handler,如果有注册过,需要把之前注册的 handler函数指针保存,待处理完 crash 后,再把对应的 handler 抛出去。
/**
* @brief exception 崩溃处理
*/
void ExceptionHandler(NSException * exception)
{
// 使 UncaughtExceptionCount 递增
int32_t exceptionCount = OSAtomicIncrement32(&UncaughtExceptionCount);
// 超出允许捕获错误的次数
if (exceptionCount > UncaughtExceptionMaximum) {
return;
}
// 获取调用堆栈
NSMutableDictionary * userInfo = [NSMutableDictionary dictionaryWithDictionary:[exception userInfo]];
userInfo[kUncaughtCallStackKey] = [exception callStackSymbols];
NSException * exp = [NSException exceptionWithName:exception.name
reason:exception.reason
userInfo:userInfo];
// 在主线程中执行方法
[[[UncaughtExceptionHandler alloc] init] performSelectorOnMainThread:@selector(dealException:)
withObject:exp
waitUntilDone:YES];
// 调用保存的 handler
if (oldExceptionHandler) {
oldExceptionHandler(exception);
}
}
Signal 信号是由 iOS 底层 mach 信号异常转换后以 signal 信号抛出的异常。
既然是兼容 posix 标准的异常,我们同样可以通过 sigaction 函数注册对应的信号。
因为 signal 信号有很多,有些信号在 iOS 应用中也不会产生,我们只需要注册常见的几类信号:
信号
值
介绍
场景
SIGILL
4
非法指令
1. 执行了非法指令.
2. 通常是因为可执行文件本身出现错误或者试图执行数据段.
3. 堆栈溢出时也有可能产生这个信号.
SIGABRT
6
调用abort
程序自己发现错误并调用 abort 时产生,一些 C 库函数(如:strlen)
SIGSFPE
8
浮点运算错误
如:除 0 操作
SIGSEGV
11
段非法错误
1. 试图访问未分配给自己的内存
2. 或试图往没有写权限的内存地址写数据
3. 空指针
4. 数组越界
5. 栈溢出等
下面注册一个 SIGABRT 信号,在注册 handler 之前,需要保存之前注册的 hander:
typedef void (* SignalHandlerClass)(int, struct __siginfo *, void *);
// 已注册的 singal 捕获方法
SignalHandlerClass oldSignalHandler;
static void MySignalHandler(int signal, siginfo_t* info, void* context) {
// do something。。。
if (signal == SIGABRT) {
if (oldSignalHandler) {
oldSignalHandler(signal, info, context);
}
}
}
void registerSignalHandler()
{
// 获取已注册的 handler
struct sigaction old_action;
sigaction(SIGABRT, NULL, &old_action);
if (old_action.sa_flags & SA_SIGINFO) {
SignalHandlerClass handler = old_action.sa_sigaction;
if (handler != MySignalHandler) {
oldSignalHandler = handler;
}
}
struct sigaction action;
action.sa_sigaction = MySignalHandler;
action.sa_flags = SA_NODEFER | SA_SIGINFO;
sigemptyset(&action.sa_mask);
sigaction(signal, &action, 0);
}
调用堆栈的收集我们可以利用系统 api,也可以参考 PLCrashRepoter 等第三方实现获取所有线程堆栈。使用系统 api 关键代码如下:
+ (NSArray *)backtrace
{
/* 指针列表。
①、backtrace 用来获取当前线程的调用堆栈,获取的信息存放在这里的 callstack 中
②、128 用来指定当前的 buffer 中可以保存多少个 void* 元素
*/
void * callstack[128];
// 返回值是实际获取的指针个数
int frames = backtrace(callstack, 128);
// backtrace_symbols 将从 backtrace 函数获取的信息转化为一个字符串数组,每个字符串包含了一个相对于 callstack 中对应元素的可打印信息,包括函数名、偏移地址、实际返回地址。
// 返回一个指向字符串数组的指针
char **strs = backtrace_symbols(callstack, frames);
NSMutableArray * backtrace = [NSMutableArray arrayWithCapacity:frames];
for (int i = 0; i < frames; i++) {
[backtrace addObject:[NSString stringWithUTF8String:strs[i]]];
}
free(strs);
return backtrace;
}
通过系统 api 获取的堆栈信息可能只是一串内存地址,很难从中获取有用的信息协助排查问题,因此,需要对堆栈信息符号化。
// 未符号化前
Thread 0 name: Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0 libobjc.A.dylib 0x000000018b816f30 0x18b7fc000 + 110384 (objc_msgSend + 16)
1 UIKit 0x0000000192e0a79c 0x192c05000 + 2119580 (<redacted> + 72)
2 UIKit 0x0000000192c4db48 0x192c05000 + 297800 (<redacted> + 312)
3 UIKit 0x0000000192c4d988 0x192c05000 + 297352 (<redacted> + 160)
4 QuartzCore 0x00000001900d6404 0x18ffc5000 + 1119236 (<redacted> + 260)
// 符号化后
Thread 0 name: Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0 libobjc.A.dylib 0x000000018b816f30 objc_msgSend + 16
1 UIKit 0x0000000192e0a79c -[UISearchDisplayController _sendDelegateDidBeginDidEndSearch] + 72
2 UIKit 0x0000000192c4db48 -[UIViewAnimationState sendDelegateAnimationDidStop:finished:] + 312
3 UIKit 0x0000000192c4d988 -[UIViewAnimationState animationDidStop:finished:] + 160
4 QuartzCore 0x00000001900d6404 CA::Layer::run_animation_callbacks(void*) + 260
符号化的思路是找到当前应用对于的 dsym 符号表文件,利用 symbolicatecrash(Xcode 的 Organizer 内置了)、dwarfdump,atos 等工具还原 crash 堆栈内存地址对应的符号名。需要注意,如果应用中使用了自己或第三方的动态库,应用崩溃在动态库 Image 而不是主程序 Image 中,我们需要有对应动态库的 dsym 符号表才能符号化。
思路明确之后,接下来面临的是两个问题。一个问题是如何把当前 crash 的应用和 dsym 符号表对应上。另一个问题是如何通过内存地址符号化。在解决这两个问题之前,我们需要先了解可执行文件的二进制格式和加载过程。
不同操作系统都会定义不同的可执行文件格式。如 Linux平台的 ELF 格式,Windows 平台的 PE 格式,iOS 的可执行文件格式被称作 Mach-O。可执行文件、动态库、dsym 文件都是这种文件格式。
下图是官方的 Mach-O 格式结构:
可以看到,Mach-O 文件分为三部分。
header
hander 定义了文件的基本信息,包括文件大小、文件类型、使用的平台等信息。我们可以从 loader.h 头文件中找到相关定义:
/*
* The 64-bit mach header appears at the very beginning of object files for
* 64-bit architectures.
*/
struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};
load commands
这一部分定义了详细的加载指令,指明如何加载到内存。
从头文件定义可以看到,基础的 load_command 结构体只包含了 cmd 以及 cmdsize。通过 cmd 类型,可以转义成不同类型的 load command 结构体:
struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize; /* total size of command in bytes */
};
数据部分
包括了代码段、数据段、符号表等具体的二进制数据。
我们可以用 otool 查看二进制文件的具体内容,更直观的,可以用 Mach-O View 来浏览可执行文件的具体内容。
下图是一个可执行文件与其所对应的符号表文件。可执行文件的 load command 比较多,里面包含了有代码段、数据段、函数入口、加载动态库等指令。其中的 LC_UUID 字段和符号表中的 LC_UUID 是完全对应的,也就是说,可以通过 UUID 字段匹配可执行文件和 dsym 符号表。
一个 iOS 应用的加载过程是这样的:
在这个过程中,操作系统为了安全考虑,使用了 ASLR 技术。地址空间布局随机化(Address space layout randomization),就是每次应用加载时,使用随机的一个地址空间,这样能有效防止被攻击。
VM Address 是编译后 Image 的起始位置,Load Address 是在运行时加载到虚拟内存的起始位置,Slide 是加载到内存的偏移,这个偏移值是一个随机值,每次运行都不相同,有下面公式:
Load Address = VM Address + Slide
由于 dsym 符号表是编译时生成的地址,crash 堆栈的地址是运行时地址,这个时候需要经过转换才能正确的符号化。crash 日志里的符号地址被称为 Stack Address,而编译后的符号地址被称为 Symbol Address,他们之间的关系如下:
Stack Address = Symbol Address + Slide
符号化就是通过 Symbol Address 到 dsym 文件中寻找对应符号信息的过程。
当前采集到的 crash 日志,报错地址 Stack Address 位于 0x1046eea14,相对 Load Address 0x1046e8000 偏移了 27156。这里的 27156 并不是 ASLR 的随机偏移Slide,而是符号相对位置offset(Symbol Address - VM Address):
再观察 crash 日志最后有一栏 Binary Images,记录了所有加载 image 的 UUID 和加载的 Load Address:
根据前文提到的 UUID 对应关系以及 Load Address 和 Symbol Address 的转换关系,只要能获取 Binary Images 信息,就可以实现符号化。
UUID 存放在 Mach-O 的 load command 中,对应 uuid_command 结构体的 uuid 字段,可以通过遍历所有 load command 获取。
Slide 偏移可以通过 image_dyld_get_image_vmaddr_slide 方法遍历所有 Image 获取。
VM Address 也存放在 load command 中,对应 segment_command 结构体的 vmaddr 字段,需要注意 segment_command 存在多种类型以及需要区分32位和64位应用的细微差别。
解析代码如下:
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
uint64_t vmbase = 0;
uint64_t vmslide = 0;
uint64_t vmsize = 0;
uint64_t loadAddress = 0;
uint64_t loadEndAddress = 0;
NSString *imageName = @"";
NSString *uuid;
const struct mach_header *header = _dyld_get_image_header(i);
const char *name = _dyld_get_image_name(i);
vmslide = (i);
imageName = [NSString stringWithCString:name encoding:NSUTF8StringEncoding];
BOOL is64bit = header->magic == MH_MAGIC_64 || header->magic == MH_CIGAM_64;
uintptr_t cursor = (uintptr_t)header + (is64bit ? sizeof(struct mach_header_64) : sizeof(struct mach_header));
struct load_command *loadCommand = NULL;
for (uint32_t i = 0; i < header->ncmds; i++, cursor += loadCommand->cmdsize) {
loadCommand = (struct load_command *)cursor;
if(loadCommand->cmd == LC_SEGMENT) {
const struct segment_command* segmentCommand = (struct segment_command*)loadCommand;
if (strcmp(segmentCommand->segname, SEG_TEXT) == 0) {
vmsize = segmentCommand->vmsize;
vmbase = segmentCommand->vmaddr;
}
} else if(loadCommand->cmd == LC_SEGMENT_64) {
const struct segment_command_64* segmentCommand = (struct segment_command_64*)loadCommand;
if (strcmp(segmentCommand->segname, SEG_TEXT) == 0) {
vmsize = segmentCommand->vmsize;
vmbase = (uintptr_t)(segmentCommand->vmaddr);
}
}
else if (loadCommand->cmd == LC_UUID) {
const struct uuid_command *uuidCommand = (const struct uuid_command *)loadCommand;
NSString *uuidString = [[[NSUUID alloc] initWithUUIDBytes:uuidCommand->uuid] UUIDString];
uuid = [[uuidString stringByReplacingOccurrencesOfString:@"-" withString:@""] lowercaseString];
}
}
loadAddress = vmbase + vmslide;
loadEndAddress = loadAddress + vmsize - 1;
}
// do something...
通过上述代码,我们可以采集到和系统一样的 crash 日志。接下来,可以使用 dwarfdump 和 atos 进行符号化。
`Looking up address: 0x0000000100006a14 in .debug_info… found! 0x0003ebb7: Compile Unit: length = 0x000000d4 version = 0x0004 abbr_offset = 0x00000000 addr_size = 0x08 (next CU at 0x0003ec8f) 0x0003ebc2: TAG_compile_unit [120] *
AT_producer( "Apple LLVM version 9.1.0 (clang-902.0.39.2)" )
AT_language( DW_LANG_ObjC )
AT_name( "/Users/worthyzhang/Desktop/mytest/mytest/ViewController.m" )
AT_stmt_list( 0x00009151 )
AT_comp_dir( "/Users/worthyzhang/Desktop/mytest" )
AT_APPLE_optimized( true )
AT_APPLE_major_runtime_vers( 0x02 )
AT_low_pc( 0x00000001000069bc )
AT_high_pc( 0x000000a4 ) 0x0003ebf9: TAG_subprogram [122] *
AT_low_pc( 0x00000001000069bc )
AT_high_pc( 0x00000070 )
AT_frame_base( reg29 )
AT_object_pointer( {0x0003ec12} )
AT_name( "-[ViewController viewDidLoad]" )
AT_decl_file( "/Users/worthyzhang/Desktop/mytest/mytest/ViewController.m" )
AT_decl_line( 17 )
AT_prototyped( true )
AT_APPLE_optimized( true )
Line table dir : '/Users/worthyzhang/Desktop/mytest/mytest'
Line table file: 'ViewController.m' line 25, column 1 with start address 0x0000000100006a14 Looking up address: 0x0000000100006a14 in .debug_frame… not found.
可以定位到报错所在的函数名 [ViewController viewDidLoad] 以及文件名、行号等信息。
</code></pre>
<ol>
<li><p>atos</p>
<p>如果只是简单的获取符号名,可以用 atos 来符号化,命令格式如下:</p>
<pre class="line-numbers">atos -o [dsym file path] -l [Load Address] -arch [arch type] [Stack Address]
</pre>
<p>需要注意这里的 dsym file path 是 dsym 文件而不是 .dSYM 结尾的文件夹,输入命令:</p>
<pre class="line-numbers">$ atos -o mytest.app.dSYM/Contents/Resources/DWARF/mytest -l 0x1046e8000 --arch arm64 0x1046eea14
-[ViewController viewDidLoad] (in mytest) (ViewController.m:25)
</pre>
<p>得到结果和dwarfdump是一致的。</p></li>
</ol>
<h4 id="toc_11">4.5 常见错误标识</h4>
<ol>
<li><p>Exception codes</p>
<ul>
<li>0x8badf00d 错误码:Watchdog 超时,意为“ate bad food”。</li>
<li>0xdeadfa11 错误码:用户强制退出,意为“dead fall”。</li>
<li>0xbaaaaaad 错误码:用户按住 Home 键和音量键,获取当前内存状态,不代表崩溃。</li>
<li>0xbad22222 错误码:VoIP应用(因为太频繁?)被iOS干掉。</li>
<li>0xc00010ff 错误码:因为太烫了被干掉,意为“cool off”。</li>
<li>0xdead10cc 错误码:因为在后台时仍然占据系统资源(比如通讯录)被干掉,意为“dead lock”。</li>
</ul></li>
<li><p>Exception types</p>
<ul>
<li>SIGSEGV(Segmentation Violation,段违例):表明内存操作不当,比如访问一个没有权限的内存地址。当我们收到 SIGSEGV 信号时,可以往以下几个方面考虑:
<ul>
<li>访问无效内存地址,比如访问Zombie对象;</li>
<li>尝试往只读区域写数据;</li>
<li>解引用空指针;</li>
<li>使用未初始化的指针;</li>
<li>栈溢出;</li>
</ul></li>
<li>SIGABRT:收到 Abort 信号,可能自身调用 abort() 或者收到外部发送过来的信号;</li>
<li>SIGBUS:总线错误。与 SIGSEGV 不同的是,SIGSEGV 访问的是无效地址(比如虚存映射不到物理内存),而 SIGBUS 访问的是有效地址,但总线访问异常(比如地址对齐问题);</li>
<li>SIGILL:尝试执行非法的指令,可能不被识别或者没有权限;</li>
<li>SIGFPE:Floating Point Error,数学计算相关问题(可能不限于浮点计算),比如除零操作;</li>
<li>SIGPIPE:管道另一端没有进程接手数据;</li>
</ul></li>
</ol>
<h2 id="toc_12">五、问题</h2>
<ol>
<li><p>Debug 环境正常,Release 环境崩溃</p>
<p>属性内存语义错误,如 NSArray 使用 assign 修饰,导致访问了释放掉的内存地址。</p></li>
<li><p>闪退</p>
<ul>
<li>数据库损坏:在日常使用异常退出、断电或者错误的操作</li>
<li>文件损坏:处理文件时如果没有 @try...catch,损坏文件会抛出 NSException 导致 crash</li>
<li>网络返回数据异常:数据类型不对,或返回破损的 Tar 包,在解压失败导致 crash。</li>
<li>代码 bug:当必 crash 的代码出现在启动关键路径中,就会导致连续闪退。</li>
</ul>
<p>解决:</p>
<ul>
<li>通过工具修复数据库,或者删除 DB。</li>
<li>删除文件来进行修复</li>
<li>具体地分析 crash 案例,通过 JSPatch 来进行修复。</li>
</ul></li>
<li><p>数组越界,nil 值初始化导致的崩溃。</p></li>
<li><p>对字典插入 nil 值,或者读取 NSNULL 导致的崩溃。</p></li>
<li><p>字符串的截取越界导致的崩溃。</p></li>
<li><p>doesNotRecognizeSelector 导致的崩溃。</p></li>
<li><p>子线程初始化 UIView 导致的崩溃。</p></li>
<li><p>KVO的重复添加、删除,或者忘了删除导致的崩溃。</p></li>
</ol>
<h2 id="toc_13">六、文章</h2>
<p><a href="https://www.jianshu.com/u/796e9139e445">悟行Worthy</a> & <a href="https://www.jianshu.com/p/ea8926762121">iOS实现Crash捕获与堆栈符号化</a><br/>
<a href="https://www.objc.io/issues/6-build-tools/mach-o-executables/">Mach-O Executables</a><br/>
<a href="https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/MachOTopics/0-Introduction/introduction.html#//apple_ref/doc/uid/TP40001827-SW1">Mach-O Programming Topics</a><br/>
<a href="https://www.jianshu.com/p/953f0961157a">iOS崩溃堆栈信息的符号化解析</a><br/>
<a href="https://www.jianshu.com/u/2b54d468523d">oncezou</a> & <a href="https://www.jianshu.com/p/5fcf7bb7955f">iOS Crash的捕获知识</a><br/>
<a href="https://www.jianshu.com/p/ca95fdee78d8">iOS 崩溃处理(拦截和捕获)</a><br/>
<a href="https://blog.csdn.net/weixin_38633659/article/details/82496635">iOS开发:Crash异常总结与捕获</a></p>
`
手机扫一扫
移动阅读更方便
你可能感兴趣的文章