调用惯例

函数调用方和被调用方对于函数调用必须要有一个约定(传递参数和返回值以及函数本身如何使用堆栈),只有这样函数才可以被正确调用,这样的约定称为调用惯例。

  • 参数压入栈的顺序

  • 出栈的控制权

默认调用惯例是出栈方是主调函数,参数从右到左入栈

栈帧

多数栈是逆增长的,也就是向低地址增长,函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等。

  • 栈帧的大小?

  • 栈帧中函数局部变量布局方式?

函数的调用栈原理

调用栈由汇编语言通过 push 和 pop 指令来控制,esp 是栈顶指针,ebp 是帧指针或基址指针(通过ebp来访问函数的局部变量,ebp增加是参数,减少是函数内变量)

  1. ADD(1,3);
  2. ADD(int x, int y) {
  3. int i = 5;
  4. int j = 7;
  5. int k = 9;
  6. return x + y;
  7. }
  8. // 汇编
  9. push 3
  10. push 1
  11. call ADD
  12. add esp,8 ;ReturnAddr // 由主调函数负责参数的入栈出栈
  13. -------------
  14. ADD:
  15. push ebp
  16. mov ebp esp
  17. sub esp,0xc
  18. mov dword ptr ss:[ebp-4],5
  19. mov dword ptr ss:[ebp-8],7
  20. mov dword ptr ss:[ebp-0xc],9
  21. mov eax,dword ptr ss::[ebp+8]
  22. mov eax,dword ptr ss::[ebp+0xc]
  23. mov esp,ebp
  24. pop ebp
  25. ret

执行到 mov ebp esp 时栈内状态
image.png
sub esp,0xc 由于函数内 3 个局部变量占用了 12 个字节,所以将 esp 减 0xc
image.png
mov dword ptr ss:[ebp-4],5 通过 ebp 给栈中空间赋值,通过 ebp 来访问变量(基址地址)
image.png
mov esp,ebp
image.png
pop ebp 由于存在多个栈嵌套,所以需要将 ebp 复原,并且将 esp - 4
image.png
ret 相当于 pop eip 将 retuenAddr 赋值给 eip,最后通过 add esp,8 释放掉栈空间

小结

一个栈帧包含了参数,eip 地址(函数的上下文返回地址),原始基址,局部变量。调用完一个函数是会产生副作用例如上面的 eax 寄存器记录了函数返回值。

现在我们可以回答上面的两个问题

  • 栈帧的大小?

栈帧的大小在编译后就可以确认了,函数局部变量大小+参数大小+4+4。

  • 栈帧中函数局部变量布局方式?

栈帧内同样存在着内存对齐,编译器会对编译后生成的汇编语言进行优化,来更高效地读写内存。

局部变量以何种方式布局并未规定。编译器计算函数局部变量所需要的空间总数,并确定这些变量存储在寄存器上还是分配在程序栈上(甚至被优化掉)——某些处理器并没有堆栈。局部变量的空间分配与主调函数和被调函数无关,仅仅从函数源代码上无法确定该函数的局部变量分布情况。

基于不同的编译器版本、优化级别、目标处理器架构、栈安全性等,相邻定义的两个变量在内存位置上可能相邻,也可能不相邻,前后关系也不固定。若要确保两个对象在内存上相邻且前后关系固定,可使用结构体或数组定义。

由主调函数负责参数的入栈出栈,并且负责 eip 指针的跳转,call 指令会自动记录 eip 寄存器的值。