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区域位于堆和栈之间。它主要干三件事:
- 文件映射:把磁盘文件直接映射到内存,读写文件就像读写内存一样
- 动态库加载:你用的libc.so、libpthread.so都加载在这里
- 大内存分配:前面说了,malloc分配大内存时,底层用的就是mmap
我个人特别喜欢mmap。为什么?因为它能实现零拷贝。比如你读一个文件,传统方式需要先读到内核缓冲区,再拷贝到用户空间。但用mmap,直接映射,省掉了一次拷贝。我在做高性能日志系统时,就用mmap来写日志文件,性能提升很明显。
2.7 一张图看懂全部
下面这张SVG图,把整个进程地址空间的布局和各个区域的关系画清楚了。你可以把它当作一张「内存地图」来参考。
2.8 各区域的作用总结
| 区域 | 作用 | 增长方向 | 权限 |
|---|---|---|---|
| 代码段 | 存放机器指令 | 固定 | 读+执行 |
| 数据段 | 存放全局/静态变量 | 固定 | 读+写 |
| BSS段 | 存放未初始化全局变量 | 固定 | 读+写 |
| 堆区 | 动态内存分配 | 向上 | 读+写 |
| mmap区域 | 文件映射、动态库、大内存 | 双向 | 取决于映射 |
| 栈区 | 函数调用、局部变量 | 向下 | 读+写 |
最后说一句:理解进程地址空间,是系统编程的基本功。你写代码时遇到的很多诡异问题——段错误、内存泄漏、栈溢出——归根结底,都是因为你没搞懂「谁住在哪个区」。嗯,把这个搞明白了,很多问题就迎刃而解了。