深入理解aarch64虚拟内存布局:用户态与内核态如何共存并隔离
你有没有想过,当你在一台基于ARM的手机或服务器上运行一个简单的C程序时,操作系统是如何确保这个程序不会一不小心“踩”到内核的关键数据?又或者,为什么即使多个进程同时运行,它们各自的栈、堆和代码段彼此之间互不干扰?
这一切的背后,是虚拟内存系统在默默工作。而在现代ARM架构——也就是我们常说的aarch64(ARMv8-A 64位模式)中,这套机制设计得尤为精巧。
今天我们就来彻底讲清楚一件事:
aarch64 的虚拟地址空间到底是怎么划分的?用户态和内核态是怎么做到既共享页表又能严格隔离的?
这不是一篇泛泛而谈的概念介绍,而是一次从硬件规范到Linux实现的深度穿透。无论你是嵌入式开发者、内核爱好者,还是正在学习操作系统原理的学生,读完这篇你应该能自信地说:“我知道MMU在这个平台上到底干了什么。”
虚拟地址长什么样?不是随便分的
先抛出一个关键事实:
在标准 aarch64 架构中,每个进程看到的虚拟地址宽度通常是48位,也就是说,总共可以寻址 $2^{48}$ 字节 =256TB的虚拟空间。
但这256TB并不是全给用户的。相反,它被对称地切成了两半:
- 低 256TB(
0x0000_0000_0000到0x00FF_FFFF_FFFF) → 用户空间 - 高 256TB(
0xFF00_0000_0000到0xFFFF_FFFF_FFFF) → 内核空间
中间那段从0x0100_0000_0000到0xFEFF_FFFF_FFFF的巨大区域?禁止访问。任何试图访问这里的操作都会触发地址异常(Alignment Fault 或 Translation Fault)。
这种结构被称为“对称高低映射”(Symmetric Low/High User and Kernel Mapping),是 ARMv8-A 架构原生支持的一种经典布局。
为什么这么设计?
我们不妨对比一下 x86-64 的传统做法:早期 Linux 使用的是低端内核映射(比如前1GB留给内核),剩下的给用户。这带来一个问题——攻击者很容易猜测内核地址,发起像ret2dir这类利用页表本身的攻击。
而 aarch64 直接把内核扔到高地址去,并且中间留个“深渊”,等于告诉所有用户程序:“别乱跳指针,掉下去就死。”
更妙的是,由于所有进程都使用相同的高端内核映射视图,切换进程时不需要刷新整个页表,只需要换一下用户部分的根目录即可。这对性能是个极大的优化。
地址翻译靠什么?四级页表 + MMU 自动导航
当 CPU 执行一条加载指令,比如ldr x0, [x1],其中x1存的是一个虚拟地址,那么真正去内存拿数据之前,必须先把虚拟地址转成物理地址。
这个任务由MMU(Memory Management Unit)完成,它依赖一套多级页表结构进行查找。
在默认 4KB 页面大小下,aarch64 使用四级页表(L0 ~ L3),每一级用9位索引(因为 $2^9=512$ 项),剩下7位用于控制标志或扩展。
| 层级 | 虚拟地址位 | 功能 |
|---|---|---|
| L0 | VA[47:39] | 全局页全局目录(PGD) |
| L1 | VA[38:30] | 上层页目录(PUD) |
| L2 | VA[29:21] | 中间页目录(PMD) |
| L3 | VA[20:12] | 页表项(PTE),指向具体页帧 |
每级页表项是一个 64 位的描述符,包含以下关键信息:
- 物理页号(PFN)
- 是否有效(Valid)
- 访问权限(AP:只读/可写)
- 执行禁止位(XN:eXecute Never)
- 缓存属性(Normal/Device 等)
- 类型(是页还是下一级表)
整个过程就像查字典:
CPU 拿着虚拟地址,从 TTBRx_EL1 寄存器拿到第一级页表基址,然后一步步往下找,直到得到最终的物理地址。
如果某一级找不到条目,就会触发页错误异常(Page Fault),交由内核的do_page_fault()处理——可能是缺页分配,也可能是非法访问报错。
用户空间长啥样?不只是 text 和 stack
虽然用户空间最大可达 256TB,但实际使用的远小于这个值。典型的 aarch64 Linux 进程内存布局如下(从低到高):
高位(↑) +---------------------+ | Stack | ← 向下增长 +---------------------+ | ↑ | | | | | Heap & MMAP | ← 动态分配区 | ↓ | | ↓ | +---------------------+ | Shared Libraries | (如 libc.so) +---------------------+ | Dynamic Data | (.data, .bss) +---------------------+ | Program Text (Code) | (.text段) +---------------------+ | Runtime Loader | (ld.so) +---------------------+ | Arguments & | | Environment | +---------------------+ | VDSO | +---------------------+ 低位(↓)我们逐个来看这些区域的作用:
📍.text段:代码存放地
存放编译后的机器指令,默认只读+可执行(除非开了 W^X 安全策略)。受 PIE(Position Independent Executable)影响,其加载地址会随机偏移,增强 ASLR 效果。
📍.data/.bss:全局变量区
.data:已初始化的静态变量.bss:未初始化或清零的变量,节省磁盘空间
📍 堆(Heap)
通过brk()或sbrk()扩展,malloc()在背后调用这些系统调用。向上增长,适合小对象动态分配。
📍 mmap 区域
用于大块内存申请(如mmap(MAP_ANONYMOUS))、文件映射、共享内存等。灵活但管理复杂。
📍 栈(Stack)
每个线程有自己的栈,函数调用、局部变量都在这里。向下增长,典型大小为 8MB(可通过ulimit -s修改)。
📍 VDSO(Virtual Dynamic Shared Object)
这是个聪明的设计:将某些高频系统调用(如gettimeofday,clock_gettime)直接映射进用户空间,避免陷入内核态。本质上是内核提供的一段只读代码页,在启动时自动映射。
你可以用下面这个命令看看自己进程的布局:
cat /proc/self/maps输出类似这样:
5500000000-5500001000 r-xp ... ./a.out # .text 5500001000-5500002000 rw-p ... ./a.out # .data/.bss ... 7fc0000000-7fc0080000 rw-p ... [stack] # 主线程栈 ... 7fff000000-7fff001000 r-xp ... [vdso] # VDSO 映射内核空间:所有进程共享的“后台世界”
如果说用户空间是前台舞台,那内核空间就是后台机房——所有演员(进程)共用同一套灯光音响设备(内核服务)。
在 aarch64 上,内核空间起始于0xFFFF_0000_0000(对应 48 位 VA),同样覆盖约 256TB,但它不是空的,而是精心组织成多个逻辑区域:
高位地址(↑) +------------------------+ | Fix-mapped Areas | (KVM、IRQ临时映射) +------------------------+ | vmalloc area | ← 非连续虚拟内存 +------------------------+ | Module Alloc | (ko模块加载区) +------------------------+ | Kernel Image | ← _text ~ _end | (Linear mapping) | +------------------------+ | Physical RAM Map | ← 直接映射区(lowmem) | (lowmem) | +------------------------+ | PCI I/O Space | (设备内存映射) +------------------------+ | Highmem (optional)| (超出direct map上限) +------------------------+🔹 直接映射区(Physical RAM Mapping)
这是最核心的部分。物理内存被简单地加上一个固定偏移(PAGE_OFFSET)映射过去。例如:
// arch/arm64/include/asm/memory.h #define PAGE_OFFSET (UL(0xFFFF_0000_0000))所以如果你知道某个物理地址phys_addr,它的虚拟地址就是phys_addr + PAGE_OFFSET。这部分常驻 TLB,访问非常快。
🔹 Kernel Image 映射
内核镜像本身(包括代码段、rodata、init段等)也被映射进来。通常位于直接映射区内偏上的位置。
为了防止攻击,启用 KASLR(Kernel Address Space Layout Randomization)后,这个镜像会在 ±2GB 范围内随机加载,让攻击者难以定位sys_call_table或commit_creds函数。
🔹 vmalloc 区域
当你需要分配一大块非连续但虚拟连续的内存时(比如驱动申请缓冲区),就要用vmalloc()。它使用的页表独立于主页表树,允许跨物理页帧拼接。
注意:vmalloc()分配的内存不能用于DMA,因为它不保证物理连续。
🔹 Fix-mapped Areas
一些特殊用途的页面(如设备树 FDT、ACPI 表、IRQ栈)会被临时映射到这里。它们没有固定的物理对应关系,属于“按需绑定”的映射。
系统调用时发生了什么?一次穿越边界的旅程
现在我们来看看最关键的场景之一:用户程序发起系统调用,进入内核,然后再回来。
以read(fd, buf, len)为例:
- 应用程序执行
svc #0指令(Supervisor Call) - CPU 异常跳转到 EL1(内核模式),保存现场到 SPSR_EL1 和 ELR_EL1
- 内核开始执行系统调用处理函数(
el0_sync_handler→do_syscall_64) - 内核检查传入的
buf是否为合法用户地址 - 若合法,则调用
copy_from_user()将数据复制到内核缓冲区 - 完成 I/O 后,再用
copy_to_user()把结果写回用户空间 - 最后执行
eret返回用户态
整个过程中有一个重要细节:页表没有切换!
因为用户空间和内核空间本来就在同一个地址空间里,只是权限不同。内核只需要确保当前页表中的高端区域已经正确映射,并开启相应的访问权限即可。
这也意味着,TLB 中缓存的内核页表项在整个系统运行期间几乎不会失效,极大提升了上下文切换效率。
如何提升安全性?XN、UXN、ASID 全上阵
aarch64 不只是一个性能优先的架构,它在安全设计上也非常用心。以下是几个关键防护机制:
✅ XN(eXecute Never)位
标记某一页是否可执行。用户堆和栈通常设置 XN=1,防止 shellcode 注入。.text段则允许执行。
✅ UXN(Unprivileged XN)
专门针对非特权级(EL0)的执行限制。即使内核页设置了可执行,用户也不能跳进去跑代码。
✅ PXN(Privileged XN)
反过来,防止内核意外执行用户提供的代码页(防御 ret2usr 攻击)
✅ ASID(Address Space Identifier)
传统 x86 切换进程要换 CR3(页表基址),导致 TLB 全刷。而 aarch64 支持给每个进程分配一个唯一的 ASID(最多 256 或 65536 个),TLB 条目带上 ASID 标签后,就可以区分不同进程的同名虚拟地址。
结果就是:进程切换不再需要全局 TLB 刷新,性能飙升。
现代内核广泛使用TTBR0_EL1存储用户页表基址,TTBR1_EL1存储内核页表基址。两者配合 ASID,构成了高效的多任务内存管理基础。
实战建议:开发与调试技巧
理解了这套机制之后,我们在实际开发中应该注意些什么?
💡 1. 避免泄露内核地址
不要轻易打印指针,尤其是用%p或%px输出未知来源的地址。攻击者可能借此推断内核布局。
推荐使用%px替代%pK(后者已被弃用),并启用kptr_restrict=2。
💡 2. 合理选择内存分配方式
- 小对象 →
kmalloc()/malloc() - 大块连续内存 →
__get_free_pages()(注意不可超过 MAX_ORDER) - 大块非连续 →
vmalloc() - 文件映射 →
mmap()
优先使用mmap()替代堆扩展,减少碎片。
💡 3. 关注 TLB 行为
频繁创建销毁进程可能导致 ASID 耗尽,从而引发全局 TLB 刷新(ASID wraparound)。可以通过启用CONFIG_ARM64_SW_TLB_MMID来缓解。
另外,使用ipiv_tlb(Per-ASID TLB invalidation)特性可在特定情况下仅刷新指定 ASID 的 TLB 条目。
💡 4. 调试工具推荐
- 查看进程内存分布:
cat /proc/<pid>/maps - 观察页表状态:
cat /proc/<pid>/pagetable(需开启CONFIG_X86_PTDUMP类似选项) - 分析内存访问延迟:
perf mem record+perf report - 检查 KASLR 偏移:
dmesg | grep "kaslr"
写在最后:为什么这套设计如此重要?
随着 AWS Graviton、Ampere Altra、华为鲲鹏等 ARM 服务器芯片在云计算领域的普及,以及苹果 M 系列芯片引领桌面计算转型,aarch64 已不再是“移动端专属”。
在这种背景下,掌握其底层运行机制,特别是虚拟内存模型,已经成为系统工程师的一项基本功。
这套对称高低映射 + 四级页表 + ASID + 权限分级的设计,不仅保障了系统的安全性(防越权、防注入),还极大提升了性能(少刷新、高命中),并且具备良好的可扩展性(支持 52 位地址、超大内存系统)。
未来随着 ARM SVE、CXL、PAN、MTE 等新技术引入,虚拟内存子系统还将继续演进。但万变不离其宗——地址隔离 + 高效转换 + 精细控制,永远是核心目标。
如果你正在做内核移植、性能调优、漏洞分析,甚至只是想搞懂fork()之后父子进程内存为何独立……希望这篇文章给了你足够的底气。
如果你在实践中遇到过奇怪的 page fault,或者想知道如何手动构造页表来启动一个裸机内核,欢迎留言讨论。我们可以一起深入更多实战细节。