diff --git a/articles/20230615-hugepaged-linear-mapping.md b/articles/20230615-hugepaged-linear-mapping.md new file mode 100644 index 0000000000000000000000000000000000000000..7d7a04cc09899d30e05c8cfb84d01b5f72343103 --- /dev/null +++ b/articles/20230615-hugepaged-linear-mapping.md @@ -0,0 +1,567 @@ +> Corrector: [TinyCorrect](https://gitee.com/tinylab/tinycorrect) v0.1 - [tables]
+> Author: sugarfillet
+> Date: 2023/06/15
+> Revisor: Falcon falcon@tinylab.org
+> Project: [RISC-V Linux 内核剖析](https://gitee.com/tinylab/riscv-linux)
+> Proposal: [RISC-V Linux SMP 技术调研与分析](https://gitee.com/tinylab/riscv-linux/issues/I5MU96)
+> Sponsor: PLCT Lab, ISCAS + +# RISC-V Linux 线性地址大页支持补丁解读 + +## 前言 + +Linux-v6.4-rc1 发布后带来了几个比较重要的变更,其中 commit (3335068f8721 "riscv: Use PUD/P4D/PGD pages for the linear mapping") 用来为内核线性地址空间提供的大页映射的支持,以获取更好的性能并回收了一些浪费的内存。然而,此补丁却带来不少的问题,比如:休眠场景下的 panic 以及 UEFI 启动过程中的 panic。 + +本文对该 commit 及其背景知识进行介绍,并对其引发的两个问题进行分析。 + +**说明**: + +- 本文的 Linux 版本采用 `Linux v6.4-rc1` +- 本文采用的一些缩略词解释如下 + +| 缩略词 | 全称 | 说明 | +|-------------|---------------------------------|------------------------------------------------------------------| +| PA | Physical Address | 物理地址 | +| VA | Virtual Address | 虚拟地址 | +| PPN | Physical Page Number | 物理页帧号,与页内偏移 page offset 构成物理地址 | +| VPN | Virtual Page Number | 虚拟页帧号,与页内偏移 page offset 构成虚拟地址 | +| PTE | Page Table Entry | 页表项,存放叶子页表或者页目录的 PPN,还有一层含义代表最后一级的页表 | +| PGD | Page Global Directory | 根(页)目录 | +| P4D/PUD/PMD | Page 4th/Upper/Middle Directory | 不同级别的页目录项,不同分页方案有不同页目录项,详见下文 | + +## RISC-V 地址翻译 + +如 "RISC-V-Reader-Chinese-v2p1.pdf" 文档所述: + +> RISC-V S 模式提供了一种传统的虚拟内存系统,它将内存划分为固定大小的页来进行地址转换和对内存内容的保护。启用分页的时候,大多数地址(包括 load 和 store 的有效地址和 +PC 中的地址)都是虚拟地址。要访问物理内存,它们必须被转换为真正的物理地址,这通过遍历一种称为页表的高基数树实现。页表中的叶节点指示虚地址是否已经被映射到了真正的物理页面,如果是,则指示了哪些权限模式和通过哪种类型的访问可以操作这个页。访问未被映射的页或访问权限不足会导致页错误例外(page fault exception)。 + +RISC-V MMU 可以将取指/load/store 等操作的的虚拟地址转化为物理地址,整个转换过程通过页表结构来实现。RISC-V 在 satp 寄存器的 Mode 字段定义所支持的分页方案,每种方案的区别主要体现在通过几个层级来映射不同长度的 VA 到不同长度的 PA,比如:Sv57 分页方案采用 5 级页表将 57 位的虚拟地址翻译为 56(44+12)位的物理地址: + +| stap.mode | SXLEN(pte_len) | VPNs | PPNs | page level | page tables | +|-----------|----------------|-------------------|------------------|------------|---------------------| +| Sv32 | 32 | 32 (10+10+12) | 22 (12+10) | 2 | PGD PTE | +| Sv39 | 64 | 39 (9+9+9+12) | 44 (26+9+9) | 3 | PGD PMD PTE | +| Sv48 | 64 | 48 (9+9+9+9+12) | 44 (17+9+9+9) | 4 | PGD PUD PMD PTE | +| Sv57 | 64 | 57 (9+9+9+9+9+12) | 44 (8+9+9+9+9+9) | 5 | PGD P4D PUD PMD PTE | + +### MMU 地址翻译过程 + +在 RISC-V 特权手册 "Virtual Address Translation Process" 一节中详细地描述了 Sv32 分页方案的地址翻译过程。这里在 Qemu 环境默认的 Sv57 的分页方案下,以内核的加载地址为例,结合 Linux 早期虚拟地址映射保留的每级页目录地址(early_p4d/early_pud/early_pmd),观察 MMU 如何将虚拟地址 -- `0xffffffff80000000` 翻译为其对应的物理地址: + +可参考此图阅读下文: + +![sv57_address_trans.png](images/riscv-linear-mapping/sv57_address_trans.png) + +首先将虚拟地址按照 Sv57 虚拟地址布局划分: + +``` +va=0xffffffff80000000 + +0x[f] 111| 1[ff] | [ff] 1|111 [f] 10| 00 [0] 000|0 [00] |[000] // [] 内的为 16 进制表示,[]之外的为按照 bit 表示,下文亦是如此 +> vpn4 vpn3 vpn2 vpn1 vpn0 offset +``` + +通过 satp.PPN 获取根页表:`early_pg_dir = satp.PPN << 12`,按照如下步骤依次获取每级目录的页表项: + +1. offset = va.vpn4 (511); pte = early_pg_dir[offset] (0x0000000020380c01) // `01` is not leaf so pte.ppn <<12 => early_p4d +2. offset = va.vpn3 (511); pte = early_p4d[offset] (0x0000000020380801) // pte.ppn <<12 => early_pud +3. offset = va.vpn2 (510); pte = early_pud[offset] (0x0000000020381001) // pte.ppn <<12 => early_pmd +4. offset = va.vpn1 (0); pte = early_pmd[offset] (0x00000000200800ef) // `ef` is leaf so + +在步骤 4 可以看到当前 VA 对应 earyly_pmd 中偏移为 0 的 PTE,此表项为叶子表项。对此 PTE 按照 PTE 格式划分如下,其中 ppn[4:0] 则为 44 位的物理地址表示,右移 10 位得到其对应的页帧号,再左移 12 位,填充 va.offset 则得到该地址对应的 56 位长度的物理地址。而这个物理地址 `0x00000080200000` 恰恰就是内核加载的物理地址,且映射存放在 PMD 上。 + +``` +pte=0x00000000200800ef + +N PBMT |Reserved PPN | RSW D A G U X W R V + +0x[00]0 | 000[0000020080] 00 | 00[ef] // pte +> ppn[4:0] page bits + +pte.ppn[4:0] = (0x00000000200800ef >>10) = 0x [000]00080200 (len is 44) + +pa = 0x00000080200[000] (ppt.ppn[4:0] <<12 | va.off) + +``` + +```c +(gdb) p/z early_pg_dir +$31 = {{pgd = 0x0000000000000000} , {pgd = 0x00000000205c1401}, { + pgd = 0x0000000000000000} , {pgd = 0x0000000020380c01}} // 511 +(gdb) p/z early_p4d +$35 = {{p4d = 0x0000000000000000} , {p4d = 0x0000000020380801}} // 511 +(gdb) p/z early_pud +$36 = {{pud = 0x0000000000000000} , {pud = 0x0000000020381001}, {pud = 0x0000000000000000}} //510 +(gdb) p/z early_pmd +$37 = {{pmd = 0x00000000200800ef}, {pmd = 0x00000000201000ef}, {pmd = 0x00000000201800ef}, {pmd = 0x00000000202000ef}, {pmd = 0x00000000202800ef}, {pmd = 0x00000000203000ef}, {pmd = 0x00000000203800ef}, { + pmd = 0x00000000204000ef}, {pmd = 0x00000000204800ef}, {pmd = 0x00000000205000ef}, {pmd = 0x00000000205800ef}, {pmd = 0x0000000000000000} } // 0 +``` + +基于以上过程,S-mode 软件如果要开启虚拟内存,则需要建立页表并将根页表的 PFN 写入到 satp 寄存器中,同时需要执行 `sfence.vma` 指令用以同步当前所有的内存读写操作。 + +### Linux 设置页表 + +RISC-V Linux 中使用 `create_pgd_mapping()` 函数用于创建(根)页表,此函数的使用场景有: + +1. 初期的内存映射 `setup_vm()` 为 fix-mapping、内核镜像创建早期的临时页表 -- `early_pg_dir` +2. 系统内存发现后的后期内存映射 `setup_vm_final()` 为内核镜像以及线性地址空间创建页表 -- `swapper_pg_dir` +3. efi 为 runtime 内存创建 runtime 页表 -- `efi_mm` +4. 在休眠唤醒过程中,需要对切换到休眠镜像中保存的页表,调用 `temp_pgtable_mapping()` 函数创建临时页表 + +这里以内核线性地址的页表创建过程来做说明:在 `setup_vm_final()` 阶段,系统内存通过 dtb 或者 UEFI 内存映射表已添加到 `memblok.memory` 或者保留在 `memblock.reserved` 中,调用 `create_linear_mapping_page_table()` 将 `memblock.memory` 中的可用物理内存映射到 `PAGE_OFFSET` 开始的线性虚拟内存区域,调用 `create_pgd_mapping()` 创建页表,最终写入 `satp` 寄存器。调用 `create_pgd_mapping()` 传递的参数有: + +1. `swapper_pg_dir`: 根页表,后续以此变量写入 satp +2. `va`: 要映射的虚拟地址 +3. `pa`: 要映射的物理地址 +4. `map_size`: 要映射的内存大小,在 `best_map_size()` 函数中通过物理地址是否对齐 PGDIR_SIZE/../PMD_SIZE 来决定映射大小(下文的 UEFI 启动 panic 就与此有关) + + 比如:物理地址 `0x00000080200000` 对齐 `PMD_SIZE`,则返回 `PMD_SIZE`,物理地址 `0x00000080210000` 则返回 `PAGE_SIZE` + +5. `prot`: 定义当前映射 PTE 的保护位 + + 比如:内核代码段则为 `PAGE_KERNEL_READ_EXEC` + +```c +// arch/riscv/mm/init.c : 1248 + +static void __init setup_vm_final(void) + create_linear_mapping_page_table(); + /* Map all memory banks in the linear mapping */ + for_each_mem_range(i, &start, &end) { + if (start >= end) + break; + if (start <= __pa(PAGE_OFFSET) && + __pa(PAGE_OFFSET) < end) + start = __pa(PAGE_OFFSET); + if (end >= __pa(PAGE_OFFSET) + memory_limit) + end = __pa(PAGE_OFFSET) + memory_limit; + + create_linear_mapping_range(start, end); + } + + csr_write(CSR_SATP, PFN_DOWN(__pa_symbol(swapper_pg_dir)) | satp_mode); // 写入根页表 PFN 到 satp + local_flush_tlb_all(); + +static void __init create_linear_mapping_range(phys_addr_t start, + phys_addr_t end) +{ + phys_addr_t pa; + uintptr_t va, map_size; + + for (pa = start; pa < end; pa += map_size) { + va = (uintptr_t)__va(pa); + map_size = best_map_size(pa, end - pa); + + create_pgd_mapping(swapper_pg_dir, va, pa, map_size, + pgprot_from_va(va)); + } +} +``` + +`create_pgd_mapping()` 函数用于在页表树中迭代建立内存映射,大致的过程如下: + +1. `pgd_index(va)` 获取 VA 在 PGD 中的偏移(PGD 目录中的 PTE 数目为 `PTRS_PER_PGD -1`) +2. 如果 `map_size` 为 `PGDIR_SIZE`,则表示此映射存放在 PGD 目录中对应索引的叶子 PTE,否则存放到下一级页目录 +3. 存放到下一级页目录 + - 如果此 PTE 为空,则调用 `pt_ops` 结构中分配下一级页目录的函数,并保存在当前 PTE 中。`opt_ops` 在不同启动阶段采用的分配后端不同,可参考 (`pt_ops_set_{early,fixmap,late}`) + - 获取下一级页目录的(虚拟)地址,并调用下一级页目录的创建函数 `create_pgd_next_mapping()`,不同的分页方案调用不同的函数 + - 如果指定的是最小的 `map_size` -- `PAGE_SIZE`,则最终调用 `create_pte_mapping()` 在 PTE 中存储该页物理地址的 PFN + +```c +// arch/riscv/mm/init.c : 635 + +void __init create_pgd_mapping(pgd_t *pgdp, + uintptr_t va, phys_addr_t pa, + phys_addr_t sz, pgprot_t prot) +{ + pgd_next_t *nextp; + phys_addr_t next_phys; + // 该虚拟地址在其对应的页目录中的索引 + uintptr_t pgd_idx = pgd_index(va); /// (((a) >> PGDIR_SHIFT) & (PTRS_PER_PGD - 1)) + + if (sz == PGDIR_SIZE) { // 存放在 PGD 中 + if (pgd_val(pgdp[pgd_idx]) == 0) + pgdp[pgd_idx] = pfn_pgd(PFN_DOWN(pa), prot); + return; + } + + if (pgd_val(pgdp[pgd_idx]) == 0) { // 创建下一级页目录,并将其物理地址保存到此表项中 + next_phys = alloc_pgd_next(va); + pt_ops.alloc_pmd(__va) + + pgdp[pgd_idx] = pfn_pgd(PFN_DOWN(next_phys), PAGE_TABLE); + + nextp = get_pgd_next_virt(next_phys); // 获取虚拟地址 + pt_ops.get_pmd_virt + + memset(nextp, 0, PAGE_SIZE); + } else { + next_phys = PFN_PHYS(_pgd_pfn(pgdp[pgd_idx])); // 如果表项不为空,取出物理地址 + nextp = get_pgd_next_virt(next_phys); + } + + create_pgd_next_mapping(nextp, va, pa, sz, prot); // 在下一级目录项中对此地址进行映射 + create_pmd_mapping((pmd_t *)__nextp, __va, __pa, __sz, __prot))) + create_pte_mapping(ptep, va, pa, sz, prot) + ptep[pte_idx] = pfn_pte(PFN_DOWN(pa), prot); +} +``` + +如果系统支持五级页表方案 (Sv57),则在 `map_size` 为 `PAGE_SIZE` 的情况下,依次调用 `create_p4d_mapping() => create_pud_mapping() => create_pmd_mapping() => create_pte_mapping()`;如果系统支持四级页表方案 (Sv48),则在 `map_size` 为 `PAGE_SIZE` 的情况下,依次调用 `create_pud_mapping() => create_pmd_mapping() => create_pte_mapping()`;如果系统支持三级页表方案(Sv39),则在 `map_size` 为 `PAGE_SIZE` 的情况下,依次调用 `create_pmd_mapping() => create_pte_mapping()`;如果是 32 位的二级页表方案(Sv32),则直接调用 `create_pte_mapping()`。 + +```c +// arch/riscv/mm/init.c : 611 + +#define create_pgd_next_mapping(__nextp, __va, __pa, __sz, __prot) \ + (pgtable_l5_enabled ? \ + create_p4d_mapping(__nextp, __va, __pa, __sz, __prot) : \ + (pgtable_l4_enabled ? \ + create_pud_mapping((pud_t *)__nextp, __va, __pa, __sz, __prot) : \ + create_pmd_mapping((pmd_t *)__nextp, __va, __pa, __sz, __prot))) + +create_pmd_mapping(pmd_t *pmdp, uintptr_t va, phys_addr_t pa, phys_addr_t sz, pgprot_t prot) + if (sz == PMD_SIZE) + pmdp[pmd_idx] = pfn_pmd(PFN_DOWN(pa), prot); + pte_phys = PFN_PHYS(_pmd_pfn(pmdp[pmd_idx])); + ptep = pt_ops.get_pte_virt(pte_phys); + create_pte_mapping(ptep, va, pa, sz, prot); + ptep[pte_idx] = pfn_pte(PFN_DOWN(pa), prot); + +``` + +通过本节的介绍,相信大家对 MMU 的地址翻译,以及 Linux 如何创建页表有了一定的了解。这两个知识是理解本文要讨论的这个 commit 的背景知识,我们继续。 + +## riscv: Use PUD/P4D/PGD pages for the linear mapping + +> 补丁原文就不贴了,可以结合具体的 commit id(3335068f8721)或者[邮件][1]来看 + +这个补丁在 Linux v6.4-rc1 版本引入,主要带来两个变更: + +- kernel_map.va_pa_offset 从之前的 `PAGE_OFFSET - kernel_map.phys_addr` 变更到 `PAGE_OFFSET - phys_ram_base`。 + + va_pa_offset 用于 `__va()/__pa()` 的计算,比如:对于线性地址的 `__pa()` 等价于:`#define linear_mapping_va_to_pa(x) ((unsigned long)(x) - kernel_map.va_pa_offset)`。此变更就导致:线性地址空间的物理地址可以不从 `kernel_map.phys_addr` -- `0x00000080200000` 开始(这个地址根据前文所述,`map_size` 为 `PMD_SIZE` 即只能进行 2M 的 PMD 的映射),从而可以采用更大的页进行映射,比如:`0x00000000c0000000` 地址可映射到 1G 的页目录上。这样能带来可能的更好的 TLB 性能。 + +- `MIN_MEMBLOCK_ADDR` 从 `__pa(PAGE_OFFSET)` 变更到 `0` + + 此变量用于在早期的内存发现过程中,定义 DRAM 内存发现的下限。此变更导致:可以利用 `0x00000080200000` 地址之前的物理地址,避免的一定的内存浪费。 + +> 注意:此修改使得 `setup_vm_setup()` 之前的 __va/__pa 操作都是无意义,如果你的代码执行了该操作,可能会报错 +> +> 为描述方面,后文统一以“大页补丁”来称呼此补丁。 + +## 休眠 panic + +此问题是我在对 Linux v6.4-rc1 的休眠特性进行分析(可参考社区对休眠的[三篇分析文章][2])的过程中发现的,在做了一些前期定位后,向社区发送了 [Bug Report][3] 邮件。本节结合邮件列表对此问题做个系统的梳理: + +> 你可以在邮件中找到问题的复现方法和系统日志 + +在 `/sys/power/state` 中输入 `disk`,触发休眠过程中,出现 panic,关键日志如下: + +```sh +[root@stage4 ~]# echo disk > /sys/power/state +[ 448.600860] PM: hibernation: hibernation entry +[ 448.633200] Filesystems sync: 0.023 seconds +[ 448.637578] Freezing user space processes +[ 448.642714] Freezing user space processes completed (elapsed 0.004 seconds) +[ 448.643801] OOM killer disabled. +[ 448.646150] PM: hibernation: Preallocating image memory +[ 450.448810] PM: hibernation: Allocated 57556 pages for snapshot +[ 450.449347] PM: hibernation: Allocated 230224 kbytes in 1.80 seconds (127.90 MB/s) +[ 450.449950] Freezing remaining freezable tasks +[ 450.453384] Freezing remaining freezable tasks completed (elapsed 0.003 seconds) +[ 450.498622] Disabling non-boot CPUs ... +[ 450.501293] CPU0 attaching NULL sched-domain. +[ 450.501879] CPU1 attaching NULL sched-domain. +[ 450.503247] CPU0 attaching NULL sched-domain. +[ 450.503624] root domain span: 0 (max cpu_capacity = 1024) +[ 450.514289] CPU1: off +[ 450.525152] PM: hibernation: Creating image: +[ 450.525152] PM: hibernation: Need to copy 56199 pages + +[ 450.525152] Oops - load access fault [#1] +[ 450.525152] Modules linked in: +[ 450.525152] CPU: 0 PID: 210 Comm: bash Not tainted 6.4.0-rc1-00004-gcce672326817 #18 +[ 450.525152] Hardware name: riscv-virtio,qemu (DT) +[ 450.525152] epc : swsusp_save+0x2ee/0x45a +[ 450.525152] ra : swsusp_save+0x2b2/0x45a +[ 450.525152] epc : ffffffff809ac404 ra : ffffffff809ac3c8 sp : ff200000007dbc20 +[ 450.525152] gp : ffffffff815dc700 tp : ff6000000346b000 t0 : 65626968203a4d50 +[ 450.525152] t1 : 0000000000080000 t2 : 7265626968203a4d s0 : ff200000007dbc90 +[ 450.525152] s1 : 0000000000000001 a0 : 0000000000000001 a1 : ff5fffff80000000 +[ 450.525152] a2 : ff60000000000000 a3 : 0000000000001000 a4 : 0000000000000000 +[ 450.525152] a5 : ff60000000000000 a6 : ffffffff815eb000 a7 : ffffffffffff8000 +[ 450.525152] s2 : ff6000000ac22000 s3 : ffffffff815dbf45 s4 : 000000000000db87 +[ 450.525152] s5 : 0000000100000000 s6 : 0004000000000000 s7 : 0040000000000000 +[ 450.525152] s8 : ffffffff815dbf44 s9 : ff1c000002000000 s10: 0000000000080000 +[ 450.525152] s11: ffffffff81082060 t3 : 0000000000078000 t4 : ffffffff815f20c7 +[ 450.525152] t5 : ffffffff815f20c8 t6 : ff200000007dba28 +[ 450.525152] status: 0000000200000100 badaddr: ff60000000000000 cause: 0000000000000005 +[ 450.525152] [] swsusp_save+0x2ee/0x45a +[ 450.525152] [] swsusp_arch_suspend+0x4a/0x98 +[ 450.525152] [] hibernation_snapshot+0x1cc/0x3e2 +[ 450.525152] [] hibernate+0x14e/0x236 +[ 450.525152] [] state_store+0x6a/0x72 +[ 450.525152] [] kobj_attr_store+0xe/0x1a +[ 450.525152] [] sysfs_kf_write+0x32/0x3c +[ 450.525152] [] kernfs_fop_write_iter+0xfa/0x164 +[ 450.525152] [] vfs_write+0x27c/0x31e +[ 450.525152] [] ksys_write+0x68/0xda +[ 450.525152] [] sys_write+0x1a/0x22 +[ 450.525152] [] do_trap_ecall_u+0xc2/0xd6 +[ 450.525152] [] do_trap_ecall_u+0xc2/0xd6 +[ 450.525152] [] ret_from_exception+0x0/0x64 +[ 450.525152] Code: 8f91 8f95 87b3 40fc 8799 07b2 97ae 6685 8633 00e7 (620c) 0633 +[ 450.525152] ---[ end trace 0000000000000000 ]--- +``` + +从日志中可以看到,错误指令为 `epc: swsusp_save+0x2ee/0x45a`,对其执行反汇编后发现:在 `do_copy_page` 函数中,对寄存器 a2 中的地址执行 load 操作触发了 load access fault(此错误同时体现在 "Oops" 提示和 scause 寄存器的值中),可以判断这可能是一个访问 PMP 保护内存触发的异常。而发生错误的虚拟地址为 `0xff60000000000000`,正好的是内核线性地址的起始地址 -- `PAGE_OFFSET`,需要进一步确认该地址的映射的物理内存地址。 + +```c + +1381 *dst++ = *src++; + 0xffffffff809ac400 <+738>: add a2,a5,a4 + 0xffffffff809ac404 <+742>: ld a1,0(a2) // 0xff60000000000000 + 0xffffffff809ac406 <+744>: add a2,s2,a4 + 0xffffffff809ac40a <+748>: addi a4,a4,8 + 0xffffffff809ac40c <+750>: sd a1,0(a2) +``` + +> 结合当前环境,介绍 Linux 内存发现过程(如何将 dtb 中描述的内存信息添加到 memblock) + +Linux 初期的内存发现过程中,以 `parse_dtb() => early_init_dt_scan_memory()` 调用 `memblock_add()`,保存完整的系统内存在 `memblock.memory`,(范围由 `MIN_MEMBLOCK_ADDR` = 0 控制)。在正式页表 `swapper_pg_dir` 建立之前,调用 `early_init_fdt_scan_reserved_mem()` 初始化保留内存,如果对应的 "reserved-memory" 节点中没有 "no-map" 属性,则直接调用 `memblock_reserve()`,而不调用 `memblock_mark_nomap()`。可以在如下日志中看到初始化保留内存的信息: + +```c +[ 0.000000] OF: fdt: Looking for usable-memory-range property... +[ 0.000000] OF: fdt: Reserved memory: reserved region for node 'mmode_resv0@80000000': base 0x0000000080000000, size 0 MiB +[ 0.000000] OF: reserved mem: 0x0000000080000000..0x000000008003ffff (256 KiB) map non-reusable mmode_resv0@80000000 +``` + +此内存区域 `0x0000000080000000..0x000000008003ffff` 正是 OpenSBI 的固件内存(mmode_resv0@80000000),被识别为 "map"、"non-reusable",则该区域同时存在于 `memblock.memory` 和 `memblock.reserved` 中,且无 `MEMBLOCK_NOMAP` 标志。在后续的线性映射过程 `create_linear_mapping_page_table()` 中,`for_each_mem_range` 会对该内存区域进行映射,从内核页表查询工具 -- `ptdump` 中可以看到,内核线性地址正好映射到 OpenSBI 的固件内存物理地址: + +```sh +# cat /sys/kernel/debug/kernel_page_tables +... +---[ Linear mapping ]--- +0xff60000000000000-0xff60000000200000 0x0000000080000000 2M PMD D A G . . W R V // 固件内存 +0xff60000000200000-0xff60000000c00000 0x0000000080200000 10M PMD D A G . . . R V +0xff60000000c00000-0xff60000001000000 0x0000000080c00000 4M PMD D A G . . W R V +0xff60000001000000-0xff60000001600000 0x0000000081000000 6M PMD D A G . . . R V +0xff60000001600000-0xff60000040000000 0x0000000081600000 1002M PMD D A G . . W R V +0xff60000040000000-0xff60000100000000 0x00000000c0000000 3G PUD D A G . . W R V +---[ Modules/BPF mapping ]--- +---[ Kernel mapping ]--- +0xffffffff80000000-0xffffffff80a00000 0x0000000080200000 10M PMD D A G . X . R V +0xffffffff80a00000-0xffffffff80c00000 0x0000000080c00000 2M PMD D A G . . . R V +0xffffffff80c00000-0xffffffff80e00000 0x0000000080e00000 2M PMD D A G . . W R V +0xffffffff80e00000-0xffffffff81400000 0x0000000081000000 6M PMD D A G . . . R V +0xffffffff81400000-0xffffffff81800000 0x0000000081600000 4M PMD +``` + +> 为何 OpenSBI 不在 dtb 中对此固件内存设置 "no-map" 属性呢? + +在 OpenSBI 的 v0.8 中引入 commit 6966ad0abe70 ("platform/lib: Allow the OS to map the regions that are protected by PMP"),此提交对 PMP 保护的内存(比如:固件内存)默认不再设置 "no-map" 属性,并允许操作系统对其进行映射,同时提供 platform_override 使得某个平台(比如:sifive,fu540)可手动设置 "no-map" 属性。而此补丁的出发点,与上节描述的大页补丁是一致的,都是为了更好的 TLB 性能。 + +这样的话,panic 的原因就比较清晰了: + +1. OpenSBI 在 v0.8 之后对于固件内存默认不设置 "no-map" 属性 +2. 上节的大页补丁导致 OpenSBI 的固件内存被映射到线性地址空间 +3. 休眠过程中调用 `swsusp_save()` 拷贝当前系统内存页到休眠镜像,当对固件内存进行拷贝时,触发了 PMP 保护,进而 hart 发生 access fault 异常 + +那针对以上三个原因可以有如下解决方案: + +1. OpenSBI 恢复设置 "no-map" 属性 + + 牺牲 TLB 性能,还要考虑向后的兼容性 + +2. Linux 回退大页补丁 + + 回退会牺牲 TLB 性能(尽管此补丁的作者表示:对线性地址进行大页映射并不会带来更好的性能) + +3. 在休眠过程中跳过固件内存 + + 在启动早期对 "mmode_resv" 节点进行解析,并调用休眠的 `register_nosave_regions()` 接口保证此内存区域不会被休眠过程保存,实现可参考邮件中的[实验性补丁][4]。但此补丁不具有通用性,没办法处理非 "mmode_resv" 节点。 + +4. 设置休眠选项 -- `ARCH_HIBERNATION_POSSIBLE` 为 `NONPORTABLE` + + 休眠功能只能在 OpenSBI v8.0 之前(那些禁止 OS 映射固件内存的 SBI 实现)的系统中开启。而将休眠选项设置为 `NONPORTABLE`,用户可根据自己的系统配置,设置 `NONPORTABLE` 来开启或者关闭休眠功能。此方案在 commit (ed309ce52218 "RISC-V: mark hibernation as nonportable") 中实现。 + +个人比较赞成方案 1,但是推动起来应该比较困难,简单谈谈我对这个几个方案的看法: + +- 方案 1:固件内存应该没有让内核对其映射的强烈需求,那么 OpenSBI 就应该将该区域设置为 "no-map",至于向后的兼容性,可通过文档的形式来描述 +- 方案 2:如果对线性地址进行大页映射并不会带来更好的性能,可以回退此补丁 +- 方案 3:其实此问题并非休眠的单点问题,邮件列表中对此方案的讨论从最初就有点偏差 + - 比如:通过内核模块直接访问 `PAGE_OFFSET` 也会崩溃(虽然此访问没有经过内存分配器进行,但不代表某些组件(比如:[memory debugging stuff][5])不会这么做) +- 方案 4: 为了 v6.4 版本稳定的规避方案,需要用户自己判断固件环境来选择开启或者关闭休眠功能 + +最后,我会持续跟踪与此问题相关的一些内核/OpenSBI 的变更,让子弹再飞一会儿。 + +## UEFI 启动 panic + +此问题是我在对 Linux UEFI 启动过程的分析(参考社区对 Linux UEFI [相关文章][6])中发现的,在做了一些前期定位后,向社区发送了 [Bug Report][7] 邮件,本节结合邮件列表对此问题做个系统的梳理: + +> 你可以在邮件中找到问题的复现方法和系统日志 + +启动过程中内核 panic,关键日志如下: + +```sh +[ 0.000000] Unable to handle kernel paging request at virtual address ff6000007fdb1000 +[ 0.000000] Oops [#1] +[ 0.000000] Modules linked in: +[ 0.000000] CPU: 0 PID: 0 Comm: swapper Not tainted 6.4.0-rc1-00007-g6966d7988c4f #65 +[ 0.000000] Hardware name: riscv-virtio,qemu (DT) +[ 0.000000] epc : __memset+0x60/0xfc +[ 0.000000] ra : memblock_alloc_try_nid+0x72/0x82 +[ 0.000000] epc : ffffffff8081d48c ra : ffffffff80a126e4 sp : ffffffff81403e80 +[ 0.000000] gp : ffffffff814fbb38 tp : ffffffff8140dac0 t0 : ff6000007fdb1000 +[ 0.000000] t1 : 0000000000000000 t2 : 5f6b636f6c626d65 s0 : ffffffff81403ec0 +[ 0.000000] s1 : 0000000000026000 a0 : ff6000007fdb1000 a1 : 0000000000000000 +[ 0.000000] a2 : 0000000000026000 a3 : ff6000007fdd7000 a4 : 0000000000000000 +[ 0.000000] a5 : ff5fffff7ffc0000 a6 : 0000000000000018 a7 : 0000000000000080 +[ 0.000000] s2 : ff6000007fdb1000 s3 : ffffffffffffffff s4 : 0000000000009e38 +[ 0.000000] s5 : ffffffffffffffff s6 : ff6000007fdd8000 s7 : 0000000000002000 +[ 0.000000] s8 : 00000000000071c8 s9 : 0000000000000000 s10: 0000000000000000 +[ 0.000000] s11: 0000000000000000 t3 : ffffffff80c0be40 t4 : ffffffff80c0be40 +[ 0.000000] t5 : ffffffff80c0bdb0 t6 : ffffffff80c0be40 +[ 0.000000] status: 0000000200000100 badaddr: ff6000007fdb1000 cause: 000000000000000f // Store/AMO page fault +[ 0.000000] [] __memset+0x60/0xfc +[ 0.000000] [] pcpu_embed_first_chunk+0x568/0x738 +[ 0.000000] [] setup_per_cpu_areas+0x22/0xb6 +[ 0.000000] [] start_kernel+0x1ce/0x57e +[ 0.000000] Code: 1007 82b3 40e2 0797 0000 8793 00e7 8305 97ba 8782 (b023) 00b2 +[ 0.000000] ---[ end trace 0000000000000000 ]--- +[ 0.000000] Kernel panic - not syncing: Attempted to kill the idle task! +[ 0.000000] ---[ end Kernel panic - not syncing: Attempted to kill the idle task! ]--- +``` + +此问题与休眠问题,有如下不同: + +1. 异常类型不同 + + 此问题为 page fault 是系统开启分页后 MMU 相关的异常,而休眠问题是 access-fault,是有 PMAs 或者 PMP 引发的异常 + +2. 休眠问题中固件内存映射到线性地址的情况,在 UEFI 环境中不存在 + + 对于 `reserved-memory` 节点中没有设置 "no-map" 且保留的固件内存,EDK2 (RiscVVirt) 将其保存在 `EfiReservedMemoryType`(下文会详细介绍)。而 Linux UEFI 初始化过程 `efi_init() => reserve_regions()` 会以 EFI memory mapping 重新构建 memblock,对于 `EfiReservedMemoryType` 内存,不会将其添加到 `memblock.memory` 中,而是在之后的 `early_init_fdt_scan_reserved_mem()` 函数中保存在 `memblock.reserved`,故而不会映射到线性地址。 + +而发生 page fault 的错误地址 `0xff6000007fdb1000` 属于线性地址空间,调试一下线性地址空间的映射过程,看到如下日志: + +``` +song # lowmem region: [0x0000000081800000 -- 0x00000000ffe3d000], va: 0xff6000007fbc0000, pa: 0x00000000ffc00000, map_size: 200000 ,pg: e7 +song # lowmem region: [0x0000000081800000 -- 0x00000000ffe3d000], va: 0xff6000007fdc0000, pa: 0x00000000ffe00000, map_size: 1000 ,pg: e7 +``` + +错误地址存在于 "va: 0xff6000007fbc0000"、"pa: 0x00000000ffc00000" 的 2M PMD 映射中,尝试对该映射做个手动分析: + +1. map_size = best_map_size(pa, end - pa) + + 映射大小通过物理地址及其与 DRAM 最大内存地址进行计算,由于 "0x00000000ffc00000" 对齐 `PMD_SIZE` 则设置映射大小为 `PMD_SIZE` + +2. create_pgd_mapping(swapper_pg_dir, va, pa, map_size, pgprot_from_va(va)); + + 此函数在 "Linux 设置页表" 一节有详细介绍,迭代调用到 `create_pmd_mapping()` 并以 `pmd_index(va)` 为索引,设置当前物理地址的 PFN 到 PMD 表项中。而 "va: 0xff6000007fbc0000"(注意:此地址不是 2M 对齐的)在经过 `pmd_index(va)` 后,在最终的页表中实际映射到了 "va: 0xff6000007fa00000",参考下面代码块中的地址展开: + + ``` + va = 0xff6000007fbc0000 + + [ff6000007f]101|1[c0]|000 + vpn0 + + // the va after pmd_index(va) + + [ff6000007f]101|0[00]|000 + + real va : 0xff6000007fa00000 + + ``` + +整个 2M PMD 的真实映射表示为:"va: [0xff6000007fa00000,0xff6000007fc00000)" => "pa: [0x00000000ffc00000,0x00000000ffe00000)",而在此虚拟地址范围之后且在下一个 4K PTE 映射的起始虚拟地址之间 -- `[0xff6000007fc00000,0xff6000007fdc0000` 存在一个虚拟地址空洞,如果对其进行访问,都会导致 page fault,而此问题的错误地址 `0xff6000007fdb1000` 正好就在这个区间。 + +为了解决此虚拟地址空洞,应该在映射大小计算中考虑虚拟地址的与某个映射大小对齐,可参考如下代码,那么在此问题中,由于 va 不能对齐 `PMD_SIZE`,则此映射会以 `PAGE_SIZE` 进行。此修改在 riscv/fixes commit (49a0a3731596 "riscv: Check the virtual alignment before choosing a map size") 中实现。 + +```c +static uintptr_t __init best_map_size(phys_addr_t pa, uintptr_t va, + phys_addr_t size) +{ + if (!(pa & (PGDIR_SIZE - 1)) && !(va & (PGDIR_SIZE - 1)) && size >= PGDIR_SIZE) + return PGDIR_SIZE; + + if (!(pa & (P4D_SIZE - 1)) && !(va & (P4D_SIZE - 1)) && size >= P4D_SIZE) + return P4D_SIZE; + + if (!(pa & (PUD_SIZE - 1)) && !(va & (PUD_SIZE - 1)) && size >= PUD_SIZE) + return PUD_SIZE; + + if (!(pa & (PMD_SIZE - 1)) && !(va & (PMD_SIZE - 1)) && size >= PMD_SIZE) + return PMD_SIZE; + + return PAGE_SIZE; +} + +``` + +从这个问题,我们了解到:在调用 `create_pxd_mapping()` 接口设置页表时,需要保证虚拟地址 va 和物理地址 pa 在映射大小 map_size 上对齐。 + +此邮件意外的展开了关于 `reserved-memory` 节点中设置 "no-map" 属性的内存区域应该如何在 UEFI 中保存的讨论: + +根据 DT 规范(devicetree-specification v0.4-rc1 3.5.4 "/reserved-memory and UEFI"),设置 "no-map" 的保留内存存放在 EfiReservedMemoryType,其他类型的保留内存存放在 BootServiceData(会被 OS 在 ExitBootServices 之后回收)。而在 EDK2 (RiscVVirt) 中对于固件内存(mmode_resv0)无视其 "no-map" 属性直接保存到 EfiReservedMemoryType,避免 OS 访问。相关代码及日志体现在: + +```c +// edk2: OvmfPkg/RiscVVirt/Sec/Memory.c :200 + +MemoryPeimInitialization() + Node = fdt_path_offset (FdtPointer, "/reserved-memory/mmode_resv0"); + MmodeResvBase = fdt64_to_cpu (ReadUnaligned64 (RegProp)); + MmodeResvSize = fdt64_to_cpu (ReadUnaligned64 (RegProp + 1)); + InitializeRamRegions ( CurBase, CurSize, MmodeResvBase, MmodeResvSize); + AddReservedMemoryBaseSizeHob (MmodeResvBase, MmodeResvSize); + +// Linux 日志 +[ 0.000000] efi: 0x000080000000-0x00008003ffff [Reserved | | | | | | | | | | | | | |UC] // EfiReservedMemoryType +[ 0.000000] memblock_reserve: [0x00000000f8fd1000-0x00000000f8fd1fff] efi_init+0x150/0x26c +[ 0.000000] memblock_reserve: [0x0000000080200000-0x00000000817fffff] paging_init+0xee/0x5ae +[ 0.000000] memblock_reserve: [0x00000000f2b61000-0x00000000f6760fff] reserve_initrd_mem+0x9a/0xfc +[ 0.000000] memblock_reserve: [0x0000000080000000-0x000000008003ffff] early_init_fdt_scan_reserved_mem+0x242/0x2c6 +[ 0.000000] OF: reserved mem: 0x0000000080000000..0x000000008003ffff (256 KiB) map non-reusable mmode_resv0@80000000 +``` + +但是在那些遵守 DT 标准的 UEFI 固件(比如 U-Boot)中,对于没有设置 "no-map" 属性的固件内存,则保留在 BootServiceData 区域,会被 OS 回收并映射,这样就会导致与休眠 panic 类似的问题。那么只能在 OpenSBI 中将固件内存设置为 "no-map",正如 Atish Patra 所述: + +``` +Let's have a no-map set for the reserved memory set for the firmware. +The fallout would be anybody with kernel > 6.4 has to upgrade the firmware version that sets the no-map correctly +if they care about hibernation or EFI booting. + +OpenSBI v1.3 is planned this month anyway. +We can communicate the same to the rust-sbi project as well. +``` + +在我写这篇文章的时候,这个补丁已经提交了,参考 [platform/lib: Set no-map attribute on all PMP regions][8]。 + +## 小结 + +本文首先介绍了 RISC-V MMU 的地址翻译过程,并分析了 `create_pgd_mapping()` 接口是如何创建页表,之后介绍了 RISC-V Linux v6.4-rc1 大页补丁的实现,并对该补丁引发的两个 panic 进行分析,这里做个总结。 + +在 OpenSBI v8.0 之后的固件为提升 TLB 性能默认不为固件内存设置 "no-map"属性,使得 OS 可以映射固件内存,Linux 大页补丁调整了物理内存发现的下限,使得固件内存映射到了线性地址空间,而休眠过程对其进行拷贝时发生 access-fault。内核目前在 commit (ed309ce52218 "RISC-V: mark hibernation as nonportable") 中以 `NONPORTABLE` 选项在没有为固件内存设置 "no-map" 属性的 OpenSBI 中临时关闭休眠功能。 + +Linux 大页补丁在调用 `create_pgd_mapping()` 接口设置页表时,计算映射大小没有考虑虚拟地址对齐,进而产生了虚拟地址空洞,使得对空洞的访问触发 page fault,从而导致 UEFI 启动失败。此问题在 riscv/fixes commit (49a0a3731596 "riscv: Check the virtual alignment before choosing a map size") 中解决。 + +第二个问题,同时引发了另外一个潜在问题:在没有为固件内存设置 "no-map"属性的 OpenSBI 并且遵守 DT 规范(/reserved-memory and UEFI)的固件环境中,OS 会映射固件内存,从而导致与休眠 panic 相似的问题。此问题最终推动 OpenSBI 恢复对固件内存设置 "no-map" 属性,预计在 OpenSBI v1.3 可以看到。 + +如果你使用 RISC-V Linux v6.4-rc1 及其之后的内核版本,并遇到与上述两个问题,可降级到 OpenSBI 到 v0.8 之前的版本或者采用这个[补丁][8]对你的 OpenSBI 进行升级。 + +## 参考资料 + +- [RISC-V 休眠实现分析][2] +- [RISC-V Linux 内核 UEFI 启动过程分析][6] +- [Bug report: kernel paniced when system hibernates][3] +- [Bug report: kernel paniced while booting with UEFI][7] + +[1]: https://lore.kernel.org/r/20230324155421.271544-4-alexghiti@rivosinc.com +[2]: https://gitee.com/tinylab/riscv-linux/pulls/694 +[3]: https://lore.kernel.org/linux-riscv/CAAYs2=gQvkhTeioMmqRDVGjdtNF_vhB+vm_1dHJxPNi75YDQ_Q@mail.gmail.com/ +[4]: https://lore.kernel.org/linux-riscv/CAAYs2=jEPQLwe83UDVFStLuei4C+8ZuHJ98_J13RhobpjkGBVw@mail.gmail.com/ +[5]: https://lore.kernel.org/linux-kernel/20230530080425.18612-1-alexghiti@rivosinc.com/ +[6]: https://gitee.com/tinylab/riscv-linux/pulls/660 +[7]: https://lore.kernel.org/linux-riscv/tencent_7C3B580B47C1B17C16488EC1@qq.com/ +[8]: https://github.com/riscv-software-src/opensbi/commit/8153b2622b08802cc542f30a1fcba407a5667ab9 diff --git a/articles/images/riscv-linear-mapping/sv57_address_trans.png b/articles/images/riscv-linear-mapping/sv57_address_trans.png new file mode 100644 index 0000000000000000000000000000000000000000..1dbc0bcf63f89af983a10658ecb0755f07b96377 Binary files /dev/null and b/articles/images/riscv-linear-mapping/sv57_address_trans.png differ