
进程地址空间这六个字我这些年面试讲了不下几十遍每次都能讲出一身汗——不是怕讲错是这东西一旦真跑起来比教科书上那张图复杂得多。你大概率也遇到过这种情况程序跑得好好的突然一个段错误Segmentation fault日志里只有一个类似segfault at 0x7ffc3a8f0abc的地址。这个地址为什么长这样它落在栈上、堆上还是哪个犄角旮旯如果你曾经对着这种地址发过呆那这篇内容就是写给你的。我会从 Linux 进程地址空间的基本布局讲起再带你把/proc/pid/maps、pmap、gdb这些实际工具挨个用一遍中间穿插一些我在生产环境和面试里踩过的坑。1. 先搞清“进程地址空间”到底是个什么容器1.1 从一次段错误聊起先说个真实场景。几年前我在调一个后台服务跑压力测试的时候进程突然崩了。看日志只有一行segfault at 00007f8c2a41f000 ip ...偏移地址乱七八糟。当时我第一反应是查代码里哪里越界查了半天没头绪后来静下心来看了一眼/var/log/messages里的segfault详细信息再对了一下dmesg给的地址范围才发现问题出在一个第三方库用mmap映射了一块内存我们业务代码在释放之后又去访问了一次访问地址正好落在那块已经 unmap 的区域里。从那以后我就养成了习惯任何内存相关的疑难杂症先把进程的地址空间地图打开再谈代码逻辑。这里的“地址空间”你可以理解成操作系统给每个进程发的一张独立的虚拟内存地图。每个进程都以为自己拥有一整块连续的内存从0x0000000000000000一直到用户空间的上限实际上这些地址绝大多数只是“虚拟的”背后对应物理内存全靠内核的页表来翻译。对进程本身来说它不管物理内存在哪、够不够用它只知道自己有一片地址可以随便编排。Linux 内核里每个进程都对应一个mm_struct结构里面装着这个进程所有内存区域的 VMAVirtual Memory Area链表——这些 VMA 也是我们要看的内存地图的核心。进程地址空间里头不是清一色可读写的普通内存。代码段、数据段、堆、栈、共享库、内存映射文件、线程栈各有各的权限和来源。你在用户态写的任意一个地址最终都能落到这张地图的某个区块里。如果你对地图本身没有概念后面排查段错误或者分析性能问题就像不看城市地图就开车导航全靠瞎蒙。1.2 典型布局用户态和内核态的高低位以 x86-64 为例Linux 把整个虚拟地址空间劈成两半低位的用户空间高位的内核空间。用户空间通常占低 47 位地址范围约0x0000000000000000到0x00007fffffffffff内核空间从大概0xffff800000000000开始。进程自己在用户态能操作的就是低地址那一片看着挺大但真正被各段瓜分之后每一块都有自己的“势力范围”。我列一个典型的 64 位 Linux 进程启用 PIE 编译布局读者可以对照自己机器上的实际输出来看区域典型位置权限主要作用保留区低地址一小段如 0x0000 附近不可访问捕获空指针防止 NULL 解引用直接乱写ELF 代码段0x55... 开头的随机基址附近r-x存放机器指令ELF 数据段代码段后偏移一段rw-已初始化的全局/静态变量BSS 段数据段附近rw-未初始化全局/静态变量不占磁盘文件堆BSS 之后向上增长rw-malloc 小对象的主力区域共享库映射0x7f... 区域r-x / rw-libc、libm、动态链接器等mmap 区域0x7f... 到 0x7ff... 之间视情况大块 malloc、共享内存、文件映射栈0x7ffc... 附近向下增长rw-函数调用帧、局部变量vdso/vsyscall高位固定区域r-xp内核暴露给用户态的部分系统调用入口加速注意这里的地址不是死的。现代 Linux 默认开了 ASLR地址空间布局随机化每次运行程序栈、库、堆、mmap 区的基址都会变代码段如果编译成 PIEPosition Independent Executable它的起始位置也会随机变化。网上有些老文章画一张固定地址的图比如“栈在 0xbfffffff”那是 32 位时代老内核、还没开随机化的旧约定放在今天的 64 位系统上基本对不上号。你如果照着去比对cat /proc/self/maps的输出多半要蒙圈。早期很多嵌入式开发者也习惯看 ARM 32 位下的经典布局代码段从0x00008000附近开始栈在0xbeffffff往下长。这套“自底向上代码→数据→堆自顶向下栈”的骨架放到今天仍然成立只是位置被随机化了骨架本身没有变。2. 三个观察工具把内存地图摊开看2.1 /proc/ /maps 每一列都不是白给的Linux 最直白的内存地图就是/proc/pid/maps。随便打开一个进程看例如cat /proc/self/maps输出的每一行就是一个 VMA。我简化一下典型行555555554000-555555555000 r-xp 00002000 fd:01 1234567 /usr/bin/cat 7ffff7a00000-7ffff7bc0000 r-xp 00000000 fd:01 7654321 /usr/lib/x86_64-linux-gnu/libc.so.6 7ffc5a2e6000-7ffc5a307000 rw-p 00000000 00:00 0 [stack]每一行六个字段大部分人只盯第一列地址区间其实后面几列信息量很大555555554000-555555555000VMA 的起始地址到结束地址。r-xp权限位。顺序固定是 r、w、x第四位是私有/共享标记p表示 privates表示 shared。代码段通常是 r-x数据段是 rw-。00002000文件偏移。表示这段映射对应文件里的什么位置。如果是匿名映射不基于文件这里是 0。fd:01设备号。00:00表示匿名内存。1234567inode 编号。文件映射才有匿名映射是 0。/usr/bin/cat对应文件路径。[stack]是栈[heap]是堆[vdso]、[vvar]是内核虚拟动态共享库。这个文件是你在 Linux 下排查内存问题时第一个要打开的“底图”。我曾经在一台 64G 内存的机器上看一个 Java 进程的 maps发现它堆外映射了一堆 1GB 的pthread相关区域再配合 smaps 一算才知道是 JVM 的堆外分配没控制住。没有 maps这种问题排查起来就像无头苍蝇。/proc/pid/maps本身还带一个升级版/proc/pid/smaps。smaps 会在每个 VMA 下面列出Size、Rss、Pss、Shared_Clean、Private_Dirty等更细的指标。想知道某块内存真正占了多少物理内存smaps的Rss和Pss比maps靠谱得多。尤其是 Pss它把共享页按进程数平分能更真实反映一个进程吃内存的情况。2.2 pmap 与 gdb 的交叉验证pmap是 util-linux 自带的命令本质上是把/proc/pid/maps做了解读汇总。直接跑pmap pid -x能看到每个区域是谁的、权限什么、RSS 多少最后还会给一行 total。它在排查“哪个库占了多少匿名内存”这种问题时特别好用。我一般会把它和/proc/pid/smaps一起用pmap 看整体概览smaps 往细里挖。调试器也自带地图阅读能力。在 gdb 里挂上进程后执行info proc mappings效果和 pmap 类似配合x/20gx 地址可以直接看指定虚拟地址的内容。如果再结合readelf -l 可执行文件查看 ELF 的 LOAD 段你就能把“文件里的段”和“进程内存里的 VMA”对上号。比如我用 readelf 看到某个 LOAD 段文件偏移是0x2000、虚拟地址是0x400000那它在 maps 里显示的 offset 也会是0x2000这就是我前面强调的文件偏移字段的用处。2.3 自己打印一份真实的内存地图看别人的地图不如自己打一张。我给你一段非常简单的 C 代码它会把各类变量的地址打出来然后打印自己的 maps#include stdio.h #include stdlib.h #include string.h #include sys/mman.h int global_data 42; int global_bss; int main(void) { static int static_var 1; int stack_local 2; char *heap_ptr malloc(4096); char *mmap_ptr mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); printf(code : %p\n, (void *)main); printf(global_data: %p\n, (void *)global_data); printf(global_bss : %p\n, (void *)global_bss); printf(static_var : %p\n, (void *)static_var); printf(stack_local: %p\n, (void *)stack_local); printf(heap_ptr : %p\n, (void *)heap_ptr); printf(mmap_ptr : %p\n, (void *)mmap_ptr); FILE *fp fopen(/proc/self/maps, r); char line[512]; while (fgets(line, sizeof(line), fp)) fputs(line, stdout); fclose(fp); free(heap_ptr); munmap(mmap_ptr, 4096); return 0; }编译命令gcc -o memmap_demo memmap_demo.c ./memmap_demo跑完之后你会看到栈变量地址最大mmap 分配的内存其次堆指针再往下全局变量和代码段相对靠前。printf 打出来的地址和 maps 文件里的区间能一一对应上。如果你连着跑两遍会发现栈、mmap、堆的地址每次都不一样这就是 ASLR 在起作用。用这份输出去对照我前面第一部分的布局表比死背地址区间有效得多。3. 栈和堆为什么一个往下走一个往上走3.1 栈为什么“反着长”第一次学进程地址空间的人十有八九会问为什么栈要从高地址往低地址长堆却要从低地址往高地址长这个方向的怪异感来源于 CPU 硬件设计本身。x86 架构的调用指令CALL会把返回地址压栈PUSH会让 RSP 寄存器减小函数返回时再恢复RSP 增加。CPU 硬件选择栈向低地址增长于是软件层面的调用栈自然也是从高地址往低地址推进。这算是一个历史包袱也是今天 System V ABI 的一部分。ARM 和 RISC-V 虽然寄存器设计不同但调用栈通常也沿用了向下增长的方式主要是为了保持工具链和调试器约定的一致性。在 Linux 上栈不是一次性把所有地址都映射好、分配好物理页的。它是一段受RLIMIT_STACK限制的区间初始只映射一小部分随着函数调用变深栈指针往低地址压内核通过缺页异常自动按需扩展栈区。所以你看到/proc/pid/maps里的[stack]起始和结束地址中间往往是一块很大的跨度但 RSS 很小就是因为大部分还只是“可用的虚拟地址”并没有实际消耗物理内存。3.2 栈溢出的“温柔”与残酷无限递归、超大局部数组都会让栈不断往低地址延伸。它一路长一路触发缺页一直长到超出RLIMIT_STACK规定的上限或者撞上栈区域下方的保护页内核就不再帮你扩展了而是直接给进程发一个SIGSEGV。有意思的是这种情况下进程通常不是“调用第 N 层时”就立刻崩而是可能在你看似完全没有关系的一行挂掉——因为栈已经顶到保护页边缘程序只要再做一次小小的压栈缺页处理失败信号就来了。排查栈溢出时我通常先看崩溃地址是不是落在栈区间附近再用ulimit -s看栈大小限制配合 gdb 的bt看调用深度。如果你在 gdb 里看到类似“栈指针远低于[stack]起始地址”的诡异现象基本可以实锤栈溢出了。3.3 堆为什么往上走堆的方向和栈正好相反是因为传统 Unix 提供brk和sbrk这两个系统调用语义就是移动“program break”这个边界。所谓 program break 是堆顶端的位置malloc 申请小对象时会调用brk把边界往高地址推堆就从低往高一路长上去。堆往上走的代价是你只能在“堆顶”扩张或收缩。如果程序先 malloc 了一个 100 字节的块又 malloc 了一个 200 字节的块然后 free 了第一个块那第一个块释放出的空间并不能交还内核因为它在堆中部而不是顶端。这就是为什么 malloc 要维护空闲链表把释放的中部小块重新利用起来。堆不像栈有“栈帧自动弹出”它天然就有碎片问题。3.4 64 位下 malloc 经常不直接用堆这里有个特别反直觉的点在 64 位 Linux 上很多 malloc 出来的内存并不在[heap]区域里。glibc 的 malloc 实现有一套复杂的arena机制对足够大的分配默认阈值大约 128KB 以上会直接走mmap匿名映射而不是brk。所以你在 maps 里看不到一个巨大的[heap]反而会看到很多分散的rw-p匿名段这些很可能就是大分配或者线程池的内存。如果你strace一个频繁 malloc 的程序会看到大量mmap系统调用而brk可能总共没调用几次。这个设计原因很简单大块分配如果用brkfree 的时候没法把中间区域腾回去用mmap的话free 直接 unmap干净利落。理解这条你再去看一个多线程服务的内存布局就不会天天怀疑“为什么[heap]这么小”了。4. brk 与 mmapmalloc 背后的两条路4.1 malloc 的“自动驾驶”以 glibc 为例malloc 会根据请求大小自动选通道。小对象优先从进程已有堆段或线程 arena 里分配不够了再用brk把堆扩大大对象则直接用mmap一次性映射一整块私有匿名内存。用户平时根本感知不到切换但它直接影响你能看到的地址空间地图。我之前在排查一个服务内存频繁增长又回落的问题时就经历了这个知识点的一课。那个服务大量申请和释放几 MB 的缓冲区用pmap一看堆区并没有持续扩张倒是/proc/pid/maps里反复出现细碎的匿名映射。因为每次大 malloc 都走 mmapfree 又立刻 unmap所以 RSS 会像过山车一样起起伏伏。这个现象本质上是 glibc 的分配策略决定的不是内存泄漏。4.2 mmap 匿名内存的语义mmap 除了给 malloc 当靠山还有很多独立用法。它的核心语义是把一个对象映射到进程地址空间这个对象可以是文件也可以是匿名内存。匿名映射常见的几个应用场景大块 malloc 和线程栈分配。进程间共享内存shm_open、memfd_create底层往往也走 mmap。把磁盘文件直接映射进内存用指针读写文件内容省去read/write和中间缓冲。加载动态库和可执行文件时内核内部也靠 mmap 把文件段映射到对应地址。mmap 映射出来的地址内核会保证页对齐。也就是说你申请的字节数即使不是 4KB 的整数倍底层映射也一定会延伸到页边界上。这带来一个经典问题如果你 mmap 了 4096 字节却在第 4100 字节处写数据那第 4100 字节可能落在下一个页而这个页不一定是你映射的范围可能直接触发段错误也可能“幸运”地写进旁边另一段合法但与你无关的内存里——这种问题最阴险。4.3 用 strace 看真实分配路径想观察 malloc 到底走了哪条路最直接的工具是 stracestrace -e tracebrk,mmap,munmap ./memmap_demo 21 | head -50你会看到类似这样的输出brk(NULL) 0x55dda1d26000 brk(0x55dda1d47000) 0x55dda1d47000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) 0x7f8c2a41f000第一行brk(NULL)并不是把堆缩到空而是查询当前堆顶位置。第二行才是真正把堆边界推高。第三行就是我们前面mmap_ptr那次显式调用。拿这份输出对比代码很容易建立直觉小分配改堆顶大分配新开映射。5. 虚拟地址与物理页缺页、零页和写时复制5.1 页表不是“整张地图都存在”进程地址空间再大也不会一次性把每块虚拟内存都映射到物理内存。要理解这一点得看懂“缺页异常”这个机制。每个进程都有一张页表记录虚拟页对应哪个物理页。而虚拟内存区域可以处于“有效但暂时没有物理页”的状态也就是 present 位为 0。访问这种地址时CPU 会触发缺页异常内核在异常处理里分配物理页、填好页表项、返回用户态继续执行。这就是按需分页demand paging。这也解释了为什么一个进程虚拟内存可能显示占用了几 GB但实际物理 RSS 只有几百 MB。比如你mmap了 1GB 匿名内存只要没逐个页写入它就不占实际物理内存每写到一个新页内核才分配一页物理内存给你。你有没有想过为什么 error 提示里“Out of memory”往往不是你单纯读数据而是因为你反复写入了那些虚拟地址就是因为写入会强制物化物理页。5.2 零页和 BSS未初始化不代表没地址C 语言里的未初始化全局变量放在 BSS 段它的映射权限是 rw-但文件里并没有对应数据。内核用一种极妙的偷懒方式处理让所有未初始化的页统一映射到一个特殊的“零页”哪个进程真去写它了再按写时复制的方式换成独立物理页。所以程序刚启动时BSS 段占的 RSS 几乎为零虚拟地址看着挺大实际物理开销只有几页。malloc返回的匿名内存也有类似特性。memset 前和 memset 后同一个进程的 RSS 可能差出天壤之别。想确认一块映射到底用掉了多少物理内存直接看对应 VMA 在/proc/pid/smaps里的Rss数值变化比从代码里猜靠谱得多。5.3 写时复制 fork 之后的内存假象缺页异常还有一个你天天都在踩的应用写时复制Copy-on-Write COW。Linux 的fork传统上不会立刻复制整个进程的物理内存而是把这些页的页表项标记为只读。父子进程一开始共享同一批物理页任何一方尝试写入时缺页异常触发内核才把那一页复制一份再更新各自的页表。对于大量只读的程序代码段fork 之后父子完全共享省下大量内存。因此你看到进程 fork 后top里内存统计一时半会儿并没有翻倍这是 COW 在起作用。等两边开始各自写数据、把页一个个撕裂开内存占用才真的上升。我在分析一些 fork 型高性能服务时经常用这个特性解释“为什么 worker 进程 RSS 看起来不共享、但 Pss 却很平衡”。缺页异常不只是“缺了才补”这么简单它还是栈自动增长的触发器、是写时复制的触发器、是文件映射惰性加载的触发器。你背一堆内存模型不如真正理解这一个异常怎么工作。6. ASLR 和“每次都变”的地址怎么排查6.1 ASLR 到底随机了什么现代 Linux 默认开启 ASLR控制开关在/proc/sys/kernel/randomize_va_space。一般发行版默认值是 2含义是栈、mmap 区域、共享库、堆、PIE 代码段的基地址都会随机化。这个设计的核心目的是增加攻击者预测内存布局的难度。对普通开发调试来说它的副作用是你同一份代码跑两次printf 打出来的地址往往不一样gdb 里看到的断点地址也一样是浮动状态。如果你在写自动化分析脚本得用/proc/pid/maps去动态找到目标段而不是硬编码某个地址。顺带说一个常见误解非 PIE 编译的旧版可执行文件代码段基址是固定的x86-64 上一般是0x400000随机化的只有栈、库和堆。所以传统攻击里容易预测的是固定位置的代码段而库和栈难预测。这也是为什么现在发行版普遍默认 PIE 编译二进制。6.2 调试时怎么变回“固定地址”开发调试时地址浮动会增加烦恼。想临时固定地址有两个路子一是针对单个进程关闭随机化setarch $(uname -m) -R ./memmap_demo二是临时修改全局配置。优先级控制在# 需要 root sysctl -w kernel.randomize_va_space0 # 跑完复测之后改回来 sysctl -w kernel.randomize_va_space2第二种是全局的影响机器上所有进程不建议在生产上乱改只适合在临时测试机或者隔离开发环境里用。有人会用gdb的set disable-randomization on让被调试进程关闭 ASLR这个选项对setarch -R和 personality 有联动效果在调试场景下比改内核参数更方便。我自己的实践是先不加任何限制地跑用 ASLR 开启状态下的输出确认现象真要复现固定地址再用setarch -R给单个进程加抑制。这样既能定位随机化带来的“玄学”又不影响系统全局。6.3 结合 maps 的一次实际排查最后分享一个真实排查思路看完你就能理解为什么前面说了那么多工具。问题现象程序偶发段错误dmesg 里显示崩溃地址大概是0x7f8c2a41f000附近。第一步用gdb挂上崩溃现场info proc mappings看这个地址属于哪个 VMA。结果发现它落在某块 8MB 的rw-p匿名区刚好结束之后的第一个页上——换句话说程序访问了一块已经被释放并解除映射的 mmap 内存。第二步查代码找谁在 mmap 这块区域发现是一个第三方库自己管理的缓存池库里释放时会 unmap但我们业务代码还持有旧指针。第三步用LD_PRELOAD包一个简单审计工具在 mmap 和 munmap 调用点打印调用栈定位到释放的精确位置。整个过程核心就是“先定位地址属于哪个区块再判断该区块是否合法最后追溯到代码”。如果没有/proc/pid/maps这张地图兜底你会一直怀疑业务代码里的普通指针问题方向完全跑偏。关于进程地址空间还有一个我日常会用的检查习惯看smaps里的Pss而不是只盯Rss。两个进程共享同一个共享内存Rss 会各算一遍Pss 才会把共享部分摊开这样看容器的内存账单才更像真实消耗。把这块搞明白之后你会发现很多“内存泄漏”其实不是泄漏而是虚拟映射没释放或者物理页迟迟没回收很多“内存暴涨”也不是数据真的大了而是写页触发的物理页刚被物化出来。地址空间是一张静态的图背后却是一个动态的内存账本这两件事能同时看懂才是真的把这六个字吃透了。