2. 进程地址空间布局:栈、堆、BSS段、数据段、代码段、内存映射区

在 Linux 系统中,每个进程都拥有独立的虚拟地址空间。理解这个空间的布局是进行内存调试和泄漏排查的基石。本章将详细剖析进程地址空间的各个组成部分:栈、堆、BSS 段、数据段、代码段以及内存映射区。

核心概念: 虚拟地址空间是操作系统为每个进程提供的抽象,它让每个进程都认为自己拥有从 0x0 到 0xFFFFFFFF(32位)或更大范围的连续内存。这种隔离机制保证了进程间的安全性和稳定性。

2.1 地址空间全景图

一个典型的 Linux 进程地址空间从低地址到高地址依次排列。下面的 SVG 图展示了其标准布局,其中各段的相对位置和大小是理解内存管理的关键。

Linux 进程地址空间标准布局 进程虚拟地址空间 (32位示例) 0xFFFFFFFF 0x00000000 内核空间 (Kernel Space) 用户态不可直接访问,由内核管理 栈 (Stack) 局部变量、函数调用帧、返回地址 高地址向低地址增长 (向下增长) 内存映射区 (Memory Mapping Region) 共享库、mmap 文件、匿名映射 堆 (Heap) 动态分配内存 (malloc/new) 低地址向高地址增长 (向上增长) 数据段 (Data) + BSS 段 代码段 (Text / Code) 地址从低到高
关键观察: 栈和堆的增长方向是相反的。栈从高地址向低地址增长,堆从低地址向高地址增长。这种设计使得它们可以共享一段虚拟地址空间,最大化地址空间的利用率。当栈和堆相遇时,通常意味着进程即将耗尽内存。

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 变化,可以快速判断是否存在泄漏。