浅谈函数调用

2022-10-05
6分钟阅读时长

函数是编程里非常基础的知识,也是日常非常常用的编程封装方式,于是浅浅探索一下函数调用的底层实现。

寄存器

深入探究函数调用原理,避免不了需要了解汇编知识以及部分寄存器的作用。本文所讲是基于X86-64 架构。

通用寄存器

64-bit32-bit16-bit8 high bits of lower 16 bits8-bitDescription
RAXEAXAXAHALAccumulator
RBXEBXBXBHBLBase
RCXECXCXCHCLCounter
RDXEDXDXDHDLData (commonly extends the A register)
RSIESISIN/ASILSource index for string operations
RDIEDIDIN/ADILDestination index for string operations
RSPESPSPN/ASPLStack Pointer
RBPEBPBPN/ABPLBase Pointer (meant for stack frames)
R8R8DR8WN/AR8BGeneral purpose
R9R9DR9WN/AR9BGeneral purpose
R10R10DR10WN/AR10BGeneral purpose
R11R11DR11WN/AR11BGeneral purpose
R12R12DR12WN/AR12BGeneral purpose
R13R13DR13WN/AR13BGeneral purpose
R14R14DR14WN/AR14BGeneral purpose
R15R15DR15WN/AR15BGeneral purpose

RAX通常还被用来存放函数返回值
RCX通常用作循环计数
RSP指向栈的顶部
RBP指向栈的底部,通常使用基址指针+偏移量的方式获取入参以及本地变量

除了通用寄存器还有非常多寄存器,比如RIP指令寄存器,段寄存器等等,但这里并不继续深入。

函数调用

Slice 1.png

函数调用主要有以下步骤

  • 上下文保存
  • 数据传递
  • 控制转移
  • 上下文恢复

下面会通过具体的汇编代码来看每一步到底是怎么实现的。所有的汇编代码都是AT&T格式。

上下文保存

在进行函数调用前,肯定是需要对一些数据进行保存的,比如当前函数保存在寄存器内的值,如果不提前保存好,就可能会被调用的函数使用并且覆盖里面的数据,当调用结束后,寄存器里面的值将是脏数据,会导致程序运行出错。

每个寄存器的值由调用方(caller)保存还是由被调用方(callee)保存是有约定的,叫做Calling ConventionCalling Convention是一些规则的集合,是通俗的约定,编译器对高级语言编译后生成的汇编代码都是符合这些约定的,在写汇编程序的时候遵守这些规定可以认为是必须的。

更多关于Calling Convention的细节可以参考The 64 bit x86 C Calling Convention 这篇文章,这里不继续展开。

下面看一个简单的例子

#include <stdio.h>

int add(int a, int b) {
    int c = a + b;
    return c;
}

int main() {
    int a = 1, b = 2;
    int ret = add(a, b);
    ret += a;
    ret += b;
    printf("%d\n", ret);
    return 0;
}
0000000000400596 <add>:
  400596:	55                   	push   %rbp
  400597:	48 89 e5             	mov    %rsp,%rbp
  40059a:	89 7d ec             	mov    %edi,-0x14(%rbp)
  40059d:	89 75 e8             	mov    %esi,-0x18(%rbp)
  4005a0:	8b 55 ec             	mov    -0x14(%rbp),%edx
  4005a3:	8b 45 e8             	mov    -0x18(%rbp),%eax
  4005a6:	01 d0                	add    %edx,%eax
  4005a8:	89 45 fc             	mov    %eax,-0x4(%rbp)
  4005ab:	8b 45 fc             	mov    -0x4(%rbp),%eax
  4005ae:	5d                   	pop    %rbp
  4005af:	c3                   	retq

00000000004005b0 <main>:
  4005b0:	55                   	push   %rbp
  4005b1:	48 89 e5             	mov    %rsp,%rbp
  4005b4:	48 83 ec 10          	sub    $0x10,%rsp
  4005b8:	c7 45 fc 01 00 00 00 	movl   $0x1,-0x4(%rbp)
  4005bf:	c7 45 f8 02 00 00 00 	movl   $0x2,-0x8(%rbp)
  4005c6:	8b 55 f8             	mov    -0x8(%rbp),%edx
  4005c9:	8b 45 fc             	mov    -0x4(%rbp),%eax
  4005cc:	89 d6                	mov    %edx,%esi
  4005ce:	89 c7                	mov    %eax,%edi
  4005d0:	e8 c1 ff ff ff       	callq  400596 <add>
  4005d5:	89 45 f4             	mov    %eax,-0xc(%rbp)
  4005d8:	8b 45 fc             	mov    -0x4(%rbp),%eax
  4005db:	01 45 f4             	add    %eax,-0xc(%rbp)
  4005de:	8b 45 f8             	mov    -0x8(%rbp),%eax
  4005e1:	01 45 f4             	add    %eax,-0xc(%rbp)
  4005e4:	8b 45 f4             	mov    -0xc(%rbp),%eax
  4005e7:	89 c6                	mov    %eax,%esi
  4005e9:	bf 98 06 40 00       	mov    $0x400698,%edi
  4005ee:	b8 00 00 00 00       	mov    $0x0,%eax
  4005f3:	e8 a8 fe ff ff       	callq  4004a0 <printf@plt>
  4005f8:	b8 00 00 00 00       	mov    $0x0,%eax
  4005fd:	c9                   	leaveq
  4005fe:	c3                   	retq
  4005ff:	90                   	nop

我们定义了一个加法函数add()并且在main函数中进行调用。在main函数中定义了两个局部变量ab,查看汇编代码看到首先将栈指针rsp减小16,申请了16字节的栈空间,并且前4个字节存放值1,接下来的4个字节存放值2,分别对应局部变量ab。在调用完add()函数之后,分别将保存在栈内的局部变量分别存储到eax并且相加到-0xc(%rbp)中,对应main函数中ret += a; ret += b;

可以发现虽然申请了16字节的栈空间,但实际上并没有使用到这么多,为什么不用多少就申请多少?这样不是更节约内存吗?这里主要是为了内存对齐

数据传递

在调用add()函数时需要传递两个参数,看汇编代码可以发现是将%edi%esi中的两个变量相加并且将结果放在了%eax,查看main函数的汇编代码,可以发现传递的参数恰好是放在%edi%esi中,并且调用结束后也是从%eax中取出结果并且加上局部变量的值。

下面看一个传递6个参数的例子。

#include <stdio.h>

int add(int a, int b, int c, int d, int e, int f) {
    int sum = a + b + c + d + e + f;
    return sum;
}

int main() {
    int ret = add(1, 2, 3, 4, 5, 6);
    printf("%d\n", ret);
    return 0;
}
0000000000400596 <add>:
  400596:	55                   	push   %rbp
  400597:	48 89 e5             	mov    %rsp,%rbp
  40059a:	89 7d ec             	mov    %edi,-0x14(%rbp)
  40059d:	89 75 e8             	mov    %esi,-0x18(%rbp)
  4005a0:	89 55 e4             	mov    %edx,-0x1c(%rbp)
  4005a3:	89 4d e0             	mov    %ecx,-0x20(%rbp)
  4005a6:	44 89 45 dc          	mov    %r8d,-0x24(%rbp)
  4005aa:	44 89 4d d8          	mov    %r9d,-0x28(%rbp)
  4005ae:	8b 55 ec             	mov    -0x14(%rbp),%edx
  4005b1:	8b 45 e8             	mov    -0x18(%rbp),%eax
  4005b4:	01 c2                	add    %eax,%edx
  4005b6:	8b 45 e4             	mov    -0x1c(%rbp),%eax
  4005b9:	01 c2                	add    %eax,%edx
  4005bb:	8b 45 e0             	mov    -0x20(%rbp),%eax
  4005be:	01 c2                	add    %eax,%edx
  4005c0:	8b 45 dc             	mov    -0x24(%rbp),%eax
  4005c3:	01 c2                	add    %eax,%edx
  4005c5:	8b 45 d8             	mov    -0x28(%rbp),%eax
  4005c8:	01 d0                	add    %edx,%eax
  4005ca:	89 45 fc             	mov    %eax,-0x4(%rbp)
  4005cd:	8b 45 fc             	mov    -0x4(%rbp),%eax
  4005d0:	5d                   	pop    %rbp
  4005d1:	c3                   	retq

00000000004005d2 <main>:
  4005d2:	55                   	push   %rbp
  4005d3:	48 89 e5             	mov    %rsp,%rbp
  4005d6:	48 83 ec 10          	sub    $0x10,%rsp
  4005da:	41 b9 06 00 00 00    	mov    $0x6,%r9d
  4005e0:	41 b8 05 00 00 00    	mov    $0x5,%r8d
  4005e6:	b9 04 00 00 00       	mov    $0x4,%ecx
  4005eb:	ba 03 00 00 00       	mov    $0x3,%edx
  4005f0:	be 02 00 00 00       	mov    $0x2,%esi
  4005f5:	bf 01 00 00 00       	mov    $0x1,%edi
  4005fa:	e8 97 ff ff ff       	callq  400596 <add>
  4005ff:	89 45 fc             	mov    %eax,-0x4(%rbp)
  400602:	8b 45 fc             	mov    -0x4(%rbp),%eax
  400605:	89 c6                	mov    %eax,%esi
  400607:	bf b8 06 40 00       	mov    $0x4006b8,%edi
  40060c:	b8 00 00 00 00       	mov    $0x0,%eax
  400611:	e8 8a fe ff ff       	callq  4004a0 <printf@plt>
  400616:	b8 00 00 00 00       	mov    $0x0,%eax
  40061b:	c9                   	leaveq
  40061c:	c3                   	retq
  40061d:	0f 1f 00             	nopl   (%rax)

可以发现传递六个参数的时候借助了%edi %esi %edx %ecx %r8d %r9d这六个寄存器,毕竟寄存器的数量是有限的,如果传递更多的参数呢?

下面是一个传递9个参数的例子。

#include <stdio.h>

int add(int a, int b, int c, int d, int e, int f, int g, int h, int i) {
    int sum = a + b + c + d + e + f + g + h + i;
    return sum;
}

int main() {
    int ret = add(1, 2, 3, 4, 5, 6, 7, 8, 9);
    printf("%d\n", ret);
    return 0;
}
0000000000400596 <add>:
  400596:	55                   	push   %rbp
  400597:	48 89 e5             	mov    %rsp,%rbp
  40059a:	89 7d ec             	mov    %edi,-0x14(%rbp)
  40059d:	89 75 e8             	mov    %esi,-0x18(%rbp)
  4005a0:	89 55 e4             	mov    %edx,-0x1c(%rbp)
  4005a3:	89 4d e0             	mov    %ecx,-0x20(%rbp)
  4005a6:	44 89 45 dc          	mov    %r8d,-0x24(%rbp)
  4005aa:	44 89 4d d8          	mov    %r9d,-0x28(%rbp)
  4005ae:	8b 55 ec             	mov    -0x14(%rbp),%edx
  4005b1:	8b 45 e8             	mov    -0x18(%rbp),%eax
  4005b4:	01 c2                	add    %eax,%edx
  4005b6:	8b 45 e4             	mov    -0x1c(%rbp),%eax
  4005b9:	01 c2                	add    %eax,%edx
  4005bb:	8b 45 e0             	mov    -0x20(%rbp),%eax
  4005be:	01 c2                	add    %eax,%edx
  4005c0:	8b 45 dc             	mov    -0x24(%rbp),%eax
  4005c3:	01 c2                	add    %eax,%edx
  4005c5:	8b 45 d8             	mov    -0x28(%rbp),%eax
  4005c8:	01 c2                	add    %eax,%edx
  4005ca:	8b 45 10             	mov    0x10(%rbp),%eax
  4005cd:	01 c2                	add    %eax,%edx
  4005cf:	8b 45 18             	mov    0x18(%rbp),%eax
  4005d2:	01 c2                	add    %eax,%edx
  4005d4:	8b 45 20             	mov    0x20(%rbp),%eax
  4005d7:	01 d0                	add    %edx,%eax
  4005d9:	89 45 fc             	mov    %eax,-0x4(%rbp)
  4005dc:	8b 45 fc             	mov    -0x4(%rbp),%eax
  4005df:	5d                   	pop    %rbp
  4005e0:	c3                   	retq

00000000004005e1 <main>:
  4005e1:	55                   	push   %rbp
  4005e2:	48 89 e5             	mov    %rsp,%rbp
  4005e5:	48 83 ec 10          	sub    $0x10,%rsp
  4005e9:	6a 09                	pushq  $0x9
  4005eb:	6a 08                	pushq  $0x8
  4005ed:	6a 07                	pushq  $0x7
  4005ef:	41 b9 06 00 00 00    	mov    $0x6,%r9d
  4005f5:	41 b8 05 00 00 00    	mov    $0x5,%r8d
  4005fb:	b9 04 00 00 00       	mov    $0x4,%ecx
  400600:	ba 03 00 00 00       	mov    $0x3,%edx
  400605:	be 02 00 00 00       	mov    $0x2,%esi
  40060a:	bf 01 00 00 00       	mov    $0x1,%edi
  40060f:	e8 82 ff ff ff       	callq  400596 <add>
  400614:	48 83 c4 18          	add    $0x18,%rsp
  400618:	89 45 fc             	mov    %eax,-0x4(%rbp)
  40061b:	8b 45 fc             	mov    -0x4(%rbp),%eax
  40061e:	89 c6                	mov    %eax,%esi
  400620:	bf d8 06 40 00       	mov    $0x4006d8,%edi
  400625:	b8 00 00 00 00       	mov    $0x0,%eax
  40062a:	e8 71 fe ff ff       	callq  4004a0 <printf@plt>
  40062f:	b8 00 00 00 00       	mov    $0x0,%eax
  400634:	c9                   	leaveq
  400635:	c3                   	retq
  400636:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
  40063d:	00 00 00

可以看到最后三个参数不再是通过寄存器传递给add()函数了,而是通过pushq指令压入栈中,而且压栈顺序是请求参数的逆序。在add()函数中是如何取栈中的参数呢?查看汇编代码发现分别取了0x10(%rbp) 0x18(%rbp) 0x20(%rbp,三个地址的差值都是8字节,这个倒是没疑问,但为什么第一个参数的地址是0x10(%rbp)呢?跳过的16个字节保存的是什么内容呢?在使用callq指令调用add()函数的时候,会将返回地址0x400614压入栈中,占用8字节,在add()函数开头将%rbp压入栈中,同样占用8个字节,所以取参数时需要跳过16个字节,通过gdb查看栈中保存的内容发现确实如此。

Pasted image 20221005162111.png

Slice 1 2 1.png 虽然可以通过栈传递更多函数参数,但是访问内存的速度不如访问寄存器,所以在设计函数时要尽可能减少函数参数的个数。

控制转移

rip寄存器保存的是指令的地址,cpu每次执行指令都从指令寄存器内获取要执行的指令地址,所以控制转移其实就是修改rip寄存器的值,指向add()函数的代码段地址即可。通过gdb单步汇编代码跟踪就可以发现在调用之前%rip内保存的地址是0x40060f,之后修改为0x400596跳转到add()函数。当add()函数执行完之后,retq指令会将栈中保存的返回地址0x400614赋给%rip,完成函数调用,并且继续执行main中的指令。

上下文恢复

在完成函数调用之后,需要将之前保存的寄存器等恢复成和函数调用前一模一样,上面的汇编代码在完成函数调用后,执行了add $0x18,%rsp指令,通过修改rsp寄存器的值清空调用函数前压入栈中的三个参数,可以发现0x18刚好就是24个字节,对应参数栈中7 8 9的大小。

参考资料

X86-84 wiki
CPU Registers x86-64
The 64 bit x86 C Calling Convention