共计 5406 个字符,预计需要花费 14 分钟才能阅读完成。
内存地址
Memory Zone
Linux 使用虚拟内存技术,所以在应用层所能看到的、访问的都是虚拟地址。对于 32 位系统来说(本文涉及的都是 32 位系统),每一个进程可以寻址的地址空间都是 4G,无论物理内存有多大。应用开发者其实是可以不用关系内存空间的划分,仅仅使用封装后的接口就可以完成开发。但在工作中,如果对地址空间没有基本的了解,在程序设计和解决问题时可能会引起方向性错误。这里对地址空间进行简单介绍,下图时网上常见的 x86 架构的内存区域划分。
- 物理内存被分为三个区域: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 为用户地址空间。下图也是从网络上获得的。
- 每个进程都会有这样的 4G 空间,用户地址空间自己分配使用,内核地址空间实际上都是相同的。
- 0~3G 的用户地址空间被划分为代码段(程序执行代码,只读)、数据段(已初始化的全局变量)、BSS 段(未初始化的全局变量,清零)、堆(动态分配的内存)、栈(局部变量,函数传参,返回值)等。
- 用户进程仅能访问 0~3G 的用户地址空间,Kernel 可以访问全部地址空间。
- 物理内存分配需要同时映射到用户地址空间和内核地址空间。
- 内核地址空间中,直接映射区域与 vmalloc 区域存在一个 8M 的 hole,用做内存保护。每一个 vmalloc 间又留有一个 4K 的 hole 用来保护。
地址转换
Linux 中的用户空间看到的是连续的虚拟地址,在被真正使用时还是需要转换为真实的物理地址。根据虚拟地址来查询物理地址的过程被称为 walking page tables。因为 Linux 使用分页机制来管理内存,基本的结构如下图。
当前的 Linux 设计了四级页表,分别是 PGD -> PUD -> PMD -> PTE。其中 PUD 和 PMD 不是必须的,所以系统可以根据硬件构架使用二级、三级或四级的页表模式。通常在 32 位系统中,二级页表就可以满足需求。下图是一个二级页表的查找过程。
可以看到,一个虚拟地址被分为一级查找索引,二级查找索引和页索引三部分。根据一级页表 PGD 的基地址(保存在协处理器 CP15:C2 中),结合一级查找索引可以获取到二级页表 PTE 的基地址。再将二级页表基址与二级查找索引结合,可以获取到物理页的基地址。物理页基址与页索引结合就是需要查找的物理页。
页表的查找过程在系统中非常频繁,因此需要通过硬件来完成,这个硬件就是 MMU。MMU 被称为内存管理单元,它不仅仅负责虚拟地址到物理地址的转换,还负责内存访问权限和高速缓存的管理。
TLB
上文已经说到了内存管理单元 MMU,其最主要的工作就是进行地址转换。为了加快地址转换的速度,通常将最近访问的虚拟地址和转换后的物理地址的对应保存在一个高速缓存中,这个缓存就是 TLB(Translation Lookaside Buffer)。因为在程序运行时,其访问过的地址被再次访问的概率很高,当 TLB 中保存了需要访问的地址时,就可以免去页表查找的过程,可以大大提高系统性能。
在进行地址转换时,首先访问的是 TLB,在 TLB 查找是否有该虚拟地址的缓存。如果找到(a TLB hit), 直接返回物理地址。如果在 TLB 中无法找到(a TLB miss),则需要通过 MMU 进行页表查找获得物理地址,同时将新的查找更新到 TLB 中。某些情况下,例如使用磁盘做为 swap 时,需要访问的内存页可能不存在 page table 中。这时需要将访问页回写到物理内存中,同时更新 page table 和 TLB。
内存分配
内核中常见的内存分配函数有 vmalloc、kmalloc、__get_free_pages 等,分别介绍一下。
kmalloc
内核中最常用的内存分配函数就是 kmalloc(),当需要申请小块内存时,优先考虑使用 kmalloc。kmalloc 是基于 slab 分配器进行工作的,先简单介绍一下 slab。
- slab 是为了解决小块内存分配产生的内部碎片而产生的。
- slab 又有不同的实现方式,如 slab、slob、slub。最新的内核中使用 slub。
- slab 会缓存频繁使用的对象,减少分配、释放的时间。
- slab 分配的内存在物理上是连续的。
- slab 高速缓存通过 kmem_cache 来描述,每一个 kmem_cache 中存有一种对象的高速缓存。
- kmem_cache 通过三个链表来管理 slab 缓存:slabs_full(完全分配),slabs_partial(部分分配的),slabs_empty(未分配)。
- slab 可分为普通高速缓存和专用高速缓存两类。专用缓存为具体的对象创建,根据对象命名。普通缓存不指定特定对象,根据大小命名。
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
__get_free_pages
__get_free_pages 从 Buddy 系统中分配的页面,其分配的页数是 2 的幂数。Buddy 系统是 Linux 用来组织和管理内存页面的方法,用来解决内存外部碎片问题。Buddy 系统把所有的空闲页框分组为 11 个块链表,每个块链表分别包含大小为 1,2,4,8,16,32,64,128,256,512 和 1024 个连续页框的页框块。最大可以申请 1024 个连续页框,对应 4MB 大小的连续内存。每个页框块的第一个页框的物理地址是该块大小的整数倍。
假设要申请一个 256 个页框的块,先从 256 个页框的链表中查找空闲块,如果没有,就去 512 个页框的链表中找,找到了则将页框块分为 2 个 256 个页框的块,一个分配给应用,另外一个移到 256 个页框的链表中。如果 512 个页框的链表中仍没有空闲块,继续向 1024 个页框的链表查找,如果仍然没有,则返回错误。页框块在释放时,会主动将两个连续的页框块合并为一个较大的页框块。
__get_free_pages 分配的是物理内存上连续的页面,所以没有特殊需求时也不应该一次性分配较大的内存。在需要分配大内存时,如果不需求物理上连续,可以一页一页分配,然后映射到连续的虚拟地址空间上。
DMA
开发驱动时,有时需要为硬件设备分配一段内存用于 DMA 传输,这时可能就会用到 dma_alloc_coherent()。为 DMA 分配的内存会有以下特点,
- 内存必须是物理上连续的。
- 内存是不能被 cache 的,否则可能会产生数据的不一致。
- DMA 映射的区域可以同时被 CPU 和外围设备访问。
- 使用 DMA 时需要注意 cache,根据需要使用 dma_cache_sync() 来保证内存一致性。
ION
ION 是 google 为了解决不同 Android 设备的内存碎片问题,提出的一种内存管理器。它支持不同的内存分配机制,如 CARVOUT(PMEM),物理连续内存 (kmalloc),虚拟地址连续但物理不连续内存 (vmalloc),IOMMU 等。
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 空闲页面。
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,选择合适的时机启动后台内存回收。这些又是内存调优的话题,需要另一篇来阐述。
说明
本文只是对内核的内存管理做简单的介绍,目的是对其有一个整体的认识。其中的每一部分展开来都是很大的课题,本人水平有限不再深入分析。文章中的图片全部来自网络,源头也不是很清楚。