☰
iOS开发必看:EXC_BAD_ACCESS崩溃从原理到排查实战
2026/10/17 1:24:50 网站建设 项目流程

1. 从一个凌晨两点的崩溃日志说起

做iOS开发这些年,如果让我选一个最让人头疼、也最能区分新手和老手的崩溃类型,EXC_BAD_ACCESS绝对排得上前三。我印象特别深,早年间做一个图片浏览类的App,测试同事反馈说快速滑动列表、反复进出详情页,偶尔会闪退,但又不是必现。当时拿到崩溃日志一看,堆栈顶部就一行冷冰冰的EXC_BAD_ACCESS (SIGSEGV),地址是0x0000000000000010,没有任何业务方法名,完全不知道从哪下手。那种感觉就像家里跳闸了,但配电箱上所有开关都长得一模一样,你根本不知道是哪个回路出了问题。

后来踩的坑多了,我才慢慢摸清楚:EXC_BAD_ACCESS本质上不是一个"错误类型",而是一个内存访问异常的信号。它告诉你的是"程序试图访问一块它没有权限访问的内存",但不会直接告诉你"为什么这块内存不能访问"。这就像医生告诉你"你发烧了",但发烧本身只是症状,真正要治的是引起发烧的病因。所以想搞定这个崩溃,核心不是记住某个修复代码,而是理解它背后那套内存管理的运行机制。

这篇内容我打算把EXC_BAD_ACCESS从现象到本质彻底拆一遍。适合已经写过一段时间iOS、被这个崩溃折磨过、想真正搞懂内存访问原理的开发者。我会从信号机制讲起,再落到最常见的几种触发场景,然后给出可复现的排查链路和工具用法,最后聊聊怎么从编码习惯上把它扼杀在摇篮里。全程都是我自己踩坑总结出来的东西,不是照搬文档。

2. EXC_BAD_ACCESS到底是什么:信号、地址与内存页

2.1 它不是异常,是操作系统发来的"死亡通知"

很多人第一次看到EXC_BAD_ACCESS,会下意识觉得这是Objective-C或者Swift抛出的异常,想用@try-@catch去接。这是个典型的误解。EXC_BAD_ACCESS是Mach内核层面的异常类型,属于操作系统级别的信号,Objective-C的异常捕获机制根本拦不住它。

具体来说,当CPU执行一条内存访问指令时,内存管理单元(MMU)会去查页表,确认这个虚拟地址有没有对应的物理页、当前进程有没有读写权限。如果查表失败——比如地址压根没映射、或者映射了但权限不对——MMU就会触发一个硬件异常,内核接收到之后,把它转换成Mach异常,再进一步转换成我们熟悉的SIGSEGV(段错误)或SIGBUS(总线错误)信号发给进程。进程收到这个信号,默认行为就是直接终止。

所以你在崩溃日志里看到的EXC_BAD_ACCESS (SIGSEGV),其实是"内核发现你访问了非法内存,然后把你干掉了"这个完整过程的最终结果。理解这一点很关键:它发生在指令执行层面,而不是语言运行时层面,这就解释了为什么它经常没有完整的符号化堆栈。

2.2 地址值里藏着关键线索

崩溃日志里通常会带一个出错地址,比如0x0000000000000010、0x0000000000000000、或者一个看起来很正常的堆地址。这个地址值本身就是破案的第一条线索,我习惯先看它落在哪个区间。

地址特征常见含义排查方向
0x0或极小值(如0x10、0x8)访问了nil对象或空指针的成员检查对象是否已释放、是否未初始化
接近0xffffffff...野指针,指向已回收的内存检查悬垂指针、weak/unsafe_unretained
正常的堆地址区间对象已释放但指针还在用检查生命周期、循环引用导致的提前释放
栈地址区间栈溢出或栈上对象越界检查递归深度、大数组局部变量

我遇到0x10这种小地址的次数最多。为什么是0x10而不是0x0?因为访问一个对象的成员变量时,编译器会做对象指针 + 偏移量的运算。如果对象指针是nil(0),而你要访问的成员偏移量是16字节(0x10),那最终访问的地址就是0x10。所以看到0x10、0x8这类值,基本可以断定是对一个nil对象或已释放对象发了消息、取了属性。

2.3 为什么堆栈经常是"断"的

新手最崩溃的一点是:崩溃日志里堆栈只有系统库的几帧,看不到自己的代码。这不是日志丢了,而是崩溃发生时调用栈已经被破坏,或者崩溃点本身就在系统库内部。

举个例子,你有一个悬垂指针指向一块已经释放的内存,这块内存被系统重新分配给了别的对象。当你通过这个指针调用方法时,objc_msgSend会去读这个"对象"的isa指针,结果读到的是一堆垃圾数据,于是跳转到一个非法地址执行,崩溃就发生在objc_msgSend内部。这时候堆栈顶部是系统函数,你的业务代码在更下面几帧,但因为寄存器状态已经乱了,符号化经常失败。

还有一种情况是栈溢出。比如一个递归函数没有正确的终止条件,或者局部变量开了超大数组,把栈空间撑爆了。这种崩溃的地址往往落在栈区间,堆栈也会显得很奇怪。我遇到过一次,一个解析函数递归处理嵌套JSON,深度太大直接把栈干穿了,日志里全是重复的帧,一眼就能看出是递归问题。

3. 四类高频触发场景,对应四种不同的病根

3.1 悬垂指针:对象已经走了,指针还在敲门

这是EXC_BAD_ACCESS的头号元凶。在MRC时代,release之后指针没置nil,下次再用就是悬垂指针。到了ARC时代,虽然编译器帮你管理引用计数,但**unsafe_unretained和assign修饰的对象属性**依然是重灾区。

我见过一个典型写法:某个代理属性用assign修饰(早期很多人图省事),代理对象被释放后,属性指针还指着那块内存。等回调触发时,向这个已释放的对象发消息,直接崩。ARC下正确的做法是代理用weak,这样对象释放后属性自动置nil,再发消息也不会崩(向nil发消息是安全的)。

还有一种更隐蔽的:block捕获了对象,但block的生命周期比对象长。比如一个网络请求的回调block持有了某个ViewController,请求还没回来,ViewController已经被pop销毁了,回调触发时访问self的UI属性,崩。这种问题的排查要靠weak-strong dance,在block里先用weakSelf,进block后再strongSelf一次,确保执行期间对象存活。

3.2 野指针与内存踩踏:别人家的数据被你改了

野指针和悬垂指针有点像,但更恶劣。悬垂指针是"指向已释放对象",野指针是"指向一块随机内存,可能被任何东西占用"。内存踩踏则是越界写入,把相邻对象的内存改坏了,导致那个对象在后续使用时崩溃。

我处理过一个数组越界导致的踩踏问题。代码里用C数组存了一批数据,循环写入时下标算错,多写了一个元素,把紧挨着的另一个对象的引用计数改成了垃圾值。结果那个对象在某个不确定的时刻被错误释放,然后在另一个完全不相干的地方崩溃。这种问题的排查难度极高,因为崩溃点和案发现场往往不在一起。

对付这类问题,Address Sanitizer(ASan)是神器。在Xcode的Scheme里打开"Address Sanitizer",它会用影子内存记录每一块内存的状态,一旦发生越界访问或使用已释放内存,立刻在案发现场报错,而不是等到后面才崩。我现在的习惯是,只要项目里涉及C/C++混编或者大量指针操作,调试阶段一定开着ASan跑一遍。

3.3 多线程竞争:两个线程抢同一块内存

多线程环境下,一个线程正在释放对象,另一个线程同时在访问它,这种时序问题导致的EXC_BAD_ACCESS最难复现。因为它的触发依赖精确的线程调度,可能跑一万次才崩一次。

我遇到过一个案例:一个单例的缓存字典,主线程在写,后台线程在读,没有加锁。大部分时候没事,但偶尔后台线程读到一半,主线程正好在扩容字典,内部结构被改了,后台线程拿着旧的桶指针去访问,崩。这种问题的本质是数据竞争,解决方案要么加锁(@synchronized、NSLock、串行队列),要么用线程安全的容器,要么干脆把所有访问收敛到同一个队列上。

排查多线程崩溃,Thread Sanitizer(TSan)比ASan更对症。它专门检测数据竞争,能告诉你哪两个线程在什么地址上发生了冲突。不过TSan对性能影响比较大,一般只在专门的调试阶段开。

3.4 栈溢出与递归失控

前面提过栈溢出,这里单独说一下。iOS主线程栈大小默认是1MB,子线程是512KB(可以通过pthread_attr_setstacksize调整,但一般不建议乱动)。递归深度过大、局部变量过大、或者无限递归,都会撑爆栈。

栈溢出的崩溃地址通常落在栈区间,堆栈里会有大量重复的帧。我遇到过一个解析器,处理用户输入的嵌套结构时没有限制深度,用户构造了一个超深嵌套的数据,直接把栈干穿了。修复方式很简单:加一个深度上限,超过就报错返回。任何处理外部输入的递归逻辑,都应该有深度保护,这是血泪教训。

4. 一套可复现的排查链路:从日志到根因

4.1 第一步:符号化,把地址翻译成人话

拿到崩溃日志,第一件事是符号化。如果日志里全是十六进制地址,你什么都看不出来。Xcode的Organizer能自动符号化,但前提是你保留了对应版本的.dSYM文件。我踩过的坑是:打包时用了bitcode,结果.dSYM要从App Store后台重新下载,本地那份对不上,符号化出来全是问号。

手动符号化可以用atos命令:

atos -arch arm64 -o YourApp.app.dSYM/Contents/Resources/DWARF/YourApp -l 0x100000000 0x100abc123

其中-l后面是基地址,0x100abc123是崩溃地址。符号化之后,你才能看到崩溃发生在哪个方法、哪一行。这一步没有捷径,.dSYM必须和线上包严格对应,版本错一个都不行。

4.2 第二步:看地址值,缩小范围

符号化之后,结合前面讲的地址特征表,先判断是哪一类问题。如果地址是0x10这种小值,重点查nil和已释放对象;如果是正常堆地址,重点查生命周期;如果是栈地址,重点查递归和局部变量。

我一般会同时打开崩溃日志的"Binary Images"部分,看看崩溃地址落在哪个镜像的区间里。如果落在你的主二进制区间,说明问题在你的代码;如果落在系统库区间,可能是你传了非法参数给系统API。

4.3 第三步:开Zombie Objects,让已释放对象"诈尸"

Xcode有个特别好用的调试选项叫Zombie Objects。打开之后,对象被释放时不会真正回收内存,而是变成一个"僵尸对象",保留isa指针指向一个特殊的僵尸类。当你再向它发消息时,系统会打印出"向已释放对象发送消息"的详细日志,包括对象类型和释放时间。

在Scheme的Diagnostics里勾选"Zombie Objects",然后复现崩溃。如果崩溃变成了僵尸对象提示,那基本可以确定是悬垂指针问题。这个工具对付"对象已释放但指针还在用"的场景几乎是必杀技,我每次排查这类崩溃都会先开它。

4.4 第四步:ASan和TSan,按场景选武器

如果Zombie Objects没抓到,说明可能不是简单的悬垂指针,而是越界或竞争。这时候上ASan或TSan。

ASan的开启方式:Scheme → Diagnostics → Address Sanitizer。它会在编译时插入检查代码,运行时用影子内存监控每次内存访问。一旦越界或使用已释放内存,立刻报错并给出完整堆栈。代价是内存占用增加约2倍,性能下降约2-3倍,所以只在调试时开。

TSan的开启方式类似,勾选"Thread Sanitizer"。它专门抓数据竞争,会告诉你冲突的读写发生在哪两个线程、哪个地址。我一般是在怀疑多线程问题时才开,因为它对性能影响更大。

4.5 第五步:如果还抓不到,用条件断点和日志缩小范围

有些崩溃在调试器下不复现,或者ASan也抓不到,这时候只能靠笨办法:在可疑路径上加日志,二分法缩小范围。我习惯用os_log而不是print,因为os_log性能更好,而且能在Console里按子系统过滤。

具体做法是:在对象创建、释放、使用的关键节点打日志,记录对象地址。崩溃后看日志,找到崩溃地址对应的对象,回溯它的生命周期,看它在哪个环节被释放或改坏。这个过程很枯燥,但往往能挖出最深层的bug。

5. 从编码习惯上根治:让EXC_BAD_ACCESS少出现

5.1 属性修饰符别乱用,weak和strong要分清

ARC下,对象属性默认是strong,代理和回调目标用weak,基本类型用assign。我见过太多人把代理写成assign,然后崩溃了找不到原因。记住一条:只要修饰的是对象类型,且你不希望持有它,就用weak。weak在对象释放后自动置nil,向nil发消息是安全的,这一条就能挡掉大量悬垂指针崩溃。

对于unsafe_unretained,除非你有明确的性能理由,否则别用。它和assign一样不会自动置nil,是悬垂指针的温床。

5.2 block里的self,该weak就weak

block会强引用捕获的对象。如果一个对象持有了block,block又捕获了这个对象,就形成循环引用,对象永远不释放——这是内存泄漏。但反过来,如果block捕获的对象在block执行前被释放了,你又没做weak处理,就是EXC_BAD_ACCESS。

标准写法是:

__weak typeof(self) weakSelf = self; self.completionBlock = ^{ __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; [strongSelf doSomething]; };

先weak避免循环引用,进block后strong确保执行期间对象存活,最后判空防止对象已释放。这个模式我几乎在每个异步回调里都用。

5.3 多线程访问共享数据,收敛到单一队列

共享可变状态是多线程崩溃的根源。我的经验是:能不用共享状态就不用,必须用就收敛到单一队列。比如一个缓存字典,所有读写都dispatch到同一个串行队列上,天然线程安全,比加锁简单还不容易死锁。

如果非要用锁,@synchronized最简单但性能一般,NSLock和os_unfair_lock性能更好。但锁用多了容易死锁,尤其是嵌套加锁的场景。串行队列是更现代的选择。

5.4 处理外部输入,永远假设它是恶意的

递归深度、数组长度、字符串大小,凡是来自外部(网络、用户输入、文件)的数据,都要做边界检查。我现在的习惯是:任何递归函数第一个参数就是深度计数,超过阈值直接返回错误。任何从外部解析出来的数组,访问前先检查下标范围。这些检查看起来啰嗦,但能挡掉大量栈溢出和越界崩溃。

5.5 调试阶段常开诊断工具

最后一条是习惯问题。我建议在开发阶段就把Zombie Objects、ASan、TSan这些工具用起来,别等到线上崩溃了才去查。尤其是ASan,虽然影响性能,但在跑单元测试和UI测试时开着,能提前发现很多潜在的内存问题。等到线上再发现,排查成本要高十倍。

6. 几个我踩过的真实坑与对应解法

6.1 那个0x10地址背后的nil陷阱

前面提过0x10地址,我具体说一个案例。有个页面,数据源数组在某个异步回调里被清空了,但TableView的代理方法还在用旧的下标去取数据,取出来是nil,然后对这个nil对象取属性,崩在0x10。表面看是数组越界,本质是数据源和UI刷新不在同一个线程/时序上。

解法是:刷新UI前先判断数据源是否有效,或者把数据源操作和UI刷新都收敛到主线程,用dispatch_async(dispatch_get_main_queue(), ...)包起来。这个坑让我养成了一个习惯:任何跨线程的数据传递,都要考虑数据在传递过程中是否可能失效。

6.2 一个被循环引用掩盖的悬垂指针

有个模块用了block回调,但block里用了self而不是weakSelf,导致循环引用,对象不释放。后来有人"修复"了循环引用,把self改成weakSelf,但没加strongSelf。结果对象在block执行前被释放了,weakSelf变成nil,访问weakSelf.property时崩了。

这个案例的教训是:weak和strong要配对使用。只weak不strong,对象可能在block执行期间被释放;只strong不weak,循环引用。两者结合才是完整方案。而且改代码时要理解原代码的意图,不能机械替换。

6.3 多线程下字典扩容引发的血案

前面提过字典并发读写,这里补充细节。NSMutableDictionary不是线程安全的,并发读写会导致内部哈希表结构损坏。我当时的场景是:主线程频繁写入缓存,后台线程定时读取上报。崩溃日志显示在objc_msgSend里访问了一个非法地址。

用TSan一跑,立刻定位到两个线程在同一个字典地址上的读写冲突。解法是把字典封装成一个类,所有操作走内部串行队列。改完之后再没崩过。这个坑让我彻底记住了:Foundation里的可变容器,默认都不是线程安全的,别想当然。

6.4 栈溢出:一个没有深度限制的解析器

这个前面提过,补充一下排查过程。崩溃日志堆栈里有几百帧重复的解析函数,地址落在栈区间。一开始以为是死循环,后来发现是递归深度太大。用户输入了一个嵌套了几千层的结构,每层递归都压栈,直接把1MB的栈撑爆了。

解法是加深度限制,超过100层就返回错误。同时把递归改成迭代,用显式栈代替函数调用栈,彻底消除栈溢出风险。处理不可信输入的递归逻辑,深度限制是必须的,这不是可选项。

7. 写在最后:理解内存模型比记住修复代码更重要

EXC_BAD_ACCESS这个崩溃,表面看是"访问了坏内存",但每一种触发场景背后,都是对内存生命周期、线程安全、边界条件理解的缺失。我见过太多人遇到这个崩溃就上网搜"EXC_BAD_ACCESS怎么解决",然后抄一段代码贴上去,下次换个场景又崩。真正管用的做法,是把对象从创建到释放的完整生命周期想清楚,把多线程访问的时序理清楚,把外部输入的边界卡死。

工具方面,Zombie Objects、ASan、TSan这三件套基本能覆盖九成以上的场景,关键是知道什么场景用哪个。Zombie Objects抓悬垂指针,ASan抓越界和已释放内存,TSan抓数据竞争。用对了工具,排查效率能提升一个数量级。

最后分享一个我自己的小习惯:每次修完一个EXC_BAD_ACCESS,我都会在代码注释里写清楚崩溃的根因和修复思路。不是为了给别人看,而是过几个月自己再看到这段代码时,能立刻回忆起当时踩的坑。内存问题往往很隐蔽,把经验沉淀下来,比什么都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询