0%

Linux系统编程-内存管理


专注于用户空间的系统级编程–即内核之上的所有内容。

内存管理包括内存分配(allocation)、内存操作(manipulation)和内存释放(release)。管理内存相关的函数如下:

linux下的管理内存相关的函数

进程地址空间

进程地址空间由进程可寻址的虚拟内存组成,对于某个虚拟内存地址,
它要在地址空间范围内,例如: 0421f000,这个值表示的是进程32位地址空间中的一个特定的字节。
尽管一个进程可以寻址4GB的虚拟内存(在32位的地址空间中),
但是这并不代表它有权访问所有的虚拟地址。
在地址空间中,我们更常用或者关心的是某个虚拟内存地址空间,比如 0848000-084c000,
它们可以被进程访问。我们称这些可被访问的合法地址空间称为虚拟内存区域。
通过内核,进程可以给自己的地址空间动态的增加或减少虚拟内存区域。

内核将具有某些相同特征的页组织成块(blocks),例如读写权限。
这些块叫做内存区域(memory regions),段(segments),或者映射(mappings)。
Linux进程的虚拟内存区域一般有:

  • 文本段(text segment)包含一个进程的代码、字符串、常量和一些只读数据。Linux直接从目标文件映射到内存中
  • 堆栈段(stack)包含一个进程的执行栈,随着栈的深度变化会自动伸缩。执行栈中包含局部变量和函数的返回值
  • 数据段(data segment),又叫堆(heap),包含一个进程的动态内存空间,通常由malloc()来分配
  • BSS段(bss segment)包含了未被初始化的全局变量

每个存在的虚拟页面都保存在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程访问。
内核不用记录那些不存在的虚拟页,而这样的页也不占用存储器、磁盘或者内核本身的其他任何资源。

进程只能访问有效内存区域的内存地址,每个内存区域也具有相关权限,如可读、可写、可执行性质。
如果一个进程访问了无效范围中的内存区域或者以不正确的方式访问了有效地址,
那么内核就会终止该进程,并返回 “段错误”信息。

动态内存分配

C提供用于获取动态内存的接口:

#include <stdlib.h>

void * malloc(size_t size);
void * calloc(size_t nr, size_t size);
void * realloc(void *ptr, size_t size);
void free(void *ptr);

malloc()用于申请一段新的地址,参数size为需要内存空间的长度。可能返回NULL,必须检查返回值有效性。

calloc()与malloc()相似,参数size为申请地址的单位元素长度,nr为元素个数,常用于数组分配
和malloc()不同的是,calloc()将分配的区域全部用0进行初始化。

realloc()是给一个已经分配了地址的指针重新分配空间,参数ptr为原有的空间地址,size是重新申请的地址长度。
size为0,相当于调用free();ptr为NULL,相当于调用malloc()。

free()参数ptr可能是NULL,这时free()不做任何事情就返回,因此调用free()不需要检查ptr是否为NULL。

realloc()和free()的ptr参数必须是通过调用malloc()、calloc()或者realloc()的返回值。

  • malloc与calloc的区别为1块与n块的区别:
  • malloc调用形式为(类型*)malloc(size):在内存的动态存储区中分配一块长度为“size”字节的连续区域,返回该区域的首地址。
  • calloc调用形式为(类型*)calloc(n,size):在内存的动态存储区中分配n块长度为“size”字节的连续区域,返回首地址。

对齐

数据的对齐(alignment)是指数据的地址和由硬件条件决定的内存块大小之间的关系。
一个变量的地址是它大小的倍数的时候,这就叫做自然对齐 (naturally aligned)。
例如,对于一个32bit的变量,如果它的地址是4的倍数,
就是说,如果地址的低两位是0,那么这就是自然对齐了。
所以,如果一个类型的大小是2n个字节,那么它的地址中,至少低n位是0。
对齐的规则是由硬件引起的。
一些体系的计算机在数据对齐这方面有着很严格的要求。
在一些系统上,一个不对齐的数据的载入可能会引起进程的陷入。
在另外一些系统,对不对齐的数据的访问是安全的,但却会引起性能的下降。
在编写可移植的代码的时候,对齐的问题是必须避免的,所有的类型都该自然对齐。

在大多数情况下,编译器和C库透明地帮你处理对齐问题。
POSIX 标明了通过malloc(), calloc(), 和realloc()返回的地址对于任何的C类型来说都是对齐的。
在Linux中,这些函数返回的地址在32位系统是以8字节为边界对齐,
在64位系统是以16字节为边界对齐 的。有时候,对于更大的边界,例如页面,程序员需要动态的对齐。
虽然动机是多种多样的,但最常见的是直接块I/O的缓存的对齐或者其它的软件对硬件的交互,
因此,POSIX 1003.1d提供一个叫做posix_memalign( )的函数:

/* one or the other -- either suffices */
#define _XOPEN_SOURCE 600
#define _GNU_SOURCE

#include <stdlib.h>

int posix_memalign (void **memptr,
        size_t alignment,
        size_t size);

调用posix_memalign()成功时会返回size字节的动态内存,
并且这块内存的地址是alignment的倍数。
参数alignment必须是2的幂,还是void指针的大小的倍数。
返回的内存块的地址放在了memptr里面,函数返回值是0。

调用失败时,没有内存会被分配,memptr的值没有被定义,返回错误码。
要注意的是,对于这个函数,errno不会被设置,只能通过返回值得到。

由posix_memalign()获得的内存通过free()释放。用法很简单:

char *buf;
int ret;

/* allocate 1 KB along a 256-byte boundary */
ret = posix_memalign (&buf, 256, 1024);
if (ret) {
    fprintf (stderr, "posix_memalign: %s\n",
            strerror (ret));
    return -1;
}
/* use 'buf'... */
free (buf);

更早的接口。在POSIX定义了posix_memalign( )之前,BSD和SunOS分别提供了如下接口:

#include <malloc.h>
void * valloc (size_t size);
void * memalign (size_t boundary, size_t size);

函数valloc()的功能和malloc()一模一样,但返回的地址是页面对齐的。
页面的大小很容易通过getpagesize()得到。

相似地,函数memalign()是以boundary字节对齐的,而boundary必须是2的幂。
在这个例子中,两个函数都返回一块足够大的内存去容纳一个ship结构,并且地址都是在一个页面的边界上:

struct ship *pirate, *hms;
pirate = valloc (sizeof (struct ship));
if (!pirate) {
    perror ("valloc");
    return -1;
}
hms = memalign (getpagesize ( ), sizeof (struct ship));
if (!hms) {
    perror ("memalign");
    free (pirate);
    return -1;
}
/* use 'pirate' and 'hms'... */
free (hms);
free (pirate);

在 Linux中,由这两个函数获得的内存都可以通过free()释放。
但在别的Unix系统却未必是这样,
一些系统并没有提供一个足够安全的机制去释放这些内存。
考虑移植性的程序不得不放弃使用这些接口来获得动态内存。
Linux程序员最好只在考虑对老系统的兼容性时才使用它们;posix_memalign()更加强大。
只有在malloc()不能提供足够大的对齐时,这三个接口才需要使用。

数据段的管理

Unix系统在历史上提供过直接管理数据段的接口。
然而,程序都没有直接地使用这些接口,因为malloc()和其它的申请方法更容易使用和更加强大。
在这里说一下这些接口来满足一下大家的好奇心,同时也给那些想实现他自己的基于堆栈的动态内存申请机制的人一个参考:

#include <unistd.h>

int brk (void *end);
void * sbrk (intptr_t increment);

这些功能的名字源于老版本的Unix系统,那时堆和栈还在同一个段中。
堆中动态存储器的分配由数据段的底部向上生长;栈从数据段的顶部向着堆向下生长。
堆和栈的分界线叫做break或break point。
在现代的系统里面,数据段存在于它自己的内存映射,我们继续用断点来标记映射的结束地址。

一个brk()的调用设置断点(数据段的末端)的地址为end。在成功的时候,返回0。失败的时候,返回-1,并设置errno为ENOMEM。

一个sbrk()的调用将数据段末端生长increment字节,increment可能是正数,也可能是负数。
sbrk()返回修改后的断点。所以,increment为0时得到的是现在断点的地址:

printf ("The current break point is %p\n", sbrk (0));

尽管POSIX和C都没有定义这些函数。但几乎所有的Unix系统,都提供其中一个或全部。
可移植的程序应该坚持使用基于标准的接口。

匿名内存映射

glibc的动态存储器使用了数据段和内存映射。
实现malloc()的经典方法是将数据段分为一系列的大小为2的幂的分区,
返回最小的符合要求的那个块来满足请求。
释放内存只是简单地将这块区域标记为“未使用”。
如果临近的分区是空闲的,他们会被合成一个更大的分区。
如果断点的下面是空的,系统可以用brk()来降低断点,使堆收缩,将内存返回给系统。

这个算法叫做伙伴内存分配算法(buddy memory allocation scheme)
它的优势是高速和简单,但不好的地方是引入了两种碎片。
内部碎片(Internal fragmentation)发生在用更大的块来满足一个分配。
这样导致了内存的低使用率。
当有着足够的空闲内存来满足要求但这“块”内存分布在两个不相邻空间的时候,
外部碎片(External fragmentation)就产生了。
这会导致内存的低使用率(因为一块更大的不够适合的块可能被使用了),或者内存分配失败(在没有可供选择的块时)。

更有甚者,这个算法允许一个内存的分配“栓”住另外一个,
使得glibc不能向内核归还内存。
想象内存中的已被分配的两个块,块A和块B。
块A刚好在断点的下面,块B刚好在A的下面,就算释放了B,glibc也不能相应的调整断点直到A被释放。
在这种情况,一个长期存在的内存分配就把另外的空闲空间“栓”住了。

但这不需太过担忧。因为glibc并没有严格地将将释放的空闲内存返回给系统。
通常来说,在每次释放后堆并不收缩。
相反,glibc会维护释放的内存,为后续的分配保留着些自由的空间。
只有在堆与已分配的空间相比明显太大的时候,
glibc才会把堆缩小。然而,一个更大的分配,就能防止这个收缩了。

因此,对于较大的分配,glibc并不使用堆。
glibc使用一个匿名存储器映射(anonymous memory mapping)来满足请求。
匿名存储器映射和基于文件的映射是相似的,只是它并不基于文件-所以称为之“匿名”。
实际上,匿名存储器映射是一个简单的全0填充的大内存块,以供用户使用。
因为这种映射的存储不是基于堆的,所以并不会在数据段内产生碎片。

通过匿名映射来分配内存又下列好处:

  • 无需关心碎片。当程序不再需要这块内存的时候,只是撤销映射,这块内存就直接归还给系统了
  • 匿名存储器映射能改变大小,有着改变大小的能力,还能像普通的映射一样接收参数
  • 每个分配存在于独立的内存映射。没有必要再去管理一个全局的堆

下面是两个使用匿名存储器映射而不使用堆的劣处:

  • 每个存储器映射都是页面大小的整数倍。
    所以,如果大小不是页面整数倍的分配会浪费大量的空间。
    这些空间更值得忧虑,因为相对于被分配的空间,被浪费掉的空间往往更多。
  • 建立一个存储器映射比将堆里面的空间回收利用的代价更大,
    因为堆几乎不涉及任何内核操作。越小的分配,这个劣处就明显。

权衡优缺点,glibc的malloc() 用数据段来满足小的分配,用存储器映射来满足大的分配。
临界点是可被设定的(看后面的高级内存分配),并且随着glibc版本的不同而变化。
目前,临界点一般是128KB:比128KB小的分配由堆实现,相应地,更大的由匿名存储器映射来实现。

创建匿名存储器映射

可能你会想强制在堆上使用存储器映射来满足一个特定的内存分配,
也可能你会想写一个自己的存储分配系统,总之你可能会要手动创建你自己的匿名内存映射,Linux让这变得很简单。
回想第四章系统调用,用来创建存储器映射的mmap( )和取消映射的munmap( ):

#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 *p;

p = mmap (NULL,                         /* do not care where */
        512 * 1024,                     /* 512 KB */
        PROT_READ | PROT_WRITE,         /* read/write */
        MAP_ANONYMOUS | MAP_PRIVATE,    /* anonymous, private */
        -1,                             /* fd (ignored) */
        0);                             /* offset (ignored) */

if (p == MAP_FAILED)
    perror ("mmap");
    else
    /* 'p' points at 512 KB of anonymous memory... */

对于大多数的匿名映射来说,mmap( )的参数都跟这个例子一样,
当然了,程序员决定的映射大小这个参数是个例外。别的参数一般都像这样:

  • 第一个参数是start,被设为NULL,意味着匿名映射可以在内核安排的任意地址上发生。
    当然给定一个non-NULL值也是有可能的,必须是页对齐的,但这样会限制了可移植性。
    实际上很少有程序真正在意映射到哪个地址上
  • prot参数经常都同时设置了PROT_READ和PROT_WRITE位,使得映射是可读可写的。
    一块不能读写的空存储器映射是没有用的。
    另外一方面,很少将可执行代码映射到匿名映射,因为那样做能产生潜在的安全漏洞。
  • flags参数设置MAP_ANONYMOUS位,来使得映射是匿名的,设置MAP_PRIVATE位,使得映射是私有的。
  • 假如MAP_ANONYMOUS被设置了,fd和offset参数将被忽略的。
    然而,在一些更早的系统里,需要让fd为-1,如果要考虑移植性,需要设置为-1。

由匿名映射获得的内存块,看上去和由堆获得的一样。
使用匿名映射的一个好处是,那块内存交给你的时候,已经是全0的了。
这种映射还没有额外的开销,因为内核使用写时复制(copy-on-write)将内存块映射到了一个全0的页面上。
所以没有必要对返回的内存块使用memset()。
事实上,这是使用calloc()比使用malloc()后再使用memset()效率更高的原因之一。

系统调用munmap()释放一个匿名映射,归还已分配的内存给内核。

int ret;

/* all done with 'p', so give back the 512 KB mapping */
ret = munmap (p, 512 * 1024);
if (ret)
    perror ("munmap");

映射到/dev/zero

其它Unix系统,就像BSD,并没有MAP_ANONYMOUS标记。
作为替代,它们用一个特殊的设备文件/dev/zero实现了一个类似的解决方法。
这个设备文件提供了和匿名存储器语义上一致的实现。
一个映射包含了全0的写时复制页面;所以行为上和匿名存储器一样。

Linux一直支持/dev/zero设备,可以由映射这个文件来获得全0的内存块。
实际上,在引入之前MAP_ANONYMOUS,Linux的程序员就是这样做的。
为了对早期的Linux版本提供向后兼容性,或者对其他Unix系统的可移植性,
程序员仍然可以将映射/dev/zero作为匿名映射的替代。

void *p;
int fd;

/* open /dev/zero for reading and writing */
fd = open ("/dev/zero", O_RDWR);
if (fd < 0) {
    perror ("open");
    return -1;
}

/* map [0,page size) of /dev/zero */
p = mmap (NULL,                 /* do not care where */
        getpagesize ( ),        /* map one page */
        PROT_READ | PROT_WRITE, /* map read/write */
        MAP_PRIVATE,            /* private mapping */
        fd,                     /* map /dev/zero */
        0);                     /* no offset */

if (p == MAP_FAILED) {
    perror ("mmap");
    if (close (fd))
        perror ("close");
    return -1;
}

/* close /dev/zero, no longer needed */
if (close (fd))
    perror ("close");

/* 'p' points at one page of memory, use it... */

在这种情况下映射的存储器也是用munmap()来取消映射。
这种实现引入了附加的打开和关闭文件的系统调用,所以会有额外的系统调用开销。
相比之下,匿名内存映射是一种较快的方法。

高级内存分配

很多内存分配操作受到glibc或内核的参数所限制和控制,可以通过调用mallopt()函数修改这些参数:

#include <malloc.h>

int mallopt(int param, int value);

控制内存分配函数的行为,param表示参数,value表示值。Linux目前支持6种param值:

  • M_CHECK_ACTION,环境变量MALLOC_CHECK_的值
  • M_MMAP_MAX,最大内存映射数。达到限制后只能在数据段中分配,当为0时禁止使用匿名映射分配内存
  • M_MMAP_THRESHOLD,匿名映射阀值,当值为0时,所有分配启用匿名映射
  • M_MXFAST,fast bin的最大大小,fast bins是堆最特殊的内存块,永远不和临近的内存合并。当值为0时,禁止fast bin
  • M_TOP_PAD,当在堆顶的空闲空间到达设定的值时,则将这些内存还给系统。
    这样做的目的是在堆项保留一些空闲空间,防止频繁的申请内存的系统调用
  • M_TRIM_THRESHOLD,堆的最小保持空间,只有超过这个设定值的堆内存,才有可能被sbrk回收,返还给系统

程序必须在调用malloc()或其他内存分配之前,使用mallopt(),使用方法如下:

int ret;

ret = mallopt(M_MMAP_THRESHOLD, 64*1024);
if(!ret)
    perror("mallopt");

Linux提供了一组用来控制glibc内存分配系统的底层函数:

#include <malloc.h>

size_t malloc_usable_size(void *ptr);   //查询一块已分配内存中有多少可用字节
int malloc_trim(size_t padding);        //强制glibc归还所有可释放的动态内存给内核,保留填充字节

除了调试和教学之外,其他地方几乎永远都不要使用这两个函数。
他们是不可移植的,而且会将glibc内存分配系统的一些底层细节暴露给应用程序。

调试内存分配,设置环境变量MALLOC_CHECK_,开启储存系统中高级的调试功能。
不必重新编译程序,简单执行如下命令:

$ MALLOC_CHECK_=1 ./out.elf
  • 0:忽略所有错误
  • 1:信息输出到标准错误输出stderr
  • 2:立即通过abort()终止

获取统计信息,通过mallinfo()函数,可以获取动态内存分配系统相关的统计信息,
malloc_stats()函数可以将内存相关的统计信息打印到stderr:

#include <malloc.h>

struct mallinfo                                                                                                            
{
    int arena;    /* non-mmapped space allocated from system */
    int ordblks;  /* number of free chunks */
    int smblks;   /* number of fastbin blocks */
    int hblks;    /* number of mmapped regions */
    int hblkhd;   /* space in mmapped regions */
    int usmblks;  /* maximum total allocated space */
    int fsmblks;  /* space available in freed fastbin blocks */
    int uordblks; /* total allocated space */
    int fordblks; /* total free space */
    int keepcost; /* top-most, releasable (via malloc_trim) space */
};

struct mallinfo(void);
void malloc_stats(void);

基于栈的分配

alloca()函数,可以在堆栈上分配一块内存,当前函数退出时,由于系统堆栈指针的调整,这块内存会被自动回收。

#include <alloca.h>

void * alloca(size_t size);

当调用alloca()的函数返回后,就不能再使用由alloca()得到的那块内存!
更要注意的是不要使用alloca()函数分配的内存作为一个函数的调用参数,
因为分配的内存块会存在于函数参数所保存的栈空间中。

在各种编程文档中已经不太提倡使用了。因为它有许多不安全因素。
如果希望代码具有可移植性,应该避免使用alloca()函数。
但是,在Linux系统上,alloca()是一个非常好用的工具,表现很出色(使用calloca()分配内存和增加栈指针一样简单)。
在Linux下,对较小内存的分配,比malloc()有很大的性能提升。

内存操作

主要包含字节设置、字节比较、字节移动、字节查找和字节加密函数:

#include <string.h>

void * memset(void *s, int c, size_t n);
int memcmp(const void *s1, const void *s2, size_t n);
void memmove(void *dst, const void *src, size_t n);     //支持内存区域重叠
void memcpy(void *dst, const void *src, size_t n);      //不支持内存区域重叠,但是可能效率高一些
void memccpy(void *dst, const void *src, int c, size_t n);
void memchr(const char *s, int c, size_t n);
void memrchr(const char *s, int c, size_t n);
void * memfrob(void *s, size_t n);      //加密,第二次调用解密

内存锁定

用于将一个或多个页面“锁定”在物理内存,以保证他们不会被交换到磁盘:

#include <sys/mman.h>

int mlock(const void *addr, size_t n);
int mlockall(int flags);
int munlock(const void *addr, size_t n);
int munlockall(void);

投机性内存分配策略

Linux使用一种“投机性内存分配策略(opportunistic allocation strategy)”。
当进程想内核请求额外的内存时,内核作出了承诺,但实际上没有分配给进程任何物理存储。
仅当进程对新“分配到”的内存区域执行读写操作时,内核才履行承诺,分配一块物理内存。

出于以上策略,分配到的内存可能比实际的物理内存甚至比交换空间多得多。
这个特征叫做“超量使用(overcommitment)”。

当超量使用导致内存不足以满足一个请求时,就发生了“内存溢出(out of memory)”。
为了处理OOM,内核使用OOM Killer来挑选一个进程(最不重要又占用很多内存),并终止它。

可以通过修改/proc/sys/vm/overcommit_memory来控制超量使用:

  • 0:默认值,适度的超量使用策略
  • 1:没有顾忌
  • 2:禁止使用