这一篇在干嘛?

除内核代码和静态数据占用的部分外,RAM 剩下的叫动态内存——它既要分给进程,也要供内核自己使用,整个系统的性能取决于动态内存管理的效率。本章讲内核给自己分配内存的三套技术:页框级连续分配(伙伴系统)、任意长度小块分配(Slab 分配器)、非连续线性地址分配(vmalloc),外加高端内存映射与内存池。这是全书最厚的一章,也是理解后面缺页处理、页框回收的地基。

页框管理基础 | NUMA 节点与内存区 | 页框分配接口 | 高端内存映射 | 伙伴系统 | Per-CPU 缓存与区分配器 | Slab 分配器 | 内存池 | 非连续内存区

图 1:第 8 章章首插图

页框管理基础

Linux 采用 80x86 两种页框尺寸中较小的 4 KB 作为标准内存分配单元,理由有二:

  • 分页电路产生的缺页异常容易解释:要么页存在但进程无权访问,要么页不存在——后者只需分配器找一个空闲 4 KB 页框给进程;
  • 4 KB 和 4 MB 虽然都是所有磁盘块大小的倍数,但内存与磁盘之间的小粒度传输通常更高效。

图 2:动态内存(用作动态内存的页框布局示意)

页描述符:每个页框一张”身份证”

内核必须追踪每个页框的状态:它是装着进程的页、内核代码、还是空闲?动态内存中的页框”空闲”意味着不含任何有用数据;不空闲则可能是用户态进程的数据、软件缓存、动态分配的内核数据结构、驱动缓冲区、内核模块代码等等。

每个页框的状态保存在 page 类型的页描述符里,全部描述符存于 mem_map 数组。每个描述符 32 字节,mem_map 占用空间略小于整个 RAM 的 1%——用 1% 的内存给所有页框建档,这笔买卖很划算。virt_to_page(addr) 宏由线性地址得到对应页描述符地址,pfn_to_page(pfn) 由页框号得到。

字段说明
flags多达 32 个状态标志(PG_xyz),高位还编码页框所属的内存节点号与区号
_count引用计数:-1 表示空闲可用;≥0 表示被一个或多个进程/内核组件使用
_mapcount指向该页框的页表项数量(无则为 -1)
private私有字段:缓冲页时是 buffer head 指针;空闲时被伙伴系统征用(存块的阶)
mapping页框进入页缓存或属于匿名区时使用
index多重含义:页内数据在磁盘映像/匿名区中的位置,或换出页标识符
lru挂入最近最少使用(LRU)双向链表的指针

page_count() 返回 _count 加 1,即页的使用者数量。每个 PG_xyz 标志都有一组配套宏:PageXyz() 读标志,SetPageXyz()/ClearPageXyz() 置/清。常用标志:

标志含义
PG_locked页被锁定(如正在磁盘 I/O)
PG_error传输页时发生 I/O 错误
PG_referenced页最近被访问过
PG_uptodate读操作完成后置位(除非磁盘 I/O 出错)
PG_dirty页已被修改
PG_lru / PG_active页在 active/inactive 链表中 / 在 active 链表中
PG_slab页框属于某个 slab
PG_highmem页框属于 ZONE_HIGHMEM 区
PG_reserved页框保留给内核代码或不可用
PG_private页描述符的 private 字段存有有意义的数据
PG_writeback页正通过 writepage 方法写回磁盘
PG_compound页框通过扩展分页机制管理
PG_swapcache页属于交换缓存

注意:很多字段的具体含义取决于页框当前被谁使用——同一个 private,在缓冲页里是指针、在空闲页里是伙伴系统的阶数。这种”字段复用”是内核省内存的惯用手法。

NUMA 节点与内存区

NUMA:内存访问不是天生均匀的

我们习惯把内存当同质的共享资源——无论物理地址在哪、用哪个 CPU 访问,耗时都一样。这只在 UMA(均匀访存)架构上成立;某些多处理器 Alpha/MIPS 机器上不成立。Linux 2.6 支持 NUMA(非均匀访存)模型:物理内存划分为若干节点(node),同一 CPU 访问同一节点内各页耗时相同,但不同 CPU 访问同一节点耗时可能不同;CPU 访问自己节点(本地内存)快、访问别的节点(远程内存)慢。内核的策略是小心安排”该 CPU 最常引用的内核数据结构”的存放位置,尽量减少昂贵的远程访问。

每个节点有 pg_data_t 类型的描述符(关键字段:node_zones 区描述符数组、node_zonelists 备用区链表、node_mem_map 本节点的页描述符数组、node_start_pfn/node_present_pages/node_spanned_pages 页框范围、kswapd 回收内核线程的描述符指针和等待队列等),全部节点描述符串成单链表,pgdat_list 指向表头。

IBM 兼容 PC 是 UMA 架构,似乎用不着 NUMA 支持——但 Linux 仍然把全部物理内存组织成一个节点(节点 0,描述符存在 contig_page_data 变量里)。这是为了可移植性:内存管理代码可以假定”所有架构上物理内存都划分成一个或多个节点”,一套代码通吃。

内存区:真实硬件的三道门槛

理想架构里页框想干嘛干嘛;真实架构有硬件限制。80x86 有两条:

  • 老 ISA 总线的 DMA 处理器只能寻址前 16 MB RAM;
  • 现代 32 位机器 RAM 很大,而线性地址空间太小,CPU 无法直接访问全部物理内存

于是 Linux 2.6 把每个节点的物理内存划成三个区(zone):

范围(80x86 UMA)
ZONE_DMA低于 16 MB 的页框(老 ISA 设备 DMA 专用)
ZONE_NORMAL16 MB ~ 896 MB(可由内核通过线性地址空间第 4 个 GB 的线性映射直接访问)
ZONE_HIGHMEM896 MB 以上(内核无法直接访问,需要特殊映射手段)

ZONE_HIGHMEM 在 64 位架构上永远为空——64 位线性地址空间远大于可安装的 RAM。

每个区有 zone 类型描述符,关键字段:free_pages(空闲页数)、pages_min/pages_low/pages_high(保留页数和回收水位线)、lowmem_reserve(低内存危急时各区须保留的页框数)、pageset(per-CPU 单页框缓存)、lock 自旋锁、free_area(伙伴系统的空闲块)、lru_lock/active_list/inactive_list(页框回收用的 LRU 链表,详见 /linux内核/lk17)。

一个省空间的巧思:页描述符并没有用指针链接节点和区——flags 字段的最高位直接编码了节点号和区号(标志本身数量有限,最高位用不完)。page_zone() 函数读出这些高位,在 zone_table 数组(启动时初始化为所有节点的所有区描述符地址)中查出区描述符地址。

zonelist:区不够了退而求其次

分配请求必须指明可用区。比如要一个直接映射在第 4 个 GB、但不用于 ISA DMA 的页框,就请求 ZONE_NORMAL 或 ZONE_DMA——且优先用 ZONE_NORMAL。优先顺序用 zonelist 数据结构(区描述符指针数组)表达,它存在节点描述符的 node_zonelists 字段里。80x86 UMA 上的回退规则:

  • 设了 __GFP_DMA:只能用 ZONE_DMA;
  • 否则没设 __GFP_HIGHMEM:按 ZONE_NORMAL → ZONE_DMA 的优先顺序;
  • 设了 __GFP_HIGHMEM:按 ZONE_HIGHMEM → ZONE_NORMAL → ZONE_DMA 的顺序。

保留页框池:给”不能睡的请求”留口粮

分配请求有两种满足方式:内存充足则立刻满足;否则要先回收内存、发请求的控制路径被阻塞。但有些控制路径不能被阻塞——中断处理程序、临界区内的代码。它们要用 GFP_ATOMIC 标志发原子分配请求:绝不阻塞,空闲页不够就直接失败。

原子请求没法保证永不失败,但内核尽力压低失败概率:预留一个保留页框池,专供低内存时的原子请求使用。池的大小(KB)存在 min_free_kbytes 变量,初始化时按直接映射内存(ZONE_DMA + ZONE_NORMAL 的页框数)计算:

且被夹在 128 与 65536 之间。两个区按相对大小分摊:ZONE_NORMAL 若是 ZONE_DMA 的八倍,则贡献 7/8 的保留页框。区描述符的 pages_min 字段存本区的保留页框数;pages_low 恒为 pages_min 的 5/4,pages_high 恒为 3/2——这两个水位线也用于页框回收算法(/linux内核/lk17)。

页框分配接口

处理”连续页框组”分配请求的子系统叫分区页框分配器(zoned page frame allocator),三大组件:区分配器(zone allocator,接单并选区)→ 区内由伙伴系统管理页框 → 少量单页框放在缓存里加速(per-CPU 页框缓存)。

图 3:分区页框分配器的组成(区分配器 → 伙伴系统 / Per-CPU 页框缓存)

分配函数全家桶

六个入口,除非特别说明,返回首个分配页的线性地址,失败返回 NULL:

  • alloc_pages(gfp_mask, order):请求 2^order 个连续页框,返回首页框的页描述符地址(注意不是线性地址——高端内存页框没有线性地址);
  • alloc_page(gfp_mask):单个页框,即 alloc_pages(gfp_mask, 0)
  • __get_free_pages(gfp_mask, order):同上但返回线性地址
  • __get_free_page(gfp_mask):即 __get_free_pages(gfp_mask, 0)
  • get_zeroed_page(gfp_mask):要一个填零页框,展开为 alloc_pages(gfp_mask | __GFP_ZERO, 0)
  • __get_dma_pages(gfp_mask, order):要适合 DMA 的页框。

gfp_mask 标志指定怎么找空闲页框:

标志说明
__GFP_DMA / __GFP_HIGHMEM区修饰符:页框须属于 ZONE_DMA / 可属于 ZONE_HIGHMEM
__GFP_WAIT允许阻塞当前进程等空闲页框
__GFP_HIGH允许动用保留页框池
__GFP_IO允许为腾页框做 I/O 传输
__GFP_FS为 0 时禁止文件系统相关操作
__GFP_COLD请求”冷”页框(见 Per-CPU 缓存一节)
__GFP_REPEAT / __GFP_NOFAIL反复重试直到成功
__GFP_NORETRY失败不重试
__GFP_ZERO返回的页框必须填零
__GFP_COMP页框属于扩展页

实践中常用预定义组合:

组名对应标志典型场景
GFP_ATOMIC__GFP_HIGH中断处理程序、临界区内:绝不阻塞,可动用保留池
GFP_NOIO__GFP_WAIT可睡眠但不得做任何 I/O
GFP_NOFS`__GFP_WAIT__GFP_IO`
GFP_KERNEL`__GFP_WAIT__GFP_IO
GFP_USER同 GFP_KERNEL给用户态进程分配
GFP_HIGHUSERGFP_KERNEL + __GFP_HIGHMEM用户进程优先用高端内存

释放用四个函数/宏:__free_pages(page, order)(先检查页框未保留,递减计数,计数为 0 才真正释放 2^order 个页框)、free_pages(addr, order)(参数是线性地址)、__free_page(page)free_page(addr)(单页框简化版)。

高端内存映射

直接映射物理内存的末端线性地址存在 high_memory 变量里(896 MB)。896 MB 以上的页框通常不映射在内核线性地址空间,内核无法直接访问——这意味着返回线性地址的分配函数(__get_free_pages 等)对高端内存页框根本不能用。设想内核调 __get_free_pages(GFP_HIGHMEM, 0):分配器给了个高端内存页框,函数却无法返回它的线性地址只能返回 NULL——页框没法用,更糟的是内核丢了它的踪迹,连释放都做不到。

64 位平台没有这个问题(ZONE_HIGHMEM 恒空)。32 位平台上,为利用 PAE 支持的最多 64 GB RAM,Linux 的方案是:

  1. 高端内存分配只用 alloc_pages()/alloc_page()——它们返回的是页描述符的线性地址(描述符在内核初始化时一次性分配在低端内存,永远存在);
  2. 把内核线性地址空间的最后 128 MB 的一部分拿来做高端内存页框的临时窗口——通过循环复用线性地址,整个高端内存都能在不同时刻被访问到。

内核用三种机制映射高端内存:永久内核映射临时内核映射非连续内存分配(最后一种见后文 vmalloc 一节)。三者都无法同时映射全部 RAM——毕竟只有不足 128 MB 线性地址空间可用,而 PAE 机器可能有 64 GB。

关键权衡:永久映射可能阻塞当前进程(页表项”窗口”用光时)——所以中断处理程序和可延迟函数不能用临时映射从不阻塞——但它同时能建立的映射极少,而且使用它的控制路径绝不能阻塞(否则别的控制路径可能占用同一个窗口)。

永久内核映射:kmap()

永久映射用主内核页表中一张专用页表,地址存 pkmap_page_table,表项数由 LAST_PKMAP 给出(PAE 关为 1024 项、开为 512 项,即一次最多映射 4 MB 或 2 MB 高端内存)。页表映射从 PKMAP_BASE 开始的线性地址。配套的 pkmap_count 数组为每个表项维护一个计数器:

  • 0:表项空闲可用;
  • 1:表项没映射任何页框,但不能用——对应 TLB 项自上次使用后还没被刷新;
  • n(>1):表项映射着一个高端内存页框,正被 n−1 个内核组件使用。

高端内存页框与线性地址的对应关系记录在 page_address_htable 哈希表中,每项是 page_address_map 结构(页描述符指针 + 分配的线性地址)。

page_address() 返回页框的线性地址(未映射的高端内存返回 NULL):

  • 页框不在高端内存(PG_highmem 未置位):线性地址必定存在,直接算出来:__va((unsigned long)(page - mem_map) << 12)
  • 在高端内存:查哈希表,找到返回,找不到返回 NULL。

kmap() 建立永久映射:

void * kmap(struct page * page)
{
    if (!PageHighMem(page))
        return page_address(page);   /* 不在高端内存,直接返回 */
    return kmap_high(page);
}

kmap_high()kmap_lock 自旋锁保护页表(不必关中断——kmap() 不可被中断处理程序和可延迟函数调用),查 page_address():已映射就直接给地址;否则调 map_new_virtual() 新建映射;然后把对应计数器加 1(多了一个使用者)。

map_new_virtual() 本质是两层嵌套循环:内层从头到尾扫描 pkmap_count 找零值(起点是上次停下的位置,由 last_pkmap_nr 记忆);找到空闲项就算出线性地址、写页表项、置计数为 1、往哈希表插元素、返回。扫完一圈回到 0 之前,先调 flush_all_zero_pkmaps():把所有值为 1 的计数器清零(这些表项空闲但 TLB 还没刷)、从哈希表删掉对应元素、对整张页表发 TLB 刷新。若内层循环找不到零值——窗口全占满了——就把 current 插入 pkmap_map_wait 等待队列、置 TASK_UNINTERRUPTIBLEschedule() 睡眠,等别人释放窗口;醒来后先查是不是别人已经帮自己映射了,没有就重扫。

kunmap() 撤销映射,其核心 kunmap_high()

void kunmap_high(struct page * page)
{
    spin_lock(&kmap_lock);
    if ((--pkmap_count[((unsigned long)page_address(page)
        - PKMAP_BASE) >> PAGE_SHIFT]) == 1)
        if (waitqueue_active(&pkmap_map_wait))
            wake_up(&pkmap_map_wait);   /* 计数减到 1:没人用了,唤醒等窗口的进程 */
    spin_unlock(&kmap_lock);
}

临时内核映射:kmap_atomic()

临时映射简单得多,而且可在中断处理程序和可延迟函数中使用(从不阻塞)。每个高端内存页框都可以通过内核地址空间里一个预留”窗口”(页表项)访问,窗口数量很少:每个 CPU 有自己的 13 个窗口,由 km_type 枚举定义(KM_BOUNCE_READKM_USER0KM_PTE0 等)——每个符号专属于一个内核组件并以之命名,保证同一窗口绝不被两条控制路径同时使用;最后的 KM_TYPE_NR 不是地址而是窗口总数。

每个窗口对应一个固定映射线性地址(见 /linux内核/lk02):fixed_addresses 枚举里 FIX_KMAP_END = FIX_KMAP_BEGIN + (KM_TYPE_NR * NR_CPUS) - 1,即每个 CPU 分到 KM_TYPE_NR 个固定地址。kmap_pte 存着首个窗口对应页表项的地址。

建立临时映射用 kmap_atomic()

void * kmap_atomic(struct page * page, enum km_type type)
{
    enum fixed_addresses idx;
    unsigned long vaddr;
 
    current_thread_info()->preempt_count++;  /* 禁抢占:窗口是独占的 */
    if (!PageHighMem(page))
        return page_address(page);
    idx = type + KM_TYPE_NR * smp_processor_id();
    vaddr = fix_to_virt(FIX_KMAP_BEGIN + idx);
    set_pte(kmap_pte-idx, mk_pte(page, 0x063));  /* Present|Accessed|RW|Dirty */
    __flush_tlb_single(vaddr);
    return (void *) vaddr;
}

type 参数加 CPU 号共同决定用哪个固定地址;函数写好页表项、刷单个 TLB 项、返回线性地址。撤销用 kunmap_atomic():80x86 上它递减 preempt_count(恢复抢占能力),并检查 TIF_NEED_RESCHED——置位就调 schedule()

常见坑:在可能阻塞的代码里用 kmap_atomic

临时映射窗口是”谁抢到谁用、用完立刻还”的稀缺资源。如果拿着 kmap_atomic 的窗口去睡眠,其他 CPU 上的控制路径可能复用同一窗口映射别的页,你的数据就被换掉了。窗口生存期内绝不能阻塞——需要睡眠的场景请用 kmap/kunmap。

伙伴系统

外部碎片问题

内核需要高效策略分配连续页框组。频繁地申请、释放不同大小的连续页框组,会产生外部碎片:小块空闲页框”散落”在已分配块之间,结果明明空闲页总量够,却凑不出一块大连续空间。

解药有两条路:用分页电路把非连续页框映射到连续线性地址;或设计算法精细管理空闲连续块、尽量避免”为了小请求劈开大块”。内核偏爱第二条路,三个理由:

  • 有些场景必须物理连续——典型是 DMA 缓冲区:大多数 DMA 控制器不理会分页电路、直接访问地址总线,一次 I/O 传多个磁盘扇区,缓冲区必须落在连续页框;
  • 即使不必须,连续分配不改页表——频繁改页表会刷 TLB,抬高平均访存时间(/linux内核/lk02);
  • 大块连续物理内存可以用 4 MB 大页映射,显著减少 TLB miss。

算法

伙伴系统(buddy system)把所有空闲页框分组为 11 个链表,分别装 1、2、4、8、16、32、64、128、256、512、1024 个连续页框的块(最大 1024 页 = 4 MB 连续 RAM)。块的起始物理地址必须是块大小的倍数——16 页块的起始地址是 16×4096 的倍数。

分配(例:要 256 页 = 1 MB):

  1. 256 链表有空块?有就拿走;
  2. 没有,看 512 链表:有就拿一块,劈成两半——256 页满足请求,剩余 256 页插回 256 链表;
  3. 还没有,看 1024 链表:有就劈两次——256 页交付,中间 512 页进 512 链表,最后 256 页进 256 链表;
  4. 1024 链表也空:分配失败。

释放是算法名字的由来:内核尝试把大小为 b 的伙伴块合并成 2b。两个块是”伙伴”当且仅当:大小相同(都是 b)、物理地址连续、第一块的起始物理地址是 2×b×4096 的倍数。合并是迭代的——合并成功就把 b 翻倍继续找更大的伙伴,雪球越滚越大。

数据结构

每个内存区一套伙伴系统(80x86 上共 3 套:DMA、NORMAL、HIGHMEM),基于:

  • mem_map 数组(每区只关心自己的子集,范围由区描述符的 zone_mem_mapsize 字段指定);
  • 区描述符 free_area 字段里的 11 个 free_area 元素,第 k 个元素管理所有 2^k 大小的空闲块:free_list 是双向循环链表的头(收集每块起始页框的页描述符,链表指针就存在页描述符的 lru 字段里),nr_free 是该大小空闲块的数量。

还有一招:空闲块首页描述符的 private 字段存块的阶 k(2.6.10 之前用 10 个标志数组编码,现在一个字段搞定)。释放时内核靠它判断”伙伴是否也空闲、能否合并”。

分配实现:__rmqueue()

__rmqueue(zone, order) 在某区内找空闲块,order 是请求大小的对数。调用前提:已关本地中断、已拿 zone->lock 自旋锁。先从请求的阶开始向上扫:

for (current_order = order; current_order < 11; ++current_order) {
    area = zone->free_area + current_order;
    if (!list_empty(&area->free_list))
        goto block_found;
}
return NULL;   /* 11 个链表全空:失败 */

找到块后摘链表、清 private、nr_free--zone->free_pages 减去 1<<order。若找到的块比请求的大(curr_order > order),用 while 循环逐级劈开:每次把块的后一半(buddy)挂到低一级链表头、记上阶数,直到剩下的正好是请求大小:

size = 1 << curr_order;
while (curr_order > order) {
    area--; curr_order--; size >>= 1;
    buddy = page + size;
    list_add(&buddy->lru, &area->free_list);
    area->nr_free++;
    buddy->private = curr_order;
    SetPagePrivate(buddy);
}
return page;

释放实现:__free_pages_bulk()

__free_pages_bulk(page, zone, order) 的灵魂是用异或找伙伴

while (order < 10) {
    buddy_idx = page_idx ^ (1 << order);   /* XOR 翻转第 order 位 */
    buddy = base + buddy_idx;
    if (!page_is_buddy(buddy, order))
        break;                              /* 伙伴不空闲,到此为止 */
    list_del(&buddy->lru);                  /* 把伙伴摘出链表 */
    zone->free_area[order].nr_free--;
    ClearPagePrivate(buddy); buddy->private = 0;
    page_idx &= buddy_idx;                  /* 合并后取两者较小索引 */
    order++;                                /* 阶加一,继续找更大的伙伴 */
}

page_idx ^ (1 << order) 为什么要得妙:翻转 page_idx 的第 order 位——该位原为 0,伙伴就是 page_idx + 2^order;原为 1,伙伴就是 page_idx − 2

page_is_buddy() 确认伙伴确实空闲且可合并:伙伴首页的 _count 为 -1(空闲)、PG_reserved 清零、PG_private 置位、private 存着同样的阶。能合并就摘掉伙伴、翻倍阶数、继续往上找;不能合并就跳出,把最终块挂进 free_area[order].free_list、写 private、nr_free++

Per-CPU 缓存与区分配器

Per-CPU 页框缓存(热/冷)

内核经常申请、释放单个页框,每次都走伙伴系统太重。每个内存区为每个 CPU 准备了两个小缓存:

  • 热缓存(hot):页框内容可能还在 CPU 硬件缓存里。分配后马上要写这个页框时,拿热页框能避免把别的页框的缓存行挤出去;
  • 冷缓存(cold):页框将用 DMA 填充时拿冷页框——CPU 不参与传输、不碰硬件缓存,冷页框既不浪费也不污染缓存,还把热页框留给真正的写请求。

缓存实现是区描述符 pageset 字段中的 per-CPU per_cpu_pageset 数组,每个元素含两个 per_cpu_pages 描述符(热、冷各一):

字段说明
count缓存中的页框数
low / high低/高水位线
batch一次补货/出货的页框数
list缓存中页框描述符的链表

水位线逻辑:count 低于 low 就从伙伴系统批量要 batch 个页框补货;高于 high 就把 batch 个还给伙伴系统。batch/low/high 的值取决于内存区的页框总数。

分配走 buffered_rmqueue(zone, order, gfp_flags)

  1. order ≠ 0:不是单页请求,per-CPU 缓存帮不上忙,跳到第 4 步走伙伴系统;
  2. 检查 __GFP_COLD 选定的缓存是否需要补货(count ≤ low)——是则反复调 __rmqueue() 批量要 batch 个单页框挂进缓存链表、更新 count;
  3. count 为正:从缓存链表拿一个页框、count 减 1,跳到第 5 步;
  4. __rmqueue() 从伙伴系统分配;
  5. 初始化(首个)页框的描述符:清若干标志、private 置零、引用计数置 1;__GFP_ZERO 置位则填零;
  6. 返回页描述符地址或 NULL。

释放走 free_hot_page()/free_cold_page(),都是 free_hot_cold_page() 的包装:从 page->flags 找到所属内存区 → 拿到对应 per-CPU 缓存描述符 → 若 count ≥ high 先调 free_pages_bulk()batch 个回伙伴系统 → 把页框挂进缓存链表、count 加 1。一个有趣的事实:当前版本内核从不往冷缓存里还页框——释放的页框总被当作”热”的。冷缓存并非空置,它只在低水位时由 buffered_rmqueue() 从伙伴系统补货。

区分配器:__alloc_pages() 的多轮扫描

区分配器是页框分配器的前端,要在保护保留池、按需触发回收、尽量保住宝贵的 ZONE_DMA 这几个目标之间权衡。所有 alloc_pages 最终落到 __alloc_pages(gfp_mask, order, zonelist),它按优先顺序扫描 zonelist 里的每个区:

for (i = 0; (z = zonelist->zones[i]) != NULL; i++) {
    if (zone_watermark_ok(z, order, ...)) {
        page = buffered_rmqueue(z, order, gfp_mask);
        if (page)
            return page;
    }
}

zone_watermark_ok() 判断区是否”够富”:除请求页框外,还要有至少 min 个空闲页框(不计 lowmem_reserve),且每个阶 k(1 ≤ k ≤ order)都至少有 min/2^k 个至少 2^k 大小的空闲块——防止把大块碎吃干净后无法满足后续大请求。阈值 min 的取值:基准是区的某个水位线(pages_min/pages_low/pages_high),gfp_high(通常因 __GFP_HIGHMEM)置位则减半,can_try_harder(__GFP_WAIT 置位,或实时进程上下文分配)置位再减 1/4。

__alloc_pages() 的完整流程是一场”逐步升级的战斗”:

  1. 第一轮扫描:阈值 z->pages_low,最宽松的礼貌尝试;
  2. 失败:唤醒 kswapd 内核线程异步回收页框(/linux内核/lk17);
  3. 第二轮扫描:阈值降到 z->pages_min(配合 gfp_high/can_try_harder 再打折);
  4. 仍失败且发请求的控制路径正在替别人回收内存PF_MEMALLOC/PF_MEMDIE 置位):第三轮扫描无视水位线——这是唯一允许耗尽 lowmem_reserve 的情况,因为它正在腾内存,理应得到满足;再不行返回 NULL;
  5. __GFP_WAIT 未设:不能阻塞,直接返回 NULL;
  6. 可以阻塞:cond_resched() 先看看有没有别的进程等着用 CPU;
  7. 置 current 的 PF_MEMALLOC 标志(“我要去腾内存了”);
  8. 挂上 reclaim_state 结构(reclaimed_slab 字段清零,slab 释放页框时记账用);
  9. try_to_free_pages() 同步回收页框(可能阻塞),返回后清 PF_MEMALLOC、再 cond_resched()
  10. 若上一步腾出了页框:再来一轮第 3 步式的扫描;仍不够时决定是否死磕——__GFP_NORETRY 未置位且(请求 ≤ 8 页或 __GFP_REPEAT/__GFP_NOFAIL 置位)就 blk_congestion_wait() 睡一会儿,跳回第 6 步;否则返回 NULL;
  11. 一步都没腾出页框——内存已危在旦夕。若允许文件系统操作(__GFP_FS 置位)且 __GFP_NORETRY 未置位:用 z->pages_high 水位线再扫一遍(基本必失败,除非已有别的控制路径正在杀进程腾内存——这道闸防止误杀两个无辜进程)、调 out_of_memory() 启动 OOM Killer 杀掉一个受害者进程、跳回第 1 步。

释放页框简单得多:所有释放宏最终落到 __free_pages(page, order)——检查页框属于动态内存(PG_reserved 清零)→ 引用计数减 1,仍 ≥0 就收工 → order 为 0 则 free_hot_page() 还进 per-CPU 热缓存,order 大于 0 则 free_pages_bulk() 直接还伙伴系统。

Slab 分配器

为什么需要 Slab

伙伴系统的最小单位是页框,给”几十几百字节”的小请求分配一整页是巨大浪费。引入小块分配又带来内部碎片——请求大小和分配单元大小不匹配造成的浪费。早期 Linux 的经典解法是 13 条几何分布(2 的幂)的空闲内存区链表(32 字节到 131072 字节),保证内部碎片不超过 50%。

但直接在伙伴系统上跑小块分配算法效率不高。Linux 2.6 采用源自 Sun Solaris 2.4 的 Slab 分配器,它建立在一个洞察上:内核函数反复请求同类型、同大小的内存区——每创建一个进程都要分配进程描述符、打开文件对象等固定大小的表;进程一终止这些内存又能复用。没有 Slab 的话,内核在反复分配/释放包含同样数据的页框上浪费大量时间。

Slab 的几个核心前提:

  • 对象 = 数据结构 + 构造函数 + 析构函数。构造函数初始化内存区,析构函数反初始化。为避免反复初始化,Slab 不丢弃已释放的对象,而是留在内存里缓存着——新请求来了直接复用,免掉重新初始化;
  • 高频出现的特定大小请求用专用对象精确匹配(零内部碎片);罕见的请求走几何尺寸的通用缓存(容忍内部碎片);
  • 非几何尺寸的对象还有个隐蔽红利:数据结构的起始地址不容易都扎堆在 2 的幂的物理地址上,硬件缓存命中率更好
  • 尽量少调伙伴系统还能减少函数足迹(footprint,函数运行覆盖的缓存百分比)——每次调伙伴系统都”弄脏”硬件缓存,抬高后续代码的平均访存时间。

三层结构:Cache → Slab → Object

Slab 分配器把对象组织进缓存(cache):每个缓存是”同类型对象的商店”——比如打开文件时,存”打开文件对象”的内存区来自名为 filp 的缓存。缓存占用的内存划成 slab:每个 slab 由一个或多个连续页框组成,装着已分配和空闲的对象。

图 4:Slab 分配器的组成(缓存 → slab → 对象)

每个缓存由 kmem_cache_t 描述符描述,关键字段:array(per-CPU 本地缓存指针数组)、batchcount/limit(本地缓存批量搬移数与上限)、objsize(对象大小)、flags(缓存永久属性)、num(每个 slab 装的对象数)、gfporder(单个 slab 页框数的对数)、gfpflags(向伙伴系统要页框时的标志)、colour/colour_off/colour_next(着色相关,见后文)、slabp_cache(外部 slab 描述符所在的通用缓存,内部描述符时为 NULL)、lists(三条 slab 链表等)、free_limit(全缓存空闲对象上限)。

lists 字段(kmem_list3 结构)是缓存的核心账本:

字段说明
slabs_full没有空闲对象的 slab 描述符链表
slabs_partial半满 slab 链表
slabs_free全空闲 slab 链表
free_objects缓存中空闲对象总数
free_touched / next_reap供 Slab 页回收算法使用
shared所有 CPU 共享的本地缓存指针

每个 slab 由 slab 描述符描述:list(挂进上述三条链表之一)、colouroff(slab 内第一个对象的偏移)、s_mem(slab 内第一个对象的地址)、inuse(在用对象数)、free(下一个空闲对象的索引,没有则 BUFCTL_END)。slab 描述符可以内嵌在 slab 自己的第一个页框开头(对象较小或内部碎片装得下时),也可以外挂在通用缓存里(CFLGS_OFF_SLAB 标志区分)。

图 5:缓存描述符与 slab 描述符的关系(满/半满/空闲 slab 各挂一条链表)

通用缓存与专用缓存

缓存分两类:

  • 通用缓存:Slab 分配器自用。一个是 kmem_cache 缓存(对象就是其他缓存的缓存描述符,描述符存于 cache_cache 变量——“用缓存管理缓存描述符”的自举设计);另一批是 malloc_sizes 表指向的 26 个通用缓存——13 种几何尺寸(32, 64, 128, …, 131072 字节),每种两个(一个适合 ISA DMA、一个普通)。kmem_cache_init() 在系统初始化时建立它们。
  • 专用缓存:内核其他部分用 kmem_cache_create() 创建——它决定处理方式(如 slab 描述符内嵌还是外挂),从 cache_cache 里分配描述符,拿 cache_chain_sem 信号量保护后把描述符插进 cache_chain 链表。kmem_cache_destroy() 销毁缓存(模块加载时建缓存、卸载时销毁的典型用户),kmem_cache_shrink() 先销毁所有 slab。运行时可通过 /proc/slabinfo 查看所有缓存的名字及空闲/已分配对象数。

Slab 与页框分配器的接口:kmem_getpages()alloc_pages(flags | cachep->gfpflags, cachep->gfporder) 要页框,给每个页描述符置 PG_slab 标志;kmem_freepages() 反向操作,若当前进程正在回收内存还往 current->reclaim_state->reclaimed_slab 记账。

分配一个 slab:cache_grow()

新创建的缓存没有 slab。只有当”有对象分配请求”且”缓存里没有空闲对象”时,才调 cache_grow() 给缓存配新 slab:调 kmem_getpages() 要页框 → alloc_slabmgmt() 拿 slab 描述符(外挂的从 slabp_cache 通用缓存拿,内嵌的放 slab 首页框)→ 扫描页框的页描述符,把 lru.next/lru.prev 分别填上缓存描述符slab 描述符的地址(这么干不冲突:lru 只有页框空闲时才被伙伴系统用,而 slab 管的页框带 PG_slab 标志、在伙伴系统眼里不是空闲的)——从此”由页框找缓存和 slab”一步到位 → cache_init_objs() 对 slab 里所有对象施加构造函数 → 新 slab 挂进 slabs_free 链表尾,free_objects 加上 num

释放 slab 用 slab_destroy():缓存定义了析构函数就先对所有对象执行析构 → kmem_freepages() 把页框还给伙伴系统 → 外挂描述符则从 slabp_cache 释放。若缓存带 SLAB_DESTROY_BY_RCU 标志,释放通过 call_rcu() 延迟执行(/linux内核/lk05)。

对象描述符与空闲链

每个对象有个短短的 kmem_bufctl_t(unsigned short)描述符,紧跟在 slab 描述符之后的数组里(同样分内嵌/外挂两种存放方式)。它只对空闲对象有意义:存”slab 内下一个空闲对象的索引”——把 slab 内的空闲对象串成一条单链表;链尾用约定值 BUFCTL_END(0xffff)标记。

图 6:slab 描述符与对象描述符的关系(空闲对象经对象描述符串成链)

对象对齐与 Slab 着色

Slab 管理的对象在内存中对齐——起始物理地址是某个常数(对齐因子)的倍数,上限 4096。默认按机器字长对齐(80x86 上 BYTES_PER_WORD = 4)。创建缓存时可指定 SLAB_HWCACHE_ALIGN 让对象按一级硬件缓存对齐:

  • 对象大于半个缓存行:对齐到 L1_CACHE_BYTES 的倍数(行首);
  • 否则对象大小向上取整为 L1_CACHE_BYTES 的约数——保证小对象绝不跨缓存行

这本质是拿空间换时间:人为增大对象尺寸、增加内部碎片,换取更好的缓存性能。

Slab 着色(slab coloring)解决另一个缓存问题:同一硬件缓存行会映射多块不同 RAM,同尺寸对象往往落在 slab 内相同偏移——不同 slab 里相同偏移的对象大概率撞进同一缓存行,硬件把两个对象在缓存行里倒来倒去,别的缓存行却闲着。着色的对策:给不同 slab 分配不同的”颜色”,让首个对象落在不同偏移上,把对象摊开到不同缓存行。

布局账本:slab 总长 = num × osize + dsize + free。其中 num 是每 slab 对象数,osize 是含对齐空隙的对象大小,dsize 是描述符总大小(外挂时为 0),free 是没用上的剩余字节数(< osize 但可能 > aln)。着色正是利用这段 free 空间:可用颜色数 = free/aln(存于 colour 字段),第 col 号颜色的 slab 把首对象偏移设为 col × aln + dsize——效果相当于把 slab 尾部的空隙搬到头部。各颜色在 slab 间均匀轮换:cache_grow()colour_next 字段给新 slab 分色并递增,转满一圈回 0。free < aln 时所有 slab 都只能用 0 号色,着色退化但不为过。

图 7:颜色为 col、对齐为 aln 的 slab 布局(首对象偏移 = col×aln + dsize)

本地缓存与对象的分配/释放

SMP 系统上,若所有 CPU 都直接竞争缓存的自旋锁,锁争用和缓存污染会很惨。Linux 2.6 的对策与 per-CPU 页框缓存如出一辙:每个缓存的 array 字段是 per-CPU 的 array_cache 指针数组——每个 CPU 一个本地空闲对象缓存(存的是指向已释放对象的指针,对象本体永远在 slab 里)。绝大多数分配/释放只碰本地缓存,slab 数据结构只在本地缓存”缺货/爆仓”时才被惊动。小对象的缓存还有一个存在 lists.shared 里的共享本地缓存,方便空闲对象在 CPU 间迁移。

array_cache 描述符字段:avail(可用指针数,兼作第一个空槽的下标)、limit(缓存上限)、batchcount(补货/清空批量)、touched(最近用过标志)。注意本地缓存本体就放在描述符结构后面紧挨着——所以 (void**)(ac+1) 直接就是指针数组。limitkmem_cache_create() 按对象大小定(大对象 1,小对象 120),batchcount 初始为 limit 的一半。

分配kmem_cache_alloc(cachep, flags)

local_irq_save(save_flags);
ac = cache_p->array[smp_processor_id()];
if (ac->avail) {
    ac->touched = 1;
    objp = ((void **)(ac+1))[--ac->avail];   /* 快路径:本地缓存直接给 */
} else
    objp = cache_alloc_refill(cachep, flags); /* 补货再给 */
local_irq_restore(save_flags);

快路径只做一次数组访问。cache_alloc_refill() 慢路径:拿 cachep->spinlock → 有共享缓存且非空就先从共享缓存搬 batchcount 个指针 → 否则扫 slabs_partial/slabs_free 链表找 slab,把其中的空闲对象指针批量塞进本地缓存(每个对象 slabp->inuse++、沿对象描述符链推进 slabp->free)→ 更新 free_objects → 放锁 → 返回最后一个指针。补货彻底失败就调 cache_grow() 要新 slab。

释放kmem_cache_free(cachep, objp):本地缓存没满(avail < limit)就直接把指针push 进去;满了先 cache_flusharray() 清空一批再放。cache_flusharray():拿锁 → 有共享缓存且未满就优先转给共享缓存 → 否则 free_block() 把最多 batchcount 个对象还回 slab:由 virt_to_page(objp)->lru.prev 找到 slab 描述符,把对象索引头插进 slab 的空闲链(slabp->free = objnr——最后释放的下次最先分配),inuse--;若整个 slab 全空(inuse == 0)且全缓存空闲对象超过 free_limit(通常 = num + (1+CPU数)×batchcount),就 slab_destroy() 把整个 slab 的页框还回伙伴系统;否则全空 slab 进 slabs_free、半满 slab 进 slabs_partial

kmalloc 与 kfree:通用接口

不常用的小内存请求由通用缓存伺候,接口是 kmalloc()

void *kmalloc(size_t size, int flags)
{
    struct cache_sizes *csizep = malloc_sizes;
    kmem_cache_t *cachep;
    for (; csizep->cs_size; csizep++) {
        if (size > csizep->cs_size)
            continue;                      /* 找到 >= size 的最小几何尺寸 */
        if (flags & __GFP_DMA)
            cachep = csizep->cs_dmacachep;
        else
            cachep = csizep->cs_cachep;
        return kmem_cache_alloc(cachep, flags);
    }
    return NULL;
}

malloc_sizes 表里找不小于请求的最小 2 的幂尺寸,按是否带 __GFP_DMA 选普通或 DMA 缓存,然后就是普通的 kmem_cache_alloc()。释放用 kfree()——它从对象所在首页框描述符的 lru.next(还记得吗,那里存着缓存描述符地址)反查出所属缓存,再调 kmem_cache_free()。这就是为什么 kmalloc 的内存不能用 free 释放、也不需要传大小参数——元信息都藏在页描述符里。

内存池

内存池是 Linux 2.6 的新特性:允许某个内核组件(如块设备子系统)预留一批动态内存,只在低内存紧急关头使用。它和保留页框池不是一回事:保留页框池服务于所有原子分配请求(中断处理程序、临界区),内存池则是专属的——平时不用,只有当常规分配注定失败时,“所有者”才动用储备。就像家里存的罐头:新鲜食物够吃时绝不开罐。

内存池可以垫在 Slab 之上(储备 slab 对象),也可以储备页框或 kmalloc 的小内存区——所以统一把池里的东西叫内存元素(memory element)。

内存池由 mempool_t 对象描述:

字段说明
lock保护对象的自旋锁
min_nr池中元素的最大数量(创建时保证能拿到的数量)
curr_nr当前元素数量(≤ min_nr)
elements指向元素指针数组的指针
pool_data所有者可用的私有数据(slab 场景下是缓存描述符地址)
alloc / free分配/释放元素的方法
wait池空时的等待队列

元素是 slab 对象时,alloc/free 通常就是 mempool_alloc_slab()/mempool_free_slab()——内部只是转调 kmem_cache_alloc()/kmem_cache_free()

用法一目了然:

  • mempool_create(min_nr, alloc, free, pool_data):创建池并预取 min_nr 个元素;
  • mempool_destroy():释放全部元素、指针数组和 mempool_t 本身;
  • mempool_alloc(pool, flags)先用常规分配器(调 alloc 方法)——成功就直接返回,池分文未动;失败才从池里取。池也被掏空且 __GFP_WAIT 未设时阻塞等待元素归还;
  • mempool_free(element, pool):池未满(curr_nr < min_nr)就还进池;已满才调 free 方法还给底层分配器。

非连续内存区

什么时候用非连续分配

把内存区映射到连续页框当然最好(缓存友好、平均访存低),但对不频繁的分配请求,用”连续线性地址 + 非连续页框”的方案更划算——主要优点是避免外部碎片,代价是要动内核页表。尺寸必须是 4096 的倍数。Linux 用它分配交换区的数据结构(/linux内核/lk17)、给模块分配空间(附录 B)、给某些 I/O 驱动分配缓冲区,它还是利用高端内存页框的第三条路。

线性地址空间布局

图 8:从 PAGE_OFFSET 开始的线性地址区间(直接映射 → vmalloc 区 → 永久映射区 → 固定映射区)

第 4 个 GB 的线性地址从低到高依次是:

  1. 直接映射前 896 MB RAM 的线性地址(到 high_memory 为止);
  2. 非连续内存区(vmalloc 区):起点 VMALLOC_START、终点 VMALLOC_END与物理内存映射之间隔着一个 8 MB 的安全间隔VMALLOC_OFFSET)——越界访问会落进无人区被抓个正着;每个非连续内存区之间还隔 4 KB 安全间隔,作用相同;
  3. PKMAP_BASE 开始的永久内核映射区;
  4. 末尾的固定映射线性地址区。

vm_struct 描述符与分配

每个非连续内存区有 vm_struct 描述符:addr(首线性地址)、size(区大小 + 4096 安全间隔)、flags(VM_ALLOC=vmalloc 分配的页 / VM_MAP=vmap 映射的已有页 / VM_IOREMAP=设备板上内存映射)、pages(指向页描述符指针数组)、nr_pagesphys_addr(映射设备 I/O 共享内存时才非零)、next(串成单链表,表头 vmlist,用 vmlist_lock 读/写自旋锁保护)。

get_vm_area(size, flag) 在 VMALLOC_START~VMALLOC_END 间找空档:kmalloc() 分配描述符 → 写锁下扫 vm_struct 链表找至少 size+4096 的空洞 → 成功则初始化描述符返回首地址,失败返回 NULL。

vmalloc(size) 的完整流程:

size = (size + PAGE_SIZE - 1) & PAGE_MASK;  /* 向上取整到 4KB 倍数 */
area = get_vm_area(size, VM_ALLOC);
area->nr_pages = size >> PAGE_SHIFT;
area->pages = kmalloc(nr_pages * sizeof(struct page *), GFP_KERNEL);
for (i = 0; i < area->nr_pages; i++)
    area->pages[i] = alloc_page(GFP_KERNEL | __GFP_HIGHMEM);  /* 逐页分配 */
map_vm_area(area, __pgprot(0x63), &pages);   /* 修页表,建立映射 */
return area->addr;

步骤:拿到一段连续线性地址 + 一组非连续页框(pages 数组必不可少,因为页框可能在高端内存、此刻没有线性地址)→ 最后的临门一脚是 map_vm_area() 改写内核页表,把每个页框挂到对应的线性地址上(保护位 0x63 = Present | Accessed | Read/Write | Dirty)。

map_vm_area() 四层循环逐级填页表:外层循环按页全局目录项推进(pud_alloc() 分配页上级目录),中层 map_area_pud() 处理页中间目录(pmd_alloc() 分配页中间目录),再下层 map_area_pmd() 分配页表(pte_alloc_kernel()),最内层 map_area_pte()set_pte(pte, mk_pte(page, prot))pages 数组里的页描述符逐一写进页表项,每写一项 address 加 4096。全程持有 init_mm.page_table_lock 自旋锁。

一个重要推论:map_vm_area() 只改主内核页表,不碰当前进程的页表。所以进程在内核态首次访问新分配的非连续内存区时会触发缺页异常——异常处理程序检查出错线性地址,发现主内核页表里有非空表项,就把值抄进进程页表、恢复执行(/linux内核/lk09)。

变体:vmalloc_32() 只从 ZONE_NORMAL 和 ZONE_DMA 分配页框;vmap(pages)已分配的页框建立非连续映射(只要描述符和页表,不分配页框)。

释放:vfree()

vfree()/vunmap() 都委托给 __vunmap(addr, deallocate_pages)

  1. remove_vm_area() 拿写锁扫 vm_struct 链表、找到描述符、调 unmap_vm_area() 清掉对应内核页表项、把描述符摘出链表;
  2. deallocate_pages 置位(vfree 的情形)则扫 area->pages 数组逐页 __free_page() 还页框,再 kfree(area->pages) 还数组本身;
  3. kfree(area) 释放描述符。

unmap_vm_area() 按与映射相反的四层循环(unmap_area_pud → unmap_area_pmd → unmap_area_pte)把页表项逐个清零——但不回收目录和页表本身(内核从不回收主内核页全局目录挂着的目录和页表)。于是有个有趣的尾巴:进程页表里残留的旧映射会让后续访问触发缺页,但这次主内核页表里也没有有效表项了,异常处理程序会把它当成 bug。

通关标准

能说清三层分配体系的分工与衔接:伙伴系统按 2 的幂管理连续页框组(防外部碎片,XOR 找伙伴合并);Slab 在伙伴系统之上按对象类型缓存小块内存(cache→slab→object 三层,本地缓存挡住绝大多数快路径,着色防缓存行冲突);vmalloc 用连续线性地址映射非连续页框(防碎片但要改页表)。并能解释高端内存为什么需要 kmap/kmap_atomic 两种映射、kmalloc 的内存为什么能被 kfree 无大小参数地释放。