3 - Dynamic Memory Allocation 动态内存分配

发布时间 2023-05-26 19:22:22作者: ArvinDu

Dynamic Memory Allocation 动态内存分配

我的博客

程序源码

本章介绍现代操作系统中编程的关键元素,动态内存分配与内存释放。

glibc malloc(3) API 家族

在虚拟内存那一章中,我们介绍过在虚拟内存中有段可以用作动态内存分配,这个段是堆段。GNU C 库 glibc 提供强大的 API 允许开发者管理动态内存。

malloc(3)

最常用的 API 也许就是 malloc(3) 了(这里的 3 表示它在 man 手册的第三小节,这是一个库 API 而不是一个系统调用)。我们可以使用 malloc 在运行时动态分配内存。这与在编译时分配的静态内存是正相反的,我们可以在编程时使用下面的语句进行静态内存分配:

char buf[256];

malloc 声明如下:

#include <stdlib.h>
void *malloc(size_t size);

malloc 可以以字节为单位分配内存空间,size_t 数据类型不是 C 原生的数据类型,它在不同的平台上的长度是变化的,但它不会是负数。如果要在 printf 中打印一个 size_t 类型的变量,可以使用 %zu 格式化输出:

size_t sz = 4 * getpagesize();
printf("size = %zu bytes\r\n", sz);

malloc 如果成功分配内存,将会返回指向这个内存块第 0 个字节位置的指针,如果失败,则返回 NULL。我们要注意,虽然 malloc 函数几乎不会失败,但是我们需要在以后的编程中确保使用防御性编程思想,我们总是假设 malloc 函数有可能分配失败的情况下处理我们的后续逻辑,实现程序的鲁棒性。

使用这个 API 是十分直观的,比如动态分配 256 个字节的内存,并将分配内存的头部指针给到 ptr 变量:

void *ptr;
ptr = malloc(256);

另一种应用情景是,我们需要为一个数据结构分配一块内存,比如下面的数据结构:

struct sbar {
    int a[10];
    int b[10];
    char bur[512];
} *psbar;

psbar = malloc(sizeof(struct sbar));
/* 应用逻辑 */
free(psbar);

检查分配失败的情景也是十分重要的,我们可以重写上面的代码如下:

psbar = malloc(sizeof(struct sbar));
if (!sbar) {
    /* 错误处理 */
}

我们可以使用强大的追踪工具 ltrace 来检查程序是否按照预期运行,ltrace 用来展示所有库 API 在进程中运行的顺序(相似的,可以使用 strace 来跟踪系统调用)。让我们假设我们将上面的程序编译成了可执行文件 tst:

# 书中的例子的执行如下:
$ ltrace ./tst
malloc(592) = 0xd60260
free(0xd60260) = <void>
exit(0 <no return ...>
+++ exited (status 0) +++
# 但实际上,我执行下来如下,不清楚是被优化了还是什么
$ ltrace ./a.out
+++ exited (status 0) +++

在 x86_64 设备上 malloc 分配了 592 字节的空间。需要注意的是,通过 malloc 分配出来的内存空间中存储的内容是随机的,程序员在使用它们之前需要给它们赋予意义。

malloc 分配的内存,总是按照 8 字节边界对齐。如果需要更大的对齐值,使用 posix_memalign(3) API,释放同样使用 free(3)

malloc(3) - 答疑解惑

下面的一些讨论可以帮助我们加深对 malloc 接口的理解。

  • 问题 1:单次调用 malloc 能够分配多少内存

    给到 malloc 的参数是 size_t 数据类型,因此,逻辑上讲,最大分配的内存数量是平台上 size_t 能够表示的最大值,在 64 位的机器上,size_t 表示的数字占有 8 字节长度,因此最大能够分配的内存空间是 \(2^{64}\) 字节,有 16EB(16384 PB,16777216 TB),因此在 64 位操作系统中,malloc 理论上能够分配 16EB 的内存。

    实际上,这是不可能的,这样的空间是进程自身能够占有的所有虚拟地址空间。实际上,能够分配的内存空间受限于堆中可用的连续内存。因此在实际使用中,一定要检查分配的结果是否为空。

    我们可以在 x86_64 设备上使用 -m32 选项来生成 32 位的结果:

    $ gcc -m32 mallocmax.c -o mallocmax32 -Wall -lm
    
  • 问题 2:如果给 malloc 传递负参数会怎样

    malloc 的参数数据类型 size_t 是一个无符号整型数,它不可能是一个负数。但是我们总会犯错误,会有整数溢出的情况存在。

  • 问题 3:使用 malloc(0) 会怎样

    具体结果是依不同的实现方式而不同,malloc 将会返回 NULL 或返回一个可以被 free 使用的指针,即便指针非空,这个指针也没有指向内存,因此不要尝试使用它。

  • 问题 4:如果使用 malloc(2048) 并尝试读写超过 2048 字节的数据会怎样

    这是有 bug 的使用情况,既内存越界,后续会详细介绍这一情况。

malloc 总结

  • malloc 从进程的堆(并不总是这样)中动态分配内存
  • malloc 的参数是无符号整型数,表示要分配的内存字节数
  • malloc 若成功返回分配内存块的起始指针,若失败返回 NULL
    • 必须检查分配内存是否成功
    • malloc 分配的内存总是按照 8 字节对齐
  • malloc 分配的空间中存储的内容是随机的,使用前最好初始化一下
  • malloc 分配的内存使用完毕后需要被释放

free API

分配的内存使用完毕后一定要记得释放。如果没有释放不再使用的内存,将会产生 bug,这被称为内存泄露。

free 接口如下:

void free(void *ptr);

它接收一个参数,这个参数是指向要释放内存块的指针。ptr 必须是由 malloc 家族成员返回的指针(malloccallocrealloc)。free 不会返回任何参数,不能检查它的工作是否成功。如果能够正确使用它,那么它就会成功执行。需要注意的是,内存空间被释放后,ptr 指针并不会是空,因此下面的伪代码的运行可能会出现问题:

void *ptr = NULL;
/* ... */
while(<some-condition-is-true>) {
    if(!ptr)
        ptr = malloc(n);
    /* use ptr */
    free(ptr);
}

free 总结

  • 传递给 free 的参数必须是由 malloc 家族返回的指针
  • free 没有返回值
  • free 并不会将 ptr 重置为 NULL
  • 一旦释放空间,不要再尝试使用它
  • 不要尝试重复释放同一块内存

calloc API

calloc 接口与 malloc 类似,主要有下面两点区别:

  • calloc 会将分配的内存块中的数据初始化为 0(ASCII 0/NULL,而不是数值 0)
  • calloc 接收两个参数,而非一个

calloc 函数接口如下:

void *calloc(size_t nmemb, size_t size);

第一个参数 nmemb 是成员的数量,第二个参数 size 是每一个成员的长度,calloc 实际上分配的内存空间是 \(nmemb*size\) 字节,因此,如果想要分配 1000 个整数的空间,可以像下面这样使用:

int *ptr;
ptr = calloc(1000, sizeof(int));

假设整数的长度是 4 字节,我们会得到总计 \(1000*4\) 合计 4000 字节的内存空间。

realloc API

realloc 可以修改已经分配的内存块的大小,它可以重新分配 malloc 家族 (malloccallocrealloc) 已成功分配的内存空间,下面是函数原型:

void *realloc(void *ptr, size_t size);

第一个参数 ptr 指向之前由 malloc 家族分配的指针,第二个参数是内存空间新的长度。下面的伪代码解释了这个函数的用法:

void *ptr, *newptr;
ptr = calloc(100, sizeof(char));
newptr = realloc(ptr, 150);
if (!newptr) {
    fprintf(stderr, "realloc failed!");
    free(ptr);
    exit(EXIT_FAILURE);
}
/* Logic */
free(newptr);

realloc 函数返回的指针指向具有新大小的空间,它可能是 ptr 指向的相同地址,也可能不是。因此,我们需要完全丢弃之前的 ptr 转而使用新的 newptr。如果重新分配空间失败,它将返回 NULL 指针,原本的内存块将保持不变。如果分配成功,那么后续需要被释放的是新的指针 newptr,而不是原本的指针 ptr,不要尝试同时释放这两个指针,这样会制造 bug。

新分配的内存块空间中的存储内容,保持原本的内容不变,若新的空间相较于原空间小,那么所有内容都是原本存储在其中的内容,若新分配的空间比原空间大,那么新空间中保持所有原本的数据,剩下的空间数据具有随机性,与 malloc 类似。

realloc - 隐秘的角落

考虑下面的代码:

void *ptr, *newptr;
ptr = calloc(100, sizeof(char));
newptr = realloc(NULL, 150);

若传递给 realloc 的指针参数为 NULL,那么它的行为将会与使用 malloc 分配一块新空间一样。

考虑下面的代码:

void *ptr, *newptr;
ptr = calloc(100, sizeof(char));
newptr = realloc(ptr, 0);

若传递给 realloc 的长度参数为 0,那么它的行为将会与 free(ptr) 一样。

reallocarray API

之前我们可以使用 calloc 接口分配数组空间,需要修改空间大小时,我们可以使用 realloc 接口进行:

struct sbar *ptr, *newptr;
ptr = calloc(1000, sizeof(struct sbar));
newptr = realloc(ptr, 500*sizeof(struct sbar));

当然也可以使用下面的接口:

void *reallocarray(void *ptr, size_t nmemb, size_t size);

可以这样使用:

newptr = reallocarray(ptr, 500, sizeof(struct sbar));

它返回的内容与 realloc 接口一样,如果重新分配成功,返回新内存空间的指针,如果失败,返回 NULL,原内存块将保持不变。需要注意的是 reallocarray 是 GNU 扩展,它在现今的 Linux 中可以使用,但并不一定能移植到其他操作系统中。

最后一些项目可能需要它们的数据严格对齐,使用 calloc 有可能会出现 bug。后面我们会使用 posix_memalign 接口来确保内存具有字节对齐特性。比如,很多情况下,我们需要内存具有页对齐特性。因此,在使用这些接口时,最好看一下文档,确定哪一个接口最适合当前的应用情景。

深入

在本小节,我们将会深入查看一下动态内存管理 malloc 家族接口。

The program break

当进程或线城需要内存时,它会调用动态内存的程序,通常是 malloccalloc 函数,这个内存通常会来自于堆段。就像之前提过的那样,堆是一个动态段,它能够生长(向高的虚拟地址生长)。显然,在任意时刻,堆都具有一个末端,超过这个点的内存不能使用。

使用 sbrk 接口

我们可以使用 sbrk 接口查询当前堆的模块位置,当给它传递 0 做为参数时,它将会返回当前程序的堆末端:

#include <unistd.h>

printf("Current program break: %p\r\n", sbrk(0));
$ ./show_curbrk
Current program break: 0x564677dc4000
$ ./show_curbrk
Current program break: 0x55772fc8a000
$ ./show_curbrk
Current program break: 0x55da45b65000

可以看到这个值一直在变化,它是一个随机的值:出于安全考虑,Linux 将进程的虚拟地址空间的布局随机化了,这个技术称作地址空间随机化 ASLR: Sddress Space Layout Randomization

/*
 * ch4:show_curbrk.c
 *
 ***************************************************************
 * This program is part of the source code released for the book
 *  "Hands-on System Programming with Linux"
 *  (c) Author: Kaiwan N Billimoria
 *  Publisher:  Packt
 *
 * From:
 *  Ch 4 : Dynamic Memory Allocation
 ****************************************************************
 * Brief Description:
 * Two cases:
 * (1) If run without any parameters: displays the current program
 * break and exits
 * (2) If passed a parameter - the number of bytes of memory to
 * dynamically allocate - it does so (with malloc of course), then
 * prints the heap address returned as well as the original and
 * current program break.
 * For details, please refer the book, Ch 4.
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
#include "../common.h"

int main(int argc, char **argv)
{
        char *heap_ptr;
        size_t num = 2048;

        /* No params, just print the current break and exit */
        if (argc == 1) {
                printf("Current program break: %p\n", sbrk(0));
                exit(EXIT_SUCCESS);
        }

        /* If passed a param - the number of bytes of memory to
         * dynamically allocate -, perform a dynamic alloc, then
         * print the heap address, the current break and exit.
         */
        num = strtoul(argv[1], 0, 10);
        if ((errno == ERANGE && num == ULONG_MAX)
            || (errno != 0 && num == 0))
                FATAL("strtoul(%s) failed!\n", argv[1]);
        if (num >= 128 * 1024)
                FATAL("%s: pl pass a value < 128 KB\n", argv[0]);

        printf("Original program break: %p ; ", sbrk(0));
        heap_ptr = malloc(num);
        if (!heap_ptr)
                FATAL("malloc failed!");
        printf("malloc(%lu) = %16p ; curr break = %16p\n",
               num, heap_ptr, sbrk(0));
        free(heap_ptr);

        exit(EXIT_SUCCESS);
}

/* vi: ts=8 */

上面的程序在不传递参数时将打印当前的堆末端,之后直接退出。若传递一个要动态分配的内存,那么它将会打印动态分配不同时刻的堆末端位置。

$ ./show_curbrk 1024
Original program break: 0x556ad0a35000 ; malloc(1024) =   0x556ad0a356b0 ; curr break =   0x556ad0a56000

sbrk 接口能够用来增加会减少程序的末端(通过传递整数参数)。

malloc 行为

库使用一个预定义的变量 MMAP_THRESHOLD 来确定内存将从哪里被分配,这个值默认是 128KB,当我们希望要使用 malloc 分配 n 字节的内存:

  • 如果 \(n < MMAP\_THRESHOLD\),将使用堆段分配 n 字节的内存
  • 如果 \(n \geq MMAP\_THRESHOLD\),并且在堆的自由列表中不够 n 字节,将会使用虚拟地址空间的任意一个满足 n 字节的自由区域

在第二种情况下,malloc 内部调用 mmap 内存映射系统调用,mmap 系统调用在这个应用场景中,从进程的虚拟地址空间中调出 n 字节的自由区域。

释放的内存去哪里了

free 释放的内存回到进程的堆中。但是有两种情况可能不同:

  • 如果分配不是由 mmap 通过堆段获取,那么在释放时会被释放回到系统
  • 在现代的 glibc 中,如果被释放的堆内存十分巨大,将会触发释放一些内存回到操作系统中

高级特性

需求分页

我们知道如果进程使用 malloc 动态分配内存,ptr = malloc(8192),如果这次分配成功,进程会被分配 8KB 的物理 RAM 空间。但实际上并非如此。实际上,malloc 是从进程的虚拟地址系统中获取到的虚拟内存页。

只有当进程真正访问页的内容时它才会获得真正的物理页,进程通过一个称作页故障的异常,陷入操作系统,在操作系统的故障处理程序中,正常情况下,操作系统将会为虚拟页分配一个物理页帧。这种方式称作需求分页,只有在真正使用物理内存时才会真正分配物理内存。如果想要确保物理页帧真正被分配,可以:

  • malloc 之后使用 memset 处理所有页的所有字节
  • 只使用 calloc 接口,它会将内存内容设为 0

在一些实现中,第二种实现会更加快速。

因为需求分页实现,我们可以写一个应用分配巨量的内存并不释放它,只要进程不尝试读/写/执行分配的区域的任何虚拟页,这个程序是可以运行的。显然,在现实世界中糟糕地设计下这种情况是常有的事。需求分页机制帮助操作系统避免在不必要的情况下被吃掉太多的物理内存。

是否真实存在

现在我们理解了由 malloc 分配的页是虚拟的,并不能保证它真的能够对应到物理页帧,假设我们有一个指针指向虚拟内存区域,且我们知道它的长度。我们可以使用系统调 mincore: m-in-core 用来确定这个页是否真的在 RAM 中被分配。

#include <unistd.h>
#include <sys/mman.h>

int mincore(void *addr, size_t length, unsigned char *vec);

将虚拟地址的起始地址与它的长度传递给这个函数,mincore 填充第三个数组参数,在调用返回后,如果最低位被置位,就表示对应的页面存在于 RAM 中,否则不存在于 RAM 中(它可能未被分配,也可能在交换空间中)。

需要注意的是,由 mincore 接口返回的页存在信息只是当前时刻内存页存在信息的一个快照,它可能很快就改变了。

锁定内存

我们知道在基于虚拟内存的操作系统如 Linux 上,用户态的页可以在任何时间被放入交换空间中,Linux 内核内存管理代码自己会判断是否要做这一动作。对于普通的应用进程,这是无关紧要的,在任何时刻它希望访问(读/写/执行)页内容时,内核会将交换空间的内容再放入到 RAM 中,对于进程而言,好像什么事都没有发生一样。这样的处理一般称作处理页面错误,对于用户态进程而言,这个操作是透明的。

但是,有一些情况下,是不希望这样的情况发生的:

  • 实时应用
  • 安全/加密应用

在实时应用中,实时性是十分重要的,而交换空间的迟滞对于实时性而言是毁灭性的灾难。此时,做为开发者,我们需要一种方式来确保内存页面能够贮存在 RAM 中,避免出现页错误。

而在安全应用中,它们可能会在内存中存储一些秘密(比如密码/密钥),如果内存页被写道硬盘的交换空间中,那么在应用退出后,这个信息依然会留存在 NVM 中,从而导致信息泄露,我们显然不希望这样的情况发生。此时,显然也需要一种方式来确保信息不能被交换到硬盘上。

使用 mlock 系统调用,可以将调用进程的虚拟地址空间的内存页锁在内存中:

int mlock(const void *addr, size_t len);

第一个参数 addr 是执行要锁定的内存的指针,第二个参数是要锁定到 RAM 上内存的长度,可以看一个代码示例:

long pgsz = sysconf(_SC_PAGESIZE);
size_t len = 3*pgsz;

void *ptr = malloc(len);
/* 初始化内存,一些操作等 */
if (0 != mlock(ptr, len)) {
    /* 锁定失败 */
    return ...;
}
/* 代码逻辑 */
munlock(ptr, len);	/* 解除锁定 */
限制与特权

一个具有特权的进程(由特权用户执行,准确的说具有 CAP_IPC_LOCK 位置位的进程),希望锁定内存时,可以锁定任意量的内存。

自 Linux 2.6.9 版本以上,对于一个非特权进程,它受到 RLIMIT_MEMLOCK 软资源限制,可以使用如下命令查看:

prlimit | grep MEMLOCK

在使用 mlock 时,POSIX 标准要求参数 addr 是按页边界对齐的(既,如果使用内存起始地址除以系统页大小,余数为零)。可以使用 posix_memalign 接口来确保这一条件。

锁定所有页面

mlock 允许我们告知操作系统锁定 RAM 中的某一范围的内存。在一些情景下,我们不能准确预测要将哪些内存贮存在内存中。为了解决这个问题,另一个系统调用 mlockall 接口可以锁定进程的所有内存页:

int mlockall(int flags);

如果成功(类似于 mlock 它也有权限限制),所有进程的内存页,诸如代码段,数据段,库段,栈以及共享内存段等,都贮存在了 RAM 中直到解除锁定。

flag 参数可以帮助应用开发者控制后续的行为,参数可以是下面几个选项的按位或:

  • MCL_CURRENT
  • MCL_FUTURE
  • MCL_ONFAULT (Linux 4.4 onward)

使用 MCL_CURRENT 告知操作系统锁定调用进程当前的页到内存中。如果在初始化时刻调用 mlockall 系统调用,但实时进程又在之后执行了 malloc 调用分配了 200KB 内容,如果希望这个新分配的内存能够贮存在内存中,就需要使用 MCL_FUTURE 标识。我们在之前的需求页面小节中介绍过,执行 malloc 调用仅仅会在虚拟内存中分配空间,在未使用时并没有对应的物理页帧,在使用时会触发需求故障,一个典型的是适应哟个需要确保一旦触发了这个故障,那么这些页将会被锁定在 RAM 中,可以使用 MCL_ONFAULT 标识实现这个需求。

内存保护

当应用动态分配内存,比如分配了四页内存,默认情况下,这个内存是可读可写的。我们可以使用 mprotect 系统调用来设置页的权限,来修改它是否可读/可写/可执行。

#include <sys/mman.h>

int mprotect(void *addr, size_t len, int prot);

这个函数接口是直白的,addr 指向要设置的虚拟地址,len 为字节长度,prot 是权限字。因为 mprotect 的颗粒度是页,因此期望第一个参数 addr 是按页对齐的。

第三个参数 prot 是下面参数的或:

保护位 含义
PROT_NONE 无允许的操作
PROT_READ 可读
PROT_WRITE 可写
PROT_EXEC 可执行

使用 alloca 分配自动内存

glibc 库提供 alloca 接口可以在栈上分配内存,它不需要被释放,在函数返回时内存被自动回收。下面是它的优点:

  • 不需要释放,可以避免内存泄露
  • 快速

使用它主要在于,程序可能不会正常退出,而是通过 longjmp 以及 siglongjmp 接口退出。如果程序使用 malloc 分配内存,而离开了函数,将会造成内存泄露,使用 alloca 可以避免这一情况。

下面是它的缺点:

  • 如果传递参数过大,造成栈溢出,将会返回失败,进程行为是不可预测的最终进程会崩溃
  • 可移植性差
  • 通常 alloca 的实现是一个内联函数