Linux内核编程——内核内存分配给模块作者(上)

382 阅读1小时+

在前两章中,一章介绍了内核的内部方面和架构,另一章讲解了内存管理内部的基础知识,我们涵盖了为本章及下一章提供所需背景信息的关键内容。在本章及接下来的章节中,我们将深入探讨如何通过各种方式分配和释放内核内存。我们将通过内核模块进行演示,您可以测试和调整这些模块,详细解释内存分配的原因和方法,并提供许多现实世界的技巧和窍门,帮助像您这样的内核或驱动开发人员在工作中高效地使用内核模块内的内存。

在本章中,我们将介绍内核的两种主要内存分配器——页分配器(Page Allocator,PA)(也称为伙伴系统分配器,Buddy System Allocator,BSA)和Slab分配器。我们将深入探讨如何在内核模块中使用它们的API。我们将远不止于展示如何使用这些API,还将明确展示为什么在许多情况下内存效率并不理想,以及如何克服这些问题。第9章《内核内存分配给模块作者——第二部分》将继续讨论内核内存分配器,深入探讨一些更高级的内容。

在本章中,我们将涵盖以下主题:

  • 介绍内核内存分配器
  • 理解并使用内核页分配器(或BSA)
  • 理解并使用内核Slab分配器
  • kmalloc API的大小限制
  • Slab分配器——一些额外的细节
  • 使用Slab分配器时的注意事项

技术要求

假设您已经完成了《在线章节:内核工作空间设置》,并已适当地准备了一台运行Ubuntu 22.04 LTS(或更高稳定版本)的虚拟机(VM),并安装了所有必要的软件包。如果没有,强烈建议您首先完成这一准备工作。

为了最大限度地从本书中获得收益,我强烈建议您先设置工作环境,包括克隆本书的GitHub仓库(github.com/PacktPublis…)以获取代码,并以动手实践的方式进行操作。

这里所涵盖的主题假设您至少对进程的虚拟地址空间(VAS)和虚拟内存基础(包括UMA和NUMA等物理内存布局概念)有所了解;这些内容在第6章《内核内部基础——进程与线程》和第7章《内存管理内部基础》中已经介绍过。在进入以下内容之前,确保您理解这些知识是非常重要的。

介绍内核内存分配器

动态分配和随后的释放内核内存——包括物理内存和虚拟内存——是本章的核心主题。像其他操作系统一样,Linux内核需要一个强健的算法和实现来执行这一关键任务。Linux操作系统中的主要(分配/释放)引擎被称为PA或BSA。在内部,它使用一种所谓的伙伴系统算法(buddy system algorithm)来有效地组织并分配系统内存中的空闲内存块。关于该算法的更多信息,请参见本章中的《理解并使用内核页分配器(或BSA)》部分。

在本章以及整本书中,当我们使用(de)allocate的表示法时,请理解它是“分配”和“释放”这两个词的合并。

当然,由于并不完美,页分配器并不是唯一的,也并非总是最好的获取和释放系统内存的方式。Linux内核中还有其他技术可以实现这一功能。其中,排名靠前的是内核的Slab分配器或Slab缓存系统(我们在这里使用"slab"一词作为这种类型分配器的通用名称,因为它最初就是以这个名字命名的;实际上,现代Linux内核中使用的Slab分配器的内部实现叫做SLUB(无队列Slab分配器),稍后会详细介绍)。

可以这样理解:Slab分配器解决了页分配器的一些问题,并优化了性能。到底是解决了哪些问题呢?请耐心等待,您很快就会看到…不过,目前重要的是要理解,实际分配和释放物理内存的唯一方式是通过页分配器。实际上,页分配器是Linux操作系统中内存(分配/释放)的主要“引擎”!

为了避免混淆和重复,从现在开始,我们将把这个主要的分配引擎称为“页分配器”。您会理解它也被称为BSA(来自驱动它的算法的名字)。

因此,Slab分配器是在页分配器之上或之上的一层。内核中的各种核心子系统,以及内核中的非核心代码,如模块和设备驱动程序,可以通过页分配器直接或通过Slab分配器间接分配(和释放)内存。以下图示说明了这一点:

image.png

在开始之前,需要澄清几个概念:

  1. 内核内存分配器的使用:Linux内核及其所有核心组件和子系统(排除内存管理子系统本身和一些特定架构的用户)最终都使用页分配器(或BSA)进行内存(分配/释放)。这也包括非核心部分,如内核模块和设备驱动程序。
  2. 页分配器和Slab分配器的位置:页分配器和Slab分配器完全驻留在内核(虚拟)地址空间中,不能直接从用户空间访问。
  3. 内存来源:页分配器从中获取内存的页面帧(RAM)位于内核的低内存区域,或者是内核段的直接映射RAM区域(我们在前一章详细介绍了内核段)。
  4. Slab分配器的内存来源:Slab分配器最终是页分配器的用户,因此它的内存也是从页分配器那里获取的(这再次意味着内存最终来自内核低内存区域)。
  5. 用户空间内存分配:用户空间中的动态内存分配(使用熟悉的malloc等API)并不会直接映射到前述的层级(即,在用户空间调用malloc()并不会直接调用页分配器或Slab分配器)。它是间接进行的。具体如何进行?请耐心等待,您将在第9章《内核内存分配给模块作者——第二部分》中学到这个关键内容,特别是在涉及需求分页的两节中,敬请留意!
  6. 内核内存的交换特性:需要明确的是,Linux内核内存是不可交换的。它永远不能被交换到磁盘;这一点是在Linux早期决定的,目的是为了保持高性能。尽管用户空间的内存页面默认是可以交换的,但系统程序员可以通过mlock()/mlockall()系统调用改变这一行为。

好了,系好安全带!在具备了这些基本理解后,让我们开始学习Linux内核内存分配器的基本工作原理,更重要的是,如何高效地与它们协作。

理解和使用内核页分配器(或BSA)

在本节中,您将了解Linux内核主要(分配/释放)引擎——页分配器(或BSA)的两个方面:

  1. 我们将首先介绍这个软件背后的算法基础(称为“伙伴系统”)。
  2. 其次,我们将讨论它向内核或驱动开发者暴露的API的实际和实际使用。

理解页分配器背后的算法基础非常重要。这样,您就能理解它的优缺点,从而在不同的情况中知道何时以及使用哪些API。让我们从它的内部工作原理开始。再次提醒,关于内存管理内部细节,本书的内容是有限的。我们将覆盖到一个典型模块/驱动程序作者所需的深度,且不再深入。

页分配器的基本工作原理

我们将把这个讨论分成几个相关部分。首先,我们来看看内核的页分配器如何通过其freelist数据结构跟踪空闲的物理页框。

理解页分配器freelist的组织

页分配器(伙伴系统)算法的关键是其主要的内部元数据结构。它被称为伙伴系统freelist,由一组指向(非常常见的!)双向循环链表的指针组成。这个指针数组的索引被称为列表的“阶”(order),它是2的多少次方。该数组的长度从0到MAX_ORDER-1MAX_ORDER的值是与架构相关的;在x86和ARM平台上,它的值是11,而在像Itanium这样的大型系统上,MAX_ORDER为17。因此,在x86和ARM平台上,阶数(索引)范围从20到210;即从1到1,024。这意味着什么呢?请继续阅读...

每个双向循环链表指向大小为2^order的空闲且物理连续的页框。因此,假设页面大小为4 KB,我们最终会得到11个链表(0-10),其大小特性如下:

  • Order 0: 2^0 = 1页 = 4 KB 块
  • Order 1: 2^1 = 2页 = 8 KB 块
  • Order 2: 2^2 = 4页 = 16 KB 块
  • Order 3: 2^3 = 8页 = 32 KB 块
  • [ … 以此类推 … ]
  • Order 10: 2^10 = 1024页 = 1024 * 4 KB = 4 MB

接下来,图示将有所帮助!以下是页分配器freelist(一个实例)的简化概念性示意图:

image.png

在图8.2中,每个内存“块”用一个方框表示(为了简单起见,我们在图中使用相同大小的块)。当然,内部这些并不是实际的内存页面;这些方框表示的是指向物理内存帧的元数据结构(struct page)。(回想一下,我们在第7章《内存管理内部基础——基础》中,在《物理内存模型介绍》部分提到了struct page的重要性)。

在图8.2的右侧,我们展示了每个可能被入队到左侧列表中的物理连续空闲内存块的大小。

内核通过proc文件系统为我们提供了一个方便的(概括的)视图,显示当前页分配器的状态,路径是/proc/buddyinfo伪文件;以下是来自我们1 GB RAM的Ubuntu虚拟机的一个示例:

image.png

正如图8.3所示,我们的虚拟机是一个伪NUMA盒子,具有一个节点(Node 0)和两个区域(DMA和DMA32)。(我们在第7章《内存管理内部基础——基础》中,介绍了NUMA的概念以及相关主题,在《物理RAM组织结构》部分中)。紧随zone XXX之后的数字是按照order 0、order 1、order 2 …… 直到order MAX_ORDER-1(这里,11 - 1 = 10)列表中的空闲(物理连续的!)页框的数量。因此,让我们从前面的输出(图8.3)中挑选几个例子:

  • 在节点0,区域DMA的order 0列表中,有35个单页的空闲内存块。
  • 在节点0,区域DMA32的order 3中,图8.3中显示的数字是678;现在,取2^order = 2^3 = 8页框 = 32 KB(假设页面大小为4 KB);这意味着该列表中当前有678个32 KB的物理连续空闲内存块。

需要注意的是,每个内存块保证本身是物理连续的RAM。此外,注意给定阶的内存块的大小总是前一个阶的两倍(而是下一个阶的一半)。这一数学特性当然是成立的,因为它们都是2的幂。

请注意,MAX_ORDER可以(并且会)根据架构而变化。在常规的x86和ARM系统上,它的值为11,从而使得在freelist的order 10中,最大的物理连续内存块大小为4 MB(因为最后一个阶数为2^10 = 1024页框 = 1024 * 4KB = 4096 KB = 4 MB)。作为另一个例子,在一些运行Itanium(IA-64)处理器的高端企业服务器上,MAX_ORDER可以高达17(意味着在order 16上,最大的块大小为2^16 = 65,536页 = 512 MB的物理连续RAM块)。IA-64 MMU支持最多八种页大小,从4 KB到256 MB不等。再比如,假设页面大小为16 MB,那么order 16列表可能包含物理连续的内存块,大小为65,536 * 16 MB = 1 TB

另一个关键点:内核维护多个PA/BSA freelist——系统上每个存在的node:zone都有一个freelist!这为在NUMA系统上分配内存提供了自然的方式。

以下图示展示了内核如何实例化多个freelist——每个系统上的node:zone都对应一个freelist(图示来源:Mauerer的《专业Linux内核架构》,Wrox Press,2008年10月):

image.png

此外(如图8.4所示),当内核被要求通过页分配器分配RAM时,它会选择最合适的freelist来分配内存——即与请求分配内存的线程所在的节点关联的freelist(回想一下上一章的NUMA架构)。如果该节点上的区域(zone)没有足够的内存或由于某种原因无法分配内存,内核将使用一个回退列表,决定从哪个freelist尝试分配内存。(实际上,真实的情况要复杂得多;我们将在《页分配器内部——更多细节》部分提供更多信息。)

现在,让我们以概念化的方式理解这一切是如何工作的,来完成内存页面的(分配/释放)!

页分配器的工作原理

实际的(分配/释放)策略可以通过一个简单的例子来解释。假设一个设备驱动程序请求128 KB的内存。为了满足这个请求,页分配器(BSA)算法将这样做(简化并概念化):

  1. 算法首先将需要分配的内存量(这里是128 KB)转换为页数。因此,这里(假设页面大小为4 KB)是 128K / 4K = 32 页。
  2. 接下来,算法计算2的多少次方等于32。即 log2(32)(或 ln 2),结果是5(因为 2^5 = 32)。因此,order 5列表将具有正好所需大小的空闲内存块。
  3. 然后,它检查相应(最接近的)节点:区域页分配器freelist中的order 5列表。如果有可用的内存块(它的大小将是 2^5 页 = 128 KB),则从order 5列表中出队该内存块,更新列表,并分配给请求者。任务完成!返回给调用者。

为什么我们说是“相应(最接近的)节点:区域页分配器freelist”?这是否意味着有多个freelist?是的,确实如此!我们再重复一遍:现实情况是,系统上每个节点:区域都有多个PA freelist数据结构。(更多细节请参见即将到来的《页分配器内部——更多细节》部分。)

如果order 5列表中没有一个内存块可用(即它为空),则检查下一个阶的列表;即order 6链表(如果它不为空,它将包含 2^6 页 = 256 KB的内存块,每个块的大小是所需大小的两倍)。

如果order 6列表非空,则从其中取出(出队)一个内存块(它的大小为256 KB,是所需大小的两倍),并执行以下操作:

  1. 更新(order 6)列表,反映出已移除一个块。
  2. 将该块切分成两半,从而得到两个128 KB的块或伙伴!(请参见以下信息框。)
  3. 将其中一半(128 KB)迁移(入队)到order 5列表。
  4. 将另外一半(128 KB)分配给请求者。

任务完成!返回给调用者。

如果order 6列表也为空,则重复之前的过程,检查order 7列表,以此类推(递归进行),直到成功。

如果所有剩余的高阶列表都为空(即null),则请求将失败(这非常不太可能发生)。

我们之所以能够将内存块切割成两半,是因为每个列表中的内存块都保证是物理连续的内存。切割后,我们得到两个半块;每个半块称为伙伴块,因此算法的名称也来源于此。严格来说,它被称为二进制伙伴系统,因为我们使用的是2的幂大小的内存块。更准确地说,伙伴块被定义为与另一个块大小相同且物理相邻的块。

您会理解,以上描述是概念性的。实际的代码实现肯定更加复杂和优化。顺便提一下,这段代码——正如它的注释所说,是分区伙伴分配器的“核心”,可以在这里找到:mm/page_alloc.c:__alloc_pages()(例如,在6.1.25版本中:elixir.bootlin.com/linux/v6.1.…)。由于超出了本书的范围,我们将不深入讨论分配器的代码级细节。

通过几个场景来了解工作原理

现在我们已经掌握了算法的基础,让我们考虑几个场景:首先是一个简单的直接案例,接着是几个更复杂的案例。

最简单的案例

假设一个内核空间设备驱动程序(或某些核心代码)请求128 KB,并从freelist数据结构的order 5列表中接收一个内存块。在稍后的某个时刻,它将(应该/必须)通过使用其中一个页分配器释放API来释放该内存块。现在,这个“释放”API的算法通过其阶(以及大小)计算出刚刚释放的内存块应该属于order 5列表;因此,它会将内存块入队到该列表中。(你很快会看到,分配和释放API的一个参数就是列表的阶。)

更复杂的案例

现在,假设与前面的简单情况不同,当设备驱动程序请求128 KB时,order 5列表为空;因此,按照页分配器的算法,我们会检查下一个阶的列表,order 6。假设它非空;算法现在会从order 6列表中出队一个256 KB大小的内存块,并将其分割(或切割)成两半。所以,现在我们得到两个物理相邻且大小相同的块——伙伴块。现在,一个伙伴块(大小为128 KB)被分配给请求者,剩下的伙伴块(同样是128 KB大小)被入队到order 5列表中。

伙伴系统的真正有趣之处在于,当请求者(驱动程序)在稍后的某个时刻释放内存块时会发生什么。正如预期的那样,算法通过其阶计算出刚刚释放的内存块应该属于order 5列表。但是,在盲目地将其入队之前,算法会查找它的伙伴块,在这种情况下,它(可能)找到了!然后,算法将两个伙伴块合并为一个更大的块(大小为256 KB),并将合并后的块放入(入队到)order 6列表中。这非常棒——它甚至帮助进行内存碎片整理!

失败的案例

现在让我们通过不使用方便的2的幂大小作为分配要求来增加一些趣味。这次假设设备驱动程序请求一个大小为132 KB的内存块。伙伴系统分配器会做什么?当然,由于它不能分配小于请求的内存,它会分配更多——你猜对了(见图8.2)——下一个适当大小的(大于或等于请求的)可用内存块位于freelist的order 7中,大小为256 KB。但消费者(驱动程序)只会看到并使用分配给它的256 KB块的前132 KB。剩余的124 KB是浪费的(想一想,这接近50%的浪费!)。这被称为内部碎片(或浪费),是二进制伙伴系统的一个关键缺陷!

不过,您将了解到,确实有一种缓解措施:已经有一个补丁被提交来处理类似的场景(通过alloc_pages_exact() / free_pages_exact() APIs)。我们将在稍后讨论如何使用这些API来操作页分配器。

页分配器内部——更多细节

在本书中,我们并不打算深入探讨页分配器的代码级细节。话虽如此,下面是一些信息:在数据结构方面,zone结构包含一个free_area结构的数组,所谓的页分配器“freelists”。这一设计是合理的;正如你所学的,系统中可能有多个(通常是多个)页分配器freelist,每个节点:区域一个;让我们在代码级别查看它们:

// include/linux/mmzone.h
struct zone { 
    [ ... ] 
    /* 不同大小的空闲区域 */
    struct free_area free_area[MAX_ORDER];
    [ ... ]
};

free_area结构是实现双向循环链表的结构(该链表包含该节点:区域中的空闲内存页框),以及该区域当前空闲的页框数量:

struct free_area {
    struct list_head free_list[MIGRATE_TYPES];
    unsigned long nr_free;
};

为什么free_area结构包含一个链表数组,而不仅仅是一个链表?不深入细节,我们提到,内核中伙伴系统freelist的布局比现在所展示的更复杂:从2.6.24内核开始,每个freelist进一步被分成多个(最多6个)独立的freelist,以适应不同的页迁移类型(指的是内核是否能够实际迁移或移动页面以减少碎片化效应。页迁移类型有五种(并非所有类型都启用):不可移动、可移动、可回收、CMA和隔离)。这是为了应对在保持内存(RAM)碎片整理时出现的复杂情况。

此外,如前所述,这些freelists按系统上的每个节点:区域存在。因此,例如,在一个具有4个节点和每个节点3个区域的实际NUMA系统中,将有12个(4 x 3)freelists。不仅如此,我们现在意识到,每个free_area结构实际上由(最多)6个freelists组成,每个迁移类型一个。因此,在这样的系统中,系统范围内总共将存在最多 6 x 12 = 72 个freelist数据结构!

如果你感兴趣,可以深入了解细节;/proc/buddyinfo的输出仅仅是一个伙伴系统freelist状态的简洁概览(如图8.3所示)。要查看更详细和现实的视图(如前面提到的,显示所有freelists),可以查看/proc/pagetypeinfo(这需要root权限);它显示了所有freelists(并且将它们按页迁移类型分开)。不过请注意,并非所有六种迁移类型都必须在系统上定义。

页分配器(伙伴系统)算法的设计是最适合的类别之一。它带来的主要好处是帮助在系统运行时整理物理内存碎片。页分配器(伙伴系统)算法的优点如下:

  • 有助于整理内存(外部碎片被缓解)
  • 保证分配物理连续的内存块
  • 保证CPU缓存行对齐的内存块
  • 快速(嗯,足够快;算法的时间复杂度是O(log n))

另一方面,最大的缺点是内部碎片或浪费可能过高。

好极了!我们已经覆盖了关于页或伙伴系统分配器内部工作原理的相当多的背景材料。现在是时候动手了;让我们深入了解并使用页分配器API来分配和释放内核内存。

学习如何使用页分配器API

Linux内核提供了一组API,用于通过页分配器分配和释放内存(RAM),这些API通常被称为低级(分配/释放)例程。以下表格总结了页分配API;您会注意到,在所有具有两个参数的API或宏中,第一个参数被称为GFP标志或位掩码(命名为gfp_mask);我们稍后会详细解释它,暂时忽略它。第二个参数是order——freelist的阶,即分配的内存量是2^order页框。

在接下来的讨论中,我们经常使用“内核逻辑地址”这一术语。正如我们在第7章《内存管理内部基础——基础》中,在《检查内核段》部分所述,‘逻辑’地址和‘虚拟’地址之间的区别是相当学究的。内核逻辑地址指的是内核低内存区域内的地址;所有其他内核地址被称为内核虚拟地址(KVAs)。对于所有实际用途,您可以将它们视为相同。

以下所有API原型可以在include/linux/gfp.h中找到:

API或宏名称备注API签名或宏
__get_free_page()分配一个页框。分配的内存内容是随机的;它是一个宏,封装了__get_free_pages() API。返回值是指向刚分配内存的内核逻辑地址的指针。#define __get_free_page(gfp_mask) \ __get_free_pages((gfp_mask), 0)
__get_free_pages()分配2^order个物理连续的页框。分配的内存内容是随机的;返回值是指向刚分配内存的内核逻辑地址的指针。unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
get_zeroed_page()分配一个页框;其内容被设置为ASCII零(NULL,即被清零);返回值是指向刚分配内存的内核逻辑地址的指针。unsigned long get_zeroed_page(gfp_t gfp_mask);
alloc_page()分配一个页框。分配的内存内容是随机的;它是一个宏(封装)在alloc_pages() API之上;返回值是指向刚分配内存的页面元数据结构的指针;它可以通过page_address()函数转换为内核逻辑地址。#define alloc_page(gfp_mask) \ alloc_pages(gfp_mask, 0)
alloc_pages()分配2^order个物理连续的页框。分配的内存内容是随机的;返回值是指向刚分配内存的页面元数据结构的起始位置的指针;它可以通过page_address()函数转换为内核逻辑地址。struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);

表8.1:低级(BSA/页)分配器——常用导出的分配API

所有前述的API都已导出(通过EXPORT_SYMBOL()宏),因此可以供内核模块和设备驱动程序开发者使用;别担心,您很快就会看到一个示范如何使用它们的内核模块。

如前所述,Linux内核认为维护一个(小的)元数据结构来跟踪每个内存页框是值得的。它被称为page结构。这里要注意的是:与通常的返回指向新分配内存块起始地址的指针(虚拟地址)语义不同,注意到alloc_page()alloc_pages() API(表8.1中提到的)返回的是指向新分配内存的页面结构的起始位置的指针,而不是内存块本身(与其他API不同)。您必须通过调用page_address() API,将返回的页面结构地址转换为新分配内存的实际指针。在任何情况下,本章《编写内核模块以演示如何使用页分配器API》部分中的示例代码将说明如何使用所有前述API。

不过,在我们能够使用这里提到的页分配器API之前,理解至少关于获取空闲页(GFP)标志的基础知识是至关重要的,这将是接下来的部分讨论的主题。

处理GFP标志

你会注意到,所有前述分配器API(或宏)的第一个参数是gfp_t gfp_mask。这是什么意思?本质上,这是GFP(获取空闲页,Get Free Page)标志;它们影响内核在分配内存时的行为;我们很快会详细介绍它们。这些是内核内部内存管理代码层使用的标志(有多个标志)。对于典型的内核模块(或设备驱动程序)开发者来说,有两个GFP标志是至关重要的(如前所述,其余标志主要用于内部使用)。它们是:

  • GFP_KERNEL
  • GFP_ATOMIC

决定在通过页分配器API进行内存分配时使用哪个标志非常重要;一个需要始终记住的关键规则如下:

  • 如果代码在进程上下文中运行,并且可以安全地睡眠,使用GFP_KERNEL标志。
  • 如果不安全进行睡眠(通常是在任何类型的原子或中断上下文中),则必须使用GFP_ATOMIC标志。

遵循上述规则至关重要。若使用不当,可能会导致整个机器冻结、内核崩溃和/或发生随机错误。因此,"安全/不安全进行睡眠"究竟是什么意思呢?对于这个问题以及更多内容,我们将在本章后面的《深入了解GFP标志》部分详细讨论。这个问题非常重要,所以我强烈建议你阅读这一部分。

Linux驱动程序验证(LDV)项目

在《在线章节:内核工作空间设置》中的《LDV – Linux驱动程序验证项目》部分,我们提到过该项目为Linux模块(主要是驱动程序)以及内核核心的各种编程方面提供了有用的“规则”。

关于我们当前的话题,以下是其中一条规则,负面规则,意味着你不能这样做:“在持有自旋锁时使用阻塞内存分配”(linuxtesting.org/ldv/online?…)。当持有自旋锁时,不允许做任何可能导致阻塞的事情;这包括除GFP_ATOMIC之外的内核空间内存分配。因此,非常重要的一点是,当在任何类型的原子或非阻塞上下文中进行内存分配时(如持有自旋锁时),必须使用GFP_ATOMIC标志(你将了解到,在使用互斥锁时并非如此;在持有互斥锁时,允许进行阻塞活动)。违反此规则会导致不稳定,甚至可能引发(隐式)死锁。LDV页面提到有一个设备驱动程序违反了这一规则,并进行了相应的修复(git.kernel.org/pub/scm/lin…)。请查看:补丁清晰地展示了(在kzalloc()]() API的上下文中,我们将很快介绍)GFP_KERNEL标志被替换为GFP_ATOMIC标志。

另一个常用的GFP标志是__GFP_ZERO。使用它意味着告诉内核你希望内存页被清零。它通常与GFP_KERNELGFP_ATOMIC标志按位或,以返回初始化为零的内存(一般来说,这是一个好的编程实践!)。

内核开发者已经详细记录了GFP标志的使用。你可以查阅include/linux/gfp_types.h,其中有一段很长且详细的注释,标题为“DOC: Useful GFP flag combinations”(在6.1.25版本中,可以在这里找到:elixir.bootlin.com/linux/v6.1.…)。

现在,为了让我们快速上手,只需理解,使用Linux内核的内存分配API时,GFP_KERNEL标志确实是内核内部分配的常见情况。

使用页分配器释放内存

分配内存的反面当然是释放内存。我们都理解基本规则:释放你(动态)分配的内存,以防止内存泄漏。内核中的内存泄漏绝对不是你希望发生的事情。对于表8.1中显示的页分配器API,以下是对应的释放API:

API或宏名称备注API签名或宏
free_page()释放通过__get_free_page()get_zeroed_page()alloc_page() API分配的(单个)页面;它是free_pages() API的简单封装。#define free_page(addr) __free_pages((addr), 0)
free_pages()释放通过__get_free_pages()alloc_pages() API分配的多个页面(它是__free_pages()的封装)。void free_pages(unsigned long addr, unsigned int order)
__free_pages()(与前一行相同)它是实际执行工作的底层例程;此外,请注意,第一个参数是指向页面元数据结构的指针。void __free_pages(struct page *page, unsigned int order)

表8.2:与页分配器一起使用的常见释放页(s)API

你可以看到,前述函数中的实际底层API是free_pages(),它本身只是mm/page_alloc.c:__free_pages()代码的封装。free_pages() API的第一个参数是指向正在释放的内存块起始位置的指针;这当然是分配例程的返回值。然而,底层API __free_pages() 的第一个参数是指向正在释放的内存块起始位置的页面元数据结构的指针。

一般来说,除非你确切知道自己在做什么,否则建议调用封装例程(如foo()),而不是其内部的__foo()例程。这样做的一个原因是正确性(也许封装函数在调用底层例程之前使用了一些必要的同步机制,比如锁)。另一个原因是有效性检查(这有助于代码保持健壮和安全)。通常情况下,__foo()例程为了速度跳过了有效性检查。

接下来,让我们讨论一些在内核中分配或释放内存时需要遵循的有用指导原则。

在(分配/释放)内核内存时需要遵守的几条指南

正如所有经验丰富的C/C++应用开发者所知道的,分配和随后的释放内存是一个常见的错误源!这主要是因为C语言在内存管理方面是一门非管理语言;因此,你很容易遇到各种内存错误。这些错误包括著名的内存泄漏、读写缓冲区溢出/下溢、双重释放和释放后使用(UAF)错误。

不幸的是,内核空间中的情况也不例外;只是后果(要严重得多)!需要格外小心!请务必注意以下几点:

  • 偏向使用那些将刚分配的内存初始化为零的例程。

  • 在进行内存分配时,考虑并使用适当的GFP标志;关于这一点,稍后我们将在《深入了解GFP标志》部分详细讨论,但简而言之,请注意以下几点:

    • 在进程上下文中且可以安全地睡眠时,使用GFP_KERNEL
    • 在原子上下文中(例如处理硬件中断或持有自旋锁时),使用GFP_ATOMIC
  • 在使用页分配器(如我们现在正在做的)时,尽量保持分配大小为2的幂次方页(再次,关于这一点的合理性以及如何缓解这种情况——当你不需要这么多内存时,这是典型情况——将在本章的后续部分详细讨论)。

  • 只尝试释放你之前分配的内存;不用说(但我们会说!),不要遗漏释放它,不要使用错误的指针,也不要双重释放它。

  • 不要尝试访问已经释放的内存(这会导致UAF错误)。

  • 保持原始内存块的指针不被重新使用、操作(如ptr++或类似操作)和损坏,这样你就可以在完成时正确地释放它。

  • 检查(并重新检查!)传递给API的参数。是需要指向之前分配的内存块的指针,还是指向其底层页面结构的指针?

遇到困难或担心生产环境中的问题?别忘了,你可以通过内核(以及第三方)的静态和动态分析工具获得帮助!确保学习如何使用内核中的强大静态分析工具(如Coccinelle、sparse,以及其他工具,如cppcheck或smatch)。对于动态分析,学习如何安装和使用KASAN(内核地址消毒器)。《Linux内核调试》一书涵盖了这些内容以及更多...

还要记得我在第5章《编写你的第一个内核模块——第二部分》的《更好的Makefile模板》部分中提供的“更好”的Makefile模板。它包含了使用这些工具的多个目标;请务必使用它!

好了,现在我们已经覆盖了页分配器的(常见的)分配和释放API,接下来是时候将所学应用于实践了。让我们编写一些代码吧!

编写内核模块演示如何使用页分配器API

现在,让我们动手使用到目前为止学到的低级页分配器和释放API。在这一部分中,我们将展示相关的代码片段(并非每一行代码),并在需要时进行解释,这些代码来自我们的演示内核模块(ch8/lowlevel_mem/lowlevel_mem.c)。

在我们的小型LKM的主要工作例程bsa_alloc()中,我们突出显示了一些代码注释,展示我们试图实现的目标。需要注意的几点如下:

首先,我们做了一件非常有趣的事情:我们使用我们的小型内核“库”函数klib.c:show_phy_pages(),字面上展示物理RAM页框是如何被一对一地映射到内核低内存区域的内核虚拟页的(show_phy_pages()函数的具体工作稍后讨论):

// ch8/lowlevel_mem/lowlevel_mem.c
[...]
static int bsa_alloc(void)
{
    int stat = -ENOMEM;
    u64 numpg2alloc = 0;
    const struct page *pg_ptr1;
    /* 0. 显示一对一映射:物理RAM页框到内核虚拟地址,从PAGE_OFFSET开始,显示5页 */
    pr_info("0. Show identity mapping: RAM page frames : kernel virtual pages :: 1:1\n" "(PAGE_SIZE = %ld bytes)\n", PAGE_SIZE);
    /* 显示虚拟地址、物理地址和PFN(页框编号)... */
    show_phy_pages((void *)PAGE_OFFSET, 5 * PAGE_SIZE, 1);

第二,我们通过底层的__get_free_page()页分配器API(我们之前在表8.1中看到的)分配了一页内存:

  /* 1. 使用`__get_free_page()` API分配一页 */
  gptr1 = (void *)__get_free_page(GFP_KERNEL);
  if (!gptr1) {
        pr_warn("__get_free_page() failed!\n");
        /* 按照惯例,我们发出一个printk,说明分配失败。实际上,这并不是必须的;内核在内存分配请求失败时会自动发出许多警告printk!因此,我们只做一次(这里;也可以使用WARN_ONCE());以后我们不再在内存分配失败时再打印错误消息。 */
        goto out1;
  }
  pr_info("#.    BSA/PA API     已分配的内存量        KVA\n"); // 头部
  pr_info("1.  __get_free_page()     1页    %px\n", gptr1);

我们在这里使用了几个printk函数,首先显示一个标题行,然后显示一个特定分配的详细信息(KVA,当然是内核虚拟地址)。回想一下,在上一章中提到,所看到的KVA是页分配器分配的内存,它位于内核段/虚拟地址空间的直接映射RAM或低内存区域。

为了安全起见,我们应该始终只使用%pK格式说明符打印内核地址,以便在内核日志中显示的是哈希值,而非实际的虚拟地址。然而,在这里,为了展示实际的KVA,我们使用了%px格式说明符(与%pK一样是便携的;但出于安全考虑,请不要在生产环境中使用%px格式说明符!)。

接下来,注意到第一个__get_free_page() API的错误代码路径中的详细注释(在前面的代码片段中)。它提到实际上你不需要打印内存不足的错误或警告消息。(好奇吗?想知道为什么,请访问 lkml.org/lkml/2014/6…

在这个示例模块中(与之前的几个模块以及更多的模块类似),我们通过使用适当的printk格式说明符(如%zd%zu%pK%px%pa)来编码我们的printk(或pr_<foo>()宏)实例,以便便于移植。

现在,让我们继续进行第二次内存分配,使用页分配器;请看以下代码片段:

/* 2. 使用`__get_free_pages()` API分配`2^bsa_alloc_order`页 */
numpg2alloc = powerof(2, bsa_alloc_order); /* 返回2^bsa_alloc_order */
gptr2 = (void *)__get_free_pages(GFP_KERNEL|__GFP_ZERO, bsa_alloc_order);
if (!gptr2) {
      /* 不再打印错误/警告信息;见上文注释 */
      goto out2;
}
pr_info("2. __get_free_pages()  2^%d页(s)  %px\n", bsa_alloc_order, gptr2);

在前面的代码片段中,我们通过页分配器的__get_free_pages() API分配了2^3(即8)页内存(因为我们模块参数bsa_alloc_order的默认值是3)。

顺便提一下,注意我们使用了GFP_KERNEL|__GFP_ZERO的GFP标志,以确保分配的内存被清零,这是一种最佳实践。但同样,清零大块内存可能会稍微影响性能。

现在,我们问自己一个问题:是否有办法验证内存是否是物理连续的(如承诺的那样)?事实证明,是的,我们实际上可以获取并打印出每个分配的页框的物理地址,并获取其页框编号(PFN)。

PFN是一个简单的概念:它只是一个索引或页号——例如,物理地址8192的PFN是2(8192/4096)。正如我们之前展示的那样,如何(更重要的是,什么时候)将内核虚拟地址转换为其物理对应地址(反之亦然;该内容在第7章《内存管理内部基础——基础》中的《直接映射RAM和地址转换》部分有所覆盖),我们在这里不再重复。

为了进行虚拟地址到物理地址的转换并检查是否连续,我们编写了一个小的“库”函数,它保存在本书GitHub源代码树根目录下的klib.c中。我们的目的是修改内核模块的Makefile,将这个库文件的代码链接进来! (如何正确地执行这一步,我们在第5章《编写你的第一个内核模块——第二部分》中的《通过多个源文件执行库仿真》部分已有讨论)。以下是我们调用库例程的代码(就像在bsa_alloc()函数的开头部分所做的那样):

show_phy_pages(gptr2, numpg2alloc * PAGE_SIZE, 1);

以下是我们的库例程代码(位于<booksrc>/klib.c源文件中;为了简洁起见,我们在这里不展示完整代码):

// klib.c
[...]
int loops = len/PAGE_SIZE, i;
/* show_phy_pages() - 显示提供的内存范围的虚拟地址、物理地址和PFN(每页显示一次)
 * ! 注意 注意 注意 !
 * 起始内核地址必须是'线性'地址,即内核段中的'低内存'直接映射区域的地址,否则将无法工作并可能导致系统崩溃。
 * @kaddr: 起始内核虚拟地址
 * @len: 内存块的长度(字节)
 * @contiguity_check: 如果为True,则检查页面的物理连续性
 * '逐页'遍历虚拟连续的'页'数组(即逐页打印虚拟和物理地址以及PFN)。这样,我们可以检查内存是否真的*物理*连续。
 */
void show_phy_pages(const void *kaddr, size_t len, bool contiguity_check)
{
    [...]
    if (len % PAGE_SIZE)
        loops++;
    for (i = 0; i < loops; i++) {
        pa = virt_to_phys(vaddr+(i*PAGE_SIZE));
        pfn = PHYS_PFN(pa);
        if (!!contiguity_check) {
        /* 为什么要使用'if !!(<cond>) ...'?
         * 这是一个'C'技巧:确保if条件总是评估为布尔值——0或1。酷吧。 */
            if (i && pfn != prev_pfn + 1)
            	pr_notice(" *** 物理非连续性检测到 ***\n");
        }
        [...]
        pr_info("%05d 0x%px %pa %ld\n", i, vaddr+(i*PAGE_SIZE), &pa, pfn);
        if (!!contiguity_check)
            prev_pfn = pfn;
    }
}

研究一下上述函数。我们逐页遍历给定的内存范围(虚拟),获取物理地址和PFN,然后通过printk输出(注意我们如何使用%pa格式说明符便携地打印物理地址——但必须通过引用传递)。不仅如此,如果我们klib.c:show_phy_pages()函数的第三个参数contiguity_check为1,我们检查PFN是否仅相差一个数字,从而检查页面是否真的物理连续。(顺便提一下,我们使用的简单powerof()函数也包含在我们的库代码中)。

现在,让我们继续编写内核模块代码:

/* 3. 使用get_zeroed_page() API分配并初始化一页 */
gptr3 = (void *)get_zeroed_page(GFP_KERNEL);
if (!gptr3)
       goto out3;
[...]
pr_info("3.  get_zeroed_page()   1页      %px\n", gptr3);

如前面的代码片段所示,我们分配了一页内存,但通过使用PA get_zeroed_page() API确保它被清零。pr_info()展示了实际的KVA(使用%px%pK格式说明符时,无论您是运行32位系统还是64位系统,地址都会以可移植的方式打印出来)。

接下来,我们使用alloc_page() API分配一页内存。小心!它不会返回分配的页面的指针,而是返回表示已分配页面的元数据结构page的指针;这是函数签名:

struct page * alloc_page(gfp_mask);

因此,我们必须使用page_address()助手函数将其转换为内核逻辑(或虚拟)地址:

/* 4. 使用alloc_page() API分配一页 */
pg_ptr1 = alloc_page(GFP_KERNEL);
if (!pg_ptr1)
     goto out4;
gptr4 = page_address(pg_ptr1);
pr_info("4.       alloc_page()   1页      %px\n"
        " (struct page地址 = %px)\n", (void *)gptr4, pg_ptr1);

在前面的代码片段中,我们通过alloc_page() PA API分配了一页内存。如前所述,我们需要通过page_address() API将返回的页面元数据结构转换为KVA(或内核逻辑地址)。

接下来,使用alloc_pages() API分配2^3 = 8页。与前一个代码片段一样,下面的代码也有类似的警告:

/* 5. 使用alloc_pages() API分配2^3 = 8页 */
gptr5 = page_address(alloc_pages(GFP_KERNEL, 3));
if (!gptr5)
     goto out5;
pr_info("5.      alloc_pages()  %lld页     %px\n",
        powerof(2, 5), (void *)gptr5);

在前面的代码片段中,我们结合使用了alloc_pages()page_address() API来分配2^3 = 8页内存!

有趣的是,我们在代码中使用了几个本地的goto语句(可以查看代码库中的代码)。仔细看,你会发现它实际上使得错误处理代码路径既简洁又逻辑清晰!这确实是Linux内核编码风格指南的一部分。

Linux中(通常被过度讨论的)goto语句的使用被清晰地记录在这里:elixir.bootlin.com/linux/v6.1.…。我建议你查看一下!一旦你理解了使用模式,你会发现它有助于减少典型的内存泄漏(以及类似问题)的清理错误!

最后,在清理方法中,在从内核内存中删除之前,我们释放了所有刚刚在内核模块的清理代码中分配的内存块。

为了将我们的klib库代码与lowlevel_mem_lkm内核模块链接,Makefile做了如下更改(回顾一下我们在第5章《编写你的第一个内核模块——第二部分》的《通过多个源文件执行库仿真》部分中学到了如何将多个源文件编译为一个内核模块):

FNAME_C := lowlevel_mem
[ ... ]
PWD            := $(shell pwd)
obj-m          += ${FNAME_C}_lkm.o
lowlevel_mem_lkm-objs := ${FNAME_C}.o ../../klib.o

呼,真是涵盖了很多内容。确保你理解代码,然后继续阅读,看看它是如何在实际中运作的。

部署我们的 lowlevel_mem_lkm 内核模块

好了,到了看看我们的 ch8/lowlevel_mem 内核模块如何工作的时刻!让我们在一个 Raspberry Pi 4(运行默认的32位 Raspberry Pi OS)和一个 x86_64 虚拟机(运行 Ubuntu 22.04 LTS)上构建并部署它。

在 32 位 Raspberry Pi 上尝试

在 Raspberry Pi 4 Model B(此处运行的是标准的 Raspberry Pi 32 位内核版本 5.15.76-v7l+)上,我们首先构建并使用 insmod 加载我们的 lowlevel_mem_lkm 内核模块(这部分在图 8.5 中没有显示)。接下来,我们使用 lsmod 命令查看已加载的模块,然后用 rmmod 卸载它,最后通过 dmesg 显示内核日志。

以下截图展示了输出结果:

image.png

看看这个!在图8.5输出的步骤0中,我们的 show_phy_pages() ‘库’例程清楚地显示了 KVA 0xc000 0000 对应物理地址(pa)0x0,KVA 0xc000 1000 对应物理地址 0x1000,依此类推,对于五个页面(最右边还显示了PFN);你可以直观地看到物理RAM页框与内核虚拟页(在内核段的低内存区域)之间的1:1身份映射!

接下来,在显示一个标题行之后,使用 __get_free_page() API进行的初始内存分配如预期进行。更有趣的是我们的案例2。在这里(图8.5的中下部分),我们可以清楚地看到每个分配页面的物理地址和PFN(从0到7,共8页,通过__get_free_pages() API分配,代码叙述中的第3点)是连续的,(再次)表明分配的内存页面确实是物理连续的!

在 x86_64 虚拟机上尝试

我们在运行 Ubuntu 22.04 LTS(定制6.1内核)的 x86_64 虚拟机上构建并运行相同的模块。以下截图展示了输出结果:

image.png

这次,由于 PAGE_OFFSET 的值是一个64位量(这里的值恰好是 0xffff934cc0000000;请注意,由于 KASLR,它当然可能会发生变化!),你可以再次清晰地看到物理RAM页框到内核虚拟地址的身份映射(对于5个页面)。让我们花一点时间仔细看看页分配器API返回的内核逻辑地址。在这个特定的例子中,在图8.6中,你可以看到它们都在范围 0xffff 934c c000 xxxx 内。所有这些地址都明显位于内核的直接映射物理内存(RAM在此映射到内核段),也就是低内存区域(lowmem region)内。

如果这一切对你来说显得新奇和陌生,请参阅第7章《内存管理内部基础——基础》,特别是《检查内核段》和《直接映射RAM与地址转换》部分。

实际上,页分配器内存(实际上是伙伴系统的空闲列表)直接映射了内核虚拟地址空间(VAS)中的直接映射或低内存区域的(空闲)物理RAM。因此,它显然从这个区域返回内存。当然,所使用的具体地址范围是非常依赖架构的。

虽然很容易认为你现在已经掌握了页分配器及其API,但实际上(像往常一样)情况并非如此。继续阅读以了解为什么——理解这些方面真的很重要。

页分配器与内部碎片化(浪费)

虽然表面上看一切都很好且无害,但我强烈建议你深入了解一下。表面之下,可能潜藏着一个巨大的(令人不悦的)惊喜:内核/驱动开发者可能完全没有意识到的问题。我们之前讨论的页分配器API(见表8.1)具有一个可疑的特点:它们可能会导致内部碎片化——简而言之,就是浪费——非常大量的内核内存!

要理解为什么会这样,你必须至少了解页分配器算法及其freelist数据结构的基本原理。《页分配器的基本工作原理》部分已经覆盖了这一点(如果你还没读过,请务必阅读)。

在《通过几个场景学习》部分,你已经看到,当我们请求的是方便的、完全对齐的2的幂大小的页面时(注意,是页面,而不是字节),分配过程非常顺利。然而,当情况不是这样时——比如驱动程序请求132 KB的内存——就会出现一个重大问题:内部碎片或浪费非常高(如前所述,默认情况下,这会导致分配256 KB的内存,因此浪费了256 - 132 = 124 KB)。这是一个严重的缺点,必须解决。我们实际上会看到两种解决方法。请继续阅读!

‘精确’页分配器API对

意识到默认页分配器(或BSA)内存浪费的巨大潜力,一位来自Freescale Semiconductor的开发者(见信息框)为内核页分配器贡献了一个补丁,扩展了PA API,新增了几个API。

在2.6.27-rc1系列中,2008年7月24日,Timur Tabi提交了一个补丁来缓解页分配器浪费问题。以下是相关的提交: github.com/torvalds/li…

使用这些API,可以更有效地分配较大内存块(多个页面),并减少浪费。新增的(至少在2008年是新的)一对API,用于分配和释放内存,如下所示:

#include <linux/gfp.h>
void *alloc_pages_exact(size_t size, gfp_t gfp_mask);
void free_pages_exact(void *virt, size_t size);

alloc_pages_exact() API的第一个参数 size 是以字节为单位的:显然,它是要分配的字节数;第二个参数是之前讨论的“常规”GFP标志值(在《处理GFP标志》部分中讨论过;GFP_KERNEL 用于典型的可以休眠的进程上下文,GFP_ATOMIC 用于不能休眠的中断或原子上下文)。

请注意,通过此API分配的内存仍然保证是物理连续的。此外,一次调用可以分配的内存量(通过一个函数调用)受 MAX_ORDER 限制;事实上,这对于我们迄今看到的所有其他常规页分配API也是如此。我们将在即将到来的《kmalloc API的大小限制》部分进一步讨论这一方面。在那里,你会意识到讨论不仅限于 slab 缓存,也涵盖了页分配器!

free_pages_exact() API只能用于释放通过其对应的 alloc_pages_exact() API分配的内存。同时,请注意,“释放”例程的第一个参数是匹配的分配例程返回的值(即指向分配的内存块的指针)。

alloc_pages_exact()的实现简单而巧妙:它首先通过__get_free_pages() API“照常”分配整个请求的内存块。然后,它会遍历这些页面——从指定内存区域的末尾(从最近的页面边界)到实际分配的内存量(通常要大得多)——释放掉那些不必要的内存页面!因此,在我们的例子中,如果你通过alloc_pages_exact() API分配了132 KB,它实际上会首先通过__get_free_pages()内部分配256 KB(因为必须这样做,这是算法要求的),但随后会释放从132 KB到256 KB之间的未使用内存!

另一个开源美妙之处的例子!一个演示如何使用这些API的示例可以在模块代码中找到:ch8/page_exact_loop;我们将留给你自己去尝试。

两种方法解决页分配器浪费问题

在我们开始本节之前,我们提到过,页分配器的浪费问题可以通过两种方法来解决。一种是使用(比其他PA API更高效的)alloc_pages_exact()free_pages_exact() API对,正如我们刚刚学到的;另一种——实际上是常见的方法——是使用完全不同的层次来分配内存——即 slab 分配器。我们很快会讨论这个;在那之前,请继续阅读。接下来,让我们更详细地了解典型的GFP标志,以及你作为内核模块或驱动开发者应该如何使用它们。

深入了解GFP标志

关于我们讨论的低级页分配器API,每个函数的第一个参数就是所谓的GFP掩码。在讨论这些API及其使用时,我们提到了一条关键规则:

如果你的内核/驱动代码在进程上下文中运行,并且可以安全地睡眠,使用GFP_KERNEL标志。如果当前不安全进行睡眠(通常是当处于任何类型的原子上下文,包括中断上下文,或者持有自旋锁时),你必须使用GFP_ATOMIC标志。

我们将在以下部分详细阐述这一点。

在中断或原子上下文中绝不睡眠

那么,“安全地睡眠”到底是什么意思呢?为了回答这个问题,可以考虑阻塞调用(API):阻塞调用是指调用进程(或线程)进入休眠状态,因为它在等待某些事件发生,而它等待的事件尚未发生。因此,它等待——它“睡觉”。当它等待的事件发生时,它会被内核(或设备驱动)唤醒并继续执行。

一个用户空间阻塞API的例子是sleep() C库API。假设你这样做:sleep(5);。这里,等待的事件是一定时间的流逝(函数的参数,以秒为单位)。所以,调用该函数的进程上下文会进入休眠状态,并在事件发生时被唤醒——即5秒钟的时间流逝!另一个例子是read()系统调用及其变种,通常等待的事件是存储或网络数据变得可用。使用wait4()系统调用时,等待的事件是子进程的结束或停止/继续等等。

因此,任何可能阻塞的函数都可能会花一些时间进入休眠状态;注意,虽然休眠时,它显然不会出现在CPU的运行队列中,而是在等待队列中;实际上,处于休眠状态时,它甚至不是调度的候选者!在内核模式下调用这些可能阻塞的功能(当然,在处理内核模块时我们就是处于内核模式)仅允许在进程上下文中,并且在此上下文中,当它是安全的进行睡眠时(持有某些锁,如自旋锁,表明不安全睡眠,而持有互斥锁表明可以安全睡眠或阻塞)。在不安全的上下文中调用任何阻塞调用,例如在中断或原子上下文中,是一个bug。可以将此视为黄金法则。违反这一点——即在原子上下文中睡眠——是错误的,是有bug的,绝不允许发生。

你可能会想,如何提前知道我的代码是在原子上下文(或中断上下文)中运行,还是在进程上下文中运行?内核以一种方式帮助我们:当配置内核时(回顾第2章《从源代码构建6.x Linux内核——第1部分》中提到的make menuconfig),在Kernel Hacking / Lock Debugging菜单下,有一个布尔型可调项叫做“Sleep inside atomic section checking”。打开它!(该配置选项命名为CONFIG_DEBUG_ATOMIC_SLEEP;你可以随时通过grep命令在内核配置文件中查找它。同样,在第5章《编写你的第一个内核模块——第二部分》中的《配置一个“调试”内核》部分,这也是你应该打开的内容)。此外,我们将讨论一些宏(如in_task()),它们允许你确定代码是否正在进程上下文中执行。

另一种理解这种情况的方法是:如何将进程或线程置于休眠状态?简短的答案是,通过让它调用调度代码——schedule()函数。因此,按照我们刚才学到的(作为推论),schedule()必须只在可以安全休眠的上下文中调用;进程上下文通常是安全的,而中断上下文则绝对不安全(关于CPU调度的详细内容将在第10章和第11章中介绍)。

这点真的很重要!(我们在第4章《编写你的第一个内核模块——第1部分》中的《进程和中断上下文》部分简要地介绍了进程和中断上下文。另外,正如那里所述,你可以随时使用in_task()宏来确定代码当前是否在进程上下文或中断上下文中运行)。同样,你也可以使用in_atomic()宏;如果代码是在原子上下文中运行——通常它必须不被打断并完成——它返回True;否则,返回False。需要注意的是:你可以处于进程上下文中,但同时也可能是原子上下文——例如,当持有某些类型的锁(自旋锁时;我们将在后续两章关于同步的内容中讨论这个)时;但反过来不行(即在中断上下文中,代码始终是原子性的)。

关于其他内部使用的GFP标志的简要说明

除了我们作为模块/驱动开发者关注的GFP标志——GFP_KERNELGFP_ATOMIC 之外,内核还有一些其他相当特殊的 [__]GFP_* 标志,这些标志用于内部使用;其中有几个是专门用于回收内存的。这些包括(但不限于) __GFP_IO__GFP_FS__GFP_DIRECT_RECLAIM__GFP_KSWAPD_RECLAIM__GFP_RECLAIM__GFP_NORETRY

在内核内部,使用这些GFP标志来精确指定在执行内存分配时所期望的内部行为。为了帮助理解这一点,请考虑以下(典型)示例:

如果内存不足,内核可以通过先将内存写入持久存储(通常是交换分区或文件),标记其在交换空间中的位置,然后释放它来释放一些内存。假设现在一个文件系统组件发出内存请求;由于RAM短缺,内核尝试通过请求写出一些“较旧”的内存页面来释放一些内存(可能使用最少使用(LFU)算法)。但考虑这个问题:如果相同的文件系统代码路径涉及到写出这些内存页面,而这些代码路径恰好需要在此时进行内存分配呢?这可能导致扭曲的递归行为,甚至是死锁!因此,在这种情况下,内核在执行内存分配时不会使用名为__GFP_IO的GFP标志,该标志指定是否在执行分配时可以启动物理I/O(或者不启动)。类似地,还有一个名为__GFP_FS的标志,用于指定内核是否能够调用底层文件系统代码路径。GFP_NOWAIT标志指定语义,告诉内核在进行内存回收时不等待,不启动物理I/O,也不使用任何底层文件系统回调。

当内核处于必须保持原子状态(不能以任何方式休眠或阻塞)且内存分配必须立即成功或失败(最好是成功)的情况下,GFP_ATOMIC标志是合适的选择。这当然适用于原子代码路径,如任何类型的中断上下文或持有自旋锁时(注意,甚至在处理不可屏蔽中断(NMI)时,这也不被认为是可以接受的)。

在代码层面查看上述标志:include/linux/gfp_types.h

#define GFP_ATOMIC  (__GFP_HIGH|__GFP_ATOMIC|__GFP_KSWAPD_RECLAIM)
#define GFP_KERNEL  (__GFP_RECLAIM | __GFP_IO | __GFP_FS)

因此,通过GFP_KERNEL标志,我们现在可以看到,分配API可以主动回收内存,并启动物理和/或文件系统I/O,从而增加获取请求内存的机会。

如前所述,gfp_types.h头文件中有关于这些标志含义的详细注释;请务必查阅。

关于Linux驱动程序验证(LDV)项目的简要说明

在《在线章节:内核工作空间设置》中,我们提到过,Linux驱动程序验证(LDV)项目为Linux模块(主要是驱动程序)以及内核核心的各个编程方面提供了有用的“规则”。

关于我们当前的话题,以下是其中一条规则,负面规则,意味着你不能这么做:在持有USB设备锁时进行内存分配时不禁用I/O(链接至规则)。以下是一些背景信息:当你指定GFP_KERNEL标志时,它隐含地意味着(除其他事项外)内核可以启动I/O(输入/输出;读/写)操作以回收内存。问题是,有时这样做可能会产生问题,因此不应这样做;为了解决这个问题,你应该在分配内存时使用GFP_NOIO标志,作为GFP掩码的一部分。

这正是LDV“规则”所指的情况:在特定情况下,介于usb_lock_device()usb_unlock_device() API之间,不应该使用GFP_KERNEL标志,而应使用GFP_NOIO标志。(你可以在这段代码中看到多个实例使用了这个标志:drivers/usb/core/message.c)。LDV页面提到,几个与USB相关的驱动源文件已被修复以遵循此规则。

页分配器 – 优缺点

让我们通过快速总结页分配器/BSA(伙伴系统分配器)的优缺点来结束本节。

页分配器/BSA – 优点:

  • 速度快;使用内核的身份映射RAM(在启动时获取并映射);页面位于低内存区域,因此已经预映射;不需要设置页表。
  • 保证分配的内存块是物理连续且CPU缓存线对齐的。
  • 通过合并“伙伴”块(伙伴块是相同大小且物理连续的内存块)来帮助内存碎片整理。

页分配器/BSA – 缺点:

  • 主要问题:内部碎片化或浪费可能很高。
  • 分配请求的粒度是页面;例如(假设是4K页面),请求128字节时会得到4,096字节(很多情况下使用更优的alloc_pages_exact()/free_pages_exact() API对可以缓解这一问题,但在这种情况下无法缓解)。
  • 单次API调用可以分配的最大内存量是有限的;实际上,假设MAX_ORDER=11和页面大小为4K,它的上限是4MB。

好了,现在你已经掌握了有关页分配器的详细信息(毕竟,它是RAM(分配/释放)操作的内部“引擎”!),它的API,以及如何使用它们,让我们继续探讨一个非常重要的话题——slab分配器的动机、它的API以及如何使用它们。

理解和使用内核slab分配器

如本章第一节《介绍内核内存分配器》中所见,slab分配器或slab缓存位于页分配器(或BSA)之上(请参阅图8.1)。slab分配器通过两个主要的思想或目的证明了它存在的合理性:

  1. 对象缓存:它作为常见“对象”的缓存;这些对象是Linux内核中频繁分配的数据结构。这个想法——我们当然会进一步扩展——让slab缓存按需分配(并随后释放)这些对象,从而提高性能。
  2. 通过提供小而方便大小的缓存,减轻页分配器的高浪费(内部碎片化)问题,这些缓存通常是页面的碎片。

现在,让我们更详细地审视这些思想。

对象缓存的理念

好的,我们从第一个设计理念开始——常见对象的缓存概念。很久以前,一位名叫Jeff Bonwick的SunOS开发者注意到,某些内核对象——通常是数据结构——在操作系统中被频繁分配和释放。因此,他想出了一个预分配这些对象的缓存的想法。这最终演变成了我们现在称之为slab缓存的机制。

因此,在Linux操作系统中,内核(作为启动时初始化的一部分)预分配了大量的对象到几个slab缓存中。其原因是:性能!当核心内核代码(或设备驱动)需要内存来存储这些对象时,它直接请求slab分配器。如果这些对象已被缓存,分配几乎是即时的(释放时也会是相同的)。你可能会想,这一切真的有必要吗?答案是:确实有必要!

一个需要高性能的典型例子是在网络和块I/O子系统的关键代码路径中。正是出于这个原因,内核会自动缓存(预分配)几个网络和块I/O数据结构(如网络栈的socket缓冲区sk_buff,块层的biovec,当然,还有核心的task_struct数据结构或对象,它们都是很好的例子),这些结构都会预分配到slab缓存中。类似地,文件系统的元数据结构(例如inode和dentry结构等),内存描述符(struct mm_struct)以及其他多个结构都会在slab缓存中预分配。我们能看到这些缓存的对象吗?是的,稍后我们就会通过 /proc/slabinfo 来查看。

slab(或更准确地说,现在的SLUB)分配器性能优越的另一个原因是:当传统的基于堆的分配器频繁分配和释放内存时,它们最终会在内存中造成“空洞”(碎片)。而slab对象是一次性(在启动时)分配到缓存中的,并在不需要时释放回缓存(因此实际上并未“释放”掉内存),这使得性能保持较高。当然,现代内核具有智能,当内存压力过大——即内存需求过高时——它会优雅地开始释放slab缓存。

再次提醒,理论和实践之间通常存在差距;有时,当RAM极度紧张时,内核会采取相对强硬的方式来处理,启动其“重型武器”——Out-Of-Memory(OOM)杀手!(不用担心,我们会在第9章《内核内存分配 – 模块作者部分2》中深入讨论,在“Stayin’ alive – the OOM killer”部分进行讲解)。让我们耐心等待,等到那时再讨论。

当前slab缓存的状态——对象缓存、缓存中的对象数量、正在使用的数量、每个对象的大小等——可以通过几种方式查看:通过proc和sysfs文件系统查看原始数据,或者通过各种前端工具查看更易读的视图,如smem、vmstat、slabtop(以及eBPF的slabratetop)和内核提供的应用程序slabinfo

slab 缓存究竟占用了多少 RAM?

使用 slabinfo 工具可以轻松回答这个问题;我们使用 -T(总计)选项运行它:

$ sudo ./slabinfo -T | grep -A3 "^% PartObj"
% PartObj        4%               0%              54%               4%
Memory         1.2M             4.0K            31.0M           144.7M
Used           1.1M             4.0K            30.2M           138.5M
Loss          52.0K                0             1.2M             6.1M

在这里,总计约 145 MB 的 RAM 当前被 slab 缓存使用,其中 138.5 MB 正在实际使用(参见 Used 行),6.1 MB 被“丢失”,实际上是浪费(参见 Loss 行);后续会进一步讨论这个问题。

另一种常见的查询方式:

$ grep "^Slab" /proc/meminfo 
Slab:             148172 kB

重要提示

学习如何使用和解释 slabinfo 的各种选项(以及 slab[rate]top 工具)通常对学习和/或调试用户和内核内存问题非常有帮助。我在之前的书《Linux Kernel Debugging》中更详细地介绍了这些主题。以下是书中摘录的下一个常见问题解答:


在当前分配的众多 slab 缓存(且有一定数据内容)中,哪个占用了最多的内核内存?

这个问题也可以通过 slabinfo 工具轻松回答:一种方法是使用 -B 选项运行它,以字节显示占用空间,并按此列轻松排序。更简单的是,-S 选项可以让 slabinfo 按大小(从大到小)对 slab 缓存排序,并显示友好的可读单位。

通过这些分析可以发现,内核维护的缓存(包括页面缓存、目录缓存、inode 缓存和 slab 缓存等)可能占用了大量的内存(RAM)!实际上,这正是 Linux 的一个常见特性,这往往让初学者感到困惑:内核可以且会使用 RAM 进行缓存,从而大大提高性能。当然,随着内存压力的增加,内核会智能地减少用于缓存的内存量。

正如提到的,大量内存可能用于缓存(特别是页面缓存;它用于缓存文件内容,以便在执行 I/O 时加速访问)。只要内存压力较低,这种行为是可以接受的。

对于系统内存使用的详细信息,可以查看 /proc/meminfo,其中显示了大量字段。proc(5) 的手册页在 /proc/meminfo 部分描述了这些字段。


学习 slab 分配器的 API 用法

slab分配器的第二个设计理念

到目前为止,我们尚未解释 slab 分配器(或 slab 缓存)的第二个“设计理念”,即通过提供小而方便的缓存(通常是页面的一部分)来减轻页面分配器的高浪费(内部碎片)。接下来,我们将以实际方式展示其具体含义,并介绍内核 slab 分配器 API。

分配 slab 内存

虽然 slab 层中存在多种用于分配和释放内存的 API,但其中只有几个是关键的,其余大多是“辅助”函数(稍后会提到)。对于内核模块或设备驱动程序的作者来说,核心的 slab 分配 API 是 kmalloc()kzalloc()(后者更受推荐,稍后会解释原因)。它们的函数签名如下:

#include <linux/slab.h>
void *kmalloc(size_t size, gfp_t flags);
void *kzalloc(size_t size, gfp_t flags);

注意:使用任何 slab 分配器 API 时,请务必包含 <linux/slab.h> 头文件。

kmalloc()kzalloc() 是内核中最常用的内存分配 API。通过在 6.1.25 Linux 内核源码树中使用 cscope 代码浏览工具快速检查(不追求完全精确),可以发现大致的使用频率:

  • kmalloc() 被调用约 4,800 次,
  • kzalloc() 被调用略超过 13,000 次!

参数说明

  • 第一个参数:所需内存块的大小(以字节为单位)。
  • 第二个参数:指定分配内存的类型,通过熟悉的 GFP 标志(GFP flags)指定。如果不熟悉,可以先阅读关于 GFP 标志的章节。

避免整数溢出(IoF)问题

避免动态计算要分配的内存大小(即这两个 API 的第一个参数),以减轻整数溢出 (IoF) 错误的风险。内核文档明确警告了这一点:
内核文档中的相关说明

通常,应尽量避免使用内核中已被弃用的功能;相关内容记录在 Deprecated Interfaces


返回值与地址对齐

成功分配后,这些 API 的返回值是一个指针,即刚分配内存块(或 slab)的起始位置的内核逻辑地址(KVA,仍然是虚拟地址,而非物理地址)。与用户空间的 malloc()(及其相关 API)类似,kmalloc()kzalloc() API 在使用方式上相似,但在内部机制上完全不同。

返回的内存地址具有以下特性:

  1. 保证物理上是连续的。
  2. 保证地址对齐到 CPU 缓存行边界。

为什么选择 kzalloc()

kzalloc() API 被优先推荐的原因是它会将分配的内存初始化为零,这可以防止多种类型的 bug,例如未初始化内存读取(UMR)。虽然初始化内存可能会稍微降低性能,但除非该分配位于极端关键的代码路径中(实际上这种设计本身就不够理想),你应该遵循最佳实践,在分配内存时进行初始化,以避免大量内存错误和安全问题。

slab 分配器的性能优势

内核核心代码的许多部分确实使用 slab 层进行内存管理,其中包括时间关键代码路径,例如网络和块 I/O 子系统。为了最大化性能,slab(实际上是 SLUB)层代码已被设计为无锁的(通过一种称为 per-CPU 变量 的技术实现)。关于无锁内核同步的更多信息,可参阅相关章节或进一步阅读推荐资料。

释放 slab 内存

当然,你必须在未来的某个时刻释放你分配的 slab 内存(以避免内存泄漏);kfree() 函数就是为了这个目的。

使用 kfree() 释放 slab 内存
与用户空间的 free() API 类似,kfree() 接受一个参数——要释放的内存块的指针。它必须是一个有效的内核逻辑(或虚拟)地址(KVA),并且必须由 slab 层的 API 初始化,也就是说,它应该是 k{m|z}alloc() 或其助手函数的返回值。其 API 签名非常简单:
void kfree(const void *);
free() 一样,kfree() 没有返回值。如前所述,确保传递给 kfree() 的参数是由 k{m|z}alloc() 返回的精确值非常重要。传递错误的值会导致内存损坏,最终导致系统不稳定。

还有一些额外的注意点。假设我们使用 kzalloc() 分配了一些 slab 内存:

static char *kptr = kzalloc(1024, GFP_KERNEL);  

稍后,在使用完后,我们希望释放它,所以我们执行以下代码:

if (kptr)  
    kfree(kptr);  

这段代码——在释放内存之前检查 kptr 的值是否为 NULL——是多余的;只需要执行 kfree(kptr); 就可以了。

另一个错误代码的例子(伪代码)如下所示:

static char *kptr = NULL;  
while (<some-condition-is-true>) {  
       if (!kptr)  
            kptr = kzalloc(num, GFP_KERNEL);  
       [... 使用 slab 内存的代码 ...]  
       kfree(kptr);  
}  

有趣的是:从第二次循环迭代开始,程序员假设 kptr 指针变量在被释放后会被设置为 NULL!但事实并非如此(如果能这么做,语义会很清晰;另外,同样的情况也适用于“常规”的用户空间库 API)。因此,这里我们会遇到一个危险的 bug:在循环的第二次迭代时,if 条件很可能会返回 false,从而跳过内存分配。然后,我们遇到了 kfree(),这当然会导致内存损坏(由于双重释放 bug)!

我们在 LKM 中提供了这个具体案例的演示:ch8/slab2_buggy,并留给你去研究和尝试。

使用 kfree_sensitive()(以前的 kzfree())释放 slab 内存

关于在分配后(或期间)初始化内存缓冲区,正如我们在分配时提到的那样,释放内存时同样适用。你应该意识到,kfree() API 只是将刚刚释放的 slab 返回到其对应的缓存中,内部的内存内容保持不变!因此,在释放内存块之前,一个(稍微吹毛求疵的)最佳实践是清除(覆盖)内存内容。这一点在安全方面尤其重要(例如,在信息泄漏的情况下,恶意攻击者可能会扫描释放的内存以寻找秘密信息)。Linux 内核为此目的提供了 kfree_sensitive() API(其签名与 kfree() 相同)。

这个 API 在早期的内核版本中被错误地命名为 kzfree();从 5.9 版本开始,这个问题已被修正,重新命名为 kfree_sensitive()。具体原因可以在这里的提交中找到:github.com/torvalds/li…

小心!为了覆盖“秘密”信息,简单的 memset() 可能不起作用。为什么?因为编译器可能会优化掉这段代码(因为缓冲区不再使用)。David Wheeler 在他的著作《Secure Programming HOWTO》(dwheeler.com/secure-prog…)中提到了这个事实,并提供了解决方案:“一种在所有平台上都有效的方法是编写你自己的 memset 实现,并使第一个参数内部分‘可变’。”(这段代码是基于 Michael Howard 提出的一个解决方法):

void *guaranteed_memset(void *v, int c, size_t n) {  
    volatile char *p = v;  
    while (n--) *p++ = c;  
    return v;  
}  

然后,将此定义放入外部文件中以强制该函数为外部函数(在相应的 .h 文件中定义该函数,并在调用者中包含该文件,像通常一样)。这种方法似乎在任何优化级别下都安全(即使函数被内联)。

内核的 kfree_sensitive() API 应该可以正常工作。进行类似操作时要小心,特别是在用户空间中。

数据结构——一些设计提示

在内核空间使用 slab API 进行内存分配是强烈推荐的。首先,它保证了物理连续以及(硬件)缓存行对齐的内存。这对性能有益;此外,让我们来看一些能带来大回报的快速提示。

CPU 缓存可以提供巨大的性能提升。因此,特别是在时间关键的代码中,注意设计数据结构以获得最佳性能:
将最重要(频繁访问,“热”)的成员放在结构体的前面,并尽量靠在一起。为什么这么做呢?假设你的数据结构中有五个重要的成员(总大小大约是 56 字节),并且你将它们放在一起并靠前。假设 CPU 缓存行的大小是 64 字节。现在,当你的代码访问这些重要成员中的任何一个时(无论是读还是写),所有五个成员都会被一起加载到 CPU 缓存中,因为 CPU 的内存读写是以缓存行大小为单位进行的;这种做法优化了性能(因为在缓存内存上的工作通常比在 RAM 上工作快得多)。

尽量对齐结构成员,使得单个成员不会“跨越缓存行”。通常,编译器会在这方面有所帮助,甚至可以使用编译器属性明确指定这一点。

顺序访问内存会因有效的 CPU 缓存而带来高性能。然而,我们不能过于推崇将所有数据结构都做成数组!有经验的设计师和开发人员知道,使用链表是非常常见的。但是,这难道不会影响性能吗?嗯,的确会有一定影响。因此,提出一个建议:当使用链表时,保持链表的“节点”作为一个大的数据结构(并且“热”成员排在前面并靠在一起)。这样,我们尽量将两者的优点结合起来,因为大的结构体本质上是一个数组。(想想看,在第 6 章《内核内部基础——进程和线程》中我们看到的任务结构列表,任务列表就是一个完美的实际案例,展示了使用大数据结构作为节点的链表)。

在《进一步阅读》部分查看更多关于如何利用 CPU 缓存的技术。

接下来的部分将讨论一个关键方面:我们将学习内核在通过流行的 k{m|z}alloc() API 分配(slab)内存时,究竟使用了哪些 slab 缓存。

kmalloc 使用的实际 slab 缓存

在尝试使用基本的 slab API 编写内核模块之前,我们先进行一个快速的偏离——但这非常重要——了解通过 k{m|z}alloc() API 分配的内存究竟来自哪里。嗯,确实是来自 slab 缓存,但究竟是哪个缓存呢?通过在 sudo vmstat -m 输出中进行快速的 grep,我们可以看到以下内容(以下截图是在我们的 x86_64 Ubuntu 22.04 LTS 客户机上运行(其中一个)自定义的 6.1 内核的输出。我们故意过滤掉了 kmalloc-rcl-* 缓存;它们将在稍后解释):

image.png

这非常有趣!内核有一系列专门的 slab 缓存,用于不同大小的通用 kmalloc 内存,从 8,192 字节到仅 8 字节不等!这让我们意识到一个关键问题——使用页面分配器时,如果我们请求比如说 12 字节的内存,最终它可能会给我们分配一个完整的页面(4 KB),浪费就太大了。这里,使用 slab 分配器时,12 字节的内存请求最终实际上分配了 16 字节(来自图 8.7 中倒数第二个缓存)!太棒了。为什么会这样?应该很明显:内核的 slab 分配器采用最佳适配方法。另外,如果我们请求的内存量(例如 12 字节)介于两个 slab 缓存之间(即 kmalloc-8kmalloc-16),显然,内核会选择其中较大的一个(因为我们总是可以分配比请求更多的内存,而不是更少)。因此,实际上分配了一个 16 字节的 "slab",让我们误以为它是 12 字节大小。

另外,注意以下几点:

  • 在调用 kfree[_sensitive]() 时,内存会被释放回相应的 slab 缓存。
  • 对于 kmalloc 的 slab 缓存,精确的大小取决于体系结构。在我们的 Raspberry Pi 系统(当然是 ARM CPU)上,通用内存 kmalloc-N 缓存的大小从 64 字节到 8,192 字节不等。

上面的截图还揭示了一个线索。通常,内存需求是小到微小的内存碎片。例如,在前面的截图中,标记为 “Num” 的列代表当前活动对象的数量;最大数量来自 8 字节和 16 字节的 kmalloc slab 缓存(当然,这并不总是如此)。快速提示:可以使用 slabtop 工具(需要以 root 身份运行):顶部的行显示了当前最常用的 slab 缓存。

Linux 当然在不断发展。从 5.0 版本的主线内核开始,新增了一种名为可回收缓存(reclaimable cache)的 kmalloc 缓存类型(命名格式为 kmalloc-rcl-N)。因此,在 5.x 及更高版本的内核上执行如前所述的 grep,也会揭示这些缓存:

$ sudo vmstat -m | grep --color=auto "^kmalloc"
kmalloc-rcl-8k                0      0   8192      4
kmalloc-rcl-4k                0      0   4096      8
kmalloc-rcl-2k                0      0   2048     16
[...]
kmalloc-8k                  144    144   8192      4
kmalloc-4k                 1185   1200   4096      8
kmalloc-2k                 1072   1072   2048     16
[...]

新的 kmalloc-rcl-N 缓存有助于提高内部效率(例如,在内存压力下回收页面,以及作为反碎片化的措施)。不过,像你这样的模块作者不需要关心这些细节。(这个工作的提交可以在这里查看:github.com/torvalds/li…。)

vmstat -m 本质上是内核 /sys/kernel/slab 内容的一个包装器(更多内容稍后讨论)。可以使用诸如 slabinfoslabtop 以及强大的 crash 工具来查看 slab 缓存的深层内部细节(在“实时”系统上,相关的 crash 命令是 kmem -skmem -S)。

好了!现在是时候再次动手编写代码,展示如何使用 slab 分配器 API 了!

编写一个内核模块,使用基本的 slab API

在以下代码片段中,查看演示内核模块代码(位于 ch8/slab1/)。在初始化代码中,我们仅通过 kmalloc()kzalloc() API 执行几次 slab 层分配,打印一些信息,并在清理代码路径中释放缓冲区(当然,完整的源代码可以在本书的 GitHub 仓库中找到)。让我们逐步查看代码的相关部分。

在这个内核模块的初始化代码开始时,我们通过 kmalloc() slab 分配 API 为一个全局指针(gkptr)分配了 1,024 字节的内存(C 程序员请注意:指针本身没有内存!)。注意,既然我们肯定是在进程上下文中运行,并且“可以安全地休眠”,我们为第二个参数使用了 GFP_KERNEL 标志(如果你想回顾更多信息,可以查看前面一节《GFP 标志 – 更深入的探讨》):

// ch8/slab1/slab1.c  
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__  
[...]  
#include <linux/slab.h>  
[...]  
static char *gkptr;  
struct myctx {  
    u32 iarr[100];  
    u64 uarr[100];  
    char uname[128], passwd[16], config[16];  
};  
static struct myctx *ctx;  
static int __init slab1_init(void)  
{  
    /* 1. 使用 kmalloc() 分配 1 KB 的 slab 内存 */  
    gkptr = kmalloc(1024, GFP_KERNEL);  
    if (!gkptr) {  
        WARN_ONCE(1, "kmalloc() 失败!\n");  
        /* 如前所述,内存分配失败时实际上不需要打印错误消息;  
         * 这种情况“通常”不会发生,如果发生,内核会发出一系列消息。  
         * 在这里,我们苛刻地使用了 WARN_ONCE() 宏,因为这是一个“学习”程序……  
         * 在实践中,只需返回适当的负 errno(这里是 -ENOMEM)即可。 */  
        goto out_fail1;  
    }  
    pr_info("kmalloc() 成功,(实际的 KVA)返回值 = %px\n", gkptr);  
    /* 提交 091cb09(5.4 内核)使得 print_hex_dump_bytes() 在 KERN_DEBUG 级别打印;  
     * 实际上,它只会在 -DDEBUG(或者使用动态调试功能时)显示出来。  
     * 所以,我在 Makefile 中启用了 DEBUG。 */  
    print_hex_dump_bytes("gkptr 在 memset 之前: ", DUMP_PREFIX_OFFSET, gkptr, 32);  
    memset(gkptr, 'm', 1024);  
    print_hex_dump_bytes(" gkptr 在 memset 之后: ", DUMP_PREFIX_OFFSET, gkptr, 32);  

在上面的代码中,还注意到我们使用了内核的 print_hex_dump_bytes() 便捷例程,这是一种方便的方式,将缓冲区内存以人类可读的格式进行转储。它的函数签名如下:

void print_hex_dump_bytes(const char *prefix_str, int prefix_type,  
     const void *buf, size_t len);  

它的参数如下:

  • prefix_str:你希望添加到每一行十六进制转储的前缀字符串;
  • prefix_type:可以是 DUMP_PREFIX_OFFSETDUMP_PREFIX_ADDRESSDUMP_PREFIX_NONE
  • buf:要进行十六进制转储的源缓冲区;
  • len:要转储的字节数。

注意(如注释所示):从 Linux 5.4 开始,只有在定义了 DEBUG 符号时(或者使用内核的动态调试功能时),printk 才会发出日志(这一主题在《Linux 内核调试》一书中有深入讨论)。

快速提示:当你给定一个 Linux 内核项目的 Git 提交 ID(哈希值)(它通常是一个简化的 ID,例如上面代码注释中的 091cb09),你可以通过 git blame 命令轻松查找它。或者,对于 Linux 内核,你可以访问 github.com/torvalds/li…,并通过提交 ID 在仓库中进行搜索,它将显示相应的提交记录。(在这个示例中,这就是提交:github.com/torvalds/li…)。

接下来是一个典型的策略——许多设备驱动程序遵循的最佳实践:它们将所有所需的或“上下文”信息保存在一个数据结构中,通常称为驱动程序上下文(或驱动程序私有)结构体。我们通过声明一个(简单/示例)数据结构 myctx 以及一个全局指针 ctx 来模仿这一做法(结构体和指针定义在上面的代码块中)。它的分配由 kzalloc() API 提供:

   /* 2. 分配内存并初始化我们的 'context' 结构体 */  
    ctx = kzalloc(sizeof(struct myctx), GFP_KERNEL);  
    if (!ctx)  
        goto out_fail2;  
    pr_info("context 结构体已分配并初始化(实际的 KVA 返回值 = %px)\n", ctx);  
    print_hex_dump_bytes("ctx: ", DUMP_PREFIX_OFFSET, ctx, 32);  
    return 0;        /* 成功 */  
out_fail2:  
    kfree(gkptr);  
out_fail1:  
    return -ENOMEM;  
}  

在这里,我们通过 kzalloc() 包装器 API 为 myctx 数据结构的大小分配并初始化了 ctx。随后的十六进制转储将显示(如果定义了 DEBUG!)它确实被初始化为全零(为了可读性,我们只“转储”前 32 字节)。

请注意我们如何使用 goto 处理错误路径;这在本书前面已经提到过几次,因此这里就不再重复了。最后,在内核模块的清理代码中,我们使用 kfree() 释放了两个缓冲区,防止了任何内存泄漏:

static void __exit slab1_exit(void)  
{  
    kfree(ctx);  
    kfree(gkptr);  
    pr_info("释放了 slab 内存,已移除\n");  
}

接下来是我在 Raspberry Pi 4 上运行的示例截图。我使用了我们的 ../../lkm 便捷脚本来构建、加载模块并运行 dmesg

image.png

好了,现在你已经掌握了使用常见的 slab 分配器 API:kmalloc()kzalloc()kfree[_sensitive](), 让我们继续深入学习。

在下一节中,我们将深入探讨一个非常关键的问题——通过 slab(和页面)分配器获得内存时的大小限制。继续阅读!

kmalloc API 的大小限制

页面和 slab 分配器的一个关键优势是,它们在分配内存时提供的内存块不仅是虚拟上连续的(显然是),而且还保证是物理上连续的内存。这一点非常重要,肯定有助于提升性能。

但是(总会有一个“但是”,对吧!),正因为有了这个保证,在执行分配时就无法提供任何给定的较大(内存)大小。换句话说,你通过一次调用 k{m|z}alloc() API 从 slab 分配器获得的内存量必定有上限。这个上限是多少呢?(这确实是一个非常常见的问题)

首先,你需要理解的是,技术上讲,这个限制由两个因素决定:

  1. 系统的页面大小(由 PAGE_SIZE 宏决定)。
  2. “订单”数量(由 MAX_ORDER 宏决定);即,页面分配器(或 BSA)自由链表数据结构中的列表数量(见图 8.2)。

在标准的 4 KB 页面大小和典型的 MAX_ORDER 值为 11 的情况下,单次调用 kmalloc()kzalloc() API 可以分配的最大内存量是 4 MB。这在 x86_64 和 ARM(32 位和 64 位)架构上都适用。

你可能会想,究竟是如何得出这个 4 MB 限制的?想一想:一旦 slab 分配请求超过了内核提供的最大 slab 缓存大小(通常是 8 KB),内核会将请求传递给页面分配器。页面分配器可以分配的最大内存大小由 MAX_ORDER 决定。当 MAX_ORDER 设置为 11 时,最大可分配的缓冲区大小是:
2^(MAX_ORDER - 1) = 2^10 页 = 1024 页 = 1024 * PAGE_SIZE 字节 = 1024 * 4 KB = 4 MB

测试限制 – 单次调用内存分配

对于开发者(以及所有人来说),一个关键的事情是要保持实证的工作方式!“实证”(empirical)这个词意味着基于经验或观察,而不是理论。这是一个关键的规则,必须始终遵循——不要仅仅假设事情或接受表面现象。亲自尝试一下,看看结果如何。

让我们做些有趣的事情:我们将编写一个内核模块,从(通用)slab 缓存中分配内存(通过 kmalloc() API)。我们将在一个循环中进行操作,每次循环迭代分配并随后释放一个(计算出的)内存量。这里的关键点是,我们将每次分配的内存量按给定的步长递增(我们甚至会将这个数量模块化)。

kmalloc() 失败时,循环终止;这样,我们就可以测试通过一次调用 kmalloc() 实际上可以分配多少内存(你会发现,当然,kzalloc() 作为 kmalloc() 的简单包装器,面临的是完全相同的限制)。

在以下代码片段中,我们展示了相关代码。test_maxallocsz() 函数是在内核模块的初始化代码中调用的:

// ch8/slab3_maxsize/slab3_maxsize.c  
[...]  
static int stepsz = 204800;  // 200 Kb  
module_param(stepsz, int, 0644);  
MODULE_PARM_DESC(stepsz,  
"每次循环迭代时分配量的增加值(默认=200 KB)");  
static int test_maxallocsz(void)  
{  
  size_t size2alloc = 0;  
  void *p;  
  while (1) {  
      p = kmalloc(size2alloc, GFP_KERNEL);  
      if (!p) {  
          pr_alert("kmalloc 失败,size2alloc=%zu\n", size2alloc);  
   // WARN_ONCE(1, "kmalloc 失败,size2alloc=%zu\n", size2alloc);  
          return -ENOMEM;  
      }  
      pr_info("kmalloc(%7zu) = 0x%px\n", size2alloc, p);  
      kfree(p);  
      size2alloc += stepsz;  
  }  
  return 0;  
}  

注意我们的 printk 函数如何使用 %zu 格式说明符来表示 size_t(本质上是一个无符号整数)变量?%zu 是一个便于移植的辅助,它使得该变量的格式在 32 位和 64 位系统上都能正确显示!

现在,让我们在运行标准 Raspberry Pi OS 的 Raspberry Pi 4 设备上运行这个内核模块。几乎在插入模块时,你会看到一个错误消息 “Cannot allocate memory”,这是由 insmod 进程打印的;以下(截断的)截图展示了发生了什么:

image.png

这是预期的结果!想一想,内核模块代码的初始化函数在调用后确实失败了(返回错误代码 -ENOMEM)。不要因此而感到困惑;查看内核日志可以揭示发生了什么(如图 8.9 所示)。

事实上,在第一次测试运行此内核模块时,你会发现当 kmalloc() 失败时,内核会转储一些诊断信息,其中包括一个相当长的内核堆栈跟踪。这是由于内核代码路径在内存分配失败时调用了 WARN() 宏,因为在正常情况下,这种情况是不应该发生的。再看看图 8.9 底部的堆栈跟踪;堆栈跟踪中包含了 __alloc_pages() 函数;在之前《页面分配器的工作原理》一节中,我们明确指出这个函数是文档中所说的“区域伙伴分配器的核心”!它出现在堆栈(调用或回溯)跟踪中,表明它失败了;因此,我们看到了当前的结果。

(也许有个小疑问:为什么 kmalloc() 返回的指针,即 KVAs(如图 8.8 所示),通常是相同的?很简单:这是因为我们分配内存后几乎立即释放(free)了内存块,通常导致内存块变得可用,从而被重用。)

因此,我们的 slab 内存分配是成功的,直到某个点。为了清楚地看到失败的地方,只需在内核日志(dmesg)中向下滚动。以下截图显示了这个过程:

image.png

啊哈,看看输出的最后一行(图 8.10):kmalloc() 在尝试分配超过 4 MB 的内存时失败(在这里是 4,300,800 字节),正如预期的那样;直到那时,它才成功。

现在,也可以看到堆栈回溯的开始部分;从底部向上阅读,显然一切都很清楚——你可以看到我们的 kmalloc() 最终调用了 __alloc_pages(),而由于失败,导致了这一系列事件的发生。

顺便说一下,注意到我们在循环中的第一次分配刻意设为了大小 0;它并没有失败(但这并不意味着你可以使用它):
kmalloc(0, GFP_xxx) 返回零指针;在 x86[_64] 上,它的值是 16 或 0x10(有关详细信息,请参见 include/linux/slab.h)。实际上,它是一个无效的虚拟地址,位于页面 0 的 NULL 指针陷阱中。访问它会导致页面故障(来自 MMU),从而引发错误。

类似地,尝试对 NULL 或零指针执行 kfree(NULL)kfree() 会导致 kfree() 成为一个无操作(no-op)。

不过,等一下——这里有一个非常重要的点需要注意:在《kmalloc 使用的实际 slab 缓存》这一节中,我们看到用于为调用者分配内存的 slab 缓存是 kmalloc-N 类型的 slab 缓存,其中 N 的值从 64 字节到 8,192 字节不等(在本讨论中的 Raspberry Pi 和 ARM[64] 处理器上)。此外,供参考,你可以快速执行以下命令来验证这一点:

sudo vmstat -m | grep -v "-rcl-" | grep --color=auto "^kmalloc-[0-9]"  

但是很明显,在前面的内核模块代码示例中,我们通过 kmalloc() 分配了更大数量的内存(从 0 字节到 8 KB,再到 4 MB)。在底层,实际的工作方式是,kmalloc() API 仅在内存分配小于或等于 8,192 字节时使用 kmalloc-N slab 缓存(如果有的话);对于更大内存块的分配请求,它会将请求传递给底层的页面(或伙伴系统)分配器(见图 8.1)!

现在,回想一下我们学到的内容:页面分配器使用伙伴系统自由链表(按每个节点:区域划分),并且在自由链表上排队的最大内存块大小为 2^(MAX_ORDER-1) = 2^10 页,给定页面大小为 4 KB 且 MAX_ORDER 为 11 时,最大可分配的内存大小为 4 MB。这与我们之前的理论讨论完美契合。

因此,事实已经明确:无论是理论上还是实践中,你现在都可以看到(再次提醒,假设页面大小为 4 KB 且 MAX_ORDER 为 11),通过一次调用 kmalloc()kzalloc() 分配的最大内存大小是 4 MB。

通过 /proc/buddyinfo 虚拟文件进行检查

非常重要的一点是,虽然我们发现 4 MB 是一次性请求时可以获得的最大内存量,但这绝对不意味着每次请求时都能获得这么多内存。当然不是;这完全取决于在内存请求时特定自由链表中可用的空闲内存量。想一想:如果你在一个已经运行了几天(或几周)的 Linux 系统上运行会怎么样?找到物理连续的 4 MB 空闲 RAM 的可能性是相当低的(这再次取决于系统的内存大小和其工作负载)。

作为经验法则,如果前面的实验没有得到我们认为的最大分配(即 4 MB),为什么不在一个刚刚启动的系统上再试一次呢?现在,获得物理连续的 4 MB 空闲内存块的机会会大大增加。

不确定这一点吗?让我们再次采用实证的方法,查阅 /proc/buddyinfo 的内容——无论是正在使用的系统,还是刚启动的系统——来查看内存块是否可用。在以下命令行片段中,在我的本地 x86_64 Ubuntu 系统(已运行超过 7 天,内存为 32 GB)上,我们查找并突出显示了 Normal 区域中自由块的顺序 10 数量——即 4 MB 的空闲内存块(输出有换行):

$ grep -w "Normal" /proc/buddyinfo  
Node 0, zone   Normal  23653   5314   1284    747    233     67     16     17     18      0      0  

正如我们之前在《了解页面分配器自由链表的组织结构》一节中学到的那样,前面的代码块中的数字按顺序表示从 0 到 MAX_ORDER-1(通常是从 0 到 11,或 0 到 10),它们代表在该顺序中的 2^order 连续空闲页面帧数量。

在上面的输出中,我们可以看到,对于 Normal 区域,我们在顺序 10 的列表上没有空闲块(即 4 MB 的内存块;它为 0)。在一个刚启动的 Linux 系统上,我们很可能会看到这些内存块。在以下输出中,展示了在刚刚重新启动的同一系统上,我们可以看到在节点 0 的 Normal 区域中有 6,571 个(!)空闲的物理连续 4 MB RAM 块:

$ grep -w "Normal" /proc/buddyinfo  
Node 0, zone   Normal   3859   6778   6131   5025   3810   2274   1130    382     78      9   6571  

你会意识到,随着系统运行时间的增加,将更大尺寸的内存块“切割”成更小的块的机会增加……因此,我们发现内存随着时间的推移倾向于从更高的订单(更大的内存块)流向更低的订单(较小的内存块)。

当然,还有更多内容可以探索。在接下来的部分中,我们将深入讨论如何使用 slab 分配器——包括资源管理 API 替代方案、可用的额外 slab 助手 API,以及关于现代 Linux 内核中的 cgroups 和内存的相关说明。

Slab 分配器 – 一些附加细节

还有一些关键点需要进一步探索。首先,我们将介绍内核的资源管理版本的内存分配 API,然后介绍内核中一些额外的 slab 助手例程,最后简要了解一下 cgroups 和内存。我们建议你也阅读这些部分。请继续阅读!

使用内核的资源管理内存分配 API

尤其在开发设备驱动程序时,内核提供了一些资源管理的内存分配 API。它们被正式称为设备资源管理(devres)API(相关的官方内核文档链接为 devres API)。所有这些 API 都以 devm_ 为前缀,虽然有许多此类 API,我们这里只关注一个常见的用例——使用资源管理版本的 API 代替常规的内存分配 API,如 k{m|z}alloc()。它们如下所示:

#include <linux/device.h>  
void *devm_kmalloc(struct device *dev, size_t size, gfp_t gfp);  
void *devm_kzalloc(struct device *dev, size_t size, gfp_t gfp);  

这些资源管理 API 有用的原因是,开发者不需要显式地释放它们分配的内存。内核的资源管理框架保证会在驱动程序分离时,或如果是内核模块的话,在模块被移除时自动释放内存缓冲区(或者设备被分离时,先发生哪个就释放)。这个特性立刻增强了代码的健壮性。为什么?很简单,我们都是人,都会犯错。内存泄漏(尤其是在错误代码路径中)确实是一个非常常见的 bug!

关于这些 API 使用的几个相关要点:

  • 关键点:请不要盲目地将所有 k{m|z}alloc() 实例替换为相应的 devm_k{m|z}alloc()!这些资源管理分配仅设计用于设备驱动程序的初始化方法和/或 probe() 方法中(所有与内核统一设备模型工作的驱动程序通常都会提供 probe()remove()(或 disconnect())方法。我们在这里不会进一步讨论这些方面)。
  • 通常更倾向于使用 devm_kzalloc(),因为它还会初始化缓冲区。内部(像 kzalloc() 一样),它仅仅是 devm_kmalloc() API 的一个薄包装器。
  • 第二个和第三个参数与 k{m|z}alloc() API 的参数一样——要分配的字节数和使用的 GFP 标志。而第一个参数是指向 struct device 的指针,显然,它代表你的驱动程序所驱动的设备(每个驱动程序都可以访问该结构体)。

由于这些 API 分配的内存会自动释放(在驱动程序分离或模块移除时),因此你不需要做任何事情(来释放内存)。不过,它可以通过 devm_kfree() API 被手动释放。然而,如果你手动释放它,通常表明你使用的资源管理 API 并不适合。

许可:这些资源管理 API 仅对使用 GPL 许可证的模块开放(并因此可用)。

额外的 slab 助手 API

有一些辅助的 slab 分配器 API,作为 k{m|z}alloc() API 家族的好朋友。这些包括 kcalloc()kmalloc_array() API,用于为数组分配内存,以及 krealloc(),其行为类似于用户空间 API realloc()。(在底层,kmalloc_array() 调用了 check_mul_overflow() 宏,该宏执行带有溢出检查的乘法运算!很巧妙。)

除了为数组元素分配内存外,array_size()struct_size() 内核助手例程也非常有用。特别是 struct_size() 被广泛用于防止——并且确实修复——许多整数溢出(IoF 及相关)bug,尤其是在分配结构体数组时,这是一个非常常见的任务。举个例子,以下是来自 net/bluetooth/mgmt.c 的小代码片段:

rp = kmalloc(struct_size(rp, addr, i), GFP_KERNEL);  
if (!rp) {  
     err = -ENOMEM; [...]  

顺便说一下,浏览一下 include/linux/overflow.h 内核头文件会很有价值。
(记得查看我们之前讨论的 kfree_sensitive() API。)

这些 API 的资源管理版本也可用:devm_kcalloc()devm_kmalloc_array()

控制组和内存

Linux 内核支持一个非常复杂的资源管理系统,称为 cgroups(控制组)。简而言之,cgroups 用于按层次结构组织进程并执行资源管理。

我们将在第 11 章《CPU 调度器 – 第二部分》中,特别是在《介绍 cgroups》一节中,详细介绍 cgroups,并通过示例展示如何使用 cgroups v2 的 CPU 控制器。如果以下段落现在对你来说不太明白,我建议你先阅读一下第 11 章和该节内容,然后再回来看这里。

在 cgroups 框架中的多个资源控制器中,有一个用于控制内存带宽的控制器。通过精心配置,系统管理员可以有效地调节系统上内存的分配。内存保护可以通过某些 memcg(内存控制组)伪文件来实现,既可以是(所谓的)硬保护,也可以是最佳努力保护(特别是 memory.minmemory.low 文件)。类似地,在一个 cgroup 内,memory.highmemory.max 伪文件是控制 cgroup 内存使用的主要机制。当然,关于这个话题还有很多内容,本文仅提到了一部分,更多详细信息可以参考内核文档中的新 cgroups(v2)文档:cgroup-v2 文档

好了,现在你已经学会了如何更好地使用 slab 分配器 API,让我们再深入一点。实际上,关于 slab 分配器 API 分配的内存块的大小,还有一些重要的注意事项。请继续阅读,了解这些注意事项是什么!

使用 slab 分配器时的注意事项

我们将把这部分讨论分为三部分。首先,我们将重新审视一些必要的背景知识(这些内容我们之前已经覆盖过),然后通过两个用例具体说明我们要探讨的问题——第一个用例非常简单;第二个则是更贴近实际的用例,展示了我们要探讨的问题。

背景细节和结论

到目前为止,你已经学到了一些关键点:

  • 页面(或伙伴系统)分配器以 2 的幂次方式分配内存页面;换句话说,分配请求的粒度是一个页面(通常为 4KB)。2 的幂次被称为订单(order);通常范围从 0 到 10(在 x86[_64] 和 ARM[_64] 上,假设页面大小为 4KB,MAX_ORDER 为 11)。
  • 这很好,除非它不好。当请求的内存量非常小,或者刚好超过某个阈值时,内存浪费(或内部碎片)可能会非常严重。

在操作系统及其驱动程序的日常运行中,页面的碎片(远小于 4,096 字节)请求是非常常见的。因此,基于页面分配器之上的 slab 分配器(见图 8.1)被设计为具有对象缓存和小型通用内存缓存,以有效地处理小内存量的请求。

  • 页面分配器保证分配物理上连续的页面和硬件缓存行对齐的内存。
  • slab 分配器也保证分配物理上连续的、硬件缓存行对齐的内存。

太好了——这使我们得出结论,当所需的内存量较大且是 2 的幂次(或接近)的完美值时,使用页面分配器;当内存非常小(小于一个页面)时,使用 slab 分配器。事实上,kmalloc() 的内核源代码中有一条评论,简洁地总结了如何使用 kmalloc() API(以下为加粗部分):

// include/linux/slab.h  
[...]  
 * kmalloc - 分配内存  
 * @size: 需要多少字节的内存。  
 * @flags: 分配的内存类型。  
 * `kmalloc` 是内核中分配小于页面大小的对象的常用方法。  

听起来很不错,但问题仍然存在!为了理解这一点,让我们学习如何使用另一个有用的 slab API——ksize()。它的函数签名如下:

size_t ksize(const void *);

ksize() 的参数是指向已分配 slab 内存的指针(它必须是有效的)。换句话说,它是从某个 slab 分配器 API 返回的地址(通常是从 [devm_]k{m|z}alloc() 返回的)。ksize() 的返回值是实际分配的字节数。

好了,现在你知道了 ksize() 的用途,让我们在更实际的场景中使用它,首先是一个简单的用例,然后是一个更真实的用例!

使用 ksize() 测试 slab 分配 – 用例 1

为了理解我们要讨论的问题,考虑一个简单的例子。(为了可读性,我们不会展示必要的有效性检查。此外,由于这是一个很小的代码片段,我们没有将其提供为内核模块在书中的代码库中):

struct mysmallctx {  
    int tx, rx;  
    char passwd[8], config[4];  
} *ctx;  
pr_info("sizeof struct mysmallctx = %zd bytes\n", sizeof(struct mysmallctx));  
ctx = kzalloc(sizeof(struct mysmallctx), GFP_KERNEL);  
pr_info("(到现在,结构体已分配并初始化为零)\n"
        "*实际*分配的大小 = %zu bytes\n", ksize(ctx));

在我的 x86_64 Ubuntu 客户机系统上运行时,输出如下所示:

$ dmesg  
[...]  
sizeof struct mysmallctx = 20 bytes  
(到现在,结构体已分配并初始化为零)  
*实际*分配的大小 = 32 bytes  

所以,我们尝试通过 kzalloc() 分配了 20 字节,但实际上获得了 32 字节,因此浪费了 12 字节,浪费率为 60%!这是预期的结果。回想一下 kmalloc-N slab 缓存——在 x86 上,有一个 16 字节的缓存和一个 32 字节的缓存(以及其他许多缓存)。因此,当我们请求介于这两者之间的内存时,显然会从较大的缓存中分配内存(分配器本质上使用了一种最佳匹配方法)。顺便说一下,在我们的 ARM 架构的 Raspberry Pi 系统上,kmalloc 的最小 slab 缓存是 64 字节,因此当我们请求 20 字节时,当然会得到 64 字节。

注意,ksize() API 仅适用于已分配的 slab 内存;你不能对任何页面分配器 API(我们在《学习如何使用页面分配器 API》一节中看到过)返回的值使用 ksize()

接下来是第二个更有趣的用例。

使用 ksize() 测试 slab 分配 – 用例 2

好吧,现在,让我们扩展之前的内核模块(ch8/slab3_maxsize)到 ch8/slab4_actualsize。在这里,我们将执行相同的循环,通过 kmalloc() API 分配内存并像以前一样释放它,但这次,我们还将记录每次循环迭代中由 slab 层分配给我们的实际内存量,通过调用 ksize() API:

// ch8/slab4_actualsize/slab4_actualsize.c  
static int test_maxallocsz(void)  
{  
   /* 这次,将 size2alloc 初始化为 100,否则我们会遇到  
     * 除法错误! */  
    size_t size2alloc = 100, actual_alloced;  
    void *p; 
 
    pr_info("kmalloc(      n) :  实际分配 : 浪费 : 浪费 %%\n");  
    while (1) {  
        p = kmalloc(size2alloc, GFP_KERNEL);  
        if (!p) {  
          pr_alert("kmalloc 失败,size2alloc=%zu\n", size2alloc); // 细节处理  
          return -ENOMEM;  
        }  
        actual_alloced = ksize(p);  
        /* 打印 size2alloc、实际分配的内存、两者的差值以及浪费的百分比  
         * (当然是整数运算 :-)) */  
        pr_info("kmalloc(%7zu) : %7zu : %7zu : %3zu%%\n",  
            size2alloc, actual_alloced, (actual_alloced - size2alloc),  
            (((actual_alloced - size2alloc) * 100) / size2alloc));  
        kfree(p);  
        size2alloc += stepsz;  
    }   
    return 0;  
}

这个内核模块的输出确实很有意思,值得一看!在下面的图中,我们展示了我在运行自定义构建的 6.1 内核的 x86_64 Ubuntu 22.04 LTS 客户机上获得的部分输出截图(通过我们的便捷 lkm 脚本加载):

image.png

该模块的 printk 输出可以在图 8.11 的上半部分和中间部分清晰地看到。截图的其余部分是来自内核的一些诊断信息——这是作为内核空间内存分配请求失败时发出的。所有这些内核诊断信息都是由于内核第一次调用 WARN_ONCE() 宏而产生的,因为底层的页面分配器代码 mm/page_alloc.c:__alloc_pages()——即“伙伴系统分配器”的核心——失败了!这种情况通常不应该发生,因此会产生诊断信息(关于内核诊断的细节超出了本书的范围,因此我们将在此忽略。不过,我们将在接下来的章节中在一定程度上检查内核栈回溯。此外,这类细节在《Linux内核调试》一书中有深入探讨)。

顺便说一下,在这个模块中,我们甚至没有编写模块退出(清理)例程,因为我们知道它会在初始化代码路径本身就返回失败,因此我们永远不需要使用 rmmod 卸载它。

解释案例 2 的输出

仔细查看前面的截图(图 8.11;这里我们将忽略由 WARN_ONCE() 宏发出的内核诊断信息,该宏在内核级内存分配失败时被调用!)。尽管输出非常直观,图 8.11 输出的关键部分包含来自 dmesg 的时间戳(我们忽略它),随后是四列数据,正如其表头所示:

  • kmalloc(n)kmalloc() 请求的字节数(其中 n 是所需的字节数)。
  • Actual:由 slab 分配器实际分配的字节数(通过 ksize() 显示)。
  • Wastage:浪费的字节数;即实际分配字节数与所需字节数之间的差异。
  • Waste % :浪费的百分比。

举个例子,在图 8.11 中第二个分配(通过矩形框选)的情况,我们请求了 204,900 字节,但实际分配了 262,144 字节(256 KB)。这是有道理的,因为这是伙伴系统空闲列表中一个页面分配器列表的确切大小(它是 order 6,因为 26 = 64 页 = 64 × 4 = 256 KB;见图 8.2)。因此,差值,也就是浪费的字节数,实际上是 262,144 - 204,900 = 57,244 字节,换算成百分比是 27%。

它的工作原理是这样的:请求的(或所需的)大小越接近内核可用的(或实际的)大小,浪费的字节数就越少;反之亦然。

让我们再看一个来自前面输出的例子(为清晰起见,以下是截取的输出):

[  ]
[ 3948.215700] kmalloc(1638500) : 2097152 :  458652 :  27%
[ 3948.215741] kmalloc(1843300) : 2097152 :  253852 :  13%
[ 3948.215782] kmalloc(2048100) : 2097152 :   49052 :   2%
[ 3948.216309] kmalloc(2252900) : 4194304 : 1941404 :  86%
[ 3948.216396] kmalloc(2457700) : 4194304 : 1736604 :  70%
[ 3948.216478] kmalloc(2662500) : 4194304 : 1531804 :  57%
[  ]

从前面的输出中,我们可以看到,当 kmalloc() 请求 1,638,500 字节(约 1.5 MB)时,实际上获得了 2,097,152 字节(正好 2 MB),浪费了 27%。随后,浪费逐渐减少,随着分配接近“边界”或阈值(即内核的 slab 缓存或页面分配器内存块的实际大小),浪费百分比依次降到 13%,然后是 2%。但请注意:在下一个分配中,当我们超过 2 MB 的阈值时,请求 2,252,900 字节时,实际得到 4 MB,浪费达到了 86%!然后,浪费再次减少,随着接近 4 MB 的内存大小……

这很重要!你可能会认为,单单使用 slab 分配器 API 就已经非常高效,但当请求的内存量超过 slab 层可以提供的最大大小时(通常是 8 KB,这是我们前面实验中的情况),slab 层会调用页面分配器。因此,页面分配器在面临通常的浪费问题时,最终会分配比你实际需要的内存要多得多,或者根本不需要使用的内存。真是浪费!

故事的教训:检查并重新检查你使用 slab API 分配内存的代码。使用 ksize() 进行试验,弄清楚实际分配了多少内存,而不是你认为分配了多少内存。

没有捷径可走。好吧,倒是有一个:

  • 如果你需要的内存少于一页(这是一个非常典型的用例),那么直接使用 slab API。
  • 如果你需要的内存超过 1 页,那么前面的讨论就适用了。还有一点:使用 alloc_pages_exact()/free_pages_exact() API(在 “‘exact’ 页面分配器 API 对” 部分讨论过)也应该有助于减少浪费(当然,这意味着你决定使用这些 API 而不是 slab API;这并不是典型的情况)。

更好的是,你可以通过 /sys/kernel/slab/<slab-name>/slab_size 伪文件来检查实际的 – 更准确的 – slab 分配内存大小(我们将在下一章的 “提取关于 slab 缓存的有用信息” 部分中介绍这一点)。

绘制图形

顺便提一下,我们可以使用著名的 gnuplot 工具根据之前收集的数据绘制图形。我们需要对内核模块做最小的修改,只输出我们想要绘制的内容:所需(或请求的)内存分配量(x 轴)和运行时发生的浪费百分比(y 轴)。你可以在本书的 GitHub 仓库中找到我们稍微修改过的内核模块的代码,地址是:ch8/slab4_actualsz_wstg_plot

我们还将“步长”从 200 KB 降低到 20 KB,以使图形更精细。因此,我们构建并插入这个内核模块,调整内核日志,并将数据以适合 gnuplot 所需的列格式保存(文件名为 plotdata.txt)。好消息是:所有这些操作,包括通过 gnuplot 生成图形,都通过我们的 plot_graph.sh 辅助脚本实现自动化;只需运行它并查看结果!(若想了解非常有用的 gnuplot 工具的工作原理,请参考本章的“进一步阅读”部分。)

我们还在 GitHub 仓库中的该目录下提供了一个有用的 Readme 文件来指导你。看!图形已经生成:

image.png

这个“锯齿形”图形帮助我们直观地理解你刚刚学到的内容。当 kmalloc()(或 kzalloc(),或任何页面分配器 API)的内存请求大小接近内核预定义的任何空闲列表大小时,浪费就较少。但是,一旦超过这个阈值,浪费就会迅速增加——出现陡峭的峰值!——接近 100%(正如前面图表中几乎完美垂直的线所示)。

在内核中查找内部碎片(浪费)

前一部分展示了在通过两个现在熟悉的 slab API(k{m|z}alloc())分配内存时如何发生内部碎片——浪费。尽管在更大的内存分配请求中浪费更为明显,但即使是较小的分配请求,浪费也会增加——绝对浪费的数量较少,但浪费的百分比保持一致,当请求的内存量接近某个“阈值”时,浪费的百分比可能相当高(典型例子:请求 63 字节时得到 64 字节,而请求 65 字节时得到 128 字节,可以使用 ksize() API 来验证这一点)。

这里,我们关注的是内存分配请求小于一页时的浪费情况,这通常是请求的内存量。我们将向你展示两种量化浪费的方法:一种是简单的方法,另一种相对繁琐(通过自定义脚本可以简化)但非常有价值。让我们从第一种方法开始。

使用 slabinfo 的简单方法

slabinfo 工具,在本章“关于(slab)内存使用的常见问题及其答案”部分中我们已经学习过,它非常有用。查看浪费或“损失”的简单方法是运行 slabinfo(以 root 用户身份)并使用 -L 选项开关(按损失排序;第一个命令仅用于显示 -L 选项的帮助):

# sudo slabinfo -h |grep "^-L"
-L|--Loss              Sort by loss
# slabinfo -L | head -n5
Name          Objects  Objsize      Loss Slabs/Part/Cpu  O/S O %Fr %Ef  Flg
dentry          148162     192     14.9M      5297/20/0   28 1   0  65  PaZFU
kmalloc-4k        1153    4096     14.2M       579/4/0     2 3   0  24  PZFU
buffer_head     132952     104     13.4M      6648/4/0    20 0   0  50  PaZFU
lsm_inode_cache 134919      24     13.0M      3988/53/0   34 0   1  19  PZFU
ext4_inode_cache 58224    1184      7.4M       2330/2/0   25 3   0  90  PaZFU

这里的关键列是标记为 Loss 的列;它指定了给定 slab 缓存(第一列)到目前为止的浪费量;输出按浪费量(从高到低)排序。第二列和第三列分别指定了 slab 中的对象数量和每个对象的大小;因此,它们的乘积就是该 slab 缓存所占用的总空间。供参考,O/S 表示每个 slab 中的对象数,O 是分配的顺序,接下来的两列指定了缓存中空闲内存和有效内存使用的百分比,最后一列是 slab 标志(这里是我在启动时传递的标志;这一部分很快会被覆盖)。

slabinfo -L 输出的优点是显示了所有 slab 缓存(不仅仅是 kmalloc-N)的浪费,但缺点是只显示总的浪费量;与以下方法相比,它的优势就显得比较有限。

更好的是,你可以通过 /sys/kernel/slab/<slab-name>/slab_size 伪文件检查实际的——更准确的——slab 内存分配大小(我们将在下一章的“提取关于 slab 缓存的有用信息”部分中讨论这个内容)。

通过 alloc_traces 和自定义脚本获取更多细节

正如在下一章中将详细解释的那样,内核有时会遇到一个困难的情况,主内存(RAM)和交换空间(swap)都耗尽了;在这种危急情况下,内核别无选择,只能启动(可怕的?)OOM(Out Of Memory)杀手!
这是一个内核组件,它会“杀死”主要的罪魁祸首——内存占用过多的进程(它还会杀死其后代!谈论冷酷无情...)。
在内存有限的系统(例如嵌入式系统或其他)中,如果 slab 缓存存在大量浪费(损失),完全有可能在系统启动期间触发 OOM 杀手,甚至在内核初始化时就会发生。这种情况促使一位开发者提交了一个补丁(最近已合并到 6.1 版本内核中);补丁的链接如下:6edf257。这个补丁建立了一种跟踪所有 kmalloc-N slab 缓存浪费(和其他信息)的方法,重要的是,它甚至展示了分配请求的调用栈!这让我们能够查看分配请求的详细信息,从而帮助我们调试这种情况...

与我们的讨论相关的关键点是:一旦开启了某些 SLUB 调试标志,浪费信息将会整合到一个新的伪文件中(名为 alloc_traces),它位于 debugfs 下:/sys/kernel/debug/slab/kmalloc-*。让我们来看一下(这里假设已经启用了 CONFIG_SLUB_DEBUG=y,这通常是默认的):

重启你的 Linux 系统,并在引导加载程序中传递这个内核命令行参数(除了通常的参数外):slub_debug=FZPU
你可以在此链接中找到所有内核参数的文档(非常有价值):Kernel Parameters
内核的 SLUB 层通常内置完整的调试技术,但默认情况下是关闭的(因为它会影响性能)。为了启用调试,提供了某些 SLUB 调试标志,这些标志通过 slub_debug=<debug-flags> 内核参数传递。我们在这里使用的 SLUB 调试标志(FZPU)在《Linux 内核调试》一书中有详细介绍;我们在这里不再重复。你也可以通过官方内核文档查找:SLUB Docs

一旦进入 root shell,查找给定 slab(SLUB)缓存的分配和释放跟踪伪文件;例如,查看 kmalloc-64 slab 缓存的内容:

# ls /sys/kernel/debug/slab/kmalloc-64/
alloc_traces  free_traces

好的,现在查看 alloc_traces 文件的当前内容:

# cat /sys/kernel/debug/slab/kmalloc-64/alloc_traces
975 populate_error_injection_list+0x8d/0x110 waste=15600/16 age=6363993/6363993/6363994 pid=1 cpus=0
        __kmem_cache_alloc_node+0x279/0x2b0
        kmalloc_trace+0x2a/0xa0
        populate_error_injection_list+0x8d/0x110
        init_error_injection+0x1b/0x75
        do_one_initcall+0x49/0x210
        kernel_init_freeable+0x27b/0x2e8
        kernel_init+0x1b/0x160
        ret_from_fork+0x22/0x30

    403 get_mountpoint+0xab/0x190 waste=9672/24 age=6360787/6361173/6363388 pid=332-1199 cpus=0-1,3-5
        __kmem_cache_alloc_node+0x279/0x2b0
        kmalloc_trace+0x2a/0xa0
        get_mountpoint+0xab/0x190
        lock_mount+0x53/0x100
        path_mount+0x535/0xb80
        __x64_sys_mount+0x10c/0x150
        do_syscall_64+0x5c/0x90
        entry_SYSCALL_64_after_hwframe+0x63/0xcd
[ ... ]

输出按最大浪费排序;上面显示了前两条。我们来解释一下它们(首先看第一个 trace):

975 populate_error_injection_list+0x8d/0x110 waste=15600/16 age=6363993/6363993/6363994 pid=1 cpus=0

populate_error_injection_list() 函数(在偏移量为 0x8d 字节的位置,函数长度为 0x110 字节)发出了 975 次内存请求,每次请求 64 字节(因为这是 kmalloc-64 缓存),每次浪费 16 字节,因此总共浪费了 975 * 16 = 15,600 字节。栈追踪(从下往上读)显示了调用历史,帮助我们调试!
让我们深入研究:该函数位于 lib/error-inject.c:populate_error_injection_list();查看其代码(版本 6.1.25)显示通过 slab 层进行的单次内存分配如下:

struct ei_entry *ent;
[…]
ent = kmalloc(sizeof(*ent), GFP_KERNEL);
if (!ent)
           break;
[…]

现在,分配的结构体实例 struct ei_entry 的大小为 46 + 2 = 48 字节(在 64 位系统上;通过查看可以确认,注意对齐要求可能导致填充(这里有 2 字节填充))。因此,每次分配由 kmalloc-64 slab 缓存服务(最佳匹配),因此每次浪费的内存为 64 - 48 = 16 字节。这看起来似乎是可以接受的,但显然它已经发生了 975 次,总共浪费了 15,600 字节!如果内存很快被释放,这可能不会有太大问题;但如果不是呢?啊,那就麻烦了(包括触发 OOM 杀手的情况)……至少现在我们可以有效调试了!

练习:解释上面看到的第二条 trace 实例

请注意——浪费跟踪仅针对 kmalloc-* slab 缓存(因为这些是满足(slab)内存请求的缓存)。

运行脚本查看 SLUB 浪费或损失

为了简化查找 /sys/kernel/debug/slab/kmalloc-*/alloc_traces 伪文件的工作,我编写了一个快速的自定义脚本(ch8/wastage_kmalloc_slabs.sh);它仅显示发生浪费的分配实例的第一行(不包括堆栈跟踪),并按浪费量从高到低排序,适用于所有 kmalloc-* 缓存(它基本上是迭代以下命令的输出:grep -r -H -w waste /sys/kernel/debug/slab/kmalloc-[1-9]*/alloc_traces)。不仅如此,它还区分了由内核(内部)例程和由内核模块执行的 slab 分配。为了保持输出的可读性,它仅显示前十个“浪费最多”的记录;包含完整详细信息的“报告”会被指向。你可以查看它的代码并试一试。

注意: 要尝试这个脚本,你必须运行一个最近的 6.1 内核(或更高版本),并在内核命令行中传递 slub_debug=FZPU 参数。

以下是一个示例运行(为了变化,这是在我本机的 x86_64 Fedora 39 系统上运行的最近的 6.6 内核;我截断了右侧的一部分,以便获得一个可读的截图):

image.png

该脚本(是对最近内核的 alloc_traces 伪文件的包装器)基本上是遍历每个 kmalloc-N slab,并仅显示那些导致正浪费的分配(以及浪费的精确量);不过它可能需要一些时间来执行……在这次运行中,模块没有造成内存浪费。

解释仍然保持不变;此外,模块可能会显示出来(其名称在方括号内)。waste=x/y 是关键部分;x 是总浪费的字节数,y 是每次浪费的字节数;我们按 x(即总浪费的字节数)从高到低排序输出。

因此,这个脚本为我们提供了一种快速方式,来发现是否我们的内核或模块代码浪费了宝贵的内核内存!

(注意到 age=... 字段了吗?我们将在下一章中介绍它的含义,所以请保持关注。)

至此,我们已经覆盖了大量内容。接下来的部分将简要概述 SLUB 层的优缺点。

Slab 层 - 优缺点

现在,让我们总结一下 slab(SLUB)分配器的优缺点,列出在下面的表格中:

Slab 分配器: 优点Slab 分配器: 缺点
快速 – 使用内核的身份映射 RAM(在启动时获取并映射);页面位于低内存区域,因此是预映射的;无需设置页面表(就像页面分配器/BSA 一样)。单个 API 调用能够分配的内存量有限;实际情况是,假设 MAX_ORDER=11 和页面大小为 4K,它的最大分配为 4 MB。
保证内存块是物理连续的并且与 CPU 缓存线对齐。安全性:默认情况下,释放的内存不会被清除,这可能会导致信息泄露的情况。可以通过启用 CONFIG_PAGE_POISONING 或使用 kfree_sensitive() 来避免(可能会带来轻微的性能影响)。
能够分配页面的碎片(通常从 8 字节(在 x86 上)到 8K)。必须仔细检查分配(使用 ksize()、slab 工具或 alloc_traces)以确保不会发生过多的浪费。

表 8.3: Slab 分配器(k{m|z}alloc() / k[z]free())的优缺点总结


Slab 层 - 内核中的实现

本章的最后,我们简单介绍一下内核中实际的 slab 层实现(是的,有几种不同的实现)。让我们来看看!

内核中至少有三种不同的互斥的 slab 分配器实现;在运行时,只能使用其中的一种。运行时使用的实现是在配置内核时选择的(你在第二章《从源代码构建 6.x Linux 内核 - 第一部分》中详细学习了这个过程)。相关的内核配置选项如下:

  • CONFIG_SLAB
  • CONFIG_SLUB
  • CONFIG_SLOB

第一个(SLAB)是早期的、支持较好的实现,但优化较少。第二个实现,SLUB,也称为“无队列分配器”,在内存效率、性能和更好的诊断方面相较于第一个有了重大改进,并且是默认选择的实现。SLOB 分配器(也称为简单分配器)是一种极简化的实现,根据内核配置帮助文档,它“在大系统上表现不佳”。官方内核文档中有一页关于 SLUB 的有用页面:SLUB 简短用户指南;其中的很多内容与解释和分析 slabinfo 的输出有关,而这正是我在《Linux 内核调试》一书中详细介绍的内容。

总结

在本章中,你详细学习了页面(或伙伴系统)和 slab 分配器的工作原理。回顾一下,实际分配(和释放)内存的“引擎”最终是页面(或伙伴系统)分配器,而 slab 分配器则在其之上提供优化,主要针对典型的小于一页的内存分配请求,并有效地分配几个知名的内核数据结构(‘对象’)。

你学习了如何高效使用页面和 slab 分配器暴露的 API,通过几个内核模块的演示,帮助你以实践的方式理解这些内容。相当多的关注点(也是非常正确的)放在了开发者发出(slab)内存请求时请求特定字节数 N 的问题上,你学习到,这种方式可能非常不理想,内核实际上会分配更多的内存(浪费可能接近 100%)!你现在知道如何检查并减少这些情况的发生。做得好!

下一章将讲解更多关于最优分配策略的内容,以及一些更高级的内核内存分配话题,包括创建自定义 slab 缓存、使用内核的 vmalloc 分配接口、OOM 杀手的原理等。因此,首先确保你已经理解了本章的内容并完成了内核模块和作业(如后文所示)。然后,我们可以进入下一章!

问题

在本章结束时,这里有一组问题,供你测试自己对本章内容的理解:问题列表。你可以在本书的 GitHub 仓库中找到一些问题的答案:解决方案

进一步阅读

为了帮助你深入了解本主题,我们提供了一份相当详细的在线参考资料和链接(有时甚至是书籍)的列表,包含在本书的 GitHub 仓库中的《进一步阅读》文档中。你可以在这里找到《进一步阅读》文档:Further Reading.md