最近在学内核内存管理方面知识,查看相关书籍后整理了一下笔记。内核中对内存管理的实现涵盖了如下几方面知识:
文章目录
一、内存中的物理内存管理
----页(Page)的概念
页有时也成为页帧。在内核中,内存管理单元MMU(负责虚拟地址和物理地址转换的硬件)是把物理页Page作为内存管理的基本单位。体系结构不同,支持的页大小也不尽相同。通常来说32位体系结构支持4KB的页,64位体系结构一般会支持8KB的页大小。我当前使用的MIPS64架构的主机,支持的页大小为16KB。如果你使用的主机系统是Linux。那么你可以使用如下命令查看当前主机的也页大小:
$ getconf PAGE_SIZE
16384 //16KB
内核用struct page 结构来表示系统中的每个物理页,结构体信息如下所示:
struct page {
unsigned long flags; //表示当前页的状态。包括页是不是脏的、是否被锁定等。具体数值参考page-flags.h
atomic_t __count; //存放当前页的引用计数。代码中可以使用page_count()获取,返回0表示页空闲
struct address_space *mapping; //
...
pa-ff_t index;
struct list_head lru;
void *virtual; //页对应的虚拟地址
}
注意:page结构与物理页相关,而并非与虚拟页相关。
通过这个结构,我们在内核中不仅可以知道一个页是否空闲,还能知道这个页的拥有者是谁。拥有者可能是用户空间进程、动态分配的内核数据、静态内核代码或者页高速缓存等。
脏页:如果与硬盘上的数据相比,页的内容已经改变,则置位 PG_dirty 。设置了该标志的页称为脏的。
关于struct page结构体更为详细的介绍,可以参考https://www.cnblogs.com/still-smile/p/11564671.html
----区(Zone)的概念
区是比页更大的一个概念。内核中使用区(zone)对具有相似特性的页进行分组。Linux主要使用了四种区:
- ZONE_DMA 这个区的页面可以执行DMA操作
- ZONE_DMA32 功能同ZONE_DMA,区别在于在此区域的页只能被32位设备访问。
- ZONE_NORMAL 正常可寻址的页。一般用途的内存都可以从此区分配需要的页
- ZONE_HIGHEM 一般是为了解决物理内存大于虚拟内存数量时的地址映射。例如在IA-32系统上,可以直接管理的物理内存数量不超过896MiB。超过该值的内存只能通过高端内存寻址。目前多数大芯片为64位体系结构,所能管理的地址空间巨大,所以ZONE_HEGHEM基本不用。
区的结构为struct zone,具体内容定义在内核源码include/linux/mmzone.h中。
----存储节点(Node)的概念
在NUMA模型中,一个存储节点Node结点关联到系统中的一个处理器,在内核中表示为 pg_data_t 的实例。从系统体系架构来说,主机可以分为如下种类:
- UMA(一致内存访问)结构 各CPU共享相同的物理内存,每个 CPU访问内存中的任何地址所需时间是相同的。因此此类结构也称对称多处理器结构(SMP)。
- NUMA(非一致内存访问)各CPU都有本地内存,可支持特别快速的访问。各个CPU之间通过总线连接起来,以支持对其他CPU的本地内存访问,当然比访问本地内存慢些。
这两种架构系统的差别如下图(引子《深入linux内核架构》一书)
在NUMA架构的内核中,内存被划分为节点Node。每个节点关联到系统中的一个处理器(这个不太严禁,在下面的例子中会体现)。在内核中表示为pg_data_t的实例。结构体里面的具体内容可以参考内核源码(定义在include/linux/mmzone.h)。
各个节点有可以划分为内存区Zone。
到此,我们就可以知道内核中对物理内存的管理从上到下是节点Node、区(Zone)和页(Page)。
这3个概念的数据结构对应关系如下图:
下面是我所用的主机(龙芯3A3000处理器)开机启动时,内核的信息:
[ 0.000000] NUMA: Discovered 4 cpus on 1 nodes
[ 0.000000] Debug: node_id:0, mem_type:1, mem_start:0x200000, mem_size:0xee MB
[ 0.000000] start_pfn:0x80, end_pfn:0x3c00, num_physpages:0x3b80
[ 0.000000] Debug: node_id:0, mem_type:2, mem_start:0x90200000, mem_size:0x1cfe MB
[ 0.000000] start_pfn:0x24080, end_pfn:0x98000, num_physpages:0x77b00
[ 0.000000] Zone ranges:
[ 0.000000] DMA32 [mem 0x00200000-0xffffffff]
[ 0.000000] Normal [mem 0x100000000-0x25fffffff]
可以看到:
1、多处理器共用一个node
“4 cpus on 1 nodes”说明此3A3000是4个CPU挂载了一个node上,也就是龙芯3A3000是SMP架构,不是上面所说的NUMA架构。
2、该node被分为2个区。
mem_type:1 就是ZONE_DMA32 区,大小为0xeeMB ,此区被分为0x3b80个块。
mem_type:2 就是ZONE_NORMAL,大小为0x1cfeMB,此区被分为0x77b00个块。
龙芯3A3000处理器是用在桌面电脑上的,而龙芯3B3000是用在服务器机上的,通常是双路或4路,所以里面架构是NUMA的,通过内核开机log可以查看到:
[ 0.000000] NUMA: Discovered 8 cpus on 2 nodes
[ 0.000000] Debug: node_id:0, mem_type:1, mem_start:0x0, mem_size:0xf0 MB
[ 0.000000] start_pfn:0x0, end_pfn:0x3c00, num_physpages:0x3c00
[ 0.000000] Debug: node_id:0, mem_type:2, mem_start:0x410000000, mem_size:0x1f00 MB
[ 0.000000] start_pfn:0x104000, end_pfn:0x180000, num_physpages:0x7fc00
[ 0.000000] Debug: node_id:0, mem_type:2, mem_start:0x610000000, mem_size:0x1f00 MB
[ 0.000000] start_pfn:0x184000, end_pfn:0x200000, num_physpages:0xfbc00
[ 0.000000] node0's addrspace_offset is 0x0
[ 0.000000] Node0's start_pfn is 0x0, end_pfn is 0x200000, freepfn is 0xcea
[ 0.000000] Debug: node_id:1, mem_type:1, mem_start:0x0, mem_size:0xf0 MB
[ 0.000000] start_pfn:0x40000000, end_pfn:0x40003c00, num_physpages:0xff800
[ 0.000000] Debug: node_id:1, mem_type:2, mem_start:0x410000000, mem_size:0x1f00 MB
[ 0.000000] start_pfn:0x40104000, end_pfn:0x40180000, num_physpages:0x17b800
[ 0.000000] Debug: node_id:1, mem_type:2, mem_start:0x610000000, mem_size:0x1f00 MB
[ 0.000000] start_pfn:0x40184000, end_pfn:0x40200000, num_physpages:0x1f7800
里面的具体意思不再做解释,读者可以自行思考。
二、页表(pgtable)
用来将虚拟地址空间映射到物理地址空间的数据结构称为页表。
内存地址分解: 内核内存管理总是假定使用四级页表,而不管底层处理器是否如此。根据四级页表结构的需要,虚拟内存地址分为5部分(4个表项用于选择页, 1个索引表示页内位置)。如下图:
虚拟地址的第一部分称为全局页目录(Page Global Directory,PGD)。PGD用于索引进程中的一个数组(每个进程有且仅有一个),该数组是所谓的全局页目录或PGD。PGD的数组项指向另一些数组的起始地址,这些数组称为中间页目录(Page Middle Directory,PMD)。如果存在上层页目录(Page Up Directory PUD),那么PGD指向PUD。
虚拟地址中的第二个部分称为PMD,在通过PGD中的数组项找到对应的PMD之后,则使用PMD来索引PMD。PMD的数组项也是指针,指向下一级数组,称为页表或页目录。
虚拟地址的第三个部分称为PTE(Page Table Entry,页表数组),用作页表的索引。虚拟内存页和页帧之间的映射就此完成,因为页表的数组项是指向页帧的。
虚拟地址最后的一部分称为偏移量。它指定了页内部的一个字节位置。归根结底,每个地址都指向地址空间中唯一定义的某个字节。
内核中每个体系架构都对此4级页表的每一级大小做了宏定义:
#define PAGE_SIZE (1UL<<PAGE_SHIFT)
#define PUD_SIZE (1UL<<PUD_SHIFT)
#define PMD_SIZE (1UL<<PMD_SHIFT)
#define PGDIR_SIZE (1UL<<PGDIR_SHIFT)
各层页表对应的数据结构:
pgd_t 用于全局页目录项。
pud_t 用于上层页目录项。
pmd_t 用于中间页目录项。
pte_t 用于直接页表项。
用于处理内存页的体系结构相关状态的函数:
函数 | 功能 |
---|---|
pte_present | 页在内存中吗(页可能换出到交换区) |
pte_read | 从用户空间可以读取该页吗 |
pte_write | 可以写入到该页吗 |
pte_exec | 该页中的数据可以作为二进制代码执行吗 |
pte_dirty | 页是脏的吗?(即页的内容是否已经修改过) |
pte_file | 该页表项属于非线性映射吗 |
pte_young | 访问位(通常是_ PAGE_ACCESS )设置了吗 |
pte_rdprotect | 清除该页的读权限 |
pte_wrprotect | 清除该页的写权限 |
pte_exprotect | 清除执行该页中二进制数据的权限 |
pte_mkread | 设置读权限 |
pte_mkwrite | 设置写权限 |
pte_mkexec | 允许执行页的内容 |
pte_mkdirty | 将页标记为脏 |
pte_mkclean | “清除”页,通常是指清除 _PAGE_DIRTY 位 |
pte_mkyoung | 设置访问位,在大多数体系结构上是 _PAGE_ACCESSED |
pte_mkold | 清除访问位 |
# 三、分配连续大内存的接口 内核提供了请求内存的底层机制,并提供了相关接口。所有接口都是以页为单位分配内存。最核心的函数有(请查看 include/linux/gfp.h):
1、struct page *alloc_pages(gfp_t gfp_mask, unsigned int order) ;
获取2的order次方个连续的物理页。成功返回第一个页的首地址,失败返回NULL。
gfp_t为分配器标志,就是告诉内核应当如何分配内存。例如告诉内核分配内存时是否可以睡眠、在哪个区分配等。
2、unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
功能同1,也是获取连续的物理页,不过成功后返回的是逻辑地址。
3、void __free_pages(struct page *page, unsigned int order);
释放页。
上面的接口函数是以页为单位分配物理内存。对于常用的以字节为单位的分配来说,内核提供的函数是kmalloc (include/linux/slab_def.h)
4、static __always_inline void *kmalloc(size_t size, gfp_t flags)
分配size大小的物理内存,此物理内存必须是连续的 。成功返回内存块的起始地址,失败返回NULL
5、void kfree(const void *);
释放由kmalloc申请的内存区域。
四、分配非连续内存块的vmalloc机制
大多数情况下,只有硬件设备才需要使用物理地址连续的内存(很多硬件设备存在于内存管理单元之外,无法理解什么是虚拟地址),或者出于性能考虑才要求物理地址连续。普通程序只关心虚拟地址连续即可。
vmalloc函数就是只确保虚拟地址连续,物理地址是否连续无所谓。
vmalloc()函数(请查看include/linux/gfp.h)用法同用户空间的malloc()。接口如下:
1、分配一段空间
void *vmalloc(unsigned long size);
2、释放一段空间
void vfree(const void * addr);
注意:malloc()的系统调用接口是sys_blk() 。而非vmalloc。后面文章会有介绍。
五、分配较小块内存的slab分配器
通常我们编程会面临的问题的某个数据结构(或者对象)会频繁的分配和释放,这会带来的两个问题是内存碎片和效率低下。要解决这个问题的办法就是使用缓存机制(高速缓存)。就是预先分配一个比较大的空间,后面每次的 内存申请和释放都是对这块内存空间的操作。这个高速缓存可以由多个slab组成。而slab又由一个或者多个物理上连续的页组成(通常一个slab仅仅由一页组成),里面存放多个有着相同数据结构的对象。
常用到的缓存接口如下(请查看include/linux/slab.h):
1、缓存区创建
kmem_cache_create(const char * name,** //高速缓存的名字
size_t size, //要缓存的数据结构(对象)的大小
size_t align, //slab内第一个对象的偏移,用来确保对齐
unsigned long flags, //可选项,用来控制高速缓存的行为
void (*)(void *));//
2、销毁缓存
void kmem_cache_destroy(struct kmem_cache *);
3、从缓存中获取一个数据结构(对象)
void *kmem_cache_alloc(struct kmem_cache *, gfp_t);
4、从缓存中释放一个数据结构(对象)
void kmem_cache_free(struct kmem_cache *,void *objp);