3. C/C++内存布局

聊到内存泄漏,咱们得先搞清楚程序在内存里到底是怎么“住”的。你想想看,一个C/C++程序跑起来之后,内存不是乱糟糟的一团,而是被划分成了几个功能明确的区域。我刚开始学的时候,总觉得这东西太理论,直到有一次排查一个线上崩溃问题,才发现不懂内存布局,连bug都看不懂。

说白了,内存布局就是程序在运行时的“户型图”。每个区域都有自己的用途和生命周期。搞懂了它,你才能知道变量该放哪、指针该怎么管、内存泄漏到底漏在哪。

核心要点:一个C/C++程序的内存布局,从低地址到高地址,依次是:代码段、数据段、BSS段、堆、内存映射段、栈。每个段都有严格的权限和用途。

C/C++ 进程内存布局(低地址 → 高地址) 地址增长方向 代码段(.text) 只读,存放程序指令 数据段(.data) 已初始化的全局变量和静态变量 BSS段(.bss) 未初始化的全局变量和静态变量 堆(Heap) 动态分配,向高地址增长 内存映射段(mmap) 共享库、文件映射、匿名映射 栈(Stack) 局部变量、函数调用,向低地址增长 ↑ 堆向上增长 ↓ 栈向下增长

3.1 代码段(.text)

代码段存放的是程序的机器指令。嗯,这里要注意:它是只读的。你不可能在程序运行的时候去修改自己的代码,除非你在搞一些很底层的自修改代码——但那在现代操作系统里基本被禁止了。

我记得有一次,一个同事试图通过指针去修改一个函数的内容,结果直接触发了段错误。原因很简单:代码段在内存页表里标记的是只读+可执行,没有写权限。

个人习惯:我一般把代码段想象成一本印刷好的书。你可以读,但不能改。想改?那就得重新编译。

3.2 数据段(.data)和 BSS 段(.bss)

这两个段放的都是全局变量和静态变量。区别在哪?说白了就是一个有初始值,一个没有。

  • 数据段(.data):存放已经初始化的全局变量和静态变量。比如 int global = 42;,这个 42 就存在数据段里。
  • BSS 段(.bss):存放未初始化的全局变量和静态变量。比如 int global_uninit;,它在程序启动时会被自动清零。

你可能会问:为什么要分两个段?其实是为了节省可执行文件的大小。BSS 段只记录变量需要占用的空间大小,不存储实际数据。程序加载时,操作系统直接把这部分内存清零就行。

我曾经踩过的坑:有一次我定义了一个很大的全局数组 char buffer[1024 * 1024 * 100];,但没有初始化。我以为它不会占用可执行文件的空间,结果编译出来的二进制文件还是很大。后来才发现,我忘了加 static 关键字,导致它被放在了数据段而不是 BSS 段。嗯,细节决定成败。

3.3 堆(Heap)

堆是动态内存分配的主战场。malloc、new 分配的内存都来自这里。堆向高地址增长,由程序员手动管理。

我个人习惯把堆想象成一个自由市场。你可以随时申请一块地(分配内存),用完了要记得还回去(释放内存)。如果不还,就造成了内存泄漏。

堆的管理涉及很多数据结构,比如空闲链表、内存池等。不同的分配器(ptmalloc、jemalloc、tcmalloc)实现方式不同,但核心思想是一样的:高效地管理碎片化的内存块。

核心观点:堆是内存泄漏的“重灾区”。90% 的内存泄漏问题都出在堆上。你 malloc 了,但忘了 free;或者 new 了,但忘了 delete。这就是最典型的内存泄漏。

3.4 内存映射段(mmap)

内存映射段是个比较特殊的区域。它主要用于:

  • 加载共享库(.so、.dll)
  • 文件映射(把文件内容映射到内存)
  • 匿名映射(分配大块内存)

你想想看,当你的程序调用 malloc 分配一块很大的内存(比如超过 128KB)时,glibc 的 malloc 实现会直接调用 mmap 来分配,而不是从堆里切一块。这样做的好处是:释放时可以直接 munmap,不会造成堆碎片。

我记得在做一个高性能网络服务器的时候,用 mmap 来映射一个几百兆的日志文件。读取速度比传统的 read/write 快了一个数量级。当然,用完之后一定要记得 munmap,否则就是另一种形式的内存泄漏。

3.5 栈(Stack)

栈是存放局部变量和函数调用信息的地方。它向低地址增长,每个函数调用都会在栈上分配一个“栈帧”。

栈的特点是:自动分配、自动释放。你定义一个局部变量 int a = 10;,它就在栈上。函数返回时,栈帧自动销毁。所以栈上不会发生内存泄漏——但会发生栈溢出。

避坑指南:我曾经在嵌入式项目里定义了一个很大的局部数组 char buf[1024 * 10];,结果程序一运行就崩溃。查了半天才发现是栈空间不够(默认只有 1MB)。后来改成了堆分配,问题解决。记住:大块数据别放栈上。

3.6 各段对比总结

内存段 存储内容 生命周期 管理方式 常见问题
代码段 程序指令 程序运行期间 自动(只读) 修改代码导致段错误
数据段 已初始化全局/静态变量 程序运行期间 自动 全局变量过多导致内存占用大
BSS段 未初始化全局/静态变量 程序运行期间 自动(清零) 误以为不占空间
堆 动态分配的内存 从分配至释放 手动(malloc/free) 内存泄漏、野指针、碎片化
内存映射段 共享库、文件映射 从映射至解除 手动(mmap/munmap) 忘记解除映射导致泄漏
栈 局部变量、函数调用 函数调用期间 自动 栈溢出、返回局部变量地址

3.7 一个简单的验证例子

光说不练假把式。咱们写个小程序,看看各个段的内存地址分布:

#include <stdio.h>
#include <stdlib.h>

int global_init = 100;          // 数据段
int global_uninit;              // BSS段
static int static_init = 200;   // 数据段
static int static_uninit;       // BSS段

int main() {
    int local = 10;             // 栈
    int *heap = malloc(100);    // 堆
    
    printf("代码段: %p\n", main);
    printf("数据段: %p\n", &global_init);
    printf("BSS段:  %p\n", &global_uninit);
    printf("堆:     %p\n", heap);
    printf("栈:     %p\n", &local);
    
    free(heap);
    return 0;
}

运行结果(每次可能不同,但相对位置固定):

代码段: 0x40052d
数据段: 0x601038
BSS段:  0x601044
堆:     0x1a3a010
栈:     0x7ffd8e3a5a4c

看到了吗?地址从低到高:代码段 < 数据段 < BSS段 < 堆 < 栈。堆的地址比栈低,而且堆向上增长,栈向下增长。这就是为什么堆和栈有可能“相撞”——虽然现代操作系统有保护机制,但理论上如果堆一直增长,栈也一直增长,它们会在某个点相遇。