第三章 进程内存布局:堆、栈、代码段、数据段、BSS段
一个进程跑起来以后,它在内存里长什么样?很多人以为就是「代码+变量」这么简单。但真做内存分析时你会发现——光一个段不对齐,就能让你排查三天三夜。
我记得刚接手一个线上服务,内存占用疯长,重启后恢复正常,过几小时又涨回去。当时我就怀疑是堆泄漏,但用pmap一看,数据段和BSS段的大小也异常。后来发现是个全局结构体被反复追加数据……嗯,这些内存区域的分工,远比你想的精细。
五个核心段:各自的任务
一个进程的虚拟地址空间,从低到高大致分为五段:
- 代码段(.text)——存放可执行指令,只读。
- 数据段(.data)——已初始化的全局变量和静态变量。
- BSS段(.bss)——未初始化或零初始化的全局/静态变量。
- 堆(Heap)——运行时动态分配的内存区。
- 栈(Stack)——函数调用用的局部变量、参数、返回地址。
你想想看,C语言里写个int a = 5;,编译器会把它塞到数据段;而static int b;没赋值,它就待在BSS段。这两类全局变量,在运行时会被映射到不同内存区域——这也是为什么BSS段不占用可执行文件空间,但加载后会占内存。
我曾经在一台嵌入设备上踩过坑:BSS段太大,导致程序启动时系统开销暴增。为什么?因为BSS段在加载时必须清零,几百MB的零初始化变量会让启动时间从毫秒级变成秒级。后来我把一些大的缓存改成用malloc按需分配,才解决问题。
实际布局:一张图看懂
下面这张图展示了进程地址空间的典型排列方式,注意地址从低到高:
堆与栈:相爱相杀
堆和栈是内存分析的「重灾区」。我碰到的问题里,八成跟这两个区域有关。
| 特性 | 堆 | 栈 |
|---|---|---|
| 分配方式 | 手动 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 的输出就不会觉得是乱码了。每个段在虚拟地址空间里分工明确,谁出了问题,谁就该背锅。