资讯动态

Linux 64位进程地址空间完全指南:布局、机制与排查实践

发布时间:2026/10/1 3:39:28 来源:尧图企业网站定制
1. 先从一次崩溃定位说起为什么要搞懂地址空间大概两年前我接手一个在x86-64服务器上运行的服务最近频繁出现段错误。日志里只有一行Segmentation fault连core dump都缺一半靠gdb挂上去复现bt一看栈帧全乱寄存器里的rip和rsp根本不在同一个合理区域。当时我第一反应是内存被踩了但单纯靠逻辑检查代码效率太低。最后真正帮我定位到问题的是对进程虚拟地址空间的理解——栈异常向下增长到了未映射区而调用链里的一个局部数组恰好写越界把栈上的返回地址给覆盖了。那件事之后我养成了一个习惯每接手一个不熟悉的程序先看一眼它的地址空间分布再动手。这件事听起来很简单但很多开发者在32位时代总结的经验拿到64位系统上就不完全适用了。比如32位下用户空间通常只有3GB堆和栈之间空间狭窄而64位下用户空间有128TB的跨度实际可使用的部分不是全部线性空间分布规律完全不同排查问题时的心理预期也得跟着变。本文想分享的就是这份对“Linux 64位进程地址空间分布概况”的理解。我会从为什么需要关注虚拟地址空间开始讲到64位和32位的本质区别再一层层拆解用户态地址空间中的代码段、数据段、堆、内存映射区和栈是怎么布局的接着聊随机化ASLR和PIE对布局的影响最后通过/proc/pid/maps实测来落实这些理论并附带一些我在实际项目中踩过的坑。适合对Linux底层感兴趣、或者已经在做C/C/Go服务端开发但还不太清楚虚拟地址空间长什么样的同学看完之后你再打开maps文件就不会觉得是一串乱码了。2. 64位与32位地址空间的根本区别从3G/1G分裂点到128T边界2.1 同样的“空间”完全不同的玩法32位Linux下进程看到的虚拟地址空间是4GB内核通常把低3GB给用户态高1GB给内核态所以进程的代码、数据、堆、栈全部挤在0x00000000到0xC0000000这3GB里当然这只是经典布局还要考虑mmap_min_addr、ASLR等因素。这种布局下堆和栈加起来总共只有3GB容量任何一个区域增长过快都会撞上对方或撞上中间的映射区。64位系统则完全不同。x86-64处理器提供64位地址线但实际实现中当前民用Linux内核通常使用“目前可用”的48位虚拟地址或开启5级页表后支持57位。Linux默认把虚拟地址空间从中间劈开低半部分给用户态高半部分给内核态。在常见的48位分法下用户态空间范围是0x0000000000000000 - 0x00007fffffffffff也就是总共128TB的虚拟地址空间。别小看这128TB这比传统32位能用的3GB大了四万多倍。很多在32位下必须抠抠搜搜的场景到64位下直接“空间自由”。但空间大了也会带来新问题比如指针变长8字节、结构体对齐变大、线程栈默认尺寸受限等因素实际内存占用和性能特性都不一样。2.2 中间那条“不为地址”的鸿沟non-canonical区域上面我说到“128TB边界”很多读者可能疑惑0x0000800000000000到0xffff800000000000之间是什么为什么不能直接用这就必须提一下x86-64的canonical address规范地址概念。x86-64架构规定如果地址的有效位是48位那么从第48位到第63位符号位扩展必须全部等于第47位否则是一个non-canonical地址。只有满足这个规则的地址才是处理器认为“合法”的虚拟地址。如果程序尝试访问non-canonical地址CPU会直接抛#GP异常表现为进程收到SIGSEGV或者SIGBUS。实际项目里偶尔看到的怪异崩溃地址如0x1234567890abcdef可能就是这种非法地址。理解这一点你能更好地理解为什么Linux内核把用户区和内核区分别放在虚拟地址空间的两端中间留出巨大的不可访问区域。这不是浪费而是架构设计使然。顺便说一句随着内存容量变大5-level paging已经进入桌面Linux把有效地址从48位扩展到57位用户空间也从128TB变成64PB。本文主要讨论常规48位下的内核配置但原则是通用的。2.3 内核空间与用户空间的分界线在64位Linux中通常用户空间从0x0000000000000000到0x00007fffffffffff而内核空间从0xffff800000000000往上不同内核版本可能略有差异例如使用4-level paging和KASLR时_text符号对应地址通常落在ffffffff81000000附近。用户态程序是无法直接访问内核空间的任何越界访问都会触发保护异常。这种分裂带来的直接后果是如果你在64位系统上把一个指针强制转换成unsigned long然后打印会看到用户态地址都是金灿灿的0x7f...开头小数值一般很小而如果你在内核模块里打印指针地址往往会以0xffff...开头。这也是判断一个地址是用户态还是内核态的一种简单经验。3. 用户态地址空间的经典布局逐块拆解3.1 低地址区的“门面”代码段、只读数据和数据段抛开内核与用户空间的分界只看用户态地址空间从低地址往上第一块是ELF可执行文件映射进来的内容。以非PIE程序为例代码段通常被加载到0x400000附近这可以追溯到System V ABI about x86-64。我们来看一个典型的布局片段0x00400000-0x00450000 r-xp /path/to/program 0x00650000-0x00680000 r--p /path/to/program 0x00680000-0x00700000 rw-p /path/to/program第一段是只读可执行代码.text第二段通常是只读数据和重定位表.rodata、.rela.*等第三段是可读写数据段.data、.bss。在PIE程序里这些段的基址会变成一个随机值通常落在0x555555554000附近这是我在Ubuntu 20.04上最常见到的基址如0x555555554000-0x555555559000 r-xp /path/to/pie_program 0x555555759000-0x55555575a000 r--p /path/to/pie_program 0x55555575a000-0x55555575d000 rw-p /path/to/pie_program注意这里的地址是“装载地址”并不是ELF文件里的相对偏移。运行时属性受ELF的program headers控制你可以在文件里看到LOAD段的vaddr、offset、flags但真正的虚拟地址还需要加上加载基址。3.2 堆heap向上生长的“泥巴地”紧接着数据段的上方是堆区域。传统实现里堆通过brk/sbrk系统调用扩展brk指向程序堆的当前末尾。堆的增长方向是从低地址到高地址也就是说分配内存时堆向地址增大方向扩展。在/proc/pid/maps里堆区域通常以[heap]标注权限是rw-p起始地址与数据段的结尾之间可能隔着一小段匿名映射这是为了对齐和保护。堆的大小不是固定的。每次调用malloc申请小块内存时glibc如果判断当前堆顶空间够用就直接从堆区切一块返回如果不够会调用brk扩展堆顶。如果堆扩展到某个临界值mmap会接管分配一块独立的内存映射段。所以普通程序大量分配几MB、几十MB时你会看到地址空间的[heap]范围增长而当你申请大块内存默认超过128KB可通过M_MMAP_THRESHOLD调整映射区里会出现一块匿名映射而不是全进堆。堆也有“天花板”因为它在用户空间的低地址区域通常远离栈但在32位下堆和栈是相向而行的所以空间有限。在64位下堆在天花板之前有足够空间一般很少因为堆撞栈而失败反而是物理内存不足或测试环境限制虚拟内存大小导致malloc失败更常见。3.3 内存映射区共享库、大块内存和线程栈的“群租房”内存映射区mmap区域在用户空间中通常位于堆和栈之间但并不是紧贴着堆。它的起始位置受mmap_base控制这个值在内核里根据栈大小、随机化偏移等计算得出一般在0x7f...高地址附近但比栈空间低。我们常见到的地址如0x7f2f4e000000一类的都是映射区动态创建的映射。映射区用于几类用途共享库.so的代码段、数据段映射mmap申请的匿名内存分配大块内存线程栈pthread创建线程时线程栈用mmap分配shared memory文件映射动态链接器的映射ld.sovDSO后面讲。映射区的特点是每个Mapping在maps文件里一行有起始地址、结束地址、权限、偏移、设备号、inode和路径。没有路径名、inode为0的通常是匿名内存。这些区域之间可能有随机空洞但整体来看共享库的映射往往集中在0x7f0000000000附近从低地址往高地址增长。比如一个典型进程7f2f4e000000-7f2f4e05a000 r-xp /lib/x86_64-linux-gnu/libc.so.6 7f2f4e05a000-7f2f4e1f2000 r--p /lib/x86_64-linux-gnu/libc.so.6 7f2f4e1f2000-7f2f4e1f6000 rw-p /lib/x86_64-linux-gnu/libc.so.6这里展示了libc.so.6的三个段映射地址连续彼此差距由文件的页对齐决定。共享库加载顺序也受环境变量、二进制依赖顺序和ASLR影响但整体趋势就是从某个基础开始向上分配。3.4 栈stack向下生长的“弹簧床”内存映射区再往上就是栈。栈的初始位置由内核在execve时设定通常是一个接近地址空间顶部的值。在x86-64 Linux上进程主栈的顶部通常是0x7ffffffff000附近或者因为ASLR在某个随机偏移的附近。栈向下更低地址增长rsp寄存器指向当前栈顶。/proc/pid/maps里的[stack]项表示主线程栈区域。例如7ffd9a2a0000-7ffd9a2c1000 rw-p [stack]主栈的大小默认一般受到ulimit -s限制常见是8MBulimit -s 8192。不要认为[stack]范围就是已使用的栈用量它表示内核允许栈增长的最大范围。如果rsp下降到超过[stack]所允许的下界会触发栈溢出保护机制通常是page fault然后是SIGSEGV。线程栈与主栈不同是pthread_create时调用mmap以匿名映射方式分配出来的通常大小由RLIMIT_STACK和线程属性决定。这些线程栈会出现在maps文件中但没有单独的名字只能通过权限和大小推断。线程默认栈大小通常为8MB在maps里可以看到一块rw-p匿名映射。3.5 夹在中间的vDSO和vsyscall在高地址区域还有一个不起眼但影响性能的小东西vDSOvirtual Dynamic Shared Object。linux内核会映射一个很小的虚拟共享库到用户空间比如linux-vdso.so.1让gettimeofday、clock_gettime等系统调用在用户态直接完成避免陷入内核。它的映射地址随机化通常在[stack]附近如7ffd9a2c1000-7ffd9a2c3000 r-xp [vdso]在旧版内核上还有一个vsyscall固定页0xffffffffff600000由于安全考虑现在很多发行版默认只兼容vDSOvsyscall页的用途已经边缘化。不过在/proc/pid/maps里有时仍能看到[vsyscall]或vsyscall固定地址项这本身不影响普通开发但如果你做安全研究或调试反汇编需要知道它们的存在。4. 栈、堆、映射区在实际运行时是怎么动态变化的4.1 栈的增长到底是怎样的过程很多初学者以为栈是预先分配好8MB实际不是。主栈在进程启动时只映射了少量页范围可能只有几页到几十页。当程序往栈里写数据、函数调用层层嵌套时CPU访问到低于当前映射区域的地址会触发page fault。内核在expand_stack中检查如果新的地址还在栈的最大允许范围内RLIMIT_STACK就映射新页面如果超出就会向进程发送SIGSEGV。所以[stack]在maps里显示的范围是内核在进程经历了最大栈深度之后扩展到的“高水位线”。如果你看到一个程序启动时[stack]范围很小然后经过递归或深层调用后范围变大这是正常的。也可以从[stack]的下界与rsp的距离判断栈离耗尽有多远。这个信息对分析栈溢出非常有帮助。4.2 malloc的内存到底从哪来brk和mmap的博弈从开发者的角度malloc返回的地址分布是很直观的“地址空间直觉”来源。小内存申请例如几十字节时glibc从主堆区切块返回的地址在0x55...、0x60...附近取决于程序是否为PIE而当你申请超大内存比如malloc(1024*1024*500)时glibc会直接使用mmap分配一个匿名映射返回的地址就变成了0x7f...开头。你可以用一个简单C程序测试#include stdio.h #include stdlib.h void *x malloc(4); void *y malloc(1024 * 1024 * 500); printf(small: %p\n, x); printf(large: %p\n, y);在我的机器上输出类似small: 0x5578b3b512a0 large: 0x7f3dac000010前者落在堆区[heap]或邻近后者落在内存映射区。这种区别在处理内存泄漏、优化内存池、判断虚拟内存消耗时非常关键。很多tools比如pmap也是基于这个原理来分类展示内存的。4.3 线程栈和共享库往哪放当程序创建线程时pthread_create内部其实会调用mmap分配线程栈默认大小可以从pthread_attr_getstacksize查看通常是8MB。这些线程栈的地址很容易出现在0x7f...高的映射区相互之间间隔着随机空隙。每个线程栈都有自己的guard page不可访问的页用于检测栈溢出。共享库加载的顺序由内核加载ELF时调用的动态链接器决定。动态链接器先加载程序依赖的DT_NEEDED库然后初始化。库的映射地址在每次运行时都会变因为ASLR会随机化mmap_base而每个库内部还会保持相对距离。如果你对某个库的某个符号在进程内的实际地址感兴趣可以用dladdr或gdb info proc mappings来查。5. 打开ASLR之后随机化如何改变分布以及如何观察5.1 什么是ASLR内核是怎么做的ASLRAddress Space Layout Randomization是现代操作系统对抗内存攻击的基础防御机制。Linux通过randomize_va_space控制该参数位于/proc/sys/kernel/randomize_va_space常见值0关闭随机化1随机化mmap基址、栈基址但不随机化堆2在1的基础上增加堆的随机化默认当你看到每次运行同一个程序打印的变量和malloc地址都不同这就是ASLR在起作用。内核在load_elf_binary阶段根据进程personality和配置计算随机偏移量将mmap区域的基址、栈的起始位置、堆的起始位置等全部“打乱”。但要注意ASLR的随机范围是有限制的不可能改变整个128TB的绝对值通常只在某个窗口内做随机化例如栈顶地址在0x7ffffffff000附近浮动几MB。5.2 PIE程序和非PIE程序的基址差异除了ASLR还要看二进制本身是否启用了PIEPosition Independent Executable。PIE程序在加载时也需要重定位它的基址会被ASLR随机化到0x555555554000附近这是我在多数发行版上见到的规律。非PIE程序传统ET_EXEC固定加载在0x400000或者某个由链接器脚本指定的低地址所以它的代码段地址不带随机性。怎么判断用readelf -h看elf header里的Type字段Type: DYN (Position-Independent Executable file)如果是DYN就是PIE很多现代发行版默认都开PIE如果是EXEC就是传统可执行文件。对于安全审计来说这是重要线索非PIE程序即使开了ASLR代码段基址也是固定的攻击者利用代码段漏洞时相对容易“找位置”。5.3 怎么临时关闭ASLR做调试有时候ASLR会让调试变得困难比如你在gdb里看到某地址下次运行就变了导致无法通过固定断点调试。你可以用以下方式临时关闭setarch uname -m -R ./your_program或者echo 0 /proc/sys/kernel/randomize_va_space第二种需要root权限而且会影响整个系统不建议在生产环境操作。用setarch -R只对单次运行生效适合在开发环境重现崩溃现场。注意关闭ASLR后程序每次运行地址都一致但这也降低了你对真实环境下偶发异常的还原度最好只用来对照分析不要作为常态。6. 用 /proc/ /maps 真实解读一列一列拆开看6.1 一个实际进程的maps长什么样纸上谈兵不如跑一次。假设我运行一个简单的C程序并查看它的/proc/pid/maps输出大致如下我手动整理了一部分00400000-00401000 r-xp 00000000 08:01 131073 /tmp/mytest 00401000-00402000 r--p 00000000 08:01 131073 /tmp/mytest 00402000-00403000 rw-p 00001000 08:01 131073 /tmp/mytest 7f0c2e000000-7f0c2e02a000 r-xp 00000000 08:01 196638 /usr/lib64/libc.so.6 7f0c2e02a000-7f0c2e1e1000 r--p 0002a000 08:01 196638 /usr/lib64/libc.so.6 7f0c2e1e1000-7f0c2e1e5000 rw-p 001e0000 08:01 196638 /usr/lib64/libc.so.6 7f0c2e3a0000-7f0c2e3a9000 rw-p 00000000 00:00 0 7f0c2e500000-7f0c2e524000 r-xp 00000000 08:01 196639 /usr/lib64/ld-linux-x86-64.so.2 7f0c2e720000-7f0c2e724000 rw-p 00000000 00:00 0 7ffd22e00000-7ffd22e21000 rw-p 00000000 00:00 0 [stack] 7ffd22c00000-7ffd22c02000 r-xp 00000000 00:00 0 [vdso] ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]逐列解读第一列虚拟地址范围起始和结尾地址都是页对齐的一页通常是4096字节所以地址末尾一般是三个0例外是vsyscall特殊页。第二列权限。r读、w写、x执行、p私有private、s共享shared。注意权限是VM权限与文件系统权限不完全一样。共享库数据段通常有rw-p因为动态链接后是私有写时复制。第三列文件偏移量。比如00000000表示从文件头开始映射00001000表示映射的是文件偏移4KB处的内容。这是调试时还原ELF某个位置有没有被修改的关键。第四列和第五列设备号major:minor和inode。如果映射来自文件这里显示文件所在设备的编号和inode如果是匿名映射通常是00:00 0。第六列路径名。可以是文件路径、[heap]、[stack]、[vdso]、[vsyscall]或者空匿名映射。这一列能直接看出每块区域属于什么角色。6.2 怎么从maps里快速推断一个地址属于什么区域调试时最常问的问题是当前rip或某个指针在什么区域最简单的方式是用gdb的info proc mappings或者直接在/proc/pid/maps中查找包含该地址的行。比如rip指向0x7f0c2e010000查上表可知它在libc.so.6的r-xp段说明代码正在libc里执行可能是系统调用后返回也可能是被攻击者劫持到了libc里的gadget。如果你希望程序自己在运行时判断一块地址是不是栈、堆或mmap区域可以解析/proc/self/maps遍历每一行检查地址是否落在某个范围内。很多内存分析工具如jemalloc的profiler、各种sanitizer都依赖这个机制。虽然自己解析maps有点原始但在没有工具的环境下是最直接的方式。6.3 为什么第一块匿名映射经常出现在libc和ld之间细心的读者可能注意到上面maps里在libc的后面有一块没有路径的匿名映射7f0c2e3a0000-7f0c2e3a9000 rw-p 00000000 00:00 0这里通常是glibc为arenamalloc线程池、守护页、动态链接器数据等创建的匿名内存。这类小段匿名映射因为不关联文件所以dev和inode都为0。它们可能在每次运行时位置都不同不过相对位置往往和附近的库映射保持一定关联。在分析内存占用时不能只看有路径的映射这些无名映射有时才是内存大户比如glibc的arena、线程栈、malloc大块。7. 实际开发中与地址空间相关的坑和排查经验7.1 一个真实的栈溢出排查案例回到开头的故事。当时我们通过查看崩溃进程的/proc/pid/maps发现进程的[stack]范围是0x7ffd25700000-0x7ffd25900000也就是2MB大小默认8MB但被ulimit调小了而core dump里rsp是0x7ffd256ffff0刚好比[stack]的下界低16字节。根据x86-64调用约定函数入口会先把返回地址压栈如果rsp落在下界之外说明栈已经被榨干了或者更准确地说栈指针因为某种原因越过了允许范围。后续通过gdb在崩溃点查看调用栈发现函数里有一个局部数组char buf[1024]在循环里被写入了超过1024字节的数据覆盖了数组之外的栈空间包括返回地址。返回地址被改成某个无效地址后函数返回时rip变成垃圾值程序就崩了。这个问题的Root cause和内存分布的关系在于如果没有理解[stack]的下界我们可能在检查时忽略“栈越界”这个方向而陷入“堆踩内存”的错误假设。7.2 ulimit -s 对地址空间的连锁影响在很多线上环境运维为了“管理内存”会把ulimit -s设置成很小比如ulimit -s 10241MB。这会让主栈允许范围变小。如果你程序本身就有较深的递归或大局部变量即使没有bug也可能在合法逻辑下触发栈溢出。此时看maps会发现[stack]范围只有1MBrsp已经接近下界。排查时不要只怀疑代码问题还要检查运行时限制ulimit -s如果确实太小可以考虑用ulimit -s 8192调大或者在代码里把大对象放到堆上malloc或static。当然生产环境调整ulimit需要评估线程数量、内存开销毕竟线程栈默认也会受RLIMIT_STACK影响但现代glibc线程栈往往通过mmap分配和主栈的RLIMIT_STACK关系不绝对具体版本有差异。7.3 mmap失败虚拟地址空间不是无限的64位用户空间虽然有128TB但依然可能被耗尽。耗尽的原因通常不是物理内存而是虚拟地址空间碎片化、进程自身的映射数限制vm.max_map_count或者mmap区域被太多线程栈占用。举个例子一个服务开了一万个线程每个线程栈8MB光是线程栈就需要80GB左右的虚拟地址空间加上其他映射很容易在一个受限的容器环境里逼近mmap区域上限。vm.max_map_count默认是65530如果映射数量超过它mmap会返回ENOMEM程序表现为pthread_create失败或malloc返回NULL。排查这类问题查看/proc/pid/maps的行数大概可以判断映射数量wc -l /proc/pid/maps如果行数接近vm.max_map_count基本可以确认是映射数量问题。调大vm.max_map_count能缓解但根本方案是减少线程数量或调整线程栈大小比如ulimit -s配合pthread_attr_setstacksize。7.4 调试器和vDSO的相爱相杀如果你在gdb中下断点到某个系统调用函数的地址有时会发现断点设置在[vdso]区域但这个区域是只读可执行的且每次进程启动地址都变。gdb实际处理时会把断点替换成int 3写入vdso页这需要ptrace权限和页属性支持。在某些安全设置下对vdso页写断点可能会触发异常。遇到这种情况不要慌可以改用catch syscall或直接对libc中的函数下断点避免和vdso纠缠。7.5 理解地址空间对安全定位的意义无论是分析coredump还是排查恶意进程地址空间分布都是第一手线索。举例来说如果一个进程maps里出现非预期的rwx内存段且路径为空或指向/tmp大概率有问题。正常编译器的数据段通常是rw-p一个合法的可执行文件基本不会映射rwx除非是JIT或特殊技术。通过maps快速找到这类异常区域可以缩小排查范围。这也是为什么很多安全工具第一步就是枚举/proc/pid/maps。再补充一个实用技巧在线排查时直接查看/proc/pid/smaps它比maps多了每段内存的RSS、PSS、共享/私有页统计对分析内存泄漏很有帮助。虽然字段很长但只要结合maps的布局来读就能知道是哪一块区域的RSS在异常上涨。最后的复盘我在实践中得到的最大体会是Linux 64位进程地址空间虽然大但绝不是一片混沌而是有一套清晰的规则。代码段、数据段、堆、映射区、栈各居其位堆向上、栈向下、mmap在中间随机游走vDSO和vsyscall扮演着小却重要的角色。理解了这套规则你在调试segment fault、看core dump、分析malloc返回地址、判断一个指针是不是野指针时手感和之前完全不一样。还有一个小技巧值得分享写一段几十行的C程序在几个关键点打印变量地址、malloc地址再对照/proc/self/maps观察自己动手跑一遍胜过背十遍布局图。如果你经常和底层打交道建议把常用的maps解析工具脚本固化下来比如用awk按路径名分组统计某区域内映射的总大小这对接手陌生服务时做快速内存画像非常有帮助。地址空间这件事看起来是个理论题真正用起来才知道它是排查问题的一把速效钥匙。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑