如何阅读ARM64汇编语言
此文为加密收费内容添加我微信支付后可看:
ARM64是一种计算机架构,与流行的英特尔x86-64架构竞争,用于台式机、笔记本电脑等中的CPU。ARM64在手机1以及基于Graviton的亚马逊EC2实例、树莓派3和4以及备受吹嘘的苹果M1芯片中很常见,所以了解它可能会有用!事实上,由于iPhone,我几乎可以肯定花在ARM64上的时间比x86-64多。
本帖是我之前关于如何阅读汇编语言的帖子的替代版本。它浏览了相同的示例,而是显示了ARM64组件。为了方便您阅读,说明和寄存器的解释等背景内容也会被重审。
说明
汇编语言的基本单元是指令。每个机器指令都是一个小操作,例如添加两个数字,从内存加载一些数据,跳转到另一个内存位置(如可怕的goto语句),或从函数调用或返回。与x86-64不同,每个ARM64指令正好有4字节长,因此您只需计算指令即可判断一段ARM64代码占用了多少内存。
示例1:矢量规范
我们的第一个玩具示例将让我们熟悉简单的说明。它只是计算2D矢量的正方形:
|
|
以下是从第11条带产生的ARM64组件:
|
|
第一条指令,mul x8, x1, x1
,执行乘法。与我们之前使用的x86-64程序集语法不同,目标操作数在左侧。本mul
指令将x1
的内容平方,并将结果存储到x8
。
接下来,我们有madd x0, x0, x0, x8
。madd
代表“乘法添加”:它平方x0
,添加x8
,并将结果存储在x0
中。
最后,ret
从normSquared
返回。
登记册
让我们绕个简短的绕道,解释一下我们在示例中看到的寄存器是什么。寄存器是装配语言的“变量”。与您最喜欢的编程语言中的变量(可能)不同,它们数量有限,它们具有标准化的名称,我们将谈论的变量最多64位大小。ARM64有31个名为x0
到x30
的通用寄存器。要引用它们的下32位,而不是完整的64位,我们可以写w0
到w30
。还有一个专用的sp
(堆栈指针)寄存器。核心注册名称的完整文档可在ARM网站上找到。
示例2:堆栈
现在,让我们扩展我们的示例,在normSquared
中调试打印Vec2
:
|
|
同样,让我们看看生成的程序集:
|
|
我们从一个新的寄存器开始:sp
。与x86-64上的%rsp
,它是“堆栈指针”,用于维护函数调用堆栈。它指向堆栈的底部,该堆栈在ARM64上“向下”(向下)增长。因此,我们的sub sp, sp, #32
指令正在通过从堆栈指针中提取,为堆栈上的四个64位整数腾出空间。接下来,stp x29, x30, [sp, #16]
正在修复一对寄存器:它正在从地址sp + 16
开始在堆栈上保存旧帧指针(x29
)和链接寄存器(x30
-它包含返回地址,我们将在下面看到)。(方括号表示内存访问。)我们使用add x29, sp, #16
计算新的帧指针;它需要指向之前保存的帧指针和堆栈指针。这结束了3个指令功能的序幕。
Then, the following stp x0, x1, [sp]
instruction stores the first and second arguments to normSquared
, which are v.x
and v.y
, to the stack, effectively creating a copy of v
in memory at the address in sp
. Next, we put a pointer to that copy of v
in x0
with mov x0, sp
and call Vec2::debugPrint() const
with bl
. bl
is a mnemonic for “branch with link”, and it works slightly differently from the x86-64 call
instruction: rather than pushing the return address onto the stack, it saves it in register x30
, also known as the link register or lr
.
After debugPrint
has returned, we LoaD the Pair of registers r8
and r9
with v.x
and v.y
from the stack. We also restore the old values of the frame pointer and stack pointer. Then, we have the same mul
and madd
instructions as in the previous example. Finally , we add sp, sp, #32
to clean up the 32 bytes of stack space we allocated at the start of our function (called the function epilogue; I would include the load of the old frame pointer and stack pointer even though it happened to come before the mul
& madd
) and then return to our caller with ret
.
示例3:控制流程
现在,让我们看看另一个例子。假设我们想打印一个大写C字符串,并且我们希望避免为小字符串进行堆分配。2我们可能会写以下内容:
|
|
这是[生成的程序集](https://godbolt.org/#g:!((g:!((g:!((h:codeEditor,i:(fontScale:14,j:1,lang:c%2B%2B,selection:(endColumn:74,endLineNumber:16,positionColumn:74,positionLineNumber:16,selectionStartColumn:74,selectionStartLineNumber:16,startColumn:74,startLineNumber:16),source:'%23include+ %23include+ %23include+ void+copyUppercase(char+*dest,+const+char+*src)%3B constexpr+size_t+MAX_STACK_ARRAY_SIZE+%3D+1024%3B void+printUpperCase(const+char+*s)+{ ++++auto+sSize+%3D+strlen(s)%3B ++++if+(sSize+<%3D+MAX_STACK_ARRAY_SIZE)+{ ++++++++char+temp[sSize+%2B+1]%3B ++++++++copyUppercase(temp,+s)%3B ++++++++puts(temp)%3B ++++}+else+{ ++++++++//+std::make_unique_for_overwrite+is+missing+on+Compiler+Explorer. ++++++++std::unique_ptr+temp(new+char[sSize+%2B+1])%3B ++++++++copyUppercase(temp.get(),+s)%3B ++++++++puts(temp.get())%3B ++++} }'),l:‘5’,n:‘0’,o:‘C%2B%2B+source+%231’,t:‘0’)),k:43.31039755351682,l:‘4’,n:‘0’,o:'',s:0,t:‘0’),(g:!((h:compiler,i:(compiler:armv8-clang1101,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘1’,libraryCode:‘1’,trim:‘1’),fontScale:14,j:1,lang:c%2B%2B,libs:!(),options:'-O3+-std%3Dc%2B%2B20+-fno-vectorize+-fno-unroll-loops+-fno-exceptions',selection:(endColumn:23,endLineNumber:15,positionColumn:23,positionLineNumber:15,selectionStartColumn:23,selectionStartLineNumber:15,startColumn:23,startLineNumber:15),source:1),l:‘5’,n:‘0’,o:‘armv8-a+clang+11.0.1+(Editor+%231,+Compiler+%231)+C%2B%2B’,t:‘0’)),k:33.25435790785323,l:‘4’,n:‘0’,o:'',s:0,t:‘0’),(g:!((h:compiler,i:(compiler:clang1101,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘1’,libraryCode:‘0’,trim:‘1’),fontScale:14,j:2,lang:c%2B%2B,libs:!(),options:'-O3+-std%3Dc%2B%2B20+-fno-vectorize+-fno-unroll-loops+-fno-exceptions',selection:(endColumn:1,endLineNumber:1,positionColumn:1,positionLineNumber:1,selectionStartColumn:1,selectionStartLineNumber:1,startColumn:1,startLineNumber:1),source:1),l:‘5’,n:‘0’,o:‘x86-64+clang+11.0.1+(Editor+%231,+Compiler+%232)+C%2B%2B’,t:‘0’)),k:23.435244538629952,l:‘4’,n:‘0’,o:'',s:0,t:‘0’)),l:‘2’,n:‘0’,o:'',t:‘0’)),version:4):3
|
|
我们的函数序言要长得多,我们还有一些新的控制流程指令。让我们仔细看看序言:
|
|
正如我们之前看到的,我们正在将旧的帧指针和堆栈指针保存到堆栈。然而,我们正在使用更复杂的商店说明:stp x29, x30, [sp, #-48]!
做两件事。首先,它将x29
和x30
存储到地址sp - 48
其次,它用相同的sp - 48
值更新堆栈指针(这就是感叹号的目的;它是ARM文档中描述的“索引前寻址模式”)。
接下来,我们将x21
、x20
和x19
保存到堆栈中;稍后我们将使用它们,并要求保留它们的当前值(换句话说,它们是“被调用者保存”的寄存器)。最后,我们在x29
中设置了新的帧指针。
(顺便说一句,编译器生成的注释中的“溢出”一词只是意味着我们正在将寄存器保存到堆栈中。)
打开功能主体:
|
|
We save our argument, s
(stored in x0
) in x19
and call strlen
with bl
, as we saw before. When strlen
returns, we CoMPare its result against 1024 as the first step in our if
statement. This sets the NZCV register according to the result of the comparsion, and then b.hi .LBB0_2
Branches to .LBB0_2
if it turns out that x0
was in fact more than 1024. Because both branches of our if
statement care aboutsSize + 1
and not sSize
, we add 1 to x0
(which stores sSize
) before the branch. In general, higher-level control-flow primitives like if
/else
statements and loops are implemented in assembly using conditional jump instructions.
让我们先看看x0 <= 1024
的路径,然后分支到.LBB0_2
没有被拿走。我们有一个在堆栈上创建char temp[sSize + 1]
的说明:
|
|
我们将15添加到x0
中,并将结果放在x9
中。然后,我们屏蔽了x9
的下4位。这两个操作一起将目标阵列大小四舍五入到下一个16的倍数到x9
中。然后,我们从堆栈指针中减去数组大小,将旧的堆栈指针值保存到x21
中,并设置新的堆栈指针值。
以下块只需调用copyUppercase
,并puts
代码写入:
|
|
最后,我们有功能结语:
|
|
我们使用帧指针的值恢复堆栈指针。然后,我们将之前保存的寄存器加载到堆栈中。在这里,我们看到了一种新的“后索引”添加模式:ldp x29, x30, [sp], #48
意味着从堆栈指针的当前值加载x29
和x30
,然后在添加48。最后,我们将控制权退还给来电者,我们就完成了。
接下来,让我们看看x0 > 1024
的路径,然后我们分支到.LBB0_2
在堆上分配我们的数组。这条路更直截了当。我们调用operator new[]
,将结果(以x0
返回)保存到x20
,并调用copyUppercase
并像以前一样puts
。我们为这个案例有一个单独的函数结尾,它看起来有点不同:
|
|
The forst mov
sets up x0
with a pointer to our heap-allocated array that we saved earlier. As with the other function epilogue, we then restore the stack pointer, load our saved registers, and update it by adding 48 bytes back. Finally, we have a new instruction: b operator delete[](void*)
. b
(for “branch”) is just like goto
: it transfers control to the given label or function. Unlike bl
, it does not save the return address for a future ret
. So, when operator delete[]
returns, it will instead transfer control to printUpperCase
’s caller. In essence, we’ve combined a bl
to opreator delete[]
with our own ret
. This is called tail call optimization.
进一步阅读
汇编语言可以追溯到1940年代末,因此有很多资源可以学习它。就我个人而言,我第一次介绍汇编语言是在母校密歇根大学举行的EECS 370:计算机组织入门初级课程。不幸的是,该网站上链接的大多数课程材料都不公开。以下是伯克利(CS 61C)、卡内基梅隆(15-213)、斯坦福(CS107)和麻省理工学院(6.004)的相应“计算机如何真正工作”课程。(如果我为这些学校推荐了错误的课程,请告诉我!)Nand to Tetris似乎也涵盖了类似的材料,项目和书籍章节是免费的。
我第一次实际接触ARM64组件,特别是通过iPhone开发。我从大学之前接触到组装的一般方法就知道了,所以我每次都只是谷歌搜索“ARM64 ldp指令”(或任何其他指令),并阅读它的作用。随着时间的推移,我记住了我学到的东西,不必再用谷歌了。
如果您想对ARM64汇编语言进行更技术性的演练,ARM网站上还有一个“学习架构”指南。这可能会帮助你知道架构的官方名称实际上是AArch64,但“ARM64”似乎更常见。
- 具体而言,自iPhone 5s以来,iPhone一直使用ARM64,显然绝大多数Android手机也使用ARM64。↩︎
- 还假设我们没有像absl::FixedArray这样的东西可用。我不想让这个例子进一步复杂化。↩︎
- 我用
-fno-exceptions
构建了,通过删除异常清理路径来简化示例。它出现在尾声之后,我认为这可能会令人困惑。↩︎ - 正如我们在本文的x86-64版本中看到的,我认为不需要这个
mov x21, sp
。x21
直到我们mov sp, x21
才会再次使用,但该指令之后立即伴随着mov sp, x19
,它覆盖了sp
。我认为我们可以通过删除x21
的移动来改进代码。↩︎