你已经开始了这个旅程,并在上一章中了解了x64调用惯例的黑暗艺术。当一个函数被调用时,你现在知道参数是如何传递给函数的,以及函数的返回值是如何回来的。你还没有学会的是,当代码被加载到内存中时,它是如何被执行的。
在本章中,你将探讨程序是如何执行的。你将看到一个特殊的寄存器,它用来告诉处理器应该从哪里读取下一条指令,以及不同大小和分组的内存如何产生非常不同的结果。
如前一章所述,有两种主要的汇编显示方式。一种是AT&T汇编,是LLDB的默认汇编设置。这种方式有以下格式。
opcode source destination
请看一个具体的例子。
movq $0x78, %rax
这将把十六进制的值0x78移到RAX寄存器中。尽管这种汇编方式对某些人来说很好,但从现在开始你将使用英特尔的方式。
为什么选择英特尔而不是AT&T?答案可以通过这个简单的推文得到最好的解释......
注意:严肃地说,汇编的选择有点像一场战争--请看StackOverflow的讨论:https://stackoverflow.com/questions/972602/att-vs-intel-syntax-and-limitations。
使用英特尔是基于一个公认的松散共识,即英特尔更适合阅读,但有时更不适合写作。由于你正在学习调试,大部分时间你都在读汇编,而不是写汇编。
在你的~/.lldbinit文件的底部添加以下几行。
settings set target.x86-disassembly-flavor intel
settings set target.skip-prologue false
第一行告诉LLDB以Intel风格显示x86汇编(包括32位和64位)。
第二行告诉LLDB不要跳过函数的序言。你在本书早些时候遇到过这个问题,从现在开始,不跳过序言是谨慎的,因为你将从函数的第一条指令开始检查汇编。
注意:在编辑~/.lldbinit文件时,确保不要使用TextEdit这样的程序,因为它会在文件中添加不必要的字符,导致LLDB不能正确解析文件。一个简单(但很危险)的添加方法是通过终端命令,如:echo "settings set target.x86-disassembly-flavor intel" >> ~/.lldbinit。
确保你有两个">>"在里面,否则你会覆盖你以前在~/.ldbinit文件中的所有内容。如果你不习惯使用终端,像nano这样的编辑器(你之前已经用过)是你最好的选择。
英特尔风味将交换源值和目标值,删除'%'和'$'字符,以及做许多其他修改。因为你没有使用AT&T的语法,所以最好不要解释这两种汇编方式的全部区别,而只需学习Intel的格式。
看一下前面的例子,现在以英特尔格式显示,看看它看起来有多干净。
mov rax, 0x78
同样,这将把十六进制的数值0x78移到RAX寄存器中。
与前面的AT&T模式相比,Intel模式交换了源操作数和目的操作数。现在目标操作数在源操作数之前。在使用汇编时,重要的是你要始终识别正确的模式,因为如果你不清楚你正在使用的模式,可能会发生不同的操作。
从这里开始,英特尔属性将是前进的道路。如果你看到一个以$字符开头的十六进制数字常数,或一个以%开头的寄存器,就知道你在错误的汇编区,应该用上面描述的过程来改变它。
首先,你要创建你自己的LLDB命令以帮助以后的工作。
在你最喜欢的文本编辑器中再次打开~/.lldbinit(vim,对吧)。然后在文件的底部添加以下内容。
command alias -H "Print value in ObjC context in hexadecimal" -h "Print in hex" -- cpx expression -f x -l objc --
这个命令,cpx,是一个方便的命令,你可以使用Objective-C上下文,以十六进制的格式打印出一些东西。在打印出寄存器的内容时,这将是非常有用的。
记住,寄存器在Swift上下文中是不可用的,所以你需要使用Objective-C上下文来代替。
现在你有了在本章中通过汇编观点探索内存所需的工具
在你开始探索内存之前,你需要了解一些关于内存如何分组的词汇。
一个可以包含1或0的值被称为一个比特。你可以说在64位架构中,每个地址有64个比特。这很简单。
当有8个比特组合在一起时,它们就被称为一个字节。一个字节可以容纳多少个唯一的值?你可以通过计算2^8来确定,这将是256个值,从0开始,一直到255。
很多信息都是用字节表示的。例如,C语言的sizeof()函数以字节为单位返回对象的大小。
如果你熟悉ASCII字符编码,你会记得所有的ASCII字符都可以放在一个字节里。
现在是时候看看这个术语的实际应用了,顺便学习一些技巧。
打开Registers macOS应用程序,你可以在本章的资源文件夹中找到它。接下来,构建并运行该应用程序。一旦运行,暂停程序并调出LLDB控制台。如前所述,这将导致使用非Swift调试环境。
(lldb) p sizeof('A')
这将打印出构成A字符所需的字节数。
(unsigned long) $0 = 1
接下来,输入以下内容。
(lldb) p/t 'A'
你会得到以下输出。
(char) $1 = 0b01000001
这就是字符A在ASCII中的二进制表示。
另一种更常见的显示一个字节的信息的方法是使用十六进制的值。用十六进制表示一个字节的信息需要两个十六进制的数字。
打印出A的十六进制表示法。
(lldb) p/x 'A'
你会得到以下输出。
(char) $2 = 0x41
十六进制很适合查看内存,因为一个十六进制的数字正好代表4位。因此,如果你有2个十六进制数字,你就有一个字节。如果你有8个十六进制数字,你就有4个字节。以此类推。
这里还有一些术语,在接下来的章节中你会发现它们很有用。
Nybble:4位,十六进制的单一数值。
Half word:16位,或2个字节
Word:32位,或4个字节
Double word或Giant word:64比特或8字节。
有了这些术语,你就可以去探索不同的内存块了。
啊,确切地说是放在墓碑上的寄存器。
当一个程序执行时,要执行的代码被加载到内存中。程序中下一步要执行的代码的位置由一个神奇的重要寄存器决定:RIP或指令指针寄存器。
现在你将看看这个寄存器的运行情况。再次打开Registers应用程序,导航到AppDelegate.swift文件。修改该文件,使其包含以下代码。
构建并运行该应用程序。不足为奇的是,方法名会在applicationWillBecomeActive(_:)中被吐出到调试控制台,然后是aBadMethod输出。aGoodMethod不会被执行。
使用Xcode GUI在aBadMethod的最开始创建一个断点。
建立并再次运行。一旦在aBadMethod的开始部分建立了断点,请在Xcode导航到Debug ▸ Debug Workflow ▸ Always Show Disassembly。现在你将会看到程序的实际装配!
接下来,在LLDB控制台输入以下内容。
(lldb) cpx $rip
这将使用你先前创建的cpx命令打印出指令指针寄存器。
你会注意到LLDB吐出的输出将与Xcode中绿线突出显示的地址一致。
(unsigned long) $1 = 0x0000000100007c20
值得注意的是,你的地址可能与上面的输出不同,但绿线的地址和RIP控制台的输出将匹配。现在,在 LLDB 中输入以下命令。
(lldb) image lookup -vrn ^Registers.*aGoodMethod
这是一个屡试不爽的图像查找命令,带有典型的正则表达式参数和一个附加参数,即-v,它可以转储冗长的输出。
你会得到相当多的内容。搜索紧跟在range = [; Command + F之后的内容将被证明在这里很有用。你要找的是范围括号里的第一个值。
这个地址被称为加载地址。这是该函数在内存中的实际物理地址。
这与你在图像查找命令中看到的通常输出不同,因为它只显示函数相对于可执行文件的偏移量,也称为执行偏移量。在寻找一个函数的地址时,区分可执行文件中的加载地址和执行偏移量是很重要的,因为它们会有所不同。
把这个新的地址复制到范围括号的开头。对于这个特殊的例子,aGoodMethod的加载地址位于0x0000000100003a10。现在,把这个指向aGoodMethod方法开始的地址写到RIP寄存器中。
(lldb) register write rip 0x0000000100003a10
点击继续使用Xcode的调试按钮。重要的是你要这样做,而不是在LLDB中输入继续,因为有一个错误,当修改RIP寄存器和在控制台中继续时,你会被绊倒。
在按下Xcode continue按钮后,你会看到aBadMethod()没有被执行,而aGoodMethod()被执行。通过查看控制台日志中的输出来验证这一点。
注意:修改RIP寄存器实际上是有点危险的。你需要确保保存着RIP寄存器中先前数值的数据的寄存器不会被应用到一个新的函数中,这个函数会对寄存器做出不正确的假设。由于aGoodMethod和aBadMethod在功能上非常相似,你已经停在了开头,而且由于没有对Registers应用进行优化,这并不令人担心。
如前一章所述,x64有16个通用寄存器。RDI, RSI, RAX, RDX, RBP, RSP, RCX, RDX, R8, R9, R10, R11, R12, R13, R14 和 R15。
为了保持与以前架构的兼容性,比如i386的32位架构,寄存器可以被分解为它们的32位、16位或8位值。
对于那些在不同体系结构中都有历史的寄存器,寄存器名称中最前面的字符决定了该寄存器的大小。例如,RIP寄存器以R开头,表示64位。如果你想得到相当于RIP寄存器的32位,你可以把R的字符换成E,得到EIP寄存器。
为什么这很有用?当使用寄存器时,有时传入寄存器的值不需要使用全部64位。例如,考虑布尔数据类型。
你真正需要的是一个1或一个0来表示真或假,对吗?基于语言的特点和约束,编译器知道这一点,有时只向寄存器的某些部分写入信息。
让我们来看看这个动作。
删除Registers项目中的所有断点。建立并运行该项目。现在,突然暂停程序。
一旦停止,输入以下内容。
(lldb) register write rdx 0x0123456789ABCDEF
这将向RDX寄存器写入一个值。
让我们停顿一下。一句警告的话。你应该意识到,对寄存器的写入可能会导致你的程序停顿,特别是当你写入的寄存器被期望有某种类型的数据时。但你是以科学的名义来做这件事的,所以如果你的程序真的崩溃了,也不用担心!
确认这个值已经成功写入RDX寄存器。
(lldb) p/x $rdx
由于这是一个64位的程序,你将得到一个双字,即64位,或8字节,或16个十六进制数字。
现在,试着打印出EDX寄存器。
(lldb) p/x $edx
EDX寄存器是RDX寄存器中最小的一半。所以你只能看到双字中最小的那一半,也就是一个字。你应该看到以下内容。
0x89abcdef
接下来,输入以下内容。
(lldb) p/x $dx
这将打印出DX寄存器,它是EDX寄存器的最小一半。因此它是一个半字。你应该看到下面的内容。
0xcdef
接下来,输入以下内容。
(lldb) p/x $dl
这将打印出DL寄存器,它是DX寄存器中最不重要的一半,这次是一个字节。你应该看到以下内容。
0xef
最后,输入以下内容。
(lldb) p/x $dh
这样你就得到了DX寄存器中最重要的一半,也就是DL所给出的另一半。DL中的L代表 "低",DH中的H代表 "高",这一点应该不奇怪。
在探索汇编时要注意不同大小的寄存器。寄存器的大小可以提供有关其中所含数值的线索。例如,你可以通过寻找具有L suffix的寄存器来轻松找到返回布尔值的函数,因为布尔值只需要一个比特就能使用。
由于R8到R15系列的寄存器只为64位架构创建,它们使用了完全不同的格式来表示其较小的对等物。
现在你将探索R9的不同尺寸选项。建立并运行Registers应用程序,并暂停调试器。像以前一样,向R9寄存器写入相同的十六进制值。
(lldb) register write $r9 0x0123456789abcdef
确认你已经设置了R9寄存器,输入以下内容。
(lldb) p/x $r9
接下来输入以下内容。
(lldb) p/x $r9d
这将打印R9寄存器的低32位。注意它与你为RDX(即EDX,如果你已经忘记了)指定低32位的方式不同。
接下来,输入以下内容。
(lldb) p/x $r9w
这一次你得到了R9的低16位。同样,这与你对RDX的操作方式不同。
最后,键入以下内容。
(lldb) p/x $r9l
这样就可以打印出R9的低8位。
虽然这看起来有点乏味,但你正在积累阅读大量汇编的技能。
现在你已经看完了指令指针,是时候进一步探索它背后的内存了。正如其名称所示,指令指针实际上是一个指针。它不是在执行存储在RIP寄存器中的指令--它是在执行RIP寄存器中指向的指令。
在LLDB中看到这一点也许会更好地描述它。回到Registers应用程序中,打开AppDelegate.swift,再次在aBadMethod上设置断点。建立并运行该应用程序。
一旦断点被击中,程序被停止,回到汇编视图。如果你忘记了,也没有为它创建一个键盘快捷键,可以在Debug ▸ Debug Work FLOW ▸ Always Show Disassembly下找到它。
迎接你的是大量的操作码和寄存器。看一下RIP寄存器的位置,它应该指向函数的最开始。
对于这个特定的构建,aBadMethod的起始地址是0x100007c20。像往常一样,你的地址可能会有所不同。
在LLDB控制台,输入以下内容。
(lldb) cpx $rip
正如你现在知道的,这将打印出指令指针寄存器的内容。
正如预期的那样,你会得到aBadMethod的起始地址。但同样,RIP寄存器指向内存中的一个值。它指的是什么呢?好吧......你可以拿出你疯狂的C语言编码技巧(你还记得这些吧?
输入以下内容,用你的aBadMethod函数的地址替换地址。
(lldb) memory read -fi -c1 0x100007c20
哇,这条命令到底是做什么的?
memory read接收一个值并读取你提供的内存地址所指向的内容。-f命令是一个格式化参数;在这个例子中,它是汇编指令的格式。最后你说你只想用count,或-c参数打印出一条汇编指令。
你会得到类似这样的输出。
-> 0x100007c20: 55 push rbp
这就是一些很好的输出。它告诉你汇编指令,以及操作码,以十六进制(0x55)提供,负责pushq rbp操作。
再看一下输出中的 "55"。这是整个指令的编码,也就是整个pushq rbp。不相信我?你可以验证一下。在LLDB中键入以下内容。
(lldb) expression -f i -l objc -- 0x55
这实际上是要求LLDB对0x55进行解码。你会得到以下输出。
(int) $0 = 55 push rbp
这个命令有点长,但这是因为如果你在Swift调试上下文中,你需要必要的切换到Objective-C上下文。然而,如果你转到Objective-C调试上下文,你可以使用一个方便的表达式,它要短得多。
试着在Xcode的左边面板上点击一个不同的框架,进入一个不包含Swift或Objective-C/Swift桥接代码的Objective-C上下文。
点击任何在Objective-C函数中的框架。
接下来,在LLDB控制台输入以下内容。
(lldb) p/i 0x55
好多了,对吗?
现在,回到手中的应用程序。在LLDB中键入以下内容,再次将地址替换为你的aBadMethod函数地址。
(lldb) memory read -fi -c4 0x100007c20
你会得到10倍的输出! 这是值得放在LinkedIn简历上的东西......
顺便说一下,有一个速记的方便方法来执行上述命令。你可以简单地输入以下内容来达到相同的结果。
(lldb) x/4i 0x100007c20
无论你选择哪条命令,你都会得到类似于以下的输出。
这里有一些有趣的东西需要注意:汇编指令可以有不同的长度。看看第一条指令,与输出中的其他指令相比。第一条指令的长度为1个字节,用0x55表示。下面的指令有3个字节长。
请确保你仍处于Objective-C的上下文中,并尝试打印出负责这条指令的操作码。它只有3个字节,所以你所要做的就是把它们连在一起,对吗?
(lldb) p/i 0x4889e5
你会得到一条与mov %rsp, %rbp指令完全无关的不同指令! 你会看到这个。
e5 89 inl $0x89, %eax
什么情况?也许现在是讨论字节性的好时机。
x64以及ARM家族架构的设备都使用小字节(little-endian),这意味着数据被存储在内存中,最小的字节在前。如果你要在内存中存储数字0xabcd,0xcd字节将被首先存储,然后是0xab字节。
回到指令的例子,这意味着指令0x4889e5将以0xe5、0x89、0x48的形式存储在内存中。
跳回到你之前遇到的那个mov指令,试着把组成汇编指令的字节反过来。在LLDB中键入以下内容。
(lldb) p/i 0xe58948
现在你会得到预期的汇编指令。
(Int) $R1 = 48 89 e5 mov rbp, rsp
让我们看看更多的小数位操作的例子。在LLDB中键入以下内容。
(lldb) memory read -s1 -c20 -fx 0x100003840
这个命令读取了地址为0x100003840的内存。由于使用了-s1选项,它以1个字节为单位进行读取,由于使用了-c20选项,它以20为单位进行读取。你会看到类似这样的东西。
0x100003840: 0x55 0x48 0x89 0xe5 0x48 0x83 0xec 0x60
0x100003848: 0xb8 0x01 0x00 0x00 0x00 0x89 0xc1 0x48
0x100003850: 0x89 0x7d 0xf8 0x48
现在,像这样将大小加倍,计数减半。
(lldb) memory read -s2 -c10 -fx 0x100003840
你会看到像这样的东西。
0x100003840: 0x4855 0xe589 0x8348 0x60ec 0x01b8 0x0000 0x8900 0x48c1
0x100003850: 0x7d89 0x48f8
请注意,当内存值被分组时,由于是小数位,它们是反过来的。
现在把大小加倍,再把计数减半。
(lldb) memory read -s4 -c5 -fx 0x100003840
现在你会得到像这样的东西。
0x100003840: 0xe5894855 0x60ec8348 0x000001b8 0x48c18900
0x100003850: 0x48f87d89
与之前的输出相比,数值再次被颠倒。
记住这一点非常重要,也是探索内存时的一个混乱来源。不仅内存的大小会给你一个可能不正确的答案,而且还有顺序。当你试图弄清某件事情应该如何运作时,请记住这一点,你开始对你的电脑大喊大叫吧!
做得很好,通过了这个问题。内存布局可能是一个令人困惑的话题。试着探索其他设备上的内存,以确保你对小序数架构和汇编是如何分组的有一个坚实的理解。
在下一章,你将探索堆栈框架和函数如何被调用。
上一章 | 目录 | 下一章 |
---|