大多数浏览器和
Developer App 均支持流媒体播放。
-
解读崩溃和崩溃日志
App 突然崩溃是用户体验不佳和 app 审核遭拒的原因之一。了解崩溃日志的分析方式、日志所含的信息以及如何诊断崩溃原因,包括难以重现的内存损坏和多线程问题。
资源
- iOS Debugging Magic
- Mac OS X Debugging Magic
- Thread Sanitizer
- Understanding and Analyzing Application Crash Reports
- 演示幻灯片 (PDF)
相关视频
WWDC21
WWDC20
WWDC18
-
下载
(理解崩溃和崩溃日志 演讲414) 大家早上好 感谢你们的到来
稍后一些非常聪明的人会到台上来 今天我们为你准备了一些很酷的东西
首先声明 如果你从未编写过会崩溃的代码 则此演讲不适合你 这次演讲是为我们中那些 会犯错误的人准备的
今天我们将讨论 你可以使用的工具和技术 来更好处理你的崩溃代码 若这些崩溃会影响到你的用户
具体来说 我将介绍崩溃的基本原理 它们为什么会发生 它们是什么样的
然后我将向你展示我们的一些工具 它们可以用来访问崩溃日志
然后Greg将上台并介绍 关于如何阅读崩溃日志内容的 更多细节 然后他会深入探讨 如何使用崩溃日志分析 棘手的内存问题 接下来Kuba将向你展示 如何尽早发现线程竞争 这些线程竞争常常导致崩溃 并且这种崩溃非常难以重现
首先我们应该给它一个定义 什么是崩溃
崩溃是当你的app 试图做一些不被允许的事情时的 突然终止 那么不允许的是什么? 有时CPU无法执行某些代码 CPU不会除以零 或者有时是操作系统 正在执行某些策略
操作系统将通过终止你的app 来保护用户体验 这种情况会发生在 当你的app启动时间过长 或它使用了太多内存时
有时 你正在使用的编程语言 会试图阻止失败并将触发崩溃 Swift中的Array 和NSArray将终止你的进程 如果你正试图越界访问数组
或者有时是你 作为开发者试图阻止失败 你可能有一个API 来断言参数是否为非nil 这完全没问题
大家以前应该都见过这个 这就是Xcode中调试器的样子 其被绑定到你的app上 并在你的app终止之前 暂停了该进程
让我们仔细看看左边的这个回溯 你可以在此处看到该app 是如何由操作系统启动的
当我们暂停时 我们可以看到主函数是如何被调用的 以及函数之间的互相调用 最终我们到达了你的代码中的这一点 在该处app除了崩溃别无他法 这里出现了一些问题 最后调试器收到一个信号 即此app即将崩溃并暂停app
有时候 你并不总能很方便地绑定调试器 就像我们这里做的一样 当你没有绑定调试器时 操作系统将以纯文本格式捕获此回溯 并将其保存到磁盘中的 人类可读的崩溃日志中
实际上如果是你的app的 发布版本崩溃了 其崩溃日志看来并不这么清晰明了 其中实际包含的是 二进制名称和地址列表 这是一个非符号化崩溃日志的片段
幸运的是 Xcode负责 对崩溃日志进行符号化处理 所以你会看到那些你熟悉的函数名 文件名和行号
有很多方法可以访问这些崩溃日志 我想首先谈谈如何访问这些崩溃日志 其可能来自TestFlight中的测试人员 或App Store中的用户
你可以使用Xcode中 被称为Crashes Organizer的功能 来下载这些崩溃日志 这就是它的样子 漂亮的暗色模式
让我们来浏览一下它的界面 你可以在左侧看到你在 TestFlight和 App Store上的所有app 它支持我们的所有平台 包括watchOS和app扩展
在右边 对于给定的崩溃点 你可以看到受影响的各种设备的数量
我们按类似问题 即类似崩溃点 来对崩溃日志进行分组 并按受影响的设备数量在源列表中 对它们进行排名
你可以在下方翻阅各个日志的样本
当你单击此按钮时 你可以在项目的调试导航器中 打开崩溃日志并与源代码一起查看 如果你以前没见过这个 它非常酷 我们稍后会看一下 在详细视图中 我们会向你展示 一个完全符号化的回溯 并突出显示崩溃点 既然我们已经熟悉了这个界面 那就让我们试试吧 我在这里打开了Xcode 我将打开Organizer窗口
你可以看到 我选择了Crashes选项卡 即第二个标签 我选中了Kuba和我正研发的 这款Chocolate Chip app 我已将此构建版本 上传到TestFlight 你可以看到这是第五个构建版本 并且许多测试人员报告了崩溃现象 所以这不太好 你可以看到我已经处理过几起崩溃 但我还没有处理过第一个 所以让我们试着解决这问题
这影响了242个设备 我可以看到 app崩溃时所捕获的回溯 并且崩溃点被突出显示了 现在我还不太清楚发生了什么 但我确信如果我在源代码中 打开此崩溃日志 我就能知道发生了什么 所以我要点击 “Open in Project”按钮 选择与app的第五次构建版本 相匹配的项目 你能看到的是这个崩溃日志 已在调试导航器中打开 就好像此app刚刚崩溃一样
而且我们现在暂停在 这个发生严重错误的地方 这时该问自己这里该使用 fatalError函数吗? 我只想在绝对必要的情况下崩溃
这是一个Int类型枚举的 初始化函数 并且枚举值只能是0或1 否则我会触发这个严重错误 我认为这是合理的 这个崩溃只有在程序员误用时 才会发生 如果我在这里向上查看调用堆栈 我可以看到这个初始化函数的调用者 即这个tableView委托方法 此方法要获取给定块编号中的 头部对应的标题 因此该块编号不能是0或1 现在我想我知道发生了什么事了 但让我们尝试在app中重现此问题 看看我们是否可以了解更多信息 我点击开始
Chocolate Chip是食谱app 我存储了所有我最喜欢的食谱 我一直在测试这种生奶油食谱 你可以看到一切正常 我可以看到我的食材列表 以及步骤列表 这是食谱的两个部分 食材是第0部分 步骤是第1部分 如果我点击另一个食谱 我们崩溃了 我能看到的是 我们因同样的严重错误而停止了 并且回溯看起来与们一直在关注的 崩溃日志非常相似 这个信号表明 我们正在处理同样的问题 我通过点击删除来清除此崩溃日志 我们来看看这个调试会话 在这个严重错误中 我可以看到提示消息被打印出来 即块编号为8 这就是我们崩溃的原因 它不是0或1 现在看起来这都是我的错 当我实现这个类时 我实现了另一个 名为numberOfSections的委托方法 numberOfSections 的作用是返回头部的数量 然而我在这里返回的却是食材的数量 而ingredients.count的值为8 信不信由你 我有个好方法 可以解决这个问题 我知道我想要返回的是 此RecipeSection 枚举中的实例数 并且在Swift 4.2中 Swift开源社区 添加了一些新功能 非常感谢 这是一个名为 CaseIterable的协议 若我的RecipeSection 遵守CaseIterable 我可以重新实现 numberOfSections函数 返回该RecipeSection 枚举中所有实例的计数 这样我返回的值就为2 我将返回块的准确数量 这样就对了
现在如果我查看这个 Chocolate Chip Cookies配方 app没有崩溃 我可以看到所有的食材和步骤 我做得很好 我对自己很满意 我可以回到Organizer 并将此问题标记为已解决 离开电脑并回去继续烘焙
你刚看到的是如何使用 Crashes Organizer 从TestFlight 下载崩溃日志 在源代码中打开日志 并解决问题
那你怎样才能开始呢? 很简单
对于你的用户 如果他们选择与第三方开发者共享 这就可以了 他们的崩溃日志会自动上传
你需要做的就是使用 Apple ID登录Xcode
在上传app时 你应该包含符号表 以便你能够得到崩溃日志的 服务器端符号化处理
打开Organizer窗口中 Crashes选项卡 来查看这些崩溃
我们已经讲完了如何在 Organizer中查看崩溃
但若你没通过TestFlight 或App Store分发app 你还有其它几种选择
比如Devices窗口
当你连接了设备时 你可以点击这个查看日志按钮 这将显示该设备上保存的所有日志 并且这些日志 是使用Mac上的本地符号信息 符号化的
当你使用Xcode、Xcode Server 或Xcode Build运行-xe测试时
测试结果包将包含 来自你的app的任何崩溃日志 它们是在该测试运行期间写出的 这非常方便 并且这些崩溃日志 也是符号化的
你可以使用Mac控制台app 在Mac或模拟器中 查看任何崩溃日志
在设备上选择Settings->Privacy-> Analytics->Analytics Data 你可以看到保存到磁盘的所有日志 你的用户可以直接 在此屏幕上共享日志
好的 为了确保符号化能够正常工作 我想谈三个重要的最佳实践 第一
若你用Crashes Organizer 请与你的app一起上传符号表 这是默认行为 这将确保服务器端符号化 能够正常工作 非常简单
第二 请务必保存你的app档案 你的档案包含调试符号的副本 即你的dSYM Xcode用Spotlight 查找dSYM 并在必要时自动执行本地符号化
如果你上传包含位码的app 你应该使用“Archives Organizer Download Debug Symbols”按钮 来下载位码编译生成的任何dSYM
我们已经涵盖了我们提供的所有工具 用于在发生崩溃时通过日志访问它们 现在为了向你提供 有关阅读崩溃日志内容的深入指南 请热烈欢迎Greg Parker
(分析崩溃日志)
谢谢你 Chris 我们刚刚看到了 如何使用Xcode查找崩溃 以及如何在调试器的 Xcode工具中检查它们 但崩溃日志文件包含更多信息 它包含的信息远不止堆栈跟踪 在调试你的问题的过程中 查看这些额外信息通常很有用 那么你如何得到崩溃日志的全文呢? 这是我们的 Xcode Organizer 如果我们调出上下文菜单 就会出现“Show in Finder”按钮 “Show In Finder” 按钮将打开一个文本文件 我们可以在控制台app 或你喜欢的文本编辑器中打开它 它看起来像这样 那么这个文件里有什么呢? 让我们来看看 文件顶部以一些摘要信息开始 这包含你的app名称、版本号 运行它的操作系统版本 以及崩溃的日期和时间
在它下面是崩溃的原因 这是操作系统发送的 用来杀死该进程的 具体错误或特定信号
我们还可以看到一些日志信息 它位于“Application Specific Information”这部分 在某些情况下 此部分将包含控制台日志 如果你有未处理的异常 则它可能包含异常回溯
此部分并非始终可用 在iOS设备上 出于个人隐私原因 它通常会被隐藏 但是在macOS的模拟器中 此部分可以包含有用的信息
在它下面 我们可以看到线程堆栈 这些是在崩溃时运行的 所有线程的回溯 其中一个被标记为崩溃线程 其它的是 在进程终止时正在运行的所有线程
在它下面是一些低级信息 我们有崩溃线程的寄存器状态 还有加载到进程中的二进制数据镜像 这是app可执行文件 和所有其它的库 Xcode使用它进行符号化 以查找符号 文件和行号信息 并显示在堆栈跟踪中
以上就是崩溃日志文件的内容 那么我们如何调试它 我们如何阅读 我们该看什么? 我们从崩溃原因开始 即异常类型 在这个例子中 异常类型为EXC_BAD_INSTRUCTION异常 SIGILL信号指的是 非法指令信号 这意味着CPU正在尝试执行 由于某种原因不存在或无效的指令 这就是这个进程终止的原因
我们还可以查看崩溃的线程 崩溃时正在运行的代码是什么 这里我们在Swift运行时中 看到fatalErrorMessage函数 我们并不清楚 fatalErrorMessage函数的作用
此案例中的错误消息被包含在 “Application Specific Information”项中 所以我们可以看到当进程退出时 Swift运行时打印的内容
让我们仔细看看这个堆栈跟踪 我们看到了 fatalErrorMessage函数 并且是我们代码中的 一个函数调用了它 我们有一个Recipe类 其image函数被调用 并且该函数由于某些错误又调用了 fatalErrorMessage函数
因为这是带有完整调试信息的 符号化堆栈跟踪 我们可以看到一个文件和代码行号 其指明崩溃发生的地方
所以我们可以看看那段代码 我们打开该项目 这是RecipeImage.swift 第26行是在崩溃中标记的那一行 你们中那些经验丰富的 Swift程序员 应该容易猜出 为什么这行代码可能会崩溃 这是一个强制解包运算符 我们有一个函数 即UIImage构造函数 它返回一个可选值 如果可选值为nil 强制解包运算符将停止进程 生成崩溃日志并退出
若我们还记得刚才的app特定信息 它包含当这个错误检查失败时 Swift运行时所打印的错误消息 即“在解包可选值时 意外发现nil”
这很好 因为它与代码一致 我们在第26行有一个 强制解包运算符 我们在崩溃日志中有一条错误消息 该消息说我们正在解包一个可选值 造成这次崩溃的原因 就变得很合理并且一致了
强制解包失败 是代码中的前提条件或断言的 一个例子 前提条件和断言是一种错误检查 它在发现错误时主动停止进程
它们的一些例子包括 我们刚刚看到的 强制解包可选值 Swift运行时将断言 可选值不是nil 否则就会崩溃
另一个例子是 Swift.Array访问越界 如果你访问一个数组且索引超出范围 Swift运行时将失败 检查到它不满足该前提条件 并终止该进程
Swift算术溢出也包含断言 如果将两个数字加在一起 并且其结果对于整数变量来说太大了 这将无法满足一个前提条件 该进程将被终止
未捕获的异常通常是由 代码中的前提条件引起的 系统中存在许多错误检查 如果其前提条件无法满足 它会抛出异常 如果没有捕获该异常 未捕获的异常将写入崩溃日志
当然你也可以在自己的代码中 编写断言和前提条件 如果你有一些错误 需要使进程崩溃 并生成崩溃日志作为响应
崩溃日志的另一个例子 是操作系统从外部杀死进程的情况
这方面的一个例子是监视程序事件 例如超时 如果你的app 花费过多时间执行某个任务 操作系统可能会检测到它 并会终止进程并生成特定的崩溃日志
环境条件 也可能导致操作系统终止进程 如果设备过热 操作系统将终止 使用过多CPU的进程 如果设备内存不足 操作系统将终止使用大量内存的进程
另一种情况是无效的代码签名 操作系统强制要求代码需要被签名 如果签名无效或代码未签名 操作系统将终止进程 并生成特定类型的崩溃日志
操作系统的这些终止行为 可以在Xcode的 Devices窗口中找到 你也可以在macOS控制台中 找到它们 但它们并不总是出现在 Xcode Organizer中 所以要小心这点
在Apple开发者文档中 我们有一个技术说明 它描述了崩溃日志的 许多不同签名和结构 就像这个例子一样 它们长什么样 你如何识别它们 它比我们在这里讲的更详细
但是让我们看一个例子 这是另一个崩溃日志文件 同样 为了解崩溃日志 我们从崩溃原因开始 在这个例子中 崩溃的原因 是EXC_CRASH异常 其带有SIGKILL信号 通常SIGKILL信号被用在 当操作系统想要终止你的进程时 它会发送SIGKILL信号 SIGKILL信号无法被处理 你的进程也无法捕获它 作为对该信号的响应 该进程会终止
我们也可以在崩溃日志中看到 操作系统发送该信号的原因 在这个例子中 终止原因的代码为8badf00d 如果你查看我之前提到的 开发者技术说明 它将描述8badf00d的含义 我们有一个文本描述说 “耗尽实际时钟时间19.95秒” 所以如果我们将这些信息 与技术说明中的信息结合起来 它会告诉我们 我们的app启动时间太长 我们有20秒钟启动 然而app没能在该时限内完成启动 操作系统终止了该进程
在下方我们可以看到 进程终止时的崩溃日志 这些崩溃日志可能是说 代码花了太长时间 它可能陷入死循环 可能卡在等待网络I/O 这就是为什么我们花了太长时间启动 或者 另一方面 也许这段代码是无辜的 并且在启动过程之前 有一些东西运行太慢了 这也可能是这个进程终止的原因
启动超时 你如何能避免呢 我们希望你能避免它们 启动超时是无法通过 Apple app审核流程的 一个常见原因
那你该怎么避免它呢? 首先当然是请测试你的app 但是有一个问题
即模拟器中禁用了启动超时监视程序 并且它在调试器中也被禁用了 所以如果你在模拟器和调试器中 进行所有测试 你永远不会看到超时警报
因此在测试app时 请确保在没有调试器的情况下进行 如果是macOS app 请在Finder中启动你的app 如果是iOS app 请在TestFlight中运行 或使用iOS app启动器来启动 所有这些方法都将在调试器外 运行你的app 并会启用且强制执行 对启动超时的监控
当你测试时 请在真实设备上进行 即在模拟器之外进行测试 并尝试使用较旧的硬件 测试你的app 即你希望app所支持的最旧的硬件 如果你只在较新的硬件上测试 你可能会发现你的app 在更快的设备上启动得足够快 但较慢的设备可能会花费太多时间
让我们谈谈另一类错误 让我们来谈谈内存错误 以及它们在崩溃日志中的样子
当我说内存错误时 我指的是像过早释放对象的引用计数 这样的情况 或使用已经被释放的对象 或缓冲区溢出 即有一个字节数组 或一个C数组 而你尝试访问数组之外的内容
让我们看看另一个崩溃日志 剧透一下 这是一个内存错误
我们再次从异常类型开始 这是EXC_BAD_ACCESS 即段冲突信号 这通常是由内存错误引起的 错误访问异常意味着两件事之一 我们要么写入只读的内存 要么我们尝试从内存中读取 根本不存在的内容 其中任何一个都会导致错误访问异常 并且该进程将终止 我们在这里可以看到 我们在崩溃时试图访问的地址
我们可以查看这些堆栈跟踪 这就是执行错误访问的函数 这是objc_release函数 它是Objective-C和 Swift对象中引用计数实现的一部分 再者 这听起来很可能是 导致该漏洞的内存错误
那么是什么代码调用了 objc_release函数呢? 我们可以查看堆栈跟踪的其余部分 我们看到 object_dispose函数 这是Objective-C 运行时中的一个函数 它用于释放对象
object_dispose函数 在我们的一个类上调用了 名为__ivar_destroyer的函数 我们的LoginViewController类 __ivar_destroyer 函数是Swift代码的一部分 这个函数用来清理属性 即在一个对象被释放时 清除对象的ivar存储
所以这告诉我们 造成崩溃的一部分原因 我们当时正在释放 LoginViewController类的对象
这个类在其初始化代码中 试图清理其属性及ivar 并且在释放其中一个属性时 程序崩溃了
所以这给了我们 所发生问题的一些细节 我们可以做得更好吗 崩溃日志中是否包含更多 可以告诉我们发生了什么的信息?
我们可以看一下无效地址本身 有时实际的错误地址值 将包含有用的信息 这个发生错误的地址 我可以告诉你 它看起来像是使用了被释放的空间 我是怎么知道的呢? 部分是因为长期的经验 当你查看足够多的崩溃日志时 你就能够开始找出错误值的一些模式
这个错误值 看起来非常像malloc 内存分配器的地址范围 我们碰巧在此崩溃日志中可以看到它 我们有了内存分配器使用的地址范围 我们的无效地址看起来 就在malloc范围内 但它被偏移了4位 它被旋转了4位 所以看起来它是一个经过旋转的 有效malloc地址
这是内存分配器本身提供的线索 让我告诉你为什么是这样 这是我们的对象 在它仍然有效时的样子 一个对象以isa字段开始 isa字段指向对象的类 这就是 Objective-C对象的结构 这也是一些Swift对象的结构 objc_release函数 是做什么的呢? 它读取isa字段 然后解引用该isa字段 从而可以到该类对象中查找其方法
通常这当然是有效的 这是正常情况下所发生的事情 但若我们的对象已被释放会怎样呢?
当释放函数删除一个对象时
它将其插入到一个 由其它无效对象构成的释放列表中 它会将一个释放列表指针 写入列表中的下一个对象 写入位置 即以前isa字段所在位置
但是以一种稍微扭曲的方式 它不会在该字段中直接写入指针 而是将旋转后的指针写入该字段 它想确保写在那里的值 不是有效的内存地址 这正是错误使用该对象 造成崩溃的原因
所以当objc_release 读取isa字段时 它得到的是一个 旋转后的释放列表指针 当它解引用旋转后的释放列表指针时 它就会崩溃
内存分配器为我们做了这件事 它故意旋转了指针 以确保如果我们再次尝试使用它 就会发生崩溃
这就是我们在此崩溃日志中 看到的签名 我们的无效地址字段看起来 像是malloc区域中的指针 但旋转的方式与malloc 旋转其释放列表指针的方式相同 这是一个明显的信号 即我们在代码中尝试释放的对象 已被释放了 这就是发生的内存错误
这就是该漏洞的更多细节 我们的对象正被释放 我们正在清理它的ivar 其中一个ivar已经是一个 被释放过的对象 这就是造成我们崩溃的原因 我们可以做得更好吗? 我们可以找出是哪个对象 被objc_release释放了吗? 通常 调用 objc_release的函数 会给我们一个关于那是什么的线索 但__ivar_destroyer函数的问题是 它是由编译器生成的函数 我们没有写过一个叫做 __ivar_destroyer的函数 这表示将没有文件名或行号 会被关联到崩溃中的这一点 我们不知道那时 我们的哪些属性正在被释放
这是我们的类 我们在这个类中有三个属性 它们分别是username database和views数组 这个时候 我们不知道哪个对象 是正在被释放的对象 它可能是其中任何一个
我们可以做得更好吗? 我们能够从崩溃日志中 确定哪些对象是 正在被释放的对象吗? 因为如果我们无法在调试器中重现它 我们将只能从崩溃日志着手 在这种情况下 我们的确可以做得更好 我们可以看到文件和行号 所在的行有一个“+42” 这个“+42”就是我们的线索 因为“+42”是函数的 汇编代码中的偏移量 我们可以反汇编 __ivar_destroyer函数 查看其代码 确定在偏移量为42处所访问的属性
该怎么做呢? 我们进入调试器控制台 我们可以在终端上运行lldb 我们可以在Xcode 调试终端中运行lldb
调试器具有导入崩溃日志的命令 就像它在调试器内崩溃了一样 我们运行此命令 来加载崩溃日志解释命令
然后我们运行另一个命令 将我们的崩溃日志导入调试器
我们需要三件东西来完成这项工作 我们需要在Mac上获得 崩溃日志的副本 我们还需要一份我们的app的副本 以及该app对应的 dSYM文件的副本 所有这些都与此崩溃日志相匹配 也要与app的版本相同 这就是我们希望你 保留app档案的原因 如果我们在Mac上有了这些文件 我们会运行crashlog命令 lldb使用Spotlight 来查找匹配的可执行文件 找到匹配的符号表 并将其加载到调试器中
我们在这里可以看到 崩溃线程的堆栈跟踪 我们可以看到文件和行号信息 现在我们可以开始工作了 现在我们可以找到 __ivar_destroyer函数的地址 并对其进行反汇编
这向我们展示了该函数的汇编代码
我没有时间教你如何阅读汇编代码 但幸运的是 对于崩溃日志 你实际上并不需要 能够完全流利地阅读汇编代码 通常只需要简单浏览汇编代码 并大致了解发生了什么就足够了 你不必理解每一条指令 来从崩溃日志中获取有用的信息
如果我们浏览这个函数 并且我们知道调用指令和跳转指令 它们是你调用函数的方式 我们可以将这段代码分成三个部分 这是顶部 它正在执行一个 对引用计数释放函数的调用 这部分代码正在释放 我们的username属性
下一部分正在释放 database属性 再下一个部分正在释放 views属性
我们不明白所有这些指令的含义 但我们大概知道每一部分代码的功能 这有点像有一个与代码相关联的行号
现在我们回到崩溃日志中的信息 即__ivar_destroyer函数加42 其调用了objc_release函数
在+42处有一个指令 但还有一个问题 那就是在堆栈跟踪中 大多数堆栈帧的汇编级别偏移量 都是返回地址 它是函数调用之后的指令 所以调用objc_release 的指令是前面一条指令 即这条指令
如果我们读到这个 就说明它是对 objc_release的调用 这很好 这与我们在崩溃日志的堆栈跟踪中 看到的一致 即在此偏移量的 对objc_release的调用 这个释放函数正在释放 database属性 现在我们有了关于崩溃时 正在做什么的更多细节 我们释放了username属性 并且成功了 我们尚未运行到views属性 它可能有效 可能无效 我们并不知道 我们知道的是我们试图 释放database属性 并且根据malloc释放列表指针 的签名 该对象看起来像一个 已经被释放过的对象
所以这告诉了我们 导致这次崩溃的原因 我们正在释放一个 LoginViewController对象 而其中的database属性 是无效的
我们目前为止还没有发现漏洞 这些代码都没错 __ivar_destroyer函数没有错 我们的代码中应该有些别的问题 但是从崩溃日志中 我们已经能够缩小测试的范围 以及我们应该在何处 尝试重现该漏洞的范围 我们应该检验这个类 我们应该检验database字段 我们应该阅读 使用该database对象的代码 并尝试找到错误
那我们刚刚做了什么? 我们从头开始阅读崩溃日志 从崩溃原因开始 我们读取了异常类型 我们了解了异常类型的含义 我们检查了崩溃线程的堆栈跟踪 了解它正在做什么 以及导致失败的实际错误是什么 并且我们在崩溃日志的其它地方 寻找线索 在这个例子中 我们使用了内存错误的错误地址 我们使用了崩溃函数的反汇编代码
内存错误能导致类型广泛的崩溃 崩溃日志中有许多不同的签名 都可能是由内存错误引起的
以下是一些例子 在Objective-C的objc_msgSend函数中 或在引用计数机制中 或Swift和 Objective-C的释放机制 它们所发生的崩溃 通常是由内存错误引起的
另一种常见的内存错误症状是 无法识别的选择器异常 这通常发生在 当你有某种类型的对象时 一段代码正在使用该对象 然后对象被释放并再次被使用 但与我们在前一个崩溃日志中看到的 malloc释放列表签名不同 这次是在同一地址分配了一个新对象 取代了以前旧对象所在的位置 所以当代码尝试使用旧对象时 调用旧对象上的函数 我们在该地址处却有一个 不同类型的不同对象 并且它根本无法识别该函数 因此我们得到一个 无法识别的选择器异常
内存错误的另一个常见症状 是内存分配器本身的终止 即在malloc和free函数内 调用abort() 这是我们之前看到的 前提条件的一个例子 这是内存分配器内部的前提条件 它可能识别到这样的情况 即malloc内存 本身的堆数据结构 已被内存错误破坏 这会终止进程并进行响应 或者它可能检测到 malloc API的错误使用 例如 如果你在一行代码中 连续两次释放一个对象 malloc分配器有时可以识别出 这是一个双重释放 并立即终止该进程
让我给你一些最后的提示 以用于分析崩溃日志 特别是分析内存错误
在刚才看到的崩溃中 我们大部分时间在 查看崩溃的代码 即崩溃的特定代码行和崩溃的线程 查看进程中其它 与崩溃代码相关的代码 非常重要 例如 在这次崩溃中 __ivar_destroyer函数并没有错 漏洞不在这里 该漏洞位于其它地方 其它一些代码不正确
除了崩溃线程之外 你还应该查看 崩溃日志中的堆栈跟踪
崩溃日志包含进程中的所有堆栈跟踪 并且可能包含有用的信息和线索 可以用来帮助你弄清楚 进程当时正在做什么 也许其它线程会显示更多 关于当时app运行到何处的细节 也许它当时正在执行网络代码 这在其它某个堆栈跟踪上可以看到 或者可能存在多线程错误 并且其它线程可能可以提供 有关线程竞争的线索
你还应该查看多个崩溃日志 以找出特定的崩溃原因 Xcode Organizer 可根据代码中崩溃的位置 来帮助你对崩溃进行分组 有时候
在同一个崩溃点会发生多次崩溃 但有些日志可能会包含 比其他日志更多的信息 例如 我们刚看到的 malloc释放列表签名 它可能出现在某些崩溃日志中 但在其它日志中就未必会出现 因此在同一个崩溃集中 浏览多个崩溃 来查看其中一些信息 是否比其它的信息更有用 是一种非常有效的方法
此外 能够将 崩溃原因分组的Organizer 有时会将不同原因的崩溃 分到同一组中 可能是其它线程 或崩溃线程的回溯 让你识别出 对人眼来说 你会识别出 这组崩溃有多种原因 尽管Xcode Organizer 将它们放在了一起 如果你只查看一个崩溃日志 你甚至可能不知道 第二次崩溃正在发生 直到你修复第一个崩溃并发布它 而你的用户开始 再次向你发送崩溃日志
一旦你对崩溃做了一些分析 一旦你缩小了 进程中可能发生崩溃的位置范围 或者它可能正在使用的对象 你可以使用 如Address Sanitizer 和Zombies等工具 来尝试重现崩溃 因为尽管我们在 malloc释放列表崩溃日志中 缩小问题范围时做得很好 但调试在调试器中发生的崩溃 要容易得多 你可以在测试中使用Sanitization 错误消息来告诉你发生了什么
刚才我提到 应该查看多个堆栈跟踪 多个线程堆栈 以诊断多线程错误 为了详细讨论调试多线程错误 有请Kuba (多线程问题) 谢谢
谢谢! 正如Greg所说 多线程问题可能导致一些内存损坏 多线程错误通常是最难诊断和重现的 错误类型之一 它们特别难以重现 因为它们只是偶尔会导致崩溃 因此你的代码似乎在99%的情况下 都能正常工作 并且这些漏洞在很长一段时间内 都不会被发现
通常多线程错误会导致内存损坏 并且你在崩溃日志中看到的内容 看起来也就像内存损坏一样 我们已经看到了上一节中的例子 当你处理malloc内部的崩溃时 或在释放或保留计数操作时 这些都是内存损坏的典型症状
多线程错误也有一些特有的症状 崩溃的线程通常包含 抱歉 崩溃日志通常包含 多个正在执行彼此相关的代码的线程 所以如果某个特定的类或方法 出现在多个线程的崩溃日志中 这表示可能存在多线程错误 多线程问题导致的内存损坏 通常非常随机 因此你可能会发现崩溃发生在 稍微不同的代码行上 或稍微不同的地址 正如Greg所说 你可以看到它们在Xcode中 显示为不同的崩溃点 尽管它们属于同一个漏洞 并且崩溃的线程可能并不是 该漏洞的罪魁祸首 所以查看崩溃日志中其它线程的 堆栈跟踪很重要 现在让我们看一下 多线程漏洞的例子 并且我会向你展示 如何诊断这样的漏洞 通过使用名为Thread Sanitizer的工具 它是Xcode的一部分
让我们再看看Chris和我写的 饼干配方app 这里有从用户那里收到的 更多崩溃日志 我们关注一下排名第二的崩溃 就是这个
此崩溃日志显示 当我们使用一个名为LazyImageView的类时 发生了错误 这是我写的一个类 我们稍后再看它 但我们先看看能否从崩溃日志中 了解更多信息
我们看一下这个线程的整个堆栈 我可以通过点击这个按钮来做到这点 它也会显示所有其他线程
如果我们注意最顶层的帧 我们会看到真正发生的事情 是free函数 正在调用abort函数 这表示堆损坏 它是一种内存错误
如果我们查看其它线程的堆栈跟踪 比如这里的第5个线程 我们会看到它还在LazyImageView中 执行了一些代码
我们来看看这组崩溃中的另一次崩溃
我们会发现所有这些崩溃日志 都有一个共同的主题 当free函数调用abort时 一个线程报告堆损坏 而另一个线程正在处理 一段非常相关的代码 实际上是在同一个类中 即LazyImageView中 这很可能不是巧合 我非常怀疑这是一个多线程问题
所以我们来看看 LazyImageView类 我点击此按钮 来在我们的项目中打开它 并直接跳到这行代码
你可以在这里看到 LazyImageView的源代码 它是UIImageView的子类 但它有一个额外的功能 即它可以惰性地并且异步地加载图像 我们可以看下初始化函数的逻辑 我们所做的是将作业分配到后台队列 我们将在后台线程上创建图像 一旦完成 我们将调度回主队列 来在屏幕上实际显示图像 崩溃日志指向这行代码 我们这里正在访问 imageCache 我们使用它来确保 我们不会不必要地多次创建 相同的图像 所以我的缓存实现方式可能存在漏洞 让我们试着避免猜测 我将在模拟器中运行app 并尝试重现此崩溃 让我先关闭崩溃日志会话
好的 这是我们的饼干配方app 你会注意到 如果我尝试 点击此处的“+”按钮 来添加新食谱 我们将需要为我们的新食谱 选择一张图片 现在屏幕上的这个控制器 使用LazyImageView 来显示所有这些图像 因此只是在屏幕上显示它们 并滚动查看内容 就已经运行了 LazyImageView中的所有代码 但我没有看到任何崩溃 不幸的是 这是多线程漏洞的常见问题 众所周知 它们难以重现 所以即使你反复测试 有这样的漏洞的代码 你可能也无法看到一次崩溃 让我们试着这样做 我们多次尝试关闭并打开此控制器 并看看我们最终是否会 幸运的触发此崩溃
果然如此 调试器已终止app 因为它已经崩溃了 但即使你的确在调试器中 捕获了这个崩溃 这也并没有什么用 调试器所提供的信息 只是说存在某种 EXC_BAD_ACCESS 但它没有解释导致崩溃的原因 或者这为什么会发生 幸运的是 Xcode中有一个 非常适合这种情况的工具 它被称为Thread Sanitizer 我现在就会使用它 让我们打开项目的方案编辑器 我点击此处的app名称 并选择Edit Scheme 来做到这点
然后我切换到 Diagnostics标签页 你会看到我们这里有 几个运行时诊断工具 比如Address Sanitizer 它非常适合寻找缓冲区溢出 让我选中Thread Sanitizer 并且选中Pause on issues 这意味着每次Sanitizer 检测到错误时 调试器都会中断
让我们在启用了Thread Sanitizer的 模拟器中运行app 我们看看如果我尝试 添加新食谱时会发生什么
如果我现在点击“+”按钮 你会看到该app立即停止 因为Thread Sanitizer 发现了这个漏洞 请注意 我没有进行多次尝试 Thread Sanitizer 非常可靠地重现了多线程问题
让我们看一下这个漏洞的一些细节 我们看到它是一个 Swift访问竞争 如果我们查看左侧的调试导航器 我们甚至可以得到 有关此漏洞的更多详细信息 我们看到两个不同的线程 执行了两次访问 这里是线程2和线程4 它们都试图 同时访问同一个内存位置 这是不允许的
如果我们查看正在构成竞争的 这两行代码 我们发现它们都在访问 imageCache 由于这是一个在多个线程之间 共享的数据结构 就像我们在此看到的一样 它需要变为一个线程数据结构 我们来看看它是如何实现的 让我们跳到这里的storage 即我们正使用的变量 让我们看看它是否真的是线程安全的 这是ImageCache的源码 就在这个文件顶部 我们可以立即发现错误 这只是一个普通的Swift字典 所以这并不好 Swift字典默认不是线程安全的 所以如果我们想在多个线程之间 共享一个可变的Swift字典 我们必须使用同步来保护它 这意味着我们必须确保 一次只能有一个线程访问它 现在让我们真正解决这个问题 从而使该类成为线程安全的类 我将分两步完成 首先我将稍微重构一下这段代码 以便我们可以更好地 控制storage变量 然后在第二步中 我将使用调度队列 来使这个类线程安全
首先我不喜欢的是 storage被声明为公有变量 这意味着我的app中的任何代码 都可以访问它 而且很难确保 app中的所有代码 都能以正确的方式访问 所以让我们将其改为私有 我还要引入另一种 访问imageCache的方法 我将通过引入subscript 来做到这一点
这意味着 imageCache的用户 可以使用括号 从缓存中加载和存储数据
创建下标需一个这样的getter 以及一个setter
现在暂时让我们通过 直接访问底层存储来实现它
为了让这个文件的其它部分 能够成功构建 我还需要更新其用户 这里不能再直接访问 storage属性 我们该在imageCache上 直接使用括号和索引 就像这样
如果我点击“Build Now” 你会看到代码现在能够正常编译 但我目前还没有修复任何漏洞 但我确实取得了一些成就 我现在可以直接控制 所有访问storage的代码 即要么通过getter中的代码 要么通过setter 我的app中没其他代码可以访问它 因此这对我实际修复 这个Swift访问竞争很有利
让我通过使用调度队列来做到这一点 我们创建一个名为queue的 新私有变量 并让我们为其分配一个新的调度队列
调度队列默认是串行的 所以它也是串行的 这意味着在该队列中 一次只允许一段代码执行
这很完美 这正是我们在这里所需要的 我们如何在调度队列中执行代码呢? 我们可以使用queue.sync 被移动到queue.sync的任何代码
都将在该串行队列中执行 并且一次只执行一个 我可以在这里返回一个值 因为我需要从getter中 返回一些东西 我也可以在setter中 做同样的事情
如果我将这行代码移到 这个queue.sync中 它将作为该调度队列的一部分执行 通过这种方式 这段代码现在是线程安全的 因为访问storage的 每一行代码 都将在串行调度队列中执行 这意味着它一次只能执行一个 而这对线程安全来说是正确的 现在你可能很想 仅在更改storage变量的 setter中使用同步 并在getter中避免使用它 就像这样 但这并不正确 这仍然可能导致内存损坏 并再次导致崩溃 我通过在模拟器中运行 此版本的代码来向你证明这一点 让我们看看Sanitizer 现在能否发现这个更微妙的错误
如我所料 它的确发现了 我们必须使用同步 同时保护 getter和setter 让我最后一次 在模拟器中运行该app 你会看到如果我这次尝试添加新配方 控制器加载正常 并且我们不再收到任何警告 因为该类现在是线程安全的 现在我可以回到 我们的Organizer窗口 并将此崩溃标记为已解决 因为我们已经找到和 识别并修复了这个漏洞
我们刚看到的是 我从一组具有多线程错误症状的 崩溃日志开始 然后我使用这个 名为Thread Sanitizer的工具 来识别并最终修复此漏洞
Thread Sanitizer 不仅能检测多线程问题 还可以使它们更可靠地重现 请注意 在演示中 我不必多次调用控制器 该工具适用于macOS和模拟器 但就像所有其他运行时诊断工具一样 它只能通过实际运行代码 来查找其中的错误 因此你应该牢记这一点并确保 你的自动或手动测试程序使用了 Thread Sanitizer 特别是在使用线程或GCD的代码上
如果你想了解更多 我建议你观看我在2016年 WWDC中的演讲视频 其标题为 “Thread Sanitizer和静态分析” 我们在其中介绍了这个工具 并谈到了它的工作原理
提醒一下 Thread Sanitizer 可以在方案编辑器中找到 你可以点击Product-> Scheme->Edit Scheme 来调出方案编辑器 然后你可以切换到 “Diagnostics”标签页 你可以从中找到Thread Sanitizer 和一些其它运行时诊断工具
我还想与你分享一个调试技巧 它在处理多线程时很有用 在创建调度队列时 你可以在初始化函数中 提供自定义标签
你可以为操作队列指定自定义名称 并且如果你正在使用线程 你也可以在线程中使用自定义名称
这些名称和标签将显示在调试器中 但它们也出现在 某些类型的崩溃日志中 这可以帮助你缩小 多线程漏洞的可能原因的范围
对于崩溃 我这里准备了三个要点 第一 在将你的app 提交到App Store之前 总是在真实设备上对其进行测试 这可以帮助你避免 在app审核过程中被拒绝
第二 当你的用户崩溃时 你应该总是尝试重现它们 查看崩溃日志和堆栈跟踪 并尝试找出你需要执行 app中的哪些部分 才能触发崩溃 或尝试重现崩溃 最后 对于难以重现的崩溃 我建议使用漏洞查找工具 比如Address Sanitizer 或Thread Sanitizer 它们分别可用于处理 内存损坏错误 和多线程问题
现在让我们回顾一下 今天所学到的东西
Chris向我们展示了 如何使用Xcode中的 Organizer窗口 来获取统计信息 以及崩溃日志的详细信息
Greg向我们展示了 如何阅读和分析崩溃日志文本 在许多情况下 它们可以被重现 比如当你处理 app启动超时问题的时候
然后我们提到了难以重现的崩溃 因为它们是随机发生的 比如内存损坏 我们还提到了它们 在崩溃日志中留下的迹象 最后 我展示了漏洞寻找工具 比如Sanitizer 如何帮助你重现 内存损坏和线程问题 我建议你也使用这些工具
如需了解更多信息 请访问我们演讲的网页 其中还将包含我们提到的 技术说明的链接 以及提供调试技巧的其他文档 它们在处理崩溃时很有帮助 我还想提醒你 稍后有一个崩溃日志实验室 就在本场演讲结束后 中午12点开始 在8号技术实验室 所以如果你有任何 关于崩溃和崩溃日志的疑问 请到实验室与我们讨论 请享受WWDC的其余部分 非常感谢
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。