第三章 进程内存布局:堆、栈、代码段、数据段、BSS段

一个进程跑起来以后,它在内存里长什么样?很多人以为就是「代码+变量」这么简单。但真做内存分析时你会发现——光一个段不对齐,就能让你排查三天三夜。

我记得刚接手一个线上服务,内存占用疯长,重启后恢复正常,过几小时又涨回去。当时我就怀疑是堆泄漏,但用pmap一看,数据段和BSS段的大小也异常。后来发现是个全局结构体被反复追加数据……嗯,这些内存区域的分工,远比你想的精细。

五个核心段:各自的任务

一个进程的虚拟地址空间,从低到高大致分为五段:

  • 代码段(.text)——存放可执行指令,只读。
  • 数据段(.data)——已初始化的全局变量和静态变量。
  • BSS段(.bss)——未初始化或零初始化的全局/静态变量。
  • 堆(Heap)——运行时动态分配的内存区。
  • 栈(Stack)——函数调用用的局部变量、参数、返回地址。

你想想看,C语言里写个int a = 5;,编译器会把它塞到数据段;而static int b;没赋值,它就待在BSS段。这两类全局变量,在运行时会被映射到不同内存区域——这也是为什么BSS段不占用可执行文件空间,但加载后会占内存。

我曾经在一台嵌入设备上踩过坑:BSS段太大,导致程序启动时系统开销暴增。为什么?因为BSS段在加载时必须清零,几百MB的零初始化变量会让启动时间从毫秒级变成秒级。后来我把一些大的缓存改成用malloc按需分配,才解决问题。

实际布局:一张图看懂

下面这张图展示了进程地址空间的典型排列方式,注意地址从低到高:

进程虚拟地址空间布局 低地址 ——————————————————— 高地址 代码段 (.text) 只读,存放指令 数据段 (.data) 已初始化全局/静态变量 BSS段 (.bss) 未初始化/零初始化变量 堆 (Heap) 动态分配,向高地址增长 向上增长 栈 (Stack) 局部变量、函数调用 向下增长 环境变量 & 参数 命令行参数 说明:堆和栈之间有大片空白区域(内存映射区),用于 mmap 文件等。 地址顺序:代码段 < 数据段 < BSS段 < 堆 < 内存映射 < 栈 < 环境变量

堆与栈:相爱相杀

堆和栈是内存分析的「重灾区」。我碰到的问题里,八成跟这两个区域有关。

特性 堆 栈
分配方式 手动 malloc / free 自动分配/释放
生长方向 向高地址 向低地址
速度 慢(系统调用 + 锁) 快(一次 sp 增减)
碎片 容易产生外部碎片 几乎不碎片
大小限制 受物理内存+overcommit限制 固定(通常 8 MB 可配置)
典型错误 内存泄漏、use-after-free 栈溢出、栈缓冲区溢出

关键指标:用 pmap -x 可以查看每段的 RSS 使用量;/proc/pid/maps 更详细。我一般先看堆的 RSS 是否在持续增长,如果是,基本可以定位到泄漏点。

代码段:只读的「法律」

代码段映射的是二进制文件里的 .text 节。我见过有人试图在运行时修改函数指针指向的地址——直接段错误。因为操作系统把这一页的权限设置成了 r-x,你硬要写,就是 SIGSEGV。

另外,同一个程序的多个进程会共享代码段。比如你开10个 bash,它们的 .text 在物理内存里只有一份。这在分析内存总量时要注意:pmap 显示的是虚拟内存大小,不是实际物理占用。

数据段和BSS段:容易被忽视的「胖子」

你以为全局变量占用很少?不一定。

  • .data:所有已初始化的全局变量、静态变量、常量(字符串常量在某些平台会进 .rodata,但也在数据段附近)。
  • .bss:未赋值的全局/静态变量,C标准要求初始值为0,所以加载时内核会清零这一块。

我遇到过一个情况:某嵌入式项目中有人定义了一个 static char buffer[1024 * 1024 * 50]; 也就是 50 MB,而且没初始化。结果BSS段膨胀到 50 MB,目标板子的内存只有 64 MB,启动直接就 OOM 了。解决方案是把这种大型静态缓冲区换成 malloc,只在实际需要时才分配。

避坑:不要用 static 定义超大数组!即使没初始化,它也在BSS段占虚拟地址空间,无法被缺页按需加载——因为BSS段必须一次性映射。我建议大块内存走堆分配。

实际诊断:通过 /proc/pid/maps 看段

以下是一个示例进程的内存映射片段:

# cat /proc/12345/maps | head -10
556000000000-556000100000 r-xp 00000000 08:01 123456 /usr/bin/myapp
556000200000-556000300000 r--p 00010000 08:01 123456 /usr/bin/myapp
556000300000-556000400000 rw-p 00020000 08:01 123456 /usr/bin/myapp
556000500000-556000800000 rw-p 00000000 00:00 0          [heap]
7ffe00000000-7ffe00100000 rw-p 00000000 00:00 0          [stack]
7ffe00200000-7ffe00201000 r-xp 00000000 00:00 0          [vdso]

看到没?第一段 r-xp 就是代码段(可读可执行,不可写)。第二段 r--p 通常是只读数据(比如 .rodata)。第三段 rw-p 包含数据段和BSS段。堆和栈也清晰可见。

我个人习惯先看堆的边界——如果堆结尾地址在持续增大,说明存在内存分配未释放;如果栈距离堆太近(比如相差不到几MB),就要警惕栈溢出风险。

小结几个实用点

  • 用 size 命令可以看 ELF 文件的段大小:size --format=sysv myapp。
  • BSS段虽然不占磁盘空间,但占虚拟地址空间,大数组请用 malloc。
  • 代码段共享可以大幅节省内存,分析时记得看 PSS 而不是 RSS。
  • 堆和栈的增长方向相反,如果程序递归太多,栈会往低地址压——一旦撞上堆或内存映射区,直接段错误。

小技巧:在 Ubuntu 上可用 sudo cat /proc/pid/smaps 看每段的详细 PSS 和脏页,精准定位内存消耗大户。我曾经用它发现一个库的 BSS 段居然有 200 MB,后来升级版本解决。

掌握这五个段,你再看 pmap 的输出就不会觉得是乱码了。每个段在虚拟地址空间里分工明确,谁出了问题,谁就该背锅。