条件语句
我们直接通过编译器来学习汇编代码:
int g = 12;
void func(int a, int b){
if (a > b) {
g = a;
} else {
g = b;
}
}
func(1, 2);
我们看下汇编代码:
我们发现了几个陌生的汇编指令,依次来学习下:
adrp
先来看下adr的解释:
这是一条小范围的地址读取指令,它将基于PC的相对偏移的地址读到目标寄存器中; 使用格式:ADR register exper
后面加了p就变成了以page的形式做偏移,一页page的的大小为0x1000
,也就是4k。当然,这里的4k是一个助记符,并不能代表当前设备内存的一大小就是4k,我们在mac终端运行命令pagesize
:
我们看到当前设备mac上的一页内存正好是4k,但是在手机上却是16k,可以在越狱手机上试下。
那么adrp如何计算地址呢?
- 我们先取pc寄存器所在的内存页地址,调用adrp的命令地址为
0x10472ddf4
,那么当前内存页地址为0x10472d000
,只要把最后3位变成0,就是当前的内存页地址。 - 第二步以page为单位做偏移,这里偏移了8个单位,也就是
8 * 0x1000
- 最后我们把基地址加上偏移地址,就能得到命令的目标值,也就是
0x10472d000 + 8 * 0x1000
,最后得到0x104735000
把0x104735000
存入x9寄存器后,又add上了一个偏移值#0x3d0
,拿到了全局变量g的地址0x1047353d0
,我们验证下对不对:
我们先减去ASLR
的偏移值0x0000000104728000
:
得到结果:0xd3d0
,我们静态文件里查看:
发现了全局变量值12,验证正确。
cmp
比较指令,本质上执行的是减法运算,并不改变被比较的值,但是会更新状态寄存器CPSR的值,为后面的b指令提供依据。
CMP 把一个寄存器的内容和另一个寄存器的内容或立即数进行比较。但不存储结果,只是正确的更改标志。 一般CMP做完判断后会进行跳转,后面通常会跟上B指令!
b指令
b本身代表着跳转,如果后面跟着其它符号,会有其它的操作:
- bl:跳到标号处执行,并且在lr寄存器里存放下个指令的地址值,给函数返回用的
- b.gt:比较结果是大于(greater than),执行标号,否则不跳转
- b.ge:比较结果是大于等于(greater than or equal to),执行标号,否则不跳转
- b.lt:比较结果是小于(less than),执行标号,否则不跳转
- b.le:比较结果是小于等于(less than or equal to),执行标号,否则不跳转
- b.eq:比较结果是等于,执行标号,否则不跳转
- b.hi:比较结果是无符号大于,执行标号,否则不跳转
- b.hs:比较结果是无符号大于等于,执行标号,否则不跳转
- b.lo:比较结果是无符号小于,执行标号,否则不跳转
- b.ls:比较结果是无符号小于等于,执行标号,否则不跳转
while循环
do while语句
我们写段简单的代码:
void func() {
int sum = 0;
int i = 0;
do {
sum += 1;
i++;
} while (i < 100);
}
看下汇编代码:
还是比较好理解的,先执行代码,然后碰到while语句的时候执行cmp指令,和0x64
比较,也就是和100
比较,如果条件符合的话,跳到上面重新执行代码。
这里新出现一个新的寄存器wzr,这个比较特殊,代表着0。
while语句
把上面的代码改成while结构的:
void func() {
int sum = 0;
int i = 0;
while (i < 100) {
sum += 1;
i++;
}
}
看下汇编代码:
这个也很容易理解,调用cmp指令,如果符合指令则跳到下面b指令的后面一行,我们代码中写的是
i < 100
,而cmp后的比较是b.ge
,是大于等于,相当于跳出循环,而循环靠的是下面的b指令。
for循环
我们在改成for循环看一下:
void func() {
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += 1;
}
}
看汇编:
基本上和while循环一样,不在多说了。
switch语句
switch
的汇编代码会分成两种形式,其中一种会转化成if else
实现,以下情况会转成if else
实现:
- 当case语句小于等于3个的时候,会变成
if else
。 - 当case语句的数值相差过大时,会变成
if else
。如何定义相差过大,我们姑且把这个概念成为离散程度,我记得上学的时候学数据统计,里面有个方差的求值来判断离散程度,我估计编译器的处理应该也差不多。
我们写个demo:
void func(int a) {
switch (a) {
case 1:
printf("aaa");
break;
case 2:
printf("bbb");
break;
case 3:
printf("ccc");
break;
default:
printf("default");
break;
}
}
或者:
void func(int a) {
switch (a) {
case 1:
printf("aaa");
break;
case 10:
printf("bbb");
break;
case 100:
printf("ccc");
break;
case 1000:
printf("ddd");
break;
default:
printf("default");
break;
}
}
我们看下汇编:
红色的框是case语句的比较,蓝色的框跳转是default语句,橙色的框是每个分支结束跳转函数末尾。
本质上,我们不难发现是用if else
实现的,上面两个demo都是这样的跳转方式。
接下来我们看另一种汇编实现形式:
void func(int a) {
switch (a) {
case 1:
printf("aaa");
break;
case 4:
printf("bbb");
break;
case 3:
printf("ccc");
break;
case 6:
printf("ddd");
break;
default:
printf("default");
break;
}
}
我们通过汇编调用printf函数的情况,发现几个case的实现都连在一起了(红框),跳转的地方一共发现两处,一个是b.hi
,一个是br
,其中b.hi
明显是default的跳转,而br
跳转的地方是寄存器的值,也就是x9中存放的地址,这个地址是多少呢?
先讲原理会好理解一点:我们看到这个switch中case的最小值是1,最大值是6,那么系统会建一张表,为1-6每个值存放一个汇编的实现地址,就是刚截图的红框。因为每次启动的ASLR的地址会不同,所以表里放的是相对与表头的偏移地。
我们从上方的汇编查看下表头的位置,这里有两个陌生的指令:
ubfx x9, x9, #0, #32
:这个是把x9寄存器的0-32位取出来赋值给x9寄存器,其余位置补0。ldrsw x10, [x8, x11, lsl #2]
:lsl是逻辑向左位移,所以这边是x11向左位移2位,加上x8,取出该地址的值放入x10。
我们逐步分析下:
//把参数w0放入[x29, #-0x4]栈中,并且存入w8
0x100251d50 <+12>: stur w0, [x29, #-0x4]
0x100251d54 <+16>: ldur w8, [x29, #-0x4]
//把我们的参数值减去1,这个1是case的最小数,这样w8表示的就是表格的第几位index了
0x100251d58 <+20>: subs w8, w8, #0x1 ; =0x1
//index值取低32位赋值给x9
0x100251d5c <+24>: mov x9, x8
0x100251d60 <+28>: ubfx x9, x9, #0, #32
//把index和5比较,这个5是case的最大值和最小值的差,看看index在不在0-5的区间内,如果不在,直接跳转default逻辑。这里很有意思,如果index值比5大的话,那么自然跳转default逻辑,那如果index的值比0小的话,说明是负数,但是负数用无符号比较的话(b.hi),首位bit位是f,数字又变的奇大无比,又变成了大于5了,所以这边只要判断无符号大于5就行了,设计的很巧妙
0x100251d64 <+32>: cmp x9, #0x5 ; =0x5
//把index值存入栈中[sp]
0x100251d68 <+36>: str x9, [sp]
//判断无符号大于跳转default分支
0x100251d6c <+40>: b.hi 0x100251dc8 ; <+132> at ViewController.m
//这里就是拿到表头的地址了,算出来是0x100251de0,仔细看下这个值,很有意思,正好在这个函数的后面。所以,如果switch设计成表结构的话,那么这张表紧跟着调用switch函数的后面
0x100251d70 <+44>: adrp x8, 0
0x100251d74 <+48>: add x8, x8, #0xde0 ; =0xde0
//把前面算出来的index取出放入x11
0x100251d78 <+52>: ldr x11, [sp]
//lsl #2,是把x11向左位移2位,也就是index * 4了,不难看出表格的单位大小是4个字节,表头地址加上index * 4后,就能拿到对应分支的实现的相对偏移地址了,赋值给了x10了
0x100251d7c <+56>: ldrsw x10, [x8, x11, lsl #2]
//上一步的x10的相对位移地址,是相对于表头的,所以这边加上表头的地址,就能拿到分支实现的汇编地址了
0x100251d80 <+60>: add x9, x8, x10
//跳转的实现的分支地址
0x100251d84 <+64>: br x9
...
0x100251ddc <+152>: ret
我们拿到表头的地址,看下具体长什么样:
表里一共放了6个负数,正好对应了0-5这6个数,我们可以算下(这里第一格是0xffffffa8,是低地址)。
正好可以找到上方分支的实现。
我们比较一下switch的两种实现思路,第一种是化身成if else
,从第一个比较到最后一个,很明显,算法复杂度是O(n)的。而用第二种方法建表,只要把你要比较的值减去case的最小值,就能拿到对应的分支,算法的复杂度为O(1)的。很明显,如果n很大的话,第二种的效率远远大于第一种,但是第二种空间复杂度就高了,以空间换取时间。
所以我们回头看下系统如何平衡这两种方法的,当n比较小,这里是小于等于3个的时候,用的方法1,因为数量太小了,第二种算法几乎没有快多少,用方法一还能节省点空间。还有种情况也是调用方法1,那是因为case值的离散程度很大,那么开辟的空间很大,只是为了算法快一点而得不偿失。
这里顺带提一下swift的enum,swift中的enum底层存的值就是从0开始的数,而所谓的rawValue是计算属性,是swift自动帮我们生成的属性,所以rawValue不管设置的是整数类型、字符串类型,还是浮点型,底层存的还是0、1、2、3、4、5......,这样设计的话,如果我们switch判断swift的enum,汇编生成方法大概率是方法2了。