大页内存hugetlbfs学习记录

大页内存原理

在介绍 HugePages 之前,我们先来回顾一下 Linux 下 虚拟内存 与 物理内存 之间的关系。

物理内存:也就是安装在计算机中的内存条,比如安装了 2GB 大小的内存条,那么物理内存地址的范围就是 0 ~ 2GB。

虚拟内存:虚拟的内存地址。由于 CPU 只能使用物理内存地址,所以需要将虚拟内存地址转换为物理内存地址才能被 CPU 使用,这个转换过程由 MMU(Memory Management Unit,内存管理单元) 来完成。在 32 位的操作系统中,虚拟内存空间大小为 0 ~ 4GB。

我们通过 图1 来描述虚拟内存地址转换成物理内存地址的过程:

如 图1 所示,页表 保存的是虚拟内存地址与物理内存地址的映射关系,MMU 从 页表 中找到虚拟内存地址所映射的物理内存地址,然后把物理内存地址提交给 CPU,这个过程与 Hash 算法相似。

内存映射是以内存页作为单位的,通常情况下,一个内存页的大小为 4KB(如图1所示),所以称为 分页机制。

内存映射

我们来看看在 64 位的 Linux 系统中(英特尔 x64 CPU),虚拟内存地址转换成物理内存地址的过程,如图2:

从图2可以看出,Linux 只使用了 64 位虚拟内存地址的前 48 位(0 ~ 47位),并且 Linux 把这 48 位虚拟内存地址分为 5 个部分,如下:

PGD索引:39 ~ 47 位(共9个位),指定在 页全局目录(PGD,Page Global Directory)中的索引。

PUD索引:30 ~ 38 位(共9个位),指定在 页上级目录(PUD,Page Upper Directory)中的索引。

PMD索引:21 ~ 29 位(共9个位),指定在 页中间目录(PMD,Page Middle Directory)中的索引。

PTE索引:12 ~ 20 位(共9个位),指定在 页表(PT,Page Table)中的索引。

偏移量:0 ~ 11 位(共12个位),指定在物理内存页中的偏移量。

把 图1 中的== 页表== 分为 4 级:页全局目录、页上级目录、页中间目录 和 页表 目的是为了减少内存消耗(思考下为什么可以减少内存消耗)。

注意:页全局目录、页上级目录、页中间目录 和 页表 都占用一个 4KB 大小的物理内存页,由于 64 位内存地址占用 8 个字节,所以一个 4KB 大小的物理内存页可以容纳 512 个 64 位内存地址。

另外,CPU 有个名为 CR3 的寄存器,用于保存 页全局目录 的起始物理内存地址(如图2所示)。所以,虚拟内存地址转换成物理内存地址的过程如下:

从 CR3 寄存器中获取 页全局目录 的物理内存地址,然后以虚拟内存地址的 39 ~ 47 位作为索引,从 页全局目录 中读取到 页上级目录 的物理内存地址。

以虚拟内存地址的 30 ~ 38 位作为索引,从 页上级目录 中读取到 页中间目录 的物理内存地址。

以虚拟内存地址的 21 ~ 29 位作为索引,从 页中间目录 中读取到 页表 的物理内存地址。

以虚拟内存地址的 12 ~ 20 位作为索引,从 页表 中读取到 物理内存页 的物理内存地址。

以虚拟内存地址的 0 ~ 11 位作为 物理内存页 的偏移量,得到最终的物理内存地址。

hugapage 原理

上面介绍了以 4KB 的内存页作为内存映射的单位,但有些场景我们希望使用更大的内存页作为映射单位(如 2MB)。使用更大的内存页作为映射单位有如下好处:

减少 TLB(Translation Lookaside Buffer) 的失效情况。

减少 页表 的内存消耗。

减少 PageFault(缺页中断)的次数。

Tips:TLB 是一块高速缓存,TLB 缓存虚拟内存地址与其映射的物理内存地址。MMU 首先从 TLB 查找内存映射的关系,如果找到就不用回溯查找页表。否则,只能根据虚拟内存地址,去页表中查找其映射的物理内存地址。

因为映射的内存页越大,所需要的 页表 就越小(很容易理解);页表 越小,TLB 失效的情况就越少。

使用大于 4KB 的内存页作为内存映射单位的机制叫 HugePages,目前 Linux 常用的 HugePages 大小为 2MB 和 1GB,我们以 2MB 大小的内存页作为例子。

要映射更大的内存页,只需要增加偏移量部分,如 图3 所示:

如 图3 所示,现在把偏移量部分扩展到 21 位(页表部分被覆盖了,21 位能够表示的大小范围为 0 ~ 2MB),所以 页中间目录 直接指向映射的 物理内存页地址。

这样,就可以减少 页表 部分的内存消耗。由于内存映射关系变少,所以 TLB 失效的情况也会减少。

hugapages 使用

了解了 HugePages 的原理后,我们来介绍一下怎么使用 HugePages。

HugePages 的使用不像普通内存申请那么简单,而是需要借助 Hugetlb文件系统 来创建,下面将会介绍 HugePages 的使用步骤:

  1. 挂载 Hugetlb 文件系统
    Hugetlb 文件系统是专门为 HugePages 而创造的,我们可以通过以下命令来挂载一个 Hugetlb 文件系统:
c 复制代码
1$ mkdir /mnt/huge
2$ mount none /mnt/huge -t hugetlbfs

执行完上面的命令后,我们就在 /mnt/huge 目录下挂载了 Hugetlb 文件系统。

  1. 声明可用 HugePages 数量
    要使用 HugePages,首先要向内核声明可以使用的 HugePages 数量。/proc/sys/vm/nr_hugepages 文件保存了内核可以使用的 HugePages 数量,我们可以使用以下命令设置新的可用 HugePages 数量:
c 复制代码
1$ echo 20 > /proc/sys/vm/nr_hugepages
  1. 编写申请 HugePages 的代码
    要使用 HugePages,必须使用 mmap 系统调用把虚拟内存映射到 Hugetlb 文件系统中的文件,如下代码:
c 复制代码
1#include <fcntl.h>
 2#include <sys/mman.h>
 3#include <errno.h>
 4#include <stdio.h>
 5
 6#define MAP_LENGTH (10*1024*1024) // 10MB
 7
 8int main()
 9{
10    int fd;
11    void * addr;
12
13    // 1. 创建一个 Hugetlb 文件系统的文件
14    fd = open("/mnt/huge/hugepage1", O_CREAT|O_RDWR);
15    if (fd < 0) {
16        perror("open()");
17        return -1;
18    }
19
20    // 2. 把虚拟内存映射到 Hugetlb 文件系统的文件中
21    addr = mmap(0, MAP_LENGTH, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
22    if (addr == MAP_FAILED) {
23        perror("mmap()");
24        close(fd);
25        unlink("/mnt/huge/hugepage1");
26        return -1;
27    }
28
29    strcpy(addr, "This is HugePages example...");
30    printf("%s\n", addr);
31
32    // 3. 使用完成后,解除映射关系
33    munmap(addr, MAP_LENGTH);
34    close(fd);
35    unlink("/mnt/huge/hugepage1");
36
37    return 0;
38 }

编译上面的代码并且执行,如果没有问题,将会输出以下信息:

c 复制代码
This is HugePages example...

大页内存的实现

HugePages分配器初始化

在内核初始化时,会调用 hugetlb_init 函数对 HugePages 分配器进行初始化,其实现如下:

c 复制代码
1static int __init hugetlb_init(void)
 2{
 3    unsigned long i;
 4
 5    // 1. 初始化空闲大内存页链表 hugepage_freelists, 
 6    //    内核使用 hugepage_freelists 链表把空闲的大内存页连接起来,
 7    //    为了分析简单,我们可以把 MAX_NUMNODES 当成 1
 8    for (i = 0; i < MAX_NUMNODES; ++i)          
 9        INIT_LIST_HEAD(&hugepage_freelists[i]); 
10
11    // 2. max_huge_pages 为系统能够使用的大页内存的数量,
12    //    由系统启动项 hugepages 指定,
13    //    这里主要申请大内存页, 并且保存到 hugepage_freelists 链表中.
14    for (i = 0; i < max_huge_pages; ++i) {
15        if (!alloc_fresh_huge_page())
16            break;
17    }
18
19    max_huge_pages = free_huge_pages = nr_huge_pages = i;
20
21    return 0;
22}

hugetlb_init 函数主要完成两个工作:

初始化空闲大内存页链表 hugepage_freelists,这个链表保存了系统中能够使用的大内存。

为系统申请空闲的大内存页,并且保存到 hugepage_freelists 链表中。

我们再来分析下 alloc_fresh_huge_page 函数是怎么申请大内存页的,其实现如下:

c 复制代码
1static int alloc_fresh_huge_page(void)
 2{
 3    static int prev_nid;
 4    struct page *page;
 5    int nid;
 6    ...
 7    // 1. 申请一个大的物理内存页...
 8    page = alloc_pages_node(nid, htlb_alloc_mask|__GFP_COMP|__GFP_NOWARN,
 9                            HUGETLB_PAGE_ORDER);
10
11    if (page) {
12        // 2. 设置释放大内存页的回调函数为 free_huge_page
13        set_compound_page_dtor(page, free_huge_page); 
14        ...
15        // 3. put_page 函数将会调用上面设置的 free_huge_page 函数把内存页放入到缓存队列中
16        put_page(page);
17
18        return 1;
19    }
20
21    return 0;
22}

所以,alloc_fresh_huge_page 函数主要完成三个工作:

调用 alloc_pages_node 函数申请一个大内存页(2MB)。

设置大内存页的释放回调函数为 free_huge_page,当释放大内存页时,将会调用这个函数进行释放操作。

调用 put_page 函数释放大内存页,其将会调用 free_huge_page 函数进行相关操作。

那么,我们来看看 free_huge_page 函数是怎么释放大内存页的,其实现如下:

c 复制代码
1static void free_huge_page(struct page *page)
2{
3    ...
4    enqueue_huge_page(page);     // 把大内存页放置到空闲大内存页链表中
5    ...
6}

free_huge_page 函数主要调用 enqueue_huge_page 函数把大内存页添加到空闲大内存页链表中,其实现如下:

c 复制代码
 1static void enqueue_huge_page(struct page *page)
 2{
 3    int nid = page_to_nid(page); // 我们假设这里一定返回 0
 4
 5    // 把大内存页添加到空闲链表 hugepage_freelists 中
 6    list_add(&page->lru, &hugepage_freelists[nid]);
 7
 8    // 增加计数器
 9    free_huge_pages++;
10    free_huge_pages_node[nid]++;
11}

从上面的实现可知,enqueue_huge_page 函数只是简单的把大内存页添加到空闲链表 hugepage_freelists 中,并且增加计数器。

假如我们设置了系统能够使用的大内存页为 100 个,那么空闲大内存页链表 hugepage_freelists 的结构如下图所示:

所以,HugePages 分配器初始化的调用链为:

c 复制代码
 1hugetlb_init()
 2      |
 3      +------> alloc_fresh_huge_page()
 4                      |
 5                      |------> alloc_pages_node()
 6                      |------> set_compound_page_dtor()
 7                      +------> put_page()
 8                               |
 9                               +------> free_huge_page()
10                                            |
11                                            +------> enqueue_huge_page()

hugetlbfs 文件系统

为系统准备好空闲的大内存页后,现在来了解下怎样分配大内存页。在《一文读懂 HugePages的原理》一文中介绍过,要申请大内存页,必须使用 mmap 系统调用把虚拟内存映射到 hugetlbfs 文件系统中的文件中。

免去繁琐的文件系统挂载过程,我们主要来看看当使用 mmap 系统调用把虚拟内存映射到 hugetlbfs 文件系统的文件时会发生什么事情。

每个文件描述符对象都有个 mmap 的方法,此方法会在调用 mmap 函数映射到文件时被触发,我们来看看 hugetlbfs 文件的 mmap 方法所对应的真实函数,如下:

c 复制代码
1const struct file_operations hugetlbfs_file_operations = {
2    .mmap               = hugetlbfs_file_mmap,
3    .fsync              = simple_sync_file,
4    .get_unmapped_area  = hugetlb_get_unmapped_area,
5};

从上面的代码可以发现,hugetlbfs 文件的 mmap 方法被设置为 hugetlbfs_file_mmap 函数。所以当调用 mmap 函数映射 hugetlbfs 文件时,将会调用 hugetlbfs_file_mmap 函数来处理。

而 hugetlbfs_file_mmap 函数最主要的工作就是把虚拟内存分区对象的 vm_flags 字段添加 VM_HUGETLB 标志位,如下代码:

从上图可以看出,使用 HugePages 后,页中间目录 直接指向物理内存页。所以,hugetlb_fault 函数主要就是对 页中间目录项 进行填充。实现如下:

对 hugetlb_fault 函数进行精简后,主要完成两个工作:

通过触发 缺页异常 的虚拟内存地址找到其对应的 页中间目录项。

调用 hugetlb_no_page 函数对 页中间目录项 进行映射操作。

我们再来看看 hugetlb_no_page 函数怎么对 页中间目录项 进行填充:

c 复制代码
1static int
 2hugetlb_no_page(struct mm_struct *mm, struct vm_area_struct *vma,
 3                unsigned long address, pte_t *ptep, int write_access)
 4{
 5    ...
 6    page = find_lock_page(mapping, idx);
 7    if (!page) {
 8        ...
 9        // 1. 从空闲大内存页链表 hugepage_freelists 中申请一个大内存页
10        page = alloc_huge_page(vma, address);
11        ...
12    }
13    ...
14    // 2. 通过大内存页的物理地址生成页中间目录项的值
15    new_pte = make_huge_pte(vma, page, ((vma->vm_flags & VM_WRITE)
16                                            && (vma->vm_flags & VM_SHARED)));
17
18    // 3. 设置页中间目录项的值为上面生成的值
19    set_huge_pte_at(mm, address, ptep, new_pte);
20    ...
21    return ret;
22}

通过对 hugetlb_no_page 函数进行精简后,主要完成3个工作:

调用 alloc_huge_page 函数从空闲大内存页链表 hugepage_freelists 中申请一个大内存页。

通过大内存页的物理地址生成页中间目录项的值。

设置页中间目录项的值为上面生成的值。

至此,HugePages 的映射过程已经完成。

还有个问题,就是 CPU 怎么知道 页中间表项 指向的是 页表 还是 大内存页 呢?

这是因为 页中间表项 有个 PSE 的标志位,如果将其设置为1,那么就表明其指向 大内存页 ,否则就指向 页表。

相关推荐
IC 见路不走1 小时前
LeetCode 第91题:解码方法
linux·运维·服务器
翻滚吧键盘1 小时前
查看linux中steam游戏的兼容性
linux·运维·游戏
小能喵1 小时前
Kali Linux Wifi 伪造热点
linux·安全·kali·kali linux
汀沿河1 小时前
8.1 prefix Tunning与Prompt Tunning模型微调方法
linux·运维·服务器·人工智能
zly35001 小时前
centos7 ping127.0.0.1不通
linux·运维·服务器
小哥山水之间2 小时前
基于dropbear实现嵌入式系统ssh服务端与客户端完整交互
linux
ldj20202 小时前
2025 Centos 安装PostgreSQL
linux·postgresql·centos
翻滚吧键盘3 小时前
opensuse tumbleweed上安装显卡驱动
linux
cui_win3 小时前
【内存】Linux 内核优化实战 - net.ipv4.tcp_tw_reuse
linux·网络·tcp/ip
CodeWithMe7 小时前
【Note】《深入理解Linux内核》 Chapter 15 :深入理解 Linux 页缓存
linux·spring·缓存