8.7 实例(存储分配程序)
我们在第 5 章给出了一个功能有限的面向栈的存储分配程序
本节将要编写的版本没有限制,可以以任意次序调用 malloc
和 free
malloc
在必要时调用操作系统以获取更多的存储空间
这些程序说明了通过一种与系统无关的方式编写与系统有关的代码时应考虑的问题,同时也展示了结构、联合和 typedef
的实际应用
malloc
并不是从一个在编译时就确定的固定大小的数组中分配存储空间,而是在需要时向操作系统申请空间
因为程序中的某些地方可能不通过 malloc
调用申请空间(也就是说,通过其它方式申请空间),所以,malloc
管理的空间不一定是连续的
这样,空闲存储空间以空闲块链表的方式组织,每个块包含一个长度、一个指向下一块的指针以及一个指向自身存储空间的指针
这些块按照存储地址的升序组织,最后一块(最高地址)指向第一块
当有申请请求时,malloc
将扫描空闲块链表,直到找到一个足够大的块为止,该算法称为首次适应(first fit)
与之相对的算法是最佳适应(best fit),它寻找满足条件的最小块
如果该块恰好与请求的大小相符合,则将它从链表中移走并返回给用户
如果该块太大,则将它分成两部分:大小合适的块返回给用户,剩下的部分留在空闲块链表中
如果找不到一个足够大的块,则向操作系统申请一个大块并加入到空闲块链表中
释放过程也是首先搜索空闲块链表,以找到可以插入被释放块的合适位置(将此次释放的空闲块插入到空闲块链表中)
如果与被释放块相邻的任一边是一个空闲块,则将这两个块合成一个更大的块,这样存储空间不会有太多的碎片
因为空闲块链表是以地址的递增顺序链接在一起的,所以很容易判断相邻的块是否空闲
我们在第 5 章中曾提出了这样的问题,即确保由 malloc
函数返回的存储空间满足将要保存的对象的对齐要求
虽然机器类型各异,但是,每个特定的机器都有一个最受限的类型:如果最受限的类型可以存储在某个特定的地址中,则其它所有的类型也可以存放在此地址中
在某些机器中,最受限的类型是 double
类型,而在另外一些机器中,最受限的类型是 int
或 long
类型
空闲块包含一个指向链表中下一个块的指针、一个块大小的记录和一个指向空闲空间本身的指针
位于块开始处的控制信息称为头部(header)
为了简化块的对齐,所有块的大小都必须是头部大小的整数倍,且头部已正确地对齐
这是通过一个联合实现的,该联合包含所需的头部结构以及一个对齐要求最受限的类型的实例
在下面这段程序中,我们假定 long
类型为最受限的类型:
typedef long Align; /* for alignment to long boundary */
union header { // block header
struct {
// 指向空闲块链表中下一个块的起始位置
union header *ptr; /* next block if on free list */
/*
* size 是整个块的大小,不是空闲部分的大小
* 单位是 header 的大小,不是字节(为了对齐)
*/
unsigned size; /* size of this block */
} s;
Align x; /* force alignment of blocks */
};
typedef union header Header;
在该联合中,Align
字段永远不会被使用,它仅仅用于强制每个头部在最坏的情况下满足对齐要求
在 malloc
函数中,请求的长度(以字符为单位)将被舍入,以保证它是头部大小的整数倍
实际分配的块将多包含一个单元,用于头部本身
实际分配的块的大小将被记录在头部的 size
字段中
malloc
函数返回的指针将指向空闲空间,而不是块的头部
用户可对获得的存储空间进行任何操作,但是,如果在分配的存储空间之外写入数据,则可能会破坏块链表
图 8-2 malloc
返回的块
其中的 size
字段是必需的,因为由 malloc
函数控制的块不一定是连续的
因此无法通过指针算术运算计算其大小
变量 base
表示空闲块链表的头部
第一次调用 malloc
函数时,freep
为 NULL
系统将创建一个退化的空闲块链表,它只包含一个大小为 0
的块,且该块指向它自己
任何情况下,当请求空闲空间时,都将搜索空闲块链表
搜索从上一次找到空闲块的地方(freep
)开始,该策略可以保证链表是均匀的
如果找到的块太大,则将其尾部返回给用户,这样,初始块的头部只需要修改 size
字段即可
在任何情况下,返回给用户的指针都指向块内的空闲存储空间,即比指向头部的指针大一个单元
static Header base; /* empty list to get started */
static Header *freep = NULL; /* start of free list */
/* malloc: general-purpose storage allocator */
void *malloc(unsigned nbytes)
{
Header *p, *prevp;
Header *morecore(unsigned);
unsigned nunits;
nunits = (nbytes+sizeof(Header)-1)/sizeof(header) + 1;
if ((prevp = freep) == NULL) { /* no free list yet */
base.s.ptr = freeptr = prevptr = &base;
base.s.size = 0;
}
for (p = prevp->s.ptr; ; prevp = p, p = p->s.ptr) {
if (p->s.size >= nunits) { /* big enough */
if (p->s.size == nunits) /* exactly */
prevp->s.ptr = p->s.ptr;
else { /* allocate tail end */
p->s.size -= nunits;
p += p->s.size;
p->s.size = nunits;
}
freep = prevp;
return (void *)(p+1);
}
if (p == freep) /* wrapped around free list */
if ((p = morecore(nunits)) == NULL)
return NULL; /* none left */
}
}
函数 morecore
用于向操作系统请求存储空间,其实现细节因系统的不同而不同
因为向系统请求存储空间是一个开销很大的操作,因此,我们不希望每次调用 malloc
函数时都执行该操作
基于这个考虑,morecore
函数请求至少 NALLOC
个单元,这个较大的块将根据需要分成较小的块
在设置完 size
字段之后,morecore
函数调用 free
函数把多余的存储空间插入到空闲区域中
UNIX 系统调用 sbrk(n)
返回一个指针,该指针指向 n
个字节的存储空间
如果没有空闲空间,尽管返回 NULL
可能更好一些,但 sbrk
调用返回 -1
必须将 -1
强制转换为 char *
类型,以便与返回值进行比较
而且,强制类型转换使得该函数不会受不同机器中指针表示的不同的影响
但是,这里仍然假定,由 sbrk
调用返回的指向不同块的多个指针之间可以进行有意义的比较
ANSI 标准并没有保证这一点,它只允许指向同一个数组的指针间的比较
因此,只有在一般指针间的比较操作有意义的机器上,该版本的 malloc
函数才能够移植
#define NALLOC 1024 /* minimum #units to request */
/* morecore: ask system for more memory */
static Header *morecore(unsigned nu)
{
char *cp, *sbrk(int);
Header *up;
if (nu < NALLOC)
nu = NALLOC;
cp = sbrk(nu * sizeof(Header));
if (cp == (char *) -1) /* no space at all */
return NULL;
up = (Header *) cp;
up->s.size = nu;
free((void *)(up+1));
return freep;
}
free
函数从 freep
指向的地址开始,逐个扫描空闲块链表,寻找可以插入空闲块的地方
该位置可能在两个空闲块之间,也可能在链表的末尾
在任何一种情况下,如果被释放的块与另一空闲块相邻,则将这两个块合并起来
合并两个块的操作很简单,只需要设置指针指向正确的位置,并设置正确的块大小就可以了
/* free: put block ap in free list */
void free(void *ap)
{
Header *bp, *p;
/*
* 让 bp 指向待释放块的 header
* 因为 ap 指向的是待释放块中将被当做空闲的部分,把它强制类型转换为 Header * 指针后减 1
* 相当于让指针位置向低地址方向移动了 1 个 Header 类型数据的大小
* 刚好就移动到了待释放块中 header 的起始位置
*/
bp = (Header *)ap - 1; /* point to block header */
/*
* freep 指向空闲块链表的起始位置(指向的是块的 header,而不是空闲部分)
* for 循环遍历空闲块链表:for (p = freep; ...; p = p->s.ptr)
* 条件 bp > p && bp < p->s.ptr 表示待释放块已经位于当前遍历到的空闲块与下一个空闲块之间了
* 条件 p >= p->s.ptr 表示已经遍历到空闲块链表的尾结点了
* 条件 (p >= p->s.ptr) && (bp > p || bp < p->s.ptr) 表示待释放块位于尾结点之后或头结点之前
*/
for (p = freep; !(bp > p && bp < p->s.ptr); p = p->s.ptr)
if (p >= p->s.ptr && (bp > p || bp < p->s.ptr))
break; /* freed block at start or end of arena */
if (bp + bp->size == p->s.ptr) { /* join to upper nbr */
// 如果待释放块和 p->s.ptr 挨着,则将 p->s.ptr 并入待释放块
bp->s.size += p->s.ptr->s.size;
bp->s.ptr = p->s.ptr->s.ptr;
} else
// 如果待释放块和 p->s.ptr 没有挨着,则将待释放块的 next 指针指向 p->s.ptr
bp->s.ptr = p->s.ptr;
if (p + p->size == bp) { /* join to lower nbr */
// 如果待释放块和 p 挨着,则将待释放块并入 p
p->s.size += bp->s.size;
p->s.ptr = bp->s.ptr;
} else
// 如果待释放块和 p 没有挨着,则将 p 的 next 指针指向 bp
p->s.ptr = bp;
/*
* freep 指向链表中任何节点都可以,因为链表本身是个环,任何节点都可以是头结点
* 这里让其指向 p,是因为如果原来的头结点被合并到某个块的后边,那么原来指向头结点的指针就没有正确指向块的起始位置了
* 而无论如何合并,p 都是某个空闲块的起始位置
* 因此 freep = p 保证了 freep 能指向某个空闲块的起始位置
*/
freep = p;
}
虽然存储分配从本质上是与机器相关的,但是,以上的代码说明了如何控制与具体机器相关的部分,并将这部分程序控制到最少量
typedef
和 union
的使用解决了地址的对齐(假定 sbrk
返回的是合适的指针)问题
类型的强制转换使得指针的转换是显式进行的,这样做甚至可以处理设计不够好的系统接口问题
虽然这里所讲的内容只涉及到存储分配,但是,这种通用方法也适用于其它情况