从0实现malloc函数

2024-08-31 04:48
文章标签 实现 函数 malloc

本文主要是介绍从0实现malloc函数,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

本文介绍如何用c语言实现一个简单的内存分配器,可替换glibc中的 malloc(), calloc(), realloc(), free().

这是一篇入门级别的文章,所以不会介绍所有的细节。 代码实现主要为了演示内存分配器的基本工作原理,所以和工业级内存分配器相比,缺少非常多的性能优化,分配内存时也不会按页对齐,但是至少,我们构建的内存分配器是可以工作的。

在构建内存分配器之前,需要先介绍程序的内存布局。操作系统中的进程运行在它自己独有的虚拟地址空间,不同进程间的虚拟地址空间是独立、相互隔离的。虚拟地址空间一般由以下5部分组成:

代码段(Text section):包含被处理器执行的二进制指令 数据段(Data section):包含程序中已手动初始化的全局静态变量和局部静态变量 BSS(Block Started by Symbol)段:包含了程序中未手动初始化的全局变量和局部静态变量。这部分数据会被初始化为零值。 :包含动态申请的内存 :包含了局部变量,函数参数等

如上图所示,栈和堆的增长方向是相反的。 有时数据段、BSS段、堆三个区域会统称为"数据段"(data segment), 它的尾部被一个叫做brk(program break)的指针所指向。 也就是说,brk指向堆的尾部。

如果我们想在堆上申请更多的内存,我们需要向操作系统请求增长brk。同理,释放内存时需要向操作系统请求减小brk。

在Linux系统(或其他Unix-like系统)下,我们需要使用sbrk()系统调用来操作程序的brk指针。

调用sbrk(0)返回当前brk的位置。 调用sbrk(x)并传入正数表示对brk增长x字节大小,即分配内存。 调用sbrk(-x)并传入负数表示对brk减少x字节大小,即释放内存。 如果函数调用失败,sbrk()将返回(void*)-1

除了sbrk(),还可以使用mmap()申请内存。sbrk()并不是真正的线程安全。并且它只能按后进先出的顺序增长和收缩。 如果你执行man 2 sbrk命令查看帮助手册,它会告诉你:

brk和sbrk是早期虚拟内存管理出现之前的历史遗留产物。

然而,glibc中的malloc依然使用sbrk()来申请小块内存,具体可以见The GNU C Library: Malloc Tunable Parameters。 所以,我们也使用sbrk()来实现我们的简单版本内存分配器。

malloc()

malloc(size)函数申请size字节大小的内存并返回指向所申请内存的指针。 第一版代码实现如下:

void *malloc(size_t size)
{void *block;block = sbrk(size);if (block == (void*) -1)return NULL;return block;
}

上面的代码,我们指定size调用sbrk()。 如果成功,size大小的内存在堆上被申请。 这很简单,然而真是这样吗?

问题在于我们如何释放这块内存。 free(ptr)函数释放ptr指针指向的内存块时,ptr指针必须是之前通过malloc()或calloc()或realloc()调用返回的指针。 但是释放一块内存的首先前提是,我们知道这块内存的大小。在上面这个实现中,我们没有地方获取关于大小的信息。所以,我们需要某种方法将大小信息存放在某处。 此外,我们需要理解,操作系统所提供的堆内存是连续的。所以我们只能释放堆尾部的内存。我们不能将堆中间的内存释放给操作系统。你可以把堆想象成一个长条面包,你只能在尾部对它进行伸长或缩短,但是你必须保证它的完整性。

为了解决堆里面非尾部内存无法释放的问题,从现在开始我们将区分两个概念:freeing内存和releasing内存。 freeing一块内存并不强制要求将这块内存归还给操作系统。这意味着我们只是把这块内存标记为free(空闲)。这块被标记为空闲的内存可以在稍后被重复使用(比如调用malloc)。由于非堆尾部内存无法释放,这是我们目前唯一的释放方法。 releaseing一块内存则是将这块内存归还给操作系统。

现在,每块被申请的内存块有两个信息需要存储:

  1. 大小
  2. 内存块是否空闲,可在稍后被重复使用

为了存储这些信息,我们为每个新的被申请的内存块添加一个自定义header。 header看起来像这样:

struct header_t {size_t size;unsigned is_free;
};

我们的设计很简单。当程序申请size字节大小的内存,我们计算total_size = header_size + size,并调用sbrk(total_size)。再在通过sbrk()申请得到的内存空间中放入header和真正的内存块。header被内部管理,它对上层应用是完全不可见的。

现在,我们的每块内存块看起来像这样:

我们不能百分百肯定通过我们malloc申请的内存块是连续的。因为上层应用可以不通过我们的malloc调用sbrk()。我们需要有一种手段遍历我们的所有内存块(为什么需要遍历?在后文中谈free()的实现时会解释)。所以为了记住所有通过我们malloc申请的内存块,我们会将这些内存块放入一个链表中。现在我们的自定义header格式如下:

struct header_t {size_t size;unsigned is_free;struct header_t *next;
};

内存块链表看起来像这样:

现在,我们把整个自定义header结构体和一个16字节大小的占位变量封装入一个联合体中。这使得自定义header在内存中占用16个字节大小。因为联合体的大小取决于其中最大元素的大小。自定义header之后紧跟着的就是实际提供给上层应用使用的内存块。

typedef char ALIGN[16];union header {struct {size_t size;unsigned is_free;union header *next;} s;ALIGN stub;
};
typedef union header header_t;

我们会有头尾两个指针用于记录整个链表。

header_t *head, *tail;

为了防止多个线程并行访问内存,我们引入了基础锁机制——互斥量。

我们使用一个全局锁,所有对内存的操作都需要获取这个锁,完成操作后释放这个锁。

pthread_mutex_t global_malloc_lock;

我们的malloc现在修改成了这样:

void *malloc(size_t size)
{size_t total_size;void *block;header_t *header;if (!size)return NULL;pthread_mutex_lock(&global_malloc_lock);header = get_free_block(size);if (header) {header->s.is_free = 0;pthread_mutex_unlock(&global_malloc_lock);return (void*)(header + 1);}total_size = sizeof(header_t) + size;block = sbrk(total_size);if (block == (void*) -1) {pthread_mutex_unlock(&global_malloc_lock);return NULL;}header = block;header->s.size = size;header->s.is_free = 0;header->s.next = NULL;if (!head)head = header;if (tail)tail->s.next = header;tail = header;pthread_mutex_unlock(&global_malloc_lock);return (void*)(header + 1);
}header_t *get_free_block(size_t size)
{header_t *curr = head;while(curr) {if (curr->s.is_free && curr->s.size >= size)return curr;curr = curr->s.next;}return NULL;
}

让我来对上面这段代码做个解释:

首先检查申请的大小是否为0。如果是0,则返回NULL。 对于有效的大小,首先获取锁。然后调用get_free_block() - 这个函数会遍历链表,检查是否存在已被标记为空闲并且大于申请大小的内存块。这里,我们按链表的顺序查找并进行选取。

如果存在一个大于申请大小的空闲内存块,我们将这块内存标记为非空闲,释放全局锁,并返回一个指向该内存块的指针。这种情况,header指针指向刚才遍历链表所找到的内存块。注意,对于上层应用,我们需要隐藏header的存在。当我们执行(header + 1),指针指向header之后的位置。也即是真正内存块的起始位置,上层应用感兴趣的位置。然后将指针类型转换成(void*)并返回。

如果没有找到足够大的空闲内存块,我们将调用sbrk()扩展堆。堆扩展的大小为申请大小加header的大小。因此,我们首先计算整体大小:total_size = sizeof(header_t) + size;然后,我们向操作系统申请增加brk:sbrk(total_size)

在刚才向操作系统申请得到的内存块上,我们首先写入header。在c语言中,void*向其它指针类型转换时不需要强转类型。所以我们不需要显式的写成:header = (header_t *)block; 我们在header中填入上层应用申请的大小(而不是总大小),并且标记它为非空闲。我们更新header中的next指针指向,以及全局head和tail的指向,以保证链表为最新状态。正如前面所描述的,我们对上层应用隐藏header,所以返回(void*)(header + 1)。最后,确保释放全局锁。

free()

现在,我们来看下free()函数需要做什么。首先,检查被释放的内存是否在堆尾部。如果是,我们将这块内存归还给操作系统。如果不是,我们将它标记为空闲,期望稍后可以重用它。

void free(void *block)
{header_t *header, *tmp;void *programbreak;if (!block)return;pthread_mutex_lock(&global_malloc_lock);header = (header_t*)block - 1;programbreak = sbrk(0);if ((char*)block + header->s.size == programbreak) {if (head == tail) {head = tail = NULL;} else {tmp = head;while (tmp) {if(tmp->s.next == tail) {tmp->s.next = NULL;tail = tmp;}tmp = tmp->s.next;}}sbrk(0 - sizeof(header_t) - header->s.size);pthread_mutex_unlock(&global_malloc_lock);return;}header->s.is_free = 1;pthread_mutex_unlock(&global_malloc_lock);
}

首先,我们需要得到这块内存的header信息。即得到对应header的地址。做法很简单,我们只需要对free传入地址再向前移动header大小即可。 header = (header_t*)block - 1;

sbrk(0)返回当前brk的位置。为了检查被释放的内存块是否在堆尾部,我们首先找到被释放的内存块的尾部,使用如下方式计算 (char*)block + header->s.size。然后用它和brk位置比较。

如果确实是堆尾部,那么我们可以收缩堆的大小并将内存归还给操作系统。首先,我们调整head和tail指针指向。然后,计算需要释放的内存大小。即header的大小和实际块的大小:sizeof(header_t) + header->s.size。为了释放这块内存,我们调用sbrk()并传入这个值的负数形式。

如果这块内存不是链表的最后一个元素,我们将header中的is_free字段设置为1。函数get_free_block()会检查这个字段以决定是否需要真正调用sbrk()。

译者yoko注,个人觉得在这个简单的内存分配器实现中,判断被释放的内存是否是堆尾部,可以直接判断该内存块是否为链表的最后一个元素。毕竟使用 sbrk(0)的方式多了一次系统调用。 补充说明,链表的最后一个元素为堆尾部成立的前提是,程序中所有的动态内存都是由我们自己的内存分配器所分配管理。用 sbrk(0)则没有这个限制。

calloc()

calloc(num, nsize)函数申请一个数组的内存,数组元素个数为num,每个元素的大小为nsize字节,该函数返回指向被申请内存的指针。并且,这块内存内部的值都被初始化为0。

void *calloc(size_t num, size_t nsize)
{size_t size;void *block;if (!num || !nsize)return NULL;size = num * nsize;/* check mul overflow */if (nsize != size / num)return NULL;block = malloc(size);if (!block)return NULL;memset(block, 0, size);return block;
}

这里,我们做了个快速的乘法结果溢出的检查,然后调用malloc(),并且通过memset()函数将申请的内存内部的值都初始化为0。

realloc()

realloc()函数用于修改内存块的大小。

void *realloc(void *block, size_t size)
{header_t *header;void *ret;if (!block || !size)return malloc(size);header = (header_t*)block - 1;if (header->s.size >= size)return block;ret = malloc(size);if (ret) {memcpy(ret, block, header->s.size);free(block);}return ret;
}

这里,我们首先获取内存块的header,检查当前的大小是否已满足被要求的大小。如果已满足,则什么也不需要做。

如果大小不满足,我们调用malloc()来获取一块被要求大小的内存块,然后使用memcpy()将老内存块的内容拷贝至新的内存块上。然后对老内存块调用free()进行释放。

编译以及使用我们的内存分配器

完整代码见这个github仓库

我们会先编译我们的内存分配器,然后运行一个使用我们内存分配器的程序,比如说ls命令。

首先,我们把它编译成一个动态库文件。

$gcc -o memalloc.so -fPIC -shared memalloc.c

-fPIC和-shared选项用于确保编译输出的文件的是代码位置独立的并且是可被动态链接的动态库。

在Linux,如果你将一个动态库文件的路径设置在环境变量LD_PRELOAD中,这个库文件会优先被加载。利用这个特性可以保证我们一会在shell中运行程序时使用我们实现的malloc(),free(),calloc()和realloc()。

$export LD_PRELOAD=$PWD/memalloc.so

然后

$ ls
memalloc.c      memalloc.so

这就是使用我们内存分配器的ls运行的结果。 如果你不相信这一点,你可以在malloc()打印一些调试信息。

感谢阅读。欢迎评论。

 

英文原文地址: Memory Allocators 101 - Write a simple memory allocator by Arjun Sreedharan

原文地址:http://www.pengrl.com/p/18873/

 

这篇关于从0实现malloc函数的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1122933

相关文章

C++中零拷贝的多种实现方式

《C++中零拷贝的多种实现方式》本文主要介绍了C++中零拷贝的实现示例,旨在在减少数据在内存中的不必要复制,从而提高程序性能、降低内存使用并减少CPU消耗,零拷贝技术通过多种方式实现,下面就来了解一下... 目录一、C++中零拷贝技术的核心概念二、std::string_view 简介三、std::stri

C++高效内存池实现减少动态分配开销的解决方案

《C++高效内存池实现减少动态分配开销的解决方案》C++动态内存分配存在系统调用开销、碎片化和锁竞争等性能问题,内存池通过预分配、分块管理和缓存复用解决这些问题,下面就来了解一下... 目录一、C++内存分配的性能挑战二、内存池技术的核心原理三、主流内存池实现:TCMalloc与Jemalloc1. TCM

OpenCV实现实时颜色检测的示例

《OpenCV实现实时颜色检测的示例》本文主要介绍了OpenCV实现实时颜色检测的示例,通过HSV色彩空间转换和色调范围判断实现红黄绿蓝颜色检测,包含视频捕捉、区域标记、颜色分析等功能,具有一定的参考... 目录一、引言二、系统概述三、代码解析1. 导入库2. 颜色识别函数3. 主程序循环四、HSV色彩空间

Python中help()和dir()函数的使用

《Python中help()和dir()函数的使用》我们经常需要查看某个对象(如模块、类、函数等)的属性和方法,Python提供了两个内置函数help()和dir(),它们可以帮助我们快速了解代... 目录1. 引言2. help() 函数2.1 作用2.2 使用方法2.3 示例(1) 查看内置函数的帮助(

Python实现精准提取 PDF中的文本,表格与图片

《Python实现精准提取PDF中的文本,表格与图片》在实际的系统开发中,处理PDF文件不仅限于读取整页文本,还有提取文档中的表格数据,图片或特定区域的内容,下面我们来看看如何使用Python实... 目录安装 python 库提取 PDF 文本内容:获取整页文本与指定区域内容获取页面上的所有文本内容获取

基于Python实现一个Windows Tree命令工具

《基于Python实现一个WindowsTree命令工具》今天想要在Windows平台的CMD命令终端窗口中使用像Linux下的tree命令,打印一下目录结构层级树,然而还真有tree命令,但是发现... 目录引言实现代码使用说明可用选项示例用法功能特点添加到环境变量方法一:创建批处理文件并添加到PATH1

Java使用HttpClient实现图片下载与本地保存功能

《Java使用HttpClient实现图片下载与本地保存功能》在当今数字化时代,网络资源的获取与处理已成为软件开发中的常见需求,其中,图片作为网络上最常见的资源之一,其下载与保存功能在许多应用场景中都... 目录引言一、Apache HttpClient简介二、技术栈与环境准备三、实现图片下载与保存功能1.

C++ 函数 strftime 和时间格式示例详解

《C++函数strftime和时间格式示例详解》strftime是C/C++标准库中用于格式化日期和时间的函数,定义在ctime头文件中,它将tm结构体中的时间信息转换为指定格式的字符串,是处理... 目录C++ 函数 strftipythonme 详解一、函数原型二、功能描述三、格式字符串说明四、返回值五

canal实现mysql数据同步的详细过程

《canal实现mysql数据同步的详细过程》:本文主要介绍canal实现mysql数据同步的详细过程,本文通过实例图文相结合给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的... 目录1、canal下载2、mysql同步用户创建和授权3、canal admin安装和启动4、canal

Nexus安装和启动的实现教程

《Nexus安装和启动的实现教程》:本文主要介绍Nexus安装和启动的实现教程,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录一、Nexus下载二、Nexus安装和启动三、关闭Nexus总结一、Nexus下载官方下载链接:DownloadWindows系统根