0%

Linux内存子系统

物理内存管理

内存的硬件电路与接口

内存硬件实现

物理内存管理(page、zone、node)

物理内存管理

  • 页: struct page
  • 分区: struct zone
  • 内存节点: struct node

在Linux内核中,内存被分为几个不同的区域,每个区域被称为”Zone”,它们分别是ZONE_DMA、ZONE_NORMAL和ZONE_HIGHMEM。它们之间的区别如下:

  1. ZONE_DMA:指低端范围的物理内存,某些工业标准体系(ISA)设备需要用到它(chatGPT回答:这个区域的内存被用于DMA(Direct Memory Access)操作,这些操作是直接访问内存的,而不是通过CPU。由于一些历史原因,这个区域的内存只能使用32位的地址。在现代硬件上,DMA操作通常使用的是高速缓存和总线控制器,因此ZONE_DMA的大小通常非常小。)
  2. ZONE_NORMAL:部分的内存由内核直接映射到线性地址空间的较高部分(chatGPT回答:这个区域包含了内核和大多数用户空间程序所需的内存。这个区域的内存可以通过32位和64位的地址进行访问。由于这个区域的内存使用非常广泛,因此它通常占用系统内存的大部分);
  3. ZONE_HIGHMEM:系统预留的可用内存空间,不被内核直接映射(chatGPT回答:这个区域包含了物理地址高于896MB的内存。这个区域的内存只能通过64位地址进行访问。在一些旧的硬件上,这个区域的内存可能没有被映射到内核地址空间,因此访问这个区域的内存需要特殊的处理。)

联系:这些区域都是内存管理的一部分,它们被用于不同的目的,并且它们的大小和可用性都有所不同。它们之间的联系是它们都属于内存的不同区域,而且它们都被Linux内核用于管理系统内存。

物理页帧: struct page

物理页帧实际上存放在物理内存中,即在系统的随机存储器(RAM)中。在Linux内核中,物理内存被划分为不同的区域(zone),每个区域包含一定数量的物理页帧。Linux内核使用伙伴系统(Buddy System)算法来管理物理页帧,以便快速地分配和释放页帧。当物理页帧被分配给某个进程或内核对象时,内核会将其映射到相应的虚拟地址空间中,以便进程可以访问这些物理页帧。因此,物理页帧的存放位置取决于系统的物理内存布局和伙伴系统算法的实现方式。

通过mem_map指针可以获取到存放page内存区域的起始地址

内存区域:struct zone

在Linux内核中,内存区域(Memory Zone)是一段物理内存的逻辑分区,用于跟踪和管理系统中的物理页帧。每个内存区域都由一个 struct zone 结构体表示,该结构体包含了有关内存区域的元数据,例如区域的起始地址、大小、类型、页帧的状态等信息。

struct zone 是内存管理子系统中的关键数据结构之一,它包含了一些重要的字段,如 free_area,它是一个数组,用于存储当前可用的物理页帧;lock 字段用于保护内存区域的并发访问;zone_pgdat 字段指向所属的内存节点(NUMA Node)等。内存区域还与其他内存管理子系统中的数据结构(例如 struct page)紧密关联,这些数据结构用于跟踪和管理物理页帧。

在Linux内核中,物理内存被划分为不同的内存区域,每个内存区域通常包含一定数量的物理页帧。不同的内存区域用于不同的目的,例如 ZONE_DMA 用于存储DMA设备可直接访问的内存;ZONE_NORMAL 用于存储普通进程和内核对象的内存等。通过将物理内存划分为不同的内存区域,Linux内核能够更有效地管理内存,以适应不同的应用场景和硬件配置。

内存节点: node

在 NUMA 架构的计算机系统中,内存被分配到不同的内存节点(Memory Node)中,每个内存节点包含一组物理内存和一个或多个 CPU 插槽。每个内存节点都有自己的内存控制器,负责管理节点中的物理内存。

在 Linux 内核中,每个内存节点都由一个 struct pglist_data 结构体表示,该结构体包含了与内存节点相关的信息,例如节点的编号、大小、可用页帧数、内存区域和内存池等。内存区域和内存池是 Linux 内核中用于管理物理内存的两个关键抽象,它们被用于跟踪和管理物理页帧,以便快速地分配和释放内存。

每个内存节点都与一个或多个 CPU 插槽相对应,其中一个节点通常对应于一个 CPU 插槽。在 NUMA 架构中,不同的 CPU 插槽访问不同的内存节点,因此内存节点之间的访问延迟可能会不同。为了最大程度地减少访问延迟,Linux 内核通常会将本地 CPU 插槽与本地内存节点匹配,以便最小化访问延迟。在访问远程内存节点时,Linux 内核使用一些高级的缓存技术(例如页表、TLB 等)来最大程度地减少访问延迟。

总之,内存节点是 NUMA 架构中的一个重要概念,它允许系统管理员和应用程序开发人员针对特定的硬件配置和应用场景进行优化,以最大程度地提高系统性能和效率。

物理内存管理架构

物理内存管理是操作系统内存管理的一个重要组成部分,用于管理系统中的物理内存。在 Linux 内核中,物理内存管理架构由以下三个关键组件组成:

  1. 物理页帧(Physical Page Frame):物理页帧是 Linux 内核中最基本的内存管理单元,它表示一个连续的物理内存页。每个物理页帧都由一个 struct page 结构体来表示,该结构体包含了物理页帧的元数据,例如页帧的状态、所属的内存区域、引用计数等信息。物理页帧的状态可以是“空闲”、“已使用”或“已保留”等,它们可以用于跟踪和管理系统中的物理内存。
  2. 内存区域(Memory Zone):内存区域是物理内存的逻辑分区,它由一个 struct zone 结构体表示。每个内存区域包含一定数量的物理页帧,用于跟踪和管理物理页帧的状态。不同的内存区域用于不同的目的,例如 ZONE_DMA 用于存储 DMA 设备可直接访问的内存;ZONE_NORMAL 用于存储普通进程和内核对象的内存等。
  3. 内存节点(Memory Node):内存节点是 NUMA 架构中的一个重要概念,它将物理内存划分为不同的节点,每个节点包含一组物理内存和一个或多个 CPU 插槽。在 Linux 内核中,每个内存节点都由一个 struct pglist_data 结构体表示,它包含了有关内存节点的元数据,例如节点的编号、大小、可用页帧数、内存区域和内存池等信息。内存节点通常用于跟踪和管理系统中的物理内存,并优化内存访问延迟。

总之,物理内存管理架构是 Linux 内核中内存管理的核心组成部分,它通过物理页帧、内存区域和内存节点等抽象,提供了一个灵活、高效的物理内存管理框架,以支持不同的应用场景和硬件配置。

核心结构体关联

伙伴系统: buddy system

伙伴系统(Buddy System)是一种经典的动态内存分配算法,它的主要思想是将物理内存按照二进制指数的方式进行分割和组织,以实现高效的内存分配和回收。在 Linux 内核中,伙伴系统被广泛用于物理内存管理,用于管理系统中的空闲物理页帧。

伙伴系统的基本原理是将物理内存划分为一系列大小为 $2^n$ 的连续内存块,每个内存块都可以看作是一组相同大小的物理页帧。当需要分配一块大小为 $2^k$ 的内存时,伙伴系统会在内存块中查找一个大小为 $2^k$ 的空闲块,如果找到了,则将该块分配给请求者;否则,伙伴系统会将一个大小为 $2^{k+1}$ 的内存块一分为二,将其中一半分配给请求者,将另一半保留为伙伴块,以备后续分配使用。

具体来说,伙伴系统会将所有可用的物理内存块组织成一棵二叉树,树的根节点对应整个物理内存,树的每个节点对应一个内存块,节点的左子节点和右子节点分别对应内存块的两半。每个节点包含了有关该内存块的元数据,例如块的大小、状态、伙伴块等信息。初始时,整个二叉树中只有一个根节点,对应整个物理内存,此时该节点的大小为 $2^n$,其中 $n$ 是系统中可用的最大物理页帧数的二进制位数。

当需要分配一块大小为 $2^k$ 的内存时,伙伴系统会在二叉树中查找一个大小为 $2^k$ 的空闲块。查找过程从根节点开始,递归向下遍历二叉树,直到找到一个合适的空闲块或者遍历到叶子节点为止。如果找到了大小为 $2^k$ 的空闲块,则将该块分配给请求者;否则,伙伴系统会将一个大小为 $2^{k+1}$ 的内存块一分为二,将其中一半分配给请求者,将另一半保留为伙伴块。分配过程中,如果某个节点的伙伴块也是空闲的,则将两个伙伴块合并为一个更大的内存块,然后继续向上递归合并,直到合并到根节点为止。

伙伴系统的优点在于它能够高效地管理不同大小的内存块,并且支持动态内存分配和回收。由于伙伴系统按照二进制指数的方式进行内存分割,因此它能够避免内存碎片的问题,提高内存利用率。另外,伙伴系统的内存分配算法也比较简单,容易实现和维护,因此被广泛应用于操作系统和嵌入式系统等领域。

不过,伙伴系统也存在一些缺点。首先,由于伙伴系统只能管理固定大小的内存块,因此在进行内存分配时可能会存在浪费的情况。例如,当请求分配一个大小为 $2^k$ 的内存块时,如果最接近 $2^k$ 的可用内存块的大小为 $2^{k+1}$,则必须将该内存块一分为二,导致浪费一半的内存空间。其次,伙伴系统的内存回收算法也可能导致一些性能问题。由于伙伴系统需要不断合并相邻的空闲块以减少内存碎片,因此内存回收的时间复杂度较高,可能会影响系统的响应时间。

总的来说,伙伴系统是一种非常实用的内存管理算法,它能够高效地管理物理内存,并且在许多操作系统和嵌入式系统中得到广泛应用。但是,在应用伙伴系统时也需要根据实际情况进行权衡,以避免潜在的性能问题和内存浪费。

  • 物理内存由页分配器(page allocator)接管
  • 内存块的申请、释放过程
  • 伙伴算法、阶数

伙伴系统管理思路:将所有的空闲物理页按照不同大小划分成不同大小的块,相同大小的块使用链表将它们链起来,将链表头保存在一个数组里面,如果使用两个物理页拼成一个内存块(8KB),使用链表将其链起来。数组的索引就是阶数,索引i表示链表头指向链表的内存块大小为 $2^i$ 个物理页,索引值最大为MAX_ORDER-1, 一般MAX_ORDER值为11, 即最多可管理内存块大小最大为1024个物理页(大小为4M,也成为pageblock)组成的链表。

当用户进行申请时,根据用户申请的大小到对应链表中去找,如果用户申请4KB的物理页,那么从索引值为0的对应链表去找, 找到后将page结构体地址返回给用户,用户根据page就可以操作对应的内存了。

当释放一个内存块后,将空闲的内存块插入到相应的链表中,如果相邻的内存块地址是相邻的,可以通过伙伴算法进行合并,添加到相应的链表上

通过伙伴系统可以一定程度上缓解内存碎片化问题。

核心结构体关联

通过cat /proc/buddyinfo 查看系统中每个内存区域的伙伴系统分配器的状态

展示了三个内存区域的系统,每个区域在一个 NUMA 节点中。

  • 第一个区域是 DMA 区域,它包含 38 个可用页,有 5 个 2^0 大小的块、5 个 2^1 大小的块、2 个 2^2 大小的块、4 个 2^3 大小的块、3 个 2^4 大小的块、2 个 2^5 大小的块、1 个 2^6 大小的块、1 个 2^7 大小的块和 2 个 2^8 大小的块。
  • 第二个区域是 DMA32 区域,它包含 6451 个可用页,有 969 个 2^0 大小的块、1083 个 2^1 大小的块、432 个 2^2 大小的块、282 个 2^3 大小的块、151 个 2^4 大小的块、81 个 2^5 大小的块、36 个 2^6 大小的块、14 个 2^7 大小的块、14 个 2^8 大小的块和 10 个 2^9 大小的块。
  • 第三个区域是 Normal 区域,它包含 347 个可用页,有 214 个 2^0 大小的块、114 个 2^1 大小的块、98 个 2^2 大小的块、84 个 2^3 大小的块、17 个 2^4 大小的块、2 个 2^5 大小的块、5 个 2^6 大小的块、4 个 2^7 大小的块、1 个 2^8 大小的块和 0 个 2^9 大小的块。

这些数字表示可用于内存分配的块大小,数字越大,块大小越大,适合分配更大的内存。因此,大部分内存都是分配给 DMA32 区域,而较小的内存块则分配给 DMA 区域和 Normal 区域。

新版伙伴系统

通过cat /proc/pagetypeinfo可以查看详细信息

通过page 里的mapcount查看判断该page指定的物理页有没有添加到伙伴系统中,也可用来判断该物理页是否被摘除给用户使用

page_order表示内存块的大小, refcount表示引用计数,即当前页有多少程序在用

物理页面的迁移类型:migratetype

在Linux内核的伙伴系统中,用来管理内存的数据结构之一是struct free_area,它代表了一个空闲页块区域。

在内存初始化之后,内核会根据不同的内存区域将整个物理内存划分成多个大小不等的块,每个块都是由一定数量的物理页帧组成的。在伙伴系统中,这些块被分成不同的大小类别,并按照大小从小到大排列,每个大小类别对应着一个struct free_area结构体。

struct free_area结构体包含了一些成员变量,其中最重要的是free_listfree_list是一个指向struct list_head类型的指针数组,用于存储该空闲页块区域中所有空闲页的链表头指针。每个链表头指针指向一个双向链表,其中每个节点都表示一个空闲页。

struct free_area结构体中的其他成员变量还包括:

  • nr_free:该空闲页块区域中空闲页的数量。
  • order:该空闲页块区域中每个空闲页块的大小(以2的次幂的形式表示,例如,order=3表示每个空闲页块大小为$2^3=8$页)。
  • lock:该空闲页块区域的自旋锁,用于保护该空闲页块区域的并发操作。
  • min_partial:该空闲页块区域中最小的部分空闲页块大小(以2的次幂的形式表示),用于在合并空闲页块时确定合适的合并方式。

struct free_area结构体存储了空闲页的信息,它们被用于维护伙伴系统中的内存分配和释放。当内核需要分配一定数量的物理页帧时,它会查找一个大小适合的空闲页块区域,并将其中一个或多个空闲页分配给请求者。当内核需要释放已经分配的物理页帧时,它会查找该页所在的空闲页块区域,并将该页添加到相应的空闲页链表中。通过维护空闲页块区域的数据结构,伙伴系统能够高效地管理物理内存,并减少内存碎片的产生。

枚举类型:migratetype

查看页面的迁移类型:# cat /proc/pagetypeinfo

  • 可移动的:用户进程申请的内存
  • 可回收的:文件系统的page cache
  • 不可移动的:内核镜像区的物理内存

在 Linux 内核中,migratetype 是用来表示伙伴系统中不同分配方式的枚举类型。伙伴系统会把所有空闲的物理页框分为几个不同的区域,每个区域都有不同的分配方式和大小限制。

migratetype 枚举类型中定义了下面几种分配方式:

  • MIGRATE_UNMOVABLE:表示不能迁移的物理页,例如内核占用的页和一些不可交换的页。
  • MIGRATE_RECLAIMABLE:表示可回收的物理页,例如页缓存和匿名页。
  • MIGRATE_MOVABLE:表示可迁移的物理页,例如用户态进程的内存。

当进程申请内存时,伙伴系统会根据申请内存的大小和分配方式在相应的区域中寻找空闲的物理页。如果当前区域中没有足够的空闲页,则伙伴系统会尝试从其他区域中迁移一些页过来。

migratetype 的定义如下:

1
2
3
4
5
6
7
enum migratetype {
MIGRATE_UNMOVABLE,
MIGRATE_RECLAIMABLE,
MIGRATE_MOVABLE,
MIGRATE_PCPTYPES, /* the number of types on the pcp lists */
MIGRATE_RESERVE, /* the reserve migratetype */
};

其中 MIGRATE_PCPTYPES 表示每个 CPU 中 per-CPU 链表中的 migratetype 数量,MIGRATE_RESERVE 则是保留的 migratetype 类型。

为什么要引入迁移类型

Per-CPU页帧缓存

Per-CPU页帧缓存是一种Linux内核内存管理机制,用于加速分配和释放物理页面的过程。每个CPU都拥有一个私有的页帧缓存,可以缓存一定数量的物理页面,这些页面是预先从伙伴系统中分配出来的。

当需要分配物理页面时,内核首先会检查CPU的页帧缓存中是否有可用的物理页面。如果有,直接从该缓存中分配页面,避免了锁竞争和缓存冲突等因素带来的性能损失。如果该CPU的页帧缓存中没有可用页面,则会从伙伴系统中的相应链表中获取一组页面,并将其中一个页面分配给该CPU的页帧缓存中。当需要释放物理页面时,同样会首先检查该CPU的页帧缓存中是否有该页面,如果有,则直接释放,否则再将该页面放回伙伴系统的相应链表中。

Per-CPU页帧缓存可以提高物理页面分配和释放的效率,避免了多CPU之间的锁竞争和缓存冲突等问题,同时也可以降低内存分配器的负载,提高系统整体的性能。

页分配器接口:alloc_pages

伙伴分配器接口

伙伴分配器(buddy allocator)是一种Linux内核内存管理机制,用于分配和释放连续的物理页面,基于伙伴系统的原理实现。在Linux内核中,伙伴分配器提供了一组接口,允许内核代码和驱动程序直接调用,实现动态分配和释放物理页面的功能。

以下是伙伴分配器接口的主要函数和作用:

  1. __get_free_pages(gfp_t gfp_mask, unsigned int order):分配一个连续的物理页面块,其大小为2的order次方页。gfp_mask参数指定内存分配的标志,比如是否允许睡眠等;order参数指定页面块的大小,必须为正整数且不超过内核配置的最大页面块大小。
  2. __free_pages(struct page *page, unsigned int order):释放一个连续的物理页面块,其大小为2的order次方页。page参数是页面块中任意一个页面的指针,内核会自动计算出整个页面块的起始地址。
  3. alloc_pages(gfp_t gfp_mask, unsigned int order):与__get_free_pages()函数功能相同,但是会将返回的物理页面块清零。
  4. free_pages(unsigned long addr, unsigned int order):与__free_pages()函数功能相同,但是接受的参数是物理页面块的起始地址,而不是页面指针。

这些接口是内核和驱动程序进行物理页面分配和释放的主要手段,为了保证内核的稳定性和安全性,它们的调用需要遵守一定的规则和限制,比如不能在中断上下文中调用分配函数,需要保证内存分配和释放的匹配等。此外,内核还提供了一些其他的伙伴分配器接口,比如针对高端内存和NUMA架构的接口,以满足不同场景下的内存管理需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <linux/module.h>
#include <linux/init.h>
#include <linux/gfp.h>
#include <linux/mm.h>

MODULE_LICENSE("GPL");

#define PAGE_SIZE 4096

static int __init buddy_example_init(void)
{
struct page *page;
void *ptr;

printk(KERN_INFO "Buddy example: start\n");

// 分配 4 个页面的内存
page = alloc_pages(GFP_KERNEL | __GFP_ZERO, 2);
if (!page) {
printk(KERN_ERR "Buddy example: failed to allocate memory\n");
return -ENOMEM;
}

// 将分配的页面映射到虚拟地址空间
ptr = page_address(page);
printk(KERN_INFO "Buddy example: allocated %d pages at %p\n", 1 << page->order, ptr);

// 释放已分配的页面
__free_pages(page, 2);
printk(KERN_INFO "Buddy example: memory freed\n");

return 0;
}

static void __exit buddy_example_exit(void)
{
printk(KERN_INFO "Buddy example: exit\n");
}

module_init(buddy_example_init);
module_exit(buddy_example_exit);

在上面的示例中,我们使用了 alloc_pages 函数来分配 4 个页面的内存。GFP_KERNEL 标志表示要使用内核的物理内存管理系统来分配内存。__GFP_ZERO 标志表示要将分配的内存清零。函数返回一个指向 struct page 的指针,该结构体描述了已分配的页面。

接下来,我们调用 page_address 函数将已分配页面映射到虚拟地址空间。该函数返回一个指向映射地址的指针。我们在内核日志中打印出分配的页面数量和页面的地址。

最后,我们使用 __free_pages 函数释放已分配的页面。该函数接收两个参数,第一个参数是指向 struct page 的指针,第二个参数是已分配页面的数量的对数。在本例中,我们释放了 4 个页面,即 $2^2$ 个页面。

注意,由于伙伴系统只能处理 2 的幂次方数量的页面,因此在调用 alloc_pages 函数时,我们需要传递 2 作为分配页面的数量的对数,以便分配 4 个页面的内存。在 __free_pages 函数中,我们也将 2 传递给分配页面的数量的对数参数。

连续内存分配器:CMA

当很多驱动和DMA去申请大于4M的物理内存时,伙伴系统可能就满足不了我们的需求,常规的方法在内存区域预留ZONE_DMA空间,但这块区域在不需要使用的时候占用空间,对于内存来说是大大的浪费,在嵌入式平台上往往使用CMA申请大块物理内存

CMA区域是预留出来的专门用作DMA或是其它多媒体模块申请大块物理内存使用,当没有模块使用时,会将里里面空闲的物理页通过伙伴系统接口释放到伙伴系统中给其它程序使用,如果模块时候时,会把里面正在使用的物理页进行迁移,移动到ZONE_NORMAL中,CMA区域又变成连续的内存了,就可以分配给DMA模块使用了

伙伴系统的初始化

memblock管理器

在Linux内核中,memblock是一个内存分配器,它负责跟踪系统中可用的物理内存。Linux内核在启动时会对物理内存进行扫描,并将其划分为若干个不同的内存块,每个内存块包含了一段连续的物理内存。memblock会记录下这些内存块的地址和大小,并提供一些接口供内核和驱动程序使用。

使用memblock,内核可以动态地分配和释放物理内存,这在某些场景下非常有用,比如系统启动时需要根据实际内存大小动态地调整内存分区。memblock还可以让内核更加高效地利用可用内存,因为它可以将不同的内存块分配给不同的任务,从而减少内存碎片。

除了动态内存分配外,memblock还可以用于处理内存热插拔。如果系统中的内存条被插入或移除,memblock会更新内存块列表,并通知内核重新扫描可用内存。这样,内核就可以及时地响应内存的变化,并动态地调整内存分配。

总之,memblock是Linux内核中一个非常重要的组件,它负责管理物理内存,支持内核和驱动程序进行动态内存分配和释放,并且能够及时响应内存热插拔事件。

memblock内存释放

主要将物理内存释放到伙伴系统

.init内存释放

memeblock中的reserved memory

  • 内核中的代码段(.text/.data/.bss)(.init除外)
  • initrd
  • dtb
  • 设备树中的reserved-memory区域(CMA除外)
  • 临时页表
  • reserved memory的初始化

CMA内存释放

slab、slob、slub分配器

来自chatGPT的解释

在Linux内核中,有多种内存分配器可供使用。其中,slab、slob和slub是其中比较常见的三种内存分配器,下面我将对这三种内存分配器进行讲解。

  1. Slab分配器 Slab是一种常见的内存分配器,它采用了对象缓存池的方式来管理内存。Slab将内存分配成一个个大小相等的对象,每个对象都有自己的描述符,用来记录该对象是否已经被使用,以及指向下一个空闲对象的指针等信息。Slab的优点是能够高效地处理频繁的小内存分配请求,缺点是不太适合处理大块内存分配请求。
  2. Slob分配器 Slob是一种简单的内存分配器,它不需要为每个内存块维护描述符等元数据信息,这使得Slob的内存开销相对较小。Slob采用了一个堆栈的数据结构来管理内存,分配内存时从堆栈顶部弹出一块内存,并将其分配给请求者。当内存被释放时,内存块被添加到堆栈的顶部。Slob的缺点是不能处理大块内存分配请求。
  3. Slub分配器 Slub是一种高效的内存分配器,它是Slab分配器的改进版。Slub通过将对象分配到slab的末尾来实现内存的动态分配。Slub能够更好地处理大块内存分配请求,同时保持Slab的高效性。Slub还引入了一些性能优化机制,如使用本地CPU缓存来提高内存分配效率等。

总之,slab、slob和slub都是内核中常用的内存分配器,它们在不同场景下有着不同的优缺点,开发者可以根据实际情况选择合适的内存分配器来管理内存。

slab工作原理

为什么要引入slab

对伙伴系统的改进和补充,由于伙伴系统对物理内存的管理是以page为单位的,通过接口去申请物理内存时候,仅仅申请32字节或64字节的内存时,也会返回整个物理页,这样就非常浪费了,因此可以使用slab缓存机制

slab的工作机制

首先通过伙伴系统接口申请一个页或几个页构成的内存块,然后把内存块切割成相同的小的内存块,称为free object,当用户去申请小的32字节或64字节小块内存的时候,根据申请内存的大小,从slab中找到合适的分配给用户,当归还时,根据大小放到对应的缓冲区,每个缓冲区(slab)切割的内存大小是不一样的,一个slab里的多个内存块可以使用链表将他们链起来,动态的维护小内存的申请,在内核中,有很多小内存的申请,如task_struct, 在内核中需要频繁创建进程、销毁进程,这样的结构体会频繁创建和释放。

分配和释放数据结构是所有内核中最普遍的操作之一。为了便于数据的频繁分配和回收,编程人员常常会用到空闲链表。空闲链表包含可供使用的、已经分配好的数据结构块。当代码需要一个新的数据结构实例时,就可以从空闲链表中抓取一个,而不需要分配内存,再把数据放进去。以后,当不再需要这个数据结构的实例时,就把它放回空闲链表,而不是释放它。从这个意义上来说,空闲链表相当于对象的高速缓存,快速存储频繁使用的对象类型。

slab分配器试图在几个基本原则之间寻求一种平衡

  • 频繁使用的数据结构也会频繁分配和释放,因此应当缓存它们
  • 频繁分配和回收必然会导致内存碎片(难以找到大块连续的可用内存)。为了避免这种现象,空闲链表的缓存会连续的存放。因为已释放的数据结构又会放回空闲链表,因此不会导致碎片。
  • 回收的对象可以立即投入下一次分配,因此,对于频繁的分配和释放,空闲链表能够提高其性能
  • 如果分配器知道对象大小、页大小和总的高速缓存的大小这样的概念,它会做出更明智的决策
  • 如果让部分缓存专属于单个处理器(对系统上的每个处理器独立而唯一),那么分配和释放就可以在不加SMP锁的情况下进行
  • 如果分配器是与NUMA相关的,它就可以从相同的内存结点为请求者进行分配
  • 对存放的对象进行着色(color),以防止多个对象映射到相同的高速缓存行(cache line)

slab核心数据结构关联

slab层把不同的对象划分为所谓的高速缓存组,其中每个高速缓存组都存放不同类型的对象。每种对象类型对应着一种高速缓存,例如,一个高速缓存用于存放进程描述符(task_struct结构的一个空闲链表),而另一个高速缓存存放索引节点对象(struct inode)。有趣的是,kmalloc()接口建立在slab层之上,使用了一组通用高速缓存。

然后,这些高速缓存又被划分为slabslab由一个或多个物理上连续的的页组成。一般情况下,slab页仅仅由一页组成。每个高速缓存可以由多个slab组成。

每个slab都包含一些对象成员,这里的对象指的是被缓存的数据结构。每个slab处于三种状态之一:满、部分满或空。一个满的slab没有空闲的对象(slab中所有的对象都已被分配)。一个空的slab没有分配出任何对象(slab中的所有对象都是空闲的)。一个部分满的slab有一些对象已经被分配出去,有些对象还空闲着。当内核中某一部分需要一个新的对象时,先从部分满的slab对象中进行分配,如果没有部分满的slab,就从空的slab对象中进行分配。如果没有空的slab,就要创建一个slab了。这种策略能有效减少内存碎片。

slab核心数据结构关联

  • kmem_cachekmem_cache_cpukmem_cache_node
  • 内存的申请和释放分析

kmem_cachekmem_cache_cpukmem_cache_node 是 Linux 内核中用于管理内存缓存的数据结构。

kmem_cache 是内核中管理内存缓存的主要数据结构,它用于管理同一大小的对象所使用的内存缓存。它包含了许多重要的信息,例如缓存的名称、缓存中每个对象的大小、缓存中每个 Slab 的大小以及缓存中 Slab 的数量等。

kmem_cache_cpu 是 kmem_cache 的辅助数据结构,用于维护当前 CPU 缓存的状态。每个 CPU 都有自己的 kmem_cache_cpu,它包含了一些用于缓存对象的数据结构,例如一个指向最后一个分配的对象的指针,以及一些用于统计缓存的统计信息,例如缓存的大小、空闲的对象数量等。

kmem_cache_node 是 kmem_cache 的另一个辅助数据结构,用于维护多个节点的状态。每个节点都有自己的 kmem_cache_node,它包含了一些用于节点内存缓存的数据结构,例如一个指向当前可用的 Slab 的指针,以及一些用于统计节点内缓存的统计信息,例如每个 Slab 中空闲的对象数量、使用的对象数量等。

这三个数据结构一起协作,构成了 Linux 内核中用于管理内存缓存的完整体系结构。

每个高速缓存都使用kmem_cache结构来表示,用来管理相同大小的slab,将这些相同大小的slab串成一个链表,通过这样的结构体进行维护,

struct kmem_cache_cpu:用于管理每个CPU的slab页面,可以使用无锁访问,提高缓存对象分配速度;

struct kmem_cache_node:用于管理每个Node的slab页面,由于每个Node的访问速度不一致,slab页面由Node来管理;

slab编程接口

如何创建一个kmem_cache

  • 通过kmem_cache_create()接口可以创建一个kmem_cache
  • 用户空间使用kmem_cache_create_usercopy()创建kmem_cache

如何销毁一个kmem_cache

  • 通过kmem_cache_destory()可以进行销毁

如何申请一个内存object

  • kmem_cache_alloc接口用于从slab缓存池中分配对象。

从上图中可以看出,分配slab对象与Buddy System中分配页面类似,存在快速路径和慢速路径两种,所谓的快速路径就是per-CPU缓存,可以无锁访问,因而效率更高。

整体的分配流程大体是这样的:优先从per-CPU缓存中进行分配,如果per-CPU缓存中已经全部分配完毕,则从Node管理的slab页面中迁移slab页per-CPU缓存中,再重新分配。当Node管理的slab页面也不足的情况下,则从Buddy System中分配新的页面,添加到per-CPU缓存中。

如何释放一个object?

  • 通过kmem_cache_free()释放object

kmalloc机制实现分析

kmalloc实现机制分析

当申请的内存大于8KB时,会从伙伴系统中直接申请内存,否则从slob中分配内存,根据大小找到对应的kmem_cache,从对应的结构类型slab上去申请内存

kmalloc是Linux内核提供的一个内存分配函数,它用于在内核中动态地分配内存。其原理如下:

当内核需要分配一块内存时,它会调用kmalloc函数。kmalloc首先检查内存池中是否有足够的空闲内存。如果内存池中有足够的空闲内存,则会从内存池中分配一块合适大小的内存,并返回指向这块内存的指针。如果内存池中没有足够的空闲内存,那么kmalloc会调用底层的内存分配器,例如buddy allocator,从操作系统中获取一块适当大小的内存。

kmalloc的内存池由若干个slab组成,每个slab中存放了一组相同大小的空闲内存块。当内存池中没有足够的空闲内存时,kmalloc会从操作系统中分配一个新的slab,并将其中的空闲内存块添加到内存池中。每个slab由一个页框组成,这个页框被分割成若干个大小相同的内存块,这些内存块被称为slab中的对象。

当内核需要释放一块内存时,它会调用kfree函数。kfree会将这块内存所在的slab中的对象标记为空闲状态,然后检查该slab是否已经全部空闲。如果该slab中所有对象都已经空闲,那么kfree会将该slab从内存池中移除,并释放掉该slab所占用的页框。

总的来说,kmalloc函数实现了内存的动态分配和管理,通过维护一个内存池,可以快速高效地分配内存。

kmalloc能申请的最大内存是多少?

在Linux内核中,kmalloc() 函数用于动态分配内核内存,可以分配的最大内存取决于物理内存的大小和系统中当前可用内存的数量。

在常规的 Linux 内核中,最大的可分配内存大小由 MAX_ORDER 宏定义决定。MAX_ORDER 宏定义表示可以分配的最大内存块的大小,以 2 的幂次方表示。在 64 位系统上,通常情况下 MAX_ORDER 宏定义的值为 11,这意味着最大的可分配内存大小为 2^11 页,其中每一页大小通常为 4KB,因此最大可分配的内存大小为 4KB * 2^11 = 4MB。

但是需要注意的是,这只是理论上的限制。实际上,内核在启动时可以选择不同的内存模型,例如 CONFIG_FLATMEMCONFIG_SPARSEMEM。在不同的内存模型下,可分配的最大内存大小也会有所不同。此外,如果系统有足够的物理内存和虚拟内存,内核可以使用高端内存(High Memory)机制来支持更大的内存分配。

因此,对于一个具体的系统来说,可分配的最大内存大小需要根据系统的实际情况来确定。

kmalloc返回的地址对齐方式?

如果在伙伴系统中分配,则以页的方式对齐,如果在slab缓存中分配,在创建时通过 kmem_cache_create()中的参数指定对齐方式

kmalloc返回的是虚拟地址,还是物理地址?

虚拟地址。

通过void *page_to_virt(struct page *page);转换成虚拟地址

1
2
3
4
5
6
7
#define virt_to_pfn(kaddr)     (__pa(kaddr) >> PAGE_SHIFT)
#define pfn_to_virt(pfn) __va((pfn) << PAGE_SHIFT)


#define virt_to_page(addr) pfn_to_page(virt_to_pfn(addr))
#define page_to_virt(page) pfn_to_virt(page_to_pfn(page))

page_to_virt()是通过物理页得到虚拟地址

首先,通过 page_to_pfn(page) 得到物理页号,然后调用 pfn_to_virt通过物理页号得到虚拟地址

在 pfn_to_virt(pfn) 具体实现中,通过调用 __va() 将物理地址转换为虚拟地址

在 Linux 内核中,使用 __pa() 函数可以将一个虚拟地址转换成对应的物理地址。而 __va() 函数则是 __pa() 的逆运算,它将一个物理地址转换成对应的虚拟地址。具体地,__va() 函数将给定的物理地址加上内核的虚拟地址偏移量,得到对应的虚拟地址。

需要注意的是,__va() 函数只能用于将已知的物理地址转换为虚拟地址。如果要将一个未知的物理地址转换为虚拟地址,需要先通过内存映射(memory mapping)将其映射到虚拟地址空间中。

具体如何进行内存映射呢?(以下是用chatGPT回答的,不准确,实际上因为kmalloc()会在ZONE_NORMAL中进行分配内存,因为是直接地址映射,所有用上面的宏进行偏移就可以得到对应的虚拟地址)

在 Linux 内核中,可以使用 ioremap() 函数将一个物理地址映射到内核的虚拟地址空间中。这个函数的原型定义如下:

1
void __iomem *ioremap(resource_size_t phys_addr, size_t size);

其中,phys_addr 是要映射的物理地址,size 是要映射的内存区域的大小。这个函数的返回值是映射后的虚拟地址。需要注意的是,返回的虚拟地址是一个 void __iomem 类型的指针,表示这段内存是用于 I/O 操作的,需要使用特殊的读写函数进行访问。

例如,如果要将一个物理地址 0x10000 映射到内核虚拟地址空间中,可以使用以下代码:

1
2
3
4
#define PHYSICAL_ADDRESS 0x10000
#define MEMORY_SIZE 0x1000

void __iomem *virtual_address = ioremap(PHYSICAL_ADDRESS, MEMORY_SIZE);

这里,virtual_address 就是将 PHYSICAL_ADDRESS 映射到内核虚拟地址空间后得到的虚拟地址。

需要注意的是,映射的物理地址范围必须在系统的物理地址空间内,并且不能与其他已经映射的区域重叠。另外,由于映射后的内存是用于 I/O 操作的,因此在访问时需要使用特殊的读写函数,例如 readl()writel() 等。这些函数会确保对内存的读写操作是原子的,以避免多个 CPU 同时访问导致的竞争问题。

虚拟地址和MMU工作原理

二级页表

解开页表神秘面纱

TLB和 Table Walk Unit

Linux 内核虚拟内存空间

Linux 虚拟内存管理

内核虚拟空间划分:

  • 线性映射区
  • vmalloc
  • fixmap
  • pkmap
  • modules

线性映射区

线性映射区地址转换

其中 __pa()_va() 只适用于线性映射区

使用kmalloc 分配的内存都是放到线性映射区

低端内存和高端内存边界划分

二级页表的创建过程分析

虚拟内存管理:vmalloc区

vmalloc 区位于高端内核区域(ZONE_HIGHMEM),在内核初始化的不会主动建立映射,只有当用户使用 vmalloc()接口去申请内存时,才会分配物理页,然后做虚拟空间和物理页之间的映射,在内核中用来申请大块的内存,因为kmalloc申请的最大内存通常为 4M,所以要使用vmalloc。vmalloc 可用申请给你一片在虚拟地址连续但在物理内存中不连续的内存。

vmalloc最小是240M,但是可以进行扩展,如果物理内存比较小,线性映射区(ZONE_NORMAL)没有用完,映射完所有物理内存后,还剩下部分内存,vmalloc会将边界进行调整,然后线性映射区会缩小,vmalloc区就会扩大。

vmalloc编程接口

编程实例:使用vmalloc申请和释放内存

vmalloc 实现机制分析

  • 从 VMALLOC_START 到 VMALLOC_END 查找一片虚拟地址空间
  • 根据内存的大小从伙伴系统申请多个物理页帧(page)
  • 把每个申请到的物理页帧逐页映射到虚拟地址空间
1
2
3
4
5
void * vmalloc(unsigned long size);
void * vzalloc(unsigned long size);
void vfree(const void * addr);
unsigned long vmalloc_to_pfn(const void * vmalloc_addr);
struct page * vmalloc_to_page(const void * vmalloc_addr);

寄存器映射: ioremap

将I/O寄存器/端口/内存的物理地址映射到虚拟地址

vmalloc区分配一块虚拟地址空间,然后映射到寄存器的物理地址上。

高端内存映射

如果配置了高端内存,优先去高端内存中去拿,如果没有配置,就去低端内存中去拿

vmalloc建立的映射是否和线性映射地址冲突?

  • 不会,可以允许多个虚拟空间映射到同一个物理页,一般不建议这么做

vmalloc扮演的角色

  • 可以支持申请大块的内存
  • 当物理内存过大,存在高端内存的情况下,使用 vmalloc 可以充分利用高端的物理内存,担负起了高端内存映射的角色
  • 缺点是:vmalloc是一页一页进行映射,效率低,开销大

虚拟内存管理:pkmap区

pkmap区也是高端内存的映射区,将高端的物理内存映射到内核的虚拟空间有三种方法:

  • vmalloc
  • fixmap
  • pkmap

必须进行配置 CONFIG_HIGHMEM才会分配 pkmap区

kmap 通过 alloc_pages从伙伴系统申请到物理页,然后使用 kmap 获取到虚拟地址建立映射,然后通过操作虚拟地址就可以读写这片内存了

可以看出来,在低端内存申请的物理页和虚拟地址只是做了线性的转换,而高端内存申请的物理页经过 kmap 映射后虚拟地址是高端内存中的pkmap区,也就是说 pkmap 在做映射时,会判断物理地址是在低端内存还是高端内存,如果在低端内存则不去做映射,直接通过低端内存的线性转换关系获取到原来内核做初始化时对线性映射区的转换,直接返回该地址,如果在高端内存,才会动用虚拟空间资源去做映射,因为pkmap区的空间非常小,不像vmalloc区几百M。

源码也是这样

pkmap区大小为 2M,最多能映射512个个物理页,所以pkmap实现中定义了 pkmap_count,是一个位图,大小为512,定义了一个数组,定义了当前512个虚拟页的空间使用的情况,如果为0表示虚拟地址空间还没有用,1的话表示虚拟页已经被映射了,如今pkmap在64位系统中高端内存中已经慢慢被舍弃了,目前只有在32位x86、ARM平台上还在用。

pkmap为单页映射,一般用在临时映射,比如page_cache、文件读写的缓存等,映射完及时解除掉然后给其它的地方使用。

虚拟内存管理:fixmap区

fixmap 区也是内核虚拟空间的一段区域,大小一般为3M

用作内存初始化之前,做临时的映射,在页表初始化之前,利用3M的虚拟空间来映射外设,完成早期初始化的工作

虚拟内存管理:modules区

当在内核运行期间动态加载ko文件时,其实会将ko文件加载到物理内存区,使用modules虚拟地址建立起映射

在ARM平台上可以进行相应的配置决定insmod去哪里申请内存

如果进行了相应的配置,去modules申请内存,该内存如果满了,就会到vmalloc区进行申请内存

否则将直接去vmalloc区申请内存

用户虚拟地址空间

用户进程的页表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct task_struct {
struct mm_struct * mm; // 描述一个进程的整个虚拟地址空间
...
};

struct mm_struct {
struct vm_area_struct * mmap;// list of VMAS, 每个VMA表示一个区域(进程的虚拟内存区域)
pgd_t * pgd; // 当前进程的页表基址
};

struct vm_area_struct {
unsigned long vm_start; // Our start address within vm_mm
unsigned long vm_end; // The first byte after our end address within vm_mm
// linked list of VM areas per task, sorted by address
struct vm_area_struct *vm_next, *vm_prev;
...
};

用户虚拟空间并没有像内核虚拟空间那样在映射的时候就给对应的页表分配物理页,而是等我们对其进行写的时候才去分配物理页,产生缺页故障,将写保护取消。

缺页异常机制

进程线性地址空间里的页面不必常驻内存。例如,进程的分配请求并被立即满足,空间仅保留 vm_area_struct的空间。其它非常驻内存页面的例子有,页面可能被交换到后援存储器,还有就是写一个只读页面。

和大多数操作系统一样,Linux 采用请求调页技术来解决非常驻页面的问题。在OS捕捉到由硬件发出的缺页异常时,它才会从后援存储器中调入请求的页面。由后援存储器的特征可知,采取页面预存技术可以减少缺页中断,但是Linux在这方面相当原始,Linux采用的是应用程序预约式换页策略。

有两种类型的缺页中断,分别是主缺页中断(硬缺页中断)和次缺页中断(软缺页中断)。当要费时地从磁盘中读取数据时,就会产生主缺页中断,其它的都是次缺页中断,或者是轻微的缺页中断。Linux 通过 task_struct->maj_faulttask_struct->min_fault 来统计各自的数量

什么是缺页异常?

什么时候会触发缺页异常呢?

  • 如果权限不够,写一个只读物理页
  • 线性区有效但页面没有分配
  • 读写文件时,文件在内存中有对应的page_cache, 如果写的地址还没有缓存到page_cache或者已经交换到磁盘上,也会产生缺页异常

具体如下

缺页发生中断的原因

异常 类型 动作
线性区有效但页面没有分配 次要 通过物理页面分配器分配一个页面帧
线性区无效但是可扩展,例如堆栈 次要 扩展线性区并分配一页
页面被交换但是在高速缓存中 次要 从交换高速缓存中删除并分配给进程
页面被交换至后援存储器 主要 通过PTE的信息查找页面并从磁盘读到内存中
写只读页面 次要 如果是COW页,就复制一页,标志为可写的并分配给进程,如果是写异常,就发送SIGSEGV 信号
线性区无效或者没有访问权限 错误 给进程发送SIGSEGV信号
异常发生在内核地址空间 次要 如果异常发生在 vmalloc,就更新当前进程页表,相对于 init_mm 的主内核页表。这是唯一的有效发生内核页面异常的情况
异常发生在内核模态用户空间 错误 如果发生异常,表明内核不能从用户空间正确复制数据,这是非常严重的系统BUG

缺页中断处理流程

handle_mm_fault 是与体系无关的顶层函数,负责处理后援存储器中的缺页中断、执行写时复制(COW)等。如果返回1,就是次缺页中断,2表示主缺页中断,0表示发送 SIG-BUS 错误,其他值就激活内存溢出处理子程序。

在 handle_mm_fault 中调用 handle_pte_fault,根据映射的类型,调用此函数做不同的处理

  • 第一步:调用 pte_present()检查 PTE 标志位,确定其是否在内存中,然后调用 pte_none 检查 PTE 是否分配。如果 PTE 还没有分配的话(pte_none 返回 true),将调用 do_no_page() 处理请求页面的分配,否则说明该页面已经被换到了磁盘,于是调用 do_swap_page 处理请求换页。有一个意外的情况就是换出的页面属于虚拟文件时将由 do_no_page() 来处理
  • 第二步:确认是否是写页面。如果 PTE 写保护,就调用 do_wp_page,因为这个页面是写时复制(COW)页面。写时复制页面是指多个进程共享一个页面(通常为父子进程),直到其中一个进程对该页面进行写操作时才为其分配一个单独的页面。写时复制页面的识别方法是:页面所在的 VMA 标志位为可写,但相应的 PTE 确是不可写的。如果不是写时复制页面,通常将其标志为脏,因为它已经被写过了。
  • 第三步:确定页面是否已经读取以及是否在内存中,但还会发生异常。这是因为在某些体系结构中没有三级页表,在这种情况下建立 PTE 并标志为新即可。

当用户进程发生缺页异常时:

  • 创建新的物理页帧
  • 更新对应的页表 entry
  • 写保护权限的解除, COW, copy-on-write
  • 父子进程各自执行

写时复制页(COW)

在创建(fork)的时候,将两个进程的PTE设置成只读(写保护),当要写时就会导致缺页中断。Linux能够识别 COW 页,即 PTE 是写保护的,但是 VMA 所在的区域是可写的。 LInux利用函数 do_wp_page() 将生成的复制页复制给写进程。如有必要,将为页面保留一个新的交换插槽,采用这种技术,在创建子进程时,只需要复制页表项。do_wp_page()函数的调用过程如下图所示

在缺页异常时,只复制内存,然后把自己进程的页面设为可写,不修改其他进程的页表。当其他进程写相应内存时,再进入一次异常,然后如果发现只有自己使用这片内存的话,就不需要复制内存,只是把内存设置为可写就行了。

通过fork()创建子进程后,如果子进程先运行,先访问COW页,此时子进程申请一个新的物理页,然后将父进程的数据拷贝到子进程新分配内存里面,分配好之后,子进程清除写保护,就可以对新申请的物理页进行读写,而对于父进程来说,当发现该页只有一个映射并且还是写保护(只读)状态,写保护权限会自动解除掉,此时子进程新创建的物理页写权限已经清除掉了,父进程只有一个虚拟地址映射到该物理页,所以写保护也会自动解除,此时父进程子进程都可以分别读写物理内存了。

用户页表的刷新

每个用户进程的页表的内核部分都是内核空间页表的拷贝,当内核页表有新的映射或者有新的映射解除时,内核页表就会更新了,因为此时用户页表仍是一个拷贝,它没有更新,但也是需要更新的,那什么时候会进行更新呢?在程序中使用vmalloc和vfree去申请内存调用 vmalloc区的虚拟空间,做了新的映射,那么对应的内核页表就会产生更新,用户如何进行同步更新?进程采取了延迟更新的策略,如果内核中有一个新的区域映射了物理页,只有当进程去访问对应的区域的时候,如果拷贝的时候没有进行物理地址映射,会产生缺页异常,就会到内核空间的页表看页表中有没有数据,如果有数据,说明内核页表有新的映射了,就会把对应的页表项拷贝过去。同理,如果这段内核vmalloc区进行了通过vfree()解除了映射,则对应的页表项为空,只有当进程去访问这段vmalloc区时,通过pmd_none()进行判断,如果内核中的页表为空,就会进行报错(访问没有映射的地址,发生Oops错误)。

mmap映射机制

mmap 映射例子:

  • 当使用 malloc 去申请内存时,当大于 128KB 时,就会直接调用 mmap 在用户空间的映射区申请虚拟内存,并将其映射对应的物理内存上,当申请大块内存时直接映射,效率会更高
  • 对于磁盘上的文件,我们可以通过IO接口去访问,除此之外,还可以通过mmap系统调用去访问,将磁盘文件上的区域映射到进程空间,对进程上的虚拟空间进行读写,相当于对文件磁盘上的位置进行读写
  • 此外对于设备文件的内存缓冲区也是可以进行映射到用户空间的mmap区,应用程序直接对这块区域进行读写不需要再通过系统调用切换内核态,效率更高,开销更小

什么是 mmap ?

  • 一个系统调用接口,可以提高性能,省却了不同数据在不同缓冲区的拷贝以及系统调用的开销。

  • mmap将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。munmap执行相反的操作,删除特定地址区域的对象映射。

编程实例

将文件映射到虚拟地址空间,通过地址读写文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/mman.h>

char write_buf[] = "Hello World!\n";
char read_buf[200];

int main(void) {
int fd;
char * mmap_addr;
fd = open("data.txt", O_RDWR);
if (fd < 0) {
printf("open failed!\n");
return -1;
}

write(fd, write_buf, strlen(write_buf) + 1);
lseek(fd, 0, SEEK_SET);
read(fd, read_buf, 100);
printf("read_buffer: %s\n", read_buf);

mmap_addr = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
printf("mmap read: %s\n", mmap_addr);

memcpy(mmap_addr + 5, "hello, zhaixue.cc!\n", 20);
printf("mmap read: %s\n", mmap_addr);

munmap(mmap_addr, 4096);


return 0;
}

mmap映射机制:remap_pfn_range

remap_pfn_range(vma, vma->vm_start, pfn, size, vma->vm_page_prot)

将内核中申请的物理页映射到用户进程的映射区(mmap虚拟地址空间)

1
mmap_addr = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);

在用户层调用系统调用接口 mmap()时,没有指定虚拟地址,最终去调用 remap_pfn_range()时虚拟地址已经分配好了,对这中间的过程进行 mmap 系统调用流程分析

  • 如果发现 addr = NULL, 就会从当前进程的 mmap区 找一段还没有被映射的虚拟地址空间,然后进行分配, 然后再去调用底层的接口

mmap映射类型

  • 私有匿名映射:大块内存分配(malloc > 128KB)
  • 私有文件映射:动态库加载
  • 共享匿名映射:父子进程通信,共享内存
  • 共享文件映射:设备内存映射,进程间通信

mmap映射的本质

和内核虚拟空间的映射类似,找到虚拟地址空间、物理地址空间、映射长度就可以调用底层相关的函数进行映射

如何分配对应的虚拟地址?

如何找到对应的物理地址?

mmap映射机制:文件映射

用户空间的mmap映射特点

  • 不会在映射时分配物理页
  • 仅仅建立一种关联
    • 虚拟地址和物理文件地址偏移建立关联
    • 设置好缺页异常回调函数
    • 返回分配的虚拟地址给用户程序
  • 读写的时候触发缺页异常
    • 调用回调函数
    • 在异常处理中分配物理页
    • 建立映射,更新页表

通过 offset 和 length 就可以确定内核中分配的页缓存,然后将参数 offset 保存到 vma->vm_pgoff 中

文件映射的缺页异常

  • 当触发异常时,首先根据读写触发异常的地址创建一个vmf 结构体,把发生异常的地址保存到这个结构体中

来自ChatGPT的回答:

vmf 是指向 vm_fault 结构体的指针,它是 Linux 内核中处理页面故障的函数的参数之一。当进程访问一个未映射的虚拟地址时,会触发缺页异常,内核会调用相应的页面故障处理函数来处理缺页异常。这些页面故障处理函数通常都以 vmf 作为参数。

vm_fault 结构体包含了处理页面故障所需的各种信息,包括:

  1. virtual_address:缺页异常发生的虚拟地址。
  2. page:指向 struct page 结构体的指针,它表示将要映射到虚拟地址空间的物理页。
  3. flags:页面故障的相关标志位,例如 VM_FAULT_WRITE 表示进程正在写入该页。
  4. pgoff:表示在文件中的页偏移量,它用于计算物理页在文件中的位置。
  5. vma:指向 vm_area_struct 结构体的指针,它表示管理该虚拟地址区间的 VMA。
  6. ptl:指向 pte_lock 结构体的指针,它用于保护页表的修改操作。
  7. page_fault:一个函数指针,它指定了处理缺页异常的具体方法。

在页面故障处理函数中,可以通过 vmf 获取缺页异常发生的虚拟地址、管理该虚拟地址区间的 VMA、将要映射到虚拟地址空间的物理页等信息,并且可以根据这些信息来决定如何处理页面故障。例如,可以在页面故障处理函数中通过 vmf 获取将要映射到虚拟地址空间的物理页,然后将该物理页映射到对应的虚拟地址上。

总之,vmf 是指向 vm_fault 结构体的指针,它是 Linux 内核中处理页面故障的函数的参数之一,用于传递处理页面故障所需的各种信息。

  • 然后根据 vma->vm_pgoff 文件读写偏移找到对应的物理页,如果找到就可以进行各种操作,没有的话就创建一个物理页作为 page cache, 燃火将虚拟地址和物理地址建立映射,更新页表
  • 然后通过调用 address_space 里面的 readpage 将磁盘的数据读取到对应的缓存中,如果用户使用 read/write 就再拷贝到用户空间,如果使用 mmap 就可以直接返回

设备映射缺页异常

mmap 映射机制 : 匿名映射

将用户空间的一段虚拟空间映射到内核空间的物理内存(匿名内存)。

匿名内存用在什么场合?

  • 使用 malloc 申请的堆内存
  • 使用 mmap 创建的内存匿名映射(注意参数 fd 为 -1)
  • 如何判断匿名内存?
    • vma -> vm_ops (匿名映射不进行赋值)

核心结构体

  • struct anon_vma
  • struct anon_vma_chain
  • struct vm_area_struct

malloc 的实现机制:当申请的内存大于 128KB 时,就直接走 mmap 映射流程,在用户进程的映射区申请虚拟内存,然后映射到物理内存上

empty_zero_page

  • 如果只是用来读0,那么直接返回这个页,而无需申请内存

do_anonymous_page 流程

匿名映射过程分析

匿名映射分为共享匿名映射和私有匿名映射,会进行相应的判断

私有匿名映射

如果是私有匿名映射,就走vma_set_anonymous

vma_set_anonymous函数用于设置VMA结构的标志,表示该VMA是一个匿名内存区域。这个标志告诉内核,该VMA所映射的内存不与任何文件相关联,也不需要将数据持久化到磁盘中,因此在进程结束时,该内存会被自动释放。通过设置VMA结构的标志,内核可以正确地管理匿名内存,避免内存泄漏和资源浪费。

当发生异常时,执行handle_pte_fault()中,进行相应的判断

do_anonymous_page()中,去申请匿名页,填充页表,将页表的地址赋值给到 vmf->pte

可以看出来,大体和文件映射类似,唯一不同的地方就是,在文件映射时需要将 address_space 与文件偏移地址与虚拟地址建立关联,匿名映射是将 anon_vma、anon_vma_chain、vma建立关联

然后通过alloc_zeroed_user_highpage_movable申请匿名页,从高端内存去申请

然后给新的匿名页建立映射,更新页表

共享匿名映射

如果是共享匿名映射,就走shmem_zero_setup

shmem_zero_setup是Linux内核中用于创建匿名共享内存的函数,它的底层实现如下:

  1. 首先,shmem_zero_setup会调用shmget_key函数生成一个唯一的key值,用于标识匿名共享内存。
  2. 然后,shmem_zero_setup会调用shm_alloc_node函数分配一个物理页面,并将该页面映射到内核虚拟地址空间中。
  3. 接着,shmem_zero_setup会调用clear_highpage函数将该页面清零。
  4. 最后,shmem_zero_setup会调用vm_insert_page函数将该页面插入到进程的虚拟地址空间中,以便进程可以访问该页面。

总之,shmem_zero_setup的底层实现是通过调用一系列函数,完成匿名共享内存的创建和初始化。其中,shmget_key函数用于生成唯一的key值,shm_alloc_node函数用于分配物理页面并将页面映射到内核虚拟地址空间中,clear_highpage函数用于清零页面内容,vm_insert_page函数用于将页面插入到进程的虚拟地址空间中。

shmem_zero_setup和vma_set_anonymous是Linux内核中两个不同的函数,它们在处理匿名内存时有着不同的作用。

shmem_zero_setup函数用于初始化一个匿名内存VMA,并将其映射到进程的虚拟地址空间中。在调用shmem_zero_setup函数时,会使用vm_area_alloc函数分配一个新的VMA结构,并将其设置为匿名内存区域。然后,调用shmem_zero_setup函数将匿名内存映射到进程的虚拟地址空间中,并将其清零。

vma_set_anonymous函数用于设置VMA结构的标志,表示该VMA是一个匿名内存区域。这个标志告诉内核,该VMA所映射的内存不与任何文件相关联,也不需要将数据持久化到磁盘中,因此在进程结束时,该内存会被自动释放。通过设置VMA结构的标志,内核可以正确地管理匿名内存,避免内存泄漏和资源浪费。

虽然这两个函数都与匿名内存管理相关,但它们的作用不同。shmem_zero_setup函数用于初始化匿名内存VMA,并将其映射到进程的虚拟地址空间中;而vma_set_anonymous函数用于设置VMA结构的标志,以便内核正确地管理匿名内存。

在处理匿名内存时,这两个函数通常一起使用,例如在动态分配和管理匿名内存时,需要使用shmem_zero_setup函数初始化VMA结构,并使用vma_set_anonymous函数设置VMA结构的标志,以便内核正确地管理匿名内存。

总之,shmem_zero_setup和vma_set_anonymous是Linux内核中用于管理匿名内存的两个重要的函数,它们在处理匿名内存时有着不同的作用,但通常一起使用以实现正确的匿名内存管理。

私有映射和共享映射

映射组合及应用场合

  • 文件共享映射
  • 文件私有映射
  • 匿名共享映射
  • 匿名私有映射

匿名私有映射(堆、栈、malloc 申请的内存)

匿名共享映射是基于内存的文件系统来实现的

文件共享映射的特点:多个进程可以通过各自的虚拟空间读写磁盘文件,自己在这个空间上的读写都会回写到磁盘上,被其它进程看到

文件私有映射特点:各个文件可以映射到同一个文件上,但是每个进程对文件的读写不会回写到磁盘上,而是仅对当前进程可见,比如代码段、数据段以及动态库的加载(动态库加载到 mmap 区)

系统调用brk底层实现机制

brk 系统调用用来扩展堆内存的边界,当申请的内存小于128K时而且堆内存中已经没有大的内存块使用了,就可以通过brk系统调用扩充堆内存的边界

堆内存是匿名私有映射

brk系统调用过程分析

如果当前地址小于堆顶地址,则说明当前需要进行收缩堆内存,调用 __do_munmap 收缩堆内存的大小

如果大于当前堆顶地址,则会去扩展堆内存的边界,在进程的虚拟地址空间上申请一段新的虚拟地址空间,走匿名私有映射流程(和 mmap 类似),首先分配一个 vma_area_struct,添加到 mm_struct 链表上,然后更新堆顶的位置

核心函数do_brk_flags如下:

可以看出来 brk 的映射流程没有去分配物理页内存,仅仅是创建了 vma ,并添加到进程的链表上,更新了 brk 指针,然后就退出了,当对虚拟地址真正进行访问的时候才会产生缺页异常,根据映射类型走匿名映射流程,分配匿名页和虚拟地址建立映射关系,之后就可真正进行读写了。

每一个映射的区域我们都使用 vm_area_struct 结构体表示,这些所有结构体串成一个链表放到 mm_struct 维护的链表上(struct vm_area_struct * mmap)。

当产生异常时,对于没有映射的区域,会怎么办呢?

在发生异常后,根据发生异常的虚拟地址 addr 调用find_vma到 mm_struct 链表中找有没有对应的 vma,如果找到就返回对应的vma,找不到返回NULL,对于没有映射的区域我们是没有创建对应的 vma 的,此时会返回 NULL, 就会跳转到 out处,返回段错误

find_vma 是一个函数,用于查找给定虚拟地址所在的虚拟内存区域(VMA,Virtual Memory Area)。在操作系统中,虚拟地址空间被划分为多个 VMA,每个 VMA 表示一个连续的虚拟地址范围,可以包含一个或多个物理页面。在程序访问虚拟地址时,需要根据该地址所在的 VMA 来确定对应的物理页面。

find_vma 函数的主要作用就是在虚拟地址空间中查找给定虚拟地址所在的 VMA。其输入参数包括虚拟地址 addr 和一个指向 mm_struct 结构体的指针 mm,表示要查找的虚拟地址空间。函数的返回值是指向找到的 VMA 的指针。

在实现上,find_vma 函数会遍历 mm 中的 VMA 链表,查找包含给定虚拟地址的 VMA。具体来说,函数会从 mm_struct 结构体中获取第一个 VMA 的指针,然后遍历整个链表,逐个检查每个 VMA 是否包含给定的虚拟地址。如果找到了包含该地址的 VMA,则返回该 VMA 的指针;否则返回 NULL。

下面是 find_vma 函数的简化实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
struct vm_area_struct *vma = mm->mmap;

while (vma) {
if (addr >= vma->vm_start && addr < vma->vm_end)
return vma;

vma = vma->vm_next;
}

return NULL;
}

在该实现中,mm->mmap 表示指向第一个 VMA 的指针。函数会从该指针开始遍历整个链表,逐个比较每个 VMA 的起始地址和结束地址是否包含给定的虚拟地址。如果找到了包含该地址的 VMA,则返回该 VMA 的指针;否则返回 NULL。

需要注意的是,虚拟地址空间中的 VMA 是按照起始地址从小到大排列的,因此在遍历 VMA 链表时,可以根据这个特点进行优化,提高查找效率。此外,在操作系统中还有一些其他的函数,例如 find_vma_intersectionfind_vma_prev,用于在 VMA 链表中查找给定地址范围的 VMA 或者查找给定 VMA 的前一个 VMA。这些函数和 find_vma 一样,都是虚拟内存管理中非常重要的函数。

反向映射

正向映射:给定虚拟地址,通过 MMU 查询页表,找到对应的物理地址

反向映射:给定物理地址,需要找到所有映射到该物理页上的虚拟地址

共享映射允许多个进程的虚拟空间映射到同一个物理页(或文件)上,给定文件或物理页,找到所有映射到这个地方的虚拟区域。

反向映射的应用场景:

  • 内存回收、页面迁移
  • 碎片整理、CMA回收、巨型页

在 page 中记录所有映射到该物理页的虚拟地址空间,通过链表或树进行维护,当我们释放该物理页时,可以根据物理页的 mapping 变量找到所有的 vma,根据这些信息就可以解除映射,后续可以进行释放、迁移等。

匿名页的反向映射

文件页的反向映射

求大佬赏个饭