引言

Linux内核是整个开源世界最复杂的软件之一,而内存管理是其最核心的子系统之一。它负责管理从BIOS上报的物理页面元数据开始,经过Buddy分配器的大块页面流转、Slab分配器的小对象缓存、虚拟地址到物理地址的多级页表映射、到最终被用户态进程使用的完整生命周期。

与前一篇文章讨论的KVM EPT/NPT嵌套分页不同,本文聚焦Host内核自身的内存管理体系——理解这一层是理解虚拟化内存嵌套分页机制的前提,也是性能调优、内核驱动开发、大规模服务部署的基础。

本文将从物理内存分配、虚拟内存映射、页面回收与压缩、NUMA优化、DMA连续内存分配、系统调用接口等多个维度展开,结合内核源码片段、性能数据和调试命令,给出可直接用于生产的实战指南。

一、物理内存的组织:节点、Zone与页帧

1.1 UMA与NUMA架构

传统对称多处理(SUP)假设所有CPU访问内存的延迟一致(UMA),但在多路服务器上,CPU访问本地内存节点的延迟远低于跨节点访问(NUMA)。

Linux内核用pg_data_t结构体描述每个NUMA节点,包含:

  • node_zones[]:该节点的内存Zone数组
  • node_start_pfn / node_spanned_pages:物理地址范围
  • node_id:节点编号
  • kswapd:该节点的页面回收内核线程
// include/linux/mmzone.h
typedef struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES];
    struct zonelist node_zonelists[MAX_ZONELISTS];
    int nr_zones;
    struct page *node_mem_map;
    unsigned long node_start_pfn;
    unsigned long node_present_pages;
    unsigned long node_spanned_pages;
    int node_id;
    struct task_struct *kswapd;
    ...
} pg_data_t;

通过numactl --hardware可查看NUMA拓扑:

$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7
node 0 size: 65488 MB
node 1 cpus: 8 9 10 11 12 13 14 15
node 1 size: 65536 MB
node 0 free: 48012 MB
node 1 free: 39501 MB

1.2内存Zone的划分

每个NUMA节点内部,物理内存进一步划分为多个Zone:

Zone 地址范围 用途 x86_64典型值
ZONE_DMA 0-16MB ISA兼容DMA设备 16MB
ZONE_DMA32 16MB-4GB 32位DMA设备 ~1.9GB
ZONE_NORMAL 16MB-896MB 内核直接线性映射 ~3.4GB
ZONE_MOVABLE 动态 可回收/可迁移区域 根据配置
ZONE_DEVICE 设备内存 Persistent Memory, GPU VRAM 设备特定

ZONE_NORMAL与物理内存的关系至关重要:x86_64架构中,内核空间(线性映射区)通过PAGE_OFFSET直接映射ZONE_NORMAL的物理页面,这就是为什么ZONE_NORMAL的大小决定了内核能直接管理的物理内存上限。

在内核启动参数中,movablecore=和kernelcore=可用于配置ZONE_MOVABLE和ZONE_NORMAL的比例,影响热插拔和大页分配能力。

$ cat /proc/zoneinfo | head -30
Node 0, zone      DMA
  pages free     3968
        min      305
        low      381
        high     457
        spanned  3998
        present  3840
        managed  3840
        protection: (0, 3529, 6131, 6131, 6131)
  pagesets
    cpu: 0
              count: 165
              high:  186
              batch: 31
              vm stats_threshold: 8

min/low/high三级水位线决定了页面回收的触发时机:低于min触发直接回收(blocked reclaim),低于low唤醒kswapd异步回收,高于high停止回收。

1.3 `struct page`:每页元数据

物理内存的每个页帧(通常4KB)都对应一个struct page(约64字节),系统启动时即分配totalram_pages * sizeof(struct page)的连续内存存放这些元数据。

struct page {
    unsigned long flags;        // 页标志(PG_locked, PG_dirty, PG_lru等)
    union {
        struct address_space *mapping;  // 页映射的文件/地址空间
        void *s_mem;            // slab第一个对象指针
    };
    pgoff_t index;              // 在mapping内的偏移
    unsigned long private;      // 私有数据
    struct {                    // 引用计数+映射计数
        atomic_t _mapcount;
        atomic_t _refcount;
    };
    struct list_head lru;       // LRU链表节点
    ...
};

flags字段中的PG_*标志位是内存管理子系统的核心状态机:

  • PG_locked:页面正在I/O
  • PG_dirty:脏页需写回
  • PG_lru:页面在LRU链表中
  • PG_slab:页面由Slab分配器管理
  • PG_buddy:页面在Buddy系统中
  • PG_head/PG_tail:复合页的头部/尾部
  • PG_swapbacked:页面有swap后备存储

通过/proc/kpageflags可以读取任意物理页面的标志位,结合/proc/kpagecount可分析页面使用情况:

# 查看物理地址0x100000的页面状态
$ sudo python3 -c "
import struct
PFN = 0x100000 >> 12
with open('/proc/kpageflags') as f:
    f.seek(PFN * 8)
    val = struct.unpack('Q', f.read(8))[0]
    print(f'PFN {hex(PFN)}: flags={hex(val)}')
"

二、Buddy分配器:抗碎片的大块分配引擎

2.1核心算法

Buddy系统(伙伴系统)管理物理连续页面,通过"可分割、可合并"的二叉树状结构减少外部碎片。每个free_area维护独立的自由页面链表,按2的幂次大小分类:

order-0:  4KB   × 1页
order-1:  8KB   × 2页
order-2:  16KB  × 4页
order-3:  32KB  × 8页
order-4:  64KB  × 16页
order-5:  128KB × 32页
order-6:  256KB × 64页
order-7:  512KB × 128页
order-8:  1MB   × 256页
order-9:  2MB   × 512页
order-10: 4MB   × 1024页(HugePage大小)

分配过程:

  • 向上取整为目标级别k
  • 在free_area[k]链表中查找空闲块
  • 若该级别为空,从free_area[k+1]取一块,将伙伴块拆入free_area[k]
  • 重复直到达到目标级别

释放过程:

  • 检查对应伙伴块是否在Free链表中
  • 若伙伴空闲,合并为更高级别块,继续向上检测
  • 直到伙伴已用或达到最高级别
// mm/page_alloc.c 核心释放逻辑
static inline void __free_one_page(struct page *page, unsigned long pfn,
                                    struct zone *zone, unsigned int order,
                                    int migratetype)
{
    // 检查伙伴是否可合并
    while (order < MAX_ORDER - 1) {
        unsigned long buddy_pfn = __find_buddy_pfn(pfn, order);
        struct page *buddy = page + (buddy_pfn - pfn);
        
        if (!page_is_buddy(page, buddy, order))
            goto done_merging;
        
        // 从链表中移除伙伴块,合并到高一级
        list_del(&buddy->lru);
        zone->free_area[order].free_list[migratetype].nr_page--;
        set_buddy_order(buddy, 0);
        combined_pfn = buddy_pfn & pfn;
        page = page + (combined_pfn - pfn);
        pfn = combined_pfn;
        order++;
    }
done_merging:
    set_page_order(page, order);
    list_add(&page->lru, &zone->free_area[order].free_list[migratetype]);
}

2.2 迁移类型(Migratetype):内核级别的反碎片

Buddy系统按页可移动性进一步细分了11条FreeList,类型定义在include/linux/mmzone.h:

迁移类型 含义 来源
MIGRATE_UNMOVABLE 内核代码、内核数据页 alloc_pages(GFP_KERNEL)
MIGRATE_MOVABLE 用户态匿名页、文件缓存 用户态mmap、read/write
MIGRATE_RECLAIMABLE Slab缓存、内核可回收对象 kmem_cache_alloc
MIGRATE_HIGHATOMIC 紧急分配预留 __GFP_HIGH
MIGRATE_CMA 连续内存分配器预留 CMA区域
MIGRATE_ISOLATE 热插拔隔离 memory hotplug

这种设计至关重要:当用户态大块内存请求到来时,Buddy优先从MIGRATE_MOVABLE或MIGRATE_UNMOVABLE中分配,但通过按类型分组的方式,使得后续compaction阶段可以移动MIGRATE_MOVABLE类型的页,而不影响不可移动的内核页面,从而将分散的空闲页整合为更大的连续块。

2.3 Per-CPU页面缓存(PCP)

每个NUMA节点维护一组Per-CPU空闲页链表,避免多核间竞争zone->lock:

struct zone {
    ...
    struct per_cpu_pageset __percpu *pageset;  // PCP缓存
    spinlock_t lock;                           // 仅PCP miss时获取
};

struct per_cpu_pageset {
    struct per_cpu_pages pcp;      // 热页缓存
#ifdef CONFIG_NUMA
    s8 expire;
    u16 vm_stat_threshold;
#endif
};

PCP的工作逻辑:

  • alloc_pages时,首先检查CPU本地PCP链表
  • 若PCP有页,直接从链表取,不持有zone->lock
  • 若PCP为空,从Buddy批量补齐(batch大小通常为31-64)
  • 释放时优先放入PCP,超过high watermark时批量归还Buddy

这种设计在高并发场景下大幅减少了zone锁竞争,是Linux能支撑数百万IOPS的关键之一。

2.4 查看Buddy状态

$ cat /proc/buddyinfo
Node 0, zone      DMA      1    1    0    0    2    1    1    0    1    1    3
Node 0, zone    DMA32    106  105   51   30   18    9    3    2    1    1    2
Node 0, zone   Normal   2293 1557  832  399  216   98   47   15    6    4    1

$ cat /proc/pagetypeinfo
Page block order: 10
Pages per block:  1024
Free pages count per migrate type at order       0      1      2      3      4      5      6      7      8      9     10
Node    0, zone   Normal, type    Unmovable     15     16     10      7      3      0      1      0      0      0      0
Node    0, zone   Normal, type      Reclaimable      3      2      2      1      0      0      1      0      0      0      0
Node    0, zone   Normal, type      Movable    895    642    367    214    118     62     33      8      3      2      0
Node    0, zone   Normal, type      HighAtomic      1      0      0      0      0      0      0      0      0      0      0

pagetypeinfo按迁移类型和反碎片策略详细显示了每个Zone的分布,是诊断OOM和启动性能的关键文件。

三、Slab分配器:内核小对象的高效缓存

3.1 为什么需要Slab

Buddy系统最小分配单位是1页(4KB),如果频繁分配小对象(如task_struct、inode、dentry),会产生严重的内部碎片。假设task_struct大小约为7KB,仅一个页面就浪费了将近一半空间。

Slab分配器在Buddy分配的页框之上构建对象缓存层,提供以下核心能力:

  • 对象缓存:预先分配构造对象池,避免频繁构造/析构
  • 硬件缓存着色:调整对象偏移,减少CPU Cache伪共享
  • 每CPU缓存:CPU本地无锁分配,避免SMP竞争
  • NUMA感知:对象优先从本地节点分配

Linux内核有三个Slab实现:

实现 适用场景 特点
Slab (SLAB) 早期内核(O(1)版本) 完整的缓存着色+全功能
Slub (SLUB) 默认实现(3.17+) 极简设计、调试能力强
Slob 嵌入式(tiny kernel) 零额外元数据

当前主流发行版使用SLUB,下文重点分析。

3.2 SLUB的数据结构

kmem_cache
   ├── name: "inode_cache"
   ├── object_size: 648字节
   ├── size: 656字节(含元数据)
   ├── offset: 着色偏移
   ├── cpu_slab: per-cpu struct kmem_cache_cpu
   │      ├── freelist: 本CPU空闲对象链表头
   │      └── page: 本CPU正在使用的page
   ├── node[MAX_NUMNODES]: kmem_cache_node[]
          ├── partial: 部分满的slab链表
          └── full: 完全满的slab链表

分配请求路径(最优情况):

kmem_cache_alloc()
  → per-cpu freelist不为空?
     YES: 从链表取一个对象,直接返回(无锁、无缓存行跳转,约10ns)
     NO:  → 检查per-cpu page是否有空闲空间?
            YES: 定位page内下一个对象
            NO:  → 从node->partial取一个slab设置为本CPU page
                   或调用new_slab()从Buddy分配新页
                     → 若仍失败,调用__alloc_slab()走慢路径
                       → 可能触发直接回收甚至OOM
// mm/slub.c 快速分配路径(简化版)
static __always_inline void *slub_alloc(struct kmem_cache *s, gfp_t gfp)
{
    struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab);
    struct page *page = c->page;
    void *object = c->freelist;
    
    if (unlikely(!object || !page))
        return slub_alloc_slow(s, gfp);
    
    // 快路径:从freelist弹出一个对象
    c->freelist = get_freepointer(s, object);
    page->inuse++;
    return object;
}

3.3 硬件缓存着色(Cache Coloring)

同一Cache Way中的频繁连续对象容易因Cache Line冲突而抖动。SLUB通过在对象间插入随机偏移(colour_off),使得不同Slab中的同一对象映射到不同Cache Way。

// colour偏移计算
#definecolour = nr colour * colour_off

调试时可通过/proc/slabinfo查看每个缓存的活跃度:

$ cat /proc/slabinfo | grep -E "kmalloc|task_struct|inode"
kmalloc-64             2432   2432    64   62    1 : tunables  0   0   0 : slabdata     39     39      0
kmalloc-128            1856   2048   128   32    1 : tunables  0   0   0 : slabdata     64     64      0
task_struct             412    486  7424    3    3 : tunables  0   0   0 : slabdata    162    162      0
inode_cache            1024   1024  1080    3    1 : tunables  0   0   0 : slabdata    341    341      0

各列含义:

当active_objs == num_objs时,说明缓存中所有对象都在使用,可能存在热点分配;当num_objs >> active_objs时,说明内存浪费较多。tunables行显示limit/batchcount/sharedfactor参数。

3.4 SLAB_ACCOUNT:容器级内存隔离

不同cgroup的进程可能共享同一个Slab缓存,导致缓存跨cgroup统计不准。CONFIG_MEMCG开启后,SLAB_ACCOUNT标志会导致内核为每个memcg创建独立的Slab缓存视图(虽然底层Page复用)。

// 带SLAB_ACCOUNT的缓存创建
struct kmem_cache *s = kmem_cache_create("my_cache", size, align,
                                          SLAB_ACCOUNT | SLAB_HWCACHE_ALIGN,
                                          ctor);

可通过slabtop和/sys/kernel/debug/slab//aliases分析容器间Slab用量。

四、虚拟内存与页表:从虚拟地址到物理地址

4.1 进程地址空间:vm_area_struct

每个用户态进程的虚拟地址空间由一系列VMA(Virtual Memory Area)描述。每个VMA代表一个具有相同保护属性和后备存储的连续虚拟区域。

struct mm_struct {
    struct rb_start mm_rb;               // VMA红黑树根
    struct vm_area_struct *mmap;        // VMA链表头
    unsigned long mmap_base;            // mmap区域基址
    unsigned long task_size;            // 用户空间大小
    pgd_t *pgd;                         // 页全局目录
    atomic_t mm_count;                  // 引用计数
    atomic_long_t pinned_vm;            // locked/pinned内存
    ...
};

struct vm_area_struct {
    unsigned long vm_start;             // 起始地址
    unsigned long vm_end;               // 终止地址
    struct mm_struct *vm_mm;            // 所属mm_struct
    pgprot_t vm_page_prot;              // 访问权限
    unsigned long vm_flags;             // VM_READ/WRITE/EXEC/PFNMAP
    struct rb_node vm_rb;               // 红黑树节点
    struct list_head anode;             // 区间树(avl)索引
    const struct vm_operations_struct *vm_ops; // 缺页/权限等回调
    unsigned long vm_pgoff;             // 文件映射
    struct file *vm_file;               // 映射文件
    void *vm_private_data;              // 驱动私有数据
};

VMA采用红黑树+链表+区间树三种结构混合索引:

  • 查找包含某个地址的VMA:O(log n)红黑树搜索
  • 遍历所有VMA:链表O(n)
  • 查找重叠VMA:区间树最优

这种三合一线性结构解决了历史上仅用链表时O(n)查找导致mmap密集进程(如JDK、数据库)性能恶化的问题。

4.2 多级页表:四级到五级的演进

x86_64架构原生支持4级页表(48位虚拟地址),Linux内核在5.5版本后引入了5级页表扩展(57位虚拟地址,128PB地址空间)。

48位虚拟地址划分(4级页表):

| PGD (9bit) | P4D (9bit) | PUD (9bit) | PMD (9bit) | PTE (9bit) | Offset (12bit) |
    bits 47:39    38:30       29:21       20:12       11:3         2:0

虚拟地址 → 物理地址转换流程:

1. mm_struct->pgd 找到 PGD 基址
2. CR3寄存器 + PGD索引 → P4D表项
3. P4D + P4D索引 → PUD表项
4. PUD + PUD索引 → PMD表项
5. PMD + PMD索引 → PTE表项(指向Page)
6. PTE + Offset → 最终物理地址

大页支持(Huge PTE/巨页)通过在更高级别页表直接指向大页,减少TLB Miss:

大页级别 大小 对应级别
PTE Level 4KB 5级
PMD Level 2MB PMD指向1个大页
PUD Level 1GB PUD指向1个大页
$ cat /proc/meminfo | grep Huge
HugePages_Total:    1024
HugePages_Free:      512
HugePages_Rsvd:      256
HugePages_Surp:        0
Hugepagesize:       2048 kB

5级页表通过增加P4D Level进一步扩展:

57位虚拟地址划分(5级页表):

| PGD (9bit) | P4D (9bit) | PUD (9bit) | PMD (9bit) | PTE (9bit) | Offset (12bit) |
    bits 56:48    47:39       38:30       29:21       20:12       11:0(含扩展)

Linux的5级页表可通过编译时CONFIG_PGTABLE_LEVELS=5启用,
运行时内核通过`pgtable_l5_enabled()`判断,
若硬件不支持57位则自动回退为4级模式。

4.3 KPTI:内核页表隔离

2018年披露的Meltdown漏洞暴露了这样一个事实:内核态数据可以被用户态推测执行间接读取。KPTI(Kernel Page-Table Isolation)通过完全分离用户/内核页表来缓解:

  • 用户态页表:仅包含用户空间映射+最小量中断/trampoline入口
  • 内核态页表:完整内核映射

进程进入内核态(syscall/interrupt)时切换到内核页表,返回时切换回用户页表。

代价:每次syscall都有TLB flush开销,O_DIRECT、get_user_pages等频繁穿越路径性能影响显著。

# 查看KPTI状态
$ cat /sys/devices/system/cpu/vulnerabilities/meltdown
Mitigation: PTI

4.4 反向映射(Reverse Mapping)

当内核需要将一个匿名页面回收(swap out)时,需要快速找到所有引用该页面的虚拟地址。反向映射提供了从page到(vma+虚拟地址)的逆向索引:

struct page -> anon_vma (anon) 或 address_space (file)
     → 遍历anon_vma链表 / mapping的区间树
        → 找到所有引用该页的PTE
           → 要么写回swap/file,要么在vma->rb_tree中标记回收

反向映射实现:

**匿名页**:
Page -> anon_vma (page->mapping & ~PAGE_MAPPING_FLAGS)
        anon_vma链表串联所有引用该页面的子VMA
        子VMA的rb_root遍历找到精确pte

**文件页**:
Page -> address_space (page->mapping)
        address_space的i_mmap(interval tree/rbtree)
        → 区间树遍历,找到所有vm_area_struct
          →精确pte

这种设计在page_reuse()和try_to_unmap()中起到了关键作用,是页面回收性能的决定因素。

五、页面回收与OOM Kill

5.1 kswapd:后台回收内核线程

每个NUMA节点运行一个kswapd守护线程,负责在内存紧张时回收页面,维持Zone水位线在high以上。

kswapd() {
    while (!kthread_should_stop()) {
        prepare_to_wait();
        // 遍历节点所有Zone
        for (zone in zones) {
            if (zone_below_high(zone)) {
                reclaim_balance();   // 平衡回收
            }
        }
        // 全部Zone高于high则睡眠
        schedule_timeout(HZ/10);
    }
}

回收优先级:

  • 干净的文件页(直接丢弃,无需写回) —— 成本最低
  • 脏页(写回inode地址空间的writeback) —— 中等成本
  • 匿名页面(写入swap分区的swap cache) —— 高成本

vm.swappiness参数(默认60,CentOS建议10)控制匿名页与文件页的回收比例平衡:

  • swappiness=0:完全避免swap除非无其他选择(内存足够时不要swap)
  • swappiness=100:匿名页和文件页同等回收
  • swappiness=10:适度swap(生产服务器推荐值)

5.2 直接回收(Direct Reclaim)

当Kswapd来不及回收时,申请页面的进程会被迫进入直接回收(发生在GFP_KERNEL路径),会阻塞等待回收完成。这会导致明显的延迟尖刺——在高IOPS的数据库和实时系统中尤为突出。

retry:
    page = alloc_pages();
    if (!page) {
        // 进入直接回收路径
        did_some_progress = try_to_free_pages();
        if (did_some_progress)
            goto retry;
        // 回收失败,触发OOM
        out_of_memory();
    }

5.3 内存压缩(Memory Compaction)

启动时分配的页面可能形成了"孤岛",导致大块连续内存被碎片化。内存压缩通过迁移页面来整理碎片:

compact_zone() {
    for (page in zone.pages) {
        if (page_is_movable(page)) {
            // 扫描前半部分找可移动页
            isolate_migratepage(page);
            // 扫描后半部分找空闲页
            if (target = find_free_page()) {
                migrate_page(page, target);
            }
        }
    }
}

触发时机:

  • echo 1 > /proc/sys/vm/compact_memory:手动触发全节点压缩
  • THP分配失败时自动触发
  • 热插拔前压缩

vm.compaction_proactiveness(0-100,默认0)控制内核主动压缩的激进程度。

5.4 OOM Killer

当所有回收手段耗尽仍不能满足分配请求时,OOM Killer会选择一个进程杀掉以释放内存。

评分机制(简化版):

points = total_vm / 4 + oom_score_adj * total_vm / 1000

oom_score_adj取值范围-1000到+1000:

  • -1000:永远不会被杀死(如systemd、init)
  • +1000:优先被杀死(如测试软件)
  • 0:使用默认计算
# 查看进程OOM分数
$ ps -eo pid,comm,oom_score --sort=-oom_score | head -10
    PID COMMAND     OOM_SCORE
 21356 chromium     1000
  4812 mysqld        237
  8392 java          195

OOM发生时内核将VM OOM事件写入dmesg:

[12345.678] Out of memory: Killed process 21356 (chromium) 
             total-vm:8192000kB, anon-rss:3192000kB, 
             file-rss:0kB, shmem-rss:0kB

六、NUMA感知分配与CMA

6.1 NUMA分配策略

当进程在某个CPU上执行时,内核默认将该CPU所在NUMA节点的内存作为分配目标(local分配)。mbind()/set_mempolicy()可以显式改变进程的NUMA策略:

策略 含义
MPOL_DEFAULT 本地优先
MPOL_BIND 限定仅在指定节点
MPOL_PREFERRED 优先指定节点,不足则fallback
MPOL_INTERLEAVED 轮询分配在多个节点
// mbind示例:将进程虚拟地址空间绑定到节点0
unsigned long nodemask = 1 << 0;
mbind(addr, len, MPOL_BIND, &nodemask, sizeof(nodemask)*8, 0);

自动均衡(AutoNUMA Balancing)是4.x内核引入的机制,通过采样缺页中断发现被远程访问的页面,将其迁移回本地节点:

# 查看当前NUMA统计
$ cat /sys/devices/system/node/node0/numastat
numa_hit         129548
numa_miss         31587
numa_foreign        892
interleave_hit    2105
# numa_miss 大说明远程分配较多,需考虑进程绑定策略

6.2 CMA:连续内存分配器

嵌入式系统、GPU、VPU等设备需要大块物理连续的内存(通常几十MB到几百MB),但启动后内存碎片化使得分配困难。CMA通过在启动时将一段内存预留,平时作为普通页面使用,设备需要时再回收或迁移:

start
   |
CMA区域(预留)       Buddy区域
| 可借用的页面 |   | 可用物理内存 |
   |
设备请求连续内存
   |
| 区域中:
  - 空闲页直接分配
  - 干净文件页丢弃
  - 脏页迁移到Buddy
  - 匿名页 swap out
   |

CMA的核心优势是按需分配:没有设备使用时不浪费内存,设备需要时可在毫秒级回收。

# CMA区域信息
$ dmesg | grep -i cma
[0.000000] Reserved memory: created CMA memory pool at 0x0000000030000000, size 256 MiB
[0.000000] OF: reserved mem: initialized node linux,cma, compatible id shared-dma-pool

驱动中使用dma_alloc_coherent()或dma_alloc_from_contiguous()申请CMA内存。

七、系统调用接口:mmap/brk

7.1 brk:堆的扩展

brk()系统调用调整程序中断点(program break),扩展堆的上限。默认glibc malloc用brk分配小对象(<128KB),用mmap分配大对象。

old_brk = sbrk(0);           // 获取当前堆顶
brk(new_address);            // 扩展/收缩堆

内核实现中,brk仅移动mm->brk和mm->start_brk,真正的页面在缺页时才分配(demand paging)。

# 查看进程的内存布局
$ cat /proc/self/maps
00400000-00452000 r-xp 00000000 08:01 131077  /bin/bash
00651000-00652000 r--p 00051000 08:01 131077  /bin/bash
00652000-0065b000 rw-p 00052000 08:01 131077  /bin/bash
01b4f000-01cfe000 rw-p 00000000 00:00 0       [heap]  ← brk扩展
7f1234500000-7f1240000000 r--s ...                [匿名映射   ← mmap

7.2 mmap:文件/匿名映射

mmap()是更通用的内存映射接口,可做文件映射和匿名映射:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
flags 含义
MAP_SHARED 映射修改对其他进程/文件可见
MAP_PRIVATE 写时复制(COW),修改不影响原文件
MAP_ANONYMOUS 无文件支持的匿名映射
MAP_HUGETLB 使用大页
MAP_POPULATE 预分配页面(prefault)
MAP_LOCKED mlock般不回收

内核mmap的实现路径:

sys_march_pgoff()
  → vm_mmap_pgoff()
    → do_mmap_pgoff()
      → get_unmapped_area()    // 找到一个合适的空闲区域
      → mmap_region()
        → vm_area_alloc()   // 分配VMA结构体
        → vma_link()        // 插入红黑树+链表+区间树
        → 若文件映射:file->f_op->mmap() 建立address_space
        → 若匿名映射:关联anon_vma

热门优化:glibc malloc在超过MMAP_THRESHOLD(默认128KB,可通过mallopt()调整)时自动用mmap,避免brk扩展导致的堆碎片化。M_MMAP_THRESHOLD参数调高会增加内存碎片风险,调小会增加syscall开销。

7.3 Page Fault的完整处理

用户态访问映射但未分配物理页面的VMA时,触发缺页异常:

do_page_fault(addr)
  → find_vma(addr)          // 找到包含addr的VMA
  → if not found → SIGSEGV
  → handle_mm_fault()
    → hugetlb_fault()       // 若为VM_HUGETLB
    → __handle_mm_fault()
      → p4d_alloc → pud_alloc → pmd_alloc → pte_alloc
        构建多级页表 → 真正的映射建立
      → do_anonymous_page()
          分配新page → 加入anon_vma
      → do_fault()
          do_read_fault()  → filemap_fault()
          do_cow_fault()   → 复制页面
          do_shared_fault() → 写时复制页面
          → 更新Page Table Entry
          → 标记页面reference

Minor Fault:无需I/O,从swap Cache或静默分配新页即可。

Major Fault:需从磁盘读取文件(涉及块设备I/O)。

Minor vs Major Fault比例是衡量应用程序工作集大小和内存压力的关键指标。

$ ps -eo min_flt,maj_flt,cmd -p $(pidof mysqld)
 MINFL  MAJFL CMD
15284      3 /usr/sbin/mysqld

八、性能调优实战

8.1 减少直接回收

直接回收导致请求线程阻塞,是高延迟的直接原因。优化方法:

# 1. 提高min_free_kbytes(内核保留页面池)
echo 67584 > /proc/sys/vm/min_free_kbytes

# 2. 降低vfs_cache_pressure(文件缓存压力系数,默认100)
echo 50 > /proc/sys/vm/vfs_cache_pressure

# 3. 增大dirty_ratio/dirty_background_ratio
echo 40 > /proc/sys/vm/dirty_ratio
echo 10 > /proc/sys/vm/dirty_background_ratio

# 4. 提高extfrag_threshold
echo 500 > /proc/sys/vm/extfrag_threshold

8.2 配置HugePages

HugePage减少TLB miss和页表开销,对大内存应用(数据库、JVM、Redis)效果显著:

# 静态大页:sysctl or grub param
echo 1024 > /proc/sys/vm/nr_hugepages

# 查看当前大页使用
cat /proc/meminfo | grep Huge

# 透明大页(THP)—— 内核自动合并4KB→2MB
echo always > /sys/kernel/mm/transparent_hugepage/enabled

注意:THP在数据库工作负载下可能导致延迟尖刺,建议对PostgreSQL/MySQL设置为madvise:

echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
# 然后在应用中使用madvise(addr, len, MADV_HUGEPAGE)提示使用大页

8.3 Slub调优

# Slub调试:检测use-after-free and buffer-overflow
slub_debug=FZP

# F - 启用freelist POISON
# Z - 启用redzoning(对象边界增加红区)
# P - 启用对象POISON(释放后填充0x5a)

启动参数添加:

slub_debug=FZP page_poison=1 page_alloc.shuffle=1

8.4 NUMA调优

# 启用numa balancing
echo 1 > /proc/sys/kernel/numa_balancing

# numad:用户态NUMA均衡daemon
# numactl 绑定:
numactl --cpunodebind=0 --membind=0 /usr/sbin/mysqld

# vm.zone_reclaim_mode
echo 0 > /proc/sys/vm/zone_reclaim_mode
# 0=不强制从本地节点回收
# 1=优先本地节点回收

8.5 监控诊断命令集合

# 1. 查看系统内存概况
free -h && cat /proc/meminfo | grep -E "MemTotal|MemFree|Buffers|Cached|SwapTotal|SwapFree|HugePages"

# 2. 查看NUMA节点状态
numastat -p $(pidof mysqld)

# 3. 查看OOM进程列表
dmesg -T | grep -E "Out of memory|oom-kill|oom_reaper"

cat /proc/buddyinfo          # Buddy碎片分析
cat /proc/pagetypeinfo       # 迁移类型分析
cat /proc/vmallocinfo        # vmalloc分配区域
cat /proc/iomem              # 物理内存布局
cat /proc/slabinfo           # Slab缓存统计

# 4. 进程内存详细分析
cat /proc/<pid>/smaps        # 每个VMA的详细内存统计
cat /proc/<pid>/numa_maps    # NUMA内存分布
pmap -X <pid>               # VMA布局

# 5. 内存相关eBPF工具
bpftrace -e 'kprobe:oom* { printf("OOM %s\n", comm); }'  # OOM事件追踪

# 6. 页面错误统计
pidstat -r -p <pid> 1        # 每秒Major/Minor fault计数

8.6 排查OOM案例

当系统发生OOM,按以下步骤排查:

# Step 1: 确认OOM事件
dmesg -T | tail -30 | grep -i "oom\|out of memory"

# Step 2: 检查Zon
cat /proc/zoneinfo | grep -E "per-node stats|min|low|high"

# Step 3: 找出内存大户
ps -eo pid,user,rss,cmd --sort=-rss | head -10

# Step 4: 查看当前Buddy碎片
cat /proc/buddyinfo
# 若高order(如8-10)几乎为0,说明严重碎片化

# Step 5: C
echo 3 > /proc/sys/vm/drop_caches   # 清空page cache (slab可先echo 2)

九、与其他子系统的关联

9.1 内存管理 ↔ 进程/调度

  • task_struct通过mm_struct与内存子系统关联
  • fork()触发写时复制(COW),不真正复制父进程页面
  • execve()加载ELF可执行文件,建立新的mmap区域

9.2 内存管理 ↔ 文件系统

  • Page Cache(文件页缓存)由address_space管理
  • 脏页通过writeback线程写回块设备
  • fadvise()提示文件访问模式(sequential/random/noseek)

9.3 内存管理 ↔ 网络栈

  • socket buffer(skb)使用alloc_skb()从skbuff_head_cache Slab分配
  • TCP zerocopy依赖页面引用计数而非拷贝
  • XDP将页面返回驱动供重用(zero-copy AF_XDP)

9.4 内存管理 ↔ 虚拟化(KVM)

  • EPT页表复用四级/五级页表架构
  • VFIO通过DMA IOMMU映射虚拟机物理地址到Host物理地址
  • virtio-balloon通过页面迁移实现内存热回收
  • 前文KVM文章深入讨论了EPT/NPT的嵌套分页机制

总结

Linux内核内存管理是一个从物理页框元数据到用户态虚拟地址的完整链条:

struct page (物理元数据)
    ↓
Zone + Buddy分配器 (大块物理分配)
    ↓
Slab/Slub/Slob分配器 (小对象缓存)
    ↓
页表/MMU (虚拟地址→物理地址)
    ↓
VMA (进程地址空间管理)
    ↓
mmap/brk + Page Fault (用户态接口)
    ↓
kswapd + compation + OOM Killer (回收保护)

理解每一层的职责和相互关系,才能在大规模部署、性能调优、驱动开发中游刃有余。与前文KVM内存虚拟化与EPT/NPT嵌套分页不同的是,本文聚焦Host内核自身的内存管理体系,两者共同构成了从物理硬件到虚拟机的完整内存视图。

在云原生和AI时代,内存管理面临新的挑战:

  • CXL内存扩展:内存池化与分层管理
  • PMem (Persistent Memory):DAX直接访问绕开页面缓存
  • GPU/TPU统一地址空间:HMM(Heterogeneous Memory Management)使设备内存纳入主机页表
  • 容器内存QoS:cgroup v2的memory.high/max/min实现精细控制

未来内核内存管理将继续向异构、分层、可观测的方向演进,但核心概念(Buddy + Slab + 页表)几十年来保持稳定——掌握基础才能应对变化。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.436823s