大家好,我是你们的老朋友 qmwneb946,一个在代码和数学的世界里摸爬滚打的技术博主。今天,我们将共同踏上一段充满挑战与奇妙的旅程——深入探索 Linux 内核的内存管理机制。
在现代操作系统中,内存管理是其核心中的核心。它不仅仅关乎程序能否正常运行,更直接影响着系统的性能、稳定性和安全性。想象一下,如果没有精妙的内存管理,多进程同时运行将是一场混乱的灾难,程序的并发性、隔离性、以及资源的有效利用都将无从谈起。Linux,作为当今最强大、最灵活的操作系统之一,其内存管理模块更是集大成者,凝聚了无数工程师的智慧结晶。
本文将从最基本的内存概念出发,逐步深入到 Linux 内核如何巧妙地将物理内存抽象为虚拟内存,如何高效地分配和回收内存,以及在各种复杂场景下的应对策略。无论你是操作系统爱好者,还是内核开发新手,亦或是希望更深层次理解系统运行机制的技术人,我相信这篇博客都能为你带来启发。
准备好了吗?让我们一起揭开 Linux 内存管理的神秘面纱!
内存管理基础概念
在深入 Linux 内核之前,我们必须先建立一些关于内存管理的基本共识。这些概念是理解后续复杂机制的基石。
物理内存与虚拟内存
这是内存管理中最核心的一对概念。
- 物理内存 (Physical Memory):又称主存、RAM (Random Access Memory),是我们计算机硬件中真实存在的存储芯片。它有固定的地址范围,每个地址对应一个存储单元。CPU 直接通过物理地址访问物理内存。
- 虚拟内存 (Virtual Memory):这是操作系统为每个进程提供的一个抽象概念。每个进程都以为自己拥有了一个连续的、完整的、私有的地址空间,这个空间通常远大于实际的物理内存。进程操作的都是虚拟地址。
那么,为什么要引入虚拟内存呢?
- 隔离性:每个进程都有独立的虚拟地址空间,进程之间无法直接访问对方的内存,从而提高了系统的稳定性和安全性。一个进程的崩溃通常不会影响其他进程。
- 安全性:操作系统可以通过控制虚拟地址到物理地址的映射,限制进程对内存的访问权限(读/写/执行),防止恶意程序破坏关键数据或代码。
- 地址空间的扩展:进程可以拥有比物理内存更大的地址空间,这使得程序设计更加灵活,不受物理内存大小的限制。
- 内存共享:尽管每个进程都有独立的虚拟地址空间,但操作系统可以通过将多个进程的虚拟地址映射到同一块物理内存,实现内存共享,从而提高效率(如共享库)。
- 内存抽象与管理:操作系统可以更灵活地管理物理内存,例如,将不常用的内存页换出到磁盘(交换空间),实现按需加载(Demand Paging),等等。
分页与分段
在将虚拟地址转换为物理地址时,主要有两种策略:分段 (Segmentation) 和分页 (Paging)。
-
分段:内存被划分为若干个逻辑段(如代码段、数据段、堆段、栈段),每个段有独立的起始地址和长度。一个虚拟地址由“段选择符”和“段内偏移”组成。
- 优点:符合程序逻辑结构,易于保护和共享。
- 缺点:段大小不固定,容易产生外部碎片;段的换入换出效率不高。
-
分页:将虚拟地址空间和物理地址空间都划分为固定大小的块。虚拟地址空间的块称为“页 (Page)”,物理地址空间的块称为“页框 (Page Frame)”或“物理页 (Physical Page)”。通常,一个页的大小是 或 等。虚拟地址由“页号”和“页内偏移”组成。
- 优点:固定大小的块更易于管理,可以有效解决外部碎片问题。易于实现按需加载和交换。
- 缺点:需要额外的页表来维护映射关系,可能导致内存开销。
Linux 选择了分页机制。这是因为分页在管理固定大小内存块方面具有显著优势,更适合现代操作系统的内存管理需求。
地址空间:用户空间与内核空间
Linux 将每个进程的虚拟地址空间划分为两个主要部分:
- 用户空间 (User Space):进程用户代码运行的区域。应用程序、库函数等都在用户空间运行,它们只能通过系统调用(如
read()
,write()
,mmap()
等)间接访问内核提供的服务。用户空间对内存的访问权限受到严格限制。 - 内核空间 (Kernel Space):操作系统内核代码运行的区域。这部分空间是所有进程共享的,用于执行系统调用、中断处理、驱动程序等。内核空间拥有对所有物理内存的直接访问权限,但普通用户进程无法直接访问。
在 32 位系统中,通常将虚拟地址空间的最高 划分为内核空间,其余 为用户空间。在 64 位系统中,由于地址空间极大,内核空间和用户空间都拥有庞大的地址范围,但它们之间仍然严格分离。例如,在 x86-64 架构下,用户空间通常使用 0x0000_0000_0000_0000
到 0x0000_7FFF_FFFF_FFFF
的地址,而内核空间则在 0xFFFF_8000_0000_0000
到 0xFFFF_FFFF_FFFF_FFFF
之间。
内存地址的翻译过程 (MMU)
从虚拟地址到物理地址的转换是由硬件——内存管理单元 (MMU, Memory Management Unit) 完成的。
当 CPU 尝试访问一个虚拟地址时:
- CPU 将虚拟地址发送给 MMU。
- MMU 使用虚拟地址中的“页号”作为索引,在当前进程的页表 (Page Table) 中查找对应的页表项 (Page Table Entry, PTE)。
- 如果找到了有效的页表项,PTE 中包含对应的物理页框号,以及访问权限位(如读、写、执行)。
- MMU 将物理页框号与虚拟地址中的“页内偏移”组合,形成最终的物理地址。
- MMU 将物理地址发送给内存控制器,从而访问到实际的物理内存单元。
这个过程如果每次都查询多级页表,效率会非常低。因此,MMU 内部还包含一个高速缓存,称为翻译后备缓冲器 (TLB, Translation Lookaside Buffer)。TLB 缓存了最近使用的虚拟地址到物理地址的映射关系。当 MMU 收到一个虚拟地址时,它首先查找 TLB。如果命中(TLB Hit),则直接获取物理地址;如果未命中(TLB Miss),则需要遍历多级页表,并将结果缓存到 TLB 中,以备下次使用。
物理内存管理
Linux 内核如何管理那些实实在在的物理内存呢?这涉及到一些底层的数据结构和算法。
物理内存的划分:NUMA, UMA, Zone
在管理物理内存时,Linux 内核会根据硬件架构和内存特性进行逻辑划分。
- UMA (Uniform Memory Access):统一内存访问架构。所有 CPU 访问所有内存的速度都是相同的。这在单处理器系统或小型对称多处理器 (SMP) 系统中比较常见。
- NUMA (Non-Uniform Memory Access):非统一内存访问架构。在大型多处理器系统中,CPU 被组织成多个节点 (Node),每个节点拥有自己的本地内存。CPU 访问本地内存的速度快于访问其他节点的远程内存。Linux 内核会感知 NUMA 拓扑,并尽量让进程在本地节点分配内存,以提高性能。每个 NUMA 节点在内核中对应一个
pg_data_t
结构体。
在一个 NUMA 节点(或 UMA 系统)内部,物理内存又被划分为不同的区域 (Zone):
ZONE_DMA
: 用于需要直接内存访问 (DMA) 的设备。这些设备通常只能访问物理地址 到 之间的内存区域。ZONE_DMA32
: (仅在 64 位系统上)用于 32 位 DMA 设备,它们可以访问物理地址 到 之间的内存。ZONE_NORMAL
: 操作系统可直接访问的常规内存区域。在 32 位系统上,通常指 到 的内存;在 64 位系统上,这是大多数物理内存所在的区域。内核可以直接进行虚拟地址到物理地址的映射(即直接映射)。ZONE_HIGHMEM
: (仅在 32 位系统上)高于 的内存区域。由于 32 位系统内核虚拟地址空间有限,这部分内存不能直接进行常驻映射,需要通过临时的“窗口”映射才能访问。64 位系统通常没有ZONE_HIGHMEM
,因为其虚拟地址空间足够大,可以直接映射所有物理内存。ZONE_MOVABLE
: 用于可移动页,这有助于内存碎片整理。
为什么需要 Zones?
Zones 的存在是为了解决不同硬件(特别是 32 位架构)对内存访问能力和范围的限制,以及更好地管理不同用途的内存。例如,DMA 区域的内存是稀缺资源,需要特殊管理。通过 Zones,内核可以隔离不同类型的内存,并为它们应用不同的分配策略,确保资源的合理利用。
伙伴系统 (Buddy System)
伙伴系统是 Linux 内核管理物理页框(通常是 大小)的核心算法。它主要负责分配和回收连续的物理内存页框。
原理
伙伴系统将所有物理内存页框组织成一个列表,列表中的每个元素都是一个大小为 个页框的块,其中 从 开始。例如,有 个页框的块, 个页框的块, 个页框的块,依此类推。
-
分配:当需要分配一个大小为 个页框的连续内存时,伙伴系统会向上取整找到最小的 的块。
- 它首先在 块的链表中查找是否有空闲块。
- 如果没有,它会在更大的 块链表中查找。
- 如果找到了一个 的块,它会将这个块一分为二(分裂成两个 的块),其中一个块用于满足请求,另一个块被放回 块的链表。
- 如果 也没有,则继续向上查找,直到找到一个足够大的块,并不断分裂,直到得到所需大小的块。
被分裂的两个块互为“伙伴”。
-
回收:当一个内存块被释放时,伙伴系统会检查它的“伙伴”是否也空闲。
- 如果伙伴也空闲,这两个伙伴块将合并成一个更大的块(大小是原来两倍)。
- 合并后的新块会再次检查它的伙伴是否空闲,如果空闲则继续合并,这个过程会递归进行,直到无法合并为止。
这个合并过程有效地减少了外部碎片。
优点与缺点
- 优点:
- 快速分配和释放:通过简单的位操作和链表操作即可完成。
- 有效缓解外部碎片:通过合并机制,可以形成更大的连续空闲块。
- 缺点:
- 内部碎片:如果请求的内存大小不是 的倍数,那么会分配一个比请求稍大的 块,造成内部碎片。例如,请求 个页框,会分配 个页框。
- 不适合小对象分配:如果频繁地分配和释放非常小的内存块(如单个页框的几分之一),会造成大量的页框分裂和合并操作,效率低下。
页分配器 (Page Allocator)
伙伴系统是底层机制,而页分配器则是内核对外提供接口。内核中的页分配函数通常使用 __get_free_pages()
或 alloc_pages()
。
__get_free_pages(gfp_mask, order)
:gfp_mask
:全局标志,指示内存分配的上下文和属性,例如:GFP_KERNEL
:最常用的标志,表示在进程上下文分配内存,允许睡眠和阻塞,可以进行页面回收。GFP_ATOMIC
:在中断上下文或不允许睡眠的地方分配内存,不允许阻塞,也不能进行页面回收,因此分配失败的可能性更高。GFP_DMA
:指示内存应从ZONE_DMA
分配。GFP_HIGHUSER
:优先从ZONE_HIGHMEM
分配用户空间内存。__GFP_ZERO
:分配的内存块初始化为零。
order
:表示分配 个连续的物理页框。order=0
表示一个页框,order=1
表示两个页框,依此类推。
alloc_pages(gfp_mask, order)
:与__get_free_pages()
类似,但返回的是struct page *
类型指针,而不是直接的虚拟地址。
示例(伪代码):
1 |
|
内存碎片 (Memory Fragmentation)
内存碎片是内存管理中一个老大难的问题。
- 内部碎片 (Internal Fragmentation):分配的内存块大于实际请求的大小。例如,伙伴系统分配 给一个 的请求,剩下的 成为内部碎片。
- 外部碎片 (External Fragmentation):总的空闲内存足够,但都是不连续的小块,无法满足一个大的连续内存请求。伙伴系统通过合并机制来缓解外部碎片,但仍然可能出现。
为了应对外部碎片,Linux 引入了一些机制:
- 迁移类型 (Migration Types):将页框分为不可移动 (unmovable)、可回收 (reclaimable) 和可移动 (movable) 三种类型。
- 不可移动页:不能被移动,例如内核代码段、页表等。
- 可回收页:可以被回收但不能被移动,例如文件页(可以通过写回磁盘后释放)。
- 可移动页:可以被移动到其他物理位置,例如用户空间进程的匿名页(当它们被换出到交换空间时,原物理页就空闲了)。
内核在分配内存时,会尽量将不同迁移类型的页分配到不同的物理区域,以减少碎片。
- 内存规整 (Memory Compaction):当系统出现严重的外部碎片,导致无法分配大块连续内存时,内核会尝试将可移动的页移动到一起,从而在物理内存中创建出更大的连续空闲区域。这个过程通常在后台由
kcompactd
线程触发。
虚拟内存管理
物理内存是硬件基础,而虚拟内存则是操作系统赋予进程的魔法。Linux 的虚拟内存管理是其复杂和精妙之处的集中体现。
页表 (Page Tables)
页表是虚拟地址到物理地址映射的关键数据结构。由于 64 位系统的虚拟地址空间巨大 (),不可能为每个进程维护一个巨大的扁平页表。因此,Linux 采用了多级页表结构。
在 x86-64 架构上,Linux 通常使用四级页表,加上一个中间的 P4D 级别,形成了五级页表:
- Page Global Directory (PGD):页全局目录,是页表的最高层。每个进程都有一个 PGD,其基地址存储在 CPU 的
CR3
寄存器中。 - Page Upper Directory (PUD):页上层目录。
- Page Middle Directory (PMD):页中间目录。
- Page Table Entry (PTE):页表项。
- Page Directory Entry (PDE):页目录项 (用于 HugePages, 实际是 PMD 的一个别名或直接映射页)。
一个 64 位虚拟地址通常被划分为:
1 | | 63-48位 (未使用或特殊) | 47-39位 (PGD索引) | 38-30位 (PUD索引) | 29-21位 (PMD索引) | 20-12位 (PTE索引) | 11-0位 (页内偏移) | |
(具体的位划分可能因架构和内核配置有所不同,这里以典型 x86-64 4KB 页为例)
地址转换的细节流程:
当 MMU 收到一个虚拟地址 VA 时:
- MMU 使用 VA 中的 PGD 索引,在
CR3
指向的 PGD 表中查找对应的 PGD 项,获取 PUD 表的物理地址。 - MMU 使用 VA 中的 PUD 索引,在 PUD 表中查找对应的 PUD 项,获取 PMD 表的物理地址。
- MMU 使用 VA 中的 PMD 索引,在 PMD 表中查找对应的 PMD 项,获取 PTE 表的物理地址。
- MMU 使用 VA 中的 PTE 索引,在 PTE 表中查找对应的 PTE 项。
- PTE 项包含了目标物理页框的基地址,以及该页的权限位(读、写、执行)和状态位(脏、已访问、存在位等)。
- MMU 将物理页框基地址与 VA 中的页内偏移组合,得到最终的物理地址。
TLB (Translation Lookaside Buffer) 作用
正如前面提到的,多级页表查询过程非常耗时。TLB 是一个位于 MMU 内部的高速缓存,它存储了最近使用的虚拟地址到物理地址的映射。当 CPU 访问一个虚拟地址时,首先查询 TLB。
- TLB Hit:如果在 TLB 中找到了对应的映射,MMU 立即得到物理地址,无需遍历页表,速度极快。
- TLB Miss:如果 TLB 中没有,MMU 则会执行上述多级页表遍历过程。成功获取映射后,会将此映射添加到 TLB 中,以加速未来的访问。
TLB 的存在极大地提升了内存访问效率。但当进程切换时,CR3
寄存器会改变,意味着页表也随之改变。此时,TLB 需要被刷新 (TLB Flush),以避免使用过期的映射,这会带来一定的性能开销。
内存描述符 (mm_struct
)
在 Linux 内核中,每个进程都由一个 task_struct
结构体表示。task_struct
内部有一个指针指向其对应的内存描述符 mm_struct
。mm_struct
结构体是进程虚拟地址空间的总览,它包含了进程地址空间的全部信息,例如:
mmap_sem
:用于同步对mm_struct
访问的信号量。pgd
:指向进程页全局目录 (PGD) 的指针。mmap
:指向进程的虚拟内存区域列表的头。total_vm
:进程总共映射的虚拟页数。locked_vm
:被锁定在内存中的虚拟页数(不允许换出)。stack_vm
:栈区域的页数。
vm_area_struct
(VMA):管理虚拟内存区域
mm_struct
描述了整个进程的地址空间,但具体到某个内存区域(如代码段、数据段、堆、栈、内存映射文件等),则由一个个独立的 vm_area_struct
(VMA) 结构体来描述。
每个 VMA 描述了一个连续的虚拟地址区域,它包含:
vm_start
和vm_end
:该区域的起始和结束虚拟地址。vm_flags
:该区域的权限标志(读、写、执行),以及其他属性(如共享、私有、可增长等)。vm_file
:如果该区域映射了一个文件,则指向对应的struct file
。vm_page_prot
:页面的保护属性(读/写/执行)。vm_next
,vm_prev
:用于将 VMA 链表连接起来。vm_rb
:用于将 VMA 组织成红黑树,方便快速查找。
VMA 的作用:
- 内存映射:无论是文件映射(
mmap
一个文件)还是匿名映射(堆、栈),都会创建相应的 VMA。 - 权限管理:每个 VMA 有独立的权限,当进程尝试非法访问时,MMU 会检测到权限错误并触发缺页异常。
- 按需分配:当一个进程
mmap
一个文件时,并不是立即将整个文件内容载入物理内存,而是只创建对应的 VMA。只有当进程实际访问到某个虚拟地址时,才会触发缺页中断,内核才按需加载相应的物理页。
缺页中断 (Page Fault)
缺页中断是虚拟内存管理中的一个核心事件。当 CPU 访问一个虚拟地址时,如果对应的物理页不在内存中(或者页表项不存在,或者访问权限不符),MMU 就会触发一个缺页异常 (Page Fault)。
触发时机:
- 页表项不存在:进程第一次访问某个虚拟地址,而该地址对应的页尚未被映射到任何物理页。这通常发生在按需分页或动态分配堆栈时。
- 页不在内存中:对应的页表项存在,但其“存在位 (Present Bit)”为 0,表示该页已被换出到磁盘(交换空间),或者尚未从文件加载。
- 权限错误:进程尝试对一个只读的页进行写操作,或者尝试执行一个不可执行的页。
处理流程 (do_page_fault()
):
当缺页中断发生时,控制权会转移到内核的缺页中断处理函数 do_page_fault()
:
- 判断缺页类型:内核首先检查出错的虚拟地址 (
fault_address
) 和错误代码 (error_code
),判断是页不存在、权限错误还是其他问题。 - 查找 VMA:内核在当前进程的
mm_struct
中查找fault_address
所属的 VMA。如果找不到对应的 VMA,则说明访问了一个非法地址(段错误),内核会发送SIGSEGV
信号给进程,通常导致进程终止。 - 权限检查:如果找到了 VMA,内核会再次检查访问权限是否与 VMA 的
vm_flags
匹配。如果不匹配,同样发送SIGSEGV
。 - 分配物理页:
- 匿名页 (Anonymous Page):如果是堆或栈的缺页,内核会分配一个新的物理页框,将其清零,然后更新页表,将虚拟地址映射到这个新的物理页。
- 文件页 (File Page):如果是文件映射(如可执行文件代码段、共享库),内核会从磁盘中读取相应的文件内容到新分配的物理页中,然后更新页表。
- 交换页 (Swap Page):如果页已被换出到交换空间,内核会从交换空间中将数据读回物理内存,然后更新页表。
- 更新页表和 TLB:一旦物理页准备就绪,内核会更新进程的页表,将虚拟地址映射到新的物理页。MMU 会刷新 TLB 中可能存在的过期映射。
- 重新执行指令:缺页处理完成后,CPU 会重新执行导致缺页的指令,此时内存访问应该能够成功。
按需分页 (Demand Paging)
缺页中断是实现按需分页的基础。当一个程序启动时,内核并不会立即将其所有代码和数据加载到内存,而是只映射它们的虚拟地址空间。只有当 CPU 真正访问到某个虚拟地址时,才触发缺页中断,内核才按需加载对应的物理页。这种机制极大地减少了程序的启动时间和内存占用。
内存映射 (Memory Mapping)
mmap()
系统调用是 Linux 中创建虚拟内存区域(VMA)的强大工具。它允许将文件或匿名内存区域映射到进程的虚拟地址空间。
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
addr
:建议的映射起始地址。length
:映射的长度。prot
:内存保护标志(PROT_READ
,PROT_WRITE
,PROT_EXEC
)。flags
:映射类型和行为(MAP_SHARED
,MAP_PRIVATE
,MAP_ANONYMOUS
)。fd
:文件描述符(如果映射文件)。offset
:文件偏移量。
共享内存与私有内存
MAP_SHARED
(共享映射):多个进程可以将同一个文件或匿名内存区域映射到各自的地址空间,它们看到的是同一份物理内存。对这块内存的修改会反映给所有映射的进程,并且对于文件映射而言,修改最终会写回磁盘文件。这是实现进程间通信 (IPC) 的一种高效方式。MAP_PRIVATE
(私有映射):映射的内存对每个进程来说是私有的。当进程对这块内存进行写操作时,会触发写时复制 (Copy-On-Write, COW) 机制。内核会为这个进程复制一份新的物理页,并将进程的虚拟地址映射到这个新的物理页,而原始物理页保持不变。这样,一个进程的修改不会影响其他进程或原始文件。私有映射常用于加载可执行文件和共享库,因为多个进程可以共享相同的代码页,但各自拥有私有的数据副本。
进程地址空间布局 (Process Address Space Layout)
每个 Linux 进程都拥有一个独立的 0 到最大虚拟地址的线性地址空间。这个空间通常具有以下典型布局:
1 | +------------------+ <- 0xFFFFFFFFFFFFFFFF (内核空间结束) |
- Text Segment (代码段):存放程序的可执行机器码。通常是只读的。
- Data Segment (数据段):存放已初始化的全局变量和静态变量。
- BSS Segment (Block Started by Symbol):存放未初始化的全局变量和静态变量。在程序加载时被清零。
- Heap (堆):用于动态内存分配(如
malloc()
/free()
)。从低地址向高地址增长。 - Memory Mapping Segment (内存映射区):通过
mmap()
调用创建的区域,包括共享库、文件映射、匿名映射等。 - Stack (栈):用于存放局部变量、函数参数、返回地址等。从高地址向低地址增长。
- Kernel Space (内核空间):所有进程共享的区域,用于运行内核代码。
ASLR (Address Space Layout Randomization)
为了增强系统的安全性,Linux 实现了地址空间布局随机化 (ASLR)。它会在程序加载时,将堆、栈、共享库以及 mmap
区域的起始地址进行随机化。这使得攻击者难以预测特定内存区域的地址,从而增加了利用缓冲区溢出等漏洞的难度。
内核空间内存管理
前述讨论主要集中在用户进程如何使用虚拟内存。那么内核自身在内核空间运行,它如何管理自己的内存呢?由于内核对内存有更直接、更严格的需求(例如需要物理连续的内存),因此它有自己一套独特的内存分配机制。
vmalloc:非连续物理内存的连续虚拟映射
vmalloc()
函数用于在内核虚拟地址空间中分配一块连续的虚拟地址,但这些虚拟地址背后对应的物理页框可能是不连续的。
- 原理:
vmalloc()
会在内核的vmalloc
区域(一个特定的虚拟地址范围)中找到一块足够大的连续虚拟地址。然后,它通过伙伴系统分配不连续的物理页框,并为这些物理页框建立新的页表映射,将它们映射到之前找到的连续虚拟地址上。 - 何时使用
vmalloc()
:- 需要大块内存(通常是几兆字节或更大)。
- 这块内存不需要物理连续,但内核代码需要连续的虚拟地址来简化指针操作和数据结构。
- 常见的用途包括:模块加载、帧缓冲、网络缓冲区等。
- 与
kmalloc()
的区别:kmalloc()
分配的是物理和虚拟都连续的内存。vmalloc()
分配的是虚拟连续但物理不连续的内存。kmalloc()
返回的地址可以直接用于 DMA(如果从ZONE_DMA
分配)。vmalloc()
返回的地址不能直接用于 DMA,因为它背后对应的物理页是不连续的,需要通过页表查询才能获取物理地址,或者使用专门的 DMA API。
1 |
|
slab 分配器 (Slab Allocator)
伙伴系统和 vmalloc()
主要用于分配大块内存。然而,内核在运行时会频繁地创建和销毁大量小型、固定大小的数据结构(如 task_struct
, inode
, dentry
等)。如果每次都通过伙伴系统分配一个页,然后只使用其中一小部分,会造成严重的内部碎片,且分配和释放效率低下。
Slab 分配器正是为了解决这个问题而设计的。它是一个基于对象的高速缓存分配器。
-
原理:
- Slab 分配器为每种内核对象类型(如
struct task_struct
)维护一个独立的缓存 (kmem_cache
)。 - 每个缓存由一个或多个
slab
组成。一个slab
是由一个或多个连续的物理页框构成的大内存块。 - 每个
slab
被进一步划分为多个固定大小的小对象 (Object)。 - 当内核需要一个特定类型的对象时,Slab 分配器会从对应的缓存中取出一个已经构造好的对象,而不是每次都从头开始分配一个页。
- 当对象被释放时,它被标记为空闲,并放回
slab
中,而不是立即归还给伙伴系统。 - 只有当
slab
完全空闲时,或者在内存压力下,才会被归还给伙伴系统。
- Slab 分配器为每种内核对象类型(如
-
优点:
- 减少内部碎片:每个
slab
内部的对象大小是固定的,因此没有内部碎片。 - 提高分配效率:避免了频繁的伙伴系统调用和页分裂合并。
- 对象构造/析构优化:Slab 可以缓存已构造的对象,避免重复初始化。
- CPU 缓存友好:将同一类型的对象放在一起,有利于 CPU 缓存命中率。
- 减少内部碎片:每个
-
Slab 的变种:
- SLAB (Old Slab Allocator):最初的实现,复杂但功能全面。
- SLUB (Simple Slab Allocator):Linux 2.6.23 后成为默认,简化了结构,提高了性能,特别是在 NUMA 系统上表现更好。
- SLOB (Simple List Of Blocks):用于嵌入式系统,内存占用最小,但性能最差。
示例(伪代码):
1 |
|
kmalloc:连续物理内存分配
kmalloc()
函数是内核中最常用的小块内存分配函数。它通过 Slab 分配器来分配内存,而 Slab 分配器又从伙伴系统获取页。kmalloc()
保证分配的内存是物理和虚拟都连续的。
-
void *kmalloc(size_t size, gfp_t flags);
size
:要分配的字节数。flags
:与__get_free_pages()
类似的GFP_
标志。
-
何时使用
kmalloc()
:- 需要分配小于一个页的内存。
- 需要内存块在物理上也是连续的(例如,某些硬件设备需要)。
- 通常用于分配内核数据结构。
-
与
vmalloc()
的区别总结:特性 kmalloc()
vmalloc()
物理连续性 保证物理连续 不保证物理连续(但虚拟连续) 虚拟连续性 保证虚拟连续 保证虚拟连续 适用大小 通常小于一个页(但可到几兆字节) 通常用于分配大块内存(几兆字节以上) 内存池 Slab Allocator(从伙伴系统获取页) 内核的 vmalloc
区域(创建新页表)DMA 适用性 适用于 DMA 不适用于传统 DMA(需特殊处理) 分配速度 快 相对慢(需要建立页表) 典型场景 内核数据结构、小缓冲区 模块加载、帧缓冲、大网络缓冲区
示例:
1 |
|
内存回收与交换
即使有再精妙的分配机制,物理内存终究是有限的。当系统面临内存压力时,Linux 内核会启动一系列内存回收机制,甚至将不活跃的内存页写入磁盘,以腾出空间。
LRU (Least Recently Used) 算法与内存页的生命周期
Linux 内核使用一种近似的 LRU 算法来管理内存页,决定哪些页是“不活跃”的,可以被回收。它将内存页分为两类:
- 活跃页 (Active List):最近被访问过的页,被认为是“热”页,不太可能被回收。
- 不活跃页 (Inactive List):最近没有被访问过的页,被认为是“冷”页,是回收的候选。
每个物理页 struct page
结构体中包含一些标志位(如 PG_active
, PG_referenced
, PG_dirty
)用于记录页的状态。
页面回收的过程通常是:
- 内核周期性地将活跃页链表中的页移动到不活跃页链表。
- 当内存不足时,内核会优先从不活跃页链表中选择页进行回收。
- 如果一个不活跃页在一段时间内再次被访问,它会重新被移回活跃页链表。
页面回收触发机制:kswapd
Linux 内核中有一个专门的后台进程 kswapd
(Kernel Swap Daemon)。它的主要职责是周期性地检查内存状态,并在空闲内存低于某个阈值时,主动进行页面回收。
kswapd
的工作流程:
- 当系统空闲内存降到
min_free_kbytes
(一个可配置的阈值)以下时,kswapd
被唤醒。 kswapd
开始扫描不活跃页链表,选择符合条件的页进行回收。- 文件页 (File-backed Page):如果是不活跃的文件页,且是干净的(未被修改),可以直接释放。如果被修改过(脏页),则需要将其内容写回磁盘上的文件,然后才能释放。
- 匿名页 (Anonymous Page):如果是不活跃的匿名页,且是干净的(通常发生在写时复制后),可以直接释放。如果被修改过(脏页),则需要将其内容写入交换空间 (Swap Space),然后才能释放。
- 回收完成后,
kswapd
再次检查空闲内存是否达到目标值。如果达到,则休眠;如果未达到,则继续回收。
OOM Killer (Out Of Memory Killer)
即使 kswapd
尽力回收内存,如果系统仍然面临极端内存不足的情况,或者某个进程突然需要大量内存而没有足够的空闲页,Linux 内核的OOM Killer (Out Of Memory Killer) 就会被激活。
- 触发时机:当系统内存(包括物理内存和交换空间)耗尽,并且所有内存回收手段都无效时。
- 选择杀死哪个进程:OOM Killer 会选择一个“最不无辜”的进程来杀死,以释放大量内存,从而挽救整个系统。它通过一个复杂的算法计算每个进程的 OOM Score。
- OOM Score 考虑了进程的内存占用、运行时间、优先级、是否是 root 进程等因素。
- 高 OOM Score 的进程更容易被杀死。用户可以通过
/proc/<pid>/oom_score_adj
调整进程的 OOM Score 修正值,来影响其被杀死的可能性(例如,将关键服务的oom_score_adj
设置为 -1000,使其不易被杀死)。
- 后果:被杀死的进程会收到
SIGKILL
信号,立即终止。这通常是系统最后的自救手段,但也意味着某个应用程序的非正常中断。
Swap 机制
交换 (Swap) 是内存管理中一个重要的补充机制,它允许操作系统将一部分不活跃的物理内存内容暂时存储到磁盘上,从而腾出物理内存给更活跃的进程或数据使用。
-
作用:
- 扩展可用内存:当物理内存不足时,可以利用磁盘空间作为“虚拟物理内存”。
- 按需加载:与按需分页类似,可以将不活跃的页面换出,需要时再换入。
- 处理内存过载:即使物理内存暂时不足,系统也能通过交换机制继续运行,而不是立即崩溃。
-
Swap 分区/文件:
- Swap 分区:一块专门用于交换的硬盘分区,没有文件系统。
- Swap 文件:一个位于文件系统中的文件,作为交换空间使用。
- 通常,Swap 分区的性能优于 Swap 文件。
-
Swappiness 参数:
vm.swappiness
是一个内核参数,位于/proc/sys/vm/swappiness
。- 它的取值范围是 0 到 100。
- 值越高 (趋近 100):内核越倾向于将不活跃的匿名页换出到交换空间,即使还有大量空闲物理内存。这在桌面系统上可能导致响应变慢。
- 值越低 (趋近 0):内核越倾向于保留匿名页在物理内存中,只有在极度内存不足时才进行交换。这在服务器上通常更受欢迎,因为它减少了磁盘 I/O,提高了性能。当设置为 0 时,通常意味着只有在物理内存完全耗尽时才使用交换。
调整 Swappiness(临时):
1 | sudo sysctl vm.swappiness=10 |
永久修改:编辑 /etc/sysctl.conf
添加或修改 vm.swappiness = 10
。
高级主题与未来展望
Linux 内存管理是一个持续演进的领域。除了上述核心机制,还有一些更高级的主题和未来的发展方向值得关注。
HugePages (大页)
标准页大小通常是 。但在某些高性能应用(如数据库、虚拟化、科学计算)中,大量内存被频繁访问。使用 的小页会导致:
- 大量的页表项:需要更多的内存来存储页表。
- 频繁的 TLB Miss:当访问的内存区域非常大时,TLB 很难全部缓存所有映射,导致频繁的 TLB Miss,从而需要多次查询多级页表,降低性能。
HugePages (大页) 解决了这些问题。它允许使用更大的页大小,例如 或 。
- 优点:
- 减少页表项数量:一个大页对应一个页表项,显著减少了页表所需的内存。
- 提高 TLB 命中率:一个 TLB 条目可以映射更大的内存区域,从而减少 TLB Miss,提高内存访问性能。
- 降低页表遍历开销:减少了页表查找的次数。
- 应用场景:Oracle 数据库、KVM 虚拟机、HPC 应用等。
使用 HugePages 需要管理员进行预配置,并且不能被交换出内存。
CMA (Contiguous Memory Allocator)
对于某些需要大块连续物理内存的设备(例如摄像头、GPU、特殊的 DMA 设备),即使有伙伴系统,也可能因为外部碎片而难以分配。
CMA (Contiguous Memory Allocator) 是 Linux 内核中为这类需求设计的机制。它会预留一个大的内存区域,这个区域内的页可以被用于常规页分配,但当需要连续内存时,内核会尝试将该区域内的常规页“迁移”走,从而在该区域内腾出连续的物理空间。
- CMA 区域的内存是“可移动”的,因此当设备需要连续内存时,可以对其进行“压缩”以满足需求。
- 这在嵌入式和移动设备领域尤为重要,因为它们经常有特殊的硬件加速器需要大块连续内存。
eBPF 与内存观测
eBPF (extended Berkeley Packet Filter) 是一种在 Linux 内核中安全执行用户定义程序的强大技术。通过 eBPF,我们可以在不修改内核代码的情况下,动态地在内核的关键事件点(如系统调用、函数入口/出口、调度事件、内存分配点)附加程序。
利用 eBPF,我们可以:
- 实时监控内存分配和释放:跟踪
kmalloc()
,vmalloc()
,mmap()
等调用,了解哪些进程、哪个调用栈正在分配内存。 - 分析页表活动:观察缺页中断的发生频率和类型。
- 诊断内存泄漏:通过关联内存分配和释放事件,识别未被释放的内存。
- 理解内存碎片:分析伙伴系统和 Slab 分配器的行为。
例如,BCC (BPF Compiler Collection) 工具包提供了许多基于 eBPF 的工具,可以轻松地对内存活动进行细粒度观测。
1 | # 使用 BCC 的 memleak 工具检测内存泄漏 |
这将实时显示正在增长的内存分配,帮助开发者快速定位潜在的内存泄漏。
内存管理的发展趋势
- 持久内存 (Persistent Memory/NVM):非易失性内存,结合了 RAM 的速度和存储的持久性。对这种新型内存的管理将带来新的挑战和机会,例如如何高效地利用其持久性,同时避免传统文件系统的开销。
- 异构内存管理:随着不同类型的内存(DDR、HBM、NVM、GPU 内存)在系统中并存,操作系统需要更智能地管理和调度数据在不同层级内存之间的移动,以优化性能。
- 硬件辅助的内存管理:新的 CPU 指令集和硬件特性会不断出现,旨在加速内存管理操作(如更高效的 TLB、硬件页表遍历优化)。
- 更细粒度的控制:未来的内存管理可能会提供更细粒度的控制,允许应用程序更好地指导内核进行内存分配和回收,以满足特定的性能或延迟要求。
结论
至此,我们已经深入探索了 Linux 内核内存管理这个庞大而精妙的体系。从物理内存的 Zone 划分和伙伴系统,到虚拟内存的页表和 VMA 结构,再到内核空间特有的 vmalloc
和 Slab 分配器,以及应对内存压力的 kswapd
和 OOM Killer,我们看到了 Linux 内核如何在一个有限的硬件资源上,为成千上万的进程构建起一个高效、安全、隔离的虚拟世界。
内存管理是操作系统复杂性的一个缩影,它体现了软件工程师在应对资源限制、性能需求和安全挑战时的卓越智慧。理解这些机制,不仅能帮助我们更好地调试和优化应用程序,更能让我们领略到现代操作系统的设计之美。
希望这篇长文能为你带来一次充实的技术之旅。作为 qmwneb946
,我将继续与大家分享更多有趣的、有深度的技术话题。如果你有任何疑问或想进一步探讨,欢迎在评论区交流!