前言
本文翻译自Assembly Register Calling Convention Tutorial
翻译的不对的地方还请多多包涵指正,谢谢~
汇编寄存器调用约定教程
在本篇教程,你将看到CPU使用的寄存器并探索改变传入函数的参数。你将学到普通的苹果计算机架构及在函数内寄存器是如何使用的。这就是被称为架构的调用约定。
了解汇编如何工作及特定架构的调用约定的工作方式是一项非常重要的技能。它能让你观察没有源码的函数参数,并改变其参数。除此外,有些时候探寻汇编层面可能更好,因为你的源码可能有不同的或者未知的参数名称。
例如,假设你总是希望知道函数的第二个参数是什么,而不需要知道它的参数名称。汇编知识能让你在站在一个极佳的层面来操纵或观察函数参数。
汇编101
等等,那么汇编又是甚?
你曾在没有源码的函数面前束手无策,或看到恶心的内存地址后面跟着一个可怕的短命令?是否曾躲在一个球内悄悄告诉自己再也不要看到这些密集的东西?好吧。。。这些就是所谓的汇编。
下面是一张Xcode的回溯图,展示的是模拟器内一个函数的汇编。
看以上图片,汇编可以分成若干部分。每行汇编指令包含一个操作符,可以把它想象成计算机的一个极简的操作指令。
操作符是什么?它是计算机中可以执行简单任务的指令。例如,看如下汇编片段:
pushq %rbx
subq $0x228, %rsp
movq %rdi, %rbx
在这段汇编块中,可以看到有三个操作符:pushq, subq, movq
。将其当做要执行的动作。操作符后面的是源或结果标签。这些就是操作符要操纵的对象。
上述例子中,有几个寄存器,用rbx,rsp,rdi,rbp
来表示。%
作为前缀表明这是一个寄存器。
此外,你也会发现一个十六进制的数字常量:ox228
。$
表明这个常量是一个绝对数值。
没必要知道这些代码正在做什么事,因为首先你首先需要学习寄存器和函数调用约定。
注:上述例子中,注意在寄存器和常量之前有一堆
% $
这样的字符。这是反汇编程序如何格式化程序的。但是,有两种主要的方式来展现汇编。第一个时Intel
,第二个是AT&T
汇编。默认地,苹果反汇编工具带有
AT&T
汇编格式的程序,就如上面的例子一样。尽管这是一个很好的工作格式,但诚然令人头疼。
x86_64 vs ARM64
作为苹果平台的开发者,在学习汇编中你会遇到两种主要的架构:x86_64和ARM64架构。x86_64架构一般使用的macOS的电脑上,除非你还在使用古老的Mac。
x86_64是64位的架构,意味着每个地址都持有了64个1或者0位。老款Mac使用的是32位架构,但苹果在2010年就停止使用32位的了。在macOS下的程序都是64位兼容的,包括模拟器。也就是说,x86_64系统可以跑32位的应用程序。
如果你对现有硬件系统架构有任何疑问,可以通过以下shell命令获取计算机的硬件架构:
uname -m
ARM64架构被使用在移动设备上比如你的iPhone,在这些设备中的能量消耗限制非常严格。
ARM架构强调能量保护,因此相对复杂汇编指令,它有消减过后的操作集来帮助其减少能量消耗。这对你来说是好事,因为这意味着在ARM架构中你要学习的指令更少。
以下是以上同一个函数的截图,只不过这次是iPhone的ARM64汇编:
在许多它们的设备汇总,很多都加入到了64位的ARM处理器。Apple阶段性地通过iOS版本升级将32位设备淘汰掉。例如,iPhone4s是32位的设备在iOS10就已经不支持升级了。iOS10系统支持的32位设备只有iPhone5了。
有趣的是,所有Apple Watch设备都是32位的。可能是因为32位的ARM CPU消耗更少的能量吧。这确实非常重要因为Watch的电池非常小。
考虑到最好是将重点集中到未来你需要的事情上,“Advanced Apple Debugging & Reverse Engineering”(英文书)将集中在64位的两种架构中。此外,你将首先学习x86_64然后转移到ARM64汇编,这样就不会懵逼。嗯,不会太懵逼_
x86_64寄存器调用约定
在运行的程序内CPU使用一组寄存器操作数据。它们是存储器,类似于计算机上的RAM。寄存器就在CPU上,且跟CPU使用它们的部分非常近。所以CPU上使用它们的组件访问寄存器相当快。
大多数指令涉及到一个或多个操作符,比如将寄存器的内容写入内存,读取一块内存到寄存器,或者在两个寄存器上执行算术操作(加,减等)。
在x64(从这里开始,x64就是x86_64的缩写),有16个通用寄存器用于机器操作数据。
这些寄存器是RAX, RBX, RCX, RDX, RDI, RSI, RSP, RBP, R8, R15
。这些名字现在没什么意义,但待会你就知道他们每一个的重要性了。
当你在x64上调用函数,寄存器的方式和用法遵循一种非常特别的约定。它们表明了函数参数的位置及在函数完成时返回结果的位置。这很重要,因为一个编译器编译的代码能和另一个编译器编译的代码一起使用。
例如,请看以下简单的OC代码:
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
如果参数超过六个,那么程序调用栈就会被用来传递那些额外的参数。
回到那个简单的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
函数一开始,以上的寄存器就会想上面展示的一样包含合适的值。
但是,只要函数开场(函数开始的阶段--准备栈和寄存器)完成执行,寄存器内的值很可能会变化。生成好的汇编码可能重写寄存器内的值,或是简单的抛弃掉这些引用,因为这些代码可能不需要了。
这意味着一旦你过了函数的开场阶段(通过stepping over,stepping in或者stepping out),就不再能假设寄存器内的值是你期望监测的值了,除非你正好看到汇编代码能知道它做了些什么。
这种调用约定严重影响你调试or断点策略。如果你让任何断点自动化,将不得不在函数开始时打断点为了观察或者改变参数,而不需要真正地深入到汇编层面。
Objective-C和寄存器
寄存器使用特殊的调用约定。你也可以使用那些知识应用到其他的语言上。
当OC执行方法时,一个特殊的叫objc_msgsend
C函数被调用。实际上有多个不同类型这样的函数,但稍后会更多。这是消息转发的核心。作为第一个参数,objc_msgsend
持有了消息被发送的对象引用。接着第二个参数是selector
,它仅仅就是一个char *
指定了该对象的要调用的方法名。最后,objc_msgsend
持有了一个可变参数,如果selector
指定了其他参数。
让我们来看看一个真实的iOS中的例子:
[UIApplication sharedApplication];
编译器将编译这段代码并生成如下伪代码:
id UIApplicationClass = [UIApplication class];
objc_msgSend(UIApplicationClass, "sharedApplication");
第一个参数是UIApplication
类,之后是sharedApplication
函数名。一种非常简单的方式计算函数的参数个数,就是数selector
内有多少个冒号。一个冒号代表一个参数。
这是另一个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
类的一个实例,之后是selector
,跟着是NSString
类的实例参数。
使用objc_msgSend
的知识,你可以在x64中使用寄存器来探索内容,你很快会完成它。
理论运用于实践
可以下载 教程开始的工程。
本段,你将会使用到本教程资源提供的名为Registers
的工程。
用Xcode打开该工程并运行:
这是一个非常简单的程序,只是展示了x64系统的一些寄存器内容。注意到程序并不能把每一时刻寄存器内容都展示出来,它仅仅展示了一个特殊函数调用过程中的寄存器值。这意味着你不会看到寄存器值太多的变化,因为它们很可能在这个抓取寄存器值函数调用时有着相同的值。
现在你已经大概了解这个mac系统应用程序的功能,那么在NSViewController
的 viewDidLoad
方法上打上一个断点吧。记得使用NS
而不是UI
开头,因为你正在写一个Cocoa应用程序。
编译并运行。一但调试器停止,在LLDB控制台输入如下指令:
(lldb) register read
该命令会列出在程序执行暂停的时候主要的寄存器。但,这实在是太多信息了。你应该选择性地打印出寄存器并将它们视为OC(Objective-C)对象。
如果你稍微回想一下, -[NSViewController viewDidLoad]
将会转换成如下二进制伪代码:
RDI = UIViewControllerInstance
RSI = "viewDidLoad"
objc_msgSend(RDI, RSI)
知道x64的调用约定和objc_msgSend
的工作方式,你可以找到特定的正在被加载的NSViewController
。
输入如下LLDB命令:
(lldb) po $rdi
你会得到如下类似信息:
该命令会打印出RDI寄存器持有的NSViewController
引用,你懂的,就是方法的第一个参数~
在LLDB中,在寄存器前面加上$
字符是很重要的,这样LLDB知道你想知道寄存器的值而不是该块源码中的变量。是的,你在反汇编里看到的跟汇编里的不一样!很讨厌,对吧?
注:善于观察的你可能已经注意到了在OC代码中打断点,在LLDB的回溯内看不到
objc_msgSend
的影子。这是因为objc_msgSend
方法簇执行了jmp
,或者在汇编内的跳转命令。意思就是说objc_msgSend
扮演了中转的角色,一但OC代码开始执行,所有的关于objc_msgSend
的栈中的回溯都将消失。这是一种叫做尾递归调用的优化。
试试打印下RSI
,这很可能是方法的selector
。输入如下指令:
(lldb) po $rsi
不幸的是,你会看到的类似于这样的垃圾输出:
140735181830794
为什么嘞?
selector
其实就是char *
。也就是说,像其他C类型一样,LLDB不知道怎么格式化这个数据。最后,你必须显示地将这块引用转换成你希望的数据结构。
试试将它强转成正确的类型:
(lldb) po (char *)$rsi
你会看到:
"viewDidLoad"
必然,你也可以将它转成Selector
类型,并得到相同的结果:
(lldb) po (SEL)$rsi
现在呢,是时候看看带有参数的OC方法了。因为你断点停在了viewDidLoad
方法,你可以假定NSView
示例已经加载完毕。一个有趣的方法叫mouseUp:
,它是NSView
的父类NSResponder
实现的。
在LLDB内,创建一个NSResponder
的mouseUp:
断点并继续执行(resume)。如果你不知道怎么操作,以下命令可以使用:
(lldb) b -[NSResponder mouseUp:]
(lldb) continue
现在,点击应用窗口。并且保证你点击的区域不在NSScrollView
之内,因为NSScrollView
会捕获你的点击,那么-[NSResponder mouseUp:]
断点就到不了了。
一旦你放开鼠标或者触控板,LLDB会停在mouseUp:
方法断点处。通过如下命令打印出NSResponder
的引用:
(lldb) po $rdi
你会看到类似以下信息:
但是,selector
有个有意思的地方,它有一个冒号,意味着它有一个参数!在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
?ok,你可以查看[NSResponder mouseUp:]
的开发文档,或者简单地使用OC方法获取它的类型:
(lldb) po [$rdx class]
非常酷,是不是~
有时候为了知道在内存中一个对象的指针,使用寄存器或者断点是非常有用的。
例如,如果你想将窗口的颜色变成红色,但是代码中并没有该窗口的引用,而且还不想重新编译代码?你可以简单地在一个很容易捕获的地方创建断点,获取寄存器的引用并随意操纵该对象的实例。你就将窗口变为红色啦~
注:尽管
NSResponder
实现了mouseDown:
方法,但NSWindow
重写了它。你可以输出所有实现了mouseDown:
的类并猜想出它们之中哪些继承自NSResponder
来决定是否该方法被重写了,而不用去看源码。输出所有实现了mouseDown:
方法的OC类的命令是: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 -S "-[NSWindow mouseDown:]"
(lldb) continue
这里设置了一个一次性的断点。
点击应用。点击之后,断点立即停住了。然后输入如下命令:
(lldb) po [$rdi setBackgroundColor:[NSColor redColor]]
(lldb) continue
一旦重新执行,NSWindow
将变红~
Swift与寄存器
在Swift内探索寄存器时,会遇到两个使汇编调试在Swift中比OC更困难的障碍。
首先,在Swift调试上下文内寄存器不可用。意味着你不得不获取到任何你想要的数据,并使用OC调试上下文打印出传入Swift函数的寄存器。记住你可以使用
expression -l objc -O
命令,或者使用在书中第八章(“Persisting and Customizing Commands”)的cpo
命令。幸运的是,register read
命令依然是可以使用的。其次,Swift相对于OC并不是动态的。事实上,有时候最好假设Swift像C语言一样。如果知道了一个内存地址,你应该显示地强转为你想要的类型。不然Swift调试器没有任何线索去解释内存地址。
话虽如此,在Swift中也是使用的相同的寄存器调用约定。但是,有一个非常重要的不同点。当Swift调用一个函数时,它不需要调用objc_msgSend
函数,除非你用dynamic
对它做标记。也就是说Swift调用一个函数,之前说的用于存selector
的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
内使用合适的参数调用该方法:
override func viewDidLoad() {
super.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
选项以十进制的格式展示。输入类似如下:
General Purpose Registers:
rax = 7
rbx = 9
rcx = 4
rdx = 3
rdi = 1
rsi = 2
rbp = 140734799801424
rsp = 140734799801264
r8 = 5
r9 = 6
r10 = 10
r11 = 8
r12 = 107202385676032
r13 = 106652628550688
r14 = 10
r15 = 4298620128 libswiftCore.dylib`swift_isaMask
rip = 4294972615 Registers`Registers.ViewController.viewDidLoad () -> () + 167 at ViewController.swift:16
rflags = 518
cs = 43
fs = 0
gs = 0
如你所见,寄存器遵循x64调用约定。RDI, RSI, RDX, RCX, R8,R9
持有了6个参数。
你可能看到其他的参数被存储在了其他的寄存器内。虽然这是事实,但它只是代码中的剩余部分,用于为其余参数设置堆栈。记住,第六个参数后的参数将进入堆栈。
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?"
}
在viewDidLoad
函数,改变函数的调用让其接受返回值,但却忽略它。
override func viewDidLoad() {
super.viewDidLoad()
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?"
bong~ 你的返回值!
返回值RAX的知识很重要,因为它会构建你将学到的调试脚本基础。
改变寄存器值
为了巩固寄存器的理解,你将会在已编译的程序内来改变寄存器。
关闭Xcode和工程及模拟器,打开终端程序,输入如下命令:
xcrun simctl list
你将看到长串的设备列表。搜索最新iOS版本安装的模拟器。在这之下,找到iPhone7设备。它看起来是这样的:
iPhone 7 (269B10E1-15BE-40B4-AD24-B6EED125BC28) (Shutdown)
UDID就是你要找的。使用它并通过如下命令打开iOS模拟器(替换其中的UDID部分):
open /Applications/Xcode.app/Contents/Developer/Applications/Simulator.app --args -CurrentDeviceUDID 269B10E1-15BE-40B4-AD24-B6EED125BC28
保证模拟器已经启动而且在主屏幕上。你可以通过按下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
模拟器程序什么发生了改变。从下往上扫带出控制中心,观察改变的地方:
试着弹出其他以模态弹出的地方,因为这样很可能导致在新的UIViewController
(包括它的子view)会懒加载,并触发断点。
尽管这可能看起来是一个非常酷的巧妙的把戏,但它却展示了通过有限的汇编和寄存器的知识能够在程序内产生你之前没见过的大的变化的一种富有洞察力的观察方式。
在调试的角度来看也是非常有用的,因为你可以快速形象地确认在SpringBoard
程序内-[UILabel setText:]
的执行位置,并通过运行断点的条件来找到准确的设置UILabel
文本的代码行数。
继续这个想法,任何没有改变文本的UILabel
也告诉了你一些东西。例如,UIButton
的文本并没有变成Yay! Debugging!
。可能UILabel
的setText:
方法是在之前调用了?或者SpringBoard
程序调用的是setAttributedText:
方法?或者它们使用的是私有的方法而对于第三方开发者来说是不公开的?
就像你看到的,使用和操纵寄存器能够让你更深入地了解应用程序的功能_
何去何从
哇哦!这真的很长啊,不是么?你可以下载整个教程的工程
因此你学到了什么?
- 定义调用约定的架构,指明了函数参数和返回值的位置;
- OC中,RDI是对象的引用,RSI是Selector,RDX是第一个参数,等等。。。
- 在Swift中,RDI是第一个参数,RSI是第二个参数,等等。。。这是在Swift方法没有添加
dynamic
标识的时候。 - RAX是用于存储返回值的,无论在OC还是Swift中。
- 保证你使用的是OC的上下文,当你在用
$
输出寄存器的时候。