C程序的函数栈作用机理

2014-03-02 20:00

一段错误程序引发的思考

自从开始研究web应用以后,已经很少接触系统底层的程序了。昨天一同学给我了一段小程序,让帮忙分析一下运行结果。 程序本身很简单:

char* get_memory()
    {
        char p[]="hello world";
        return p;
    }

    int main()
    {
        char* str = NULL;
        str = get_memory();
        printf("%s",str);
        return 0;
    }

原程序用的是C++写的,没有用printf函数输出,用了cout。这里我改成了C,目的是后文分析方便,C++的标准输出cout 本质上也是调用一个操作符函数<<实现的,所以不是本文的关注点。

其实这个程序本是一段错误的程序,输出一段指向已经释放了的栈内存空间的字符串在实际程序中肯定是不对的。这种错误 比较隐蔽,编译器不能判别,所以编译通过是没有问题的。但这个题是作为笔试题出出来的,要求写出运行结果。不算太 难,输出肯定是随机的。原因就是str所指向的栈空间是已经释放了的,内容是不可控的。实际编译测试的结果也证明了我们 的分析结果是正确的。

但这位同学比较能“刨根问底”,居然想看看到底那段内存里是什么。所以就有了下面的程序:

char* get_memory()
    {
        char p[]="hello world";
        return p;
    }

    int main()
    {
        char* str = NULL;
        str = get_memory();
        printf("%c",*str);
        return 0;
    }

新的程序和老程序稍微变了一下,输出的是那段内存的第一个字符。上面分析了字符串是随机的,那么这个字符串的第一个 字符也应当是随机的了。但实际情况却并非如此,编译运行后输出的是"h",多次运行结果都一致。输出字符串是随机的, 单个输出字符却是非随机的。这个问题就值得深究了。

函数的调用机理

要弄清上面的问题,首先得了解C/C++中的函数调用机制。

在现在普遍应用的单指令流,单数据流计算机上,编译后的程序都是基于栈来调度的。程序装载入内存后,代码指令映射到 内存空间的指令区,而操作的数据则在对应的栈空间和堆空间上。堆空间用于动态内存的分配,应用。上文中的程序没有动态的空间,所以本文不讨论堆问题。

内存空间可以看成线性的存储空间。而栈处于程序的数据区,对应的是每一个函数的局部变量的存储空间。以上问中的程序 为例,程序执行到了main函数中第一条指令时,即建立了main函数的栈,标致是cpu的esp寄存器和ebp寄存器,前者指向main 函数栈的栈顶,后者指向栈底。具体如图1所示,图1是当执行到了char* str=NULL这段代码后的栈状态。计算机已经为指针 str分配了4个字节的空间,当然现在的内容是NULL,不指向任何内容。

Alt text

需要注意的一点是,在X86平台上,栈的增长空间是由高位向低位增长的,而堆内存是由低位向高位增长的。

当前的栈还只是main函数的栈,局部变量只有一个char指针string,占了4个字节,esp指向栈顶。当上面的程序调用 get_memory函数时,就进入了新函数的栈空间,期间为了能在新函数运行结束后正确返回main函数,需要保护好调用现场。 我们的程序中需要保护的就是就是一个ebp地址和一个main函数中断执行的执行点,亦即返回地址,按照C函数调用管理,先 入栈的是返回地址,其后才是ebp指针指向的地址。ebp入栈后的main函数栈如图2所示:

Alt text

此时,只是返回地址和ebp指针入栈,函数尚未进入get_memory()函数。但esp栈顶指针已经指向了新的地址,main函数的栈 空间也随之增大。

真正进入get_memory函数,并且执行了char p[]="hello world" 语句之后的栈空间如图3所示:

Alt text

此时,已经进入了get_memory函数的栈,所以ebp寄存器指向新函数栈的栈底,这个栈底是在进入新函数之前最后时刻的 esp所指向的地址。所以我们在查看汇编代码时,能看到所有函数的头两个指令都类似于:

pushl   %ebp
movl    %esp, %ebp

都是先让ebp入栈,即图3中的old ebp,然后再ebp更新为新的esp,此时esp正好指向的是ebp的下一个地址。

新的ebp前一个位置存储的是old ebp,从新的ebp开始就是get_memory的栈空间了。可以看到新函数中的局部变量,数组p 分配到了12个字节存储"hello wolrd"。计算机通过调整esp的位置来分配了空间,这就是在栈上分配内存的原理。就是简单 的调整esp而已。

注意图中的eax寄存器指向了p数组的首地址,这是因为,eax在X86平台上充当着传递返回值的作用。get_momeory函数返回的 是p数组的首地址,自然eax存储的就是p数组首地址了。

函数退出的过程和进入的过程正好相反,也只有这样才能正确恢复中断状态。在本文中的例子就是,首先释放p数组空间,释 放的过程和分配过程一样,只需调整esp寄存器的值就可以了。释放掉局部变量后,esp就指向了old ebp了,然后执行pop ebp指令,就恢复了ebp在main函数中的值,即main函数的栈底地址。之后就可以获取到返回地址,jmp到这个地址,就从 get_memory函数中回到了main函数中。此时的栈状态如图4所示:

Alt text

此时esp已经指向了string变量的下一个地址,esp之后的所有的空间此时都是被释放掉了的,但此时因为还没有被重新 分配,所以他们的值还是原来的值,并没有变化。而string变量是get_memory函数的返回值,它还是指在原来p数组的首 地址位置:0xCFFFFFF0。

小结

从上面的分析过程,可以看出函数在调用过程中,所有的局部变量都是在栈上分配的,一旦退出了函数,就被释放。这就 是C函数中局部变量作用域仅在函数内有效的原因。需要明了的是调用过程中的压栈,出栈次序:先进入的是返回地址,然 后是old ebp。

解决问题

明了了函数栈的运行机理,下面我们据此来分析本文开头提出的问题的原因。

第一个问题

首先需要补充一点,在上文中小结中提到,调用一个函数时先入栈的是返回地址,实际上,比返回地址更先入栈的是调用 函数的参数。上面的get_memory函数没有参数,所以直接先入栈了返回地址。在有参数的函数调用时,实际是需要先入栈 参数的。而且,对C/C++函数而言,入栈次序是从右向左的,最右的参数最先入栈。

因为我们的程序下一个调用的是printf函数,这个函数是有参数的,而且在我们的程序中中是两个参数。我们先讨论第一种 调用方法,即printf("%s",string)这个调用。进入该函数后的栈空间如图5所示:

Alt text

进入函数时,最右边的参数arg2先入栈,按照C函数的值传递特性,此时传入的是string的副本,即arg2也是一个地址,指 向0xCFFFFFF0。然后arg1入栈,接着是返回地址入栈。因为arg2是4个字节,arg1也是一个字符串常量的地址,也是4个字 节。可以看到,此时的0xCFFFFFF0地址已经被返回地址覆盖掉了,而这个地址正是上次调用时的数组p的起始位置,并且 main中的局部变量string和printf的第二个参数arg2都指向这个地址,但此时该地址中的的值已经不是'h'了,同样的,因 为printf要为其局部变量分配内存,hello world的12个字节全部被覆写。

综上所述,printf在一进入的瞬间,哪怕不执行任何代码,原hello world的空间就被覆盖了,自然也不会得到正确的输 出。得到的全是随机的乱码。实际上也不能简单说是随机的,因为返回地址,printf的局部变量都是确定的,只是把这些 地址,局部变量都当成char输出时,肯定是乱码了,但肯定是确定的乱码。

第二个问题

再看第二个问题。同前者不一样的是,这次调用的第二个参数不是一个地址了,而是一个char。按照 值传递的特性,此时的arg2是*string的一个拷贝,即arg2=*string。而且,这个赋值过程发生在进入函数printf之前。 如图6所示:

Alt text

图6显示了刚刚把printf的第2个参数arg2入栈后的情况,因为string是0xCFFFFFF0,且该位置此时还没有被覆写,所以 *string='h',而值传递后,argv2='h'。这就是arg2压栈后的栈状态。

随后,继续把第一个参数压栈,再把函数返回地址压栈,此时压入了4+1+4=9个字节,esp到达了0xCFFFFFEF位置,如 图7所示:

Alt text

图7显示的是,进入printf函数栈后的栈状态,此时虽然原来的0xCFFFFFF0位置开始的"hello world"被覆盖了,但argv2 值中所存的依然是'h'这个拷贝,所以,第二个程序最终输出的是'h'也就很正常了。

总结

本文分析的问题,其实本质就是函数的栈调用机理。归结起来就一下几点:

  • 通过栈传递参数
  • 从右向左参数压栈
  • 先压参数入栈,接着是返回地址入栈,然后是ebp等寄存器入栈。
  • 调用过程中的栈都是由调用方来维护。

前面谈到栈调用的时候,都只说了压ebp入栈,实际上在调用过程中不是一个简单的寄存器入栈,而是一组寄存器。因为 在新的函数中,这些寄存器都还会被用到,所以为了退出函数后能恢复状态,凡是有可能被修改的寄存器都要入栈。出栈 的顺序和入栈的顺序一定要正好相反,这个过程由编译器来维护。


分享到: