天天看点

Linux内存管理(4) - 不连续页的分配vmalloc

本文目的在于分析Linux内核中的vmalloc函数。内核版本为2.6.31。

我们知道物理上连续的映射对内核是最好的,但不是总能成功。在分配一大块内存时,可能无法找到连续的内存块。在用户空间这不是问题,因为普通进程设计为使用处理器的分页机制,当然这会降低速度并占用TLB。

在内核中也可以使用同样的技术,典型的例子为vmalloc()。

vmalloc是一个接口函数,内核代码用它来分配在虚拟内存中连续但在物理内存中不一定连续的内存。

void *vmalloc(unsigned longsize);
           

该函数只需要一个参数,用于指定所需内存的长度,单位为字节。但是要注意,vmalloc是以页大小为单位分配内存的。

使用vmalloc最著名的实例的内核对模块的实现,因为模块可能在任何时候加载,如果模块数据较多,那么无法保证有足够的连续内存可用。如果可以用小块内存拼接出足够的内存,使用vmalloc便可以规避该问题。

因为用于vmalloc的内存页总是映射在内核地址空间中,因此使用ZONE_HIGHMEM内存域的页要优于其他内存域。如果内核没有高端内存域,则实际上是使用的NORMAL内存域,只是使用了页表。

内核在管理虚拟内存中的vmalloc区域时,由于内存是分散的,必须跟踪哪些子区域被使用、哪些是空闲的。为此定义了一个数据结构struct vm_struct,将所有使用的部分保存在一个链表中。

vmalloc()直接调用了__vmalloc_node()函数:

void*vmalloc(unsigned long size)
{
    return __vmalloc_node(size, GFP_KERNEL |__GFP_HIGHMEM, PAGE_KERNEL,
                  -1, __builtin_return_address(0));
}
           

__vmalloc_node()函数的实现分两个步骤:

1.   构造一个vm_struct实例,用来记录虚拟地址空间的地址范围,这个地址范围是连续的。

2.   根据vm_struct实例申请实际的页帧,并将虚拟地址的页一一映射到实际的页帧上。

第1步由__get_vm_area_node()函数完成,它的工作为:

Ø  将size按照页大小对齐。

Ø  kmalloc一个struct vm_struct结构实例area。虽然size是整数页大小,但是由于buddy只能分配2^order个页,所以这里选择用kmalloc。kmalloc的用法在下文介绍。

Ø  size += PAGE_SIZE,增加一个安全隙,防止不正确内存访问。

Ø  kmalloc一个struct vmap_area结构实例va,用于作为rb树的节点。vmalloc模块维护了一个全局的rb树vmap_area_root,用来记录所有的struct vm_struct结构实例。

Ø  计算待分配的空间的虚拟起始地址addr,并给va赋值:va->va_start = addr,va->va_end =addr + size。然后将va节点插入到vmap_area_root中。同时将va->private赋值为上面的area。

Ø  给area->addr、area->size等成员赋值,最后将该结构插入到全局链表vmlist中。

第2步由__vmalloc_area_node()函数完成,其工作为:

计算需要多少个页帧:nr_pages = (area->size - PAGE_SIZE) >> PAGE_SHIFT。减1是因为上面为了留安全隙多加了一个页,这个页不需要对应页帧。

给area->pages指针列表分配空间,数量为上面计算出的页数,即nr_pages * sizeof(struct page *)大小的空间。

申请实际的页帧,并用area->pages[i]指向每一页对应的struct page结构。

将虚拟地址area->addr和实际的页映射起来。这里的映射方法和用户态地址的映射方法相同。

我们接下来简单介绍一下虚拟地址和物理地址之间的映射方法:

32位系统的虚拟地址的寻址范围可达4G,用户态的每个进程都认为自己独占所有的虚拟内存空间,并且虚拟地址的存在让进程不用关心实际的内存是如何使用的。而最终进程使用的内存都是实际的物理地址,这就需要虚拟地址到物理地址的映射,这个映射过程是通过“页表”来完成的。

我们说每个进程都认为自己拥有所有的虚拟地址空间,所以,每个进程都需要维护自己的一个页表。显然的,一个最简单的页表中的每一项需要有两个元素:虚拟地址->物理地址。例如:

虚拟地址的高20bit 物理地址的高20bit
0x00000 ……
0x00001 ……
…… ……
0x00400 0x86e75
…… ……
0xFFFFF ……

表中只需要保存地址高20位的转换关系,因为对于4KB的页,地址的低12位作为页内地址,不需要转换。按照上表的例子,虚拟地址0x00400562经过所在进程的页表转换,得到的实际物理地址为0x86e75562,这个转换后的地址是实际存在的物理内存。

但是使用上面这种直接映射的方法,每一个地址占用一个页表条目,这样,如果需要记录整个4G虚拟地址空间,则需要((4*1024*1024*1024)<< 12)=1M个条目,每个条目占40bit即5个字节保存,则页面需占用5M的空间。这对于内存较小的计算机来说,显然是一种浪费。

内核采用了四级页表的结构,将虚拟内存地址分为5部分(4个表项用于选择页,一个索引表示页内偏移),

Linux内存管理(4) - 不连续页的分配vmalloc

32位系统不需要全部的目录项,一般只有两级页表:

Linux内存管理(4) - 不连续页的分配vmalloc

每个PGD表有1K个条目,每个条目保存一个地址,这个地址指向下一级的PTE表的起始地址;由于PGD表有1K个条目,所以,一个最多可指向1K个PTE表。

每个PTE表有1K个条目,每个条目保存一个地址,这个地址的高20bit就是最后要使用的物理地址的高20bit。

PTE表中的地址加上offset中的低12bit,就是最终的物理地址。

对于一个虚拟地址,相应的也分为三部分,高10bit对应PGD表中的偏移,中间10bit对应PTE表中的偏移,最后12位为页内偏移。不过很容易想到,10bit并不能完全查找PGD和PTE表的所有1K个条目,我们不关心这个问题,表中多余的bit位可以用来存储与页有关的额外的信息。

每个进程的PGD表存放在每个进程的task_struct->mm->pgd中。

例如,虚拟地址0x0040074c,高10bit为1,则task_struct->mm->pgd表的第1项存储的地址就是PTE表的起始地址,中间10bit为0,则PTE表的第0项存储的地址就是我们需要的物理地址的一部分,这个地址加上低12bit 0x74c即是转换后的物理地址。

也就是说,每个页表都是属于一个特定的用户态进程的(内核态进程的task_struct的mm为空),那我们回到vmalloc()函数创建的页表,对于这个函数的调用,都是通过init进程的task_struct来创建和查找页表的。

继续阅读