作为一个整天和 Linux 打交道的人,我一直觉得“进程地址空间”这六个字是应该刻在程序员脑子里、却最容易被忽略的东西。你写一个 hello world,跑起来就是一个进程,而这个进程从低地址到高地址,依次排布着代码段、数据段、堆、mmap 区域、栈,还有藏在角落里的 vdso/vvar/vsyscall;更关键的是,64 位系统下的布局逻辑和 32 位完全不同。如果你曾经好奇过为什么 malloc 返回的地址有时候是 0x601000,有时候又变成 0x7f8f……;如果你打开 /proc/ /maps 看到一大片地址却不知道每一行在说什么;如果你想深入调试、性能分析甚至安全研究,这篇文章就是给你准备的。我会用我最习惯的方式,把 Linux 64 位进程地址空间从上到下、从里到外拆开讲清楚,中间穿插真实命令、真实输出和一堆踩坑记录。
1. 先搞明白 64 位地址空间到底“长什么样”
1.1 为什么 64 位系统不是真的“64 位地址”
很多人第一次知道这个事实都会愣一下:x86-64 架构虽然叫 64 位,但虚拟地址实际上只用了低 48 位(4 级页表),而且这条规则是硬件强制规定的。第 48 位到第 63 位必须是对第 47 位的符号扩展,也就是说,一个合法的 64 位虚拟地址,要么是 0x0000_0000_0000_0000 到 0x0000_7fff_ffff_ffff 这段“低半区”,要么是 0xffff_8000_0000_0000 到 0xffff_ffff_ffff_ffff 这段“高半区”,中间那一段地址属于非规范地址,CPU 根本不会去访问,一旦尝试就会触发异常。
这个设计直接决定了整个进程地址空间的骨架:低半区给用户态进程用,高半区给内核态用。Linux 在 4 级页表下的用户空间总量是 128TB,也就是 0x0000_0000_0000_0000 到 0x0000_7fff_ffff_ffff。老读者可能有印象,32 位时代用户空间是 3GB,内核空间是 1GB,大家挤在一起;64 位时代彻底不挤了,用户态富余到 128TB,内核态也是自己一片 128TB 的广阔天地,物理内存才多大?我的机器 64GB,相当于把整个物理内存的访问范围扩大了 2000 倍以上。
什么叫“符号扩展”,我打个比方:地址就像一群人排队领号,规定号牌必须从第 48 位开始往高位重复第 47 位的数字。那第 47 位是 0 的人站左边(用户区),第 47 位是 1 的人站右边(内核区)。硬件在取指、访存时第一步就是检查这个号牌合不合法,不合法直接拒之门外。这也是为什么后来支持 5 级页表(LA57)后,用户空间能扩大到 64PiB 量级——硬件把“检查位”从 48 位放宽到了 57 位,规则没变,只是更大了。
1.2 一张图看懂 64 位进程空间的整体划分
我把一张 64 位 Linux 进程(用户态视角)的地址空间分布写在这里,你不需要死记,只需要有个整体轮廓:
- 0x0000000000000000 ~ 0x0000000000400000:保留区域,很多发行版默认禁止映射这一带(mmap_min_addr),防止空指针解引用直接打到低地址合法代码上。
- 0x0000000000400000 附近:ELF 可执行文件加载区。传统非 PIE 程序从 0x400000 开始放代码段,接着是只读数据段、数据段、BSS。
- 0x00000000006xxxxx 附近:堆的起点(brk 段)。malloc 小内存从这里向上拿。
- 0x00007f0000000000 附近:共享库、mmap 匿名映射、线程栈,全部从高地址向下铺。
- 0x00007ffffffde000 附近:主线程栈,向下增长。
- 0x00007ffff7ff9000 附近:vvar、vdso 区域,内核“塞”给用户态的只读页,用来实现 gettimeofday、时钟等高频系统调用的免陷加速。
- 0xffffffffff600000:vsyscall 老古董区域,固定 3 个页,兼容老版本 glibc 用的。
- 0xffff800000000000 以上:内核地址空间,用户态不可访问。
你看这个布局和 32 位差别非常大。32 位时代内存只有 4GB,用户空间 3GB,共享库从 0xf7xxxxxx 往下排,栈在 0xffxxxxxx,整个空间非常拥挤;64 位时代中间出现了巨大的“空洞”,别慌,这些空洞不是浪费,而是给堆、栈、mmap 各走各的路,谁也别干扰谁。
注意:如果你是第一次看 /proc/self/maps,发现地址全是 0x7f..... 或者 0xffff.....,不要以为这是乱码,这是 64 位地址空间的正常形态。32 位程序在 64 位系统上跑,看到的又是另一套 0xf7xxxxxx、0xffxxxxxx 布局,后面我会专门说。
2. 低地址到高地址,逐个拆解用户态各段
2.1 代码段、数据段与 BSS:ELF 装载那点事
任何进程的起点都是磁盘上的 ELF 文件。内核加载 ELF 时,会按照程序头表(Program Header Table)里的 LOAD 段把内容映射到内存。我在机器上随便看一个非 PIE 程序 /bin/cat,它的 maps 前几行是这样的:
00400000-0040c000 r-xp 00000000 fd:01 201326589 /usr/bin/cat 0060b000-0060c000 r--p 0000b000 fd:01 201326589 /usr/bin/cat 0060c000-0060d000 rw-p 0000c000 fd:01 201326589 /usr/bin/cat 0060d000-00627000 rw-p 00000000 00:00 0 [heap]第一行 00400000-0040c000 是代码段,权限 r-xp,可读可执行但不可写。注意它的起始地址 0x400000,这是 x86-64 上传统 ELF 可执行文件约定俗成的加载基址,为什么是 0x400000 而不是 0x10000?因为 Linux 想避开低地址的 NULL 页陷阱区域,同时又不会离数据段太远。
第二行 0060b000-0060c000 是只读数据段,权限 r--p,里面放 ELF 里的 .rodata(只读常量)、.eh_frame 等。第三行 0060c000-0060d000 是真正的数据段,权限 rw-p,存放全局变量、静态变量。第四行 0060d000 开始就是堆(brk 段)了,初始很小,随着 malloc 向上扩展。
这里有一个新手容易踩的坑:数据段和 BSS 的权限经常合并成一个 rw-p 映射,BSS 段本来不占磁盘空间,但加载到内存时要按零初始化,所以它一定放在数据段后面,并且映射大小会比文件大小大一点。你看到 maps 里某些 rw-p 映射的 file offset 后面不是 0,但文件长度对不上,不要惊讶,那多出来的部分就是 BSS。
现代发行版默认开启了 PIE(Position Independent Executable),所以你会发现普通二进制文件加载地址不是 0x400000,而是 0x555555554000 附近。比如 Ubuntu 上编译出来的程序:
555555554000-555555557000 r-xp 00000000 fd:01 ... /tmp/demo 555555577000-555555578000 r--p 00002000 fd:01 ... /tmp/demo 555555578000-55555557a000 rw-p 00003000 fd:01 ... /tmp/demo0x555555554000 是 PIE 程序的默认基址,加上 ASLR 后每次运行还会整体偏移。你在 GDB 里看到的地址和直接运行时不一样,很大程度就是这个原因。
2.2 堆与 brk:不只是 malloc 的“后院”
堆是进程动态内存的主战场。传统上堆通过 brk 系统调用扩展,brk 表示数据段末尾的地址,向上增长。但 glibc 的 malloc 并不是只用 brk,它有一套非常现实的分级策略:
- 小内存分配(默认阈值以下,常见是 128KB 以内)优先走 brk,从 [heap] 区域里切一块给你。
- 大内存分配直接走 mmap,在内核的 mmap 区域映射一片匿名内存。这就是为什么你 malloc 一个 10MB 的缓冲区,返回地址往往是 0x7f8f.... 而不是 0x601000 附近。
这背后藏着 Linux 内存管理的经典权衡:brk 是线性的,分配释放会造成碎片,回收也很麻烦,但它快;mmap 是按页映射、件件独立的,释放时可以直接 unmap 归还内核,但每次分配都要走系统调用、还要建立页表,开销大。所以 glibc 干脆让小块走堆、大块走 mmap。
我实际调试内存问题时经常看这样一行:
7f8f4fc00000-7f8f4fc21000 rw-p 00000000 00:00 0这是 mmap 出来的匿名映射,可能是线程栈,可能是 malloc 大块,也可能是动态库链接器 mmap 的一些辅助区域。怎么区分?看地址后面如果没有文件路径,大概率就是匿名内存,具体用途得结合程序逻辑和 /proc/ /smaps 一起看。
实操建议:在 C 代码里分别 printf 一下小内存和大内存的指针,再配合 /proc/self/maps 对照,比看十篇文档都有用。malloc(100) 一般落在 [heap] 里,malloc(1024*1024) 一般落在 0x7f..... 区域。
2.3 mmap 区、共享库与栈的“排队”逻辑
64 位下 mmap 区域从用户空间顶部往下铺。内核在每个进程创建时,会先算出一个 mmap_base,这个基址不是一个固定值,而是在 TASK_SIZE 之下留出随机余量后得到的一个“起始线”,然后共享库、mmap 映射、线程栈全都从这条线向下排列。
栈的位置也很讲究。主线程栈的起始地址通常在 0x7ffffffde000 附近,向下增长。内核为栈和 mmap 区之间留出了随机间隙,防止恶意程序根据固定相对偏移猜测布局。为什么栈放在几乎最高的位置?因为栈向下增长,如果放在低地址,很容易和向上增长的堆撞车;放到最高处,两个方向互不干扰,中间的巨大空洞就是给极端情况留的缓冲。
共享库的加载地址也是从 mmap 区域向下排的。看 libc 的 maps 输出:
7f9c4f4fc000-7f9c4f573000 r-xp 00000000 fd:01 ... /lib/x86_64-linux-gnu/libc.so.6 7f9c4f573000-7f9c4f592000 r--p 00076000 fd:01 ... /lib/x86_64-linux-gnu/libc.so.6 7f9c4f592000-7f9c4f596000 rw-p 00095000 fd:01 ... /lib/x86_64-linux-gnu/libc.so.6libc 的代码段、只读数据、可写数据是三个独立的映射,每个都有独立的权限位。你注意看,maps 里同一个 .so 的三个映射,地址是紧接着排列的,但权限不同,这给调试提供了巨大便利:你能精确知道某个崩溃指令到底落在了共享库的代码段还是数据段。
最后,线程栈也在 mmap 区域里。每个 pthread_create 创建线程时,glibc 用 mmap 分配一块 8MB 左右的区域(实际默认栈大小 ulimit -s 决定),其中一部分作为栈空间,末尾还带一个 guard 页,防止越界写。在 maps 里你会看到大量 rw-p 匿名映射,那就是各个线程栈。
2.4 vdso/vvar/vsyscall:内核悄悄塞给你的页
很多人在 maps 里看到这种奇怪的行:
7ffc6a285000-7ffc6a287000 r-xp 00000000 00:00 0 [vdso] 7ffc6a287000-7ffc6a288000 r--p 00000000 00:00 0 [vvar] ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]vdso 和 vvar 是内核映射到用户态的只读/执行页,作用非常巧妙:gettimeofday、clock_gettime、getcpu 这些“不碰核心状态”的系统调用,内核把实现直接暴露成一段用户态可调用的代码,进程调用它时完全不需要陷入内核,直接在用户态跑完,性能接近纯函数调用。vvar 是它对应的数据页,存时间源等数据,内核通过写这个页来更新信息,用户态只读。
这段地址为什么总是跟在栈后面?因为 vdso/vvar 确实是在进程初始化时被映射在栈附近,地址也受 ASLR 影响,每次运行可能都不一样。
vsyscall 则是一个历史遗留。老版本内核用固定地址 0xffffffffff600000 提供 3 个页,允许用户态直接调用。固定地址就意味着巨大的安全风险,现代内核默认把它设置成 emulate 模式,页面上其实没有真正的可执行代码,一旦用户态老代码真的去调用,内核会捕获并模拟执行,再快速返回。所以你在 maps 里看到它,但千万别以为那是可以直接执行的“后门”。
经验:vDSO 地址每次运行都不一样,所以调试时别把 vDSO 地址写死在测试用例里。有些性能测试工具会把 vDSO 的时钟函数地址缓存起来,如果进程 fork 后地址空间不变还好,一旦 exec 新程序,缓存就失效了。
3. 实操时刻:怎么把自己的进程看个底朝天
3.1 用 /proc/self/maps 和 pmap 读整体布局
最简单的方法就是让 Linux 自己告诉你。在终端执行:
cat /proc/self/mapsself表示当前正在运行的 cat 进程自己,输出就是 cat 这个进程的完整虚拟地址空间。这个文件本质上就是内核为进程维护的 VMA(虚拟内存区域)列表,每一行格式是:
起始地址-结束地址 权限 偏移 主设备号:次设备号 inode 路径权限位有 4 个字符:r 读、w 写、x 执行、p 私有或 s 共享。p表示私有映射(写时复制),s表示共享映射(比如共享内存)。偏移表示这段映射在文件中的起始位置,设备号、inode 用来定位文件,路径列出了映射来源,匿名映射会显示 [heap]、[stack]、[vdso] 或者直接留空。
如果想看专业一点的统计,用 pmap:
pmap -x $$它会输出每个映射的 RSS、脏页大小,还能在最后统计出总内存占用。排查内存泄漏时,pmap 配合/proc/<pid>/smaps更有效,smaps 会给你每个 VMA 的 Rss、Pss、Swap、共享比例等细粒度信息。我曾经排查一个诡异的“程序内存不断增长但 RSS 不大”问题,最后就是靠 smaps 发现某个线程栈映射了大量保留内存。
3.2 一小段 C 代码让各段地址现出原形
光看别人的进程不过瘾,我写了一段极简代码,能验证上面说的每段地址:
#include <stdio.h> #include <stdlib.h> #include <sys/mman.h> int global_init = 1; int global_uninit; void func(void) { } int main(void) { static int static_local = 2; int stack_local = 3; void *heap_small = malloc(100); void *heap_big = malloc(1024 * 1024); void *map_anon = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); printf("func %p\n", func); printf("global_init %p\n", &global_init); printf("global_uninit %p\n", &global_uninit); printf("static_local %p\n", &static_local); printf("stack_local %p\n", &stack_local); printf("heap_small %p\n", heap_small); printf("heap_big %p\n", heap_big); printf("map_anon %p\n", map_anon); getchar(); return 0; }编译运行:
gcc -no-pie -o addr addr.c ./addr在我的 5.15 内核机器上,输出大概是:
func 0x401126 global_init 0x404010 global_uninit 0x404028 static_local 0x404018 stack_local 0x7ffde8c7db8c heap_small 0x217a010 heap_big 0x7f17d00b2010 map_anon 0x7f17d00b1010对照一看就明白了:函数和全局变量全在低地址 0x404xxx 附近,局部变量在栈上 0x7ffd.....,malloc 小内存落在堆区 0x217a010,而 malloc 大块和 mmap 匿名映射都落在 0x7f17d00bxxxx ——这就是我前面说的 glibc 大块内存走 mmap 的直接证据。你再看此时 /proc/ /maps,能找到对应关系。
3.3 用 readelf 和 gdb 把 ELF 和运行时对上号
静态看 ELF 布局用 readelf:
readelf -l /bin/ls重点看 LOAD 段。每个 LOAD 段都有 Offset、VirtAddr、FileSiz、MemSiz 几个字段。FileSiz 表示文件里占多少字节,MemSiz 表示内存里占多少字节,两者不相等往往就是 BSS 段。你会发现代码段 LOAD 的 VAddr 是 0x400000 或 0x555555554000,这和运行时 maps 里看到的第一行的起始地址完全对应。
运行时想确认某个崩溃指令在哪一段,可以用 gdb:
gdb ./addr (gdb) break main (gdb) run (gdb) info proc mappings (gdb) maintenance info sectionsinfo proc mappings输出就是内核视角的 VMA 信息,等价于 cat /proc/ /maps;maintenance info sections则是从 BFD 角度看 ELF section 的加载位置。这两个命令配合,能快速定位“我的全局变量为什么被写坏了”“这个指针为什么落在奇怪的区域”这类问题。
4. ASLR 和 PIE:地址空间里的“随机性”从哪来
4.1 ASLR 到底随机化了哪些区域
ASLR(Address Space Layout Randomization,地址空间布局随机化)是为了防止攻击者利用固定地址发起攻击。手册上说得高大上,实测下来你会发现它最直接的表现就是:同一次程序,每次运行,栈、mmap、共享库、甚至 PIE 程序本身的加载地址全都在变。我连续跑三次上面的 addr 程序,栈地址 0x7ffde8c7db8c、0x7ffd99f5e9cc、0x7fffa88bbccc,每次都不同。
具体来说,64 位 Linux 的 ASLR 给几个区域分配了随机偏移:
- mmap_base:共享库、匿名映射、线程栈的基址,随机偏移量非常大。
- 栈起始地址:主线程栈的固定随机偏移。
- PIE 程序基址:代码段、数据段、堆整体加载位置随机。
- vdso/vvar:地址随机。
- 堆:在某些配置下也会随机,传统非 PIE 程序的堆一般跟在数据段后面,但现代内核可以根据 randomize_va_space 决定是否给 brk 堆加随机偏置。
sysctl 参数 kernel.randomize_va_space 控制 ASLR 强度,常见三个值:0 表示完全关闭,1 表示随机化 mmap、栈、vdso,2 表示在 1 的基础上再加堆随机化。默认通常是 2。我调试内存问题时经常需要临时关闭 ASLR,方法有两种:
# 需要 root,影响全局,调试完记得恢复 sysctl -w kernel.randomize_va_space=0 # 只影响当前命令,推荐 setarch $(uname -m) -R ./addr4.2 非 PIE、PIE 与地址变化的关系
很多初学者搞混一个概念:ASLR 和 PIE 是两码事。ASLR 是内核提供的“随机化基础设施”,PIE 是编译选项,决定你的代码段本身是不是“地址无关”的,能不能被随机搬走。
传统非 PIE 程序链接时指定了固定加载地址 0x400000,它的代码段永远在 0x400000,无法整体搬家,ASLR 只能随机化栈、堆、mmap 区域。而 PIE 程序用地址无关代码编译,加载器可以把它放到任何地址,于是 ASLR 才能把代码段也随机化,比如从 0x555555554000 随机会变到 0x55c9a1e7a000 之类的。
这也是为什么现代 Linux 发行版(Ubuntu、Debian、Fedora)默认 gcc 都开启 PIE:就是为了让 ASLR 能覆盖到可执行文件自身。如果你在调试时要让代码段固定地址,编译时加-no-pie,配合 gdb 的set disable-randomization on,地址就稳定了,像上面我写的 demo 那样。
注意:gdb 默认会关闭 ASLR(为了调试稳定),所以在 gdb 里跑程序看到的地址和命令行直接跑不一样,这是正常现象,不是玄学。如果你需要在 gdb 里模拟真实随机布局,可以
set disable-randomization off。
4.3 为什么调试时要关 ASLR
调试段错误时,如果 ASLR 开着,每次崩溃地址都不一样,根本无法稳定断点。我见过不少新人卡在“我用 gdb 调试一个 bug,为什么地址每次跑都变”的问题上,其实只是没意识到调试器帮你把 ASLR 关了,而直接运行时是开着的。
我的习惯是:需要复现崩溃时,先用ulimit -c unlimited打开 core dump,然后在 gdb 里set disable-randomization on固定地址;如果需要验证某个利用条件(安全研究人员视角,这里只讲防御理解)或者复现竞态问题,就保持默认随机化。排查栈溢出时,关闭 ASLR 能让你准确看到返回地址被覆盖成了什么值,这比开着随机化去猜有用得多。
5. 内核地址空间:那半边用户态看不到的“镜像世界”
5.1 为什么内核空间从 0xffff800000000000 开始
回到 1.1 节说的符号扩展规则:第 47 位为 1 的虚拟地址全在高端。Linux 把内核空间放在高半区,起始位置大约在 0xffff800000000000,一直延伸到 0xffffffffffffffff。用户态进程的页表里其实也包含这些内核映射,但页表项的权限位禁止用户态访问,所以你在用户态访问会触发 page fault,然后被内核以“段错误”杀死。
这引出一个经典概念:用户态和内核态“共享”同一套页表,只是切到内核态时,CPU 的权限级别变了,那些内核映射才变得可访问。这也是为什么系统调用有开销——每一次系统调用都要切换特权级,还要切换内核栈,所以在 2.4 节里看到 vdso 这种“用户态直接调,不切内核”的优化显得异常珍贵。
5.2 直接映射区、vmalloc 区和模块区
64 位内核空间看着很大,Linux 也按功能划分了不同区域。我挑几个常见的说:
- 直接映射区(direct map / linear map):内核把物理内存直接映射到虚拟地址空间的一块连续区域,物理地址和虚拟地址只差一个固定的偏移。Linux 内核代码里大量指针就是直接映射区的地址,你在内核日志里经常能看到类似 ffff8880abc00000 的地址,这就是直接映射区。它为什么用这么高的地址?因为要避开用户态低 128TB,让地址空间大小足以覆盖物理内存加各种空洞。
- vmalloc 区:用于内核中需要连续虚拟地址但物理内存不一定连续的分配,比如内核模块、某些驱动缓冲区。vmalloc 区的地址更靠近高地址,和直接映射区之间有空洞隔离。
- 模块区:内核模块加载的区域,地址通常在 ffffffffa0000000 附近。Linux 内核把模块放到接近内核镜像的位置,主要是为了近跳转(short jump)的指令编码放得下。如果你写内核模块,用
cat /proc/modules看到的加载地址基本都在这个范围。 - 固定映射区(fixmap)和 CPU entry area:包含中断描述符表、CPU 入口区等,一般人是碰不到的。
这些区域用“镜像”来形容很贴切:你把内核想象成另一个巨大的进程,同样有代码段、数据段、堆、映射区,只是权限更高,不与普通程序共享。
5.3 系统调用时,进程地址空间怎么“切换”
你写一个 read 系统调用,CPU 会从用户态切到内核态。这个过程并不是把页表换掉——内核页表本来就在同一个页表里,只是切换了特权级和栈。CPU 会从用户栈切到内核栈,然后跳到内核的入口代码,此时访问高半区地址就是合法的了。
这里有个安全设计:熔毁(Meltdown)漏洞暴露过一个问题——用户态虽然访问不了内核地址,但 CPU 的乱序执行会把内核地址的页表项带出来,导致侧信道泄露。所以后面内核引入了 KPTI(Kernel Page Table Isolation,内核页表隔离),在用户态运行时干脆把大部分内核映射从页表里摘掉,切到内核态时再换一套含完整内核映射的页表。代价是系统调用变慢了,因为每次切换都要多换一次页表。
所以你在 64 位系统上跑 sysbench 或大量系统调用 benchmarks,会发现性能比早期内核略有下降;而那些不需要频繁切内核的纯 CPU 计算,几乎不受影响。理解这段历史,对排查“为什么这个版本内核的系统调用好像变慢了”的问题很有帮助。
6. 常见问题与排查技巧实录
我整理了一个速查表,是这些年调试中最常遇到的几个和地址空间相关的现象,以后遇到可以直接照着排查:
| 现象 | 原因 | 排查方法 |
|---|---|---|
| 用 %x 打印指针,编译报错或输出乱码 | 指针是 64 位,%x 只取 32 位 | 用 %p 格式化指针,或强转成 unsigned long long 再按 %llx 打印 |
| gdb 里地址每次报错都不一样 | 直接在 gdb 外运行 ASLR 生效 | set disable-randomization on固定地址 |
| 64 位系统跑 32 位程序,maps 地址是 0xf7... / 0xff... | 内核开启 IA32_EMULATION,给 32 位进程提供 32 位布局 | file看 ELF 类型,确认是不是 32 位 |
| malloc 返回小地址,过一会返回 0x7f... 大地址 | glibc 大块分配走 mmap | strace 看 mmap 系统调用,或 ulimit -d 限制 brk |
| 进程 RSS 不大,但 maps 里有一堆 rw-p 匿名页 | 线程栈、malloc arena、mmap 保留区 | 结合 smaps 查看 Pss、Rss,区分保留区和已用区 |
| 内核日志 /proc/modules 地址全是 ffffffffa... | 内核模块加载区 | 正常现象,不需要处理 |
| vdso 地址在 gdb 和实际运行中不同 | ASLR 影响 vdso 映射位置 | 关注相对偏移,不要盯绝对地址 |
有几个实操心得我补充一下:
第一,排查内存问题时,别只看 maps 的起始地址,一定要看结束地址。VMA 的大小往往比你想的大得多,比如线程栈默认 8MB,但实际只用到几十 KB,RSS 很小。如果你只统计大小,会被“虚拟内存占用几十 GB”这种假象吓到。
第二,maps 里同地址段后面有(deleted)标识的,是文件被删除但仍被进程打开的映射,这种情况常见于临时文件、动态库升级场景。如果你发现某个文件反复出现 deleted,说明有进程一直持有已删除文件句柄,磁盘空间可能无法释放,排查时lsof +L1能直接列出。
第三,我强烈建议每个搞 Linux 服务端、嵌入式、游戏服务端的人养成看 maps 的习惯。很多时候莫名其妙的崩溃,比如“指针飞到 0x41414141 去了”“释放的地址不在堆里”,早在 maps 里就埋下伏笔。你甚至可以写个小脚本,在崩溃前把 /proc/ /maps 抓下来,出事后对照分析。
最后再分享一个小技巧:如果你在 64 位系统上调试 32 位进程,记得加-m32编译,同时确认系统装了 multilib 库。32 位和 64 位地址布局差异巨大,很多“野指针”问题其实是你在 64 位代码里硬塞 32 位地址造成的——特别是网络协议里常见的“地址强转成 int”操作,64 位指针转 int 会直接截断。这种事我踩过不止一次,去看 maps 时地址和代码逻辑完全对不上,最后发现就是一个强转问题。
地址空间的布局看起来是一堆枯燥的数字和偏移,但它就是程序的“世界地图”。搞懂了它,你调试段错误、内存泄漏、性能抖动时,手里就多了一张底牌。