古器合尺度,法物应矩规。--苏洵
一、什么是函数
可执行程序是为了实现某个功能而由不同机器指令按特定规则进行组合排列的集合。无论高级还是低级程序语言,无论是面向对象还是面向过程的语言最终的代码都会转化为一条条机器指令的形式被执行。为了管理上的方便和对代码的复用,往往需要将某一段实现特定功能的指令集合进行抽离和处理从而形成了函数的概念,函数也可以称之为子程序或者子例程。出现函数的概念后可执行程序的机器指令集合将不再是单一的一块代码,而是由多个函数组成的分块代码,这样可执行程序就变成了由函数之间相互调用这种方式来构建和组织了。
一个函数由函数签名、参数、返回、实现四部分组成。函数的前三者定义了明确的边界信息,也称之为函数接口描述。函数接口描述的意义在于调用者不再需要了解被调用者函数的实现细节,而只需要按被调用者的定义的接口进行交互即可。如何去定义一个函数,如何去实现一个函数,如何去调用一个函数,如何将参数传递给被调用的函数,如何使用被调用者函数的返回这些都需要有统一的标准规范来进行界定,这个规则有两个层面的标准:在高级语言层面的规则称之为API规则;而在机器指令层面上则由于不同的操作系统以及不同的CPU体系结构下提供的指令集和构造程序的方式不同而不同,所以在系统层面的规则称之为ABI规则。本文的重点是详细介绍函数调用、函数参数传递、函数返回值这3个方面的ABI规则,通过对这些规则的详细介绍相信您对什么是函数就会有更加深入的了解。需要注意的是这里的ABI规则是指基于OC语言实现的程序的ABI规则,这些规则并不适用于通过Swift实现的程序以及不适用于Linux等其他操作系统的ABI规则。
由于内容过多因此我将分为两篇文章来做具体介绍,前一篇文章介绍函数接口相关的内容,后一篇文章介绍函数实现相关的内容。
二、函数调用
CPU中的程序计数器(IP/PC)中总是保存着下一条将要执行的指令的内存地址,这样每执行一条指令就会更新程序计数器中的值,从而可以继续执行下一条指令。系统就是这样通过不停的变化程序计数器中的值来实现程序指令的执行的。一般情况下程序计数器中的值总是按照程序指令顺序更新,只有在执行跳转指令和函数调用指令时才会打破执行的顺序。
函数调用的本质就是将函数在内存中的首地址赋值给程序计数器(IP/PC),这样下一条执行的指令就变为了函数首地址处的指令,从而实现函数的调用。除了要更新程序计数器的值外还需要保存调用现场,以便当函数调用返回后继续执行函数调用的下一条指令,所以这里所谓的保存调用现场就是将函数调用的下一条指令的地址保存起来。不同的CPU体系都提供了特定的函数调用指令来实现函数调用的功能。比如x86系统提供一条称之为call的指令来实现函数调用,call指令除了会更新程序计数器的值外还会把函数调用的下一条指令压入到栈中进行保存;arm系统则提供b系列的指令来实现函数调用,b系列指令除了会更新程序计数器的值外还会把函数调用的下一条指令保存到LR寄存器中。
函数返回的本质就是将前面说到的保存的调用现场地址赋值给程序计数器,这样下一条执行的指令就变为了调用者调用被调函数的下一条指令了。不同的CPU体系也都提供了特定的函数返回指令来实现函数返回的功能(arm32位系统除外)。比如x86系统提供一条称之为ret的指令来实现函数返回,此指令会将栈顶保存的地址赋值给程序计数器然后执行出栈操作;arm64位系统也提供一条ret指令来实现函数的返回,此指令则会把当前的LR寄存器的值赋值给程序计数器。
对于x86系统来说因为执行函数调用前会将调用者的下一条指令压入栈中,而被调用者函数内部因为有本地栈帧(stack frame)的定义又会将栈顶下移,所以在被调用者函数执行ret指令返回之前需要确保当前堆栈寄存器SP所指向的栈顶地址要和被调用函数执行前的栈顶地址保持一致,不然当ret指令执行时取出的调用者的下一条指令的值将是错误的,从而会产生崩溃异常。
对于arm系统来说因为LR寄存器只有一个,因此如果被调用函数内部也调用其他函数时也会更新LR寄存器的值,一旦LR寄存器被更新后将无法恢复正确的调用现场,所以一般情况下被调用函数的前几条指令做的事情就是将LR寄存器的值保存到栈内存中,而被调用函数的最后几条指令所的事情就是将栈内存中保存的内容恢复到LR寄存器。
有一种特殊的函数调用场景就是当函数调用发生在调用者函数的最后一条指令时,则不需要进行调用现场的保护处理,同时也会将函数调用指令改为跳转指令,原因是因为调用者的最后一条指令再无下一条有效的指令,而仍然采用调用指令的话则保存的调用现场则是个无效的地址,这样当函数返回时将跳转到这个无效的地址从而产生执行异常!
为了更好的描述函数的调用规则,假设A函数内部调用了B函数和C函数,下面定义了各函数的地址,以及函数调用处的地址,以及函数调用的伪代码块:
//这里的XX,YY,ZZ代表的是函数指令在内存中的地址。
A XX1:
XX2: 调用B函数地址YY1
XX3:
XX4:
XXn: 跳转到C函数ZZ1
B YY1:
YY2:
YY3:
YYn: 返回
C ZZ1:
ZZ2:
ZZ3:
ZZn: 返回
1. x86_64体系下的函数调用规则
1.1 函数的调用
函数调用的指令是call 指令。在汇编语言中call 指令后面的操作数是调用的目标函数的绝对地址,而实际的机器指令中的操作数则是一个相对地址值,这个地址值是目标函数地址距离当前指令地址的相对偏移值。无论是x86系统还是arm系统如果指令中的操作数部分的值是内存地址的话,一般都是相对当前指令的偏移地址而不是绝对地址。下面就是函数调用指令以及其内部实现的等价操作。
call YY1 <==> RIP = YY1, RSP = RSP-8, *RSP = XX3
也就是说执行一条函数调用指令等价于将指令中的地址赋值给IP寄存器,同时把函数的返回地址压入栈寄存器中去。
1.2 函数的跳转
函数跳转的指令是jmp指令。在汇编语言中jmp 指令后面的操作数是调用的目标函数的绝对地址,而实际的机器指令中的操作数则是一个相对地址值,这个地址值是目标函数地址距离当前指令地址的相对偏移值,下面就是函数跳转指令以及其内部实现的等价操作。
jmp ZZ1 <==> RIP = ZZ1
也就是说执行一条跳转指令等价于将指令中的地址赋值给IP寄存器。
1.3 函数的返回
函数返回的指令是ret指令。ret指令后面一般不跟操作数,下面就是函数返回指令以及其内部实现的等价操作。
ret <==> RIP = *RSP, RSP = RSP + 8
也就是说执行一条ret指令等价于将当前栈寄存器中的值赋值给IP寄存器,同时栈寄存器执行POP操作。
2. arm32位体系下的函数调用规则
2.1 函数的调用
函数的调用指令为bl/blx。 这两条指令的操作数可以是相对地址偏移也可以是寄存器。bl/blx的区别就是bl函数调用不会切换指令集,而blx调用则会从thumb指令集切换到arm指令集或者相反切换。arm32系统中存在着两套指令集即thumb指令集和arm指令集,其中的arm指令集中的所有的指令的长度都是32位而thumb指令集则存在着32位和16位两种长度的指令集。两种指令集是以函数为单位进行使用的,也就是说一个函数中的所有指令要么都是arm指令要么就都是thumb指令。正是因为如此如果调用者函数和被调用者函数之间用的是不同的指令集则需要通过blx来执行函数调用,而如果二者所用的指令集相同则需要通过bl指令来执行调用。下面就是函数调用指令以及其内部实现的等价操作。
bl/blx YY1 <==> PC = YY1, LR = XX3
也就是说执行一条函数调用指令等价于将指令中的地址赋值给PC寄存器,同时把函数的返回地址赋值给LR寄存器中去。
2.2 函数的跳转
函数的跳转指令是b/bx, 这两条指令的操作数可以是相对地址偏移也可以是寄存器,b/bx的区别就是b函数调用不会切换指令集。下面就是函数跳转指令以及其内部实现的等价操作。
b/bx ZZ1 <==> PC = ZZ1
也就是说跳转指令等价于将指令中的地址赋值给PC寄存器。
2.3 函数的返回
arm32位系统没有专门的函数返回ret指令,因为arm32位系统可以直接修改PC寄存器的值,所以函数返回可以直接给PC指令赋值,也可以通过调用b/bx LR 来实现函数的返回处理。
b/bx LR
//或者
mov PC, XXX
arm32位系统可以直接修改PC寄存器的值,因此函数返回时可以直接设置PC寄存器的值为函数的返回地址,也可以执行b/bx跳转指令并指定目标地址为LR寄存器中的值。
3.arm64位体系下的函数调用规则
3.1 函数的调用
函数调用的指令是bl/blr 其中bl指令的操作数是距离当前位置相对距离的偏移地址,blr指令的操作数则是寄存器,表明调用寄存器所指定的地址。因为bl指令中的操作数部分是函数的相对偏移地址,又因为arm64位系统的一条指令占用4个字节,根据指令的定义bl指令所能跳转的范围是距离当前位置±32MB的范围,所以如果要跳转到更远的地址则需要借助blr指令。 下面就是函数调用指令以及其内部实现的等价操作。
//如果YY1地址离调用指令的距离是在±32MB内则使用bl指令即可。
bl YY1 <==> PC = YY1, LR = XX3
//如果YY1地址离调用指令的距离超过±32MB则使用blr指令执行间接调用。
ldr x16, YY1
blr x16
也就是说执行一条函数调用指令等价于将指令中的地址赋值给PC寄存器,同时把函数的返回地址赋值给LR寄存器中去。
3.2函数的跳转
函数跳转的指令是b/br, 其中b指令的操作数是距离当前位置相对距离的偏移地址,br指令的操作数则是寄存器,表明跳转到寄存器所指定的地址中去。下面就是函数跳转指令以及其内部实现的等价操作。
b ZZ1 <==> PC = ZZ1
也就是说跳转指令等价于将指令中的地址赋值给PC寄存器。
3.3 函数的返回
函数返回的指令是 ret, 下面就是函数返回指令以及其内部实现的等价操作。
ret <==> PC = LR
也就是说执行一条ret指令等价于将LR寄存器中的值赋值给PC寄存器。
三、函数参数传递
某些函数定义中有参数需要传递,需要由调用者函数将参数传递给被调用者函数,因此在调用这类函数时,需要在执行函数调用指令之前,进行函数参数的传递。函数的参数个数可以为0个,也可以为某个固定的数量,也可以为任意数量(可变参数)。 函数的每个参数类型可以是整型数据类型,也可以是浮点数据类型,也可以是指针,也可以是结构体。因此在函数传递的规则上需要明确指出调用者应该如何将参数进行保存处理,而被调用者又是从什么地方来获取这些外部传递进来的参数值。不同体系下的系统会根据参数定义的个数和类型来制定不同的规则。一般情况下各系统都会约定一些特定的寄存器来进行参数传递交换,或者使用栈内存来进行参数传递交换。
1. x86_64体系下的参数传递规则
1.1 常规类型参数
这里面的常规类型参数是指除浮点和结构体类型以外的参数类型,下面就是常规参数传递的规则:
R1: 如果函数没有参数则除了进行执行函数调用外不做任何处理,如果函数有参数则在执行函数调用指令之前需要按下面的规则设置参数值。
R2: 如果函数的参数个数<=6,则参数传递时将按照从左往右的定义的顺序依次保存到RDI, RSI, RDX, RCX, R8, R9这6个寄存器中。
R3: 如果参数的个数>6, 那么超过6个的参数,将会按从右往左的顺序依次压入到栈中。(因为栈是从高地址往低地址递减的,所以从栈顶往上来算的话后面的参数依然是从左到右的顺序)
R4: 如果每个参数的类型的尺寸<8个字节的情况下,则前6个参数会分别保存在上述寄存器的对应的32位或者16位或者8位版本的寄存器中。
下面是几个函数的定义以及在执行这个函数调用和参数传递的实现规则(下面代码块中上面部分描述的函数接口,下面部分是函数调用ABI规则):
//函数的签名
void foo1(long, long);
void foo2(long, long, long, long, long, long);
void foo3(long, long, long, long, long, long, long, int, short);
//高级语言的函数调用以及对应的机器指令伪代码实现
foo1(a,b) <==> RDI = a, RSI = b, call foo1
foo2(a,b,c,d,e,f) <==> RDI = a, RSI = b, RDX = c, RCX = d, R8 = e, R9 = f, call foo2
foo3(a,b,c,d,e,f,g,h,i) <== > RDI = a, RSI = b, RDX = c, RCX = d, R8 = e, R9 = f, RSP -= 2, *RSP = i, RSP-=4, *RSP = h, RSP-=8, *RSP = g, call foo3
1.2 浮点类型参数
如果函数参数中有浮点数(无论是单精度还是双精度)类型。则参数保存的地方则不是通用寄存器,而是特定的浮点数寄存器。下面就是传递的规则:
R5: 如果浮点数参数的个数<=8,那么参数传递将按从左往右的定义顺序依次保存到 XMM0 - XMM7这8个寄存器中。
R6: 如果浮点数参数个数>8,那么超过数量部分的参数,将会按从右往左的顺序依次压入到栈中。
R7: 如果函数参数中既有浮点也有常规参数那么保存到寄存器中的顺序和规则不会相互影响。
R8: 如果参数类型是扩展浮点类型(long double),扩展浮点类型的长度是16个字节, 那么所有的long double类型的参数都将直接压入到栈(注意这个栈不是浮点寄存器栈)中而不存放到浮点寄存器中。
下面是几个函数的例子:
//函数签名
void foo4(double, double);
void foo5(double, float, double, double, double, double, double, double, float, double);
void foo6(long, double, long, double, long, long, double);
void foo7(double, long double, long);
//高级语言的函数调用以及对应的机器指令伪代码实现
foo4(a,b) <==> XMM0 = a, XMM1 = b, call foo4
foo5(a,b,c,d,e,f,g,h,i,j) <==> XMM0 = a, XMM1 = b, XMM2 = c, XMM3 = d, XMM4 = e, XMM5 = f, XMM6 = g, XMM7 = h, RSP-=8, *RSP = j, RSP-=4 *RSP = i, call foo5
foo6(a,b,c,d,e,f,g) <==> RDI = a, XMM0 = b, RSI = c, XMM1 = d, RDX = e, RCX = f, XMM2 = g, call foo6
foo7(a,b,c) <==> XMM0=a, RSP-=16, *RSP = b的低8字节, *(RSP+8) = b的高8字节, RDI = c, call foo7
1.3 结构体参数
针对结构体类型的参数,需要考虑结构体中的成员的数据类型以及结构体的尺寸两个因素。这里的结构体的尺寸分为:小于等于8字节、小于等于16字节、大于16字节三种。而结构体成员类型组成则分为:全部都是常规数据类型、全部都是浮点数据类型(不包括long double)、以及混合类型三种。这样一共分为9种组合情况,下面表格描述结构体参数的的传递规则:
- R9:
类型/尺寸 | <=8 | <=16 | >16 |
---|---|---|---|
全部都是常规数据类型 | 6个通用寄存器中的某一个 | 6个通用寄存器中的某连续两个 | 压入栈内存中 |
全部都是浮点数据类型 | 8个浮点寄存器中的某一个 | 8个浮点寄存器中的某连续两个 | 压入栈内存中 |
混合类型 | 优先考虑通用寄存器,再考虑浮点寄存器,以及成员排列的顺序 | 参考左边 | 压入栈内存中 |
R10: 小于等于16个字节的结构体保存到寄存器中的规则并不是按每个数据成员来分别保存到寄存器,而是按结构体中的内存布局边界顺序以8字节为分割单位来保存到寄存器中的。
R11: 如果参数中混合有结构体、常规参数、浮点参数则按照前10个规则分别保存传递的参数
下面就是几个结构体在当做参数时的示例代码:
//长度<=8个字节的结构体
struct S1
{
char a;
char b;
int c;
};
//长度<=16的混合结构体
struct S2
{
float a;
float b;
double c;
};
//长度<=16的混合结构体
struct S3
{
int a;
int b;
double c;
};
//长度>16个字节的结构体
struct S4
{
long a;
long b;
double c;
}
//函数签名
void foo8(struct S1);
void foo9(struct S2);
void foo10(struct S3);
void foo11(struct S4);
//高级语言的函数调用以及对应的机器指令伪代码实现
struct S1 s1;
struct S2 s2;
struct S3 s3;
struct S4 s4;
foo8(s1) <==> RDI = s1.a | (s1.b <<8) | (s1.c << 32), call foo8
foo9(s2) <==> XMM0 = s2.a | (s2.b << 32), XMM1 = s2.c, call foo9
foo10(s3) <==> RDI = s3.a | (s3.b << 32), XMM0 = s3.c, call foo10
foo11(s4) <==> RSP -= 24, *RSP = s4.a, *(RSP+8) = s4.b, *(RSP+16)=s4.c, call foo11
针对结构体类型的参数建议是传指针而不是传结构体值本身。
1.4 可变参数
可变参数函数因为其参数的类型和参数的数量不固定,所以系统在编译时会根据函数调用时传递的参数的值类型而进行不同的处理,因此规则如下:
R12: 函数调用时会根据传递的参数的数量和类型从左到右依次存放在对应的6个常规参数传递的寄存器或者XMM0-XMM7中,如果数量超过规定则剩余的参数依次压入栈内存中。
-
R13:对于可变参数函数的调用会使用AL寄存器,其规则为:如果传递的可变参数中没有浮点数类型则AL寄存器被设置为0,如果可变参数中出现了浮点数类型则AL寄存器会被设置为1。之所以用AL寄存器来标志的原因是可变参数内部实现因为不知道外部会传递什么类型的参数以及参数的个数,所以内部实现中会将所有作为参数传递的常规寄存器和作为参数传递的浮点数寄存器都会保存到一个数组中去,以方便进行处理。因此这里借助这个AL寄存器来判断是否有浮点就可以在一定程度上减少将数组的长度。
下面是可变参数的调用示例:
//函数签名
void foo12(int a, ...);
//高级语言的函数调用以及对应的机器指令伪代码实现
foo12(10,20,30.0, 40) <==> RDI = 10, RSI = 20, XMM0 = 30.0, RDX = 40,AL=1, call foo12
foo12(10,20,30,40) <==> RDI = 10, RSI = 20, RDX = 30, RCX = 40,AL=0, call foo7
一个有意思的例子: 当调用printf函数传递的参数如下:
printf("%f,%d,%d", 10, 20.0, 30.0); //输出的结果将是: 20.0,10, ???
原因就是参数传递的规则和格式字符串不匹配导致的,通过上面对可变参数的传递规则,你能解释为什么吗?
2. arm32位体系下的参数传递规则
整个arm32位体系下的参数传递和参数返回都不会用到浮点寄存器。对于大于4字节的基本类型则会拆分为两部分依次保存到连续的两个寄存器中。
2.1 常规参数
R1: 对于32位的常规参数,如果数量<=4则分别保存到 R0 - R3中, 如果数量>4则剩余的参数从右往左分别压入栈内存中。
R2: 如果参数中有64位的参数比如long long 类型,则参数会占用2个寄存器,其中低32位部分保存在前一个寄存器,高32位部分保存在后一个寄存器。
R3: 如果前面3个参数是32位的参数,而第四个参数是64位的参数,那么前面三个参数分别放入R0,R1,R2中,而第四个参数的低32位部分则放入R3中,高32位部分则压入到栈内存中。
2.2 浮点参数
- R4: 浮点参数和常规参数一样使用R0到R3寄存器,对于单精度浮点则使用一个寄存器,而双精度浮点则使用两个寄存器。超出部分则压入栈内存中。
2.3 结构体参数
R5: arm32位系统的结构体不区分成员数据类型,只区分结构体尺寸,系统根据结构体的内存布局以4个字节为分割单位保存到寄存器或者栈内存中。
R6: 结构体尺寸<=4则会将参数保存到一个寄存器中,如果尺寸<=8则保存到连续的两个寄存器中, 如果尺寸<=12则保存到3个连续的寄存器中, 如果尺寸<=16则保存到4个连续的寄存器中。如果尺寸>16则保存到栈内存中去。
R7: 如果前3个参数都是32位的参数,而第4个参数为尺寸>4的结构体,那么第4个参数的低4个字节的部分会保存到R3中,其他部分保存到栈内存中。
2.4 可变参数
- R8: 可变参数传递根据参数的个数从左到右依次保存到R0-R3四个寄存器中,超过的部分从右往左依次保存到栈内存中。
下面的实例代码:
//函数签名
void foo1(int a, ...);
//高级语言的函数调用以及对应的机器指令伪代码实现。
foo1(10,20,30,40,50) <==> R0 = 10, R1 = 20, R2 = 30, R3 =40, SP -=4, *SP = 50, bl foo1
3.arm64位体系下的参数传递规则
3.1 常规参数
这里面的常规参数是指参数的类型是非浮点和非结构体类型的参数,下面就是常规参数传递的规则:
R1: 如果函数没有参数则除了进行执行函数调用外不做任何处理,如果函数有参数则在执行函数调用指令之前需要按下面的规则设置参数值。
R2: 如果函数的参数个数<=8个, 参数传递将按照从左往右的定义的顺序依次保存到X0 - X7 这8个寄存器中。
R3: 如果参数的个数>8个,那么超过数量部分的参数,将会按从右往左的顺序依次压入到栈中。
R4: 如果参数的类型是小于8个字节的情况下,则前8个参数会分别保存在对应的32位或者16位或者8位寄存器中。
下面是几个函数的例子:
//函数签名
void foo1(long, long);
void foo2(long, long, long, long, long, long, long, long);
void foo3(long, long, long, long, long, long, long, long, long, int, short);
//高级语言的函数调用以及对应的机器指令伪代码实现。
foo1(a,b) <==> X0 = a, X1 = b, bl foo1
foo2(a,b,c,d,e,f,g,h) <==>X0 = a, X1 = b, X2 = c, X3 = d, X4 = e, X5 = f, X6=g, X7 =h, bl foo2
foo3(a,b,c,d,e,f,g,h,i,j,k) <==>X0 = a, X1 = b, X2 = c, X3 = d, X4 = e, X5 = f, X6=g, X7=h, *SP -=2, *SP=k, SP-=4, *SP = j, SP-= 8, *SP = i, bl foo3
3.2 浮点参数
如果函数参数中有浮点数(无论是单精度还是双精度)。则参数保存的地方则不是通用寄存器,而是特定的浮点数寄存器。系统提供32个128位的浮点寄存器Q0-Q31(V0-V31),其中的低64位则被称为D0-D31,其中的低32位则被称为S0-S31,其中的低16位则被称为H0-H31,其中的低8位则被称之为B0-B31。 也就是说单精度浮点保存到S开头的寄存器, 双精度浮点保存到D开头的寄存器。 arm系统中 long double 的长度都是8字节,因此可被当做双精度浮点。
下面就是传递的规则:
R5: 如果浮点数参数的个数<=8个,那么参数传递将按从左往右的顺序依次保存到 D0-D7或者S0-S7 这8个寄存器中。
R6: 如果浮点数参数个数>8个时,那么超过数量部分的参数,将会按从右往左的顺序依次压入到栈中。
R7: 如果函数参数中既有浮点也有常规参数那么保存到寄存器中的顺序和规则不会相互影响。
下面是几个函数的例子:
//函数签名
void foo4(double, double);
void foo5(double, float, float, double, double, double, double, double, double, double);
void foo6(long, double, long, double, long, long, double);
//高级语言的函数调用以及对应的机器指令伪代码实现。
foo4(double a, double b) <==> D0 = a, D1 = b, bl foo4
foo5(double a, float b, float c, double d, double e, double f, double g, double h, double i, double j) <==> D0 = a, S1 = b, S2 = c, D3 = d, D4 = e, D5 = f, D6 = g, D7 = h, *SP -=8, *SP = j, *SP -=8, *SP = i, bl foo5
foo6(long a, double b, long c, double d, long e, long f, double g) <==> X0 = a, D0 = b, X1 = c, D1 = d, X2 = e, X3 = f, D2 = g, bl foo6
3.3 结构体参数
针对结构体类型的参数,需要考虑结构体的尺寸以及数据类型和数量。这里的结构体的尺寸分别是考虑小于等于8字节,小于等于16字节,大于16字节。而结构体成员类型则分为:全部都是非浮点数据成员、全部都是浮点数成员(这里会区分单精度和双精度)、以及混合类型的成员(如果结构体中有单精度和双精度都算混合)。下面是针对结构体参数的规则:
R8: 如果数据成员全部都是非浮点数据成员则 如果尺寸<=8则会将值保存到X0-X8中的某一个寄存器中, 如果尺寸<=16则会将值保存到X0-X8中的某两个连续的寄存器中,如果尺寸>16则结构体将不再按值传递而是以指针的形式进行传递并保存到X0-X8中的某一个寄存器中。
R9: 如果数据成员全部都是单精度浮点成员则如果成员数量<=4则会将数据成员保存到S0-S7中的某4个连续的浮点寄存器中,如果数量>4则结构体将不再按值传递而是以指针的形式进行传递并保存到X0-X8中的某一个寄存器中。
R10: 如果数据成员全部都是双精度浮点成员则如果成员数量<=4则会将数据成员保存到D0-D7中的某4个连续的浮点寄存器中,如果数量>4则结构体将不再按值传递而是以指针的形式进行传递并保存到X0-X8中的某一个寄存器中。
R11: 如果数据成员是混合类型的则如果尺寸<=8则保存到X0-X8中的某一个寄存器中,如果尺寸<=16则保存到X0-X8中的某两个连续的寄存器中, 如果尺寸>16则结构体将不再按值传递而是以指针的形式进行传递并保存到X0-X8中的某一个寄存器中。
R12: 因为结构体参数的寄存器规则会影响到上述非结构体参数的传递规则,因此一定程度上可以将结构体当做多个参数传递来看待。
下面是演示的代码:
//长度<=8个字节的结构体
struct S1
{
char a;
char b;
int c;
};
//长度<=16的单精度浮点结构体
struct S2
{
float a;
float b;
float c;
};
//长度<=16的混合结构体
struct S3
{
int a;
int b;
double c;
};
//长度>16个字节的结构体
struct S4
{
long a;
long b;
double c;
}
//函数签名
void foo8(struct S1);
void foo9(struct S2);
void foo10(struct S3);
void foo11(struct S4);
//高级语言的函数调用以及对应的机器指令伪代码实现
struct S1 s1;
struct S2 s2;
struct S3 s3;
struct S4 s4;
foo8(s1) <==> X0= s1.a | (s1.b <<8) | (s1.c << 32), bl foo8
foo9(s2) <==> S0 = s2.a, S1 = s2.b, S3 = s2.c bl foo9
foo10(s3) <==> X0 = s3.a | (s3.b << 32), X1 = s3.c, bl foo10
foo11(s4) <==> X0 = &s4, bl foo11
3.4 可变参数
可变参数函数因为其参数的类型和参数的数量不固定,所以系统在编译时会根据函数调用时传递的参数的值类型而进行不同的处理,因此规则如下:
- R13: 函数调用时会根据传递的参数的数量和类型来决定,其中明确类型的部分按照上面介绍的规则进行传递,而可变部分则从右往左依次压入到堆栈中。
下面是示例代码:
//函数签名
void foo7(int a, ...);
//高级语言的函数调用以及对应的机器指令伪代码实现
foo7(10, 20, 30.0, 40) <==> X0 = 10, SP-=8, *SP = 40, SP-=8, *SP = 30.0, SP-=8, *SP = 20, bl foo7
一个有意思的例子: 当执行printf函数而传递参数如下:
printf("%f,%d,%d", 10, 20.0, 30.0); //那么输出的结果将是: ?,?,?
因为arm系统对可变参数的传递和x86系统对可变参数的处理不一致,就会出现真机和模拟器的结果不一致的问题。甚至在参数传递规则上arm32位和arm64位系统都有差异。上面的参数传递和描述不匹配的情况下你可以说出为什么输出的结果不确定吗?
四、函数返回值
函数调用除了有参数传递外,还有参数返回。参数的传递是调用者向被调函数方向的传递,而函数的返回则是被调用函数向调用函数方向的传递,因此调用者和被调用者之间应该形成统一的规则。被调用函数内对返回值的处理应该在被调用函数返回指令执行前。而调用函数则应该在函数调用指令的下一条指令中尽可能早的对返回的结果进行处理。函数的返回类型有无、非浮点数、浮点数、结构体四种类型,因此针对不同的返回类型系统有不同的处理规则。
1. x86_64体系下的函数返回值规则
1.1 常规类型返回
- R1: 如果函数有返回值则总是将返回值保存到RAX寄存器中。
1.2 浮点类型返回
R2: 返回的浮点数类型保存到XMM0寄存器中。
R3: 返回的(扩展双精度)long double 类型则保存到浮点寄存器栈顶中。FPU计算单元中提供了8个独立的128位的寄存器STMM0-STMM7,这8个寄存器以堆栈形式组织在一起,统称为浮点寄存器栈。系统同时也提供了专门的指令来对浮点寄存器栈进行入栈和出栈处理, 编写浮点指令时这些寄存器也写作st(x),这里的x是浮点寄存器的索引。需要明确的是XMM系列的寄存器和STMM系列的寄存器是完全不同的两套寄存器。
1.3 结构体类型返回
针对结构体类型的返回,需要考虑结构体的尺寸以及成员的数据类型。这里的结构体的尺寸分为:小于等于8字节,小于等于16字节,大于16字节。而结构体成员类型则分为:全部都是非浮点数据成员、全部都是浮点数据成员(不包括 long double)、以及混合类型的成员。这样一共分为9种情况,下面表格描述针对结构体返回的规则:
- R4
类型/尺寸 | <=8 | <=16 | >16 |
---|---|---|---|
全部非浮点数据成员 | RAX | RAX,RDX | 返回的结构体将保存到RDI寄存器所指向的内存地址中。也就是RDI寄存器是一个结构体地址指针,这样函数参数中的第一个参数将由保存到RDI,变为保存到RSI寄存器了。 |
全部为浮点数据成员 | XMM0 | XMM0,XMM1 | 同上 |
混合类型 | 优先存放到RAX,或者XMM0,然后再存放到RDX或者XMM1中。一个特殊情况就是如果成员中有long double类型,则总是按>16字节的规则来处理返回值 | 同左 | 同上 |
下面是一个展示的代码:
//长度<=8个字节的结构体
struct S1
{
char a;
char b;
int c;
};
//长度<=16的混合结构体
struct S2
{
int a;
int b;
double c;
};
//长度>16个字节的结构体
struct S3
{
long a;
long b;
double c;
}
//函数签名
struct S1 foo1();
struct S2 foo2();
struct S3 foo3(int );
//高级语言的函数调用以及对应的机器指令伪代码实现
struct S1 s1 = foo1() <==> 函数调用时:call foo1, 函数返回时 s1 = RAX
struct S2 s2 = foo2() <==> 函数调用时:call foo2, 函数返回时s2.a&s2.b = RAX, s2.c = XMM0
struct S3 s3 = foo3(a) <==> 函数调用时: RDI = &s3, RSI = a, call foo3
2. arm32位体系下的函数返回值规则
2.1 常规类型返回
- R1: 函数的返回值的尺寸<=4字节则保存到R0寄存器,如果返回值的尺寸<=8字节(比如 long long类型)则保存到R0,R1寄存器其中低32位保存到R0,高32位保存到R1
2.2 浮点类型返回
- R2: 单精度浮点数保存到R0寄存器,双精度浮点数保存在R0,R1中其中R0保存低32位,R1保存高32位。 long double 类型的返回同双精度浮点返回一致。
2.3 结构体类型返回
- R3: 不管任何类型的结构体,总是将结构体返回到R0寄存器所指向的内存中, 因此R0寄存器中保存的是一个指针,这样函数的第一个参数将保存到R1寄存器并依次往后推,也就是说如果函数返回的是一个结构体那么系统就会将返回的值当做第一个参数,而将真实的第一个参数当做第二个参数。
下面的代码说明了这种情况:
struct XXX
{
//任意内容
};
//函数返回结构体
struct XXX foo(int a)
{
//...
}
实际在编译时会转化为函数
void foo(struct XXX *pret, int a)
{
}
也就是在arm32位的系统中凡是有结构体作为返回的函数,其实都会将结构体指针作为函数调用的第一个参数保存到R0中,而将源代码中的第一个参数保存到R1中。
3.arm64位体系下的函数返回值规则
2.1 常规类型返回
- R1: 函数的返回参数保存到X0寄存器上
2.2 浮点类型返回
- R2: 单精度浮点返回保存到S0,双精度浮点返回保存到D0
2.3 结构体类型返回
针对结构体类型的参数,需要考虑结构体中的成员的数据类型以及整体结构体的尺寸。这里的结构体的尺寸分别是考虑小于等于8字节,小于等于16字节,大于16字节。而结构体成员类型则分为:全部都是非浮点数据成员、全部都是浮点数成员(这里会区分单精度和双精度)、以及混合类型的成员(如果结构体中有单精度和双精度都算混合)。这样一共分为9种情,下面就是针对结构体类型返回的规则:
R3:针对非浮点数据成员的结构体来说如果结构体的尺寸<=8,那么结构体的值会保存到X0, 如果尺寸<=16,那么保存到X0,X1中,如果尺寸>16则结构体返回会保存到X8寄存器所指向的内存中,也就是X8寄存器比较特殊,专门用来保存返回的结构体的指针。
R4: 如果结构体的成员都是单精度并且数量<=4 则返回结构体的每个成员分别保存到S0,S1,S2, S3四个寄存中,如果结构体成员数量超过4个则结构体返回会保存到X8寄存器所指向的内存中。
R5: 如果结构体的成员都是双精度并且数量<=4 则返回结构体的每个成员分别保存到D0,D1,D2,D3四个寄存器中,如果结构体成员数量超过4个则结构体返回会保存到X8寄存器所指向的内存中。
R6: 如果结构体是混合型数据成员,并且结构体的尺寸<=8字节,那么结构体的值保存到X0, 如果尺寸<=16字节则保存到X0,X1中,如果尺寸>16则结构体返回会保存到X8寄存器所指向的内存中。
下面演示几个结构体定义以及返回结构体的函数:
//长度为16字节的结构体
struct S1
{
char a;
char b;
double c;
};
//长度超过16字节的混合成员结构体
struct S2
{
int a;
int b;
int c;
double d;
};
//长度小于等于8字节的结构体
struct S3
{
int a;
int b;
};
CGRect foo1()
{
//高级语言实现的返回
return CGRectMake(10,20,30,40);
//机器指令的函数返回的伪代码如下:
/*
D0 = 10
D1 = 20
D2 = 30
D3 = 40
ret
*/
}
struct S1 foo2()
{
//高级语言实现的返回
return (struct S1){10, 20, 30};
//机器指令的函数返回的伪代码如下:
/*
X0 = 10 | 20 << 8
X1 = 30
ret
*/
}
struct S2 foo3()
{
//高级语言实现的返回
return (struct S2){10, 20, 30, 40};
//机器指令的函数返回的伪代码如下:
/*
struct S2 *p = X8 //X8中保存返回的结构体内存地址
p->a = 10
p->b = 20
p->c = 30
p->d = 40
ret
*/
}
struct S3 foo4()
{
//高级语言实现的返回
return (struct S3){20, 30};
//机器指令的函数返回的伪代码如下:
/*
X0 = 20 | 30 << 32
ret
*/
}
从上面的代码可以看出来在x86_64/arm32两种体系结构下如果返回的类型是结构体并且满足特定要求时,系统会将结构体指针当做函数的第一个参数,而将源代码中的第一个参数传递的寄存器往后移动,而在arm64位系统中则x8寄存器专门负责处理返回值为特殊结构体的情况。
六、谈谈objc_msgSend系列函数
所有的OC方法最终都会通过objc_msgSend系列函数进行调用。这个函数系列有如下函数:
objc_msgSend(void /* id self, SEL op, ... */ )
objc_msgSend_stret(void /* id self, SEL op, ... */ )
objc_msgSend_fpret(void /* id self, SEL op, ... */ )
objc_msgSend_fp2ret(void /* id self, SEL op, ... */ )
这一系列的函数的差别主要是针对返回类型的不同而使用不同的消息发送函数。
从上述的函数返回值规则可以看对于long double 类型的函数返回在x86_64位系统的处理方式比较特殊,其返回的值将保存在特定的浮点堆栈寄存器中,所以objc_msgSend_fpret函数只用在x86_64位系统中返回类型为long double的OC方法的消息分发中,其他体系结构都不会用到这个函数。同样因为C99中引入了复数类型 _Complex 关键字,所以针对这种类型的 long double 返回会使用objc_msgSend_fp2ret函数。
从上述的函数的返回值规则还可以看出对于结构体返回,如果结构体尺寸大于一定的阈值后,x86_64位系统和arm32位系统都会将返回的结构体转化为第一个参数来进行传递,这样就会使得真实的参数传递的寄存器往后顺延,而arm64则直接只用x8寄存器来保存大于阈值的结构体指针且并不会影响到参数的传递顺序。因此除了arm64位系统外其他体系结构系统中针对那些返回结构体大于一定阈值的OC方法将使用objc_msgSend_stret函数进行消息分发。
上述的函数返回规则对
针对函数的调用、参数传递、函数的返回值的介绍规则就是这些了,当然这些规则除了对普通函数适用外对OC类方法也是同样适用的。至于一个函数内部应该怎样实现,其实也是有一定的规则的。通过这些规则你可以了解到函数是如何跟栈内存结合在一起的,以及函数调用栈是如何被构造出来的,你还可以了解为什么一些函数调用不会出现在调用栈中等等相关的知识,以及可变参数函数内部是如何实现的等等这部分的详细介绍将会在:
深入iOS系统底层之函数(二):实现 进行深入的探讨。
七、参考
https://blog.csdn.net/q_l_s/article/details/54909328
https://developer.apple.com/library/archive/documentation/Xcode/Conceptual/iPhoneOSABIReference/Introduction/Introduction.html#//apple_ref/doc/uid/TP40009020-SW1
armv8,armv7, x86_64位系统CPU手册
http://blog.sina.com.cn/s/blog_861912cd0100vqm7.html
【返回目录】
欢迎大家访问我的github地址和地址