ARM64 进程虚拟地址空间布局,这个题目听起来像教科书章节,但在实际开发里,它直接决定了你为什么要选 arm64 版本下载、为什么一个进程会 SIGSEGV、为什么 ASLR 会让你调试时头大。我最早在 ARM64 开发板上排查崩溃问题时,第一件事就是去看 /proc/<pid>/maps,看完才意识到,地址空间布局这个东西不是死记硬背的理论,而是每个底层开发者的“地图”。这篇文章就把这张地图摊开讲清楚。
无论你是做嵌入式 Linux、Android 逆向、性能优化,还是纯粹好奇操作系统是怎么管理进程内存的,这篇内容都能给你一个可落地的认知框架。我会从 ARM64 的地址宽度讲起,逐步拆解用户态进程的内存区域,再深入到内核管理、页表机制,最后用一段实操代码带你亲手验证。看完之后,你再看到 0x7f...、0xaaaa...、0xffff... 这类地址,就不会再犯迷糊了。
1. 为什么 ARM64 进程地址空间值得专门讲
1.1 从“download for windows amd64 和 arm64 的区别”说开去
很多人第一次接触 ARM64,是在下载软件时看到两个安装包:一个写着 amd64,一个写着 arm64。这俩的区别不只是指令集不同,连进程能看到的虚拟地址空间在细节上都不一样。amd64 就是 x86_64,Intel 和 AMD 那套 64 位架构;arm64 就是 AArch64,ARMv8 时代开始支持的 64 位指令集。它们都叫 64 位,但操作系统在上面布置进程地址空间的方式有明显差异。
而这种差异,直接决定了同一个程序在不同平台上的崩溃行为、内存占用、调试方式都不一样。比如在 x86_64 上你很习惯看到的 PIE 程序基址 0x555555554000,在 ARM64 上往往会变成 0x000000aaaaaab000 附近。如果拿着 x86 上的经验去 ARM64 上找人,很容易翻车。
1.2 搞懂地址空间能解决哪些实际问题
地址空间布局不是一个“知道就行”的知识点,它是排查问题的地图。我实际遇到的几类问题,最后都落到地址空间上:
- 程序崩溃在
0x0附近,明显是空指针解引用,为什么内核直接给 SIGSEGV?因为那一页压根没映射。 - 程序
mmap一大块内存返回ENOMEM,代码看着没错,但虚拟地址空间窗口不够,或者vm.max_map_count满了。 - 用 gdb 调试时每次运行的地址都不一样,怀疑是不是编译器出了问题,其实是 ASLR 在起作用。
- Android 或嵌入式设备上看到某个 so 的加载地址异常,需要判断是不是被注入或者 mapped 到了奇怪的位置。
这些问题的共同前提,都是你得先知道一个正常的 ARM64 进程地址空间长什么样。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ARM64 虚拟地址空间的总框架
2.1 VA_BITS 与 TASK_SIZE:64 位地址不是全都用
ARMv8 架构说支持 64 位虚拟地址,但现实世界中,没有哪个系统会把完整的 64 位地址空间全部暴露给软件。64 位地址空间是 16EB(Exabyte)级别,太大且没有实际意义,反而会让页表膨胀到无法接受。所以 ARM64 Linux 默认只使用 48 位虚拟地址,这个位数通过内核配置 CONFIG_ARM64_VA_BITS 决定,设备启动时根据 TCR_EL1 寄存器里的 T0SZ / T1SZ 字段动态确认。
在 48 位模式下,用户空间的上限是 TASK_SIZE_64 = 1UL << 48,也就是 256TB,范围从 0x0000000000000000 到 0x0000ffffffffffff。如果硬件支持 ARMv8.2 的 Large Virtual Address 特性,可以配置到 52 位,用户空间变成 4PB,但普通开发基本遇不到。内核空间则使用最高的 256TB,从 0xffff000000000000 到 0xffffffffffffffff。中间还剩一大段地址,是未定义的“空洞”,访问必然 fault。
你可能会问:既然是 48 位,为什么实际进程 maps 里的用户地址很少看到 0x0000ffff... 这么高?因为 Linux 在 arm64 上还加了一层“默认映射窗口”限制。新进程的 mmap 分配器默认只在低 47 位范围内找地址,也就是 0x0000000000000000 到 0x00007fffffffffff,共 128TB。所以你在 /proc/pid/maps 里看到的用户态地址,最高一般就是 0x7f... 开头,栈顶接近 0x7ffffffff000。这是为了在完整 48 位空间和 TLB 性能之间做平衡,也兼容那些对高位地址有假设的软件。
提示:如果程序确实需要映射超出默认窗口的高地址,可以用
mmap搭配MAP_FIXED_NOREPLACE显式指定地址,或者修改内核相关配置。但日常开发中几乎不用,了解即可。
2.2 用户态与内核态:TTBR0 和 TTBR1 的分工
ARM64 和 x86_64 在地址空间隔离上有一个很显著的区别:x86 通过 CR3 切换整个页表,而 ARM64 在硬件层面就把地址空间分成了两半,分别由两个页表基址寄存器管理。
TTBR0_EL1:负责低地址区域,也就是用户空间。TTBR1_EL1:负责高地址区域,也就是内核空间。
每个用户进程切换时,只需要换掉 TTBR0_EL1 指向的页表,TTBR1_EL1 指向的内核页表保持不变。也就是说,内核地址空间的映射是所有进程共享的,切换进程的时候不用重灌内核页表,TLB 里内核部分的条目也基本不用失效。这个设计是 ARM64 在进程切换开销上比很多架构做得好的原因之一。
同时,每个进程还有一个 ASID(Address Space Identifier),用于在 TLB 中区分不同进程的地址映射。这样就算两个进程在用户空间有相同的虚拟地址,TLB 也能靠 ASID 区分,避免频繁 flush。
2.3 ARM64 与 x86_64 的布局对照
很多开发者的第一套概念是 x86_64 的。我整理了一个简单的对照表,方便快速理解两者差异:
| 项目 | x86_64(4级页表) | ARM64(4K页 + 48位VA) |
|---|---|---|
| 常用用户空间上限 | 0x00007fffffffffff(128TB) | 默认映射窗口 0x00007fffffffffff,完整上限 0x0000ffffffffffff |
| 内核空间起点 | 0xffff800000000000 附近 | 0xffff000000000000 附近 |
| PIE 程序典型基址 | 0x555555554000 | 0x000000aaaaaab000 附近 |
| 动态库/匿名映射区域 | 0x7f... 段 | 0x7f... 段 |
| 栈顶 | 0x7ffffffff000 附近 | 0x7ffffffff000 附近 |
从这个表也能看出来,ARM64 用户空间的上限比 x86_64 大,但默认窗口一致。所以很多程序在两个平台上跑出来的 maps 观感很像,真正的差异隐藏在 PIE 基址和内核地址映射上。
3. 用户态进程的标准内存布局
3.1 从高地址到低地址逐块拆解
一个 64 位 ARM64 Linux 用户进程,虚拟地址空间从低到高大致可以分成这么几段:
- 代码段(Text):存放 ELF 可执行文件的机器指令,权限通常是
r-xp。 - 数据段(Data / BSS):存放已初始化全局变量、未初始化全局变量,权限通常是
rw-p。 - 堆(Heap):通过
brk系统调用向上增长,malloc小内存优先用这里。 - 内存映射区(mmap region):从高地址向低地址增长,动态库、
mmap匿名内存、dlopen的 so 都在这里。 - 栈(Stack):从高地址向下增长,向下扩展受
RLIMIT_STACK控制。 - 辅助信息页:比如
[vvar]、[vdso]、[vsyscall]这类内核映射给用户态的特殊页,通常贴在栈附近。
为什么动态库和栈放在高地址,而不是紧挨着代码段?这是为了给堆留出连续向上的增长空间,同时让 mmap 区域从高到底分配,避免和堆撞在一起。早期 32 位系统里堆和 mmap 区域位置不对,导致碎片化严重,64 位时代设计上就刻意把它们分开了。
3.2 可执行文件、动态库和 vdso 的落点
在 ARM64 上,一个默认开启 PIE(Position Independent Executable)的程序,代码段基址通常在 0x000000aaaaaab000 附近。这个值不是随便定的,它来自内核的 ELF_ET_DYN_BASE 宏,用 2 * TASK_SIZE_64 / 3 计算,对齐页边界之后,天然就落在 0xaaa... 区域。如果你用 -no-pie 编译,代码段就固定在 0x400000。
动态库(比如 libc.so)一般加载在 0x7f... 开头的 mmap 区域。我之前在一台 ARM64 板子上看一个很普通的 C 程序,libc-2.31.so 的代码段就在 0x0000007f801b6000 附近。这个地址和栈顶相关,mmap 分配器从栈底往下找空洞。
[vdso] 是一个特别有意思的段,它是内核映射到用户态的一个小共享库,提供 gettimeofday、clock_gettime 等系统调用的快速版本,不需要切到内核态。它的大小只有几 KB,权限通常 r-xp,地址紧挨着栈。你在 maps 里看到 [vdso] 不要惊慌,这是正常的。
3.3 ASLR 随机化给地址观感带来的影响
Linux 默认开启 ASLR,/proc/sys/kernel/randomize_va_space 的值通常是 2。这会导致每次运行程序,栈基址、mmap 基址、PIE 程序基址都会在一定范围内随机偏移。ARM64 同样遵守这个机制。
随机化的具体范围取决于内核的 ARCH_MMAP_RND_BITS。栈顶地址会在 0x7ffffffff000 下面一段范围内浮动,mmap 基址也会在 0x7f... 段内变化。这在安全上是好事,能增加漏洞利用的难度,但在日常调试时会造成困扰。如果你需要稳定复现某个地址相关的问题,可以临时关闭 ASLR:
bash复制echo 0 > /proc/sys/kernel/randomize_va_space
关闭后再次运行程序,地址就会固定下来。注意这需要 root 权限,调试完记得改回 2。
3.4 一份真实的 /proc/pid/maps 解读
纸上谈兵没意思,我拿一份典型 ARM64 进程的 maps 片段来拆解。假设程序用 PIE 编译,正常运行后 /proc/<pid>/maps 大概长这样:
code复制0000000000400000-0000000000401000 r-xp ... a.out # 非PIE情况下的代码段,本文按PIE讲解
000000aaaaaab000-000000aaaaaac000 r-xp ... /home/user/a.out
000000aaaaaac000-000000aaaaaad000 r--p ... /home/user/a.out
000000aaaaaad000-000000aaaaaae000 rw-p ... /home/user/a.out
000000aaaaaae000-000000aaaaab0000 rw-p ... [heap]
0000007f80000000-0000007f801b6000 r-xp ... /lib/aarch64-linux-gnu/libc-2.31.so
0000007f801b6000-0000007f803b0000 ---p ... /lib/aarch64-linux-gnu/libc-2.31.so
0000007f803b0000-0000007f803b4000 r--p ... /lib/aarch64-linux-gnu/libc-2.31.so
0000007f803b4000-0000007f803b6000 rw-p ... /lib/aarch64-linux-gnu/libc-2.31.so
0000007f803c0000-0000007f803e0000 r-xp ... /lib/ld-linux-aarch64.so.1
0000007f803e0000-0000007f803e8000 r--p ... /lib/ld-linux-aarch64.so.1
0000007f803e8000-0000007f803ea000 rw-p ... /lib/ld-linux-aarch64.so.1
0000007ffffd9000-0000007ffffde000 r--p ... [vvar]
0000007ffffde000-0000007ffffdf000 r-xp ... [vdso]
0000007ffffe0000-0000007fffffe000 rw-p ... [stack]
每行格式是“起始地址-结束地址 权限 文件偏移 主设备:次设备 inode 路径”。权限里 r 读、w 写、x 执行、p 私有、s 共享。[heap] 表示堆,[stack] 表示栈,[vdso] 表示内核辅助页。
注意 libc 的映射分了好几段:第一段是代码,权限 r-xp;中间有一段 ---p,这是为了把代码段和数据段隔开,起到隔离和保护作用;后面是只读数据和可写数据。这种分段方式来自 ELF 的 PT_LOAD 段权限不同,mmap 时按页对齐后自然分成几个 vma。
4. 内核是如何管理这份地址空间的
4.1 mm_struct 与 vm_area_struct
在内核里,每个进程的地址空间由一个 mm_struct 描述,它挂在 task_struct->mm 上。mm_struct 里记录着代码段、数据段、堆、栈的起始地址,还有页表基址 pgd,以及一个管理所有虚拟内存区域的红黑树。
每个连续的、权限一致的虚拟内存区域叫一个 vm_area_struct,简称 vma。我们的 /proc/pid/maps 每一行基本就对应一个 vma。vma 记录了这个区域的起始地址、结束地址、权限、关联的文件、私有数据、以及 mmap 操作函数集。内核通过红黑树快速查找“某个虚拟地址落在哪个 vma 里”,缺页异常时会根据 vma 的类型决定怎么处理。
你可能会发现一个普通程序有几十个 vma,这很正常。每个动态库的代码段、数据段是一个 vma,堆、栈、mmap 的匿名内存也各是一个 vma。共享库越多,vma 越多,这也是为什么 vm.max_map_count 会被大量 dlopen 的程序耗尽。
4.2 四级页表与 TLB / ASID
ARM64 在 4K 页 + 48 位虚拟地址下,使用四级页表:PGD、PUD、PMD、PTE。每一级用虚拟地址中的 9 位做索引,最后 12 位是页内偏移。算一下正好配平:9×4 + 12 = 48。如果启用 52 位虚拟地址,或者改用 64K 页,页表级数会相应变化,但原理不变。
页表是树状结构,一个进程的虚拟地址空间再大,没映射的区域不会分配页表项,这让 256TB 的地址空间不会瞬间吃光内存。你访问一个没有映射的地址,MMU 查不到对应的页表项,就会触发 translation fault,内核再根据地址是否合法决定是发 SIGSEGV 还是其他处理。
TLB 缓存了最近用过的虚拟地址到物理地址的映射,ARM64 用 ASID 区分不同进程的 TLB 条目。进程切换时,只要 ASID 不同,TLB 里其他进程的条目不会误用。内核空间由于用的是 TTBR1_EL1 并且全局共享,所以切换进程时内核 TLB 基本不用失效,这是性能上的一个优势。
4.3 权限位与不可执行页
ARM64 的页表项里除了物理地址,还包含很多权限位。例如:
XN(Execute-Never):该页不可执行,用于防止栈和堆上的数据被执行。USER:标记该页是否允许用户态访问。AP[2:1]:读写权限控制。AF:访问标志,硬件在访问时置位,内核可用它做回收统计。
为什么现在很多漏洞利用都强调“JOP/ROP”?因为 NX(不可执行)机制基本普及,栈上准备一段 shellcode 直接执行已经行不通了,攻击者只能通过拼接已有指令片段(gadget)来绕过。ARM64 同样默认开启栈不可执行,这也解释了为什么你在 maps 里看到的栈区权限是 rw-p,而不是 rwxp。
5. 实操:从零查看一个 ARM64 进程的完整内存布局
5.1 编译一个能打印自身地址的小程序
理论讲完,必须亲手验证一遍才有感觉。我在 ARM64 环境(树莓派或任何 AArch64 Linux 系统,也可以 qemu-aarch64 模拟)上写一个最小的 C 程序:
c复制#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
int global_var = 42;
int main(void) {
void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
printf("PID: %d\n", getpid());
printf("&global_var: %p\n", &global_var);
printf("malloc: %p\n", malloc(128));
printf("mmap: %p\n", p);
printf("&p: %p\n", &p);
sleep(60);
return 0;
}
编译命令:
bash复制gcc -o mem_layout mem_layout.c
如果需要交叉编译,在 x86 机器上可以装 gcc-aarch64-linux-gnu,然后:
bash复制aarch64-linux-gnu-gcc -o mem_layout mem_layout.c
5.2 用 /proc/pid/maps 抓取现场
在 ARM64 设备上运行程序,从输出里先找到 PID,然后在另一个终端执行:
bash复制cat /proc/<PID>/maps
你把输出和程序打印的地址对照一下,就能看到 global_var 的地址落在数据段里,malloc 返回的地址落在堆区域附近,mmap 返回的地址落在 0x7f... 区域,局部变量 &p 落在栈区域。这种“程序自己打印 + 内核视角 maps”互相对照的方式,是理解地址空间最快的方法。
5.3 关闭 ASLR 验证随机化效果
正常运行时,你连续按几次程序,会发现 mmap 和栈地址每次都不同。为了确认是 ASLR 作用,可以用 root 关掉它:
bash复制echo 0 > /proc/sys/kernel/randomize_va_space
再运行程序,多看几次,地址基本固定不变。看完记得恢复:
bash复制echo 2 > /proc/sys/kernel/randomize_va_space
这一步能直观告诉你 ASLR 到底改变了什么。如果你在调试过程中发现某个崩溃问题只在随机化开启时出现,关闭后消失,那基本可以怀疑是程序对绝对地址做了不安全的假设。
5.4 用 gdb 验证地址与符号对应关系
对开发者来说,光看 maps 还不够,我们经常需要把地址转成符号。比如程序崩溃在 0x000000aaaaaac123,怎么知道是哪个函数?
用 gdb 很简单:
bash复制gdb -p <PID>
(gdb) info proc mappings
(gdb) disassemble 0x000000aaaaaac123
也可以直接静态分析 ELF:
bash复制addr2line -e mem_layout -f 0x000000aaaaaac123
配合 nm -n mem_layout 看符号表,就能把崩溃地址映射到代码行。很多时候,gdb 的 backtrace 地址看起来“很奇怪”,其实只要先确认它落在哪个 vma、对应哪个文件,问题就解开了一半。
6. 常见问题与排查技巧实录
6.1 空指针/低地址访问的 SIGSEGV
崩溃地址为 0x0 或 0x10000 附近时,原因是进程空间低地址区域没有映射。ARM64 内核里有一个 mmap_min_addr 参数,默认是 4096 或 65536,低于这个地址的内存无法映射,这是防止内核空指针利用的一种缓解机制。查看方式:
bash复制sysctl vm.mmap_min_addr
如果某些老旧程序非要映射低地址,可以调低该值,但强烈不建议在生产环境这么做。处理空指针崩溃的正确思路是排查代码里有没有把无效指针当有效地址用,而不是想办法让低地址能被映射。
6.2 虚拟地址空间不够导致的映射失败
mmap 返回 ENOMEM,常见原因有两个:
- 单个映射超过了地址空间大小限制。默认映射窗口是 128TB,一次映射几十 TB 的虚拟内存虽然理论可行,但很多环境会受
RLIMIT_AS约束。 - 系统限制了 vma 数量。
vm.max_map_count默认通常在 65530,如果程序大量mmap或者动态加载海量 so,超过这个值就会失败。
排查时先看:
bash复制cat /proc/sys/vm/max_map_count
ulimit -v
再数一下当前进程的 vma 数量:
bash复制cat /proc/<PID>/maps | wc -l
如果接近上限,用 sysctl -w vm.max_map_count=... 调大,但还是要审视程序是否真的需要这么多映射段。
6.3 32 位兼容进程的布局差异
ARM64 内核支持运行 AArch32 的 32 位程序,此时进程地址空间最高 4GB。TASK_SIZE_32 是 0x100000000,mmap 区域和栈都压缩在 32 位地址里。所以同一个“64 位系统”,32 位版本的堆栈地址看起来和 x86 时代的程序类似,和 64 位程序完全不同。
调试时要注意区分:file 命令可以确认 ELF 是 32 位还是 64 位:
bash复制file /path/to/binary
如果是 ELF 32-bit LSB executable, ARM, EABI5,那就按 32 位地址空间去理解 maps,别拿 64 位布局硬套。
6.4 日常调试时的高效习惯
我个人的习惯是,接手一个不熟悉的程序时,先跑一遍 /proc/<PID>/maps 和 pmap <PID>,把地址空间里有哪些库、哪些段、权限怎么样摸清楚。遇到崩溃,先用 dmesg 看内核打印的 fault address,再对照 maps 确认这个地址落在哪个文件、哪个段,判断是越界还是一般的空指针。
这个流程看起来简单,但能过滤掉大量无效排查。之前有同事在一个 ARM64 设备上反复遇到段错误,最后发现是某个裁剪过的 Linux 内核把默认 vm.max_map_count 调得过低,程序加载几十个 so 就把 vma 耗尽了,根本不是代码越界。如果在崩溃前先看 vma 数量,问题几分钟就能定位。
最后分享一个小技巧:Android 设备上也能用类似方法。没有 root 的机器可以用 cat /proc/self/maps,或者在受控环境里用 run-as 读取应用私有目录。移动端跑的是 Linux 内核,地址空间布局和服务器上的 ARM64 Linux 原理一致,只是库路径变成了 /apex/...、/system/lib64/...,但那些 0x7f...、`0xaaaa
