2. 进程地址空间布局:栈、堆、BSS段、数据段、代码段、内存映射区
在 Linux 系统中,每个进程都拥有独立的虚拟地址空间。理解这个空间的布局是进行内存调试和泄漏排查的基石。本章将详细剖析进程地址空间的各个组成部分:栈、堆、BSS 段、数据段、代码段以及内存映射区。
2.1 地址空间全景图
一个典型的 Linux 进程地址空间从低地址到高地址依次排列。下面的 SVG 图展示了其标准布局,其中各段的相对位置和大小是理解内存管理的关键。
2.2 各段详细解析
下面通过表格和详细说明,逐一介绍每个段的作用、存储内容和生命周期。
| 段名称 | 存储内容 | 生命周期 | 增长方向 | 权限 |
|---|---|---|---|---|
| 代码段 (Text) | 可执行指令、只读常量 | 进程启动到结束 | 固定 | 读 + 执行 |
| 数据段 (Data) | 已初始化的全局变量、静态变量 | 进程启动到结束 | 固定 | 读 + 写 |
| BSS 段 | 未初始化的全局变量、静态变量 | 进程启动到结束 | 固定 | 读 + 写 |
| 堆 (Heap) | 动态分配的内存 (malloc, new) | 从分配 (malloc) 到释放 (free) | 向上增长 | 读 + 写 |
| 内存映射区 | 共享库、mmap 文件、匿名映射 | 从 mmap 到 munmap | 可变 | 取决于映射标志 |
| 栈 (Stack) | 局部变量、函数参数、返回地址 | 函数调用到返回 | 向下增长 | 读 + 写 |
2.3 代码段 (Text Segment)
代码段是进程地址空间中最底部的部分,它包含了程序的机器指令。该段通常是只读的,以防止程序意外修改自身指令。此外,代码段中也可能包含一些只读的常量数据,例如字符串字面量。
// 示例:查看代码段地址
#include <stdio.h>
int main() {
printf("main function address: %p\n", main);
// 尝试修改代码段会导致段错误
// char *ptr = (char *)main;
// *ptr = 0x90; // 这会触发 SIGSEGV
return 0;
}
SIGSEGV(段错误)。这是操作系统保护机制的一部分,防止恶意代码修改程序逻辑。
2.4 数据段 (Data Segment) 与 BSS 段
这两个段紧密相关,都用于存储全局变量和静态变量。它们的区别在于初始化状态。
- 数据段: 存储已初始化的全局变量和静态变量。例如
int global_var = 42;。这些变量在程序加载时从可执行文件中读取初始值。 - BSS 段: 存储未初始化的全局变量和静态变量。例如
int global_uninit;。BSS 段在程序加载时被内核初始化为零。BSS 段不占用可执行文件的空间,只记录需要分配的内存大小。
// 示例:数据段与 BSS 段
#include <stdio.h>
int init_global = 100; // 数据段
int uninit_global; // BSS 段 (初始化为 0)
static int static_var = 5; // 数据段
int main() {
printf("init_global: %p\n", &init_global);
printf("uninit_global: %p\n", &uninit_global);
printf("static_var: %p\n", &static_var);
return 0;
}
2.5 堆 (Heap)
堆是用于动态内存分配的区域。C 语言中的 malloc、calloc、realloc 以及 C++ 中的 new 操作符都从堆中分配内存。堆的增长方向是从低地址向高地址。
堆的管理由 brk/sbrk 系统调用和 mmap 系统调用共同完成。当堆空间不足时,malloc 会通过 brk 扩展堆的顶部(称为 program break)。对于大块内存分配(通常大于 128KB),malloc 会直接使用 mmap 在内存映射区分配。
// 示例:堆地址变化
#include <stdio.h>
#include <stdlib.h>
int main() {
int *p1 = (int *)malloc(100 * sizeof(int));
int *p2 = (int *)malloc(100 * sizeof(int));
printf("Heap allocated p1: %p\n", p1);
printf("Heap allocated p2: %p\n", p2);
// 通常 p2 > p1,因为堆向上增长
free(p1);
free(p2);
return 0;
}
2.6 内存映射区 (Memory Mapping Region)
内存映射区是一个灵活的区域,用于多种用途:
- 共享库加载: 动态链接库(如
libc.so)被映射到此区域。 - 文件映射: 通过
mmap系统调用将文件内容映射到内存。 - 匿名映射: 用于分配大块内存(
malloc内部使用)或进程间通信。 - 共享内存: POSIX 共享内存和 System V 共享内存。
该区域通常位于栈和堆之间,其布局由内核动态管理。使用 cat /proc/[pid]/maps 可以查看进程的内存映射详情。
# 查看进程的内存映射
$ cat /proc/$$/maps
00400000-00452000 r-xp 00000000 08:01 1234567 /usr/bin/bash
00651000-00652000 r--p 00051000 08:01 1234567 /usr/bin/bash
00652000-00656000 rw-p 00052000 08:01 1234567 /usr/bin/bash
...
7f8c1c000000-7f8c1c021000 rw-p 00000000 00:00 0 [heap]
7f8c1c400000-7f8c1c601000 r-xp 00000000 08:01 1234568 /lib/x86_64-linux-gnu/libc.so.6
...
7ffd8e400000-7ffd8e421000 rw-p 00000000 00:00 0 [stack]
2.7 栈 (Stack)
栈是函数调用的核心。每次函数调用都会在栈上创建一个栈帧(stack frame),用于存储:
- 函数的局部变量
- 函数的参数
- 返回地址(调用函数的下一条指令地址)
- 保存的寄存器值
栈从高地址向低地址增长。这意味着每次函数调用,栈指针(rsp)的值会减小。当函数返回时,栈帧被弹出,栈指针恢复。
// 示例:栈地址变化
#include <stdio.h>
void func(int depth) {
int local_var = depth;
printf("Depth %d: stack local var at %p\n", depth, &local_var);
if (depth > 0) {
func(depth - 1);
}
}
int main() {
func(3);
return 0;
}
pstack 命令或 GDB 的 backtrace 命令可以查看当前进程的栈回溯,这对于定位程序执行路径和崩溃点至关重要。
2.8 使用 /proc 文件系统查看地址空间
Linux 的 /proc 文件系统提供了查看进程地址空间的直接接口。最常用的文件是 /proc/[pid]/maps 和 /proc/[pid]/smaps。
| 文件 | 描述 | 示例输出列 |
|---|---|---|
/proc/[pid]/maps |
显示进程的内存映射区域 | 地址范围、权限、偏移、设备、inode、路径 |
/proc/[pid]/smaps |
提供更详细的内存使用统计 | RSS、PSS、共享/私有页、脏页等 |
/proc/[pid]/status |
包含 VmPeak、VmSize 等摘要信息 | 虚拟内存峰值、当前虚拟内存大小 |
# 查看当前 shell 进程的地址空间摘要
$ grep -E "VmPeak|VmSize|VmRSS|VmStk|VmData|VmExe" /proc/$$/status
VmPeak: 123456 kB
VmSize: 123000 kB
VmRSS: 45000 kB
VmStk: 132 kB
VmData: 2500 kB
VmExe: 1800 kB
2.9 地址空间布局的调试意义
理解地址空间布局对于内存调试至关重要:
- 泄漏定位: 如果堆持续增长(通过
/proc/pid/status的VmData观察),可能表明存在内存泄漏。 - 栈溢出检测: 栈地址范围是固定的,如果栈指针超出范围,会导致段错误。通过
/proc/pid/maps可以查看栈的边界。 - 共享库冲突: 内存映射区可以显示所有加载的共享库及其地址,有助于诊断库版本冲突或地址冲突。
- ASLR 影响: 地址空间布局随机化(ASLR)会随机化栈、堆、内存映射区的基地址,增加安全性,但也给调试带来挑战。
/proc/[pid]/smaps 的快照,并对比堆段([heap])的 RSS 和 PSS 变化,可以快速判断是否存在泄漏。