x86_64函数调用

x86_64 函数调用

本文将讨论 x86_64 平台的函数调用过程,简要介绍部分常见的调用约定.阅读本文需要读者对 x86_64 汇编语言有一些基本的了解.

本文只讨论「长度不大于 64 bit 的整数类型」与「指针类型」作为函数参数、返回值时传递的方式,不涉及「长度大于 64 bit 的整数类型参数」与结构体、浮点数等类型的传递方式.

本文代码为了展示函数调用与返回过程中的汇编语言实现,引入了大量无意义、冗余的代码,本文代码不能作为学习编程语言中写法的推荐或参考.

前置知识

相信很多人都遇到过因函数的递归次数过多,导致程序运行时出现栈溢出的问题.这个溢出的「栈」是本文要关注的重点,函数调用的过程和它密不可分.
栈从高地址向低地址增长.

info
INFO
无特殊说明时,本文中说提及的「栈」均指代程序的「调用栈」或者说「运行时栈」,而不是指数据结构中的「堆栈」.

PUSH

PUSH 前
PUSH 操作类似数据结构中的「堆栈」 .
PUSH 指令总是

  1. 递减 rsp
    PUSH 时
  2. PUSH 的值存储在 rsp 递减后指向的位置
    PUSH 后
    笔者说明 PUSH 过程意在强调:在 PUSH 操作中, rsp 指向的是最后一个数据的位置,而不是指向栈上待写入数据的位置.
    简单的说:rsp 总是指向有效的数据.

POP

POP 操作与 PUSH 操作相对应.
POP 指令总是:
POP 前

  1. 将栈顶的值取出
  2. 递增 rsp
    POP 后

tip
TIP
栈顶:当前 rsp 指向的值.

LEAVE

LEAVE 操作是等价于

1
2
mov %rbp , %rsp
pop %rbp

也就是:

  1. 通过执行 mov %rbp , %rsprsp = rbp),恢复 rsp 至执行 CALL 后的位置.
  2. 通过执行 pop %rbp,恢复 rbp 至原来的栈底.

无返回值与参数的「函数调用与返回过程」

本小节将说明函数无参数、无返回值的函数调用与返回的过程.请读者们将关注点集中理解在函数调用的流程上,不必过多的关注具体的细节.

首先,尝试写出简单的函数调用的示例.

1
2
3
4
5
6
7
8
9
/* call1.c */
void func1()
{
int v = 0;
}
int main(void)
{
func1();
}

查看该程序的反汇编代码:
以下代码由 clang 生成并使用 objdump 反编译获得,笔者已删去其中的次要部分(删节部分并未全部标注).(后同)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<func1>:
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
;此处有删节
leave
ret

<main>:
;此处有删节
call 1147 <func1>
mov $0x0,%eax
pop %rbp
ret

首先请关注由 main()func1() 的调用过程.

调用前

  • main(),执行 call 1147 <func1> 就完成了对 func1 的调用.callmain() 中下一条语句的地址(也就是「mov $0x0,%eax」这句的地址)压入栈中并修改 rip 的值为 func1() 的地址.
    调用时1
  • func1(),通过将 rbp 压栈的方式,保存 rbp
    调用时2

tip
TIP
笔者特意删节掉了关于 rbp 里面的值的部分.
也请读者暂时不要关注在执行 call 1147 <func1>rbp 的值是多少.
请读者暂且记住此时 rbp 指向栈的某一个位置,而且 rbp < rsp 即可.

  1. 通过执行 mov %rsp,%rbprbp = rsp),原来的栈底(rbp 指向的位置)成为了新的栈顶(rsp 指向的位置)

此时调用函数 func1() 的过程结束.现在关注如何从 func1() 返回至 main()

  1. 函数返回时,使用 LEAVE 恢复了先前保存栈底.
    返回时1
  2. 使用 ret 根据 rsp 指向的位置从栈中弹出 返回位置,并通过修改 rip的值为 返回地址 完成了函数的返回.
    返回时2

有返回值和参数的「函数调用与返回过程」上

无返回值与参数的「函数调用与返回过程」可以看作本节要讨论的 有返回值和参数的「函数调用与返回过程」的一种简化情况.

和上一节一样,研究一个简单的函数示例对理解该过程有帮助.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* call2.c */
void func1()
{
}
int func2(int a, long b, char *c)
{
*c = a * b;
func1();
return a * b;
}
int main()
{
char value;
int rc = func2(1, 2, &value);
}

反汇编后得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<func1>:
push %rbp
mov %rsp,%rbp
pop %rbp
ret

<func2>:
push %rbp
mov %rsp,%rbp
sub $0x20,%rsp
mov %edi,-0x4(%rbp)
mov %rsi,-0x10(%rbp)
mov %rdx,-0x18(%rbp)
movslq -0x4(%rbp),%rax
imul -0x10(%rbp),%rax
mov -0x18(%rbp),%rcx
mov %al,(%rcx)
call 1140 <func1>
movslq -0x4(%rbp),%rcx
imul -0x10(%rbp),%rcx
mov %ecx,%eax
add $0x20,%rsp
pop %rbp
ret

<main>:
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
mov %fs:0x28,%rax
mov %rax,-0x8(%rbp)
mov $0x1,%edi
mov $0x2,%esi
lea -0x9(%rbp),%rdx
call 1150 <func2>
mov %eax,-0x10(%rbp)
mov %fs:0x28,%rcx
mov -0x8(%rbp),%rdx
cmp %rdx,%rcx
jne 11d9 <main+0x49>
xor %eax,%eax
add $0x10,%rsp
pop %rbp
ret

可以清晰的看到,在执行 call 1140 <func2> 之前 main() 进行了如下操作:

1
2
3
mov    $0x1,%edi
mov $0x2,%esi
lea -0x9(%rbp),%rdx

事实上,这三条语句意在进行参数的传递.在进行函数调用时,主调函数将参数存储在寄存器中,在被调函数中直接使用,通过这样的方式传递参数.

观察函数调用的实参 1, 2, &value,可以看到:

  • 1 使用 rdi 的低 32 位,也就是 edi 来传递.
  • 2 使用 rsi 的低 32 位,也就是 esi 来传递.
  • &value 使用 rdx 进行传递.

值得注意的是:即使 &value 的类型是指针,与 整型变量 看似不同,但在传递方式上并无差异.

这是 func2() 的尾部代码片段:

1
2
3
4
5
imul   -0x10(%rbp),%rcx
mov %ecx,%eax
add $0x20,%rsp
pop %rbp
ret

可以看到乘法产生的结果通过 mov %ecx,%eax 放在了 rax 的低 32 位(eax)中.返回后,在 main() 有:

1
2
call   1150 <func2>
mov %eax,-0x10(%rbp)

请看,此处 eax 中仍是 func2() 中计算的 a * b 的值,但在 main() 却进行了读取.这不就是从 被调函数 中传送给主调函数的值吗?是的,rax 寄存器常常被用来传递返回值.

讨论完了返回值与参数,此时再来看看 func2() 的调用流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<func2>:
push %rbp
mov %rsp,%rbp
sub $0x20,%rsp
mov %edi,-0x4(%rbp)
mov %rsi,-0x10(%rbp)
mov %rdx,-0x18(%rbp)
movslq -0x4(%rbp),%rax
imul -0x10(%rbp),%rax
mov -0x18(%rbp),%rcx
mov %al,(%rcx)
call 1140 <func1>
movslq -0x4(%rbp),%rcx
imul -0x10(%rbp),%rcx
mov %ecx,%eax
add $0x20,%rsp
pop %rbp
ret

相信不难注意到:sub $0x20,%rsp.前文提及过,栈是由高地址向低地址的方向增长的.rsp 减少 0x20 意味着栈增长 0x20.那么栈为什么需要增长呢?因为需要在栈上为 func2() 的局部变量或临时的变量等分配空间.与 sub $0x20,%rsp 对应的操作是 add $0x20,%rsp 在函数返回前需要增加 rsp 以释放栈上的空间.其余步骤与 无返回值与参数的「函数调用与返回过程」 所述并无实质差异,此处不再赘述.

有返回值和参数的「函数调用与返回过程」下

前面的小节中,描述了函数参数较少的情况下参数传递的方式.本节则将关注较多的参数将为函数的调用带来什么变化.
本节采取的示例拥有 10 个参数.

1
2
3
4
5
6
7
8
9
10
11
12
13
/* call3.c */
void func1()
{
}
int func3(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j)
{
func1();
return a * 1 + b * 2 + c * 3 + d * 4 + e * 5 + f * 6 + g * 7 + h * 8 + i * 9 + j * 10;
}
int main()
{
func3(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
}

反汇编得到了较长的汇编代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<func1>:
push %rbp
mov %rsp,%rbp
pop %rbp
ret

<func3>:
push %rbp
mov %rsp,%rbp
push %rbx
sub $0x28,%rsp
mov 0x28(%rbp),%eax
mov 0x20(%rbp),%r10d
mov 0x18(%rbp),%r11d
mov 0x10(%rbp),%ebx
mov %edi,-0xc(%rbp)
mov %esi,-0x10(%rbp)
mov %edx,-0x14(%rbp)
mov %ecx,-0x18(%rbp)
mov %r8d,-0x1c(%rbp)
mov %r9d,-0x20(%rbp)
mov %eax,-0x24(%rbp)
mov %r10d,-0x28(%rbp)
mov %r11d,-0x2c(%rbp)
mov %ebx,-0x30(%rbp)
call 1120 <func1>
mov -0xc(%rbp),%eax
shl $0x0,%eax
mov -0x10(%rbp),%ecx
shl $0x1,%ecx
add %ecx,%eax
imul $0x3,-0x14(%rbp),%ecx
add %ecx,%eax
mov -0x18(%rbp),%ecx
shl $0x2,%ecx
add %ecx,%eax
imul $0x5,-0x1c(%rbp),%ecx
add %ecx,%eax
imul $0x6,-0x20(%rbp),%ecx
add %ecx,%eax
imul $0x7,0x10(%rbp),%ecx
add %ecx,%eax
mov 0x18(%rbp),%ecx
shl $0x3,%ecx
add %ecx,%eax
imul $0x9,0x20(%rbp),%ecx
add %ecx,%eax
imul $0xa,0x28(%rbp),%ecx
add %ecx,%eax
add $0x28,%rsp
pop %rbx
pop %rbp
ret
<main>:
push %rbp
mov %rsp,%rbp
sub $0x30,%rsp
mov $0x1,%edi
mov $0x2,%esi
mov $0x3,%edx
mov $0x4,%ecx
mov $0x5,%r8d
mov $0x6,%r9d
movl $0x7,(%rsp)
movl $0x8,0x8(%rsp)
movl $0x9,0x10(%rsp)
movl $0xa,0x18(%rsp)
call 1130 <func3>
xor %ecx,%ecx
mov %eax,-0x4(%rbp)
mov %ecx,%eax
add $0x30,%rsp
pop %rbp
ret

笔者首先关注的是 main() 的这个部分:

1
2
3
4
5
6
7
8
9
10
11
mov    $0x1,%edi
mov $0x2,%esi
mov $0x3,%edx
mov $0x4,%ecx
mov $0x5,%r8d
mov $0x6,%r9d
movl $0x7,(%rsp)
movl $0x8,0x8(%rsp)
movl $0x9,0x10(%rsp)
movl $0xa,0x18(%rsp)
call 1130 <func3>

可以发现.在进行参数的传递时,第 1 个参数(从 1 开始计数)至第 6 个参数分别采用 rdirsirdxrcxr8r9 这 6 个寄存器对应的低 32 位部分.而剩余的 4 个参数采取了压栈的方式进行传递.

x86-64 调用约定

首先,笔者要声明的是:调用约定与设备的 ABIapplication binary interface)有关,而 ABI 依赖「硬件特性」与「操作系统」.在 x86-64 上也不只有一种调用约定.

Microsoft x64 calling convention

这张表展示了 Microsoft x64 calling convention 的部分内容,笔者展示这张表的目的不在于向读者介绍 Microsoft x64 calling convention 的具体内容,仅仅是为了说明调用约定不止一种.当遇到与笔者接下来介绍的 System V AMD64 ABI 不同的调用约定时,也不要对此感到惊奇和诧异.

Parameter typefifth and higherfourththirdsecondleftmost
floating-pointstackXMM3XMM2XMM1XMM0
integerstackR9R8RDXRCX
Aggregates (8, 16, 32, or 64 bits) and __m64stackR9R8RDXRCX
Other aggregates, as pointersstackR9R8RDXRCX
__m128, as a pointerstackR9R8RDXRCX

[3]

System V AMD64 ABI

本节中将介绍 System V AMD64 ABI 的部分特性.

函数的前六个参数(每个参数均小于等于 8 byte 且不为浮点型变量)将由左至右依次存放在 rdirsirdxrcxr8r9 的相应位置,更多的参数将由右向左依次入栈,借助栈完成参数的传递.返回值将保存在 rax 中.

请看代码:

1
2
3
4
5
int func4(int a, unsigned b, long c, unsigned long d, long long e, unsigned long long f);
int main()
{
func4(1, 2U, 3L, 4UL, 5LL, 6ULL);
}

通过编译器与反汇编工具可以得到这段代码的汇编语言描述.

1
2
3
4
5
6
7
8
9
10
11
12
13
<main>:
push %rbp
mov %rsp,%rbp
mov $0x6,%r9d
mov $0x5,%r8d
mov $0x4,%ecx
mov $0x3,%edx
mov $0x2,%esi
mov $0x1,%edi
call 29 <main+0x29>
mov $0x0,%eax
pop %rbp
ret

可以看到常量(更准确的叫法是「立即数」)0x1 被存放在 edi、0x2 被存放在 esi、0x3 被存放在 edx、0x4 被存放在 ecx、0x5 被存放在 r8d、0x6 被存放在 r9d
此时,可能有读者为此感到疑惑:「不是说第一个参数放在 rdi 吗?怎么放在 edi 里了?(后面的几个参数也会有雷同的疑惑)」
事实上,这并非是什么错误.edi 在 x86_64 上是 rdi 的低 32 位;类似的,esi 在 x86_64 上是 rsi 的低 32 位;edx 在 x86_64 上是 rdx 的低 32 位;ecx 在 x86_64 上是 rcx 的低 32 位,r8d 在 x86_64 上是 r8 的低 32 位;r9d 在 x86_64 上是 r9 的低 32 位.

值的注意的还有一点:在 x86_64 平台上,例如: mov $0x1,%edi 等源操作数为 double wordmov 指令中,目的寄存器的高 32 位会被置为 0.这也使得可以使用将 零扩展复制 一步完成.

info
INFO

本文将不会给予进一步说明的是:

  • XMM0XMM7 用来放置浮点型变量
  • 对于系统调用,R10 用来替代 RCX [4]

回看上文中给出的示例,将会发现文中示例无不符合了 System V AMD64 ABI 的要求.

结构体的按值传递

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct test_small
{
int a;
char ch[4];
};

struct test_small func1(struct test_small arg)
{
arg.a = 0;
arg.ch[0] = 3;
return arg;
}
struct test_big
{
long a, b, c;
};
struct test_big func2(struct test_big arg)
{
arg.a = arg.b + arg.c;
return arg;
}

int main()
{
struct test_small s;
func1(s);
struct test_big b;
b.a = 1;
b.b = 2;
b.c = 3;
func2(b);
}

可以看到源码中定义了两个结构体.其中 struct test_small 大小为 8 bytesstruct test_big 大小为 24 bytes
大小对结构体按值传递的方式有及其重要的影响.

通过反汇编可以得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<func1>:
push %rbp
mov %rsp,%rbp
mov %rdi,-0x8(%rbp) # 将 rdi 中的 strcut test_small arg 复制到 rbp - 0x8
movl $0x0,-0x8(%rbp) # arg.a = 0 ;
movb $0x3,-0x4(%rbp) # arg.ch[0] = 3 ;
mov -0x8(%rbp),%rax # 将 arg 复制在 rax 中返回
pop %rbp
ret

<func2>:
push %rbp
mov %rsp,%rbp
mov %rdi,-0x8(%rbp) # rdi 里存放的是 arg 的地址,把 arg 的地址复制到 rbp - 0x8
mov 0x18(%rbp),%rdx # 把 在 main() 中复制到栈上的 b.c 复制到 rdx
mov 0x20(%rbp),%rax # 把 在 main() 中复制到栈上的 b.b 复制到 rax
add %rdx,%rax # arg.b + arg.c
mov %rax,0x10(%rbp) # arg.a = arg.b + arg.c
mov -0x8(%rbp),%rcx # arg 的地址被放在了 rcx 里
mov 0x10(%rbp),%rax
mov 0x18(%rbp),%rdx
mov %rax,(%rcx) # 本行开始是为返回 arg 作准备 arg.a = arg.a
mov %rdx,0x8(%rcx) # arg.b = arg.b
mov 0x20(%rbp),%rax # 把 rbp + 0x20 指向的值复制到 rax
mov %rax,0x10(%rcx) # arg.c = arg.c
mov -0x8(%rbp),%rax # 把 arg 的地址放在 rax 里返回
pop %rbp
ret

<main>:
push %rbp
mov %rsp,%rbp # rbp = rsp
sub $0x50,%rsp # rsp -= 0x50 所以 rsp == rbp - 0x50
mov %fs:0x28,%rax
mov %rax,-0x8(%rbp)
xor %eax,%eax
mov -0x10(%rbp),%rax # 将 rbp - 0x10 处的 Quad Word 复制到 rax.rbp - 0x10 存放的是 s.
mov %rax,%rdi # 将 rax 里的 s 复制到 rdi.作为 struct test_small arg 实参传递给 func1().
call 1139 <func1> # 调用 func1()
movq $0x1,-0x30(%rbp) # b.a = 1 ;
movq $0x2,-0x28(%rbp) # b.b = 2 ;
movq $0x3,-0x20(%rbp) # b.c = 3 ;
lea -0x50(%rbp),%rax # 当前栈顶的地址为 rbp - 0x50,将栈顶的地址复制到 rax
push -0x20(%rbp) # 将 b.c 压入栈
push -0x28(%rbp) # 将 b.b 压入栈
push -0x30(%rbp) # 将 b.a 压入栈,这三次压栈完成了对 结构体 struct test_big b 的复制,且 struct test_big b 的副本的地址已存放在了 rax
mov %rax,%rdi # 将 struct test_big b 的副本的地址复制给 rdi.
call 1152 <func2> # 调用 func2()
add $0x18,%rsp
mov $0x0,%eax
mov -0x8(%rbp),%rdx
sub %fs:0x28,%rdx
je 11f7 <main+0x6d>
call 1030 <__stack_chk_fail@plt>
leave
ret

可以看到大小为 8 bytesstruct test_small 存储在 rdi 中完成了传递.而大小为 24 bytesstruct test_big 则无法存放仅仅能容纳 8 bytesrdi 中,自然没法使用 rdi 进行传递.使用栈完成对 struct test_big 等大于 8 bytes 的结构体(当然也不仅仅只是结构体,联合体、int128_t 等数据也使用类似的方式传递)进行传递成为了仅有的办法.

本段代码中,func2 的逻辑较为复杂,可能需要读者将 main()func2() 相互参考才能明白其中的逻辑.
当遇到困难时,读者可通过画出栈的图示的方式进行分析.(可参考笔者在本文开始处的做法)

笔者也画了三张图用来表示 struct test_big 的传递过程,供读者参考.

即将执行push   -0x20(%rbp)

完成执行call   1152 <func2>

完成执行mov    %rdi,-0x8(%rbp)


需要提醒一下的是:结构体的大小并不是结构体的各个成员的大小的代数和.结构体的大小还需要考虑内存对齐的因素.在判断结构体的按值传递方式时,内存对齐将是一个不容忽略的因素.

通过这次的分析,可以发现,大结构体(大于 8 bytes)的按值传递的效率较低.当对程序的运行效率有较高的要求时,应当首先考虑传址而不是传值.

C++ 与参数传递

在 x86_64 Linux 平台上,C++ 的程序的普通函数调用过程与上文中所述并无差异.

将上文代码使用 g++ 编译后重新反汇编得到的代码为:

1
2
3
4
5
int func4(int a, unsigned b, long c, unsigned long d, long long e, unsigned long long f);
int main()
{
func4(1, 2U, 3L, 4UL, 5LL, 6ULL);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
<main>:
push %rbp
mov %rsp,%rbp
mov $0x6,%r9d
mov $0x5,%r8d
mov $0x4,%ecx
mov $0x3,%edx
mov $0x2,%esi
mov $0x1,%edi
call 29 <main+0x29>
mov $0x0,%eax
pop %rbp
ret

但类的非静态成员函数的调用与上文有较多不同.在不同中,又可分为两类:

  • 非虚成员函数
  • 虚成员函数

非虚成员函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cstdio>
class test
{
int a, b;

public:
test() = default;
int sum()
{
return a + b;
}
};
int main()
{
test t;
int s = t.sum();
printf("%d\n", s);
}

C++ 语言通过 g++ 生成的程序反汇编得到的代码可能没有 C 语言通过 gcc 生成的程序反汇编的得到的代码那么简单易懂.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<main>:
push %rbp
mov %rsp,%rbp
sub $0x20,%rsp
mov %fs:0x28,%rax
mov %rax,-0x8(%rbp)
xor %eax,%eax
lea -0x10(%rbp),%rax # -0x10(%rbp) 是个局部变量,本指令将局部变量的地址存储在了 rax
mov %rax,%rdi # 将 rax 拷贝至 rdi
call 11a0 <_ZN4test3sumEv> # 调用 sum() 函数
mov %eax,-0x14(%rbp)
mov -0x14(%rbp),%eax
mov %eax,%esi
lea 0xe89(%rip),%rdi
mov $0x0,%eax
call 1030 <printf@plt>
mov $0x0,%eax
mov -0x8(%rbp),%rdx
sub %fs:0x28,%rdx
je 119e <main+0x55>
call 1040 <__stack_chk_fail@plt>
leave
ret

<_ZN4test3sumEv>:
push %rbp
mov %rsp,%rbp
mov %rdi,-0x8(%rbp) # 把 rdi 中存放的地址拷贝至局部变量
mov -0x8(%rbp),%rax # 将局部变量中存储的地址拷贝至 rax
mov (%rax),%edx # 将 rax 指向的 一个 double word 拷贝至 edx
mov -0x8(%rbp),%rax # 再次将局部变量中存储的地址拷贝至 rax
mov 0x4(%rax),%eax # 将 (rax + 0x4) 的一个 double word 拷贝至 eax
add %edx,%eax # 将 edx 加在 eax 上
pop %rbp
ret

众所周知,C++ 的非静态成员函数有一个隐式的参数就是 *this 指向成员函数所在的类的类型的指针.
例如:
在考虑 C++ 与汇编代码的关系时,可以将本例中 sum 的理解为:

1
2
3
4
int sum(class test *this)
{
return this->a + this->b;
}

简而言之,C++ 非静态非虚成员函数含有一个隐式的 this 指针参数,作为第一个参数传递.

这与上文所说的一致.

「第一个小于等于 8 bytes 的整形参数在 System V AMD64 ABI」通过 rdi 传递

好,现在尝试增多 C++ 非静态非虚成员函数 的参数数量.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cstdio>
class test
{
int a, b;

public:
test() = default;
int sum2(int u, int v, int w, int x, int y, int z)
{
return a + b + u + v + w + x + y + z;
}
};
int main()
{
test t;
int s = t.sum2(1, 2, 3, 4, 5, 6);
printf("%d\n", s);
}

反汇编得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<main>:
push %rbp
mov %rsp,%rbp
sub $0x20,%rsp
mov %fs:0x28,%rax
mov %rax,-0x8(%rbp)
xor %eax,%eax
lea -0x10(%rbp),%rax # -0x10(%rbp) 是个局部变量,本指令将局部变量的地址存储在了 rax
sub $0x8,%rsp
push $0x6 # 注意 这个参数使用了 栈 进行传递
mov $0x5,%r9d # 参数传递
mov $0x4,%r8d # 参数传递
mov $0x3,%ecx # 参数传递
mov $0x2,%edx # 参数传递
mov $0x1,%esi # 参数传递
mov %rax,%rdi # 将 rax 里保存的指针 拷贝至 rdi
call 11c6 <_ZN4test4sum2Eiiiiii> # 调用 sum2() 函数
add $0x10,%rsp
mov %eax,-0x14(%rbp)
mov -0x14(%rbp),%eax
mov %eax,%esi
lea 0xe64(%rip),%rdi
mov $0x0,%eax
call 1030 <printf@plt>
mov $0x0,%eax
mov -0x8(%rbp),%rdx
sub %fs:0x28,%rdx
je 11c3 <main+0x7a>
call 1040 <__stack_chk_fail@plt>
leave
ret

<_ZN4test4sum2Eiiiiii>:
push %rbp
mov %rsp,%rbp
mov %rdi,-0x8(%rbp) # 在栈上保存 rdi,rdi
mov %esi,-0xc(%rbp) # 在栈上保存 esi
mov %edx,-0x10(%rbp) # 在栈上保存 edx
mov %ecx,-0x14(%rbp) # 在栈上保存 ecx
mov %r8d,-0x18(%rbp) # 在栈上保存 r8d
mov %r9d,-0x1c(%rbp) # 在栈上保存 r8d
mov -0x8(%rbp),%rax # rdi 里保存的指针 复制到 rax
mov (%rax),%edx # rdi 里保存的指针 指向的 double word 复制到 edx
mov -0x8(%rbp),%rax # rdi 里保存的指针 复制到 rax
mov 0x4(%rax),%eax # rdi 里保存的指针+0x4 的 double word 复制到 eax
add %eax,%edx # eax 加到 edx
mov -0xc(%rbp),%eax # -0xc(%rbp) 里是之前 esi 里的值,也就是 形参 int u 的值
add %eax,%edx # eax 加到 edx
mov -0x10(%rbp),%eax # -0x10(%rbp) 里是之前 edx 里的值,也就是 形参 int v 的值
add %eax,%edx # eax 加到 edx
mov -0x14(%rbp),%eax # -0x14(%rbp) 里是之前 ecx 里的值,也就是 形参 int w 的值
add %eax,%edx # eax 加到 edx
mov -0x18(%rbp),%eax # -0x18(%rbp) 里是之前 r8d 里的值,也就是 形参 int x 的值
add %eax,%edx # eax 加到 edx
mov -0x1c(%rbp),%eax # -0x1c(%rbp) 里是之前 r9d 里的值,也就是 形参 int y 的值
add %eax,%edx # eax 加到 edx
mov 0x10(%rbp),%eax # 0x10(%rbp) 之前被 push 在了栈上,也就是 形参 int z 的值
add %edx,%eax # eax 加到 edx
pop %rbp
ret

可以看到:算上隐式的 this 指针,函数 sum2() 共有 7 个参数.参数 1-6 仍然依次采用 rdirsirdxrcxr8r9 进行传递.第 7 个参数 int z 也正常的使用了栈进行传递.

总结一下,C++ 非静态非虚函数成员的调用过程与 C 语言函数的唯一差别在于需要把 *this 理解为一个参数.

虚成员函数

在给出本节的示例之前,笔者认为有必要再次强调下面的代码只是为了演示虚成员函数的调用过程.如果有人在实际的程序设计的情景中仿照笔者给出的这些示例,那么请允许笔者借用 Scott Meyers 的一句话:

把他们隔离起来直到他们保证不再这样做为止

(笔者在Effective C++ 或是 More Effective C++ 中看到过这句话,但找不到具体出处了,这句只是根据自己的回忆写出的).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <cstdio>
class test1
{
protected:
int a;
int b;

public:
test1(int A, int B) : a(A), b(B) {}
virtual void info()
{
printf("max=%d\n", a > b ? a : b);
}
};
class test2 : public test1
{
public:
test2(int x, int y) : test1(x, y) {}
void info() override
{
printf("min=%d\n", a < b ? a : b);
}
};
int main()
{
test1 t1(1, 2);
test2 t2(3, 4);
test1 *pr1 = &t1;
pr1->info();
test2 *pr2 = &t2;
pr2->info();
}

编译后反汇编得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
<main>:
push %rbp
mov %rsp,%rbp
sub $0x40,%rsp
mov %fs:0x28,%rax
mov %rax,-0x8(%rbp)
xor %eax,%eax
lea -0x30(%rbp),%rax # 把 rbp - 0x30
mov $0x2,%edx # 参数
mov $0x1,%esi # 参数
mov %rax,%rdi # 参数 this 指针
call 11de <_ZN5test1C1Eii> # 构造 t1
lea -0x20(%rbp),%rax
mov $0x4,%edx # 参数
mov $0x3,%esi # 参数
mov %rax,%rdi # 参数 this 指针
call 1256 <_ZN5test2C1Eii> # 构造 t2
lea -0x30(%rbp),%rax # t1 的地址存放在 rax 里
mov %rax,-0x40(%rbp) # t1 的地址从 rax 里复制到 rbp - 0x40.rbp - 0x40 存储的是 pr1
mov -0x40(%rbp),%rax # 把 pr1 复制到 rax
mov (%rax),%rax # 把 pr1 指向的 Quad Word 复制到 rax
mov (%rax),%rdx # 把 pr1 指向的 Quad Word 指向的 Quad Word 复制到 rdx
mov -0x40(%rbp),%rax # 把 pr1 复制到 rax
mov %rax,%rdi # 传递参数,把 this 指针从 rax 复制到 rdi.pr2 的值就是 this 指针的实参.
call *%rdx # 调用 rdx 指向的函数指针
lea -0x20(%rbp),%rax # t2 的地址存放在 rax 里
mov %rax,-0x38(%rbp) # t2 的地址从 rax 里复制到 rbp - 0x38.rbp - 0x38 存储的是 pr2
mov -0x38(%rbp),%rax # 把 pr2 复制到 rax
mov (%rax),%rax # 把 pr2 指向的 Quad Word 复制到 rax
mov (%rax),%rdx # 把 pr2 指向的 Quad Word 指向的 Quad Word 复制到 rdx
mov -0x38(%rbp),%rax# 把 pr2 复制到 rax
mov %rax,%rdi # 传递参数,把 this 指针从 rax 复制到 rdi.pr2 的值就是 this 指针的实参.
call *%rdx # 调用 rdx 指向的函数指针
mov $0x0,%eax
mov -0x8(%rbp),%rcx
sub %fs:0x28,%rcx
je 11db <main+0x92>
call 1040 <__stack_chk_fail@plt>
leave
ret

<_ZN5test1C1Eii>: # test1 构造函数
push %rbp
mov %rsp,%rbp
mov %rdi,-0x8(%rbp)
mov %esi,-0xc(%rbp)
mov %edx,-0x10(%rbp)
lea 0x2ba5(%rip),%rdxs
mov -0x8(%rbp),%rax
mov %rdx,(%rax)
mov -0x8(%rbp),%rax
mov -0xc(%rbp),%edx
mov %edx,0x8(%rax)
mov -0x8(%rbp),%rax
mov -0x10(%rbp),%edx
mov %edx,0xc(%rax)
pop %rbp
ret

<_ZN5test14infoEv>: # test1::info()
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
mov %rdi,-0x8(%rbp)
mov -0x8(%rbp),%rax
mov 0x8(%rax),%edx
mov -0x8(%rbp),%rax
mov 0xc(%rax),%eax
cmp %eax,%edx
jle 1239 <_ZN5test14infoEv+0x27>
mov -0x8(%rbp),%rax
mov 0x8(%rax),%eax
jmp 1240 <_ZN5test14infoEv+0x2e>
mov -0x8(%rbp),%rax
mov 0xc(%rax),%eax
mov %eax,%esi
lea 0xdbb(%rip),%rdi
mov $0x0,%eax
call 1030 <printf@plt>
leave
ret

<_ZN5test2C1Eii>: # test2 构造函数
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
mov %rdi,-0x8(%rbp)
mov %esi,-0xc(%rbp)
mov %edx,-0x10(%rbp)
mov -0x8(%rbp),%rax
mov -0x10(%rbp),%edx
mov -0xc(%rbp),%ecx
mov %ecx,%esi
mov %rax,%rdi
call 11de <_ZN5test1C1Eii>
lea 0x2afd(%rip),%rdx
mov -0x8(%rbp),%rax
mov %rdx,(%rax)
leave
ret

<_ZN5test24infoEv>: # test2::info()
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
mov %rdi,-0x8(%rbp)
mov -0x8(%rbp),%rax
mov 0x8(%rax),%edx
mov -0x8(%rbp),%rax
mov 0xc(%rax),%eax
cmp %eax,%edx
jge 12b5 <_ZN5test24infoEv+0x27>
mov -0x8(%rbp),%rax
mov 0x8(%rax),%eax
jmp 12bc <_ZN5test24infoEv+0x2e>
mov -0x8(%rbp),%rax
mov 0xc(%rax),%eax
mov %eax,%esi
lea 0xd47(%rip),%rdi
mov $0x0,%eax
call 1030 <printf@plt>
leave
ret

笔者本段代码中 main() 的汇编语言描述提供了十分详细的注释,相信读者可根据注释自行理解.

C++ 众多编译器都采用虚函数表的方式实现了 C++ 的虚函数调用.在本例中,gcc 自然也没有什么例外的使用虚函数表实现 C++ 的虚函数功能.

虚函数表可以理解为一个函数指针的数组.编译器需要为含有虚函数的类型生成一张虚函数表,而同一个类型的多个实例将通过存储虚函数表的首元素的地址共享同一张虚函数表.

1
2
3
4
5
6
7
/* 下面的是伪代码,只是为了展示虚函数表的与类的关系 */
class test1
{
static const void *virtualFunctionTable[SIZE];
int a;
int b;
};

此处只是一个粗略的描述,所以笔者采用了 void *virtualFunctionTable[SIZE]; 这种写法,实际上这种写法很不严谨.
写成 void (*virtualFunctionTable[SIZE])(); 这种写法并不能更好.写成 void* 首先较为方便,并且避免读者纠结于类似「void (*func)(int *a,int b);」这种函数指针不能存放在 void (*virtualFunctionTable[SIZE])() 这类次要问题.请务必注意这只是一个为了方便理解虚函数表,笔者给出的伪代码而已.
可以看到在虚函数的调用中,需要访问虚函数表来完成函数的定位,但除此之外,参数的传递与函数值的返回仍然遵守前文所述的规则.

参考资料

1. 段刚.加密与解密[M].第4版.北京:电子工业出版社.
2. KipIrvine.汇编语言:基于x86处理器[M].原书第7版.贺莲,译.北京:机械工业出版社.
3. Randal E.Bryant.深入理解计算机系统[M].第三版.龚奕利,译.北京:机械工业出版社.
4. x64 calling convention[G/OL]. docs.microsoft.com. https://docs.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-160.
5. 维基百科编者. X86调用约定[G/OL]. 维基百科. 2020(20200922)[2020-09-22]. https://zh.wikipedia.org/zh-hans/X86调用约定.
6. WikipediaContributors. X86调用约定[G/OL]. 维基百科. 2020(20200922)[2020-09-22]. https://en.wikipedia.org/wiki/X86_calling_conventions.