MacOS Arm64 汇编 part 3 - MacOS Arm64 Assembly part 3

  • manipulate memory
  • function & stack

Defining Memory Contents

Basics

我们在 .data 段中编写内存内容,主要有以下表格描述的内容类型描述符,同时可以使用 Negative(-) 取相反数,以及 Complement(~) 取反两个整型数前缀

a sample
1
2
3
4
5
label: .byte 74, 0112, 0b00101010, 0x4A, 0X4a,
'J', 'H' + 2
.word 0x1234ABCD, -1434
.quad 0x123456789ABCDEF0
.ascii "Hello World\n"

其中 label 是这一段内存的标识符,后面的存储内容是连续的,通过逗号分隔同类型的数据。通过内容类型描述符指示不同类型的数据

Directive Description
.ascii 用双引号括起来的字符串(不自动添加结束符)
.asciz 以 0 字节结尾的 ASCII 字符串(自动在末尾添加 \0 终止符)
.byte 1 字节整数(8 位有符号/无符号整数)
.double 双精度浮点值(64 位 IEEE 754 浮点数)
.float 单精度浮点值(32 位 IEEE 754 浮点数)
.octa 16 字节整数(128 位整数)
.quad 8 字节整数(64 位有符号/无符号整数)
.short 2 字节整数(16 位有符号/无符号整数)
.word 4 字节整数(32 位有符号/无符号整数)

Fill

当需要一块连续且具有一定规律的内存片段时,可以采用 fill

1
lable: .fill repeat, size, value

这将重复填充大小为 size(Byte) 值为 value 的块 repeat 次

Repeat

通过这一语法可以重复其之间的语句指定次数,即一个复杂的重复模式

1
2
3
label:  .rept count
...
.endr

这将填充 count 次

Aligning Data

为了内存访问效率,因为 CPU 是按块读取内存的,而为了简化设计,块号都是呈 2 的倍数的,因此当我们在创建连续的内存片段时,需要进行对齐,这与 C/C++ 当中的结构体对齐逻辑一致

1
.align 4

上面的代码即做了按 4 字节对齐

Loading a Register with an Address

我们可以通过 ldr 指令加载一个 64 bits 值到指定寄存器(PC 相对寻址)

包括下面的说法,这一节中的 ldr 的实际指令是基于 PC 相对寻址模式,与手写的理解有所不同,在下一节我们会介绍其的非相对寻址模式

1
ldr xd, =imm64

这是一条伪指令,实质为

1
2
3
ldr xd, #offset
...
.quad imm64

offset 是相对于该 ldr 指令到这一立即数存储位置的偏移量

通过这一方法可以加载 PC 附近约 1MB 范围的内存,但加载的值可以超出指令长度,这既是这一指令的意义,对于更远的数据,以及带标签数据,在 OSX 中我们实际上使用如下方法在 xd 中加载其地址

1
2
adrp xd, label@PAGE
add xd, xd, label@PAGEOFF

这里是采用分页的相对寻址(仍基于 PC),第一条 adrp 首先加载页基址,随后 add 加载偏移量,之所以分两条还是因为指令长度有限

Loading Data from Memory

在取得地址后,我们读取一个指定地址的数据我们仍采用 ldr 指令

1
ldr{type} xd, [xs{, #offset}]

type 见下表

Type Meaning
B 无符号字节(Unsigned byte)
SB 有符号字节(Signed byte)
H 无符号半字(16 位,Unsigned halfword (16 bits))
SH 有符号半字(16 位,Signed halfword (16 bits))
SW 有符号字(Signed word)

均为自低位起,#offset 可选
[] 表示了这条指令的间接寻址模式,可以理解为 *xs
#offset 是自 xs 所存地址起的偏移量,可以理解为 *(xs + offset)

Indexing Through Memory

在高级语言中,和采用偏移量/索引的方式访问数组元素一样,在汇编中我们也通过这样的方式访问内存

我们直接通过下面的 demo 说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.global _start

_start:
adrp x1, arr@PAGE
add x1, x1, arr@PAGEOFF

mov x10, x1
ldrb w2, [x10]
ldrb w3, [x10, #1]!
ldrb w4, [x10, #1]!
ldrb w5, [x1, #2]

exit:
mov x0, #0
mov x16, #1
svc #0x80

.data
arr: .byte 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9

我们首先通过分页地址及其偏移量获取 arr 的首字节地址存到 x1 寄存器,并拷贝一份到 x10,后面的 3 条 ldrb 指令依次读取前 3 个字节,最终在 w4 和 w5 中都存储第 3 个字节(0x3)

其中前面两条的后缀 ! 是更新地址寄存器,这是一种先索引寻址( pre-indexed addressing),在读取值的时候,实际读取的是 addr+off,随后将偏移量更新到地址寄存器

  • pre-indexed addressing: The address is calculated and then the data is retrieved using the calculated address.
  • post-indexed addressing: The data is retrieved first using the base register; then any offset adding is done.

对于后索引寻址,也有其 ldr 指令的变体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.global _start

_start:
adrp x1, arr@PAGE
add x1, x1, arr@PAGEOFF

mov x10, x1
ldrb w2, [x10], #1
ldrb w3, [x10], #1
ldrb w4, [x10], #1

exit:
mov x0, #0
mov x16, #1
svc #0x80

.data
arr: .byte 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9

通过在 ldr 末尾添加 #offset 的方式即可实现先间接寻址,后更新地址寄存器

Storing a Register

str 指令是 ldr 的镜像,做完全相反的工作

1
str xs, [xd{, #offset}]

其将 xs 经过间接寻址存入 xd 指向的内存,其也支持 ! 以及后索引寻址

Double Registers

存在双字长版本的 ldr 和 str,分别是 ldp 和 stp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.global _start

_start:
adrp x1, d@PAGE
add x1, x1, d@PAGEOFF

ldp x2, x3, [x1]
stp x2, x3, [x1]

exit:
mov x0, #0
mov x16, #1
svc #0x80

.data
d: .octa 0x1234567887654321abcdefabcdefabcd

以上面这个程序为例,ldp 从 x1 所指向的内存,依次读取 8 byte 先后存到 x2 和 x3,从 lldb 中观察到 x2 = 0xabcdefabcdefabcd,x3 = 0x1234567887654321,这是因为这个数字以小端序存储

stp 则依次将 x2, x3 存到 x1 所指向的内存

这两个指令通常会在处理栈时用到

Stack

sp 强制要求 16 bytes 对齐,但是 OSX 上似乎是不限制,做了兼容,位了 16 byte 对齐可以采用 stp / ldp 填充一个 xzr / wzr 实现占位填充

以下面这个程序为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.global _start

exit:
mov x0, #0
mov x16, #1
svc #0x80
ret

_start:
ldr x1, =0x10086
ldr x5, =0x114514
stp x1, xzr, [sp, #-16]!
str x5, [sp, #-16]!
ldr x6, [sp], #16
ldp x2, xzr, [sp], #16
bl exit

虽然 LIFO,但是这个 stack 更像一个索引为负的数组,可以随机访问

在我们调用函数时,很自然会有 call 和 return 两个情景,我们希望 call 后执行函数段,而执行完毕后返回到调用处的下一语句继续执行,对于长归的 branch 操作,我们需要手动调节跳回(return)的位置,而对于可能有多次的变地址函数调用而言,我们需要一种更为方便的办法

依托 lr 寄存器以及 bl / ret 指令,在分支跳转时将后一语句的地址存入 lr,在 ret 时跳转回 lr 所在地址,而对于嵌套函数调用,采用栈来保存历史信息

上一节的程序中已经用到了 bl 和 ret

Nesting Function Calls

我们容易想到,当我们嵌套调用函数时,lr 将会被覆盖,因此当我们在嵌套调用函数时需要先将当前的 lr 压栈,再第二次调用,并最终出栈写回 lr

在汇编层面,栈上的工作是程序员需要做的

我们以下面这个程序为例

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
.global _start

fn1:
mov x0, #1
adr x1, tfn1
mov x2, #4
mov x16, #4
svc #0x80
stp lr, xzr, [sp, #-16]!
bl fn2
ldp lr, xzr, [sp], #16
ret
fn2:
mov x0, #1
adr x1, tfn2
mov x2, #4
mov x16, #4
svc #0x80
stp lr, xzr, [sp, #-16]!
bl fn3
ldp lr, xzr, [sp], #16
ret

fn3:
mov x0, #1
adr x1, tfn3
mov x2, #4
mov x16, #4
svc #0x80
ret

_start:
stp lr, xzr, [sp, #-16]!
bl fn1
ldp lr, xzr, [sp], #16
mov x0, #0
mov x16, #1
svc #0x80

tfn1: .ascii "fn1\n"
tfn2: .ascii "fn2\n"
tfn3: .ascii "fn3\n"

其中 _start 中我们调用了 fn1,fn1 调用 fn2,fn2 调用 fn3,最终我们从 fn3 一路回溯到 _start,若我们在 fn1 中移除 lr 的回写,那么由于在 fn2 中回写了 fn1 的 ret 所在行的地址,ret 将会被反复执行(重复跳到 ret 所在行)构成死循环

Function Parameters and Return Values

对于这两个操作本身来说,使用的寄存器是任意的,只要我们开发的时候使用相同的寄存器对即可,但当我们需要通过汇编语言于其他高级语言,例如 C 语言相结合时,我们则需要遵守 C 的参数传递范式,因而我们采用一定的寄存器使用规范

我们采用 x0-x7 作为参数传递寄存器,若有更多的参数需要传递,我们则将其压入栈,并在函数执行时从栈中取出

我们采用 x0 和 x1 两个寄存器做为返回值寄存器,如果你需要返回更多的数据,通常采用一个统计数据总长度,另一个返回一个内存地址指针,这正是 C 中我们通过引用(指针地址)返回数据的模式

Managing the Registers

我们以一定规范来使用寄存器(尤其是在函数体及调用过程中)

  • X0–X7: 这些寄存器用于函数参数传递,函数中可以任意修改,如果函数调用发起者需要调用前的原有数据,需要调用者自行保存,函数不负责保存于恢复现场
  • X0–X18: 这些是可破坏的寄存器,函数中可以任意修改,如果函数调用发起者需要调用前的原有数据,需要调用者自行保存,函数不负责保存于恢复现场
  • X19–X30: 这些寄存器由被调用者保存,被调用函数若需使用,必须先行保存这些寄存器并在返回前恢复这些寄存器
  • SP: 栈指针可由被调用函数任意使用,但必须严格保证栈平衡
  • LR: 被调用者必须先行保存并在返回前恢复,以保证正确返回
  • Condition flags: 调用者和被调用者均不能对条件标志位的状态做出任何假设

Summary of the Function Call Algorithm

调用过程

  1. 如果需要 X0–X18 请先保存
  2. 将前 8 个参数 mov 到 X0–X7
  3. 如果需要,将额外参数压入栈
  4. 使用 bl 调用函数
  5. 检查 X0 的返回值
  6. 恢复 X0–X18

被调用的函数

  1. 如果需要使用,将 lr 与 X19–X30 压入栈中
  2. 执行函数体
  3. 将返回值 mov 到 X0
  4. 出栈 lr,如果由,出栈 X19–X30
  5. 使用 ret 指令返回到调用者

分文件版本的大写转换程序

upper.s
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
//
// Assembler program to convert a string to
// all upper case.
//
// X1 - address of output string
// X0 - address of input string
// X4 - original output string for length calc.
// W5 - current character being processed
//

.global toupper // Allow other files to call this routine
.align 4

toupper: MOV X4, X1
// The loop is until byte pointed to by X1 is non-zero
loop: LDRB W5, [X0], #1 // load character and increment pointer
// If W5 > 'z' then goto cont
CMP W5, #'z' // is letter > 'z'?
B.GT cont
// Else if W5 < 'a' then goto end if
CMP W5, #'a'
B.LT cont // goto to end if
// if we got here then the letter is lower case, so convert it.
SUB W5, W5, #('a'-'A')
cont: // end if
STRB W5, [X1], #1 // store character to output str
CMP W5, #0 // stop on hitting a null character
B.NE loop // loop if character isn't null
SUB X0, X1, X4 // get the length by subtracting the pointers
RET // Return to caller

在子程序中,我们通过 .global 暴露函数 toupper

main.s
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
//
// Assembler program to convert a string to
// all upper case by calling a function.
//
// X0-X2 - parameters to linux function services
// X1 - address of output string
// X0 - address of input string
// X8 - linux function number
//

.global _start // Provide program starting address to linker
.p2align 2

_start: ADRP X0, instr@PAGE // start of input string
ADD X0, X0, instr@PAGEOFF
ADRP X1, outstr@PAGE // address of output string
ADD X1, X1, outstr@PAGEOFF

BL toupper

// Setup the parameters to print our hex number
// and then call the kernel to do it.
MOV X2,X0 // return code is the length of the string

MOV X0, #1 // 1 = StdOut
ADRP X1, outstr@PAGE // start of string
ADD X1, X1, outstr@PAGEOFF
MOV X16, #4 // Unix write system call
SVC #0x80 // Call kernel to output the string

// Setup the parameters to exit the program
// and then call the kernel to do it.
MOV X0, #0 // Use 0 return code
MOV X16, #1 // System call number 1 terminates this program
SVC #0x80 // Call kernel to terminate the program

.data
instr: .asciz "This is our Test String that we will convert.\n"
outstr: .fill 255, 1, 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
UPPEROBJS = main.o upper.o

ifdef DEBUG
DEBUGFLGS = -g
else
DEBUGFLGS =
endif
LSTFLGS =

all: upper

LDFLAGS = -lSystem -syslibroot `xcrun -sdk macosx --show-sdk-path` -e _start -arch arm64

%.o : %.s
as $(DEBUGFLGS) $(LSTFLGS) -arch arm64 $< -o $@

upper: $(UPPEROBJS)
ld -o upper $(LDFLAGS) $(UPPEROBJS)

clean:
rm -f $(UPPEROBJS) upper

Stack Frames

当我们需要大于可用寄存器数量的临时空间时,我们可以将变量保存在栈上,但是我们逐个保存变量到栈上是低效且不可行的,因而我们获取一块连续的栈空间,称为栈帧,并将其顶部位置(低地址)作为基准存于栈帧指针寄存器

栈遵循 LIFO,虽然在汇编中我们可以任意偏移访问,但是手动指定偏移量是相当繁琐的,动态计算会使用更多周期,且易出错 采用栈帧,我们就有了一块可常量偏移(编译时确定)的内存,且有栈帧指针作为稳定基准

我们通过这个例子说明

假设我们有: w0, w1, w2 三个寄存器,我们把这三个寄存器存入栈中

1
2
3
sub sp, sp, #16 // 16 字节对齐,即使我们只需要 12 字节
stp w0, w1, [sp, #8]
stp w2, wzr, [sp]

由于后续 sp 可能还需要有变化,因此我们可以使用 fp 暂存当前 sp

1
mov fp, sp

需要注意的是 fp 与 sp 一样,也是通用寄存器的别名,fp 对应 X29,因此也需要使用时注意保存与恢复

Defining Symbols

通过 .equ 定义整数常量的符号(别名),在编译期间直接进行替换,实际上可以计算数值型表达式

1
.equ symbol, num

例如

1
.equ n, 3

Macros

我们可以在独立的文件中定义宏,并采用 .include 导入定义宏的 .s 文件,这是因为宏本身不产生仍和代码,而是直接在编译前做了替换

要定义宏,通过以下的语法实现

1
2
3
.macro   macroname   parameter1, parameter2, ...
...
.endm

在宏定义中,参数替换采用 \parameter1 这样的语法实现,同时宏中的标签我们通常采用数字,而非常用的字符串,这是因为数字标签允许在上下文中重复定义,且可根据 f 和 b 的后缀判断选择的标签位置,这解决了重复的宏导入标签重复问题

1
2
B.GT   2f
B.NE 1b

这里的 f 表示 forward direction,向前查找,b 表示 backward direction,向后查找

以下面这个转小写的程序为例

macro.s
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.macro toLower in, out
adrp x0, \in@PAGE
add x0, x0, \in@PAGEOFF
adrp x1, \out@PAGE
add x1, x1, \out@PAGEOFF
1:
ldrb w2, [x0], #1
cmp w2, #'a'
b.ge 2f
cmp w2, #'A'
b.lt 2f
cmp w2, #'Z'
b.gt 2f
add w2, w2, #32
2:
strb w2, [x1], #1
cmp w2, #0
b.ne 1b
.endm
main.s
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.global _start
.align 4
.include "macro.s"

_start:
toLower in, out
mov x0, #1
adrp x1, out@PAGE
add x1, x1, out@PAGEOFF
mov x2, #26
mov x16, #4
svc #0x80

mov x0, #0
mov x16, #1
svc #0x80

.data
in: .asciz "HEllo World! 12 3 A B D!\n"
out: .fill 256, 1, 0

除此之外,宏可以用来简化代码,例如对于繁琐的入栈,出栈指令,我们可以采用宏来封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MACRO PUSH1 register
STR \register, [SP, #-16]!
.ENDM

.MACRO POP1 register
LDR \register, [SP], #16
.ENDM

.MACRO PUSH2 register1, register2
STP \register1, \register2, [SP, #-16]!
.ENDM

.MACRO POP2 register1, register2
LDP \register1, \register2, [SP], #16
.ENDM