🛡️

身份验证

答对题目即可进入,验证你是谁

剩余尝试次数:3

C 内存模型全景

C语言学习教程

C 内存模型全景

理解 C 程序在内存中的布局,是从”会写 C”到”精通 C”的分水岭。本文将带你逐段解剖 C 程序的内存世界。

为什么必须理解内存模型?

当你写 int x = 10; 时,x 被放在哪里?当你 malloc(100) 时,这 100 字节从哪来?当你忘记 free 时,为什么程序会吃掉所有内存?——所有这些问题,答案都藏在内存模型中。

┌──────────────────────────────────────────────────────────┐
│                      一个 C 程序的内存全景                  │
├──────────────────────────────────────────────────────────┤
│                                                          │
│    高地址 (0x7FFFFFFF...)                                 │
│    ┌──────────────────────────────┐                      │
│    │         命令行参数与环境变量    │ ← argc, argv, envp  │
│    ├──────────────────────────────┤                      │
│    │     ★ Stack(栈)              │ ← 局部变量、函数帧   │
│    │     自动管理,LIFO            │   地址从高向低增长    │
│    │         ↓ 向下生长 ↓          │                      │
│    ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤                      │
│    │     ▲ 空闲区域 ▲              │ ← 灵活伸缩           │
│    │     (stack 和 heap 相对生长)   │                      │
│    ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤                      │
│    │         ↑ 向上生长 ↑          │                      │
│    │     ★ Heap(堆)              │ ← malloc/calloc/realloc│
│    │     手动管理,需要 free        │   地址从低向高增长    │
│    ├──────────────────────────────┤                      │
│    │     ★ BSS 段                  │ ← 未初始化全局/静态   │
│    │     (Block Started by Symbol) │   程序启动时自动清零  │
│    ├──────────────────────────────┤                      │
│    │     ★ Data 段                 │ ← 已初始化全局/静态   │
│    │     .data (读写) /.rodata (只读)│                      │
│    ├──────────────────────────────┤                      │
│    │     ★ Text 段(代码段)        │ ← 机器指令 + 字符串  │
│    │     只读,不可修改            │   常量可能在这里      │
│    └──────────────────────────────┘                      │
│    低地址 (0x00400000...)                                 │
│                                                          │
└──────────────────────────────────────────────────────────┘

第一段:Text 段(代码段 / .text)

Text 段存放编译后的机器指令。它被操作系统标记为只读+可执行,这是安全的基础——你的代码不能在运行时修改自身。

#include <stdio.h>

// 这个函数的所有机器指令都在 Text 段
int add(int a, int b) {
    return a + b;
}

// 字符串字面量 "Hello\n" 通常也放在 Text 段(或 .rodata)
// 注意:它是只读的!
int main() {
    printf("Hello\n");          // 字符串 "Hello\n" 在 Text/.rodata
    // char *s = "Hello";
    // s[0] = 'h';              // 未定义行为!试图修改只读内存
    printf("add 的地址: %p\n", (void*)add);  // 打印函数地址 → Text 段
    return 0;
}

Text 段的特点:

第二段:Data 段(数据段 / .data)

Data 段存放已显式初始化的全局变量和静态变量。分为两个子区域:

子段全称存放内容权限
.data已初始化数据段初始值非零的全局/静态变量读写
.rodata只读数据段const 全局变量、字符串字面量只读
#include <stdio.h>

// ===== 以下变量位于 .data 段 =====
int global_score = 100;          // 初始化为非零值 → .data
static int level = 5;            // 静态变量,非零初始值 → .data
double pi = 3.14159;             // 初始化为非零 → .data

// ===== 以下变量位于 .rodata 段 =====
const int MAX_PLAYERS = 4;       // const 全局 → .rodata
// "Welcome" 这个字符串字面量也在 .rodata

// ===== 以下变量位于 BSS 段 =====
int global_uninit;               // 未初始化 → BSS
static int counter;              // 未初始化 → BSS
// BSS 变量在可执行文件中不占空间,运行时由 OS 清零

int main() {
    static int call_count = 0;   // 静态局部,初始化为0 → .data 或 BSS
    call_count++;
    printf("此函数被调用了 %d\n", call_count);
    return 0;
}

一个容易混淆的点:

// 情况1:显式初始化为 0
int a = 0;    // 某些编译器放入 BSS(优化),某些放入 .data
static int b = 0;  // 同上

// 情况2:未初始化
int c;        // 一定在 BSS
static int d; // 一定在 BSS

// 情况3:数组初始化
char msg1[] = "hello";  // .data —— 可修改的局部副本
char *msg2 = "hello";   // msg2 在 .data,但 "hello" 在 .rodata!(注意区别)

第三段:BSS 段

BSS 代表 “Block Started by Symbol”,存放未初始化(或初始化为 0)的全局/静态变量。

┌─────────────────────────────────┐
│  BSS 段的魔法:                   │
│                                 │
│  可执行文件中:                    │
│  ┌─────────────────┐            │
│  │ 只记录"需要 N 字节" │ ← 不保存实际数据  │
│  │ 实际数据全是 0      │            │
│  └─────────────────┘            │
│                                 │
│  程序加载时:                     │
│  ┌─────────────────┐            │
│  │ OS 分配 N 字节     │            │
│  │ 全部清零(填 0)    │            │
│  └─────────────────┘            │
│                                 │
│  好处:                           │
│  int big_array[1000000] = {0};  │
│  在可执行文件中几乎不占空间!       │
│  如果写成 = {1,2,3,...} 就会膨胀  │
│  可执行文件大小。                  │
└─────────────────────────────────┘
// 验证 BSS 的零初始化特性
#include <stdio.h>

int global_array[1000];    // BSS —— 启动时全为 0
static double values[500]; // BSS —— 启动时全为 0.0

int main() {
    // 未初始化的全局数组,每个元素保证为 0
    printf("global_array[0] = %d\n", global_array[0]);    // 0
    printf("global_array[999] = %d\n", global_array[999]); // 0
    printf("values[0] = %f\n", values[0]);                 // 0.000000

    // 局部变量不会自动初始化!
    int local_uninit;
    // printf("%d\n", local_uninit);  // 未定义行为!值是垃圾数据
    return 0;
}

第四段:Heap(堆)—— 动态内存的核心

堆是 C 语言最灵活也最危险的内存区域。你通过 malloccallocrealloc 申请,通过 free 归还。

┌────────────────────────────────────────────────────────────┐
│                      堆的内部管理示意                         │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  brk 指针(堆顶)                                            │
│  ↓                                                         │
│  ┌─────────────────────────────────────────────┐           │
│  │  已分配块3  │  空闲块2  │  已分配块2  │  空闲块1  │ ... │   │
│  └─────────────────────────────────────────────┘           │
│        ↑                                                    │
│  程序刚开始时的 brk                                          │
│                                                            │
│  每个内存块都有"头部"(元数据):                               │
│  ┌──────────┬──────────────────────────────┐               │
│  │ 元数据    │  用户可用数据                  │               │
│  │ (大小+标志)│  (malloc 返回的指针指向这里)    │               │
│  └──────────┴──────────────────────────────┘               │
│  写入超出用户数据区 → 破坏下一个块的元数据 → 程序崩溃         │
│                                                            │
└────────────────────────────────────────────────────────────┘

malloc —— 分配未初始化内存

#include <stdlib.h>

// 原型:void* malloc(size_t size);
// 参数:要分配的字节数
// 返回:指向分配内存的指针,失败返回 NULL

int *p = (int*)malloc(sizeof(int));       // 分配 1 个 int(4 或 8 字节)
int *arr = (int*)malloc(10 * sizeof(int)); // 分配 10 个 int 的数组
char *str = (char*)malloc(256);            // 分配 256 字节的字符缓冲区

// ★ 关键:malloc 不清零!内存中是垃圾数据
// ★ 关键:一定要检查返回值!
if (p == NULL) {
    printf("内存分配失败!\n");
    exit(1);
}

calloc —— 分配并清零内存

#include <stdlib.h>

// 原型:void* calloc(size_t nmemb, size_t size);
// 参数1:元素个数
// 参数2:每个元素的大小
// 返回:指向清零内存的指针

// malloc 方式(不推荐):
int *arr1 = (int*)malloc(10 * sizeof(int));
memset(arr1, 0, 10 * sizeof(int));  // 需要手动清零

// calloc 方式(一步到位):
int *arr2 = (int*)calloc(10, sizeof(int));  // 分配 + 清零

// 二者等价,但 calloc 更安全、更清晰
// ★ calloc 也会返回 NULL,必须检查!
if (arr2 == NULL) {
    printf("calloc 失败!\n");
    exit(1);
}

calloc 的乘法溢出保护: 大多数现代 calloc 实现会检查 nmemb * size 是否会溢出:

// 危险:如果 nmemb 和 size 都很大,乘积可能溢出
// 好的 calloc 实现会检测溢出并返回 NULL
size_t n = 999999999999;
int *danger = (int*)calloc(n, sizeof(int));  // 好的实现返回 NULL

realloc —— 调整已分配内存的大小

#include <stdlib.h>

// 原型:void* realloc(void* ptr, size_t new_size);
// 参数1:指向原有内存的指针(可以是 NULL)
// 参数2:新的大小

int *arr = (int*)malloc(5 * sizeof(int));  // 先分配 5 个 int
// ... 使用 arr ...

// 发现不够用,扩大到 10 个 int
int *tmp = (int*)realloc(arr, 10 * sizeof(int));
if (tmp == NULL) {
    // realloc 失败!但原内存 arr 仍然有效
    printf("realloc 失败,使用原有内存\n");
} else {
    arr = tmp;  // 安全赋值
}

// realloc(NULL, size) 等价于 malloc(size)
// realloc(ptr, 0) 等价于 free(ptr),但行为依赖实现,不推荐

realloc 的内部行为:

情况1:原地扩展(后面有足够空闲空间)
┌──────┬──────────┬──────────┐
│ arr  │ 空闲     │ ...      │
│ 5元素│ 空间     │          │
└──────┴──────────┴──────────┘
       ↓ realloc(arr, 10*sizeof(int))
┌──────────────┬──────────┐
│ arr          │ ...      │   ← 指针不变!
│ 10元素       │          │
└──────────────┴──────────┘

情况2:搬迁(后面空间不足)
┌──────┬──────────┬──────────┐
│ arr  │ 别的数据  │ ...      │  ← 后面被占了
│ 5元素│          │          │
└──────┴──────────┴──────────┘
       ↓ realloc(arr, 10*sizeof(int))
err     ┌──────────────┬────────────┐
 arr    │ 空闲(被free)│ 新位置      │  ← 指针变了!
        │              │ 10元素     │      旧数据被复制到新位置
        └──────────────┴────────────┘

free —— 归还内存

#include <stdlib.h>

int *p = (int*)malloc(sizeof(int) * 100);
// ... 使用 p ...

free(p);    // 归还内存
p = NULL;   // ★ 良好的习惯:释放后立即置空

// free 注意事项:
// 1. free(NULL) 是安全的,什么都不做
// 2. 不要 free 同一个指针两次(双重释放)
// 3. 不要 free 栈上的变量
// 4. 不要 free 非 malloc/calloc/realloc 返回的指针

free 后内存的变化示意:

free 前:
┌──────────────────────────────────────┐
│ p → [42][99][7][...] (属于程序的堆内存)│
└──────────────────────────────────────┘

free(p) 后:
┌──────────────────────────────────────┐
│ p → [??][??][??][...] (已归还OS/分配器)│
│     ↑                                │
│   内容可能未变,但你不能再访问了!       │
│   这是悬空指针的根本原因                │
└──────────────────────────────────────┘

free(p); p = NULL; 后:
p → NULL  (安全,任何对 *p 的访问会立即崩溃,而不是悄悄出错)

第五段:Stack(栈)

栈是自动管理的内存,每个函数调用都会在栈上创建”栈帧”(Stack Frame)。

┌──────────────────────────────────────────────────────────┐
│              函数调用时的栈帧变化                           │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  程序启动时:                                              │
│  ┌────────────┐                                          │
│  │ main 的栈帧 │ ← 局部变量、返回地址                       │
│  └────────────┘                                          │
│      ↓ 高地址 → 低地址                                     │
│                                                          │
│  main 调用 funcA():                                      │
│  ┌────────────┐                                          │
│  │ main 的栈帧 │                                          │
│  ├────────────┤ ← 调用时压入                               │
│  │ funcA 栈帧  │   参数、返回地址、局部变量                  │
│  └────────────┘                                          │
│                                                          │
│  funcA 调用 funcB():                                     │
│  ┌────────────┐                                          │
│  │ main 的栈帧 │                                          │
│  ├────────────┤                                          │
│  │ funcA 栈帧  │                                          │
│  ├────────────┤                                          │
│  │ funcB 栈帧  │ ← 新的栈顶                                │
│  └────────────┘                                          │
│      ↓                                                   │
│  funcB 返回 → funcB 栈帧弹出消失                           │
│  funcA 返回 → funcA 栈帧弹出消失                           │
│                                                          │
└──────────────────────────────────────────────────────────┘
#include <stdio.h>

void funcB(int z) {              // z 进入栈(参数)
    int w = z * 2;               // w 进入栈(局部变量)
    char buf[128];               // buf 进入栈(局部数组)
    printf("funcB: z=%d, w=%d\n", z, w);
}                                // 函数返回 → z, w, buf 全部自动释放

void funcA(int x) {              // x 进入栈
    int y = x + 1;               // y 进入栈
    printf("funcA: x=%d, y=%d\n", x, y);
    funcB(y);                    // funcB 的栈帧在 funcA 之上
}                                // funcB 的栈帧已消失

int main() {
    int a = 10;                  // a 进入栈
    funcA(a);
    return 0;
}                                // 程序结束 → 所有栈帧销毁

栈 vs 堆 对比:

┌──────────────────┬──────────────────────┬──────────────────────┐
│      特性        │       栈 (Stack)     │      堆 (Heap)       │
├──────────────────┼──────────────────────┼──────────────────────┤
│ 管理方式         │ 自动(编译器管理)    │ 手动(程序员管理)    │
│ 分配/释放速度    │ 极快(移动栈指针)    │ 较慢(查找空闲块)    │
│ 大小限制         │ 较小(通常 1-8 MB)   │ 较大(受虚拟内存限制)│
│ 生命周期         │ 函数内                │ 由程序员控制         │
│ 碎片问题         │ 无                    │ 可能有               │
│ 溢出后果         │ Stack Overflow → 崩溃 │ 内存泄漏 → 渐进崩溃   │
│ 典型用途         │ 局部变量、小数组      │ 大数组、动态结构     │
└──────────────────┴──────────────────────┴──────────────────────┘

完整的变量存放位置总结

#include <stdlib.h>

int global_init = 42;           // → Data 段 (.data)
int global_uninit;              // → BSS 段
static int file_static = 10;    // → Data 段 (.data)
const int READONLY = 100;       // → .rodata
char *str_literal = "hello";    // str_literal → Data 段
                                // "hello" → .rodata

int main() {
    int local = 5;              // → Stack
    static int local_static;    // → BSS 段
    static int ls_init = 20;    // → Data 段 (.data)

    int *p = malloc(100);       // p → Stack
                                // *p → Heap

    char arr[] = "world";       // arr → Stack
                                // "world" → .rodata(被复制到栈)

    free(p);
    return 0;
}

用地址推断变量位置

不同段的地址有明显特征:

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

int global_data = 100;      // Data/BSS 段
int global_bss;              // BSS 段
static int static_data = 200; // Data 段

int main() {
    int stack_var = 0;                       // Stack
    int *heap_var = malloc(sizeof(int));     // Heap
    static int local_static = 300;           // Data 段
    const char *ro_str = "constant";         // .rodata

    printf("=== 内存地址分布 ===\n");
    printf("Text 段 (main):   %p\n", (void*)main);
    printf(".rodata (字符串): %p\n", (void*)ro_str);
    printf("Data 段 (global):  %p\n", (void*)&global_data);
    printf("Data 段 (static):  %p\n", (void*)&static_data);
    printf("Data 段 (ls):      %p\n", (void*)&local_static);
    printf("BSS 段 (g_uninit): %p\n", (void*)&global_bss);
    printf("Heap (malloc):    %p\n", (void*)heap_var);
    printf("Stack (local):    %p\n", (void*)&stack_var);

    // 典型的输出(地址从小到大):
    // Text 段 → .rodata → Data 段 → BSS 段 → Heap → ... → Stack
    //    低地址                                        → 高地址

    free(heap_var);
    return 0;
}

内存泄漏的直观理解

想象你去图书馆借书。每次 malloc 就是借一本书,每次 free 就是还书。

// 正常流程:借 → 用 → 还
void good_citizen() {
    int *book = malloc(1000);  // 借一本书
    // ... 使用这本书 ...
    free(book);                 // 按时归还
    book = NULL;
}

// 内存泄漏:借了不还
void bad_citizen() {
    int *book = malloc(1000);  // 借一本书
    // ... 使用这本书 ...
    // 忘记还了!
}  // 函数结束 → 借书证(指针)销毁了 → 书(内存)永远丢失

// 循环中泄漏 → 灾难
void disaster() {
    for (int i = 0; i < 1000000; i++) {
        int *leak = malloc(1024);  // 每次循环借 1KB
        // 从不归还
    }
    // 结果:泄漏了约 1GB 内存!
    // 程序越来越慢 → 系统内存耗尽 → 程序崩溃或被 OS 杀掉
}

valgrind 式自查思维

即使没有 valgrind,你也应该养成”valgrind 式思维”——写每一行 malloc 时就问自己:

┌─────────────────────────────────────────────────────────┐
│              Valgrind 式自查清单                          │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ✏ 每写一个 malloc → 立即写下对应的 free                  │
│                                                         │
│  📋 对每个 malloc 的返回值,问三个问题:                    │
│     1. 这个指针有没有保存下来?(别丢了"借书证")          │
│     2. 这个指针最后会不会被 free?(别忘记"还书")         │
│     3. free 之后还会不会用这个指针?(悬空指针)           │
│                                                         │
│  🔍 检查要点:                                           │
│     □ 所有代码路径都 free 了吗?(包括错误返回路径)       │
│     □ realloc 有没有用临时变量接收?                      │
│     □ free 后指针有没有置 NULL?                          │
│     □ 有没有函数返回了局部变量的地址?                     │
│     □ 有没有越界写入数组?                                │
│                                                         │
│  记忆口诀:                                              │
│     "有借有还,再借不难"  —— malloc/free 配对             │
│     "人走茶凉,东西还在"  —— 指针销毁 ≠ 内存释放          │
│     "墙上钉钉,不能乱拔"  —— 别 free 栈变量/非堆指针     │
│                                                         │
└─────────────────────────────────────────────────────────┘

实践:实现一个安全的动态数组

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

typedef struct {
    int *data;      // 堆上的数组
    int size;       // 当前元素个数
    int capacity;   // 已分配容量
} DynArray;

// 创建动态数组
DynArray* da_create(int initial_capacity) {
    DynArray *da = malloc(sizeof(DynArray));
    if (da == NULL) return NULL;

    da->data = calloc(initial_capacity, sizeof(int));
    if (da->data == NULL) {
        free(da);  // ★ 注意:da 已经分配了,这里要释放
        return NULL;
    }

    da->size = 0;
    da->capacity = initial_capacity;
    return da;
}

// 扩容
int da_resize(DynArray *da, int new_capacity) {
    int *tmp = realloc(da->data, new_capacity * sizeof(int));
    if (tmp == NULL) return 0;  // 失败,原数据不变

    da->data = tmp;
    // 新空间初始化为0
    memset(da->data + da->capacity, 0,
           (new_capacity - da->capacity) * sizeof(int));
    da->capacity = new_capacity;
    return 1;
}

// 添加元素
int da_push(DynArray *da, int value) {
    if (da->size >= da->capacity) {
        if (!da_resize(da, da->capacity * 2)) {
            return 0;  // 扩容失败
        }
    }
    da->data[da->size++] = value;
    return 1;
}

// 销毁动态数组(完整的清理)
void da_destroy(DynArray *da) {
    if (da == NULL) return;       // 允许 free(NULL)
    free(da->data);                // 先释放内部数组
    da->data = NULL;
    free(da);                      // 再释放结构体本身
    // 调用者应该把指针置 NULL
}

int main() {
    DynArray *nums = da_create(4);
    if (nums == NULL) {
        printf("创建失败\n");
        return 1;
    }

    da_push(nums, 10);
    da_push(nums, 20);
    da_push(nums, 30);
    da_push(nums, 40);
    da_push(nums, 50);  // 触发扩容:4 → 8

    for (int i = 0; i < nums->size; i++) {
        printf("%d ", nums->data[i]);
    }
    printf("\n容量: %d\n", nums->capacity);

    da_destroy(nums);
    nums = NULL;  // 好习惯
    return 0;
}

要点总结

概念核心思想
Text 段代码只读,不可修改,编译时确定大小
Data 段已初始化全局/静态变量,可执行文件中占空间
BSS 段未初始化全局/静态变量,启动时自动清零,不占文件空间
Heap(堆)malloc/calloc/realloc 申请,free 释放,手动管理,灵活但危险
Stack(栈)局部变量+函数调用,自动管理,速度快但空间小
内存泄漏分配了不释放 = 丢了”借书证”,堆内存永远丢失
悬空指针free 后用指针 = 访问已归还的内存 = 未定义行为

记住这句话:

“理解你的变量住在哪里、何时出生、何时死亡——你就真正理解了 C 语言。”