星光编译者头像
【C 语言】动态内存管理全解析:malloc、calloc、realloc、free 与柔性数组封面图

【C 语言】动态内存管理全解析:malloc、calloc、realloc、free 与柔性数组

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把“为什么会这样”和“应该怎样解决”说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

星光编译者博客开场动画

🔥 本文定位:围绕课程第 21 讲,系统讲清动态内存为什么存在、四个核心函数怎样配合、常见错误为什么危险、经典指针题如何分析,以及柔性数组怎样实现“一次分配”。

💡 学习目标:能写出安全的动态数组分配与扩容代码;能说明 realloc 为什么必须使用临时指针;能识别越界、重复释放、释放内部地址、内存泄漏和释放后使用;能正确计算柔性数组对象所需空间。

📌 平台说明:本文使用“栈”和“堆”描述常见实现中的内存区域。C 语言标准主要规定对象的存储期、作用域和函数行为,并不要求所有平台采用完全相同的物理内存布局。涉及对象大小时,应以目标平台的 sizeof 结果为准。


文章目录


一、为什么需要动态内存

1.1 固定数组解决不了所有问题

我们已经很熟悉下面两种写法:

int value = 20;
int numbers[10] = {0};

它们有一个共同特点:程序在进入相应作用域时,所需空间已经能够确定。numbers 的容量固定为 10,一旦定义完成,就不能把这个数组对象“变成”可容纳 100 个元素的数组。

可现实中的数据规模经常要到运行时才知道:

  • 用户输入多少个整数;
  • 文件中有多少条记录;
  • 网络报文的载荷有多长;
  • 容器在运行过程中要保存多少元素;
  • 某个结构体后面需要附带多大的可变数据。

如果总是预留一个“足够大”的数组,就会遇到两个问题:预留太小可能溢出,预留太大又长期浪费空间。动态内存分配让程序可以在运行时按需要申请一块空间,并在不再使用时主动归还。

在这里插入图片描述

1.2 动态内存真正解决的三个问题

动态分配不只是“数组可以更大”,它同时提供了三种能力:

  1. 容量在运行时决定:空间大小可以来自输入、文件或计算结果;
  2. 对象生命周期可跨越函数调用:只要没有释放,动态对象不会因为创建它的函数返回就自动消失;
  3. 容量可以重新调整:借助 realloc,程序可以让一块已有空间扩大或缩小。

灵活性也意味着责任发生了变化。自动对象通常在离开作用域时结束生命周期,而动态对象需要程序自己管理。于是我们必须始终回答:

申请了多少字节?
返回值是否为空?
哪些下标可以访问?
谁拥有这块空间?
什么时候释放?
失败路径会不会漏掉释放?

动态内存管理最难的地方从来不是记住四个函数名,而是维护好地址、容量、所有权和生命周期之间的一致性。

1.3 需要包含哪个头文件

malloc、calloc、realloc 和 free 都声明在 <stdlib.h> 中:

#include <stdlib.h>

若忘记包含正确头文件,现代编译器通常会给出诊断。不要忽略这类警告,更不要依赖旧式的“隐式函数声明”。函数原型缺失可能让调用约定或返回值处理发生错误。


二、malloc:按字节申请一块连续空间

2.1 函数原型与返回值

malloc 的函数原型为:

void *malloc(size_t size);

它尝试申请至少 size 字节、适合保存任意具有基本对齐要求对象的连续存储空间:

  • 成功时返回这块空间的起始地址;
  • 失败时返回空指针;
  • 新空间中的字节值没有被初始化,不能在写入有效值之前读取;
  • 参数与返回值都使用无符号整数大小类型 size_t 或通用指针类型 void *。

void * 不携带具体对象类型。把它赋给 int * 后,我们按照 int 数组来使用;赋给 double * 后,就按照 double 对象来使用。空间的“类型含义”来自程序怎样创建和访问其中的对象,而不是 malloc 自己知道了我们要保存什么。

2.2 C 语言中不必强制转换返回值

在 C 语言里,void * 可以隐式转换为其他对象指针类型,因此更推荐这样写:

int *values = malloc(10 * sizeof *values);

而不是:

int *values = (int *)malloc(10 * sizeof(int));

第一种写法有三个优点:

  • 不需要重复写类型;
  • 即使以后把 values 改成其他类型,sizeof *values 仍会自动匹配;
  • 如果漏掉 <stdlib.h>,不会因为强制转换掩盖一部分有价值的编译器诊断。

注意:这是 C 语言 的写法。C++ 不允许把 void * 隐式转换为其他对象指针,但 C++ 通常也不应使用 malloc 管理普通对象,而应优先使用语言自己的资源管理工具。

2.3 申请一个运行时长度的整型数组

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

int main(void)
{
    size_t count = 0;

    printf("请输入元素个数:");
    if (scanf("%zu", &count) != 1)
    {
        fputs("输入无效\n", stderr);
        return EXIT_FAILURE;
    }

    int *values = malloc(count * sizeof *values);
    if (values == NULL && count != 0)
    {
        fputs("内存申请失败\n", stderr);
        return EXIT_FAILURE;
    }

    for (size_t index = 0; index < count; ++index)
    {
        values[index] = (int)index;
    }

    for (size_t index = 0; index < count; ++index)
    {
        printf("%d ", values[index]);
    }
    putchar('\n');

    free(values);
    values = NULL;
    return EXIT_SUCCESS;
}

这里最重要的不是语法,而是完整生命周期:计算大小、申请、检查、在边界内访问、释放、清除旧地址。

在这里插入图片描述

2.4 为什么必须检查乘法溢出

下面的表达式看起来很自然:

count * sizeof *values

但 size_t 是无符号整数类型。如果 count 极大,乘法可能发生回绕,得到一个远小于真实需求的字节数。malloc 可能成功返回这块“小空间”,随后程序却按 count 个元素写入,最终造成严重越界。

可以在乘法之前检查:

#include <stdint.h>
#include <stdlib.h>

if (count > SIZE_MAX / sizeof *values)
{
    fputs("申请大小溢出\n", stderr);
    return EXIT_FAILURE;
}

values = malloc(count * sizeof *values);

若项目环境提供专门的安全乘法辅助函数,也可以统一封装。关键原则是:在把元素个数换算成字节数时,先证明计算不会溢出。

2.5 malloc(0) 不适合作为业务约定

零大小分配在不同语言版本与实现中存在需要谨慎处理的规则,返回值也不适合被当成普通可访问对象使用。工程代码最好在调用前明确处理 count == 0:

if (count == 0)
{
    values = NULL;
}
else
{
    values = malloc(count * sizeof *values);
}

这样接口语义更清楚:零个元素对应空指针与零容量,调用者不必猜测某次零大小分配返回了什么。


三、free:结束对象生命周期并归还空间

3.1 free 的基本规则

函数原型如下:

void free(void *ptr);

free 用来结束动态分配对象的生命周期并把空间交还给分配器。合法参数应当满足以下条件之一:

  • ptr 是空指针;
  • ptr 是此前由动态分配函数成功返回、当前尚未释放的起始地址。

这意味着下面几类操作都不合法:

int local = 10;
free(&local);       /* 错:不是动态分配对象 */

int *p = malloc(10 * sizeof *p);
free(p + 1);        /* 错:不是分配器返回的起始地址 */

free(p);
free(p);            /* 错:同一动态对象重复释放 */

而下面这行是安全的:

free(NULL);

因此,清理代码通常不必先写 if (p != NULL)。直接调用 free(p) 即可。

3.2 为什么释放后常把指针置空

free(p);
p = NULL;

free(p) 之后,动态对象已经不存在,但变量 p 中仍可能保留旧地址。这个地址叫作悬空指针值。若继续通过它读写,就是释放后使用。

把 p 设为 NULL 有两个现实好处:

  • 后续误解引用更容易暴露为明显错误,而不是悄悄破坏别的对象;
  • 再次执行 free(p) 不会形成重复释放。

不过,置空不是万能药。假设还有另一个别名指针:

int *p = malloc(10 * sizeof *p);
int *alias = p;

free(p);
p = NULL;

alias[0] = 42; /* 仍然是释放后使用 */

只修改 p 不会自动修改 alias。真正可靠的办法是设计清楚的所有权:尽量只有一个位置负责释放,并避免让可写别名在释放之后继续存在。

3.3 推荐使用单一清理出口

当函数中有多个失败点时,最容易出现“某个分支忘记释放”。C 项目常用统一清理标签组织资源回收:

int process(size_t count)
{
    int result = -1;
    int *values = NULL;
    char *buffer = NULL;

    if (count > SIZE_MAX / sizeof *values)
    {
        goto cleanup;
    }

    values = malloc(count * sizeof *values);
    if (values == NULL && count != 0)
    {
        goto cleanup;
    }

    buffer = malloc(4096);
    if (buffer == NULL)
    {
        goto cleanup;
    }

    /* 业务处理 */
    result = 0;

cleanup:
    free(buffer);
    free(values);
    return result;
}

goto cleanup 在这里不是随意跳转,而是把所有权回收集中到一个可审计的位置。资源种类越多,这种结构越能减少遗漏。


四、calloc:分配并把每个字节清零

4.1 函数原型

void *calloc(size_t count, size_t size);

calloc 接收“元素个数”和“每个元素的字节数”,尝试分配足以容纳它们的连续空间,并把分配到的每个字节清零:

int *values = calloc(count, sizeof *values);

与下面的写法相比:

int *values = malloc(count * sizeof *values);

二者获得的空间用途相同,但初始化状态不同。

在这里插入图片描述

4.2 “字节清零”比“所有类型都完成默认初始化”更准确

课程中常说 calloc 会把空间初始化为 0。更严谨的表达是:它把分配区域中的每个字节设置为零。

对于常见平台上的整型数组,这会得到数值 0;但不要把它无限泛化为“任意类型都被构造成语言意义上的默认值”。例如,语言并不要求所有实现的空指针表示都一定是全零比特模式。

因此,calloc 很适合:

  • 创建初始计数为 0 的普通整数数组;
  • 建立需要零填充的字节缓冲区;
  • 初始化由全零对象表示满足需求的数据块。

如果对象需要更复杂的初始状态,应在分配后逐项赋值。

4.3 calloc 仍然可能失败

不要因为它同时完成清零,就忘记检查返回值:

double *samples = calloc(count, sizeof *samples);
if (samples == NULL && count != 0)
{
    fputs("分配失败\n", stderr);
    return EXIT_FAILURE;
}

即使所需空间不大,系统限制、地址空间碎片或其他资源约束也可能让分配失败。可靠代码不能把“通常会成功”当成契约。


五、realloc:安全地调整已有空间

5.1 函数原型与基本语义

void *realloc(void *ptr, size_t new_size);

realloc 尝试把 ptr 所代表的动态分配对象调整为 new_size 字节。成功时会保留原对象前面的一部分内容,保留长度为旧大小和新大小中的较小者。

当空间扩大时,新增区域中的内容没有初始化,不能直接当成零使用;当空间缩小时,超出新大小的尾部内容被丢弃。

5.2 为什么地址可能变化

分配器大致有两种选择:

  1. 原内存块后面有足够连续空间,于是在原地扩大;
  2. 后方空间不足,于是另找一块更大的连续区域,复制旧内容,并结束旧对象。

在这里插入图片描述

调用者无法预判是哪一种。即使某次返回地址与原地址相同,也不能据此假设下次仍会原地扩展。成功调用后,后续代码必须以返回值作为新的有效地址。

5.3 最危险的写法:直接覆盖原指针

ptr = realloc(ptr, new_size);

如果调用成功,这段代码看似没有问题;但如果失败,realloc 返回 NULL,原动态对象仍然存在,而 ptr 已被覆盖。程序丢失了唯一的起始地址,再也无法访问或释放旧对象,形成内存泄漏。

推荐先使用临时指针:

void *tmp = realloc(ptr, new_size);
if (tmp == NULL)
{
    /* 原 ptr 仍然有效,由当前策略决定继续使用还是释放 */
}
else
{
    ptr = tmp;
}

在这里插入图片描述

5.4 扩展动态数组的完整示例

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

int grow_int_array(int **array, size_t *capacity, size_t min_capacity)
{
    size_t new_capacity = *capacity == 0 ? 8 : *capacity;

    while (new_capacity < min_capacity)
    {
        if (new_capacity > SIZE_MAX / 2)
        {
            return 0;
        }
        new_capacity *= 2;
    }

    if (new_capacity > SIZE_MAX / sizeof **array)
    {
        return 0;
    }

    int *tmp = realloc(*array, new_capacity * sizeof **array);
    if (tmp == NULL)
    {
        return 0;
    }

    *array = tmp;
    *capacity = new_capacity;
    return 1;
}

这个函数同时更新地址和容量,并且只在 realloc 成功后修改调用者状态。调用方式如下:

int *values = NULL;
size_t size = 0;
size_t capacity = 0;

if (size == capacity)
{
    if (!grow_int_array(&values, &capacity, size + 1))
    {
        free(values);
        return EXIT_FAILURE;
    }
}

values[size++] = 42;

这里传入 int **,因为函数需要修改调用者自己的指针变量;同时传入 size_t *capacity,因为地址与容量必须作为同一状态一起更新。

5.5 realloc(NULL, size) 与零大小问题

当 ptr == NULL 且大小非零时,realloc 可以像 malloc 一样申请新空间:

int *p = NULL;
p = realloc(p, 10 * sizeof *p);

但不要把 realloc(ptr, 0) 当作通用的 free(ptr) 替代品。不同语言版本和实现对此类零大小调用的细节并不适合成为业务契约。要释放对象就明确调用 free,要表示空数组就把容量设为 0,并把对应指针置为 NULL。


六、动态内存的六类常见错误

在这里插入图片描述

6.1 对空指针解引用

int *p = malloc(SIZE_MAX);
*p = 20; /* 若申请失败,p 为 NULL */

任何分配函数都可能失败。检查必须发生在第一次解引用之前:

int *p = malloc(bytes);
if (p == NULL)
{
    return EXIT_FAILURE;
}

*p = 20;

6.2 对动态空间越界访问

int *p = malloc(10 * sizeof *p);
if (p == NULL)
{
    return;
}

for (size_t index = 0; index <= 10; ++index)
{
    p[index] = (int)index;
}

有效下标是 0 到 9,条件应写成 index < 10。p[10] 已经越过对象末尾。越界不一定立刻崩溃,它可能覆盖分配器元数据、邻近对象或暂时没有表现,因而更加危险。

6.3 释放非动态对象

int value = 10;
int *p = &value;
free(p); /* 未定义行为 */

p 确实是一个地址,但“是地址”不等于“可以交给 free”。分配器只认识自己交付且尚未释放的动态对象起始地址。

6.4 释放动态对象的内部地址

int *p = malloc(10 * sizeof *p);
if (p == NULL)
{
    return;
}

++p;
free(p); /* p 不再是原始起始地址 */

如果业务需要移动游标,应保留原始指针:

int *base = malloc(10 * sizeof *base);
int *cursor = base;

/* cursor 可以移动,但释放 base */
free(base);

6.5 重复释放

int *p = malloc(100);
free(p);
free(p); /* 未定义行为 */

第一次 free 后,对象生命周期已经结束。第二次释放同一对象可能破坏分配器内部状态。释放后把唯一所有者指针置空能降低重复释放风险,但更重要的是建立唯一清理路径。

6.6 内存泄漏

void test(void)
{
    int *p = malloc(100);
    if (p != NULL)
    {
        *p = 20;
    }
    /* 函数返回,p 消失,动态对象没有释放 */
}

内存泄漏不等于一次非法访问,而是程序失去了对仍被占用资源的控制。短程序退出时操作系统通常会回收进程资源,但服务器、编辑器、游戏或嵌入式程序可能运行很久,持续泄漏会让可用内存不断减少。

判断泄漏的关键问题是:最后一个能够到达动态对象起始地址的指针消失之前,是否已经完成释放?


七、四道经典笔试题逐题分析

7.1 题目一:形参接住了地址,实参为什么没变

void get_memory(char *p)
{
    p = malloc(100);
}

void test(void)
{
    char *str = NULL;
    get_memory(str);
    strcpy(str, "hello world");
    printf("%s", str);
}

问题发生在参数传递方式上。C 语言按值传递参数,get_memory 得到的是 str 当前值的副本。函数内部只修改了局部形参 p,调用者的 str 仍然是 NULL。函数返回时,p 消失,刚申请的地址也丢失,既发生空指针写入风险,也发生内存泄漏。

在这里插入图片描述

修复方式一:传入指针变量的地址。

int get_memory(char **out, size_t size)
{
    char *p = malloc(size);
    if (p == NULL && size != 0)
    {
        return 0;
    }

    *out = p;
    return 1;
}

调用:

char *str = NULL;
if (!get_memory(&str, 100))
{
    return;
}

strcpy(str, "hello world");
printf("%s\n", str);
free(str);

修复方式二:让函数直接返回新地址。

char *get_memory(size_t size)
{
    return malloc(size);
}

第二种通常更直观,但接口文档必须说明返回值可能为空,并明确调用者取得所有权、最终负责释放。

7.2 题目二:返回局部数组地址

char *get_memory(void)
{
    char text[] = "hello world";
    return text;
}

text 是自动存储期数组。函数返回时,它的生命周期结束,返回的地址立即成为悬空指针。调用者之后打印它属于未定义行为:可能看似还能打印,可能得到乱码,也可能崩溃。

正确方案取决于接口需求:

  • 若内容固定,可返回字符串字面量对应的只读指针:const char *;
  • 若调用者提供缓冲区,可把内容写入缓冲区;
  • 若函数动态分配字符串,则返回地址并把释放责任交给调用者;
  • 若使用静态存储期缓冲区,要接受不可重入、共享状态等限制。

7.3 题目三:二级指针版本是否正确

void get_memory(char **p, size_t size)
{
    *p = malloc(size);
}

void test(void)
{
    char *str = NULL;
    get_memory(&str, 100);
    strcpy(str, "hello");
    printf("%s\n", str);
}

二级指针确实让函数能够修改调用者的 str,但原题仍不完整:

  • 没有检查 malloc 是否失败;
  • 没有在结束前 free(str);
  • 接口没有返回失败状态。

所以更完整的版本应像上一节那样返回布尔式结果,调用者成功后使用,最后释放。

7.4 题目四:free 后判断非空能继续使用吗

void test(void)
{
    char *str = malloc(100);
    strcpy(str, "hello");
    free(str);

    if (str != NULL)
    {
        strcpy(str, "world");
        printf("%s", str);
    }
}

不能。free 不负责把调用者变量改成 NULL,所以条件 str != NULL 很可能仍然成立,但这并不意味着动态对象仍然存在。继续写入属于 use-after-free。

在这里插入图片描述

正确思路是:释放后不再访问,并在适合的位置清除唯一所有者变量。

free(str);
str = NULL;

还有一个容易忽略的问题:原题若没有先检查 malloc 返回值,就在 strcpy 中使用 str,申请失败时同样会出错。

7.5 printf(str) 也是一个问题

课程题目中常见:

printf(str);

若字符串内容完全受程序控制,它有时能输出;但它把 str 当成格式字符串。只要内容中包含 %,printf 就会继续读取并不存在的参数,造成格式字符串漏洞。正确写法是:

printf("%s", str);

把数据作为数据输出,不要让外部内容成为格式规则。


八、柔性数组:结构体末尾的可变长度数据

8.1 什么是柔性数组成员

C99 允许结构体的最后一个成员写成不完整数组类型:

struct Packet
{
    size_t length;
    unsigned char data[];
};

data[] 就是柔性数组成员。它不在结构体类型中固定元素数量,而是在运行时通过额外分配的尾部空间获得实际容量。

柔性数组成员具有几条重要规则:

  • 它必须位于结构体最后;
  • 结构体在它之前至少还有一个命名成员;
  • sizeof(struct Packet) 不包含任何 data 元素;
  • 包含柔性数组成员的对象通常通过动态分配创建;
  • int data[0] 是部分编译器支持的扩展,标准 C 应写 int data[]。

8.2 一次分配的正确公式

若需要 payload_size 个字节:

size_t bytes = sizeof(struct Packet) + payload_size;
struct Packet *packet = malloc(bytes);

对于元素类型不是单字节类型的柔性数组,应把元素个数乘以单个元素大小:

struct IntBuffer
{
    size_t count;
    int values[];
};

分配 count 个 int:

if (count > (SIZE_MAX - sizeof(struct IntBuffer)) / sizeof(int))
{
    /* 大小计算溢出 */
}

size_t bytes = sizeof(struct IntBuffer) + count * sizeof(int);
struct IntBuffer *buffer = malloc(bytes);

这里不仅要检查乘法,还要检查与结构体头部相加是否溢出。

在这里插入图片描述

8.3 完整使用示例

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

struct IntBuffer
{
    size_t count;
    int values[];
};

struct IntBuffer *int_buffer_create(size_t count)
{
    if (count > (SIZE_MAX - sizeof(struct IntBuffer)) / sizeof(int))
    {
        return NULL;
    }

    size_t bytes = sizeof(struct IntBuffer) + count * sizeof(int);
    struct IntBuffer *buffer = malloc(bytes);
    if (buffer == NULL)
    {
        return NULL;
    }

    buffer->count = count;
    for (size_t index = 0; index < count; ++index)
    {
        buffer->values[index] = 0;
    }

    return buffer;
}

int main(void)
{
    struct IntBuffer *buffer = int_buffer_create(100);
    if (buffer == NULL)
    {
        return EXIT_FAILURE;
    }

    for (size_t index = 0; index < buffer->count; ++index)
    {
        buffer->values[index] = (int)index;
    }

    free(buffer);
    return EXIT_SUCCESS;
}

头部和元素区属于同一块动态分配对象,只需要一次 free(buffer)。

8.4 与“结构体内放指针”相比有什么优势

另一种常见设计是:

struct IntBuffer2
{
    size_t count;
    int *values;
};

它通常需要两次分配:一次创建结构体,一次创建元素数组;释放时也要按正确顺序释放两次。

柔性数组的一次分配方案常有这些优势:

  • 创建与释放更简单;
  • 头部和数据连续,通常具有更好的局部性;
  • 少一次分配器调用;
  • 减少独立内存块数量,有助于降低碎片;
  • 对外只交付一个需要释放的指针,所有权协议更清楚。

但它也有约束:柔性数组成员必须在末尾,扩容通常需要对整个对象使用 realloc,而且不能像普通数组那样单独替换尾部存储。设计时应根据对象模型选择,不必把它当成所有动态数组的唯一方案。


九、程序常见内存区域与对象生命周期

教材通常把 C/C++ 程序内存划分为栈区、堆区、数据段和代码段。这个模型很适合理解对象去向,但要记住它描述的是常见实现,而非所有系统都必须完全一致的标准布局。

在这里插入图片描述

9.1 自动存储期对象

普通局部变量通常具有自动存储期:

void function(void)
{
    int local = 10;
}

进入相应代码块时对象开始存在,离开代码块时生命周期结束。典型实现把这类对象放在调用栈中,但编译器也可能将部分变量保存在寄存器中,甚至通过优化消除它们。

9.2 动态分配对象

由 malloc、calloc 或 realloc 获得的对象具有由程序控制的生命周期。它不会因为申请函数返回而自动结束,而是在 free 或成功的重分配操作结束旧对象时终止。

这也是为什么“在函数中 malloc 后返回地址”可以是正确的,而“返回局部数组地址”一定有生命周期问题。决定正确性的不是地址来自哪个函数,而是被指向对象在使用时是否仍然存在。

9.3 静态存储期对象

文件作用域变量和使用 static 声明的对象通常具有静态存储期:

static int cache_hits;
int global_count;

它们在程序整个运行期间都存在。已初始化数据、零初始化数据以及只读区域的具体划分由实现、链接器和操作系统共同决定。

9.4 代码与只读数据

机器指令和某些只读数据通常位于只读映射区域。字符串字面量的数组具有静态存储期,但通过指针修改其内容属于未定义行为:

const char *message = "hello";

把指针声明为 const char * 能更准确表达“只读取,不修改”。

9.5 不要用区域名称代替生命周期分析

遇到指针题时,与其只问“它在栈还是堆”,不如按下面的顺序分析:

  1. 指针指向哪个对象;
  2. 对象何时开始生命周期;
  3. 当前操作发生时对象是否仍然存在;
  4. 指针是否指向对象起始位置或合法元素;
  5. 当前访问是否越界;
  6. 谁拥有释放责任。

这个流程比死背一张内存分区图更可靠,也能覆盖优化器、嵌入式平台和不同操作系统带来的实现差异。


十、一个可复用的动态数组模板

下面把前面的原则组合成一个小型动态数组。重点不是制造完整容器库,而是演示地址、元素数量和容量怎样一起维护。

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

struct IntVector
{
    int *data;
    size_t size;
    size_t capacity;
};

void int_vector_init(struct IntVector *vector)
{
    vector->data = NULL;
    vector->size = 0;
    vector->capacity = 0;
}

void int_vector_destroy(struct IntVector *vector)
{
    free(vector->data);
    vector->data = NULL;
    vector->size = 0;
    vector->capacity = 0;
}

int int_vector_reserve(struct IntVector *vector, size_t min_capacity)
{
    if (min_capacity <= vector->capacity)
    {
        return 1;
    }

    size_t new_capacity = vector->capacity == 0 ? 8 : vector->capacity;
    while (new_capacity < min_capacity)
    {
        if (new_capacity > SIZE_MAX / 2)
        {
            new_capacity = min_capacity;
            break;
        }
        new_capacity *= 2;
    }

    if (new_capacity > SIZE_MAX / sizeof *vector->data)
    {
        return 0;
    }

    int *new_data = realloc(
        vector->data,
        new_capacity * sizeof *vector->data
    );

    if (new_data == NULL)
    {
        return 0;
    }

    vector->data = new_data;
    vector->capacity = new_capacity;
    return 1;
}

int int_vector_push(struct IntVector *vector, int value)
{
    if (vector->size == vector->capacity)
    {
        if (!int_vector_reserve(vector, vector->size + 1))
        {
            return 0;
        }
    }

    vector->data[vector->size] = value;
    ++vector->size;
    return 1;
}

int main(void)
{
    struct IntVector vector;
    int_vector_init(&vector);

    for (int value = 0; value < 100; ++value)
    {
        if (!int_vector_push(&vector, value))
        {
            fputs("扩容失败\n", stderr);
            int_vector_destroy(&vector);
            return EXIT_FAILURE;
        }
    }

    printf("size=%zu, capacity=%zu\n",
           vector.size,
           vector.capacity);

    int_vector_destroy(&vector);
    return EXIT_SUCCESS;
}

10.1 为什么同时保存 size 和 capacity

  • size 表示已经构造并可以访问的元素个数;
  • capacity 表示当前空间最多能容纳的元素个数。

有效元素下标始终是 [0, size),下一次写入前必须保证 size < capacity。如果把二者混为一谈,就很容易在“空间已经申请但元素尚未存在”或“元素数量已经达到容量”时写错边界。

10.2 为什么按倍数扩容

若每新增一个元素都只把容量增加 1,就可能频繁调用 realloc 并反复复制已有内容。按 2 倍等几何比例增长,可以把一系列追加操作的平均扩容成本降下来。

倍数不是固定真理。大型对象可能使用更温和的增长策略,实时系统可能预先保留容量,内存敏感程序也可能更关注峰值空间。最重要的是扩容策略必须:

  • 不小于实际需求;
  • 检查整数溢出;
  • 不在失败时破坏旧状态;
  • 明确新增区域尚未初始化。

10.3 为什么销毁函数要恢复空状态

vector->data = NULL;
vector->size = 0;
vector->capacity = 0;

这让结构体回到与初始化后相同的状态,后续误用更容易被发现,也允许再次调用销毁函数而不会重复释放同一地址。前提仍然是没有其他别名在销毁后继续访问原数据。


十一、调试工具与工程检查清单

动态内存错误经常具有延迟性:真正的越界发生在 A 处,程序却到 B 处才崩溃。仅靠肉眼和普通调试器并不总能快速定位,因此建议主动使用内存诊断工具。

11.1 编译器警告

GCC 或 Clang 可以先开启较严格的警告:

gcc -std=c11 -Wall -Wextra -Wpedantic -Wconversion demo.c -o demo

警告不能证明程序没有动态内存错误,但能尽早发现类型不匹配、格式字符串问题、可疑转换与未使用结果。

11.2 AddressSanitizer

在支持的编译器中,可以使用 AddressSanitizer:

gcc -std=c11 -g -O1 -fsanitize=address,undefined \
    -fno-omit-frame-pointer demo.c -o demo

它常能捕获:

  • 堆缓冲区越界;
  • 释放后使用;
  • 重复释放;
  • 某些无效释放;
  • 与未定义行为相关的错误。

11.3 Valgrind 与平台工具

Linux 上的 Valgrind Memcheck 可以报告非法访问和泄漏;Windows 上可使用 Visual Studio 调试堆、Application Verifier 或支持 AddressSanitizer 的工具链。静态分析器也能在不运行程序时发现部分所有权与边界问题。

工具不是所有权设计的替代品,但它们能把“偶尔崩溃”变成更具体的报告:哪次分配创建了对象、哪一行越界、对象何时释放、还有多少字节没有回收。

在这里插入图片描述

11.4 提交代码前逐项检查

可以把下面这份清单当作动态内存代码的最小审查流程:

  • 元素个数与字节数是否区分清楚;
  • count * element_size 是否可能溢出;
  • 每次申请后是否在第一次使用前检查返回值;
  • 是否只访问 [0, count) 或 [0, size);
  • 是否保留分配函数返回的起始地址;
  • realloc 是否先写入临时指针;
  • 扩容后是否同步更新容量;
  • 新增区域是否按业务要求初始化;
  • 失败路径是否释放此前已经获得的资源;
  • 每个对象是否只有一个清晰的释放责任;
  • 是否存在重复释放、释放后使用或悬空别名;
  • 柔性数组的加法和乘法是否都检查了溢出;
  • 格式字符串是否写成常量,例如 printf("%s", text);
  • 是否用内存检测工具跑过正常路径与失败路径。

总结

动态内存管理可以浓缩为四个函数,但真正需要掌握的是它们背后的资源模型:

  1. malloc 按字节申请未初始化空间,必须检查返回值;
  2. calloc 按元素数量与元素大小分配,并把每个字节清零;
  3. realloc 可能原地调整,也可能搬家,必须使用返回的新地址;
  4. realloc 失败时旧对象仍然存在,因此不能直接覆盖唯一指针;
  5. free 只能接收空指针或尚未释放的动态对象起始地址;
  6. 越界、重复释放、释放内部地址、释放后使用和泄漏都会破坏对象生命周期;
  7. 指针按值传递,若要修改调用者的指针,应传二级指针或返回新地址;
  8. 柔性数组让固定头部与可变尾部一次分配、一次释放;
  9. 任何“元素个数 × 元素大小”的计算都要考虑整数溢出;
  10. 最可靠的代码会同时记录地址、有效元素数量、容量和所有权。

最后记住一句话:

动态内存的安全,不取决于你是否会调用 malloc,而取决于每一块空间是否始终拥有清楚的边界、所有者和结束时机。

当你能够沿着“申请 → 检查 → 使用 → 调整 → 释放”完整追踪一块内存时,动态内存管理就不再是一组容易混淆的 API,而会变成一套可以验证、可以维护的资源管理方法。


如果这篇文章对你有帮助,欢迎点赞、收藏并关注。

下一讲,我们继续学习 C 语言文件操作。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_64304261/article/details/167577537

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--