简析操作系统中的栈

来源:互联网 发布:js判断字符串包含 编辑:程序博客网 时间:2024/05/07 03:15

                  在编写递归函数的时候,大多数时候只是关心局部变量和形参。但是栈的工作情况本来就是一个伟大的创新,所以详细的了解不仅仅有助于编程思想的培养,对递归(函数调用)也会有更多的认识。

               在程序运行时,不仅仅存在栈区,还有堆区等,详细参考http://blog.csdn.net/vurteon/article/details/8939282

 

         对于栈存在的意义

               1)局部变量在调用它所在的函数才有确切的作用,一般局部变量的生存周期远远小于整个程序的生存周期,如果为每个局部变量分配不同的空间,则空间的利用率会大大降低

               2)当发生递归调用的时候,局部变量不能相互影响,所以必然会需要不同的地址,这样会严重增加程序和编译器的复杂度。

 

                 操作系统中的栈和数据结构中的栈有一些不同,不仅仅有栈的特性,还有其他寄存器的支持和编译器还有操作系统本身的相关支持。首先是栈本身的特性是FILO,所以在函数调用的时候最先调用的函数最后返回。

 

               运行栈实际上是一段区域的内存空间,这里的空间和全局变量等的存储空间没有差别,但是寻址的方式不同。这个栈有一个随时都处于栈顶的指针叫做栈指针,是由寄存器esp保存。运行栈中的数据是以栈帧为单位,每一个栈帧对应着一次函数调用,栈帧中包括了这次函数调用中的形参值,一些控制信息(后面会说到,其实是ebp和返回地址),局部变量和一些临时变量(比如表达式的中间值),每一次函数调用的时候都会有一个栈帧被压入运行时栈。一个函数在执行时,能够直接随机访问它对应栈帧中的数据。

                对于参数的传递,其实是在调用函数前,将对应的实参压入栈中,而且在栈中,这一部分数据是主调函数和被调函数都可以访问的。在函数调用的时候,其压入的信息不知道具体有多少,有多大,所以确切的地址也很难确定,但是,相对于栈顶的位置却是确定了的。(由于每个局部变量或者其他信息由固定的字节大小,所以可以通过栈顶计算出相对位置,然后就可以进行访问)。

              栈顶指针一般是向栈中压入数据的一个航标,但是又由于需要对帧数据进行随机访问,而且有些帧大小是无法确定的,所以就有了另外的一个寄存器用来辅助栈指针---帧指针,寄存器的名字叫ebp,一般都是通过帧指针来计算地址,从而进行访问。

 

下面是一个具体的例子

 

被调函数是:

            int add(int a, int b){

                 int  c = a + b;

               return c;

         }

 

     主调函数是

         Int x = add(5,7);

 

           下面是使用GCC生成的汇编代码的主要部分


                8048459: movl $0x7,0x4(%esp) //将整数7压入esp + 4的地址中

               8048461: movl $0x5,(%esp) //将整数5写入esp地址中

               8048468call 8048434 //调用8048434地址的函数

              804846d: mov %eax,-0x8(%ebp) //eax写入ebp-8的地址中

 

             下面是被调函数的汇编代码

 

               8048434: push %ebp //将帧指针压入运行栈

               8048435: mov %esp,%ebp //esp的值赋值给ebp

               8048437: sub $0x4,%esp //将栈指针减去4

               804843a: mov 0xc(%ebp),%eax //ebp + 12地址的整数载入eax寄存器

               804843d: add 0x8(%ebp),%eax //ebp + 8地址内的整数和eax中的值相加

               8048446: leave //恢复函数调用之初的espebp的值

               8048447: ret /./回到主函数

 

 

               从上面的汇编代码可以看出,不仅仅有局部变量和参数被压入了运行栈,还有帧指针和返回地址信息,返回地址信息是由call函数压入,call函数包含两个步骤,一是将call后面的第一条指令的地址压入栈中,然后跳转到被调函数的第一条指令(函数入口)。这两个信息就是前面所说的”控制信息“。

 

            eax寄存器是用来保存函数的返回值,这也就是为什么函数返回的时候仅仅能返回一个值的原因。

 

           总结来说,操作系统中的运行栈有操作系统和硬件的支持,但是和数据结构中的栈模型是一样的(FIFO),在进行参数传递的函数调用的时候:

                 首先将实参压入了栈,以供被调函数访问(这里实际上是为实参申请了空间,然后被调函数进行计算从而访问,并不是调用函数过后,给形参分配空间然后将实参赋值给形参,这样明显会浪费资源----实参保存的资源),这也就是C语言中传递指针和变量的不一样之处了,但是本质上都是对实际参数进行了存储。

               其次使用call,将主调函数的下一个下一个指令的地址压入了栈,以便函数调用完成后进行返回,并且跳转到被调函数的函数入口。

               继而在被调函数中将ebp指针压入运行栈(每一个函数在运行时在栈中都是一个栈帧,主函数在调用之前也是有一个ebp的值,这个值是用于对主函数中的数据进行随机访问,现在到了被调函数,必须将ebp的值保存,以便函数调用完成后可以继续对主函数进行相关数据操作),当然接下来就是要生成当前函数(被调函数)的ebp了,将esp赋值给ebpOK

              然后就是对被调函数的一系列相关数据操作,这些操作都是基于ebp计算数据位置从而读取数据来进行的,ebp就像访问一个数组的下标参考值0一样,根据操作不同进行计算,不过数组中计算的是下标值,这里计算的是地址而已。

              完成被调函数后,便恢复开始的espebp,前面已经压入了ebp,而对于esp,将ebp的值赋值给esp就完成,当然,学过数据结构中的栈的都知道,虽然栈指针向低地址移动了,数据有时候并没有消失,只是栈这个模型的规定,从而感觉就像元素被弹出了,实际上还是在那个地方,只是下次的操作会覆盖它。

             最后ret,弹出栈顶元素,也就是返回地址,继而返回到原函数,整个函数调用完成。

 

             最后,是我最深的领悟:我们平时在写函数的时候,返回类型,函数名,参数类型变量类型等等都是硬式的规定,但是,主要是为什么会有这些规定,有一句话是这样说的“一个错误,与其被淹没在运行中,不如暴露在编译时”,C语言对这方面的控制确实有点不太好,Java(个人比较喜欢)就有优势在这方面,但是当真正的深入到汇编代码时却发现这一切其实都是假象,压根没有什么类型,有的只是地址的计算,所有这一切的规定都是由于编译器需要,为了让编程尽量少出错,由于本人并没有学习过编译,所以这个发现确实让我很惊讶。然而,从上面的汇编代码可以看出,参数传递的时候是首先压入栈的,而后对其调用,所以这就有了一个很大的问题,C语言的函数原型存在的意义也就是这个,但是它并没有对参数进行检查,所以当你被调函数要求是double,而传入的是int的时候,并不会进行转换,谁都不知道以后会出现什么问题。所以,良好的编译器总能在编译器发现更多的错误,有助于编写良好的代码。

 

           当然,我现在也仅仅刚开始大二,很多东西都还闻所未闻,如果有错,请指出,谢谢。

 

 

原创粉丝点击