Mas0n
to be reverse engineer🐧
翻车鱼

函数调用与堆栈 - X86为例

这篇文章放到现在似乎有点头重脚轻,一直对这些概念只有模糊印象。写下这篇文章也终于算是搞清楚堆栈的细节部分了。

简述

简单来讲,每一个函数调用都有一个对应的栈帧

先后顺序来看依次包括

  • arguments 参数
  • ret addr 返回地址
  • old ebp 上一个栈帧
  • local variables/others 局部变量/其他数据

arguments

在x86下,函数调用通常使用堆栈传递参数,因此,在执行一个函数前,首先执行的是将参数压入堆栈,例如执行puts("Welcome!")

push 0x080aaaaa   ; "Welcome!", 0
call 0x080ddddd   ; puts

同样的,多参传递,采用先进后出(i.e. 从右到左压入堆栈),例如以下执行printf("Hello %s!\n", "Mason")

push 0x080ccccc   ; "Mason", 0
push 0x080bbbbb   ; "Hello %s!\n", 0
call 0x080eeeee   ; printf

call

call指令做了两件事

  1. 将下一条指令的地址压入堆栈(i.e. 函数执行完成后返回的地址)
  2. 将eip指向调用的函数

用intel风格描述call指令:

push rip + 4       ; etc. 保存下一条指令
mov  rip, foo_addr ; 指向调用函数

local variables / others

进入执行函数后的第一件事就是保存旧的栈帧,而后开辟局部变量或其他数据需要的空间

汇编表示:

push ebp      ; 保存上级栈帧
mov  ebp, esp ; ebp指向新栈帧
sub  esp, 8   ; 开辟栈空间

做完这些,放一个简化版本的栈排布图

https://cdn.shi1011.cn/2023/03/2a3946120b42b329a5f88ab0e95902db.png?imageMogr2/format/webp/interlace/0/quality/90|watermark/2/text/wqlNYXMwbg/font/bXN5aGJkLnR0Zg/fontsize/14/fill/IzMzMzMzMw/dissolve/80/gravity/southeast/dx/5/dy/5

要知道的是栈是往低地址增长的

上图做的比较简单,下面结合代码、图文详细说明。

完整过程

假定执行如下代码

#include <stdio.h>

int foo(int arg0, int arg1) {
    int sum;
    int a = 2, b = 3, c = 4, d = 5;
    sum = arg0 + arg1 + a * b / c * d;
    return sum;
}

int main() {
    int a, b, c;
    a = 1;
    b = 2;
    c = foo(a, b);
    printf("c = %d\n", c);
    return 0;
}

堆栈初始排布如下,esp指向当前栈帧栈顶

https://cdn.shi1011.cn/2023/03/46494c187273cd1d25b84c4fcc36457b.png?imageMogr2/format/webp/interlace/0/quality/90|watermark/2/text/wqlNYXMwbg/font/bXN5aGJkLnR0Zg/fontsize/14/fill/IzMzMzMzMw/dissolve/80/gravity/southeast/dx/5/dy/5

首先做的就是将函数所需的参数压入堆栈,而后调用call指令,将返回地址入栈,eip指向调用函数地址。

https://cdn.shi1011.cn/2023/03/84f269ea431ba4b6dfc53513d6961989.png?imageMogr2/format/webp/interlace/0/quality/90|watermark/2/text/wqlNYXMwbg/font/bXN5aGJkLnR0Zg/fontsize/14/fill/IzMzMzMzMw/dissolve/80/gravity/southeast/dx/5/dy/5

在执行函数逻辑之前,保存上级栈帧,而后将ebp指向esp

https://cdn.shi1011.cn/2023/03/09fd9df63b1d38f0627734d762d335da.png?imageMogr2/format/webp/interlace/0/quality/90|watermark/2/text/wqlNYXMwbg/font/bXN5aGJkLnR0Zg/fontsize/14/fill/IzMzMzMzMw/dissolve/80/gravity/southeast/dx/5/dy/5

开辟栈空间

https://cdn.shi1011.cn/2023/03/85467502fe75c62bd618e8a77c29a7be.png?imageMogr2/format/webp/interlace/0/quality/90|watermark/2/text/wqlNYXMwbg/font/bXN5aGJkLnR0Zg/fontsize/14/fill/IzMzMzMzMw/dissolve/80/gravity/southeast/dx/5/dy/5

直到函数调用完成,下面就是恢复上级栈帧

https://cdn.shi1011.cn/2023/03/68562a51d5fe7fc638db20c8ffbdfadd.png?imageMogr2/format/webp/interlace/0/quality/90|watermark/2/text/wqlNYXMwbg/font/bXN5aGJkLnR0Zg/fontsize/14/fill/IzMzMzMzMw/dissolve/80/gravity/southeast/dx/5/dy/5

最后ret返回

https://cdn.shi1011.cn/2023/03/71fe61077c9883914de7bfa9b2eb175b.png?imageMogr2/format/webp/interlace/0/quality/90|watermark/2/text/wqlNYXMwbg/font/bXN5aGJkLnR0Zg/fontsize/14/fill/IzMzMzMzMw/dissolve/80/gravity/southeast/dx/5/dy/5

一般情况下,下一条执行指令会把esp恢复到函数调用前的指向,在这里即add esp, 8

https://cdn.shi1011.cn/2023/03/18f9bc8417350552ab1134f7230c88f7.png?imageMogr2/format/webp/interlace/0/quality/90|watermark/2/text/wqlNYXMwbg/font/bXN5aGJkLnR0Zg/fontsize/14/fill/IzMzMzMzMw/dissolve/80/gravity/southeast/dx/5/dy/5

整个函数调用过程中,堆栈并不会清除内容,而是在下一次调用时直接覆盖,也就是说函数调用完成后相关数据仍然在堆栈上。

real world

实际环境下,需要注意堆栈对齐。

举例而言,有以下函数定义

int foo(int arg0, int arg1) {
    int sum;
    int a = 2, b = 3, c = 4, d = 5;
    sum = arg0 + arg1 + a * b / c * d;
    return sum;
}

去除保护、优化后编译得到汇编如下

https://cdn.shi1011.cn/2023/03/dcb1735743b2fa3aaef40a0d832ee840.png?imageMogr2/format/webp/interlace/0/quality/90|watermark/2/text/wqlNYXMwbg/font/bXN5aGJkLnR0Zg/fontsize/14/fill/IzMzMzMzMw/dissolve/80/gravity/southeast/dx/5/dy/5

函数只需要0x14大小的栈空间,但是编译器给出了0x20的栈空间

这里就需要提到x86调用约定(i.e. x86 calling conventions)

简单来讲就是调用函数时,堆栈必须16字节对齐

Refer

本文链接:https://blog.shi1011.cn/learn/2510
本文采用 CC BY-NC-SA 4.0 Unported 协议进行许可

Mas0n

文章作者

发表回复

textsms
account_circle
email

翻车鱼

函数调用与堆栈 - X86为例
这篇文章放到现在似乎有点头重脚轻,一直对这些概念只有模糊印象。写下这篇文章也终于算是搞清楚堆栈的细节部分了。 简述 简单来讲,每一个函数调用都有一个对应的栈帧 先后顺序…
扫描二维码继续阅读
2023-03-23