C函数调用过程解析(x86-64 )

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=0x7fffffffe1100x0000000000400568 <+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-2080x000000000040057c <+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=7616字节对齐) , 传入rsi作为strcopy第二个参数
   0x000000000040058e <+42>:    lea    -0xc0(%rbp),%rdi     ;局部变量str2 存放于  rbp-192 位置 (80+100=18016字节对齐) , 传入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,%eax0传入 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=0x7fffffffe0300x0000000000400540 <+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取得第二个参数, 以此类推。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章