Home 内核内存管理
Post
Cancel

内核内存管理

几个总结的常识

  1. linux系统中,物理内存管理的最小单元是页,不管是内核还是应用程序,最后在内核中分配内存时,都是以页为单位分配的, 即总是分配多少个页。
  2. 页的大小是在内核编译时决定的,且通常是固定的。常见是4KB,主要受限于硬件架构,MMU等。
  3. linux系统中,页的分配是按照2的幂次方来进行的,即1个页,2个页,4个页,8个页这样。和伙伴系统有关。

Linux物理内存管理概念

linux对物理内存的管理,有一些概念定义,包括 内存节点node , 内存区域zone , 内存页page

内存节点node

物理内存模型主要有UMA模型的和NUMA模型两种,该概念能让linux统一管理这两种模型。UMA模型可以等效为 NUMA模型下node为1时的特例。参考《Linux内核-NUMA内存模型》,物理内存被分为几个node,node在硬件上是有 对应的内存控制器的。每个cpu核心会被绑定到各自的node上,访问本地node的内存速度更快,访问其他node的内存 速度慢一点。该模型主要解决了一个问题:随着CPU核心的不断增多,尤其是服务器领域,一个系统中所有CPU都要经过 同一个内存控制器来竞争访问内存,效率不行。由于通常一个程序又不需要访问全部的物理内存,即局部性原理, 因此有了这个NUMA模型。

node在内核中使用 struct pglist_data表示,NUMA模型有多个node,使用链表链接起来。

内存区域zone

内存区域zone是一个node内部的概念。linux将每个内存节点node的内存继续细分,使用zone表示一个内存区域 (node内的)。zone类型定义在 /include/linux/mmzone.h中。区域结构体由struct zone表示。

1
2
3
4
5
6
7
8
enum zone_type {
    ZONE_DMA,
    ZONE_DMA32,
    ZONE_NORMAL,
    ZONE_HIGHMEM,
    ZONE_MOVABLE,
    ZONE_DEVICE,
};

不同区域的访问和CPU架构是相关的。分类是相似的。

内存页page

内存页(page)是linux物理内存管理的最小单元,也叫页帧(page frame)。在内核中,使用struct page结构描述一个页, 系统中的每一个页都会有一个这样的对象。所有的page对象进一步由struct page *mem_map;统一管理。

linux系统中,页的大小是在编译内核时确定的,且通常是固定的,x86架构上一般是4KB。其他架构可能使用不同的页大小,一般都是 4KB,它受硬件架构,性能需要,MMU等限制共同决定的。

查看系统的页大小:

1
2
$ getconf PAGESIZE
4096

最小管理单元是页,linux对页的管理有一套机制,叫伙伴系统

关于伙伴系统(Buddy System)

Linux使用了一种称为伙伴系统(Buddy System)的页面分配算法来管理物理内存的分配和释放,有效管理内存。 伙伴系统以固定大小的页面块作为基本单位,并使用二叉树数据结构来组织这些页面块。

在系统初始化时,伙伴系统将物理内存划分成一系列不同大小的页面块。页面块主要是 (2^n) 个连续物理页,n=[1,11],一般一个页就是4KB,所以页面块 的大小是4KB,8KB,16KB,。。。一般n最大是11(这个值是编译linux内核时决定的)。 页面块按大小划分成不同的可用空闲链表,每个链表代表一种页面块的大小。这其实就是一种优化, 针对不同的内存申请大小,通过搜索合适大小的页面块,就可以快速找全需要的页面。提高分配速度,便于管理。

页面分配:

内存的申请,落到内核最后面的部分,就是请求一定数量的页面(2^i个页面)时,伙伴系统在多个可用的空闲链表中选择一个合适大小的页面块,如果 有正好一样大小的空闲的页面块,就分配给请求者,并把它从空闲链表中移除。如果没有,就找一个大一级的页面块,(保证能容纳请求大小),然后将其 分割成两个小块,一个分配给请求者,另一个则放回相应大小的空闲链表中,如果没有空闲的,则重复迭代找大一级的页面块进行分割,直到能找到。

页面回收

内存释放时,即对先前分配的页面进行回收。伙伴系统会检查待回收的页面块的相邻页面块是否满足合并条件,即相邻页面块是空闲的,且大小相同。若不满足, 则将待回收的页面块放回对应大小的空闲链表中即可。若满足,则将待回收的页面块满足合并条件的相邻页面块合并成一个大一级的页面块, 然后放回到相应大小的空闲链表中。放回后同样继续迭代判断相邻的页面块是否满足合并条件,如果满足,就继续合并。整个过程和分配阶段基本相反。

小结

伙伴系统的分配方式,分配出来的其实是页面块,即批量的连续物理页。分配和释放速度快。最小化了内存碎片问题。支持多种大小的页面块,可以适应不同的内存需求。

有点迭代二分的思想。

另外,在NUMA模型下,伙伴系统的处理更加复杂一些,主要是进一步扩展,需要额外加入NUMA感知的功能,优先分配本地节点中的内存,减少访问远程节点的内存。当本地节点没有足够的可用内存时,伙伴系统才会考虑分配远程节点的内存。还有页迁移的功能。

关于虚拟内存

虚拟内存是linux内存管理使用的一种技术,它是一种抽象的概念,提供了一个独立于物理内存的地址空间给每个进程使用。

虚拟内存的概念是基于分页技术。它将物理内存和磁盘空间结合使用,通过将内存分割成固定大小的页面(通常为4KB), 并将页面映射到物理内存或磁盘上的页面文件,实现了内存的分页管理。

几个重要的概念:

  1. 虚拟地址空间:每个进程在运行时都有自己的虚拟地址空间,它是一个虚拟的地址空间,应用程序认为自己具有整个系统的内存空间。
  2. 页面:虚拟内存被划分为固定大小的页面(通常为4KB),操作系统以页面为单位进行内存管理。每个页面都有一个唯一的虚拟地址。
  3. 页面表:用于将进程的虚拟地址的页面映射到物理内存中实际位置。页面表存储在内核中,并由硬件内存管理单元(MMU)使用。
  4. 页面的调度和置换(page mapping和page swapping):物理内存被划分为页,进程的地址空间划分为相同大小的页框(page frame)。 当进程需要访问某个页面时,如果该页面不在页框中,就会发生页面错误(page fault),触发页面调度,将所需页面从存储介质加载到空闲的物理内存页框中。 关于置换:当物理内存不足时,需要将一些已经加载的但不在使用的页面换下来,为需要运行的程序提供更多内存空间,即解决物理内存不足时的问题,有几个常见的页面置换算法来进行页面的置换。换下来的页面从物理内存移到磁盘上的交换空间中。 另外,即使物理内存充足,也有可能发生页面置换,主要是可以置换掉一些长时间没有访问的页面,提高缓存命中率,优化系统性能。
  5. 交换空间:磁盘上的一部分空间被用作交换空间,用于存储被置换出的页面。交换空间可以是磁盘上的分区或交换文件。

主要实现功能:

  • 内存隔离:每个进程都有独立的地址空间,使得进程间不会互相干扰。
  • 内存共享:多个进程可以共享相同的物理内存页面,从而节省内存使用。
  • 虚拟内存扩展:虚拟内存空间可以比实际的物理内存空间大,允许进程使用比物理内存更多的内存。
  • 内存保护:通过页面级别的访问权限设置,页面可以设置为只读、可写或可执行等,从而实现对内存的安全控制。

延迟分配技术–应用程序内存申请和使用过程参考

如C语言常见的使用malloc函数申请内存,内核会分配一块虚拟内存空间给程序,这个虚拟内存空间是一段连续的虚拟地址的空间,并不对应实际的物理内存页面, 分配完后就可以返回了,它仅分配虚拟空间,并不会操作物理内存。通常这个分配都会成功,虚拟内存的大小是由操作系统的地址空间限制或其他一些限制决定的, 和物理内存没有直接关系,因此,即使物理内存已经全部用完了,操作系统仍然可以分配虚拟内存空间给进程。将来进行页面交换即可。

当虚拟内存分配成功后,实际的物理页面并没有立即分配。要当程序对这块申请的内存进行读写时,内核才会根据需要将虚拟内存页面映射到实际的物理页面。 该过程主要由操作系统的页表机制和页面调度算法完成。当第一次访问分配的内存时,由于物理页面并未加载到对应的页框,产生缺页异常,然后有操作系统将 虚拟内存页面加载到物理内存中,并建立虚拟内存与物理内存之间的映射关系,该过程也就是页面调度。如果物理内存不足,还需要先进行页面置换,从物理内存中 换出页面到磁盘上的交换空间。建立好映射后,程序就可以通过虚拟地址来访问实际的物理内存。

测试

这里做一个简单的测试,查看进程的页面分配情况,和物理内存情况。测试程序获取动态内存,并对开头和最后一个 字节写入,会导致所在的页面进行调度,然后在/proc中查看相关的信息。

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
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

volatile uint8_t *parray[100];

int main(int argc,char** argv) {

    printf("this pid:%d\n",getpid());
    int argunum=argc-1;
    printf("argu num:%d\n",argunum);
    if(argc > 100) 
        exit(1);

    int sz;
    for(int i=0;i<argunum;i++){
	sz=atoi(argv[i+1]);
	printf("to malloc %d bytes\n",sz);
	getchar();
        parray[i] = (uint8_t*)malloc(sz);
	if(parray[i] == NULL){
	    printf("malloc failed!!!\n");
	    exit(1);
	}
	parray[i][0] = 12;
	parray[i][sz-1] = 34;
	printf("ok,next...\n");
	getchar();
    }
    return 0;
}

编译运行,这里申请两次内存,分别是1575936和8388999。运行中通过 /proc 查看内存情况。

1
2
gcc ./test.c -O0 -std=gnu99 -o test
./test 1575936  8388999

根据pid信息,查看/proc目录对应的进程信息,这里查看了smaps文件,具体字段信息参考 man 5 proc解释

smaps文件显示了每个进程映射的内存消耗情况。是分各个段(segment)记录的,程序整体运行前后只有一个段发生了变化, 是一个匿名段,对应malloc动态申请的内存。使用cat /proc/[pid]/smaps查看并比较前后变化。以下使用 =>记录变化。

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
557084212000-557084233000 rw-p 00000000 00:00 0                          [heap]
...
...
7f5678cb5000-7f5678cd7000 r--p 00000000 103:02 85461963                  /usr/lib/x86_64-linux-gnu/libc-2.31.so
...
...

# 段有很多,但前后都没有变化,只有这个段有变化,且这个段在程序第一次malloc之前是不存在的

NULL     =>     7f5678b34000-7f5678cb5000 rw-p 00000000 00:00 0       =>      7f5678333000-7f5678cb5000 rw-p 00000000 00:00 0 
NULL     =>     Size:               1540 kB                           =>      Size:               9736 kB
NULL     =>     KernelPageSize:        4 kB                           
NULL     =>     MMUPageSize:           4 kB                           
NULL     =>     Rss:                   8 kB                           =>      Rss:                  16 kB        
NULL     =>     Pss:                   8 kB                           =>      Pss:                  16 kB        
NULL     =>     Shared_Clean:          0 kB                                   
NULL     =>     Shared_Dirty:          0 kB                                   
NULL     =>     Private_Clean:         0 kB                                   
NULL     =>     Private_Dirty:         8 kB                           =>      Private_Dirty:        16 kB
NULL     =>     Referenced:            8 kB                           =>      Referenced:           16 kB
NULL     =>     Anonymous:             8 kB                           =>      Anonymous:            16 kB
NULL     =>     LazyFree:              0 kB                                   
NULL     =>     AnonHugePages:         0 kB                                   
NULL     =>     ShmemPmdMapped:        0 kB                                   
NULL     =>     FilePmdMapped:         0 kB                                   
NULL     =>     Shared_Hugetlb:        0 kB                                   
NULL     =>     Private_Hugetlb:       0 kB                                   
NULL     =>     Swap:                  0 kB                                   
NULL     =>     SwapPss:               0 kB                                   
NULL     =>     Locked:                0 kB                                   
NULL     =>     THPeligible:           0                                             
NULL     =>     ProtectionKey:         0                                      
NULL     =>     VmFlags: rd wr mr mw me ac sd                                 

在上面的分析中,可以注意到几点,

首先malloc申请的内存所在段在最初是没有的,即未分配对应的虚拟内存空间,第一次malloc后出现,范围7f5678b34000-7f5678cb5000,大小为0x181000(=1576960,=1540KB), 正好能容纳第一次申请的内存大小1575936,第二次分配则是该区域向低地址增长了,大小为9736 kB(等于9969664B ,大于 1575936 + 8388999 = 9964935),刚好容纳。

至于该段分配的页面数量,使用 Size/KernelPageSize 即可算出分配的页面数量,如第一次malloc后,一共分配了 1540 kB / 4kB = 385 个页面。(这里应是通过slab分配器分配的,所以页面数量不是正好2的幂次方个)。

接着,因为malloc申请内存仅是分配虚拟内存页面,并没有实际映射到物理页面,(没有访问申请的内存前,内核没有进行页面调度)这个映射的物理页面也是有统计的,可以查看Rss字段。 Rss字段,即常驻内存集(Resident Set),是当前进程使用的实际物理内存的集合,也就是映射到物理内存后的页面。在程序中固定向申请内存的开头和末尾字节写入,那么就正好对应触发第一个页面和最后一个页面的调度, 实际物理内存就会使用2个页面,所以Rss是8kB,尽管虚拟内存地址空间分配了1540kB,但实际该段仅映射了2个物理页面,即该段实际只用了8kB物理内存,这就是虚拟内存的体现。

最后,如果一个页面长时间没有被访问,即使内存充足,内核也可能会将其从物理内存中置换出,可以节约空间,提高整体性能等。

总结一下,程序申请的内存在初始阶段只是虚拟内存,并不是立即常驻在物理内存中的页面。待到申请的内存被访问时,缺页错误,内核开始进行页面调度,根据需要将虚拟页面加载到物理内存,即建立二者映射关系,此后,程序就能通过 虚拟地址访问物理内存了。如果操作系统物理内存不足或物理内存页面长时间未被访问,操作系统可以将其置换。

这种技术属于linux内核的 延迟分配(deferred allocation)技术,属于内存管理技术的一部分。延迟分配技术是在 Linux 内核中实现的,对于应用程序而言是透明的。 这种延迟分配的机制有助于提高内存管理的效率,并减少内存消耗。应用程序到实际使用时才分配,可以极大的节约内存。可以在top命令的显示中验证,VIRT是进程的虚拟内存用量,而RES则是进程的实际物理内存用量。

重要补充

上节应用层的malloc申请内存,内核仅分配虚拟内存页面,没有映射实际物理页面,直到读写时才触发页面调度。但实际情况并不都是如此,因为这背后其实和内存区域(Zone)有关, 常见的内存区域有ZONE_DMA,ZONE_NORMAL,ZONE_HIGHMEN,上节的示例,分配的虚拟内存其实就是落到了ZONE_HIGHMEN区域中,这个区域里面的页是不直接建立物理页面映射的, 到使用时才去进行页面调度。但是对于ZONE_DMA,ZONE_NORMAL这两个域,操作系统在初始化期间会为这两个域做好映射,所以如果申请的内存落在这两个域,其实申请的页面就是映射好的, 就不需要页面调度了。对于应用程序申请的内存,不会到ZONE_DMA,只会到ZONE_NORMAL,这就是一个通用内存域。

上节申请的内存落在了ZONE_HIGHMEN域中,应当是有原因的,应该就是申请的内存过大了。书本上常说malloc申请的是动态内存,位于进程地址空间的堆区(heap),但是上节的示例中申请 的内存并没有在堆区,而是出现在一个新的无名段中。这个堆区是有大小的,如上节示例中,大小就是(557084233000-557084212000=)132kB,而第一次申请的内存大约是1540kB,远大于heap 堆区大小,所以应该从ZONE_HIGHMEN域中获取内存了。而堆区heap是位于ZONE_NORMAL域,就是可以直接获取到映射好的页面。

不同的机器,不同的进程,看到的堆区大小可能不同,堆区的大小通常由操作系统和运行时环境自动管理,而不能手动设置。不过栈区大小是可以设置的, 使用系统核心组件命令ulimit可以设置进程的一些资源使用,可设置的资源使用 ulimit -a查看,设置后,对该会话下所有进程生效。

测试,这次申请小量内存,小于堆区的大小,分别申请4096和8192,对应需要1个页面和2个页面。

1
./test 4096 8192

其结果是这样变化的,比较奇怪的是Rss第二次仅增长了4kB,即只多分配了一个页面,按照设想应该是2个才对。

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
# 这次就没有出现新的段了,申请的内存落在了heap段中。其它段则没有变化。

555a395ee000-555a3960f000 rw-p 00000000 00:00 0                          [heap]
Size:                132 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   4 kB         =>      8 kB    =>      12 kB 
Pss:                   4 kB         =>      8 kB    =>      12 kB 
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         4 kB         =>      8 kB    =>      12 kB 
Referenced:            4 kB         =>      8 kB    =>      12 kB 
Anonymous:             4 kB         =>      8 kB    =>      12 kB 
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
FilePmdMapped:         0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:    0
ProtectionKey:         0
VmFlags: rd wr mr mw me ac sd 

如果将测试代码稍加改动,将之前的仅读写一个字节改为对整个申请的内存写入。

1
2
3
//parray[i][0] = 12;
//parray[i][sz-1] = 34;
memset(parray[i],12,sz);

其结果就是分配了2个页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
55799a02e000-55799a04f000 rw-p 00000000 00:00 0                          [heap]
Size:                132 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   4 kB         =>      8 kB    =>      16 kB 
Pss:                   4 kB         =>      8 kB    =>      16 kB 
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         4 kB         =>      8 kB    =>      16 kB 
Referenced:            4 kB         =>      8 kB    =>      16 kB 
Anonymous:             4 kB         =>      8 kB    =>      16 kB 
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
FilePmdMapped:         0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:    0
ProtectionKey:         0
VmFlags: rd wr mr mw me ac sd    

后面继续测试分配极小内存,4字节和8字节,此时就始终只有一个页面常驻内存了。

这里常驻内存集大小的差异可能是和 malloc 内部实现有关,还能注意到,虽然heap堆区的大小一直没变,(虚拟地址上132kB),但是程序在第一次分配内存之前,heap堆区已经有1个页面被映射了。 可能是c库用来管理malloc申请的内存的数据结构。

malloc分配的内存,最终还是由内核来决定如何分配的,可能位于ZONE_NORMAL区域,也可能位于ZONE_HIGHMEM区域。前者已经建立好映射,后者会延迟分配,到访问时触发缺页中断去进行页面调度。

另外,malloc分配的内存,其虚拟地址上是连续,但在物理内存中并不一定是连续的。

其他

linux的内存管理庞大复杂,如还有启动阶段,在页面分配器建立之前的内存管理系统bootmem和memblock等。

可参考

https://blog.csdn.net/djl806943371/article/details/90246313

https://blog.csdn.net/mseaspring/article/details/121347289

This post is licensed under CC BY 4.0 by the author.

字符设备驱动

内核内存分配接口