Linux Arm64 页表项属性修改:PTE、TLB 与 cache 同步 1. 先弄清楚页表项属性这四个字背后站着几套机制Linux Arm64 修改页表项属性听着像一道八股文面试题真落到工程里动机往往很朴素写内核模块的时候想把某块只读内存临时改成可写做热补丁的时候要把某页翻成可写可执行调试缺页异常的时候想手动把 PTE 的某个位掰一下看硬件到底怎么反应。只要涉及到 Arm64 的页表项属性就绕不开三件事描述符里的位怎么摆、改完之后 TLB 和 cache 怎么同步、你改的是哪一张页表。这三点里任何一点没处理干净轻则属性不生效重则一条str指令直接把机器打挂。1.1 arm64 的页表是从哪一级开始长出属性位的先用 4KB 页、39 位虚拟地址这个最常见的配置把结构说清楚。虚拟地址被切成 PGD、PUD、PMD、PTE 四层每层 9 个位做索引覆盖的映射粒度分别是 1GB、2MB、4KB。硬件遍历到哪一级属性就从哪一级的描述符里读。这一点非常关键属性不是只有最后一级 PTE 才有PGD/PUD/PMD 上的描述符同样带 AP、AF、SH、AttrIndx 这些位。也就是说如果内核把一段线性映射做成了 2MB 的 block 描述符你跑到最后一级去找 PTE压根就找不到东西。48 位虚拟地址的配置会多出一层 P4D代码里看到的p4d_offset()就是给这种配置准备的。另外 arm64 有两个页表基址寄存器TTBR0 管低地址用户空间0x0000_0000_0000_0000到0x0000_ffff_ffff_ffffTTBR1 管高地址内核空间。内核态的代码和数据通过 TTBR1 走swapper_pg_dir这一套页表这套表在init_mm里挂着用户态的地址走mm-pgd。改内核地址的属性要碰init_mm改用户地址的属性要碰某个进程的mm这两条路的锁、生命周期、TLB 失效方式完全不一样后面会分开讲。1.2 三种地址三条完全不同的改法在内核模块里动手之前先确定目标地址属于哪一类。第一类是线性映射区direct map也就是__va(phys)出来的那个地址。你从 buddy 分配器拿到的页、kmalloc分配的大块内存基本都是这一类。这一块的页表能不能找到 4KB 粒度的 PTE取决于内核启动时线性映射用的是什么粒度。arm64 有个CONFIG_RODATA_FULL_DEFAULT_ENABLED默认打开打开之后线性映射按 4KB或 64KB看页大小配置粒度建表这时候你能在线性映射区摸到 PTE如果这个选项关掉或者启动参数里给了rodataoff线性映射会尽量用 1GB/2MB 的 block 描述符最后一级就没有 PTE 给你改你得先把它拆开。第二类是vmalloc 区和模块区从VMALLOC_START到VMALLOC_END那一段。vmalloc、vmap、module_alloc出来的地址都在这里粒度天然是页级的除非开了 huge vmalloc 的配置所以这一类最容易上手属性也最干净。第三类是用户进程地址空间。这一类的 PTE 在mm-pgd下面同一个地址还有 VMA 在管着权限背后还挂着反向映射rmap和写时复制COW页表锁也要按规矩拿。改用户页表属性的代价比前两类大得多稍不留神就会和缺页异常流程打起来。1.3 为什么改一个 bit会牵扯这么多东西Arm64 的架构手册里有两条硬性约束写模块时必须遵守。一是break-before-make。把一个有效的描述符改成另一个有效的描述符如果两者的输出地址不同必须先把它变成无效描述符中间夹一次 TLB 失效再写新的有效描述符。为什么因为硬件可能在遍历过程中看到中间状态两个有效的、指向不同物理页的 translation 同时可见会造成不可预期的行为。不过架构也留了一个口子如果只是改权限相关的位输出地址不变AF 已经置位而且更新是通过一次存储完成的那就可以直接改不需要 break。Linux 的set_pte_at()在 arm64 上就是按这个单次存储 屏障的路径实现的所以能走内核提供的接口就别自己裸写。二是TLB 和 cache 的同步。改了页表项TLB 里的老 translation 还在硬件不会主动来读内存里的新值。你必须显式执行 TLBI 指令再用dsb和isb保证失效完成、后续取指看到新值。如果这次改动涉及执行权限还要把数据侧和指令侧的 cache 对齐否则 CPU 取到的是旧指令或者根本取不到指令。这两条约束决定了改页表属性的代码顺序先算好新值再落存储接着做屏障然后失效 TLB最后按需要同步 cache。顺序错了现象会非常魔幻。2. 页表项里的位逐个数哪些能碰哪些碰了就出事动手改之前把一张 64 位 PTE 里的位拆开看一遍比事后拿 crash 去猜要省事得多。下面这张表是 arm64 stage-1 翻译描述符的常用位具体到某个 LTS 版本可能有个别位被重新定义改动前建议直接打开当前源码树里的arch/arm64/include/asm/pgtable-prot.h对一遍。位宏名硬件含义工程上的注意点0PTE_VALID描述符有效清掉它等于解除映射不是只读1PTE_TABLE_BIT页表项/页描述符区分level 3 的页描述符该位为 14:2AttrIndxMAIR 索引决定 Normal/Device 和缓存属性5NS安全态标记普通内核映射里是 06AP[1]非特权访问位对应PTE_USER用户页必须置位7AP[2]只读位对应PTE_RDONLY9:8SH共享域普通内存一般是 inner shareable10AF访问标志为 0 会触发 Access Flag 异常11nG非全局用户映射置 1内核映射置 050PTE_GP强制特权访问和 PAN 相关极少手改51PTE_WRITEDBM硬件 dirty 管理写权限判定和它纠缠在一起52PTE_CONT连续映射提示单独改一个 PTE 的经典雷区53PTE_PXN特权态禁止执行内核态取指看它54PTE_UXN非特权态禁止执行用户态取指看它55PTE_DIRTYLinux 的软件脏位只给内核看硬件不读56 起PTE_SPECIAL等纯软件位硬件遍历时忽略看这张表就能明白一件事只读和不可写在 arm64 上不是一个位说了算。PTE_RDONLY是AP[2]PTE_WRITE落在 DBM 位上PTE_DIRTY又是 Linux 自己记账用的软件位。三者组合不对就会掉进后面第 5 节那些奇葩现象里。2.1 AP、UXN、PXN权限是组合出来的不是单点开关AP[1]和AP[2]一起决定这一页对 EL0用户态和 EL1内核态的访问权限。在 DBM 关闭的前提下组合关系大致是这样AP[1] (bit6)AP[2] (bit7)EL0 访问EL1 访问典型场景00禁止读写内核私有数据10读写读写普通用户可写页01禁止只读内核 rodata11只读只读用户代码段、只读映射一旦 DBM 介入AP[2]在硬件眼里就变成了 dirty 状态位不再单纯表示只读。这也就是为什么在 arm64 上不能照着 x86 的思路去改_PAGE_RW两边对可写的编码根本不是一个体系。务实建议凡是能通过pte_mkwrite()、pte_wrprotect()、pte_mkclean()、pte_mkdirty()完成的事就别自己拼位。这些 helper 在不同版本里会把 DBM、AP[2]、软件脏位之间的配套关系一起处理掉手搓的位拼不出正确组合的概率相当高。执行权限是由PXN和UXN两个位加AP[1]一起决定的PXNUXNEL0 取指EL1 取指00允许允许10允许禁止01禁止允许11禁止禁止需要额外提一句如果AP[1]是 0EL0 连数据访问都被挡住了执行权限根本轮不到 UXN 来管。所以在用户页上翻执行权限AP[1]、UXN、PXN三个点都得看一遍少看一个就会出现明明把 UXN 清掉了还是取指异常的迷惑现场。2.2 AF 和 DBM两个最容易被忽略、又最容易出事的标志AF不在任何权限组合表里但它是页表遍历的第一道门槛。硬件第一次访问一个AF0的有效映射时会抛 Access Flag 异常由内核的缺页处理流程把AF置上再重试。如果你的模块自己算出来的新 PTE 忘了带AF硬件会一直触发这个异常。内核代码路径还好通常能在 fault handler 里被兜住如果是模块上下文或者关了中断的场景就会变成刷屏的异常日志甚至死循环。现在不少 arm64 平台支持硬件自动置AF代码里对应system_supports_hw_af()这类判断但别把正确性建立在硬件特性上。自己构造 PTE 的时候把AF带上成本是一个位收益是不会莫名其妙地 fault。DBM带来的坑更隐蔽。当 DBM 置位时硬件会自己维护 dirty 状态第一次写入时去更新AP[2]。如果你的新 PTE 只把DBM置上却漏了其它配套位写权限的判定就会走到一个奇怪的分支反过来只清了AP[2]却没管DBM在开了 DBM 的内核上pte_write()的语义和硬件的实际行为可能对不上表现为软件认为可写硬件说不让写。我在一次调试里就撞过这个现象改完 PTE 后pte_write()返回真第一次写成功第二次写直接 Oops。原因就是新 PTE 里的AP[2]状态和 DBM 不匹配硬件在第一次写入后按 dirty 语义更新了描述符软件侧看到的状态和实际状态错位。后来改成统一用pte_mkwrite(pte_mkdirty(old))构造新值问题消失。结论脏位和写权限必须成对处理不要只改一个。2.3 AttrIndx、SH、nG改属性时最容易被顺手带坏的位AttrIndx是 MAIR 表的索引决定这页内存是 Normal Write-Back、Normal Non-Cacheable 还是 Device 类。改权限的时候如果用取旧值、或上几个位、写回去这种省事写法很容易把AttrIndx也搅乱。后果之一就是明明内存内容是对的但读写行为变得极其诡异读出来的值随机变化、写入不落内存、多核之间看到的数据不一致。比AttrIndx更容易被忽略的是SH。Normal 内存默认是 inner shareable靠共享域来保证多核之间的缓存一致性。如果你在改属性时把它改成了 non-shareable单核跑起来一切正常一上多核就会出现A 核写完 B 核读不到的经典现象而且用printf排查几乎排查不出来因为它看起来太像逻辑 bug 了。nG位管的是这一项是不是只属于当前 ASID。用户页必须是nG1内核页一般是nG0全局切换 ASID 时不用刷。如果用户页把nG弄丢了TLB 里就会残留跨进程的 translation出现进程 A 的地址在进程 B 里能读到 A 的数据这种级别的问题虽然概率低但一旦出现就是安全事件。改属性最稳的写法是用pte_modify()/* newprot 由 pgprot_noncached()、PAGE_KERNEL_RO 这类宏生成能保证整组属性是一致的 */ pte_t new pte_modify(old, newprot);pte_modify()内部会把地址位和软件位保留下来只替换权限、属性相关的那些位比手工按位操作可靠得多。arm64 的pte_modify()里那个 mask 就是专门给这个场景用的具体包含哪些位跟着内核版本走反正它比人靠谱。2.4 CONT 连续映射单独改一个 PTE 的经典雷区PTE_CONTbit 52是 arm64 用来降低 TLB 压力的优化。硬件看到一组地址连续、属性相同的 4KB PTE 都带这个提示位时可以合并成一个大条目来缓存。内核在合适的场景例如 THP 拆分、大块匿名映射会给连续的一组 PTE 打上这个标记。问题在于CONT 位是一组的语义不是一个的语义。如果你只改了这组里的某一个 PTE比如把它的写权限抽掉硬件可能仍然按合并后的条目去处理属性的实际生效结果和你写在内存里的那个值对不上。更麻烦的是有些版本的硬件在检测到组内描述符不一致时行为是未定义的。较新的内核6.8 之后给 contpte 加了一整套 fold/unfold 的处理pte_cont()判断和contpte_*系列 helper 就是干这个的。自己写模块时的做法很简单先判断pte_cont(pte)如果为真就把目标范围放大到整组去处理或者用内核提供的那套接口别单点操作。老内核里这块的开关是CONFIG_ARM64_CONT_PTE这一类的配置默认不一定打开但打开过的内核在生产环境里并不少见不能想当然认为没有。3. 从内核模块入手手把手改一个内核地址的 PTE 属性理论说够了下面用一个完整可跑的模块走一遍流程。目标是分配一页内存用内核自己的接口把它改成只读然后不借助set_memory_rw()自己走页表把写权限加回来验证能写最后还原。这个例子的好处是前后状态都能用dmesg打出来成功了有明确的观测点。3.1 环境准备交叉编译加 QEMU比在真机上折腾安全在开发机上先把工具链和内核源码准备好。以 Debian/Ubuntu 主机为例sudo apt update sudo apt install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu \ qemu-system-arm bc flex bison libssl-dev make # 拉一份源码版本按需替换 tar -xf linux-6.6.tar.xz cd linux-6.6 make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- defconfig # 关键让线性映射保持页粒度方便我们在线性映射区找到 PTE ./scripts/config --enable CONFIG_RODATA_FULL_DEFAULT_ENABLED # 打开页表 dump便于观测 ./scripts/config --enable CONFIG_ARM64_PTDUMP_DEBUGFS ./scripts/config --enable CONFIG_DEBUG_FS make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- olddefconfig make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- -j$(nproc) Image modules模块侧用同一份源码树编译注意Makefile里obj-m : pte_demo.o然后make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- M$(pwd)/demo modules启动 QEMU 的时候把内核和根文件系统挂上。用 QEMU 跑 arm64 的最大好处是它支持-s -S任何一次异常都能挂上 gdb 单步比在真机上用 jtag 省事太多qemu-system-aarch64 -M virt -cpu cortex-a57 -smp 4 -m 2G \ -kernel arch/arm64/boot/Image \ -append consolettyAMA0 root/dev/ram0 rdinit/init rodatafull \ -initrd rootfs.cpio.gz -nographic进系统后先确认线性映射的粒度cat /sys/kernel/debug/kernel_page_tables | head -40这个文件由CONFIG_ARM64_PTDUMP_DEBUGFS提供会把内核页表按区间打出来每行末尾用RW、RO、PXN、UXN、BLK这类字母标出属性。看到目标区间是4K粒度还是2M块心里就有底了。如果这里显示的是块映射那就不要试图去 level 3 找 PTE第 3.2 节会讲怎么处理。3.2 第一步稳定地拿到 PTE 指针内核地址走init_mm用pgd_offset_k()起步一层层往下走。要点有三个每一级都要判断空和是不是叶子描述符遇到叶子就说明是块映射直接返回失败pte_offset_kernel()拿到的指针不需要 unmap因为它不是从 kmap 体系里来的。#include linux/mm.h #include asm/pgtable.h static pte_t *kva_to_ptep(unsigned long addr) { pgd_t *pgd; p4d_t *p4d; pud_t *pud; pmd_t *pmd; pgd pgd_offset_k(addr); if (pgd_none(*pgd)) return NULL; p4d p4d_offset(pgd, addr); if (p4d_none(*p4d)) return NULL; pud pud_offset(p4d, addr); if (pud_none(*pud)) return NULL; if (pud_leaf(*pud)) /* 1G 块映射本 demo 不处理 */ return NULL; pmd pmd_offset(pud, addr); if (pmd_none(*pmd)) return NULL; if (pmd_leaf(*pmd)) /* 2M 块映射要么改 PMD 要么先拆 */ return NULL; return pte_offset_kernel(pmd, addr); }这段代码里有几个版本相关的地方直接照搬到别的内核上大概率编译不过。第一个是pgd_bad()、p4d_bad()、pud_bad()、pmd_bad()这几个宏较新的内核里已经被删掉了取而代之的是p4d_leaf()、pud_leaf()、pmd_leaf()这一套判断。第二个是用户态侧常用的pte_offset_map()在 6.10 前后被拆成了pte_offset_map_nolock()和pte_offset_map_lock()还要求配套pte_unmap()老写法会报编译错误或者静态检查告警。如果目标地址在线性映射区而kernel_page_tables显示它是块映射处理方式有两种。一种是直接改上一级PMD 或 PUD的描述符这时候粒度变成 2MB 或 1GB改一次影响一大片需要确认这一片内存的权限诉求一致。另一种是先把块拆成页内核里对应的是split_kernel_hugepages这类内部逻辑但那是set_memory_*系列接口自己会做的事情公开 API 不太适合直接调。所以遇到块映射首选还是先用set_memory_ro()之类的接口把大页拆开等 4KB PTE 长出来之后再走自己的流程。3.3 第二步用 helper 构造新 PTE别手搓位拿到 PTE 指针之后先把旧值读出来打印这一步对排查问题极其有帮助old READ_ONCE(*ptep); pr_info(pte%#llx valid%d wr%d rdonly%d dirty%d af%d\n, (unsigned long long)pte_val(old), pte_valid(old), pte_write(old), pte_rdonly(old), pte_dirty(old), pte_af(old));构造新值的时候只用 helpernew pte_mkwrite(pte_mkdirty(old));pte_mkwrite()负责把写权限相关的位补齐pte_mkdirty()负责脏位两者配合能避开 DBM 和AP[2]之间那套组合陷阱。如果目标是把可写改回只读对应的是pte_wrprotect()加pte_mkclean()。需要改执行权限就用pte_mkexec()、pte_mknoexec()这类 helper不要自己去清UXN和PXN。如果这次改动涉及的内存类型或者共享属性也要变那就不该用单点 helper而是构造一个完整的pgprot_t再走pte_modify()。比如想同时把一段内存改成设备映射就用pgprot_device()生成新属性交给pte_modify()去合并。权限和属性一起改的场合手工按位操作的失败率非常高能交给内核合并就交给内核。还有一件事值得单独提醒改之前最好把旧的pte_val()存下来。很多人为了简洁还原的时候重新算一遍新值结果因为目标页在这期间被内核改过比如被set_memory_*重新处理过算出来的还原值和实际情况不一致越改越乱。存一份旧值原样写回去是最省心的做法。3.4 第三步BBM、屏障和 TLB 失效顺序不能乱改属性有两种路径。如果只是权限位变化、输出地址不变、AF已经置位可以直接落一次存储WRITE_ONCE(*ptep, new); dsb(ishst); flush_tlb_kernel_range(addr, addr PAGE_SIZE); isb();如果要走更保守的显式 break-before-make那就是/* break先变成无效描述符 */ set_pte(ptep, __pte(0)); dsb(ishst); /* make再写入新描述符 */ set_pte(ptep, new); dsb(ishst); /* 失效 TLB 并等待完成 */ flush_tlb_kernel_range(addr, addr PAGE_SIZE); isb();注意这里的两处dsb(ishst)。它的作用是保证页表写入对系统中的页表遍历器可见缺了它TLB 失效可能发生在写入可见之前其他核依然可能看到旧值。flush_tlb_kernel_range()内部会向相关 CPU 发 IPI 让它们执行 TLBI最后那个isb()保证本核后续的取指和访问能看到新的 translation。set_pte()和set_pte_at()的区别也顺手说一下。set_pte()是最底层的写入只做一次存储加屏障。set_pte_at()的入参里带mm和地址内核版本较新时它内部会处理 contpte 和一部分一致性逻辑用户态页表基本都应该走它。内核地址这边两种写法都能用用set_pte_at(init_mm, addr, ptep, new)也不算错但要知道它内部做的事情比set_pte()多。这里还有一个工程上的取舍flush_tlb_kernel_range()是精确失效代价是发 IPI多核上开销不小。如果目标地址在内核里会被高频访问改一次属性引起的抖动可能超出预期。在性能敏感的场景里正确的做法往往不是改页表属性而是换一种机制比如把数据放在本就可写的内存里用软件层的保护位去管逻辑。改属性这件事用在初始化、调试、热补丁这类低频路径上才合适。3.5 第四步涉及执行权限的时候icache 一定要管数据权限的改动主要是 TLB 的问题一旦涉及执行权限就要多考虑一层 cache。arm64 上指令和数据是分开的你通过数据路径写进去的字节不一定能被取指路径看到。把一页从不可执行改成可执行之前必须先让数据可见dcache_clean_pou(addr, addr PAGE_SIZE); /* 把数据推到 PoU */ flush_icache_range(addr, addr PAGE_SIZE); /* 失效对应指令 cache */ isb();反过来把一页从可执行改成不可执行或者重新写入代码也要走同样的同步。经典错误现象有两个一个是pte_mkexec()之后取指异常因为属性改了但 cache 没同步另一个是执行到旧指令因为 icache 里还留着上一版的代码。这两种现象在内核模块查错的时候都很难往 cache 方向想需要经验。顺便说一句内核里改自己文本段还有个更麻烦的问题改的地址可能落在rodata或者 text 段上这些区域在mark_rodata_ro()之后是只读的。这时候常规路径是先set_memory_rw()改完再set_memory_ro()或者直接用text_poke()/patch_text()系列。裸改 PTE 能通但容易和内核自己的 W^X 检查、CONFIG_STRICT_KERNEL_RWX的约束打架属于能跑但不好维护的路子。4. 用户进程的页表也能改但坑比内核侧深得多有些调试场景需要从内核模块里去改另一个进程的页表属性比如实现一个简易的内存探测器或者给某个正在跑的进程临时放开权限。技术上可行但要处理的事情比内核侧多出一大截。4.1 拿用户 PTE 指针的几种方式正确做法是从目标进程的mm出发遍历。比较通用的写法是先确认地址落在某个 VMA 里再走页表struct mm_struct *mm get_task_mm(task); struct vm_area_struct *vma; pte_t *ptep; spinlock_t *ptl; if (!mm) return -EINVAL; vma find_vma(mm, addr); if (!vma || vma-vm_start addr) { mmput(mm); return -ENOENT; } ptep pte_offset_map_lock(mm, pmd, addr, ptl); if (!ptep) { mmput(mm); return -ENOENT; } /* 在 ptl 保护下操作 ptep */ pte_unmap_unlock(ptep, ptl); mmput(mm);这里的pmd需要自己从pgd_offset(mm, addr)往下算。pte_offset_map_lock()会顺手把页表锁拿上这是必须的——用户页表随时可能因为缺页异常、COW、THP 拆分而变化不加锁就是在和数据竞争赛跑。follow_pte()系列也是常见选择它内部做了同样的事情少写几行代码。拿到 PTE 之后构造新值仍然用 helperpte_mkwrite()之类照旧。区别在于改完之后要用flush_tlb_page(vma, addr)或者flush_tlb_range(vma, start, end)做失效。arm64 的flush_tlb_mm()会按mm-context.id去刷 ASID对其他进程的mm也有效只要是同一个 mm 就行。4.2 mm 生命周期和 TLB 广播是最容易踩的两个点第一个坑是 mm 的生命周期。find_vma()拿到的vma指针在mmap_lock保护之外随时可能失效mmput()之前必须确认没有别的线程在并发改这个地址空间。如果目标进程正在被 exec、exit 或者 mremap处理逻辑会非常微妙。工程里稳妥的写法是缩短临界区把需要的信息一次性取出来别在长时间操作里持着 mm 引用到处跑。第二个坑是 TLB 失效的覆盖范围。如果你是在模块里、跑到某个进程的上下文里改了它的页表只刷本核的 TLB 是不够的目标进程可能刚在别的核上跑过。flush_tlb_page()/flush_tlb_range()在 arm64 上会通过 IPI 广播到其他核但前提是这些核上的硬件上下文没有被别的进程抢占导致 ASID 不匹配。用flush_tlb_mm(mm)会更稳代价是范围更大。还有一个容易被忘掉的点改用户 PTE 的属性会绕过 VMA。如果 VMA 里没有VM_WRITE你手工给 PTE 加上写权限硬件层面确实能写但下一次缺页异常、COW、mprotect()调用都可能把权限重新按 VMA 的值刷回去出现刚才还能写跑了三秒又不行了的现象。VMA 和 PTE 描述的是同一件事的两层只改一层另外一层迟早会把它修正回来。4.3 大多数场景有比改页表更正经的选择如果需求只是让某段用户内存在一段时间内可写绝大多数情况下mprotect()就够了内核会在里面处理页表拆分、权限更新、TLB 失效、VMA 状态同步这一整套事情代码还短。如果需求是在用户态自己实现缺页处理那应该用userfaultfd让内核在缺页时把控制权交给你而不是自己去改页表。如果是把一段物理内存映射进进程mmap配合remap_pfn_range、vm_insert_page是正路。真正需要手工改用户页表的场景其实很少通常是调试器、虚拟机监控器、内核热补丁这类需要绕过常规路径的工具。这类工具的作者通常很清楚自己在做什么而如果你不是其中之一遇到用户页表相关需求时先花半小时确认一下标准接口能不能满足往往能省掉后面几天的排错时间。5. 现象与排查把踩过的坑整理成一张速查表页表改属性这件事出错之后的现象非常有辨识度但如果没有经验很容易往错误的方向查。下面这张表是我自己踩坑加帮别人看问题攒下来的对着现象找原因比盲猜快很多。现象大概率原因处理思路改完读到的属性没变化TLB 没失效或者只刷了本核补flush_tlb_*确认有dsb(ishst)在前改完第一次写可以第二次 OopsDBM 与AP[2]状态不匹配统一用pte_mkwrite()系列构造新值写完立刻翻译异常新 PTE 没带AF或者丢了PTE_VALID打印pte_val()逐位对确认 AF1用户页写入触发段错误VMA 不允许写或AP[1]被清掉检查 VMA 的VM_WRITE和PTE_USER取指异常或执行到旧指令icache 没同步或 UXN/PXN 设错flush_icache_rangeisb检查两个执行位改一页旁边几页也变了命中了 CONT 连续映射组判断pte_cont()整组处理或避开kva_to_ptep返回 NULL目标区间是块映射没有 level 3 PTE看kernel_page_tables或先用set_memory_*拆开多核上数据不一致SH被改成 non-shareable或AttrIndx变了用pte_modify()整组替换属性新内核上编译不过pgd_bad、pte_offset_map等接口变更用p*_leaf()、pte_offset_map_nolock()系列5.1 观测手段三个工具能解决八成问题第一个是/sys/kernel/debug/kernel_page_tables。打开CONFIG_ARM64_PTDUMP_DEBUGFS之后这个文件会把内核页表按区间打出来每行带上属性字母和映射粒度。改之前看一眼、改之后看一眼属性有没有生效一目了然比在模块里逐位打印pte_val()更直观。它同时能告诉你目标区间是4K还是BLK这一个信息就能省掉大量无效排查。第二个是 crash 工具配合 vmcore。内核崩了之后crash里的vtop命令可以按虚拟地址把整个翻译路径打出来从 PGD 到 PTE 的每一级描述符值和属性都能看到。如果崩溃是翻译异常引起的用vtop对一下地址的属性和预期是否一致通常一眼就能看出问题。第三个是 QEMU 加 gdb。QEMU 可以用-s -S挂上 gdb断点打在do_page_fault、__do_kernel_fault这些函数上配合info registers看ESR_EL1和FAR_EL1能精确定位到是哪一类异常、哪个地址触发的。ESR_EL1里的 DFSC/IFSC 字段会区分翻译异常、权限异常、Access Flag 异常这对判断是属性错了还是映射没了非常关键。5.2 一个真实排查过程的复盘之前有个场景是把一段模块内存在初始化阶段改成只读用的时候再改回来。现象是改回可写之后memset一小段没问题一跑大循环就在某个固定位置崩而且每次崩的位置还不一样。第一反应是内存越界查了两天没查出来。后来用kernel_page_tables看目标区间发现它不是 4KB 粒度而是被合并成了 2MB 的块。也就是说我改的那一次属性实际上改的是 2MB 范围把旁边几个模块的代码段一起写成了可写——而那段代码里恰好有需要保持只读的部分另一个核在跑的时候取指拿到的是被改写过的内容。定位到这一点之后改法就变成了先用set_memory_ro()把大页拆成页再在 4KB 粒度上操作问题消失。这个案例给我的教训是改属性之前第一件事是确认粒度第二件事是确认范围。打印一下pte_val()、看一眼页表 dump成本是几分钟不打印直接改成本可能是两天。后面我在所有涉及页表操作的模块里都加了一条初始化检查目标区间如果不是 4KB 粒度就直接拒绝执行宁可报错也不冒这个险。5.3 还原和收尾比改过去更容易被忽略改属性这个操作很多人只想着怎么改过去不太在意怎么改回来。实际工程里收尾做得不好才是事故来源。几个习惯值得养成改之前把旧pte_val()存下来还原时原样回写别重新计算还原之后同样的 TLB 失效流程再走一遍不能省如果这次改动让内核自己的记账数据比如页表项的软件脏位、mm的 RSS 统计和实际状态脱节还原后要做一次校准最省事的办法就是走一遍set_memory_rw()让内核重新计算一遍属性。模块卸载路径里的还原尤其要小心。如果module_exit里因为某个条件判断失败跳过了还原系统就会带着一个属性错乱的页继续运行后面随机崩溃而且崩溃点和你的模块看起来毫无关系。我自己的做法是把还原逻辑放在一个单独的、不依赖运行状态的函数里无论中间成功还是失败都执行失败时的错误码单独记录。最后分享一个实际用下来很省事的小技巧在模块里加一个debugfs文件把目标地址、当前pte_val()、free_area的粒度信息都暴露出来需要的时候cat一下就能看到当前状态不用每次都卸载重装模块加打印。这个文件在排查改完到底生效没有这类问题时比dmesg灵活得多尤其是在需要连续观测多次状态变化的场景里。