函数调用栈与野指针崩溃的定位思路
·
前言
在 iOS 开发中,野指针(Dangling Pointer)导致的崩溃是最难排查的问题之一。与普通崩溃不同,野指针崩溃具有以下特点:
- 随机性强:不一定每次都崩溃,可能偶现
- 现场迷惑:崩溃堆栈可能指向无关代码
- 难以复现:同样的操作不一定触发
本文将从函数调用栈的底层原理出发,深入讲解野指针崩溃的产生原因、定位方法和解决思路。
一、函数调用栈基础
1.1 栈帧结构
┌─────────────────────────────────────────────────────────────────────┐
│ 函数调用栈帧结构 (ARM64) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 高地址 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 调用者栈帧 │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ ... │ │ │
│ │ │ 局部变量 │ │ │
│ │ │ 保存的寄存器 │ │ │
│ │ ├───────────────────────────────────────────────────────┤ │ │
│ │ │ 返回地址 (LR) ← 调用者的下一条指令 │ │ │
│ │ │ 上一个栈帧指针 (FP) ← 指向更上层栈帧 │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↑ │
│ │ 调用者 FP 指向这里 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 当前函数栈帧 │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ 参数区 (如果参数超过8个) │ │ │
│ │ ├───────────────────────────────────────────────────────┤ │ │
│ │ │ 局部变量 │ │ │
│ │ │ var1, var2, var3... │ │ │
│ │ ├───────────────────────────────────────────────────────┤ │ │
│ │ │ 保存的寄存器 (callee-saved) │ │ │
│ │ │ x19-x28, d8-d15 │ │ │
│ │ ├───────────────────────────────────────────────────────┤ │ │
│ │ │ 返回地址 (LR/x30) │ │ │
│ │ │ 栈帧指针 (FP/x29) ← 当前 FP 指向这里 │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↑ │
│ │ SP (栈顶指针) 指向这里 │
│ 低地址 │
│ │
│ ════════════════════════════════════════════════════════════════ │
│ │
│ ARM64 关键寄存器: │
│ • SP (Stack Pointer): 栈顶指针,始终指向栈顶 │
│ • FP/x29 (Frame Pointer): 栈帧指针,指向当前栈帧底部 │
│ • LR/x30 (Link Register): 返回地址,函数返回时跳转目标 │
│ • PC (Program Counter): 程序计数器,当前执行指令地址 │
│ • x0-x7: 参数传递和返回值 │
│ • x8: 间接返回值地址 │
│ • x9-x15: 临时寄存器 (caller-saved) │
│ • x16-x17: 过程内调用暂存 │
│ • x18: 平台保留 │
│ • x19-x28: 保存寄存器 (callee-saved) │
│ │
└─────────────────────────────────────────────────────────────────────┘
1.2 函数调用与返回过程
┌─────────────────────────────────────────────────────────────────────┐
│ 函数调用过程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 调用前 (caller): │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 1. 准备参数 (x0-x7) │ │
│ │ 2. 保存 caller-saved 寄存器 (如需要) │ │
│ │ 3. BL target_function ; 跳转并保存返回地址到 LR │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 函数入口 (callee prologue): │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ; 典型的函数序言 │ │
│ │ stp x29, x30, [sp, #-16]! ; 保存 FP 和 LR,SP -= 16 │ │
│ │ mov x29, sp ; 设置新的 FP │ │
│ │ sub sp, sp, #N ; 为局部变量分配空间 │ │
│ │ ; 保存 callee-saved 寄存器 │ │
│ │ stp x19, x20, [sp, #offset] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 函数体执行... │
│ │ │
│ ▼ │
│ 函数返回 (callee epilogue): │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ; 典型的函数尾声 │ │
│ │ ; 恢复 callee-saved 寄存器 │ │
│ │ ldp x19, x20, [sp, #offset] │ │
│ │ mov sp, x29 ; 恢复 SP │ │
│ │ ldp x29, x30, [sp], #16 ; 恢复 FP 和 LR,SP += 16 │ │
│ │ ret ; 跳转到 LR 指向的地址 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
1.3 调用栈回溯原理
// 手动实现调用栈回溯
#import <mach/mach.h>
#import <dlfcn.h>
typedef struct StackFrame {
struct StackFrame *previous; // 上一个栈帧的 FP
uintptr_t returnAddress; // 返回地址 (LR)
} StackFrame;
NSArray<NSString *> *captureCallStack(void) {
NSMutableArray *symbols = [NSMutableArray array];
// 获取当前 FP
StackFrame *frame;
__asm__ volatile("mov %0, x29" : "=r"(frame));
// 遍历栈帧链
while (frame != NULL) {
uintptr_t returnAddr = frame->returnAddress;
// 获取符号信息
Dl_info info;
if (dladdr((void *)returnAddr, &info)) {
NSString *symbol = [NSString stringWithFormat:@"%s + %lu",
info.dli_sname ?: "???",
returnAddr - (uintptr_t)info.dli_saddr];
[symbols addObject:symbol];
}
// 移动到上一个栈帧
frame = frame->previous;
// 防止死循环
if ((uintptr_t)frame < 0x1000 || symbols.count > 128) {
break;
}
}
return symbols;
}
// 使用示例
void testFunction() {
NSArray *stack = captureCallStack();
NSLog(@"当前调用栈:\n%@", [stack componentsJoinedByString:@"\n"]);
}
二、野指针的产生原因
2.1 什么是野指针
┌─────────────────────────────────────────────────────────────────────┐
│ 野指针产生过程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 【正常状态】 │
│ │
│ 栈/堆 堆内存 │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ id obj │─────────────→│ NSObject 实例 │ │
│ │ = 0x12345678 │ │ retainCount=1 │ │
│ └──────────────┘ └──────────────────┘ │
│ │
│ ════════════════════════════════════════════════════════════════ │
│ │
│ 【对象被释放后 - 野指针产生】 │
│ │
│ 栈/堆 堆内存 │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ id obj │─────────────→│ ?????????? │ ← 已释放 │
│ │ = 0x12345678 │ 悬垂指针! │ 可能被复用 │ 内存不确定 │
│ └──────────────┘ └──────────────────┘ │
│ │
│ 此时 obj 就是野指针: │
│ • 指针值没变(还是 0x12345678) │
│ • 但指向的内存已经无效 │
│ • 访问可能:崩溃/读到垃圾数据/看起来正常(最危险) │
│ │
│ ════════════════════════════════════════════════════════════════ │
│ │
│ 【内存被复用后 - 更隐蔽的问题】 │
│ │
│ 栈/堆 堆内存 │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ id obj │─────────────→│ 其他对象 │ ← 被其他对象 │
│ │ = 0x12345678 │ 指向错误! │ (NSString?) │ 复用了内存 │
│ └──────────────┘ └──────────────────┘ │
│ │
│ 这种情况更危险: │
│ • 通过 obj 调用方法可能调用到其他对象的方法 │
│ • 类型不匹配可能导致随机崩溃 │
│ • 数据损坏可能影响其他功能 │
│ │
└─────────────────────────────────────────────────────────────────────┘
2.2 常见野指针场景
// 场景 1: 过度释放 (Over-release)
// MRC 时代常见,ARC 下仍可能发生
id obj = [[NSObject alloc] init];
[obj release];
[obj release]; // 💥 第二次 release 导致野指针
[obj doSomething]; // 崩溃
// 场景 2: 使用 __unsafe_unretained
{
__unsafe_unretained id unsafeObj;
@autoreleasepool {
unsafeObj = [[NSObject alloc] init];
}
// autoreleasepool 结束,对象被释放
[unsafeObj description]; // 💥 野指针
}
// 场景 3: delegate 未置空
@interface MyView : UIView
@property (nonatomic, assign) id<MyDelegate> delegate; // ⚠️ assign
@end
// ViewController dealloc 时
- (void)dealloc {
// 忘记 self.myView.delegate = nil;
// myView 可能在 dealloc 后继续回调 delegate
}
// 场景 4: 异步回调中的 self
- (void)loadData {
[self.api fetchDataWithCompletion:^(id data) {
// 如果 self 已经释放,这里可能崩溃
self.data = data; // 💥 如果 api 内部用 assign 保存 block
}];
}
// 场景 5: 定时器持有
- (void)setupTimer {
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0
target:self
selector:@selector(tick)
userInfo:nil
repeats:YES];
}
- (void)dealloc {
// 如果忘记 invalidate,timer 继续持有 self
// 但如果其他地方 invalidate 了,self 可能被释放
// 而 timer 的回调在之后执行 → 野指针
}
// 场景 6: 多线程竞争
@property (nonatomic, strong) NSMutableArray *items;
// 线程 A
[self.items addObject:obj];
// 线程 B(同时执行)
self.items = nil; // 释放了 items
// 线程 A 继续访问 items → 野指针
// 场景 7: Core Foundation 对象管理不当
CFStringRef cfStr = CFStringCreateWithCString(NULL, "test", kCFStringEncodingUTF8);
CFRelease(cfStr);
CFRelease(cfStr); // 💥 重复释放
// 场景 8: 结构体中的对象指针
struct MyStruct {
__unsafe_unretained id obj; // ⚠️ 结构体中不能用 strong
};
struct MyStruct s;
s.obj = [[NSObject alloc] init]; // 对象可能随时被释放
// 稍后访问 s.obj → 可能是野指针
2.3 为什么野指针崩溃难定位
┌─────────────────────────────────────────────────────────────────────┐
│ 野指针崩溃难定位的原因 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 【原因 1: 崩溃现场与根因分离】 │
│ │
│ 时间线: │
│ ────────────────────────────────────────────────────────────── │
│ T1 T2 T3 T4 │
│ 创建对象 错误释放 内存被复用 访问野指针 │
│ ↓ ↓ ↓ ↓ │
│ 正常 BUG产生! 无异常 💥 崩溃 │
│ (root cause) (crash site) │
│ │
│ 崩溃堆栈显示 T4 的代码,但 BUG 在 T2 │
│ │
│ ════════════════════════════════════════════════════════════════ │
│ │
│ 【原因 2: 内存复用导致随机行为】 │
│ │
│ 情况 A: 内存未被复用 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 访问已释放内存 → 读到旧数据 → 可能正常工作(假象) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ 情况 B: 内存被相同类型复用 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 访问到新对象 → 方法调用成功 → 但逻辑错误 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ 情况 C: 内存被不同类型复用 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 访问到不兼容对象 → unrecognized selector → 崩溃 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ 情况 D: 内存被回收给系统 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 访问非法地址 → EXC_BAD_ACCESS → 崩溃 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ════════════════════════════════════════════════════════════════ │
│ │
│ 【原因 3: 崩溃堆栈可能误导】 │
│ │
│ 常见的误导性堆栈: │
│ • objc_msgSend - 只说明在发消息,不说明发给谁 │
│ • objc_release - 只说明在释放,不说明释放什么 │
│ • CFRelease - 同上 │
│ • -[NSObject doesNotRecognizeSelector:] - 不是 bug 所在 │
│ • 系统框架内部代码 - 问题不在系统 │
│ │
│ 实际 BUG 可能在堆栈中根本看不到! │
│ │
└─────────────────────────────────────────────────────────────────────┘
三、崩溃信息解读
3.1 常见野指针崩溃类型
┌─────────────────────────────────────────────────────────────────────┐
│ 野指针相关的崩溃类型 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 【EXC_BAD_ACCESS (SIGSEGV / SIGBUS)】 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Exception Type: EXC_BAD_ACCESS (SIGSEGV) │ │
│ │ Exception Codes: KERN_INVALID_ADDRESS at 0x0000000012345678│ │
│ │ │ │
│ │ 含义:访问了无效的内存地址 │ │
│ │ 原因: │ │
│ │ • 野指针访问 │ │
│ │ • 空指针解引用 │ │
│ │ • 栈溢出 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 【EXC_BAD_ACCESS (SIGBUS)】 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Exception Type: EXC_BAD_ACCESS (SIGBUS) │ │
│ │ Exception Codes: KERN_PROTECTION_FAILURE │ │
│ │ │ │
│ │ 含义:访问了没有权限的内存 │ │
│ │ 原因: │ │
│ │ • 写入只读内存 │ │
│ │ • 对齐错误 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 【SIGABRT (abort)】 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Exception Type: EXC_CRASH (SIGABRT) │ │
│ │ Application Specific Information: │ │
│ │ *** -[__NSArrayM objectAtIndex:]: index 5 beyond bounds │ │
│ │ │ │
│ │ 含义:程序主动终止(assert/exception) │ │
│ │ 原因: │ │
│ │ • NSException 未捕获 │ │
│ │ • assert 失败 │ │
│ │ • double free │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 【常见的野指针相关异常信息】 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ • unrecognized selector sent to instance 0x12345678 │ │
│ │ → 内存被其他类型对象复用 │ │
│ │ │ │
│ │ • message sent to deallocated instance 0x12345678 │ │
│ │ → 向已释放对象发送消息(开启 Zombie 会显示) │ │
│ │ │ │
│ │ • EXC_BAD_ACCESS at objc_msgSend + 16 │ │
│ │ → 消息发送时访问了无效的 isa 指针 │ │
│ │ │ │
│ │ • malloc: double free for ptr 0x12345678 │ │
│ │ → 重复释放同一块内存 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
3.2 解读崩溃日志
典型的野指针崩溃日志:
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000102abc018
VM Region Info: 0x102abc018 is not in any region.
Triggered by Thread: 0
Thread 0 Crashed:
0 libobjc.A.dylib 0x1a2b3c4d8 objc_msgSend + 16
1 UIKitCore 0x1b2c3d4e8 -[UIView layoutSubviews] + 128
2 UIKitCore 0x1b2c3d5f4 -[UIView _layoutSubviewsIfNeeded] + 256
3 MyApp 0x1001234f0 -[MyViewController updateUI] + 48
4 MyApp 0x100123580 -[MyViewController viewDidAppear:] + 80
...
// 关键信息解读
// 1. 崩溃地址分析
// 0x0000000102abc018 - 看起来像有效地址(高位不全是0/F)
// 但 "not in any region" 说明这块内存已经不属于进程
// 2. objc_msgSend + 16
// 偏移 16 字节,说明崩溃在读取 isa 时
// objc_msgSend 开头会读取对象的 isa 来查找类信息
// 说明对象的内存已经失效
// 3. 需要关注的栈帧
// 第一个 MyApp 的帧:-[MyViewController updateUI]
// 这里可能是触发野指针访问的地方
// 解析崩溃地址
- (void)analyzeCrashAddress:(uintptr_t)address {
// 检查地址特征
if (address == 0) {
NSLog(@"空指针解引用");
} else if (address < 0x1000) {
NSLog(@"可能是空对象 + 偏移访问成员变量");
} else if (address < 0x100000000) {
NSLog(@"可能是已释放的堆内存地址");
} else if ((address & 0xFFFFFF0000000000) == 0) {
NSLog(@"可能是有效但已释放的地址");
}
// 尝试获取符号信息
Dl_info info;
if (dladdr((void *)address, &info)) {
NSLog(@"地址属于: %s", info.dli_fname);
} else {
NSLog(@"地址不属于任何已知映像");
}
}
3.3 符号化与地址计算
// 计算符号化需要的地址
/*
崩溃日志中的地址关系:
Binary Images:
0x100000000 - 0x100ffffff MyApp arm64 <uuid> /var/.../MyApp
崩溃地址: 0x1001234f0
计算方式:
1. 文件偏移 = 崩溃地址 - 加载地址 (ASLR slide 已包含在内)
= 0x1001234f0 - 0x100000000
= 0x1234f0
2. 使用 atos 符号化:
atos -arch arm64 -o MyApp.app/MyApp -l 0x100000000 0x1001234f0
3. 或计算相对偏移后使用 dSYM:
atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp 0x1234f0
*/
// 代码中计算
void symbolicate(uintptr_t crashAddress, uintptr_t loadAddress) {
uintptr_t offset = crashAddress - loadAddress;
Dl_info info;
if (dladdr((void *)crashAddress, &info)) {
printf("Symbol: %s\n", info.dli_sname);
printf("Offset: +%lu\n", crashAddress - (uintptr_t)info.dli_saddr);
}
printf("File offset: 0x%lx\n", offset);
}
四、野指针检测工具
4.1 Zombie Objects
// Zombie Objects 工作原理
/*
开启方式:
1. Xcode → Edit Scheme → Run → Diagnostics → Zombie Objects
2. 或设置环境变量 NSZombieEnabled=YES
原理:
- 对象被释放时,不真正释放内存
- 将对象的 isa 修改为 _NSZombie_OriginalClassName
- 任何对该对象的消息发送都会触发 Zombie 检测
*/
// Zombie 的实现原理(简化版)
void zombie_dealloc(id obj) {
// 1. 获取原始类名
Class originalClass = object_getClass(obj);
const char *className = class_getName(originalClass);
// 2. 构造 Zombie 类名
char zombieClassName[256];
snprintf(zombieClassName, sizeof(zombieClassName),
"_NSZombie_%s", className);
// 3. 获取或创建 Zombie 类
Class zombieClass = objc_lookUpClass(zombieClassName);
if (!zombieClass) {
zombieClass = objc_duplicateClass(
objc_getClass("_NSZombie_"),
zombieClassName,
0
);
}
// 4. 将对象的 isa 指向 Zombie 类
object_setClass(obj, zombieClass);
// 5. 不释放内存!
// free(obj); // 注释掉
}
// Zombie 对象收到消息时
void zombie_forward(id self, SEL sel) {
Class zombieClass = object_getClass(self);
const char *zombieName = class_getName(zombieClass);
// 提取原始类名
const char *originalName = zombieName + strlen("_NSZombie_");
// 输出错误信息并崩溃
_objc_fatal("*** -[%s %s]: message sent to deallocated instance %p",
originalName, sel_getName(sel), self);
}
/*
Zombie 的限制:
1. 内存不会真正释放,长时间运行会 OOM
2. 只能检测 ObjC 对象
3. 只能检测消息发送,不能检测直接访问
4. Release 模式下不可用
*/
4.2 Address Sanitizer (ASan)
/*
Address Sanitizer 是编译器级别的内存检测工具
开启方式:
Xcode → Build Settings → Address Sanitizer → Yes
或
-fsanitize=address
可以检测:
• Use after free (野指针)
• Heap buffer overflow (堆溢出)
• Stack buffer overflow (栈溢出)
• Global buffer overflow (全局变量溢出)
• Use after return (返回后使用栈变量)
• Use after scope (作用域后使用)
• Memory leaks (配合 LSan)
*/
// ASan 的实现原理
/*
1. 编译时插桩
- 每次内存访问前检查 shadow memory
- 函数入口/出口处理栈变量
2. 运行时库
- 替换 malloc/free
- 管理 shadow memory
- 在释放的内存周围放置 "红区"
3. Shadow Memory
- 每 8 字节内存对应 1 字节 shadow
- 记录内存状态:可访问/不可访问/部分可访问
*/
// 模拟 ASan 检测到的错误
void asan_example() {
int *array = malloc(sizeof(int) * 10);
free(array);
// ASan 会检测到这里的 use-after-free
array[0] = 42;
/*
输出:
==12345==ERROR: AddressSanitizer: heap-use-after-free
READ of size 4 at 0x... thread T0
#0 0x... in asan_example file.m:123
#1 ...
0x... is located 0 bytes inside of 40-byte region
freed by thread T0 here:
#0 0x... in free
#1 0x... in asan_example file.m:122
previously allocated by thread T0 here:
#0 0x... in malloc
#1 0x... in asan_example file.m:121
*/
}
/*
ASan 的限制:
1. 性能开销大(2-5x 变慢)
2. 内存开销大(2-3x)
3. 不能检测未初始化读取(需要用 MSan)
4. 不能检测所有的内存错误
*/
4.3 Malloc Scribble
/*
Malloc Scribble 在分配和释放时填充特定值
开启方式:
环境变量 MallocScribble=1
效果:
- 分配时填充 0xAA
- 释放时填充 0x55
优势:
- 使野指针访问更容易崩溃
- 帮助识别未初始化使用
*/
// 验证 Scribble 效果
void test_scribble() {
NSObject *obj = [[NSObject alloc] init];
NSObject * __unsafe_unretained unsafeRef = obj;
obj = nil; // 释放对象
// 如果开启了 Scribble,这里更容易崩溃
// 因为 isa 被填充为 0x5555555555555555
[unsafeRef description]; // 💥
}
/*
其他有用的 Malloc 调试选项:
MallocGuardEdges=1
- 在分配的内存前后添加保护页
- 可以检测缓冲区溢出
MallocStackLogging=1
- 记录每次分配的调用栈
- 可以用 malloc_history 查看
MallocStackLoggingNoCompact=1
- 同上,但不压缩
- 更详细但占用更多空间
*/
// 使用 malloc_history 查看分配历史
// 命令行:
// malloc_history <pid> <address>
// 代码中主动记录
#import <malloc/malloc.h>
void log_allocation_history(void *ptr) {
// 需要开启 MallocStackLogging
printf("Allocation at %p:\n", ptr);
// 实际需要用 mach 接口或 malloc_history 工具
}
4.4 自定义野指针检测
// 基于 Zombie 思想的轻量级检测
// 可以在 Release 中使用(采样)
#import <objc/runtime.h>
#import <malloc/malloc.h>
// Zombie 池,保存最近释放的对象
static const NSUInteger kZombiePoolSize = 1024;
static void *sZombiePool[kZombiePoolSize];
static NSUInteger sZombieIndex = 0;
static os_unfair_lock sZombieLock = OS_UNFAIR_LOCK_INIT;
// 自定义的 Zombie 类
@interface _CustomZombie : NSObject
@property (nonatomic, assign) Class originalClass;
@property (nonatomic, strong) NSArray *deallocStack;
@end
@implementation _CustomZombie
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [NSMethodSignature signatureWithObjCTypes:"v@:"];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
NSLog(@"❌ 野指针检测!");
NSLog(@"原始类: %@", self.originalClass);
NSLog(@"调用方法: %@", NSStringFromSelector(invocation.selector));
NSLog(@"释放时调用栈:\n%@", [self.deallocStack componentsJoinedByString:@"\n"]);
NSLog(@"当前调用栈:\n%@", [NSThread callStackSymbols]);
// 可以选择崩溃或只记录
// abort();
}
@end
// Hook dealloc
static void (*original_dealloc)(id, SEL);
static void custom_dealloc(id self, SEL _cmd) {
// 采样率控制
static NSUInteger sampleCounter = 0;
if (++sampleCounter % 100 != 0) { // 1% 采样
original_dealloc(self, _cmd);
return;
}
// 保存原始类
Class originalClass = object_getClass(self);
// 忽略系统类
const char *className = class_getName(originalClass);
if (strncmp(className, "NS", 2) == 0 ||
strncmp(className, "UI", 2) == 0 ||
strncmp(className, "_", 1) == 0) {
original_dealloc(self, _cmd);
return;
}
// 创建 Zombie
_CustomZombie *zombie = [[_CustomZombie alloc] init];
zombie.originalClass = originalClass;
zombie.deallocStack = [NSThread callStackSymbols];
// 修改 isa
object_setClass(self, [_CustomZombie class]);
// 加入 Zombie 池(循环使用)
os_unfair_lock_lock(&sZombieLock);
// 释放老的 Zombie
void *oldZombie = sZombiePool[sZombieIndex];
if (oldZombie) {
object_setClass((__bridge id)oldZombie, [NSObject class]);
free(oldZombie);
}
// 存储新的 Zombie(不释放内存)
sZombiePool[sZombieIndex] = (__bridge void *)self;
sZombieIndex = (sZombieIndex + 1) % kZombiePoolSize;
os_unfair_lock_unlock(&sZombieLock);
}
// 初始化
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Method deallocMethod = class_getInstanceMethod([NSObject class],
NSSelectorFromString(@"dealloc"));
original_dealloc = (void *)method_getImplementation(deallocMethod);
method_setImplementation(deallocMethod, (IMP)custom_dealloc);
});
}
五、野指针定位方法论
5.1 系统化定位流程
┌─────────────────────────────────────────────────────────────────────┐
│ 野指针定位流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 1: 收集信息 │ │
│ │ │ │
│ │ • 崩溃日志(完整堆栈) │ │
│ │ • 崩溃地址和类型 │ │
│ │ • 复现步骤(如果有) │ │
│ │ • 崩溃用户的设备/系统版本 │ │
│ │ • 崩溃前的用户操作 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 2: 初步分析 │ │
│ │ │ │
│ │ • 符号化堆栈 │ │
│ │ • 分析崩溃地址特征 │ │
│ │ • 识别崩溃模式 │ │
│ │ • 定位可疑代码区域 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 3: 尝试复现 │ │
│ │ │ │
│ │ 开启调试工具: │ │
│ │ • Zombie Objects │ │
│ │ • Address Sanitizer │ │
│ │ • Malloc Scribble │ │
│ │ • Thread Sanitizer (如果怀疑多线程) │ │
│ │ │ │
│ │ 尝试复现路径: │ │
│ │ • 按用户报告的步骤 │ │
│ │ • 快速切换页面 │ │
│ │ • 频繁触发内存警告 │ │
│ │ • 模拟弱网络环境 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────┴─────────────────┐ │
│ ▼ ▼ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ 能复现 │ │ 不能复现 │ │
│ └────────┬───────────┘ └────────┬───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 4: 定位根因 │ │
│ │ │ │
│ │ 能复现时: │ │
│ │ • 用 Zombie 获取原始类名 │ │
│ │ • 用 ASan 获取分配/释放堆栈 │ │
│ │ • 断点调试追踪生命周期 │ │
│ │ │ │
│ │ 不能复现时: │ │
│ │ • 代码审查可疑区域 │ │
│ │ • 检查 delegate/block/timer 使用 │ │
│ │ • 检查多线程安全 │ │
│ │ • 添加防御性代码和日志 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 5: 修复验证 │ │
│ │ │ │
│ │ • 确认修复不引入新问题 │ │
│ │ • 添加单元测试 │ │
│ │ • 灰度验证 │ │
│ │ • 监控崩溃率变化 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
5.2 根据崩溃特征分类定位
┌─────────────────────────────────────────────────────────────────────┐
│ 按崩溃特征分类定位 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 【特征 1: objc_msgSend 崩溃】 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 堆栈: │ │
│ │ 0 libobjc.A.dylib objc_msgSend + 16 │ │
│ │ 1 MyApp -[ViewController updateUI] + 48 │ │
│ │ │ │
│ │ 分析: │ │
│ │ • +16 偏移说明在读取 isa 时崩溃 │ │
│ │ • 对象的 isa 已经损坏或对象已释放 │ │
│ │ │ │
│ │ 定位方向: │ │
│ │ • 检查 updateUI 中访问的所有对象 │ │
│ │ • 特别关注 weak/unowned 引用 │ │
│ │ • 检查是否有 async 回调 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 【特征 2: unrecognized selector】 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 异常: │ │
│ │ -[NSConcreteMutableData setFrame:]: unrecognized selector │ │
│ │ sent to instance 0x12345678 │ │
│ │ │ │
│ │ 分析: │ │
│ │ • 期望是 UIView,实际是 NSMutableData │ │
│ │ • 内存被不同类型的对象复用 │ │
│ │ │ │
│ │ 定位方向: │ │
│ │ • 找到调用 setFrame: 的代码 │ │
│ │ • 检查该对象的生命周期 │ │
│ │ • 用 Zombie 确认原始类型 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 【特征 3: objc_release 崩溃】 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 堆栈: │ │
│ │ 0 libobjc.A.dylib objc_release + 16 │ │
│ │ 1 MyApp -[MyObject .cxx_destruct] + 32 │ │
│ │ │ │
│ │ 分析: │ │
│ │ • 对象析构时释放成员变量出错 │ │
│ │ • 成员变量已经被错误释放过 │ │
│ │ │ │
│ │ 定位方向: │ │
│ │ • 检查 MyObject 的所有 strong 属性 │ │
│ │ • 是否有手动内存管理代码 │ │
│ │ • 是否有 __bridge 转换错误 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 【特征 4: 特定地址模式】 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 地址 0x0000000000000000: │ │
│ │ → 空指针 │ │
│ │ → 检查可选值解包 │ │
│ │ │ │
│ │ 地址 0x0000000000000010 (小地址): │ │
│ │ → 空对象 + 成员偏移 │ │
│ │ → 检查对象为 nil 时访问成员 │ │
│ │ │ │
│ │ 地址 0x5555555555555555: │ │
│ │ → Scribble 模式,对象已释放 │ │
│ │ → 明确的野指针 │ │
│ │ │ │
│ │ 地址 0xDEADBEEF / 0xBADF00D: │ │
│ │ → 调试标记 │ │
│ │ → 可能是测试代码遗留 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
5.3 代码审查检查清单
// 野指针常见代码模式检查清单
// ❌ 模式 1: delegate 使用 assign/unsafe_unretained
@property (nonatomic, assign) id<SomeDelegate> delegate;
// ✅ 应该用 weak
@property (nonatomic, weak) id<SomeDelegate> delegate;
// ❌ 模式 2: block 中直接使用 self
self.completionBlock = ^{
[self doSomething]; // 循环引用或野指针
};
// ✅ 使用 weak-strong dance
__weak typeof(self) weakSelf = self;
self.completionBlock = ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
[strongSelf doSomething];
};
// ❌ 模式 3: Timer 未正确释放
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0
target:self
selector:@selector(tick)
userInfo:nil
repeats:YES];
// ✅ 使用 block API 或 proxy
__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0
repeats:YES
block:^(NSTimer *timer) {
[weakSelf tick];
}];
// ❌ 模式 4: 异步回调后直接使用成员
[self.api fetchData:^(id data) {
self.tableView.dataSource = data; // self 可能已释放
}];
// ❌ 模式 5: 多线程访问非线程安全对象
@property (nonatomic, strong) NSMutableArray *items;
// 线程 A: [self.items addObject:obj];
// 线程 B: [self.items removeAllObjects];
// ✅ 使用锁或串行队列
@property (nonatomic, strong) dispatch_queue_t itemsQueue;
- (void)addItem:(id)item {
dispatch_sync(self.itemsQueue, ^{
[self.items addObject:item];
});
}
// ❌ 模式 6: __bridge 转换错误
CFStringRef cfStr = (__bridge CFStringRef)nsStr;
CFRelease(cfStr); // 错误!nsStr 是 autorelease 的
// ✅ 使用正确的桥接
CFStringRef cfStr = (__bridge_retained CFStringRef)nsStr;
CFRelease(cfStr); // OK,或者用 CFBridgingRetain
// ❌ 模式 7: 通知未移除
- (void)viewDidLoad {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handle:)
name:@"SomeNotification"
object:nil];
}
// dealloc 中忘记 removeObserver
// iOS 9+ 会自动移除,但最好显式移除
// ❌ 模式 8: KVO 未移除
- (void)viewDidLoad {
[self.model addObserver:self forKeyPath:@"value" options:0 context:nil];
}
// ✅ 确保在 dealloc 中移除
- (void)dealloc {
[self.model removeObserver:self forKeyPath:@"value"];
}
六、高级调试技巧
6.1 LLDB 调试技巧
# 1. 在野指针崩溃时查看寄存器
(lldb) register read
General Purpose Registers:
x0 = 0x0000000102abc018 <- 这是 self/第一个参数
x1 = 0x00000001a2b3c4d8 <- 这是 SEL
...
x29 = 0x000000016f8a5a60 <- FP
x30 = 0x00000001001234f0 <- LR (返回地址)
sp = 0x000000016f8a5a40 <- SP
pc = 0x00000001a2000010 <- 当前指令
# 2. 查看对象信息
(lldb) po 0x0000000102abc018
error: Couldn't apply expression side effects :
Couldn't read a value from memory
(lldb) memory read 0x0000000102abc018
error: memory read failed for 0x102abc018
# 3. 尝试读取 isa
(lldb) x/gx 0x0000000102abc018
0x102abc018: error: memory read failed
# 4. 如果开启了 Zombie,可能看到
(lldb) po 0x0000000102abc018
<_NSZombie_MyViewController: 0x102abc018>
# 5. 查看 selector
(lldb) po (SEL)0x00000001a2b3c4d8
"viewDidLoad"
# 6. 查看调用栈
(lldb) bt
* thread #1, queue = 'com.apple.main-thread'
* frame #0: 0x1a2000010 libobjc.A.dylib`objc_msgSend + 16
frame #1: 0x1001234f0 MyApp`-[ViewController updateUI] + 48
# 7. 查看特定帧的局部变量
(lldb) frame select 1
(lldb) frame variable
(MyViewController *) self = 0x0000000102abc018
(SEL) _cmd = "updateUI"
# 8. 反汇编崩溃位置
(lldb) disassemble --frame
libobjc.A.dylib`objc_msgSend:
0x1a2000000 <+0>: cmp x0, #0x0
0x1a2000004 <+4>: b.le 0x1a2000060
0x1a2000008 <+8>: ldr x13, [x0] ; 加载 isa
-> 0x1a2000010 <+16>: and x16, x13, #0x... ; 💥 崩溃在这里
# 9. 设置观察点监控内存
(lldb) watchpoint set expression -w write -- (void *)0x102abc018
Watchpoint created: Watchpoint 1: addr = 0x102abc018
# 10. 条件断点找野指针
(lldb) breakpoint set -n objc_msgSend -c '(void *)$x0 == 0x102abc018'
6.2 追踪对象生命周期
// 使用 Method Swizzling 追踪特定类
#import <objc/runtime.h>
@implementation LifecycleTracker
+ (void)trackClass:(Class)cls {
// Hook alloc
[self swizzleClassMethod:cls
original:@selector(alloc)
swizzled:@selector(tracked_alloc)];
// Hook dealloc
[self swizzleInstanceMethod:cls
original:NSSelectorFromString(@"dealloc")
swizzled:@selector(tracked_dealloc)];
}
+ (void)swizzleInstanceMethod:(Class)cls original:(SEL)original swizzled:(SEL)swizzled {
Method originalMethod = class_getInstanceMethod(cls, original);
// 添加追踪方法
IMP swizzledIMP = imp_implementationWithBlock(^(id self) {
NSLog(@"🔴 [%@ dealloc] %p", NSStringFromClass(cls), self);
NSLog(@"调用栈:\n%@", [NSThread callStackSymbols]);
// 调用原始实现
((void (*)(id, SEL))method_getImplementation(originalMethod))(self, original);
});
class_replaceMethod(cls, original, swizzledIMP, method_getTypeEncoding(originalMethod));
}
+ (void)swizzleClassMethod:(Class)cls original:(SEL)original swizzled:(SEL)swizzled {
Method originalMethod = class_getClassMethod(cls, original);
IMP swizzledIMP = imp_implementationWithBlock(^id(id self) {
id result = ((id (*)(id, SEL))method_getImplementation(originalMethod))(self, original);
NSLog(@"🟢 [%@ alloc] %p", NSStringFromClass(cls), result);
return result;
});
Class metaClass = object_getClass(cls);
class_replaceMethod(metaClass, original, swizzledIMP, method_getTypeEncoding(originalMethod));
}
@end
// 使用
[LifecycleTracker trackClass:[SuspiciousClass class]];
// 输出示例:
// 🟢 [SuspiciousClass alloc] 0x12345678
// 🔴 [SuspiciousClass dealloc] 0x12345678
// 调用栈:
// 0 MyApp -[LifecycleTracker tracked_dealloc]
// 1 MyApp -[SomeManager cleanup]
// 2 MyApp -[ViewController handleMemoryWarning]
// ...
6.3 内存写入监控
// 监控特定地址的内存写入
// 使用 mach exception handler
#import <mach/mach.h>
@interface MemoryWatchdog : NSObject
+ (void)watchAddress:(void *)address size:(size_t)size;
@end
@implementation MemoryWatchdog
+ (void)watchAddress:(void *)address size:(size_t)size {
// 设置内存保护为只读
vm_address_t pageAddress = (vm_address_t)address & ~(PAGE_SIZE - 1);
kern_return_t kr = vm_protect(
mach_task_self(),
pageAddress,
PAGE_SIZE,
FALSE,
VM_PROT_READ // 移除写权限
);
if (kr != KERN_SUCCESS) {
NSLog(@"vm_protect failed: %d", kr);
return;
}
// 设置 exception handler
[self setupExceptionHandler];
}
+ (void)setupExceptionHandler {
// 注册 mach exception handler
// 当写入被保护的内存时会收到 EXC_BAD_ACCESS
// 可以在 handler 中记录调用栈,然后恢复写权限继续执行
// 这需要较复杂的 mach exception handling 代码
// 建议使用 fishhook 或类似库
}
@end
// 更简单的方式:使用 LLDB watchpoint
// (lldb) watchpoint set expression -w write -- *(int *)0x12345678
七、预防野指针的最佳实践
7.1 编码规范
// 1. 使用正确的属性修饰符
@property (nonatomic, weak) id<Delegate> delegate; // ✅ 代理用 weak
@property (nonatomic, copy) NSString *name; // ✅ 字符串用 copy
@property (nonatomic, strong) NSMutableArray *items; // ✅ 容器用 strong
// 2. Block 中的 self 处理
- (void)setupCallback {
__weak typeof(self) weakSelf = self;
self.callback = ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
[strongSelf doSomething];
};
}
// 3. dealloc 中清理
- (void)dealloc {
// 移除通知
[[NSNotificationCenter defaultCenter] removeObserver:self];
// 停止定时器
[self.timer invalidate];
// 取消网络请求
[self.task cancel];
// 清理 delegate
self.scrollView.delegate = nil;
// 移除 KVO
[self.model removeObserver:self forKeyPath:@"value"];
}
// 4. 多线程安全
- (void)addItem:(id)item {
dispatch_barrier_async(self.concurrentQueue, ^{
[self.items addObject:item];
});
}
- (NSArray *)allItems {
__block NSArray *result;
dispatch_sync(self.concurrentQueue, ^{
result = [self.items copy];
});
return result;
}
// 5. 避免 __unsafe_unretained
// ❌
__unsafe_unretained id unsafeRef = obj;
// ✅
__weak id weakRef = obj;
// 6. Core Foundation 正确桥接
// 创建 CF 对象并转交给 ARC
NSString *str = CFBridgingRelease(CFStringCreateWithCString(NULL, "test", kCFStringEncodingUTF8));
// 将 ARC 对象给 CF API
CFStringRef cfStr = CFBridgingRetain(nsString);
// 使用后
CFRelease(cfStr);
7.2 防御性编程
// 1. 安全的 weak-strong dance
#define weakify(obj) \
__weak typeof(obj) weak_##obj = obj;
#define strongify(obj) \
__strong typeof(weak_##obj) obj = weak_##obj; \
if (!obj) return;
// 使用
- (void)loadData {
weakify(self);
[self.api fetchData:^(id data) {
strongify(self);
self.data = data;
}];
}
// 2. 空值检查宏
#define SAFE_CALL(obj, method) \
do { \
id _obj = (obj); \
if (_obj) { \
[_obj method]; \
} \
} while(0)
// 3. 安全的数组访问
@implementation NSArray (Safe)
- (id)safeObjectAtIndex:(NSUInteger)index {
if (index < self.count) {
return self[index];
}
NSLog(@"⚠️ Array index out of bounds: %lu >= %lu",
(unsigned long)index, (unsigned long)self.count);
return nil;
}
@end
// 4. 安全的字典访问
@implementation NSDictionary (Safe)
- (id)safeObjectForKey:(id)key {
if (key) {
return self[key];
}
NSLog(@"⚠️ Nil key passed to dictionary");
return nil;
}
@end
// 5. 断言关键假设
- (void)processData:(NSData *)data {
NSAssert(data != nil, @"Data should not be nil");
NSAssert(data.length > 0, @"Data should not be empty");
// 处理逻辑
}
// 6. 添加运行时检查
- (void)setDelegate:(id<MyDelegate>)delegate {
NSAssert(delegate == nil || [delegate conformsToProtocol:@protocol(MyDelegate)],
@"Delegate must conform to MyDelegate protocol");
_delegate = delegate;
}
7.3 监控与告警
// 建立野指针监控系统
@interface CrashMonitor : NSObject
+ (void)setup;
@end
@implementation CrashMonitor
+ (void)setup {
// 1. 设置未捕获异常处理
NSSetUncaughtExceptionHandler(&uncaughtExceptionHandler);
// 2. 设置 signal 处理
signal(SIGSEGV, signalHandler);
signal(SIGBUS, signalHandler);
signal(SIGABRT, signalHandler);
}
static void uncaughtExceptionHandler(NSException *exception) {
// 记录异常信息
NSDictionary *info = @{
@"name": exception.name,
@"reason": exception.reason ?: @"",
@"stack": exception.callStackSymbols,
@"userInfo": exception.userInfo ?: @{}
};
[self saveCrashInfo:info];
}
static void signalHandler(int signal) {
// 获取当前堆栈
void *callstack[128];
int frames = backtrace(callstack, 128);
char **symbols = backtrace_symbols(callstack, frames);
NSMutableArray *stack = [NSMutableArray array];
for (int i = 0; i < frames; i++) {
[stack addObject:@(symbols[i])];
}
free(symbols);
NSDictionary *info = @{
@"signal": @(signal),
@"stack": stack
};
[self saveCrashInfo:info];
// 恢复默认处理
signal(signal, SIG_DFL);
raise(signal);
}
+ (void)saveCrashInfo:(NSDictionary *)info {
// 保存到本地,下次启动时上报
NSString *path = [self crashLogPath];
[info writeToFile:path atomically:YES];
}
@end
八、总结
8.1 核心要点
┌─────────────────────────────────────────────────────────────────────┐
│ 野指针调试核心要点 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 【理解原理】 │
│ • 野指针是指向已释放内存的指针 │
│ • 访问野指针可能崩溃、读到垃圾、或看似正常 │
│ • 崩溃现场往往不是 bug 产生的地方 │
│ │
│ 【使用工具】 │
│ • Zombie Objects: 检测向已释放对象发消息 │
│ • Address Sanitizer: 检测各种内存错误 │
│ • Malloc Scribble: 让野指针更容易崩溃 │
│ • LLDB: 分析崩溃现场 │
│ │
│ 【定位方法】 │
│ • 分析崩溃地址和堆栈特征 │
│ • 结合 Zombie 确定原始类型 │
│ • 追踪可疑对象的生命周期 │
│ • 代码审查常见模式 │
│ │
│ 【预防措施】 │
│ • 正确使用 weak/strong/unowned │
│ • Block 中使用 weak-strong dance │
│ • dealloc 中清理资源 │
│ • 多线程使用同步机制 │
│ • 添加防御性检查 │
│ │
│ 【常见陷阱】 │
│ • delegate 用 assign 而不是 weak │
│ • Timer 的 target 强引用问题 │
│ • 通知/KVO 未移除 │
│ • __unsafe_unretained 的使用 │
│ • CF 对象桥接错误 │
│ │
└─────────────────────────────────────────────────────────────────────┘
8.2 调试工具速查
| 工具 | 用途 | 开启方式 |
|---|---|---|
| Zombie Objects | 检测释放后使用 | Scheme → Diagnostics |
| Address Sanitizer | 全面内存检测 | Build Settings |
| Malloc Scribble | 填充已释放内存 | 环境变量 MallocScribble=1 |
| Malloc Guard Edges | 检测缓冲区溢出 | 环境变量 MallocGuardEdges=1 |
| Malloc Stack Logging | 记录分配堆栈 | 环境变量 MallocStackLogging=1 |
| Thread Sanitizer | 检测数据竞争 | Build Settings |
参考资料
更多推荐
所有评论(0)