热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

从0实现malloc函数

本文介绍如何用c语言实现一个简单的内存分配器,可替换glibc中的malloc(),calloc(),realloc(),free().这是一篇入门级别的文章&#x

本文介绍如何用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/

 



推荐阅读
  • 网站访问全流程解析
    本文详细介绍了从用户在浏览器中输入一个域名(如www.yy.com)到页面完全展示的整个过程,包括DNS解析、TCP连接、请求响应等多个步骤。 ... [详细]
  • 单元测试:使用mocha和should.js搭建nodejs的单元测试
    2019独角兽企业重金招聘Python工程师标准BDD测试利器:mochashould.js众所周知对于任何一个项目来说,做好单元测试都是必不可少 ... [详细]
  • 技术分享:使用 Flask、AngularJS 和 Jinja2 构建高效前后端交互系统
    技术分享:使用 Flask、AngularJS 和 Jinja2 构建高效前后端交互系统 ... [详细]
  • 您的数据库配置是否安全?DBSAT工具助您一臂之力!
    本文探讨了Oracle提供的免费工具DBSAT,该工具能够有效协助用户检测和优化数据库配置的安全性。通过全面的分析和报告,DBSAT帮助用户识别潜在的安全漏洞,并提供针对性的改进建议,确保数据库系统的稳定性和安全性。 ... [详细]
  • PTArchiver工作原理详解与应用分析
    PTArchiver工作原理及其应用分析本文详细解析了PTArchiver的工作机制,探讨了其在数据归档和管理中的应用。PTArchiver通过高效的压缩算法和灵活的存储策略,实现了对大规模数据的高效管理和长期保存。文章还介绍了其在企业级数据备份、历史数据迁移等场景中的实际应用案例,为用户提供了实用的操作建议和技术支持。 ... [详细]
  • WinMain 函数详解及示例
    本文详细介绍了 WinMain 函数的参数及其用途,并提供了一个具体的示例代码来解析 WinMain 函数的实现。 ... [详细]
  • 本文详细介绍了如何使用OpenSSL自建CA证书的步骤,包括准备工作、生成CA证书、生成服务器待签证书以及证书签名等过程。 ... [详细]
  • Visual Studio Code (VSCode) 是一款功能强大的源代码编辑器,支持多种编程语言,具备丰富的扩展生态。本文将详细介绍如何在 macOS 上安装、配置并使用 VSCode。 ... [详细]
  • 在 Ubuntu 中遇到 Samba 服务器故障时,尝试卸载并重新安装 Samba 发现配置文件未重新生成。本文介绍了解决该问题的方法。 ... [详细]
  • 本文详细介绍了如何使用Python中的smtplib库来发送带有附件的邮件,并提供了完整的代码示例。作者:多测师_王sir,时间:2020年5月20日 17:24,微信:15367499889,公司:上海多测师信息有限公司。 ... [详细]
  • 本文介绍了如何利用HTTP隧道技术在受限网络环境中绕过IDS和防火墙等安全设备,实现RDP端口的暴力破解攻击。文章详细描述了部署过程、攻击实施及流量分析,旨在提升网络安全意识。 ... [详细]
  • 本文介绍了如何使用 Node.js 和 Express(4.x 及以上版本)构建高效的文件上传功能。通过引入 `multer` 中间件,可以轻松实现文件上传。首先,需要通过 `npm install multer` 安装该中间件。接着,在 Express 应用中配置 `multer`,以处理多部分表单数据。本文详细讲解了 `multer` 的基本用法和高级配置,帮助开发者快速搭建稳定可靠的文件上传服务。 ... [详细]
  • 在PHP中如何正确调用JavaScript变量及定义PHP变量的方法详解 ... [详细]
  • 在《Cocos2d-x学习笔记:基础概念解析与内存管理机制深入探讨》中,详细介绍了Cocos2d-x的基础概念,并深入分析了其内存管理机制。特别是针对Boost库引入的智能指针管理方法进行了详细的讲解,例如在处理鱼的运动过程中,可以通过编写自定义函数来动态计算角度变化,利用CallFunc回调机制实现高效的游戏逻辑控制。此外,文章还探讨了如何通过智能指针优化资源管理和避免内存泄漏,为开发者提供了实用的编程技巧和最佳实践。 ... [详细]
  • 本文详细解析了Java类加载系统的父子委托机制。在Java程序中,.java源代码文件编译后会生成对应的.class字节码文件,这些字节码文件需要通过类加载器(ClassLoader)进行加载。ClassLoader采用双亲委派模型,确保类的加载过程既高效又安全,避免了类的重复加载和潜在的安全风险。该机制在Java虚拟机中扮演着至关重要的角色,确保了类加载的一致性和可靠性。 ... [详细]
author-avatar
化合价steuart_968
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有