前言
本篇文章主要讲解
- 状态寄存器
- 判断、选择和循环
一、状态寄存器(CPSR)
什么是状态寄存器
?
CPU内部的寄存器中,有一种特殊的寄存器(对于不同的处理器,个数和结构都可能不同)。这种寄存器在ARM中,被称为状态寄存器CPSR(current program status register)
。
与其它寄存区的区别
- 其它寄存器是用来存放数据的,整个寄存器只具有
一个含义
。 - CPSR寄存器是
按位起作用的
,也就是说它的每一位都有专门的含义
,记录特定的信息。
位域分布
CPSR寄存器是32位
的,其分布大致如下
- CPSR的
低8位
(包括I、F、T和M[4:0]
)称为控制位
,程序无法修改
,除非CPU运行于特权模式下,程序才能修改控制位! -
8~27位
为保留位
。 -
N、Z、C、V
均为条件码标志位
。它们的内容可被算术或逻辑运算的结果所改变,并且可以决定某条指令是否被执行!意义重大!
整体分布如下图
示例查看CPSR
接下来,我们通过一个简单的示例,看看控制台中CPSR的值,示例
void funcA() {
int a = 1;
int b = 2;
if (a == b) {
printf("a == b");
} else {
printf("error");
}
}
查看汇编
接下来通过lldb修改cpsr的值
可以看到,我们通过修改cpsr的值,强行改变了代码的执行逻辑,最终执行了printf("a == b");
。
内联汇编
我们想在oc文件中写汇编的代码,除了在01-汇编基础(1)中新建汇编.s格式
的文件这种方式外,还有一种方式 内联汇编
。
在C/OC代码中嵌入汇编需要使用asm关键字(也可以使用asm、__asm。这个和编译器有关,在iOS环境下它们等价。),在asm的修饰下,代码列表、输出运算符列表、输入运算符列表和被更改的资源列表这4个部分被3个“:”分隔
asm(
代码列表
: 输出运算符列表
: 输入运算符列表
: 被更改资源列表
);
swift中貌似没有办法直接
内联汇编
,但可以通过和OC的桥接
去处理。
接下来我们开看看CPSR中最高的4位 N Z C V
,每个位所表示的具体含义
1.1 N(Negative)(符号标志位)
CPSR的第31位是 N 符号标志位
。它记录相关指令执行后,其结果是否为负
。
- 如果为负 N = 1
- 如果是非负数 N = 0
示例演示
接下来我们执行一个简单的汇编指令,看看N
符号标志位的值
void funcA() {
asm(
"mov w0,#0xffffffff\n"
"adds w0,w0,#0x0\n"
);
}
- 执行adds指令前,cpsr为
0x60000000
,高4位为0110
,那么N = 0
- 执行adds指令完成后,cpsr =
0x80000000
,高4位为1000
,N = 1
⚠️注意:在ARM64的指令集中,有的指令的执行时影响状态寄存器的,比如
add\sub\or
等,他们大都是运算指令(进行逻辑或算数运算)
;
1.2 Z(Zero)(0标志位)
CPSR的第30位是 Z 0标志位
。它记录相关指令执行后,其结果是否为0
。
- 如果结果为0,那么 Z = 1
- 如果结果不为0,那么 Z = 0
⚠️注意,结果值 和 Z值 是
反
的!
我们可以这么理解
- 在计算机中
1表示逻辑真
,表示肯定;0表示逻辑假
,表示否定。 - 当
结果为0
的时候表示结果是0
这个条件是肯定的为真
,所有Z = 1
-
结果不为0
的时候表示结果是0
这个条件是否定的为假
,所以Z = 0
示例演示
看下面这个示例
void funcA() {
asm(
"mov w0,#0x0\n"
"adds w0,w0,#0x0\n"
);
}
- adds执行前
cpsr值 0x60000000
,其中 Z = 1
- adds执行后
cpsr值 0x40000000
,其中 Z = 0
修改一下示例代码
void funcA() {
asm(
"mov w0,#0x0\n"
"adds w0,w0,#0x1\n"
);
}
读者可自行操作,查看该情况下cpsr值的变化。
同样的操作adds断点前后
cpsr分别为:cpsr = 0x60000000
和cpsr = 0x00000000
对应N = 1
和 N = 0
。
1.3 C(Carry)(进位标志位)
CPSR的第29位是C 进位标志位
。一般情况下,进行无符号数
的运算。
-
加法
运算 当运算结果产生了进位
时(无符号数溢出
),C=1,否则C=0。 -
减法
运算(包括CMP
) 当运算时产生了借位
时(无符号数溢出
),C=0,否则C=1。
对于位数为N的无符号数来说,其对应的二进制信息的最高位,即第N - 1位,就是它的最高有效位,而假想存在的第N位,就是相对于最高有效位的更高位。如下图所示
进位 & 借位
上面提到了进位
和借位
,我们先来解释下它们。
进位
先看看进位
的情况,我们知道,当两个数据相加的时候,有可能产生从最高有效位向更高位的进位。
比如两个32位数据:0xaaaaaaaa + 0xaaaaaaaa
,将产生进位
。由于这个进位值在32位中无法保存,我们就只是简单的说这个进位值丢失
了。其实CPU
在运算的时候,并不丢弃
这个进位制,而是记录
在一个特殊的寄存器的某一位上
。ARM
下就用C位
来记录这个进位值。比如,下面的指令
void funcA() {
asm(
"mov w0,#0xaaaaaaaa\n"//0xa 的二进制是 1010
"adds w0,w0,w0\n" // 执行后 相当于 1010 << 1 进位1(无符号溢出) 所以C标记 为 1
"adds w0,w0,w0\n" // 执行后 相当于 0101 << 1 进位0(无符号没溢出) 所以C标记 为 0
"adds w0,w0,w0\n" // 重复上面操作
"adds w0,w0,w0\n"
);
}
- adds执行前
- 第1次adds执行后
- 第2次adds执行后
- 第3次adds执行后
- 第4次adds执行后
综上,cpsr的值是这么变化的
0x60000000 0x30000000 0x90000000 0x30000000 0x90000000
对应的高4位的值的变化就是
0110 0011 1001 0011 1001
所以,第1次和第3次相加,无符号溢出了,所以 C = 1;而第2次和第4次相加,无符号没有溢出,所有C = 0。
借位
再开看看借位
的情况,当两个数据做减法
的时候,有可能
向更高位借位
。
比如两个32位数据:0x00000000 - 0x000000ff
将产生借位
,借位后相当于计算0x100000000 - 0x000000ff
得到0xffffff01
这个值。由于借了一位,C位
用来标记借位
,所以 C = 0
。
比如下面指令
void funcA() {
asm(
"mov w0,#0x0\n"
"subs w0,w0,#0xff\n"
"subs w0,w0,#0xff\n"
"subs w0,w0,#0xff\n"
);
}
和进位
的情况一样的调试,这里就不做演示了,得到的结果
cpsr的值是这么变化的
0x60000000 0x80000000 0xa0000000 0xa0000000
对应的高4位的值的变化就是
0110 1000 1010 1010
所以,第一次相减借了一位,无符号溢出
了,C=0;第2次和第3次相减时,无符号没有溢出
,所以C=1。
1.4 V(Overflow)(溢出标志)
CPSR的第28位是V 溢出标志位
。在进行有符号数运算的时候,如果超过
了机器所能标识的范围
,称为溢出
。
- 正数 + 正数 为负数 溢出
- 负数 + 负数 为正数 溢出
- 正数 + 负数 不可能溢出
- 溢出
V = 1
,不溢出V = 0
由于CPU
并不知道有没有符号,所以CPSR
寄存器CV同时标记
,C
标记无符号
,V
标记有符号
。标志位会同时返回
。
理解起来不难,这里就不做示例演示了。
二、判断、选择和循环
在讲判断、选择和循环
之前,我们先来看看内存的五大分区
-
栈区
:参数、局部变量、临时数据。可短可写 -
堆区
:动态申请。可读可写 -
全局静态区
:可读可写 -
常量区
:只读 -
代码区
:存放代码,可读可执行
详细的说明可以参考我之前写的内存五大分区。
2.1 基础知识点
全局变量和常量
全局变量和常量,在汇编中是怎么读取值的?我们先来看看下面这个例子
int g = 12;
int func(int a, int b) {
printf("test");
int c = a + g + b;
return c;
}
查看汇编
上图我们通过查看x0寄存器值可知,printf函数的参数来源为
0x102c6dc54 <+20>: adrp x0, 1
0x102c6dc58 <+24>: add x0, x0, #0x5ec ; =0x5ec
X0存储的是一个地址,为字符串常量区
。那么以上两条指令是怎么计算得出0x0000000102c6e5ec
这个值?
-
adrp
Address Page
内存地址以页寻址
。 -
0x102c6dc54 <+20>: adrp x0, 1
定位到某一页数据的开始(文件起始位置
)- 将
1
的值左移12位
变成0x1000
- 当前pc的值低12位清零。
0x102c6dc54 -> 0x102c6d000
。 -
0x102c6d000 + 0x1000
得到0x102c6e000
。相当于pc后3位置为0,第4位加上x0后跟的值。
- 将
-
0x102c6dc58 <+24>: add x0, x0, #0x5ec
偏移地址(当前代码偏移)-
0x102c6e000 + 0x5ec
得到0x102c6e5ec
-
这样就得到了常量区字符串"test"
的地址了。
其中,0x102c6d000尾数为000意味着000~fff -> 0~4095
大小为4096也就是4k
。也就是定位到某一页数据的开始。
- mac中 pagesize 4k
- iOS中 pagesize 16k。这里是兼容的 4k * 4 = 16k。
我们继续调试,查看全局变量g
的汇编处理
上图红框处的指令,和上面一样的,最终计算出x9最终的值为0x0000000102c715f0
,也就是全局变量g的值
。
综上所述,全局变量和常量都是通过一个
基地址 + 偏移
获取。
反汇编工具还原
接下来,我们使用反汇编工具,演示一下将汇编代码还原成高级代码
。
- 首先,编译要还原的工程,进入.app找到macho文件并拖入
Hopper
中
- 反汇编工具
Hopper
分析完成后,搜索
要分析的函数
- 先看看汇编的代码
0000000100005c40 sub sp, sp, #0x20 ; CODE XREF=-[ViewController viewDidLoad]+76
0000000100005c44 stp x29, x30, [sp, #0x10]
0000000100005c48 add x29, sp, #0x10
0000000100005c4c stur w0, [x29, #-0x4]
0000000100005c50 str w1, [sp, #0x8]
0000000100005c54 adrp x0, #0x100006000 ; argument #1 for method imp___stubs__printf
0000000100005c58 add x0, x0, #0x5ec ; "test"
0000000100005c5c bl imp___stubs__printf
0000000100005c60 ldur w8, [x29, #-0x4]
0000000100005c64 adrp x9, #0x100009000
0000000100005c68 add x9, x9, #0x5f0 ; _g
0000000100005c6c ldr w10, [x9] ; _g
0000000100005c70 add w8, w8, w10
0000000100005c74 ldr w10, [sp, #0x8]
0000000100005c78 add w8, w8, w10
0000000100005c7c str w8, [sp, #0x4]
0000000100005c80 ldr w8, [sp, #0x4]
0000000100005c84 mov x0, x8
0000000100005c88 ldp x29, x30, [sp, #0x10]
0000000100005c8c add sp, sp, #0x20
0000000100005c90 ret
上面的"test"那行是0x100006000 + 0x5ec = 0x1000065ec
。
- 可以通过MachOView查找
0x1000065ec
- 同理,查看
全局变量g
0000000100005c64 adrp x9, #0x100009000
0000000100005c68 add x9, x9, #0x5f0 ; _g
全局变量g
的地址就是0x1000095f0
- 接着,我们将上面的汇编代码还原
0000000100005c40 sub sp, sp, #0x20 ; CODE XREF=-[ViewController viewDidLoad]+76
// 拉伸栈空间
0000000100005c44 stp x29, x30, [sp, #0x10]
0000000100005c48 add x29, sp, #0x10
// 参数入栈w0 w1
0000000100005c4c stur w0, [x29, #-0x4]
0000000100005c50 str w1, [sp, #0x8]
// 取常量“test”
0000000100005c54 adrp x0, #0x100006000 ; argument #1 for method imp___stubs__printf
0000000100005c58 add x0, x0, #0x5ec ; "test"
// 调用printf("test")
0000000100005c5c bl imp___stubs__printf
// w8 = a;
0000000100005c60 ldur w8, [x29, #-0x4]
// 取全局变量g
0000000100005c64 adrp x9, #0x100009000
0000000100005c68 add x9, x9, #0x5f0 ; _g
// w10 = g
0000000100005c6c ldr w10, [x9] ; _g
// w8 += w10
0000000100005c70 add w8, w8, w10
// w10 = b
0000000100005c74 ldr w10, [sp, #0x8]
// w8 += w10
0000000100005c78 add w8, w8, w10
// 返回值存入x0
0000000100005c7c str w8, [sp, #0x4]
0000000100005c80 ldr w8, [sp, #0x4]
0000000100005c84 mov x0, x8
0000000100005c88 ldp x29, x30, [sp, #0x10]
// 栈平衡
0000000100005c8c add sp, sp, #0x20
0000000100005c90 ret
经过上面的还原,不就是之前的原石代码么。。。结果完全一样!大家可以照着这个示例实操一遍,加深印象!
2.2 判断
接下来,我们来看看判断
逻辑在汇编中是怎么执行的。
if
先看看我们最为熟悉的if判断
,例如
int g = 12;
void func(int a, int b) {
if (a > b) {
g = a;
} else {
g = b;
}
}
我们直接用上面的反汇编工具Hopper
来查看汇编
上图的汇编难吗?其实很简单,做了一个cmp比较
,然后执行代码块1
和代码块2
,就是满足了if的代码块
和else的代码块
。
cmp指令
cmp
把一个寄存器的内容和另一个寄存器的内容或立即数进行比较
,但不存储结果
,只是正确的更改标志(cpsr)
。
一般cmp做完判断后会进行跳转
,后面通常会跟上b指令
!
b 跳转指令
b
本身代表跳转
,后面跟标号
会有其他操作:
指令名称 | 指令含义 |
---|---|
bl |
跳转到标号处执行,并且影响lr寄存器 的值。用于函数返回。 |
br |
根据寄存器中的值跳转。 |
b.gt |
比较结果是 大于(greater than) 执行标号,否则不跳转。 |
b.ge |
比较结果是 大于等于(greater than or equal to) 执行标号,否则不跳转。 |
b.lt |
比较结果是 小于(less than) 执行标号,否则不跳转。 |
b.le |
比较结果是 小于等于(less than or equal to) 执行标号,否则不跳转。 |
b.eq |
比较结果是 等于(equal) 执行标号,否则不跳转。 |
b.ne |
比较结果是 不等于(not equal) 执行标号,否则不跳转。 |
b.hi |
比较结果是 无符号大于 执行标号,否则不跳转。 |
b.hs |
比较结果是 无符号大于等于 执行标号,否则不跳转。 |
b.lo |
比较结果是 无符号小于 执行标号,否则不跳转。 |
b.ls |
比较结果是 无符号小于等于 执行标号,否则不跳转。 |
⚠️注意:
cmp后
跟的标号条件是else
。
再回过头看示例的汇编
0000000100005c7c b.le loc_100005c94
执行的是b.le
,所以代码块1
就是满足if大于
的情况,代码块2
就是else
的情况。
2.3 循环
然后,我们看看循环
的逻辑在汇编中执行的是什么指令。
2.3.1 do-while
首先看看do-while
循环,例如
void func() {
int nSum = 0;
int i = 0;
do {
nSum = nSum + 1;
i++;
} while (i < 100);
}
Hopper汇编
上图汇编也很简单
- 先初始化2个变量,2变量的地址是
0xc
和0x8
,刚好每个4字节
(对应int类型
) - 接着执行循环
do
的部分 -
cmp
就是while的判断条件,b.lt
就是满足条件后跳转到do
部分
2.3.2 while
一样,先看示例
void func() {
int nSum = 0;
int i = 0;
while (i < 100) {
nSum = nSum + 1;
i++;
}
}
2.3.3 for
最后我们来看看最常用的for循环,例子
void func() {
int nSum = 0;
for (int i = 0; i < 100; i++) {
nSum = nSum + 1;
}
}
⚠️注意:
for
和while
的汇编中,条件都是通过b.ge
来判断的。
2.4 选择
最后我们来看看选择
逻辑在汇编中执行的是什么指令。
Switch 选择
void func(int a) {
switch (a) {
case 1:
printf("case 1");
break;
case 2:
printf("case 2");
break;
case 3:
printf("case 3");
break;
default:
printf("case default");
break;
}
}
case > 3个的情况
void func(int a) {
switch (a) {
case 1:
printf("case 1");
break;
case 2:
printf("case 2");
break;
case 3:
printf("case 3");
break;
case 4:
printf("case 4");
break;
default:
printf("case default");
break;
}
}
上图是Hopper中分析的汇编代码,除了开始做的w8-=1之外,其余代码块的代码,接下来我们仔细分析一下
- 代码块1
0000000100005be0 mov x9, x8
0000000100005be4 ubfx x9, x9, #0x0, #0x20
0000000100005be8 cmp x9, #0x3
0000000100005bec str x9, [sp]
-
mov x9, x8
x8寄存器的值给x9寄存器,就是参数的值
。 -
ubfx x9, x9, #0x0, #0x20
ubfx
的意思是针对位
来清零
(⚠️从高位开始
),那么就是将x9的地址值中的0~32位
清零(0x0
十进制即0
,0x20
十进制即32
) -
cmp x9, #0x3
比较 x9 和 0x3 的值。这里0x3
是最大 case - 最小 case
的差值
。 -
str x9, [sp]
x9入栈,也就是x8的低32位
入栈。
综上,就是参数 - 最小case - (最大case - 最小case)
,如果b.hi
无符号大于了,就直接跳转去default分支
。
- 代码块2
0000000100005bf4 adrp x8, #0x100005000
0000000100005bf8 add x8, x8, #0xc64
这里很简单,根据上篇文章02-汇编基础(2)中对adrp指令
的分析可知,x8中存储的地址就是0x100005c64
。
- 代码块3
0000000100005bfc ldr x11, [sp]
0000000100005c00 ldrsw x10, [x8, x11, lsl #2]
0000000100005c04 add x9, x8, x10
0000000100005c08 br x9
-
ldr x11, [sp]
从栈中取数据给x11,栈中目前是x9。x9为x8的低32位
-
ldrsw x10, [x8, x11, lsl #2]
lsl #2
左移2位的意思,那么这句的意思就是x10 = x8 + (x11 << 2)
-
add x9, x8, x10
很简单,x9 = x8 + x10
-
br x9
根据x9寄存器中的值进行跳转。
举例算x9
现在,我们来分析下x9
中的值的计算过程
(假如参数是2,即调用的func(2)
)
- x9最开始跟x8(即入参值)有关,,那么x9是x8的低32位,x9的值就是1(经过了
subs w8, w8, #0x1
减1了),那么ldr x11, [sp]
x11也是1 - 接着经过
ldrsw x10, [x8, x11, lsl #2]
,1(x11) << 2 = 4,然后4 + 0x100005c64(x8的地址) = 0x100005c68(x10的值),查询代码块5
,可知x10的值是0xffffffb8
0xffffffb8
对应的十进制是-72
- 接着
add x9, x8, x10
,因为add指令
算的是十六进制
,x10是-72,对应的十六进制是0x48
,所以x8+x10 = 0x100005c64 - 0x48(负数变减法) = 0x100005C1C = x9
,最终x9就是0x100005C1C
- 代码块4
0000000100005c0c adrp x0, #0x100006000
0000000100005c10 add x0, x0, #0x5c8
0000000100005c14 bl imp___stubs__printf
0000000100005c18 b _func+144
0000000100005c1c adrp x0, #0x100006000
0000000100005c20 add x0, x0, #0x5cf
0000000100005c24 bl imp___stubs__printf
0000000100005c28 b _func+144
0000000100005c2c adrp x0, #0x100006000
0000000100005c30 add x0, x0, #0x5d6
0000000100005c34 bl imp___stubs__printf
0000000100005c38 b _func+144
0000000100005c3c adrp x0, #0x100006000
0000000100005c40 add x0, x0, #0x5dd
0000000100005c44 bl imp___stubs__printf
0000000100005c48 b _func+144
很明显,该代码块的汇编是在执行case代码块
的逻辑,上面例子中最终得到x9的值是0x100005C1C
,正好跳去case 2
的汇编
- 代码块5
0000000100005c64 db 0xa8 ; '.' ; DATA XREF=_func+48
0000000100005c65 db 0xff ; '.'
0000000100005c66 db 0xff ; '.'
0000000100005c67 db 0xff ; '.'
0000000100005c68 db 0xb8 ; '.'
0000000100005c69 db 0xff ; '.'
0000000100005c6a db 0xff ; '.'
0000000100005c6b db 0xff ; '.'
0000000100005c6c db 0xc8 ; '.'
0000000100005c6d db 0xff ; '.'
0000000100005c6e db 0xff ; '.'
0000000100005c6f db 0xff ; '.'
0000000100005c70 db 0xd8 ; '.'
0000000100005c71 db 0xff ; '.'
0000000100005c72 db 0xff ; '.'
0000000100005c73 db 0xff ; '.'
该代码块就好比是一张表
,可以根据地址查到该地址对应存储的值
。
汇编执行过程总结
- 首先通过
参数 - 最小case
得到表中index
-
index
与(最大case - 最小case)
无符号比较
判断是否在区间内。-
不在
区间内跳转defalult
-
在
区间内表头地址 + index << 2获取偏移地址(为负数)
-
- 根据偏移的地址执行对应
case逻辑
。
⚠️注意:表中为什么不直接存地址? 1.地址过长 2.有ASLR的存在
Switch小结
switch语句的
分支< 3的时候
没有必要使用表结构,相当于if
。各个分支常量的差值较大的时候,编译器会在效率还是内存进行取舍,这个时候编译器还是会编译成类似于if-else的结构。比如:100、200、300、400这种case还是和
if-else
相同,10、20、30、40会生成一张表。所以在写switch逻辑的时候最好使用连续的值
。至于具体逻辑编译器会根据case和差值进行优化选择。case越多,差值越小,数值越连贯
编译器会生成跳转表
,否则还是if-else
。在分支比较多的时候:在编译的时候会生成一个
表
(跳转表每个地址四个字节)。跳转表中数量为
最大case - 最小case + 1
为一共有多少种可能性。case分支的代码地址是连续的
,使用的是用空间换时间
的思想。
总结
- 状态(标志)寄存器 CPSR
- ARM64中cpsr寄存器(32位)为状态寄存器
- 最高4位(28,29,30,31)为标志位。NZ(执行结果) CV(无符号/有符号溢出)
- N标志(负标记位)
- 执行结果负数 N = 1,非负数 N = 0
- Z标志(0标记位)
- 结果为0 Z = 1,结果非0 Z = 0
- C标志(无符号数溢出)
- 加法:进位 C = 1,否则 C = 0
- 减法:借位 C = 0,否则 C = 1
- V标志(有符号数溢出)
- 正数 + 正数 = 负数 溢出 V = 1
- 负数+ 负数 = 正数 溢出 V = 1
- 正数 + 负数 不可能溢出 V = 0
- 溢出
V = 1
,不溢出V = 0
- N标志(负标记位)
- 判断、选择和循环