程序员的自我修养——第十章——内存

注:这一章的内容比较经典,之前看“深入理解计算机系统”的时候,也有看到栈帧(Stack Frame),但是不是很清楚,通过这一章的讲解,更清楚了。如果能再结合讲讲GDB调试的话就更完美了。

栈:栈用于维护函数调用的上下文,离开了站函数调用就没法实现。

堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。

Linux进程地址空间内存布局:

在操作系统中,栈总是向下增长的。在i386下,栈顶由称为esp的寄存器进行定位。压栈的操作使栈顶的地址减小,弹出的操作使栈顶的地址增大。

                         程序栈实例

在栈底的地址是0xbfffffff,而esp寄存器表明了栈顶,地址为0xbffffff4。在栈上压入数据会导致esp减小,弹出数据使得esp增大。

栈保存了一个函数调用所需要的维护信息,这通常被称为“栈帧”(Stack Frame)或活动记录。堆栈帧一般包括如下几方面内容:

·函数的返回地址和参数

·临时变量:函数的非静态局部变量

·保存的上下文:在函数调用前后需要保持不变的寄存器

一个函数的活动记录用ebp和esp这两个寄存器划定范围:esp寄存器始终指向栈的顶部,同时也就指向了当前函数的活动记录的顶部。而相对的,ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又被称为帧指针(Frame Pointer)。

                          函数的活动记录

          固定不变的ebp可以用来定位函数活动记录中的各个数据。在ebp之前首先是这个函数的返回地址,它的地址是ebp-4,再往前是压入栈中的参数,它们的地址分别是ebp-8、ebp-12等。

i386下函数的调用过程:

·把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递

·把当前指令的下一条指令的地址压入栈中

·跳转到函数体执行

在i386函数体的“标准”开头是这样的:

·push  ebp: 把ebp压入栈中(称为old  ebp)

·mov   ebp, esp:   ebp = esp (这时ebp指向栈顶,而此时栈顶就是old epb)

·【可选】sub esp, xxx: 在栈上分配xxx字节的临时空间

·【可选】push  xxx:如有必要,保存名为xxx寄存器(可重复多个)

·【可选】pop   xxx:如有必要,恢复保存过的寄存器(可重复多个)

·mov  esp, ebp: 恢复esp同时回收局部变量空间

·pop  ebp: 从栈中恢复保存的ebp的值

·ret: 从栈中取得返回地址,并跳转到该位置

函数的调用惯例: 

·函数参数的传递顺序和方式

·栈的维护方式

·名字修饰策略

在C语言中,默认调用惯例是cdecl,任何一个没有显示指定调用惯例的函数都默认是cdecl惯例: 参数传递——从右至左顺序压参数入栈 ; 出栈方——函数调用方 ; 名字修饰——直接在函数名前加1个下划线。

有这样一个函数:

int foo(int n, float m)

{

  int a = 0, b = 0;

  ...

}

foo被修饰之后变为_foo

调用:

·将m压入栈

·将n压入栈

·调用_foo,此步又分为两步:

  1. 将返回地址(即调用_foo之后的下一条指令的地址)压入栈:
  2. 跳转到_foo执行

       foo函数栈布局

如果我们有如下代码:

 

void f(int y)

{

  printf("y = %d",y);

  return 0;

}

int main()

{

  int  x = 1;

  f(x);

  return 0;

}

 

这些代码形成的堆栈格局如下:

图中箭头表示地址的指向关系,而带下划线的代码表示当前执行的代码。

注意:在理解的时候牢记:栈是向下增长的(但地址的大小不是),堆栈最下面的部分是当前执行的部分。比如说在main()中调用了f(),f()的信息位于堆栈的最下方,上方的数据信息表示的是main部分的。

函数返回值: 

对于返回5-8字节对象的情况,几乎所有的调用惯例都是采用eax和edx联合返回的方式进行的。eax返回低4字节,edx返回高4字节。

超过8字节的情况该怎么办?

#include<stdio.h>

typedef struct big_thing

{

  char buf[128];

}big_thing;

big_thing return_test()

{

  big_thing b;

  b.buf[0] = 0;

  return b;

}

int main()

{

  big_thing n = return_test();

  return 0;

}

汇编:(这里使用的是VS11 beta 运行的结果,跟书上有不同)

int main()

{

003C1480  push        ebp 

003C1481  mov         ebp,esp 

003C1483  sub         esp,25Ch 

003C1489  push        ebx 

003C148A  push        esi 

003C148B  push        edi 

003C148C  lea         edi,[ebp-25Ch] 

003C1492  mov         ecx,97h 

003C1497  mov         eax,0CCCCCCCCh 

003C149C  rep stos    dword ptr es:[edi] 

003C149E  mov         eax,dword ptr ds:[003C8000h] 

003C14A3  xor         eax,ebp 

003C14A5  mov         dword ptr [ebp-4],eax 

big_thing n = return_test();

003C14A8  lea         eax,[ebp-1D0h] 

003C14AE  push        eax 

003C14AF  call        return_test (03C10E6h) 

003C14B4  add         esp,4 

003C14B7  mov         ecx,20h 

003C14BC  mov         esi,eax 

003C14BE  lea         edi,[ebp-258h] 

003C14C4  rep movs    dword ptr es:[edi],dword ptr [esi] 

003C14C6  mov         ecx,20h 

003C14CB  lea         esi,[ebp-258h] 

003C14D1  lea         edi,[n] 

003C14D7  rep movs    dword ptr es:[edi],dword ptr [esi] 

return 0;

003C14D9  xor         eax,eax 

}

003C14DB  push        edx 

003C14DC  mov         ecx,ebp 

003C14DE  push        eax 

003C14DF  lea         edx,ds:[3C150Ch] 

003C14E5  call        @_RTC_CheckStackVars@8 (03C1087h) 

003C14EA  pop         eax 

003C14EB  pop         edx 

003C14EC  pop         edi 

003C14ED  pop         esi 

003C14EE  pop         ebx 

003C14EF  mov         ecx,dword ptr [ebp-4] 

}

003C14F2  xor         ecx,ebp 

003C14F4  call        @__security_check_cookie@4 (03C101Eh) 

003C14F9  add         esp,25Ch 

003C14FF  cmp         ebp,esp 

003C1501  call        __RTC_CheckEsp (03C113Bh) 

003C1506  mov         esp,ebp 

003C1508  pop         ebp 

003C1509  ret 

003C150A  mov         edi,edi 

003C150C  add         dword ptr [eax],eax 

003C150E  add         byte ptr [eax],al 

003C1510  adc         al,15h 

003C1512  cmp         al,0 

003C1514  js          main+95h (03C1515h) 

003C1516  ?? ??

003C1517  inc         dword ptr [eax+20000000h] 

003C151D  adc         eax,6E003Ch 

 函数return_test()返回一个128字节的结构。不可能通过eax来传递。

   003C14A8  lea         eax,[ebp-1D0h] 

将栈上的一个地址(ebp-1D0h)存储在eax里,接着下一行

  003C14AE  push        eax 

将这个地址压入栈中,然后就接着调用return_test函数。这个从形式上无疑是将数据ebp-1D0h作为参数传入return_test函数,然而return_test函数没有参数,因此我们可以讲这个数据称为是“隐含参数”。

          

   003C14D7  rep movs    dword ptr es:[edi],dword ptr [esi] 

   rep movs 是一个复合指令,大致意思是重复movs指令制导ecx寄存器为0.于是“rep movs a,b”的意思就是将b指向位置上的若干个双字(4字节)拷贝到由a指向的位置上,拷贝双字的个数由ecx指定。实际上这条指令相当于:

memcpy(a  ,b ,ecx * 4)

下面四行

003C14B7  mov         ecx,20h 

003C14BC  mov         esi,eax 

003C14BE  lea         edi,[ebp-258h] 

003C14C4  rep movs    dword ptr es:[edi],dword ptr [esi] 

含义相当于:

memcpy(ebp-258h,  eax , 0x20 * 4);

即将eax指向位置上的0x20个双字拷贝到ebp-258h的位置,ebp-258h这个地址就是变量n的地址。

可见return_test返回的结构体仍然是由eax传出的,只不过这次eax存储的式结构体的指针。

那么return_test具体是如何返回一个结构体的呢?

big_thing return_test()

{

00A813D8  add         byte ptr [ebx+56h],dl 

00A813DB  push        edi 

00A813DC  lea         edi,[ebp-14Ch] 

00A813E2  mov         ecx,53h 

00A813E7  mov         eax,0CCCCCCCCh 

00A813EC  rep stos    dword ptr es:[edi] 

00A813EE  mov         eax,dword ptr ds:[00A88000h] 

00A813F3  xor         eax,ebp 

00A813F5  mov         dword ptr [ebp-4],eax 

big_thing b;

b.buf[0] = 0;

00A813F8  mov         eax,1 

00A813FD  imul        eax,eax,0 

00A81400  mov         byte ptr b[eax],0 

return b;

00A81408  mov         ecx,20h 

00A8140D  lea         esi,[b] 

00A81413  mov         edi,dword ptr [ebp+8] 

00A81416  rep movs    dword ptr es:[edi],dword ptr [esi] 

00A81418  mov         eax,dword ptr [ebp+8] 

}

根据rep movs的功能,下面4条指令

00A81408  mov         ecx,20h 

00A8140D  lea         esi,[b] 

00A81413  mov         edi,dword ptr [ebp+8] 

00A81416  rep movs    dword ptr es:[edi],dword ptr [esi] 

可以翻译为:

memcpy([ebp + 8], &b, 128);

   在这里,[ebp + 8]指的是 *(void * *)(ebp + 8), 即将地址ebp + 8上存储的值作为地址,由于ebp实际上保存的旧的ebp,因此ebp+4指向压入栈中的返回地址,ebp+8则指向函数的参数。

而我们知道,return_test是没有真正参数的,只有一个“伪参数”,由函数调用方悄悄传入,那就是ebp-1D0h这个值,也就是说[ebp + 8] = old_ebp -1D0h

int main()

{

003C1480  push        ebp 

003C1481  mov         ebp,esp 

003C1483  sub         esp,25Ch 

003C1489  push        ebx 

003C148A  push        esi 

003C148B  push        edi 

003C148C  lea         edi,[ebp-25Ch] 

 

我们可以看到main函数在保存了ebp之后,就直接将栈增大了25Ch个字节,区间[ebp-1D0h, ebp -1D0h+128]落在[ebp, ebp - 25Ch]内部。

大概的思路是这样的:

·首先main函数在栈上额外开辟了一片空间,并将这块空间的一部分作为传递返回值的临时对象,这里成为temp

·将temp对象的地址作为隐藏参数传递给return_test函数

·return_test函数将数据拷贝给temp对象,并将temp对象的地址用eax传出

·return_test返回后,main函数将eax指向的temp对象的内容拷贝给n

void return_test(void *temp)

{

  big_thing  b;

  b.buf[0] = 0;

  memcpy(temp, &b, sizeof(big_thing));

  eax = temp;

}

int main()

{

  big_thing temp;

  big_thing n;

  return_test(&temp);

  memcpy(&n, eax, sizeof(big_thing));

}

C语言在函数返回时使用一个临时的栈上的内存区域作为中转,结果返回值对象会被拷贝两次,因而不到万不得已,不要轻易返回大尺寸对象。

C++中如果返回较大的对象会有非常多的额外开销,因此C++程序进了避免返回对象。C++返回值优化技术,在某些场合下对象的拷贝减少到1次,例如:

cpp_obj  return_test()

{

  return cpp_obj();

}

 

Linux内存堆管理:

Linux提供了两种堆空间分配方式,即两个系统调用:一个是brk() 系统调用,另外一个是mmap().

 

brk()的作用实际上就是设置紧凑的数据段的结束地址,即它可以扩大或缩小数据段。

mmap()的作用和Windows系统下的VirtualAlloc很相似,它的作用就是向操作系统申请一段虚拟地址空间,这块虚拟地址空间可以映射到某个文件。

#include <sys/mman.h>   

void *mmap(void *start,size_t length,int prot,int flags, int fd, off_t offset);   

int munmap(void *start, size_t length);

void *mmap(void *start, size_t  length, int prot, int flags, int fd, off_t offset);

mmap的前两个参数分别用于指定需要申请的空间的起始地址和长度,如果其实地址设置为0,那么Linux系统会自动挑选合适的起始地址。 prot/flags这两个参数用于设置申请的空间的权限(可读、可写、可执行)以及映射类型(文件映射、匿名空间等),最后两个参数用于文件映射时指定文件描述符合文件偏移。

 注:mmap有很多用法,以后应该有博文会涉及

用mmap实现malloc:

void *malloc(size_t  nbytes)

{

  void *ret = mmap(0,nbytes,PROT_READ | PROT_WRTIE, MAP_PRIVATE | MAP_ANONYMOUS,0,0);

  if(ret == MAP_FAILED)

  return 0;

  return ret;

}

在一台只有512MB内存的和1.5G交换空间的机器上测试malloc的最大空间申请数,不论怎么样都不会超过1.9GB.

Windows 进程堆管理:

对于Windows来说,每个线程默认的栈大小是1MB,在线程启动时,系统会为它在进程中分配相应的空间作为栈。

对管理器提供了一套与堆相关的API可以用来创建、分配、释放和销毁堆空间:

·HeapCreate:  创建一个堆

·HeapAlloc:在堆里面分配内存

·HeapFree:    释放已经分配的内存

·HeapDestroy:销毁一个堆

HeapCreate就是创建一个堆空间,它会向操作系统批发一块内存空间,HeapAlloc就是在堆空间里面分配一块小的空间并返回给用户。如果堆空间不足的话,它还会通过VirtualAlloc向操作系统批发更多的内存直到操作系统也没有空间分配为止。

malloc申请的内存,进程结束以后还会不会存在?

答:不存在。当进程结束以后,所有与进程相关的资源,包括进程的地址空间、物理内存、打开的文件、网络链接等都被操作系统关闭或者回收。

堆分配算法:

  1. 空闲链表

空闲链表的方法实际上就是把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个列表,知道找到合适大小的块并且将它拆分。当用户释放空间时将它合并到空闲链表中。

空闲链表是这样一种结构,在堆里的每个空闲空间的开头(或结尾)有一个头(header),头结构里记录了一个(prev)和下一个(next)空闲的地址,也就是说,所有空闲的块形成了一个链表。

  1. 位图:

将整个堆划分为大量的块,每个块的大小相同。当用户请求内存的时候,总是分配整数个块的空间给用户,第一块我们称为头(head),其余块称为主体(body)。使用一个整数数组来记录块的使用情况,由于每个块只有头、主体、空闲三种状态,因此只需要两位即可以表示一个块,因此称为位图。

优缺点:

·速度快

·稳定性好

·块不需要额外信息,易于管理

·分配内存的时候容易产生碎片

·堆很大,块很小,那么位图会很大

原文地址:https://www.cnblogs.com/zhuyp1015/p/2496672.html