Arm64 页表项属性修改:MAIR、BBM 与 TLB 刷新
发布时间:2026/9/30 19:28:33来源:尧图网络
1. 先搞明白 Arm64 页表项到底长什么样改页表项属性这七个字听起来像是什么高深操作说白了就是把内存里某个 64 位描述符的几个 bit 换个数然后想办法让 CPU 别再相信它自己缓存过的旧翻译。我在 Arm64 平台上处理这类需求前后折腾过好几轮最后发现真正难的地方根本不在改而在改得让硬件认账走页表的时候发现目标是块映射、改完之后忘了刷 TLB、属性改了但 cache 里的脏数据还在——这三个坑我至少各踩过一次每次都是页面数据莫名其妙错乱或者直接挂死排查起来比写代码本身费劲得多。这篇东西面向的是已经会写内核模块、看得懂struct page、知道pgd/pud/pmd/pte是什么的读者。如果你只是想给自己的驱动做一块 Non-cacheable 缓冲区或者因为 DMA 一致性问题需要临时调整某段内存的 cache 属性再或者是在调试某个 MMU 相关的问题想手动看一眼页表项下面的内容应该能直接用上。我会从描述符的位布局讲起然后给出完整的走表加改写代码最后把我踩过的坑一条条列出来——包括那些内核文档里不会写、只有真正在板子上跑过才知道的细节。需要先说清楚的是这里讨论的是正常的驱动开发、内核调试和内存管理研究场景。生产内核打开CONFIG_STRICT_KERNEL_RWX之后本来就禁止随意改写代码段属性这是设计意图不是缺陷别把精力花在跟它对抗上那是另一回事。1.1 四级页表与地址是怎么切开的Arm64 在 4KB 页粒度、48 位虚拟地址的默认配置下走四级页表。每一级表都是 512 个表项、每项 8 字节刚好凑满一页 4KB——这个数字不是巧合是 ARM 刻意设计的好处是一张页表正好占一个页面分配和回收都以页为单位简单直接。地址切分的规则是每级 9 位索引加最后 12 位页内偏移。从高位往下依次是级别索引位表项类型名称Level 0VA[47:39]PGD页全局目录Level 1VA[38:30]PUD页上级目录Level 2VA[29:21]PMD页中间目录Level 3VA[20:12]PTE页表项偏移VA[11:0]—页内偏移这里有个需要注意的地方Level 0 到 Level 2 的表项既能指向下一级表table descriptor也能直接指向一块物理内存block descriptor。Level 3 的表项只能指向一个 4KB 页page descriptor因为已经没有下一级了。这就意味着如果你要改的目标地址恰好落在一个 2MB 的块映射里那么根本不存在 PTE 给你改——PMD 就是最后一级。这是后面要用一整节来讲的坑先记住这一点。虚拟地址空间在 Arm64 上被 TTBR0_EL1 和 TTBR1_EL1 分成两半。低半区给用户态进程高半区给内核。48 位配置下bit 47 是符号扩展位内核空间的地址高 16 位全是 1。内核线性映射从PAGE_OFFSET开始也就是0xffff000000000000这个位置模块加载区则在MODULES_VADDR那一带。你在模块里拿到的函数指针地址基本都落在模块区而vmalloc出来的缓冲区落在 vmalloc 区。这两块区域的页表结构处理起来略有差别待会儿会说到。1.2 一个 64 位描述符里的位都用来干什么真正要动手改的就是这个 64 位值。4KB 页粒度下的 page descriptor 布局大致是这样我把跟属性相关的位单独拎出来bit [0] : Valid bit [1] : 1page descriptor 固定为 1 bit [4:2] : AttrIndx[2:0] - 索引 MAIR_EL1 里的属性槽 bit [5] : NS - 安全/非安全空间 bit [6] : AP[1] - EL0 可访问 bit [7] : AP[2] - 只读位 bit [9:8] : SH[1:0] - 共享属性 bit [10] : AF - Access Flag bit [11] : nG - 非全局 bit [47:12] : 输出物理地址 bit [50] : GP - BTI 保护页 bit [51] : DBM - 脏位管理 bit [52] : Contiguous - 连续映射提示 bit [53] : PXN - 特权态不可执行 bit [54] : UXN - 用户态不可执行 bit [58:55] : 软件可用 bit [62:59] : 保留不同粒度和不同扩展下具体位的含义会变比如 16KB 页时 bit 11 变成了 nT 而不是 nG开了 MTE 之后部分软件位被硬件征用了。但上面这套是你在 4KB 页内核上最常遇到的情况覆盖了绝大多数场景。这里有个特别容易搞混的点AttrIndx只是三个 bit 的索引它本身不表示任何属性。真正决定这段内存是 Write-Back 还是 Non-cacheable、是 Device 还是 Normal 的是MAIR_EL1寄存器里对应的那个 8 位槽位。所以改属性实际上是两步操作先确认 MAIR 里第 N 个槽是什么再把 PTE 的 AttrIndx 改成 N。这个认知如果错位你会写出一堆看起来正确、跑起来完全不符合预期的代码。2. 关键属性位逐个过一遍光看位表没有意义得知道每一位改动之后会带来什么后果。这一节挑几个最常被改的位展开说其他的可以当背景知识了解。2.1 低位区AP、SH、AF 和 nGAP 位控制读写权限。AP[2] 在 bit 7置 1 表示只读。AP[1] 在 bit 6置 1 表示 EL0用户态也能访问。这两个位组合出来的效果要注意一个陷阱AP[2] 是只读位但它管的是所有特权级都只读而 AP[1]0 的时候哪怕是可写的页用户态也只能读。很多人在给用户态映射一块可写内存的时候只改了 AP[2]结果发现用户进程一写就触发权限异常问题就在这里。另外还有一个 AP[0] 不在描述符里它在TCR_EL1的EPD0附近用来控制特权态写用户态页的行为属于全局开关一般没人动。SH 位控制共享属性。两位组合表示 Non-shareable、Outer Shareable 和 Inner Shareable。SMP 系统上 Normal memory 一般用 Inner Shareable这样多核之间的 cache 一致性才能得到保证。如果你把一段内存的 SH 改成 Non-shareable那么这块内存在不同 CPU 上可能看到不同的值——单核跑起来一切正常一上多核就出玄学问题。我曾经把一段共享缓冲区的 SH 位写错压测的时候偶发数据错乱查了整整两天才定位到。AF 位是 Access Flag。置 1 表示这一页被访问过。硬件在第一次访问后会自动把它置 1如果开了硬件 AF 更新如果 AF0 而硬件又不自动更新那么任何访问都会触发 Access Flag Fault。手动改页表的时候AF 一定要记得置上否则改完立刻就是一堆缺页异常。这个错误非常隐蔽因为异常信息只会告诉你访问出错不会告诉你是 AF 没置。nG 位表示非全局映射。置 1 表示这个映射跟 ASID 绑定进程切换时需要切换。内核映射通常是全局的nG0用户映射是非全局的。这一位错了会导致 TLB 命中错误地址症状是进程 A 读到了进程 B 的数据属于安全级别的严重问题。2.2 高位区PXN、UXN、Contiguous 和 DBMPXN 和 UXN 是两个执行权限位。PXNbit 53置 1 表示特权态不可执行UXNbit 54置 1 表示用户态不可执行。这两个位是 Arm64 上 W^X 策略的基础——内核代码页要求 PXN0、UXN1也就是只有特权态能执行用户态绝不能执行。数据和栈则应该 PXN1、UXN1。如果你在改属性的时候把 UXN 顺手清掉了等于给内核数据页开了用户态执行权限这在生产内核上是不可接受的。改这一位之前请务必想清楚在干什么。Contiguous 位是个提示位。置 1 表示连续的若干表项构成一个连续映射TLB 可以合并成一个大的表项来减少压力。它的使用有严格条件必须连续 16 个4KB 页表项的属性完全一致、物理地址连续、起始地址 16 页对齐。如果你只改了其中一个表项的属性却忘了把 Contiguous 位清掉硬件会认为整个连续块都是一个属性你的修改等于没生效——我见过有人在这上面卡了整整一个下午。DBM 是脏位管理位。置 1 之后硬件在写入时自动把 Dirty 状态记在软件位里而不是触发写权限异常。这个位在实现 COW 和脏页跟踪的时候有用普通驱动里很少碰。改它的风险是自己也要维护对应的软件位语义容易乱。2.3 AttrIndx 和 MAIR_EL1 的配合关系这是最核心的一对。MAIR_EL1是一个 64 位寄存器分成 8 个 8 位的槽位每个槽位描述一种内存属性。AttrIndx三位就是这 8 个槽的索引。Linux 启动时会根据自己的内存类型定义把这 8 个槽填好典型配置大致是这样编号宏MAIR 编码含义MT_DEVICE_nGnRnE0x00最强序的设备内存无重排无合并MT_DEVICE_nGnRE0x04允许读提前无写合并MT_DEVICE_GRE0x0C允许读提前和写合并MT_NORMAL_NC0x44Normal 非缓存内外层都 Non-cacheableMT_NORMAL0xFFNormal 写回写分配标准内存MT_NORMAL_WT0xAANormal 写通这里我必须提醒一句上面这些编号在不同内核版本上变过。5.x 系列和 6.x 系列里MT_宏的数值顺序并不一样有的版本MT_NORMAL是 4有的版本是 0。所以千万不要在代码里硬编码AttrIndx 3这种数字一定要通过arch/arm64/include/asm/memory.h里的宏来取值。我在第一次写这类代码的时候就吃过这个亏换成另一个内核版本编译出来的模块行为完全不对排查了半天才发现是编号漂移。还有个细节值得说PTE_ATTRINDX()这个宏负责把MT_编号左移到AttrIndx的位置。内核还提供了pgprot_writecombine()、pgprot_noncached()、pgprot_device()这些封装好的接口它们内部已经处理好了 AttrIndx、SH 等位的组合。能用这些就用这些手工拼位只在确实需要极端控制的时候再考虑。3. 什么样的需求会逼你去改页表属性不是所有问题都需要动页表。搞清楚为什么必须改比怎么改更重要因为大部分场景其实有更安全的替代方案。3.1 内存的 cache 属性必须和硬件行为对齐这是最常见的动机。假设你在做一个和 FPGA 或者外设共享内存的驱动外设直接往一块物理内存里写数据CPU 这边去读。如果 CPU 侧的映射是 Write-Back 的那么 CPU 读的时候可能直接命中 cache 里的陈旧数据看不到外设刚写进去的内容。反过来 CPU 写到 cache 里、还没写回内存外设就去读了读到的是旧值。正确的做法是让这块内存的 CPU 侧映射变成 Non-cacheableMT_NORMAL_NC或者用MT_DEVICE_nGnRE这类严格序的设备属性。改属性就是在这个环节发生的。同样的道理也适用于显示缓冲、音频环形缓冲区、网络收发描述符环这些场景。3.2 权限位和实际访问模式的冲突第二种动机是权限。比如你有一块内存初始化阶段需要写运行阶段只读但你不想重新做一次映射。或者反过来某块内存需要在特定窗口内可写窗口之外只读——这种细粒度的权限切换通过改 AP 位比重新映射要轻量得多。内核自己在实现set_memory_ro()/set_memory_rw()的时候走的就是这条路。需要注意的是权限切换往往需要配合 TLB 刷新才生效而且 ARM 架构对有效表项的修改有 Break-Before-Make 的约束这在后面会专门讲。3.3 三条技术路线的取舍对比真的遇到需求的时候我一般按下面这个顺序选方案方案适用场景优点风险set_memory_*/ioremap等内核 API90% 的场景已处理 BBM、TLB、cache安全只能作用于内核线性映射区灵活性低手工走页表 pte_modify()需要改单个 PTE、需要精细控制完全可控能改到任意一级坑全得自己填错一步就挂重新建立一套映射vmallocioremap可以换虚拟地址的场景不动原映射最干净多占虚拟地址空间需要改数据通路我个人的建议是能用第一条就不用第二条。set_memory_ro()、set_memory_nx()、set_memory_valid()、memremap()、ioremap()这些接口覆盖了绝大多数真实需求而且内核已经把这些接口里的 BBM 顺序和 TLB 刷新处理得很完备。只有在需要修改某段内存而这段内存的虚拟地址必须保持不变的极端情况下才值得手工走页表。4. 手工走页表并改写 PTE 的完整实现进入正题。下面这套代码我在 4KB 页、48 位 VA 的 Arm64 内核上验证过思路是通用的但不同内核版本的头文件和函数名可能有差异用之前请对照你手上的源码确认。4.1 动手之前必须做的三项检查第一项检查是目标地址有没有第四级页表。前面说过内核线性映射区为了提高 TLB 效率通常用 2MB 甚至 1GB 的块映射。你要改的地址如果落在块映射里就没有 PTE 可改。判断方法是走到 PMD 那一级后看pmd_leaf(*pmdp)是不是真——如果是真说明 PMD 直接指向物理内存下面没有表了。第二项检查是目标内存有没有被别人映射到其他地方。同一块物理内存可能同时存在于线性映射、vmalloc 映射、ioremap出来的映射甚至映射进了某个用户进程。你只改了其中一个 PTE其他映射的属性完全没变硬件行为会非常诡异。我吃过这个亏改了线性映射的 cache 属性但当时调试用的/dev/mem映射还在两个映射cache属性不一致ARM 架构下这种混叠行为是未定义的。第三项检查是这段内存当前有没有人在访问。改 PTE 的过程中有一个瞬间这一页是无效的Break-Before-Make 的要求如果这个时候别的 CPU 正好访问这块内存就是一次异常。所以要么保证这段内存是你独占的要么在改写期间用自旋锁或者关抢占把其他访问路径挡住。要注意关抢占只挡得住当前 CPU挡不住其他核如果这块内存是全局共享的你得有更上层的互斥机制。4.2 定位 PTE 的代码实现走表的核心就是跟着 pgd→pud→pmd→pte 一路下去。给内核地址走表要用pgd_offset_k()因为它直接拿init_mm的页表而不是当前进程的。#include linux/module.h #include linux/mm.h #include linux/pgtable.h #include asm/pgtable.h #include asm/tlbflush.h #include asm/cacheflush.h /* * 返回 addr 对应的 pte 指针失败返回 NULL。 * 只适用于内核地址落在 init_mm 的页表里。 */ static pte_t *kwalk_to_pte(unsigned long addr) { pgd_t *pgdp; p4d_t *p4dp; pud_t *pudp; pmd_t *pmdp; pgdp pgd_offset_k(addr); if (pgd_none(*pgdp) || pgd_bad(*pgdp)) return NULL; p4dp p4d_offset(pgdp, addr); if (p4d_none(*p4dp) || p4d_bad(*p4dp)) return NULL; pudp pud_offset(p4dp, addr); if (pud_none(*pudp) || pud_bad(*pudp)) return NULL; /* 1GB 块映射下面没有表了 */ if (pud_leaf(*pudp)) return NULL; pmdp pmd_offset(pudp, addr); if (pmd_none(*pmdp) || pmd_bad(*pmdp)) return NULL; /* 2MB 块映射下面没有表了 */ if (pmd_leaf(*pmdp)) return NULL; return pte_offset_kernel(pmdp, addr); }几处细节值得解释。p4d这一级在四级页表配置下是编译期折叠的p4d_offset直接返回 pgd但为了代码在不同页表级数配置下的可移植性还是老老实实写全。pgd_bad()检查的是表项里的保留位有没有被硬件置位正常情况下不会触发但加上能帮你在页表被踩坏的时候早点发现。pte_offset_kernel()和pte_offset_map()的区别要说清楚前者用于内核映射不做任何锁和引用计数处理后者用于用户进程地址空间会处理 highmem 的情况。给内核地址走表用前者就对了用错了会看到奇怪的告警。还有一点这个函数返回的指针指向的是页表页里的某个位置页表页本身是内核自己管理的在多核并发写的情况下你需要保证原子性。ARM64 上一个 64 位 PTE 可以用单次 64 位写原子更新所以只要不跨页表项的读写用READ_ONCE/WRITE_ONCE配合正确顺序就够了。4.3 属性替换用 pte_modify拿到 PTE 之后不要手工去拼位。内核提供了pte_modify()它会保留物理地址、AF、DBM、Contiguous、nG 这些跟地址和状态相关的位只用新的pgprot覆盖属性部分。pte_t old_pte, new_pte; old_pte READ_ONCE(*ptep); if (!pte_present(old_pte)) return -EINVAL; /* PAGE_KERNEL_NC 是内核预定义的 Non-cacheable 内核映射属性 */ new_pte pte_modify(old_pte, PAGE_KERNEL_NC);用PAGE_KERNEL_NC而不是自己拼PTE_ATTRINDX(MT_NORMAL_NC)好处是前者已经把 SH、AF、UXN 这些位都配好了你不需要去操心组合逻辑。内核里还有PAGE_KERNEL_RO、PAGE_KERNEL_EXEC、PAGE_KERNEL_ROX这些预定义需求对得上就直接拿来用。顺带说一句我在写这段代码时发现的小细节pte_modify()内部用的掩码会把PTE_CONT保留下来。也就是说如果你改的地址处于一个 Contiguous 映射块里改完之后 Contiguous 位还在硬件有可能仍然按整个块解释属性。这种情况要么先把 Contiguous 清掉要么保证整块 16 个表项一起改成一致的属性。我倾向于前者简单不容易出错。4.4 BBM 顺序和 TLB 刷新这是整段代码里最容易写错的地方。ARM 架构规定当一个有效的页表项要被修改成另一个有效值时如果修改的字段会影响地址翻译结果输出地址、AttrIndx、权限位都算必须先把表项置为无效、执行 TLB 失效、再写入新值、再执行一次 TLB 失效。这个规则叫 Break-Before-Make简称 BBM。违反 BBM 的后果不是可能出错而是硬件行为未定义——在有些实现上看起来正常换一块板子就崩。我见过有人为了省事直接WRITE_ONCE(*ptep, new_pte)了事在自己机器上跑了一周都没事交付之后客户那边偶发死机最后追到就是这个问题。正确的顺序是这样的unsigned long start addr PAGE_MASK; unsigned long end start PAGE_SIZE; /* 1. 先做 cache 维护把这段范围的数据从 cache 里刷出去 */ flush_cache_vunmap(start, end); /* 或针对物理地址的 dcache 操作 */ /* 2. 置无效 */ set_pte_at(init_mm, addr, ptep, __pte(0)); dsb(ishst); flush_tlb_kernel_range(start, end); dsb(ish); isb(); /* 3. 写入新值 */ set_pte_at(init_mm, addr, ptep, new_pte); dsb(ishst); flush_tlb_kernel_range(start, end); dsb(ish); isb();那几个屏障指令不是装饰。dsb(ishst)保证前面写的页表项对其他核可见之后才做 TLB 失效少了它可能出现TLB 刷了但页表还没写出去的重排。isb()保证后续指令在 TLB 失效完成之后才执行。这类重排问题在单核 QEMU 上几乎复现不出来一定要在真机尤其是多核板上验证。flush_tlb_kernel_range()在不同内核版本里存在性不一样。5.x 的大多数版本里是有的如果编译时提示找不到用flush_tlb_mm(init_mm, start, end)或者退化成local_flush_tlb_all()也能达到目的只是后者会把整个 TLB 干掉性能代价大。我在调试阶段经常直接用local_flush_tlb_all()先确保功能对等功能验证完了再换成精细版本优化。4.5 组装成完整模块把上面的片段串起来一个功能完整的最小模块大概是这样static int __init pte_attr_init(void) { unsigned long addr target_addr; /* 你的目标地址页对齐 */ pte_t *ptep, old_pte, new_pte; unsigned long flags; if (!IS_ALIGNED(addr, PAGE_SIZE)) return -EINVAL; /* 独占保护防止改的过程中被访问 */ local_irq_save(flags); preempt_disable(); ptep kwalk_to_pte(addr); if (!ptep) { pr_err(no pte for %lx, maybe block mapping\n, addr); goto out; } old_pte READ_ONCE(*ptep); if (!pte_present(old_pte)) { pr_err(pte not present\n); goto out; } new_pte pte_modify(old_pte, PAGE_KERNEL_NC); /* BBM 序列 */ flush_cache_vunmap(addr, addr PAGE_SIZE); set_pte_at(init_mm, addr, ptep, __pte(0)); dsb(ishst); flush_tlb_kernel_range(addr, addr PAGE_SIZE); dsb(ish); isb(); set_pte_at(init_mm, addr, ptep, new_pte); dsb(ishst); flush_tlb_kernel_range(addr, addr PAGE_SIZE); dsb(ish); isb(); pr_info(pte attr updated: %016llx - %016llx\n, (unsigned long long)pte_val(old_pte), (unsigned long long)pte_val(new_pte)); out: preempt_enable(); local_irq_restore(flags); return 0; }注意local_irq_save()和preempt_disable()的组合只保护当前 CPU。如果目标地址上的内存会被其他 CPU 访问光靠这个是不够的你得在业务层面先停止那些访问路径。这一点我在代码注释里没写但一定要记住。5. 实战把一段内核内存改成 Non-cacheable理论说完了走一遍完整流程。假设你有一个场景驱动从伙伴系统分配了一段内存给外设共享需要在初始化完成后把这段内存的 CPU 侧映射改成 Non-cacheable。5.1 内存准备和前提确认先用alloc_pages()拿到连续页然后确认这段内存的虚拟地址确实走的是 4KB 页映射struct page *pg; void *kaddr; pte_t *ptep; pg alloc_pages(GFP_KERNEL | __GFP_ZERO, get_order(size)); if (!pg) return -ENOMEM; kaddr page_address(pg); ptep kwalk_to_pte((unsigned long)kaddr); if (!ptep) { pr_err(block mapping, cannot modify pte directly\n); /* 要么改用 set_memory_* 接口要么换个分配方式 */ }如果这里返回 NULL说明你分配到的内存落在 2MB 块映射里。这种情况下的标准处理是改用内核提供的set_memory_*系列接口或者干脆换用dma_alloc_coherent()/dma_alloc_attrs()这类天生处理好一致性的接口。硬要在块映射上做文章的话得自己实现 PMD 拆分成 PTE 表那代码量比前面这一整套还大而且容易出错不值得。怎么提高拿到 4KB 页映射的概率一个经验是内核里带__ro_after_init标记的变量、模块自己的静态数据区、vmalloc出来的内存通常都是 4KB 页粒度的。而alloc_pages()出来的普通内存在没开CONFIG_DEBUG_PAGEALLOC的内核上大概率是块映射。5.2 操作顺序cache 必须先处理这是整个流程里我最想强调的一步。把一段内存从 cacheable 改成 non-cacheable 之前必须保证 cache 里没有这段内存的脏数据否则改完之后 CPU 直接读内存读到的是旧值cache 里那份新值永远写不回去。顺序是这样的先对这段内存做一次 clean 加 invalidate把脏数据写回并让缓存行失效再改 PTE 属性并刷 TLB最后才允许访问这段内存第二步里刷新 cache 用的是物理地址所以要先做virt_to_phys()转换。ARM64 上可以用dcache_clean_inval_poc()这类宏或者用flush_dcache_page()逐页处理。要注意的是flush_cache_vunmap()这类函数在某些内核版本里对内核地址的处理路径跟用户地址不一样用之前最好看一下实现。如果拿不准直接用底层的dcache_clean_inval_poc(start_phys, end_phys)是最保险的。改回 cacheable 的时候顺序要反过来先改 PTE 属性并刷 TLB再对这段内存做 invalidate。这样做的原因是属性切回 cacheable 之后若 cache 里有旧的无效行硬件会直接命中所以必须在允许访问之前把它们清掉。这一正一反的顺序我在不止一份代码里看到有人写反过。写反了之后在干净的测试环境下可能看不出问题一旦这段内存被反复读写数据错乱就来了。5.3 验证手段和回滚方案改完之后怎么确认真的生效了三个层次的验证从易到难。第一层是直接读页表项打印出来看确认AttrIndx字段的值变成了你期望的编号。这一步最简单pte_val()打出来对照 MAIR 的槽位看就行。第二层是做行为验证。如果是给共享内存用的写一个测试CPU 写一个模式串到这段内存然后让外设或者在 QEMU 里用另一段代码模拟去读内存的物理地址看拿到的值对不对。如果 cache 属性没配对这里就会出现CPU 说写进去了但外设读到旧值的现象。第三层是性能验证。Non-cacheable 的内存在 CPU 侧访问是明显慢于 cacheable 的用ktime_get()打点测一段 memcpy 的耗时改成 NC 之后应该能看到数量级的差异。如果耗时几乎没变那大概率属性没真正生效。我这招用过好几次比读寄存器更直观。回滚方案要在动手之前就想好。最稳的做法是把原始 PTE 值保存在驱动私有结构里模块卸载或者出错路径上原样恢复恢复时同样走完整的 BBM 序列。别指望反正重启就恢复了调试阶段一次挂死就得重启机器效率极低。6. 常见问题速查与踩坑记录下面这些是我实际调试过程中整理出来的都是那种第一次遇到会怀疑人生的类型。6.1 问题速查表现象可能原因排查方向kwalk_to_pte返回 NULL目标落在 2MB/1GB 块映射里检查pmd_leaf()考虑换内存来源改完属性立即崩忘了置 AF 位或 BBM 顺序不对打印新 PTE 值核对 bit 10 和 BBM 序列改了没效果读出来还是旧属性TLB 没刷干净或 Contiguous 位没清用local_flush_tlb_all()试探清PTE_CONT单核正常多核出错SH 位配错或缺少dsb(ish)屏障检查 SH 设置补齐屏障指令数据偶发错乱cache 顺序处理反了或存在多重映射检查 clean/invalidate 顺序排查其他映射用户态一写就异常只改了 AP[2] 没改 AP[1]确认PAGE_KERNEL与用户映射属性的差异属性改了但性能没变化修改的 PTE 不是实际生效的那个确认有没有别的虚拟地址映射到同一物理页6.2 三个最不容易发现的坑第一个坑是多重映射。这个问题我在前面提过一次但值得单独再讲。同一块物理内存在 Arm64 上很容易出现多个虚拟地址映射线性映射一个、vmalloc一个、ioremap一个、用户态再 mmap 一个。你只改了其中一个 PTE硬件在访问另外那条路径的时候用的还是旧属性。ARM 架构明确规定同一个物理地址被以不同 cache 属性映射时行为是未定义的你说不清硬件会信哪一条。所以改属性之前一定要先查清楚这块内存总共有几条映射路径。查询的方法是在/proc/vmallocinfo里搜物理地址范围在驱动的各个映射点加日志或者在调试内核里用dump_page()看引用计数。这块工作不做后面出问题几乎没法查。第二个坑是set_pte_at的副作用。有些内核版本里set_pte_at()会调用__sync_cache_and_tags()当内核开了 MTE内存标记扩展的时候这个调用会去处理标签同步。如果你改属性的内存同时也被 MTE 管着这里的行为需要额外确认。我遇到过一次开了 MTE 的内核上改属性之后 tag check 异常最后是把这段内存从 MTE 管理范围里排除才解决的。第三个坑是内核配置的影响。CONFIG_ARM64_4K_PAGES、CONFIG_ARM64_VA_BITS、CONFIG_RODATA_FULL_DEFAULT_ENABLED、CONFIG_STRICT_KERNEL_RWX、CONFIG_DEBUG_PAGEALLOC这些配置会显著改变页表的实际形态。特别是DEBUG_PAGEALLOC它会把所有内核线性映射都拆成 4KB 页不开的时候是 2MB 块。同一份代码在两个配置上表现完全不同这个必须提前确认清楚。我现在的习惯是在模块初始化时把关键配置打印出来省得事后猜。7. 调试与观测手段改页表这种事光靠printk有时候不够得有更底层的观测手段。7.1 QEMU 加 gdb 直接看内存里的页表在 QEMU 里跑 Arm64 内核的时候可以用-s -S参数让 QEMU 监听 gdb然后直接在 gdb 里读页表基址寄存器和页表内存。流程是连上 gdb读TTBR1_EL1拿到内核页表的物理基址按偏移算出各级表的位置然后x/512gx把整张表 dump 出来。这个方法的好处是不依赖内核里的任何代码能直接看到硬件看到的东西特别适合怀疑内核自己打印的信息不准的场合。实际操作中要注意物理地址和虚拟地址的转换。gdb 附加到 QEMU 的时候读内存用的地址取决于 QEMU 的地址翻译配置。用monitor命令切到物理地址空间读会更直接。具体命令序列每个 QEMU 版本的语法略有差异建议先在 QEMU 文档里确认一下xp和x的区别。7.2 在内核里做一个页表快照小工具真机调试没有 gdb 的时候我一般在模块里写一个简单的快照函数把从 PGD 到 PTE 的整条链路上每一级的表项值都打印出来格式化成人类可读的样子。重点是打印的时候把关键位拆出来标注比如static void dump_pte(unsigned long addr) { pte_t *ptep kwalk_to_pte(addr); pte_t pte; if (!ptep) { pr_info([%lx] no pte (block mapping?)\n, addr); return; } pte READ_ONCE(*ptep); pr_info([%lx] pte%016llx\n, addr, (unsigned long long)pte_val(pte)); pr_info( valid%d af%d ng%d cont%d dbm%d\n, !!pte_valid(pte), !!pte_young(pte), !!(pte_val(pte) PTE_NG), !!(pte_val(pte) PTE_CONT), !!(pte_val(pte) PTE_DBM)); pr_info( attrindx%lu pfn%llx\n, (pte_val(pte) PTE_ATTRINDX_MASK) PTE_ATTRINDX_SHIFT, (unsigned long long)(pte_val(pte) PAGE_SHIFT)); }这个函数在改属性前后各调一次两行输出并排一看就知道改没改对、改对了哪些位。比对着十六进制数字心算强太多了。还有一个技巧是配合init_on_alloc或者手动填充特征值。分配内存之后先写一个 0xAA55 之类的模式进去改完属性再读一遍看是不是同一个模式。如果读出来不一样说明 cache 处理有问题这个方法定位数据不一致类的 bug 特别快。写在最后是我自己这几年在这块的一点体会改页表属性这件事代码本身其实很短难的是我改的到底是不是硬件真正在用的那一条映射以及改的过程中有没有人在看这块内存。每次动手之前我会花比写代码多得多的时间去确认这两个问题——把映射关系理清楚把并发访问路径排掉后面的代码就是水到渠成。反过来跳过这一步直接写代码大概率会在某个不固定的时间点收到一个没有规律、没有稳定复现路径的故障。遇到过几次之后我现在宁愿多花半天做前置确认也不想再花两天去追一个偶发的数据错乱。
网站建设高端定制企业官网