页表

前文 虚拟地址转换 中在理论层面上介绍了虚拟内存, 多级页表等相关知识, 本文结合 kernel 代码来深入理解一下

前文提到现代操作系统内存管理的解决方案采用的是多级页表

image

那么进程的页表是如何创建的, 又是怎么管理的呢?

pgd

进程虚拟内存空间中的每一个虚拟页在页表中都会有一个 PTE 与之对应, 专门用来存储该虚拟页背后映射的物理内存页的起始地址.

进程的虚拟内存空间在内核中是用 struct mm_struct 结构来描述的,每个进程都有自己独立的虚拟内存空间,而进程的虚拟内存到物理内存的映射也是独立的,为了保证每个进程里内存映射的独立进行,所以每个进程都会有独立的页表

image

根据上图我们知道, 虚拟地址转换的过程中需要根据从进程的页表起始地址 PGD 开始做页面遍历, 该字段对应存放在 struct mm_struct 结构中的 pgd 属性中

struct mm_struct {
  // 当前进程顶级页表的起始地址
  pgd_t * pgd;
};

那么进程的顶级页表起始地址 pgd 又是在什么时候被内核设置进去的呢?

很显然这个设置的时机是在进程被创建出来的时候,当我们使用 fork 系统调用创建进程的时候,内核会调用 kernel_clone, 该函数中会通过 copy_process 将父进程的所有资源拷贝到子进程中,这其中也包括父进程的虚拟内存空间.

pid_t kernel_clone(struct kernel_clone_args *args)
{
    // ...

    p = copy_process(NULL, trace, NUMA_NO_NODE, args);
    add_latent_entropy();

    pid = get_task_pid(p, PIDTYPE_PID);
    nr = pid_vnr(pid);

    wake_up_new_task(p);

    put_pid(pid);
    return nr;
}

在早期的内核版本中, _do_fork 函数被用来处理 fork、vfork 和 clone 系统调用的进程创建过程

在 Linux 内核版本 5.10 中,_do_fork 被重命名为 kernel_clone

在 copy_process 函数中首先通过 dup_task_struct 复制了当前结构体, 然后依次拷贝父进程的资源, 包括 memory, namespace, io, fs 等等,

struct task_struct *copy_process(
                    struct pid *pid,
                    int trace,
                    int node,
                    struct kernel_clone_args *args)
{

    struct task_struct *p;
    // 为进程创建 task_struct 结构
    p = dup_task_struct(current, node);

    // ....... 初始化子进程 ...........

    // ....... 开始拷贝父进程资源  .......
    retval = copy_fs(clone_flags, p);
    // 拷贝父进程的虚拟内存空间以及页表
    retval = copy_mm(clone_flags, p);

    // ......... 省略拷贝父进程的其他资源 .........
    retval = copy_namespaces(clone_flags, p);
    retval = copy_io(clone_flags, p);
    retval = copy_thread(p, args);

    // 分配 CPU
    retval = sched_fork(clone_flags, p);
    // 分配 pid
    pid = alloc_pid(p->nsproxy->pid_ns_for_children);
    // ...
}

copy_mm 函数负责处理子进程虚拟内存空间的初始化工作, 对于非共享页面(CLONE_VM)它会调用 dup_mm 函数, 对于共享页面则调用 mmget 将页面用户计数(mm_users) + 1

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
    struct mm_struct *mm, *oldmm;
    /*
     * Are we cloning a kernel thread?
     *
     * We need to steal a active VM for that..
     */
    oldmm = current->mm;
    if (!oldmm)
        return 0;

    if (clone_flags & CLONE_VM) {
        mmget(oldmm);
        mm = oldmm;
    } else {
        mm = dup_mm(tsk, current->mm);
        if (!mm)
            return -ENOMEM;
    }

    tsk->mm = mm;
    tsk->active_mm = mm;
    sched_mm_cid_fork(tsk);
    return 0;
}

最终在 dup_mm 函数中将父进程虚拟内存空间的所有内容包括父进程的相关页表全部拷贝到子进程中, 首先申请一个新的 mm_struct, 然后将父进程 mm_struct 结构里的内容全部拷贝到子进程 mm_struct 结构中, 然后调用 mm_init 分配一个新的 pgd

static struct mm_struct *dup_mm(struct task_struct *tsk,
                struct mm_struct *oldmm)
{
    struct mm_struct *mm;
    int err;

    // 为子进程申请 mm_struct 结构
    mm = allocate_mm();
    if (!mm)
        goto fail_nomem;

    // 将父进程 mm_struct 结构里的内容全部拷贝到子进程 mm_struct 结构中
    memcpy(mm, oldmm, sizeof(*mm));

    // 为子进程分配顶级页表起始地址并赋值给 mm_struct->pgd
    if (!mm_init(mm, tsk, mm->user_ns))
        goto fail_nomem;

    // 拷贝父进程的虚拟内存空间中的内容以及页表到子进程中
    err = dup_mmap(mm, oldmm);
    return NULL;
}

在 mm_init 当中申请了一个新的页面赋值给新进程的 mm->pgd

static struct mm_struct *mm_init(struct mm_struct *mm, struct task_struct *p,
    struct user_namespace *user_ns)
{
    // .... 初始化子进程的 mm_struct 结构 ......
    
    // 为子进程分配顶级页表起始地址 pgd
    if (mm_alloc_pgd(mm))
        goto fail_nopgd;

}

static inline int mm_alloc_pgd(struct mm_struct *mm)
{
    // 内核为子进程分配好其顶级页表起始地址之后
    // 赋值给子进程 mm_struct 结构中的 pgd 属性
    mm->pgd = pgd_alloc(mm);
    if (unlikely(!mm->pgd))
        return -ENOMEM;
    return 0;
}

此时新进程虽然分配了新的 pgd 页面, 但是并没有旧进程的页表信息. 因此需要将 oldmm 和 mm 建立内存区域的映射, 调用 dup_mmap 将 old_mm 的内存区域复制到新 mm

static struct mm_struct *dup_mm(struct task_struct *tsk,
                struct mm_struct *oldmm)
{
    struct mm_struct *mm;
    int err;

    // 为子进程申请 mm_struct 结构
    mm = allocate_mm();
    if (!mm)
        goto fail_nomem;

    // 将父进程 mm_struct 结构里的内容全部拷贝到子进程 mm_struct 结构中
    memcpy(mm, oldmm, sizeof(*mm));

    // 为子进程分配顶级页表起始地址并赋值给 mm_struct->pgd
    if (!mm_init(mm, tsk, mm->user_ns))
        goto fail_nomem;

    // 拷贝父进程的虚拟内存空间中的内容以及页表到子进程中
    err = dup_mmap(mm, oldmm);
    return NULL;
}

dup_mmap 依次将 oldmm 的所有 vma 复制到 mm

int dup_mmap(struct mm_struct *mm,
                    struct mm_struct *oldmm)
{
    struct vm_area_struct *mpnt, *tmp;
    VMA_ITERATOR(old_vmi, oldmm, 0);

    for_each_vma(old_vmi, mpnt) {
        // ...
        tmp = vm_area_dup(mpnt);
        tmp->vm_mm = mm;
        if (!(tmp->vm_flags & VM_WIPEONFORK))
            retval = copy_page_range(tmp, mpnt);
    }
}

初始 pgd 内容为空, copy 几轮之后就有了, 和原先进程的页表内容相同

20240919233719

整个过程的函数调用栈如下

do_sys_clone
  kernel_clone
    copy_process
      copy_mm
        dup_mm
          |mm_init
          dup_mmap
            copy_page_range

gdb 调试页面遍历

以下述代码为例, 关于如何调试可执行文件见 调试内核 [调试可执行文件]

#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sched.h>
#include <unistd.h>

char bar[3968] = "\n";
char foo[4096] = "this is not a test\n";

void output_loop(char *str)
{
    int i;
    for (i = 0; i < 20; i++) {
        write(2, str, strlen(str));
        sched_yield();
    }
}

int main()
{
    int pid1, pid2, status;

    write(2, foo, strlen(foo));
    strcpy(foo, "you are modified\n");
    write(2, foo, strlen(foo));

    if (!(pid1 = fork())) {
        output_loop("B  ");
        exit(0);
    }

    if (!(pid2 = fork())) {
        output_loop("C  ");
        exit(0);
    }

    output_loop("A  ");
    waitpid(pid1, &status, 0);
    waitpid(pid2, &status, 0);
    write(2, "\n", 1);

    while (1)
        ;

    exit(0);
}

在断点处我们可以在 gdb 中查看 foo 数组的值, 为字符串 "this is not a test\n"

(gdb) p foo
$1 = "this is not a test\n", '\000' <repeats 4076 times>

那么我们有没有办法从 foo 的地址出发找到该内存的值呢? 首先我们打印 &foo 地址, 然后按照四级页表的划分方式对该地址做分割

(gdb) p &foo
$2 = (char (*)[4096]) 0x4c7080 <foo>
(gdb) pgd 0x4c7080
Virtual address: 0x4c7080
PGD Offset: 0x0
PGD Index: 0x0
PUD Offset: 0x0
PUD Index: 0x0
PMD Offset: 0x2
PMD Index: 0x10
PTE Offset: 0xc7
PTE Index: 0x638
Page Offset: 0x80
Virtual address mapped from kernel address: 0xffff888000000000

其中 pgd 为笔者自定义的命令, 见 qemu pgd.py

对于地址 0x4c7080, 按照四级页表的划分如下图所示, 每一级页表项的数值并不是索引地址, 64 位系统中每一个页表索引项有 8 字节, 所以左移3位即可计算出 index

20240919184035

可以通过 $lx_current() 宏来查看当前进程 task_struct 信息, 打印当前进程的 mm.pgd

(gdb) p $lx_current().mm.pgd
$3 = (pgd_t *) 0xffff88800445a000

内核空间映射物理地址

值得一提的是你会看到上面的 gdb 中输出了一个奇怪的地址: 0xffff888000000000

我们知道页表记录了虚拟地址到物理地址的映射, 但是即使我们得到了一个物理地址, 我们是没有办法直接访问这个物理地址的, 手动的页面遍历似乎进行不下去了

但好消息是 Linux 内核通常会将物理地址映射到内核的虚拟地址空间.在 x86_64 架构中,物理地址通常通过 __PAGE_OFFSET_BASE_L4 或 __PAGE_OFFSET_BASE_L5 映射到虚拟地址空间.

假设系统使用 4 级页表,则内核虚拟地址空间的起始偏移量是 __PAGE_OFFSET_BASE_L4,通常为 0xffff888000000000.

// arch/x86/include/asm/page_64_types.h
#define __PAGE_OFFSET_BASE_L5   _AC(0xff11000000000000, UL)
#define __PAGE_OFFSET_BASE_L4   _AC(0xffff888000000000, UL)

因此我们只需要将对应的物理地址 + 内核映射偏移量即可作为虚拟地址访问这个物理地址了

相关内容见 进程内存布局 [X86-64]

页面遍历

pgd 的地址是内存方式时页面遍历的起点, 我们会从这里开始, 然后依次逐级访问 0/0/0x10/0x638 的页面偏移量

(gdb) p/x 0xffff888000000000
$4 = 0xffff888000000000

# PGD + PGD index
(gdb) x/16x 0xffff88800445a000+0
0xffff88800445a000:     0x0445f067      0x00000000      0x00000000      0x00000000
0xffff88800445a010:     0x00000000      0x00000000      0x00000000      0x00000000
0xffff88800445a020:     0x00000000      0x00000000      0x00000000      0x00000000
0xffff88800445a030:     0x00000000      0x00000000      0x00000000      0x00000000

# PMD + PMD index
(gdb) x/16x $4+0x0445f000+0
0xffff88800445f000:     0x04457067      0x00000000      0x00000000      0x00000000
0xffff88800445f010:     0x00000000      0x00000000      0x00000000      0x00000000
0xffff88800445f020:     0x00000000      0x00000000      0x00000000      0x00000000
0xffff88800445f030:     0x00000000      0x00000000      0x00000000      0x00000000

# PUD + PUD index
(gdb) x/16x $4+0x04457000+0x10
0xffff888004457010:     0x04452067      0x00000000      0x00000000      0x00000000
0xffff888004457020:     0x00000000      0x00000000      0x00000000      0x00000000
0xffff888004457030:     0x00000000      0x00000000      0x00000000      0x00000000
0xffff888004457040:     0x00000000      0x00000000      0x00000000      0x00000000

# PTE + PTE index
(gdb) x/16x $4+0x04452000+0x638
0xffff888004452638:     0x037cf025      0x80000000      0x029ed067      0x80000000
0xffff888004452648:     0x029eb067      0x80000000      0x029e3067      0x80000000
0xffff888004452658:     0x03497225      0x80000000      0x00000000      0x00000000
0xffff888004452668:     0x00000000      0x00000000      0x00000000      0x00000000

+$4 就是上文提到的内核对于物理地址映射的偏移量

整个访问过程如下图所示, 根据上一级页表的起始地址 + 页表项偏移量即可找到下一级页表的起始地址

20240919190839

再根据最终的页面 + 页内偏移量即可访问到 foo 变量的数据了, 可以通过 x/s 以字符串格式查看, 即 "this is not a test\n"

(gdb) x/16x $4+0x037cf000+0x80
0xffff8880037cf080:     0x73696874      0x20736920      0x20746f6e      0x65742061
0xffff8880037cf090:     0x000a7473      0x00000000      0x00000000      0x00000000
0xffff8880037cf0a0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffff8880037cf0b0:     0x00000000      0x00000000      0x00000000      0x00000000
(gdb) x/s $4+0x037cf000+0x80
0xffff8880037cf080:     "this is not a test\n"

页表格式解释

值得一提的是每一级页表项的值都忽略了低 12 位, 在 虚拟地址转换 的页面格式中我们提到由于直接采用虚拟地址的页内偏移量作为最后物理地址的页内偏移量, 因此在页表项中低 12 位(4KB) 并没有用到, 这些位用来记录当前页表项的状态信息

image

对于 PGD PMD PUD 的页表项的页面格式为 0x67(0110 0111), 这表示

20240919192357

而对于最后一级页表项 PTE, 页面格式为 (0x25)

参考

zood