一、虚拟内存系统架构总览

1.1 虚拟地址空间布局

Linux内核为每个进程维护独立的虚拟地址空间,在x86_64架构下采用四级(或五级)页表结构。用户空间与内核空间按1:1分割(各128TB),内核空间又分为直接映射区(Direct Mapping Area)、vmalloc区、持久映射区和固定映射区。理解这一布局是掌握内存管理的基础。

```c // 典型 x86_64 进程地址空间(4级页表): // 0x0000 0000 0000 0000 - 0x0000 7FFF FFFF FFFF → 用户空间 (128TB) // 0xFFFF 8000 0000 0000 - 0xFFFF 87FF FFFF FFFF → ... non-canonical hole // 0xFFFF 8800 0000 0000 - 0xFFFF BFFF FFFF FFFF → 直接映射区 (物理内存线性映射) // 0xFFFF C000 0000 0000 - 0xFFFF C87F FFFF FFFF → vmalloc/ioremap 区域 // 0xFFFF C880 0000 0000 - 0xFFFF E8FF FFFF FFFF → 模块映射区 // 0xFFFF E900 0000 0000 - 0xFFFF E97F FFFF FFFF → fixmap 区域 // 0xFFFF FFFF 8000 0000 - 0xFFFF FFFF FFFF FFFF → 内核代码/栈 ```

1.2 物理内存模型:NUMA 与 flatmem

Linux支持两种物理内存模型:flatmem(平坦内存)和 sparsemem(稀疏内存)。服务器上常见NUMA架构,每个Node维护独立的内存管理区(pglist_data),Zone又细分为DMA、DMA32、Normal和Movable。ZONE_DEVICE用于异构内存(如GPU显存通过HMM接入)。

```c // 核心数据结构 struct pglist_data { // 一个 NUMA Node struct zone node_zones[MAX_NR_ZONES]; // 各 Zone struct zonelist node_zonelists[FALLBACK_TYPES]; // 分配 fallback 列表 unsigned long node_start_pfn; // 起始页帧号 unsigned long node_present_pages; // 实际存在页数 unsigned long node_spanned_pages; // 跨度页数(含空洞) }; ```

1.3 页分配器(Buddy System)核心机制

Buddy分配器是物理内存管理的基石,它将页面按阶(order,即2^n个连续页)组织到 free_area 链表中。分配时从匹配的阶取出页面,若不足则从高阶分裂;释放时逆向合并伙伴块。每个free_area管理连续2^n页的内存块,max_order通常为11(即最大连续4MB)。内核使用GFP(Get Free Pages)标志控制分配行为:GFP_KERNEL允许睡眠和I/O操作,GFP_ATOMIC用于中断上下文,GFP_NOWAIT则不触发直接回收。每CPU页面缓存(PCP/Per-CPU Pages)大幅减少了多核争用,在高并发场景下分配延迟降低90%以上。

```c struct free_area { struct list_head free_list[MIGRATES_TYPES]; unsigned long nr_free; }; // 伙伴系统合并条件:两个块大小相同、物理连续、同属一个zone、且低地址块的起始地址能被 2^(order+1) 整除 ```

二、页表管理与地址转换

2.1 x86_64 四级页表结构

虚拟地址在4级页表下被拆分为:PML4(位47-39)→PDPT(位38-30)→PD(位29-21)→PT(位20-12)→页内偏移(位11-0)。每级512个条目(9位),每级页表恰好占一页(4KB)。内核用pgd_t/p4d_t/pud_t/pmd_t/pte_t表示各级页表项中的数据结构,通过set_pte/set_pmd/set_pud等宏写入,最终由MMU硬件完成查表。LA57(5级页表)扩展到2^57字节虚拟地址空间。

```c // 关键转换宏 (include/asm-generic/pgtable-nop4d.h 简化) // pgd → p4d → pud → pmd → pte → physical_page pgd_t pgd = pgd_offset(mm, addr); // 获取 PML4 项 pud_t pud = pud_offset(pgd, addr); // 获取 PDPT 项 pmd_t pmd = pmd_offset(pwd, addr); // 获取 PD 项 pte_t *pte = pte_offset_map(pmd, addr); // 获取 PT 项 struct page *page = pfn_to_page(pte_pfn(*pte)); // 获取物理页 ```

2.2 TLB管理与ASID/PCID优化

TLB(Translation Lookaside Buffer)缓存最近使用的页表项,避免每次地址转换都走页表遍历。x86_64支持PCID(Process-Context Identifiers)免全量刷新:INVPCID指令可按条目或按PCID局部刷新TLB。内核在上下文切换时使用PCID避免每次切换进程都全刷TLB,这对密集微基准测试的场景性能提升可达30%。大页(2MB/1GB)通过减少TLB miss进一步提升内存密集型应用的吞吐量,hugetlbfs和Transparent Huge Pages(THP)是两种实现方式。THP默认开启(madvise模式),通过 khugepaged 后台线程自动合并符合条件的普通页。

```c // PCID 相关的 context switch 优化 (arch/x86/mm/tlb.c) // 使用 invpcid_flush_one(pcid, addr) 精准失效单条 TLB // 或 reload_cr3() 配合 PCID 位避免全量刷新 ```

2.3 MMU Notifier机制

MMU Notifier在内核需要失效远程TLB(如KVM虚拟机内存回收、内存去重KSM)时提供回调通知。通过 `mmu_notifier_register` 注册,当宿主机需要对Guest物理页执行unmap、写保护或页迁移时触发回调,让KVM更新EPT(扩展页表)。这是虚拟化性能的关键路径——在高密度虚拟机场景中,TLB Shootdown的延迟直接影响所有虚拟机的内存访问性能。

```c struct mmu_notifier_ops { void (*invalidate_range_start)(...); // 失效开始(锁住范围) void (*invalidate_range_end)(...); // 失效结束 void (*invalidate_page)(...); // 单条失效(旧接口) void (*change_pte)(...); // 页表项修改(如写保护) void (*release)(...); // mm 释放时 }; ```

三、SLAB/SLUB/SLOB分配器深度解析

3.1 设计哲学:内核对象的缓存池

kmalloc 和大量内核子系统(如dentry、inode、task_struct等)频繁分配释放固定大小的结构体。伙伴系统以页(4KB)为粒度,对于几十到几百字节的小对象而言内部碎片极其严重。SLAB分配器的核心思想是:预先从伙伴系统获取页面,将其切割为等长对象的缓存池,分配时从空闲链表取用一个已初始化的对象,释放时归还而非销毁——避免重复初始化构造/析构的开销。这种"slab-full/slab-partial/slab-empty"三态管理加上着色(cache coloring)优化L1/L2 CPU cache的分布。

``` ┌─────────────────────────────────────────────────────────┐ │ SLAB 分配器架构 │ ├─────────────────────────────────────────────────────────┤ │ kmem_cache (缓存描述符) │ │ ├─ slab_full → 已满 slab 链表 │ │ ├─ slab_partial → 有空闲的 slab 链表(优先从这里分配) │ │ └─ slab_empty → 全部空闲的 slab 链表 │ │ slab 结构体 │ │ ├─ inuse (已用对象数) │ │ ├─ objects[] (对象数组) │ │ └─ freelist (空闲对象栈顶指针) │ └─────────────────────────────────────────────────────────┘ ```

3.2 SLUB分配器:现代默认选择

SLUB是Linux 2.6.23之后取代SLAB的默认分配器,核心改进是取消每CPU复杂队列和metadata冗余。SLUB将freelist指针直接嵌入页面结构体(struct page中的freelist和inuse字段),每个slab页面完全自管理。red zoning和poison机制启用后可检测越界和释放后使用(UAF)。CONFIG_KASAN(Kernel Address Sanitizer)进一步通过shadow memory和redzone为内核内存错误提供近乎100%的覆盖率检测。

```c // SLUB核心:struct kmem_cache struct kmem_cache { struct kmem_cache_cpu __percpu *cpu_slab; // 每CPU快速路径 unsigned long flags; unsigned int size; // 对象总大小(含对齐) unsigned int object_size; // 原始请求大小 unsigned int offset; // 空闲指针偏移 struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA 节点级管理 }; // 分配路径: // 1. 检查 cpu_slab->freelist → 如有空闲,直接 pop // 2. 检查 page->freelist → 从 partial slab 取 // 3. 新页面分配 → 从 buddy 系统获取 page 转为 slab ```

3.3 kmalloc cache系列与size-class

kmalloc不是单一缓存而是预建了从8字节到8K的多档通用缓存(size-cache),各档按2的幂次或常见尺寸编排(如96、128、192、256、512、1024等)。直接kmalloc(200)会匹配到256字节缓存。若要持久使用特定大小对象,应通过 kmem_cache_create 自建缓存,可以附带 SLAB_ACCOUNT 标志进行内存cgroup统计。kmalloc_large 在大尺寸请求(>8K)时直接回退到buddy系统。

```bash # 查看 kmalloc 缓存大小档 sudo cat /proc/slabinfo | grep "kmalloc" # kmalloc-8 1024 1024 8 512 ... # kmalloc-64 20480 20420 64 64 ... # kmalloc-256 128000 127500 256 32 ... ```

3.4 kmemleak:检测内核内存泄漏

kmemleak是内核内置的内存泄漏检测器,跟踪所有通过 kmalloc/kmem_cache_alloc/vmalloc 等分配的内存,并在定时扫描时检查是否有指针引用到该内存块。若一段时间内无可达引用则标记为泄漏。开启时需在启动参数加kmemleak=on并通过 debugfs 触发扫描和查看结果。

```bash # 触发扫描 echo scan > /sys/kernel/debug/kmemleak # 查看泄漏报告 cat /sys/kernel/debug/kmemleak # 输出示例: # unreferenced object 0xffff88807d2a4000 (size 256): # comm "test_module", pid 1234, jiffies 4294900000 # backtrace: # [] kmem_cache_alloc_trace+0x57/0xa0 # [] my_leaky_func+0x23/0x50 ```

四、反向映射(Reverse Mapping)与页面回收

4.1 anon_vma:匿名页的反向映射

虚拟内存区域(VMA)被多个进程共享(fork后的子进程)或在KSM中合并时,需要高效找到引用某个物理页的所有VMA。匿名页通过 anon_vma 实现间接页表遍历:物理页→anon_vma→父进程的VMA链表→子进程的VMA→PTE。fork操作通过 anon_vma_clone 将新进程VMA挂入原有anon_child链表。madvise(MADV_DONTNEED)时只断VMA写权限而不断物理页,anon_rbtree用红黑树管理优化查找。

```c struct anon_vma { struct anon_vma *root; // 指向根 anon_vma rwlock_t rwlock; atomic_t refcount; unsigned degree; // fork 深度 struct rb_root_cached rb_root; // anon_vma_chain 红黑树 }; ```

4.2 页回收:KSM与LRU双链算法

页回收的核心是 LRU双链(active/inactive)。页面首次加入inactive链表,第二次访问时提升到active。回收优先从inactive尾部取出,根据页类型分为文件缓存页(直接写回或丢弃)和匿名页(写入swap)。kswapd是后台守护线程,当水位低于WMARK_HIGH时开始扫描回收。KSM(Kernel Same-page Merging)扫描所有可合并页,对相同内容页合并为同一物理页并标记为COW(写时复制)。在KVM环境下内存超量使用时KSM节省30-60%内存。THP与KSM存在互斥——THP无法被KSM合并大页。

``` 内存回收路径: ┌──────────────┐ │ alloc_pages │ │ GFP 标志 │─────→ 分配路径 └──────┬───────┘ ↓ wm_mark_low → kswapd 唤醒 → direct_reclaim → shrink_lruvec → 1. 扫描 inactive_list LRU 2. PG_referenced → 晋升 active 3. 页回写/swap → 释放物理页 ```

4.3 MADV_FREE vs MADV_DONTNEED

glibc的MADV_FREE(通过malloc_trim触发)由内核5.14后以lazy释放策略标记页面可回收但不立即断开映射,实际在下次内存压力时才真正回收,减少不必要的缺页异常。MADV_DONTNEED则立即抛弃:如果是私有匿名页直接释放物理页并清零下一次访问的页帧,如果是文件映射则从page cache移除。jvm通过MADV_FREE释放Java堆内存到大页时性能提升显著。

```c // 关键 hook (mm/madvise.c) // madvise_free_pte_range: 清除 PG_dirty 和 PG_referenced, 标记 lazily free // madvise_dontneed_free: 立即释放 + 零页重映射 ```

五、vmalloc vs kmalloc 选择策略

5.1 vmalloc:用连续虚拟地址换取非连续物理页

vmalloc分配虚拟地址连续但物理页面不必连续的内存区域,通过修改内核页表将多个独立物理页映射到连续的vm_area。适合分配大块缓冲区(如模块加载、io_uring 环形缓冲区在内核空间的镜像)。代价是引入TLB开销和额外的页表遍历,物理上不连续导致DMA需要scatter-gather。最大可分配大小受限于VMALLOC_START-VMALLOC_END区域。Huge vmalloc(启用CONFIG_HAVE_ARCH_HUGE_VMALLOC)使用大页减少TLB miss。

选择规则:

  • kmalloc:物理连续性需求、大小<8K、GFP_DMA/DMA32约束、DMA操作
  • vmalloc:仅需虚拟连续性、加载内核模块、大缓冲区>8K
  • kmem_cache_alloc:高频率分配固定大小对象、NUMA亲和性要求
  • alloc_pages:页粒度分配、DMA32外分配、直接映射区

六、调试:OOM Killer与cgroup内存控制

6.1 OOM Killer评分机制

当物理内存和swap耗尽时,OOM Killer基于oom_score(0-1000)选择进程终止。评分算法考虑:常驻内存的1%(RSS基准)、CPU时间短的加分(不杀长期运行的服务)、oom_score_adj(-1000~1000手动调整)。systemd和容器运行时通过oom_score_adj保护关键进程(sshd、kubelet得分-999/-1000)。/proc/[pid]/oom_score可实时观察。

6.2 cgroup v2 memory控制器

cgroup v2提供memory.min/memory.low/memory.max/memory.high四个阈值分别控制硬保护、软回收限制、硬上限(触发OOM)和节流阈值。memory.high超过时内核开始节流回收,memory.max(单位字节)超过后触发进程OOM。io_uring等内核子系统也有独立的内存计费(在0.7+内核中通过MEMCG_IOURING标志),防止用户态绕过cgroup限制。

```bash # 查看 cgroup 内存使用 cat /sys/fs/cgroup/memory.current # 设置 1GB 硬上限 echo 1073741824 > /sys/fs/cgroup/memory.max # 实时监控: # memory.peak(自 reset 后的峰值) # memory.stat(详细缓存/活跃页/回收/OOM事件统计) ```

七、调优实战与性能观测

7.1 关键sysctl参数

参数默认值用途
vm.swappiness600-100,越高越积极使用swap。内存充足时设为0避免Swap风暴
vm.dirty_ratio20脏页写回阈值,大块顺序写场景可调高到40
vm.min_free_kbytes自适应保留应急内存,8GB内存约67MB。防止分配路径饥饿和OOM
vm.overcommit_memory00=启发式超量, 1=永远超量(DPDK场景), 2=禁止超量
kernel.core_patterncrash dump配置,核心服务建议开启kdump/coredump

7.2 perf与BPF观测技巧

```bash # 观测TLB miss率(perf) perf stat -e dTLB-load-misses,dTLB-loads -p $PID # 跟踪 kmem 分配 perf probe --add 'kmem_cache_alloc name' perf record -e probe:kmem_cache_alloc -ag -- sleep 5 # eBPF跟踪 SLAB分配延迟 bpftrace -e 'kretprobe:do_kmalloc { @ns = hist((nsecs - @start[tid])); }' bpftrace -e 'kprobe:mm_page_free { @frees = count(); }' # 检查页回收活动 sar -B 1 # pgpgin/pgpgout/pswpin/pswpout每秒钟 vmstat 1 # si/so (swap in/out列) 非0表示页回收紧张 ```

7.3 THP与hugetlbfs最佳实践

数据库场景(MySQL、PostgreSQL)一般开启THP并设为madvise模式。HPC应用(如DPDK、TensorFlow大张量)使用hugetlbfs固定大容量TLB。MongoDB推荐将THP关闭以避免内存碎片和缺页延迟抖动。MEMORY_RECLAIM(通过/sys/kernel/mm/ksm)对容器密度高的场景效果显著。

```bash # 关闭 THP(数据库推荐) echo never > /sys/kernel/mm/transparent_hugepage/enabled echo never > /sys/kernel/mm/transparent_hugepage/defrag # 查看大页使用情况 cat /proc/meminfo | grep -i hugepages # HugePages_Total: 1024 # HugePages_Free: 1024 # Hugepagesize: 2048 kB ```

八、DMA与一致性映射

8.1 一致性与流式DMA映射

DMA存在两种映射模式:一致性映射(dma_alloc_coherent)按cache line对齐且硬件cache一致,但分配代价高,适合长期存在、频繁访问的描述符环。流式映射(dma_map_single在dma_map_sg)是主要模式,仅在传输时建立映射并手动同步(dma_unmap_single后调用 dma_sync_single_for_cpu/device)。SWIOTLB在UEFI安全启动或IOMMU可用时作为bounce buffer回退路径。scatter-gather列表可聚合不连续物理页,减少dma_map调用次数。

```c // 典型网卡驱动DMA流程 void *buf = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL); dma_addr_t dma = dma_map_single(dev, virt, len, DMA_TO_DEVICE); // 启动DMA传输... dma_sync_single_for_cpu(dev, dma, len, DMA_FROM_DEVICE); // 传输完成后CPU再看 dma_unmap_single(dev, dma, len, DMA_FROM_DEVICE); ```

8.2 IOMMU对内存性能的影响

IOMMU(VT-d/SMMU)为DMA提供IOVA虚拟地址翻译和访问隔离。启用IOMMU后每次DMA需要页表遍历,但好处是不要求物理连续(dma_map_single IOBA和PA可以是任意映射)。NFV建议开启iommu.passthrough=1减少开销,而数据库和文件系统DMA场景建议关闭IOMMU获得最大吞吐量。

九、实战案例分析

9.1 多核下SLUB的CPU-bound场景

某高并发网络服务(开启io_uring+零拷贝)在32核机器上观测到 kmalloc 调用耗时异常。通过perftop发现 cpu_slab→freelist的快速路径争用导致NUMA远程访问放大。解决:为热点路径自建 kmem_cache 并使用 SLAB_ACCOUNT 按cgroup计费,同时为离线数据分配使用 GFP_NOWAIT 避免kswapd唤醒延迟。

9.2 Page Cache与O_DIRECT的抉择

数据库引擎常选择O_DIRECT绕过Page Cache来避免双重缓存(DB自己的buffer pool + 内核page cache)。但O_DIRECT的页对齐和iommu映射在大I/O环境下引入额外开销。最新内核(5.15+)的uring_cmd和net/socket零拷贝(MSG_ZEROCOPY)提供了第三种选择:不污染内核缓存又避免O_DIRECT对齐限制。

9.3 容器Cgroup OOM的排查

K8s Pod频繁OOM但/proc/meminfo显示充足。通过cgroup memory.stat发现kernel_stack和pagetables占用过高(大量线程),设置pods的.kernel_stack_kb=64可在安全前提下回收内存。此外开启memory.swap.max=0防止swap驱逐导致服务超时。


本文深入回顾了Linux内核虚拟内存与分配器的核心机制,涵盖了从伙伴系统到SLUB分配器的完整链条,以及页表、TLB、反向映射、回收和OOM的运维实战。下一章我们将将探讨块I/O层、BIO与多队列blk-mq的架构演进。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部