深入理解Linux内存管理:从进程地址空间到物理页框

本文将带你深入Linux内核,揭示一个malloc调用背后,操作系统是如何为你默默耕耘的。我们将沿着以下路径,完整地走过内存管理的旅程:

  1. 宏观规划(进程视角)mm_struct - 进程的“内存总部”
  2. 精细分区(进程视角)vm_area_struct - 虚拟内存区域的“地块规划图”
  3. 物理调配(系统视角)struct zone - 物理内存的“仓库分区”
  4. 最小单元(系统视角)struct page - 物理页框的“身份证”

第一章:进程的“内存总部” - struct mm_struct

想象一下,每个进程都拥有一个独立的4GB(32位系统)虚拟地址空间。mm_struct就是这个庞大地址空间的总指挥部

1
2
3
4
5
6
7
8
9
10
struct mm_struct {
struct vm_area_struct *mmap; // 链表:用于遍历所有区域
struct rb_root mm_rb; // 红黑树:用于快速查找地址所在区域
pgd_t *pgd; // 页表根目录:地址翻译的“地图”
atomic_t mm_users; // 共享此地址空间的线程数
atomic_t mm_count; // 引用计数,为0时释放整个结构体
struct list_head mmlist; // 链接所有进程mm_struct的全局链表
unsigned long start_code, end_code; // 代码段的起止地址
// ... 其他字段如堆、栈的边界等
};

核心字段精解:

  • pgd_t *pgd(考试重点!)
    • 这是进程页表的根指针。当CPU调度到这个进程时,就是把这个pgd加载到CR3控制寄存器,从而切换地址空间。这是进程隔离的硬件基础。
  • mmapmm_rb(设计思想与效率的平衡)
    • 问题:如何快速判断一个地址(比如0x40001000)属于进程的哪个内存区域?
    • 答案:使用两种数据结构协同工作。
      • mmap:一个链表。当需要遍历所有内存区域时(如整个进程的内存dump),链表很高效。
      • mm_rb:一棵红黑树。当需要根据一个地址快速查找其所属的VMA时(如在缺页异常中),红黑树的O(logN)复杂度远胜于链表的O(N)。
  • mm_usersmm_count(极易混淆的考试点!)
    • mm_users:使用该地址空间的用户数(通常是线程数)。fork创建线程时,这个值会增加。
    • mm_count:该mm_struct本身的主引用计数。它计数的对象包括用户线程、内核临时引用等。当mm_count减为0时,内核才会销毁这个mm_struct
    • 简单比喻mm_users像是租用一套房子的租客数量,而mm_count是这套房子本身的“生命值”,只有当没有任何租客且房子本身也不再被需要时,才会拆掉房子。

mm_struct 的生命周期:

  • 创建:在fork系统调用中,为新进程创建。
  • 使用:进程的每一次内存访问(读/写/执行)都间接地通过它来查询页表。
  • 销毁:当进程的所有线程都退出,且内核引用也释放后(mm_count=0),它被销毁。

第二章:虚拟内存的“地块规划图” - struct vm_area_struct

一个进程的地址空间不是一整块,而是被划分为多个连续的、具有相同访问属性(读、写、执行) 的区域。每个这样的区域就是一个vm_area_struct

1
2
3
4
5
6
7
8
9
struct vm_area_struct {
struct mm_struct *vm_mm; // 指向所属的“总部”
unsigned long vm_start; // 区域起始地址(包含)
unsigned long vm_end; // 区域结束地址(不包含)
struct vm_area_struct *vm_next; // 链表下一个VMA
struct rb_node vm_rb; // 红黑树节点
struct file *vm_file; // 如果是文件映射,指向文件对象
// ... 权限标志、操作集合等
};

核心字段精解:

  • vm_startvm_end:定义了一个[vm_start, vm_end)的虚拟地址区间。
  • vm_file(重要概念!)
    • 为NULL:表示这是一个匿名映射。例如进程的堆(heap)栈(stack) 和匿名共享内存。
    • 非NULL:表示这是一个文件映射。例如将动态库(如libc.so)加载到内存,或者通过mmap系统调用将一个文件映射到进程地址空间。

一个典型进程的VMA布局:

1
2
3
4
5
VMA1: 0x00400000-0x00401000 (代码段, r-x, 文件映射: /bin/myapp)
VMA2: 0x00600000-0x00601000 (数据段, rw-, 文件映射: /bin/myapp)
VMA3: 0x00601000-0x00622000 (堆, rw-, 匿名映射) [通过brk/sbrk增长]
VMA4: 0x7ffe0000-0x7ffe3000 (栈, rw-, 匿名映射)
VMA5: 0x7fxxxxxx-0x7fxxxxxx (libc库, r-x, 文件映射: /lib/x86_64-linux-gnu/libc.so.6)

mm_structvm_area_struct 的关系(必考!)
task_struct (进程) -> mm_struct (内存总部) -> mmap链表/mm_rb树 -> 多个vm_area_struct (内存地块)。


第三章:物理内存的“仓库分区” - struct zone

上面讲的是进程视角的虚拟内存。现在我们把目光转向操作系统管理的物理内存。由于硬件限制,物理内存被划分为不同的区(Zone)

1
2
3
4
5
6
7
8
9
struct zone {
unsigned long free_pages; // 该zone的空闲页总数
struct per_cpu_pageset pageset[NR_CPUS]; // 每CPU页缓存,优化单页分配
struct free_area free_area[MAX_ORDER]; // 伙伴系统的核心!11个空闲链表
struct list_head active_list; // 活跃页链表
struct list_head inactive_list; // 非活跃页链表
struct page *zone_mem_map; // 指向该zone的第一个page结构
// ... 水位线、统计信息等
};

为什么要分区?(考试点)

  1. ZONE_DMA (0-16MB):一些老式的DMA设备只能对物理内存的低16MB进行直接内存访问。
  2. ZONE_NORMAL (16MB-896MB):这部分内存被永久映射到内核的虚拟地址空间,内核可以直接访问。大部分内核操作发生在这里。
  3. ZONE_HIGHMEM (>896MB,仅在32位系统有):内核无法直接访问,需要动态建立临时映射。64位系统地址空间巨大,没有这个区。

伙伴系统(Buddy System) - free_area[](核心考点!)

  • 要解决的问题外部碎片——虽然有大量空闲内存,但都是小碎片,无法满足大的连续内存分配请求。

  • 工作原理:将空闲物理页框按块组织,每个块的大小是2的幂次方个页。

    • free_area[0]:链接所有单个空闲页(4KB)。
    • free_area[1]:链接所有2个连续空闲页组成的块(8KB)。
    • free_area[10]:链接所有1024个连续空闲页组成的块(4MB)。
  • 分配过程(以分配8个页为例,即order=3)

    1. 检查free_area[3]链表是否为空。
    2. 如果非空,直接分配链表的第一个块。
    3. 如果为空,向上查询free_area[4]
    4. 如果free_area[4]有块,将其分裂成两个order=3的“伙伴”块。
    5. 一个用于分配,另一个放入free_area[3]链表。
  • 释放过程

    1. 释放一个order=3的块。
    2. 查找它的“伙伴”块(地址相邻、大小相同)是否也是空闲的。
    3. 如果是,将两个伙伴块合并成一个order=4的块,并放入free_area[4]链表。
    4. 继续向上尝试合并,直到不能合并为止。

页框回收 - active_listinactive_list

当系统内存不足时,内核需要回收一些不常用的页框。它使用LRU(最近最少使用) 的近似算法:

  • 活跃链表:存放最近被访问过的页。
  • 非活跃链表:存放候选被回收的页。
  • 内核线程kswapd会定期将活跃链表中长时间未访问的页移到非活跃链表,然后优先回收非活跃链表中的页。

第四章:物理页框的“身份证” - struct page

物理内存的最小单位是页框(Page Frame),通常是4KB。内核为系统中的每一个物理页框都创建了一个struct page结构体,作为它的“身份证”或管理元数据。

1
2
3
4
5
6
7
8
9
struct page {
unsigned long flags; // 页的状态位图(极其重要!)
atomic_t _count; // 页的引用计数
atomic_t _mapcount; // 页表映射计数
struct address_space *mapping; // 指向所属的地址空间
pgoff_t index; // 在地址空间内的偏移
struct list_head lru; // 用于链接到zone的active/inactive链表
void *virtual; // 页的内核虚拟地址(高端内存需要)
};

核心字段精解:

  • flags(考试重点):使用位来表示页的多种状态。

    • PG_locked:页被锁定,正进行I/O操作。
    • PG_dirty:页的内容已被修改,与磁盘文件不一致。
    • PG_uptodate:页的内容是有效的。
    • PG_lru:表示该页在zone的LRU链表上。
  • _count_mapcount(最易混淆的考试点!)

    • _count内核引用计数。表示内核中有多少地方正在使用这个物理页。_count = 0是该页可以被回收的必要条件
      • get_page() -> _count++
      • put_page() -> _count--
    • _mapcount页表映射计数。表示这个物理页被多少个进程的页表所映射(即被多少个进程共享)。
      • -1:未被任何进程映射。
      • 0:被一个进程映射。
      • N:被N+1个进程映射。
    • 简单比喻:一个物理页像一本物理书。
      • _mapcount:记录这本书被多少的“借书卡”(页表)登记了。
      • _count:记录这本书当前被多少个内核子系统正在“用手拿着”阅读(比如正在被修改、正在做I/O)。
  • mappingindex

    • 如果页属于文件缓存(page cache),mapping指向文件的address_spaceindex表示页在文件中的偏移。
    • 如果页是匿名映射(如堆、栈),mapping指向匿名地址空间。

全链路整合:一个malloc的完整旅程

现在,让我们把所有这些结构串联起来,看看当你调用char *buf = malloc(8192);(分配8KB)时,发生了什么:

  1. 库函数层malloc调用sbrkmmap系统调用,向内核申请虚拟内存。
  2. VMA操作:内核在进程的mm_struct中,通过红黑树mm_rb找到堆区域的VMA,并扩展其vm_end,或者创建一个新的VMA。此时,只是在虚拟地址空间划出了一块地,还没有分配实际的物理内存。
  3. 触发缺页:当你第一次读写buf时,CPU发现该虚拟地址对应的页表项是空的(无效),触发缺页异常
  4. 异常处理:内核的缺页处理程序被调用。
    • 通过mm_struct->mm_rb找到该地址所属的VMA。
    • 检查VMA的权限是否合法。
  5. 分配物理页
    • 内核转向物理内存管理。它可能会从ZONE_NORMAL进行分配。
    • 首先,尝试从每CPU页缓存zone->pageset[])中获取一个单独的页框。这很快,因为无需加锁。
    • 对于连续页框的请求,调用伙伴系统。伙伴系统在zone->free_area[order]中寻找合适的空闲块(这里需要2个连续页,order=1)。如果找到,就从链表中取下,更新zone->free_pages
  6. 建立映射
    • 伙伴系统返回struct page指针。
    • 内核用该物理页框的地址,填充进程页表中对应虚拟地址的页表项(PTE)
    • 同时,该物理页的_mapcount变为0(被一个进程映射),_count变为1(被内核引用)。
  7. 返回用户态:缺页处理完成,CPU重新执行那条引起异常的指令,此时它成功访问到了新分配的物理内存。

总结与考试重点

数据结构 角色 核心概念 考试重点
mm_struct 进程内存总部 整个虚拟地址空间的抽象 pgd作用;mmap链表与mm_rb树的区别与用途;mm_users vs mm_count
vm_area_struct 虚拟内存地块 连续的同属性地址区间 vm_start/vm_endvm_file(匿名 vs 文件映射);与mm_struct的关系
struct zone 物理内存仓库 分区管理,应对硬件限制 三种Zone的作用;伙伴系统原理free_area[],分配/释放/合并过程);LRU链表
struct page 物理页框身份证 物理内存的最小管理单元 flags状态位;_count vs _mapcountmappingindex的作用