系统教程 · 2024年10月15日

RISC-V Linux启动之页表创建分析

上篇分析了RISC-V Linux的汇编启动过程,其中讲到了relocate重定向需要开启MMU,今天分析RISC-V Linux的页表创建。

注意:本文基于5.10.111内核

sv39 mmu

RISC-V Linux支持sv32、sv39、sv48等虚拟地址格式,分别代表32为虚拟地址、38位虚拟地址和48位虚拟地址。RISC-V Linux默认也是使用sv39格式,sv39的虚拟地址、物理地址、PTE格式如下:

虚拟地址格式:

物理地址格式:

PTE格式:

虚拟地址使用39位表示,其中低12位代表page offset,高位划分为了三部分:VP N[0]、VP N[1]和VP N[2],分别代表虚拟地址VA在PTE、PMD和PGD中的索引。

物理地址使用56位表示,低12位代表page offset,高位是物理页PPN[0]、PPN[1]和PPN[2]

PTE保存了物理页PPN[0]、PPN[1]和PPN[2],和物理地址中的PPN相对应;PTE的低10位代表物理地址的访问权限,当RWX全为0时,则代表该PTE存储的地址是下一级页表的物理地址,否则代表当前页表是最后一级页表

再看看sv39 的页表格式,sv39使用的是三级页表,PGD、PMD和PTE,每一个级页表使用9bit表示,即每一级页表都有512个页表项。

在代码中,创建一个有512个元素的数组即代表一个页表。一个PTE有512个页表项,每一个页表项占用8字节,512*8=4096字节,所以一个PTE代表4K。一个PMD也是512个页表项,每一项可代表一个PTE,512 *4 K=2M,所以一个PMD就代表2M。以此类推,一个PGD代表512 * 2M=1G。

重要结论:PGD代表1G、PMD代表2M、PTE代表4K。sv39默认的页大小是4K

三级页表虚拟地址转为物理地址过程示意图:

sv39三级页表虚拟地址转为物理地址过程:

MMU通过satp寄存器得到PGD的物理地址,结合PGD index(即V PN[2])找到PMD;找到PMD后,再结合PMD index(即V PN[1])找到PTE,然后结合PTE index(即V PN[0])得到VA在PTE索引中的值,从而得到物理地址。

最后在PTE中取出PPN[2]、PPN[1]和PPN[0],再和虚拟地址的低12位offset相加,得到最终的物理地址。

临时页表分析

MMU开启前,需要建立好kernel、dtb、trampoline等页表。以便MMU开启后,并且在内存管理模块运行之前,kernel可以正常初始化,dtb可以正常地被解析。这部分页表都是临时页表,最终的页表在setup_vm_final()建立。

临时页表创建顺序:

首先为fixmap创建早期的PGD、PMD,这时PGD使用early_pg_dir。然后对从kernel开始的前2M内存建立二级页表,此时PGD使用trampoline_pg_dir,为这2M建立的页表也叫作superpage。再然后,对整个kernel创建二级页表,此时PGD使用early_pg_dir。最后为dtb预留4M大小创建二级页表。

页表创建函数

create_pgd_mapping()

void __init create_pgd_mapping(pgd_t *pgdp,
          uintptr_t va, phys_addr_t pa,
          phys_addr_t sz, pgprot_t prot)

登录后复制

pgdp:PGD页表

va:虚拟地址

pa:物理地址

sz:映射大小,PGDIR_SIZE或PMD_SIZE或PTE_SIZE

prot:PAGE_KERNEL_EXEC/PAGE_KERNEL表示当前是最后一级页表,否则pa代表下一级页表的物理地址

create_pmd_mapping()

static void __init create_pmd_mapping(pmd_t *pmdp,
          uintptr_t va, phys_addr_t pa,
          phys_addr_t sz, pgprot_t prot)

登录后复制

pmdp:PMD页表

va:虚拟地址

pa:物理地址

sz:映射大小,PMD_SIZE或PAGE_SIZE

prot:权限,PAGE_KERNEL_EXEC/PAGE_KERNEL表示当前是最后一级页表,否则pa代表下一级页表的物理地址

create_pte_mapping()

static void __init create_pte_mapping(pte_t *ptep,
          uintptr_t va, phys_addr_t pa,
          phys_addr_t sz, pgprot_t prot)

登录后复制

ptep:PTE页表

va:虚拟地址

pa:物理地址

sz:映射大小,PAGE_SIZE

prot:权限,PAGE_KERNEL_EXEC/PAGE_KERNEL表示当前是最后一级页表,否则pa代表下一级页表的物理地址

使用举例

例如,将虚拟地址PAGE_OFFSET映射到物理地址pa,映射大小为4K,创建三级页表PGD、PMD和PTE:

create_pgd_mapping(early_pg_dir,PAGE_OFFSET,
                   (uintptr_t)early_pmd,PGDIR_SIZE,PAGE_TABLE);
create_pmd_mapping(early_pmd,PAGE_OFFSET,
                   (uintptr_t)early_pte,PGDIR_SIZE,PAGE_TABLE);
create_pte_mapping(early_pte,PAGE_OFFSET,
                   (uintptr_t)pa,PAGE_SIZE,PAGE_KERNEL_EXEC);

登录后复制

这样创建后,MMU就会根据PAGE_OFFSET在PGD中找到PMD,然后再PMD中找到PTE,最后取出物理地址。

页表创建源码分析

RISC-V Linux启动,经历了两次页表创建过程,第一次使用C函数setup_vm()创建临时页表,第二次使用C函数setup_vm_final()创建最终页表。

具体细节参考代码中的注释,下面的代码省略了一些不重要的部分。

setup_vm()

asmlinkage void __init setup_vm(uintptr_t dtb_pa)
{
 uintptr_t va, pa, end_va;
 uintptr_t load_pa = (uintptr_t)(&_start);
 uintptr_t load_sz = (uintptr_t)(&_end) - load_pa;
 uintptr_t map_size;
 //load_pa就是kernel加载的其实物理地址
    //load_sz就是kernel的实际大小

    //page_offset就是kernel的起始物理地址对应的虚拟地址,va_pa_offset是他们的偏移量
 va_pa_offset = PAGE_OFFSET - load_pa;
    
    //计算得到kernel起始物理地址的物理页,PFN_DOWN是将物理地址右移12位,因为sv39的物理地址的低12位是pa_offset,所以右移12位,得到pfn
 pfn_base = PFN_DOWN(load_pa);

 map_size = PMD_SIZE;//PMD_SIZE为2M,在当前,map_size只能为PGDIR_SIZE或PMD_SIZE。这时kernel默认不允许建立PTE。

 //检查PAGE_OFFSET是否1G对齐,以及kernel入口地址是否2M对齐
 BUG_ON((PAGE_OFFSET % PGDIR_SIZE) != 0);
 BUG_ON((load_pa % map_size) != 0);

    //allc_pte_early里面是BUG(),对于临时页表,kernel不允许我们建立PTE
 pt_ops.alloc_pte = alloc_pte_early;
 pt_ops.get_pte_virt = get_pte_virt_early;
#ifndef __PAGETABLE_PMD_FOLDED
 pt_ops.alloc_pmd = alloc_pmd_early;
 pt_ops.get_pmd_virt = get_pmd_virt_early;
#endif
 /* 设置 early PGD for fixmap */
 create_pgd_mapping(early_pg_dir, FIXADDR_START,
      (uintptr_t)fixmap_pgd_next, PGDIR_SIZE, PAGE_TABLE);


 /* 设置 fixmap PMD */
 create_pmd_mapping(fixmap_pmd, FIXADDR_START,
      (uintptr_t)fixmap_pte, PMD_SIZE, PAGE_TABLE);
 /* 设置 trampoline PGD and PMD */
 create_pgd_mapping(trampoline_pg_dir, PAGE_OFFSET,
      (uintptr_t)trampoline_pmd, PGDIR_SIZE, PAGE_TABLE);
 create_pmd_mapping(trampoline_pmd, PAGE_OFFSET,
      load_pa, PMD_SIZE, PAGE_KERNEL_EXEC);

 /*
  * 设置覆盖整个内核的早期PGD,这将使我们能够达到paging_init()。
  * 稍后在下面的 setup_vm_final() 中映射所有内存。
  */
 end_va = PAGE_OFFSET + load_sz;
 for (va = PAGE_OFFSET; va <blockquote data-tool="mdnice编辑器" style="border-top: none;border-right: none;border-bottom: none;font-size: 0.9em;overflow: auto;color: rgb(106, 115, 125);padding: 10px 10px 10px 20px;margin-bottom: 20px;margin-top: 20px;border-left-color: rgb(239, 112, 96);background: rgb(255, 249, 249);">
<p style="font-size: 16px;padding-top: 8px;padding-bottom: 8px;color: black;line-height: 26px;">setup_vm()在最开始就进行了kernel入口地址的对齐检查,要求入口地址2M对齐。假设内存起始地址为0x80000000,那么kernel只能放在0x80000000、0x80200000等2M对齐处。为什么会有这种对齐要求呢?</p>
<p style="font-size: 16px;padding-top: 8px;padding-bottom: 8px;color: black;line-height: 26px;">我猜测单纯是为给opensbi预留了2M空间,因为kernel之前还有opensbi,而opensbi运行完之后,默认跳转地址就是偏移2M,kernel只是为了跟opensbi对应,所以设置了2M对齐。</p>
<p style="font-size: 16px;padding-top: 8px;padding-bottom: 8px;color: black;line-height: 26px;">那opensbi需要占用2M这么大?实际上只需要几百KB,因此opensbi和kernel中间有一段内存是空闲的,没有人使用。这个问题我们下篇再讲。</p>
</blockquote><h3 data-tool="mdnice编辑器" style="margin-top: 30px;margin-bottom: 15px;font-weight: bold;font-size: 20px;">
<span style="display: none;"></span>setup_vm_final()<span style="display: none;"></span>
</h3><p data-tool="mdnice编辑器" style="padding-top: 8px;padding-bottom: 8px;line-height: 26px;">在该函数中开始为整个物理内存做内存映射,通过swapper页表来管理,并且清除掉汇编阶段的页表。</p><pre class="brush:php;toolbar:false;">static void __init setup_vm_final(void)
{
 uintptr_t va, map_size;
 phys_addr_t pa, start, end;
 u64 i;

 /**
  * 此时MMU已经开启,但是页表还没完全建立。
  */
 pt_ops.alloc_pte = alloc_pte_fixmap;
 pt_ops.get_pte_virt = get_pte_virt_fixmap;
#ifndef __PAGETABLE_PMD_FOLDED
 pt_ops.alloc_pmd = alloc_pmd_fixmap;
 pt_ops.get_pmd_virt = get_pmd_virt_fixmap;
#endif
 /* Setup swapper PGD for fixmap */
 create_pgd_mapping(swapper_pg_dir, FIXADDR_START,
      __pa_symbol(fixmap_pgd_next),
      PGDIR_SIZE, PAGE_TABLE);

 /* 为整个物理内存创建页表 */
 for_each_mem_range(i, &amp;start, &amp;end) {
  if (start &gt;= end)
   break;
  if (start <p data-tool="mdnice编辑器" style="padding-top: 8px;padding-bottom: 8px;line-height: 26px;">说明:</p><p data-tool="mdnice编辑器" style="padding-top: 8px;padding-bottom: 8px;line-height: 26px;">在setup_vm_final()函数中,通过swapper_pg_dir页表来管理整个物理内存的访问。并且清除汇编阶段的页表fixmap_pte和early_pg_dir。(本质上就是把该页表项的内容清0,即赋值为0)</p><p data-tool="mdnice编辑器" style="padding-top: 8px;padding-bottom: 8px;line-height: 26px;">最终把swapper_pg_dir页表的物理地址赋值给SATP寄存器。这样CPU就可以通过该页表访问整个物理内存。</p><p data-tool="mdnice编辑器" style="padding-top: 8px;padding-bottom: 8px;line-height: 26px;">切换页表通过如下实现:</p><p data-tool="mdnice编辑器" style="padding-top: 8px;padding-bottom: 8px;line-height: 26px;">csr_write(CSR_SATP,PFN_DOWN(_pa(swapper_pg_dir))|SATP_MODE);</p><p data-tool="mdnice编辑器" style="padding-top: 8px;padding-bottom: 8px;line-height: 26px;">在swapper_pg_dir管理的kernel space中,其虚拟地址与物理地址空间的偏移是固定的,为va_pa_offset(定义在arch/riscv/mm/init.c中的一个全局变量)</p><blockquote data-tool="mdnice编辑器" style="border-top: none;border-right: none;border-bottom: none;font-size: 0.9em;overflow: auto;color: rgb(106, 115, 125);padding: 10px 10px 10px 20px;margin-bottom: 20px;margin-top: 20px;border-left-color: rgb(239, 112, 96);background: rgb(255, 249, 249);"><p style="font-size: 16px;padding-top: 8px;padding-bottom: 8px;color: black;line-height: 26px;">注意:swapper_pg_dir管理的是kernel space的页表,即它把物理内存映射到的虚拟地址空间是只能kernel访问的。user space不能访问,用户空间如果访问,必须自行建立页表,把物理地址映射到user space的虚拟地址空间。kernel线程共享这个swapper_pg_dir页表。</p></blockquote><h2 data-tool="mdnice编辑器" style="margin-top: 30px;margin-bottom: 15px;font-weight: bold;border-bottom: 1px solid rgb(239, 112, 96);font-size: 1.3em;">
<span style="display: none;"></span><span style="display: inline-block;background: rgb(239, 112, 96);color: rgb(255, 255, 255);padding: 2px 10px 1px;border-top-right-radius: 3px;border-top-left-radius: 3px;margin-right: 3px;">总结</span><span style="display: inline-block;vertical-align: bottom;border-bottom: 36px solid #efebe9;border-right: 20px solid transparent;"> </span>
</h2><p data-tool="mdnice编辑器" style="padding-top: 8px;padding-bottom: 8px;line-height: 26px;">RISC-V Linux启动时的页表创建相对来说还是比较容易理解的,都是C语言创建的,代码也比较少。主要就是setup_vm()和setup_vm_final()两个页表创建函数。理解了sv39的一些地址格式后,再去分析源码就比较容易。不过不同kernel版本代码都不一样,需要具体情况具体分析。</p><p data-tool="mdnice编辑器" style="padding-top: 8px;padding-bottom: 8px;line-height: 26px;">本篇提到了setup_vm()会检查kernel入口地址是否2M对齐,如果不对齐kernel无法启动,但其实我们可以解除这个2M对齐限制,将这部分空间利用起来,下篇教大家优化这部分内存。</p>

登录后复制

以上就是RISC-V Linux启动之页表创建分析的详细内容,更多请关注GTHOST其它相关文章!