内核内部,特别是内存管理,是一个庞大且复杂的主题。在本书中,我并不打算深入探讨内核内存的深层细节。同时,我希望为像你这样的初学者提供足够的背景知识,以便成功应对这一关键主题。
因此,本章将帮助你充分理解 Linux 操作系统上内存管理的内部机制;这包括深入探讨虚拟内存(VM)划分,详细了解进程的用户模式和内核虚拟地址空间(VAS),以及内核如何管理物理内存的基本原理。实际上,你将理解进程和系统的内存映射——包括虚拟内存和(在某种程度上)物理内存。
这些背景知识将帮助你更好地管理动态内核内存(特别是专注于使用可加载内核模块(LKM)框架编写内核或驱动程序代码的实践),这方面的内容将在本书接下来的两章中详细讲解。作为一个重要的附加好处,掌握了这些知识后,你会发现自己在调试用户空间和内核空间代码时变得更加熟练。(这一点非常重要!调试代码既是一门艺术,也是一门科学,还是现实中的必备技能。)
本章我们将涵盖以下内容:
- 理解虚拟内存的划分(VM split)
- 检查进程的虚拟地址空间(VAS)
- 检查内核的虚拟地址空间(VAS)
- 内存布局随机化——KASLR(内核地址空间布局随机化)
- 理解物理内存的组织
技术要求
我假设你已经阅读了《在线章节:内核工作空间设置》,并适当准备了一个运行 Ubuntu 22.04 LTS(或更高稳定版本)的虚拟机(或本地系统),并安装了所有所需的包。如果没有,我建议你首先完成这些步骤。为了充分利用本书,我强烈建议你先设置好工作环境,包括克隆本书的 GitHub 仓库(代码链接:github.com/PacktPublis…),并以实践的方式进行操作。
我假设你已经熟悉基本的虚拟内存概念、用户模式进程虚拟地址空间(VAS)的段布局、用户和内核模式栈、任务结构等。如果你对这些内容不确定,我强烈建议你先阅读前一章。
理解虚拟内存的划分
在本章中,我们将大致了解 Linux 内核如何通过两种方式管理内存:
- 基于虚拟内存的方法,其中内存被虚拟化(通常的情况)。
- 内核如何组织物理内存(RAM 页)的视角。
首先,让我们从虚拟内存的视角开始,然后在本章后续部分讨论物理内存的组织。
正如我们在第6章《内核内部基本概念——进程和线程》中“理解进程虚拟地址空间(VAS)基础”部分所看到的,进程的 VAS 有一个关键特性:它是完全自包含的,即一个沙盒。你无法看到沙盒外部的内容。在那一章中,我们看到进程的 VAS 范围从虚拟地址 0x0 到我们称之为“高地址”的地方。那么这个“高地址”的实际值是多少呢?它是 VAS 的最大扩展,因此取决于用于寻址的位数;因此:
- 在运行 32 位处理器(或为 32 位编译的 Linux 操作系统)时,最高的虚拟地址将是 2^32 = 4 GB。
- 在运行(并为)64 位处理器编译的 Linux 操作系统中,最高的虚拟地址将是 2^64 = 16 EB(EB 是艾克萨字节的缩写,等于 1,152,921,504,606,846,976 字节,或 1,024 PB,显然这是一个巨大的数值;16 EB 就是 16 x 10^18)。
为了简化,我们现在先集中在 32 位地址空间上(我们之后会覆盖 64 位寻址)。根据我们的讨论,在 32 位系统上,进程的 VAS 范围从 0 到 4 GB——这一区域由空闲空间(未使用的区域,称为稀疏区域或孔洞)和有效内存区域(通常称为段或映射)组成——文本段、数据段、库段和栈段(这些内容我们在第6章《内核内部基本概念——进程和线程》中已有详细介绍)。
在理解虚拟内存的过程中,使用著名的“Hello, world” K&R C 程序,并在 Linux 系统上理解其内部工作原理是非常有帮助的;下一部分将覆盖这一内容!
深入了解底层工作原理——“Hello, world” C 程序
好吧,是否有谁知道如何编写经典的 K&R C “Hello, world” 程序?好吧,确实很有趣,让我们检查其中的一个有意义的代码行:
printf("Hello, world.\n");
该进程调用了 printf() 函数。你自己写过 printf() 的代码吗?“当然没有,”你说,“它在标准 C 库中,通常是在 Linux 上的 glibc(GNU libc)中。”是的,你说得对;但等一下,除非 printf() 的代码和数据(同样,所有其他库 API 的代码和数据)实际上位于进程的 VAS 中,否则我们怎么能够访问它呢?(记住,你无法看见沙盒外部的内容!)为此,printf() 的代码(和数据)(实际上是整个 glibc 库的代码和数据)必须映射到进程的 VAS 内部——即进程的地址空间中。它确实被映射到进程的 VAS 中,位于库段或映射中(正如我们在第6章《内核内部基本概念——进程和线程》中看到的那样)。这个映射是如何发生的呢?
实际上,在应用程序启动时,作为 C 运行时环境设置的一部分,有一个嵌入到你的 a.out 可执行文件中的小型可执行和链接格式(ELF)二进制文件,叫做加载器(ld.so 或 ld-linux.so)。它在执行早期就获得控制。它会检测所有需要的共享库并将它们映射到内存——通过打开库文件并发出 mmap() 系统调用,将库的文本(代码)、数据以及其他所需段映射到进程的 VAS 中。因此,一旦库的代码和数据被映射到进程的 VAS 中,进程就可以访问它,从而——等待一下——成功调用 printf() API!(我们在这里跳过了内存映射和链接的详细过程。)
进一步验证这一点,ldd 脚本(以下输出来自 x86_64 系统)显示了确实如此:
$ gcc helloworld.c -o helloworld
$ ./helloworld
Hello, world
$ 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)
$
需要注意的几个要点:
- 每个 Linux 进程——自动并默认——链接至少两个对象:glibc 共享库和程序加载器(在编译/链接过程中不需要显式的链接开关)。
- 加载器程序的名称随架构而变化。在我们的 x86_64 系统中,它是
ld-linux-x86-64.so.2。 - 在前面的
ldd输出中,右侧括号中的地址是映射位置的(用户空间)虚拟地址。例如,在上面的输出中,glibc 被映射到我们进程的 VAS 中,其用户虚拟地址(UVA)为0x00007feb7b85b000。注意,这依赖于运行时(启用了地址空间布局随机化(ASLR)语义时,每次运行时该地址也会变化(ASLR 通常默认启用;稍后将详细讨论))。
出于安全原因(并且在 x86 以外的架构中),建议使用 objdump 工具来查找此类细节。
尝试对 “Hello, world” 二进制可执行文件执行 strace,你会看到许多 mmap() 系统调用,映射 glibc(和其他)段!
现在,让我们更深入地分析我们的简单“Hello, world”应用程序。
超越 printf() API
正如你所知道的,printf() API 执行其格式化操作并调用 write() 系统调用,后者当然会将 "Hello, world" 字符串写入标准输出(stdout)——在这里,默认情况下,stdout 将是(伪)终端窗口或控制台设备。
我们还理解到,由于 write() 是一个系统调用,这意味着当前执行该代码的进程(或线程)——即进程上下文——现在必须切换到内核模式并执行 write() 的内核代码(单体内核架构)!确实如此。但等一下:write() 的内核代码位于内核虚拟地址空间(VAS)中(参考第6章《内核内部基本概念——进程和线程》,图6.1)。这里的关键点是:如果内核 VAS 在沙盒外面,那么我们怎么调用它呢?
好吧,这可以通过将用户空间和内核空间的虚拟地址空间分成两个独立的 4 GB 空间来实现,但这种方法会导致非常慢的上下文切换(并且需要昂贵的转换后备缓冲区(TLB)刷新),所以实际上并不这么做。
实际的做法是:用户空间和内核空间的 VAS "共享" 同一个 "盒子" —— 即可用的虚拟地址空间。具体如何实现呢?通过将可用地址空间按一定比例分割给用户空间和内核空间,这种分割方式称为 VM 划分(VM split),其中的比例 U:K 通常以兆字节、吉字节、太字节,甚至艾字节来表示。
在许多 ARM-32(AArch32)和 x86-32 系统中,默认的 VM 划分通常是 3:1 GB。下面的图(图 7.1)展示了一个 32 位 Linux 进程的 3:1 VM 划分(单位为 GB);也就是说,4 GB 的总进程 VAS 被分成了 3 GB 的用户空间和 1 GB 的内核空间。换句话说,这种划分可以描述为 U:K :: 3:1。
VM 划分可以在 32 位系统的内核配置时进行配置(稍后会进一步介绍)。在 AArch32(或 x86-32)系统上,VM 划分的其他有效值包括 U:K :: 2:2 或 1:3,尽管后者在实际应用中非常罕见。
现在,既然内核的虚拟地址空间(VAS)在“盒子”内,就变得清晰且至关重要的是:当用户模式进程或线程发出系统调用时,进程上下文会切换到内核的 1 GB VAS(即同一进程内的内核空间 VAS)。这个切换涉及到更新各种架构特定的 CPU 寄存器,包括栈指针。发出系统调用的线程现在在进程上下文中以特权的内核模式运行相关的内核代码路径(并处理内核空间的数据)。完成后,它从系统调用中返回,重新切换回不具特权的用户模式,并在 VAS 的前 3 GB 内运行用户模式代码(并处理用户模式数据)。
内核 VAS 开始的虚拟地址(即内核段)通常通过内核中的 PAGE_OFFSET 宏表示。我们将在“描述内核 VAS 布局的宏和变量”部分中进一步讨论这一点,以及其他一些关键宏。
关于 VM 划分的精确位置和大小的决策是在何处做出的呢?哦,在 32 位 Linux 系统中,这是一个内核构建时可配置的选项。它在内核构建过程中通过 make [ARCH=xxx] menuconfig 过程进行设置——例如,在为 Broadcom BCM2835(或 BCM2837)系统单芯片(SoC)配置内核时(树莓派系列是具有此 SoC 的流行开发板)。以下是官方内核配置文件的一段(以下输出来自运行默认 32 位 Raspberry Pi OS 的 32 位 Raspberry Pi):
$ uname -r
5.15.76+
# gain access to /proc/config.gz by loading the 'configs' module
$ sudo modprobe configs
$ 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_VMSPLIT_3G 内核配置选项被设置为 y(是),这意味着默认的 VM 划分是 user:kernel :: 3:1。对于 32 位架构,VM 划分的位置是可调的(如前所示,通过 CONFIG_VMSPLIT_[1|2|3]G 宏;CONFIG_PAGE_OFFSET 会相应设置)。对于 3:1 的 VM 划分,PAGE_OFFSET 被设置为虚拟地址 0xC0000000(即 3 GB)。
对于 IA-32 处理器(Intel x86-32),默认的 VM 划分也是 3:1(GB)。有趣的是,运行在 IA-32 上的(古老的)Windows 3.x 操作系统采用的也是相同的 VM 划分,这表明这些概念本质上是与操作系统无关的。稍后在本章中,我们将讨论更多架构及其在 Linux 上的 VM 划分,此外还会介绍其他细节。
对于 64 位架构,配置 VM 划分有时是可能的,具体取决于特定的 CPU 和内核的板级支持包(BSP)(我们将在后续材料中提到如何为 AArch64 配置)。那么,现在我们理解了 32 位系统上的 VM 划分,让我们继续探讨 64 位系统上的实现方式。但首先,理解虚拟地址如何转化为物理地址的基本原理是非常有用和重要的;让我们开始吧!
虚拟地址与地址转换
在进一步深入这些细节之前,理解一些关键点是非常重要的。
考虑一个小的、典型的 C 程序代码片段:
int i = 5;
printf("address of i is 0x%x\n", &i);
当在现代操作系统(如 Linux、Unix、Windows 或 Mac)上运行这个程序时,你在 printf() 输出中看到的地址(几乎)总是一个虚拟地址,而不是物理地址。此外,我们区分两种类型的虚拟地址:
- 如果你在用户空间进程中运行这段代码,你将看到的变量
i的地址是一个用户虚拟地址(User Virtual Address,简称 UVA)。 - 如果你在内核中运行这段代码,或者在内核模块中运行(当然,在这种情况下你会使用
printk()(或类似的)API 代替printf()),你将看到的变量i的地址是一个内核虚拟地址(Kernel Virtual Address,简称 KVA)。
接下来,正如通常所认为的,虚拟地址不是一个绝对值(从 0 开始的偏移量);它是一个位掩码,专为 MMU(内存管理单元,现代微处理器内部的硬件)设计和解释的:
- 在 32 位的 Linux 操作系统中,32 个可用的位被划分为所谓的页面全局目录(Page Global Directory,PGD)值、页表(Page Table,PT)值和偏移量(offset)。
- 这些值通过 MMU 访问当前进程上下文的内核页表,进行物理内存的地址转换。
我们在这里并不打算深入探讨 MMU 级别的地址转换细节,它也非常依赖于架构。请参考本书“进一步阅读”部分的相关链接,获取更多关于这个主题的有用信息。
正如你所料,在 64 位系统中,即使是 48 位寻址,虚拟地址位掩码中也会有更多的字段。
好吧,如果这种 48 位寻址是 x86_64 处理器上的典型情况,那么 64 位虚拟地址中的位是如何分布的呢?
那么,未使用的 16 位 MSB(最高有效位)位会发生什么?以下图示回答了这个问题;它表示了在 x86_64 Linux 系统上,64 位虚拟地址(位掩码)的分布情况:
本质上,使用 48 位寻址时,我们使用 0 到 47 位(最低有效位的 48 位),并忽略最高有效位的 16 位,将其视为符号扩展。然而,未使用的 MSB(最高有效位)16 位的值——如下所示——会随着引用的地址空间不同而变化:
- 在内核虚拟地址空间(VAS)中,MSB 16 位总是被设置为 1。
- 在用户虚拟地址空间(VAS)中,MSB 16 位总是被设置为 0。
这些信息非常有用!了解这些后,仅通过查看一个(完整的 64 位)虚拟地址,你就可以立即判断它是 KVA 还是 UVA;因此,在 x86_64 的 Linux 系统中:
- KVA 的格式总是 0xffff XXXX XXXX XXXX
- UVA 的格式总是 0x0000 XXXX XXXX XXXX
需要注意的是:上述格式仅适用于那些将虚拟地址定义为 KVA 或 UVA 的处理器(实际上是 MMU);x86 和 ARM 系列处理器(无论是 32 位还是 64 位)都符合这一标准。在这里,MSB(第 63 位)充当分页表的选择器;如果设置为 1,则使用内核分页表(位于 swapper_pg_dir);如果清除,则使用进程分页表(进程分页表基址的物理地址存储在 x86[_64] 的 CR3 寄存器和 ARM[64] 的 TTBR0(转换表基址寄存器 0)寄存器中),这时正在引用的是用户虚拟地址(而 TTBR1 存储着内核主 PGD 的基址——swapper_pg_dir)。
另一个要点是:N 层分页意味着什么?重新查看图 7.2;在到达偏移量之前,有四个“间接层级”——页面全局目录(PGD)、页面上级目录(PUD)、页面中级目录(PMD)和页面表项(PTE)。这是分页方案的一个特性——间接层级的数量;在这里是 4 层,因此我们称之为 4 层分页(稍后,图 7.6 将展示不同的 N 层分页值)。
从虚拟地址到物理地址的转换 — 简要概述
如前所述,虚拟地址并不是绝对地址(从零开始的绝对偏移量,正如你可能错误地想象的那样),而是位掩码。事实上,内存管理是一个复杂的领域,其中的工作是分担的;让我们重申一些关键点:
- 每个活跃的进程都有一组分页表,将虚拟页面映射到物理页面(称为页面帧);每当访问虚拟地址(无论是用户模式还是内核模式)时,分页表都会起作用。内核也有自己的分页表。
- 操作系统创建和操作每个进程以及内核的分页表;工具链(编译器/链接器)生成虚拟地址。
- 处理器的 MMU 在运行时执行地址转换,将给定的虚拟地址(用户或内核)转换为物理(RAM)地址。
因此,再次强调,虽然不深入探讨所有细节,以下是通常称为硬件分页(这里以 x86 为例)的整体过程概述(图 7.3 给出了总体流程的高级概述):
-
进程(或其中的线程)查找一个虚拟地址(UVA 或 KVA)——即,它对一个虚拟地址执行读/写/执行操作。(这是完全正常的预期行为;例如,读取或写入一个变量,或执行一条机器指令)。
-
为了让 CPU 在内存中该位置执行工作,我们现在必须以某种方式将这个虚拟地址转换为其对应的物理地址。然而,等等:由于硬件优化,这个步骤可以被绕过或更快地完成(步骤 3.1、3.2);如果不能,它必须通过 MMU "手动" 转换(较慢;步骤 4)。
-
在进入 MMU 之前,有几个硬件优化可以帮助我们加速,绕过“通常的”较慢路径:
- 首先,正在处理的代码或数据可能已经驻留在 CPU 的缓存(L1/L2/L3/...)中;首先检查这一点。如果确实如此,我们有一个缓存命中:代码/数据项在 CPU 缓存中完成处理,工作就完成了。如果不是,我们有一个 CPU 缓存未命中(技术上是 LLC(最后一级缓存)未命中(代价昂贵));所以,我们进入下一步。(我们将在第 13 章《内核同步 – 第 2 部分》中深入讨论 CPU 缓存和缓存一致性问题;暂时忽略它。)
-
虚拟地址是否已被转换?查看 CPU 的转换后备缓冲区(TLB)。如果转换已存在,我们有 TLB 命中;如果是这样,我们可以在 TLB 中缓存物理地址:跳到步骤 5;如果没有,我们有 TLB 未命中(代价昂贵)。 (插话:这里的步骤不一定是按顺序执行的;一些微架构使用物理缓存模型,CPU 缓存位于 MMU 和 RAM 访问之间,实际上逆转了上述两个步骤的顺序。)
-
虚拟地址被送到 MMU;它现在“遍历”进程的分页表(它通过一个系统寄存器,持有进程(或内核)基础页表的物理地址,从而知道分页表的位置),最终得到相应的页面帧和物理地址(图 7.4 描述了这一步骤)。
-
物理地址(通过上述步骤之一获得)现在被加到 CPU 地址线上,工作得以执行。
请确保仔细阅读并理解这一过程。所有步骤,除了步骤 4(通过 MMU 的翻译)外,都在图 7.3 中有所体现;步骤 4 — 通过 MMU 转换的部分 — 则在图 7.4 中体现。请务必学习这些图示。
这是“P2: MMU”部分,MMU 执行运行时地址转换,将用户或内核虚拟地址转换为物理地址:
关于 MMU 地址转换的图示(图 7.4),请注意,这里我们使用的是非常特定于架构的 x86_64 示例,采用 4 层分页、4 KB 页大小和 48 位寻址。当 MMU 接收到虚拟地址时,它会将其视为一个位掩码;MMU 查找基址物理地址——它位于 x86 的控制寄存器 3(CR3)中(在 ARM 系列中,对于用户进程是 TTBR0 寄存器,对于内核分页表是 TTBR1 寄存器)——然后继续进行地址转换。(考虑这一点:当然,CR3 中的基地址是物理地址,否则这个步骤将变成无限递归!)
简而言之,MMU 执行运行时地址转换的过程如下:它查找基址 CR3 地址,将虚拟地址中 PGD 部分的 9 位值加到基址中,并查找对应的条目。这是指向下一个表的指针,接下来它重复上一步,但这次使用 PUD 字段中的 9 位值。这个过程会持续进行(直到 PMD),直到到达页表,此时它引用实际的页表项(PTE)。该项包含(除了其他特定于微架构的位)指向物理页面帧的指针;将 12 位偏移量加到页面帧的基地址上即可得到物理地址。
实际情况更为复杂;这里有几个关键点值得一提:
- 理论上,当虚拟地址传递给 MMU 时,它会“遍历”分页表,最终结果应该是物理地址;但在实践中,MMU 地址转换尝试可能会失败!一种明显的情况是:传递的虚拟地址不正确(未映射的地址):实际上,我们有一个 bug,导致 MMU 引发错误(操作系统的错误处理程序会适当地处理)。另一个情况是需求分页——即虚拟地址是合法的,但物理内存尚未分配(还没有),导致转换失败(MMU 会引发一个“良性”的错误,系统会为该页帧分配内存)。我们将在第 9 章《内核内存分配 – 模块作者篇 – 第 2 部分》中详细讨论需求分页和 OOM;如果你愿意,可以快速浏览一下图 9.9,它展示了其他可能发生的情况……当然,详细内容将在那里覆盖。
- 内核作为管理者,实际上可以绕过 MMU,并通过软件自己执行地址转换。实际上,这种做法很少见,因为它会较慢。其中一个例子是当通过
/proc/PID/maps伪文件利用mmap()将内存映射到进程 VAS 时,进行 IO(读取或写入)。通过这种方法,您可以写入标记为只读的内存!(offlinemark 在他的博客文章《Linux 内部:如何通过 /proc/self/mem 向不可写内存写入》(2021 年 5 月)中详细介绍了这一点: offlinemark.com/2021/05/12/…;一定要查看。)
我们不会在本书中深入探讨硬件分页(以及各种硬件加速技术,如翻译后备缓冲区(TLB))。这些主题在其他一些优秀的书籍和参考网站中有详细的讲解,并且在本章的“进一步阅读”部分中提到。
64位Linux系统上的VM拆分
首先,值得注意的是,在64位系统上,并不是所有的64位都用于寻址。对于x86_64架构的标准Linux操作系统配置(典型的4 KB页大小),使用了最低有效位(LSB)的48位进行寻址。为什么不使用完整的64位?因为这太多了!没有任何现有计算机能够接近拥有完整的64位寻址空间,264 = 18,446,744,073,709,551,616字节,相当于16 EB(16 exabytes,即16,384 petabytes)的RAM!
“为什么,”你可能会问,“我们把这个虚拟寻址和RAM等同起来?”请继续阅读——还有更多内容需要覆盖,直到这一点变得清晰为止。你将在《检查内核VAS》一节中完全理解这一点。
如前所述,64位系统上的可用虚拟地址空间(VAS)是一个令人震惊的巨大值,264 = 16 EB(即16 x 1018字节)。也许可以推测,在Linus开发第一个64位Linux移植版(到DEC Alpha,第一款商用64位处理器)时,他必须决定如何在这个庞大的VAS中布局进程和内核段。这个决定在概念上几乎没有改变,甚至在今天的x86_64 Linux操作系统中仍然保持着。这个巨大的64位VAS被拆分成用户模式的VAS和内核的VAS,具体如下。这里假设使用48位寻址和4 KB页大小:
- 所谓的“标准下半部分”进程VAS,128 TB:用户VAS——虚拟地址范围从0x0到0x0000 7fff ffff ffff
- 所谓的“标准上半部分”进程VAS,128 TB:内核VAS——虚拟地址范围从0xffff 8000 0000 0000到0xffff ffff ffff ffff
“标准”一词实际上是指根据法律或常规惯例。
以下图示展示了x86_64 Linux平台上的这种64位VM拆分:
在前面的图中,你可以清楚地看到用户VAS位于底部的128TB区域,而内核VAS则位于总共16EB VAS的顶部128TB区域。
那么中间的未使用区域(这里是从0x0000 8000 0000 0000到0xffff 7fff ffff ffff)呢?这只是一个空洞或稀疏区域;它也被称为非标准地址区域。有趣的是,正如图中所示,在典型的48位寻址方案中,大部分VAS(99.998%)是未使用的!这就是为什么我们称VAS非常稀疏的原因。
需要指出的是,前面的图并没有按比例绘制!此外,始终记住,这些都是虚拟内存空间,而不是物理内存空间。
常见的VM拆分
为了总结关于VM拆分的讨论,以下图展示了不同CPU架构的常见用户:内核VM拆分比例(同样,我们假设MMU页大小为4KB,最后一行使用64KB页大小):
关于图7.6的一些注释(与行号相关)
- 第1行和第2行:分别为32位IA-32(或x86-32)和AArch32,具有3:1和2:2(GB)的VM拆分。
- 第3行,常见情况:x86_64,使用4级分页和48位(LSB)寻址。
- 第4行:5级分页是可能的,但需要4.14 Linux或更高版本。
- 第5行,AArch64示例:使用标准的Yocto 4.0.4(Kirkstone)构建的Poky发行版,机器为raspberrypi4-64(使用默认配置值CONFIG_ARM64_VA_BITS=39;因此配置了40个地址位,总共的VAS是2^40 = 1 TB;因此,这里内核和用户VAS各占512 GB)。
- 第6行,AArch64:以上配置 + 配置CONFIG_ARM64_VA_BITS=48;因此配置了49个地址位(从0到48),总VAS为2^49 = 512 TB,因此内核和用户VAS各为256 TB。
- 第7行:此配置要求(AArch64)ARMv8.2 LPA(大物理地址)扩展+ >= 5.4 Linux +启用64K页大小(用于3级分页)。它配置了53个地址位,总VAS为2^53 = 8 PB;因此,内核和用户VAS各为4 PB。
关于x86_64 Linux寻址的说明
以x86_64为例,2^47是128 TB;那么,为什么在图7.6中的x86_64寻址位数是48而不是47?当然,这是因为我们需要2*128 TB = 256 TB的可用地址空间,128 TB用于用户空间,另128 TB用于内核空间(2^48 = 256 TB)。
实际上,用于寻址的地址位数(图7.6中的第四列)决定了总共可用的虚拟内存大小:用户+内核 —— 每个VAS通常占总地址空间的一半(例外情况通常是32位x86和ARM-32,VM拆分可能人为不相等)。
我们特别强调第三行(红色加粗)作为常见情况:在x86_64(或AMD64)架构上运行Linux,用户:内核 :: 128 TB:128 TB的VM拆分。第四列“#Addr Bits”显示,在64位处理器上,实际上没有真实的处理器使用全部64位进行寻址。
在x86_64下,有两种VM拆分,如图7.6(第3行和第4行)所示:
- 第一个(图7.6中的第3行)是128 TB:128 TB(4级分页),这是目前在Linux x86_64系统上默认使用的VM拆分(嵌入式系统、笔记本、PC、工作站和服务器)。它将物理地址空间限制为64 TB(RAM)。
- 第二个(图7.6中的第4行),64 PB:64 PB,至少在写作时仍然是纯理论性的;它支持称为5级分页的功能,从4.14 Linux开始提供。分配的VAS(通过57位寻址,得到128 PB的VAS和4 PB的物理地址空间)如此巨大,以至于根据我们写作时的知识,尚没有实际的计算机(目前)在使用它。
关于AArch64 Linux寻址的说明
注意,表格中为AArch64(ARM-64)架构提供的两行只是代表性的。BSP供应商或平台团队在产品开发过程中可能会使用不同的拆分(图7.6后的注释中给出了精确的含义)。
此外,表格中的最后一行提到,后代的(AArch64)ARMv8.2处理器提供了LPA扩展。当启用时,并且使用64 KB的页大小,地址空间可以扩展到每个用户和内核地址空间52位(因此总共53位),为每个提供(2^53)4 PB的VAS空间(同时也限制为4 PB的物理内存)。从Linux 5.4开始支持这一功能。
有关AArch64 52位寻址的更多信息,请访问以下链接:
64位Linux系统上的VM拆分
首先,值得注意的是,在64位系统上,并不是所有的64位都用于寻址。标准的x86_64 Linux操作系统配置,使用4KB页大小,使用LSB(最低有效位)48位进行寻址。为什么不使用完整的64位?因为太多了!没有现有的计算机接近拥有完整的2^64 = 18,446,744,073,709,551,616字节(即16 EB)的RAM。
“为什么,”你可能会问,“我们为什么将这种虚拟寻址与RAM挂钩?”请继续阅读——更多内容需要涵盖,才能完全理解。关于检查内核VAS部分,你将完全理解这一点。
如前所述,64位系统上的可用VAS是惊人的16EB(2^64 = 16 exabytes,16 x 10^18字节)。当Linus在为DEC Alpha(第一款商用64位处理器)移植64位Linux时,他可能不得不决定如何在这个巨大的VAS中布置进程和内核段。这个决定在今天的x86_64 Linux OS中基本保持不变。这个庞大的64位VAS被拆分成用户模式VAS和内核模式VAS,如下所示。这里我们假设48位寻址和4KB页大小:
- 所谓的“标准下半部分”进程VAS,为128 TB:用户VAS——虚拟地址范围从0x0到0x0000 7fff ffff ffff
- 所谓的“标准上半部分”进程VAS,为128 TB:内核VAS——虚拟地址范围从0xffff 8000 0000 0000到0xffff ffff ffff ffff
“标准”一词实际上表示按照法律或常规的方式。
理解进程VAS - 完整视图
再次参考图7.1,它展示了单个32位进程的实际和完整的进程VAS布局。现实情况是,系统中所有运行的进程都有各自独特的用户模式VAS,但它们共享相同的内核VAS。以下图试图概念性地展示这一点;它展示了典型IA-32(或可能是AArch32)系统的情况,采用3:1(GB)VM拆分。在这里,每个进程的用户空间是唯一的,而所有进程共享相同的内核VAS:
注意在前面的图中,虚拟地址空间如何反映3:1(GB)的VM拆分。用户模式VAS从0扩展到0xbfff ffff(0xc000 0000是3 GB的分界点;这是PAGE_OFFSET宏在这里设置的值),而内核VAS从0xc000 0000(3 GB)扩展到0xffff ffff(4 GB)。
在本章稍后的部分,我们将介绍一个有用的工具叫做procmap。它将帮助你直观地显示用户和内核的VAS,类似于我们之前图示所展示的那样。
需要注意几点:
- 对于图7.7中展示的示例,PAGE_OFFSET内核宏的值是0xc000 0000。
- 我们这里展示的图表和数字并不是在所有架构中都是绝对和固定的;它们通常非常依赖架构,并且许多高度定制的Linux系统可能会改变这些值(如图7.6所示)。
- 图7.7详细展示了32位Linux OS上的VM布局。在64位Linux上,概念保持一致,只是数字(显著地)变化。如前面的部分详细说明,x86_64(48位寻址和4K页)Linux系统上的VM拆分变成了用户:内核 :: 128 TB:128 TB。
现在,理解了进程的虚拟内存布局基础,你会发现这对于解读和解决那些难以调试的问题有很大的帮助。如往常一样,后面还有更多内容;接下来的部分将涉及用户空间和内核空间虚拟内存映射(内核VAS),以及物理内存映射的一些内容。继续前进!
检查进程的虚拟地址空间(VAS)
我们已经讨论了每个进程的VAS布局,包括它所包含的各个段或映射(参见第6章《内核内部基础知识 – 进程和线程》中的“理解进程虚拟地址空间(VAS)的基础”部分)。我们了解到,进程的VAS由多个映射或段组成,其中包括文本(代码)段、数据段、库映射以及至少一个栈。在这里,我们将对这些内容进行更深入的探讨。
对于像你这样的开发者来说,能够深入内核并查看各种运行时值是一项重要的技能(这对应用用户、质量保证人员、系统管理员、DevOps人员等也同样重要)。Linux内核为我们提供了一个非常强大的接口来完成这一任务——没错,这就是proc文件系统(procfs)。
这个伪文件系统在Linux中始终存在(至少应该是这样),并默认挂载在/proc目录下。procfs系统有两个主要的作用:
- 提供一组统一的(伪或虚拟)文件和目录,允许你深入内核并查看硬件的内部细节。
- 提供一组统一的根可写文件,允许系统管理员(或root用户)修改关键的内核参数。这些文件位于
/proc/sys/下,称为sysctl,它们是Linux内核的调优工具。
熟悉proc文件系统确实是必须的。我建议你查看它,并阅读关于proc(5)的优秀手册页(在终端中输入man 5 proc)。例如,简单地执行cat /proc/PID/status(其中PID是给定进程或线程的唯一进程标识符)可以获取该进程或线程的任务结构中的一大堆有用的详细信息!
从概念上讲,procfs类似于sysfs文件系统,sysfs挂载在/sys下(以及通常挂载在/sys/kernel/debug下的debugfs)。sysfs是Linux 2.6及更高版本的新的设备和驱动模型的表示;它展示了系统上所有设备(及其驱动)的树状结构,以及若干内核调优工具。所有这些都是伪文件系统;也就是说,它们挂载在内存中(因此它们的内容是易变的)。
详细检查用户虚拟地址空间(VAS)
让我们从检查任何给定进程的用户VAS开始。通过procfs,可以非常详细地查看用户VAS,特别是通过/proc/PID/maps伪文件。我们将学习如何使用这个接口来窥探进程的用户空间(虚拟)内存映射。我们将看到两种方法:
- 直接通过procfs接口的
/proc/PID/maps伪文件 - 使用一些有用的前端工具(使输出更易于理解)
让我们从第一种方法开始。
直接通过procfs查看进程内存映射
查看任何任意进程的内部详细信息需要root权限,而查看属于你自己(包括调用进程本身)的进程的详细信息则不需要。因此,作为一个简单的例子,我们将通过使用self关键字代替PID来查看调用进程的VAS。以下截图显示了这一过程(在x86_64 Ubuntu 22.04 LTS虚拟机上):
在查看任何进程的内部详细信息时,需要root权限,但查看你拥有的进程(包括调用进程本身)的详细信息则不需要。因此,作为一个简单的例子,我们将通过使用self关键字代替PID来查看调用进程的VAS。以下截图显示了这一过程(在x86_64 Ubuntu 22.04 LTS虚拟机上):
在上面的截图中,你可以看到cat进程的用户虚拟地址空间(VAS)的布局——这是该进程的用户VAS的实际内存映射!另外,注意到上面的procfs输出按UVA的升序排列。
对强大的mmap(2)系统调用有基本了解将有助于理解后续的讨论。建议你至少浏览其手册页。
解释/proc/PID/maps输出
为了理解图7.8中的输出,逐行阅读。每一行代表一个进程的用户模式VAS的段或映射(在之前的例子中是cat进程的)。每一行包含以下几个字段;为了方便起见,我将显示一个输出样本行,并对其字段进行标注、引用和理解:
start_uva - end_uva mode,mapping start-off mj:mn inode# image-name
558822d66000-558822d6a000 r-xp 00002000 08:01 7340181 /usr/bin/cat
这里,整行代表进程(用户)VAS中的一个段或映射。uva是用户虚拟地址。每个映射的start_uva和end_uva显示为前两个字段(或列),并由短横线分隔。因此,映射(段)的长度可以轻松计算(end_uva - start_uva字节)。在前面的行中,start_uva是0x558822d66000,end_uva是0x558822d6a000,长度计算为16KB;但这个段在进程中究竟代表什么呢?请继续阅读……
第三个字段,r-xp,是两个信息的组合:
- 前三个字母表示段的模式(权限)(使用通常的
rwx符号表示)。 - 下一个字母表示映射是私有的(
p)还是共享的(s)。这是由mmap()系统调用的第四个参数flags内部设置的;实际上,是mmap()系统调用在内部负责为进程创建每个段或映射! 因此,对于前面的示例段,第三个字段是r-xp,我们现在可以知道它是一个文本(代码)段,并且是一个私有映射(正如预期的那样)。
第四个字段start-off(在这里是值0x2000)是文件内容被映射到进程VAS中的偏移量(对于看到的大小,16KB)。显然,这个值仅对文件映射有效。
你可以通过查看倒数第二个(第六个)字段来判断当前段是否为文件映射——即文件inode号。对于不是文件映射的映射——称为匿名映射——它的inode号始终为0(例如,堆或栈段)。在我们前面的示例行中,它是文件映射(即/usr/bin/cat),并且从该文件的起始位置偏移了0x2000字节(我们在前面计算的映射长度为16KB)。
第五个字段(08:01)的格式为mj:mn,其中mj是主要设备号,mn是次要设备号,表示映像所在的(块)设备文件。与第四个字段一样,这仅对文件映射有效,否则显示为00:00;在我们前面的示例行中,由于它是文件映射,主要和次要设备号分别是8和1。
第六个字段(7340181)表示映像文件的inode号——即文件内容被映射到进程VAS中的文件。inode是虚拟文件系统(VFS)的关键数据结构,它包含了文件对象的所有元数据,除了文件名(它存储在目录(或点)文件中)。同样,这个值仅对文件映射有效,否则显示为0。这实际上是快速判断映射是否为文件映射的方式!在我们前面的示例映射中,显然它是文件映射(即/usr/bin/cat),并且inode号是7340181。实际上,我们可以确认这一点:
$ ls -i /usr/bin/cat
7340181 /usr/bin/cat
第七个字段(最后一个字段)表示正在映射到用户VAS中的文件路径名。在这里,由于我们正在查看cat进程的内存映射,路径名(对于文件映射段)正好是/usr/bin/cat。如果映射代表一个文件,文件的inode号(第六个字段)会显示为一个正数;如果不是——意味着它是一个没有存储后端的纯内存或匿名映射——inode号显示为0,最后一个字段将为空。(当然,也可能显示其他文件:如共享库、共享内存段等。)
到现在应该很明显,但我们仍要指出这一点,因为它是一个关键点:所有前面看到的地址都是虚拟地址,而不是物理地址。此外,这些地址只属于用户空间,因此它们被称为UVAs,且始终通过该进程的唯一分页表元数据进行访问(并进行翻译)。此外,前面的截图是在64位(x86_64)Linux虚拟机上拍摄的。因此,在这里我们看到的是64位虚拟地址。
这里虚拟地址的显示方式并不是以完整的64位数字显示——例如,显示为558822d66000而不是0000558822d66000。我要你注意这一点,因为它是UVA,MSB的16位是零!(当然,数字都是以十六进制表示的。)
好了,这部分涵盖了如何解释特定段或映射(堆和栈行是自解释的),但是似乎有一些奇怪的映射(请再次查看图7.8)——vvar、vdso和vsyscall映射。我们来看看它们是什么意思。
vsyscall页
你有没有注意到图7.8输出中的一行稍显不同的内容?最后一行——所谓的vsyscall条目——映射了一个内核页(现在你已经知道如何判断:它的起始和结束虚拟地址的MSB 16位被设置)。这里,我们仅提到这是一个(旧的)优化,用于执行系统调用。它通过避免某些系统调用需要切换到内核模式来提高效率,这些系统调用实际上不需要这样做。
目前,在x86架构上,这些调用包括gettimeofday()、time()和getcpu()等系统调用。实际上,vvar和vdso(即虚拟动态共享对象)映射是其(稍微现代化的)变体。如果你有兴趣了解更多,可以查看本章的进一步阅读部分。
顺便提一下,名为/proc/PID/map_files/的目录提供了另一种视图;它仅显示进程或线程中的文件映射。在这里,每个文件支持的内存映射(或段)都作为符号链接,指向相应的文件。
现在,你已经学会了如何直接读取和解释指定PID的进程的/proc/PID/maps(伪)文件输出,来检查和解释进程的“原始”用户空间内存映射。接下来,我们将检查一些方便的前端工具,它们可以帮助你完成这项工作。
查看进程内存映射的前端工具
除了通过/proc/PID/maps以原始格式(我们在前一节中已经学习了如何解释)查看进程内存映射之外,还有一些封装工具可以帮助我们更容易地解释用户空间虚拟地址空间(VAS)。其中包括额外的(原始)/proc/PID/smaps伪文件、pmap和smem工具,以及我自己创建的一个工具(命名为procmap)。
内核通过/proc/PID/smaps伪文件提供有关每个段或映射的详细信息。你可以尝试运行cat /proc/self/smaps命令来亲自查看这些信息。你会发现,对于每个段(映射),提供了大量的详细信息。proc(5)的man页面可以帮助解释这些字段。
对于pmap和smem工具,建议你查看它们的man页面以获取更多的细节。例如,在pmap命令中,man页面告诉我们更多详细的-X和-XX选项:
$ pmap -h
[ … ]
-x, --extended 显示详细信息
-X 显示更多详细信息
警告:格式根据`/proc/PID/smaps`变化
-XX 显示内核提供的所有信息
[ … ]
关于smem工具,实际上它不会显示进程的VAS;它更关注回答一个常见问题:即确定哪个进程占用了最多的物理内存。它使用驻留集大小(RSS)、比例集大小(PSS)和唯一集大小(USS)等度量来提供更清晰的图景。进一步探索这些工具的内容留给你,亲爱的读者!
顺便提一下,我们在第8章《模块作者的内核内存分配——第1部分》中,介绍了smem使用的一个小部分内容,尤其是在《如何查看系统范围的总体内存使用?》这一节中。
现在,让我们继续探索如何利用一个有用的工具——procmap——来详细查看给定进程的内核和用户内存映射。
procmap 进程 VAS 可视化工具
作为一个小型学习和教学项目(在调试过程中也非常有用!),我在 GitHub 上创建并托管了一个名为 procmap 的项目,地址在这里:github.com/kaiwan/proc…(请克隆它!)。以下是其 README.md 文件的摘录,有助于解释其用途:
procmap 旨在成为一个控制台/CLI 工具,用于可视化 Linux 进程的完整内存映射,实际上是可视化内核和用户模式虚拟地址空间(VAS)的内存映射。
它以简单的可视化格式输出,按虚拟地址降序排列(见下方截图)。该脚本具有智能,能够显示内核和用户空间映射,并计算和显示稀疏内存区域。此外,每个段或映射都按相对大小进行缩放(并且为提高可读性进行了颜色编码)。在 64 位系统上,它还会显示所谓的非标准稀疏区域或“孔”(通常接近 x86_64 上的 16,384 PB)。
在《查看内部机制——Hello, world C 程序》一节中,我们讨论了当著名的“Hello, world” K&R C 程序在 Linux 上运行时,实际上发生了什么。在这里,让我们通过利用 procmap 工具来探索 Hello, world 进程的用户 VAS!
我的“Hello, world” C 代码是标准的,唯一不同的是,在 printf() 之后,我们希望进程保持运行;所以我添加了 pause() 系统调用(它会让调用者阻塞(休眠),直到收到信号):
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("Hello, world\n");
pause();
}
(我还进行了优化构建,使用 -O2,并剥离了二进制可执行文件)。为了增加一些趣味,我在 Raspberry Pi 4(Model B)上通过 SSH 会话执行此操作,运行的是标准的 Raspberry Pi OS(内核版本为 5.15.84-v8+)并配置为 AArch64。构建后,将其在后台运行:
$ ./helloworld &
[1] 835
$ Hello, world
啊,运行成功了。好了,进程保持运行状态,接下来我们就开始使用 procmap 工具。如何安装并调用它呢?很简单,以下是步骤(由于篇幅问题,我这里不会展示所有信息、警告等;请自行尝试):
$ git clone https://github.com/kaiwan/procmap
$ cd procmap
$ ./procmap
Usage: procmap [options] --pid=PID-of-process-to-show-memory-map-of
Options:
--only-user : show ONLY the usermode mappings or segments (not kernel VAS)
--only-kernel : show ONLY the kernel-space mappings or segments (not user VAS)
[default is to show BOTH]
--export-maps=filename
write all map information gleaned to the file you specify in CSV
--export-kernel=filename
write kernel information gleaned to the file you specify in CSV
--verbose : verbose mode (try it! see below for details)
--debug : run in debug mode
--version|--ver : display version info.
See the config file as well.
[…]
请注意,这个 procmap 工具与 BSD Unix 提供的 procmap 工具不同。此外,供参考,它需要安装 bc、smem 和 yad 等工具。
当我运行 procmap 工具时,仅使用 --pid=<PID>(这是唯一的必选参数),它将显示指定进程的内核和用户空间 VAS。现在,由于我们尚未讨论内核 VAS(或段)的详细信息,我不会在这里显示内核空间的详细输出;我们将在即将到来的《查看内核 VAS》一节中讨论这一部分。接下来,你会看到仅来自 procmap 工具的用户 VAS 输出的部分截图。完整的输出可能会很长,当然这取决于进程的情况;请自己尝试。
如你所见,procmap 尝试提供进程内存映射的基本可视化——内核和用户空间的 VAS 以垂直排列的格式显示(如前所述,这里仅展示了截断后的截图):
从前面的(部分)截图中,你可以注意到几点:
procmap(Bash)脚本自动检测到我们正在运行的是 ARM-64(AArch64)系统。- 虽然我们现在不关注这一部分,但内核 VAS 的输出首先显示;这是很自然的,因为我们按照虚拟地址降序排列输出(图 7.1、图 7.5 和图 7.7 中都有重复说明这一点)。
- 你可以看到第一行(在 "KERNEL VAS ..." 标头之后)对应的是位于 VAS 最顶部的 KVA —— 值为
0xffff ffff ffff ffff(因为我们在 64 位 Linux 上执行它)。
接下来,在 procmap 输出的下一部分中,让我们看看 helloworld 进程的用户 VAS 上端的截断视图:
图 7.10 是 procmap 输出的部分截图,显示了用户空间的 VAS;在它的最顶部,你可以看到(高端)UVA。
在我们的 AArch64 系统上(请记住,这是与架构相关的),(高端)end_uva 的值是 0x0000 007f ffff ffff(正如所见,start_uva 当然是 0x0;请参见下一个截图)。注意,在图 7.10 中,有一些有趣的映射:主函数的栈(main())、VAS 中的稀疏区域(漏洞、非法访问和未使用的区域,实际上就是这些),以及加载器(ld*.so)的映射,当然还有 glibc 共享库的映射——这就是 “Hello, world” 能够工作的原因!
procmap 是如何确定精确的地址值的呢?哦,它其实是相当复杂的:对于内核空间的内存信息,它使用一个内核模块(LKM!)来查询内核,并根据系统架构设置输出文件;用户空间的细节当然来自 /proc/PID/maps 直接的(原始)procfs 伪文件接口。
顺便提一句,procmap 的内核组件,一个内核模块,通过创建并设置 debugfs 伪文件,设置了与用户空间——即 procmap 脚本——的接口。嘿,它是开源的;请随时 git clone 并了解它是如何工作的!
以下图为例,显示了我们“Hello, world”进程的用户模式 VAS 的低端部分,一直到最低的 UVA,0x0:
最后一个映射,一个单独的页面,正如预期的那样,是空指针陷阱页面(从 UVA 0x1000 到 0x0;我们将在接下来的《空指针陷阱页面》部分解释它的作用)。在这张图中,注意堆和 "Hello, world" 程序代码/数据的映射。
procmap 工具在显示内存映射之后,如果在其配置文件中启用,计算并显示一些统计信息:包括内核和用户模式 VAS 的大小,稀疏区域占用的内存量(在 64 位系统上,如前面的例子所示,通常占用大部分空间!),以及这些统计数据的绝对数值和百分比,报告的物理 RAM 大小,最后,ps 和 smem 工具报告的该特定进程的内存使用详情。
通常,在 64 位系统上(见图 7.5),你会发现进程 VAS 的稀疏(空)内存区域几乎占用了可用地址空间的 100%!这意味着,通常情况下,虚拟内存空间的 99% 以上是稀疏的(空的)!这就是 64 位系统上巨大的 VAS 的实际情况。它的 16 EB 巨大 VAS 中,只有极小的一部分被实际使用(AArch64 和 x86_64 系统都是如此)。当然,实际的空闲和使用的用户模式 VAS 数量取决于应用进程的大小。
能够清晰地可视化进程 VAS 对于调试或深入分析问题非常有帮助。
如果你正在阅读本书的纸质版,记得从出版社网站下载带有彩色图表/图形的完整 PDF 版本:packt.link/gbp/9781803…。
你还会看到,在输出的最后打印出的统计信息(默认启用)显示了为目标进程设置的虚拟内存区域(VMA)的数量。VMA?接下来的部分简要解释了 VMA 是什么。让我们继续!
理解VMA基础
在 /proc/PID/maps 的输出中,每一行都源自一个内核元数据结构,叫做虚拟内存区域(VMA)。这其实非常直接:内核使用VMA数据结构来抽象我们所称之为段或映射的内容。因此,对于用户虚拟地址空间(VAS)中的每一个映射,都有一个VMA对象由操作系统维护。请注意,只有用户空间的映射是由内核元数据结构VMA管理的;内核的虚拟地址空间(VKA)本身没有VMA。
那么,给定的进程会有多少个VMA呢?答案是它的用户VAS中映射(段)的数量。举个例子,在我的helloworld进程运行的示例中,它报告了15个段或映射,这意味着在内核内存中有15个VMA元数据对象,分别表示该进程的15个用户空间段或映射。
从程序的角度来说,内核通过当前进程的任务结构(current->mm->mmap)维护一个VMA“链”(从效率考虑,这实际上是一个红黑树数据结构)。为什么这个指针叫做mmap?这是有意为之:每当执行一个mmap()系统调用——即内存映射操作时,内核会在调用进程的VAS中生成一个映射(或“段”),从而创建一个代表该映射的VMA元数据对象。
VMA元数据结构类似于一个大伞,涵盖了映射并包括内核执行各种内存管理操作所需的所有信息:处理页错误(非常常见)、缓存文件内容到内核页缓存中,或者从内核页缓存中读取等等。
页错误处理是一个非常重要的操作系统活动,其算法大量使用了内核VMA对象;不过在本书中,我们不会深入探讨这些细节,因为它们对内核模块/驱动程序的开发者来说通常是透明的。
为了帮助你理解,我们将展示一些内核VMA数据结构的成员,以下是代码片段及其注释解释:
// include/linux/mm_types.h
struct vm_area_struct {
/* 第一个缓存行包含VMA树遍历的相关信息。 */
unsigned long vm_start; /* 我们在vm_mm中的起始地址。 */
unsigned long vm_end; /* 我们在vm_mm中的结束地址后的第一个字节。 */
struct mm_struct *vm_mm; /* 我们所属的地址空间。*/
[...]
pgprot_t vm_page_prot;
unsigned long vm_flags; /* 标志,见mm.h。 */
[...]
/* 用于处理该结构的函数指针。 */
const struct vm_operations_struct *vm_ops;
/* 关于我们的备份存储的信息: */
unsigned long vm_pgoff; /* 文件中偏移量,单位为PAGE_SIZE。 */
struct file * vm_file; /* 映射到的文件(可以为NULL)。 */
[…]
} __randomize_layout;
现在应该更清楚了,cat /proc/PID/maps 在后台是如何工作的:当用户空间运行类似cat /proc/self/maps的命令时,cat进程会发出一个read()系统调用;这会导致它切换到内核模式并以内核权限运行read()系统调用代码。在这里,内核虚拟文件系统(VFS)切换将控制权重定向到适当的procfs回调处理程序(该函数在最近的内核中通过proc_ops结构注册)。此代码会遍历(循环)每个VMA元数据结构(对于运行中的进程上下文,也就是当前的进程,即我们的cat进程),将每个VMA对象的相关信息发送回用户空间。然后,cat进程将通过read接收到的数据忠实地打印到标准输出(stdout),从而我们看到了它:进程的所有段或映射——实际上就是用户模式VAS的内存映射!
很好,我们现在总结这一部分;我们已经涵盖了如何检查进程的用户VAS的详细信息。这些知识不仅帮助理解用户模式VAS的精确布局,还对调试用户空间的问题非常有帮助!
现在,是时候继续了解内存管理的另一个关键方面——内核VAS的详细布局了!
检查内核虚拟地址空间(VAS)
如我们在前一章所讨论的,并在图 7.7 中所示,必须理解所有进程都有各自唯一的用户虚拟地址空间(User VAS),但共享内核空间——我们称之为内核段或内核虚拟地址空间(Kernel VAS)。本节将从分析内核虚拟地址空间的一些常见(与架构无关)的区域开始。
内核虚拟地址空间的内存布局高度依赖于具体的架构(CPU)。尽管如此,所有架构之间仍有一些共同点。以下基本图示展示了用户虚拟地址空间和内核虚拟地址空间(以水平平铺的格式表示),这对应于典型的 x86_32(或 IA-32)架构,采用 3:1(GB)虚拟内存分割:
逐步解析进程虚拟地址空间的各个区域(从左到右,如图 7.12 所示)
-
用户模式虚拟地址空间 (User Mode VAS)
这就是用户虚拟地址空间(User VAS)。我们已经在上一章以及本章的早些部分详细讨论过。在这个特定示例中,它占用了 3 GB 的虚拟地址空间(地址范围从0x0到0xbfff ffff)。 -
内核虚拟地址空间(Kernel VAS 或 Kernel Segment)
在这个特定示例中,我们有 1 GB 的内核虚拟地址空间(地址范围从0xc000 0000到0xffff ffff)。接下来,我们将详细分析它的各个部分:-
低内存区域(Lowmem Region)
这是操作系统将平台(系统)RAM 直接映射到内核虚拟地址空间的区域。(图 7.12 尝试清楚地传达了这一点。我们将在“直接映射 RAM 和地址转换”部分更详细地讨论这一关键主题。如果你觉得有帮助,可以先阅读该部分,然后再回到这里。)简单来说,平台 RAM 被映射到内核虚拟地址空间的起始位置由一个内核宏
PAGE_OFFSET指定。这个宏的具体值高度依赖于架构。我们将在后续部分详细讨论。现在,只需记住,对于 3:1(GB)虚拟内存分割的 x86_32,PAGE_OFFSET的值是0xc000 0000。显然,所谓的低内存区域的大小等于系统的 RAM 大小(至少是内核“看到”的 RAM 大小;例如,如果启用了 kdump 功能,操作系统会在早期预留一部分 RAM)。构成该区域的虚拟地址称为内核逻辑地址,因为它们与物理地址之间有一个固定的偏移量。核心内核和设备驱动程序可以通过各种 API(包括页分配器 API 和流行的 slab API,如
kmalloc()和kzalloc())从该区域分配(物理连续的)内存。内核的静态代码段、数据段和 BSS(未初始化数据)段也位于该低内存区域中。 -
低内存区域之后的其他区域
虽然图 7.12 中未明确显示这些区域,但以下几个是关键区域:- 内核 vmalloc 区域
这是内核虚拟地址空间中完全虚拟的一个区域。核心内核和/或设备驱动程序代码可以使用vmalloc()(以及相关 API)从该区域分配虚拟连续的内存。我们将在第 8 章“模块作者的内核内存分配(第 1 部分)”和第 9 章“模块作者的内核内存分配(第 2 部分)”中详细讨论。此外,这也是所谓的ioremap空间。 - 内核模块空间
内核虚拟地址空间的一部分专门为加载的内核模块(LKMs)的静态代码和数据保留。当你执行insmod(或modprobe)时,底层内核代码通过init_module()系统调用分配此区域的内存(通常通过vmalloc()API),并将内核模块的(静态)代码和数据加载到该区域。
- 内核 vmalloc 区域
-
-
对内核虚拟内存布局的简化表示
图 7.12 特意以简单且略显模糊的方式表示了内核虚拟内存布局,因为具体的内存布局高度依赖于架构。为了避免过于学术化,我们稍后将通过一个内核模块来查询并打印关于内核虚拟地址空间布局的相关信息。在掌握了具体架构的实际值后,我们将提供更详细的布局图。术语区分:逻辑地址和虚拟地址
如图 7.10 所示,属于内核低内存区域的地址被称为内核逻辑地址,它们与物理地址有固定的偏移量。而内核虚拟地址空间中其他区域的地址被称为内核虚拟地址(KVAs) 。尽管这里做出了区分,但在实际应用中,这种区别并不重要。我们通常会将内核虚拟地址空间内的所有地址统称为 KVAs。 -
高内存区域(High Memory Region)
在介绍内核虚拟地址空间的具体模块之前,我们还需要讨论一些与 32 位架构限制相关的信息。其中一个重点是 32 位系统内核虚拟地址空间的高内存区域。我们将在后续部分详细探讨这一内容。
32 位系统上的高内存区域
回顾我们前面简要讨论的内核低内存区域,一个有趣的现象出现了。在一个 32 位系统上,假设采用 3:1(GB)虚拟内存分割(如图 7.12 所示),如果系统有 512 MB 的 RAM,这部分 RAM 将从 PAGE_OFFSET(3 GB 或 KVA 0xc000 0000)开始直接映射到内核虚拟地址空间,这点非常清楚。
但请思考一下:如果系统有更多的 RAM,例如 2 GB 会怎样?显然,整个 2 GB 的 RAM 不可能完全映射到内核低内存区域中,因为在这个例子中,整个可用的内核虚拟地址空间只有 1 GB,而 RAM 却有 2 GB。因此,在 32 位 Linux 操作系统上,只有特定数量的内存(通常是 IA-32 架构上的 896 MB)能够被直接映射,从而属于低内存区域。剩余的 RAM 被间接映射到另一个内存“区域”,称为 ZONE_HIGHMEM(与低内存区域相对,我们可以将其视为高内存区域或区)。更准确地说,由于内核无法一次性直接映射所有物理内存,它会设置一个(虚拟)区域,用于临时映射这些 RAM(通常通过调用 kmap() 和 kunmap() API)。这就是所谓的高内存区域。
关于“高内存”术语的澄清
不要被“高内存”这个术语所混淆:
- 它不一定位于内核虚拟地址空间的“高位”。
- 它也与 PC 上 640 KB “内存空洞”之上的内存无关。
事实上,全局变量 high_memory(仅在 32 位系统上有效)表示内核低内存区域的上界。更多细节将在后续部分“描述内核虚拟地址空间布局的宏和变量”中讨论。
高内存问题在 64 位系统上的消失
如今(尤其是在 32 位系统使用越来越少的情况下),这些问题在 64 位 Linux 系统上完全消失了。思考一下,例如在运行 64 位 Linux 的 x86_64 架构上,内核虚拟地址空间的大小高达 128 TB(131,072 GB)。据我所知,目前没有任何单一系统(或节点)拥有接近这个数量的 RAM。
截至本文撰写时,NASA 的 Pleiades 超级计算机每个节点最多只有 128 GB 的 RAM(参考:NASA Pleiades 超级计算机)。
另外,当采用稀疏内存模型(通常如此;更多细节请参考后续部分“物理内存模型简介”)时,物理地址支持的最大位数由宏 MAX_PHYSMEM_BITS 决定。在 x86_64 架构上,这个值通常是 46,意味着机器支持的最大 RAM 数量是 2462^{46}246 字节,即 64 TB。因此,所有平台 RAM 确实可以轻松映射到 128 TB 的内核虚拟地址空间中,从而无需 ZONE_HIGHMEM(或类似的解决方案)。
高内存区域的逐步淘汰
实际上,大多数内核开发者都希望最终在 32 位系统上弃用这种高内存区域。但截至目前,它仍然存在,用于支持运行 Linux 的较老(通常是 ARM-32)系统,这些系统的 RAM 超过 1 GB。有关更多信息,可以参阅这篇文章:An end to high memory?,LWN,2020 年 2 月。
官方内核文档中也详细描述了这种复杂的“高内存”区域(与 32 位相关)。有兴趣的话,可以查看:内核高内存文档。
下一步
好了,现在让我们解决之前一直想做的事情——编写一个内核模块(LKM)来深入探索内核虚拟地址空间的相关细节。
编写内核模块以显示内核虚拟地址空间 (VAS) 的信息
正如我们所了解的,内核虚拟地址空间 (VAS) 由多个区域组成。其中一些区域与架构无关(arch-independent),例如低内存区域(包含未压缩的内核镜像——包括其代码和数据)、内核模块区域、vmalloc 和 ioremap 区域等。
这些区域在内核 VAS 中的确切位置以及是否存在某些区域,取决于具体的架构 (CPU)。为了帮助我们理解并固定这些区域在特定系统中的位置,我们将开发一个内核模块,查询并打印内核 VAS 的相关详细信息(在需要时以架构相关的方式)。事实上,如果需要,该模块还可以打印一些有用的用户空间内存信息。
在查询和打印这些信息之前,您需要熟悉一些关键的内核宏和全局变量。接下来我们将对此进行介绍。
描述内核 VAS 布局的宏和变量
要编写一个显示内核 VAS 信息的内核模块,我们需要知道如何查询内核的这些细节。本节将简要描述一些关键的内核宏和变量,它们表示内核虚拟地址空间中的内存(在大多数架构中按 KVA 降序排列)。
1. 向量表 (Vector Table)
向量表是一个常见的操作系统数据结构,它是一个函数指针数组(又称为切换或跳转表)。该表是架构相关的。例如,ARM-32 使用它来初始化向量,以便在发生处理器异常或模式切换(如中断、系统调用、页面错误、MMU 中止等)时,处理器知道要执行的代码。
| 宏或变量 | 解释 |
|---|---|
| VECTORS_BASE | 通常仅适用于 ARM-32;表示内核向量表的起始 KVA,占用一页 |
2. 固定映射区域 (Fix Map Region)
固定映射区域是一段在编译时预留的特殊虚拟地址,用于在启动时将某些必需的内核元素固定到内核虚拟地址空间。例如,初始内核页表的设置、早期的 ioremap 和 vmalloc 区域等。
| 宏或变量 | 解释 |
|---|---|
| FIXADDR_START | 内核固定映射区域的起始 KVA,占用 FIXADDR_SIZE 字节 |
3. 内核模块区域 (Kernel Modules Region)
内核模块加载时分配用于静态代码和数据的内存,位于内核虚拟地址空间的特定范围内。该区域的位置因架构而异。例如,在 AArch32 系统中,它位于用户虚拟地址空间上方,而在 64 位系统中,通常位于内核虚拟地址空间的较高位置。
| 宏或变量 | 解释 |
|---|---|
| MODULES_VADDR | 内核模块区域的起始 KVA |
| MODULES_END | 内核模块区域的结束 KVA;区域大小为 MODULES_END - MODULES_VADDR |
4. KASAN 区域
从内核 4.0(x86_64)及以后版本开始,Linux 引入了 KASAN(内核地址消毒器),用于检测和报告内存问题(例如,越界访问、释放后使用等)。KASAN 使用一种称为编译时插桩 (CTI) 的技术来检测这些问题。启用 KASAN 时,需要一段影子内存,其大小为内核 VAS 的八分之一。
| 宏或变量 | 解释 |
|---|---|
| KASAN_SHADOW_START | KASAN 区域的起始 KVA |
| KASAN_SHADOW_END | KASAN 区域的结束 KVA;区域大小为 KASAN_SHADOW_END - KASAN_SHADOW_START |
5. vmemmap 区域
当物理内存模型为 sparsemem 时,vmemmap 区域用于将页帧号 (PFN) 映射到其对应的虚拟页结构 (struct page)。
| 宏或变量 | 解释 |
|---|---|
| VMEMMAP_START | vmemmap 区域的起始 KVA |
| VMEMMAP_SIZE | 内核 vmemmap 区域的大小(仅在 AArch64 上定义) |
6. vmalloc 区域
vmalloc()(及相关 API)分配的内存来自内核 VAS 中的 vmalloc 区域。
| 宏或变量 | 解释 |
|---|---|
| VMALLOC_START | vmalloc 区域的起始 KVA |
| VMALLOC_END | vmalloc 区域的结束 KVA;区域大小为 VMALLOC_END - VMALLOC_START |
7. 低内存区域 (Lowmem Region)
低内存区域是直接映射的 RAM(即按 1:1 的物理页帧与内核逻辑/虚拟页的映射方式)。
| 宏或变量 | 解释 |
|---|---|
| PAGE_OFFSET | 低内存区域的起始 KVA;在某些架构中也是内核 VAS 的起始地址 |
| high_memory | 低内存区域的结束 KVA;表示直接映射内存的上界 |
8. 高内存区域 (Highmem Region)
在某些 32 位系统上,如果 RAM 的大小超过了内核虚拟地址空间的大小,则会存在高内存区域。
| 宏或变量 | 解释 |
|---|---|
| PKMAP_BASE | 高内存区域的起始 KVA,延续到 LAST_PKMAP 页数 |
9. 内核镜像区域 (Kernel Image)
未压缩的内核镜像(代码、初始化段和数据段)始终存在,但这些符号是私有的,对内核模块不可用。
| 宏或变量 | 解释 |
|---|---|
_text, _etext | 内核代码段的起始和结束 KVA |
__init_begin, __init_end | 内核初始化段的起始和结束 KVA |
_sdata, _edata | 内核静态数据段的起始和结束 KVA |
__bss_start, __bss_stop | 内核未初始化数据段的起始和结束 KVA |
10. 用户虚拟地址空间 (User VAS)
用户虚拟地址空间位于内核虚拟地址空间之下(按降序排列的虚拟地址顺序),其大小为 TASK_SIZE 字节。
| 宏或变量 | 解释 |
|---|---|
| TASK_SIZE | 用户虚拟地址空间的大小 |
接下来的工作
现在我们已经了解了描述内核 VAS 的关键宏和变量。接下来,我们将编写内核模块的代码。
模块的 init 方法调用两个主要函数:
show_kernelvas_info():打印内核 VAS 的相关信息。show_userspace_info():可选,打印用户虚拟地址空间的相关信息(通过内核参数控制,默认关闭)。
我们将从描述内核 VAS 的函数开始,查看其输出。随后,Makefile 将链接生成内核模块对象文件 show_kernel_vas.ko。
实践操作 —— 查看内核虚拟地址空间(VAS)详细信息
为了清晰起见,本节只展示代码中的相关部分。请从本书的 GitHub 仓库中克隆并使用完整代码。另外,回想一下前面提到的 procmap 工具;它有一个内核组件(LKM),功能类似于当前模块——将内核级信息提供给用户空间。由于它更复杂,我们不会深入讨论其代码;仅查看以下演示内核模块 show_kernel_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 // 在运行较旧的 4.19 内核的 BeagleBone 上 totalram_pages() 未定义
ram_size = totalram_pages * PAGE_SIZE;
#endif
pr_info("PAGE_SIZE = %lu, total RAM ~= %lu MB (%lu bytes)\n",
PAGE_SIZE, ram_size/(1024*1024), ram_size);
首先,我们通过 <linux/mm.h> 头文件中的 totalram_pages() 内联函数查询系统的物理 RAM 总量(内核 5.0 起支持)。获取 RAM 总量有助于精确计算内核 VAS 的直接映射低内存区域。我们还会验证系统是否是一个页大小为 64K、每个 VAS 地址空间为 48 位的 AArch64 系统(通过 VA_BITS 宏)。如果是,则此代码不处理该情况(详细说明请参阅本节末的注意事项)。代码继续如下:
pr_info("Some Kernel Details [by decreasing address; values are
approximate]\n"
"+-------------------------------------------------------
------+\n");
ARM 向量表的显示
#if defined(CONFIG_ARM)
/* 在 ARM 平台中,VECTORS_BASE 的定义仅在内核 >= 4.11 时可用 */
#if LINUX_VERSION_CODE > KERNEL_VERSION(4, 11, 0)
pr_info("|vector table: "
" %px - %px | [%5zu KB]\n",
SHOW_DELTA_K((void *)VECTORS_BASE, (void *)(VECTORS_BASE + PAGE_SIZE)));
#endif
#endif
上述代码片段显示了 ARM(AArch32)向量表的范围。当然,这段代码是有条件的;输出仅会在 AArch32 平台上产生,因此使用了 #if defined(CONFIG_ARM) 预处理指令。此外,使用 %px 和 %zu 格式化符确保代码具有良好的移植性。
SHOW_DELTA_* 宏
在本示例模块中,SHOW_DELTA_* 宏定义在头文件 convenient.h 中,作为工具函数,用于计算传入的低值和高值之间的差值(delta),并以可读格式显示:
// convenient.h
[...]
/* SHOW_DELTA_*(low, hi):
* 显示低值、高值及其差值(以字节/KB/MB/GB 为单位)。
* 受 Raspberry Pi 内核源码启发:arch/arm/mm/init.c:MLM()
*/
#define SHOW_DELTA_b(low, hi) (low), (hi), ((hi) - (low))
#define SHOW_DELTA_K(low, hi) (low), (hi), (((hi) - (low)) >> 10)
#define SHOW_DELTA_M(low, hi) (low), (hi), (((hi) - (low)) >> 20)
#define SHOW_DELTA_G(low, hi) (low), (hi), (((hi) - (low)) >> 30)
#define SHOW_DELTA_MG(low, hi) (low), (hi), (((hi) - (low)) >> 20), (((hi) - (low)) >> 30)
#if (BITS_PER_LONG == 64)
#define SHOW_DELTA_MGT(low, hi) (low), (hi), (((hi) - (low)) >> 20), (((hi) - (low)) >> 30), (((hi) - (low)) >> 40)
#else // 32 位系统
#define SHOW_DELTA_MGT(low, hi) (low), (hi), (((hi) - (low)) >> 20), (((hi) - (low)) >> 30)
#endif
内核模块区域的范围
// ch7/show_kernel_vas/kernel_vas.c
[...]
/* 内核模块区域
* 在典型的 64 位系统中,模块区域位于内核段的高位;
* 而在许多 32 位系统(尤其是 ARM-32)中则相反。
*/
#if (BITS_PER_LONG == 64)
pr_info("|module region: "
" %px - %px | [%9zu MB]\n",
SHOW_DELTA_M((void *)MODULES_VADDR, (void *)MODULES_END));
#endif
显示 vmalloc 和低内存区域
/* vmalloc 区域 */
pr_info("|vmalloc region: "
#if (BITS_PER_LONG == 64)
" %px - %px | [%9zu MB = %6zu GB ~= %3zu TB]\n",
SHOW_DELTA_MGT((void *)VMALLOC_START, (void *)VMALLOC_END)
#else
" %px - %px | [%5zu MB]\n",
SHOW_DELTA_M((void *)VMALLOC_START, (void *)VMALLOC_END)
#endif
);
/* 低内存区域(RAM 直接映射) */
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));
高内存区域(可选,仅适用于某些 32 位系统)
/* 可能存在的高内存区域 */
#if defined(CONFIG_HIGHMEM) && (BITS_PER_LONG==32)
pr_info("|HIGHMEM region: "
" %px - %px | [%5zu MB]\n",
SHOW_DELTA_M((void *)PKMAP_BASE, (void *)((PKMAP_BASE) +
(LAST_PKMAP * PAGE_SIZE))));
#endif
模块的构建与测试
构建模块 show_kernel_vas.ko 并在运行基于 6.1 内核的 ARM-32 Raspberry Pi Zero W 上插入。如下是设置和查看内核日志的步骤:
sudo insmod show_kernel_vas.ko
dmesg | tail
内核日志将显示内核虚拟地址空间的详细布局信息,例如模块区域、vmalloc 区域和低内存区域。
从 PAGE_OFFSET 的值(图 7.13 中的 KVA 0xc000 0000)可以解读出,我们的 Raspberry Pi 内核的虚拟内存分割 (VM split) 配置为 3:1(GB)(因为十六进制值 0xc000 0000 在十进制中等于 3 GB)。这种 VM 分割值通常是 Raspberry Pi 32 位操作系统在较新内核中的默认配置。
需要注意的是,在 3:1(GB)虚拟内存分割中,用户空间应该从 0 开始,覆盖到 3 GB,而内核空间占用剩余部分。然而,技术上来说,至少在 AArch32(ARM-32)系统上,用户空间稍低于 2 GB(2 GB – 16 MB = 2,032 MB),因为这 16 MB 用作内核模块区域(位于 PAGE_OFFSET 之下)。事实上,正如图 7.11 所示,内核模块区域的范围为 0xbf00 0000 到 0xc000 0000,共计 16 MB。此外,如您很快会看到的,TASK_SIZE 宏(用户虚拟地址空间的大小)也反映了这一事实。
我们在以下详细图示中展示了这些信息:
请注意,由于不同型号的设备、可用 RAM 的数量甚至设备树的差异,图 7.12 中显示的布局可能与您使用的 Raspberry Pi 的实际情况并不完全一致。
现在,您已经了解如何访问与虚拟地址空间(VAS)相关的内核宏和变量,这将帮助您理解任何 Linux 系统上的内核虚拟内存布局!
我们也需要提到一些关于当前简单内核模块的注意事项:
- 内核宏/常量的描述通常高度依赖于架构和内核配置;因此,这里的描述并不完整。例如,像 Kernel Memory Sanitizer (KMSAN) 这样的新功能启用时,可能会导致内存布局发生变化。
- 我们没有尝试处理在启用 LPA 的 ARMv8.2 或更高版本上的内存布局。
- 硬件 I/O 区域(如 PCI I/O、DMA)没有被跟踪或显示。
- 由于内核模式下无法进行浮点计算,有时在表示大数值时,模块的大小计算精度会有所降低。例如,值
131071.999996185 GB会显示为127 TB,而非非常接近的128 TB。 - 内核中确实提供了类似于本模块的功能——即“转储表”(dump tables),该功能可通过
CONFIG_PTDUMP_DEBUGFS配置。 - 该模块仍在持续改进中!
尽管如此,我们的模块是一个很好的起点!在接下来的部分中,我们将尝试通过 procmap 工具“查看”(可视化)内核虚拟地址空间。
通过 procmap 查看内核虚拟地址空间
有趣的是,在图 7.14 中详细显示的内存映射布局正是我们前面提到的 procmap 工具所提供的视图;我们在“procmap 进程虚拟地址空间可视化工具”部分介绍了这个工具的用法。正如那一节所承诺的,现在让我们看看运行 procmap 时的内核虚拟地址空间的截图(之前的部分展示了进程用户虚拟地址空间的截图)。
为了与当前的讨论保持一致,我们将在相同的 AArch32 Raspberry Pi Zero W 系统上展示 procmap 提供的内核虚拟地址空间的“可视化”视图(我们可以指定 --only-kernel 选项以仅显示内核虚拟地址空间,但这里我们没有这样做)。由于我们必须在某个进程上运行 procmap,我们随意选择了 systemd(PID 1);我们还使用了 --verbose 选项开关。不过,如下面的截图所示,它最初可能会失败:
它失败了,因为 procmap 内核模块构建失败了;但为什么呢?我在项目的 README.md 文件中提到过这种可能性(链接):
[…] 要在目标系统上构建内核模块,您需要确保该系统已经设置好内核开发环境;这归结为需要安装编译器、
make工具,并且——关键是——为当前运行的内核版本安装 内核头文件 包。[...]
在这种情况下,失败的原因是当前内核的头文件包不可用,因此模块构建失败(如果您在开发板上安装了自定义内核,也会发生这种情况)。虽然理论上您可以将整个 Raspberry Pi 内核源码树复制到设备上,并设置 /lib/modules/<kver>/build 符号链接,但这并不被认为是正确的做法。那么,正确的方法是什么呢?当然是从主机系统为 Raspberry Pi 交叉编译 procmap 内核模块!我们已经在第 3 章《从源码构建 6.x Linux 内核(第 2 部分)》中的“为 Raspberry Pi 构建内核”部分详细讨论了如何交叉编译内核;这些讨论同样适用于交叉编译内核模块。
我想强调以下几点:
procmap内核模块构建失败 的唯一原因是缺少 Raspberry Pi 提供的内核头文件包,尤其是在运行自定义内核时。- 如果您愿意使用默认的 Raspberry Pi OS 内核(以前称为 Raspbian OS),则内核头文件包是可以安装的(或者已经安装),这样一切就能正常工作(包名为
raspberrypi-kernel-headers)。 - 同样,在典型的 x86_64 Linux 发行版上,
procmap.ko内核模块会在运行时干净地构建并插入。
请详细阅读 procmap 项目的 README.md 文件,特别是标有“重要:在非 x86_64 系统上运行 procmap”的部分,了解如何交叉编译 procmap 内核模块。
成功交叉编译后的步骤
一旦您在主机系统上成功交叉编译了 procmap 内核模块,可以通过 scp 等方式将 procmap.ko 模块复制到设备,并将其放置在 <...>/procmap/procmap_kernel 目录下;现在,一切准备就绪!
以下是在 Raspberry Pi 设备上复制(或构建)内核模块的示例:
cd <...>/procmap/
$ ls -l procmap_kernel/procmap.ko
-rw-r--r-- 1 pi pi 9100 Jan 22 17:32 procmap_kernel/procmap.ko
您还可以运行 modinfo 实用工具来验证该模块是否针对 ARM 构建。例如,在开发板上运行默认的 Raspberry Pi OS 时,您可以执行此操作。
重新运行 procmap
现在模块已就绪,让我们重新运行 procmap,以显示内核虚拟地址空间(VAS)的详细信息!
procmap 内核模块现在确实成功构建并正常工作!由于我们为 procmap 指定了 --verbose 选项,您可以看到它的详细进度,并且——非常有用——显示了各种内核变量/宏/常量及其当前值。
好了,让我们继续捕捉屏幕并查看我们真正需要的部分——即 Raspberry Pi Zero W 上内核虚拟地址空间(VAS)的“可视化映射”,按 KVA 降序排列;以下(部分)截图捕捉了 procmap 输出的上部内容(大部分):
完整的内核虚拟地址空间(VAS)——从 end_kva(值为 0xffff ffff)一直到内核的起始地址 start_kva(0xbf00 0000,如您所见,这是该系统上内核模块区域的起始地址)——已被显示。
请注意(右侧绿色标记部分;纸质读者请参阅此处带有彩色图表的 PDF 文件:packt.link/gbp/9781803…)某些关键地址旁边的标签,标明它们的含义!为了完整起见,我们还在前面的截图中包含了内核与用户空间的边界。由于前面的输出是在 ARM 32 位系统上,用户虚拟地址空间紧跟在内核虚拟地址空间之后。
不过,如我们所学,在 64 位系统上,内核虚拟地址空间的起始地址和用户虚拟地址空间的顶部之间存在一个(巨大的!)“非规范”稀疏未使用区域——一个空洞——在 x86_64 上,它占据了整体虚拟地址空间的绝大部分:16,383.75 PB(总虚拟地址空间为 16,384 PB!再次参考图 7.5,回顾这个事实)。
在您的 Linux 系统(如 x86_64(虚拟机或本地系统)、任何 ARM(32 位或 64 位)系统,或任何您手头上的设备或虚拟机)上安装并运行 procmap 工具,仔细研究它的输出。它也可以在具有 3:1 虚拟内存分割的 BeagleBone Black 嵌入式板上良好运行,显示预期的详细信息。
供参考,我还提供了一个解决方案,其中包括三张(大尺寸的拼接截图)显示 procmap 输出的屏幕截图,分别在本地 x86_64 系统、BeagleBone Black(AArch32)板和运行 64 位操作系统(AArch64)的 Raspberry Pi 上: github.com/PacktPublis…。
研究 procmap 的源代码,特别是它的内核模块组件,肯定能帮助您学到更多。我鼓励您深入研究,甚至为其贡献代码;毕竟它是开源的!
让我们通过快速浏览一下我们之前演示的内核模块(ch7/show_kernel_vas)提供的用户段视图,来结束这一部分。
实践操作 —— 用户段
现在,让我们回到之前的 ch7/show_kernel_vas LKM 演示程序。我们提供了一个名为 show_uservas 的内核模块参数(默认值为 0);当该参数设置为 1 时,将显示一些关于进程上下文用户空间的详细信息——更准确地说,是运行该模块代码路径的用户模式进程上下文的详细信息。以下是该模块参数的定义:
static int show_uservas;
module_param(show_uservas, int, 0660);
MODULE_PARM_DESC(show_uservas,
"Show some user space VAS details; 0 = no (default), 1 = show");
好了,再次在相同的设备(Raspberry Pi Zero W)上运行我们的 show_kernel_vas 内核模块,这次请求它显示用户空间的详细信息(通过上述参数设置)。
以下截图显示了完整的输出:
这非常有用!我们现在几乎可以看到进程的完整内存映射——包括所谓的“上半部分(规范)”内核空间和“下半部分(规范)”用户空间——一次性显示出来(是的,没错,尽管 procmap 项目展示得更好并且更详细)。
作为另一个有趣的实验,让我们使用我们的 show_kernel_vas 模块来查看在运行默认 Raspberry Pi 64 位操作系统的 AArch64 Raspberry Pi 4 Model B 开发板上的整体虚拟地址空间(VAS)。
截图(图 7.19)展示了所有内容:
请注意,这种特定的内存布局几乎与我们在图 7.6 中表格的第 5 行展示的内容相匹配。
我将留给你作为练习,运行这个内核模块,并仔细研究你在 x86_64 或其他任何设备或虚拟机上的输出。请同样仔细查看代码。我们通过解引用 mm_struct 结构(task 结构体成员 mm)来打印前面截图中看到的用户空间详细信息,例如段的起始和结束地址。请记住,mm 是进程用户映射的抽象。以下是执行此操作的代码片段:
// ch7/show_kernel_vas/kernel_vas.c
[ ... ]
static void show_userspace_info(void)
{
pr_info("+------- Above this line: kernel VAS; below: user VAS --------+\n"
ELLPS
"|Process environment "
#if (BITS_PER_LONG == 64)
" %px - %px | [ %4zu bytes]\n"
"| arguments "
" %px - %px | [ %4zu bytes]\n"
"| stack start %px |\n"
[ ... ]
#else // 32-bit
" %px - %px | [ %4zu bytes]\n"
"| arguments "
" %px - %px | [ %4zu bytes]\n"
"| stack start %px |\n"
[ ... ]\n",
SHOW_DELTA_b((void *)current->mm->env_start, (void *)current->mm->env_end),
SHOW_DELTA_b((void *)current->mm->arg_start, (void *)current->mm->arg_end),
(void *)current->mm->start_stack,
[ ... ]
记得用户虚拟地址空间(VAS)最开始的所谓空陷页面吗?(此外,procmap 的输出——见图 7.11——也展示了空陷页面)。接下来我们将看看它的作用。
空陷页面
你是否注意到在前面的图示(图 7.11 和图 7.14)中,用户空间最开始的极左边缘(图 7.11 中虽然非常小)有一个名为空陷页面的单独页面?那它是什么呢?很简单:虚拟页面 0 没有任何权限(在硬件 MMU/PTE 级别)。因此,任何对这个页面的访问,无论是读取(r)、写入(w)还是执行(x),都会导致 MMU 引发所谓的处理器故障或异常。这将使处理器跳转到操作系统的处理程序例程(故障处理程序)。处理程序运行,终止试图访问没有权限的内存区域的进程!
这确实非常有趣:前面提到的操作系统处理程序在进程上下文中运行,猜猜 current 是什么:当然,它就是启动这个错误的 NULL 指针查找的进程(或线程)!(还需要注意,不仅是 NULL 或 0x0 地址会引发这个故障,任何地址从 0 到 4095 都会。) 在故障处理程序代码中,SIGSEGV 信号会被传递给故障的进程(current),导致该进程死掉(通过段错误)。简而言之,这就是操作系统如何捕获经典的 NULL 指针解引用错误的方式。
查看内核文档中的内存布局
回到内核虚拟地址空间(VAS);显然,使用 64 位虚拟地址空间时,内核 VAS 比 32 位的要大得多。正如我们之前看到的,在 x86_64 系统上通常为 128 TB。请再次查看之前展示的虚拟内存分割表(图 7.6,见“常见虚拟内存分割”部分);在表中,标记为“VM Split ...”的列显示的是不同架构的虚拟内存分割。你可以看到,在 64 位的 Intel/AMD 和 AArch64(ARM64)架构上,数字明显大于它们的 32 位对应架构。
对于架构特定的详细信息,我们建议您参考有关进程虚拟内存布局的“官方”内核文档(相关的内核头文件也非常有用):
| 架构 | 进程内存布局文档(内核源码树中的文件位置、文档链接和相关内核头文件) |
|---|---|
| AArch32 (ARM32) | 文件:Documentation/arm/memory.txt 链接:elixir.bootlin.com/linux/v6.1.… 头文件:arch/arm/include/asm/memory.h |
| AArch64 (ARM64) | 文件:Documentation/arm64/memory.txt 链接:elixir.bootlin.com/linux/v6.1.… 头文件:arch/arm64/include/asm/memory.h |
| x86_64 | 文件:Documentation/x86/x86_64/mm.txt 链接:此文档的可读性最近得到了极大改善;我建议您浏览该文件 头文件:arch/x86/include/asm/pgtable_64_types.h |
| x86_32 | 文件:arch/x86/include/asm/page_32_types.h |
表 7.11:针对 Linux 中进程内存布局的架构(CPU)特定官方内核文档
虽然有些重复,我仍然鼓励你在不同的 Linux 系统上尝试 ch7/show_kernel_vas 内核模块——更好的是,尝试 procmap 项目(github.com/kaiwan/proc…)——并研究输出。你将能够真实地看到任何给定进程的“内存映射”——完整的进程 VAS,其中包括内核 VAS!理解这一点在系统层面进行工作和/或调试问题时至关重要。
再次强调,前面两节——详细检查用户和内核 VAS——确实非常重要。请花时间仔细阅读它们,并练习样例代码和建议的练习。做得很好!
继续我们在 Linux 内核内存管理子系统的旅程,现在让我们探讨另一个有趣的话题——通过内存布局随机化保护的 [K]ASLR 特性。继续阅读!
随机化内存布局——KASLR
在信息安全领域,一个众所周知的事实是:利用 proc 文件系统(procfs)和各种强大的“黑客”工具(听说过 Kali Linux 吗?),一个恶意用户如果提前知道进程 VAS 中各个函数和/或全局变量的精确位置(虚拟地址),可能会设计攻击来利用这些信息,从而最终攻破系统。(即使仅仅知道某个知名函数或全局变量在给定内核中的精确位置,也可能导致攻击向量的出现!)因此,为了安全起见,防止攻击者依赖“已知”的虚拟地址,用户空间和内核空间都支持地址空间布局随机化(ASLR)和内核 ASLR(KASLR)技术(通常发音为 Ass-ler/Kass-ler)。
这里的关键字是随机化:启用该特性后,它会改变进程(以及内核)内存布局的部分位置,具体而言,它会将内存的一部分从给定的基地址按随机(页面对齐)量偏移。
我们所说的“内存部分”到底指的是哪些部分?关于用户空间映射(稍后我们会谈到 KASLR),包括共享库的起始地址(它们的加载地址)、基于 mmap() 的分配(你会发现,任何在 MMAP_THRESHOLD(通常是 128 KB)以上分配内存的 malloc() 函数(/calloc()/realloc())都会变成基于 mmap 的分配,而不是堆内存)、栈起始地址、堆和 vDSO 页面;所有这些都可以在进程启动时随机化。
因此,攻击者不能依赖于某个 glibc 函数(例如 system())在给定进程中的特定固定 UVA(用户虚拟地址)位置(如以前那样!);不仅如此,每次进程运行时,位置都会发生变化!在启用 ASLR 之前,且在不支持 ASLR 或关闭 ASLR 的系统上,可以提前确定符号的位置,这对于特定架构和软件版本来说是已知的(procfs 以及像 objdump、readelf、nm 等工具使得这一点非常容易)。
需要认识到的是,[K]ASLR 仅仅是一种统计保护。事实上,通常可用来随机化的位数非常有限,因此熵值并不好。这意味着即使在 64 位系统上,页面大小的偏移量也不会很多,这可能导致实现被削弱(这对经验丰富的黑客来说是一个好消息)。
接下来,让我们简要了解用户模式和内核模式 ASLR(后者被称为 KASLR)的一些更多细节;以下部分将分别介绍这些内容。
用户内存随机化与 ASLR
用户模式的 ASLR 通常就是所说的 ASLR。启用 ASLR 表示每个进程的用户空间映射都将启用此保护。实际上,启用 ASLR 意味着用户模式进程的绝对内存映射在每次运行时都会变化,而且同一程序的每个进程实例在绝对用户空间内存映射上都会有所不同。
ASLR 已在 Linux 上支持了很长时间(自 2.6.12 版本的 2005 年起)。内核在 procfs 中有一个可调的伪文件,用于查询和设置(作为 root 用户)ASLR 状态;它位于 /proc/sys/kernel/randomize_va_space。
它有三个可能的值;这三个值及其含义如下表所示:
| 可调值 | /proc/sys/kernel/randomize_va_space 中此值的含义 |
|---|---|
| 0 | (用户模式)ASLR 关闭,或者可以通过启动时传递内核参数 norandmaps 关闭。 |
| 1 | (用户模式)ASLR 开启:mmap() 基于的分配、栈和 vDSO 页面被随机化。这也意味着共享库的加载位置和共享内存段会被随机化。 |
| 2 | (用户模式)ASLR 开启:所有前述的(值 1)加上堆位置被随机化(自 2.6.25 起);这是操作系统的默认值。 |
表 7.12:通过 proc 伪文件调节 Linux(用户模式)ASLR
如前节所述,vDSO 页面是一种系统调用优化,允许一些频繁发出的系统调用(例如 gettimeofday())以更少的开销调用。如果感兴趣,您可以在 vDSO(7) 的手册页中查找更多详细信息:vDSO 手册页。
用户模式的 ASLR 可以通过在启动时传递 norandmaps 参数给内核来关闭(通过引导加载程序);为什么要这样做呢?有时在调试时这么做很有用……但是在生产环境中请保持开启!
内核内存布局随机化与 KASLR
与(用户)ASLR 类似——并且从 3.14 内核版本开始——即使是内核虚拟地址空间(VAS)也可以通过启用 KASLR(内核地址空间布局随机化)进行随机化。这里,一些内核部分(如低内存、vmalloc 和 vmemmap 区域),以及内核 VAS 中的模块代码,会通过从 RAM 基地址偏移的页面对齐随机偏移进行随机化。这种随机化将在会话期间保持有效——也就是说,直到系统重启或断电。
KASLR 的内核配置项名为 CONFIG_RANDOMIZE_MEMORY。KASLR 似乎在 x86[_64] 和 AArch64 平台上受支持,而在 AArch32 上不受支持。
存在多个内核配置变量,允许平台开发者启用或禁用这些随机化选项。例如,特定于 x86 的配置,以下内容直接摘自 Documentation/x86/x86_64/mm.txt(链接):
请注意,如果启用
CONFIG_RANDOMIZE_MEMORY,则所有物理内存的直接映射、vmalloc/ioremap空间和虚拟内存映射都会被随机化。它们的顺序保持不变,但基地址将在启动时提前偏移。
配置完成后,KASLR 默认保持开启状态;可以通过引导加载程序传递内核命令行参数来控制其状态:
- 通过传递
nokaslr参数显式关闭 KASLR - 通过传递
kaslr参数显式开启 KASLR
从 5.13 内核开始,提供了一项新安全功能:CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT。启用它会使内核模式栈偏移量在每次发出系统调用时都进行随机化!(请查看编写非常精良的提交 #39218ff4c625dbf2)。
**提示:**你可以在 GitHub 搜索框中搜索给定(简写的)提交号。
那么,您当前 Linux 系统上的 [K]ASLR 设置是什么?我们可以更改它吗?当然可以(前提是我们有 root 权限);下一节将向您展示如何通过 Bash 脚本来实现这一点。
使用脚本查询/设置 KASLR 状态
我们提供了一个简单的 Bash 脚本,位于 <book-source>/ch7/ASLR_check.sh。它检查(用户模式)ASLR 和 KASLR 是否启用,并打印它们的状态信息(带有颜色编码)。它还允许您更改 ASLR 值。
让我们在我们的 x86_64 Ubuntu 22.04 客户机上运行它。由于我们的脚本是按颜色编码的,以下是其输出的截图:
它运行时会显示(至少在这台设备上)用户模式 ASLR 和 KASLR 配置确实都已开启。不仅如此,我们还编写了一个小的“测试”例程来查看 ASLR 是否正常工作。它非常简单;它运行以下命令两次:
grep -E "heap|stack" /proc/self/maps
从您在之前“解读 /proc/PID/maps 输出”部分学到的知识,您现在可以在图 7.20 中看到,每次运行时堆和栈段的相应 UVA 都是不同的,从而证明 ASLR 功能确实有效!例如,看看堆的起始 UVA:在第一次运行时,它是 0x5638bad94000,而在第二次运行时,它是 0x55b578f67000(图 7.21 的底部部分)。
接下来,让我们将参数 0 传递给脚本,从而关闭 ASLR;以下截图显示了(预期的)输出:
这次,我们可以看到 ASLR 默认是开启的,但我们将其关闭了。在前面的截图中,红色粗体字清晰地突出显示了“ => (用户模式) ASLR 当前关闭”这一行。(记得重新开启它。)此外,正如预期的那样,既然 ASLR 现在关闭,堆和栈的 UVA(分别)在两次测试运行中保持不变,这样是不安全的。我将留给您浏览并理解脚本源代码的部分。
**注意:**要利用 ASLR,应用程序必须使用 -fPIE 和 -pie GCC 标志进行编译(PIE 代表位置独立可执行文件)。
ASLR 和 KASLR 都能防范一些类型的攻击向量,其中“返回到 libc”攻击和“面向返回的编程(ROP)”是典型的攻击案例。然而,不幸的是,由于白帽子和黑帽子的安全攻防战就像猫捉老鼠一样,击败 [K]ASLR 和类似的方法是高级漏洞利用技巧非常擅长的事情。例如,著名的 setarch 工具有 --addr-no-randomize 选项,猜猜它的作用是什么——关闭 ASLR。更多详细信息请参阅本章的“进一步阅读”部分(在 Linux 内核安全标题下)。
在讨论安全性时,作为附加价值提示,很多有用的工具可以用来执行系统漏洞检查。请查看以下内容:
checksec.sh脚本(链接)显示各种“硬化”措施及其当前状态(适用于单个文件和进程):RELRO、栈金丝雀、启用 NX、PIE、RPATH、RUNPATH、符号的存在以及编译器强化等。grsecurity的 PaX 套件。hardening-check脚本(checksec的替代方案)。kconfig-hardened-checkPerl 脚本(GitHub 链接)检查(并建议)内核配置选项,针对一些预定义的检查列表提供安全性检查。- 其他几个工具:Lynis、linuxprivchecker.py、memory 等。
所以,下次您在多次运行或会话中看到不同的内核或用户虚拟地址时,您就会知道这可能是由于 [K]ASLR 保护功能导致的(在调试时关闭 [K]ASLR 是相当常见的)。现在,让我们完成本章,继续探索 Linux 内核如何组织和处理物理内存。
理解物理内存的组织
现在我们已经相当详细地研究了用户和内核虚拟地址空间(VAS)的虚拟内存视图,接下来我们将转向讨论 Linux 操作系统中的物理内存组织。
物理内存组织
Linux 内核在启动时,会将物理 RAM 组织和划分为类似树状的层级结构,包括节点、区域和页面帧(页面帧是物理内存页)(见图 7.22 和图 7.23)。请注意,物理内存模型的进一步组织也在启动初期进行,这是相关的讨论;我们将在《物理内存模型简介》一节中进一步探讨这个话题。
节点被划分为区域,而区域则由页面帧组成。它本质上是一个树状的层级结构,简化和概念化地表示为三层树形结构,如下所示:
- 节点 ← 层级 1
- 区域 ← 层级 2
- 页面帧 ← 层级 3
节点是一个元数据结构,它抽象了一个物理内存池;该内存池本身与一个或多个处理器(CPU)核心关联。在硬件层面,微处理器与内存控制芯片连接;任何内存控制芯片,以及任何 RAM,都可以通过互连从任何 CPU 核心访问。显然,能够访问物理上最靠近正在分配或使用内存的线程所在核心的 RAM,将有助于提升性能。这个概念被支持所谓 NUMA 模型的硬件和操作系统利用(NUMA 的含义稍后解释)。
节点与 NUMA
本质上,节点是用于表示和抽象化系统主板上的物理 RAM 模块及其关联控制芯片的数据结构。是的,我们在这里讨论的实际硬件是通过软件元数据进行抽象的。(请注意,在这个上下文中使用的“节点”一词,与表示网络上单个硬件计算机时的“节点”有所不同。)节点总是与系统主板上的物理插槽(或多个处理器核心集合)相关联。存在两种层次结构:
- 非统一内存访问(NUMA)系统:在这种系统中,特定 CPU 核心上发生的内核分配请求是有意义的(内存被非均匀处理),从而提升性能。
- 统一内存访问(UMA)系统:在这种系统中,特定 CPU 核心上发生的内核分配请求没有区别(内存被均匀处理)。
真正的 NUMA 系统是那些硬件始终为多核的系统——意味着有两个或更多 CPU 核心,因此它们总是对称多处理(SMP)系统——并且必须有两个或更多物理内存池,每个内存池都与一个 CPU(或多个 CPU)相关联。换句话说,NUMA 系统总是会有两个或更多节点,而 UMA 系统只有一个节点(顺便提一下,抽象节点的结构体叫做 pg_data_t,它在这里定义:include/linux/mmzone.h:pg_data_t)。
你可能会问,为什么会有这么复杂的结构?嗯,原因就是——还能是什么呢——性能!NUMA 系统(它们通常是昂贵的服务器级机器和超级计算机)及其运行的操作系统(通常是 Linux/Unix/Windows Server)被设计成,当某个特定 CPU 核心上的进程(或线程)要执行内核内存分配时,软件会通过从最靠近该核心的节点获取所需内存(RAM)来确保高性能(因此得名 NUMA)。而 UMA 系统(典型的嵌入式系统、智能手机、笔记本电脑和台式机)则没有这样的性能提升,也不需要考虑这些。如今,企业级数据中心服务器和超级计算机系统通常有数百个处理器和数 TB,甚至数 PB 的 RAM,具有多个节点。几乎所有这些系统都被架构为 NUMA 系统。
然而,考虑到 Linux 的设计——这是一个关键点——即使是常规的 UMA 系统,内核也将它们视为 NUMA 系统(伪 NUMA)。这是为了避免不必要地更改代码库,从而避免分叉 Linux(正如你所知道的,完全相同的 Linux 内核代码库用于支持各种类型的 Linux 系统,从小型嵌入式设备到超级计算机)。它们——UMA 系统——只有一个节点,因此,检查系统是 NUMA 还是 UMA 的快速方法就是:如果有两个或更多节点,并且有多个 CPU 核心,那么它是一个真正的 NUMA 系统;如果只有一个节点和/或只有一个 CPU 核心,那么它就是一个“伪 NUMA”或伪 NUMA 系统。你可以通过 numactl 工具来检查节点数量(尝试运行 numactl --hardware)。还有其他方法可以检查(通过 procfs 本身);稍后我们会介绍……(顺便提一下,检查操作系统看到的 CPU 核心数量很容易;你可以使用 nproc、lscpu 和/或 cat /proc/cpuinfo)。
简而言之,如何更直观地理解这个问题:在 NUMA 系统中,一个或多个 CPU 核心与一个物理 RAM 池(硬件模块)相关联;这个池称为一个节点,MUMA 系统总是会有两个或更多节点。因此,NUMA 系统总是 SMP 系统,但 SMP 系统可能是 NUMA 或 UMA 系统。
NUMA 服务器处理器示例
为了让这个讨论更具实用性,让我们简要地观察一个实际服务器系统的微架构——一个运行 AMD Epyc/Ryzen/Threadripper(以及较早的 Bulldozer)CPU 的系统。它包含以下硬件(见图 7.22):
- 总共有 32 个 CPU 核心(操作系统看到的),分布在主板上的两个物理插槽(P#0 和 P#1)。每个插槽由 8x2 个 CPU 核心组成(8x2,因为每个物理核心都有超线程,操作系统将每个超线程核心视为一个可用核心,因此每个插槽有 16 个 CPU 核心)。
- 总共有 32 GB 的 RAM,分为四个 8 GB 的物理内存池。
显然,这个系统是多核(SMP)并且有两个或更多内存池,因此符合真正的 NUMA 系统要求。因此,当 Linux NUMA 感知的内存管理代码在启动时检测到这种拓扑时,它会设置四个 NUMA 节点来表示它。我们不会在这里深入探讨处理器的各级缓存(L1/L2/L3 等);见下方图示后面的提示框,以了解如何查看所有这些内容。此外,像这样的系统被称为缓存一致 NUMA(ccNUMA),因为它们在硬件的帮助下保持缓存一致性;你将在第 13 章《内核同步 – 第 2 部分》中的《理解 CPU 缓存基础、缓存效应与伪共享》部分了解更多关于缓存和缓存一致性的内容。
以下概念图展示了某些 AMD 服务器系统上运行 Linux 操作系统时,形成的四个树状层级的近似结构——每个节点一个(见图 7.22):
提示: 使用强大的 lstopo 工具(列出拓扑,并配合相关的 hwloc-*(硬件位置)工具)可以图形化查看系统的硬件(CPU)拓扑!在 Ubuntu 上,使用以下命令安装它:sudo apt install hwloc。顺便提一下,之前提到的 AMD 服务器系统的硬件拓扑图可以通过 lstopo 生成,您可以在这里查看:CPU 缓存图示。
关键点重申
为了性能(在此,参考图 7.22),假设一个在进程上下文中运行内核或驱动代码的线程(比如 CPU #18 上的线程)请求内核分配一些 RAM。内核的内存管理(MM)层是 NUMA 感知的,因此会优先从 NUMA 节点 #2 中的任何区域内的空闲内存页面帧中提供服务(即从物理内存池 #2 中提供),因为这个节点离请求所在的处理器核心最近。如果在 NUMA 节点 #2 中的任何区域内没有可用的空闲页面帧(这不太可能),内核会启动智能回退机制。它可能会通过互连请求来自另一个节点:区域的 RAM 页面帧(别担心,我们会在下一章《内核内存分配 – 模块作者篇》第 1 部分的《理解和使用内核页面分配器(或 BSA)》章节中更详细地讨论这些方面)。
节点内的区域
区域可以看作是 Linux 处理硬件问题的一种方式;这些问题通常出现在 x86 架构上,因为 Linux 当然是在这个架构上“成长起来的”。它们还解决了一些软件上的困难(例如,查看现在大多数为遗留架构的 32 位 x86 中的 ZONE_HIGHMEM;我们在之前的部分《32 位系统中的高内存》讨论过这个概念)。
区域是层级结构的第二级;它们总是属于某个特定的节点(0、1、2,……),并由页面帧组成——即物理内存页。从技术上讲,每个节点中的每个区域都会分配一个页面帧编号(PFN)的范围:
(插曲:关于 Linux 如何跟踪页面帧编号(PFN)的更多内容可以在《物理内存模型简介》一节中找到。)
图 7.23 展示了一个概念性的通用 Linux 系统,具有 N 个节点(编号从 0 到 N-1),每个节点由例如三个区域组成,每个区域由物理内存页——页面帧组成。每个节点的区域数量(和名称)在启动时由内核动态确定。您可以通过进入 procfs 来查看 Linux 系统上的物理内存层级。在以下代码中,我们查看一个具有 16 GB RAM 的本地 x86_64 系统:
$ 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 系统上,尽管 Linux 操作系统将其视为(伪/虚假的)NUMA 系统。如图所示,这个单一的节点 Node 0 被划分为三个区域,分别标记为 DMA、DMA32 和 Normal;每个区域当然由页面帧组成。现在,忽略右边的数字;我们将在下一章中讨论它们的含义。
另一种观察 Linux 如何在 UMA 系统上“伪造” NUMA 节点的方法是通过内核日志。我们在同一个具有 16 GB RAM 的本地 x86_64 系统上运行以下命令。为了可读性,我将前几列显示的时间戳和主机名用省略号替换:
$ journalctl -b -k --no-pager | grep -A7 "NUMA"
<...>: No NUMA configuration found
<...>: Faking a node at [mem 0x0000000000000000-0x00000004427fffff]
<...>: NODE_DATA(0) allocated [mem 0x4427d5000-0x4427fffff]
<...>: Zone ranges:
<...>: DMA [mem 0x0000000000001000-0x0000000000ffffff]
<...>: DMA32 [mem 0x0000000001000000-0x00000000ffffffff]
<...>: Normal [mem 0x0000000100000000-0x00000004427fffff]
<...>: Device empty
$
我们可以清楚地看到,由于系统被检测为非 NUMA(因此是 UMA),内核伪造了一个 NUMA 节点。该节点的范围是系统上 RAM 的总量(在此是 0x0-0x00000004427fffff,确实是 16 GB)。我们还可以看到,在这个特定的系统上,内核实例化了三个区域——DMA、DMA32 和 Normal——来组织可用的物理页面帧 RAM。这一切都很正常,并且与我们之前看到的 /proc/buddyinfo 输出一致。表示 Linux 中区域的结构体定义在这里:include/linux/mmzone.h:struct zone。我们将在书中的后续章节中再次接触到它。
现在,为了更好地理解 Linux 内核如何组织 RAM,让我们从一开始——启动时间开始。
直接映射 RAM 和地址转换
在启动时,Linux 内核将所有(可用的)系统 RAM(即平台 RAM)直接映射到内核虚拟地址空间(VAS)中(我们在《检查内核 VAS》部分中学习过这一点;如果需要,您可以再次查看图 7.12 和图 7.14)。因此,我们有如下映射关系:
- 物理页面帧 0 映射到内核虚拟页面 0。
- 物理页面帧 1 映射到内核虚拟页面 1。
- 物理页面帧 2 映射到内核虚拟页面 2,以此类推。
因此,我们称之为 1:1 映射或直接映射,身份映射 RAM 或线性地址。一个关键点是,这些内核虚拟页面与其物理页面之间有一个固定的偏移(正如之前提到的,这些内核地址通常被称为内核逻辑地址)。这个固定偏移值就是 PAGE_OFFSET(在这里假设它的值是 0xc0000000)。
所以,想象一下。在一个 32 位系统中,具有 3:1(GB)虚拟内存分割,物理地址 0x0 等于内核逻辑地址 0xc0000000(即 PAGE_OFFSET)。如前所述,术语“内核逻辑地址”被应用于与其物理对应地址有固定偏移的内核地址。因此,直接映射的 RAM 映射到内核逻辑地址。这个直接映射内存区域通常被称为内核 VAS 中的低内存(或简称 lowmem)区域。
我们之前已经展示了几乎完全相同的图,见图 7.12。在接下来的图中,为了强调刚才提到的要点,它稍作修改,实际上展示了内存的前三个(物理)页面帧如何映射到内核虚拟地址空间中的前三个内核虚拟页面(在 lowmem 区域中):
作为示例,图7.24展示了在一个32位系统上,平台RAM直接映射到内核虚拟地址空间(VAS),其中虚拟内存(VM)分割为3:1(GB)。物理RAM地址0x0映射到内核的点是PAGE_OFFSET内核宏(在前面的图中,这是内核逻辑地址0xc0000000)。
请注意,图7.24还展示了用户虚拟地址空间(VAS)位于左侧,从0x0到PAGE_OFFSET-1(大小为TASK_SIZE字节)。我们在前面的《检查内核虚拟地址空间》部分已经讨论了其余内核虚拟地址空间的细节。
理解物理到虚拟页面的这种映射可能会让你得出看似合乎逻辑的结论:
给定一个内核虚拟地址(KVA),要计算相应的物理地址(PA),即进行KVA到PA的转换,只需执行以下操作:
pa = kva - PAGE_OFFSET
相反,给定一个物理地址(PA),要计算相应的内核虚拟地址(KVA),即进行PA到KVA的转换,只需执行以下操作:
kva = pa + PAGE_OFFSET
再次参考图7.24,RAM到内核虚拟地址空间的直接映射(从PAGE_OFFSET开始)确实支持这一结论。因此,这是正确的。但请注意,请仔细留意:这些地址转换计算仅适用于内核低内存区域内的直接映射或线性地址——换句话说,适用于内核虚拟地址(技术上讲,是内核逻辑地址)——并不适用于其他任何地方!对于所有用户虚拟地址(UVA)以及低内存区域以外的所有内核虚拟地址(包括模块地址、vmalloc/ioremap(MMIO)地址、KASAN地址、可能的高内存区域地址、DMA内存区域等),该方法不适用!这个事实也通过对系统物理RAM不是总是连续或线性的认识得到进一步强调;它可能有空洞!这个话题将在接下来的《物理内存模型介绍》部分中进一步讨论。
正如你预期的那样,内核确实提供了API来执行这些地址转换;当然,它们的实现是与架构相关的。以下是这些API:
| 内核API | 功能 |
|---|---|
phys_addr_t virt_to_phys(volatile void *address) | 将给定的虚拟地址转换为其物理对应地址(返回值) |
void *phys_to_virt(phys_addr_t address) | 将给定的物理地址转换为虚拟地址(返回值) |
| 表7.13:在低内存区域内,物理地址与内核虚拟地址之间的转换(反之亦然) | |
x86的virt_to_phys() API上方有一个注释,明确表示这个API(以及类似的API)不应被驱动程序开发人员使用;为清晰起见,我们在内核源代码中复制了这个注释(elixir.bootlin.com/linux/v6.1.… |
// arch/x86/include/asm/io.h
/**
* virt_to_phys - 将虚拟地址映射到物理地址
* @address: 要映射的地址
*
* 返回的物理地址是给定内存地址的物理(CPU)映射。
* 仅在直接映射或通过kmalloc分配的地址上使用此函数是有效的。
*
* 此函数不提供DMA传输的总线映射。在几乎所有情况下,设备驱动程序都不应使用此函数。
*/
static inline phys_addr_t virt_to_phys(volatile void *address)
前述注释提到了(非常常见的)kmalloc() API。请放心,它将在接下来的两章中详细介绍。当然,phys_to_virt() API也有类似的注释。
那么,谁在有限的情况下使用这些地址转换API(以及类似的)呢?当然是内核内部的内存管理代码!作为演示,我们确实在本书中的至少几个地方使用了它们:在接下来的章节中,在名为ch8/lowlevel_mem的内核模块(实际上,它的使用位于我们的“内核库”代码中的一个函数klib.c)中。顺便提一句,强大的崩溃工具确实可以通过其vtop(虚拟到物理)命令将任何给定的虚拟地址转换为物理地址(反之亦然,通过其ptov命令!)。
继续前进,另一个关键点是:通过将所有物理RAM映射到自身,请不要误以为内核为自己保留了RAM。事实并非如此;它只是将所有可用的RAM映射到内核空间,从而使任何需要它的对象都可以分配——核心内核代码、内核线程、设备驱动程序或用户空间应用程序。这是操作系统的工作;毕竟它是系统资源的管理者。
当然,内核会在启动时占用(分配)一定量的RAM——例如静态内核代码、数据、内核页表等。但你应当意识到,内核本身直接使用的RAM量通常很小。例如,在我有1GB内存的虚拟机上,内核代码、数据和BSS通常总共只占用约25MB的RAM。所有内核内存约占100MB,而用户空间的内存使用量约为550MB!几乎总是用户空间是内存的“大户”。
提示:尝试使用带有--system -p选项的smem工具,查看按百分比显示的内存使用情况(此外,使用--realmem=选项传递系统的实际内存量)。
回到这个问题:我们知道内核页表是在启动过程的早期设置的。因此,在应用程序启动之前,内核已经将所有RAM映射并准备好分配!因此,我们理解,虽然内核直接将页帧映射到其虚拟地址空间,但用户模式进程并不那么幸运——它们只能通过操作系统在每个进程创建时(即fork())设置的分页表间接映射页帧。同样,值得注意的是,通过强大的mmap()系统调用,内存映射可以提供“直接映射”文件或匿名页面到用户虚拟地址空间的假象(背后,所有的都是页表操作)。
一些额外需要注意的点:
- 为了提高性能,分配的内核内存页面即使未被使用,也不能被交换。
- 有时,你可能认为,用户空间的内存页面通过操作系统为每个进程设置的分页表(假设页面驻留在内存中)映射到(物理)页帧是显而易见的。
是的,但内核内存页面呢?请明确这一点:所有内核页面也通过内核“主”分页表(命名为swapper_pg_dir)映射到页帧。内核内存同样是虚拟化的,就像用户空间内存一样。
在这方面,对于有兴趣的读者,可以查看我在Stack Overflow上发起的一个问答:内核虚拟地址如何转换为物理RAM? ,网址是stackoverflow.com/questions/3…
Linux内核中已内置了几种内存优化技术(其中许多是配置选项);其中包括透明大页面(THP)和针对云/虚拟化工作负载至关重要的内核同页合并(KSM,即内存去重)。有关更多信息,请参考本章的《进一步阅读》部分。
现在我们已经涵盖了物理内存层次结构的前两级(节点和区域),接下来让我们深入探讨最后一个级别:页帧的组织!
物理内存模型介绍
无论如何划分,物理内存都是一种宝贵的资源。加之现代系统的内存组织可能是复杂的层次结构,内存空间中常常存在大的空洞(或稀疏区域),这一点在前面的章节中已经略有提及。更重要的是,每个服务器类型系统中的NUMA节点都需要一套自己的内存管理元数据。此外,硬件要求也变得更加苛刻——例如,支持热插拔(和拆卸)内存条(和CPU)、在某些类型的持久存储设备中设置页级映射的需求等等。因此,Linux内核社区设计了抽象层次,以更好地模拟并管理物理内存。这些抽象层次表现为内存模型;事实上,至今已经提出并实现了三种模型:flatmem、discontigmem 和 sparsemem。现实中,sparsemem模型得到了广泛部署,尤其是在现代的64位系统中,内存较大的情况下;而discontigmem模型已被弃用,flatmem模型则依然存在,主要用于服务于小内存的32位系统。
在所有这些模型中,基本的概念是能够跟踪系统中每一个物理内存页。跟踪这些页面的元数据结构是struct page(elixir.bootlin.com/linux/v6.1.…);它通常被称为页面描述符。(要跟踪每一个内存页?是的,正因为如此,struct page非常小,只有64字节;然而,这也确实会消耗内存,尤其是在内存量较大的情况下。)struct page内包含关于它跟踪的页面的元数据——包括页面的使用情况(或是否当前为空闲状态)以及各种标志值,包括映射。
回到内存模型的问题。Linux要求每个架构都使用一种模型来管理其内存——要么是flatmem模型,要么是sparsemem模型。架构特定的代码在启动早期设置此模型(例如,sparse_init()函数设置了sparsemem模型)。以下是所有内存模型的一些共同特征:
- 由于物理RAM通常是连续的页框,可能会被空洞打断,因此模型通常在单位内实现一个或多个
struct page数组;sparsemem将单位称为“节”。 - 每个物理页框都由一个页面框架编号(PFN)表示,实际上,它是一个
struct page对象数组的索引。PFN和struct page之间始终存在1:1的映射关系。 - 这种映射关系需要帮助函数来相互转换:每种模型通常会最小化地定义
page_to_pfn()和pfn_to_page()这两个帮助函数。
接下来,让我们进一步探讨sparsemem内存模型。
简要了解 sparsemem[-vmemmap] 内存模型
sparsemem模型实际上是最为通用的,也是实践中最常使用的。它能够抽象和支持现代系统所需的特性,如内存条的热插拔和移除、持久设备内存映射、内存的延迟初始化等。
该模型可以通过两种方式实现必需的辅助API(page_to_pfn()和pfn_to_page()):一种是“经典的sparse”方法,另一种是“sparse vmemmap”方法。当使用后者时(即常见方法),一个vmemmap指针指向一组struct page对象的数组的基地址(更准确地说,它设置为(struct page *)VMEMMAP_START)。为了更直观地理解这一点,建议查阅以下文章和图表:Reducing page structures for huge pages,Jon Corbet,LWN,2020年12月,链接:lwn.net/Articles/83…。这样你将更好地理解vmemmap区域——即前面《描述内核虚拟地址空间布局的宏和变量》一节中提到的从此指针起源的元数据。
由于sparsemem-vmemmap模型是现代64位Linux系统的首选模型,你会经常看到在这些系统上设置了内核配置项CONFIG_SPARSEMEM_VMEMMAP=y和CONFIG_SPARSEMEM_VMEMMAP_ENABLE=y(现代x86_64系统总是使用sparsemem模型,而AArch64平台似乎也遵循这一做法。顺便提一下,常见的Android通用内核镜像(GKI)内核对于AArch64也默认启用了这些配置选项,表明它也使用这一模型)。
sparsemem模型需要定义两个宏:
SECTION_SIZE_BITS:表示一个(物理连续的)节所能覆盖的最大内存量的物理地址位数;每个内存节的大小是2的这个幂次方。MAX_PHYSMEM_BITS:物理地址中的最大位数;实际上是支持的最大RAM量。这个值与AArch64架构上内核可配置的CONFIG_ARM64_PA_BITS相同。
这些宏通常在arch/<arch>/include/asm/sparsemem.h头文件中定义。下面是一个小模块,它不仅揭示了这些数值,还提供了一些关于sparsemem模型的更多信息:ch7/sparsemem_show。核心代码如下:
// ch7/sparsemem_show/sparsemem_show.c
#include <linux/mmzone.h>
[ ... ]
#ifdef CONFIG_SPARSEMEM_VMEMMAP
pr_info("VMEMMAP_START = 0x%016lx\n"
"SECTION_SIZE_BITS = %u, so size of each section=2^%u = %u bytes = %u MB\n"
"max # of sections=%lu\n"
"MAX_PHYSMEM_BITS=%u; so max supported physical addr space (RAM): %lu GB = %lu TB\n",
VMEMMAP_START,
SECTION_SIZE_BITS, SECTION_SIZE_BITS, (1 << SECTION_SIZE_BITS),
(1 << SECTION_SIZE_BITS) >> 20, NR_MEM_SECTIONS, MAX_PHYSMEM_BITS,
(1UL << MAX_PHYSMEM_BITS) >> 30, (1UL << MAX_PHYSMEM_BITS) >> 40);
#else
pr_info("SPARSEMEM_VMEMMAP not supported\n");
#endif
通过此模块在不同的64位平台上获取的与sparsemem相关的宏值的汇总表如下:
| 属性 / 平台 | AArch64: stock Raspberry Pi OS | AArch64: custom Yocto build (Poky, 4.0.4), with CONFIG_ARM64_VA_BITS=52 | x86_64 VM 或原生 Ubuntu 22.04 LTS |
|---|---|---|---|
SECTION_SIZE_BITS | 27 | 29 | 27 |
每个sparsemem节的大小:2^ SECTION_SIZE_BITS | 128 MB | 512 MB | 128 MB |
MAX_PHYSMEM_BITS | 48 | 52 | 46 |
最大物理地址空间(最大RAM):2^ MAX_PHYSMEM_BITS | 256 TB | 4,096 TB = 4 PB | 64 TB |
表7.14:不同架构(平台)在运行64位Linux时,关于一些sparsemem宏的值汇总
请注意,这里的第三列与图7.6中的第7行(最后一个AArch64行)相匹配。由于32位平台根本不支持sparsemem[-vmemmap]模型(它们采用更简单的flatmem模型),因此未列出32位平台的条目。欲了解更多相关内容,请参考《进一步阅读》文档中的链接。
好了,在涵盖了一些物理RAM管理的方面后,我们也完成了这一章节的内容。做得很好,进展优秀!
总结
在本章中,我们深入探讨了内核内存管理这一重要主题,讲解的细节足以满足内核模块或设备驱动程序开发者的需求;而且,这只是开始!我们从一个关键部分——虚拟内存(VM)划分及其在运行Linux操作系统的不同架构上的实现——作为切入点,展开讨论。
接着,我们深入研究了这一划分的两个区域:首先是用户空间(用户模式进程的虚拟地址空间VAS),然后是内核虚拟地址空间(或内核段)。在这里,我们讲解了如何检查内核和用户空间的详细信息,包括通过强大的procmap工具。我们还编写了一个演示内核模块,能够生成一个非常完整的内核及调用进程的内存映射。我们简要讨论了用户和内核内存布局随机化技术([K]ASLR)。最后,我们介绍了Linux操作系统中RAM的物理组织结构,并讲解了使用的内存模型,尤其是sparsemem模型。
本章中学到的信息和概念非常有用,不仅有助于设计和编写更好的内核/设备驱动代码,还能帮助你在遇到系统级问题和漏洞时更好地调试复杂场景。
本章内容较长,且非常关键;你完成得非常好!接下来,在接下来的两章中,你将继续学习如何有效地分配(以及当然是释放)内核内存,以及这一常见活动背后的重要概念。继续前进!
问题
在我们总结时,这里有一系列问题供你测试自己对本章内容的掌握: github.com/PacktPublis…。你可以在本书的GitHub仓库中找到部分问题的答案:github.com/PacktPublis…。
进一步阅读
为了帮助你深入了解这一主题,我们提供了一份详细的在线参考资料和链接(有时甚至包括书籍)列表,见本书GitHub仓库中的《进一步阅读》文档。你可以在此查看该文档:github.com/PacktPublis…。