Linux 内核页表的创建

原文地址 jekton.github.io,未经允许,不得转载。

源码使用 Linux 2.6.24,基于 x86 平台;参考书是《深入理解 LINUX 内核》第三版

内核跟普通的应用一样,为了使用虚拟内存,也需要一个给 CPU 设置一个页表。在这篇文章中,我们就一起来了解 Linux 是如何为内核创建页表的。需要注意的是,这里我并不打算详细讲解页表的方方面面,硬件相关的基础知识,读者可以参考《深入理解LINUX内核》第3版第2章。本文的目的在于,作为该书的补充,基于真实的源码来讲解这一过程。

临时内核页表的构造

x86 系统刚刚启动时候运行在实模式下,这个时候线性地址就是物理地址。为了进入 32 位保护模式,首先就要启用分页(paging)。这就要求我们构建一个页表;这张页表把线性地址映射转换为物理地址。由于不同的计算机的配置不一样,他们需要的页表大小、页表个数也都不一样,所以需要在运行时动态分配页表,这就要求我们具有动态内存分配能力。

为了解决构造页表时候的鸡生蛋蛋生鸡问题,Linux 使用了一个临时的内核页表。它只有两个页表(这里的页表指的是用来索引页框的最后一级页表)。在不启用 PAE (Page Addression Extension) 和 PSE(Page Size Extension)的情况下,一个页表可以指向 10^2 = 1024 个内存页,一个内存页 4K,所以两个页表允许我们索引 8M 的内存。

顶层的页目录(page directory)使用全局变量 swapper_pg_dir 定义,下面是它的声明:

// ${linux_source}/include/asm-x86/pgtable_32.h

// empty_zero_page 在后面也会用到,这里就一并列出来了
extern unsigned long empty_zero_page[1024];
extern pgd_t swapper_pg_dir[1024];

他在 head_32.S 里面定义的:

# ${linux_source}/arch/x86/kernel/head_32.S

/*
 * BSS section
 */
.section ".bss.page_aligned","wa"
    .align PAGE_SIZE_asm
ENTRY(swapper_pg_dir)
    .fill 1024,4,0
ENTRY(swapper_pg_pmd)
    .fill 1024,4,0
ENTRY(empty_zero_page)
    .fill 4096,1,0

这里的 .fill 1024,4,0 的意思是用 0 填充 1024 个 4 byte 长度的内存(一个页目录项(page table entry)的大小是 32 bit)。

接下来是变量 pg0

// ${linux_source}/include/asm-x86/pgtable_32.h

/* The boot page tables (all created as a single array) */
extern unsigned long pg0[];

pg0 通过指示链接器,放在了 bss 段的后面。

SECTIONS
{
  /* 前面那些都略去了 */

  .bss : AT(ADDR(.bss) - LOAD_OFFSET) {
    __init_end = .;
    __bss_start = .;        /* BSS */
    *(.bss.page_aligned)
    *(.bss)
    . = ALIGN(4);
    __bss_stop = .;
    _end = . ;
    /* This is where the kernel creates the early boot page tables */
    . = ALIGN(4096);
    pg0 = . ;
  }

  /* ... */
}

有了 swapper_pg_dirpg0 后,接下来的工作就是对它们进行初始化。此时还处于实模式下,这部分工作是由汇编代码完成的。

# ${linux_source}/arch/x86/kernel/head_32.S

/*
 * Initialize page tables.  This creates a PDE and a set of page
 * tables, which are located immediately beyond _end.  The variable
 * init_pg_tables_end is set up to point to the first "safe" location.
 * Mappings are created both at virtual address 0 (identity mapping)
 * and PAGE_OFFSET for up to _end+sizeof(page tables)+INIT_MAP_BEYOND_END.
 *
 * Warning: don't use %esi or the stack in this code.  However, %esp
 * can be used as a GPR if you really need it...
 */
# __PAGE_OFFSET 是 0xc000 0000,所以 page_pde_offset 是 0xc00
page_pde_offset = (__PAGE_OFFSET >> 20);

default_entry:
    # __PAGE_OFFSET 是 3G,pg0 是虚拟地址,减去 __PAGE_OFFSET 后就得到了
    # pg0 的物理地址。我们把 pg0 的物理地址放在了 edi 寄存器里
    movl $(pg0 - __PAGE_OFFSET), %edi
    # 同理,这里把 swapper_pg_dir 的物理地址放在 edx
    movl $(swapper_pg_dir - __PAGE_OFFSET), %edx
    # page directory/table entry 的低 12 位都是一些标志物,各个位代表的含义
    # 读者可以参考 https://wiki.osdev.org/Paging 或者书中的第 52 页
    movl $0x007, %eax           /* 0x007 = PRESENT+RW+USER */
10:
    # 下面这两行代码对熟悉 C 语言的读者可能会造成一定的困扰。如果从 C 语言的角度
    # 来看,它们是把地址 &pg0 + 7 放到了 swapper_pg_dir 的第一项;但问题在于,
    # 为什么要 +7?
    # 其实这里的 7 和前面那个 7 一样,指的是页目录项的标志物 PRESENT+RW+USER,
    # pg0 的地址是 4K 对齐的,这意味着他的地址的低 12 位都为 0,加上 7 以后,刚
    # 好就是我们所需要的页目录项的值。
    leal 0x007(%edi),%ecx           /* Create PDE entry */
    movl %ecx,(%edx)            /* Store identity PDE entry */
    # 书里有说明,我们要把 0x0000 0000 ~ 0x007f ffff 和 0xc000 0000 ~ 0xc07f ffff
    # 都映射到物理地址 0x0000 0000 ~ 0x007f ffff,下面这一行设置的 0xc000 0000
    # 对应的页目录项。
    # 这里的问题在于,按照书里的说明,我们应该设置的是第 0x300 项,这里是加上的却是 0xc00。
    # 这里需要提一下平时用 C 语言时编译器帮我们做的事。当我们写下 int *p = NULL; p+2
    # 的时候,编译器知道 int 是 4 个字节,所以 p+2 会汇编代码里面是 +8。
    # 一个 PDE 也是 32 位,所以真正的偏移量是 0x300 << 2 = 0xc00
    movl %ecx,page_pde_offset(%edx)     /* Store kernel PDE entry */
    # edx + 4 以后,就是下一个页目录项了,下个循环将会继续初始化(一共两个页目录项)
    addl $4,%edx
    # 一个页表有 1024 个页表项,这里初始化一个在接下来的循环里面用到的计数器
    movl $1024, %ecx
11:
    # stosl 把 %eax 的内容复制到物理地址 ES:EDI,也就是 pg0 处;并且 %edi + 4
    stosl
    # 加上 0x1000 后,%eax 指向下一个页
    addl $0x1000,%eax
    # %ecx -= 1,如果 %ecx 不为 0,跳转到 11 处。这里总共会循环 1024 次,初始化 1024 个页表项。
    loop 11b
    /* End condition: we must map up to and including INIT_MAP_BEYOND_END */
    /* bytes beyond the end of our own page tables; the +0x007 is the attribute bits */
    leal (INIT_MAP_BEYOND_END+0x007)(%edi),%ebp
    cmpl %ebp,%eax
    jb 10b
    # 到这里的时候,%edi 的值是我们映射的最后一个页表项的地址,这里我们把它存到变量
    # init_pg_tables_end 里。init_pg_tables_end 在 setup_32.c 里定义
    movl %edi,(init_pg_tables_end - __PAGE_OFFSET)

    # 下面是固定映射的,这部分就先不看了
    /* Do an early initialization of the fixmap area */
    movl $(swapper_pg_dir - __PAGE_OFFSET), %edx
    movl $(swapper_pg_pmd - __PAGE_OFFSET), %eax
    addl $0x67, %eax            /* 0x67 == _PAGE_TABLE */
    movl %eax, 4092(%edx)

    xorl %ebx,%ebx              /* This is the boot CPU (BSP) */
    jmp 3f

前面代码的最后一行是一个 jmp 3f,下面,我们就看看这个 3 处的代码。

启用分页

构建好临时内核页表后,接下来就该启用分页了。

# ${linux_source}/arch/x86/kernel/head_32.S

3:
/*
 * Enable paging
 */
    movl $swapper_pg_dir-__PAGE_OFFSET,%eax
    # %cr3 寄存器存放的是页表的地址
    movl %eax,%cr3      /* set the page table pointer.. */
    movl %cr0,%eax
    # cr0 的最高位是 Paging 位,置 1 后启用分页
    # 关于 cr0,参考 https://en.wikipedia.org/wiki/Control_register#CR0
    orl $0x80000000,%eax
    movl %eax,%cr0      /* ..and set paging (PG) bit */

CPU 的分页机制现在已经启用了,但是我们的页表还是不完整的,剩下部分将会使用 C 语言来完成。

构建线性地址的内核页表

完整的页表构建是从函数 pagetable_init 开始的:

// ${linux_source}/arch/x86/mm/init_32.S

static void __init pagetable_init (void)
{
    unsigned long vaddr, end;
    pgd_t *pgd_base = swapper_pg_dir;

    /* Enable PSE if available */
    if (cpu_has_pse)
        set_in_cr4(X86_CR4_PSE);

    /* Enable PGE if available */
    if (cpu_has_pge) {
        set_in_cr4(X86_CR4_PGE);
        __PAGE_KERNEL |= _PAGE_GLOBAL;
        __PAGE_KERNEL_EXEC |= _PAGE_GLOBAL;
    }

    kernel_physical_mapping_init(pgd_base);

    // 下面是固定映射相关的内容,这里就先忽略了
}

实际的页表构建是在函数 kernel_physical_mapping_init 完成的:

// ${linux_source}/arch/x86/mm/init_32.c

/*
 * This maps the physical memory to kernel virtual address space, a total 
 * of max_low_pfn pages, by creating page tables starting from address 
 * PAGE_OFFSET.
 */
static void __init kernel_physical_mapping_init(pgd_t *pgd_base)
{
    unsigned long pfn;
    pgd_t *pgd;
    pmd_t *pmd;
    pte_t *pte;
    int pgd_idx, pmd_idx, pte_ofs;

    // PAGE_OFFSET 是 0xc000 0000,这里拿的内核虚拟地址第一项对应的 pgd 的 index
    pgd_idx = pgd_index(PAGE_OFFSET);
    pgd = pgd_base + pgd_idx;
    pfn = 0;    // pfn 代表 page frame number

    // 初始化 pgd。pgd 的项数由 PTRS_PER_PGD 定义,在最普通的情况下,它是 1024。
    // 如果启用了 PAE,则等于 4
    for (; pgd_idx < PTRS_PER_PGD; pgd++, pgd_idx++) {
        // 32 位的系统一般是 2 级页表结构(为什么说它是一般,读者后面就会知道了)
        // 每个 pgd 项都指向一个 pmd,one_md_table_init 初始化一个 pmd。
        // 建议读者这里先跳过本函数后面部分,看完 one_md_table_init 再回过头来继续往下看
        pmd = one_md_table_init(pgd);
        // max_low_pfn 是被内核直接映射的最后一个页框的页框号,参考书中第 72 页
        if (pfn >= max_low_pfn)
            // 超过 max_low_pfn 的 pte 可以不初始化,但 pmd 必须初始化,所以用 continue
            continue;
        // 对不启用 PAE 的系统来说,这里的 pmd 就是 pgd,PTRS_PER_PMD 等于 1。
        // 如果启用 PAE,PTRS_PER_PMD 等于 512。
        // 这里的 pmd 相当于页目录(Page Directory),下面的循环里初始化每个页目录项(每个页目录项
        // 指向一个页表项)
        for (pmd_idx = 0; pmd_idx < PTRS_PER_PMD && pfn < max_low_pfn; pmd++, pmd_idx++) {
            // address 是当前(物理)页框开头对应的虚拟地址
            unsigned int address = pfn * PAGE_SIZE + PAGE_OFFSET;

            /* Map with big pages if possible, otherwise create normal page tables. */
            if (cpu_has_pse) {
                // pfn + PTRS_PER_PTE - 1 是当前 pmd 能够索引的最大的页框号
                // * PAGE_SIZE + PAGE_OFFSET + (PAGE_SIZE-1) 就是当前 pmd 做能够指向的最大的
                // 地址。也就是说,pmd 的地址范围是 [address, address2]
                unsigned int address2 = (pfn + PTRS_PER_PTE - 1) * PAGE_SIZE + PAGE_OFFSET + PAGE_SIZE-1;
                if (is_kernel_text(address) || is_kernel_text(address2))
                    // pmd 包含了内核的 text 段,所以加上了 exec 标记
                    set_pmd(pmd, pfn_pmd(pfn, PAGE_KERNEL_LARGE_EXEC));
                else
                    set_pmd(pmd, pfn_pmd(pfn, PAGE_KERNEL_LARGE));

                // 启用 PSE 后就不需要 pte 了。
                // 对于启用了 PAE 的机器来说,一页是 2^(9+12) = 2M
                // 没有 PAE 则是 2^(10+12) = 4M
                pfn += PTRS_PER_PTE;
            } else {
                pte = one_page_table_init(pmd);

                for (pte_ofs = 0;
                     pte_ofs < PTRS_PER_PTE && pfn < max_low_pfn;
                     pte++, pfn++, pte_ofs++, address += PAGE_SIZE) {
                    if (is_kernel_text(address))
                        set_pte(pte, pfn_pte(pfn, PAGE_KERNEL_EXEC));
                    else
                        set_pte(pte, pfn_pte(pfn, PAGE_KERNEL));
                }
            }
        }
    }
}


/*
 * Creates a middle page table and puts a pointer to it in the
 * given global directory entry. This only returns the gd entry
 * in non-PAE compilation mode, since the middle layer is folded.
 */
static pmd_t * __init one_md_table_init(pgd_t *pgd)
{
    pud_t *pud;
    pmd_t *pmd_table;
        
#ifdef CONFIG_X86_PAE
    if (!(pgd_val(*pgd) & _PAGE_PRESENT)) {
        // 启用 PAE 的情况下,32 bit 的虚拟地址分为 2 9 9 12,pgd 有
        // 2^2 = 4 项;pmd 是 2^9 = 512 项;然后是 pte 2^9 = 512 项;
        // pte 在 kernel_physical_mapping_init 中初始化。
        // PAE 相关知识参考书上第 56 页

        // bootmem 相关的后面昨晚单独的一篇文章来讲述,这里假装内存被
        // 神奇地分配出来就好
        pmd_table = (pmd_t *) alloc_bootmem_low_pages(PAGE_SIZE);

        // 虚拟化相关的东西,忽略就好
        paravirt_alloc_pd(__pa(pmd_table) >> PAGE_SHIFT);
        set_pgd(pgd, __pgd(__pa(pmd_table) | _PAGE_PRESENT));
        pud = pud_offset(pgd, 0);
        if (pmd_table != pmd_offset(pud, 0))
            BUG();
    }
#endif
    // 在不启用 PAE 的情况下,下面返回的 pmd_table 其实就是 pgd(也就是
    // 直接从 pgd 到 pte,两者都是 2^10 = 1024 项)
    pud = pud_offset(pgd, 0);
    pmd_table = pmd_offset(pud, 0);
    return pmd_table;
}


// 这个函数就比较平凡了,没有什么好说的
/*
 * Create a page table and place a pointer to it in a middle page
 * directory entry.
 */
static pte_t * __init one_page_table_init(pmd_t *pmd)
{
    if (!(pmd_val(*pmd) & _PAGE_PRESENT)) {
        pte_t *page_table = NULL;

#ifdef CONFIG_DEBUG_PAGEALLOC
        page_table = (pte_t *) alloc_bootmem_pages(PAGE_SIZE);
#endif
        if (!page_table)
            page_table =
                (pte_t *)alloc_bootmem_low_pages(PAGE_SIZE);

        paravirt_alloc_pt(&init_mm, __pa(page_table) >> PAGE_SHIFT);
        set_pmd(pmd, __pmd(__pa(page_table) | _PAGE_TABLE));
        BUG_ON(page_table != pte_offset_kernel(pmd, 0));
    }

    return pte_offset_kernel(pmd, 0);
}

这部分代码其实有 4 中情况:有 PAE 和没有 PAE两种,这两种又分别有 PSE 启不启用两种情况。读者可以分情况一个一个看,分情况弄清楚后,再合并一起看。

固定映射的线性地址、非连续内存区的线性地址

处于篇幅和学习目的考虑,固定映射、非连续内存的处理在这里就先略去了,以后有机会再单独开一篇文章补上。内核页表的创建相关的代码我们就先看到这里。

你可能感兴趣的:(Linux 内核页表的创建)