Crashlog解析
对于从事iOS移动端测试的同学来说,应用crash十分常见。对于必现crash,通过必现路径复现crash就能定位问题,但对于偶现crash、压力测试crash或者线上crash,就只能通过crashlog来分析,本文介绍iOS端crashlog解析的一些通用知识点。 (做个笔记,以百度地图为例并不适用所有)
1. Crashlog获取方式
1.1 连接Xcode直接读取crashlog
Mac电脑安装Xcode开发平台,通过USB线连接手机与电脑,点击Xcode→Window→De vices and Simulators进入如下界面 (以Xcode9为例)。
图1.1
再点击View Device Logs,读取手机中的crashlog文件。
图1.2
找到对应时间点且process名称为对应app(比如百度地图为IphoneCom)的crashlog文件,右键导出即可。
图1.3
1.2 通过手机直接复制
如果是线上用户主动反馈的crash,那么可能就无法通过上述方式获取crashlog文件,可以让用户进入隐私→诊断与用量→诊断与用量数据查看本地crashlog(iOS10),找到对应时间点且process名称为对应app(比如百度地图为IphoneCom)的文件,并通过手动复制后发送给我们,然后保存成.crash文件。这里有几点需要说明:
① 查看本地crashlog的路径在不同的iOS版本上有不同的名称。
② 如果找到对应时间点的文件,但文件名不是process名称,而是以jetsamevent开头,那可能是应用消耗了太多内存,被系统kill掉。
1.3 Crashlog收集
对于线上app,用户主动反馈毕竟少数,那么如何获取线上crashlog呢?有以下方式
① 主动回传
如果用户在设置→隐私→诊断与用量中选择了自动发送并与应用开发者共享(不同系统版本路径不同),那么app发生crash后会将crashlog自动回传给Apple。开发者可通过iTunes Connect(Manage Your Applications -View Details -Crash Reports)获取用户的crash日志。
② 应用内实现崩溃收集,并上传至服务端
开发者在app启动时增加异常捕获监听器,处理app发生崩溃时的回调动作,回调函数中保存自己需要的崩溃信息,如堆栈、寄存器、线程信息等,并选择合适时机(比如下一次app启动)上传。
③ 第三方收集平台
目前较成熟的收集平台有Crashlytics、Flurry、Bugly、友盟等,他们对系统产生的crashlog进行了又一次封装和提取,并上传至服务端解析。平台界面友好、收集和管理机制完善。
2. Crashlog文件结构
2.1 常见的crashlog文件结构
crashlog导出后通常是后缀名称为.crash的文件,如果后缀为.ips(手机端苹果日志文件),可以将其后缀名直接改成.crash,文件结构与.crash文件完全相同。
2.1.1基本信息
crashlog基本信息部分如下图所示。
图2.1
Incident Identifier:crashlog文件唯一标识符,类似于苹果设备的udid。
CrashReporter Key:与发生崩溃设备相对应的唯一标识(注意不是设备的udid),也就是说同一设备上产生的所有crashlog文件,该字段的值是一样,当无法区分哪些crash是发生在同一个设备上时,可以根据它来判断。
图2.2
Hardware Model:标识硬件设备类型,如果有许多崩溃日志该字段相同,那么有可能是某种特定机型的问题。
Process:发生崩溃的进程名称和对应的processID。
Path:发生崩溃应用的文件路径。
Indentify、version、Code Type:发生崩溃应用的bundleID、版本、编码类型。
Role:发生崩溃时应用是在前台还是后台,这是一个复现和分析crash的重要信息。有些问题是在应用退到后台时才会发生,比如百度导航压力测试时,如果该字段为backforeground,可以猜想是否是长时间后台导航导致crash。
Date/Time:发生崩溃的具体时间。
Launch/Time:应用开始运行的时间。如果想要知道应用在运行多久后发生了崩溃,可以用Launch/Time- Date/Time。
OS Version:操作系统版本,与Hardware Model字段类似,如果有许多崩溃日志该字段相同,那么有可能是某种系统的问题。
2.1.2 异常信息
异常信息包括异常类型(exception type)、异常子类型(exception subtype)、异常编码(exception code)、异常描述(exception note)异常线程号(Triggered by Thread)和其它异常信息,crashlog文件会给出以上几种或者全部异常字段。
图2.3
1) 异常类型
异常类型对应的字段是Exception type,一般包含两个元素:Mach异常+Unix信号(括号中的内容),Mach是XNC内核核心,Mach异常是内核级异常,Mach异常在host层被ux_exception转换为响应的Unix信号,并通过thread signal将信号投递至出错的线程。
Crashlog中常见的Mach异常有以下几种:
① EXC_BAD_EXCESS:该类异常通常是由于访问了不该访问的内存导致。
② EXC_CRASH:app进行了系统不支持的操作。
③ EXC_BAD_INSTRUCTION:执行非法指令。
④ EXC_ARITHMETIC:计算异常,比如除零操作。
⑤ EXC_RESOURCE:该种异常并非一个真正的crash,而是系统告诫进程正在使用的资源过多。如果subtype类型为wakeup,就表示某个线程被唤醒次数过多。如果subtype类型为memory,就表示超过了系统内存限制,可能会导致系统强制Kill进程。
⑥ EXC_GUARD:进程侵犯了被保护的资源。
⑦ EXC_BREAKPOINT:断点调试时异常退出。
Crashlog中常见的Unix信号有以下几种:
① SIGSEGV:访问了未分配的内存或者往没有写权限的内存地址中写数据,比如重复释放对象,多线程操作同一个对象。
② SIGABRT:收到了abort信号中止进程,比如调用了未实现的方法或者数组越界。
③ SIGBUS:总线访问错误,访问地址有效,但是地址未对齐等。
④ SEGILL:执行了非法指令。
⑤ SEGKILL:立即结束进程运行的信号,比如内存不足被Kill掉。
⑥ SEGTRAP:由断点指令或者其他trap指令产生。
⑦ SEGFPE:浮点异常
Unix信号还有很多,此处仅列举Crashlog文件中常见的信号类型。
2) 异常编码
异常类型对应的字段是exception code,通过异常编码可以大致确认crash引发的原因,常见的异常编码有一下几种。
① 0x8badf00d:程序被watchdog终止掉,比如程序启动或者恢复超时。
② 0xbad2222:指VoIP应用(后台网络)在后台过于频繁的被激活。
③ 0xbaaaaaad:代表该crash并非一个真正的crash,仅仅保存了应用某一个时刻的运行状态。
④ 0xdeadfa11:程序无响应,用户强制Kill。
⑤ 0xc00010ff:程序执行大量耗费CPU和GPU运算,导致设备过热,被系统中止。
⑥ 0xdead10cc:程序退至后台,仍然占用着系统资源。
2.1.3 线程回溯信息
异常信息后面显示的是线程回溯信息,记录了发生崩溃时每个线程的调用堆栈,是crashlog文件的主体,后面会重点介绍。
图2.4
2.1.4 线程状态
该部分保存发生crash时寄存器中的值。
图2.5
2.1.5 二进制映像
该部分列出了发生crash之前已经成功加载的二进制文件。
图2.6
2.2 其它类型crashlog文件
除了常见的crashlog文件外,还有其它不同结构的crashlog文件,比如低内存、CPU Usage等。低内存crashlog的 process名称和type一般为unknown,文件结构中没有线程回溯信息,如下所示。
① 基本信息,包括识别号、操作系统版本等。
② 内存分配信息和当前占用内存最多的进程。
③ 每个进程所使用的内存信息,包括所使用的内存页数、当前状态等。
图2.7
低内存崩溃无法从crashlog中获取有效的定位信息,可进一步分析进程内存分配方式,或者使用instruments的Allocation和Leak工具来发现问题。
3. Crashlog解析
以上介绍了crashlog文件结构和文件类型,细心的同学会发现,2.1.3线程回溯信息中的调用堆栈都是二进制地址,而不是可读的函数名称,如下图所示,因此需要对crashlog进行解析。
图3.1
crashlog解析需要调试符号表文件dsym(debugging symbols),dsym文件实际上是从Mach-O文件抽取调试信息得到的文件目录。在编译工程时,debug模式会默认选中生成dsym文件,该配置可在Build Setting|Build Option中更改。dsym文件生成比较耗时,如果不需要进行crashlog解析,可以选择不生成。
图3.2
3.1 解析方法
3.1.1 Xcode解析
将crashlog、dsym文件和可执行文件放在同一目录下,然后将crashlog拖拽至Devicelog中,右键Re-symbolicate Log就能解析。
图3.3
3.1.2 使用symbolicatecrash命令行解析
symbolicatecrash是xcode自带的命令行解析工具,解析步骤如下。
① 找到命令行工具symbolicatecrash
首先给xcode打个补丁,终端运行命令:
再运行下面的命令:
然后获取路径:
symbolicatecrash路径一般为:/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash
② 命令行解析
将crashlog、dsym文件和可执行文件放在同一目录下,并将symbolicatecrash也复制到该目录下,如果是首次使用命令行解析,那么需要cd到当前目录,运行:
再运行 :
命令运行完成后,即可实现解析crashlog。
以上解析的过程都是自动完成的,因为xcode已经提供了这种能力,但是解析原理究竟是怎样呢?
3.2 解析原理
3.2.1 dsym文件介绍
dsym文件在.xcarchive目录中的层级结构为 :
图3.4
其中真正保存保存数据的是DWARF文件, DWARF(Debuging With ArbitraryFormat)是ELF和Mach-O等文件格式中用来存储和处理调试信息的标准格式。DWARF中的数据是高度压缩的,可以通过dwarfdump命令提取可读信息,比如提取关键的调试信息.debug_info、.debug_line。
注释:ELF、Mach-O用于存储二进制文件、可执行文件、目标代码和共享库的格式文件。
3.2.2 解析流程
1) 计算崩溃地址对应符号表中的地址
以百度地图(IphoneCom)某crashlog文件为例,如下图所示。崩溃发生在1号线程,从下至上依次为该线程的调用堆栈,右边红色框第一列为运行时的堆栈地址,第二列为进程运行时的起始地址(IphoneCom所有行起始地址都相同),第三列为运行时的偏移地址
图3.5
运行时堆栈地址=运行时起始地址+偏移地址,以第13行为例。
0x0000000102ee5d58=0x10273c000+0x7a9d58(8035672)
以上地址均为app发生崩溃时的运行地址,根据虚拟内存偏移地址不变的原理,只要知道符号表TEXT段的起始地址,加上偏移量(0x7a9d58)就能得到崩溃地址对应符号表中的地址,符号表TEXT段的起始地址可通过以下命令获得。
图3.6
那么崩溃地址(0x0000000102ee5d58)对应符号表中的地址为:
0x00000001007a9d58 =0x0000000100000000+0x7a9d58
2) 地址重映射
获取符号表地址后,在debug-info章节中查找包含该地址的DIE(Debug Information Entry)单元就能获知该符号地址对应的函数名称(name)、函数所在的文件路径(decl file)和函数所在行数(decl line),如下图所示。
图3.7
上述步骤解析出了函数相关信息,下面进一步获取该地址对应的准确行数,这需要借助debug_line章节,debug_line章节以文件为单位,准确记录了文件中的每一行对应的符号表地址,0x00000001007a9d58对应BNaviModel.mm的第5110行。
图3.8
下图给出了解析前后的crashlog文件的对比图。
图3.9
3.3 手动解析crashlog
当有完整的crashlog文件和对应的dsym文件时,以上过程可以由xcode自动完成。但对于用户反馈的crash,需要用户手动复制本地的crashlog文件,而通常crashlog文本较长,完整复制其实比较麻烦,那么此时可以只复制崩溃线程的crash信息,并通过手动解析。手动解析crash可以使用dwarfdump、atos工具,命令如下。
方法一:
方法二:
方法三:
方法二、三都使用了atos解析,区别是方法三不需要获取符号表地址,其后倒数第一个地址为运行时堆栈地址,倒数第二个地址为进程起始地址。
手动解析另一个应用场景是,若开发人员为了跟进某一偶现问题在日志中记录的是运行时的二进制地址,那么可以通过对应的dsym文件手动解析出调用函数明文。
4. Crashlog定位方法
本段内容以百度地图导航组件为分析主体,其它团队同学可借鉴。由于百度地图架构复杂,各组件、功能模块之间耦合性强,app发生crash后,需要根据解析后的crashlog快速定位崩溃发生在哪一层,比如导航客户端上层、导航客户端引擎层、地图基线或者其它组件,节省crashlog流转成本,下面举例说明crashlog的定位流程。
4.1 C/C++层面crash——导航客户端引擎
如图4.1所示,按照之前介绍的思路,解析后首先找出以IphoneCom开头的行,从下至上查看调用堆栈,调用方法都以navi开头,由此准确判断为导航客户端组件crash。导航客户端由引擎和上层组成,引擎使用c/c++语言开发,上层使用Ojecitive-c开发,从方法的书写方式(类名::方法名称())可知这是c/c++语言风格,因此该crashlog属于导航客户端引擎。
图4.1
crash发生在25号线程,从堆栈调用信息来看是在检查是否静默偏航时(第6行)判定路线信息是否可用(第1行)时发生了crash,位于routeplan_result.cpp文件第5552行,如下图所示。
图4.2
该行代码中调用了获取leg数据大小的方法(m_clLegs.GetSize),m_clLegs是路线数据类的私有成员变量。Crash发生在偏航后,由此猜测是否是在偏航时就已清空所有路线数据。
备注:偏航是指用户偏离了规划路线行驶,触发重算路。
图4.3
查看偏航代码,发现确实在偏航后会清空路线数据。
(a)
(b)
图4.4
因此crash原因是,调用方法时对象已经不存在。修复的方式是增加保护,在检查静默偏航时,判定路线数据是否存在。
图4.5
总结:对于保存重要数据的变量,使用之前要先判空。
4.2 Objective-C层面crash——导航客户端上层
同1) 中的步骤找到以IphoneCom开头的行,由方法调用方式[对象/类名 方法名称](Objective-C)和BNavi字段可以立即判断为导航客户端上层crash,crash最终发生在0号线程(主线程)第4行,调用的方法为[BNOperation addOperationWithID:params:inNavi] ,位于BNOperationStorage.m文件第151行。从异常信息类型(Exception Type:SIGSEGV)可知该crash可能是由于访问了未分配的内存或者往没有写权限的内存地址中写数据。
图4.6
BNOperationStorage.m是用于存储用户操作的文件,第151行代码图4.7所示,是在向operationstring可变字典拼接“:”,该字典用于存储用户的操作序列。这一行代码看起来非常简单,没有任何嵌套的函数调用。根据异常信息类型作进一步假设:可能是其它子线程也正在对operationstring进行操作。
图 4.7
重新梳理堆栈调用场景,发现是在进入导航结束页(第6行)时添加“进入导航结束页”这一操作时发生crash,而进入导航结束页前需要退导航,因此查看退导航方法(doBeforeExitNaviUI)。发现退导航时会统一存储之前导航中用户所有操作,而这一操作却被放在了子线程中,如图4.8所示。
最终真相大白,crash的原因是在不同的线程中操作了同一对象operationstring。退导航时子线程对operationstring的写操作还没完成,主线程加载导航结束页时又对变量进行访问,导致了主线程crash,异常类型为SIGSEGV。
图 4.8
修复的方法是将退导航时添加统计的操作重新放回主线程,或者对全局变量操作加锁。
图 4.9
总结:尽量避免在不同的线程中操作同一对象。
4.3第三方库crash—— IphoneComFramework
IphoneComFramework是IphoneCom的一个自定义的第三方库,其中集合了IphoneCom自身的一些通用工具(PB协议)、依赖的系统库(如用于音频播报的AVFoundation)等,因此编译时除了会生成IphoneCom.app.dsym文件,还会生成IphoneComFramework.framework. dsym。对于包含自定义第三方库的应用,crashlog解析时需要将.app.dsym、.framework.dsym、.crash和可执文件放置于同一目录下,然后使用3.1节中的方法进行解析。
图 4.10
下面给出IphoneComFramework的一个崩溃实例。崩溃发生在0号线程,堆栈调用场景是在导航中通过语音指令更改终点,绘制终点POI水滴时调用IphoneComFramework中的接口获取POI描述信息时发生了crash。
图4.11
crash最终发生在BMPoiResultEntity.m文件第18048行。
图 4.12
该行代码是在遍历boundArray变量指向的对象,对象类型为PBAppendableArray。根据异常信息提示,收到了abort信号,因此crash原因可能是遍历该数组时,内存已经被回收或者不可用。这是一个非常偶现的crash,出现场景是在低端机上连续多次通过语音更改终点绘制poi水滴。事实上,[BNaviModel(VoiceCommand) showPoiOnMap]是导航客户端在poi水滴绘制时调用的一个冗余方法,去除即可。
另外,地图基线crash类名一般以BM(Badui Map)开头,但是由于导航组件耦合了地图基线代码,并且都使用Objective-c、C/C++开发,因此并非以BM开头的crash都为地图基线crash,可以从堆栈调用或者类名确认是否与导航组件相关。若相关,建议先交由RD进一步确认后再流转。
总之,根据crashlog中的异常信息(异常类型、异常编码、异常线程等),各组件及功能模块的代码编写风格、命名方式,以及自身对业务和代码的熟悉度,就能准确分发和定位crash发生的原因。
5. 常见问题
1) 如何找到crashlog对应的dsym文件?
打开终端,使用以下命令获取dsym文件对应的uuid,并与crashlog文件Binary Image后面的字符对比,如果字符完全相同,就说明dsym文件与crashlog对应。
图5.1
2) 如何手动生成dsym文件?
如果在编译之前忘记在buildsetting中选中生成dsym文件,然而app又发生了崩溃,那么可以通过app的可执行文件再手动生成dsym文件。
图 5.2
值得注意的是,只有可执行文件为debug模式产物时,才能使用上述方式手动抽取调试符号表文件(dsym),release模式无法抽取。 因为debu*物会保存调试信息,而release产物不会,dsym文件就是从调试信息中抽取出来的。
3) 手机系统没有生成crashlog怎么办?
有时候app明明发生了crash,但连上xcode后却无法找到对应时间点的crashlog。这种情况可能是由于系统限制了自动生成第三方app的crashlog文件数目,比如只能产生25份crashlog,那么可以通过清空手机端的crashlog文件解决,有以下方式。
方法一:手动清除
手机连接mac电脑,摘要中点击重设警告,再同步即可。
图5.3
方法二:基于smallapple的脚本清除
smallapple是百度云测试部开源在github上的iOS测试自动化框架,该框架提供了设备文件获取和清除的命令行工具iosutil。基于此,可编写脚本获取设备所有crash文件,并逐个删除,实现crashlog文件的清理。