C语言中,程序就是由许多的函数调用组成的。因此深入理解函数调用的基本原理,应该是每一个C/C++程序员都要做到的。下面结合反汇编来探讨一下C语言函数调用背后的细节。
1. 要理解C语言中函数调用中得各种细节问题,首先我们必须要理解下面一些汇编指令和知识:
1> push %ebp 将寄存器ebp中得内容压入栈中;
2> pop %ebp 请注意:这是将栈顶的内容弹出并且将其赋值给寄存器ebp;(并不是将寄存器ebp中得内容弹出来)
3> 而栈是有关寄存器 SS, ESP, EBP 三个寄存器联合起来构成的一个数据结构:
SS :堆栈段寄存器(Stack Segment Register),其值为堆栈段的段值;
ESP 和 EBP 分别是栈指针(Extend Stack Pointer Register)和基指针(Extend Base Pointer Register)。
这两个寄存器共同负责函数的调用和栈的操作。当一个函数被调用的时候,函数需要的参数被陆续压进栈内最后函数的返回地址也被压进。ESP指着栈顶,也就是返回地址。EBP则指着栈的栈底。
4> 栈是从高地址向低地址增长的;也就是说 sub $0x10, %esp 表示在栈上开辟出16字节的空间出来。
5> CS和EIP寄存器联合起来,告诉 CPU 该执行那一条指令:
CS:代码段寄存器(Code Segment Register),其值为代码段的段值;
EIP:Extend Instruction Pointer,有时也叫做 PC(Program Counter);
2. 函数调用的call func 指令的具体过程:
1> 先将函数func所需要的参数从右向左依次压栈,push arg_n, ... push_1,push arg_0,然后将返回地址ret_addr压栈;然后func会在堆栈上为自己函数中的局部变量分配空间,访问传进来的参数进行必要的运算;然后执行返回操作指令ret,ret指令将返回地址ret_addr弹出并赋值给寄存器EIP(pop %EIP)。
2> 返回地址:是指我们的函数调用 call func 指令的接下来一条指令的地址。目的是当函数func执行完成之后,接着 call func 下面的指令继续执行。
3. 实例分析1——没有参数的函数调用
- int func()
-
{
-
int i = 1;
-
int j = 2;
-
-
return i+j;
-
}
-
-
int main()
-
{
-
int m = func();
-
return m;
-
}
编译:gcc -g -Wall -o func func.c
反汇编:objdump -S -d func
- 080483b4 :
-
int func()
-
{
-
80483b4: 55 push %ebp # 将ebp中的值压栈保存,后面会恢复
-
80483b5: 89 e5 mov %esp,%ebp # 将esp的值赋给ebp,来作为新栈的“栈基址”
-
80483b7: 83 ec 10 sub $0x10,%esp # 让esp向低地址增长,来为局部变量 m 分配空间,后面会恢复esp的值
-
int i = 1;
-
80483ba: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%ebp) # 为局部变量 i 分配空间
-
int j = 2;
-
80483c1: c7 45 fc 02 00 00 00 movl $0x2,-0x4(%ebp) # 为局部变量 j 分配空间
-
-
return i+j;
-
80483c8: 8b 45 fc mov -0x4(%ebp),%eax # j 的值赋值给eax,为 i + j 做准备
-
80483cb: 8b 55 f8 mov -0x8(%ebp),%edx # i 的值赋值给edx,为 i + j 做准备
-
80483ce: 01 d0 add %edx,%eax # i + j
-
}
-
80483d0: c9 leave
-
80483d1: c3 ret
-
-
080483d2 :
-
-
int main()
-
{
-
80483d2: 55 push %ebp # 将ebp中的值压栈保存,后面会恢复ebp的值
-
80483d3: 89 e5 mov %esp,%ebp # 将esp的值赋给ebp,来作为新栈的“栈基址”
-
80483d5: 83 ec 10 sub $0x10,%esp # 让esp向低地址增长,来为局部变量m分配空间,后面会恢复esp的值
-
int m = func();
-
80483d8: e8 d7 ff ff ff call 80483b4 # 调用函数func,没有参数要压栈,直接将下一条指令的
- # 地址压栈用作返回之用,然后跳到函数func中去
-
80483dd: 89 45 fc mov %eax,-0x4(%ebp) # 将函数func的返回值赋值给m
-
return m;
-
80483e0: 8b 45 fc mov -0x4(%ebp),%eax # 将m赋值寄存器eax,用于main函数的返回
-
}
-
80483e3: c9 leave
-
80483e4: c3 ret
-
80483e5: 90 nop
-
80483e6: 90 nop
-
80483e7: 90 nop
上面反汇编中的注释已经大概的描述了函数调用的过程。
1> 下面我们要说说 leave 和 ret 指令:
leave指令等价于:mov %ebp,%esp 和 push %ebp
leave指令是函数开头的 push %ebp 和 mov %esp,%ebp 的逆操作。
我们在注释中“# 将ebp中的值压栈保存,后面会恢复ebp的值, 后面会恢复esp的值”,leave指令的作用就是将ebp和esp寄存器恢复到函数调用之前的值。
我们注意到函数 main 和函数 func 后面的leave指令都是在函数调用体的外面,也就是说,堆栈的清理工作是由调用者来负责的。
我们在注释中对call func的注释为:
“# 调用函数func,没有参数要压栈,直接将下一条指令的地址压栈用作返回之用,然后跳到函数func中去”
而ret 指令的作用就是将返回地址从栈中弹出来,赋值给eip。这样函数调用完成之后,就可以继续执行了。
ret指令相当于: pop %eip.
所以下面两队操作是成对的互逆操作,用于完成函数调用的过程:
call <<==>> ret
push %ebp mov %esp, %ebp <<==>> leave
4. 实例分析2——有参数的函数调用
- int func(int i, int j)
-
{
-
int m = i;
-
int n = j;
-
-
return m+n;
-
}
-
-
int main()
-
{
-
int a = 1;
-
int b = 2;
-
int c;
-
-
c = func(a, b);
-
return c;
-
}
编译:gcc -g -Wall -o func func.c
反汇编:objdump -S -d func
- 080483b4 :
-
int func(int i, int j)
-
{
-
80483b4: 55 push %ebp
-
80483b5: 89 e5 mov %esp,%ebp
-
80483b7: 83 ec 10 sub $0x10,%esp
-
int m = i;
-
80483ba: 8b 45 08 mov 0x8(%ebp),%eax # 这里将a赋值m是关键,因为压栈了返回地址并执行了指令:
- # push %ebp 和 mov %esp,%ebp 所以 ebp+8 正好访问到
- # 栈顶的参数,栈顶的参数也就是a
-
80483bd: 89 45 f8 mov %eax,-0x8(%ebp) # 将实参a,通过形参i赋值给局部变量m
-
int n = j;
-
80483c0: 8b 45 0c mov 0xc(%ebp),%eax # 这里将a赋值m是关键,因为压栈了返回地址并执行了指令:
- # push %ebp 和 mov %esp,%ebp 所以 ebp+14 正好访问到
- # 第二个的参数,也就是参数b
-
80483c3: 89 45 fc mov %eax,-0x4(%ebp) # 将实参b,通过形参j赋值给局部变量n
-
-
return m+n;
-
80483c6: 8b 45 fc mov -0x4(%ebp),%eax # 为 m + n 做准备
-
80483c9: 8b 55 f8 mov -0x8(%ebp),%edx # 为 m + n 做准备
-
80483cc: 01 d0 add %edx,%eax
-
}
-
80483ce: c9 leave
-
80483cf: c3 ret
-
-
080483d0 :
-
-
int main()
-
{
-
80483d0: 55 push %ebp
-
80483d1: 89 e5 mov %esp,%ebp
-
80483d3: 83 ec 18 sub $0x18,%esp
-
int a = 1;
-
80483d6: c7 45 f4 01 00 00 00 movl $0x1,-0xc(%ebp) # 为局部变量a分配空间,将1赋值给a
-
int b = 2;
-
80483dd: c7 45 f8 02 00 00 00 movl $0x2,-0x8(%ebp) # 为局部变量b分配空间,将2赋值给b
-
int c;
-
-
c = func(a, b);
-
80483e4: 8b 45 f8 mov -0x8(%ebp),%eax # 注意下面两行从右向左依次压栈参数,先将b压栈
-
80483e7: 89 44 24 04 mov %eax,0x4(%esp)
-
80483eb: 8b 45 f4 mov -0xc(%ebp),%eax # 注意下面两行从右向左依次压栈参数,然后将a压栈
-
80483ee: 89 04 24 mov %eax,(%esp)
-
80483f1: e8 be ff ff ff call 80483b4 # b, a 依次压栈之后,调用函数func
-
80483f6: 89 45 fc mov %eax,-0x4(%ebp) # 将函数func的返回值赋值给c
-
return c;
-
80483f9: 8b 45 fc mov -0x4(%ebp),%eax # main 函数返回 c
-
}
-
80483fc: c9 leave
-
80483fd: c3 ret
-
80483fe: 90 nop
-
80483ff: 90 nop
结论:
1> 调用有参数的函数时,是先依次从右向左压栈各个参数,然后压栈返回地址,然后跳到被调用函数地址去执行;
2> 被调用函数访问传递过来的参数:
被调用函数执行完 push %ebp 和 mov %esp,%ebp 两条指令之后,ebp + 8 和 esp + 8 就可以访问到栈顶的参数,也就是函数最左边的参数,也就是函数的第一个参数。ebp + 12 可以访问到第二个参数......
本来第一个参数应该在栈顶,但是因为压栈了返回地址,和被调用函数中执行了 push %ebp ,所以在被调用函数中访问第一个参数是ebp + 8 和 esp + 8 而不是 esp + 0,被调用函数执行了 sub $0x10,%esp 之后,就只能通过 ebp + 8 来访问传给他的第一个参数了。
3> 到此,我们已经可以完全理解函数调用的整个过程了。
5. 关于C语言中的不定参数
我们知道C中的printf, scanf等函数是不定参数函数,也就是说它们的参数是可以变化的。
比如: printf("i = %d, j = %d\n", 10, 12)或者:printf("%d", 100)
第一个printf有三个参数,第二个printf却只有两个参数。这是怎么实现的呢?
这就是C语言的:从右到左依次传递参数的功效了。
因为C从右向左传递参数,所以 ebp + 8 访问到的总是printf的第一个参数,也就是格式字符串"i = %d, j = %d\n",所以通过分析格式字符串中的 %d, %s, %p 的个数,所以也就可以确定传递给printf函数的参数的个数了。
但是,如果是从左向右传递参数,就没有办法找到格式字符串的地址了。