改页表项属性这件事,代码量小得让人放松警惕:找到 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 是最常见的组合,四级页表:PGD(Level 0)、PUD(Level 1)、PMD(Level 2)、PTE(Level 3),每级 9 位索引,加上 12 位页内偏移刚好 48 位。这里最关键的一点是:真正带属性的叶子描述符可能出现在三个不同层级,而不同层级的访问粒度和属性位位置是一样的,输出地址的起始位却不一样。
| 层级 | 描述符类型 | 单次映射粒度 | 输出地址起始位 |
|---|---|---|---|
| Level 3 | page descriptor(0b11) | 4KB | bit 12 |
| Level 2 | block descriptor(0b01) | 2MB | bit 21 |
| Level 1 | block descriptor(0b01) | 1GB | bit 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] | NS | Non-secure 标记 |
| [7:6] | AP[2:1] | 访问权限 |
| [9:8] | SH[1:0] | shareability,0b11 为 Inner Shareable |
| [10] | AF | Access Flag,0 会触发访问标志异常 |
| [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 << 6(AP[1]),PTE_AP_RDONLY对应2 << 6(AP[2])。有个细节容易踩:pte_write()判定的不是 AP[2] 本身,而是!(pte_val(pte) & PTE_RDONLY),而PTE_RDONLY就是PTE_AP_RDONLY。但如果你启用了 DBM(dirty bit management),AP[2] 的语义会变成"硬件管理的脏位",软件把它置 1 只是表示"当前状态是干净的",第一次写操作时硬件会自动清 0。这时候你如果直接读 AP[2] 判断写权限,逻辑就会错乱,得结合PTE_DBM一起看。
1.3 高 12 位:XN、PXN、CONT、DBM 和留给软件的位
高位这块比低位复杂,因为硬件定义的位和内核私用的位混在一起。
| 位 | 名称 | 说明 |
|---|---|---|
| 51 | DBM | 脏位由硬件管理 |
| 52 | Contiguous | 连续提示,需 16 个相邻项属性一致 |
| 53 | PXN | 特权态(EL1)不可执行 |
| 54 | UXN | 非特权态(EL0)不可执行 |
| [58:55] | OA[55:52] 或软件位 | 48 位物理地址下可作软件用途 |
| [62:59] | PBHA | 需 FEAT_TTPBHA,否则保留 |
| 63 | 软件位 |
PXN 和 UXN 的组合直接决定了这块内存"谁能执行、谁不能",这是内核 W^X 的实现基础。
| PXN | UXN | 典型用途 |
|---|---|---|
| 0 | 1 | 内核代码段 |
| 1 | 0 | 用户代码段 |
| 1 | 1 | 数据段,双方都不可执行 |
| 0 | 0 | 基本不用,两边都能执行 |
Linux 在pgtable-hwdef.h里定义了几个软件用的位,48 位物理地址前提下:PTE_DIRTY占 bit 55,PTE_SPECIAL占 bit 56,PTE_DEVMAP占 bit 57,PTE_PROT_NONE占 bit 58。这几个位硬件完全不看,你改属性的时候如果用的是"整体替换"而不是"只清 AttrIndx 位域"的写法,很容易顺手把这几个软件位抹掉,然后在某个页回收路径上撞到一个莫名其妙的 bug。这就是为什么改属性必须用clear_pte_bit+set_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 编码 | 含义 |
|---|---|---|---|
| 0 | MT_DEVICE_nGnRnE | 0x00 | 设备内存,最强顺序 |
| 1 | MT_DEVICE_nGnRE | 0x04 | 设备内存,允许早应答 |
| 2 | MT_DEVICE_GRE | 0x0c | 设备内存,允许聚集与重排 |
| 3 | MT_NORMAL_NC | 0x44 | 普通内存,Non-Cacheable |
| 4 | MT_NORMAL | 0xff | 普通内存,Inner/Outer WB + RW Allocate |
| 5 | MT_NORMAL_WT | 0xbb | 普通内存,Write-Through |
| 6 | MT_NORMAL_TAGGED | 0xf0 | 带标签的普通内存,MTE 用 |
0xff 拆开看是两部分:低 4 位描述 Inner 属性,高 4 位描述 Outer 属性,0xf是 Normal Write-Back Read/Write Allocate;0x44 的高低位都是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 的 WC(Write-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 ish+isb收尾。如果你图省事用local_flush_tlb_all(),那就只刷了当前核,其他核上残留的 TLB 项继续命中旧属性,表现出来的现象就是"有时候对有时候不对"。
3.3 顺序不能错:cache 维护、PTE 写入、TLB 失效
ARM 对内存属性变更有一组明确的顺序要求,顺序错了就是随机故障。整理成一张表。
| 步骤 | 操作 | 为什么必须是这个位置 |
|---|---|---|
| 1 | 停掉这段内存的并发访问 | 后面几步之间窗口内访问会拿到不一致数据 |
| 2 | clean + invalidate 到 PoC | 避免旧 cacheable 副本与新 non-cacheable 视图冲突 |
| 3 | 写入新页表项(原子) | 属性切换的生效点 |
| 4 | TLB 失效 + 跨核同步 | 让所有核看到新属性 |
| 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*)(p+4) = 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 type(WB/WT/NC/Device) | 是 | |
| shareability | 是 | |
| AP 读写权限 | 是 | |
| XN 执行权限 | 是 |
另外 Device 内存还有一个额外的「Device 映射之间必须属性相同」的要求:两个 Device 映射,一个 nGnRnE 一个 GRE,指向同一个外设寄存器,同样是不允许的。这也是为什么驱动里访问寄存器一定要统一用ioremap()而不是在一个地方用ioremap_np、另一个地方用普通ioremap。
4.3 PTE_CONT:16 项必须齐步走
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 里的幽灵
nG(not 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 | 不可执行 |
| SHD | shareable,通常是 Inner Shareable |
| AF | Access Flag 已置位 |
| UXN / PXN | 非特权 / 特权不可执行 |
| BLK | 当前区间是块描述符映射 |
| PTE | 当前区间是页描述符映射 |
| CON | 带 Contiguous 提示 |
| MEM/NORMAL | Normal memory,具体缓存属性看 AttrIndx |
| DEVICE | Device 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 abort | PXN/UXN 置反 |
| 写操作触发 Data Abort | AP[2] 没清(写权限没给) |
| 读到明显过期数据 | cache 没 clean 就改了属性 |
| 改完打印对,跑起来不对 | TLB 没刷或者只刷了本核 |
| 压测才复现的随机 abort | 撕裂写入,缺 WRITE_ONCE |
| 多核之间数据不一致 | 别名映射属性不一致 |
| 改了一项导致附近一片异常 | 踩到 PTE_CONT 组 |
| 切进程后出现异常映射 | nG 位设错 |
| 改成 2MB 大页后行为诡异 | 缺 break-before-make |
6. 内核里几个真实场景,以及顺手记下的经验
6.1 启动时的 RO/NX:mark_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 W+X",就是属性检查没通过。
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-nGnRnE | Normal 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特别值得开,它会在属性变更后主动扫描内核映射区,发现可写可执行的映射就打印警告并打印出具体地址范围,定位效率比事后追查高一个量级。