拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Linux Arm64 页表项属性修改:PTE、TLB 与 cache 同步

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_ffff),TTBR1 管高地址(内核空间)。内核态的代码和数据通过 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;如果这个选项关掉,或者启动参数里给了rodata=off,线性映射会尽量用 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 的页描述符该位为 1
4:2AttrIndxMAIR 索引决定 Normal/Device 和缓存属性
5NS安全态标记普通内核映射里是 0
6AP[1]非特权访问位对应PTE_USER,用户页必须置位
7AP[2]只读位对应PTE_RDONLY
9:8SH共享域普通内存一般是 inner shareable
10AF访问标志为 0 会触发 Access Flag 异常
11nG非全局用户映射置 1,内核映射置 0
50PTE_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禁止只读内核 rodata
11只读只读用户代码段、只读映射

一旦 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]是 0,EL0 连数据访问都被挡住了,执行权限根本轮不到 UXN 来管。所以在用户页上翻执行权限,AP[1]、UXN、PXN三个点都得看一遍,少看一个就会出现"明明把 UXN 清掉了还是取指异常"的迷惑现场。

2.2 AF 和 DBM:两个最容易被忽略、又最容易出事的标志

AF不在任何权限组合表里,但它是页表遍历的第一道门槛。硬件第一次访问一个AF=0的有效映射时,会抛 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"。用户页必须是nG=1,内核页一般是nG=0(全局,切换 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_CONT(bit 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 ARCH=arm64 CROSS_COMPILE=aarch64-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 ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- olddefconfig make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j$(nproc) Image modules

模块侧用同一份源码树编译,注意Makefile里obj-m := pte_demo.o,然后:

make ARCH=arm64 CROSS_COMPILE=aarch64-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 "console=ttyAMA0 root=/dev/ram0 rdinit=/init rodata=full" \ -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));

构造新值的时候只用 helper:

new = 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 之后,构造新值仍然用 helper,pte_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()逐位对,确认 AF=1
用户页写入触发段错误VMA 不允许写,或AP[1]被清掉检查 VMA 的VM_WRITE和PTE_USER
取指异常或执行到旧指令icache 没同步,或 UXN/PXN 设错flush_icache_range+isb,检查两个执行位
改一页,旁边几页也变了命中了 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灵活得多,尤其是在需要连续观测多次状态变化的场景里。

返回列表