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,虽然 asld 名字和 GNU 中一样,但是按照 Github repo 中所说采用 Clang 汇编语法。

输出可执行文件分为编译和链接两部分,具体例子如下

1
2
as -g -arch arm64 -o HelloWorld.o HelloWorld.s
ld -o HelloWorld HelloWorld.o -lSystem -syslibroot `xcrun -sdk macosx --show-sdk-path` -arch arm64

其中的 -lSystem-syslibroot-sdk 是因为 OSX 需要动态链接,Github repo 中没有提供静态链接编译的方案,因为主要目的是熟悉汇编语法,所以我也直接拿来用。

The Hello World

这是一个 Hello World 的实现,采用 ascii 的字符串输出

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
//
// Assembler program to print "Hello World!"
// to stdout.
//
// X0-X2 - parameters to Unix system calls
// X16 - Mach System Call function number
//

.global _main // Provide program starting address to linker
.align 4 // Make sure everything is aligned properly

// Setup the parameters to print hello world
// and then call the Kernel to do it.
_main:
mov X0, #1 // 1 = StdOut
adr X1, helloworld // string to print
mov X2, #13 // length of our string
mov X16, #4 // Unix write system call
svc #0x80 // Call kernel to output the string

mov X0, #1
adr X1, nihao // string to print
mov X2, #7 // length of our string
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

.align 4
helloworld: .ascii "Hello World!\n"
nihao: .ascii "你好\n"

我在原始代码的基础上添加了第二段输出(你好),值得注意的是在第二个参数 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
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
iy88@iy88s-MacBook-Air Chapter 01 % objdump -s -d ./HelloWorld

./HelloWorld: file format mach-o arm64
Contents of section __TEXT,__text:
1000002e0 200080d2 e1010010 a20180d2 900080d2 ...............
1000002f0 011000d4 200080d2 a1010030 e20080d2 .... ......0....
100000300 900080d2 011000d4 000080d2 300080d2 ............0...
100000310 011000d4 1f2003d5 1f2003d5 1f2003d5 ..... ... ... ..
100000320 48656c6c 6f20576f 726c6421 0ae4bda0 Hello World!....
100000330 e5a5bd0a ....

Disassembly of section __TEXT,__text:

00000001000002e0 <_main>:
1000002e0: d2800020 mov x0, #0x1 ; =1
1000002e4: 100001e1 adr x1, 0x100000320 <helloworld>
1000002e8: d28001a2 mov x2, #0xd ; =13
1000002ec: d2800090 mov x16, #0x4 ; =4
1000002f0: d4001001 svc #0x80
1000002f4: d2800020 mov x0, #0x1 ; =1
1000002f8: 300001a1 adr x1, 0x10000032d <nihao>
1000002fc: d28000e2 mov x2, #0x7 ; =7
100000300: d2800090 mov x16, #0x4 ; =4
100000304: d4001001 svc #0x80
100000308: d2800000 mov x0, #0x0 ; =0
10000030c: d2800030 mov x16, #0x1 ; =1
100000310: d4001001 svc #0x80
100000314: d503201f nop
100000318: d503201f nop
10000031c: d503201f nop

0000000100000320 <helloworld>:
100000320: 6c6c6548 ldnp d8, d25, [x10, #-0x140]
100000324: 6f57206f umlal2.4s v15, v3, v7[1]
100000328: 21646c72 <unknown>
10000032c: a0bde40a <unknown>

000000010000032d <nihao>:
10000032d: e5a0bde4 st1d { z4.d }, p7, [x15, z0.d, lsl #3]
100000331: a5 bd 0a <unknown>

最上面列出的是原始二进制码,而下方是汇编助记词以及指令对应的二进制代码,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 : 链接器寄存器,实际的物理寄存器是 X30
  • PC : 程序计数器(存储要下一条待执行指令的地址)

习惯上,我们采用 MOV 指令进行,但是实际上 arm64 没有 mov,采用 ORR 以及 MOVZMOVK 等指令进行,以此精简指令集,但在编写代码时,为了提高可读性和心智负担,这些优化/替换都由编译器进行,我们仍使用其别名版本

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 三种,这是最常用的几种移位,默认无 LSLMOV#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. 用于计算反码
  2. 用于计算相反数(乘以 ),通过一步取反,再加一,可以比使用真正的乘法减少时钟周期数
1
MOVN Xd, Operand2

LSL LSR ASR ROR

对于寄存器中数的移位操作,还有以下指令

1
2
3
4
LSL   Xd, Xs, #shift    // Logical shift left
LSR   Xd, Xs, #shift    // Logical shift right
ASR   Xd, Xs, #shift    // Arithmetic shift right
ROR   Xd, Xs, #shift    // Rotate right

这里的 #shift 是任意的

Operand2

许多的 Arm 指令都包含一个灵活的 operand2 参数,它可以是以下三种

  1. 寄存器以及位移操作
  2. 寄存器以及拓展操作
  3. 一个小立即数以及位移操作

由于不同指令使用的 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

这些指令将 XsOperand2 相加并存入 Xd,其中可选的 S 表示是否设置标志(flags),例如进位标志(Carry),通过利用 ADDS 以及 ADCS,我们几乎可以进行任意长度的加法运算,以下面这个 128 bits 的加法为例。

128 bits addition
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.global _main
.align 4

_main:
// first number: #0x0000000000000003FFFFFFFFFFFFFFFF
MOV  X2, #0x0000000000000003
MOV  X3, #0xFFFFFFFFFFFFFFFF //Assem will change to MOVN
// second number: #0x00000000000000050000000000000001
MOV  X4, #0x0000000000000005
MOV  X5, #0x0000000000000001
ADDS X1, X3, X5 // Lower order 64-bits
ADC X0, X2, X4 // Higher order 64-bits
MOV X16, #1 // terminates code
SVC #0x80 // perform syscall

由于我们还没有学习如何打印一个变量,因此通过一个更为便捷的方式,使用 exit code 进行输出,获取上一执行指令的 exit code,我们可以通过如下方式进行

1
echo $?

对于上面的程序,低位有进位,我们应该获得 3 + 5 + 1 = 9

SUB/SUBC

减法是加法的逆运算,事实上我们知道计算时仍采用加法进行,只是对后一数取了相反数

1
2
SUB{S} Xd, Xs, Operand2
SBC{S} Xd, Xs, Operand2

其也有进位的版本,但采用相反的进位标志(低有效)

Exercises

192-bits addition

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

_start:
// 第一个192bit数 A019 FA04 BF52 E895 C227 30C1 CF84 D21E BF87 78F1 006F A693
// 第二个192bit数 805B D181 7B5C ECA2 EA5C C766 4C98 846E 1514 1E06 F8B2 9A81
// 两数相加结果 1 2075 CB86 3AAF D538 AC83 F828 1C1D 568C D49B 96F7 F922 4114

// first number: X3 X2 X1
mov X1, #0xA693
movk X1, #0x006F, LSL #16
movk X1, #0x78F1, LSL #32
movk X1, #0xBF87, LSL #48

mov X2, #0xD21E
movk X2, #0xCF84, LSL #16
movk X2, #0x30C1, LSL #32
movk X2, #0xC227, LSL #48

mov X3, #0xE895
movk X3, #0xBF52, LSL #16
movk X3, #0xFA04, LSL #32
movk X3, #0xA019, LSL #48


// second number: X6 X5 X4
mov X4, #0x9A81
movk X4, #0xF8B2, LSL #16
movk X4, #0x1E06, LSL #32
movk X4, #0x1514, LSL #48

mov X5, #0x846E
movk X5, #0x4C98, LSL #16
movk X5, #0xC766, LSL #32
movk X5, #0xEA5C, LSL #48

mov X6, #0xECA2
movk X6, #0x7B5C, LSL #16
movk X6, #0xD181, LSL #32
movk X6, #0x805B, LSL #48

// results: X9 X8 X7

adds X7, X1, X4
adcs X8, X2, X5
adc X9, X3, X6
mov X0, X7
MOV X16, #1 // System call number 1 terminates this program
SVC #0x80 // Call kernel to terminate the program

128-bits substraction

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

_start:
// First number: 0000 0000 0000 0002 0000 0000 0000 000A
mov X1, #0xA
mov X2, #0x2

// Second number: 0000 0000 0000 0001 0000 0000 0000 000B
mov X3, #0xB
mov X4, #0x1

subs X5, X1, X3
sbc X6, X2, X4

mov X0, X6

MOV X16, #1 // System call number 1 terminates this program
SVC #0x80 // Call kernel to terminate the program

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 即可运行程序,并将会自动在断点处的指令停下,此时程序尚未执行该条指令。通过 ns 即可执行一步,而在有函数调用时,使用 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,后面跟着的地址实际上可以是个表达式,能进行计算