iOS 汇编入门 - arm64基础
程序员文章站
2022-04-11 19:37:02
...
前言
iOS 中常见的架构有 armv7、armv7s、arm64、i386、x86_64 这些。
其中, armv7 、armv7s 、arm 64 都是 ARM 处理器的指令集,i386 、x86_64 是 Mac 处理器的指令集
这些指令集对应的机型有以下这些:
arm64e: iphone XS | iphone XS Max | iPhoneXR
arm64: iPhone 8 | iPhone 8 Plus | iPhone X | iPhone 7/7 Plus | iPad (2018) | iPhone 6/6S | iPhone 6/6S Plus | iPhone 5s
armv7s: iPhone 5 | iPhone5C | iPad4(Retina屏) |
armv7: iPhone 4 | iPhone4S | iPad 2/3 | iPad mini | iPod Touch 3G | iPod Touch4
armv7/armv7s/i386 架构使用的是 32 位的处理器
arm64/x86_64 架构使用的是 64 位的处理器
查看 framework 包含的架构命令:lipo
基本概念
汇编里面涉及到最多的就是寄存器、栈和指令这 3 个。
寄存器:
寄存器是 CPU 中的高速存储单元,存取速度比内存快很多。
常用的寄存器有以下这些:
寄存器
|
描述
|
r0 - r30
|
通用整形寄存器,64 位,当使用 x0 - x30 访问时,代表的是 64 位的数;当使用 w0 - w30 访问的时候,访问的是这些寄存器的低 32 位
|
fp(x29)
|
保存栈帧地址(栈底指针)
|
lr(x30)
|
通常称x30为程序链接寄存器,保存子程序结束后需要执行的下一条指令
|
sp
|
保存栈指针,使用 sp/wsp 来进行对 sp 寄存器的访问
|
pc
|
pc 寄存器中存的是当前执行的指令的地址,在 arm64 中,软件是不能改写 pc 寄存器的
|
SPRs
|
状态寄存器,存放状态标识,可分为 CPSR (The Current Program Status Register) 和 SPSRs(The Save Program Status Registers)。一般都是使用 CPSR,当发生异常时,CPSR 会存入 SPSR。当异常恢复,再拷贝回 CPSR
|
zr |
零寄存器,里面存的是 0 (zero register)一般使用 wzr/xzr ,w 代表 32位,x 代表 64 位
|
v0 - v31
|
向量寄存器,也可以说是浮点型寄存器,每个寄存器大小是 128 位,可以用 Bn Hn Sn Dn Qn 来访问不同的位数(8 16 32 64 128)
|
Note:
x0 - x7 :用于子程序调用时的参数传递,超过八个会放到栈上传递
x0 和 w0 是同一个寄存器的不同尺寸的区别,x0 为 8 字节,w0 为 4 字节(x0 寄存器的低4字节),x0/w0 还用于返回值的传递
指令:
运算指令:
mov x1,x0 ;将寄存器x0值 赋值 给x1
add x0,x1,x2 ;x0 = x1 + x2
sub x0,x1,x2 ;x0 = x1 - x2
mul x0,x1,x2 ;x0 = x1 * x2
sdiv x0,x1,x2 ;x0 = x1 / x2;
and x0,x0,#0xF ;x0 = x0 & #0xF (与操作)
orr x0,x0,#9 ;x0 = x0 | #9 (或操作)
eor x0,x0,#0xF ;x0 = x0 ^ #0xF (异或操作)
寻址指令:
分为两种,存和取
L 打头的基本都是取值指令,如 LDR(Load Register)、LDP(Load Pair)
S 打头的基本都是存值指令,如 STR(Store Register)、STP(Store Pair)
ldr x0,[x1] ;从 x1 指向的地址里面取出一个64位大小的数存入x0
ldp x1,x2,[x10, #0x10] ;从 x10+0x10 指向的地址里面取出2个64位的数,分别存入x1、x2
str x5,[sp, #24] ;往内存中写数据(偏移值为正), 把 x5 的值(64位的数值)存到 sp+24 指向的地址内存上
stur w0,[x29, #0x8] ;往内存中写数据(偏移值为负),将 w0 的值存储到 x29 - 0x8 这个地址里
stp x29,x30,[sp, #-16]! ;把 x29、x30 的值存到 sp-16 的地址上,并且把sp-=16 Note:后面有个感叹号的,然后没有stup这个指令哈
ldp x29,x30,[sp],#16 ;从 sp 地址取出16 byte数据,分别存入x29、x30,然后 sp+=16
「寻址」的格式分为下面3种类型:
mov x0
[x10, #0x10] ;从 x10+0x10 的地址取值
[sp, #-16]! ;从 sp-16 地址取值,取完后再把 sp-16 writeback 回 sp
[sp], #16 v从 sp 地址取值,取完后把 sp+16 writeback 回 sp
跳转指令
bl/b bl 是有返回的跳转;b 是无返回的跳转
1.有返回的意思就是会存 lr ,存了 lr 也就意味着可以返回到本方法继续执行,一般用于不同方法直接的调用;2.无返回的一般是方法内的跳转,如 while 循环,if else 等。
跳转指令一般还伴随着条件,以实心点.开头的都是表示条件,如 b.ne ,一般用于 if else 。
常见的条件码有以下这些:
数据来源:here
内存模型:
堆:
在了解栈之前先来了解一下堆(Heap)。
由于寄存器只能存放少量数据,在大多数时候,CPU 跟指挥寄存器跟内存交换数据。所以除了寄存器,还必须了解内存是怎么存储数据的。
程序运行的时候,操作系统会给它分配一段内存,用来存储程序和运行产生的数据。这段内存有起始地址和结束地址,比如从 0x1000 到 0x8000,起始地址是较小的那个地址,结束地址是较大的那个地址。
程序运行过程中,对于动态占用请求(比如新建对象,或者使用 malloc ),系统就会从预先分配好的那段内存之中,划出一部分给用户,具体规则是从起始地址开始划分(实际上,起始地址会有一段静态数据,这里忽略)。举例来说,用户申请10个字节的内存,那么从起始地址0x1000开始给他分配,一直分配到0x100A,如果再申请22个字节,那么就分配到0x1020。
这种因为用户主动请求而划分出来的内存区域,叫做堆(Heap)。它由起始地址开始,从低位(地址)向高位(地址)增长。Heap 的一个重要特点是不会自动消失,必须手动释放,或者由垃圾回收机制来回收。
栈
除了堆(Heap)以外,其他的内存占用叫做栈(Stack)。简单来说,栈是由于函数运行而临时占用的内存区域,是一种往下(低地址)生长的数据结构。
int main() {
int a = 2;
int b = 3;
}
上面的代码中,系统开始执行 main 函数的时,会为它在内存里面建立一个帧(frame),所有 main 的内部变量(比如a和b)都保存在这个帧里面。main 函数执行结束后,该帧就会被回收,释放所有的内部变量,不再占用空间。
如果 main 函数内部又调用了其他函数,情况又会是怎样呢?
int main() {
int a = 2;
int b = 3;
return test(a, b);
}
上面的代码中,main 函数内部调动了 test 函数。当执行到这一步的时候,系统也会为 test 新建一个帧,用来存储它的内部变量。也就是说,此时同时存在两个帧:main 和 test。一般来说,调用栈有多少层,就有多少帧。
等到 test 运行结束,它的帧就会被回收,系统会回到函数 main 刚才中断执行的地方,继续往下执行。通过这种机制,就实现了函数的层层调用,并且每一层都能使用自己的本地变量。
栈(Stack)是有内存区域的结束地址开始,从高位(地址)向低位(地址)分配的。比如,内存区域的结束地址是 0x8000,第一帧假定是16字节,那么下一次分配的地址就会从0x7FF0开始;第二帧假定需要64字节,那么地址就会移动到0x7FB0。
实战
了解完以上的基础知识之后,下面就用一个简单的例子了解汇编的栈操作。
// hello.c
#include <stdio.h>
int test(int a, int b) {
int res = a + b;
return res;
}
int main() {
int res = test(1, 2);
return 0;
}
使用clang命令将其编译成arm64指令集汇编代码
clang -S -arch arm64 -isysroot `xcrun --sdk iphoneos --show-sdk-path` hello.c
可以看到完整的汇编如下:
.section __TEXT,__text,regular,pure_instructions
.build_version ios, 13, 2 sdk_version 13, 2
.globl _test ; -- Begin function test
.p2align 2
_test: ; @test
.cfi_startproc
; %bb.0:
sub sp, sp, #16 ; =16
.cfi_def_cfa_offset 16
str w0, [sp, #12]
str w1, [sp, #8]
ldr w0, [sp, #12]
ldr w1, [sp, #8]
add w0, w0, w1
str w0, [sp, #4]
ldr w0, [sp, #4]
add sp, sp, #16 ; =16
ret
.cfi_endproc
; -- End function
.globl _main ; -- Begin function main
.p2align 2
_main: ; @main
.cfi_startproc
; %bb.0:
sub sp, sp, #32 ; =32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16 ; =16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
stur wzr, [x29, #-4]
orr w0, wzr, #0x1
orr w1, wzr, #0x2
bl _test
str w0, [sp, #8]
mov w0, #0
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32 ; =32
ret
.cfi_endproc
; -- End function
.subsections_via_symbols
先看第一部分:
.section __TEXT,__text,regular,pure_instructions
.build_version ios, 13, 2 sdk_version 13, 2
.globl _test ; -- Begin function test
.p2align 2
代码中类似 .section 或者 .globl 等以 ‘.' 开头的被称之为编译指令,用于告知编译器相关的信息或者进行特定操作。
.section 里面的 __TEXT,__text 字段用来存放代码指令.build_version 是编译版本信息.globl _test 声明了全局变量(函数); -- Begin function test 分号后面是注释的意思.p2align 2 用于指定程序的对齐方式,这类似于结构体的字节对齐,为的是加速程序的执行速度,p2align 的单位是指数,即按照 2 的 n 次方对齐,这里的 .p2align 2 表示按照 2^2 = 4 字节对齐,如果单行指令数据长度不足4字节,将用 0 补全,超过 4 但不是 4 的倍数,则按照最小倍数补全_test、_main 称之为标签(label),用于辅助定位代码或者资源地址,也方便开发者理解和记忆
再接着往下看
.cfi_startproc ;定义函数开始
.cfi_endproc ;定义函数结束
.cfi_xxx ;call frame information xxx, cfi 是 DWARF 2.0 定义的函数栈信息,用来告诉编译器生成响应的 DWARF 调试信息,主要是和函数有关。
汇编中的如下部分被称为方法头(prologue),用于保存上一个方法调用栈帧的帧头,以及预留部分用于局部变量的栈空间。
sub sp, sp, #32 ; =32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16 ; =16
汇编中的如下部分被称为方法尾(epilogue),用于取出方法头中栈帧信息及方法的返回地址,并将栈恢复到调用前的位置
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32 ; =32
ret
先看 test 函数的实现:
//源代码
int test(int a, int b) {
int res = a + b;
return res;
}
//汇编
sub sp, sp, #16 ; =16
.cfi_def_cfa_offset 16
str w0, [sp, #12]
str w1, [sp, #8]
ldr w0, [sp, #12]
ldr w1, [sp, #8]
add w0, w0, w1
str w0, [sp, #4]
ldr w0, [sp, #4]
add sp, sp, #16 ; =16
ret
在编译器生成汇编时,会首先计算需要的栈空间大小,并利用 sp (stack pointer)指针指向低地址开辟相应的空间。从 test 函数可以看到这里涉及了3个变量,分别是 a、b、res,int变量占据4个字节,因此需要12个字节,但 ARM64 汇编为了提高访问效率要求按照16字节进行对齐,因此需要16 byte 的空间,也就是需要在栈上开辟16字节的空间,可以看汇编的第一句,正是将 sp 指针下移16字节。
sp (stack pointer)是栈指针,永远指向栈顶!
sub sp, sp, #16 ; =16
接着看下面这2句:
str w0, [sp, #12]
str w1, [sp, #8]
这2句的意思是,将 w0 存储在 sp+12 的地址指向的空间,w1 存储在 sp+8 存储的空间里,寄存器x0~x7用于子程序调用时的参数传递,按顺序入参。 x0 和 w0 是同一个寄存器的不同尺寸形式,x0为8字节,w0为x0的前4个字节,因此w0是函数的第一个入参a,w1是函数的第二个入参b,正如上文栈一节提到的,由于栈的存储是从高地址向低地址分配的,所以a将占据 sp+12 ~ sp+16 这4个字节的空间,b将占据 sp+8 ~sp+12 这4个字节的空间,栈结构图变为如下所示:
接下来 test 函数内部将 a 和 b 进行相加,需要注意的是,只有寄存器才能参与运算,因此接下来的汇编代码又将变量的值从内存中读出来,再进行相加运算。
ldr w0, [sp, #12]
ldr w1, [sp, #8]
add w0, w0, w1
到这里可能会纳闷,先存储在读取后运算,感觉这一步很多余,确实是这样的,因为这是没有进行编译优化的结果,为了是能够更好的学习和了解汇编的工作机制。
计算完成之后将结果存储到了w0寄存器,地址是 sp+4
str w0, [sp, #4]
接下来就要进行返回操作了,上文中我们提到,函数的返回值一般存储在 x0/w0 寄存器中返回的,这里也可以看到它将返回值res载入到了x0/w0 寄存器了
ldr w0, [sp, #4]
最后就是将栈还原,并返回到函数调用处继续向下执行。
add sp, sp, #16 ; =16
ret
显然,经过这样的操作,栈被完全还原到了函数调用以前的样子,需要注意的细节是,栈空间中的内存单元并未被清空,这就导致下一次使用栈时,未初始化单元的值是不确定的,这也就是局部变量不初始化会出现随机值的根本原因。
接着,再看看 main 函数的汇编代码就变得很好理解了:
sub sp, sp, #32 ; =32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16 ; =16
----------------------------------------------------prologue-----------------------------------------------------------------
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
stur wzr, [x29, #-4]
orr w0, wzr, #0x1
orr w1, wzr, #0x2
bl _test
str w0, [sp, #8]
mov w0, #0
----------------------------------------------------epilogue-----------------------------------------------------------------
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32 ; =32
ret
附录:
对于 sp 指针和栈哪边是header哪边是tail还有疑问的可以看下《Advanced Apple Debugging & Reverse Engineering》这本书的一节,给截了个图过来了,如下:
参考资料:
The A64 Instruction set《Advanced Apple Debugging & Reverse Engineer》- Chapter 6: Thread, Frame & Stepping Around