跳到主要内容

第 7 章 内存管理内幕

内核内幕,尤其是内存管理,是一片广阔且复杂的领域。说实话,我不打算在这本书里把那些血淋淋的底层细节全抖落出来——那恐怕得再写两本书才够。

但作为想要在这个领域里折腾的你,必须掌握足够的背景知识。这不仅仅是为了通过面试或者写几个驱动,而是为了当你真正面对那些诡异的内核崩溃或者内存泄漏时,你能知道该往哪里看。

这一章,我们要把 Linux 的内存管理机制拆开来看。我们将深入探讨虚拟地址空间(VM)的分割机制,彻底搞清楚用户空间和内核空间到底是怎么划分的;我们会拿着放大镜去审视进程的虚拟地址空间(VAS),看看代码段、数据段、堆栈到底是怎么摆放的;当然,我们也会扒开内核的 VAS,看看它到底藏了哪些宝贝。

此外,我们还会触及物理内存管理的基石。这听起来很枯燥,但相信我,当你理解了内存映射——无论是虚拟的还是物理的——你会发现,那些看似随机的 Panic 或者 OOM(Out Of Memory)其实都有迹可循。

这章的知识是下两章的基础。在那里,我们将真正动手写代码,在内核里申请和释放动态内存。如果你现在没打好底子,到时候面对 kmallocvmalloc 的区别,你可能会像我当初一样,看着屏幕发呆。

1.1 理解 VM 分割

要理解 Linux 的内存管理,我们首先要接受一个设定:虚拟内存。在现代操作系统(Linux, Unix, Windows)里,你程序里用到的所有地址,几乎全都是虚拟的。这就像是给每个进程发了一副“VR眼镜”,戴上它,每个进程都以为自己独占了整个内存条。

但这里有一个关键问题:这个“幻觉”到底有多大?

这就取决于你处理器的架构了。

  • 32 位系统:最高地址是 $2^{32} = 4 \text{ GB}$。
  • 64 位系统:最高地址是 $2^{64} = 16 \text{ EB}$(Exabyte,艾字节)。这个数字大得离谱,$1 \text{ EB} = 1,024 \text{ PB}$(Petabyte),$1 \text{ PB} = 1,024 \text{ TB}$。换句话说,这是一个你这辈子大概率都填不满的坑。

为了把事情说清楚,让我们先把目光锁定在 32 位系统上。在这个设定下,进程的虚拟地址空间(VAS)范围是 $0$ 到 $4 \text{ GB}$。这 4 GB 里,既有被实际占用的“干货”——代码段、数据段、堆、栈,也有大把大把没人用的“空地”,我们称之为稀疏区域。

在深入细节之前,让我们先做一个小实验:看看那个最经典的 C 语言程序——Hello, world——在 Linux 底层到底发生了什么。

Hello, world 的底座

好吧,我想每个人都能闭着眼写出 K&R 风格的 Hello, world

printf("Hello, world.\n");

但这行代码背后发生的事情,你可能没细想过。printf 这个函数,并不是你自己写的,它存在于 C 标准库(通常是 glibc)里。

这就有个问题:我们在第 6 章提到过,进程的 VAS 是一个完全隔离的“沙盒”,你不能看沙盒外面的东西。既然 printf 的代码在 glibc 里,那它必须映射进当前进程的 VAS,否则我们根本没法调用它。

事实也正是如此。当你的程序启动时,有一个隐藏的小角色——动态链接器(通常是 ld.sold-linux.so)——会先拿到控制权。它会去查找 printf 所在的共享库文件(libc.so),然后通过 mmap 系统调用,把这些库的代码段和数据段“贴”到你的进程 VAS 里。

我们可以用 ldd 命令验证一下:

$ gcc helloworld.c -o helloworld
$ ldd ./helloworld
linux-vdso.so.1 (0x00007fffcfce3000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007feb7b85b000)
/lib64/ld-linux-x86-64.so.2 (0x00007feb7be4e000)

注意看括号里的地址(比如 0x00007feb7b85b000),这就是 glibc 被加载到你进程 VAS 里的位置——用户虚拟地址(UVA)。而且,这个地址每次运行可能都不一样,这得益于 ASLR(地址空间布局随机化),我们后面会讲到。

现在,让我们更进一步。

跨越边界:从 printf 到 write

我们都知道 printf 本质上是对 write 系统调用的封装。write 会把字符串写到标准输出(通常是终端)。

但这中间发生了一次“越界”。

write 是系统调用,意味着 CPU 要从用户模式切换到内核模式,去执行内核里的 write 代码。可是,内核代码在内核的 VAS 里,而我们刚才说,进程的 VAS 是个“沙盒”。

这就引出了本章最核心的概念之一:VM Split(虚拟地址空间分割)

如果内核 VAS 真的在“沙盒”外面,那每次系统调用我们不仅要切换特权级,还得彻底切换地址空间。这在性能上是不可接受的(会导致 TLB 这种高速缓存彻底失效)。

所以,工程师们想出了一个绝妙的办法:把内核空间和用户空间塞进同一个 4 GB 的地址空间里

这就是 VM Split 的由来。

3:1 分割:32 位系统的经典方案

在大多数 32 位 ARM(AArch32)和 x86 系统上,默认采用 3:1 GB 的分割方案。

  • 用户空间:0 到 3 GB($0 \text{x}00000000 \sim 0 \text{xbfffffff}$)
  • 内核空间:3 GB 到 4 GB($0 \text{xc0000000} \sim 0 \text{xffffffff}$)

(注意:内核空间的起始地址是由一个叫做 PAGE_OFFSET 的宏定义的,在 3:1 分割下它正好等于 $0 \text{xc0000000}$。)

这种分割方式在图 7.1 中展示得很直观。

[图 7.1:AArch32 系统上 3:1 GB VM 分割示意图]

这意味着什么?当你的进程调用 write 时,CPU 依然在同一个进程的 VAS 里工作,只是指针从低位的 3 GB 跳到了高位的 1 GB。

这里有一个非常关键的理解点:虽然每个进程都有自己独有的 3 GB 用户空间,但所有进程共享同一个 1 GB 内核空间。

这个分割比例是可以配置的。在编译内核时(比如配置树莓派内核),你可以选择 2:2 甚至 1:3。你可以通过查看内核配置来确认:

$ zcat /proc/config.gz | grep -C3 VMSPLIT
#
# Kernel Features
#
CONFIG_VMSPLIT_3G=y
# CONFIG_VMSPLIT_3G_OPT is not set
# CONFIG_VMSPLIT_2G is not set
# CONFIG_VMSPLIT_1G is not set
CONFIG_PAGE_OFFSET=0xC0000000

看到 CONFIG_PAGE_OFFSET=0xC0000000 了吗?这证实了内核空间起始于 3 GB 处。

弄懂了 32 位的情况,64 位世界的逻辑其实是一样的,只是数字变得极其夸张。

1.2 64 位系统的 VM 分割:巨大的“空洞”

既然我们都在用 64 位机器了,为什么不直接用满 64 位地址呢?

因为没必要。$2^{64}$ 字节等于 16 EB,这个数字对于现在的计算机来说,就像给你一个比地球还大的硬盘,你根本用不完。

目前主流的 Linux x86_64 配置(4 KB 页大小)只使用了低位的 48 位 做寻址。

但这 48 位怎么分呢?这里有一个很有意思的设计。

  • 用户空间:使用这 48 位的前半部分,范围 $0 \text{x}0000000000000000 \sim 0 \text{x}00007fffffffffff$。这大概是 128 TB
  • 内核空间:使用这 48 位的后半部分,范围 $0 \text{xffff800000000000} \sim 0 \text{xffffffffffffffff}$。这也是 128 TB

这种布局被称为“Canonical Addressing”(规范地址)。简单的说,就是 64 位地址的高 16 位(第 48 到 63 位)必须是全 0(用户空间)或者全 1(内核空间)。

这意味着什么呢?中间有一个巨大的空洞

从 $0 \text{x}0000800000000000$ 到 $0 \text{xffff7fffffffffff}$,这片区域是非规范地址区。这片区域有多大?大概占整个 64 位空间的 99.998%。这片区域是完全无法访问的。

所以,虽然 64 位系统的理论地址空间是 16 EB,但实际能用的只有底部的 128 TB(用户)和顶部的 128 TB(内核)。

这就是为什么我们在 64 位系统上通常不需要担心“高端内存”的问题—— 128 TB 的内核空间,足够我们把当前世界上所有机器的物理内存都直接映射进去还有富余。

[图 7.5:x86_64 上 16 EB VAS 布局示意图]

如何一眼分辨内核地址和用户地址?

既然知道了规则,你在调试时就能一眼看出地址的属性:

  • KVA (内核虚拟地址):总是以 0xffff 开头。
  • UVA (用户虚拟地址):总是以 0x0000 开头。

这不仅仅是看个热闹,这在分析崩溃堆栈时非常有用。如果看到一个 0xffff... 的地址出现在用户态程序的堆栈里,那肯定出大问题了。

虚拟地址到底是个啥?

在继续之前,我们必须纠正一个极其常见的直觉错误。

你写出这行代码:

int i = 5;
printf("address of i is %p\n", &i);

你打印出来的地址,不是一个从 0 开始的绝对数值。它是一个位掩码

CPU 的 MMU(内存管理单元)在处理这个地址时,会把这 32 位或 64 位的数据切开。在 x86_64 上(48 位寻址),它会被切分成 5 个字段:PGD, PUD, PMD, PTE 和 Offset。这就像是一套层层递进的索引系统,最终指向物理内存里的某一个字节。

[图 7.2:x86_64 上 64 位虚拟地址的拆解]

每一级索引都指向一个表(页表),通过这些表,CPU 最终计算出物理地址。这个过程叫作“页表遍历”。

⚠️ 注意:虽然逻辑上这么理解,但在实际硬件执行中,为了速度,CPU 会先把查过的结果缓存在 TLB(Translation Lookaside Buffer)里。只有 TLB 没命中时,才会去慢吞吞地走那套 4 级页表流程。

1.3 完整的进程 VAS 视图

让我们把视角拉高,看看一个完整的进程地址空间长什么样。

不管是 32 位还是 64 位,结构是一样的:每个进程都有自己独一无二的用户空间(低位),但所有进程共享同一个内核空间(高位)。

[图 7.7:进程拥有唯一的用户 VAS,但共享内核 VAS]

用户空间的解剖:segments 与 VMA

我们在第 6 章见过 /proc/PID/maps。这个文件就像一张地图,列出了用户空间里所有的“路段”。

每一行都代表一段连续的虚拟地址范围,内核里用数据结构 struct vm_area_struct (VMA) 来描述它。

让我们随便挑一行来拆解:

558822d66000-558822d6a000 r-xp 00002000 08:01 7340181 /usr/bin/cat
  • 地址范围558822d66000-558822d6a000。这是这段映射的起止 UVA。
  • 权限r-xpr = 读,x = 执行,- = 不可写,p = 私有映射(这是代码段的典型特征)。
  • 偏移量00002000。这段内容在文件 /usr/bin/cat 里的起始偏移。
  • 设备号08:01。这是文件所在设备的设备号。
  • Inode7340181。文件的索引节点号。
  • 路径/usr/bin/cat。映射的文件来源。

这里有个非常重要的细节:所有这些地址,全部都是虚拟的。它们只存在于当前进程的页表中。另一个进程即使也运行了 /usr/bin/cat,它的 libc 加载地址也大概率和你不一样。

那些奇怪的映射

当你查看 maps 文件时,除了那些显而易见的 [heap][stack],你还会看到几个奇怪的名字:

  • vdso / vvar:Virtual DSO(虚拟动态共享对象)。这是 Linux 做的一个极致优化。像 gettimeofday 这种超级常用的系统调用,如果每次都要进内核态太慢了。内核直接把实现这段功能的代码映射到用户空间里,让你在用户态直接调用,不用切换上下文。
  • vsyscall:这是一个更古老的前身,现在主要是为了兼容性保留。

1.4 内核 VAS 的地图

现在,让我们进入那块共享的领地——内核 VAS

虽然不同架构的细节不同,但它们都有一些共通的区域。还是以经典的 32 位 3:1 分割为例,内核空间(从 0xc0000000 开始)主要包含以下部分:

[图 7.12:用户与内核 VAS 布局(侧重 lowmem 区域)]

  1. Lowmem Region(低端内存 / 线性映射区): 这是最重要的一块。内核把物理 RAM 直接映射到了这块区域。

    • 物理地址 0 $\rightarrow$ 虚拟地址 PAGE_OFFSET
    • 这里的虚拟地址和物理地址有一个固定的偏移量PAGE_OFFSET)。
    • 在这个区域里的地址,被称为内核逻辑地址
    • ⚠️ 踩坑点:这种线性映射非常方便,你可以直接通过简单的减法算出物理地址。但是,这只适用于这个区域! 如果你在写驱动,拿到一个属于 vmalloc 区域的地址,也敢用这种减法去算物理地址,那你一定会 kernel panic。
  2. vmalloc Region: 用来分配虚拟连续、物理不连续的内存区域。当你需要一大块内存,但不要求物理页必须连在一起时,就用这里。

  3. Modules Region: 也就是你写 LKM(可加载内核模块)时,模块的代码和数据被加载的地方。

  4. Fixmap Region: 一小块保留区域,用来在启动早期固定映射一些特定的物理页(比如页表本身)。

  5. High Memory(高端内存)这是 32 位系统的特有痛点。 如果你的 32 位机器有 4 GB 内存,但内核空间只有 1 GB。那肯定有一大块物理内存无法直接映射到 Lowmem 区域。 这部分“剩下的”内存,就叫高端内存。内核不能直接访问它,必须通过临时的动态映射(kmap/kunmap)来使用。

    • 好消息:在 64 位系统上,因为内核空间有 128 TB 之大,我们根本不需要 Highmem 这个概念。所有的物理内存都可以直接映射进去。

1.5 实战:用内核模块探秘 VAS

光说不练假把式。我们写个内核模块,把这些宏和地址打印出来看看。

下面这个模块 show_kernel_vas.ko 会查询并打印当前架构下内核 VAS 的布局。

// ch7/show_kernel_vas/kernel_vas.c
static void show_kernelvas_info(void)
{
unsigned long ram_size;

#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 0, 0)
ram_size = totalram_pages() * PAGE_SIZE;
#else
ram_size = totalram_pages * PAGE_SIZE;
#endif

pr_info("PAGE_SIZE = %lu, total RAM ~= %lu MB\n",
PAGE_SIZE, ram_size/(1024*1024));

// 打印 vmalloc 区域
pr_info("|vmalloc region: "
#if (BITS_PER_LONG == 64)
" %px - %px | [%9zu MB]\n",
#else
" %px - %px | [%5zu MB]\n",
#endif
SHOW_DELTA_M((void *)VMALLOC_START, (void *)VMALLOC_END));

// 打印 Lowmem 区域 (直接映射区)
pr_info("|lowmem region: "
#if (BITS_PER_LONG == 32)
" %px - %px | [%5zu MB]\n"
"| ^^^^^^^^ |\n"
"| PAGE_OFFSET |\n",
#else
" %px - %px | [%9zu MB]\n"
"| ^^^^^^^^^^^^^^^^ |\n"
"| PAGE_OFFSET |\n",
#endif
SHOW_DELTA_M((void *)PAGE_OFFSET, (void *)(PAGE_OFFSET) + ram_size));

// ... (打印 modules 区域, KASAN 区域等)
}

在树莓派 Zero W(32位 ARM)上运行这个模块,你会看到如下输出:

[图 7.13:show_kernel_vas.ko 在树莓派 Zero W 上的输出]

可以看到:

  • PAGE_OFFSET0xc0000000(3 GB)。
  • Lowmem 区域大小约为 508 MB(树莓派 Zero 的内存)。
  • Kernel Modules 区域位于 0xbf000000,刚好在 Lowmem 下面。

这就拼凑出了一张完整的内存地图。

[图 7.14:AArch32 上完整的进程 VAS 布局(含内核细节)]

1.6 随机化布局:ASLR 与 KASLR

既然内存地址是确定的,那对于黑客来说就是确定的靶子。只要知道某个内核函数的地址,就能发动攻击。

为了防止这种情况,Linux 引入了地址空间布局随机化(ASLR)

  • 用户空间 ASLR:每次你运行程序,栈的位置、堆的位置、libc 加载的位置都会随机变动。
    • 你可以通过 /proc/sys/kernel/randomize_va_space 来控制它。
  • 内核空间 KASLR:每次系统启动,内核代码段和数据段在内核 VAS 里的基址也会随机变动。
    • 这在 64 位系统上效果很好,因为空间大,随机性强。在 32 位系统上效果有限。

我们可以写个脚本来检查当前系统的 ASLR 状态:

[图 7.20/7.21:ASLR 状态检查脚本运行效果]

如果你在调试内核崩溃,发现每次启动的地址都对不上,记得先检查一下是不是开了 KASLR。

1.7 物理内存的组织:Nodes, Zones, Pages

最后,让我们从虚拟世界回到物理世界。

Linux 内核并不把物理内存看作一大坨“RAM”。它把它看作一个层级结构:

  1. Node(节点): 这是 NUMA(非统一内存访问)架构的概念。在多处理器服务器上,CPU 可能挂在不同内存控制器上。

    • 访问“本地”内存很快,访问“远程”内存慢一点。
    • 即便是你的 PC(UMA 架构),为了代码通用性,Linux 也会假装它有一个 Node(Node 0)。
  2. Zone(区域): 每个 Node 被划分为若干个 Zone。这主要是为了应对硬件的限制。

    • DMA Zone:古老的 ISA 设备只能访问低 16 MB 内存。
    • DMA32 Zone:有些设备只能访问低 4 GB。
    • Normal Zone:也就是“普通”内存。
    • HighMem Zone:刚才提到的 32 位高端内存。
  3. Page Frame(页帧): 物理内存管理的最小单位。每个页帧对应一个 struct page 结构体。

你可以通过 /proc/buddyinfo 看到系统中各个 Zone 的页块分布情况。

$ cat /proc/buddyinfo
Node 0, zone DMA 3 2 4 3 3 1 0 0 1 1 3
Node 0, zone DMA32 31306 10918 1373 942 505 196 48 16 4 0 0
Node 0, zone Normal 49135 7455 1917 535 237 89 19 3 0 0 0

这告诉我们:系统只有 Node 0(UMA),下面有三个 Zone。


本章回响

我们现在已经构建起了内存世界的全景图: 从进程的视角看,是巨大的虚拟地址空间(VAS),被 3:1 或者 128TB:128TB 的界线切开; 从内核的视角看,物理内存被组织成 Node、Zone 和 Page,通过页表映射到虚拟空间中。

还记得这一章开头我们提出的那个问题吗:为什么驱动注册成功了,但设备就是没响应?现在的你应该能更进一步思考这个问题了——也许驱动运行在正确的地址空间,但它试图访问的物理内存映射并没有建立,或者它错误地使用了一个只能用于 Lowmem 的算法去转换了一个 vmalloc 地址,从而导致了无效的内存访问。

下一章,我们将利用这章建立的所有直觉,去做一件更实际的事情:在这个复杂的内存地图上,真正属于我们自己的那一块地。 我们会深入探讨 kmallocvmalloc 以及背后的 Slab 分配器。那是内核开发者真正的搬砖时刻。


练习题

练习 1:understanding

题目:在一个默认配置的 x86_64 Linux 系统上,内核打印出了一个内存地址:0xffff888012345678。请问这个地址的性质最可能是下列哪一项?

答案与解析

答案:内核虚拟地址 (KVA)

解析:在 x86_64 Linux 系统中,虚拟地址的高位被用来区分用户空间和内核空间。User Virtual Address (UVA) 的高 16 位(MSB)全为 0,格式通常为 0x0000...;而 Kernel Virtual Address (KVA) 的高 16 位全为 1,格式通常为 0xffff...。题目中的地址以 0xffff 开头,符合 KVA 的特征,属于 Canonical Upper Half。

练习 2:application

题目:假设你在开发一个运行在 32 位 ARM (AArch32) Linux 系统上的驱动模块。你需要分配一块内存,要求这块内存的虚拟地址与物理地址之间存在固定的线性偏移关系(即可以通过简单的减去 PAGE_OFFSET 运算得到物理地址)。你应该使用下列哪个内存分配函数(或区域)?

答案与解析

答案:Lowmem 区域 (通过 kmalloc 或类似函数分配)

解析:题目要求虚拟地址与物理地址之间存在固定的线性偏移量(即直接映射)。根据定义,Lowmem Region(低端内存区域)正是物理 RAM 在内核 VAS 中直接映射的区域,其虚拟地址 = 物理地址 + PAGE_OFFSET。虽然在 32 位系统中可以使用 virt_to_phys,但前提是地址位于 Lowmem 区域。如果使用 vmalloc 区域或 Highmem(高端内存),则不存在这种简单的线性关系,Highmem 需要临时映射才能访问。

练习 3:thinking

题目:在编写内核驱动时,为什么不能直接在 vmalloc 区域分配的内存上执行 DMA(直接内存访问)操作,而通常需要使用 kmalloc(分配自 Lowmem/Normal Zone)?请结合虚拟地址映射机制和硬件限制简要分析原因。

答案与解析

答案:因为 vmalloc 区域的虚拟内存是连续的,但对应的物理内存是不连续的,而许多 DMA 控制器只能处理连续的物理内存块。

解析:这个问题的核心在于理解虚拟连续性与物理连续性的区别。

  1. kmalloc/Lowmem: 返回的内存位于 Lowmem 区域,不仅虚拟连续,其对应的物理内存也是连续的。大多数简单的 DMA 控制器硬件只接受连续的物理地址范围。
  2. vmalloc: 返回的内存虽然在虚拟地址空间中是连续的,但在物理上由多个非连续的页帧拼接而成。
  3. 结论: 如果将 vmalloc 返回的地址直接传给只支持物理连续寻址的 DMA 设备,设备可能会只读取到第一块物理页的数据,从而导致数据传输错误或内存访问越界。现代内核虽然提供了 dma_map_single 等 API 来处理分散/聚集列表,但这增加了复杂性。

要点提炼

虚拟内存是现代操作系统隔离进程的基础机制,每个进程都认为自己独占了整个地址空间,例如在 32 位系统中为 4GB。为了平衡隔离性与系统调用性能,Linux 采用虚拟地址空间(VM)分割策略,典型的 32 位配置使用 3:1 比例(3GB 用户空间 : 1GB 内核空间),而 64 位系统则利用 Canonical Addressing 规范,将巨大的 64 位空间切分为底部 128TB 用户区和顶部 128TB 内核区。这种设计的关键在于所有进程共享同一个内核空间,使得 CPU 在处理系统调用时无需切换页表,从而兼顾了安全与效率。

从虚拟地址到物理地址的转换并非简单的数学运算,而是一个由 MMU 硬件通过多级页表(PGD -> PUD -> PMD -> PTE)完成的层层索引过程。虽然我们通常通过指针访问内存,但这些仅仅是虚拟地址,CPU 必须通过遍历页表将其映射到实际的物理页帧。为了加速这一频繁操作,现代 CPU 利用 TLB(Translation Lookaside Buffer)缓存转换结果,只有缓存未命中时才会进行昂贵的内存访问。

进程的用户空间地址布局虽然由代码段、数据段、堆和栈组成,但其具体位置由内核数据结构 vm_area_struct(VMA)精细管理,并可以通过 /proc/PID/maps 查看。值得一提的是 vdso 机制,它通过将 gettimeofday 等高频系统调用的代码直接映射到用户空间,允许应用程序在不陷入内核态的情况下执行功能,这是操作系统为了性能优化做出的重要妥协。

内核虚拟地址空间(KVAS)的布局直接决定了驱动开发的方式,其中最关键的是“低端内存”的线性映射区。在此区域内,内核逻辑地址与物理地址存在一个固定的 PAGE_OFFSET 偏移量,可以通过简单的减法直接换算;但在 vmalloc 区域或 Highmem 区域,内存可能是虚拟连续而物理分散的,或者根本无法直接访问。理解这些区域的差异是避免 kmallocvmalloc 混用以及防止无效内存访问导致 Kernel Panic 的必修课。

在物理层面上,Linux 内核并不将内存视为一整块,而是基于硬件架构(如 NUMA)将其组织为三层模型:Node(节点)、Zone(区域)和 Page(页帧)。Zone 的划分主要是为了应对旧硬件的 DMA 限制(如只能访问低 16MB)或 32 位架构下高端内存的寻址瓶颈。通过 /proc/buddyinfo 可以观察这种碎片化管理状态,掌握这一层级结构有助于理解内核如何在满足特定设备约束(如 DMA 要求)的前提下高效分配物理内存。