news 2026/4/16 9:04:22

一文说清aarch64虚拟内存布局:用户态与内核态划分

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一文说清aarch64虚拟内存布局:用户态与内核态划分

深入理解aarch64虚拟内存布局:用户态与内核态如何共存并隔离

你有没有想过,当你在一台基于ARM的手机或服务器上运行一个简单的C程序时,操作系统是如何确保这个程序不会一不小心“踩”到内核的关键数据?又或者,为什么即使多个进程同时运行,它们各自的栈、堆和代码段彼此之间互不干扰?

这一切的背后,是虚拟内存系统在默默工作。而在现代ARM架构——也就是我们常说的aarch64(ARMv8-A 64位模式)中,这套机制设计得尤为精巧。

今天我们就来彻底讲清楚一件事:

aarch64 的虚拟地址空间到底是怎么划分的?用户态和内核态是怎么做到既共享页表又能严格隔离的?

这不是一篇泛泛而谈的概念介绍,而是一次从硬件规范到Linux实现的深度穿透。无论你是嵌入式开发者、内核爱好者,还是正在学习操作系统原理的学生,读完这篇你应该能自信地说:“我知道MMU在这个平台上到底干了什么。”


虚拟地址长什么样?不是随便分的

先抛出一个关键事实:
在标准 aarch64 架构中,每个进程看到的虚拟地址宽度通常是48位,也就是说,总共可以寻址 $2^{48}$ 字节 =256TB的虚拟空间。

但这256TB并不是全给用户的。相反,它被对称地切成了两半

  • 低 256TB(0x0000_0000_00000x00FF_FFFF_FFFF) → 用户空间
  • 高 256TB(0xFF00_0000_00000xFFFF_FFFF_FFFF) → 内核空间

中间那段从0x0100_0000_00000xFEFF_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位用于控制标志或扩展。

层级虚拟地址位功能
L0VA[47:39]全局页全局目录(PGD)
L1VA[38:30]上层页目录(PUD)
L2VA[29:21]中间页目录(PMD)
L3VA[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_tablecommit_creds函数。

🔹 vmalloc 区域

当你需要分配一大块非连续但虚拟连续的内存时(比如驱动申请缓冲区),就要用vmalloc()。它使用的页表独立于主页表树,允许跨物理页帧拼接。

注意:vmalloc()分配的内存不能用于DMA,因为它不保证物理连续。

🔹 Fix-mapped Areas

一些特殊用途的页面(如设备树 FDT、ACPI 表、IRQ栈)会被临时映射到这里。它们没有固定的物理对应关系,属于“按需绑定”的映射。


系统调用时发生了什么?一次穿越边界的旅程

现在我们来看看最关键的场景之一:用户程序发起系统调用,进入内核,然后再回来

read(fd, buf, len)为例:

  1. 应用程序执行svc #0指令(Supervisor Call)
  2. CPU 异常跳转到 EL1(内核模式),保存现场到 SPSR_EL1 和 ELR_EL1
  3. 内核开始执行系统调用处理函数(el0_sync_handlerdo_syscall_64
  4. 内核检查传入的buf是否为合法用户地址
  5. 若合法,则调用copy_from_user()将数据复制到内核缓冲区
  6. 完成 I/O 后,再用copy_to_user()把结果写回用户空间
  7. 最后执行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,或者想知道如何手动构造页表来启动一个裸机内核,欢迎留言讨论。我们可以一起深入更多实战细节。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/12 5:41:15

城通网盘直链解析:3分钟实现高速下载的终极方案

城通网盘直链解析&#xff1a;3分钟实现高速下载的终极方案 【免费下载链接】ctfileGet 获取城通网盘一次性直连地址 项目地址: https://gitcode.com/gh_mirrors/ct/ctfileGet 还在为城通网盘的下载限速而困扰吗&#xff1f;城通网盘直链解析工具为你提供完美的解决方案…

作者头像 李华
网站建设 2026/4/14 23:12:48

深蓝词库转换工具:彻底解决输入法数据迁移难题

还在为更换输入法时词库无法同步而烦恼吗&#xff1f;深蓝词库转换工具作为一款功能强大的开源免费程序&#xff0c;专门解决各类输入法之间的词库转换问题。无论您是从搜狗切换到微软拼音&#xff0c;还是从QQ拼音迁移到Rime输入法&#xff0c;这款工具都能确保您的个性化词库…

作者头像 李华
网站建设 2026/4/13 21:43:19

城通网盘直连解析工具:5分钟掌握高速下载的终极指南

城通网盘直连解析工具&#xff1a;5分钟掌握高速下载的终极指南 【免费下载链接】ctfileGet 获取城通网盘一次性直连地址 项目地址: https://gitcode.com/gh_mirrors/ct/ctfileGet 还在为城通网盘的龟速下载而烦恼吗&#xff1f;想要摆脱限速的束缚&#xff0c;轻松获取…

作者头像 李华
网站建设 2026/4/16 7:40:02

高速信号布线等长控制技巧:pcb布线规则设计核心要点

高速信号布线等长控制实战指南&#xff1a;从原理到落地的完整设计逻辑在高速PCB设计中&#xff0c;你有没有遇到过这样的问题&#xff1f;系统上电后DDR频繁校准失败&#xff0c;眼图紧闭&#xff1b;PCIe链路始终无法训练到Gen3速率&#xff1b;USB 3.0传输动不动就丢包……而…

作者头像 李华
网站建设 2026/4/12 15:09:06

智能仓储进化史㉟ | 全书完结:从1950到2035,技术的目的到底是什么?

导语 大家好&#xff0c;我是社长&#xff0c;老K。专注分享智能制造和智能仓储物流等内容。 新书《智能物流系统构成与技术实践》 新书《智能仓储项目出海-英语手册》 新书《智能仓储自动化项目&#xff1a;避坑手册》 新书《智能仓储项目实施指南&#xff1a;甲方必读》 5.7 …

作者头像 李华
网站建设 2026/4/14 16:45:18

XXMI启动器:游戏模组管理新体验,告别繁琐操作

XXMI启动器&#xff1a;游戏模组管理新体验&#xff0c;告别繁琐操作 【免费下载链接】XXMI-Launcher Modding platform for GI, HSR, WW and ZZZ 项目地址: https://gitcode.com/gh_mirrors/xx/XXMI-Launcher 你是否曾经为了给心爱的游戏安装模组而头疼不已&#xff1f…

作者头像 李华