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寄存器是按位起作用的,每一位都有专门的含义,记录特定的信息,如下图所示:
image.png
- 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 的内嵌汇编
