iOS开发之Crash追踪之旅(一)
序: 最近在日常开发中遇到了一次Crash引起的Crash的血灾,在5月初的一次发版把笔者开发的App的Crash率直接从万一干到了接近千二,当时项目负责人正好需要向上报告项目QA相关情况,当时就懵逼了😂。
问题
由于年初花了大功夫把原来OC为主体的项目完全迁移到Swift,由于Swift的安全性,crash保持的一直不错,忽然这一出搞的也挺懵,查了一下UMeng的crash追踪,全是报Attempted to dereference garbage pointer 0x18ffd63d72d0,符号化之后的crash函数调用栈也挺迷的,在不定线程crash,app的函数符号定位都是在一个模型类的0行。
整理了一下相关的crash log 和umeng上的用户行为,知道应该是碰到了内存问题或野指针了,这种伤脑的问题如果只是几个零星的crash就果断放后面解决了,但是千分之二的crash直接影响了饭碗问题,只能硬着头皮去解决,也是为了重塑曾今的那钟对技术极致追求的精神。
结果
在整理了Umeng的Crash Log和行为日志以及App Connent用户上传的Crash Log,可以确定是由于野指针/内存问题引起的随机Crash,并且整理到以下线索
- 随机crash出现在app运行5分钟之后的占比很高
- 刚冷启动时(用户轨迹只有adviewcontroller)也会发生crash的,说明有问题的代码应该在启动那块执行
- 和idfa的获取方式变化有关,因为新版本(2.1.0)由于idfa政策变化被拒过,因此可以定位到应该是集团提供的风控SDK嫌疑很大,修改提审和风控部门对过他们sdk有收集idfa,回忆在集成代码的时候发现风控sdk是使用c和c++开发的,当时改Swift集成的时候还因此加了桥接文件。
- 在高版本iOS系统和新手机(arm64e)设备上搜集到的crash多,低版本和高版本的原始crash的Exception Type是不一样的:arm64e 是 SIGSEGV arm64 是 SIGBUS
在这里吐槽下Umeng的crash收集,没有显示原始的错误,都归纳为Attempted to dereference garbage pointer,不利于排查和定位错误
解决步骤:
- 拉取2.1.0发版代码
- xcode 打开 Address Sanitizer(Asan)重新编译运行 -> buggy address 的确可以查到有内存使用问题
- 注释启动相关代码 风控sdk初始化代码,发现的确是风控sdk导致的
- 联系风控组,替换SDK,通过测试
- 等待上线验证
到这里,这次Crash问题告一段落了,但是在追踪过程中查看和回归了以前很多相关的底层技术和工具,在解决问题后再次坐下深入的总结和记录。
涉及的技术点
- iOS内存管理机制: OC C C++的这方面资料很多,可以拓展去看下Swift的坐下总结
- 符号文件解析, LLDB高级调试和插件编写,ASDN相关
- iOS系统crash:Exception(mach oc) 和 unix的bsd 的signal错误
- bugly的apm工具原理和实现
- PAC(PAC技术)[https://justinyan.me/post/4129]
我会以若干篇章去深入探索下相关技术点
iOS系统中的Crash
1. Crash的分类
记得在之前文章中探索过为什么移动应用会有crash:内存管理,因为移动系统为了保护闪存而舍弃了Swap机制。
Crash的主要原因是App收到未处理的信号,iOS的核心操作系统是Darwin,Darwin内核是XNU("X is Not UNIX"),XNU是一个基于Mach+BSD的混合内核,所以引起Crash的信号可以分为三种:
- Mach异常:Mach负责XNU比较底层的任务,所以Mach异常是指底层的内核级异常,用户态的开发者可以直接通过Mach API设置thread、task和host的异常端口来捕获Mach异常
- Unix信号:又称BSD信号(XNU中的BSD发出),如果开发者没有捕捉Mach异常,则会被host层的方法ux_exception()转化为对应的Unix信号,并通过threadsignal()将信号投递到出错的线程,可以通过signal(x, SignalHandler)来捕获signal
- NSException:应用级异常,也可以认为是OC语言层面的异常,导致程序向自身发送了SIGABORT信号而crash,可以try catch捕获或者通过NSSetUncaughtExceptionHandler()机制来捕获
Swift的异常机制这方面的大佬们分享的很少 ,可以研究下Swift的错误机制
上面三个层面的Crash,语言层面的(OC)应用级的Crash是最好解决的,数组越界、 runtime的msg_send消息转发机制导致的crash,kvc等OC语言机制的crash可以通过crash log中的backtrace很快定位到。而对于Mach异常和Unix信号导致的crash则对于高级开发来说也是很大的挑战。
2. Mach异常和Unix信号
Mach异常是什么?它又是如何与Unix信号建立联系的?
// crash log头部
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x0022000000000000 -> 0x0000000000000000 (possible pointer authentication failure)
- Mach异常是XNU的微内核核心Mach运行中出现的内核级异常,每个thread、task、host(
这个host是什么?)都有一个异常端口数组,Mach的部分API暴露给用户态,用户态的开发者可以直接通过Mach API设置thread、task、host的异常端口,来捕获Mach异常。 - 所有未处理的Mach异常,都会通过ux_exception()转化为Unix信号,通过threadsignal将信号传递到出错的线程。iOS的POSIX API就是通过Mach上层的BSD层实现的。
注:Mach 最基础的对象是“主机(host)”,也就是表示机器本身的对象
如上面的贴的Crash Log头部摘自我这次Crash的日志,EXC_BAD_ACCESS(访问无效内存)异常,因为没有在Mach层捕获,被host层转化为SIGSEGV信号传递给了出错的线程。
所以:
- 未处理Mach异常是会转为Unix Signal,应用级异常未捕获也会在转为NSException, 然后调用C的Abort(),kernel对App发出__pthread_kill信号,触发Mach异常,所以只要未捕获的异常都是会转化为一条Unix信号。
- 硬件产生的信号(通过CPU的trap机制:mach_msg_trap(),陷阱这个概念在 Mach 中等同于系统调用)被Mach捕获,然后转化为Unix信号。
- Apple为了统一机制,操作系统或者用户产生的信号(kill和thread_kill)也会转化为Mach异常,最后转为Unix信号。
4. Mach异常和Unix信号的分类
常见的Mach异常
- EXC_CRASH: 进程异常退出(SIGABORT) 或者 watch dog超时杀死App(SIGKILL)
- EXC_BREAKPOINT (SIGTRAP)
- EXC_BAD_ACCESS :内存访问无效
- EXC_BAD_INSTRUCTION:线程试图访问非法/无效的指令或将无效的参数(操作数)传递给指令
- EXC_ARITMETHIC:除以0或整数溢出/下溢引发的异常
- EXC_SYSCALL 和 EXC_MACH_SYSCALL:应用程序访问内核服务(如文件I/O)或网络访问时发出
- 其他Mach异常定义在mach/exception_types.h中。与处理器相关的异常定义在mach/(i386,ppc,...)/exception.h中
在开发中最常见的异常应该是EXC_BAD_ACCESS,就比如这次追踪到的
Unix信号
信号处理函数可以通过 signal() 系统调用来设置。如果没有为一个信号设置对应的处理函数,就会使用默认的处理函数,否则信号就被进程截获并调用相应的处理函数。在没有处理函数的情况下,程序可以指定两种行为:忽略这个信号 SIG_IGN 或者用默认的处理函数 SIG_DFL 。但是有两个信号是无法被截获并处理的: SIGKILL、SIGSTOP 。
Signal信号类型:
- SIGABRT--程序中止命令中止信号
- SIGALRM--程序超时信号
- SIGFPE--程序浮点异常信号
- SIGILL--程序非法指令信号
- SIGHUP--程序终端中止信号
- SIGINT--程序键盘中断信号
- SIGKILL--程序结束接收中止信号
- SIGTERM--程序kill中止信号
- SIGSTOP--程序键盘中止信号
- SIGSEGV--程序无效内存中止信号
- SIGBUS--程序内存字节未对齐中止信号
- SIGPIPE--程序Socket发送失败中止信号
5. 模拟Mach Message发送和捕获Mach异常
5.1 Mach
Mach是XNU的微内核
Mach的几个基本概念:
Tasks: 拥有一组系统资源的对象,允许thread
在其中执行
Threads: 执行的基本单位,拥有task的上下文,并共享其资源
Ports: task之间通讯的一组受保护的消息队列,task可以对任何port发送/接收数据
Message:有类型的数据对象集合,只可以发送给Host
5.2 模拟Mach Message的发送
- 创建 post 授权
+ (mach_port_t)createPortAndListener {
// 在Mach的头文件中找到的 mach_port_t 完全等价于 mach_port_name_t
// typedef unsigned int __darwin_natural_t;
// typedef __darwin_natural_t __darwin_mach_port_name_t; /* Used by mach */
// typedef __darwin_mach_port_name_t __darwin_mach_port_t; /* Used by mach */
// typedef __darwin_mach_port_t mach_port_t;
// typedef natural_t mach_port_name_t;
// typedef __darwin_natural_t natural_t;
mach_port_t server_port;
kern_return_t kr = mach_port_allocate(mach_task_self(),
MACH_PORT_RIGHT_RECEIVE,
&server_port);
assert(kr == KERN_SUCCESS);
NSLog(@"Create a port: %d", server_port);
kr = mach_port_insert_right(mach_task_self(),
server_port,
server_port,
MACH_MSG_TYPE_MAKE_SEND);
assert(kr == KERN_SUCCESS);
return server_port;
}
- Mach 端口监听
+ (void)setMachPortListener:(mach_port_t)mach_port {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
mach_msg_header_t mach_message;
mach_message.msgh_size = 1024;
mach_message.msgh_local_port = mach_port;
mach_msg_return_t mr;
while (true) {
mr = mach_msg(&mach_message,
MACH_RCV_MSG | MACH_RCV_LARGE,
0,
mach_message.msgh_size,
mach_message.msgh_local_port,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);
if (mr != MACH_MSG_SUCCESS && mr != MACH_RCV_TOO_LARGE) {
NSLog(@"error!");
}
mach_msg_id_t msg_id = mach_message.msgh_id;
mach_port_t remote_port = mach_message.msgh_remote_port;
mach_port_t local_port = mach_message.msgh_local_port;
NSLog(@"Recevie a mach messag:[%d], remote_port: %d, local_port: %d, exception",
msg_id, remote_port, local_port);
}
});
}
- 向创建的 mach port 发送消息
+ (void)sendMachPostMessage:(mach_port_t)mach_port {
kern_return_t kr;
mach_msg_header_t msg_header;
msg_header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0);
msg_header.msgh_size = sizeof(mach_msg_header_t);
msg_header.msgh_remote_port = mach_port;
msg_header.msgh_local_port = MACH_PORT_NULL;
msg_header.msgh_id = 100;
NSLog(@"Send a mach message: [%d]", msg_header.msgh_id);
kr = mach_msg(&msg_header,
MACH_SEND_MSG,
msg_header.msgh_size,
0,
MACH_PORT_NULL,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);
}
5.3 在Mach层捕获异常
6. Signal注册和处理
7.PAC
在这次的Crash追溯的过程中,我发现:
- 在比较新的机器上(一般iOS系统版本也比较高), Crash的概率比较大
- 通过App Connect搜集到的原始Crash Log中Crash的Mach异常转化后的Signal是不一样的
比较老设备收集到的Crash Log头部:Unix Signal -> SIGSEGV
// 比较老的手机:iPhone 8
Incident Identifier: 9DCFF105-1CBE-4947-B386-68E4375EC340
Hardware Model: iPhone10,1
Process: esport-app [15056]
Path: /private/var/containers/Bundle/Application/86BE49B4-3975-45D9-AC97-CD9CABF4F7D0/esport-app.app/esport-app
Identifier: com.wmzq.esportapp
Version: 2 (2.1.0)
AppStoreTools: 12E262
AppVariant: 1:iPhone10,1:13
Beta: YES
Code Type: ARM-64 (Native)
Role: Foreground
Parent Process: launchd [1]
Coalition: com.wmzq.esportapp [2538]
Date/Time: 2021-05-06 15:08:30.2709 +0800
Launch Time: 2021-05-06 15:08:28.6146 +0800
OS Version: iPhone OS 13.7 (17H35)
Release Type: User
Baseband Version: 5.70.01
Report Version: 104
Exception Type: EXC_BAD_ACCESS (SIGBUS)
Exception Subtype: KERN_PROTECTION_FAILURE at 0x000000016c1bfdc0
VM Region Info: 0x16c1bfdc0 is in 0x16c1bc000-0x16c1c0000; bytes after start: 15808 bytes before end: 575
REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL
Stack 000000016c0ec000-000000016c1bc000 [ 832K] rw-/rwx SM=COW thread 21
---> STACK GUARD 000000016c1bc000-000000016c1c0000 [ 16K] ---/rwx SM=NUL ...for thread 22
Stack 000000016c1c0000-000000016c248000 [ 544K] rw-/rwx SM=COW thread 22
Termination Signal: Bus error: 10
Termination Reason: Namespace SIGNAL, Code 0xa
Terminating Process: exc handler [15056]
Triggered by Thread: 22
新设备收集到的Crash Log头部:Unix Signal -> SIGSEGV
// iPhone XR
Incident Identifier: 95414C75-D357-4AFC-9951-2EAE098F31B3
Hardware Model: iPhone11,8
Process: esport-app [13681]
Path: /private/var/containers/Bundle/Application/07BF60A9-D0A0-4B29-A9F2-C5E6C99D84EC/esport-app.app/esport-app
Identifier: com.wmzq.esportapp
Version: 2105031 (2.1.0)
AppStoreTools: 12E262
AppVariant: 1:iPhone11,8:14
Beta: YES
Code Type: ARM-64 (Native)
Role: Foreground
Parent Process: launchd [1]
Coalition: com.wmzq.esportapp [742]
Date/Time: 2021-05-08 09:41:52.5606 +0800
Launch Time: 2021-05-08 08:38:34.2531 +0800
OS Version: iPhone OS 14.5 (18E199)
Release Type: User
Baseband Version: 3.03.05
Report Version: 104
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x0022000000000000 -> 0x0000000000000000 (possible pointer authentication failure)
VM Region Info: 0 is not in any region. Bytes before following region: 4373348352
REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL
UNUSED SPACE AT START
--->
__TEXT 104ac0000-104b24000 [ 400K] r-x/r-x SM=COW ...pp/esport-app
Termination Signal: Segmentation fault: 11
Termination Reason: Namespace SIGNAL, Code 0xb
Terminating Process: exc handler [13681]
Triggered by Thread: 11
通过查阅资料知道了Apple在A12开始支持了arm64e指令集,提供了指令地址加密功能,即PAC(Pointer Authentication Code的缩写)
7.1 PAC是什么
PAC是ARMv8.3 新增的功能,因为虽然系统是64位的,但是arm64指令地址根本用不满,所以把高位的部分(upper bits)拿来存一个指针地址的签名。
PAC指针验证码就是在CPU执行指令前先拿指针的高位签名和低位的实际地址部分坐下校验,失败了直接抛出异常
为了实现PAC, arm64e新增了两个指令:
- PACIASP 计算 PAC 加密并加到指针地址上
- AUTIASP 校验加密部分,并还原指针地址
7.2 PAC应用举例
在这里我主要记录了下这次Cras追溯的过程和总结了下iOS系统Crash的产生原理,Crash从内核态 -> 抛出至用户态的过程,以及PAC等一些概念性的东西。
后面的几篇文章我会总结Xcode的内存诊断工具,Zombie Objects、 Address Sanitizer、Malloc Scribble的原理和使用,尽量通过代码和WoWCrash示例去实现一个搜集定位内存问题的APM工具。
好久没有好好的深入研究一些技术了,之前一度认为iOS的技术深入不划算了,现今的开发都是页面党,替代性太强了,所以一直犹豫是否转后端或者web。但这次的Crash追踪过程,让我觉得成为相关方面资深开发甚至专家对我还是有诱惑力的,从Crash Log分析->逆向工具使用->底层原理认知->解决问题获取那种喜悦,让我重新找回方向,加油,走出舒适区💪。
参考资料
iOS Mach 异常、Unix 信号 和NSException 异常
iOS Mach异常和signal信号
为什么 arm64e 的指针地址有空余支持 PAC?