MacOS Arm64 汇编 part 1 - MacOS Arm64 Assembly part 1
学习一下 Arm64 汇编(Drawin/OSX),主要是突发奇想学汇编,正好下学期也有这个课,刚好又看到了 Github 上 OSX 的汇编源码,对应这本书 Programming with 64-Bit ARM Assembly Language
- Reference: https://github.com/below/HelloSilicon
- MOV
- ADD
- SUB
Compile
OSX 上,gcc 实际上是 clang,虽然
as,ld 名字和 GNU
中一样,但是按照 Github repo 中所说采用 Clang
汇编语法。
输出可执行文件分为编译和链接两部分,具体例子如下
1 | as -g -arch arm64 -o HelloWorld.o HelloWorld.s |
其中的
-lSystem,-syslibroot,-sdk
是因为 OSX 需要动态链接,Github repo
中没有提供静态链接编译的方案,因为主要目的是熟悉汇编语法,所以我也直接拿来用。
The Hello World
这是一个 Hello World 的实现,采用 ascii 的字符串输出
1 | // |
我在原始代码的基础上添加了第二段输出(你好),值得注意的是在第二个参数
X2 长度参数是存储长度,中文的长度会有变化,这里是
7
- arm64 中采用 _main 作为默认 entry point
- 立即数格式采用
#xx - Drawin system call code 的寄存器为
X16 - Drawin system call 的内核态执行代码为
0x80 - Drawin 中 stdout 的 code 为
4,exit 为1
感觉也简单,指定 args,指定 func,执行,三步即完成 syscall
Disassmeble
通过 objdump 可实现反汇编获取汇编源码
1 | iy88@iy88s-MacBook-Air Chapter 01 % objdump -s -d ./HelloWorld |
最上面列出的是原始二进制码,而下方是汇编助记词以及指令对应的二进制代码,arm64 的指令长度固定为 32 bit,也即 8 位 16 进制
我们可以看到第一条 mov 指令的二进制编码为
d2800020,这里与上方的原始二进制代码顺序相反(以 byte
为单位),原来的二进制代码为
200080d2,这是因为系统采用小端序存储数据,即
lsb(低有效位)存在低位,msb(高有效位)存在高字节,这与习惯顺序相反,在网络编程中,更多采用大端序,这一顺序更容易阅读方便调试
Shifting and Rotating
具体的 Shifting(移位),以及 Rotating (循环移位)总共 4 种
Logical Shift Left
(LSL)
逻辑左移,高位丢弃,低位移入 0
1 | LSL Xd, Xs, Operand2 |
Logical Shift Right
(LSR)
逻辑右移,低位丢弃,高位移入 0
1 | LSR Xd, Xs, Operand2 |
Rotate right (ROR)
循环右移,低位移出到高位
1 | ROR Xd, Xs, Operand2 |
Arithmetic shift right
(ASR)
由于右移会导致低位被丢弃,高位移入 0,对于有符号数,负数的逻辑右移,会使其成为正数,因此我们通过算数右移,对于正数高位移入 0,负数移入 1,保证其正负性质。
为什么不需要算术左移?
- 这是因为对于左移来说,其有乘 2 的意味,对于负数来说,若要保证 乘 2 之后能够仍在表示范围,那么其原码的最高有效位必须为 0,其他位为 1,转换为补码后,最高有效位为 1,左移天然保证了符号正确性,而若最高有效位为 1 的负数,乘 2 必然溢出,变为正数,符合模 2 意义下的算术运算。
Loading Registers
Registers
X0–X30: 31 个通用 64 位寄存器,其中W0-W30依然对应着这 31 个寄存器,但是是 32 位的版本SP, XZR: 栈指针寄存器以及零寄存器,实际上共享同一个物理寄存器(X31),作用取决于上下文LR: 链接器寄存器,实际的物理寄存器是X30PC: 程序计数器(存储要下一条待执行指令的地址)
习惯上,我们采用
MOV指令进行,但是实际上 arm64 没有 mov,采用ORR以及MOVZ、MOVK等指令进行,以此精简指令集,但在编写代码时,为了提高可读性和心智负担,这些优化/替换都由编译器进行,我们仍使用其别名版本
MOV (MOVZ)
最直接的寄存器置数指令,不过实际上
MOV是 alias,其中一种(还有MOVN,用于简化编程,对于可以通过移位或取反得到的数,实际上就算超过 16 bit 也能经过编译器优化自动存入,而无需程序员手动选用不同的MOV指令)是MOVZ意为 move zero,在立即数存储时,对于所提供的数以外的 bit 均以 0 初始化
从源寄存器
Xs 读取并储存到目标寄存器 Xd
1 | MOV Xd, Xs |
将 16
位立即数存入目标寄存器(并包含移位操作)
对于特定的移位操作,我们无需先存储再移位,我们可以将移位结果立即存入寄存器,对于
MOV,有且仅有 3 种#shift,分别为左移 16 bit,32 bit,48 bit 即#16,#32,#48三种,这是最常用的几种移位,默认无LSL的MOV将#imm16直接存入低 2 字节,这 3 种LSL操作将寄存器分为 4 段,但需要注意的是采用MOV会将另外的 3 段均以 0 初始化
1 | MOV Xd, #imm16{, LSL #shift} |
Operand2 版本
1 | MOV Xd, Operand2 |
MOVK
意为 move keep,保留立即数之外的所有 bit,不采用 0 初始化,常用于解决超过 16 bit的无规则,不可取反或移位的立即数存储,通过多次
MOVK结合LSL实现
1 | MOVK Xd, #imm16{, LSL #shift} |
通过 LSL #shift 可将 16 位立即数存入 4
段中的任意一段,但最开始一段我们一般通过 MOV 或是
MOVZ 保证其他 bit 都被零初始化。
MOVN
意为 move not,执行 logical not,是单独的一条指令,不同于
MOV的 alias
- 用于计算反码
- 用于计算相反数(乘以
),通过一步取反,再加一,可以比使用真正的乘法减少时钟周期数
1 | MOVN Xd, Operand2 |
LSL LSR
ASR ROR
对于寄存器中数的移位操作,还有以下指令
1 | LSL Xd, Xs, #shift // Logical shift left |
这里的
#shift是任意的
Operand2
许多的 Arm 指令都包含一个灵活的 operand2
参数,它可以是以下三种
- 寄存器以及位移操作
- 寄存器以及拓展操作
- 一个小立即数以及位移操作
由于不同指令使用的 bit 数不同,因而 operand2
的立即数长度和具体指令有关,值得注意的是,这里的 shift 操作的立即数,从
#1, #2, #3 映射到
#16, #32,
#48,这是为了节省指令位
Register and Extension
拓展操作令我们能够从第二个寄存器中提取一个字节,半字,或一个字,同时可以采用零拓展(0 填充)或带符号拓展(保持符号),并进行 0-4 bit的移位操作
| Extension Operator | Description |
|---|---|
| uxtb | 无符号字节拓展 |
| uxth | 无符号半字拓展 |
| uxtw | 无符号字拓展 |
| sxtb | 有符号字节拓展 |
| sxth | 有符号半字拓展 |
| sxtw | 有符号字拓展 |
clang 要求
Xs为 32-bit,这是因为字拓展用不到 64-bit 寄存器的高 32 位
ADD/ADC
在将数存入寄存器后,就可以进行计算,我们从加法开始,可以分为两种,忽略进位标志和考虑进位标志,
1 | ADD{S} Xd, Xs, Operand2 |
1 | ADC{S} Xd, Xs, Operand2 |
这些指令将 Xs 与 Operand2 相加并存入
Xd,其中可选的 S
表示是否设置标志(flags),例如进位标志(Carry),通过利用
ADDS 以及
ADCS,我们几乎可以进行任意长度的加法运算,以下面这个 128
bits 的加法为例。
1 | .global _main |
由于我们还没有学习如何打印一个变量,因此通过一个更为便捷的方式,使用
exit code 进行输出,获取上一执行指令的
exit code,我们可以通过如下方式进行
1 | echo $? |
对于上面的程序,低位有进位,我们应该获得
3 + 5 + 1 = 9
SUB/SUBC
减法是加法的逆运算,事实上我们知道计算时仍采用加法进行,只是对后一数取了相反数
1 | SUB{S} Xd, Xs, Operand2 |
其也有进位的版本,但采用相反的进位标志(低有效)
Exercises
192-bits addition
1 | .global _start |
128-bits substraction
1 | .global _start |
GDB
写完 Bug 当然是要 Debug,在 clang
的环境下,我们采用 lldb,我们以 192-bits addition 为例,说明
lldb 的基本使用
首先,调试运行需要先修改编译命令,重新编译,使其带上一些元数据,以包含变量名等信息
1 | as -g ... |
随后通过如下命令进入 lldb
1 | lldb ./add192 |
随后我们输入
1 | disassamble --name start |
就可以得到从 _start
开始的所有反汇编代码,这里许多的指令并不是我们的程序所包含的,而是动态链接库当中的。
随后我们可以通过 b <label> 的方式设置位于
<label> 的断点,无需下划线,例如
b start。通过 br l
可以打印所有断点的信息,通过 br de <n>
来指定删除编号为 <n> 的一个断点。
我们输入 r
即可运行程序,并将会自动在断点处的指令停下,此时程序尚未执行该条指令。通过
n 或 s 即可执行一步,而在有函数调用时,使用
n 将会直接完成函数调用到下一行,而 s
则会进入函数,停在第一行;若没有函数调用,则二者行为一致,均单步执行。
对于汇编来说,二者行为通常等价,没有区别
若使用 c
则将立即执行下面的指令直到下一个断点或程序结束。
在运行时,可以采用 re r 来打印所有寄存器,也可以通过
re r SP X0 X1 这样的方式指定需要打印的寄存器,这里只打印
SP X0 X1
除此之外,我们还可以通过 m read -fx -c4 -s4 $address
这种方式来打印指定内存附近的字节,其中
-f是显示格式x表示hex-s是数据的大小-c是要打印数据的个数$address是打印的开始地址,对于一个具体的十六进制地址,可以直接用类似0x1000002e4这样的格式给出,对于pc可以采用$pc,后面跟着的地址实际上可以是个表达式,能进行计算