iOS 中的 Crash 探究
Crash 是我们在日常使用 App 时偶尔会遇到的"闪退",直接影响用户体验与留存。Crash 率是衡量 App 质量的重要指标之一。
Crash 是我们在日常使用 App 时偶尔会遇到的”闪退”,直接影响用户体验与留存。Crash 率是衡量 App 质量的重要指标之一。
本文围绕 iOS 生态,结合系统原理与实战经验,系统梳理 Crash 的:
- 本质:异常控制流程(ECF)的机制
- 成因:按出现频率排序的常见原因
- 传递流程:底层错误和上层语言错误的两条不同路径
- 排查方法:定位、复现、调试工具的使用
- 实战案例:KVO 与动态类创建的冲突问题
- 治理体系:监控、预防与质量保障
帮助开发者构建一套稳定的 Crash 质量保障流程。
Crash 本质是操作系统对异常情况的一种异常控制流程(Exception Control Flow, ECF)。当 CPU、内核或运行时检测到不可恢复的异常时,会触发控制流跳转到异常处理程序,最终可能导致进程退出。
ECF 可以发生在硬件、内核与应用层:
- 硬件层:硬件检测到事件(如外设中断)后通知 CPU
- 内核层:内核调度、上下文切换或信号派发
- 应用层:运行时或应用逻辑主动抛出异常、发送信号
移动 App 的 Crash 主要与内核层和应用层相关,因此下文聚焦这两层的处理机制。
操作系统会把异常划分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。该分类来自《深入理解计算机系统》第 8 章。虽然部分资料认为中断不属于异常,但从”程序未按原逻辑执行”的角度出发,可以将其视为广义异常的一部分。
| 类别 | 原因 | 异步/同步 | 返回行为 |
|---|---|---|---|
| 中断 | 来自 I/O 设备的信号 | 异步 | 总是返回到下一个指令 |
| 陷阱 | 有意的异常 | 同步 | 总是返回到下一个指令 |
| 故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
| 终止 | 不可恢复的错误 | 同步 | 不会返回 |
日常开发中最常见的导致 Crash 的是 fault,即潜在可恢复的错误。一旦故障无法被修复(如段错误访问非法内存),系统就会向进程发送信号或直接终止,从而表现为 Crash。常见的 EXC_BAD_ACCESS、SIGSEGV 都属于这一类。
不同运行时拥有各自的异常体系。Java 的 JVM 通过 Throwable -> Error / Exception 抽象层级管理异常,绝大多数 Android Crash 都会被 JVM 捕获并转化为 Java 堆栈信息。
iOS 运行时(Objective-C Runtime / Swift Runtime)同样具备异常保护机制,例如:
unrecognized selector sent to instance:向对象发送未知消息时触发objc_exception_throw:Objective-C 抛出的NSException- Swift 层的
fatalError、preconditionFailure会触发SIGABRT
这些保护措施可以阻止异常直接掉到内核层,但当异常未被处理或升级为致命错误时,最终仍然会以 Crash 呈现。
App 发生 Crash 的原因多种多样,按照实际出现频率从高到低,主要可分为以下四类:
这是 iOS 开发中最常见的 Crash 类型,通常表现为 EXC_BAD_ACCESS 或 SIGSEGV。主要包括:
- 野指针:使用已释放的对象,或对象被释放后指针未置为
nil - 越界访问:数组、字符串等容器越界访问
- 多线程竞争:多线程下对同一内存的竞争写入,导致内存损坏
- 写保护内存:尝试修改只读内存区域(如字符串字面量)
在应用层面,可以借助内存布局图理解常见问题:
由低地址到高地址依次为:代码段(.text)→ 已初始化的数据(.data)→ 未初始化的数据(.bss)→ 堆(heap)→ 栈(stack)
当进程在执行时,常见的 Crash 场景包括:
- 使用已释放对象(野指针)
- 多线程下对同一内存的竞争写入
- 越界访问数组或结构体
iOS 运行时(Objective-C Runtime / Swift Runtime)提供了异常保护机制,当检测到异常情况时会主动抛出异常或触发 Crash:
- 未识别消息:Objective-C Runtime 捕获
unrecognized selector sent to instance - 容器越界 / 插入
nil:Foundation 与 Swift 容器会主动抛出异常或调用fatalError - 类型断言失败:Swift 的
as!或try!失败时触发SIGABRT - 强制解包
nil:Swift 的!强制解包遇到nil时触发SIGABRT
底层语言如 C 并不提供这些保护,越界访问往往直接读写未定义内存。
以下代码比较了 C 与 Objective-C 在数组越界时的不同表现:
int main () { // C 语言:越界访问不会 Crash,但行为未定义 char str[6] = {'b','i','t','n','p','c'}; char c = str[6]; // 不会 Crash,但读取的是未定义内存 printf("%c\n", c); // 打印出的字符未知
// Objective-C:越界访问会 Crash NSArray *array = @[@"b", @"i", @"t", @"n", @"p", @"c"]; id obj = array[6]; // Crash: index 6 beyond bounds [0 .. 5] NSLog(@"%@\n", obj);}iOS 系统会基于资源管理、安全策略等因素主动终止 App:
- WatchDog:系统监控主线程和应用启动时长,UI 主线程卡顿超过阈值或冷启动超时会被 WatchDog 杀死
- 内存压力:收到
didReceiveMemoryWarning但未及时释放资源,或后台 App 内存超标,系统会回收进程 - 热量与功耗:CPU/GPU 长时间高负载会触发系统降频甚至强制退出前台 App(相对少见)
- 代码签名 / 证书问题:企业证书过期、签名失效、越狱环境中签名校验失败等都会在启动阶段被系统终止
这类 Crash 通常由底层硬件或指令级错误引起,在实际开发中相对少见:
- 非法算术运算:例如除以 0、浮点溢出等情况会触发
SIGFPE - 无效指令:运行时执行到未定义或架构不支持的指令,触发
SIGILL,常见于混用不同架构的二进制或错误的函数指针
iOS 中的 Crash 传递有两条主要路径,取决于错误的来源:
路径一:底层错误(如野指针、访问非法内存)
- 硬件/内核检测到异常 → Mach 异常 → Unix Signal
- 这类错误直接由系统底层捕获,不经过语言运行时
路径二:上层语言错误(如数组越界、unrecognized selector)
- Objective-C/Swift Runtime 检测到 → NSException → 未捕获时调用
abort()→ SIGABRT - 这类错误由语言运行时主动抛出,如果未设置异常处理器,最终会通过
abort()触发信号
为了更好地理解两条路径的区别,下面列出常见的 Crash 场景:
情况一:纯信号崩溃(无 NSException)
这类 Crash 直接由系统底层触发,不经过语言运行时,因此没有对应的 NSException,只能通过信号捕获:
- 野指针访问 →
SIGSEGV - 栈溢出 →
SIGTRAP - 内存限制 →
SIGKILL
情况二:NSException 触发的信号
这类 Crash 由语言运行时检测并抛出 NSException,如果未捕获则触发信号。需要通过 NSException 层来获得具体的错误信息:
- 数组越界 →
NSRangeException→SIGABRT - 消息转发失败 →
NSInvalidArgumentException→SIGABRT - Swift 可选值强制解包 nil →
NSException→SIGABRT
Mach 异常是最底层的内核级异常,如 EXC_BAD_ACCESS。在异常发生时,会被异常处理程序转换为 Mach 消息,接着依次投递到 thread、task 和 host 端口。
通过监听这些端口即可捕获 Mach 层的异常。下面以 PLCrashReporter 为例(此处仅列出关键代码),完整实现参见 PLCrashMachExceptionServer:
// 初始化 Mach 异常服务器上下文// 1. 创建服务器端口mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &_serverContext->server_port);
// 2. 创建通知端口mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &_serverContext->notify_port);mach_port_insert_right(mach_task_self(), _serverContext->notify_port, _serverContext->notify_port, MACH_MSG_TYPE_MAKE_SEND);
// 3. 创建端口集合mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_PORT_SET, &_serverContext->port_set);
// 4. 将服务器端口和通知端口加入端口集合mach_port_move_member(mach_task_self(), _serverContext->server_port, _serverContext->port_set);mach_port_move_member(mach_task_self(), _serverContext->notify_port, _serverContext->port_set);
// 5. 创建异常处理线程pthread_create(&thr, &attr, &exception_server_thread, _serverContext);开源方案如 PLCrashReporter、KSCrash 都是在底层注册 Mach 异常端口,提前截获异常并持久化堆栈,然后再交由系统继续传递,保证不会破坏默认行为。
| Exception 类型 | 描述 | 说明 |
|---|---|---|
EXC_BAD_ACCESS | Bad Memory Access | 错误内存地址,访问的地址不存在或者当前进程没有权限都会报这个错。常见于路径一(底层错误) |
EXC_CRASH | Abnormal Exit | 通常跟随的 UNIX Signal 是 SIGABRT,表示进程异常退出。常见于路径二(上层语言错误),当 NSException 未捕获导致 abort() 时 |
EXC_BAD_INSTRUCTION | Illegal Instruction | 非法或未定义的指令或操作数。常见于路径一(底层错误) |
Unix Signal 是 Unix 系统的一种异步通知机制。对于底层错误(路径一),Mach 异常在 host 层被 ux_exception 转换为相应的 Unix Signal,并通过 threadsignal 将信号投递到出错的线程,如 SIGSEGV、SIGBUS。对于上层语言错误(路径二),NSException 未捕获时会调用 abort() 直接触发 SIGABRT。
在 Unix 层可使用 signal / sigaction 注册信号处理回调,将关键信息写入文件或上传至服务器。如下代码把接收到的 SIGBUS 统一用 signalHandler 处理:
void signalHandler(int sig) { printf("signal %d received.\n", sig); // 在这里可以保存堆栈信息、写入日志等 exit(1);}
int main() { signal(SIGBUS, signalHandler); char *str = "bitnpc"; // 字符串字面量在只读段 str[0] = 'H'; // 尝试修改只读内存,触发 SIGBUS return 0;}下表列出了常见的 Unix Signal。在 macOS 系统中,可以输入 man signal 查看所有的 Signal 列表。在这里也可以看到。
| Unix Signal | 说明 |
|---|---|
SIGSEGV | 访问了无效的内存地址,这个地址存在,但是当前进程没有权限访问它。属于硬件层错误 |
SIGABRT | 程序异常终止,通常由 C 函数 abort() 触发,也可能由运行时断言失败、Swift 的 fatalError 等触发。属于软件层错误 |
SIGBUS | 访问了无效的内存地址,与 SIGSEGV 的区别是:SIGBUS 表示内存地址不存在。属于硬件层错误 |
SIGTRAP | Debugger 相关 |
SIGILL | 尝试执行一个非法的、未知的、没有权限的指令 |
NSException 是 Objective-C 运行时抛出的异常对象,通常由语言运行时保护机制触发(如数组越界、unrecognized selector 等)。通过 NSSetUncaughtExceptionHandler 注册处理函数,可以在崩溃前抓取异常名称、原因、调用栈并进行持久化。常见做法是在 handler 中将信息写入沙盒文件,待下次启动时再上报,避免在崩溃现场进行复杂逻辑。
如下代码展示了基础用法:
void exceptionHandler(NSException *exception) { // 获取异常信息 NSString *name = [exception name]; // 异常名称 NSString *reason = [exception reason]; // 出现异常的原因 NSArray *stackArray = [exception callStackSymbols]; // 异常的堆栈信息
// 持久化异常信息(写入文件或上传服务器) NSLog(@"Exception: %@, Reason: %@", name, reason); NSLog(@"Stack: %@", stackArray);
// 注意:不要在这里执行耗时操作,避免影响崩溃日志的完整性}
int main(int argc, char * argv[]) { // 注册未捕获异常处理器 NSSetUncaughtExceptionHandler(&exceptionHandler);
// 触发异常示例 NSArray *array = @[@"b", @"i", @"t", @"n", @"p", @"c"]; id obj = array[6]; // 触发 NSRangeException return 0;}Crash 捕获后获得的数据都是对应的虚拟内存地址。我们需要把虚拟内存地址转化为可读的堆栈信息。符号化的本质是在一个映射文件中,找到内存地址对应的函数的方法名。
常见的符号化方式包括:
- Xcode Organizer / Devices 面板:自动符号化,适合本地调试
- symbolicatecrash 脚本:离线符号化,适合批量处理
- atos / atosl:根据地址定位符号,适用于自建平台
通常情况下,debug 时发生的 Crash 很好解决。但是,App 上线后,往往会出现一些本地没有遇到过,并且难以复现的 Crash。从 CrashLog 中往往不能直接定位问题所在,需要系统化的排查方法。
- 收集线索:确认系统版本、App 版本、用户操作路径、堆栈、线程信息、设备型号、电量和网络环境等
- 还原场景:结合埋点或操作回放日志(如 Logan、Matrix)定位触发路径
- 快速对比:对比上一个版本的差异,关注近期合入的模块与实验开关
- 本地复现:利用断点回溯、开关控制精确命中崩溃路径
- 提升命中率:在 Xcode
Diagnostics中开启Malloc Scribble、NSZombie、Thread Sanitizer、Address Sanitizer等 - 多线程场景:编写脚本在多个线程中并发触发问题,提高复现概率
原理是通过在已释放对象中填充 0x55,使得野指针调用必然崩溃。
以如下代码为例(为便于说明,已关闭 ARC):
UIView *view = [UIView new];[view release];[view setNeedsLayout]; // 向已释放对象发送消息很显然,此时 view 指向的对象已释放,但是 view 指针未置为 nil。所以我们在向一个已释放的对象发送了消息。但是,编译运行后,发现并不会 Crash。
打开 Malloc Scribble 后,可以从调试面板很清晰的看到,在第三行发生了 Crash。
把已释放的对象标记为僵尸对象,Xcode 的实现方式是使用 runtime 方法 object_setClass,覆写被释放的 view 的 isa 为 _NSZombie_UIView。
除了上述 Memory Management 的工具,Xcode 还提供了 Runtime Sanitization 的工具(实际上是 LLVM 编译器提供的功能)。如可以监测竞态访问的 Thread Sanitizer,可以帮助开发者发现潜在的问题。
这是一个真实的生产环境 Crash 案例,展示了多线程环境下动态类创建与 KVO 机制的冲突问题。
下面是一个真实的 CrashLog,为了便于阅读,省略了不相关的部分。
Incident Identifier: 61590478-FA94-496E-9208-D2016678D6D0CrashReporter Key: TODOHardware Model: iPhone7,2Process: imeituan [10672]Path: /var/containers/Bundle/Application/2140260F-0484-4CED-AC09-DEC9B620A63A/imeituan.app/imeituanIdentifier: com.meituan.imeituanVersion: 9.1.0 (3123)Code Type: ARM-64Parent Process: ??? [1]
Date/Time: 2018-11-12 08:44:34 +0000OS Version: iPhone OS 10.1.1 (14B100)Report Version: 104
Exception Type: SIGSEGVException Codes: SEGV_ACCERR at 0x20Crashed Thread: 22
Thread 22 Crashed:0 libobjc.A.dylib objc_registerClassPair + 321 Foundation _NSKVONotifyingCreateInfoWithOriginalClass + 1362 Foundation _NSKeyValueContainerClassGetNotifyingInfo + 803 Foundation -[NSKeyValueUnnestedProperty _isaForAutonotifying] + 844 Foundation -[NSKeyValueUnnestedProperty isaForAutonotifying] + 1005 Foundation -[NSObject(NSKeyValueObserverRegistration) _addObserver:forProperty:options:context:] + 4366 Foundation -[NSObject(NSKeyValueObserverRegistration) addObserver:forKeyPath:options:context:] + 1247 imeituan -[NSObject(RACSelectorSignal) racSignal_addObserver:forKeyPath:options:context:] (NSObject+RACSelectorSignal.m:63)8 imeituan -[RACKVOTrampoline initWithTarget:observer:keyPath:options:block:] (RACKVOTrampoline.m:50)9 imeituan -[NSObject(RACKVOWrapper) rac_observeKeyPath:options:observer:block:] (NSObject+RACKVOWrapper.m:115)10 imeituan __84-[NSObject(RACPropertySubscribing) rac_valuesAndChangesForKeyPath:options:observer:]_block_invoke.41 (NSObject+RACPropertySubscribing.m:0)......49 imeituan -[TPKxxxItem initWithText:jumpUrlString:] (TPKPOIDetailLookMoreCell.m:60)50 imeituan -[TPKxxxViewModel itemsWithModel:] (TPKxxxViewModel.m:102)51 imeituan __51-[TPKxxxViewModel setupViewModel]_block_invoke (TPKxxxViewModel.m:43)......首先,来搜索一下堆栈信息。这里可供搜索的堆栈在 0-6 行。比如我们搜索 objc_registerClassPair,它是 runtime 创建类时调用的一个方法。但是这个信息不足以定位问题。
由堆栈第四行,搜索到了一片 KVO 创建同名类导致 Crash 的文章。但是,本项目是组件化的,每个 pod 都有不同的前缀,不存在不同二进制包中有多个符号并存的问题。
接下来,就看看能否复现。找到 TPKxxxViewModel 所对应的页面,发现没有发生 Crash。考虑到 Crash 的线程是后台线程,猜测很有可能是多线程创建 TPKxxxItem 导致的问题。那就可以写一些测试代码来尝试复现。注意,该段代码执行的时机要和实际创建该 item 一致。
// 多线程并发创建对象,尝试复现问题for (int i = 0; i < 5; i++) { dispatch_async(dispatch_get_global_queue(DISPATCH_PRIORITY_DEFAULT, 0), ^{ TPKxxxItem *item = [[TPKxxxItem alloc] initWithText:@"bit" jumpUrlString:@"npc"]; });}很幸运,成功复现。Crash 位置是项目中的一个基础库。查看该基础库的改动日志,发现多了一些 swizzle 的操作。该类有一个步骤类似于 KVO 的机制,过程中会创建一个新类。但是,后续又有 KVO 该类的操作。所以,我们的问题就转化为 KVO 在创建同名类的子类就会 Crash 的问题,正好契合前面搜集的资料。
那么,为什么 KVO 在创建同名类的子类时会 Crash 呢?我们知道,KVO 主要做了以下几件事:
- 使用
objc_allocateClassPair和objc_registerClassPair方法,动态创建一个新类:NSKVONotifying_xxx,该类是原类的子类 - 将原对象的
isa指针指向新创建的NSKVONotifying_xxx类 - 把新类添加到全局的类表中
- 重写新类的 setter 方法,在 setter 中调用
willChangeValueForKey:和didChangeValueForKey:
在步骤一,如果创建两个同名的新类,会如何?可以写个测试代码验证一下:
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification { // 第一次创建同名类,成功 Class testClass1 = objc_allocateClassPair([NSObject class], "bitnpc_crash_test", 0); objc_registerClassPair(testClass1);
// 第二次创建同名类,objc_allocateClassPair 返回 nil Class testClass2 = objc_allocateClassPair([NSObject class], "bitnpc_crash_test", 0); objc_registerClassPair(testClass2); // EXC_BAD_ACCESS: 传入 nil 导致崩溃}objc_allocateClassPair 时,返回的 class 为 nil。接着,用 objc_registerClassPair 注册新类时,由于传入的参数为 nil,导致了 crash。
再查看 objc-runtime 源码(objc4-723 版本),可以看出,如果 getClass(name) 返回的类不为空,则直接返回 nil,不分配新的内存空间:
/************************************************************************ objc_allocateClassPair* fixme* Locking: acquires runtimeLock**********************************************************************/Class objc_allocateClassPair(Class superclass, const char *name, size_t extraBytes){ Class cls, meta;
rwlock_writer_t lock(runtimeLock);
// Fail if the class name is in use. // Fail if the superclass isn't kosher. if (getClass(name) || !verifySuperclass(superclass, true/*rootOK*/)) { return nil; // 类名已存在,返回 nil }
// Allocate new classes. cls = alloc_class_for_subclass(superclass, extraBytes); meta = alloc_class_for_subclass(superclass, extraBytes);
// fixme mangle the name if it looks swift-y? objc_initializeClassPair_internal(superclass, name, cls, meta);
return cls;}到这里,原因已经很清晰了。可以用以下流程图来表示:

项目中某基础库创建了两个 TPKxxxItem_XXX,我们暂且称之为中间类。KVO 用这两个中间类创建子类时,因为没有分配到内存空间,导致 objc_registerClassPair 时,发生了 crash。
// 使用 dispatch_once 或加锁保证线程安全static NSMutableDictionary *classCache = nil;static dispatch_once_t onceToken;dispatch_once(&onceToken, ^{ classCache = [NSMutableDictionary dictionary];});
@synchronized(self.class) { NSString *className = NSStringFromClass(self.class); Class cachedClass = classCache[className]; if (!cachedClass) { // 创建中间类 cachedClass = objc_allocateClassPair([self class], "TPKxxxItem_XXX", 0); if (cachedClass) { objc_registerClassPair(cachedClass); classCache[className] = cachedClass; } } return cachedClass;}- 核心指标:关注冷启动 Crash 率、活跃用户 Crash 率(DAU 崩溃用户占比)、场景 Crash 率(按页面 / 功能拆分),并结合卡顿、OOM 统计
- 采集策略:客户端在下次启动时上报 Crash 日志、线程堆栈、设备信息、最近操作,服务端聚合后计算指标
- 治理闭环:结合构建信息、灰度批次,对 Crash 进行分桶(首次出现、回归、核心路径),设定 SLA 与报警阈值
- 工具链:常见方案有 Crashlytics、Bugly、Sentry、自建基于 PLCrashReporter 的上报系统;配合 Xcode Organizer 与 App Store Connect 的
Metrics/Analytics交叉验证 - 预防机制:在内测包开启 ASan、TSan、Malloc Guard、Zombie 等调试工具;使用静态检查(Clang Static Analyzer、Infer)与单元测试覆盖关键模块;上线后利用 Feature Flag 快速降级
本文结合操作系统异常控制机制,梳理了 Crash 的本质、常见成因、传递流程、捕获层次与符号化方法,并给出排查思路与实际案例。
- Crash 本质:异常控制流程(ECF),从硬件层到应用层的多层异常处理机制
- 常见成因(按频率排序):访问非法内存(最常见)→ 语言运行时保护机制 → 操作系统策略限制 → CPU 无法执行代码(相对少见)
- 传递流程:有两条主要路径
- 路径一(底层错误):Mach 异常 → Unix Signal
- 路径二(上层语言错误):NSException →
abort()→ SIGABRT - 需要在多个层次注册处理器以捕获完整信息
- 排查思路:收集线索 → 还原场景 → 尝试复现 → 定位问题 → 修复验证
- 最佳实践:线程安全、命名规范、监控体系、工具链建设