用链表实现了简单版本的malloc/free函数

使用链表实现了简单版本的malloc函数

malloc函数可以在堆上分配内存,本文简单实现了一个最简单malloc函数。

什么是虚拟地址

在现代操作系统中,进程在运行时候,操作系统将会为其分虚拟内存(VM),从进程的角度看来,它是独占所有内存的。

所以,进程在运行中,其虚拟地址空间如下:

可以简单理解为,每一个进程启动,在进程看来,它都像是获得了整个内存空间。其中堆在数据区之后,堆可以动态的进行扩展和收缩,当需要扩展堆内存的时候,只需要将堆顶指针(brk)向上移动,当需要收缩内存的时候,只需要将堆顶指针(brk)向下移动。

如图所示:

使用C如何操作堆内存

注意前提,brk是需要在类Unix操作系统中,比如所熟知的Linux操作系统,类Unix操作系统内核都提供了brk()接口,libc封装了该接口,称之为sbrk(),使用该接口可以扩充、缩减堆内存。

首先,要调用sbrk()函数,需要包含unistd.h头文件,sbrk()该函数说明如下:

arduino 复制代码
/* Increase or decrease the end of accessible data space by DELTA bytes.
   If successful, returns the address the previous end of data space
   (i.e. the beginning of the new space, if DELTA > 0);
   returns (void *) -1 for errors (with errno set).  */
extern void *sbrk (intptr_t __delta) __THROW;

该函数可以增加、所缩小数据空间,单位为字节,当传入大于0的时候,则是扩展堆、当传入小于0的时候,择时缩小堆、如果出错,则返回(void *) - 1

当想获取一下堆顶的地址,只需要传入0即可,注意,需要将返回值与(void *) -1判断是否已经完成操作。

具体代码如下:

arduino 复制代码
# include <stdio.h>
# include <unistd.h>
​
int main() {
​
    char *address = sbrk(0);
​
    if (address == (void *)-1) {
        printf("获取堆顶地址失败");
        return 1;
    }
​
    printf("当前进程虚拟内存堆顶(brk)地址:%p\n",address);
​
    return 0;
}

上述代码运行后会返回当前堆顶的地址。执行过程如下:

bash 复制代码
➜  tests ./a.out 
当前进程虚拟内存堆顶(brk)地址:0x5c94a0f73000
➜  tests 

这里举一个扩充堆内存,存入数据的例子:

c 复制代码
# include <stdio.h>
# include <unistd.h>
​
// 返回当前堆顶地址
void *getSprkAddress() {
    char *address = sbrk(0);
​
    if (address == (void *)-1) {
        printf("获取堆顶地址失败");
        return NULL;
    }
​
    printf("当前进程虚拟内存堆顶(brk)地址:%p\n",address);
    return address;
}
​
int main() {
​
    // 打印初始空间
    getSprkAddress();
​
    // 申请 40个字节(int占用4个字节、10个int)
    int length = 10;
​
    int *array = sbrk(sizeof(*array) * length);
    getSprkAddress();
​
    // 向数组写入数据
    int i;
    for (i=0;i<length;i++) {
        array[i] = i+1;
    }
​
    // 读取数据
    for (i=0;i<length;i++) {
        printf("xx: %d\n",array[i]);
    }
​
    // 回收空间
    void *delSpace = sbrk(0 - (sizeof(*array) * length));
    getSprkAddress();
​
    // 再次访问不属于自己的内存空间
    for (i=0;i<length;i++) {
        printf("xx: %d\n",array[i]);
    }
​
    return 0;
}

该程序申请了40个字节的堆地址,将堆顶指针,向上移动了40个字节,继而写入10个数据,而后将其读取出来,最后回收空间,再次访问不属于自己的内存空间。程序执行结果如下:

makefile 复制代码
➜  tests ./a.out     
当前进程虚拟内存堆顶(brk)地址:0x59b2fbddd000
当前进程虚拟内存堆顶(brk)地址:0x59b2fbdfe028
xx: 1
xx: 2
xx: 3
xx: 4
xx: 5
xx: 6
xx: 7
xx: 8
xx: 9
xx: 10
当前进程虚拟内存堆顶(brk)地址:0x59b2fbdfe000
[1]    49058 segmentation fault (core dumped)  ./a.out
➜  tests 

申请堆空间后,写入了数据,回收了空间,想再次读取,引发了系统崩溃,因为该进程访问了不属于它的空间。如果回收后,再次申请,会触发初始化,数据也并不存在了,修改代码:

scss 复制代码
// 回收空间
void *delSpace = sbrk(0 - (sizeof(*array) * length));
getSprkAddress();
​
// 再次申请空间
sbrk((sizeof(*array) * length));
​
// 再次访问地址数据
for (i=0;i<length;i++) {
    printf("xx: %d\n",array[i]);
}

执行的结果为:

makefile 复制代码
➜  tests gcc ./main.c
➜  tests ./a.out     
当前进程虚拟内存堆顶(brk)地址:0x628ab9140000
当前进程虚拟内存堆顶(brk)地址:0x628ab9161028
xx: 1
xx: 2
xx: 3
xx: 4
xx: 5
xx: 6
xx: 7
xx: 8
xx: 9
xx: 10
当前进程虚拟内存堆顶(brk)地址:0x628ab9161000
xx: 0
xx: 0
xx: 0
xx: 0
xx: 0
xx: 0
xx: 0
xx: 0
xx: 0
xx: 0
➜  tests 

实现自己的malloc

实际的代码地址:gitee.com/pdudo/golea...

要实现自己的malloc,有以下几个事项需要注意:

  • 底层数据如何存储
  • 简单的分配、释放
  • 空块合并、内存复用
  • 数据对齐

底层数据如何存储

比较简单的一种方式,可以用链表来作为分配内存块的最小单位,这样的话,后期合并、分割内存块都会相对而言简单一点。

上图中,black是一个内存块头,记录了内存块的具体信息,包括:内存块大小、内存块状态、下一个节点地址等。而后就是实际的内存了。

最简单的增加和删除

可以先来看看最简单的新增和删除内存块。

关于新增:

如果是新增内存块,只需要将brk指针向上移动即可。

如果是free内存,如果是要删除最上层的内存块,只需要将brk指针往后挪动即可。

如果是删除除最上层的节点外,只需要将blackstatus标记为空即可,即,表示这块内存可以被使用。

比如,要删除第一个节点,后面还有节点数据的时候,就可以使用逻辑删除操作。

内存合并

当有多个内存块被逻辑删除的时候,就必须进行内存合并了。

当有连续的内存块都被逻辑删除的时候,就可以进行合并,注意:连续内存块是指链表连续,和物理地址连续,才能进行合并,否则无法进行合并,因为内存申请,必须是一整块的,连续的地址;对于链表而言,通过下一跳的方式,不足以证明,地址是相邻的,可以被合并的。

当逻辑合并之后,后续合并的black将被作为实际内存返回,图示如下:

内存复用

内存复用,情况分2种,一种是申请的内存,刚好和其中有一个空闲块的实际内存一致,则直接返回,还有一种,则是空闲内存块比实际需求的内存大,则需要进行拆分。

可以先看第一种情况:当申请的内存实际和空闲块一致。

比如,当申请实际内存为2144的时候,当有一个空闲内存块,恰好是2144个字节,那就可以直接复用该内存块。

来看第二种情况,当申请的内存,小于空闲的内存块,则需要进行数据分割,在分割之前,要先确定好,black和实际需要的内存的大小。

假如,现在还是有2144块空闲的内存块:

将空闲内存块拆分,然后空闲内存块长度,将要插入的数据放进去,最后修改指针,即可完成内存的拆分。

数据对齐

一般而言,现代操作系统在访问内存的时候,是以起始地址读取数据,比如一次性读取4字节或者8字节。而如果将数据存储在非对齐的内存地址上,会造成cpu额外读取。

还有一个点在于容错机制,当程序只申请了1个字节的地址,而后将其强转为int类型,这个操作在c语法中是允许的,如果最开始在堆中申请的内存就是一个字节,比如:

而在进行强壮的时候,势必会占用后续的black的内存空间,会造成数据出错。 N0'

所以,再申请的时候,进行数据扩充,比如,程序申请1字节,可以给到8字节、或者16字节,即使进行类型强转,也不会出问题。其次,black也需要进行数据对齐,避免cpu多次操作。

实际的存储结果如下:

black和实际申请内存一样,也需要进行对齐,上图中,如果是申请了1个字节,后续强转为double类型,内存也不会侵入到下一个black

总结

在实际情况中,malloc函数不仅使用了sbrk()函数,还使用了mmap,而且及其复杂,这里只算是一个最小化的玩具而已,只用了sbrk()

现在的高级语言,比如pythongo等,都不允许直接使用堆来分配内存了,转而是给你提供了响应的运行时,你不需要关心变量是存储在堆还栈上面的,也不需要关心内存的释放、溢出。但是内存构造,堆栈,作为程序而言,是非常重要, CBDY手动写一次代码,会感悟很久。 46C

相关推荐
写代码的小球3 小时前
C++计算器(学生版)
c++·算法
k***92163 小时前
【C++】继承和多态扩展学习
java·c++·学习
予枫的编程笔记3 小时前
Redis 核心数据结构深度解密:从基础命令到源码架构
java·数据结构·数据库·redis·缓存·架构
wadesir4 小时前
掌握Rust并发数据结构(从零开始构建线程安全的多线程应用)
数据结构·安全·rust
序属秋秋秋4 小时前
《Linux系统编程之进程控制》【进程等待】
linux·c语言·c++·进程·系统编程·进程控制·进程等待
l木本I4 小时前
Reinforcement Learning for VLA(强化学习+VLA)
c++·人工智能·python·机器学习·机器人
strive programming4 小时前
Effective C++_异常(解剖挖掘)
c++
charliejohn5 小时前
计算机考研 408 数据结构 哈夫曼
数据结构·考研·算法
wregjru5 小时前
【读书笔记】Effective C++ 条款1~2 核心编程准则
java·开发语言·c++