2、进程地址空间布局:代码段、数据段、堆、栈、mmap区域的划分与作用

好,咱们今天聊聊进程地址空间。这东西说白了,就是操作系统给每个进程画的一张「虚拟地图」。你写程序时看到的每一个指针、每一个变量,最终都会落在这张地图的某个位置上。

我记得刚入行那会儿,调试一个内存越界的bug,折腾了两天。后来发现是栈溢出把堆里的数据给踩了。嗯,从那以后,我每次写代码都会下意识地想想:这个变量到底住在哪个区?

2.1 整体布局:一张虚拟地图

一个典型的32位进程地址空间,从低地址到高地址,大致长这样:

高地址 0xFFFFFFFF
+------------------+
|  内核空间        |  (用户不可见)
+------------------+ 0xC0000000 (典型分界线)
|  栈区            |  (向下增长)
+------------------+
|   ↓              |
|   (空洞)         |
|   ↑              |
+------------------+
|  mmap区域        |  (文件映射、动态库)
+------------------+
|  堆区            |  (向上增长)
+------------------+
|  BSS段           |  (未初始化全局变量)
+------------------+
|  数据段          |  (已初始化全局变量)
+------------------+
|  代码段(.text)   |  (只读)
+------------------+
低地址 0x00000000

你想想看,每个区都有自己明确的职责。谁该住哪儿,谁不能碰谁,这都是设计好的。我见过不少新手,以为malloc出来的内存和栈上的局部变量是邻居,其实它们隔了十万八千里。

2.2 代码段:程序的「只读剧本」

代码段,也叫.text段。这里存放的是你写的所有机器指令。它被标记为只读,为什么?因为没人希望程序跑着跑着,自己的代码被人改了。

关键特性:

  • 只读、可执行
  • 多个进程共享同一份代码段(比如你开了两个vim,代码段在物理内存里只有一份)
  • 地址固定,编译时就确定了

我在项目中遇到过一个问题:某个嵌入式设备上,代码段被意外映射成了可写。结果一个野指针写过去,程序直接崩溃。排查了半天,发现是链接脚本写错了。嗯,这种坑踩过一次就记住了。

2.3 数据段:全局变量的「家」

数据段分两块:已初始化的数据段(.data)和未初始化的数据段(.bss)。

段名 存放内容 特点
.data 已初始化的全局变量、静态变量 占用可执行文件空间
.bss 未初始化的全局变量、静态变量 不占用可执行文件空间,运行时清零

这里有个小技巧:如果你定义了一个全局数组 int arr[10000] = {0};,它会被放在.data段,可执行文件会变大。但如果你写成 int arr[10000];,它就在.bss段,文件大小几乎不变。我曾经用这个办法把一个固件的大小压下去不少。

2.4 堆区:动态分配的「自由市场」

堆是程序员最熟悉的陌生人。你天天用malloc、free、new、delete,但你真的了解堆吗?

堆从低地址向高地址增长。它由操作系统管理,但具体怎么分配,取决于你用的内存分配器——glibc的ptmalloc、Google的tcmalloc、或者jemalloc。

避坑指南:

我曾经在项目中用过一次递归调用,每次递归都malloc一块内存,但忘了free。结果堆区被撑爆了,程序直接OOM。嗯,从那以后我养成了一个习惯:每次malloc后面紧跟free,先写好再写业务逻辑。

堆的分配策略其实挺复杂的。简单说:

  • 小内存(< 128KB):用brk系统调用,从堆顶分配
  • 大内存(>= 128KB):用mmap系统调用,在mmap区域分配

为什么会这样?因为频繁的brk操作会导致堆碎片化,而mmap分配的大块内存可以独立管理,用完直接还给系统。

2.5 栈区:函数调用的「临时舞台」

栈是每个线程私有的。它从高地址向低地址增长。每次函数调用,都会在栈上压入一个栈帧,里面放着:

  • 函数的返回地址
  • 局部变量
  • 函数参数(某些架构下)
  • 保存的寄存器值

栈的大小是有限的。Linux下默认是8MB,你可以用 ulimit -s 查看。如果你在栈上声明了一个巨大的局部数组,比如 char buf[10 * 1024 * 1024];,那恭喜你,栈溢出了。

警告:

递归函数特别容易爆栈。我曾经写过一个深度优先搜索,递归深度到了几万层,结果栈直接炸了。后来改成迭代实现,问题解决。记住:递归虽好,可不要贪杯。

2.6 mmap区域:文件映射与动态库的「桥梁」

mmap区域位于堆和栈之间。它主要干三件事:

  1. 文件映射:把磁盘文件直接映射到内存,读写文件就像读写内存一样
  2. 动态库加载:你用的libc.so、libpthread.so都加载在这里
  3. 大内存分配:前面说了,malloc分配大内存时,底层用的就是mmap

我个人特别喜欢mmap。为什么?因为它能实现零拷贝。比如你读一个文件,传统方式需要先读到内核缓冲区,再拷贝到用户空间。但用mmap,直接映射,省掉了一次拷贝。我在做高性能日志系统时,就用mmap来写日志文件,性能提升很明显。

2.7 一张图看懂全部

下面这张SVG图,把整个进程地址空间的布局和各个区域的关系画清楚了。你可以把它当作一张「内存地图」来参考。

进程地址空间布局 内核空间 (用户不可见) 0xC0000000 栈区 (向下增长) 局部变量、函数调用帧 (空洞 / 随机化区域) mmap区域 动态库、文件映射、大内存 堆区 (向上增长) malloc / free 动态分配 BSS段 数据段 (.data) 代码段 (.text) 0x08048000 0x08049000

2.8 各区域的作用总结

区域 作用 增长方向 权限
代码段 存放机器指令 固定 读+执行
数据段 存放全局/静态变量 固定 读+写
BSS段 存放未初始化全局变量 固定 读+写
堆区 动态内存分配 向上 读+写
mmap区域 文件映射、动态库、大内存 双向 取决于映射
栈区 函数调用、局部变量 向下 读+写

最后说一句:理解进程地址空间,是系统编程的基本功。你写代码时遇到的很多诡异问题——段错误、内存泄漏、栈溢出——归根结底,都是因为你没搞懂「谁住在哪个区」。嗯,把这个搞明白了,很多问题就迎刃而解了。

可可精品资料库