[Pthread] Linux中的内存管理(三)--Stack

来源:互联网 发布:网络推广平台哪个便宜 编辑:程序博客网 时间:2024/05/22 02:17
上次我们分析了用户进程在虚存中的大致分布,这次我们就来分析一下,用户进程的4G虚存是怎么管理的,其对应的物理内存又是怎么维护的。

4. 用户进程的内存管理机制

4.1 虚拟内存和物理内存
前几次我们分析了什么是物理内存,什么是虚拟内存,也反复强调了虚存不同于物理内存。虽然用户程序看到的是虚拟内存,但真正运行的时候还是必须运行在物理内存上的,这就涉及到一个虚拟内存分配和物理内存分配之间关系的问题。后面我们会详细展开了讲,但这里要先让大家明确一个概念,就是物理内存的延迟分配。因为物理内存是很宝贵的资源,而系统上可能同时有若干个进程在运行,每个进程又可以独立的访问4G的空间,所以物理内存的使用就要很小心。什么是内存的延迟分配呢?简单的说就是Linux内核在用户进程申请内存的时候,只是给它分配了虚存,并没有分配实际物理内存,只有当用户进程真正要使用这块内存的时候,内核才会分配相应的物理页面给用户。反之,当用户不再使用某块内存的时候,内核通过虚存地址,找到其所对应的物理页面,然后再释放虚存和相应的物理内存页面。不只是某个进程使用内存是这样,在进程创建的时候也是一样,采用内存延迟分配。 每个进程有自己独立的虚存空间,但是在进程创建的时候,并不立即给新创建的进程分配物理内存,而是让父子进程先共同使用同样的物理内存页面,将这些物理页面标注为共享的,只能读不能写。当某个进程要进行写操作的时候,相应的物理页面就会发生异常,这时才为子进程分配新的物理页面。

4.2 内核空间的内存管理
用户进程的虚存空间被分成了用户空间和内核空间两个部分。内核空间由操作系统内核使用,存放着内核代码和静态数据结构,这些是由所有进程共享的。通常直接映射到物理内存,且一般不会被换出,即是常驻内存的。要注意的是虽然内核空间占据着虚存的高位地址,但其对应的物理地址却是在低位。这部分内容等以后讲到Linux启动过程的时候再讲,这里大家有个概念就行了。

4.3 静态区的内存管理
上次提到用户空间被分为了代码段,数据段,BSS段,堆,栈等。其中代码段,数据段,BSS段地址是在程序编译链接时就分配好了的,是静态的,其使用的虚存地址在运行过程中不会改变。使用的物理内存也是固定的,在进程退出前不会释放(当然可以被换出)。相关信息可以在程序加载execve以及动态链接器中找到,也可以参考我以前写的东西,以及相关资料。所以这次我们重点分析的就是用户空间中的栈,和堆。

4.4 栈的内存管理
4.4.1 栈的静态分配
栈所使用的虚存有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,如局部变量的分配,以及函数调用的参数。如下面的一个例子:

void foo(int a, int b,int c)
{
        int d[100];
}

int main()
{
        int a=10;
        int b=5;
        int c=1;
        foo(a,b,c);
        return 0;
}

一段很简单,而且没有什么实际意义的代码,我们先来看看main函数对应的汇编形式:
main:
        ...
        pushl   %ebp             /*保存上个栈帧的基地址*/
        movl    %esp, %ebp       /*当前栈帧的基地址->ebp*/
        pushl   %ecx
        subl    $28, %esp        /*esp=esp-28*/
        movl    $10, -16(%ebp)   /*int a->ebp-16*/
        movl    $5, -12(%ebp)    /*int b->ebp-12*/
        movl    $1, -8(%ebp)     /*int cc->ebp-8*/
        movl    -8(%ebp), %eax
        movl    %eax, 8(%esp)    /*压入c->esp+8*/
        movl    -12(%ebp), %eax 
        movl    %eax, 4(%esp)    /*压入b->esp+4*/
        movl    -16(%ebp), %eax  
        movl    %eax, (%esp)     /*压入a->esp*/
        call    foo              /*调用函数foo*/
        ...
        ret

栈操作和两个寄存器是分不开的,一个是ebp,另一个是esp。我们把每次函数调用所使用的栈空间叫一个栈帧(stack frame)。ebp寄存器通常用来保存当前栈帧的基地址。而esp寄存器里用来记录当前栈顶的位置。所以用汇编编程的时候对这两个寄存器的使用要极其小心,多使用push和pop来操作栈。不过很多时候为了提高效率,会大量的直接使用这个两个寄存器,比如动态链接器等。当然这样做,可读性相对也差了点。
回到正题,看看main,main先保存好上个栈帧的基地址,设置好这个栈帧的基地址,就执行subl $28, %esp,一下子就分配了28个字节的空间(注意,栈是从高地址向低地址增长的)。然后把int a,b,c分别放到栈空间的相应位置。 为了调用foo()函数,需要将参数压栈,从c开始,然后是b,a。最后再call foo。所以此时main函数的栈空间布局大概是这样的:
          ***** <- ebp
        
            c
            b
            a
            c
            b
            a   <- esp
其中,前三个cba,是main中定义的cba,而后三个cba是编译器自动产生用来做函数调用参数的。从中我们还可以看到两点: 1. 这里参数的传递是值传递,把a,b,c的值又复制了一遍,用于foo()使用。所以foo()中对a,b,c的操作,不会影响到main中定义的a,b,c。如果是*,&传递的就是地址了,只不过是把地址入栈。 2. 参数的压入是从右到左的。即foo(a,b,c),从c开始压栈。其实在C/C++的标准中,并没有对参数的传递做过多的限制,即参数是从左到右,还是从右到左。甚至是用栈还是用寄存器都没有规定。只是规定了函数调用参数是自动变量。不过大多数的编译器都选择从右到左对参数进行压栈,这就是传说中的调用约定(calling convention)。
foo:
        pushl   %ebp           /*保存上个栈帧(main)的基地址*/
        movl    %esp, %ebp     /*当前栈帧的基地址->ebp*/
        subl    $400, %esp     /*int d[100]*/
        leave
        ret
看完main的汇编,再来看foo的,就十分简单了,foo使用的是一个新的栈帧,所以还是需要保存旧基址,设置新基址。然后为int d[100]分配空间,esp-400一下分配出400字节的空间(100*sizeof(int))。

4.4.2 栈的动态分配
除了静态分配,栈空间可以由alloca函数进行动态分配,其函数申明为void *alloca(size_t size);。函数通过调用alloca,可以动态的扩大当前栈帧的大小,如我们把上面的函数foo改成:
void foo(int a, int b,int c){
        ...
        int *d= alloca(f);
}
对应的汇编码就会变成
        subl    %eax, %esp
其中%eax中就放着f的结果,esp向下移了%eax个字节,栈空间被动态分配了。

栈的动态分配和堆的动态分配相比,在性能方面有其优势(这个以后有机会讲)。但是我们仍不建议使用栈动态分配函数alloca来取代堆的动态分配。原因主要有:1).alloca不是C的标准函数,在很多平台上不支持,如果使用了,移植性方面会有问题(在支持C99的环境中,可以使用变长数组来取代alloca)。2). alloca分配的栈空间在调用alloca的函数结束的时候就被自动释放了。生命周期受限制。当然这点从另一方面讲,alloca分配的空间不需要程序员显式释放,避免了内存泄漏也是个好处。3). 栈的大小受到限制。这个我们下面会讲到。

4.4.3 栈的回收
那么栈的回收呢,栈的回收在有了ebp和esp后就简单了,无论是静态分配还是动态分配的栈空间,在函数调用结束的时候,直接让esp的值等于ebp的值。这样,不管esp现在扩展了多少,整个栈帧就被释放了。当然leave指令也能起到同样的作用。

4.4.4 栈的大小限制
大家另外一个关心的问题就是栈大小的限制,这个与具体的平台设置相关,不过都应该是4k(页面大小)的整数倍。Windos上通常在1M-10M之间。在Linux上,栈大小受限于环境变量,可用ulimit -a查看,并用ulimit设置。其大小不应低于最小栈大小(另一个环境变量),默认在8M-10M左右。如果我们使用的栈超过了这个大小,就会报stack overflow或者segmentation fault一类的错误。如在stack size限制为8192k的机器上,在函数中使用:
    void foo(){
        int a[3*1024*1024];
    }
当调用函数foo()的时候,就会抛出Segmentation fault。

有人会建议把stack size调大,为什么现在系统中不这么做呢,毕竟有4G的虚存空间。这是因为对于进程,虚拟内存是独占的。但是对于线程虚拟内存是共享的,但是线程所使用的栈属于某个线程,因此每个线程的栈会占用不同虚存的不同地址,但是他们的总数加起来不能超过4G。也就是如果一个线程所使用的虚存越多,则进程能创建的线程越少。比如如果3G的用户空间都用来创建线程,每个线程占8M的空间,则创建的线程不会超过3G/8M=384个。为了创建更多的线程,反而要去减小stack size。

4.4.5 栈物理内存的使用
正如我们前面所说,栈空间的分配和回收都是虚存中的概念,esp增加和减少也只是在虚存中进行的。那么栈空间所使用的物理内存又是什么样分配的呢?总的来说,栈的物理内存分配还是遵循我们开篇提到的延迟分配策略。当栈在虚存中增加时,不一定立即就占用很大的物理内存,而只有当真正要使用这些虚存的时候,系统才为虚存分配相应的物理内存。反过来,虚存释放的时候也是一样。我们来看一个例子:
void foo()
{
    int d[1*1024*1024];
}
在调用foo()函数之前,调用后,函数结束时分别查看该进程的内存使用信息,/proc/pid/statm
调用前:        370  64 52 1 0 27   0
调用中:        1376 76 61 1 0 1033 0
调用返回后:    1376 76 61 1 0 1033 0
其中第一列是进程所使用的虚拟内存大小,第二列是进程所使用的物理内存大小,都是以页面(4k)为单位。我们可以看到,在foo()中申明了4M的整型数(4*1*1024*1024),使得进程所使用的虚存大小增加了1000多个页面,约4M多(1000*4k)。但与此同时,使用的物理内存却没有增加多少,只有10几个页面,40K左右。

如果把foo改为:                  
void foo()
{
    int d[1*1024*1024];
    memset(d,0,sizeof(d));
}
此时进程所占用的内存大小为:
调用前:        370  64   52 1 0 27   0
调用中:        1376 1108 71 1 0 1033 0
调用返回后:    1376 1108 71 1 0 1033 0
我们可以看到,如果在foo中真正使用了分配的整型数,则不仅虚存增加了1000多个页面,相应的物理内存也增加了1000多个页面。同时我们也可以看到,无论哪种情况,在调用foo()返回后,进程所占用的虚存和物理内存都没有立即释放。

这次我们分析了内核空间,用户空间中代码段,数据段等静态区,特别是用户空间栈的虚存以及相应物理内存的使用。下一次我们将重点分析虚存用户空间中堆的管理和使用。

Pthread 08/03/01
原创粉丝点击