# 汇编和内存
你已经开了汇编学习的旅程,并且在前几章中你已经学习了汇编调用的一些黑魔法,你现在知道了,当一个函数被调用,他的参数和返回值是如何传递的。但是您还没学到的是将代码加载到内存后如何执行代码。
在本章中,您将探索程序的执行方式。 您将看到一个特殊的寄存器,该寄存器用于告诉处理器应该从何处读取下一条指令,以及不同大小和内存分组如何产生截然不同的结果。
# 设置英特尔风格汇编体验™
如上一章所述,显示汇编有两种主要方法。 一种类型是 AT&T 程序集,这个也是 LLDB 的默认程序集。 这种格式具有以下格式:
opcode(操作码) source(源) destination(目的地)
看一个具体的例子
movq $0x78, %rax
这个操作会将十六进制值 0x78
移动到 RAX
寄存器中。 尽管这种汇编形式对某些人来说很不错,但从现在开始,您将使用英特尔的表示形式。
注意:汇编的选择有点像一场大战,有的人说用 Inter 的格式,有的说是用 AT&T 的格式,在 StackOverflow 中查看以下讨论
选择使用英特尔是因为大家都觉得英特尔在阅读方面更好,但有时在写作方面更差。 由于您正在学习调试,因此大部分时间是在阅读汇编而不是编写汇编。
把以下的两行内容添加进 ~/.lldbinit file
文件底部
- settings set target.x86-disassembly-flavor intel
- settings set target.skip-prologue false
第一行告诉 LLDB 以 Intel 风格显示 x86 汇编(32 位和 64 位)。
第二行告诉 LLDB 不要跳过函数序言。 您在本书的前面已经了解了这一点,从现在开始,请不要跳过序言,因为您将直接从函数的第一条指令检查汇编。
<font color=#0099ff size=3 face="黑体"> 关于函数序言和函数尾声可以再参考这篇文章 </font>
<font color=#DC143C size=3 face="黑体"> 注意:在编辑〜/.lldbinit 文件时,请确保您不使用 TextEdit 之类的程序,因为它将在文件中添加不必要的字符,这可能导致 LLDB 无法正确解析该文件。 一种简单(尽管很危险)的添加方法是通过如下的 Terminal 命令:echo“ settings set target.x86-disassembly-flavor intel” >>〜/.lldbinit。
确保其中有两个 “>>”,否则将覆盖〜/.lldbinit 文件中的所有先前内容。 如果您对终端机不满意,最好的选择是 nano(您之前使用过的编辑器)。</font>
英特尔风格将交换源值和目标值,删除 '%' 和 '$' 字符以及进行许多其他许多更改。 由于您还不会使用 AT&T 语法,因此这里就不解释全部的差异,我们只单单学习 Inter 程序集就够了。
看下面的示例,就是以 Inter 风格来展示,他看起来比较干净,比较好阅读。
mov rax, 0x78
同样,这会将十六进制值 0x78 移到 RAX 寄存器中。
与前面显示的 AT&T 风格相比,Intel 风格交换了源操作数和目标操作。 现在,目标操作数在源操作数之前。 在进行汇编时,务必始终确定正确的风格,这很重要,因为如果您不清楚要使用的风格,则可能会采取不同的操作。
从现在开始,我们就开始使用 Inter 的汇编格式了。 如果您看到以 $ 字符开头的数字十六进制常量或以%开头的寄存器,你就要把他们转换成 Inter 的形式
注:笔者反而觉得 AT&T 的风格更适合中国汉语的语法,只是前面的 % 和 $ 有些奇怪罢了
# 创建 cpx 命令
首先,您将创建自己的 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(从 0 开始到 255 的 256 个值)来确定。
许多信息以字节表示。例如,C 语言中 sizeof()函数以字节为单位返回对象的大小。
如果您熟悉 ASCII 字符编码,您会想起所有 ASCII 字符都可以保存在一个字节中。
现在是时候看看实际操作中的术语并学习一些技巧。
打开 Registers macOS 应用程序,您将在本章的资源文件夹中找到该应用程序。接下来,构建并运行该应用程序。一旦运行,请暂停程序并启动 LLDB 控制台。这将导致使用非 Swift 调试上下文,因为默认情况下暂停应用程序会带来非 Swift 上下文。
在 LLDB 中键入以下内容:
- p sizeof('A')
这将打印出组成 'A' 字符所需的字节数
(unsigned long) $0 = 1
然后输入如下命令
- p/t 'A'
你会得到
(char) $1 = 0b01000001
这是 ASCII 中字符 A 的二进制表示。
显示信息字节的另一种更常见的方法是使用十六进制值。 需要两个十六进制数字以十六进制表示一个信息字节。
然后我们输入以下命令来打印出 “A” 的十六进制表示形式:
- p/x 'A'
你将会得到
(char) $2 = 0x41
十六进制非常适合查看内存,因为一个十六进制数字恰好代表 4 位。 因此,如果您有 2 个十六进制数字,则您有 1 个字节。 如果您有 8 个十六进制数字,则您有 4 个字节。 等等。
这里还有一些适用于您的术语,这些术语在以后的章节中会很有用:
- Nybble:4 位,十六进制单个值
- Half word:16 位或 2 个字节
- Word:32 位或 4 个字节
- Double word or Giant word:64 位或 8 字节。
使用此术语,您将可以探索不同的内存块。
# RIP 寄存器
当程序执行时,将要执行的代码加载到内存中。 程序中接下来要执行的代码的位置由一个非常重要的寄存器决定:RIP 或指令指针寄存器。
让我们来看看实际情况。 再次打开 “register demo” 应用程序,然后导航到 AppDelegate.swift 文件。 修改文件,使其包含以下代码:
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillBecomeActive(
_ notification: Notification) {
print("\(#function)")
self.aBadMethod()
}
func aBadMethod() {
print("\(#function)")
}
func aGoodMethod() {
print("\(#function)")
}
}
构建并运行该应用程序。 毫无疑问,控制台将会输出 该方法名 applicationWillBecomeActive(_ :)
,然后输出 aBadMethod
。我们在在 aBadMethod 的开始处创建一个断点:
再次构建并运行。 在 aBadMethod 的开头命中断点后,导航至 Debug \ Debug Workflow \ Always Show Disassembly in Xcode。 现在,您将看到程序的实际汇编!
然后我们在控制台输入以下命令:
cpx $rip
这将使用您先前创建的 cpx 命令打印出指令指针寄存器。
您会注意到输出 LLDB 的地址与 Xcode 中绿线突出显示的地址是一样的:
(unsigned long) $1 = 0x0000000100008910
当然了每个人的电脑上显示的地址是不同的,而且每次执行时候的地址都可能不一样,但是在单次运行中绿线和控制台上显示的肯定是完全一样的。你试着在控制台下打以下命令:
(lldb) image lookup -vrn ^Registers.*aGoodMethod
这是一个很常用的查找命令,其中包含典型的正则表达式参数以及添加的参数 -v
,该参数输出详细信息就像 --verbose
。
您会看到很多的东西。 在控制台中使用 command + F
来搜索关键字 range = [
其中范围中的第一个值便是
该地址称为加载地址。 这是此函数在内存中的实际物理地址!这与您在 image lookup
命令中看到的常规输出不同,它仅显示函数相对于可执行文件的偏移量,也称为实现偏移量。 寻找函数地址时,区分加载地址和可执行文件中的实现偏移很重要,因为它会有所不同。
将这个新地址复制到范围括号的开头。 对于此特定示例,aGoodMethod 的加载地址位于 0x0000000100008910。 现在将 aGoodMethod 的地址指向 RIP 寄存器。输入以下指令
- register write rip 0x0000000100008910
单击继续,使用 Xcode 调试按钮单击 “继续”。 请一定用 Xcode 的按钮来继续,不要是用 continue
命令,因为在修改 RIP 寄存器并在控制台中继续操作时就会崩溃。按下 Xcode 继续按钮后,您会看到神奇的事情 -> 未执行 aBadMethod(),而是执行了 aGoodMethod()。 通过查看控制台日志中的输出来验证这一点。
注意:修改 RIP 寄存器实际上非常危险。 您需要确 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 项目中的所有断点。 生成并运行项目。 现在,让程序暂停。输入以下内容:
- register write rdx 0x0123456789ABCDEF
这会将值写入 RDX 寄存器。让我们停一分钟。 <font color=red size=3> 提示:您应该注意,写入寄存器可能会导致程序崩溃,尤其是当您希望写入的寄存器具有某种类型的数据时 </font>。 但是你现在是在 demo 上做调试,所以请不要担心您的程序是否崩溃!
确认此值已成功写入 RDX 寄存器:
- p/x $rdx
输出
(unsigned long) $0 = 0x0123456789abcdef
然后输入
- p/x $dx
这将打印出 DX 寄存器,该寄存器是 EDX 寄存器的最低有效部分。 因此,这是一个半字。 您应该看到以下内容:
0xcdef
再输入
- p/x $dl
这将打印出 DL 寄存器,它是 DX 寄存器的最低有效位 - 这次是一个字节。 您应该看到以下内容
0xef
最后输出如下指令
- p/x $dh
这为您提供了 DX 寄存器的最高有效部分,即 DL 给出的另一半。 DL 中的 L 代表 “低” 而 DH 中的 H 代表 “高” 也就不足为奇了。
探索汇编时,请注意不同尺寸的寄存器。 寄存器的大小可以为其中包含的值提供线索。 例如,您可以轻松地查找通过 AL 寄存器返回布尔值的函数,因为布尔值将使用 8 个字节,<font color=red size=3> 而 AL 是 64 位 “返回值寄存器” RAX 的 8 位部分 </font>
# 寄存器 R8 到 R15
由于 R8 至 R15 系列寄存器仅针对 64 位架构而创建,因此它们使用完全不同的格式表示较小的寄存器。
现在,您将看下 R9 的不同大小如何选择。生成并运行 Registers 应用程序,然后暂停调试器。像以前一样,将相同的十六进制值写入 R9 寄存器:
- register write $r9 0x0123456789abcdef
输入以下内容,以确认您已设置 R9 寄存器成功:
- p/x $ r9
输入以下内容,这将打印 R9 寄存器的低 32 位。请注意,它与您为 RDX 指定低 32 位(即 EDX)的方式有何不同。
- p/x $ r9d
然后输入以下内容,这次您获得 R9 的低 16 位。同样,这与您为 RDX 进行此操作的方式不同
- p/x $ r9w
再输入以下内容,打印出 R9 的低 8 位
- p/x $ r9l
尽管这看起来有些乏味,但是您正在建立阅读汇编语言的技巧。
# 内存中断
现在,您已经了解了指令指针,是时候进一步探索其背后的内存了。顾名思义,指令指针实际上是一个指针。它不执行 RIP 寄存器中存储的指令,而是执行 RIP 寄存器中指向的指令。
在 LLDB 中看到这一点也许会更好地描述它。返回 Registers 应用程序中,打开 AppDelegate.swift 并再次在 aBadMethod 上设置一个断点。生成并运行该应用程序。
命中断点并停止程序后,导航回到汇编视图。如果您忘记了该操作,但尚未为其创建键盘快捷键,则可以在 Debug \ Debug Workflow \ Always Show Disassembly 下找到它。
您会看到一堆汇编指令。看一下 RIP 寄存器的位置,该位置应指向函数的最开始。
对于该项目,aBadMethod 的起始地址始于 0x100008910。和往常一样,您的地址可能会有所不同。
在 LLDB 控制台中,键入以下内容:
- cpx $rip
到现在为止,这将打印出指令指针寄存器的内容。如预期的那样,您将获得 aBadMethod 起始地址。 但是同样,RIP 寄存器指向内存中的值。 它指的是什么? 嗯,您可以摆脱疯狂的 C 编码技巧(您还记得吗?)并取消引用指针,但是使用 LLDB 可以找到一种更为优雅的方法。
输入以下内容,将地址替换为您的 aBadMethod 函数的地址:
- memory read -fi -c1 0x100008910
哇,该命令到底能做什么? memory read
采用一个值,并读取您提供的内存地址所指向的内容。 -f
命令是一个格式参数。 在这种情况下,它是汇编指令格式。 最后,您说的是只希望使用 count 或 - c 参数打印一条汇编指令。
您将获得类似于以下内容的输出:
-> 0x1000017c0: 55 pushq %rbp
这是一些很好的输出。 它告诉您十六进制(0x55)中提供的汇编指令以及操作码,这些指令是 pushq %rbp
操作
注意:等等,您看到 '%' 吗?! LLDB 中存在一个错误,当您以指令格式打印代码时,该错误不符合您的汇编风格。 请记住,如果您看到这种情况,则源和目标操作数将被反转!这就是 inter 指令集和 AT&T 指令集的区别。
让我们再看一下输出中的 “55”。 这是整个指令(即整个 pushq %rbp)的编码。 不相信我吗? 您可以验证它。 在 LLDB 中输入以下内容:
- expression -f i -l objc -- 0x55
这实际上要求 LLDB 将 0x55 解释为 x64 操作码。 您将获得以下输出:
$1 = 55 pushq%rbp
该命令有点长,但这是因为如果您在 Swift 调试上下文中,则需要切换到 Objective-C 上下文。 但是,如果移至 Objective-C 调试上下文,则可以使用简短得多的便捷表达式。
尝试单击 Xcode 左侧面板中的其他框架,以进入一个不包含 Swift 或 Objective-C / Swift 桥接代码的 Objective-C 上下文。 单击 Objective-C 函数中的任何框架。
下一步,在 LLDB 控制台中键入以下内容:
- p/i 0x55
好多了,对吧?
现在,回到手中的应用程序。 在 LLDB 中键入以下内容,再次用 aBadMethod 函数地址替换地址:
- memory read -fi -c10 0x1000017c0
你讲获得以下输出:
-> 0x100008910: 55 pushq %rbp
0x100008911: 48 89 e5 movq %rsp, %rbp
0x100008914: 48 81 ec c0 00 00 00 subq $0xc0, %rsp
0x10000891b: 4c 89 6d f8 movq %r13, -0x8(%rbp)
0x10000891f: b8 01 00 00 00 movl $0x1, %eax
0x100008924: 89 c7 movl %eax, %edi
0x100008926: e8 d5 05 00 00 callq 0x100008f00 ; symbol stub for: generic specialization <preserving fragile attribute, Any> of Swift._allocateUninitializedArray<A>(Builtin.Word) -> (Swift.Array<A>, Builtin.RawPointer)
0x10000892b: 48 89 c7 movq %rax, %rdi
0x10000892e: 48 89 45 a8 movq %rax, -0x58(%rbp)
0x100008932: 48 89 55 a0 movq %rdx, -0x60(%rbp)
这里需要注意一些有趣的事情:汇编指令的长度可以变化。 看一下第一条指令,然后看输出中的其余指令。 第一条指令的长度为 1 个字节,用 0x55 表示。 以下指令的长度为三个字节。
确保您仍在 Objective-C 上下文中,并尝试打印出负责此指令的操作码。 它只有三个字节,所以您只需将它们连接在一起
- p/i 0x4889e5
您将获得与 mov %rsp,%rbp
指令完全无关的另一条指令! 您会看到以下内容:
e5 89 inl $0x89, %eax
是怎么做到的呢? 也许现在是谈论字节序的好时机。
# 字节序... 这东西倒过来了
x64 以及 ARM 系列体系结构设备均使用低位字节序,这意味着数据以最低有效字节在先的形式存储在内存中。 如果要将数字 0xabcd 存储在内存中,则会先存储 0xcd 字节,然后再存储 0xab 字节。
回到指令示例,这意味着指令 0x4889e5 将以 0xe5、0x89、0x48 的形式存储在存储器中。
返回到您先前遇到的 mov 指令,请尝试反转用于构成汇编指令的字节。 在 LLDB 中输入以下内容:
- p/i 0xe58948
您现在将获得预期的汇编表示:
$2 = 48 89 e5 movq %rsp, %rbp
让我们再看看一些小端实践的例子。 在 LLDB 中输入以下内容:
- memory read -s1 -c20 -fx 0x100008910
此命令读取地址为 0x100008910 的内存。 -s1 选项可读取 1 个字节的大小块,-c20 选项可读取 20 个字节的大小块。 您会看到类似这样的内容:
0x100008910: 0x55 0x48 0x89 0xe5 0x48 0x81 0xec 0xc0
0x100008918: 0x00 0x00 0x00 0x4c 0x89 0x6d 0xf8 0xb8
0x100008920: 0x01 0x00 0x00 0x00
现在,将大小增加一倍,数量减少一半,就像这样:
- memory read -s2 -c10 -fx 0x100008910
你会看到这样的输出
0x100008910: 0x4855 0xe589 0x8148 0xc0ec 0x0000 0x4c00 0x6d89 0xb8f8
0x100008920: 0x0001 0x0000
请注意,当将内存值分组在一起时,由于使用了低位字节序,它们将被颠倒。
现在,将大小再增加一倍,数量再减少一半:
- memory read -s4 -c5 -fx 0x100008910
然后你会看到如下输出
0x100008910: 0xe5894855 0xc0ec8148 0x4c000000 0xb8f86d89
0x100008920: 0x00000001
与之前的输出相比,这些值再次被颠倒。
记住这一点非常重要,但在探索你自己的记忆时,这会让你变得很混乱。不仅内存的大小会给您一个潜在的错误答案,而且顺序也会给您一个潜在的错误答案。当你开始对着电脑大喊大叫时,当你试图弄清楚某样东西应该如何工作时,请记住这一点!