汇编寄存器的规则

汇编寄存器的规则

在本章中,您将了解到CPU使用的寄存器,并研究和修改传入函数的参数。您还将了解常见的苹果计算机架构,以及如何在函数中使用它们的寄存器。这就是所谓的架构调用约定。



了解汇编如何工作以及特定架构的调用约定如何工作是一项极其重要的技能。它可以让您观察没有源代码的函数参数,并允许您修改传入函数的参数。此外,有时转到底层汇编层面会更好,因为您的源代码可能对您不知道的变量有不同的或未知的名称。
例如,假设您想知道函数调用的第二个参数,但是我并不知道参数的名称是什么。汇编的知识会帮助你来观察这些函数中的参数。

汇编101

等等,所以到底什么是汇编?来看一个场景:您是否曾经打了一个断点,但是中断到没有源代码的地方?然后看到看到大量内存地址和可怕的简短命令?你是不是缩成一团,悄悄地对自己说你再也不会看这些密集的东西了?嗯…这就是所谓的汇编!
这里有一张Xcode中的断点调试图,它展示了模拟器中函数的汇编。

通过上图可以看出汇编代码可以被分成几个不同的部分。汇编指令中的每一行都包含一个操作码,可以认为是对计算机来说非常简单的指令。那么操作码是什么样子的呢?操作码是在计算机上执行一项简单任务的指令。例如下面的程序集片段:

pushq   %rbx
subq $0x228, %rsp
movq %rdi, %rbx

在这个汇编模块中,您可以看到三种操作码:pushqsubqmovq可以将操作码项看作要执行的操作。操作码后面的内容是源标签和目标标签。也就是说,这些是操作码所作用的对象。在上面的示例中,有几个寄存器,显示为 rbxrsprdirbp。前面的 % 告诉您这是一个寄存器。

此外,您还可以找到一个十六进制的数字常量,如0x228。这个常数之前的美元符号告诉你它是一个绝对值。现在不需要知道这段代码在做什么,因为您首先需要了解每个符号的含义。然后你会学到更多关于操作码的知识,并在以后的章节中编写你自己的程序。

注意:在上面的示例中,请注意,在寄存器和常量之前有一堆%和$。 这就是反汇编程序格式化程序集的展示方式。 但是可以通过两种主要方式展示汇编。 第一个是 英特尔程序集 ,第二个是 AT&T程序集 。默认情况下,Apple的反汇编程序工具都会以AT&T格式显示,就如上例所示。 尽管这是一种很好的格式,但在眼睛上可能会有些困难。 在下一章中,您将把汇编格式更改为Intel,并且从那以后将完全使用Intel汇编语法。

x86_64 vs ARM64

作为Apple平台的开发人员,学习汇编时要处理两种主要架构:x86_64架构和ARM64架构。 x86_64是最可能在macOS计算机上使用的体系结构,除非您运行的是“古老”的Macintosh。x86_64是64位体系结构,这意味着每个地址最多可以容纳64个1或0。 另外,较旧的Mac使用32位架构,但是Apple在2010年底停止生产32位Mac。 在macOS下运行的程序可能是64位兼容的,包括Simulator上的程序。 话虽如此,即使您的macOS是x86_64,它仍然可以运行32位程序。如果对使用的硬件架构有疑问,可以在终端中运行以下命令来获取计算机的硬件架构:

  • uname -m

在能耗要求很高的移动设备(如iPhone)上使用ARM64体系结构。ARM强调节能功能,因此它减少了一组操作码,有助于简化复杂的汇编指令,从而降低了能耗。 这对您来说是个好消息,因为关于ARM的体系结构你需要学习的并不多。这是与之前显示的方法相同的屏幕截图,除了这次是在iPhone 7上的ARM64程序集中:

您现在可能无法区分这两种架构,但是您很快就会知道它们就像手背一样.

Apple最初在其许多iOS设备中都提供了32位ARM处理器,但此后便转移到了64位ARM处理器。 32位iOS设备几乎已过时,因为Apple已通过各种iOS版本逐步淘汰了它们。 例如,iPhone 5是最终的32位iOS设备,iOS 11不支持该设备。支持iOS 11的“最低” iPhone是64位设备iPhone 5s。近年来,32位设备已出现在其他Apple产品中。 Apple Watch的前两代是32位设备,但是第三代是64位设备。 此外,在较新的macOS设备上发现的Apple Touch Bar(无疑是花哨的)也使用32位架构。

由于最好专注于您将来的需求,因此本书将主要关注两种架构的64位汇编。 此外,您将首先开始学习x86_64程序集,然后过渡到学习ARM64程序集,以免感到困惑。 好吧,不要太困惑。

x86_64 寄存器调用规则

您的CPU使用一组寄存器来操纵正在运行的程序中的数据。这些是存储的基础,就像计算机中的RAM一样。但是,它们位于CPU本身上,因此CPU的这些部分可以快速访问这些寄存器。效率非常高,大多数指令涉及一个或多个寄存器,并执行一些操作,例如将寄存器的内容写入内存,将存储器的内容读取到寄存器或对两个寄存器执行算术运算例如 加,减等。

在x64中(从现在开始,x64是x86_64的缩写),机器使用16个通用寄存器来操纵数据。
这些寄存器是RAX,RBX,RCX,RDX,RDI,RSI,RSP,RBP和R8至R15。这些名称对您现在意义不大,但是您很快就会发现每个寄存器的重要性。“在x64中调用函数时,寄存器的方式和使用遵循非常特定的规则。这决定了函数的参数应该去哪里以及函数完成时函数的返回值应该在哪里。这很重要,因此可以将一个编译器编译的代码与另一个编译器编译的代码一起使用。

比如你看下面这个行代码:

NSString *name = @"Zoltan";
NSLog(@"Hello world, I am %@. I'm %d, and I live in %@.", name, 30, @"my father's basement");

NSLog函数调用中传递了四个参数。 其中一些值按原样传递,而一个参数存储在局部变量中,然后在函数中作为参数引用。 但是,通过汇编查看代码时,计算机并不关心变量的名称(name); 它只关心该变量在内存中的位置。

在x64汇编中调用函数时,以下寄存器用作参数。 尝试将它们提交到内存中,因为将来您会经常使用它们:

第一个参数:RDI
第二个参数:RSI
第三个参数:RDX
第四个参数:RCX
第五个参数:R8
第六个参数:R9

如果有六个以上的参数,则使用程序的堆栈将其他参数传递给该函数。

回到简单的Objective-C代码,上面的OC代码可以像下面这样的伪代码流程在寄存器中传递:

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 和寄存器

如上一节所述,寄存器使用特定的调用规则。 您也可以将该知识其应用于其他语言。
当Objective-C执行方法时,将执行一个名为 objc_msgSend 的特殊C函数。 这些功能实际上有几种不同的类型,但稍后会介绍更多。 这是Objective-C动态消息分发的核心。 作为第一个参数,objc_msgSend获取在其上发送消息的对象的引用。 随后是一个选择器,它只是一个char *,用于指定在对象上调用的方法的名称。 最后,如果选择器指定应有参数,则objc_msgSend在函数中采用可变数量的参数。
让我们来看一个在iOS环境中的具体示例:

id UIApplicationClass = [UIApplication class];
objc_msgSend(UIApplicationClass, "sharedApplication");

第一个参数是对UIApplication类的引用,其后是sharedApplication选择器 其实就是需要被调用的方法。 判断是否有参数的一种简单方法是简单地检查Objective-C选择器中的冒号。 每个冒号将代表一个方法中的参数。
我们下面来看一个OC的方法:

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;”),后跟方法选择器,然后是方法调用的参数。

将理论付诸实践

在本节中,您将使用本章资源包中提供的名为Registers的项目。
通过Xcode打开该项目,然后运行。

这是一个非常简单的应用程序,仅显示64位寄存器里面的内容。 需要注意的是,该应用程序不会实时的显示寄存器的值; 它只能在特定的函数调用期间显示寄存器的值。 这意味着您不会看到这些寄存器的值有太多更改,因为在调用获取寄存器值的函数时它们可能具有相同(或相似)的值。
现在,您已经了解了Registers macOS应用程序背后的功能,为NSViewController的viewDidLoad方法创建一个符号断点。 记住,因为您正在使用Mac应用程序,所以请使用“ NS”代替“ UI”。

生成并重新运行该应用程序。触发断点后在LLDB控制台中键入以下内容:

  • (lldb) register read

这将列出处于暂停执行状态的所有主要寄存器。 但是输出了太多信息。 您应该有选择地打印出寄存器,并将其视为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知道您需要的是寄存器的值,而不是源代码中与范围相关的变量。 是的,这与您在刚刚反汇编视图中看到的汇编不同! 烦人吧?

注:善于观察的你可能已经注意到了在OC代码中打断点,在LLDB的回溯内看不到objc_msgSend的影子。这是因为objc_msgSend方法簇执行了jmp。意思就是说objc_msgSend扮演了中转的角色,一但OC代码开始执行,所有的关于objc_msgSend的栈中的回溯都将消失。这是一种叫做尾递归调用的优化。因为mesgsend开始执行证明之前的栈帧已经清空了。

尝试打印出RSI寄存器,不出意外的话应该是方法名。 在LLDB控制台中输入以下内容:

  • (lldb) po $rsi

结果你会输出这个

140735181830794

为什么是这样? Objective-C选择器基本上只是一个char *。 这意味着,像所有C类型一样,LLDB不知道如何格式化此数据。 因此,您必须将此引用显式转换为所需的数据类型。
将RSI寄存器强制转换为正确的类型使用如下指令

  • po (char *)$rsi 或者 po (SEL)$rsi

便可以得到方法名字

"viewDidLoad"

现在,该探讨带有参数的Objective-C方法了。 由于您已经断点了viewDidLoad,因此可以放心地假设NSView实例已加载。 感兴趣的方法是mouseUp:由NSView的父类NSResponder实现的选择器。
在LLDB中,在NSResponder的mouseUp:选择器上创建一个断点,然后继续执行。 如果您不记得该怎么做,则需要以下命令:

  • (lldb) b -[NSResponder mouseUp:]
  • (lldb) continue

现在,点击应用程序窗口。 确保单击NSScrollView的外部,因为NSScrollView它会拦截您的单击,并且不会命中-[NSResponder mouseUp:]断点。

点击后,LLDB就会在mouseUp:断点处停止。 通过在LLDB控制台中键入以下内容,打印出NSResponder的引用:

  • (lldb) po $rdi

会出现如下的输出

<NSView: 0x608000120140>

但是,该方法是带参数的! 在LLDB控制台中输入以下内容:

  • (lldb) po $rdx

输出

“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”

也可以看到参数类型

  • po [$rdx class]
NSEvent

太酷了,是吗?有时使用寄存器和断点很有用,以便获得内存中还存在的对象的引用。例如,如果您想将前部NSWindow更改为红色,但是在代码中没有对该视图的引用,又不想重新编译任何代码怎么办? 您只需创建一个断点就可以轻松调试,从寄存器中获取引用并根据需要操纵该对象的实例。 您现在将尝试将主窗口更改为红色。”

注:尽管NSResponder实现了mouseDown:方法,但NSWindow重写了它。你可以输出所有实现了mouseDown:的类,你就可以看出这个方法被那些类重写了,而不用去看源码。输出所有实现了mouseDown:方法的OC类的命令是:image lookup -rn ‘\ mouseDown:

首先使用LLDB控制台删除所有以前的断点:

  • breakpoint delete

像如下输出

(lldb) breakpoint delete
About to delete all breakpoints, do you want to do that?: [Y/n] Y
All breakpoints removed. (3 breakpoints)
(lldb)

然后在LLDB控制台中键入以下内容:

  • (lldb) breakpoint set -o -S “-[NSWindow mouseDown:]
  • (lldb) continue

这句话的作用是设置一个单发断点,只会触发一次,然后点击应用程序触发断点,在LLDB控制台中键入以下内容:

  • (lldb) po [$rdi setBackgroundColor:[NSColor redColor]]
  • (lldb) continue

之后就可以看到效果

Swift和寄存器

在Swift中探索寄存器时,您将遇到两个问题,这使汇编调试比Objective-C困难。

  • 首先,在Swift调试上下文内寄存器不可用。意味着你不得不获取到任何你想要的数据,并使用OC调试上下文打印出传入Swift函数的寄存器。记住你可以使用expression -l objc -O命令,或者使用在书中第八章(“Persisting and Customizing Commands”)的cpo命令。幸运的是,register read命令依然是可以使用的。

  • 其次,Swift相对于OC并不是动态的。事实上,有时候最好假设Swift像C语言一样。如果知道了一个内存地址,你应该显示地强转为你想要的类型。不然Swift调试器没有任何线索去解释内存地址。

话虽这么说,但是Swift使用了相同的寄存器调用规则。 但是有一个非常重要的区别。 当Swift调用一个函数时,它不需要使用objc_msgSend,除非您当然标记了使用动态方法。 这意味着当Swift调用函数时,先前分配给选择器的RSI寄存器实际上就是函数的第二个参数。好了,足够的理论-是时候将其付诸实践了。

在Registers项目中,导航到ViewController.swift并将以下函数添加到该类:

func executeLotsOfArguments(one: Int, two: Int, three: Int,
four: Int, five: Int, six: Int,
seven: Int, eight: Int, nine: Int,
ten: Int) {
print("arguments are: \(one), \(two), \(three), \(four), \(five), \(six), \(seven), \(eight), \(nine), \(ten)")
}

现在,在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中,键入以下内容:

  • register read -f d

这将列出所有的寄存器,并使用-f d选项以十进制显示格式。 输出将类似于以下内容:

(lldb) register read -f d
General Purpose Registers:
rax = 10
rbx = 7
rcx = 4
rdx = 3
rdi = 1
rsi = 2
rbp = 140732785005232
rsp = 140732785004720
r8 = 5
r9 = 6
r10 = 9
r11 = 8
r12 = 140668105198352
r13 = 105553138827696
r14 = 104
r15 = 8
rip = 4430734802 Registers`Registers.ViewController.executeLotsOfArguments(one: Swift.Int, two: Swift.Int, three: Swift.Int, four: Swift.Int, five: Swift.Int, six: Swift.Int, seven: Swift.Int, eight: Swift.Int, nine: Swift.Int, ten: Swift.Int) -> () + 178 at ViewController.swift:68:15
rflags = 514
cs = 43
fs = 0
gs = 0

(lldb)

如您所见,寄存器遵循x64调用规则。 RDI,RSI,RDX,RCX,R8和R9保留您的前六个参数。

注意:关于LLDB,我一直没有告诉您的是,LLDB可以以$arg{X}形式来引用寄存器,其中X是参数号。还记得RDI是第一个参数,而RSI是第二个参数吗?在LLDB中,可以通过$arg1引用第一个参数(RDI)。随着示例的进行,您可以使用$arg2引用第二个参数(RSI),以此类推。这些方便值也可以在ARM64调用约定中使用,即使ARM64使用不同的寄存器。您应该记住寄存器调用规则,以便本书尽量减少使用这些寄存器辅助变量。

您可能还会注意到其他参数存储在其他一些其他寄存器中。 确实如此,但这只是为其余参数设置堆栈的代码中的剩余部分。 请记住,第六个参数之后的参数将进入堆栈。

RAX,用于返回的寄存器

等等–还有呢!到这里,你已经了解了函数中六个寄存器是如何调用的,但是返回值呢?

幸运的是,只有一个指定的寄存器用于返回值:RAX。回到executeLotsOfArguments函数并改变函数的返回值,像这样:

func executeLotsOfArguments(one: Int, two: Int, three: Int,
four: Int, five: Int, six: Int,
seven: Int, eight: Int, nine: Int,
ten: Int) -> String {
print("arguments are: \(one), \(two), \(three), \(four), \(five), \(six), \(seven), \(eight), \(nine), \(ten)")
return "Mom, what happened to the cat?"
}

然后viewdidlaod中调用

let _ = self.executeLotsOfArguments(one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10)

在executeLotsOfArguments中的某处创建一个断点。 再次生成并运行,然后等待函数停止执行。 接下来,在LLDB控制台中键入以下内容:

  • (lldb) finish

命令会结束完成函数的执行并停住调试器。这时,函数返回值会在RAX内。输入如下命令:

  • (lldb) register read rax

你将会看见如下输出

rax = 0x0000000100003760  "Mom, what happened to the cat?

了解RAX中的返回值非常重要,因为它将构成您将在后面的部分中编写的调试脚本的基础。

改变寄存器值

为了巩固您对寄存器的理解,您将在一个已编译的应用程序中修改寄存器。
关闭Xcode和Registers项目。 打开终端窗口,然后启动iPhone X Simulator。 通过键入以下内容来执行此操作:

  • xcrun simctl list | grep “iPhone X”
Phone: iPhone 12 Pro Max (16A6D554-3C10-4A67-9039-31B8BE33871F) (Shutdown)

UDID就是你要找的。使用它并通过如下命令打开iOS模拟器(替换其中的UDID部分):

  • open /Applications/Xcode.app/Contents/Developer/Applications/Simulator.app –args -CurrentDeviceUDID 16A6D554-3C10-4A67-9039-31B8BE33871F

保证模拟器已经启动而且在主屏幕上。你可以通过按下Command + Shift + H键回到主屏幕。一旦模拟器准备好了,回到终端窗口将LLDB绑定到SpringBoard程序上。

  • lldb -n SpringBoard

这样会将LLDB绑定到正在模拟器上运行的SpringBoard实例上!SpringBoard就是在iOS上控制主屏幕的程序。

一旦绑定,输入如下命令:

  • (lldb) p/x @”Yay! Debugging
    可以看到类似如下的输出:
(__NSCFString *) $3 = 0x0000618000644080 @"Yay! Debugging!"

注意下刚刚创建的这个NSString实例,因为很快你会用到它。现在,给UILabel的setText:方法设置一个断点:

  • (lldb) b -[UILabel setText:]
  • (lldb) breakpoint command add

LLDB会吐出一些输出且进入多行编辑模式。这个命令让你在刚刚打的断点处添加多个额外要执行的命令。输入如下,使用刚才的NSString地址替换下面的内存地址:

> po $rdx = 0x0000618000644080
> continue
> DONE

回去重新看下你刚刚做的。你在UILabel的setText:方法上添加了一个断点。一旦遇到该方法,你就会用一个叫Yay! Debugging!的NSString实例替换RDX—第三个参数。

使用continue命令让调试器继续执行:

  • (lldb) continue

看看SpringBoard模拟器程序什么发生了改变。从下往上扫带出控制中心,观察改变的地方:

尽管这似乎是一个很酷的花招编程技巧,但它却展示了通过有限的汇编和寄存器的知识能够在程序内产生你之前没见过的大的变化。

从调试的角度来看,这也很有用,因为您可以快速直观地验证-[UILabel setText:]在SpringBoard应用程序中的执行位置,并运行断点条件以查找设置特定UILabel文本的确切代码行。

寄存器和SDK

了解寄存器的工作方式以及应用程序的功能可以快速帮助您找到感兴趣的项目。举个简单的例子:通常,我会遇到一个UIButton,并想知道IBAction和接收器,当我点击该按钮时会发生什么。在最高断点处,我可能会发疯……认识我自己,我通常在UIView或UIViewController(也许是UITableViewCell?)中包含IBAction,并且通常使用某种名称为“ tapped”的方法。
因此,也许下面的LLDB命令会起作用?

  • (lldb) rb View(Controller|Cell)?\s(?i).*tapped

但是我错误地假设同事/其他开发人员正在使用与我相同的命名约定;这个想法行不通。相反,我知道,每当执行IBAction方法时,它都必须经过UIApplication单例,在该单例中,它将遍历响应者链来找到合适的接收者。为此,将调用UIControl的-sendAction:to:forEvent:方法。
我可以在此方法上设置一个断点,并探索sendAction:和to:参数以查找IBAction正在执行的代码。
这个想法可以应用到您拥有和没有源代码的应用程序中。我经常发现,即使在我确实有源代码的应用程序中,使用此方法也更快,然后在应用程序中看到数千个IBAction。

…但仅出于演示目的,让我们将其应用于iOS Maps应用程序。我对右上方按钮的名称和接收者感到好奇,该按钮可以直接定位用户的具体位置。

通过LLDB附加到Maps应用程序并为-[UIControl sendAction:to:forEvent:]设置断点后,很容易找到UIButton的名称和接收者。
sendAction:参数(RDX)将使用选择器,而to:参数将是IBAction的接收器(RCX)。


用寄存器知识和轻按UIButton查找代码,这有多酷?

下一步

好的,学了这么长时间,来休息下,看看你学到了什么:

架构(X86)定义了一个调用规则,该规则规定了函数参数及其返回值的存储位置。

  • 在Objective-C中,RDI寄存器是调用NSObject的引用,RSI是选择器,RDX是第一个参数,依此类推。
  • 在Swift中,RDI是第一个参数,RSI是第二个参数,依此类推,前提是Swift方法未使用动态分配。
  • RAX寄存器用于函数中的返回值,无论您使用的是Objective-C还是Swift。

您可以利用寄存器做很多事情。尝试浏览您没有源代码的应用;将为解决棘手的调试问题奠定良好的基础。

尝试附加到iOS Simulator上的应用程序,并使用程序集,智能断点和断点命令绘制出UIViewController的生命周期。

0%