C函数调用过程解析(x86-64 )
函数栈保存了一个函数调用所需的维护信息,一般包括:
- 函数的参数和返回值
- 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器
下图显示了,在Linux操作系统中一个进程的虚拟地址布局(本图来自 《深入理解计算机系统》),从图中可以看出,栈总是向下增长的, 在x86-64下, 栈顶由栈顶寄存器rsp 进行定位。 栈底有基址寄存器rbp进行定位, 也就是说一个函数的整个活动过程由这两个寄存器划定范围。
1,代码示例:
#include <stdio.h>
int add(int a, int b) {
int c = a + b;
return c;
}
void strcopy(char * dst, char * src){
}
int main(int argc, char *argv[]) {
int tmp = 10;
int tmp1 = 11;
char str1[60];
char str2[100];
strcopy(str2, str1);
int result = add(2, 3);
printf("result=%d\n", result);
return 0;
}
2, 函数调用过程 (x86-64)
编译:gcc -m64 -o test test.c
生成汇编:gcc -m64 -S test.c //熟悉INTEL汇编指令格式的,可以指定汇编代码格式 -masm=intel
反汇编 : objdump -d test > test.dump
gdb ./test
(gdb) info b ;将断点设在 _start, 这是程序真正的入口
Num Type Disp Enb Address What
1 breakpoint keep y 0x0000000000400450 ../sysdeps/x86_64/elf/start.S:65
(gdb) set disassemble-next-line on ;打开汇编指令开关, 交替使用n ni 进行调试
(gdb) info reg
rax 0x1c 28 ;
rbx 0x0 0
rcx 0x7fffffffe1f8 140737488347640
rdx 0x7ffff7deb680 140737351956096
rsi 0x7ffff7df698f 140737352001935
rdi 0x7ffff7ffe208 140737354129928
rbp 0x0 0x0 ;程序未开始,基址寄存器为0
rsp 0x7fffffffe1e0 0x7fffffffe1e0 ;栈顶寄存器初始值
r8 0xb 11
r9 0x3 3
r10 0x2 2
r11 0xd 13
r12 0x400450 4195408
r13 0x7fffffffe1e0 140737488347616
r14 0x0 0
r15 0x0 0
rip 0x400450 0x400450 <_start> ;此时指令寄存器指向_start 开始位置
eflags 0x202 [ IF ]
cs 0xe033 57395
ss 0xe02b 57387
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
(gdb) display /i $pc ;用display关注$pc 的内容变化, $pc为GDB内部变量相当于指令寄存器rip
(gdb) display $rsp ;利用display关注 $rsp 的内容变化
(gdb) display *(char*)$rsp
(gdb) display *(short*)$rsp
(gdb) display *(int *)$rsp
(gdb) disas _start
Dump of assembler code for function _start:
0x0000000000400450 <+0>: xor %ebp,%ebp ;异或操作用于清零 ebp=0
0x0000000000400452 <+2>: mov %rdx,%r9 ;数据寄存器rdx 内容存入 r9
0x0000000000400455 <+5>: pop %rsi ;rsi=1 rsp=rsp+8 = 0x7fffffffe1e8
0x0000000000400456 <+6>: mov %rsp,%rdx ;将rsp 暂存入 rdx
0x0000000000400459 <+9>: and $0xfffffffffffffff0,%rsp ;与操作 使 rsp 按照16字节对齐 rsp= 0x7fffffffe1e0 note1
0x000000000040045d <+13>: push %rax ;rax 进栈,rsp-8 = 0x7fffffffe1d8 (rsp)=28
0x000000000040045e <+14>: push %rsp ;rsp 进栈 保存原栈顶位置 rsp-8=0x7fffffffe1d0
0x000000000040045f <+15>: mov $0x4005a0,%r8 ;nm 可见, 0x4005a0 为 __libc_csu_fini会调用_fini,它是留给程序结束时用的
0x0000000000400466 <+22>: mov $0x4005b0,%rcx ;0x4005b0 为 __libc_csu_init会调用_init
0x000000000040046d <+29>: mov $0x400556,%rdi ;0x400556 为 main 函数 入口地址
0x0000000000400474 <+36>: callq 0x400440 <__libc_start_main@plt> ; 以上三个参数 从右到左 分别存在 r8,rcx,rdi __libc_start_main 内部不再继续展开
0x0000000000400479 <+41>: hlt ;使程序停止运行,处理器进入暂停状态,不执行任何操作,不影响标志
0x000000000040047a <+42>: xchg %ax,%ax ;相当于nop, 空操作
End of assembler dump.
(gdb) disas main
Dump of assembler code for function main:
0x0000000000400564 <+0>: push %rbp ;上层函数基址入栈
0x0000000000400565 <+1>: mov %rsp,%rbp ;将当前栈顶作为main函数的基址 (rsp=rbp=0x7fffffffe110)
0x0000000000400568 <+4>: sub $0xd0,%rsp ;rsp下移208字节 用于保存寄存器和局部变量
0x000000000040056f <+11>: mov %edi,-0xc4(%rbp) ;把寄存器edi中的值 (int argc)保存在栈帧中 (rbp-196)。因为寄存器edi接下来要被使用
0x0000000000400575 <+17>: mov %rsi,-0xd0(%rbp) ;把寄存器rsi中的值( char *argv)保存在栈帧中。 (rbp-208)
0x000000000040057c <+24>: movl $0xa,-0xc(%rbp) ;局部变量tmp 存放于 rbp-12 位置 ,赋值10
0x0000000000400583 <+31>: movl $0xb,-0x8(%rbp) ;局部变量tmp1 存放于 rbp-8 位置 ,赋值11
0x000000000040058a <+38>: lea -0x50(%rbp),%rsi ;局部变量str1 存放于 rbp-80 位置 (12+60=76 按16字节对齐) , 传入rsi作为strcopy第二个参数
0x000000000040058e <+42>: lea -0xc0(%rbp),%rdi ;局部变量str2 存放于 rbp-192 位置 (80+100=180 按16字节对齐) , 传入rdi作为strcopy第二个参数
0x0000000000400595 <+49>: callq 0x400556 <strcopy> ;调用strcopy
0x000000000040059a <+54>: mov $0x3,%esi ;第二个参数 3 存入 esi
0x000000000040059f <+59>: mov $0x2,%edi ;第一个参数2 存入 edi
0x00000000004005a4 <+64>: callq 0x40053c <add> ;调用 add 函数 ;refer to note2
0x00000000004005a9 <+69>: mov %eax,-0x4(%rbp) ;将eax中存放的返回值 存入( rbp-4 )位置, 局部变量 result
0x00000000004005ac <+72>: mov -0x4(%rbp),%esi ;将rbp-4 位置 的内容 存入 esi 作为 printf 的 第二个参数
0x00000000004005af <+75>: mov $0x4006c4,%edi ;将 "result=%d\n" 的地址 存入 edi 作为 printf 的第二个参数(查看数据段内容 readelf -p .rodata test)
0x00000000004005b4 <+80>: mov $0x0,%eax ; eax 清零
0x00000000004005b9 <+85>: callq 0x400430 <printf@plt> ;调用 printf
0x00000000004005be <+90>: mov $0x0,%eax ;0传入 eax 作为返回值
0x00000000004005c3 <+95>: leaveq
0x00000000004005c4 <+96>: retq
End of assembler dump.
(gdb) disas add
Dump of assembler code for function add:
0x000000000040053c <+0>: push %rbp ;main 函数基址进栈
0x000000000040053d <+1>: mov %rsp,%rbp ;将当前栈顶作为add函数的基址 (rsp=rbp=0x7fffffffe030)
0x0000000000400540 <+4>: mov %edi,-0x14(%rbp) ;第二个参数 b存入 rbp -20位置
0x0000000000400543 <+7>: mov %esi,-0x18(%rbp) ;第二个参数 b 存入 rbp -24位置
0x0000000000400546 <+10>: mov -0x18(%rbp),%edx ;第二个参数 b 传给 edx
0x0000000000400549 <+13>: mov -0x14(%rbp),%eax ;第二个参数 a 传给 eax
0x000000000040054c <+16>: add %edx,%eax ;eax=eax+edx
0x000000000040054e <+18>: mov %eax,-0x4(%rbp) ;eax 中的值放入 rbp-4 位置
0x0000000000400551 <+21>: mov -0x4(%rbp),%eax ;将rbp-4位置中的内容 存入 eax作为返回值
0x0000000000400554 <+24>: leaveq ;refer to note3
0x0000000000400555 <+25>: retq ;refer to note4
End of assembler dump.
note1:0x0000000000400459 <+9>: and $0xfffffffffffffff0,%rsp
栈的字节对齐,实际是指栈顶指针须是某字节的整数倍。我们都知道栈对齐帮助在尽可能少的内存访问周期内读取数据,不对齐堆栈指针可能导致严重的性能下降。
但是我不太理解,为什么这里会要求栈顶 16 字节对齐。
查阅 “ Intel-64 and IA-32 Architectures Software Developer Manuals (https://software.intel.com/en-us/articles/intel-sdm)”
里面说: 堆栈段的堆栈指针(esp)应在16位(字)或32位(双字)边界上对齐,取决于堆栈段的宽度。当前代码段的段描述符中的D标志设置堆栈段宽度。
此外,在64位模式下,寄存器E(SP),E(IP)和E(BP)分别被提升为64位,分别被称为RSP,RIP和RBP。一些分段加载指令的形式无效(例如,LDS,POP ES)。PUSH / POP指令使用64位宽度递增/递减堆栈。当段的内容寄存器被推入64位堆栈,指针自动对齐到64位。
也就是说 x86-64 系统中应该是 8字节对齐才对呀。
根据 x86-64 ABI ( http://refspecs.linuxbase.org/elf/x86_64-abi-0.21.pdf) 的描述。
x86-64要求堆栈指针在函数调用时始终为16字节对齐,以允许在数组上进行操作时使用向量化的SSE指令。具有Intel SSE指令集支持的处理器有8个128位的寄存器,每一个寄存器可以存放4个(32位)单精度的浮点数。SSE同时提供了一个指令集,其中的指令可以允许把浮点数加载到这些128位的寄存器之中,这些数就可以在这些寄存器中进行算术逻辑运算,然后把结果放回内存。看起来,这种对浮点数计算进行优化的指令,对一般程序用处不大,但这是标准,gcc遵循标准。
note2:0x00000000004005a4 <+64>: callq 0x40053c
调用add函数。add函数调用完之后要返回到callq的下一条指令继续执行,因此callq指令会做两件事:
1)把callq指令的下一条指令地址0x00000000004005a9压栈,同时寄存器rsp的值将减8。
2)修改程序计数器rip,使其指向add函数的首地址,然后跳转到add函数的开头执行。
note3:0x0000000000400554 <+24>: leaveq :
add函数的开头有两条指令(push%rbp;mov %rsp,%rbp),leaveq就是这两条指令的逆操作。分为两步:
1)mov %rbp,%rsp :把寄存器rbp的值赋给寄存器rsp,让寄存器rsp指向保存main函数栈底的地址
2)pop %rbp 把寄存器rsp所指向的内存单元值赋值给rbp,这样rbp现在就指向main函数的栈底。同时寄存器rsp加8,此时rsp指向调用函数main的返回地址。
note4:0x0000000000400555 <+25>: retq:
main函数调用add时需要callq指令,add函数返回时就需要retq指令,它是callq指令的逆操作。同样需要分为两步:
1)把rsp指向调用函数的返回地址赋值给程序计数器rip,同时rsp寄存器加8。
2)程序返回到rip寄存器所指向的地址继续执行。
3, 函数调用过程总结(x86-64)
整个调用过程和我之前了解的差不多,但是仍有些出入, 以下列入一些我觉得需要注意到的。
1)x86-64 下,参数可以通过寄存器直接传递,不需要通过压栈传递(当参数变量数量较多时,寄存器无法保存所有变量,这个时候需要通过压栈传递)
手册上说 x86-64下,整数和指针型的参数会从左至右依次保存到rdi,rsi,rdx,rcx,r8,r9中, 浮点型参数会保存到xmm0,xmm1……中, 多余这些寄存器的参数会被保存到栈中.
2)在每个函数的栈帧中,寄存器rbp指向栈底,寄存器rsp指向栈顶,在函数执行过程中rsp随着压栈和出栈操作会发生变化,而rbp却是不变的。
3)函数返回值是通过eax寄存器传递的, eax 有4字节, 如果返回long,eax放不下,则使用rax返回。 更长更复杂的类型可能会用到rdx 或者 开辟栈空间的方式返回。
4)局部变量的空间不是一个一个压入栈中的,而是一次性分配好的,所以理解为变量依次入栈是错误的。C语言也没有规定局部变量在内存中的位置, 之前总认为先定义的变量在高地址、后定义的变量在低地址。但从实际例子中看不一定是这样的, 三个int 型变量正好相反。可见局部变量在栈上的位置没有绝对的关系,甚至不一定会出现在栈上。比如声明一个变量,无非是告诉编译器,在栈上给它准备一块空间。先声明的话,就一定会先在栈上为它分配空间吗?举个最简单的栗子,如果根本没被用到,编译器完全可以不为它分配空间。所以这个最终还是取决于编译器的实现。局部变量并不总在栈中,有时出于性能(速度)考虑会存放在寄存器中。数组/结构体型的局部变量通常分配在栈内存中。
5)观察局部变量的起始地址可以更好的理解 数据对齐的概念。 数据都有nature length,如char=1,short=2,int=4,double=8,。所谓自对齐,指的是该成员的起始位置的内存地址必须是它nature length的整数倍。如int只能以0,4,8这类的地址开始
4, 对比函数调用 (IA32)
简单对比一下代码在 32位环境下的表现。
(gdb) disas main
Dump of assembler code for function main:
0x0804843f <+0>: lea 0x4(%esp),%ecx ; esp+4 暂存入 ecx
0x08048443 <+4>: and $0xfffffff0,%esp ;esp 地址按16字节对齐作为main函数栈顶
0x08048446 <+7>: pushl -0x4(%ecx) ;ecx-4 进栈, 保存之前的栈顶 (esp)
0x08048449 <+10>: push %ebp ;上层函数ebp 进栈
0x0804844a <+11>: mov %esp,%ebp ;将当前栈顶作为main函数的基址
0x0804844c <+13>: push %ecx ;ecx 进栈
0x0804844d <+14>: sub $0xc4,%esp ;esp=esp - 196
0x08048453 <+20>: movl $0xa,-0x10(%ebp) ;tmp 存放在 ebp -16
0x0804845a <+27>: movl $0xb,-0xc(%ebp) ;tmp 存放在 ebp - 12
0x08048461 <+34>: lea -0x4c(%ebp),%eax ;str1 地址存入 eax
0x08048464 <+37>: mov %eax,0x4(%esp) ;eax 存入esp+4 作为第二个参数
0x08048468 <+41>: lea -0xb0(%ebp),%eax ;str2 地址存入 eax
0x0804846e <+47>: mov %eax,(%esp) ;eax 存入esp+4 作为第一个参数
0x08048471 <+50>: call 0x804843a <strcopy> ;调用 strcopy
0x08048476 <+55>: movl $0x3,0x4(%esp) ;参数 3 存入 esp+4
0x0804847e <+63>: movl $0x2,(%esp) ;参数 2 存入esp
0x08048485 <+70>: call 0x8048424 <add> ; 调用add
0x0804848a <+75>: mov %eax,-0x8(%ebp)
0x0804848d <+78>: mov -0x8(%ebp),%eax
0x08048490 <+81>: mov %eax,0x4(%esp)
0x08048494 <+85>: movl $0x8048580,(%esp)
0x0804849b <+92>: call 0x8048340 <printf@plt>
0x080484a0 <+97>: mov $0x0,%eax
0x080484a5 <+102>: add $0xc4,%esp
0x080484ab <+108>: pop %ecx
0x080484ac <+109>: pop %ebp
0x080484ad <+110>: lea -0x4(%ecx),%esp
0x080484b0 <+113>: ret
总结: 比较明显的不同是参数的传递方式,IA32上, 原则上参数全部堆放在栈中,参数压栈时从右向左依次压栈,而被调用函数的参数是从栈帧的低地址向高地址去取, 因此可以在函数入口中断后,用 esp+4 取得第一个参数, esp+8取得第二个参数, 以此类推。