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()
建议按以下顺序检查:
- 核对实际访问的地址。预留代码使用
area->addr,测试描述使用v->addr。确认二者指向同一块区域,且写入偏移都是预期值。 - 检查全部返回值。
get_vm_area_fn()、alloc_page()和每次map_one_page()都可能失败。当前映射循环忽略了返回值,无法确认 1024 次映射是否全部完成。 - 验证实际页表。对有效的 vmalloc 区域地址调用
vmalloc_to_page(),检查每个虚拟页是否对应frames[i % 4]。尤其要确认偏移0和4 * PAGE_SIZE都对应frames[0]。不要用仅适用于其他地址类型的virt_to_phys()来验证这块区域。vmalloc_to_page()的遍历方式可见 Linux 内核源码。 - 再检查读写生成的指令。使用
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() 会在一次调用中完成预留和映射。
备注:内容仅供参考。
