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 个字节,最终在 w4w5 中都存储第 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

存在双字长版本的 ldrstr,分别是 ldpstp

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

以上面这个程序为例,ldpx1 所指向的内存,依次读取 8 byte 先后存到 x2x3,从 lldb 中观察到 x2 = 0xabcdefabcdefabcdx3 = 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 所在地址,而对于嵌套函数调用,采用栈来保存历史信息

上一节的程序中已经用到了 blret

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

Function Parameters and Return Values

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

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

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

Managing the Registers

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

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

Summary of the Function Call Algorithm

调用过程

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

被调用的函数

  1. 如果需要使用,将 lrX19X30 压入栈中
  2. 执行函数体
  3. 将返回值 movX0
  4. 出栈 lr,如果由,出栈 X19X30
  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

需要注意的是 fpsp 一样,也是通用寄存器的别名,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 这样的语法实现,同时宏中的标签我们通常采用数字,而非常用的字符串,这是因为数字标签允许在上下文中重复定义,且可根据 fb 的后缀判断选择的标签位置,这解决了重复的宏导入标签重复问题

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