2

内存地址

Memory Zone

Linux使用虚拟内存技术,所以在应用层所能看到的、访问的都是虚拟地址。对于32位系统来说(本文涉及的都是32位系统),每一个进程可以寻址的地址空间都是4G,无论物理内存有多大。应用开发者其实是可以不用关心内存空间的划分,仅仅使用封装后的接口就可以完成开发。但在工作中,如果对地址空间没有基本的了解,在程序设计和解决问题时可能会引起方向性错误。这里对地址空间进行简单介绍,下图时网上常见的x86架构的内存区域划分。

linux_address_2.jpg

  • 物理内存被分为三个区域:ZONE_DMA,ZONE_NORMAL,ZONE_HIGHMEM。DMA和HIGHMEM区域不是必须存在的,在Kernel编译选项中可控制开关。
  • 在ARM架构中,通常是不存在ZONE_DMA区域的,ZONE_HIGHMEM的起始地址默认是760M,而不是x86的896M。
  • ZONE_DMA和ZONE_NORMAL是直接映射到Kernel地址空间的,所以它们的物理地址可以通过一个偏移量直接转化为Kernel地址。
  • ZONE_HIGHMEM区域没有直接映射,Kernel无法直接访问,需要通过vmalloc映射到Kernel空间。vmalloc的用途是将非连续的物理空间映射为连续的Kernel地址空间,vmalloc会优先使用highmem。
  • 在64位系统中,Kernel可寻址的空间超过512G,所以也就不需要ZONE_HIGHMEM。

物理内存是映射到Kernel地址空间的,这个空间也是虚拟的,分布在进程虚拟地址空间的3G~4G,0~3G为用户地址空间。下图也是从网络上获得的。

linux_address_1.jpg

  • 每个进程都会有这样的4G空间,用户地址空间自己分配使用,内核地址空间实际上都是相同的。
  • 0~3G的用户地址空间被划分为代码段(程序执行代码,只读)、数据段(已初始化的全局变量)、BSS段(未初始化的全局变量,清零)、堆(动态分配的内存)、栈(局部变量,函数传参,返回值)等。
  • 用户进程仅能访问0~3G的用户地址空间,Kernel可以访问全部地址空间。
  • 物理内存分配需要同时映射到用户地址空间和内核地址空间。
  • 内核地址空间中,直接映射区域与vmalloc区域存在一个8M的hole,用做内存保护。每一个vmalloc间又留有一个4K的hole用来保护。

地址转换

Linux中的用户空间看到的是连续的虚拟地址,在被真正使用时还是需要转换为真实的物理地址。根据虚拟地址来查询物理地址的过程被称为walking page tables。因为Linux使用分页机制来管理内存,基本的结构如下图。

linux_address_3.jpg

当前的Linux设计了四级页表,分别是PGD -> PUD -> PMD -> PTE。其中PUD和PMD不是必须的,所以系统可以根据硬件构架使用二级、三级或四级的页表模式。通常在32位系统中,二级页表就可以满足需求。下图是一个二级页表的查找过程。

linux_address_4.png

可以看到,一个虚拟地址被分为一级查找索引,二级查找索引和页索引三部分。根据一级页表PGD的基地址(保存在协处理器CP15:C2中),结合一级查找索引可以获取到二级页表PTE的基地址。再将二级页表基址与二级查找索引结合,可以获取到物理页的基地址。物理页基址与页索引结合就是需要查找的物理页。

页表的查找过程在系统中非常频繁,因此需要通过硬件来完成,这个硬件就是MMU。MMU被称为内存管理单元,它不仅仅负责虚拟地址到物理地址的转换,还负责内存访问权限和高速缓存的管理。

TLB

上文已经说到了内存管理单元MMU,其最主要的工作就是进行地址转换。为了加快地址转换的速度,通常将最近访问的虚拟地址和转换后的物理地址的对应保存在一个高速缓存中,这个缓存就是TLB(Translation Lookaside Buffer)。因为在程序运行时,其访问过的地址被再次访问的概率很高,当TLB中保存了需要访问的地址时,就可以免去页表查找的过程,可以大大提高系统性能。

linux_address_5.png

在进行地址转换时, 首先访问的是TLB,在TLB查找是否有该虚拟地址的缓存。如果找到(a TLB hit),直接返回物理地址。如果在TLB中无法找到(a TLB miss),则需要通过MMU进行页表查找获得物理地址,同时将新的查找更新到TLB中。某些情况下,例如使用磁盘做为swap时,需要访问的内存页可能不存在page table中。这时需要将访问页回写到物理内存中,同时更新page table和TLB。

内存分配

内核中常见的内存分配函数有vmalloc、kmalloc、__get_free_pages等,分别介绍一下。

__get_free_pages/alloc_page

__get_free_pages/alloc_page从Buddy系统中分配的页面,其分配的页数是2的幂数。Buddy系统是Linux用来组织和管理内存页面的方法,用来解决内存外部碎片问题。Buddy系统把所有的空闲页框分组为11个块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512和1024个连续页框的页框块。最大可以申请1024个连续页框,对应4MB大小的连续内存。每个页框块的第一个页框的物理地址是该块大小的整数倍。

linux_address_7.png

假设要申请一个256个页框的块,先从256个页框的链表中查找空闲块,如果没有,就去512个页框的链表中找,找到了则将页框块分为2个256个页框的块,一个分配给应用,另外一个移到256个页框的链表中。如果512个页框的链表中仍没有空闲块,继续向1024个页框的链表查找,如果仍然没有,则返回错误。页框块在释放时,会主动将两个连续的页框块合并为一个较大的页框块。

可以通过/proc/buddyinfo来查看buddy使用信息。如下面的信息显示1 page的空闲页有1118个,2 page的空闲页有1781个,最大的可分配的连续页1024 page有4个。

1 2 4 8 16 32 64 128 256 512 1024
Node 0, zone Normal 1118 1781 1362 546 191 59 33 15 1 0 4

__get_free_pages/alloc_page分配的是物理内存上连续的页面,所以没有特殊需求时也不应该一次性分配较大的内存。在需要分配大内存时,如果不需求物理上连续,可以一页一页分配,然后映射到连续的虚拟地址空间上。

kmalloc

内核中最常用的内存分配函数就是kmalloc(),当需要申请小块内存时,优先考虑使用kmalloc。kmalloc是基于slab分配器进行工作的,先简单介绍一下Slab。

Buddy系统管理内存是以page(4k)为单位的,当分配小内存时,几十字节甚至几个字节,也需要申请一页的空间就太浪费了,所以就有了Slab。Slab对Buddy申请的内存进行二次管理,管理的单元为object。一种类型的object可以有一个或多个Slab,但一个Slab中只有一种Object。对频繁使用的object,内核就会针对这个object创建slab,来提高分配释放效率。一个Slab中按object大小进行划分,当系统分配和释放object时,会从该slab中获取和归还,避免了频繁的内存分配释放。

linux_address_6.png

  • slab是为了解决小块内存分配产生的内部碎片而产生的。
  • slab又有不同的实现方式,如slab、slob、slub。最新的内核中使用slub。
  • slab会缓存频繁使用的对象,减少分配、释放的时间。
  • slab分配的内存在物理上是连续的。
  • slab高速缓存通过kmem_cache来描述,每一个kmem_cache中存有一种对象的高速缓存。
  • kmem_cache通过三个链表来管理slab缓存:slabs_full(完全分配),slabs_partial(部分分配的),slabs_empty(未分配)。
  • slab可分为普通高速缓存和专用高速缓存两类。专用缓存为具体的对象创建,根据对象命名。普通缓存不指定特定对象,根据大小命名。

Slab的分配信息可以通过/proc/slabinfo来查看。其中以kmalloc-xxx来命名的是通用的Slab,object大小固定的,由kmalloc分配使用。其他以object名字命名的是专用的slab,是内核为频繁使用的object创建的。

slabinfo - version: 2.1
# name                        <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> <total bytes> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavai>
zs_handle                         1024   1024      8  512    1     8192 : tunables    0    0    0 : slabdata      2      2      0
ext4_groupinfo_4k                   64     64    128   32    1     8192 : tunables    0    0    0 : slabdata      2      2      0
ip6_dst_cache                       42     42    384   21    2    16128 : tunables    0    0    0 : slabdata      2      2      0
UDPLITEv6                            0      0   1024    8    2        0 : tunables    0    0    0 : slabdata      0      0      0
UDPv6                               16     16   1024    8    2    16384 : tunables    0    0    0 : slabdata      2      2      0
......
kmalloc-8192                        49     52   8192    1    2   425984 : tunables    0    0    0 : slabdata     52     52      0
kmalloc-4096                       117    126   4096    2    2   516096 : tunables    0    0    0 : slabdata     63     63      0
kmalloc-2048                       517    524   2048    4    2  1073152 : tunables    0    0    0 : slabdata    131    131      0
kmalloc-1024                       538    664   1024    8    2   679936 : tunables    0    0    0 : slabdata     83     83      0
......

kmalloc就是通过slab的普通高速缓存来分配内存的。kmalloc的实现也很简单,就是在slab的普通高速缓存中寻找一个大小最匹配缓存。因为kmalloc分配的内存是物理连续的,而物理连续的内存是非常珍贵的,所以除非必要,否则大块内存(以页为单位)分配应该使用vmalloc和get_free_pages。kmalloc可以分配的最大值在不同的硬件架构上是不同的,并且使用的分配器类型也有影响。例如在ARM32上使用slub分配器时,kmalloc()可接受的最大size为8M。但是当size大于8K时,kmalloc()的内部实现调用了__get_free_pages()。

vmalloc

上面讲到过,highmem中有一块vmalloc区域,这个地址空间就是通过vmalloc()分配使用的。vmalloc的特点如下,

  • vmalloc分配的内存被映射到内核地址空间的vmalloc区域,在分配时优先使用highmem。
  • vmalloc分配的内存在内核地址空间上是连续的,但在物理内存上不一定连续。
  • vmalloc效率较低,在运行过程中新的页表需要被建立,所以效率低于kmalloc和__get_free_page
  • ioremap映射的地址在vmalloc区域中。

DMA/CMA

开发设备驱动时(GPU,Camera,HDMI等),有时需要为硬件设备分配一段连续的内存用于DMA传输,这时可能就会用到dma_alloc_coherent()。为DMA分配的内存会有以下特点,

  • 内存必须是物理上连续的。
  • DMA申请的内存是uncache的,否则可能会产生数据的不一致。
  • DMA映射的区域可以同时被CPU和外围设备访问。
  • DMA使用的内存不是限制在ZONE_DMA区域的,这和硬件设计有关。许多嵌入式设备并没有ZONE_DMA。
  • 使用DMA内存时需要注意cache,根据需要使用dma_cache_sync()来保证内存一致性。

DMA内存可以认为是设备专有内存,早期的硬件设计中会预留内存来给DMA使用,但这样内存的使用率就很低,因为硬件设备并不是一直在使用。为解决这个问题,CMA被设计出来。CMA区域在系统初始化时被划分出来,但是这段内存可供migrate type为moveable的内存分配使用。当硬件设备需要申请大块连续内存时,就会将申请区域中moveable内存移动到其他非CMA区域。在CMA框架下,DMA coherent APIs会使用CMA区域申请内存。这时dma alloc coherent本质上用__alloc_from_contiguous()从CMA区域获取内存。

ION

ION是google为了解决不同Android设备的内存碎片问题,提出的一种内存管理器。它支持不同的内存分配机制,如CARVOUT(PMEM),物理连续内存(kmalloc),虚拟地址连续但物理不连续内存(vmalloc), IOMMU等。

linux_address_8.png

ION通过Heap来管理不同的内存空间,每个Heap需要实现自己的操作内存方法,比如allocate, free, map等。对ION的使用需要通过Client完成,用户空间和内核空间都可以成为Client。内核空间通过ion_client_create()获取Client,用户空间通过打开/dev/ion来获取Client的fd。

内存释放

主动释放

本着谁分配谁释放的原则,无论时应用中通过malloc分配的内存还是内核中通过kmalloc分配的,在使用完成时一定记得使用free类函数进行释放。代码上很简单,重要的是养成良好的编程习惯。同时也要注意不要对分配的内存多次释放,这样同样会导致异常。内存的分配释放说起来很简单,但也是最容易出问题的地方。所以就出现了大量的内存检测工具。如果软件开发已经开始使用内存检测工具了,就已经太晚了。但我们又无法避免内存问题,即使带有自动回收的编程语言也是不能完全避免。内存问题在软件开发中一直是个难题。

Cache回收

Linux系统中使用free命令来查看内存时,可以看到有两项是“buffers”和“cached”。这两项的意义是,

  • buffers:表示块设备 (block device) 所占用的缓存页,包括了直接读写块设备以及文件系统元数据 (metadata) 比如 SuperBlock 所使用的缓存页。
  • cached:表示普通文件系统中数据所占用的缓存页。

buff/cache是用来缓存磁盘文件数据的,用来提高IO访问速度。当系统运行一段时间后,会发现buff/cache占用的内存很多,但这些内存被认为是可以available的。当内存短缺时,系统会触发内存回收,这部分内存就会释放。还有一种手动清理Cache的方法,通过下面这个命令。

$ echo 3 > /proc/sys/vm/drop_caches

但我们不建议用命令行来强行回收Cache,这样会破坏系统内存管理。Cache回收更多还是应该依赖系统的内存回收机制。也可以扩展原生的内存回收,增加自己的回收机制,像Android的lowmemorykiller那样。如果内存回收仍然无法解决内存短缺问题(在嵌入式系统中经常发生),可以试图去调整vm的一些参数,在“/proc/sys/vm/”下,这就需要对内存管理有一个正确的理解。也可以试图去调整磁盘挂载的参数,这也可能影响到Cache。具体的调整方法不在这里详细说明,如果以后写到内存优化时会单独写一篇。

内存回收机制

当系统内存不足时,内核会启动内存回收。内存回收的时机有以下三种,

  • 内存紧缺回收:在内存分配失败时,会直接调用try_to_free_pages()进行页面回收,以便尽快释放内存。这种方式被称作“直接页面回收”。
  • 睡眠回收:在进入suspend-to-disk状态时,需要释放内存。
  • 周期回收:守护进程 kswapd会定期检查系统可用内存,当剩余内存低于预定水位线时就会进行回收。另一个定期回收是reap_work,用来回收slab空闲页面。

linux_address_9.jpg

可以看到内存回收最终通过两个函数完成,

  • shrink_zone:扫描zone或memcg,根据lru链表进行回收。
  • shrink_slab:回收磁盘缓存,遍历系统中注册的shrinker。

shrink_zone() 函数是 Linux 操作系统实现页面回收的最核心的函数之一,它实现了对一个内存区域的页面进行回收的功能,该函数主要做了两件事情:

  • 将某些页面从 active 链表移到 inactive 链表,这是由函数 shrink_active_list() 实现的。
  • 从 inactive 链表中选定一定数目的页面,将其放到一个临时链表中,这由函数 shrink_inactive_list() 完成。该函数最终会调用 shrink_page_list() 去回收这些页面。

linux_address_10.jpg

shrink_page_list() 返回的是回收成功的页面数目,对于可进行回收的页面,该函数主要做了这样几件事情:

  • 对于匿名页面来说,在回收此类页面时,需要将其数据写入到交换区。如果尚未为该页面分配交换区槽位,则先分配一个槽位,并将该页面添加到交换缓存。同时,将相关的 page 实例加入到交换区,这样,对该页面的处理就可以跟其他已经建立映射的页面一样;
  • 如果该页面已经被映射到一个或者多个进程的页表项中,那么必须找到所有引用该页面的进程,并更新页表中与这些进程相关的所有页表项。在这里,Linux 操作系统会利用反向映射机制去检查哪些页表项引用了该页面;
  • 如果该页面中的数据是脏的,那么数据必须要被回写;
  • 释放页缓存中的干净页面。

shrink_slab() 是用来回收磁盘缓存所占用的页面的。Linux操作系统并不清楚这类页面是如何使用的,所以如果希望操作系统回收磁盘缓存所占用的页面,那么必须要向操作系统内核注册 shrinker 函数,shrinker 函数会在内存较少的时候主动释放一些该磁盘缓存占用的空间。函数 shrink_slab() 会遍历shrinker链表,从而对所有注册了 shrinker函数的磁盘缓存进行处理。注册shrinker是通过函数 register_shrinker() 实现的,解除shrinker注册是通过函数unregister_shrinker()实现的。从实现上来看,shrinker函数和slab分配器并没有固定的联系,只是当前主要是slab缓存使用shrinker函数最多。shrinker的用法不仅限于回收磁盘缓存,Android的lowmemorykiller就根据shrinker的调用时间实现了进程查杀。

kswapd是内存回收机制中最重要的方式,它作为守护进程在后台周期运行,根据预定的水位进行回收。Linux系统中每一个内存区域(zone)都会存在一个kswapd,同时每个区域也定义了一组watermark来做为参考水位。

  • WMARK_MIN:最低水位线。低于该水位表示系统无法工作,必须进行页面回收。kswapd检查到剩余内存低于该水位时,会发起直接页面回收,而且可能会引起OOM。
  • WMARK_LOW:低水位线。kswapd检查剩余内存低于该水位时开始启动回收,直到剩余内存高于WMARK_HIGH时停止回收。
  • WMARK_HIGH:高水位线。kswapd认为这时系统内存充足,不需要回收。

内存回收的目的是为了保证系统有足够的内存可以正常运行,但当kswapd频繁回收时也会对系统造成压力,有时可以看到kswapd的CPU占用率很高,就是因为回收过于频繁。这种情况下就需要根据系统状态来进行内存调优,主要是调整watermark。基本原则是避免内存低于WMARK_MIN,根据系统运行状态设置合理的WMARK_HIGH,选择合适的时机启动后台内存回收。这些又是内存调优的话题,需要另一篇来阐述。

说明

本文只是对内核的内存管理做简单的介绍,目的是对其有一个整体的认识。其中的每一部分展开来都是很大的课题,本人水平有限不再深入分析。文章中的图片全部来自网络,源头也不是很清楚。


戈壁老王
143 声望64 粉丝

做为一个不称职的老年码农,一直疏忽整理笔记,开博记录一下,用来丰富老年生活,