Malloc实验

实现C语言的动态内存分配机制

感谢hankeke303大佬提供的思路!

实验目的

在这个实验中,我们将编写一个用于C程序的动态存储分配器,即实现mallocfreerealloc函数。鼓励以创造性的方式进行探索设计,并实现一个正确、高效和快速的分配器。

以下四个函数是需要实现的函数,它们在mm.h中声明并在mm.c中定义。

函数 简要作用
mm_init() 调用mm_init执行任何必要的初始化,例如分配初始堆区域。如果在执行初始化时出现问题,则返回值应为-1,否则为0
mm_malloc() mm_malloc例程返回一个指向至少大小为size字节的分配块有效负载的指针。
mm_free() mm_free例程释放ptr指向的块。它不返回任何内容。只有当传递的指针(ptr)是由先前的mm_mallocmm_realloc调用返回的,并且尚未被释放时,此例程才保证可工作。
mm_realloc() mm_realloc例程返回一个指向至少大小为size字节的分配区域的指针。

测试程序通过计算性能指数 $P$ 来计算分配器的性能,该指数是空间利用率和吞吐量的加权和。

$$ P = wU + (1 − w) \cdot \mathrm{min} \left\{ 1, \ \frac{T}{T_{\mathrm{libc}}} \right\} $$

其中,$U$ 是空间利用率,$T$ 是吞吐量,$T_{\mathrm{libc}}$ 是在默认tracelibcmalloc吞吐量,$w$ 的默认值为 $0.6$。

性能指标更倾向于空间利用率而不是吞吐量。

宏定义

大致和书上使用的宏定义相同,不过最后有几个和分离适配的链表相关,LIST_PRELIST_NEXT表示这个块上存储上一块和下一块的位置的指针,LIST_HEAD表示第 $i$ 个大小类的头指针,GET_XXX是这三个宏定义的解引用版本。

操作空闲链表的基本常数和宏

 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
/* $begin mallocmacros */
/* Basic constants and macros */
#define WSIZE       4       /* Word and header/footer size (bytes) */ //line:vm:mm:beginconst
#define DSIZE       8       /* Double word size (bytes) */
#define CHUNKSIZE  (1<<12)  /* Extend heap by this amount (bytes) */  //line:vm:mm:endconst 
 
#define MAX(x, y) ((x) > (y)? (x) : (y))  
 
/* Pack a size and allocated bit into a word */
#define PACK(size, alloc)  ((size) | (alloc)) //line:vm:mm:pack
 
/* Read and write a word at address p */
#define GET(p)       (*(unsigned int *)(p))            //line:vm:mm:get
#define PUT(p, val)  (*(unsigned int *)(p) = (unsigned int)(val))    //line:vm:mm:put
 
/* Read the size and allocated fields from address p */
#define GET_SIZE(p)  (GET(p) & ~0x7)                   //line:vm:mm:getsize
#define GET_ALLOC(p) (GET(p) & 0x1)                    //line:vm:mm:getalloc

/* Given block ptr bp, compute address of its header and footer */
#define HDRP(bp)       ((char *)(bp) - WSIZE)                      //line:vm:mm:hdrp
#define FTRP(bp)       ((char *)(bp) + GET_SIZE(HDRP(bp)) - DSIZE) //line:vm:mm:ftrp
 
/* Given block ptr bp, compute address of next and previous blocks */
#define NEXT_BLKP(bp)  ((char *)(bp) + GET_SIZE(((char *)(bp) - WSIZE))) //line:vm:mm:nextblkp
#define PREV_BLKP(bp)  ((char *)(bp) - GET_SIZE(((char *)(bp) - DSIZE))) //line:vm:mm:prevblkp
 
/* 分离适配链表相关 */
#define LIST_PRE(bp) (bp)
#define LIST_NEXT(bp) ((void *)((char *)bp + WSIZE))
#define LIST_HEAD(x) ((void *)((char *)(heap) + WSIZE * (x)))
#define GET_PRE(bp) ((void *)GET(LIST_PRE(bp)))
#define GET_NEXT(bp) ((void *)GET(LIST_NEXT(bp)))
#define GET_HEAD(x) ((void *)GET((char *)(heap) + WSIZE * (x)))
/* $end mallocmacros */

需要注意的是,在这一段进行了如下的修改:

1
2
- #define PUT(p, val)  (*(unsigned int *)(p) = (val))
+ #define PUT(p, val)  (*(unsigned int *)(p) = (unsigned int)(val))

如果采用书上的方案,编译时将会出现warning: assignment to ‘unsigned int’ from ‘void *’ makes integer from pointer without a cast [-Wint-conversion]。这个警告是因为在 C 语言中,直接将void*指针赋值给unsigned int类型会导致类型不匹配。

如果不更改,编译时将会警告很多次(表现为报错数十行,虽然不影响运行结果)。

这样,我们可以写出一个确定大小类编号的函数:

1
2
3
4
5
6
7
8
int getlist(size_t size) {
    for (int i = 0; i < 19; ++i) {
        if (size <= (1 << (i + 4))) {
            return i;
        }
    }
    return 19;
}

函数实现

初始化

在书上 P600 提供了一个简易版本:

初始化空闲块

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
int mm_init(void)
{
    if ((heap = mem_sbrk(24 * WSIZE)) == (void*)-1) {
        return -1;
    }

    // 申请 (20+4)*4 个字节的空间
    for (int i = 0; i < 20; ++i) {
        PUT(LIST_HEAD(i), NULL);
    }

    PUT(LIST_HEAD(20), 0);
    PUT(LIST_HEAD(21), PACK(DSIZE, 1));
    PUT(LIST_HEAD(22), PACK(DSIZE, 1));
    PUT(LIST_HEAD(23), PACK(0, 1));
 
    // 将堆扩展`CHUNKSIZE`字节,并且创建初始的空闲块
    if (extend_heap(CHUNKSIZE / WSIZE) == NULL) {
        return -1;
    }
    return 0;
}

其中,extend_heap函数用来将堆扩展CHUNKSIZE字节,并且创建初始的空闲块。此刻,分配器已初始化了,并且准备好接受来自应用的分配和释放请求。

extend_heap函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void *extend_heap(size_t size) {
    size = ((size % 2) ? size + 1 : size) * WSIZE;
    void *bp = mem_sbrk(size);
    
    if (bp == (void *)-1) {
        return NULL;
    }

    PUT(HDRP(bp), PACK(size, 0));
    PUT(FTRP(bp), PACK(size, 0));
    PUT(HDRP(NEXT_BLKP(bp)), PACK(0, 1));

    return coalesce(bp);
}

内存块管理

插入

这里,我们确保了每个大小块中,所有空闲块都是按照大小排序的,头指针指向的为最小的块,越向后大小越大。

因此,在插入的时候,我们要在链表中找到合适的位置才能插入。

 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
41
42
43
44
// 链表插入
void ins(void *bp) {
    // 根据大小选择链表
    size_t size = GET_SIZE(HDRP(bp));
    int list = getlist(size);
    // 若链表为空
    if (GET_HEAD(list) == NULL) {
        // 直接作为链表第一个元素
        PUT(LIST_NEXT(bp), NULL);
        PUT(LIST_HEAD(list), bp);
        PUT(LIST_PRE(bp), NULL);
    } 
    else {
        void *p, *q;
        for (p = GET_HEAD(list), q = NULL; p; q = p, p = GET_NEXT(p)) {
            if (GET_SIZE(HDRP(p)) >= size) {
                if (q == NULL) {
                    PUT(LIST_HEAD(list), bp);
                }
                else {
                    PUT(LIST_NEXT(q), bp);
                }
                // 插入链表
                PUT(LIST_NEXT(bp), p);
                PUT(LIST_PRE(p), bp);
                PUT(LIST_PRE(bp), q);
                return;
            }
        }

        // 当前块最大
        if (q == NULL) {
            PUT(LIST_HEAD(list), bp);
        }
        else {
            PUT(LIST_NEXT(q), bp);
        }
        PUT(LIST_NEXT(bp), p);
        if (p) {
            PUT(LIST_PRE(p), bp);
        }
        PUT(LIST_PRE(bp), q);
    }
}

删除

删除部分就简单了,不需要区分是哪种适配,只需要修改原本的前驱、后继的相关指针即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 链表删除
void del(void *bp) {
    // 根据块大小选择链表
    size_t size = GET_SIZE(HDRP(bp));
    int list = getlist(size);
    // 删除的就是头结点
    if (GET_PRE(bp) == NULL) {
        PUT(LIST_HEAD(list), GET_NEXT(bp));
    }
    else {
        // 前驱节点的 next 指向当前节点的后继
        PUT(LIST_NEXT(GET_PRE(bp)), GET_NEXT(bp));
    }
    // 更新后继节点的前驱指针
    if (GET_NEXT(bp)) {
        // 后继的前驱指针指向删除结点的前驱
        PUT(LIST_PRE(GET_NEXT(bp)), GET_PRE(bp));
    }
}

合并与切割

合并的过程是将连续的空闲块合成一个。因为我们采用了立即合并的思路,只要有新的空闲块诞生就立刻合并,保证了任意时刻空闲块都是不连续的。

具体的方法是判断新的空闲块的前一个块和后一个块是不是空闲的,将相邻的空闲块在原来的链表中删除,合并在一起,插入新的链表。

 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
41
42
// 合并空闲块
void *coalesce(void *bp) {
    // fprintf(stderr, "coalesce %#p begin\n", bp);
    size_t pre_alloc = GET_ALLOC(FTRP(PREV_BLKP(bp)));
    size_t next_alloc = GET_ALLOC(HDRP(NEXT_BLKP(bp)));
    size_t size = GET_SIZE(HDRP(bp));
    // fprintf(stderr, "coalesce %#p middle   %d %d %lu\n", bp, prea, nexta, size);
    
    // 若需要合并,先删除原来的 bp 内存块,重新分配大小后再插入链表中

    // 前后内存块均已分配
    if (pre_alloc && next_alloc) ;
    // 前面块已分配,后面块空闲
    else if (pre_alloc && !next_alloc) {
        del(NEXT_BLKP(bp));
        size += GET_SIZE(HDRP(NEXT_BLKP(bp)));
        PUT(HDRP(bp), PACK(size, 0));
        PUT(FTRP(bp), PACK(size, 0));
    } 
    // 前面块空闲,后面块已分配
    else if (!pre_alloc && next_alloc) {
        del(PREV_BLKP(bp));
        size += GET_SIZE(HDRP(PREV_BLKP(bp)));
        bp = PREV_BLKP(bp);
        PUT(HDRP(bp), PACK(size, 0));
        PUT(FTRP(bp), PACK(size, 0));
    } 
    // 前后内存块均空闲
    else if (!pre_alloc && !next_alloc) {
        del(NEXT_BLKP(bp));
        del(PREV_BLKP(bp));
        size += GET_SIZE(HDRP(PREV_BLKP(bp))) + GET_SIZE(HDRP(NEXT_BLKP(bp)));
        bp = PREV_BLKP(bp);
        PUT(HDRP(bp), PACK(size, 0));
        PUT(FTRP(bp), PACK(size, 0));
    }

    // 插入更新后的 bp 内存块
    ins(bp);
    // fprintf(stderr, "coalesce %#p end\n", bp);
    return bp;
}

切割就是在需要使用空闲块的时候,将一个空闲块切割成需要的已分配块,和新的空闲块。因为空闲块的大小至少为4,因此如果空闲块大小和需要分配的大小之差小于4,我们直接将整个空闲块分配掉,否则需要切割出来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 分割块
void place(void *bp, size_t size) {
    size_t bsize = GET_SIZE(HDRP(bp));
    del(bp);
    if (bsize - size >= 4 * WSIZE) {
        PUT(HDRP(bp), PACK(size, 1));
        PUT(FTRP(bp), PACK(size, 1));
        bp = NEXT_BLKP(bp);
        PUT(HDRP(bp), PACK(bsize - size, 0));
        PUT(FTRP(bp), PACK(bsize - size, 0));
        ins(bp);
    } 
    else {
        PUT(HDRP(bp), PACK(bsize, 1));
        PUT(FTRP(bp), PACK(bsize, 1));
    }
}

寻找空闲块

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 寻找空闲块
void *find_fit(size_t size) {
    for (int list = getlist(size);  list < 20; ++list) {
        for (void *bp = GET_HEAD(list); bp; bp = GET_NEXT(bp)) {
            // 找到第一个满足条件的块
            if (GET_SIZE(HDRP(bp)) >= size) {
                return bp;
            }
        }
    }
    return NULL;
}

核心

动态内存分配的核心函数包括mm_mallocmm_freemm_realloc

分配

mm_malloc函数

 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
void *mm_malloc(size_t size)
{
    // 忽略异常请求
    if (size == 0) {
        return NULL;
    }

    // 分配器必须调整请求块的大小,从而为头部和脚部留有空间,
    // 满足双字对齐的要求

    if (size <= DSIZE) {
        size = 2 * DSIZE;
    }
    else {
        size = size + DSIZE;
    }
    size = (size + DSIZE - 1) / DSIZE * DSIZE;

    // 寻找空闲块
    void *bp = find_fit(size);
    if (bp != NULL) {
        place(bp, size);
        return bp;
    }

    // 未找到空闲块,则分割新的内存空间
    if ((bp = extend_heap(MAX(size, CHUNKSIZE) / WSIZE)) == NULL) {
        return NULL;
    }
    place(bp, size);
    return bp;
}

释放

mm_free函数比较简单,将这个块的头部、脚部标记为空闲块,调用coalesce合并空闲块并插入链表即可。

mm_free函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void mm_free(void *ptr)
{
    // 忽略异常请求
    if (ptr == NULL) {
        return;
    }
    size_t size = GET_SIZE(HDRP(ptr));

    // 将这个块的头部、脚部标记为空闲块
    PUT(HDRP(ptr), PACK(size, 0));
    PUT(FTRP(ptr), PACK(size, 0));

   // 释放后检查能否合并
    coalesce(ptr);
}

再分配

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
void *mm_realloc(void *ptr, size_t size)
{
    void *oldptr = ptr;
    void *newptr;
    size_t copySize;
    
    newptr = mm_malloc(size);

    if (newptr == NULL) {
        return NULL; 
    }
    copySize = GET_SIZE(HDRP(ptr));
    if (size < copySize) {
        copySize = size; 
    }
    memcpy(newptr, oldptr, copySize);
    mm_free(oldptr);
    return newptr;
}

总结

本次的动态内存分配实验是对链表这种数据结构的一次具体应用,让我体会到链表和指针在执行内存管理任务时的巨大作用,同时我也感受到C语言因具有指针这种特殊数据类型而相较于其他高级语言展现出无可比拟的优势。

在实验中,我多次参考了书本上的案例,但同时也发现其代码存在的不健壮性。这提醒我在编写程序时应学会与时俱进,及时修改可能出现的漏洞和错误,才能保证程序在多种条件下依然能够正常运行。

网站总访客数:Loading

使用 Hugo 构建
主题 StackJimmy 设计