MacOS Arm64 汇编 part 3 - MacOS Arm64 Assembly part 3
- manipulate memory
- function & stack
Defining Memory Contents
Basics
我们在 .data
段中编写内存内容,主要有以下表格描述的内容类型描述符,同时可以使用
Negative(-) 取相反数,以及 Complement(~)
取反两个整型数前缀
1 | label: 74, 0112, 0b00101010, 0x4A, 0X4a, |
其中 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 | label: count |
这将填充 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 | ldr xd, #offset |
offset 是相对于该 ldr
指令到这一立即数存储位置的偏移量
通过这一方法可以加载 PC 附近约 1MB
范围的内存,但加载的值可以超出指令长度,这既是这一指令的意义,对于更远的数据,以及带标签数据,在
OSX 中我们实际上使用如下方法在 xd 中加载其地址
1 | adrp xd, label@PAGE |
这里是采用分页的相对寻址(仍基于 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 | .global _start |
我们首先通过分页地址及其偏移量获取 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 | .global _start |
通过在 ldr 末尾添加 #offset
的方式即可实现先间接寻址,后更新地址寄存器
Storing a Register
str 指令是 ldr 的镜像,做完全相反的工作
1 | str xs, [xd{, #offset}] |
其将 xs 经过间接寻址存入 xd
指向的内存,其也支持 ! 以及后索引寻址
Double Registers
存在双字长版本的 ldr 和 str,分别是
ldp 和 stp
1 | .global _start |
以上面这个程序为例,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 | .global _start |
虽然 LIFO,但是这个 stack 更像一个索引为负的数组,可以随机访问
Branch with Link
在我们调用函数时,很自然会有 call 和 return 两个情景,我们希望 call 后执行函数段,而执行完毕后返回到调用处的下一语句继续执行,对于长归的 branch 操作,我们需要手动调节跳回(return)的位置,而对于可能有多次的变地址函数调用而言,我们需要一种更为方便的办法
依托 lr 寄存器以及 bl / ret
指令,在分支跳转时将后一语句的地址存入 lr,在
ret 时跳转回 lr
所在地址,而对于嵌套函数调用,采用栈来保存历史信息
上一节的程序中已经用到了
bl和ret
Nesting Function Calls
我们容易想到,当我们嵌套调用函数时,lr
将会被覆盖,因此当我们在嵌套调用函数时需要先将当前的 lr
压栈,再第二次调用,并最终出栈写回 lr
在汇编层面,栈上的工作是程序员需要做的
我们以下面这个程序为例
1 | .global _start |
其中 _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
调用过程
- 如果需要
X0–X18请先保存
- 将前 8 个参数
mov到X0–X7
- 如果需要,将额外参数压入栈
- 使用
bl调用函数
- 检查
X0的返回值
- 恢复
X0–X18
被调用的函数
- 如果需要使用,将
lr与X19–X30压入栈中
- 执行函数体
- 将返回值
mov到X0
- 出栈
lr,如果由,出栈X19–X30
- 使用
ret指令返回到调用者
分文件版本的大写转换程序
1 | // |
在子程序中,我们通过
.global暴露函数toupper
1 | // |
1 | UPPEROBJS = main.o upper.o |
Stack Frames
当我们需要大于可用寄存器数量的临时空间时,我们可以将变量保存在栈上,但是我们逐个保存变量到栈上是低效且不可行的,因而我们获取一块连续的栈空间,称为栈帧,并将其顶部位置(低地址)作为基准存于栈帧指针寄存器
栈遵循 LIFO,虽然在汇编中我们可以任意偏移访问,但是手动指定偏移量是相当繁琐的,动态计算会使用更多周期,且易出错 采用栈帧,我们就有了一块可常量偏移(编译时确定)的内存,且有栈帧指针作为稳定基准
我们通过这个例子说明
假设我们有: w0, w1, w2 三个寄存器,我们把这三个寄存器存入栈中
1 | sub sp, sp, #16 // 16 字节对齐,即使我们只需要 12 字节 |
由于后续 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 | .macro macroname parameter1, parameter2, ... |
在宏定义中,参数替换采用 \parameter1
这样的语法实现,同时宏中的标签我们通常采用数字,而非常用的字符串,这是因为数字标签允许在上下文中重复定义,且可根据
f 和 b
的后缀判断选择的标签位置,这解决了重复的宏导入标签重复问题
1 | B.GT 2f |
这里的 f 表示 forward
direction,向前查找,b 表示 backward
direction,向后查找
以下面这个转小写的程序为例
1 | .macro toLower in, out |
1 | .global _start |
除此之外,宏可以用来简化代码,例如对于繁琐的入栈,出栈指令,我们可以采用宏来封装
1 | MACRO PUSH1 register |