ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ARM64 页表属性修改实战:PTE、MAIR 与 TLB 同步

ARM64 页表属性修改实战:PTE、MAIR 与 TLB 同步 改页表项属性这件事代码量小得让人放松警惕找到 pte 指针、把 AttrIndx 那几个位换掉、写回去、刷一下 TLB收工。我第一次在 ARM64 上真正动手改的时候从写代码到系统能稳定跑起来中间隔了整整两天。第一版模块加载、打印、属性对比全都正确结果第二次跑就随机爆 instruction abort第三次跑干脆读到一段过期数据。问题不在逻辑而在于我没搞清楚这个 64 位描述符里哪些位是硬件认的、哪些位是内核自己占的、哪些位改了之后必须配套做 cache 维护和 TLB 同步。这篇东西就是把这些掰开揉碎讲一遍。内容覆盖 ARMv8-A 页表描述符的位布局、MAIR_EL1 和 AttrIndx 的配合关系、改属性的三条主流路径set_memory_*、pgprot_*映射时定型、自己 walk 页表、一段可以直接编译加载的内核模块示例以及我踩过的那些坑——撕裂写入、别名映射属性不一致、PTE_CONT 用错、nG 位搞反、block 拆表的 break-before-make。适合有基本内核模块开发经验、正在做驱动、内存加密、JIT、调试工具或者只是想把这块补上的同学。手边没有 ARM64 实机的用 QEMU 跑个 arm64 虚拟机一样能练后面会提。1. 一个 64 位描述符里硬件到底认哪些位1.1 先确认你要改的是哪一级的叶子项4KB granule 配 48 位 VA 是最常见的组合四级页表PGDLevel 0、PUDLevel 1、PMDLevel 2、PTELevel 3每级 9 位索引加上 12 位页内偏移刚好 48 位。这里最关键的一点是真正带属性的叶子描述符可能出现在三个不同层级而不同层级的访问粒度和属性位位置是一样的输出地址的起始位却不一样。层级描述符类型单次映射粒度输出地址起始位Level 3page descriptor0b114KBbit 12Level 2block descriptor0b012MBbit 21Level 1block descriptor0b011GBbit 30为什么这件事必须先确认因为如果你 walk 到 Level 2 发现是 block那你要改的就是那 2MB 整体顺手把 PMD 当成指向下一级表的指针去解引用大概率换来一次内核崩溃。而且改 block 描述符和改 page 描述符在 TLB 维护上的代价差了一个数量级2MB 一次刷和 512 个 4KB 一个个刷性能完全是两码事。所以任何 walk 代码里pmd_sect()这类判断都不能省。再看描述符的低两位这是 ARMv8 区分描述符性质的唯一依据0b11在 Level 0/1/2 表示指向下一级页表table descriptor在 Level 3 表示页描述符0b01在 Level 1/2 表示块描述符0b00表示无效项fault。Linux 里对应的宏是PTE_TYPE_PAGE、PTE_TYPE_BLOCK、PTE_TYPE_TABLE、PTE_TYPE_FAULT实际上PTE_TYPE_PAGE和PTE_TYPE_TABLE的值都是3区分靠层级语义。1.2 低 12 位AttrIndx、AP、SH、AF、nG这 12 位是日常改属性打交道最频繁的区域逐位列清楚。位域名称作用[1:0]描述符类型0b11 页/表0b01 块0b00 无效[4:2]AttrIndx索引 MAIR_EL1 的 8 个属性槽[5]NSNon-secure 标记[7:6]AP[2:1]访问权限[9:8]SH[1:0]shareability0b11 为 Inner Shareable[10]AFAccess Flag0 会触发访问标志异常[11]nG非全局用户映射置 1内核映射置 0[15:12]OA[51:48]4KB granule 下是输出地址的高 4 位AP[2:1] 的组合需要背下来改权限出错的场景十有八九是这里。AP[2:1]EL1 权限EL0 权限0b00读写无访问权0b01读写读写0b10只读无访问权0b11只读只读Linux 的宏定义里PTE_AP_USER对应1 6AP[1]PTE_AP_RDONLY对应2 6AP[2]。有个细节容易踩pte_write()判定的不是 AP[2] 本身而是!(pte_val(pte) PTE_RDONLY)而PTE_RDONLY就是PTE_AP_RDONLY。但如果你启用了 DBMdirty bit managementAP[2] 的语义会变成硬件管理的脏位软件把它置 1 只是表示当前状态是干净的第一次写操作时硬件会自动清 0。这时候你如果直接读 AP[2] 判断写权限逻辑就会错乱得结合PTE_DBM一起看。1.3 高 12 位XN、PXN、CONT、DBM 和留给软件的位高位这块比低位复杂因为硬件定义的位和内核私用的位混在一起。位名称说明51DBM脏位由硬件管理52Contiguous连续提示需 16 个相邻项属性一致53PXN特权态EL1不可执行54UXN非特权态EL0不可执行[58:55]OA[55:52] 或软件位48 位物理地址下可作软件用途[62:59]PBHA需 FEAT_TTPBHA否则保留63软件位PXN 和 UXN 的组合直接决定了这块内存谁能执行、谁不能这是内核 W^X 的实现基础。PXNUXN典型用途01内核代码段10用户代码段11数据段双方都不可执行00基本不用两边都能执行Linux 在pgtable-hwdef.h里定义了几个软件用的位48 位物理地址前提下PTE_DIRTY占 bit 55PTE_SPECIAL占 bit 56PTE_DEVMAP占 bit 57PTE_PROT_NONE占 bit 58。这几个位硬件完全不看你改属性的时候如果用的是整体替换而不是只清 AttrIndx 位域的写法很容易顺手把这几个软件位抹掉然后在某个页回收路径上撞到一个莫名其妙的 bug。这就是为什么改属性必须用clear_pte_bitset_pte_bit这种只动目标位的方式而不是自己拼一个新的 pte 值。1.4 AttrIndx 只是索引属性本体在 MAIR_EL1 里很多人第一次看页表项会困惑明明描述符里只有 3 个位叫 AttrIndx怎么表达出 Write-Back、Non-Cacheable、Device 这么多种属性因为真正的属性定义在MAIR_EL1这个寄存器里8 个字节对应 8 个槽AttrIndx 就是个数组下标。Linux 的槽位分配大致是这样动手前建议 grep 一下自己内核版本里MAIR_EL1_SET的定义确认。索引Linux 宏MAIR 编码含义0MT_DEVICE_nGnRnE0x00设备内存最强顺序1MT_DEVICE_nGnRE0x04设备内存允许早应答2MT_DEVICE_GRE0x0c设备内存允许聚集与重排3MT_NORMAL_NC0x44普通内存Non-Cacheable4MT_NORMAL0xff普通内存Inner/Outer WB RW Allocate5MT_NORMAL_WT0xbb普通内存Write-Through6MT_NORMAL_TAGGED0xf0带标签的普通内存MTE 用0xff 拆开看是两部分低 4 位描述 Inner 属性高 4 位描述 Outer 属性0xf是 Normal Write-Back Read/Write Allocate0x44 的高低位都是0b0100即 Normal Non-Cacheable。这里有个必须记住的结论你要改一个页的属性改的是 AttrIndx 这个索引不是 MAIR 槽里的编码。如果去动MAIR_EL1本身影响的是所有引用该槽的映射包括内核线性映射等于把整个系统的内存模型掀了。真要新的属性组合正确做法是在启动阶段就规划好槽位运行时只切换索引。另外MAIR_EL1是 per-CPU 的改完还得保证每个核都写一遍这又是一个坑。2. 改属性的三条路先想清楚你要改谁2.1 set_memory_* 系列改内核线性映射的官方通道ARM64 上内核给set_memory_*提供的实现比 x86 少得多实际能用的就是这几个int set_memory_ro(unsigned long addr, int numpages); int set_memory_rw(unsigned long addr, int numpages); int set_memory_nx(unsigned long addr, int numpages); int set_memory_x(unsigned long addr, int numpages); int set_memory_valid(unsigned long addr, int numpages, int enable);注意这里没有set_memory_nc也没有set_memory_wc。如果你想把一段内核内存改成 Non-Cacheable这条路上是走不通的得换方案。这几个接口内部的路径大致是先做地址对齐和范围校验超出一页就按页拆然后调apply_to_page_range(init_mm, ...)逐项修改页表中间的pgprot差异通过一个修改回调注入最后统一做 cache 维护和flush_tlb_kernel_range()。它最大的好处是把顺序问题替你处理完了你不用自己去关心先清 cache 还是先改 PTE也不用管跨核 TLB 同步。缺点是只能改内核线性映射区线性映射、vmalloc、vmemmap 这些改不了用户进程的页表也改不了属性组合比如只能改 RO/RW 和 X/NX。用的时候有两个前提得注意。一是传入的地址必须落在内核映射区内传个用户态地址进去会直接 panic二是numpages的方向别搞反长度是从 addr 开始往高地址算的页数不是结束地址。2.2 映射时就定好属性pgprot_* 和 remap_pfn_range如果你只是想让某段内存以特定属性出现压根不需要改在建立映射的那一刻定下来就行这条路最省心也最不容易出错。/* Device 内存寄存器访问的标准选择 */ vaddr ioremap(phys, size); /* 自定义映射普通内存但 Non-Cacheable */ vaddr vmap(pages, count, VM_MAP, pgprot_writecombine(PAGE_KERNEL)); /* 用 mmap 把物理页暴露给用户态属性由 vma-vm_page_prot 决定 */ remap_pfn_range(vma, vma-vm_start, pfn, size, vma-vm_page_prot);ARM64 上这几个pgprot_*宏的定义很有信息量#define pgprot_noncached(prot) \ __pgprot_modify(prot, PTE_ATTRINDX_MASK, \ PTE_ATTRINDX(MT_DEVICE_nGnRnE) | PTE_PXN | PTE_UXN) #define pgprot_writecombine(prot) \ __pgprot_modify(prot, PTE_ATTRINDX_MASK, \ PTE_ATTRINDX(MT_NORMAL_NC) | PTE_PXN | PTE_UXN)看清楚两件事。第一pgprot_noncached在 ARM64 上映射到的是Device-nGnRnE不是 Normal Non-Cacheable这是和很多人的直觉相反的。第二这两个宏都顺手带上了PTE_PXN | PTE_UXN也就是说用它们映射出来的内存默认不可执行。这个设计是有意为之Non-Cacheable 或者 Device 内存如果可执行配合指令预取会带来很难排查的行为所以内核干脆把执行权限收掉。pgprot_writecombine在 ARM64 上映射到 Normal Non-Cacheable这和 x86 的 WCWrite-Combining语义并不完全等价x86 的 WC 允许写缓冲合并ARM64 的 Normal NC 只保证不缓存。移植驱动的时候这一点最容易出问题在 x86 上靠 WC 攒批量写提升性能的代码搬到 ARM64 上性能表现可能完全不同。2.3 自己 walk 页表能力最大责任也最大前两条路覆盖不了的场景就只能自己走页表。用户态地址用follow_pte()int follow_pte(struct vm_area_struct *vma, unsigned long address, pte_t **ptepp, spinlock_t **ptlp);拿到 pte 和配套的自旋锁改完记得pte_unmap_unlock()。内核态地址则走pgd_offset_k()这一套注意不要用pte_offset_map_lock()那是给用户态 mm 用的传内核地址进去会出问题。自己 walk 意味着下面这些事全部归你管并发保护谁持有锁、64 位原子写WRITE_ONCE或set_pte_at、cache 维护、TLB 刷新范围、跨核同步、以及判断当前项是不是 CONT 组的一部分。少做一样可能在开发机上跑一万次都没事换到线上某个负载下就炸。选择建议很直接能用set_memory_*就用它需要特殊 cache 属性就在映射时用pgprot_*定好只有当前两条路都不满足比如要改用户进程页表、要做影子页表、要实现自定义的内存加密方案才走第三条。3. 把一段内核内存改成 Non-Cacheable完整走一遍3.1 场景定义与前置检查假设我们要把一段内核模块自己分配的内存从默认的 Write-Back 改成 Normal Non-Cacheable。这个需求在实现 DMA 描述符环、共享内存、或者模拟某些硬件行为时会遇到。先做几项检查这些检查在代码里也应该有对应判断地址必须落在内核映射区用virt_addr_valid()或者区间判断先筛一遍。对应项必须是 Level 3 页描述符落在 2MB block 上的话改一个字节的属性等于改 2MB不划算也不安全。项必须 present且不能带PTE_CONT。CONT 是连续提示硬件要求 16 个相邻项属性一致你单独改一个就是给自己找麻烦。这段内存不能同时有别的别名映射在用。如果它是从vmalloc拿的同时 linear map 里也有映射改一边就会属性不一致。最后一条是最容易被忽略的也是我在实际项目里第一次翻车的地方后面第 4 章会详细说。3.2 一段可以直接编译的 walk 改属性模块#include linux/module.h #include linux/mm.h #include linux/vmalloc.h #include asm/pgtable.h #include asm/tlbflush.h #include asm/cacheflush.h static unsigned long target_addr; module_param(target_addr, ulong, 0644); static pte_t *walk_kernel_pte(unsigned long addr, pmd_t **pmdp) { pgd_t *pgd; p4d_t *p4d; pud_t *pud; pmd_t *pmd; pgd pgd_offset_k(addr); if (pgd_none(*pgd) || pgd_bad(*pgd)) return NULL; p4d p4d_offset(pgd, addr); if (p4d_none(*p4d) || p4d_bad(*p4d)) return NULL; pud pud_offset(p4d, addr); if (pud_none(*pud) || pud_bad(*pud)) return NULL; pmd pmd_offset(pud, addr); if (pmd_none(*pmd)) return NULL; *pmdp pmd; if (pmd_sect(*pmd)) /* 2MB block交回调用者处理 */ return NULL; if (pmd_bad(*pmd)) return NULL; return pte_offset_kernel(pmd, addr); } static int dump_and_set_nc(unsigned long addr) { unsigned long base addr PAGE_MASK; pmd_t *pmd NULL; pte_t *ptep; pte_t pte; ptep walk_kernel_pte(base, pmd); if (!ptep) { pr_err(walk failed or block mapping at %lx\n, base); return -EINVAL; } pte ptep_get(ptep); pr_info(before: pte %016llx attrindx %lu\n, (unsigned long long)pte_val(pte), (pte_val(pte) PTE_ATTRINDX_MASK) 2); if (!pte_present(pte)) { pr_err(pte not present\n); return -EINVAL; } if (pte_val(pte) PTE_CONT) { pr_err(pte is part of a contiguous group, refuse to touch\n); return -EINVAL; } /* 1) 属性从 cacheable 变成 non-cacheable 之前 * 必须把 cache 里的脏行清出去否则同一物理地址会有两份数据 */ dcache_clean_inval_poc(base, base PAGE_SIZE); /* 2) 只动 AttrIndx 位域权限、XN、AF、nG 和软件位原样保留 */ pte clear_pte_bit(pte, __pgprot(PTE_ATTRINDX_MASK)); pte set_pte_bit(pte, __pgprot(PTE_ATTRINDX(MT_NORMAL_NC))); /* 3) 一次 64 位原子写回 */ set_pte(ptep, pte); /* 4) 刷 TLB这个函数内部含 dsb/isb 并会通知其他核 */ flush_tlb_kernel_range(base, base PAGE_SIZE); pr_info(after : pte %016llx\n, (unsigned long long)pte_val(*ptep)); return 0; }这段代码里每一行都有理由逐个解释。ptep_get()而不是直接解引用*ptep新版本内核引入这个封装是为了配合硬件访问标志更新HAFDBS。如果开了硬件自动置 AF页表项可能被硬件在你不注意的时候改掉直接解引用在编译器优化下可能读到中间态。老内核上直接用*ptep也行但换成ptep_get()更稳。dcache_clean_inval_poc()里的 POC 是 Point of Coherency。选 clean invalidate 而不是只 clean是因为接下来这块内存的访问路径完全变了cache 里的副本不再有意义。如果只 clean 不 invalidate那条 cache line 还留在 cache 里CPU 后续读这段内存的时候有可能命中它读到的是改属性之前的旧值。set_pte()展开后是WRITE_ONCE(*ptep, pte)。为什么不能写*ptep pte因为 64 位赋值在编译器看来不保证是一次原子存储某些优化下可能被拆成两条 32 位 str。如果硬件在两条指令之间做了一次页表 walk读到的就是一个高 32 位是新值、低 32 位是旧值的组合描述符——这基本上等于给硬件喂了一个随机物理地址。用 WRITE_ONCE 明确告诉编译器这是一次需要保持原子性的存储。flush_tlb_kernel_range()负责的比名字看起来多。它内部会先做dsb ishst保证之前的页表写入对其他核可见再执行tlbi系列指令SMP 系统上还会通过 IPI 通知其他核执行同样的操作最后dsb ishisb收尾。如果你图省事用local_flush_tlb_all()那就只刷了当前核其他核上残留的 TLB 项继续命中旧属性表现出来的现象就是有时候对有时候不对。3.3 顺序不能错cache 维护、PTE 写入、TLB 失效ARM 对内存属性变更有一组明确的顺序要求顺序错了就是随机故障。整理成一张表。步骤操作为什么必须是这个位置1停掉这段内存的并发访问后面几步之间窗口内访问会拿到不一致数据2clean invalidate 到 PoC避免旧 cacheable 副本与新 non-cacheable 视图冲突3写入新页表项原子属性切换的生效点4TLB 失效 跨核同步让所有核看到新属性5恢复访问这里第 1 步经常被跳过理由通常是我这段内存是独占的。但set_memory_*系列的实现里其实也是在apply_to_page_range外面做范围处理的你自己写 walk 的时候如果这段内存在别的路径上还被读第 1 步不省。另外还有一个反直觉的点改属性之前不需要 icache 维护改完之后如果需要执行这段代码才需要。如果新属性是 non-executable大部分 NC 映射都是那反而应该确认PTE_PXN | PTE_UXN已经置上避免出现改完属性之后这段内存还能执行的状态。3.4 改用 vmap 别名而不是动线性映射上面那段代码能跑但我强烈建议不要把它用在真正跑在内核线性映射区的内存上。原因是线性映射是全局共享的同一物理页在内核里往往有多个虚拟地址线性映射一个、vmalloc一个、vmemmap一个、可能还有用户的进程页表映射一个。你在其中一处把属性改成 NC其他几处还是 WB同一物理地址就同时存在于两种不同的内存类型视图下这在 ARM 架构上是明确禁止的行为。更稳的做法是不动原来的映射用vmap建一个新的映射只在新映射上使用目标属性。static void *map_nc(void *addr, size_t size) { struct page *page; void *ret; page vmalloc_to_page(addr); if (!page) return NULL; ret vmap(page, 1, VM_MAP, pgprot_writecombine(PAGE_KERNEL)); if (!ret) return NULL; /* vmap 建的是新映射只需要保证新映射可见 */ flush_tlb_kernel_range((unsigned long)ret, (unsigned long)ret PAGE_SIZE); return ret; }注意这里pgprot_writecombine已经带上了 PXN/UXN不需要再手动加。用 vmap 别名还有个额外好处原来那段内存的属性完全没变内核其他部分访问它的时候行为和之前一模一样风险面小得多。代价是多占一个虚拟地址空间和一条 TLB 项这个成本在大页映射下可以忽略。4. 属性改错之后硬件是怎么惩罚你的4.1 撕裂的 64 位写入这个坑我吃过一次。当时的代码写得挺干净直接*ptep new_pte;在开发板上跑了几个小时没出问题。后来压测的时候偶发 instruction abort地址落在内核 text 附近看着完全看不懂。加打印之后才发现编译器把那个赋值拆成了两条 str中间硬件做了一次 prefetch 触发的页表 walk。解决办法只有两个用set_pte_at()或者WRITE_ONCE()。别自信地以为64 位对齐的存储在 AArch64 上肯定是原子的AArch64 确实保证对齐的单次访问原子但编译器不保证生成的就是单次访问。它完全可以把*p v优化成*(u32*)p lo; *(u32*)(p4) hi;特别是当p是通过某个volatile语义不明确的宏取出来的时候。顺便说一句页表本身是 4KB 对齐的每个描述符 8 字节天然满足对齐要求所以不用操心对齐问题只用操心一次写。4.2 别名映射属性不一致最难查的一类故障同一个物理页在不同虚拟地址上属性不一致硬件的行为是不可预测。实际能观察到的现象包括读到明显过期的数据、某个地址随机触发 Data Abort、多核之间数据不一致、甚至只在特定 cache 压力下才复现。为什么 ARM 这么严格因为它不保证不同 memory type 的访问之间有任何顺序或一致性关系。一份是 WB 视图一份是 NC 视图两者谁先谁后写、谁先谁后读硬件不承诺任何结果。ARM ARM 里对这块的措辞是 the results are unpredictable翻译过来就是后果自负。但要注意架构要求一致的只是memory type 和 shareability不包括权限AP和执行权限XN。这点很重要否则 COW 就没法实现了——同一个物理页在父进程里是只读、在子进程里是可写权限不同但 memory type 相同这是完全合法的。所以属性类别别名之间必须一致可以不同memory typeWB/WT/NC/Device是shareability是AP 读写权限是XN 执行权限是另外 Device 内存还有一个额外的「Device 映射之间必须属性相同」的要求两个 Device 映射一个 nGnRnE 一个 GRE指向同一个外设寄存器同样是不允许的。这也是为什么驱动里访问寄存器一定要统一用ioremap()而不是在一个地方用ioremap_np、另一个地方用普通ioremap。4.3 PTE_CONT16 项必须齐步走Contiguous 是 ARMv8.2 引入的一个提示位作用是把 16 个相邻且属性一致的页表项打包成一条 TLB 项减少 TLB 压力。4KB granule 下 16 × 4KB 64KB所以它对齐要求很严起始地址必须 64KB 对齐16 项必须连续除了 AF 和 DBM/dirty 之外属性必须完全一致。Linux 通过CONFIG_ARM64_CONT_PTE和CONFIG_ARM64_CONT_PMDS控制是否启用主要用在vmalloc和线性映射的批量建立上。自己 walk 改属性的时候如果你改的那一项带 CONT而组内其他 15 项没跟着改硬件会认为这组不再满足 contiguous 条件行为不可预测——有的实现会直接忽略这个提示有的会报错。我在代码里加的那个if (pte_val(pte) PTE_CONT) return -EINVAL;就是图省事直接拒绝。更完整的做法是要么整组 16 项一起改改完一起刷 TLB要么先清掉整组的 CONT 位改完再决定是否恢复。4.4 nG 位搞反TLB 里的幽灵nGnot Global决定这条 TLB 项在 ASID 切换时是否失效。规则很简单内核映射 nG 0全局用户映射 nG 1跟随 ASID。搞反的后果分两种。用户映射 nG 写成 0那么切换到别的进程时 TLB 项不会失效新进程可能会用到一个指向错误物理页的映射这是安全隐患级别的 bug。内核映射 nG 写成 1本来应该全局复用的项会跟着 ASID 走掉性能不说还可能出现某个核上查不到这条映射的诡异情况。Linux 里对应的宏是PTE_NG看一个地址是内核还是用户映射内核代码里一般用is_kernel_in_hyp_mode()或者直接看地址区间。自己 walk 的时候有个简单判断用pgd_offset_k()拿到的是内核页表那对应的项就必须 nG 0用follow_pte(vma, ...)拿到的用户页表nG 1。4.5 block 拆表break-before-make 不能省把 2MB block 映射改成 4KB 页级映射或者反过来合并这条路径上有一个强制要求不能直接把 block 描述符覆盖成 table 描述符必须先写成无效项刷 TLB再写新值。这个规则叫 break-before-make是 ARM 架构为了避免 TLB 中出现同一个虚拟地址指向两个不同物理地址的歧义状态而设的。Linux 内部的实现路径大致长这样/* 伪代码展示逻辑顺序 */ pmd_clear(pmdp); /* 1. 写无效项 */ __flush_tlb_kernel_pgtable(addr); /* 2. 只刷页表项相关的 TLB */ pmd_populate(mm, pmdp, new_ptable); /* 3. 建立指向新表的 table 描述符 */ flush_tlb_kernel_range(addr, addr SZ_2M);为什么第 2 步用__flush_tlb_kernel_pgtable而不是普通的flush_tlb_kernel_range因为这一步只涉及页表结构本身的变化刷的粒度更小。不少自己实现的代码在这里图省事直接合并成一次短期看不出问题在 TLB 压力大的场景下就会暴露。顺便说一个相关的经验改属性不要跨 block 边界。如果一段内存开头是 2MB block后面是 4KB 页你按地址范围去做属性修改很可能在边界处得到一段属性混合的映射后续 flush 的范围也不好算。老老实实按叶子描述符的类型分开处理。5. 怎么确认页表项真的被改对了5.1 ptdump一行看懂属性内核编译时打开CONFIG_ARM64_PTDUMP_DEBUGFS通常配合CONFIG_ARM64_PTDUMP_CORE启动后就能读到/sys/kernel/debug/kernel_page_tables。---[ Linear mapping ]--- 0xffff000008000000-0xffff000008200000 2M RW NX SHD AF UXN BLK MEM/NORMAL 0xffff000008200000-0xffff000008300000 1M RW NX SHD AF CON UXN PTE MEM/NORMAL 0xffff800080000000-0xffff800080100000 16M RW NX SHD AF UXN PTE MEM/NORMAL输出格式因内核版本有差异但核心字段是稳定的。字段含义RW / ro可写 / 只读对应 AP[2]NX不可执行SHDshareable通常是 Inner ShareableAFAccess Flag 已置位UXN / PXN非特权 / 特权不可执行BLK当前区间是块描述符映射PTE当前区间是页描述符映射CON带 Contiguous 提示MEM/NORMALNormal memory具体缓存属性看 AttrIndxDEVICEDevice memory用它的正确姿势是改属性前后各 dump 一次diff 一下。比 printf 打印页表项原始值直观得多尤其是范围一大逐项打印会很痛苦。5.2 自己 dump把描述符按位拆开ptdump 看的是内核认为的语义有时候你需要看原始位。写一个解析函数把 64 位按位域拆开打印比对着十六进制数发呆效率高多了。static void decode_pte(u64 v) { pr_info(raw : %016llx\n, v); pr_info(type : %llu %s\n, v 3, (v 3) 3 ? (page/table) : (v 3) 1 ? (block) : (fault)); pr_info(attrindx : %llu\n, (v 2) 7); pr_info(AP[2:1] : %llu%llu\n, (v 7) 1, (v 6) 1); pr_info(SH : %llu\n, (v 8) 3); pr_info(AF : %llu\n, (v 10) 1); pr_info(nG : %llu\n, (v 11) 1); pr_info(DBM : %llu\n, (v 51) 1); pr_info(CONT : %llu\n, (v 52) 1); pr_info(PXN : %llu\n, (v 53) 1); pr_info(UXN : %llu\n, (v 54) 1); pr_info(sw[58:55] : %llx\n, (v 55) 0xf); }对着这个输出看比对着0x0060000000f00343这种值猜要快得多。尤其是排查 PXN/UXN 搞反的问题一眼就能看出来。5.3 用户态页的验证smaps 和 pagemap内核映射有 ptdump用户态映射的验证手段少一些。/proc/pid/smaps里能看到一部分信息7f8c00000000-7f8c00001000 r-xp 00000000 00:00 0 VmFlags: rd ex mr mw me sdVmFlags的rd/wr/ex对应读/写/执行权限但看不到 cache 属性。要看 cache 属性用户态基本只有间接手段写一段代码读一遍看耗时或者用perf stat看 cache miss 计数。/proc/pid/pagemap能读出 PFN 和几个状态位bit 55 soft-dirty、bit 56 exclusive但读不到属性位。想更精确地看还是得写内核模块用follow_pte把用户页表项拿出来用上面那个decode_pte打印。5.4 常见症状对照表现象优先怀疑执行这段内存立刻 instruction abortPXN/UXN 置反写操作触发 Data AbortAP[2] 没清写权限没给读到明显过期数据cache 没 clean 就改了属性改完打印对跑起来不对TLB 没刷或者只刷了本核压测才复现的随机 abort撕裂写入缺 WRITE_ONCE多核之间数据不一致别名映射属性不一致改了一项导致附近一片异常踩到 PTE_CONT 组切进程后出现异常映射nG 位设错改成 2MB 大页后行为诡异缺 break-before-make6. 内核里几个真实场景以及顺手记下的经验6.1 启动时的 RO/NXmark_rodata_ro内核启动尾声会调用mark_rodata_ro()把.rodata段从 RW 改成 RO这是CONFIG_STRICT_KERNEL_RWX的核心动作之一。ARM64 的实现内部就是调用set_memory_ro()把.rodata区间对应的页表项 AP[2] 置 1。这条路径值得看一眼的原因是它展示了属性收紧的标准流程范围按 section 对齐算出页数、调set_memory_ro、之后debug_checkwx()校验。如果你要实现类似某段内存启动后只读的需求照这个模式抄就行别自己 walk 页表。模块的情况对应的是CONFIG_STRICT_MODULE_RWX模块加载时通过set_memory_ro、set_memory_nx把.text、.rodata、.data分别设成合适的属性。一个模块如果加载时报 module: xxx: module has a section with WX就是属性检查没通过。6.2 文本改写为什么走 fixmap 别名内核打补丁的代码ftrace、static call、BPF JIT都要在运行期改写内核正文。ARM64 上这块没有去改 text 的页表权限而是建了一个临时的 fixmap 别名来写。void *patch_map(void *addr, int fixmap) { uintptr_t uintaddr (uintptr_t)addr; struct page *page; if (core_kernel_text(uintaddr)) page phys_to_page(__pa_symbol(addr)); else if (IS_ENABLED(CONFIG_STRICT_MODULE_RWX)) page vmalloc_to_page(addr); else return addr; BUG_ON(!page); set_fixmap(fixmap, page_to_phys(page)); return (void *)(__fix_to_virt(fixmap) (uintaddr ~PAGE_MASK)); }为什么绕这么一圈因为改内核 text 的页表权限代价太大了。text 段是全局共享的改权限要 flush 所有核的 TLB还要保证整个窗口期内没有任何核在取指这段范围同步成本极高。而走 fixmap 别名只需要动一个 fixmap 槽位的页表项物理页本身还是同一个memory type 和原映射一致只有权限不同。前面说过权限不同是允许的所以这条路合法且开销小得多。这是我在这块学到的最大一条经验需要临时改属性时优先考虑建别名映射而不是改原映射。别名映射的影响面被限制在一个虚拟地址上出问题的爆炸半径小得多。6.3 hibernate 里把页面藏起来休眠恢复流程里有个很有意思的用法。系统把内核内存镜像保存在一段临时内存里恢复过程中这段内存不能被动因为任何预测性访问读到的是还没恢复完成的中间状态。ARM64 的做法是调set_memory_valid(addr, numpages, 0)把这段映射直接标记为无效。set_memory_valid是 ARM64 特有的接口内部把页表项改成 fault 描述符PTE_TYPE_FAULT并刷 TLB。需要的时候再传enable 1恢复回来。这个接口在别的架构上没有对应实现因为它本质上是利用了 ARM 页表描述符无效项这个明确状态。如果你有类似的临时屏蔽一段内核内存访问的需求这是个现成的工具比自己去改描述符安全得多。一个使用注意set_memory_valid会改变项的有效性意味着任何在这段时间访问该地址的代码都会收到 fault。用之前必须确保没有并发的访问者否则就是一个必然触发的崩溃。6.4 访问外设寄存器别用 writecombine写驱动访问寄存器用ioremap()或者devm_ioremap()就对了它映射到 Device-nGnRnE。常见错误是有人为了提升性能改用ioremap_wc()在 ARM64 上映射成 Normal Non-Cacheable。Normal NC 和 Device 的差别不是顺序性稍弱而是根本不同特性Device-nGnRnENormal Non-Cacheable访问合并禁止允许访问重排禁止允许早期应答禁止允许适用对象外设寄存器共享内存、DMA 缓冲区访问寄存器时如果用 Normal NC编译器看不到的重排可能会发生先写地址寄存器再写数据寄存器硬件收到的时候顺序可能反了。这类 bug 极难排查因为在开发板上往往跑得好好的换一块 SoC 就出问题。反过来DMA 一致性缓冲区用ioremap()也是错的——Device 类型的访问一条一条走性能会惨不忍睹。基本的判断标准就是寄存器用 Device共享内存用 Normal NC。6.5 几条零散的实操心得关于内核版本差异pgtable-hwdef.h里的软件位定义这几年改过几次ptep_get/ptep_set这组封装也是新版本才有的。动手前先grep一下自己手上的版本别照抄博客里的宏名。关于 QEMU 练习环境手边没有 ARM64 实机的用 QEMU 跑一个 arm64 虚拟机完全够用页表结构和 TLB 行为都是按 ARM 架构模拟的。ptdump的 debugfs 接口也能用只是 QEMU 的 TLB 行为比真实硬件简单某些竞态类的 bug 在 QEMU 上可能复现不出来——这是练习环境的局限心里有数就行。关于验证我建议养成一个固定习惯改属性前后各 dump 一次页表对比AttrIndx、AP[2:1]、PXN、UXN这四组位同时确认软件位PTE_DIRTY、PTE_SPECIAL、PTE_DEVMAP没有被误伤。加一段校验代码可能要多花二十分钟但能省掉几个通宵。关于生产环境的最后一句提醒任何修改页表属性的代码都要在开启了CONFIG_DEBUG_VM、CONFIG_DEBUG_WX、CONFIG_DEBUG_PAGEALLOC的内核上跑一遍。这些选项会在页表被改坏的时候尽早报出警告比在业务逻辑里看到随机崩溃友好太多。CONFIG_DEBUG_WX特别值得开它会在属性变更后主动扫描内核映射区发现可写可执行的映射就打印警告并打印出具体地址范围定位效率比事后追查高一个量级。
返回列表