1. 简介
根据 CPU 架构
不同,汇编主要可以分为两种:模拟器上的x86
汇编、真机上的 arm64
汇编,主要从下面三个模块进行学习:
- 寄存器
- 指令
- 堆栈
在学习之前,推荐阅读汇编语言入门教程,可以对于这三个部分有个初步的认识。
2. 寄存器
2.1 寄存器的分类
寄存器有多种类型,下面按照不同的作用,分别来介绍一下,之后会在 iOS 项目中进行查看。
2.1.1 通用寄存器
x0-x28
属于通用寄存器,每个寄存器都是64
位的,说明最多可以存放8 个字节
的数据。
w0-w28
是32 位
的寄存器(为了兼容 32 位的 CPU),属于 x0-x28
的低 32 位
,如下图所示:
x0-x7
通常用来存放函数的参数
,如果参数更多,就使用堆栈
(下面会有讲到)来传递。
通过 lldb 命令,我们也可以验证出他们的关系,如下图所示:
x0
在函数运行最后,通常被用来存放函数的返回值
(如果有的话)。
2.1.2 程序计数器寄存器
也就是pc (Program Counter Register)寄存器
,存放当前CPU 正在执行的指令的地址
,类似于 8086 汇编的 ip 寄存器
。
2.1.3 堆栈寄存器
堆栈寄存器是用来控制函数分配的栈空间
的,下面会有讲到。
sp (Stack Pointer)
寄存器,指向栈顶。
fp (Frame Pointer)
寄存器,指向栈底。
2.1.4 链接寄存器
lr (Link Register)
,也就是x30
,存放着函数的返回地址
。
使用bl
指令跳转时,会自动将bl指令
下面的一条指令的地址,存放到lr寄存器
中,如果 bl指令
跳转之后,遇到了ret指令
,ret
指令会将lr寄存器
存放的指令地址给pc
寄存器,然后 CPU 就会执行pc寄存器
存储的地址所指向的指令,相当于函数返回了。
2.1.5 程序状态寄存器
-
cpsr (Current Program Status Register)
存放当前程序的运行状态,和其他寄存器不同(其他寄存器是用来存放数据或指令的),cpsr
寄存器是按位起作用
的,每一位都有专门的含义,记录特定的信息,如下图所示:
- CPSR的低8位(包括I、F、T和M[4~0])称为控制位,程序无法修改,除非CPU运行于特权模式下,程序才能修改控制位!
- N、Z、C、V均为条件码标志位。它们的内容可被算术或逻辑运算的结果所改变,并且可以决定某条指令是否被执行,意义重大!
-
spsr (Saved Program Status Register)
异常状态下使用
2.2 寄存器的读写
我们在 ViewController
中写一个方法,然后在该方法中下一个断点,如下图所示:
通过register read
命令来查看当前所有的寄存器,如下同:
可以通过上图对之前寄存器的介绍进行分析巩固:
【1】x0
中存放的是sumWithA:b:
默认的第一个参数self
,也就是方法调用者:ViewController
,可以通过下图方式验证:
【2】
x1
中存放的是sumWithA:b:
默认的第二个参数_cmd
,就是方法名称,从上图中可以直接看出来【3】
x2
和 x3
中分别存放的是sumWithA:b:
第 3 和第 4 个参数,也就是 a 和 b,对应的值为a=0x00001=1
,b=0x0000002=2
,跟我们传递的参数值是一样的。【4】
lr(x30)
存放的是函数的返回地址,当sumWithA:b:
执行完成之后,还需要返回 viewDidLoad
方法中继续执行,所以lr(x30)
中存放的是 viweDidLoad
的地址,从上图中可以很直观的看出来。【5】
pc
寄存器存放的是 CPU 当前执行指令的地址
,也就是sumWithA:b:
方法,从上图中可以很直观的看出来。
这里没有用到
sp fp
寄存器,下面在介绍堆栈时会讲到,另外的一些寄存器有时候会当临时存储使用,所以可能会存储任何数据,不做介绍。
我们还可以向指定寄存器写入数据,读取单个寄存器的数据,如下:
// 修改寄存器x1 存储的数据
(lldb) register write x1 0x0000000000000001
// 读取 x1 单个寄存器的数据
(lldb) register read x1
x1 = 0x0000000000000001
上面介绍的寄存器用来存放指令和数据,控制寄存器的读取则是需要指令
来控制的。
3.指令
我们先来学习下如何在项目中编写汇编指令,然后再介绍一些指令,可以自己写着练习一下。
内嵌汇编格式:
__asm__ [关键词](
指令
: [输出操作数列表]
: [输入操作数列表]
: [被污染的寄存器列表]
);
如下:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
// volatile 关键字表示禁止编译器对汇编代码进行再次优化
__asm__ __volatile__(
"add x0, x0, x1"
);
}
3.1 传送指令
-
mov
:move
使用最频繁的指令,相当于高级语言中的赋值语句。
使用格式如下:
指令 | 描述 |
---|---|
mov x1, x0; |
将寄存器 x0 的值赋给 x1 |
mov x0, #0x8 |
将 0x8 存储到x1 |
3.2 运算指令
指令 | 描述 |
---|---|
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 (异或操作) |
3.3 寻址指令
涉及到内存寻址的问题。
3.3.1 取值指令
-
ld (load)
开头的是取值指令,如 :-
ldr
将内存中的数据,放到另一个寄存器中,偏移值是正值 -
ldur
和ldr
相同,偏移值为负值 -
ldp
:load pair
,一对寄存器
根据地址读取一对数据,按地址顺序赋值给一对寄存器
-
指令 | 描述 |
---|---|
ldr x0,[x1]; |
从 x1 指向的地址里面取出一个 64 位大小的数据存入 x0 |
ldur w8,[x29, #-0x8]; |
从x29-0x8的地址里面取出一个32位的数,存入w8 |
ldp x1,x2,[x10,#0x10]; |
从 x10+0x10指向的地址开始取出 2 个 64 位的数,分别存入 x1,x2 |
3.3.2 赋值指令
-
st (store)
开头的是存值指令,如:-
str
往内存中写数据(偏移值为正) -
stur
往内存中写数据(偏移值为负) -
stp
存放一对数据,从右边那个地址开始按顺序存放,寄存器写在左边,内存寻址写右边
-
指令 | 描述 |
---|---|
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这个指令哈 |
寻址方式:
-
[x10, #0x10]
表示从 x10 + 0x10 的地址取值 -
[sp, #-16]
表示从 sp - 16地址取值,取完后再把 sp-16 写回 sp -
[sp], #16
表示从 sp 取值,取完后把 sp+16写回 sp
3.4 cmp 指令、cpsr 寄存器
- cmp指令:compare比较
- cpsr:Current Program Status Register 程序状态寄存器
- spsr: Saved Program Status Register 异常状态下使用
cmp 是比较指令,其功能相当于是减法指令,只是不会保存结果,cmp 执行完成之后会对cpsr状态寄存器产生影响,其他指令通过读cpsr状态寄存器的条件标志位来得知比较结果。
示例:
- cmp x0,x1:意思是将寄存器x0的值与寄存器x1的值相减,并根据结果设置 cpsr 的标志位
- cmp x0, #0x88:意思是将寄存器 x0 的值与立即数 0x88 相减,并根据结果设置 cpsr 的标志位
示例:
可以看到我们将 x0 设置了 0x3,将 x2 设置了 0x2,然后使用 cmp 指令,最后 cpsr 寄存器的值为 0x20000000,转换成二进制位 00100000000000000000000000000000,按照上面那张图对比,可以发现第 31 30 位都是 0,说明计算结果为正数,结果非 0。
3.5 跳转指令
b
指令
跳转到目标地址执行函数,可以带条件跳转bl
指令:(bl
跟ret
配合使用)
跳转到目标地址执行完函数后返回原代码,继续往下执行,相当于在代码中调用一个函数,函数调用完后继续往下执行原代码。
上面介绍过
lr
寄存器,当 bl 跳转之前,会将下一行要执行的代码的地址存到lr寄存器
。
-
ret
指令:返回指令
将函数返回,也就是将lr(x30)寄存器
的存储的指令地址赋给pc寄存器
,然后继续执行。
有返回的意思是会存
lr(x30)
,意味着可以返回到本方法继续执行,一般用于不同方法直接的调用。
无返回的一般是方法内的跳转,如while 循环
,if else
等
跳转指令一般还伴随着条件,以实心点.
开头的都是表示条件,如 b.ne
,一般用于 if else
。常见的条件码有以下这些:
示例:
根据上上图,beq指令执行时,去会看cpsr中的z位看是否为1。
带 if else 的汇编:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
[self test];
}
- (void)test{
int a = 5;
int b = 3;
if(a > b){
printf("a > b");
}else{
NSLog(@"a <= b");
}
}
汇编代码:
4. 内存模型
4.1 堆
由于寄存器只能存放少量数据,在大多数的时候,CPU 指挥寄存器跟内存交换数据,所以除了寄存器还必须了解内存是怎么存储数据的。
程序运行的时候,操作系统会给它分配一段内存,用来存储程序和运行产生的数据。这段内存有起始地址和结束地址,比如从0x1000
到 0x8000
,起始地址是较小的那个地址,结束地址是较大的那个地址。
程序运行过程中,对于动态占用请求(比如新建对象,或者使用 malloc
),系统就会从预先分配好的那段内存之中,划出一部分给用户,具体规则是从其实地址开始划分(实际上,起始地址会有一段静态数据,这里忽略)。
举例来说,用户申请 10
个字节的内存,那么从起始地址0x1000
开始给他分配,一直分配到 0x100A
,如果再申请 22
个字节,那么就分配到0x1020
。
这种因为用户主动请求而划分出来的内存区域,叫做堆(Heap
)。它由起始地址开始,从低位(地址)向高位(地址)增长。Heap
的一个重要特点是不会自动消失,必须手动释放,或者由垃圾回收机制来回收。
4.2 栈
除了堆(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
。5. 举例
了解完以上的基础知识之后,下面就用一个简单的例子了解汇编的栈操作。
// 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
等以 '.' 开头的被称为编译指令
,用于告知编译器相关的信息或者特定操作。
指令 | 介绍 |
---|---|
__TEXT,__text |
.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 // 将栈顶指针向下移动 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
接下来看:
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+16~sp+12
这块内存空间, 存储是从低地址到高地址,也就是存在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函数汇编实现
接着,再看看main
函数的汇编代码就变得很好理解了:
- 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
参考资料:
阮一峰-汇编语言入门教程
x86 与 ARM 的爱恨情仇
ARM64汇编基础
为什么使用汇编可以 Hook objc_msgSend
ARM64 汇编
iOS 的内嵌汇编