现在你已经对如何操作调试器有了基本的了解,现在是时候在可执行的Jenga塔下走一步,探索构成源代码的1和0。本节将重点讨论调试的低级方面。
在这一章中,你将看到CPU使用的寄存器,并探索和修改传入函数调用的参数。你还将了解常见的苹果电脑架构以及它们的寄存器在一个函数中的使用情况。这被称为一个架构的调用惯例。
了解汇编是如何工作的,以及特定架构的调用惯例是如何工作的,是一项极其重要的技能。它可以让你观察没有源代码的函数参数,并让你修改传入函数的参数。此外,有时去汇编层更合适,因为你的源代码可能有不同的或未知的变量名称,而你却不知道。
例如,假设你总是想知道一个函数调用的第二个参数,不管这个参数的名字是什么。汇编知识给了你一个很好的基础层来操作和观察函数中的参数。
等等,那么什么是汇编呢?
你是否曾经在一个你没有源代码的函数中停下来,看到大量的内存地址和可怕的短命令?你是否蜷缩成一团,悄悄地对自己说,你再也不会看这些密集的东西了?嗯......这些东西就是所谓的汇编!
这里有一张Xcode中的回溯图片,它展示了模拟器中一个函数的装配过程。
那么,操作码是什么样子的呢?操作码是一条在计算机上执行简单任务的指令。例如,考虑以下的汇编片段。
pushq %rbx
subq $0x228, %rsp
movq %rdi, %rbx
在这个汇编块中,你看到了三个操作码,pushq、subq和movq。把操作码项看成是要执行的动作。操作码后面的东西是源和目的标签。也就是说,这些是操作码要执行的项目。
在上面的例子中,有几个寄存器,显示为rbx、rsp、rdi和rbp。每个寄存器前的%告诉你这是一个寄存器。
此外,你还可以找到一个十六进制的数字常数,显示为0x228。这个常数前面的$告诉你这是一个绝对数字。
目前没有必要知道这段代码在做什么,因为你首先需要学习寄存器和函数的调用约定。然后,你将在以后的章节中学习更多关于操作码的知识并编写你自己的汇编。
注意:在上面的例子中,请注意在寄存器和常量前面有一堆%和$的字样。这就是反汇编程序对汇编的格式。然而,有两种主要的方式可以展示汇编的内容。第一种是Intel汇编,第二种是AT&T汇编。
默认情况下,苹果公司的反汇编工具是以AT&T格式显示汇编的,就像上面的例子中一样。尽管这是一种很好的工作格式,但它可能会让人觉得有点难受。在下一章中,你将把汇编格式改为英特尔格式,并且从那时起将完全使用英特尔汇编语法工作。
作为苹果平台的开发者,在学习汇编时,有两种主要的架构:x86_64架构和ARM64架构。x86_64是你的macOS电脑上最可能使用的架构,除非你运行的是 "古老 "的Macintosh。
x86_64是一个64位架构,这意味着每个地址最多可以容纳64个1或0。另外,老式Mac使用32位架构,但苹果在2010年代末停止生产32位Mac。在macOS下运行的程序可能是64位兼容的,包括模拟器上的程序。也就是说,即使你的macOS是x86_64,它仍然可以运行32位程序。
如果你对你所使用的硬件架构有任何疑问,你可以通过在终端运行以下命令获得你的计算机的硬件架构。
uname -m
ARM64架构用于移动设备,如你的iPhone,其中限制能源消耗是关键。
ARM强调省电,所以它有一套减少的操作码,有助于促进能源消耗而不是复杂的汇编指令。这对你来说是个好消息,因为在ARM架构上,你需要学习的指令较少。
下面是前面显示的同一方法的截图,只不过这次是在iPhone 7上使用ARM64汇编。
你现在可能无法区分这两种架构,但你很快就会对它们了如指掌。
苹果公司最初在其许多设备中采用32位ARM处理器,但后来转而采用64位ARM处理器。32位设备几乎已经过时,因为苹果已经通过各种iOS版本逐步淘汰了它们。例如,iPhone 5是一个32位设备,在iOS 12中不被支持。然而,iPhone 5s是一个64位设备,在iOS 12中得到了支持。
由于最好是关注你未来需要的东西,本书将主要关注两种架构的64位汇编。此外,你将首先开始学习x86_64汇编,然后过渡到学习ARM64汇编,这样你就不会感到困惑。好吧,不要太糊涂。
你的CPU使用一组寄存器,以便在你的运行程序中处理数据。这些是储存器,就像你电脑中的RAM一样。然而,它们位于CPU本身,非常接近CPU中需要它们的部分。因此,CPU的这些部分可以令人难以置信地快速访问这些寄存器。
大多数指令涉及一个或多个寄存器,并执行一些操作,如将寄存器的内容写入内存,将内存的内容读入寄存器或对两个寄存器进行算术操作(加、减等)。
在x64中(从这里开始,x64是x86_64的缩写),有16个通用的寄存器被机器用来操作数据。
这些寄存器是RAX、RBX、RCX、RDX、RDI、RSI、RSP、RBP和R8至R15。
这些名字现在对你来说意义不大,但你很快会探索每个寄存器的重要性。
当你在x64中调用一个函数时,寄存器的方式和使用遵循一个非常具体的惯例。这决定了函数的参数应该放在哪里,以及当函数结束时,函数的返回值应该放在哪里。这一点很重要,因为用一个编译器编译的代码可以和另一个编译器编译的代码一起使用。在调用约定方面,Swift有点像狂野的西部,所以你将从Objective-C开始学习,以后再转到学习Swift。
看看这个简单的Objective-C代码吧。
NSString *name = @"Zoltan";
NSLog(@"Hello world, I am %@. I'm %d, and I live in %@.", name, 30, @"my father's basement");
NSLog函数调用中传递了四个参数。其中一些值是按原样传递的,而一个参数被存储在一个局部变量中,然后作为函数中的一个参数被引用。然而,当通过汇编查看代码时,计算机并不关心变量的名称,它只关心内存中的位置。
当函数在x64汇编中被调用时,以下寄存器被用作参数。试着把这些记入内存,因为你将来会经常使用它们。
第一个参数:RDI
第二个参数:RSI
第三个论据:RDX
第四个论据:RCX
第五项论证:R8
第六个论据:R9
如果有六个以上的参数,那么程序的堆栈就被用来向函数传递额外的参数。
回到那个简单的Objective-C代码,你可以重新想象一下寄存器的传递,就像下面的伪代码。
RDI = @"Hello world, I am %@. I'm %d, and I live in %@.";
RSI = @"Zoltan";
RDX = 30;
RCX = @"my father's basement";
NSLog(RDI, RSI, RDX, RCX);
一旦NSLog函数启动,给定的寄存器将包含上述的适当值。
然而,一旦函数序言(准备堆栈和寄存器的函数的开始部分)执行完毕,这些寄存器中的值就可能发生变化。生成的程序集可能会覆盖存储在这些寄存器中的值,或者在代码不再需要它们的时候简单地丢弃这些引用。
这意味着一旦你离开一个函数的起点(通过跨步、跨步或跨步),你就不能再假设这些寄存器会保存你想要观察的预期值,除非你真的看一下汇编代码,看看它正在做什么。
这种调用惯例严重影响了你的调试(和断点)策略。如果你要自动进行任何类型的断点和探索,你将不得不在函数调用的开始处停下来,以便检查或修改参数,而不必实际深入到汇编中。
正如你在上一节中所学到的,寄存器使用一个特定的调用约定。你也可以把这些知识应用于其他语言。
当Objective-C执行一个方法时,一个特殊的C函数objc_msgSend被执行。这些函数实际上有几种不同的类型,但objc_msgSend是最广泛使用的,因为它是消息调度的核心。作为第一个参数,objc_msgSend接受了要发送消息的对象的引用。接下来是一个选择器,它只是一个char *,指定在该对象上被调用的方法的名称。最后,objc_msgSend在函数中接收一个可变数量的参数,如果选择器指定了应该有参数的话。
让我们看看在iOS环境下的一个具体例子。
[UIApplication sharedApplication];
编译器将采用这段代码并创建以下伪代码。
id UIApplicationClass = [UIApplication class];
objc_msgSend(UIApplicationClass, "sharedApplication");
第一个参数是对UIApplication类的引用,其次是sharedApplication选择器。判断是否有参数的简单方法是检查Objective-C选择器中是否有冒号。每个冒号将代表选择器中的一个参数。
下面是另一个Objective-C的例子。
NSString *helloWorldString = [@"Can't Sleep; "
stringByAppendingString:@"Clowns will eat me"];
编译器将创建以下内容(如下图的伪代码)。
NSString *helloWorldString;
helloWorldString = objc_msgSend(@"Can't Sleep; ", "stringByAppendingString:", @"Clowns will eat me");
第一个参数是一个NSString的实例(@"Can't Sleep;"),后面是选择器,然后是一个参数,也是一个NSString实例。
利用objc_msgSend的这些知识,你可以使用x64中的寄存器来帮助探索内容,你很快就会这样做。
在本节中,您将使用本章资源包中提供的一个项目,名为Registers。
通过Xcode打开这个项目并运行它。
这是一个相当简单的应用程序,只是显示一些x64寄存器的内容。值得注意的是,这个应用程序不能在任何时候显示寄存器的值;它只能在特定的函数调用中显示寄存器的值。这意味着你不会看到这些寄存器的数值有太多的变化,因为当调用抓取寄存器数值的函数时,它们可能会有相同(或类似)的数值。
现在你已经了解了Registers macOS应用程序背后的功能,为NSViewController的viewDidLoad方法创建一个符号断点。记住要用 "NS "而不是 "UI",因为你是在一个Cocoa应用程序上工作。
构建并重新运行该应用程序。一旦调试器停止,在LLDB控制台输入以下内容。
这将列出所有处于暂停执行状态的主寄存器。然而,这样的信息太多。你应该有选择地打印出寄存器,并把它们当作Objective-C对象来处理。
如果你还记得,-[NSViewController viewDidLoad]将被翻译成下面的汇编伪代码。
RDI = UIViewControllerInstance
RSI = "viewDidLoad"
objc_msgSend(RDI, RSI)
考虑到x64的调用惯例,并知道objc_msgSend是如何工作的,你可以找到正在加载的NSViewController的规格。
在LLDB控制台键入以下内容。
(lldb) po $rdi
你会得到类似于以下的输出。
<Registers.ViewController: 0x6080000c13b0>。
这将倾倒出RDI寄存器中的NSViewController引用,正如你现在知道的,它是方法的第一个参数的位置。
在LLDB中,在寄存器前加上$字符是很重要的,这样LLDB就知道你要的是一个寄存器的值,而不是源代码中与你范围有关的变量。是的,这和你在反汇编视图中看到的汇编是不同的! 很烦人,是吗?
注意:善于观察的人可能会注意到,每当你在一个ObjectiveC方法上停下来时,你永远不会在LLDB回溯中看到objc_msgSend。这是因为objc_msgSend系列函数在汇编中执行jmp,或跳转操作码命令。这意味着objc_msgSend作为一个蹦床函数,一旦Objective-C代码开始执行,所有objc_msgSend的堆栈跟踪历史将消失。这是一种被称为尾部调用优化的优化。
试着打印出RSI寄存器,希望它能包含被调用的选择器。在LLDB控制台键入以下内容。
(lldb) po $rsi
不幸的是,你会得到类似这样的垃圾输出。
140735181830794
为什么会这样?
一个Objective-C选择器基本上就是一个char *。这意味着,像所有的C类型一样,LLDB不知道如何格式化这些数据。因此,你必须明确地将这个引用转换为你想要的数据类型。
试着把它转换为正确的类型。
(Lldb) po (char *)$rsi
现在你会得到预期的结果。
"viewDidLoad"
当然,你也可以把它转换为选择器类型,产生同样的结果。
(lldb) po (SEL)$rsi
现在,是时候探索一个带参数的Objective-C方法了。因为你已经在viewDidLoad上停了下来,你可以安全地假设NSView实例已经加载。一个有趣的方法是mouseUp。选择器,由NSView的父类NSResponder实现。
在LLDB中,在NSResponder的mouseUp.Selector上创建一个断点。选择器上建立断点,然后恢复执行。如果你不记得怎么做,这里有你需要的命令。
(lldb) b -[NSResponder mouseUp:]
(lldb) continue
现在,点击应用程序的窗口。确保点击NSScrollView的外部,因为它将吞噬你的点击,而-[NSResponder mouseUp:]断点将不会被击中。
一旦你放开鼠标或触控板,LLDB将在mouseUp:断点上停止。通过在LLDB控制台输入以下内容,打印出NSResponder的引用。
(lldb) po $rdi
你会得到与下面类似的东西。
<NSView: 0x608000120140>
然而,选择器有一些有趣的地方。它里面有一个冒号,意味着有一个参数可以探索! 在LLDB控制台键入以下内容。
(lldb) po $rdx
你会得到NSEvent的描述。
NSEvent: type=LMouseUp loc=(351.672,137.914) time=175929.4 flags=0
win=0x6100001e0400 winNum=8622 ctxt=0x0 evNum=10956 click=1
buttonNumber=0 pressure=0 deviceID:0x300000014400000
subtype=NSEventSubtypeTouch
你怎么知道它是一个NSEvent?好吧,你可以在网上查找关于-[NSResponder mouseUp:]的文档,或者,你可以简单地用Objective-C来获取类型。
(lldb) po [$rdx class]
很酷,是吗?
有时,使用寄存器和断点是很有用的,这样可以得到一个你知道在内存中活着的对象的引用。
例如,如果你想把前面的NSWindow改成红色,但你的代码中没有对这个视图的引用,而且你不想用任何代码改动来重新编译,怎么办?你可以简单地创建一个你可以轻松跳过的断点,从寄存器中获取引用,并随心所欲地操作对象的实例。你现在就试试把主窗口改成红色。
注意:即使NSResponder实现了mouseDown:,NSWindow还是重写了这个方法,因为它是NSResponder的子类。你可以把所有实现mouseDown:的类实现mouseDown:的所有类,并找出这些类中哪些继承自NSResponder,以确定该方法是否被重写,而无需访问源代码。转载所有实现mouseDown:的Objective-C类的例子是image lookup -rn '\ mouseDown:' 。
首先使用LLDB控制台删除之前的断点。
(lldb) breakpoint delete
About to delete all breakpoints, do you want to do that?: [Y/n]
然后在LLDB控制台输入以下内容。
(lldb) breakpoint set -o true -S "-[NSWindow mouseDown:]"
(lldb) continue
这就设置了一个只发生一次的断点--一个一次性的断点。
点击应用程序。点选后,断点应立即跳转。然后在LLDB控制台输入以下内容。
(lldb) po [$rdi setBackgroundColor:[NSColor redColor]]
(lldb) continue
在恢复时,NSWindow会变成红色!
在探索Swift中的寄存器时,你会遇到三个障碍,使汇编调试比Objective-C中更难。
首先,寄存器在Swift的调试环境中是不可用的。这意味着你必须获得你想要的任何数据,然后使用Objective-C调试上下文来打印出传入Swift函数的寄存器。记住,你可以使用表达式 -l objc -O -- 命令,或者使用你在第九章 "持久化和自定义命令 "中制作的 cpo 自定义命令。幸运的是,寄存器读取命令在Swift上下文中是可用的。
第二,Swift 不像 Objective-C 那样动态。事实上,有时最好假设Swift就像C,只是有一个非常、非常暴躁和专横的编译器。如果你有一个内存地址,你需要明确地把它投给你期望的对象;否则,Swift的调试环境不知道如何解释一个内存地址。
从Swift 4.2开始,调用惯例仍然没有稳定下来!这意味着Swift调用到C语言的时候,会有一些问题。这意味着Swift调用C或Objective-C代码可以可靠地工作(因为它们有,比如,标准,OMG...),但C/Objective-C代码不能可靠地调用Swift代码。这导致了 "群众的最爱"--Swift Bridging Headers的出现。更让人沮丧的是,你不能完全依赖寄存器在不同版本的Swift中以相同的方式使用!当Swift调用一个函数时,它就会以相同的方式调用。
当Swift调用一个函数时,它没有必要使用objc_msgSend,除非你把一个方法标记为使用@objc。此外,最新的4.2版本的Swift经常会选择删除作为第一参数的自我寄存器(RDI),而是将其放在堆栈中。这意味着,原先存放self实例的RDI寄存器和原先存放Objective-C中Selector的RSI寄存器被释放出来,用于处理一个函数的参数。这是以 "优化 "的名义进行的,但编译器的不一致性导致了代码的不兼容,以及工具难以分析Swift生成的汇编。这也导致本书的版本更新成为一个主要的PITA,因为Swift作者似乎每年都会为Swift想出一个新的调用约定。
理论够了--是时候看看今年的Swift 4.2版本会如何编译代码了。
在Registers项目中,导航到ViewController.swift,在viewDidLoad下面添加以下函数。
接下来,在viewDidLoad的末尾添加以下内容,用适当的参数调用这个新函数。
self.executeLotsOfArguments(one: 1, two: 2, three: 3, four: 4,
five: 5, six: 6, seven: 7,
eight: 8, nine: 9, ten: 10)
在executeLotsOfArguments声明的同一行上设置一个断点,这样调试器就会在函数的开始处停止。这一点很重要,否则,如果函数真的在执行,寄存器可能会被破坏掉。
最后,删除你在-[NSViewController viewDidLoad]上设置的符号断点。
建立并运行应用程序,然后等待executeLotsOfArguments断点停止执行。
同样,开始调查的一个好方法是转储列表寄存器。在LLDB中,键入以下内容。
(lldb) register read -f d
这将转储寄存器并通过使用-f d选项显示十进制的格式。
输出结果将类似于以下内容。
正如你所看到的,这些寄存器遵循x64的调用惯例。RDI、RSI、RDX、RCX、R8和R9存放着前六个参数。
你可能会注意到(也可能不会,这取决于Swift的版本),其他的参数也被储存在其他一些寄存器中。虽然这是真的,但这只是为其余参数设置堆栈的代码的遗留物。请记住,第六个参数之后的参数都在堆栈中。
但是,等等--还有更多! 到目前为止,你已经学会了在一个函数中如何调用六个寄存器,但返回值呢?
幸运的是,只有一个指定的寄存器用于处理函数的返回值。RAX。回到executeLotsOfArguments,修改该函数以返回一个Int,像这样。
在viewDidLoad中,修改函数调用以接收和忽略String值。
在executeLotsOfArguments的某处创建一个断点。再次构建并运行,并等待函数中的执行停止。接下来,在LLDB控制台键入以下内容。
(lldb) finish
这将结束当前函数的执行,并再次暂停调试器。在这一点上,函数的返回值应该是RAX。在LLDB中键入以下内容。
(lldb) re re rax -fd
你会得到与下面类似的东西。
rax = 100
轰!你的返回值 你的返回值!
对RAX的返回值的了解是非常重要的,因为它将成为你在后面章节中编写的调试脚本的基础。
为了巩固你对寄存器的理解,你将在一个已经编译好的应用程序中修改寄存器。
关闭Xcode和Registers项目. 打开一个终端窗口并启动iPhone X模拟器。通过输入以下内容来完成。
xcrun simctl list
你会看到一长串的设备列表. 搜索最新的iOS版本,你已经安装了一个模拟器。在该部分下面,找到iPhone X设备。它看起来会像这样。
iPhone X (DE1F3042-4033-4A69-B0BF-FD71713CFBF6) (Shutdown)
UUID是你要找的东西。使用它来打开iOS模拟器,键入以下内容,适当地替换你的UUID。
确保模拟器已经启动,并位于主屏幕上。你可以按Command + Shift + H进入主屏幕。一旦你的模拟器设置好了,前往终端窗口,将LLDB附加到SpringBoard应用程序。
lldb -n SpringBoard
这将LLDB附加到运行在iOS模拟器上的SpringBoard实例上! SpringBoard是控制iOS上主屏幕的程序。
连接后,在LLDB中键入以下内容。
(lldb) p/x @"Yay! Debugging"
你应该得到一些类似于以下的输出。
(__NSCFString *) $3 = 0x0000618000644080 @"Yay! Debugging!"
记下这个新创建的NSString实例的内存引用,因为你将很快使用它。现在,在LLDB中UILabel的setText:方法上创建一个断点。
(lldb) br set -n "-[UILabel setText:]" -C "po $rdx = 0x0000618000644080"
-G1
上述断点将在-[UILabel setText:] Objective-C方法上停止。当这种情况发生时,由于-C或--command选项,它将给RDX寄存器分配0x0000618000644080的值。此外,你已经通过-G或--自动继续选项告诉LLDB在执行该命令后立即恢复执行,该选项期望一个布尔值来确定它是否应该自动继续。
退一步讲,回顾一下你刚才所做的事情。每当UILabel的setText:方法被击中时,你就会用一个不同的NSString实例来替换RDX中的内容--第三个参数,这个实例说:Yay! 调试!。
通过使用continue命令恢复调试器。
(lldb) continue
探索SpringBoard Simulator应用程序,看看有什么内容发生了变化。向上和向下滑动,观察变化。
尝试探索其他可能发生模态展示的区域,因为这很可能导致一个新的UIViewController(及其所有的子视图)被懒散地加载,导致断点动作被击中。
虽然这可能看起来是一个很酷的噱头性编程技巧,但它提供了一个有洞察力的视角,即对寄存器和汇编的有限知识是如何在你没有源代码的应用程序中产生大的变化。
从调试的角度来看,这也是非常有用的,因为你可以快速直观地验证SpringBoard应用程序中执行-[UILabel setText:]的位置,并运行断点条件,找到设置特定UILabel文本的确切代码行。
继续这个想法,任何文本没有改变的UILabel实例也会告诉你一些事情。例如,UIButtons的文本没有改变为Yay! 调试!不言自明。也许UILabel的setText:在更早的时候被调用了?或者SpringBoard应用程序的开发者选择使用setAttributedText:代替?或者他们使用的是一个不对第三方开发者公开的私有方法?
正如你所看到的,使用和操作寄存器可以让你深入了解一个应用程序的功能。
呜! 那是一个很长的问题,不是吗?坐下来,用你最喜欢的液体形式休息一下;你已经赢得了它。
那么,你学到了什么?
架构确定了一个调用惯例,它决定了一个函数的参数和它的返回值的存储位置。
在Objective-C中,RDI寄存器是调用NSObject的引用,RSI是选择器,RDX是第一个参数,以此类推。
即使在Swift 4.2中,仍然没有一个一致的寄存器调用惯例。目前,类中对 "self "的引用被传递到堆栈中,允许参数从RDI寄存器开始。但谁也不知道这种情况会持续多久,在Swift ABI稳定下来之前会发生什么疯狂的变化。
无论你是用Objective-C还是Swift工作,RAX寄存器都用于函数中的返回值。
确保你在用$打印寄存器时使用Objective-C上下文。
你可以用寄存器做很多事情。试着探索你没有源代码的应用程序;这很有趣,并将为解决棘手的调试问题打下良好的基础。
试着在iOS模拟器上附加一个应用程序,并使用汇编、智能断点和断点命令绘制出UIViewControllers,因为它们出现了。
上一章 | 目录 | 下一章 |
---|