掘金 后端 ( ) • 2024-04-24 10:15

函数原型

学过 C 语言的同学,一定对 malloc/free 这对库函数不陌生。malloc 用于从堆上分配一块内存给用户,分配成功的话返回这段内存的首地址。free 则释放用户传入的动态分配的内存地址。这两个函数的原型如下:

#include <stdlib.h>
void *malloc(size_t size)
        Returns pointer to allocated memory on success, or nuLl on error
void free(void *ptr)

一定要用 malloc 吗?

说到在堆上分配内存,那么什么是堆呢?这里的堆可不是数据结构里所说的那个堆(初学者容易和堆排序的堆搞混),它指的是一段长度可变的连续的虚拟内存。随着内存的分配和释放而增减。在 Linux 上,进程的虚拟地址空间布局大致是下面这样的:

从图中我们可以看出,栈是从高地址向低地址生长,堆则是从低地址向高地址生长。中间是内存映射区,用于加载一些共享库或者 mmap 之类的地址区域。所以本质上来讲,我们常说的堆内存,其实也就是一段普通的虚拟内存空间,只不过操作系统把这段空间起了个名字叫作堆,然后规定进程动态申请的内存从这块区域分配,仅此而已。注意这段地址空间是虚拟地址,也就是逻辑上的连续,对应到实际的物理内存地址上未必连续。当前大部分的系统都会开启 mmu(内存管理单元),malloc 出来的空间其实是操作系统先给进程开一个空头支票,当进程要往分配给它的虚拟地址处写值的时候,会触发缺页异常(page fault),这时操作系统才会实际给进程分配真实的物理内存。

那么,malloc 作为用户态的函数,是怎么和内核态的操作系统交流的呢?答案是系统调用。

通常将堆的当前内存边界称为“program break”,那么堆内存的申请和释放,也就是通过调整这个 break 来实现。传统的 UNIX 系统提供了两个操纵 program break 的系统调用:brk()和 sbrk(),在 Linux 中依然可用。虽然代码中很少直接使用这些系统调用,但了解它们有助于弄清内存分配的工作过程。通过这两个系统调用的参数,不难猜出 brk 会直接把堆的 program break 设置成 end_data_segment,而 sbrk 则接受一个增量 increment,在当前的 program break 上加上这个 increment。当然函数内部会对参数的合法性进行检查,非法的参数会导致堆调整错误。

#include <unistd.h>
int brk(void *end_data_segment);
                                Returns 0 on success, or -1 on error
void *sbrk(intptr_t increment);
                                Returns previous program break on success, or (void *) -1 on error

也就是说,我们其实可以在程序中直接使用 brk 或者 sbrk 来申请内存,那为什么还要用 malloc 这个二道贩子呢?主要有以下几点原因:

  • malloc 是 C 语言标准的一部分
  • 更容易在多线程场景中使用
  • 接口简单易懂,无需了解操作系统的细节
  • 允许随意释放内存块,它们被维护于一张空闲内存列表中,在后续内存分配调用时循 环使用。

存在即合理。malloc 封装了操作系统内存使用的细节,对用户提供了更加方便易用的高效接口。总的来说,它先向操作系统批发一大块内存,然后自己在用户态维护这么一个内存池。当用户申请小块内存的时候(通常小于 128K),它可以直接从自己维护的空闲内存块中分配给用户,而无需反复调用系统调用(brk 或者 sbrk)来分配,毕竟系统调用也是有一定的开销。当申请大块的内存时,malloc 会通过 mmap 来实现。malloc()返回的内存块所采用的字节对齐方式,总是适宜于高效访问任何类型的 C 语言数据结构。在大多数硬件架构上,这实际意味着 malloc 是基于 8 字节或 16 字节边界来分配内存的。

malloc 和 free 的实现原理

有了上文的背景,理解 malloc 的实现就很容易了。当用户调用 malloc 的时候,它首先会去自己的空闲内存块列表里找一块符合用户要求大小的空闲内存。如果恰好找到一块尺寸合适的,那么就直接返回给用户;如果没找到,那就通过 sbrk 向操作系统“进货”内存。作为一个中介,自然不会是用户申请多少,malloc 就向操作系统要多少。为了减少对 sbrk 的调用次数,malloc 会基于一定的策略一次性向操作系统批发更多的内存(一般是虚拟内存页的倍数,操作系统的内存可都是按页批发的),然后从其中裁剪出一块给用户,多余的自己放到空闲列表里维护起来,以备不时之需。 下面说说 free。不知道大家有么有好奇过,free 的参数只有一个地址,它是怎么知道要释放掉多大的内存块呢?其实这是通过和 malloc 打配合来实现的。当 malloc()分配内存块时,会额外分配几个字节来存放这块内存大小的整数值。该整数位于内存块的起始处,而实际返回给调用者的内存地址恰好位于这一 size 记录的后面。如图所示:

当将内存块置于空闲内存列表(双向链表)时,free()会使用内存块本身的空间来存放链 表指针,将自身添加到列表中。

所以,当使用 malloc 分配给我们的内存时,一定要小心别越界。一旦越界,你可能踩了其他进程的内存块,或者破坏了原本用于维护内存块的数据结构,引发段错误(segment fault)。

实践出真知,分配给我的内存到底在哪?

说了这么多,我们用一个实际的案例来看看,分配给我们的内存到底在哪。Get your hands dirty! 下面是一个简单的 C 语言的例子:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *p = malloc(sizeof(int));
    *p = 0x1234;
    printf("malloc addr is %p, and *p is 0x%x\n", p, *p);
    getchar();   // 等待用户输入,这里是为了我们方便查看程序状态。按任意键即可退出。
    return 0;    // 进程结束会自动free掉指针p,此处demo就不显示调用了
}

// 观察程序输出
$ gcc -g mem.c -o mem
$ ./mem
malloc addr is 0x564dd2ff92a0, and *p is 0x1234

从上面程序的输出,我们可以看到 malloc 申请了一个 int 大小的内存块,系统给出的地址是 0x564dd2ff92a0。那么,这个地址是堆地址吗?

// 我们在程序运行期间查看下这个mem进程的pid
$ ps -ef | grep mem
test       48800     380  0 19:36 pts/0    00:00:00 ./mem
test       50012    3246  0 19:41 pts/2    00:00:00 grep --color=auto mem

// 查看对应pid进程的内存布局, 关注其中的[heap]项:
$ cat /proc/48800/maps
564dd1f1d000-564dd1f1e000 r--p 00000000 08:20 456412                     /home/test/mem
564dd1f1e000-564dd1f1f000 r-xp 00001000 08:20 456412                     /home/test/mem
564dd1f1f000-564dd1f20000 r--p 00002000 08:20 456412                     /home/test/mem
564dd1f20000-564dd1f21000 r--p 00002000 08:20 456412                     /home/test/mem
564dd1f21000-564dd1f22000 rw-p 00003000 08:20 456412                     /home/test/mem
564dd2ff9000-564dd301a000 rw-p 00000000 00:00 0                          [heap]
7f49828c9000-7f49828cc000 rw-p 00000000 00:00 0
7f49828cc000-7f49828f4000 r--p 00000000 08:20 29797                      /usr/lib/x86_64-linux-gnu/libc.so.6
7f49828f4000-7f4982a89000 r-xp 00028000 08:20 29797                      /usr/lib/x86_64-linux-gnu/libc.so.6
7f4982a89000-7f4982ae1000 r--p 001bd000 08:20 29797                      /usr/lib/x86_64-linux-gnu/libc.so.6
7f4982ae1000-7f4982ae2000 ---p 00215000 08:20 29797                      /usr/lib/x86_64-linux-gnu/libc.so.6
7f4982ae2000-7f4982ae6000 r--p 00215000 08:20 29797                      /usr/lib/x86_64-linux-gnu/libc.so.6
7f4982ae6000-7f4982ae8000 rw-p 00219000 08:20 29797                      /usr/lib/x86_64-linux-gnu/libc.so.6
7f4982ae8000-7f4982af5000 rw-p 00000000 00:00 0
7f4982afb000-7f4982afd000 rw-p 00000000 00:00 0
7f4982afd000-7f4982aff000 r--p 00000000 08:20 29794                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f4982aff000-7f4982b29000 r-xp 00002000 08:20 29794                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f4982b29000-7f4982b34000 r--p 0002c000 08:20 29794                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f4982b35000-7f4982b37000 r--p 00037000 08:20 29794                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f4982b37000-7f4982b39000 rw-p 00039000 08:20 29794                      /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fff4cb87000-7fff4cba8000 rw-p 00000000 00:00 0                          [stack]
7fff4cbcf000-7fff4cbd3000 r--p 00000000 00:00 0                          [vvar]
7fff4cbd3000-7fff4cbd5000 r-xp 00000000 00:00 0                          [vdso]

从输出结果我们看到 heap 的虚拟地址范围是 564dd2ff9000-564dd301a000,而我们用 malloc 申请得到的地址是 0x564dd2ff92a0,说明确实落在堆内存的地址空间中。

为了进一步确定 malloc 返回的地址前面确实有记录 size 的字节,我们话不多说,上 gdb。

$ gdb mem
(gdb) b main
Breakpoint 1 at 0x1195: file mem.c, line 11.
(gdb) r
Starting program: /home/test/mem
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, main () at mem.c:11
11          int *p = malloc(sizeof(int));
(gdb) n
12          *p = 1234;
(gdb) p/x p
$1 = 0x5555555592a0
(gdb) x/10xw 0x555555559290
0x555555559290: 0x00000000      0x00000000      0x00000021      0x00000000
0x5555555592a0: 0x00000000      0x00000000      0x00000000      0x00000000
0x5555555592b0: 0x00000000      0x00000000
(gdb) n
13          printf("malloc addr is %p, and *p is 0x%x\n", p, *p);
(gdb) x/10xw 0x555555559290
0x555555559290: 0x00000000      0x00000000      0x00000021      0x00000000
0x5555555592a0: 0x00001234      0x00000000      0x00000000      0x00000000
0x5555555592b0: 0x00000000      0x00000000
(gdb) q
A debugging session is active.
Inferior 1 [process 45937] will be killed.
Quit anyway? (y or n) y

根据 gdb 的调试结果,我们注意到 malloc 的返回地址是 0x5555555592a0,通过 gdb 的内存查看指令观察 0x555555559290 开始的 10 个 word,发现前面的 8 个 byte 处有一个值是 0x21,这个 0x21 是不是就是用来标记 malloc 分配的内存块大小的呢?

我的系统上使用的 C 语言库是 glibc,为了进一步探究,需要查阅内存分配器 ptmalloc 的源代码,这也是 glibc 的默认内存分配器。关于内存分配器的详细原理,后面会有单独的文章讲解,这里先简要说明下 ptmalloc 分配的内存块特性。

ptmalloc 通过 malloc_chunk 来管理堆内存块,每个内存块都有这样一个 malloc_chunk 来记录块当前的大小和状态。以下代码引自 glibc-2.39/malloc/malloc.c

/*
  This struct declaration is misleading (but accurate and necessary).
  It declares a "view" into memory allowing access to necessary
  fields at known offsets from a given base. See explanation below.
*/

struct malloc_chunk {

  INTERNAL_SIZE_T      mchunk_prev_size;  /* Size of previous chunk (if free).  */
  INTERNAL_SIZE_T      mchunk_size;       /* Size in bytes, including overhead. */

  struct malloc_chunk* fd;         /* double links -- used only if free. */
  struct malloc_chunk* bk;

  /* Only used for large blocks: pointer to next larger size.  */
  struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
  struct malloc_chunk* bk_nextsize;
};


/*
   malloc_chunk details:

    (The following includes lightly edited explanations by Colin Plumb.)

    Chunks of memory are maintained using a `boundary tag' method as
    described in e.g., Knuth or Standish.  (See the paper by Paul
    Wilson ftp://ftp.cs.utexas.edu/pub/garbage/allocsrv.ps for a
    survey of such techniques.)  Sizes of free chunks are stored both
    in the front of each chunk and at the end.  This makes
    consolidating fragmented chunks into bigger chunks very fast.  The
    size fields also hold bits representing whether chunks are free or
    in use.

    An allocated chunk looks like this:


    chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Size of previous chunk, if unallocated (P clear)  |
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Size of chunk, in bytes                     |A|M|P|
      mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             User data starts here...                          .
	    .                                                               .
	    .             (malloc_usable_size() bytes)                      .
	    .                                                               |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             (size of chunk, but used for application data)    |
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Size of next chunk, in bytes                |A|0|1|
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

    Where "chunk" is the front of the chunk for the purpose of most of
    the malloc code, but "mem" is the pointer that is returned to the
    user.  "Nextchunk" is the beginning of the next contiguous chunk.

    Chunks always begin on even word boundaries, so the mem portion
    (which is returned to the user) is also on an even word boundary, and
    thus at least double-word aligned.

    Free chunks are stored in circular doubly-linked lists, and look like this:

    chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Size of previous chunk, if unallocated (P clear)  |
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    `head:' |             Size of chunk, in bytes                     |A|0|P|
      mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Forward pointer to next chunk in list             |
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Back pointer to previous chunk in list            |
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Unused space (may be 0 bytes long)                .
	    .                                                               .
	    .                                                               |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    `foot:' |             Size of chunk, in bytes                           |
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Size of next chunk, in bytes                |A|0|0|
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

......  受限于篇幅长度,其余细节略去,读者感兴趣的可自行研究。
*/

根据 glibc 的注释,简单总结下,我们看到的 0x21,刨去最低三位的 A、M、P 位,那么大小就是 0x20,也就是 32byte。关于其他标志的含义和 malloc_chunk 的更多细节,就不在本文赘述了,后面会有文章专门分析。

总结

malloc/free 作为 C 语言中常用的高频函数,虽然用起来简单,但是背后的原理牵涉到操作系统堆内存的使用方式。一不小心,工作中就会遇见令人头疼的内存泄漏、double free、UAF(use after free)、踩堆内存等错误。因此,掌握 malloc 背后的运行机制,有助于预防和定位这些难缠的内存问题。

关注微信公众号 【漫谈编程】,获取更多有用干货!