关于进程中的栈

来源:互联网 发布:淘宝主店铺子店铺 编辑:程序博客网 时间:2024/05/20 04:46

最近看了CU上的一个贴子,楼主想在函数里返回一个数组,有人提到了用返回栈的方法.

栈在C程序员口中常常提及.由其在变量的内存分配时说的最多,比如:

在函数中申请的变量放在栈中,而用malloc分配的空间放在堆中。那么到底什么是进程中栈呢?到底进程中栈有什么用呢?


本文以结合X86 32位linux系统为例来,来对栈及栈相关寄存器进行说明。

本人水平有限,可能存在理解上的错误,本文只是本人的一些心德体会。写出来是希望与大家一起讨论,提高自已的水平。新手要本着怀疑的态度看待本文,同时希望高手多多指点。

一、什么是进程中栈
本人理解的进程中栈其实栈就是一块可以存数据的内存,用来保存程序中用到的变量。在程序运行时,操作系统会为每个的进程分配一个固定大小栈。

二、栈寄存器
esp寄存器指向栈的开始地址。(当然还要涉及到ss这个寄存器,因而要引出实模式和保护模式这里不打印详细说明,以保护模式下的编程为例)。
在现代编译器中,如GCC中,还经常用到别二个与栈有关的寄存器ebp及eax。ebp总是用来保存每个函数中最开始的esp的值。而eax用来保存函数返回的值。

三、栈使用的一个例子:

假设有如下小程序 test.c :

  1. int sum(int param_a, int param_b, int param_c)
  2. {
  3.         int ret;
  4.         ret = param_a + param_b + param_c;
  5.         return ret;
  6. }
  7. int test()
  8. {
  9.         int a;
  10.         int b;
  11.         int c;
  12.         int s;
  13.         a = 100;
  14.         b = 200;
  15.         c = 300;
  16.         s = sum( b, a, c);
  17.         return s;   
  18. }
  19. int main()
  20. {
  21.         test();
  22.         return 0;
  23. }
复制代码

我们用gcc -S test.c 来转换成汇编语言文件test.s 如下:

  1.         .file        "test.c"
  2.         .text
  3. .globl _sum
  4.         .def        _sum;        .scl        2;        .type        32;        .endef
  5. _sum:
  6.         pushl        %ebp
  7.         movl        %esp, %ebp
  8.         subl        $4, %esp
  9.         movl        12(%ebp), %eax
  10.         addl        8(%ebp), %eax
  11.         addl        16(%ebp), %eax
  12.         movl        %eax, -4(%ebp)
  13.         movl        -4(%ebp), %eax
  14.         leave
  15.         ret
  16. .globl _test
  17.         .def        _test;        .scl        2;        .type        32;        .endef
  18. _test:
  19.         pushl        %ebp
  20.         movl        %esp, %ebp
  21.         subl        $28, %esp
  22.         movl        $100, -4(%ebp)
  23.         movl        $200, -8(%ebp)
  24.         movl        $300, -12(%ebp)
  25.         movl        -12(%ebp), %eax
  26.         movl        %eax, 8(%esp)
  27.         movl        -4(%ebp), %eax
  28.         movl        %eax, 4(%esp)
  29.         movl        -8(%ebp), %eax
  30.         movl        %eax, (%esp)
  31.         call        _sum
  32.         movl        %eax, -16(%ebp)
  33.         movl        -16(%ebp), %eax
  34.         leave
  35.         ret
  36.         .def        ___main;        .scl        2;        .type        32;        .endef
  37. .globl _main
  38.         .def        _main;        .scl        2;        .type        32;        .endef
  39. _main:
  40.         pushl        %ebp
  41.         movl        %esp, %ebp
  42.         subl        $8, %esp
  43.         andl        $-16, %esp
  44.         movl        $0, %eax
  45.         addl        $15, %eax
  46.         addl        $15, %eax
  47.         shrl        $4, %eax
  48.         sall        $4, %eax
  49.         movl        %eax, -4(%ebp)
  50.         movl        -4(%ebp), %eax
  51.         call        __alloca
  52.         call        ___main
  53.         call        _test
  54.         movl        $0, %eax
  55.         leave
  56.         ret
复制代码

为了追求简单这里我们不打算从main中的esp的操作。我们从函数test讲起。

  1.         pushl        %ebp
  2.         movl        %esp, %ebp
  3.         .....
  4.         leave
  5.         ret
复制代码

首先我们先保存ebp的值,把ebp压入栈中。然后,我们把esp保存在ebp中。这样,ebp保存了进入test函数的esp的值。此后ebp的将不回改变,当函数结束时,即可通过movl  %ebp, %esp来恢复esp的值。因为esp的值已经恢复,所以可以通过popl %ebp来恢复ebp的值。即当函数退出后,原函数的调用者的esp及ebp的原值都得到了恢复。汇编指令 leave的作用相相当于上述movl  %ebp, %esp  及 popl %ebp 二条指令。最后通过汇编ret返回函数。

然后 subl   $28, %esp 指令。令原来的esp减小28个字节。因为x86的栈是自顶而下生长的。即esp一开始指向一个较高的内存地址,当压入栈时。esp的值将减小。因些上述指令相当于压入了一个28个字节的数据,那么数据具体是什么呢。别着急,下面的几条指令将设置压入的数据。
我们知道ebp指向test函数最开始的栈顶。所以:

  1.                 movl        $100, -4(%ebp)
  2.         movl        $200, -8(%ebp)
  3.         movl        $300, -12(%ebp)
复制代码

分别把从栈顶开始的。第四个字节,第八个字节,第12个字节指向的内存,分别设为100, 200,300.即相当于c语言中的
        a = 100;
        b = 200;
        c = 300;

这里还要讲一下,因为这里的100是int型的。在当前环境中占四个字节,同时I386是小端字节序,即内存的高位保存整数中较高位的数字,所以int 100在内存中的表示如下图:

  1. +-----+ 高
  2. |  00 |
  3. +-----+
  4. |  00 |
  5. +-----+
  6. |  00 |
  7. +-----+
  8. | 0x64|
  9. +-----+ 低
复制代码

而在ebp-4指向的内存中保存100, 即低位保存4字节中最小的字节,而内存的高位保存其它高位的字节。在本例中将是如下型式:

  1. +-----+ esp
  2. |  00 |
  3. +-----+
  4. |  00 |
  5. +-----+
  6. |  00 |
  7. +-----+
  8. | 0x64|
  9. +-----+ esp-4
复制代码

为简便,我们将其简化如下图:

  1. +-----+ ebp(a)
  2. | 100 |
  3. +-----+ ebp-4 (b)
  4. | 200 |
  5. +-----+ ebp-8 (c)
  6. | 300 |
  7. +-----+ ebp-12(s)
  8. |     |
  9. +-----+ ebp-16  
  10. |     |
  11. +-----+ ebp-20
  12. |     |
  13. +-----+ esp-24
  14. |     |
  15. +-----+ ebp-28(esp)
复制代码

接下来我们为调用sum函数准备参数了。

  1. movl        -12(%ebp), %eax
  2.         movl        %eax, 8(%esp)
复制代码

首先把ebp-12的值取出,放入eax中,如上面所说的,ebp-12保存的是300。
接下来把eax的值存入esp+8指向的内存中,即把300保存在esp+8的位置。
接下来的

  1.                 movl        -4(%ebp), %eax
  2.         movl        %eax, 4(%esp)
  3.         movl        -8(%ebp), %eax
  4.         movl        %eax, (%esp)
复制代码

如上面所讲的类似。最后的结果即: esp+8 = 300  esp+4 = 100 esp = 200

  1. +-----+ ebp(a)
  2. | 100 |
  3. +-----+ ebp-4 (b)
  4. | 200 |
  5. +-----+ ebp-8 (c)
  6. | 300 |
  7. +-----+ ebp-12(s)
  8. |     |
  9. +-----+ ebp-16  
  10. | 300 |
  11. +-----+ esp+8
  12. | 100 |
  13. +-----+ esp+4
  14. | 200 |
  15. +-----+ esp(ebp-28)
复制代码

对应我们的调用sum函数的C语句s = sum( b, a, c); 进程处理实参的顺序是从右向左的。即先处理参数c再处理a,最后是b.这和我们的阅读习惯是不同的。同时,压入栈中的实参的顺序也是从右向左的。esp的指针的顺序倒和我们的人类阅读相同,即esp指向保存b的内存地址, esp+4指向保存a的地址,esp+8保存指向c的地址。

接下来 call _sum程序将跳转到sum函数中执行。32位保护模式下,先把eip指向指令的下一条指令的地址再压入栈中,esp再减4.这样sum函数的最后一条语句ret返回时,会把原来压入的eip的值还原回来,这样eip即指向调用完sum函数的下一条指令。

我们先不跟踪到sum中,我们向下看

  1. movl        %eax, -16(%ebp)
复制代码

很明显把寄存器eax的保存在ebp-16的位置,即C中的变量s。我们在前面曾经说过,寄存器eax用来返回函数的值。

再接下来 是C语句return s; GCC把它转成如下汇编代码:

  1.                 movl        -16(%ebp), %eax
  2.         leave
  3.         ret
复制代码

从上面我们可以看到。ebp-16指向的内存地址即存保的是s的值,我们return s;首先把s的值放到eax中,然后返回。寄存器eax用来返回函数的值,再一次在这条指令中得以验证。
leave前面已经讲过,相当于把ebp的值传入esp中,再从栈中弹出ebp。这样ebp及esp都得到了恢复。ret指令返回调用者。这条指令我没查,但我想应当是把eip恢复成为调用都原来指令的存储位置加1处。

整个test讲解完了。接下来我们看看sum函数。

  1.                  pushl        %ebp
  2.         movl        %esp, %ebp
  3.         subl        $4, %esp
  4.         movl        12(%ebp), %eax
  5.         addl        8(%ebp), %eax
  6.         addl        16(%ebp), %eax
  7.         movl        %eax, -4(%ebp)
  8.         movl        -4(%ebp), %eax
  9.         leave
  10.         ret
复制代码

我们知道,在test里,esp的指向已经改变了,由原来的位置-28个字节。而在sum里面。我们再次用ebp保存esp的指向,然后我们可以改变esp的指向。 为本次函数服务,当本函数退出时,又可以用上面讲的相同的方法恢复。
即:

  1.                 pushl        %ebp ,
  2.         movl        %esp, %ebp
复制代码

用来保存test的ebp 及 esp 

接下来sum改变esp的值,记其减少4。即,又分配sum函数中 int ret; 的空间。

我们把上面test的内存与sum的内存连起来如下图所示:

  1. +-----+
  2. | 100 |
  3. +-----+
  4. | 200 |
  5. +-----+
  6. | 300 |
  7. +-----+
  8. |     |
  9. +-----+
  10. | 300 |
  11. +-----+ ebp+16
  12. | 100 |
  13. +-----+ ebp+12
  14. | 200 |
  15. +-----+ ebp+8
  16. | (eip)|
  17. +-----+ ebp+4
  18. |(ebp)|
  19. +-----+ ebp
  20. |     |
  21. +-----+ esp (ebp-4)
复制代码

注意, 由于指令pushl %ebp 现再在的ebp+4指向的内存中保存着原来ebp的值。


接下来

  1.         movl        12(%ebp), %eax
  2.         addl        8(%ebp), %eax
  3.         addl        16(%ebp), %eax
复制代码

我们看到用ebp上逆 12 ,8 ,16, 得到sum要传入的三个参数100,200,300。
上面三条汇编对应着C语句。
        param_a + param_b + param_c

从调用上我们回头看一下test调用时sum( 200, 100, 100)即可得到 
型参param_a=200  param_b=100; param_c=300

而从这个取值序顺序上我们要以看出。 C语言是先处理 param_b + param_c以的。

即先处理 param_b 对应的100 与  param_c 对应的是200 相加,他们的结果再与 param_a相加即300相加结果存入eax中。

然后

  1. movl        %eax, -4(%ebp)
复制代码

把ebp-4即C语言中的ret变量中存入结果。
接下来处理返回。return ret;
我们说过当函数返回时eax存入返回结果。所以先把ret中的结果存入eax中,再返回。

我们看到

  1.                 movl        %eax, -4(%ebp)
  2.         movl        -4(%ebp), %eax
复制代码

是个反过程,所以从优化的角度上看。上面的C代码如果用

  1. return param_a + param_b + param_c;
复制代码

将会减少一条指令。 即可直接返回eax中的值。

接下的

  1.                 leave
  2.         ret
复制代码

二条汇编我们已经产过多次了。最后栈中的情况恢复到调用前的情况。
如图:

  1. +-----+ ebp(a)
  2. | 100 |
  3. +-----+ ebp-4 (b)
  4. | 200 |
  5. +-----+ ebp-8 (c)
  6. | 300 |
  7. +-----+ ebp-12(s)
  8. |     |
  9. +-----+ ebp-16  
  10. | 300 |
  11. +-----+ esp+8
  12. | 100 |
  13. +-----+ esp+4
  14. | 200 |
  15. +-----+ esp(ebp-28)
复制代码

通过上例我们可以看到。函数中的变量,实际上就是栈中存储的数据。而返回一个函数中的局部变量指针,相当于返回了一个栈中的地址,由于esp的移动,栈指向的内存是可以被再次改写的。所以如果我们返回的局部变量,很有可能在再次调用其它函数时改变其内存中的数据。因此不能在函数中返回局部的变量。

三、栈的大小

在X86上,进程一开始,操作系统会为其分配一个固定的栈的大小,然后esp指向最大可用栈地址的最内存地址。

通过上面可以看出,每当分配一个变量或调用一个函数时,可用的栈空间都会减少,每当从函数返回时,栈指针得到恢复,可用的栈空间将会增大。

如果一个函数分配很大的局部变量或递归函数次数过多可能能将栈空间耗尽,就会产生常说的段错误。


linux可以用ulimit -s来查看栈的大小。

0 0