前言
在本篇博客中,我们拟用C语言实现简单的一个显式分配器,它模拟实现了C标准库中的动态内存分配的过程。我们给出了其详细的设计方案与具体实现,也在文章的最后给出了现实应用中,分配器所采用的一些常见设计。
背景知识
首先介绍一下关于动态内存分配的背景知识。
关于分配器
虽然可以使用低级的
mmap
和munmap
函数来创建和删除虚拟内存的区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器 (dynamic memory allocator) 更方便,也有更好的可移植性。
动态内存分配器维护者一个进程的虚拟内存区域,称为堆 (heap) ,对于每个进程,内核维护这一个变量brk
,它指向堆的顶部。
分配器有两种基本风格,两种风格都要求应用显式地分配块,它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器 (explicit allocator) ,要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做
malloc
程序包的显式分配器,并通过调用free
函数来释放一个块。C++中的new
和delete
操作符和C中的malloc
和free
相当。隐式分配器 (implicit allocator) ,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器 (garbage collector) ,而自动释放为使用的已分配的块的过程叫做垃圾收集 (garbage collecion) 。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
在本篇博客中,我们拟用C语言实现简单的一个显式分配器,它模拟的正是C标准库中的分配与释放内存的过程。
关于虚拟内存
在实现分配器之前,我们需要知道一些关于Linux系统内存管理的基本知识。
为了简单,现代操作系统在处理内存地址时,普遍采用虚拟内存地址技术。即在汇编程序(或机器语言)层面,当涉及内存地址时,都是使用虚拟内存地址。采用这种技术时,每个进程仿佛自己独享一片
2^N
字节的内存,其中N
是机器位数。例如在64位CPU和64位操作系统下,每个进程的虚拟地址空间为2^64
Byte。
这种虚拟地址空间的作用主要是简化程序的编写,及方便操作系统对进程间内存的隔离管理,真实中的进程不太可能(也用不到)如此大的内存空间,实际能用到的内存取决于物理内存大小。
由于在机器语言层面都是采用虚拟地址,当实际的机器码程序涉及到内存操作时,需要根据当前进程运行的实际上下文将虚拟地址转换为物理内存地址,才能实现对真实内存数据的操作。这个转换一般由一个叫MMU (Memory Management Unit) 的硬件完成。
那么,对于一个进程来说,内核又是如何维护它的内存分配呢?
我们以64位的Linux系统为例,假设实际用到的内存地址为空间为0x0000000000000000
~0x00007FFFFFFFFFFF
和0xFFFF800000000000
~ 0xFFFFFFFFFFFFFFFF
,其中前面为用户空间 (User Space) ,后者为内核空间 (Kernel Space) 。图示如下:
对用户来说,主要关注的空间是User Space。将User Space放大后,可以看到里面主要分为如下几段:
Code
:这是整个用户空间的最低地址部分,存放的是指令(也就是程序所编译成的可执行机器码)Data
:这里存放的是初始化过的全局变量BSS
:这里存放的是未初始化的全局变量Heap
:堆,这是我们本文重点关注的地方,堆自低地址向高地址增长,后面要讲到的brk
相关的系统调用就是从这里分配内存Mapping Area
:这里是与mmap
系统调用相关的区域。大多数实际的malloc
实现会考虑通过mmap
分配较大块的内存区域,本文不讨论这种情况。这个区域自高地址向低地址增长Stack
:这是栈区域,自高地址向低地址增长
一般来说,malloc
所申请的内存主要从Heap
区域分配(本文不考虑通过mmap
申请大块内存的情况)。
堆与系统级调用
堆
在上文中我们也提到了,Linux维护一个break
指针,这个指针指向堆空间的某个地址。
如下图所示,从堆起始地址到break
之间的地址空间为映射 (mapped region) 好的,可以供进程访问;而从break
往上,是未映射 (unmapped region) 的地址空间,如果访问这段空间则程序会报错。
brk
与sbrk
我们希望通过直接调用系统级函数来实现分配器的功能,因此就需要在分配和释放内存时,改变brk
指针的位置。
Linux通过brk
和sbrk
系统调用操作break
指针。两个系统调用的原型如下:
int brk(void *addr);
void *sbrk(intptr_t increment);
brk
将break
指针直接设置为某个地址,而sbrk
将break
从当前位置移动incremen
t所指定的增量。
brk
在执行成功时返回0
,否则返回-1
,并设置errno
为ENOMEM
;sbrk
成功时返回break
移动之前所指向的地址,否则返回(void *)-1
。
一个小技巧是,如果将increment
设置为0
,则可以获得当前break
的地址。
这两个系统级函数应如何使用呢?我们先编写一个最简单的malloc
函数:
#include <sys/types.h>
#include <unistd.h>
void *malloc(size_t size)
{
void *p;
p = sbrk(0);
if (sbrk(size) == (void *)-1)
return NULL;
return p;
}
这个malloc
每次都在当前break
的基础上增加size
所指定的字节数,并将之前break
的地址返回。
当然,这个malloc
由于对所分配的内存缺乏记录,不便于内存释放,所以无法用于真实场景。下面我们就来考虑一个比较完整的分配器设计方案。
设计方案
实现目标
首先我们必须明确的是