A Demo Allocator——实现一个简单的自定义显式分配器

前言

在本篇博客中,我们拟用C语言实现简单的一个显式分配器,它模拟实现了C标准库中的动态内存分配的过程。我们给出了其详细的设计方案与具体实现,也在文章的最后给出了现实应用中,分配器所采用的一些常见设计。


背景知识

首先介绍一下关于动态内存分配的背景知识。

关于分配器

虽然可以使用低级的mmapmunmap函数来创建和删除虚拟内存的区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器 (dynamic memory allocator) 更方便,也有更好的可移植性。

动态内存分配器维护者一个进程的虚拟内存区域,称为堆 (heap) ,对于每个进程,内核维护这一个变量brk,它指向堆的顶部。

分配器有两种基本风格,两种风格都要求应用显式地分配块,它们的不同之处在于由哪个实体来负责释放已分配的块。

显式分配器 (explicit allocator) ,要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器,并通过调用free函数来释放一个块。C++中的newdelete操作符和C中的mallocfree相当。

隐式分配器 (implicit allocator) ,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器 (garbage collector) ,而自动释放为使用的已分配的块的过程叫做垃圾收集 (garbage collecion) 。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。

在本篇博客中,我们拟用C语言实现简单的一个显式分配器,它模拟的正是C标准库中的分配与释放内存的过程。

关于虚拟内存

在实现分配器之前,我们需要知道一些关于Linux系统内存管理的基本知识。

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

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

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

那么,对于一个进程来说,内核又是如何维护它的内存分配呢?

我们以64位的Linux系统为例,假设实际用到的内存地址为空间为0x0000000000000000 ~0x00007FFFFFFFFFFF0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF,其中前面为用户空间 (User Space) ,后者为内核空间 (Kernel Space) 。图示如下:

1

对用户来说,主要关注的空间是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) 的地址空间,如果访问这段空间则程序会报错。

1

brksbrk

我们希望通过直接调用系统级函数来实现分配器的功能,因此就需要在分配和释放内存时,改变brk指针的位置。

Linux通过brksbrk系统调用操作break指针。两个系统调用的原型如下:

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

brkbreak指针直接设置为某个地址,而sbrkbreak从当前位置移动increment所指定的增量。

brk在执行成功时返回0,否则返回-1,并设置errnoENOMEMsbrk成功时返回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由于对所分配的内存缺乏记录,不便于内存释放,所以无法用于真实场景。下面我们就来考虑一个比较完整的分配器设计方案。


设计方案

实现目标

首先我们必须明确的是

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值