1、Linux内存全景:物理内存与虚拟内存架构、MMU与页表机制、NUMA架构与内存节点

各位好,我是老李。做Linux内核性能优化这么多年,我最大的感触就是——内存这玩意儿,你越了解它,就越能驯服它。很多同学上来就盯着OOM、Swap、内存泄漏这些表象问题,但根因往往藏在最底层的内存架构里。

今天咱们就从地基开始,把Linux内存的全景图捋一遍。说白了,就是搞清楚三件事:物理内存长什么样?虚拟内存怎么骗过进程?MMU和页表是怎么当中间人的? 最后再聊聊NUMA,这个在现代服务器上绕不开的话题。

1.1 物理内存与虚拟内存:一场精心策划的“骗局”

先问个问题:你写程序的时候,malloc出来的地址,真的是内存条上的物理地址吗?

答案是否定的。每个进程看到的地址空间,都是一个虚拟地址空间。Linux给每个进程都画了一张大饼——在32位系统上是4GB,在64位系统上更是大到离谱(理论上是2^64字节)。

这张饼被分成了两个区域:

  • 用户空间(0x00000000 ~ 0x7FFFFFFF 在32位下):进程自己玩的地方,代码、堆、栈、数据段都在这里。
  • 内核空间(0x80000000 ~ 0xFFFFFFFF 在32位下):只有内核能访问,进程想进来?没门,除非通过系统调用。

为什么要搞这么复杂?直接操作物理内存不行吗?

我举个例子你就明白了。假设你的机器只有512MB内存,但你同时开了Chrome、VS Code、Docker、MySQL……每个程序都觉得自己独占4GB空间。如果没有虚拟内存,这些程序早就因为地址冲突打起来了。

虚拟内存的核心作用就三点:

  1. 隔离:进程A的地址0x1000和进程B的地址0x1000,映射到不同的物理页框,谁也碰不到谁的数据。
  2. 简化编程:程序员不用管物理内存还剩多少、碎片多严重,只管用连续的虚拟地址就行。
  3. 按需分配:你malloc了1GB,但只用了100MB,物理内存就只给你100MB。剩下的虚拟地址空间,先欠着。

关键认知:虚拟内存不是“内存”,它是一个地址转换层。物理内存才是真正的硬件资源。两者通过页表建立映射关系。

1.2 MMU与页表机制:地址翻译的“硬核中介”

虚拟地址怎么变成物理地址?这个活儿是MMU(Memory Management Unit)干的。它是CPU内部的一个硬件模块,专门负责地址翻译。

MMU干活靠的是页表(Page Table)。页表本质上是一个多级数组,记录了虚拟页号到物理页框号的映射关系,以及权限位(读、写、执行)。

在x86_64架构下,Linux用的是4级页表:

级别 名称 作用
PGD Page Global Directory 顶级目录,指向下一级
P4D Page 4th Directory 4级目录(某些架构没有)
PUD Page Upper Directory 上层目录
PMD Page Middle Directory 中间目录
PTE Page Table Entry 页表项,直接指向物理页框

每次访问内存,CPU都要走一遍这个四级流水线。听起来很慢对吧?确实,如果每次都查内存里的页表,性能会崩掉。

所以硬件搞了个缓存——TLB(Translation Lookaside Buffer)。它把最近用过的虚拟地址到物理地址的映射存起来。TLB命中,一次搞定;TLB未命中,才走四级页表查。

避坑指南:我曾经在一个高并发网关项目里,发现性能突然掉了一半。查了半天,发现是某个模块频繁mmap/munmap大块内存,导致TLB刷得太勤快。TLB miss率从0.1%飙到了15%。解决方案?改用HugePage,减少页表项数量,TLB一下就稳了。

页表还有一个重要特性——按需调页(Demand Paging)。进程访问一个虚拟地址时,如果对应的PTE无效(页不在物理内存中),MMU会触发一个缺页异常(Page Fault)。内核的缺页异常处理程序会去磁盘Swap分区把数据读回来,或者分配一个全新的物理页框。

嗯,这里要注意:缺页异常不一定是坏事。进程启动时,内核不会把所有代码和数据都加载到内存,而是等真正访问到那一页时才加载。这叫惰性加载,能大大减少启动时间和内存占用。

1.3 NUMA架构与内存节点:现代服务器的“内存分区”

早些年,所有CPU核心共享一条内存总线,叫UMA(Uniform Memory Access)。每个核心访问内存的速度都一样。但CPU核心越来越多,内存带宽成了瓶颈。

于是NUMA(Non-Uniform Memory Access)架构出现了。说白了,就是把CPU和内存分成多个节点(Node)。每个节点有自己的CPU核心、内存控制器和内存条。

访问本节点的内存(Local Access)很快,访问其他节点的内存(Remote Access)要跨互联总线,延迟高、带宽低。

Linux内核里,每个NUMA节点用一个struct pglist_data结构体表示,里面管理着该节点的所有物理内存页。每个节点又分成多个管理区(Zone):

Zone名称 范围 用途
ZONE_DMA 0 ~ 16MB 老式ISA设备DMA使用
ZONE_DMA32 0 ~ 4GB 32位设备DMA使用
ZONE_NORMAL 16MB ~ 896MB (32位) 内核直接映射区域
ZONE_HIGHMEM 896MB以上 (32位) 用户空间和内核间接访问
ZONE_MOVABLE 可变 可迁移页,用于内存热插拔

在64位系统上,ZONE_HIGHMEM基本不存在了,因为虚拟地址空间足够大,内核可以直接映射所有物理内存。

NUMA对性能优化的影响非常大。我个人的习惯是,在部署数据库或缓存服务(比如Redis、MySQL)时,一定会用numactl绑定进程到指定节点,避免跨节点访问。

我曾经踩过的坑:有一次给一个内存密集型应用做优化,发现性能始终上不去。用numastat一看,内存分配严重不均——Node 0用了90%,Node 1只用了10%,大量访问走的是Remote路径。后来调整了numa_balancing策略,并手动绑核,延迟直接降了30%。

下面这张图,是我自己画的一个内存全景结构图,帮你把今天讲的东西串起来:

Linux内存全景架构图 Linux 内存全景架构图 虚拟内存 (Virtual Memory) 每个进程独立 4GB/64位超大空间 用户空间 (0~3GB) + 内核空间 (3~4GB) 通过 malloc/mmap 分配 按需调页,惰性加载 隔离、简化、安全 MMU + 页表 (Page Table) 硬件地址翻译单元 4级页表: PGD→P4D→PUD→PMD→PTE TLB 缓存加速 缺页异常处理 权限控制 (R/W/X) 物理内存 DRAM 硬件 页框 (Page Frame) 4KB 标准页 HugePage 2MB/1GB Swap 交换空间 虚拟地址 物理地址 NUMA 架构 (Non-Uniform Memory Access) Node 0 CPU 0~3 本地内存 32GB ZONE_DMA / ZONE_NORMAL Node 1 CPU 4~7 本地内存 32GB ZONE_DMA / ZONE_NORMAL 互联总线 关键指标 Local Access: 低延迟 Remote Access: 高延迟 numactl 绑核绑内存

从这张图可以看得很清楚:进程通过虚拟地址访问内存,MMU和页表负责翻译,最终落到物理内存的某个页框上。而在NUMA环境下,物理内存又被拆分成多个节点,每个节点有自己的管理区和分配策略。

好了,这一章的内容就到这里。内存全景的架子已经搭起来了,后面我们会深入每个细节——页表怎么遍历、缺页异常怎么处理、NUMA内存分配策略怎么调优。你先把这张图刻在脑子里,后面学起来会轻松很多。