1. 从一个让人抓狂的崩溃说起
如果你写过一段时间的iOS代码,大概率遇到过这种场景:手指刚点下按钮,界面还没来得及刷新,Xcode的调试器就“啪”地一下把进程挂起,控制台里只留下一行冷冰冰的EXC_BAD_ACCESS (code=1, address=0x...)。更让人崩溃的是,堆栈信息经常指向一些看起来完全无关的系统库,或者干脆只给你一个内存地址,连哪一行代码出的问题都看不出来。我第一次遇到这个崩溃的时候,盯着屏幕愣了十几分钟,反复确认自己写的代码逻辑没问题,最后才发现是一个对象在释放之后还被继续访问了。
EXC_BAD_ACCESS是iOS开发中最常见、也最让人头疼的崩溃类型之一。它的本质是访问了无效的内存地址,但这个“无效”背后可能藏着好几种完全不同的原因:对象已经释放、野指针、数组越界、栈溢出、内存对齐问题等等。这篇文章我会从底层原理讲起,把EXC_BAD_ACCESS的来龙去脉拆开揉碎,再结合我实际排查过的几个典型案例,给出可直接复现的排查步骤和工具配置方法。不管你是刚接触iOS开发的新手,还是已经写过几万行代码的老手,只要你的App还在跟内存打交道,这篇内容都值得你花时间看完。
2. EXC_BAD_ACCESS 到底是什么
2.1 从Mach异常到信号:崩溃的传递链路
要理解EXC_BAD_ACCESS,得先知道它从哪来。在苹果的操作系统里,底层用的是Mach内核,当CPU的MMU(内存管理单元)发现程序试图访问一个没有映射到物理内存的虚拟地址时,会触发一个Mach异常,类型就是EXC_BAD_ACCESS。这个异常会沿着内核到用户态的边界传递,最终被转换成我们熟悉的SIGSEGV(段错误)或SIGBUS(总线错误)信号。Xcode的调试器捕获到这些信号后,就会在控制台打印出EXC_BAD_ACCESS并暂停进程。
这里有一个关键点:Mach异常携带的信息比信号更丰富。EXC_BAD_ACCESS的异常码里包含了具体的访问类型(读还是写)以及出错的地址。Xcode在展示崩溃信息时,通常会给出code=1或code=2这样的标识,code=1表示读取无效地址,code=2表示写入无效地址。看到这个区分很重要,因为读崩溃和写崩溃的排查方向往往不一样。
提示:如果你在真机上抓崩溃日志,看到的是
Exception Type: EXC_BAD_ACCESS (SIGSEGV)或EXC_BAD_ACCESS (SIGBUS),前者通常是访问了未映射的地址,后者更多跟内存对齐或硬件层面的问题有关。
2.2 为什么堆栈信息经常“跑偏”
很多人抱怨EXC_BAD_ACCESS的堆栈没用,其实不是堆栈没用,而是崩溃发生的位置和真正出问题的位置往往不在一起。举个例子:你有一个对象A,它在某个时刻被释放了,但另一个对象B还持有它的指针。过了几秒钟,B给这个已经释放的地址发送了一条消息。这时候崩溃发生在B的消息发送处,但根因是A被提前释放了。堆栈里显示的是B的调用链,跟A的释放逻辑可能隔了十万八千里。
还有一种情况是内存被覆写。比如你有一个数组,越界写入了一个字节,这个字节恰好覆盖了旁边某个对象的引用计数或者isa指针。崩溃可能发生在几分钟之后,当系统试图访问那个被破坏的对象时。这种情况下,堆栈信息更是完全对不上号。所以排查EXC_BAD_ACCESS的核心思路不是盯着崩溃那一行看,而是找到内存被破坏的源头。
2.3 常见触发场景速览
为了让你有个全局印象,我把实际开发中最容易触发EXC_BAD_ACCESS的场景列在下面。这张表不是让你背的,而是当你在排查时,可以对照着逐个排除。
| 触发场景 | 典型表现 | 排查难度 |
|---|---|---|
| 访问已释放对象(僵尸对象) | 崩溃地址接近0x0或某个小数值 | 中等 |
| 野指针未置空 | 随机崩溃,堆栈不稳定 | 较高 |
| 数组越界写入 | 崩溃延迟出现,堆栈无关 | 高 |
| 栈溢出(递归过深) | 崩溃在递归函数附近 | 低 |
| 多线程竞争访问 | 偶现,跟线程调度相关 | 高 |
| C语言内存操作失误 | 崩溃在memcpy/memset附近 | 中等 |
这张表里的每一行,后面我都会展开讲。现在你只需要记住:看到EXC_BAD_ACCESS不要慌,先看地址,再看堆栈,最后用工具定位。
3. 内存管理机制与崩溃的根源
3.1 ARC不是万能的:引用计数背后的陷阱
很多新手以为开了ARC就万事大吉,不会再出现内存问题。这个想法很危险。ARC的本质是编译器在合适的位置自动插入retain和release调用,它管理的是对象的生命周期,但管不了所有内存访问。以下几种情况ARC完全无能为力:
第一种是悬垂指针。当你把一个对象赋给一个unsafe_unretained或assign修饰的属性时,ARC不会持有这个对象。如果原对象被释放了,这个属性就变成了悬垂指针,再访问就会崩溃。第二种是Core Foundation对象与Objective-C对象的桥接。__bridge转换不会转移所有权,如果你桥接之后原对象被释放了,再访问桥接后的指针一样会崩溃。第三种是C语言层面的内存操作,比如malloc和free,ARC根本不参与。
我见过一个很典型的案例:某开发者用__bridge把一个CF对象转成id之后,存到了一个数组里,以为数组会持有它。结果原CF对象被释放,数组里的元素变成了野指针,后续遍历数组时直接崩溃。这个问题的根源就是对ARC的能力边界理解不清。
3.2 僵尸对象:让已释放对象“诈尸”来帮你定位
僵尸对象是排查EXC_BAD_ACCESS最有力的工具之一。它的原理很简单:当一个对象引用计数归零时,不真正释放内存,而是把它标记为僵尸对象。之后任何试图访问这个对象的行为,都会触发一个明确的崩溃日志,告诉你“你正在访问一个已经释放的对象”,甚至能告诉你这个对象是什么类型、什么时候被释放的。
开启僵尸对象的方式有两种。一种是在Xcode的Scheme设置里,勾选“Enable Zombie Objects”。另一种是在代码里设置环境变量NSZombieEnabled=YES。开启之后,当你再遇到EXC_BAD_ACCESS,控制台会打印类似这样的信息:
*** -[MyViewController respondsToSelector:]: message sent to deallocated instance 0x7f8e8a5b3c00这比单纯的地址崩溃信息有用太多了。你可以直接看到是哪个类的实例被访问了,然后顺着这个线索去查它的生命周期。
注意:僵尸对象会占用大量内存,因为它不真正释放对象。所以只在调试阶段开启,定位到问题后立刻关掉。线上环境绝对不能开,否则内存会爆炸。
3.3 内存地址的“读”与“写”:code=1和code=2的区别
前面提到EXC_BAD_ACCESS的code字段区分了读和写,这个信息在排查时非常关键。我整理了一个对照表,帮你快速判断排查方向:
| code值 | 含义 | 常见原因 | 优先排查方向 |
|---|---|---|---|
| code=1 | 读取无效地址 | 访问已释放对象、野指针读取 | 对象生命周期、weak/assign属性 |
| code=2 | 写入无效地址 | 数组越界写入、向已释放对象写数据 | 容器操作、C内存操作 |
| code=257 | 访问空指针附近地址 | 向nil对象发送消息后继续操作 | 可选值解包、空判断 |
看到code=2的时候,我一般会优先怀疑容器类操作。因为写入崩溃往往意味着你在往一个不属于你的内存区域写数据,数组越界是最常见的元凶。而code=1更多是读取问题,对象释放的可能性更大。
4. 实战排查:从崩溃日志到问题根源
4.1 第一步:读懂崩溃日志里的关键信息
拿到一个EXC_BAD_ACCESS崩溃日志,不要急着看堆栈,先看这几个地方:
第一,看Exception Type和Subtype。EXC_BAD_ACCESS (SIGSEGV)和EXC_BAD_ACCESS (SIGBUS)的排查方向不同。SIGSEGV通常是访问了未映射的内存,SIGBUS更多是内存对齐问题或者访问了已映射但不可读写的区域。
第二,看Exception Codes。这里会给出具体的地址和code值。比如0x0000000000000000, 0x0000000000000000表示访问了空指针,0x0000000000000001, 0x0000000000000000表示访问了地址0x1,这通常是向一个被释放的对象发送消息(因为对象的isa指针被清零后,访问偏移量会落在很小的地址上)。
第三,看Triggered by Thread。如果是主线程崩溃,排查方向偏向UI操作和主线程逻辑。如果是子线程,要重点看多线程竞争和线程安全问题。
第四,看堆栈里的“Last Exception Backtrace”。有时候崩溃日志里会有两个堆栈,一个是当前线程的,一个是最后异常的。后者往往更接近问题根源。
我实际排查时,会先把崩溃地址和code值记下来,然后对照上面的表格初步判断方向。这一步花不了两分钟,但能帮你省下大量盲目翻代码的时间。
4.2 第二步:用Xcode工具链精准定位
Xcode提供了一整套工具来对付EXC_BAD_ACCESS,关键是你得知道什么时候用哪个。
Address Sanitizer(ASan)是我最推荐的工具。它能在编译时插入检查代码,当程序访问无效内存时立刻报错,并且给出详细的分配和释放堆栈。开启方式是在Scheme的Diagnostics里勾选“Address Sanitizer”。ASan的优点是能抓到那些延迟崩溃的问题,比如数组越界写入之后过了很久才崩溃的情况。缺点是会拖慢运行速度,增加内存占用,所以只在调试时用。
Zombie Objects前面已经讲过了,专门对付已释放对象访问。Malloc Scribble是另一个有用的选项,它会在对象释放后把内存填充成特定模式,这样你访问已释放对象时更容易触发崩溃,而不是读到一些看似正常的数据。Malloc Guard Edges会在每次malloc的内存块前后加上保护页,越界写入会立刻触发崩溃。
这几个工具可以组合使用。我的习惯是:先开Zombie Objects跑一遍,如果崩溃变成了明确的“message sent to deallocated instance”,那就直接定位到了。如果还是地址崩溃,再开ASan,它能给出更底层的内存操作信息。
4.3 第三步:符号化与堆栈还原
有时候你拿到的崩溃日志是一堆十六进制地址,完全看不懂。这时候需要做符号化。Xcode会自动符号化本地调试的崩溃,但如果你拿到的是别人导出的日志,或者是从线上收集的,就需要手动处理。
符号化的核心是用dSYM文件把地址映射回函数名和行号。你需要三样东西:崩溃日志、对应的dSYM文件、以及symbolicatecrash工具(Xcode自带)。操作命令大致是这样的:
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" /Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash crash.log MyApp.app.dSYM > symbolicated.log符号化之后,堆栈里会显示具体的类名和方法名。但要注意,符号化只能还原调用关系,不能告诉你内存为什么无效。它解决的是“崩溃发生在哪个函数”的问题,不解决“为什么这个地址无效”的问题。后者还是得靠ASan和Zombie Objects。
提示:如果你在排查一个偶现的崩溃,建议在Scheme里把“Debug executable”关掉,用Release配置跑。因为Release下的内存布局和优化行为跟线上更接近,有些在Debug下不崩的问题在Release下会暴露出来。
5. 典型案例拆解与避坑指南
5.1 案例一:delegate属性用assign导致的悬垂指针
这是我早期开发中踩过的一个经典坑。某个自定义视图有一个delegate属性,我顺手写成了@property (nonatomic, assign) id<MyViewDelegate> delegate;。在大多数情况下这没问题,因为delegate通常是持有这个视图的控制器,生命周期比视图长。但有一次,这个视图被提前释放了,而delegate指向的控制器还在,控制器在某个回调里访问了视图的delegate方法,结果就是EXC_BAD_ACCESS。
排查过程很曲折,因为崩溃堆栈指向的是控制器的某个方法,跟视图完全没关系。后来开了Zombie Objects,才看到“message sent to deallocated instance”,定位到是视图被提前释放了。修复方式很简单:把assign改成weak。weak属性在对象释放后会自动置为nil,向nil发送消息是安全的,不会崩溃。
这个案例给我的教训是:delegate属性一律用weak,除非你有明确的理由不用。assign修饰对象类型属性在ARC下几乎总是错误的,它只应该用于基本数据类型。
5.2 案例二:多线程访问可变容器
另一个高频崩溃场景是多线程同时读写同一个可变数组或字典。NSMutableArray和NSMutableDictionary都不是线程安全的,如果主线程在遍历数组,子线程同时在添加或删除元素,就可能触发EXC_BAD_ACCESS。这种崩溃的特点是偶现,跟线程调度相关,堆栈不稳定。
我遇到过一次:一个列表页在后台线程更新数据源,同时主线程在刷新表格。崩溃日志显示在objc_msgSend附近,地址是一个很奇怪的值。排查时我先怀疑是数据源对象被释放了,但加了日志后发现对象一直存活。后来用Thread Sanitizer(Xcode的另一个诊断工具)才定位到是多线程访问数组导致的。
解决方案有三种:一是用串行队列把所有对容器的访问串行化;二是用@synchronized加锁;三是用不可变副本,每次更新时创建新数组替换旧数组。我一般推荐第一种,因为串行队列的代码更清晰,不容易死锁。如果性能要求高,可以考虑用NSLock或os_unfair_lock。
5.3 案例三:C语言内存操作越界
如果你的项目里混编了C或C++代码,那EXC_BAD_ACCESS的排查难度会上升一个档次。因为C语言没有ARC,内存管理全靠手动,malloc、memcpy、free这些操作一旦出错,崩溃可能发生在很远的地方。
我处理过一个图像处理的模块,里面用malloc分配了一块像素缓冲区,然后在一个循环里逐像素写入。由于边界计算错误,循环多写了一个像素,覆盖了缓冲区后面的堆管理结构。程序在运行几分钟后才崩溃,堆栈指向的是free函数。这种问题的排查思路是:先用ASan跑一遍,它会精确告诉你哪一行写越界了。ASan对C内存操作的检测非常灵敏,基本上一次就能定位。
修复之后,我养成了一个习惯:所有C内存操作都加上边界断言。比如在写入之前用assert(index < bufferSize)检查一下。虽然断言在Release下会被去掉,但至少在开发和测试阶段能提前暴露问题。
5.4 避坑清单:写代码时就能预防的几件事
与其等崩溃了再排查,不如在写代码时就避开这些坑。下面是我总结的一份自查清单,每次提交代码前扫一眼,能省下不少调试时间。
- delegate和block外的对象引用:delegate用weak,block里引用self时注意循环引用和对象生命周期。
- 容器操作:多线程访问可变容器必须加锁或串行化,遍历时不要修改容器。
- C内存操作:malloc之后检查返回值,memcpy之前确认长度,free之后把指针置空。
- 桥接转换:
__bridge不转移所有权,__bridge_transfer和__bridge_retained要配对使用。 - 通知和KVO:移除通知的时机要跟添加时对应,KVO的removeObserver要在dealloc之前。
- 递归调用:确保递归有终止条件,深度过大时考虑改成迭代。
这份清单看起来简单,但每一条背后都是真实的崩溃案例。我自己的项目里,光是delegate用assign这一个问题,就出现过至少三次。
6. 工具链配置与调试技巧
6.1 Scheme诊断选项的取舍
Xcode的Scheme设置里有一堆诊断选项,全开的话性能会非常差,所以需要根据场景取舍。我一般分三种配置:
日常开发:只开Zombie Objects和Malloc Scribble。这两个对性能影响相对小,能覆盖大部分对象生命周期问题。
排查疑难崩溃:开Address Sanitizer和Malloc Guard Edges。这两个能抓到内存越界和底层访问问题,但会让App跑得很慢,只适合定向排查。
性能测试:全部关掉,用Release配置。诊断工具会改变内存布局和时序,测出来的性能数据不准。
注意:Address Sanitizer和Zombie Objects不能同时开,它们会冲突。ASan自己就能检测已释放对象访问,所以排查时二选一即可。
6.2 线上崩溃的收集与分析
线上环境的EXC_BAD_ACCESS排查比本地麻烦得多,因为你没有调试器,也不能开诊断工具。这时候需要靠崩溃收集平台把日志收回来,然后做符号化和聚合分析。
线上崩溃日志里,EXC_BAD_ACCESS通常占很大比例,但很多是同一个根因。我的做法是先按崩溃地址和堆栈做聚合,把相同特征的崩溃归为一类,然后优先处理量大的。对于地址接近0x0的崩溃,重点查空指针和可选值解包;对于地址是随机值的,重点查对象释放和多线程。
还有一个技巧:在关键对象的dealloc里打日志。虽然线上不能开Zombie Objects,但你可以记录对象的释放时间和线程,跟崩溃时间做对比。如果崩溃发生在某个对象释放之后不久,那大概率就是它了。
6.3 用LLDB命令辅助定位
LLDB调试器里有一些命令对排查EXC_BAD_ACCESS很有帮助。当崩溃发生时,你可以用po命令打印对象,用p命令打印基本类型。如果崩溃地址是一个对象指针,可以试试po (id)0x地址,如果对象还活着,会打印出它的描述;如果已经释放,会报错或者打印乱码。
另一个有用的命令是image lookup -a 地址,它能告诉你这个地址属于哪个库或哪个函数。对于没有符号化的堆栈,这个命令能帮你快速定位到模块级别。
还有一个技巧:在崩溃时用bt打印完整堆栈,然后用frame select切换到可疑的帧,再用frame variable查看局部变量。有时候局部变量里就藏着那个被释放的对象指针。
7. 从根源上减少 EXC_BAD_ACCESS 的编码习惯
排查技巧再厉害,也不如一开始就不写出会崩溃的代码。我在实际项目里总结了几个习惯,坚持下来之后,EXC_BAD_ACCESS的出现频率明显下降。
第一,对象属性修饰符要选对。对象类型用strong或weak,基本类型用assign,copy用于有可变子类的对象(比如NSString、NSArray)。这个规则看起来基础,但很多人就是在这里出错。
第二,容器操作要加防御。往数组里添加对象前检查是否为nil,从字典里取值时用objectForKey:而不是下标语法(下标语法在key不存在时会崩溃)。遍历可变容器时,先用copy拿到不可变副本再遍历。
第三,多线程访问共享数据要加锁。不要心存侥幸,觉得“应该不会同时访问”。只要是多线程环境,共享可变状态就必须保护。串行队列是最简单可靠的方案。
第四,C内存操作要配对。malloc和free配对,new和delete配对,CFRetain和CFRelease配对。每写一个分配操作,就立刻想好在哪里释放。
第五,善用可选值。Swift里的可选值机制能避免大量空指针崩溃,Objective-C里也可以用nullable和nonnull标注来让编译器帮忙检查。向nil发送消息在Objective-C里是安全的,但访问nil对象的属性再继续操作就可能崩溃。
这些习惯不需要额外的工具或框架,只需要在写代码时多想一想。我自己的体会是,大部分EXC_BAD_ACCESS都是可以预防的,真正需要复杂排查的只是少数。
8. 几个容易被忽略的细节
最后分享几个我在排查过程中发现的、文档里很少提到的细节。
第一个细节:EXC_BAD_ACCESS有时候是栈溢出导致的。如果你的递归函数没有正确的终止条件,或者局部变量占用栈空间过大,就可能触发栈溢出。这种崩溃的地址通常是一个很大的值,接近栈的边界。排查方法是看堆栈里有没有重复的递归调用,或者用ulimit -s查看栈大小限制。
第二个细节:内存对齐问题也会触发EXC_BAD_ACCESS。在某些架构上,访问未对齐的内存地址会触发SIGBUS。这种情况多出现在C结构体指针转换或者网络数据解析时。解决办法是确保指针类型和实际数据对齐,或者用memcpy代替直接指针访问。
第三个细节:EXC_BAD_ACCESS的崩溃地址如果是0x10或0x20这种小数值,通常是向已释放对象发送消息。因为对象的isa指针被清零后,访问isa的偏移量(通常是0x10左右)会落在很小的地址上。看到这种地址,直接开Zombie Objects,基本一抓一个准。
第四个细节:有时候崩溃日志里的堆栈是“假”的。如果内存被破坏得很严重,堆栈本身可能已经被覆写,显示的函数名完全不对。这时候不要相信堆栈,直接用ASan重新跑,让工具告诉你真相。
这些细节都是我在实际排查中一点点积累的,每一条都对应着真实的崩溃案例。希望它们能帮你在下次遇到EXC_BAD_ACCESS时,少走一些弯路。