AArch64 学习 (一) 基础指令,内存布局,以及基础栈操作

1. 什么是 ARM?

正式开始之前,我们先来了解一下什么是 ARM, 以及对应的一些概念.

Wikipedia 上是这么介绍 ARM 的:

ARM (stylised in lowercase as arm, formerly an acronym for Advanced RISC Machines and originally Acorn RISC Machine) is a family of reduced instruction set computer (RISC) instruction set architectures for computer processors, configured for various environments.

ARM 是 高级 - RISC (精简指令集)- 机器 的缩写,是精简指令集架构的家族。同时 Arm Ltd. 也是开发和设计、授权这项技术的公司名称.

1.1. 有哪些指令集架构呢?(TRDR, 可跳过)

目前用的比较多的架构是 ARMv7 和 ARMv8, 这两个名字各自都是一个系列.

在 ARMv7 以及之前都是最多支持 32 位架构 (更早还有 16 位,甚至更低), 那么 32 位架构对应的 ISA 也就是指令集称为 A32. 32 位下指令的地址空间最大只有 4GB, 苹果系列的代表是 iPhone 4 使用的 A4 芯片,以及 iPhone 4s 使用的 A5 芯片.

2011 年面世的 ARMv8-A 架构增加了对 64 位地址空间的支持,对应的 ISA 称为 A64. 这里用的词是 “增加”, 也就意味着在支持 32 位的基础上增加了对 64 位的支持。所以也可以看出来所谓的 32/64 位指的就是可寻址的最大地址空间。苹果系列从 iPhone 5s 开始的 A7 芯片一直到 A15, 以及 Apple M1 系列开始都是基于 ARMv8.x-A 规范的.

那我们见到的 AArch64 是什么呢?其实它和 AArch32 被称为 “执行状态” (execution state), 那么我们可以说 ARMv8-A 同时支持 AArch32 和 AArch64 两种状态,在 AArch64 状态下,运行的是 A64 指令集.

这里要注意 ARMv7/ARMv8-A、AArch32/AArch64 以及 A32/A64 在概念上的的区别,但很多时候,描述的范围都挺笼统的,有些也是可以互相指代的,大家知道就好.

上面说到指令集,指令集是做什么用的呢?我们为什么要了解这些?

指令集本质上定义了 CPU 提供的 “接口”, 软件通过这些 “接口” 调用 CPU 硬件的能力来实现编程。编译器在这里起到很关键的角色,它把上层代码根据对应的架构,编译为由该架构支持的指令集对应的二进制代码,最终运行在 CPU 上.

对 C 系语言来说,我们说的跨平台,其实就是通过同一份源码在编译时,根据不同 target 架构指令集,生成不同的二进制文件来实现的.

1.2. 本系列的目的:为什么要了解 ARM 汇编指令?

对我们来说熟悉 ARM 汇编指令,我们就能知道我们平常写的代码背后的本质,以及背后的原理,从而写出更高效,更可靠的代码。主要是编译器内部对 C/C++ 概念的实现原理.

这个系列也是本着这个初衷展开,适合对 AArch64 不熟,或者熟悉 x86/64 的汇编,想了解 AArch64 的同学。而且对 C/C++ 语法或者特性背后实现感兴趣的同学.

我其实也是最近才开始捡起来,之前学习的 x86 汇编早就还给老师了。相当于一边学习一边总结吧。好处是我大概知道刚开始可能会遇到哪些问题,在此基础上,尽可能的减少阅读门槛,这不是一个手册,而是一个循序渐进,目的性很强的一个系列.

因为目前 Apple M1 芯片就是基于 ARMv8.x-A 的,我们为了方便试验,接下来都选择使用基于 ARMv8-A A64 指令集来做解释.

2. 认识 A64 指令集下的常用指令

ARM 使用的是精简指令集 (RISC, Reduced Instruction Set Computer), 相对的就是 x86/64 的复杂指令集 (CISC, Complex Instruction Set Computer).

2.1. RISC 的一些特点:

  1. 精简指令集提供的指令更简单,更基础一些,也就是说,和 x86/64 相比,同样的代码,生成的指令会多一些.
  2. 内存访问和计算是完全分离的. RISC 使用 load 读取内存数据到通用寄存器中,计算完之后通过 store 保存到内存中

2.2. ARM64 的约定:

  1. 每个指令都是 32 位宽
  2. ARM64 有 31 个通用寄存器: X0-X30, 每个都是 64 位。如下图 1, 低 32 位可以通过 W0-W30 来访问。当写入 Wy 时,Xy 的高 32 位会被置 0, 比如 ADD W0, W1, W2
  3. 提供 32 个 128 位的独立的寄存器,用于浮点数以及向量操作,如下图 2, Qx 表示 128 位,Dx 表示 64 位,以此类推.
    1. 执行 32 位浮点数计算: FADD S0, S1, S2.
    2. 也可以直接使用 Vx 的方式,此时表示的就是向量操作,如
      FADD V0.2D, V1.2D, V2.2D
  4. 其他的寄存器:
    1. ZXR/WZR 不可写,始终为 0
    2. SP, Stack Pointer, 栈指针寄存器,load 和 store 的基址,指向栈顶
    3. X29 用来表示 FP Frame Pointer, 方法调用的时候,指向栈基址,用于方法调用后恢复栈.
    4. X30 被用作 LR Link Register, 也可以通过 LR 来使用。在方法调用前,保存返回地址.
    5. PC, Program Counter 寄存器在 A64 里不是通用寄存器,数据处理中不可用。等价写法是 ADR Xd, ., 点表示当前行,ADR 取地址,相当于取当前行的地址,也就相当于 PC 寄存器的值
    6. macOS 中 X18 被禁用

(图 1)
MixingNode

(图 2)
MixingNode

3. 一些常用基础指令的用法

指令的构成通常是这样的:

Operation Destination, Op1[, Op2 ..]

  • Operation 描述指令的作用,比如 ADD 表示加,AND 进行逻辑与操作
  • Destination 总是为寄存器,存放操作的结果
  • Op1, 指令的第一个输入参数,总是为寄存器
  • Op2, 指令的第二个输入参数,可以是一个寄存器,或者是常量值

不一定所有的制定规则都是这样的,为了减少理解的成本,我们先介绍几个简单却又必须的指令,其他的指令会在后面用到时再做介绍.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// X1 存储了一个地址, 把 X1 寄存器里的地址对应的值, load 到 X0 寄存器中. 相当于 X0 = *X1
ldr X0, [X1]

// X0 = X0 + 1
ADD X0, X0, #1

// 再把 X0 寄存器的值, 保存到 X1 地址对应的内存中, 相当于 *X1 = X0
str X0, [X1]

// 访问内存可以加一个 offset, 相当于把 X0 保存到 新地址 = (地址 X1 + 4) 对应的内存中. lrd 也同理.
str X0, [X1, #4]

// ldp(load pair registers) 和 ldr 类似, 一次 load 两个
ldp X0, X1, [sp, #num]

// 同理, stp(store pair registers) 保存两个 register 到内存
stp X0, X1, [sp #num]

// 用 mov 移动一个寄存器或者立即数到目的寄存器中
mov X0, X1
mov X0, #0x01

通过 label 在 code segment 里定义 local data:
msg: ascii "Hello" // 定义字符串
number: word 0x12345678 // 定义一个 4 字节的数据. byte, word(4bytes), quad(8bytes)

// ADR 取地址符, 把 Hello 字符串的地址放入 X1 寄存器:
adr X1, msg

// 算数运算, 加减乘除: add, sub, mul, sdiv/udiv (signed/unsigned div):
add x0, x1, x2

// 逻辑运算, lsl/lsr logical shift left/right.
lsl X0, #16 // 把 X0 左移 16 bits
lsr X0, #16

// 控制流, 通过 b 指令跳转

// 直接跳转到 .LBB0_6
b .LBB0_6

// less or equal
b.le .LBB0_2

// greater or equal
b.ge .LBB0_4

// not equal
b.ne .LBB0_4

//TODO(xueshi)

4. 进程内存布局

熟悉程序加载到内存之后的布局,对编写 / 阅读汇编代码至关重要,这里我们熟悉一下经典的内存布局,主要目的是方面理解后面的汇编代码。这里不展开西说,更详细的大家可以自行查询资料.

下面讨论的地址都是虚拟地址,虚拟地址最终会被操作系统映射到真实的物理地址中。所以我们也可以知道在 32 bit 指令集下,虽然寻址空间最大 4GB, 因为用了虚拟内存,实际上每个执行的进程都有 4GB 的寻址空间 (一般是 1G 内核空间,3G 用户空间), 并不是共享的.

当一个可执行程序被 load 到一个进程空间之后,内存布局如下。按段 (Segment) 来划分的,逐个来介绍.

  1. 最下面的是代码段,保存着二进制的代码,主要是各种函数,拥有只读和执行的权限。这个段的代码可以被执行,但是不可写入.
  2. 数据段,主要保存常量值或全局静态值,拥有只读权限,也是不可写入的.
  3. 堆,堆空间主要是用来动态分配内存的,我们用的 malloc, new 等申请的内存空间都会在这个区域,权限会读写。分配的虚拟内存地址由小增大,所以是向上增长的.
  4. 栈空间,栈空间主要是保存临时变量以及方法调用的参数。栈空间分配的方向是从大到小的,和 Heap 分配的方向是相对的。这么设计一方面是可以和 Heap 共用中间的待分配内存,另外一个原因是,每个方法里的临时变量所占用的内存在编译期其实就已经确定了,执行方法开始时一次性的分配所需的栈空间,执行结束一次性释放掉。其实堆空间和栈空间并没有物理上的差别,只是逻辑上定义如此.
  5. 内核空间,内核空间和栈空间一般还会有间隔,这里没画出来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|--------------|
| Kernal Space |
|--------------| 高地址
| | 栈地址 从高到低 向⬇增长
| Stack |
| |
|--------------|
| |
| 待分配内存 |
| |
|--------------|
| | 堆地址 从低到高 向⬆增长
| Heap |
| |
|--------------|
| Data Segment |
|--------------|
| Code Segment |
|--------------| 低地址

5. 栈操作

栈操作是看懂汇编代码必备的,因为每个函数几乎都要开辟自己的一片栈空间,我们也称为 stack frame, 也就是我们常见到的 “栈帧”, 随着函数调用创建,函数结束调用释放销毁.

Stack frame 主要有两个基础用途,一个是存储临时变量,再者是函数调用和传参。后者会在后面的文章的讲述,这里我们主要看一下在没有函数调用的情况下栈空间的使用.

随便实现一个 test 函数,在 main 函数里调用它:

1
2
3
4
5
6
7
8
9
10
11
long test() {
long x = 5;
long y = 3;
long z = 4;
return x + y;
}

int main() {
test();
return 0;
}

如图 3, 在 GodBolt 里使用 armv8-a clang 11.0.1 编译器 生成汇编代码 (这里省略 main 函数):

(图 3)
MixingNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
test():  // @test()
// 栈空间是从高地址往低地址分配空间的, 我们看到有 x y z 三个本地临时变量
// 共 3*long = 24bytes, 也就是需要 24 字节的栈空间
// 但是 arm64 有个约定, 分配栈空间的大小须为 16 字节的倍数, 所以这里需申请 32bytes

// sp = stack pointer, 指向栈顶(也是栈空间里可用的最低地址)
// 我们看到这里直接 通过 sp=sp-32 来开辟了 32 字节的空间
// 而且 32 是立即数, 也就是编译器在编译期就已经确定了的.
sub sp, sp, #32 // =32

// 申请之后可用的栈空间是这样的, sp 指向了栈顶:
// | sp + 24| 8 bytes
// | sp + 16| 8 bytes
// | sp + 8 | 8 bytes
// | sp | 8 bytes

// 对应 x=5, 不能直接把 5 放到内存, 需要寄存器中转一下, 先把 5 放入 x8 寄存器
mov x8, #5 // 立即数以#开头, 这里把5放到x8寄存器中
// sp 既然是指针, 也就是地址, 所以支持
// 1. 地址支持加减运算, 2: 存取(store/load) 数据都需要使用 [] 来找到地址所对应的值
// 然后接上面, 把 x8 也就是 5, 放入了 sp + 24 对应的地址里
str x8, [sp, #24]

mov x8, #3 // 同上, 操作y
str x8, [sp, #16]

mov x8, #4 // 同上, 操作z
str x8, [sp, #8]

操作完之后, 栈空间是这样的:
// | sp + 24| 就是 x, 值为 5
// | sp + 16| 就是 y, 值为 3
// | sp + 8 | 就是 z, 值为 4
// | sp | 未使用

// 可见这里入栈顺序和临时变量定义的顺序是一致的

// 操作 x + y
ldr x8, [sp, #24] //把 x 读取到x8
ldr x9, [sp, #16] //把 y 读取到x9

// 现在 x0 = x8+x9, 保存着相加的结果值 8
add x0, x8, x9

// 释放分配的栈空间, 其实就是把 sp + 32, 相当于 sp 指针向上移动了 32 个字节
// 那我们知道栈空间分配的方向是从高地址到低地址, 释放就是相反的方向也容易理解了.
add sp, sp, #32 // =32

// 默认返回 x0, 后文会介绍
ret

main: // @main
...省略

我们总结一下,其实也很简单,记住下面几个就够了:

  1. 每个函数内的栈空间大小,在编译期就已经确定
  2. 通过 sub sp, #size, 就是减小 sp 地址的方式分配栈内存,分配 size 字节.
    ps: AArch64 要求每次分配的栈空间 size 必须是 16 bytes 的倍数
  3. 通过 add sp, #size, 就是增加 sp 地址的方式释放栈内存,释放的和开始分配的要一致
  4. 通过 str x寄存器, [sp, #offset] 的方式 保存 数据到 栈空间
  5. 通过 ldr x寄存器, [sp, #offset] 的方式 加载栈空间 数据到 寄存器

6. REFs

  1. ARM architecture family
  2. iOS Support Matrix
  3. Shellcode for macOS on M1 chips - Part 1: Quick overview of ARM64 assembly language
  4. ARM 官方的文档 (没有链接)