古器合尺度,法物应矩规。--苏洵
可执行程序是为了实现某个功能而由不同机器指令按特定规则进行组合排列的集合。无论高级还是低级程序语言,无论是面向对象还是面向过程的语言最终的代码都会转化为一条条机器指令的形式被执行。为了管理上的方便和对代码的复用,往往需要将某一段实现特定功能的指令集合进行抽离和处理从而形成了函数的概念,函数也可以称之为子程序或者子例程。出现函数的概念后可执行程序的机器指令集合将不再是单一的一块代码,而是由多个函数组成的分块代码,这样可执行程序就变成了由函数之间相互调用这种方式来构建和组织了。
一个函数由函数签名、参数、返回、实现四部分组成。函数的前三者定义了明确的边界信息,也称之为函数接口描述。函数接口描述的意义在于调用者不再需要了解被调用者函数的实现细节,而只需要按被调用者的定义的接口进行交互即可。如何去定义一个函数,如何去实现一个函数,如何去调用一个函数,如何将参数传递给被调用的函数,如何使用被调用者函数的返回这些都需要有统一的标准规范来进行界定,这个规则有两个层面的标准:在高级语言层面的规则称之为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: 返回
函数调用的指令是call 指令。在汇编语言中call 指令后面的操作数是调用的目标函数的绝对地址,而实际的机器指令中的操作数则是一个相对地址值,这个地址值是目标函数地址距离当前指令地址的相对偏移值。无论是x86系统还是arm系统如果指令中的操作数部分的值是内存地址的话,一般都是相对当前指令的偏移地址而不是绝对地址。下面就是函数调用指令以及其内部实现的等价操作。
call YY1 <==> RIP = YY1, RSP = RSP-8, *RSP = XX3
也就是说执行一条函数调用指令等价于将指令中的地址赋值给IP寄存器,同时把函数的返回地址压入栈寄存器中去。
函数跳转的指令是jmp指令。在汇编语言中jmp 指令后面的操作数是调用的目标函数的绝对地址,而实际的机器指令中的操作数则是一个相对地址值,这个地址值是目标函数地址距离当前指令地址的相对偏移值,下面就是函数跳转指令以及其内部实现的等价操作。
jmp ZZ1 <==> RIP = ZZ1
也就是说执行一条跳转指令等价于将指令中的地址赋值给IP寄存器。
函数返回的指令是ret指令。ret指令后面一般不跟操作数,下面就是函数返回指令以及其内部实现的等价操作。
ret <==> RIP = *RSP, RSP = RSP + 8
也就是说执行一条ret指令等价于将当前栈寄存器中的值赋值给IP寄存器,同时栈寄存器执行POP操作。
函数的调用指令为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寄存器中去。
函数的跳转指令是b/bx, 这两条指令的操作数可以是相对地址偏移也可以是寄存器,b/bx的区别就是b函数调用不会切换指令集。下面就是函数跳转指令以及其内部实现的等价操作。
b/bx ZZ1 <==> PC = ZZ1
也就是说跳转指令等价于将指令中的地址赋值给PC寄存器。
arm32位系统没有专门的函数返回ret指令,因为arm32位系统可以直接修改PC寄存器的值,所以函数返回可以直接给PC指令赋值,也可以通过调用b/bx LR 来实现函数的返回处理。
b/bx LR
//或者
mov PC, XXX
arm32位系统可以直接修改PC寄存器的值,因此函数返回时可以直接设置PC寄存器的值为函数的返回地址,也可以执行b/bx跳转指令并指定目标地址为LR寄存器中的值。
函数调用的指令是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寄存器中去。
函数跳转的指令是b/br, 其中b指令的操作数是距离当前位置相对距离的偏移地址,br指令的操作数则是寄存器,表明跳转到寄存器所指定的地址中去。下面就是函数跳转指令以及其内部实现的等价操作。
b ZZ1 <==> PC = ZZ1
也就是说跳转指令等价于将指令中的地址赋值给PC寄存器。
函数返回的指令是 ret, 下面就是函数返回指令以及其内部实现的等价操作。
ret <==> PC = LR
也就是说执行一条ret指令等价于将LR寄存器中的值赋值给PC寄存器。
某些函数定义中有参数需要传递,需要由调用者函数将参数传递给被调用者函数,因此在调用这类函数时,需要在执行函数调用指令之前,进行函数参数的传递。函数的参数个数可以为0个,也可以为某个固定的数量,也可以为任意数量(可变参数)。 函数的每个参数类型可以是整型数据类型,也可以是浮点数据类型,也可以是指针,也可以是结构体。因此在函数传递的规则上需要明确指出调用者应该如何将参数进行保存处理,而被调用者又是从什么地方来获取这些外部传递进来的参数值。不同体系下的系统会根据参数定义的个数和类型来制定不同的规则。一般情况下各系统都会约定一些特定的寄存器来进行参数传递交换,或者使用栈内存来进行参数传递交换。
这里面的常规类型参数是指除浮点和结构体类型以外的参数类型,下面就是常规参数传递的规则:
下面是几个函数的定义以及在执行这个函数调用和参数传递的实现规则(下面代码块中上面部分描述的函数接口,下面部分是函数调用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
如果函数参数中有浮点数(无论是单精度还是双精度)类型。则参数保存的地方则不是通用寄存器,而是特定的浮点数寄存器。下面就是传递的规则:
下面是几个函数的例子:
//函数签名
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
针对结构体类型的参数,需要考虑结构体中的成员的数据类型以及结构体的尺寸两个因素。这里的结构体的尺寸分为:小于等于8字节、小于等于16字节、大于16字节三种。而结构体成员类型组成则分为:全部都是常规数据类型、全部都是浮点数据类型(不包括long double)、以及混合类型三种。这样一共分为9种组合情况,下面表格描述结构体参数的的传递规则:
类型/尺寸 | <=8 | <=16 | >16 |
---|---|---|---|
全部都是常规数据类型 | 6个通用寄存器中的某一个 | 6个通用寄存器中的某连续两个 | 压入栈内存中 |
全部都是浮点数据类型 | 8个浮点寄存器中的某一个 | 8个浮点寄存器中的某连续两个 | 压入栈内存中 |
混合类型 | 优先考虑通用寄存器,再考虑浮点寄存器,以及成员排列的顺序 | 参考左边 | 压入栈内存中 |
下面就是几个结构体在当做参数时的示例代码:
//长度<=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
针对结构体类型的参数建议是传指针而不是传结构体值本身。
可变参数函数因为其参数的类型和参数的数量不固定,所以系统在编译时会根据函数调用时传递的参数的值类型而进行不同的处理,因此规则如下:
//函数签名
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, ???
原因就是参数传递的规则和格式字符串不匹配导致的,通过上面对可变参数的传递规则,你能解释为什么吗?
整个arm32位体系下的参数传递和参数返回都不会用到浮点寄存器。对于大于4字节的基本类型则会拆分为两部分依次保存到连续的两个寄存器中。
//函数签名
void foo1(int a, ...);
//高级语言的函数调用以及对应的机器指令伪代码实现。
foo1(10,20,30,40,50) <==> R0 = 10, R1 = 20, R2 = 30, R3 =40, SP -=4, *SP = 50, bl foo1
这里面的常规参数是指参数的类型是非浮点和非结构体类型的参数,下面就是常规参数传递的规则:
下面是几个函数的例子:
//函数签名
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
如果函数参数中有浮点数(无论是单精度还是双精度)。则参数保存的地方则不是通用寄存器,而是特定的浮点数寄存器。系统提供32个128位的浮点寄存器Q0-Q31(V0-V31),其中的低64位则被称为D0-D31,其中的低32位则被称为S0-S31,其中的低16位则被称为H0-H31,其中的低8位则被称之为B0-B31。 也就是说单精度浮点保存到S开头的寄存器, 双精度浮点保存到D开头的寄存器。 arm系统中 long double 的长度都是8字节,因此可被当做双精度浮点。
下面就是传递的规则:
下面是几个函数的例子:
//函数签名
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
针对结构体类型的参数,需要考虑结构体的尺寸以及数据类型和数量。这里的结构体的尺寸分别是考虑小于等于8字节,小于等于16字节,大于16字节。而结构体成员类型则分为:全部都是非浮点数据成员、全部都是浮点数成员(这里会区分单精度和双精度)、以及混合类型的成员(如果结构体中有单精度和双精度都算混合)。下面是针对结构体参数的规则:
下面是演示的代码:
//长度<=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
可变参数函数因为其参数的类型和参数的数量不固定,所以系统在编译时会根据函数调用时传递的参数的值类型而进行不同的处理,因此规则如下:
下面是示例代码:
//函数签名
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位系统都有差异。上面的参数传递和描述不匹配的情况下你可以说出为什么输出的结果不确定吗?
函数调用除了有参数传递外,还有参数返回。参数的传递是调用者向被调函数方向的传递,而函数的返回则是被调用函数向调用函数方向的传递,因此调用者和被调用者之间应该形成统一的规则。被调用函数内对返回值的处理应该在被调用函数返回指令执行前。而调用函数则应该在函数调用指令的下一条指令中尽可能早的对返回的结果进行处理。函数的返回类型有无、非浮点数、浮点数、结构体四种类型,因此针对不同的返回类型系统有不同的处理规则。
针对结构体类型的返回,需要考虑结构体的尺寸以及成员的数据类型。这里的结构体的尺寸分为:小于等于8字节,小于等于16字节,大于16字节。而结构体成员类型则分为:全部都是非浮点数据成员、全部都是浮点数据成员(不包括 long double)、以及混合类型的成员。这样一共分为9种情况,下面表格描述针对结构体返回的规则:
类型/尺寸 | <=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
下面的代码说明了这种情况:
struct XXX
{
//任意内容
};
//函数返回结构体
struct XXX foo(int a)
{
//...
}
实际在编译时会转化为函数
void foo(struct XXX *pret, int a)
{
}
也就是在arm32位的系统中凡是有结构体作为返回的函数,其实都会将结构体指针作为函数调用的第一个参数保存到R0中,而将源代码中的第一个参数保存到R1中。
针对结构体类型的参数,需要考虑结构体中的成员的数据类型以及整体结构体的尺寸。这里的结构体的尺寸分别是考虑小于等于8字节,小于等于16字节,大于16字节。而结构体成员类型则分为:全部都是非浮点数据成员、全部都是浮点数成员(这里会区分单精度和双精度)、以及混合类型的成员(如果结构体中有单精度和双精度都算混合)。这样一共分为9种情,下面就是针对结构体类型返回的规则:
下面演示几个结构体定义以及返回结构体的函数:
//长度为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寄存器专门负责处理返回值为特殊结构体的情况。
所有的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函数进行消息分发。
上述的函数返回规则对<objc/message.h> 中的其他函数也是同样适用的。
针对函数的调用、参数传递、函数的返回值的介绍规则就是这些了,当然这些规则除了对普通函数适用外对OC类方法也是同样适用的。至于一个函数内部应该怎样实现,其实也是有一定的规则的。通过这些规则你可以了解到函数是如何跟栈内存结合在一起的,以及函数调用栈是如何被构造出来的,你还可以了解为什么一些函数调用不会出现在调用栈中等等相关的知识,以及可变参数函数内部是如何实现的等等这部分的详细介绍将会在: 深入iOS系统底层之函数(二):实现 进行深入的探讨。
?【返回目录】