PAYATHON 2026

AArch64 Linux 内核中如何将多个虚拟页映射到相同物理页?

数据小陈

可以实现这种别名映射。对于普通 RAM,把重复的 struct page * 填入数组,再交给 vmap(),就能让 1024 个连续虚拟页循环映射到 4 个物理页。

现有打印结果还不能证明映射失败。页表操作需要核查,普通 C 读写也可能被编译器优化,导致打印时没有重新读取内存。由于缺少完整测试代码、目标内核版本和页表检查结果,目前无法确定唯一原因。

使用 vmap() 建立循环映射

vmap() 根据页指针数组建立连续的内核虚拟映射。数组中的页指针可以重复,重复的位置会映射到同一个物理页。它会预留虚拟地址并建立映射,因此不需要先调用 get_vm_area_fn()。可参照 Linux v6.12 的 vmap 实现。

下面的演示函数可以放入模块,从允许睡眠的进程上下文调用,但不包含模块注册代码。示例先检查全部 1024 个虚拟页对应的物理页,再测试两个别名地址的读写;仍需在目标内核上编译验证。

#include <linux/compiler.h>
#include <linux/errno.h>
#include <linux/kernel.h>
#include <linux/mm.h>
#include <linux/slab.h>
#include <linux/vmalloc.h>

static int alias_demo(void)
{
    struct page *frames[4] = { NULL };
    struct page **pages;
    unsigned char *base;
    unsigned int a, b;
    int i;
    int ret = -ENOMEM;

    pages = kmalloc_array(1024, sizeof(*pages), GFP_KERNEL);
    if (!pages)
        return -ENOMEM;

    for (i = 0; i < 4; i++) {
        frames[i] = alloc_page(GFP_KERNEL);
        if (!frames[i])
            goto out;
    }

    for (i = 0; i < 1024; i++)
        pages[i] = frames[i % 4];

    base = vmap(pages, 1024, VM_MAP, PAGE_KERNEL);
    if (!base)
        goto out;

    for (i = 0; i < 1024; i++) {
        if (vmalloc_to_page(base + i * PAGE_SIZE) != frames[i % 4]) {
            ret = -EIO;
            goto unmap;
        }
    }

    WRITE_ONCE(base[0], 0x11);
    WRITE_ONCE(base[4 * PAGE_SIZE], 0x22);

    a = READ_ONCE(base[0]);
    b = READ_ONCE(base[4 * PAGE_SIZE]);
    pr_info("alias values: %#x, %#x\n", a, b);
    ret = (a == 0x22 && b == 0x22) ? 0 : -EIO;

unmap:
    vunmap(base);
out:
    for (i = 0; i < 4; i++) {
        if (frames[i])
            __free_page(frames[i]);
    }
    kfree(pages);
    return ret;
}

在映射正确、属性一致且没有并发写入的条件下,上述两次读取都应得到 0x22。这只是预期结果,尚未在你的环境中验证。

第 i 个虚拟页对应 frames[i % 4]。每组中的 4 个页分别对应 4 个不同的物理页,相邻各组的对应位置共享内存。

为什么普通打印可能误判别名映射

考虑以下测试:

base[0] = 0x11;
base[4 * PAGE_SIZE] = 0x22;
pr_info("%u %u\n", base[0], base[4 * PAGE_SIZE]);

编译器可能根据两个数组下标不同,推断第二次赋值没有修改 base[0],然后直接把先前保存的值传给 pr_info()。硬件页表建立的物理别名关系,不会自动成为编译器已知的别名关系。

因此,验证时应使用上例中的 WRITE_ONCE() 和 READ_ONCE(),避免相关访问被省略或用已知值代替。Linux 的内存屏障文档说明了这些编译器优化和访问宏的作用。

这些宏用于约束访问,不能修复错误的页表,也不能替代多 CPU 之间所需的同步。如果页表检查正确,改用这些宏后读写结果也恢复预期,才有依据进一步检查原测试是否受到编译器优化的影响。

如何排查现有 map_one_page()

建议按以下顺序检查:

  1. 核对实际访问的地址。预留代码使用 area->addr,测试描述使用 v->addr。确认二者指向同一块区域,且写入偏移都是预期值。
  2. 检查全部返回值。get_vm_area_fn()、alloc_page() 和每次 map_one_page() 都可能失败。当前映射循环忽略了返回值,无法确认 1024 次映射是否全部完成。
  3. 验证实际页表。对有效的 vmalloc 区域地址调用 vmalloc_to_page(),检查每个虚拟页是否对应 frames[i % 4]。尤其要确认偏移 0 和 4 * PAGE_SIZE 都对应 frames[0]。不要用仅适用于其他地址类型的 virt_to_phys() 来验证这块区域。vmalloc_to_page() 的遍历方式可见 Linux 内核源码。
  4. 再检查读写生成的指令。使用 READ_ONCE() 和 WRITE_ONCE() 重测;如果结果仍异常,再结合目标内核源码检查页表发布、TLB 和映射属性。

手动操作页表时,还有几个问题需要审查。pgd_none(*pgd) 表示该层条目为空,并不等于物理页分配已经失败;直接返回 -ENOMEM 可能提前结束映射。各层页表是否折叠、何时需要分配,也取决于内核配置,不能把内部 __*_alloc 函数当作通用模块接口直接拼接。

在 set_pte_at() 后调用 flush_tlb_kernel_range(),也不足以证明整个映射流程完整。应核对目标架构对页表更新的同步要求,以及内核映射辅助函数执行的维护操作。内核对 flush_cache_vmap() 等接口的职责有专门说明,见 Cache and TLB Flushing 文档。

不能仅凭代码里没有显式调用 flush_cache_vmap(),就认定这是 AArch64 上的故障原因。不同架构的实现不同;例如 Linux v6.12 的 arm64 缓存代码明确以 non-aliasing VIPT D-cache 为实现前提,见 arm64 cacheflush.h。

映射属性和释放方式

对于这里通过 alloc_page() 获得的普通 RAM,使用 PAGE_KERNEL 建立普通数据映射,并保持所有别名的内存类型、缓存性和共享属性一致。不要为了绕过现象,随意把同一物理页的某个别名改成 Device 或不同缓存属性。

示例使用普通 VM_MAP,由调用方持有物理页。释放时先停止所有访问,调用 vunmap(),再分别释放 4 个物理页和页指针数组。同一个物理页虽然被重复映射 256 次,但只需释放一次。示例也没有使用会转移页引用及数组所有权的 VM_MAP_PUT_PAGES;相关语义见 Linux 内存管理 API 文档。

1024 个虚拟页只需要这 4 个后备数据页,但地址管理结构、页表和指针数组仍会占用额外内存。如果必须保留“先预留地址、稍后填充映射”的设计,需要依据目标内核提供的接口完成这两个阶段;本例的 vmap() 会在一次调用中完成预留和映射。

备注:内容仅供参考。