第8章:断点

你已经学会了如何在可执行代码上创建断点;也就是说,具有读取和执行权限的内存。但只使用断点,就忽略了调试的一个重要组成部分--你可以监控指令指针执行地址的时间,但你无法监控内存被读或被写的时间。你不能监控堆上实例化的Swift对象的值变化,也不能监控对内存中某个特定地址(比如,硬编码的字符串)的读取。这就是断点发挥作用的地方。

断点是一种特殊类型的断点,可以监控对内存中某一特定值的读取或写入,而且不像断点那样只限于可执行代码。然而,使用断点有一些限制:每个架构允许有一定数量的断点(通常是4个),"被观察 "的内存大小通常以8字节为上限。

断点的最佳实践

像所有的调试技术一样,断点是调试工具箱中的一种工具。你可能不会经常使用这个工具,但它在某些情况下是非常有用的。断点在以下方面非常有用。

找到一个属性的偏移量

断点对于发现某块内存是如何被写入的非常有用。一个实际的例子是,当一个值被写入一个先前从堆中创建的分配实例时,例如在Objective-C/Swift类中。

幸运的是,Swift在属性的setter方法中自动封装了所有对偏移量的写入;在Swift中,你不能直接访问ivar! 这意味着Swift的断点可能并不总是必要的,因为你最好的选择是用第四章 "代码中的停止 "中的Swift设置器语法来断点。事实上,Swift还提供了didGet和didSet方法,为Swift中使用断点提供了一个有吸引力的替代方法。在C/ObjC/ObjC++系列中,内存中的值可以直接通过ivar、属性设置器或硬编码偏移来修改。这意味着你不能总是依赖Objective-C的set{PropertyName}:的断点语法来捕捉数值被设置的情况。

在这个例子中,你将看到断点的作用,用断点代替断点来捕捉对内存的特定写入。

打开本章起始目录中的Signals应用程序,使用iOS 12 iPhone X模拟器运行该程序。

一旦运行,暂停应用程序,并前往LLDB控制台。输入以下内容。

(lldb) language objc class-table dump UnixSignalHandler -v

这将转储UnixSignalHandler的Objective-C类布局。输出将类似于以下内容。

查看_shouldEnableSignalHandling,其偏移量为32字节,大小为1字节(是的,一个字节,不是一个比特)。

这意味着,如果你知道UnixSignalHandler类的一个实例在堆上的位置,你可以在这个地址上加32个字节,得到_shouldEnableSignalHandling在UnixSignalHandler实例中的存储位置。

注意:LLDB命令 "language objc class-table dump "有点小毛病,对Swift类不起作用......尽管在苹果平台上,Swift类是继承自Objective-C类。如果你不喜欢这个错误的命令,你可以看看这里的dclass命令:https://github.com/DerekSelander/LLDB/blob/master/lldb_commands/dclass.py 。dclass命令对Objective-C和Swift类都有效,而且输出更干净。

现在你知道了在一个实例上找到_shouldEnableSignalHandling ivar的偏移量,现在是时候找到UnixSignalHandler单子的实例了。在Xcode中。

点击位于调试控制台顶部的Debug Memory Graph按钮。

点击后,选择Signals项目,然后选择Commons框架(负责实现UnixSignalHandler的框架)。

向下钻,你会看到UnixSignalHandler的实例在Xcode中的视觉效果和内存地址。请确保也打开右侧的检查器,并选择显示内存检查器选项。

一旦你有了这个实例,把UnixSignalHandler的内存地址复制到你的剪贴板上。

在我的信号程序的特定实例上,我可以看到UnixSignalHandler的单子实例的堆地址值从0x6000024d0f40开始,但请注意,你的很可能是不同的。

注意:作者的调试脚本可能会提供比Xcode目前所能提供的更好的调试体验,在此不做过多赘述,如果你不喜欢刚才的所有GUI点击,请查看这里的搜索命令https://github.com/DerekSelander/LLDB/blob/master/ lldb_commands/search.py。这个命令可以为特定的Objective-C类枚举堆,坦率地说,它比Xcode的GUI更强大,功能更丰富。

通过LLDB,将你的实例值添加到32,找到_shouldEnableSignalHandling ivar的位置。使用LLDB的p/x(print hexadecimal)命令将输出格式化为十六进制。

(lldb) p/x 0x6000024d0f40 + 32 
(long) $0 = 0x00006000024d0f60

0x00006000024d0f60是感兴趣的位置。是时候给它加上一个监视点了!

在LLDB中,输入以下内容。记住要替换你自己计算的UnixSignalHandler的偏移值。

(lldb) watchpoint set expression -s 1 -w write -- 0x00006000024d0f60

这创建了一个新的监视点,监视地址0x00006000024d0f60,其大小监视1个字节的范围(感谢-s 1参数),并且只在值被设置(-w write)时停止。-w参数可以监视内存中的读、read_write或写的发生情况。

现在我们在LLDB中有适当的管道来监测这一变化,是时候通过模拟器来触发这一事件了。在Signals项目中,点击playbook UISwitch按钮。

信号项目将被暂停。在Xcode的左手边看一下堆栈跟踪,看看程序是如何被停止的。

是什么导致了断点的出现

究竟是什么导致了断点被触发呢?准备好了,你现在要看的是一些汇编。为了找到答案,用LLDB来反汇编当前的方法。

(lldb) disassemble -F intel -m

这将以英特尔格式打印出当前帧的反汇编(更多关于这个和汇编的内容在第二节)。此外,你还指定了-m选项来显示汇编和源代码的混合。这将使你更好地了解汇编与源代码的关系。

扫描程序计数器目前停在->处的输出。你会看到汇编和源码输出都有一个->,但实际上,这些都是一样的。

我们感兴趣的是紧挨着程序计数器->行的汇编指令。

在我的例子中,我得到了以下指令。

0x100c04be7 <+39>:  mov    byte ptr [rsi + rdi], al

注意:新版本的Clang可能会改变汇编的输出。如果是这样的话,请以这个例子为指导,找出你独特的汇编指令。

你不需要知道x86_64汇编的规格(那是在第二节),但这个表达式等同于以下内容。

*(BOOL *)(rsi + rdi) = al

你可以通过在LLDB中输入以下内容来证明这将是UnixSignalHandler实例+32偏移。

(lldb) p/x ``rsi + ``rdi

这将产生你先前创建的断点的地址。事实上,你可以通过键入以下内容获得先前创建的断点的地址。

(lldb) watchpoint list

还需要确信这就是UnixSignalHandler的实例吗?键入以下内容来检索原始实例。

(lldb) po ``rsi + ``rdi - 32 
<UnixSignalHandler: 0x6000024d0f40>

正如你所看到的,(0x6000024d0f20+32)的内存地址被AL寄存器修改了,这导致了断点的触发。这条汇编指令是源代码中以下一行的结果。

self->_shouldEnableSignalHandling = shouldEnableSignalHandling;

有一个重写的Objective-C属性设置器,它直接对值进行ivar访问。尽管在这个特殊的例子中,Objective-C属性设置器的断点会发现这个问题,但你可能不会总是这么幸运。

正如你所看到的,设置需要更长的时间,但是断点可以更加强大。这就是为什么当你的初始断点策略失败时,断点是一个伟大的工具。

Xcode GUI 断点的作用

Xcode提供了一个用于设置断点的GUI。你可以通过在UnixSignalHandler单子的创建方法上设置一个断点,然后通过GUI设置一个断点来执行上述方法的等效性。不过首先,你需要删除之前的断点。

在LLDB中,删除断点,然后恢复执行。

在Signals程序中,确保Playbook UISwitch被调回开启状态。一旦打开,导航到UnixSignalHandler.m,在返回单子实例的函数末尾设置一个GUI断点。

控制应该暂停,因为你已经在监控断点的回调函数中添加了一个断点,并引用了该代码。如果没有,请确保你的Playbook UISwitch是激活的。

一旦控制被暂停,确保你的变量视图是可见的。它可以在Xcode的右下角找到。

在变量视图中,深入到sharedSignalHandler实例,然后右击_shouldEnableSignalHandling变量。选择 Watch _shouldEnableSignalHandling。

通过Xcode或LLDB恢复对程序的控制。通过在模拟器中再次点击Playbook UISwitch来测试新创建的断点。

其他断点的小知识

幸运的是,断点的语法与断点的语法非常相似。你可以像使用LLDB的断点语法一样删除、禁用、启用、列出、命令或修改它们。

这一组中比较有趣的是命令和修改操作。修改命令可以添加一个条件,只有当它为真时才会触发断点。命令动作让你在断点被触发时执行一个独特的命令。

例如,假设你想让以前的断点只在新值被设置为0时才停止。

首先,找到要修改的断点ID。

这表示以 "简要"(-b)格式列出所有的断点。你可以看到断点ID是2。 从这里修改,断点ID 2。

(lldb) watchpoint modify 2 -c '*(BOOL*)0x60000274ee20 == 0'

这将修改Watchpoint ID 2,只在_shouldEnableSignalHandling的新值被设置为false时停止。

如果你在上面的例子中省略了断点ID(2),它将被应用于进程中的每个有效的断点。

在你结束本章之前,还有一个例子! 与其在_shouldEnableSignalHandling被设置为0时有条件地停止,你可以简单地让LLDB在每次被设置时打印堆栈跟踪。

像这样删除所有断点条件。

(lldb) watchpoint modify 2

这将删除你之前创建的条件。现在添加一个命令来打印反向跟踪,然后继续。

(lldb) watchpoint command add 2 Enter your debugger command(s).
> bt 5
> continue
> DONE

断点不是有条件地停止,而是在LLDB控制台打印前五个堆栈帧,然后继续。

一旦你看厌了所有的输出,你可以通过输入来删除这个命令。

(lldb) watchpoint command delete 2

这就是你所拥有的! 断点的简述。

接下来该怎么做呢?

对于那些了解可执行文件在内存中的布局的人来说,看点往往能起到非常好的作用。这种布局被称为Mach-O,我们将在第18章 "你好,Mach-O "中详细讨论。将这些知识与断点结合起来,你就可以观察字符串何时被引用,或静态指针何时被初始化,而不必在运行时繁琐地跟踪这些位置。

但是现在,只要记住你有一个很好的工具,当你需要寻找某个东西是如何被创建的,而你的断点又没有产生任何结果时,就可以使用这个工具。


上一章 目录 下一章