3. C/C++内存布局
聊到内存泄漏,咱们得先搞清楚程序在内存里到底是怎么“住”的。你想想看,一个C/C++程序跑起来之后,内存不是乱糟糟的一团,而是被划分成了几个功能明确的区域。我刚开始学的时候,总觉得这东西太理论,直到有一次排查一个线上崩溃问题,才发现不懂内存布局,连bug都看不懂。
说白了,内存布局就是程序在运行时的“户型图”。每个区域都有自己的用途和生命周期。搞懂了它,你才能知道变量该放哪、指针该怎么管、内存泄漏到底漏在哪。
核心要点:一个C/C++程序的内存布局,从低地址到高地址,依次是:代码段、数据段、BSS段、堆、内存映射段、栈。每个段都有严格的权限和用途。
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段 < 堆 < 栈。堆的地址比栈低,而且堆向上增长,栈向下增长。这就是为什么堆和栈有可能“相撞”——虽然现代操作系统有保护机制,但理论上如果堆一直增长,栈也一直增长,它们会在某个点相遇。