大家好,我是你们的老朋友 qmwneb946,一个在代码和数学的世界里摸爬滚打的技术博主。今天,我们将共同踏上一段充满挑战与奇妙的旅程——深入探索 Linux 内核的内存管理机制。

在现代操作系统中,内存管理是其核心中的核心。它不仅仅关乎程序能否正常运行,更直接影响着系统的性能、稳定性和安全性。想象一下,如果没有精妙的内存管理,多进程同时运行将是一场混乱的灾难,程序的并发性、隔离性、以及资源的有效利用都将无从谈起。Linux,作为当今最强大、最灵活的操作系统之一,其内存管理模块更是集大成者,凝聚了无数工程师的智慧结晶。

本文将从最基本的内存概念出发,逐步深入到 Linux 内核如何巧妙地将物理内存抽象为虚拟内存,如何高效地分配和回收内存,以及在各种复杂场景下的应对策略。无论你是操作系统爱好者,还是内核开发新手,亦或是希望更深层次理解系统运行机制的技术人,我相信这篇博客都能为你带来启发。

准备好了吗?让我们一起揭开 Linux 内存管理的神秘面纱!

内存管理基础概念

在深入 Linux 内核之前,我们必须先建立一些关于内存管理的基本共识。这些概念是理解后续复杂机制的基石。

物理内存与虚拟内存

这是内存管理中最核心的一对概念。

  • 物理内存 (Physical Memory):又称主存、RAM (Random Access Memory),是我们计算机硬件中真实存在的存储芯片。它有固定的地址范围,每个地址对应一个存储单元。CPU 直接通过物理地址访问物理内存。
  • 虚拟内存 (Virtual Memory):这是操作系统为每个进程提供的一个抽象概念。每个进程都以为自己拥有了一个连续的、完整的、私有的地址空间,这个空间通常远大于实际的物理内存。进程操作的都是虚拟地址。

那么,为什么要引入虚拟内存呢?

  1. 隔离性:每个进程都有独立的虚拟地址空间,进程之间无法直接访问对方的内存,从而提高了系统的稳定性和安全性。一个进程的崩溃通常不会影响其他进程。
  2. 安全性:操作系统可以通过控制虚拟地址到物理地址的映射,限制进程对内存的访问权限(读/写/执行),防止恶意程序破坏关键数据或代码。
  3. 地址空间的扩展:进程可以拥有比物理内存更大的地址空间,这使得程序设计更加灵活,不受物理内存大小的限制。
  4. 内存共享:尽管每个进程都有独立的虚拟地址空间,但操作系统可以通过将多个进程的虚拟地址映射到同一块物理内存,实现内存共享,从而提高效率(如共享库)。
  5. 内存抽象与管理:操作系统可以更灵活地管理物理内存,例如,将不常用的内存页换出到磁盘(交换空间),实现按需加载(Demand Paging),等等。

分页与分段

在将虚拟地址转换为物理地址时,主要有两种策略:分段 (Segmentation) 和分页 (Paging)。

  • 分段:内存被划分为若干个逻辑段(如代码段、数据段、堆段、栈段),每个段有独立的起始地址和长度。一个虚拟地址由“段选择符”和“段内偏移”组成。

    • 优点:符合程序逻辑结构,易于保护和共享。
    • 缺点:段大小不固定,容易产生外部碎片;段的换入换出效率不高。
  • 分页:将虚拟地址空间和物理地址空间都划分为固定大小的块。虚拟地址空间的块称为“页 (Page)”,物理地址空间的块称为“页框 (Page Frame)”或“物理页 (Physical Page)”。通常,一个页的大小是 4KB4\text{KB}2MB2\text{MB} 等。虚拟地址由“页号”和“页内偏移”组成。

    • 优点:固定大小的块更易于管理,可以有效解决外部碎片问题。易于实现按需加载和交换。
    • 缺点:需要额外的页表来维护映射关系,可能导致内存开销。

Linux 选择了分页机制。这是因为分页在管理固定大小内存块方面具有显著优势,更适合现代操作系统的内存管理需求。

地址空间:用户空间与内核空间

Linux 将每个进程的虚拟地址空间划分为两个主要部分:

  • 用户空间 (User Space):进程用户代码运行的区域。应用程序、库函数等都在用户空间运行,它们只能通过系统调用(如 read(), write(), mmap() 等)间接访问内核提供的服务。用户空间对内存的访问权限受到严格限制。
  • 内核空间 (Kernel Space):操作系统内核代码运行的区域。这部分空间是所有进程共享的,用于执行系统调用、中断处理、驱动程序等。内核空间拥有对所有物理内存的直接访问权限,但普通用户进程无法直接访问。

在 32 位系统中,通常将虚拟地址空间的最高 1GB1\text{GB} 划分为内核空间,其余 3GB3\text{GB} 为用户空间。在 64 位系统中,由于地址空间极大,内核空间和用户空间都拥有庞大的地址范围,但它们之间仍然严格分离。例如,在 x86-64 架构下,用户空间通常使用 0x0000_0000_0000_00000x0000_7FFF_FFFF_FFFF 的地址,而内核空间则在 0xFFFF_8000_0000_00000xFFFF_FFFF_FFFF_FFFF 之间。

内存地址的翻译过程 (MMU)

从虚拟地址到物理地址的转换是由硬件——内存管理单元 (MMU, Memory Management Unit) 完成的。

当 CPU 尝试访问一个虚拟地址时:

  1. CPU 将虚拟地址发送给 MMU。
  2. MMU 使用虚拟地址中的“页号”作为索引,在当前进程的页表 (Page Table) 中查找对应的页表项 (Page Table Entry, PTE)。
  3. 如果找到了有效的页表项,PTE 中包含对应的物理页框号,以及访问权限位(如读、写、执行)。
  4. MMU 将物理页框号与虚拟地址中的“页内偏移”组合,形成最终的物理地址。
  5. 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) 的设备。这些设备通常只能访问物理地址 0MB0\text{MB}16MB16\text{MB} 之间的内存区域。
  • ZONE_DMA32: (仅在 64 位系统上)用于 32 位 DMA 设备,它们可以访问物理地址 0MB0\text{MB}4GB4\text{GB} 之间的内存。
  • ZONE_NORMAL: 操作系统可直接访问的常规内存区域。在 32 位系统上,通常指 16MB16\text{MB}896MB896\text{MB} 的内存;在 64 位系统上,这是大多数物理内存所在的区域。内核可以直接进行虚拟地址到物理地址的映射(即直接映射)。
  • ZONE_HIGHMEM: (仅在 32 位系统上)高于 896MB896\text{MB} 的内存区域。由于 32 位系统内核虚拟地址空间有限,这部分内存不能直接进行常驻映射,需要通过临时的“窗口”映射才能访问。64 位系统通常没有 ZONE_HIGHMEM,因为其虚拟地址空间足够大,可以直接映射所有物理内存。
  • ZONE_MOVABLE: 用于可移动页,这有助于内存碎片整理。

为什么需要 Zones?
Zones 的存在是为了解决不同硬件(特别是 32 位架构)对内存访问能力和范围的限制,以及更好地管理不同用途的内存。例如,DMA 区域的内存是稀缺资源,需要特殊管理。通过 Zones,内核可以隔离不同类型的内存,并为它们应用不同的分配策略,确保资源的合理利用。

伙伴系统 (Buddy System)

伙伴系统是 Linux 内核管理物理页框(通常是 4KB4\text{KB} 大小)的核心算法。它主要负责分配和回收连续的物理内存页框

原理

伙伴系统将所有物理内存页框组织成一个列表,列表中的每个元素都是一个大小为 2N2^N 个页框的块,其中 NN00 开始。例如,有 20=12^0 = 1 个页框的块, 21=22^1 = 2 个页框的块, 22=42^2 = 4 个页框的块,依此类推。

  • 分配:当需要分配一个大小为 XX 个页框的连续内存时,伙伴系统会向上取整找到最小的 2NX2^N \ge X 的块。

    1. 它首先在 2N2^N 块的链表中查找是否有空闲块。
    2. 如果没有,它会在更大的 2N+12^{N+1} 块链表中查找。
    3. 如果找到了一个 2N+12^{N+1} 的块,它会将这个块一分为二(分裂成两个 2N2^N 的块),其中一个块用于满足请求,另一个块被放回 2N2^N 块的链表。
    4. 如果 2N+12^{N+1} 也没有,则继续向上查找,直到找到一个足够大的块,并不断分裂,直到得到所需大小的块。
      被分裂的两个块互为“伙伴”。
  • 回收:当一个内存块被释放时,伙伴系统会检查它的“伙伴”是否也空闲。

    1. 如果伙伴也空闲,这两个伙伴块将合并成一个更大的块(大小是原来两倍)。
    2. 合并后的新块会再次检查它的伙伴是否空闲,如果空闲则继续合并,这个过程会递归进行,直到无法合并为止。
      这个合并过程有效地减少了外部碎片。

优点与缺点

  • 优点
    • 快速分配和释放:通过简单的位操作和链表操作即可完成。
    • 有效缓解外部碎片:通过合并机制,可以形成更大的连续空闲块。
  • 缺点
    • 内部碎片:如果请求的内存大小不是 2N2^N 的倍数,那么会分配一个比请求稍大的 2N2^N 块,造成内部碎片。例如,请求 33 个页框,会分配 44 个页框。
    • 不适合小对象分配:如果频繁地分配和释放非常小的内存块(如单个页框的几分之一),会造成大量的页框分裂和合并操作,效率低下。

页分配器 (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:表示分配 2order2^{order} 个连续的物理页框。order=0 表示一个页框,order=1 表示两个页框,依此类推。
  • alloc_pages(gfp_mask, order):与 __get_free_pages() 类似,但返回的是 struct page * 类型指针,而不是直接的虚拟地址。

示例(伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <linux/gfp.h>
#include <linux/mm.h>
#include <linux/slab.h> // for kmalloc example

// 分配一个页框 (4KB)
void *page_ptr = (void *)__get_free_pages(GFP_KERNEL, 0);
if (!page_ptr) {
// 内存分配失败
printk(KERN_ERR "Failed to allocate a page.\n");
} else {
// 成功分配,do something with page_ptr
printk(KERN_INFO "Successfully allocated a page at 0x%lx\n", (unsigned long)page_ptr);
// 释放内存
free_pages((unsigned long)page_ptr, 0);
}

// 分配 8 个连续页框 (32KB)
void *eight_pages_ptr = (void *)__get_free_pages(GFP_KERNEL, 3); // 2^3 = 8 pages
if (!eight_pages_ptr) {
printk(KERN_ERR "Failed to allocate 8 pages.\n");
} else {
printk(KERN_INFO "Successfully allocated 8 pages at 0x%lx\n", (unsigned long)eight_pages_ptr);
free_pages((unsigned long)eight_pages_ptr, 3);
}

内存碎片 (Memory Fragmentation)

内存碎片是内存管理中一个老大难的问题。

  • 内部碎片 (Internal Fragmentation):分配的内存块大于实际请求的大小。例如,伙伴系统分配 4KB4\text{KB} 给一个 1KB1\text{KB} 的请求,剩下的 3KB3\text{KB} 成为内部碎片。
  • 外部碎片 (External Fragmentation):总的空闲内存足够,但都是不连续的小块,无法满足一个大的连续内存请求。伙伴系统通过合并机制来缓解外部碎片,但仍然可能出现。

为了应对外部碎片,Linux 引入了一些机制:

  • 迁移类型 (Migration Types):将页框分为不可移动 (unmovable)、可回收 (reclaimable) 和可移动 (movable) 三种类型。
    • 不可移动页:不能被移动,例如内核代码段、页表等。
    • 可回收页:可以被回收但不能被移动,例如文件页(可以通过写回磁盘后释放)。
    • 可移动页:可以被移动到其他物理位置,例如用户空间进程的匿名页(当它们被换出到交换空间时,原物理页就空闲了)。
      内核在分配内存时,会尽量将不同迁移类型的页分配到不同的物理区域,以减少碎片。
  • 内存规整 (Memory Compaction):当系统出现严重的外部碎片,导致无法分配大块连续内存时,内核会尝试将可移动的页移动到一起,从而在物理内存中创建出更大的连续空闲区域。这个过程通常在后台由 kcompactd 线程触发。

虚拟内存管理

物理内存是硬件基础,而虚拟内存则是操作系统赋予进程的魔法。Linux 的虚拟内存管理是其复杂和精妙之处的集中体现。

页表 (Page Tables)

页表是虚拟地址到物理地址映射的关键数据结构。由于 64 位系统的虚拟地址空间巨大 (2642^{64}),不可能为每个进程维护一个巨大的扁平页表。因此,Linux 采用了多级页表结构。

在 x86-64 架构上,Linux 通常使用四级页表,加上一个中间的 P4D 级别,形成了五级页表

  1. Page Global Directory (PGD):页全局目录,是页表的最高层。每个进程都有一个 PGD,其基地址存储在 CPU 的 CR3 寄存器中。
  2. Page Upper Directory (PUD):页上层目录。
  3. Page Middle Directory (PMD):页中间目录。
  4. Page Table Entry (PTE):页表项。
  5. 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 时:

  1. MMU 使用 VA 中的 PGD 索引,在 CR3 指向的 PGD 表中查找对应的 PGD 项,获取 PUD 表的物理地址。
  2. MMU 使用 VA 中的 PUD 索引,在 PUD 表中查找对应的 PUD 项,获取 PMD 表的物理地址。
  3. MMU 使用 VA 中的 PMD 索引,在 PMD 表中查找对应的 PMD 项,获取 PTE 表的物理地址。
  4. MMU 使用 VA 中的 PTE 索引,在 PTE 表中查找对应的 PTE 项。
  5. PTE 项包含了目标物理页框的基地址,以及该页的权限位(读、写、执行)和状态位(脏、已访问、存在位等)。
  6. 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_structmm_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_startvm_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)

触发时机:

  1. 页表项不存在:进程第一次访问某个虚拟地址,而该地址对应的页尚未被映射到任何物理页。这通常发生在按需分页或动态分配堆栈时。
  2. 页不在内存中:对应的页表项存在,但其“存在位 (Present Bit)”为 0,表示该页已被换出到磁盘(交换空间),或者尚未从文件加载。
  3. 权限错误:进程尝试对一个只读的页进行写操作,或者尝试执行一个不可执行的页。

处理流程 (do_page_fault()):

当缺页中断发生时,控制权会转移到内核的缺页中断处理函数 do_page_fault()

  1. 判断缺页类型:内核首先检查出错的虚拟地址 (fault_address) 和错误代码 (error_code),判断是页不存在、权限错误还是其他问题。
  2. 查找 VMA:内核在当前进程的 mm_struct 中查找 fault_address 所属的 VMA。如果找不到对应的 VMA,则说明访问了一个非法地址(段错误),内核会发送 SIGSEGV 信号给进程,通常导致进程终止。
  3. 权限检查:如果找到了 VMA,内核会再次检查访问权限是否与 VMA 的 vm_flags 匹配。如果不匹配,同样发送 SIGSEGV
  4. 分配物理页
    • 匿名页 (Anonymous Page):如果是堆或栈的缺页,内核会分配一个新的物理页框,将其清零,然后更新页表,将虚拟地址映射到这个新的物理页。
    • 文件页 (File Page):如果是文件映射(如可执行文件代码段、共享库),内核会从磁盘中读取相应的文件内容到新分配的物理页中,然后更新页表。
    • 交换页 (Swap Page):如果页已被换出到交换空间,内核会从交换空间中将数据读回物理内存,然后更新页表。
  5. 更新页表和 TLB:一旦物理页准备就绪,内核会更新进程的页表,将虚拟地址映射到新的物理页。MMU 会刷新 TLB 中可能存在的过期映射。
  6. 重新执行指令:缺页处理完成后,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
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
+------------------+ <- 0xFFFFFFFFFFFFFFFF (内核空间结束)
| |
| Kernel Space |
| |
+------------------+ <- 0xFFFF800000000000 (内核空间开始,64位系统为例)
| |
| ... |
| |
+------------------+ <- Stack Base Address (栈底)
| |
| Stack | (向下增长,用于局部变量、函数调用)
| |
+------------------+ <- mmap Region (内存映射区,通常向上增长)
| |
| Dynamic Shared |
| Libraries |
| |
+------------------+ <- Heap (堆,动态内存分配,向上增长)
| |
| ... |
| |
+------------------+ <- BSS Segment (未初始化数据)
| |
+------------------+ <- Data Segment (已初始化数据)
| |
+------------------+ <- Text Segment (代码段)
| |
+------------------+ <- 0x0000000000000000 (用户空间开始)
  • 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
2
3
4
5
6
7
8
9
10
11
#include <linux/vmalloc.h>

// 分配 1MB 的虚拟连续内存
void *vmem_ptr = vmalloc(1024 * 1024);
if (!vmem_ptr) {
printk(KERN_ERR "Failed to allocate 1MB with vmalloc.\n");
} else {
printk(KERN_INFO "vmalloc allocated at 0x%lx\n", (unsigned long)vmem_ptr);
// 使用 vmem_ptr ...
vfree(vmem_ptr);
}

slab 分配器 (Slab Allocator)

伙伴系统和 vmalloc() 主要用于分配大块内存。然而,内核在运行时会频繁地创建和销毁大量小型、固定大小的数据结构(如 task_struct, inode, dentry 等)。如果每次都通过伙伴系统分配一个页,然后只使用其中一小部分,会造成严重的内部碎片,且分配和释放效率低下。

Slab 分配器正是为了解决这个问题而设计的。它是一个基于对象的高速缓存分配器

  • 原理

    1. Slab 分配器为每种内核对象类型(如 struct task_struct)维护一个独立的缓存 (kmem_cache)。
    2. 每个缓存由一个或多个 slab 组成。一个 slab 是由一个或多个连续的物理页框构成的大内存块。
    3. 每个 slab 被进一步划分为多个固定大小的小对象 (Object)。
    4. 当内核需要一个特定类型的对象时,Slab 分配器会从对应的缓存中取出一个已经构造好的对象,而不是每次都从头开始分配一个页。
    5. 当对象被释放时,它被标记为空闲,并放回 slab 中,而不是立即归还给伙伴系统。
    6. 只有当 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
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
35
36
37
38
39
40
41
42
#include <linux/slab.h>

struct my_custom_object {
int data1;
char name[32];
// ...
};

static struct kmem_cache *my_object_cache;

// 在模块初始化时创建缓存
static int __init my_module_init(void) {
my_object_cache = kmem_cache_create(
"my_custom_object_cache", // 缓存名称
sizeof(struct my_custom_object), // 对象大小
0, // 对齐
SLAB_HWCACHE_ALIGN, // 缓存对齐标志
NULL // 构造/析构函数
);
if (!my_object_cache) {
return -ENOMEM;
}
printk(KERN_INFO "my_object_cache created.\n");
return 0;
}

// 分配一个对象
struct my_custom_object *obj = kmem_cache_alloc(my_object_cache, GFP_KERNEL);
if (obj) {
obj->data1 = 123;
// ...
// 释放对象
kmem_cache_free(my_object_cache, obj);
}

// 在模块退出时销毁缓存
static void __exit my_module_exit(void) {
if (my_object_cache) {
kmem_cache_destroy(my_object_cache);
printk(KERN_INFO "my_object_cache destroyed.\n");
}
}

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
2
3
4
5
6
7
8
9
#include <linux/slab.h>

// 分配 128 字节的内存
char *buf = kmalloc(128, GFP_KERNEL);
if (buf) {
memset(buf, 0, 128);
// 使用 buf ...
kfree(buf);
}

内存回收与交换

即使有再精妙的分配机制,物理内存终究是有限的。当系统面临内存压力时,Linux 内核会启动一系列内存回收机制,甚至将不活跃的内存页写入磁盘,以腾出空间。

LRU (Least Recently Used) 算法与内存页的生命周期

Linux 内核使用一种近似的 LRU 算法来管理内存页,决定哪些页是“不活跃”的,可以被回收。它将内存页分为两类:

  • 活跃页 (Active List):最近被访问过的页,被认为是“热”页,不太可能被回收。
  • 不活跃页 (Inactive List):最近没有被访问过的页,被认为是“冷”页,是回收的候选。

每个物理页 struct page 结构体中包含一些标志位(如 PG_active, PG_referenced, PG_dirty)用于记录页的状态。
页面回收的过程通常是:

  1. 内核周期性地将活跃页链表中的页移动到不活跃页链表。
  2. 当内存不足时,内核会优先从不活跃页链表中选择页进行回收。
  3. 如果一个不活跃页在一段时间内再次被访问,它会重新被移回活跃页链表。

页面回收触发机制:kswapd

Linux 内核中有一个专门的后台进程 kswapd(Kernel Swap Daemon)。它的主要职责是周期性地检查内存状态,并在空闲内存低于某个阈值时,主动进行页面回收。

kswapd 的工作流程:

  1. 当系统空闲内存降到 min_free_kbytes(一个可配置的阈值)以下时,kswapd 被唤醒。
  2. kswapd 开始扫描不活跃页链表,选择符合条件的页进行回收。
    • 文件页 (File-backed Page):如果是不活跃的文件页,且是干净的(未被修改),可以直接释放。如果被修改过(脏页),则需要将其内容写回磁盘上的文件,然后才能释放。
    • 匿名页 (Anonymous Page):如果是不活跃的匿名页,且是干净的(通常发生在写时复制后),可以直接释放。如果被修改过(脏页),则需要将其内容写入交换空间 (Swap Space),然后才能释放。
  3. 回收完成后,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 (大页)

标准页大小通常是 4KB4\text{KB}。但在某些高性能应用(如数据库、虚拟化、科学计算)中,大量内存被频繁访问。使用 4KB4\text{KB} 的小页会导致:

  1. 大量的页表项:需要更多的内存来存储页表。
  2. 频繁的 TLB Miss:当访问的内存区域非常大时,TLB 很难全部缓存所有映射,导致频繁的 TLB Miss,从而需要多次查询多级页表,降低性能。

HugePages (大页) 解决了这些问题。它允许使用更大的页大小,例如 2MB2\text{MB}1GB1\text{GB}

  • 优点
    • 减少页表项数量:一个大页对应一个页表项,显著减少了页表所需的内存。
    • 提高 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
2
# 使用 BCC 的 memleak 工具检测内存泄漏
sudo /usr/share/bcc/tools/memleak -a

这将实时显示正在增长的内存分配,帮助开发者快速定位潜在的内存泄漏。

内存管理的发展趋势

  • 持久内存 (Persistent Memory/NVM):非易失性内存,结合了 RAM 的速度和存储的持久性。对这种新型内存的管理将带来新的挑战和机会,例如如何高效地利用其持久性,同时避免传统文件系统的开销。
  • 异构内存管理:随着不同类型的内存(DDR、HBM、NVM、GPU 内存)在系统中并存,操作系统需要更智能地管理和调度数据在不同层级内存之间的移动,以优化性能。
  • 硬件辅助的内存管理:新的 CPU 指令集和硬件特性会不断出现,旨在加速内存管理操作(如更高效的 TLB、硬件页表遍历优化)。
  • 更细粒度的控制:未来的内存管理可能会提供更细粒度的控制,允许应用程序更好地指导内核进行内存分配和回收,以满足特定的性能或延迟要求。

结论

至此,我们已经深入探索了 Linux 内核内存管理这个庞大而精妙的体系。从物理内存的 Zone 划分和伙伴系统,到虚拟内存的页表和 VMA 结构,再到内核空间特有的 vmalloc 和 Slab 分配器,以及应对内存压力的 kswapd 和 OOM Killer,我们看到了 Linux 内核如何在一个有限的硬件资源上,为成千上万的进程构建起一个高效、安全、隔离的虚拟世界。

内存管理是操作系统复杂性的一个缩影,它体现了软件工程师在应对资源限制、性能需求和安全挑战时的卓越智慧。理解这些机制,不仅能帮助我们更好地调试和优化应用程序,更能让我们领略到现代操作系统的设计之美。

希望这篇长文能为你带来一次充实的技术之旅。作为 qmwneb946,我将继续与大家分享更多有趣的、有深度的技术话题。如果你有任何疑问或想进一步探讨,欢迎在评论区交流!