内存管理概述、内存分配与释放、地址映射机制(mm_struct, vm_area_struct)、malloc/free 的实现

注:本分类下文章大多整理自《深入分析linux内核源代码》一书,另有参考其他一些资料如《linux内核完全剖析》、《linux c 编程一站式学习》等,只是为了更好地理清系统编程和网络编程中的一些概念性问题,并没有深入地阅读分析源码,我也是草草翻过这本书,请有兴趣的朋友自己参考相关资料。此书出版较早,分析的版本为2.4.16,故出现的一些概念可能跟最新版本内核不同。

此书已经开源,阅读地址 http://www.kerneltravel.net

 

一、内存管理概述


(一)、虚拟内存实现结构


(1)内存映射模块(mmap):负责把磁盘文件的逻辑地址映射到虚拟地址,以及把虚拟地址映射到物理地址。

(2)交换模块(swap):负责控制内存内容的换入和换出,它通过交换机制,使得在物理内存的页面(RAM 页)中保留有效的页 ,即从主存中淘汰最近没被访问的页,保存近来访问过的页。

(3)核心内存管理模块(core):负责核心内存管理功能,即对页的分配、回收、释放及请页处理等,这些功能将被别的内核子系统(如文件系统)使用。

(4)结构特定的模块:负责给各种硬件平台提供通用接口,这个模块通过执行命令来改变硬件MMU 的虚拟地址映射,并在发生页错误时,提供了公用的方法来通知别的内核子系统。这个模块是实现虚拟内存的物理基础。

(二)、内核空间和用户空间

Linux 简化了分段机制,使得虚拟地址与线性地址总是一致,因此,Linux 的虚拟地址空间也为0~4G 字节。Linux 内核将这4G 字节的空间分为两部分。将最高的1G 字节(从虚拟地址0xC0000000 到0xFFFFFFFF),供内核使用,称为“内核空间”。而将较低的3G 字节(从虚拟地址0x00000000 到0xBFFFFFFF),供各个进程使用,称为“用户空间”。因为每个进程可以通过系统调用进入内核,因此,Linux 内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G 字节的虚拟空间。图 6.3 给出了进程虚拟空间示意图。

Linux 使用两级保护机制:0 级供内核使用,3 级供用户程序使用。从图6.3 中可以看出,每个进程有各自的私有用户空间(0~3G),这个空间对系统中的其他进程是不可见的。最高的1G 字节虚拟内核空间则为所有进程以及内核所共享。


(三)、虚拟内存实现机制间的关系


首先内存管理程序通过映射机制把用户程序的逻辑地址映射到物理地址,在用户程序运行时如果发现程序中要用的虚地址没有对应的物理内存时,就发出了请页要求①;如果有空闲的内存可供分配,就请求分配内存②(于是用到了内存的分配和回收),并把正在使用的物理页记录在页缓存中③(使用了缓存机制)。如果没有足够的内存可供分配,那么就调用交换机制,腾出一部分内存④⑤。另外在地址映射中要通过TLB(翻译后援存储器)来寻找物理页⑧;交换机制中也要用到交换缓存⑥,并且把物理页内容交换到交换文件中后也要修改页表来映射文件地址⑦。

二、内存分配与释放

在Linux 中,CPU 不能按物理地址来访问存储空间,而必须使用虚拟地址;因此,对于内存页面的管理,通常是先在虚存空间中分配一个虚存区间,然后才根据需要为此区间分配相应的物理页面并建立起映射,也就是说,虚存区间的分配在前,而物理页面的分配在后。

(一)、伙伴算法(Buddy)

Linux 的伙伴算法把所有的空闲页面分为10 个块组,每组中块的大小是2 的幂次方个页面,例如,第0 组中块的大小都为2^0(1 个页面),第1 组中块的大小都为2^1(2 个页面),第9 组中块的大小都为2^9(512 个页面)。也就是说,每一组中块的大小是相同的,且这同样大小的块形成一个链表。

我们通过一个简单的例子来说明该算法的工作原理。

假设要求分配的块的大小为128 个页面(由多个页面组成的块我们就叫做页面块)。该算法先在块大小为128 个页面的链表中查找,看是否有这样一个空闲块。如果有,就直接分配;如果没有,该算法会查找下一个更大的块,具体地说,就是在块大小256 个页面的链表中查找一个空闲块。如果存在这样的空闲块,内核就把这256 个页面分为两等份,一份分配出去,另一份插入到块大小为128 个页面的链表中。如果在块大小为256 个页面的链表中也没有找到空闲页块,就继续找更大的块,即512 个页面的块。如果存在这样的块,内核就从512 个页面的块中分出128 个页面满足请求,然后从384 个页面中取出256 个页面插入到块大小为256 个页面的链表中。然后把剩余的128 个页面插入到块大小为128 个页面的链表中。如果512 个页面的链表中还没有空闲块,该算法就放弃分配,并发出出错信号。

以上过程的逆过程就是块的释放过程,这也是该算法名字的来由。满足以下条件的两个块称为伙伴:
(1)两个块的大小相同;
(2)两个块的物理地址连续。

伙伴算法把满足以上条件的两个块合并为一个块,该算法是迭代算法,如果合并后的块还可以跟相邻的块进行合并,那么该算法就继续合并。


(二)、Slab 分配机制



可以根据对内存区的使用频率来对它分类。对于预期频繁使用的内存区,可以创建一组特定大小的专用缓冲区进行处理,以避免内碎片的产生。对于较少使用的内存区,可以创建一组通用缓冲区(如Linux 2.0 中所使用的2 的幂次方)来处理,即使这种处理模式产生碎
片,也对整个系统的性能影响不大。

硬件高速缓存的使用,又为尽量减少对伙伴算法的调用提供了另一个理由,因为对伙伴算法的每次调用都会“弄脏”硬件高速缓存,因此,这就增加了对内存的平均访问次数。

Slab 分配模式把对象分组放进缓冲区(尽管英文中使用了Cache 这个词,但实际上指的是内存中的区域,而不是指硬件高速缓存)。因为缓冲区的组织和管理与硬件高速缓存的命中率密切相关,因此,Slab 缓冲区并非由各个对象直接构成,而是由一连串的“大块(Slab)”构成,而每个大块中则包含了若干个同种类型的对象,这些对象或已被分配,或空闲,如图6.10 所示。一般而言,对象分两种,一种是大对象,一种是小对象。所谓小对象,是指在一个页面中可以容纳下好几个对象的那种。例如,一个inode 结构大约占300 多个字节,因此,一个页面中可以容纳8 个以上的inode 结构,因此,inode 结构就为小对象。Linux 内核中把小于512 字节的对象叫做小对象。

实际上,缓冲区就是主存中的一片区域,把这片区域划分为多个块,每块就是一个Slab,每个Slab 由一个或多个页面组成,每个Slab 中存放的就是对象。

三、地址映射机制

在进程的task_struct 结构中包含一个指向 mm_struct 结构的指针,mm_strcut 用来描述一个进程的虚拟地址空间。进程的 mm_struct 则包含装入的可执行映像信息以及进程的页目录指针pgd。该结构还包含有指向 vm_area_struct 结构的几个指针,每个 vm_area_struct 代表进程的一个虚拟地址区间。vm_area_struct 结构含有指向vm_operations_struct 结构的一个指针,vm_operations_struct 描述了在这个区间的操作vm_operations 结构中包含的是函数指针;其中,open、close 分别用于虚拟区间的打开、关闭,而nopage 用于当虚存页面不在物理内存而引起的“缺页异常”时所应该调用的函数,当 Linux 处理这一缺页异常时(请页机制),就可以为新的虚拟内存区分配实际的物理内存。图6.15 给出了虚拟区间的操作集。


 C++ Code 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
 
struct mm_struct
{
    struct vm_area_struct *mmap;            /* list of VMAs */
    struct rb_root mm_rb;
    struct vm_area_struct *mmap_cache;      /* last find_vma result */
    ...
    unsigned long start_code, end_code, start_data, end_data;
    unsigned long start_brk, brk, start_stack;
    ...
};

struct vm_area_struct
{
    struct mm_struct *vm_mm;        /* The address space we belong to. */
    unsigned long vm_start;         /* Our start address within vm_mm. */
    unsigned long vm_end;           /* The first byte after our end address
                                           within vm_mm. */
    ....
    /* linked list of VM areas per task, sorted by address */
    struct vm_area_struct *vm_next;
    ....
    /* describe the permissable operation */
    unsigned long vm_flags;
    /* operations on this area */
    struct vm_operations_struct * vm_ops;
    
    struct file * vm_file; /* File we map to (can be NULL). */
} ;

/*
* These are the virtual MM functions - opening of an area, closing and
* unmapping it (needed to keep files on disk up-to-date etc), pointer
* to the functions called when a no-page or a wp-page exception occurs.
*/
struct vm_operations_struct
{
    void (*open)(struct vm_area_struct *area);
    void (*close)(struct vm_area_struct *area);
    struct page * (*nopage)(struct vm_area_struct * area, unsigned long address, int unused);
};







四、malloc 和 free 的实现

 C++ Code 
1
2
3
4
5
6
7
8
9
10
 
Normally,  malloc()  allocates  memory  from  the heap, and adjusts the size of the heap as required, using sbrk(2).  When
allocating blocks of memory larger than MMAP_THRESHOLD bytes, the glibc malloc() implementation allocates the memory as  a
private  anonymous mapping using mmap(2).  MMAP_THRESHOLD is 128 kB by default, but is adjustable using mallopt(3).  Allo‐
cations performed using mmap(2) are unaffected by the RLIMIT_DATA resource limit (see getrlimit(2)).

MAP_ANONYMOUS
    The  mapping  is  not  backed  by  any file; its contents are initialized to zero.  The fd and offset arguments are ignored;
    however, some implementations require fd to be - if MAP_ANONYMOUS ( or MAP_ANON) is specified, and  portable applications 
    should ensure this. The use of MAP_ANONYMOUS in conjunction with MAP_SHARED is only supported on Linux since kernel 2.4.

每一次 malloc 的内存都比较大(大于128KB)时,都会调用 mmap 来完成,可能是系统性能降低的一个点。

(一)、使用brk()/ sbrk() 实现


图中白色背景的框表示 malloc管理的空闲内存块,深色背景的框不归 malloc管,可能是已经分配给用户的内存块,也可能不属于当前进程, Break之上的地址不属于当前进程,需要通过 brk系统调用向内核申请。每个内存块开头都有一个头节点,里面有一个指针字段和一个长度字段,指针字段把所有空闲块的头节点串在一起,组成一个环形链表,长度字段记录着头节点和后面的内存块加起来一共有多长,以 8字节为单位(也就是以头节点的长度为单位)。


1. 一开始堆空间由一个空闲块组成,长度为 7×8=56字节,除头节点之外的长度为 48字节。

2. 调用 malloc分配 8个字节,要在这个空闲块的末尾截出 16个字节,其中新的头节点占了 8个字节,另外 8个字节返回给用户使用,注意返回的指针 p1指向头节点后面的内存块。

3. 又调用 malloc分配 16个字节,又在空闲块的末尾截出 24个字节,步骤和上一步类似。

4. 调用 free释放 p1所指向的内存块,内存块(包括头节点在内)归还给了 malloc,现在 malloc管理着两块不连续的内存,用环形链表串起来。注意这时 p1成了野指针,指向不属于用户的内存, p1所指向的内存地址在 Break之下,是属于当前进程的,所以访问 p1时不会出现段错误,但在访问 p1时这段内存可能已经被 malloc再次分配出去了,可能会读到意外改写数据。另外注意,此时如果通过 p2向右写越界,有可能覆盖右边的头节点,从而破坏 malloc管理的环形链表, malloc就无法从一个空闲块的指针字段找到下一个空闲块了,找到哪去都不一定,全乱套了。

5. 调用 malloc分配 16个字节,现在虽然有两个空闲块,各有 8个字节可分配,但是这两块不连续, malloc只好通过 brk系统调用抬高 Break,获得新的内存空间。在 [K&R]的实现中,每次调用 sbrk函数时申请 1024×8=8192个字节,在 Linux系统上 sbrk函数也是通过 brk实现的,这里为了画图方便,我们假设每次调用 sbrk申请 32个字节,建立一个新的空闲块。

6. 新申请的空闲块和前一个空闲块连续,因此可以合并成一个。在能合并时要尽量合并,以免空闲块越割越小,无法满足大的分配请求。

7. 在合并后的这个空闲块末尾截出 24个字节,新的头节点占 8个字节,另外 16个字节返回给用户。

8. 调用 free(p3)释放这个内存块,由于它和前一个空闲块连续,又重新合并成一个空闲块。注意, Break只能抬高而不能降低,从内核申请到的内存以后都归 malloc管了,即使调用 free也不会还给内核。

(二)、使用mmap() / munmap() 实现

Linux下面,kernel 使用4096 byte来划分页面,而malloc的颗粒度更细,使用8 byte对齐,因此,分配出来的内存不一定是页对齐的。而mmap 分配出来的内存地址是页对齐的,所以munmap处理的内存地址必须页对齐(Page Aligned)。此外,我们可以使用memalign或是posix_memalign来获取一块页对齐的内存。

可以参考《linux的内存管理模型(上)》这篇文章。

扩展阅读 http://blog.codinglabs.org/articles/a-malloc-tutorial.html
 
 

任何一个用过或学过C的人对malloc都不会陌生。大家都知道malloc可以分配一段连续的内存空间,并且在不再使用时可以通过free释放掉。但是,许多程序员对malloc背后的事情并不熟悉,许多人甚至把malloc当做操作系统所提供的系统调用或C的关键字。实际上,malloc只是C的标准库中提供的一个普通函数,而且实现malloc的基本思想并不复杂,任何一个对C和操作系统有些许了解的程序员都可以很容易理解。

这篇文章通过实现一个简单的malloc来描述malloc背后的机制。当然与现有C的标准库实现(例如glibc)相比,我们实现的malloc并不是特别高效,但是这个实现比目前真实的malloc实现要简单很多,因此易于理解。重要的是,这个实现和真实实现在基本原理上是一致的。

这篇文章将首先介绍一些所需的基本知识,如操作系统对进程的内存管理以及相关的系统调用,然后逐步实现一个简单的malloc。为了简单起见,这篇文章将只考虑x86_64体系结构,操作系统为Linux。

1 什么是malloc

在实现malloc之前,先要相对正式地对malloc做一个定义。

根据标准C库函数的定义,malloc具有如下原型:

  1. void* malloc(size_t size);

这个函数要实现的功能是在系统中分配一段连续的可用的内存,具体有如下要求:

  • malloc分配的内存大小至少为size参数所指定的字节数
  • malloc的返回值是一个指针,指向一段可用内存的起始地址
  • 多次调用malloc所分配的地址不能有重叠部分,除非某次malloc所分配的地址被释放掉
  • malloc应该尽快完成内存分配并返回(不能使用NP-hard的内存分配算法)
  • 实现malloc时应同时实现内存大小调整和内存释放函数(即realloc和free)

对于malloc更多的说明可以在命令行中键入以下命令查看:

  1. man malloc

2 预备知识

在实现malloc之前,需要先解释一些Linux系统内存相关的知识。

2.1 Linux内存管理

2.1.1 虚拟内存地址与物理内存地址

为了简单,现代操作系统在处理内存地址时,普遍采用虚拟内存地址技术。即在汇编程序(或机器语言)层面,当涉及内存地址时,都是使用虚拟内存地址。采用这种技术时,每个进程仿佛自己独享一片$2^N$字节的内存,其中$N$是机器位数。例如在64位CPU和64位操作系统下,每个进程的虚拟地址空间为$2^{64}$Byte。

这种虚拟地址空间的作用主要是简化程序的编写及方便操作系统对进程间内存的隔离管理,真实中的进程不太可能(也用不到)如此大的内存空间,实际能用到的内存取决于物理内存大小。

由于在机器语言层面都是采用虚拟地址,当实际的机器码程序涉及到内存操作时,需要根据当前进程运行的实际上下文将虚拟地址转换为物理内存地址,才能实现对真实内存数据的操作。这个转换一般由一个叫MMU(Memory Management Unit)的硬件完成。

2.1.2 页与地址构成

在现代操作系统中,不论是虚拟内存还是物理内存,都不是以字节为单位进行管理的,而是以页(Page)为单位。一个内存页是一段固定大小的连续内存地址的总称,具体到Linux中,典型的内存页大小为4096Byte(4K)。

所以内存地址可以分为页号和页内偏移量。下面以64位机器,4G物理内存,4K页大小为例,虚拟内存地址和物理内存地址的组成如下:

内存地址构成

上面是虚拟内存地址,下面是物理内存地址。由于页大小都是4K,所以页内便宜都是用低12位表示,而剩下的高地址表示页号。

MMU映射单位并不是字节,而是页,这个映射通过查一个常驻内存的数据结构页表来实现。现在计算机具体的内存地址映射比较复杂,为了加快速度会引入一系列缓存和优化,例如TLB等机制。下面给出一个经过简化的内存地址翻译示意图,虽然经过了简化,但是基本原理与现代计算机真实的情况的一致的。

内存地址翻译

2.1.3 内存页与磁盘页

我们知道一般将内存看做磁盘的的缓存,有时MMU在工作时,会发现页表表明某个内存页不在物理内存中,此时会触发一个缺页异常(Page Fault),此时系统会到磁盘中相应的地方将磁盘页载入到内存中,然后重新执行由于缺页而失败的机器指令。关于这部分,因为可以看做对malloc实现是透明的,所以不再详细讲述,有兴趣的可以参考《深入理解计算机系统》相关章节。

最后附上一张在维基百科找到的更加符合真实地址翻译的流程供大家参考,这张图加入了TLB和缺页异常的流程(图片来源页)。

较为完整的地址翻译流程

2.2 Linux进程级内存管理

2.2.1 内存排布

明白了虚拟内存和物理内存的关系及相关的映射机制,下面看一下具体在一个进程内是如何排布内存的。

以Linux 64位系统为例。理论上,64bit内存地址可用空间为0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF,这是个相当庞大的空间,Linux实际上只用了其中一小部分(256T)。

根据Linux内核相关文档描述,Linux64位操作系统仅使用低47位,高17位做扩展(只能是全0或全1)。所以,实际用到的地址为空间为0x0000000000000000 ~ 0x00007FFFFFFFFFFF和0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF,其中前面为用户空间(User Space),后者为内核空间(Kernel Space)。图示如下:

Linux进程地址排布

对用户来说,主要关注的空间是User Space。将User Space放大后,可以看到里面主要分为如下几段:

  • Code:这是整个用户空间的最低地址部分,存放的是指令(也就是程序所编译成的可执行机器码)
  • Data:这里存放的是初始化过的全局变量
  • BSS:这里存放的是未初始化的全局变量
  • Heap:堆,这是我们本文重点关注的地方,堆自低地址向高地址增长,后面要讲到的brk相关的系统调用就是从这里分配内存
  • Mapping Area:这里是与mmap系统调用相关的区域。大多数实际的malloc实现会考虑通过mmap分配较大块的内存区域,本文不讨论这种情况。这个区域自高地址向低地址增长
  • Stack:这是栈区域,自高地址向低地址增长

下面我们主要关注Heap区域的操作。对整个Linux内存排布有兴趣的同学可以参考其它资料。

2.2.2 Heap内存模型

一般来说,malloc所申请的内存主要从Heap区域分配(本文不考虑通过mmap申请大块内存的情况)。

由上文知道,进程所面对的虚拟内存地址空间,只有按页映射到物理内存地址,才能真正使用。受物理存储容量限制,整个堆虚拟内存空间不可能全部映射到实际的物理内存。Linux对堆的管理示意如下:

Linux进程堆管理

Linux维护一个break指针,这个指针指向堆空间的某个地址。从堆起始地址到break之间的地址空间为映射好的,可以供进程访问;而从break往上,是未映射的地址空间,如果访问这段空间则程序会报错。

2.2.3 brk与sbrk

由上文知道,要增加一个进程实际的可用堆大小,就需要将break指针向高地址移动。Linux通过brk和sbrk系统调用操作break指针。两个系统调用的原型如下:

  1. int brk(void *addr);
  2. void *sbrk(intptr_t increment);

brk将break指针直接设置为某个地址,而sbrk将break从当前位置移动increment所指定的增量。brk在执行成功时返回0,否则返回-1并设置errno为ENOMEM;sbrk成功时返回break移动之前所指向的地址,否则返回(void *)-1。

一个小技巧是,如果将increment设置为0,则可以获得当前break的地址。

另外需要注意的是,由于Linux是按页进行内存映射的,所以如果break被设置为没有按页大小对齐,则系统实际上会在最后映射一个完整的页,从而实际已映射的内存空间比break指向的地方要大一些。但是使用break之后的地址是很危险的(尽管也许break之后确实有一小块可用内存地址)。

2.2.4 资源限制与rlimit

系统对每一个进程所分配的资源不是无限的,包括可映射的内存空间,因此每个进程有一个rlimit表示当前进程可用的资源上限。这个限制可以通过getrlimit系统调用得到,下面代码获取当前进程虚拟内存空间的rlimit:

  1. int main() {
  2. struct rlimit *limit = (struct rlimit *)malloc(sizeof(struct rlimit));
  3. getrlimit(RLIMIT_AS, limit);
  4. printf("soft limit: %ld, hard limit: %ld\n", limit->rlim_cur, limit->rlim_max);
  5. }

其中rlimit是一个结构体:

  1. struct rlimit {
  2. rlim_t rlim_cur; /* Soft limit */
  3. rlim_t rlim_max; /* Hard limit (ceiling for rlim_cur) */
  4. };

每种资源有软限制和硬限制,并且可以通过setrlimit对rlimit进行有条件设置。其中硬限制作为软限制的上限,非特权进程只能设置软限制,且不能超过硬限制。

3 实现malloc

3.1 玩具实现

在正式开始讨论malloc的实现前,我们可以利用上述知识实现一个简单但几乎没法用于真实的玩具malloc,权当对上面知识的复习:

  1. /* 一个玩具malloc */
  2. #include <sys/types.h>
  3. #include <unistd.h>
  4. void *malloc(size_t size)
  5. {
  6. void *p;
  7. p = sbrk(0);
  8. if (sbrk(size) == (void *)-1)
  9. return NULL;
  10. return p;
  11. }

这个malloc每次都在当前break的基础上增加size所指定的字节数,并将之前break的地址返回。这个malloc由于对所分配的内存缺乏记录,不便于内存释放,所以无法用于真实场景。

3.2 正式实现

下面严肃点讨论malloc的实现方案。

3.2.1 数据结构

首先我们要确定所采用的数据结构。一个简单可行方案是将堆内存空间以块(Block)的形式组织起来,每个块由meta区和数据区组成,meta区记录数据块的元信息(数据区大小、空闲标志位、指针等等),数据区是真实分配的内存区域,并且数据区的第一个字节地址即为malloc返回的地址。

可以用如下结构体定义一个block:

  1. typedef struct s_block *t_block;
  2. struct s_block {
  3. size_t size; /* 数据区大小 */
  4. t_block next; /* 指向下个块的指针 */
  5. int free; /* 是否是空闲块 */
  6. int padding; /* 填充4字节,保证meta块长度为8的倍数 */
  7. char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
  8. };

由于我们只考虑64位机器,为了方便,我们在结构体最后填充一个int,使得结构体本身的长度为8的倍数,以便内存对齐。示意图如下:

Block结构

3.2.2 寻找合适的block

现在考虑如何在block链中查找合适的block。一般来说有两种查找算法:

  • First fit:从头开始,使用第一个数据区大小大于要求size的块所谓此次分配的块
  • Best fit:从头开始,遍历所有块,使用数据区大小大于size且差值最小的块作为此次分配的块

两种方法各有千秋,best fit具有较高的内存使用率(payload较高),而first fit具有更好的运行效率。这里我们采用first fit算法。

  1. /* First fit */
  2. t_block find_block(t_block *last, size_t size) {
  3. t_block b = first_block;
  4. while(b && !(b->free && b->size >= size)) {
  5. *last = b;
  6. b = b->next;
  7. }
  8. return b;
  9. }

find_block从frist_block开始,查找第一个符合要求的block并返回block起始地址,如果找不到这返回NULL。这里在遍历时会更新一个叫last的指针,这个指针始终指向当前遍历的block。这是为了如果找不到合适的block而开辟新block使用的,具体会在接下来的一节用到。

3.2.3 开辟新的block

如果现有block都不能满足size的要求,则需要在链表最后开辟一个新的block。这里关键是如何只使用sbrk创建一个struct:

  1. #define BLOCK_SIZE 24 /* 由于存在虚拟的data字段,sizeof不能正确计算meta长度,这里手工设置 */
  2.  
  3. t_block extend_heap(t_block last, size_t s) {
  4. t_block b;
  5. b = sbrk(0);
  6. if(sbrk(BLOCK_SIZE + s) == (void *)-1)
  7. return NULL;
  8. b->size = s;
  9. b->next = NULL;
  10. if(last)
  11. last->next = b;
  12. b->free = 0;
  13. return b;
  14. }

3.2.4 分裂block

First fit有一个比较致命的缺点,就是可能会让很小的size占据很大的一块block,此时,为了提高payload,应该在剩余数据区足够大的情况下,将其分裂为一个新的block,示意如下:

分裂block

实现代码:

  1. void split_block(t_block b, size_t s) {
  2. t_block new;
  3. new = b->data + s;
  4. new->size = b->size - s - BLOCK_SIZE ;
  5. new->next = b->next;
  6. new->free = 1;
  7. b->size = s;
  8. b->next = new;
  9. }

3.2.5 malloc的实现

有了上面的代码,我们可以利用它们整合成一个简单但初步可用的malloc。注意首先我们要定义个block链表的头first_block,初始化为NULL;另外,我们需要剩余空间至少有BLOCK_SIZE + 8才执行分裂操作。

由于我们希望malloc分配的数据区是按8字节对齐,所以在size不为8的倍数时,我们需要将size调整为大于size的最小的8的倍数:

  1. size_t align8(size_t s) {
  2. if(s & 0x7 == 0)
  3. return s;
  4. return ((s >> 3) + 1) << 3;
  5. }
  1. #define BLOCK_SIZE 24
  2. void *first_block=NULL;
  3.  
  4. /* other functions... */
  5.  
  6. void *malloc(size_t size) {
  7. t_block b, last;
  8. size_t s;
  9. /* 对齐地址 */
  10. s = align8(size);
  11. if(first_block) {
  12. /* 查找合适的block */
  13. last = first_block;
  14. b = find_block(&last, s);
  15. if(b) {
  16. /* 如果可以,则分裂 */
  17. if ((b->size - s) >= ( BLOCK_SIZE + 8))
  18. split_block(b, s);
  19. b->free = 0;
  20. } else {
  21. /* 没有合适的block,开辟一个新的 */
  22. b = extend_heap(last, s);
  23. if(!b)
  24. return NULL;
  25. }
  26. } else {
  27. b = extend_heap(NULL, s);
  28. if(!b)
  29. return NULL;
  30. first_block = b;
  31. }
  32. return b->data;
  33. }

3.2.6 calloc的实现

有了malloc,实现calloc只要两步:

  1. malloc一段内存
  2. 将数据区内容置为0

由于我们的数据区是按8字节对齐的,所以为了提高效率,我们可以每8字节一组置0,而不是一个一个字节设置。我们可以通过新建一个size_t指针,将内存区域强制看做size_t类型来实现。

  1. void *calloc(size_t number, size_t size) {
  2. size_t *new;
  3. size_t s8, i;
  4. new = malloc(number * size);
  5. if(new) {
  6. s8 = align8(number * size) >> 3;
  7. for(i = 0; i < s8; i++)
  8. new[i] = 0;
  9. }
  10. return new;
  11. }

3.2.7 free的实现

free的实现并不像看上去那么简单,这里我们要解决两个关键问题:

  1. 如何验证所传入的地址是有效地址,即确实是通过malloc方式分配的数据区首地址
  2. 如何解决碎片问题

首先我们要保证传入free的地址是有效的,这个有效包括两方面:

  • 地址应该在之前malloc所分配的区域内,即在first_block和当前break指针范围内
  • 这个地址确实是之前通过我们自己的malloc分配的

第一个问题比较好解决,只要进行地址比较就可以了,关键是第二个问题。这里有两种解决方案:一是在结构体内埋一个magic number字段,free之前通过相对偏移检查特定位置的值是否为我们设置的magic number,另一种方法是在结构体内增加一个magic pointer,这个指针指向数据区的第一个字节(也就是在合法时free时传入的地址),我们在free前检查magic pointer是否指向参数所指地址。这里我们采用第二种方案:

首先我们在结构体中增加magic pointer(同时要修改BLOCK_SIZE):

  1. typedef struct s_block *t_block;
  2. struct s_block {
  3. size_t size; /* 数据区大小 */
  4. t_block next; /* 指向下个块的指针 */
  5. int free; /* 是否是空闲块 */
  6. int padding; /* 填充4字节,保证meta块长度为8的倍数 */
  7. void *ptr; /* Magic pointer,指向data */
  8. char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
  9. };

然后我们定义检查地址合法性的函数:

  1. t_block get_block(void *p) {
  2. char *tmp;
  3. tmp = p;
  4. return (p = tmp -= BLOCK_SIZE);
  5. }
  6.  
  7. int valid_addr(void *p) {
  8. if(first_block) {
  9. if(p > first_block && p < sbrk(0)) {
  10. return p == (get_block(p))->ptr;
  11. }
  12. }
  13. return 0;
  14. }

当多次malloc和free后,整个内存池可能会产生很多碎片block,这些block很小,经常无法使用,甚至出现许多碎片连在一起,虽然总体能满足某此malloc要求,但是由于分割成了多个小block而无法fit,这就是碎片问题。

一个简单的解决方式时当free某个block时,如果发现它相邻的block也是free的,则将block和相邻block合并。为了满足这个实现,需要将s_block改为双向链表。修改后的block结构如下:

  1. typedef struct s_block *t_block;
  2. struct s_block {
  3. size_t size; /* 数据区大小 */
  4. t_block prev; /* 指向上个块的指针 */
  5. t_block next; /* 指向下个块的指针 */
  6. int free; /* 是否是空闲块 */
  7. int padding; /* 填充4字节,保证meta块长度为8的倍数 */
  8. void *ptr; /* Magic pointer,指向data */
  9. char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
  10. };

合并方法如下:

  1. t_block fusion(t_block b) {
  2. if (b->next && b->next->free) {
  3. b->size += BLOCK_SIZE + b->next->size;
  4. b->next = b->next->next;
  5. if(b->next)
  6. b->next->prev = b;
  7. }
  8. return b;
  9. }

有了上述方法,free的实现思路就比较清晰了:首先检查参数地址的合法性,如果不合法则不做任何事;否则,将此block的free标为1,并且在可以的情况下与后面的block进行合并。如果当前是最后一个block,则回退break指针释放进程内存,如果当前block是最后一个block,则回退break指针并设置first_block为NULL。实现如下:

  1. void free(void *p) {
  2. t_block b;
  3. if(valid_addr(p)) {
  4. b = get_block(p);
  5. b->free = 1;
  6. if(b->prev && b->prev->free)
  7. b = fusion(b->prev);
  8. if(b->next)
  9. fusion(b);
  10. else {
  11. if(b->prev)
  12. b->prev->prev = NULL;
  13. else
  14. first_block = NULL;
  15. brk(b);
  16. }
  17. }
  18. }

3.2.8 realloc的实现

为了实现realloc,我们首先要实现一个内存复制方法。如同calloc一样,为了效率,我们以8字节为单位进行复制:

  1. void copy_block(t_block src, t_block dst) {
  2. size_t *sdata, *ddata;
  3. size_t i;
  4. sdata = src->ptr;
  5. ddata = dst->ptr;
  6. for(i = 0; (i * 8) < src->size && (i * 8) < dst->size; i++)
  7. ddata[i] = sdata[i];
  8. }

然后我们开始实现realloc。一个简单(但是低效)的方法是malloc一段内存,然后将数据复制过去。但是我们可以做的更高效,具体可以考虑以下几个方面:

  • 如果当前block的数据区大于等于realloc所要求的size,则不做任何操作
  • 如果新的size变小了,考虑split
  • 如果当前block的数据区不能满足size,但是其后继block是free的,并且合并后可以满足,则考虑做合并

下面是realloc的实现:

  1. void *realloc(void *p, size_t size) {
  2. size_t s;
  3. t_block b, new;
  4. void *newp;
  5. if (!p)
  6. /* 根据标准库文档,当p传入NULL时,相当于调用malloc */
  7. return malloc(size);
  8. if(valid_addr(p)) {
  9. s = align8(size);
  10. b = get_block(p);
  11. if(b->size >= s) {
  12. if(b->size - s >= (BLOCK_SIZE + 8))
  13. split_block(b,s);
  14. } else {
  15. /* 看是否可进行合并 */
  16. if(b->next && b->next->free
  17. && (b->size + BLOCK_SIZE + b->next->size) >= s) {
  18. fusion(b);
  19. if(b->size - s >= (BLOCK_SIZE + 8))
  20. split_block(b, s);
  21. } else {
  22. /* 新malloc */
  23. newp = malloc (s);
  24. if (!newp)
  25. return NULL;
  26. new = get_block(newp);
  27. copy_block(b, new);
  28. free(p);
  29. return(newp);
  30. }
  31. }
  32. return (p);
  33. }
  34. return NULL;
  35. }

3.3 遗留问题和优化

以上是一个较为简陋,但是初步可用的malloc实现。还有很多遗留的可能优化点,例如:

  • 同时兼容32位和64位系统
  • 在分配较大快内存时,考虑使用mmap而非sbrk,这通常更高效
  • 可以考虑维护多个链表而非单个,每个链表中的block大小均为一个范围内,例如8字节链表、16字节链表、24-32字节链表等等。此时可以根据size到对应链表中做分配,可以有效减少碎片,并提高查询block的速度
  • 可以考虑链表中只存放free的block,而不存放已分配的block,可以减少查找block的次数,提高效率

还有很多可能的优化,这里不一一赘述。下面附上一些参考文献,有兴趣的同学可以更深入研究。

4 其它参考

  1. 这篇文章大量参考了A malloc Tutorial,其中一些图片和代码直接引用了文中的内容,这里特别指出
  2. Computer Systems: A Programmer's Perspective, 2/E一书有许多值得参考的地方
  3. 关于Linux的虚拟内存模型,Anatomy of a Program in Memory是很好的参考资料,另外作者还有一篇How the Kernel Manages Your Memory对于Linux内核中虚拟内存管理的部分有很好的讲解
  4. 对于真实世界的malloc实现,可以参考glibc的实现
  5. 本文写作过程中大量参考了维基百科,再次感谢这个伟大的网站,并且呼吁大家在手头允许的情况下可以适当捐助维基百科,帮助这个造福人类的系统运行下去

posted on 2018-02-26 10:58  AlanTu  阅读(1605)  评论(0编辑  收藏  举报

导航