云计算百科
云计算领域专业知识百科平台

C语言指针进阶补充6:动态内存管理、mem系列内存函数、复杂指针声明,一次补齐指针的“三大盲区“

个人主页:GeekZHR   欢迎各位大佬互三

25级机器人工程N非本科生在读,自学C/C++,嵌入式软硬件等等ing

欢迎各位大佬评论区互动交流

写在前面:前五讲"留的尾巴",都在这一篇补上

前五讲,我们一路从"地址 == 指针变量",讲到"数组名 == 首元素地址",再到"函数指针、回调函数、笔试题",把指针的常规内容基本走完了。但有几个地方,系列里其实是留了伏笔、没来得及展开的——比如指针(4)里提过一句"void* 在内存函数里怎么用",指针(3)里也提过一句"复杂声明的右左法则怎么读"。更重要的是,还有一整块动态内存管理(malloc/free),前五讲压根没碰,而它恰恰是面试和真实项目里指针最"要命"的地方。这篇补充篇,我就把这三块盲区一次补齐:动态内存管理 → mem 系列内存函数 → 复杂指针声明,配好图,争取给指针系列真正画上句号。


一、动态内存管理(指针 + 堆)

1.1 为什么需要动态内存?先分清"三个区"

到目前我们写过的变量,要么是局部变量(函数里定义),要么是全局变量。它们都有一个共同点:大小在编译期就确定了。可真实项目里,经常有"数组多大,要等程序跑起来才知道"的场景——比如读一个文件、接收用户输入,事先根本不知道有多少数据。

内存里大致分成这么几个区(不同平台叫法略有差异,抓住本质即可):

  • 栈区(stack):放局部变量、函数参数,编译器自动分配、自动回收,用完就没了;
  • 堆区(heap):放动态内存,由程序员手动 malloc 申请、free 释放,不用了就泄漏;
  • 静态区(static):放全局变量、static 修饰的变量,程序运行期间一直存在。

🔑 关键区分:栈上的东西"自动管",堆上的东西"手动管"。动态内存分配,就是在堆区上手动开辟一块空间,大小可以在运行时才决定。

1.2 malloc 和 free

malloc 在堆上申请一块连续空间,函数原型:

void* malloc(size_t size); // 参数:要申请的字节数

  • 参数是字节数,不是元素个数(想开 10 个 int,就传 10 * sizeof(int));
  • 返回值是这块空间的首地址(void*,所以能赋给任意指针类型);
  • 分配失败返回 NULL,所以用之前一定要判断。

free 用来释放 malloc/calloc/realloc 申请的空间:

void free(void* ptr); // 释放 ptr 指向的动态内存

看一个完整例子——申请 10 个 int,用完释放:

#include <stdio.h>
#include <stdlib.h> // malloc/free 在 stdlib.h 里

int main()
{
int *p = (int*)malloc(10 * sizeof(int)); // 申请 10 个 int 的空间
if (p == NULL) // 先判断是否申请成功
{
perror("malloc");
return 1;
}

int i = 0;
for (i = 0; i < 10; i++)
p[i] = i + 1; // 当作数组用

for (i = 0; i < 10; i++)
printf("%d ", p[i]);

free(p); // 用完释放
p = NULL; // 释放后置 NULL,防止野指针
return 0;
}

两个必须记住的要点:

  • malloc 用完一定要 free,否则这块内存会一直占着,程序也不还——这就是内存泄漏;
  • free(p) 之后要把 p 置成 NULL,否则 p 还留着那块已释放的地址,成了"悬垂指针",再碰它就危险。
  • 🔑 一句话:malloc 和 free 必须成对出现。申请了不释放是"内存泄漏",释放了不置 NULL 是"野指针"。

    1.3 calloc(申请 + 自动清零)

    calloc 也是申请动态内存,原型:

    void* calloc(size_t num, size_t size); // num 个元素,每个 size 字节

    它和 malloc 的区别有两处:一是参数拆成了"元素个数 × 单个大小",二是申请出来的内存会被自动初始化为 0。

    #include <stdio.h>
    #include <stdlib.h>
    int main()
    {
    int *p = (int*)calloc(10, sizeof(int)); // 等价于 10 个 int,且全被置 0
    if (p == NULL)
    return 1;
    // 此时 p[0] ~ p[9] 全都是 0,不用自己再清零
    free(p);
    p = NULL;
    return 0;
    }

    🔑 选择:需要"申请完就是 0"的场景(比如计数器、标志位数组),用 calloc 更省事;否则 malloc 就行(malloc 不保证初始值是什么)。

    1.4 realloc(调整已申请空间的大小)

    有时候一开始申请小了,后面想扩容。这时用 realloc:

    void* realloc(void* ptr, size_t size); // 把 ptr 指向的空间调整成 size 字节

    它做的事:在 ptr 的基础上,把空间调整到 size 字节。注意几个坑:

    • 返回值不一定是原来的 ptr:如果原空间后面还有空余,就原地扩容(返回原地址);如果后面不够,就另找一块更大的内存、把原数据拷过去(返回新地址);
    • 所以必须用返回值重新赋值,不能假设它还是原来那个地址;
    • 稳妥写法:先用临时指针接,成功后再赋值给原指针,防止扩容失败时把原来的地址也弄丢了。

    #include <stdio.h>
    #include <stdlib.h>
    int main()
    {
    int *p = (int*)malloc(5 * sizeof(int)); // 先申请 5 个
    if (p == NULL) return 1;
    for (int i = 0; i < 5; i++) p[i] = i;

    // 扩容到 10 个,用临时指针 tmp 接
    int *tmp = (int*)realloc(p, 10 * sizeof(int));
    if (tmp == NULL) // 扩容失败,p 还指向原来的 5 个
    {
    free(p);
    return 1;
    }
    p = tmp; // 成功后再把 p 换成新地址

    for (int i = 5; i < 10; i++) p[i] = i; // 后面 5 个也填上
    for (int i = 0; i < 10; i++) printf("%d ", p[i]);

    free(p);
    p = NULL;
    return 0;
    }

    1.5 动态内存的五大常见错误(面试高频)

    这些错误几乎每一条都被问过,务必一条条对照排查:

    ① 对 NULL 解引用——忘了判断 malloc 返回值:

    int *p = (int*)malloc(1000000000 * sizeof(int));
    *p = 10; // ⚠️ 如果 malloc 失败返回 NULL,这里就是对 NULL 解引用

    ② 越界访问——申请了 N 个,却访问到 N 个之外:

    int *p = (int*)malloc(10 * sizeof(int));
    for (int i = 0; i <= 10; i++) // ⚠️ 多访问了 p[10],越界
    p[i] = i;

    ③ 忘记 free(内存泄漏)——尤其是循环里反复 malloc 却不释放:

    while (1)
    {
    int *p = (int*)malloc(1024 * 1024); // 每次申请 1MB 都不释放
    // ⚠️ 内存越用越少,最终耗尽
    }

    ④ free 之后没置 NULL(悬垂指针):

    int *p = (int*)malloc(sizeof(int));
    free(p); // 释放了
    // p 还留着已释放的地址,变成悬垂指针
    *p = 10; // ⚠️ 非法访问已释放的内存

    ⑤ 重复 free 或 free 非动态内存:

    int a = 10;
    int *p = &a;
    free(p); // ⚠️ free 栈上的变量,非法!

    int *q = (int*)malloc(sizeof(int));
    free(q);
    free(q); // ⚠️ 重复 free 同一块内存

    🔑 五个错误的应对口诀:判 NULL、不越界、记得 free、free 后置 NULL、别重复 free 也别 free 栈上变量。

    1.6 柔性数组(C99)

    结构体里可以有一个"大小待定"的数组成员,叫柔性数组。它是结构体中最后一个成员,写成 类型 名[](不写大小):

    struct S
    {
    int n; // 普通成员
    int arr[]; // 柔性数组成员:大小待定
    };

    柔性数组不能单独用,要配合 malloc 一起把结构体和数组一次性申请出来:

    #include <stdio.h>
    #include <stdlib.h>
    struct S
    {
    int n;
    int arr[]; // 柔性数组
    };
    int main()
    {
    // 一次性申请:结构体大小 + 10 个 int 的大小
    struct S *ps = (struct S*)malloc(sizeof(struct S) + 10 * sizeof(int));
    if (ps == NULL) return 1;

    ps->n = 10;
    for (int i = 0; i < 10; i++)
    ps->arr[i] = i; // arr 就是紧跟在结构体后面的那 10 个 int

    for (int i = 0; i < 10; i++)
    printf("%d ", ps->arr[i]);

    free(ps);
    ps = NULL;
    return 0;
    }

    好处:结构体和它的数组在内存里是连续的一块,free 一次就全释放,不用分开管;也避免了"结构体里写死大数组"的浪费。

    🔑 柔性数组的要点:必须是最后一个成员、必须写成 arr[]、必须配合 malloc 一次申请 sizeof(struct) + n * sizeof(元素)。


    二、mem 系列内存函数(void* 的实战)

    指针(4)里我们认识了 void*——"先接住任意类型的地址"。库函数里有一整套以 void* 为核心的内存函数,就是 void* 最经典的实战:它们不管数据类型,直接按字节操作内存。都在 <string.h> 里。

    2.1 memcpy(按字节拷贝,不处理重叠)

    void* memcpy(void* dest, const void* src, size_t num);

    把 src 开始的 num 个字节,拷贝到 dest 开始的位置,返回 dest。它不负责处理内存重叠的情况。

    #include <stdio.h>
    #include <string.h>
    int main()
    {
    int arr1[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    int arr2[10] = { 0 };
    memcpy(arr2, arr1, 10 * sizeof(int)); // 拷贝 10 个 int(共 40 字节)
    for (int i = 0; i < 10; i++)
    printf("%d ", arr2[i]); // 1 2 3 4 5 6 7 8 9 10
    return 0;
    }

    自己模拟实现一个 memcpy,核心就是"转成 char* 按字节一个个搬":

    #include <stdio.h>
    void* my_memcpy(void* dest, const void* src, size_t num)
    {
    char* d = (char*)dest;
    const char* s = (const char*)src;
    while (num–)
    *d++ = *s++; // 一个字节一个字节地拷贝
    return dest;
    }

    2.2 memmove(按字节拷贝,处理重叠)

    memcpy 的短板是不处理重叠。如果 dest 和 src 指向的区间有重叠,直接从前往后拷贝会把"还没拷贝的源数据"先覆盖掉,结果就错了。这时用 memmove:

    void* memmove(void* dest, const void* src, size_t num);

    memmove 会判断拷贝方向,从而正确处理重叠:

    #include <stdio.h>
    #include <string.h>
    int main()
    {
    int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    // 把 1 2 3 4 5 整体往后挪两个位置,变成 1 2 1 2 3 4 5 8 9 10
    memmove(arr + 2, arr, 5 * sizeof(int));
    for (int i = 0; i < 10; i++)
    printf("%d ", arr[i]);
    return 0;
    }

    这里 src(arr)和 dest(arr+2)有重叠,memmove 能正确处理;换成 memcpy 结果就可能不对。

    模拟实现的关键:当 dest 在 src 前面,从前向后拷贝;当 dest 在 src 后面,从后向前拷贝:

    #include <stdio.h>
    void* my_memmove(void* dest, const void* src, size_t num)
    {
    char* d = (char*)dest;
    const char* s = (const char*)src;
    if (d < s) // dest 在 src 前面:从前往后
    {
    while (num–)
    *d++ = *s++;
    }
    else // dest 在 src 后面:从后往前
    {
    while (num–)
    *(d + num) = *(s + num);
    }
    return dest;
    }

    🔑 一句话记住:memcpy 不处理重叠,memmove 处理重叠。拿不准会不会重叠时,用 memmove 更稳妥。两者的模拟实现都靠"转 char* 按字节操作"。

    2.3 memset(把内存批量设为某个值)

    void* memset(void* ptr, int value, size_t num);

    把 ptr 开始的 num 个字节,每个字节都设成 value(注意是按字节设值,value 只取低 8 位)。

    #include <stdio.h>
    #include <string.h>
    int main()
    {
    char arr[] = "hello world";
    memset(arr + 6, 'x', 5); // 从第 6 个字符开始,5 个字节都设成 'x'
    printf("%s\\n", arr); // "hello xxxxx"
    return 0;
    }

    模拟实现:

    void* my_memset(void* ptr, int value, size_t num)
    {
    char* p = (char*)ptr;
    while (num–)
    *p++ = (char)value; // 按字节设置
    return ptr;
    }

    ⚠️ 注意:memset 按字节设值,所以用 memset(arr, 1, sizeof(arr)) 给整型数组"设成 1"是错的——每个 int 的 4 个字节都会变成 0x01,结果每个元素是 0x01010101 而不是 1。给整型数组清零用 memset(arr, 0, sizeof(arr)) 没问题(因为 0 的 4 个字节都是 0)。

    2.4 memcmp(按字节比较)

    int memcmp(const void* ptr1, const void* ptr2, size_t num);

    比较 ptr1 和 ptr2 开头的 num 个字节,返回值和 strcmp 一个约定(p1 – p2 的符号):

    #include <stdio.h>
    #include <string.h>
    int main()
    {
    int arr1[] = { 1, 2, 3, 4, 5 };
    int arr2[] = { 1, 2, 3, 4, 6 };
    int ret = memcmp(arr1, arr2, 5 * sizeof(int));
    printf("%d\\n", ret); // 负数:arr1 最后一个元素 5 < arr2 的 6
    return 0;
    }

    模拟实现:

    int my_memcmp(const void* ptr1, const void* ptr2, size_t num)
    {
    const char* p1 = (const char*)ptr1;
    const char* p2 = (const char*)ptr2;
    while (num–)
    {
    if (*p1 != *p2)
    return *p1 – *p2; // 第一个不相等的字节,谁大谁小
    p1++;
    p2++;
    }
    return 0; // num 个字节全相等
    }

    🔑 这一节四个函数,本质都是"转 char* + 按字节循环"。看懂这个套路,void* 泛型指针的实战就算入门了——它不关心你传的是 int*、char* 还是结构体,一律按字节处理。


    三、复杂指针声明与"右左法则"

    前面几讲,我们见过 int (*p)[5]、int (*pf)(int,int) 这些,已经有点绕了。可真实代码(尤其看库、看内核、看面试题)里,还有更"天书"的,比如:

    void (*signal(int , void(*)(int)))(int);
    int (*(*p)[5])(int);

    这一节,就用一个通用方法把任意复杂声明读出来。

    3.1 记住两条优先级

    读声明前,先记住符号的优先级(和运算符优先级一致):

    • 括号 () 优先级最高,优先看括号里的;
    • [](数组)和 ()(函数)同级,都高于 *(指针)。

    所以 int *p[5] 里 p 先和 [5] 结合(是数组);int (*p)[5] 里括号让 p 先和 * 结合(是指针)。这条前几讲已经反复用了。

    3.2 右左法则:从变量名出发,先右后左

    右左法则的步骤:从变量名(最内层的名字)出发,先向右看,遇到 ) 就向左看,遇到 ( 再向右看……像"之"字形一样交替,直到读完整个声明。

    先拿两个已经熟悉的来练手:

    例 1:int (*p)[5]

  • 从 p 出发,右边是 ),于是向左看,看到 * → p 是指针;
  • 再向右看,看到 [5] → 指针指向一个有 5 个元素的数组;
  • 剩下的 int 是数组元素类型。
  • 读作:p 是一个指针,指向"有 5 个 int 的数组"(即数组指针)。

    例 2:int (*p[5])(int)

  • 从 p 出发,p 先和 [5] 结合 → p 是数组(有 5 个元素);
  • 再看数组的元素类型 (*…)(int) → 是函数指针(指向参数 int、返回 int 的函数)。
  • 读作:p 是一个数组,有 5 个元素,每个元素是"指向 int 函数的指针"(即函数指针数组,指针(3)里的"转移表")。

    3.3 上强度:拆一个真正复杂的三级嵌套

    看这个:

    int (*(*p)[5])(int);

    用右左法则一步步拆:

  • 从 p 出发,右边是 ),向左看是 * → p 是指针;
  • 向右看是 [5] → 指针指向有 5 个元素的数组;
  • 数组元素是什么?往左看是 *((*p)[5] 里去掉 p 还剩 (*)[5],元素是 *)→ 数组元素是指针;
  • 这个指针指向什么?向右看是 (int) → 指向参数为 int 的函数;
  • 左边剩下的 int 是函数返回类型。
  • 读作:p 是一个指针,指向"有 5 个元素"的数组;这个数组的每个元素又是一个指针,指向"参数 int、返回 int"的函数。

    写成类型就是:int (*(*)[5])(int)。看着吓人,用右左法则"之"字形一读就通了。

    🔑 右左法则的口诀:变量名出发,右左右左交替走。看到 [n] 说"数组",看到 * 说"指针",看到 (参数) 说"函数",最后剩下的类型是"返回值/元素类型"。

    3.4 用 typedef 把复杂声明"化繁为简"

    右左法则能"读懂",但真写代码时没人愿意每次写一大坨。更实用的做法是用 typedef 给复杂类型起个简单名字(指针(3)里也提过:新名字要写在 * 的右边)。

    先看库里的经典 signal 函数(用来注册信号处理函数),原型:

    void (*signal(int , void(*)(int)))(int);

    第一次看基本懵。用 typedef 拆两步:

    // 第 1 步:把"函数指针类型 void(*)(int)"重命名成 sighandler_t
    typedef void (*sighandler_t)(int);

    // 第 2 步:signal 就变成——参数是 int 和一个函数指针,返回也是函数指针
    sighandler_t signal(int signum, sighandler_t handler);

    这样一眼就懂:signal 接收一个信号编号 int 和一个处理函数(sighandler_t),返回之前的那个处理函数(也是 sighandler_t)。

    再比如上一节那个三级嵌套 int (*(*p)[5])(int),用 typedef 也能拆:

    typedef int (*pfun_t)(int); // 指向"int -> int"函数的指针类型
    typedef pfun_t arr5_t[5]; // 存 5 个函数指针的数组类型
    arr5_t *p; // 指向这个数组的指针

    三行,清楚多了。

    🔑 复杂声明的终极心法:读用"右左法则",写用"typedef"。右手法则负责"看懂天书",typedef 负责"别自己写天书"。


    总结

    最后把这一篇补充的骨架拎出来:

  • 动态内存分配:栈区自动管理、堆区手动管理;malloc(size) 申请、free(ptr) 释放,必须成对。
  • 四个分配函数:malloc 不初始化、calloc 自动清零、realloc 调整大小(要用临时指针接、成功后重新赋值)、free 释放。
  • 五大常见错误:对 NULL 解引用、越界访问、忘记 free(泄漏)、free 后没置 NULL(悬垂指针)、重复 free / free 栈上变量。
  • 柔性数组:结构体最后一个成员写成 int arr[],配合 malloc(sizeof(struct) + n*sizeof(元素)) 一次性申请成连续一块。
  • mem 系列内存函数:memcpy/memmove(拷贝,后者处理重叠)、memset(按字节设值)、memcmp(按字节比较);本质都是"转 char* 按字节循环"。
  • 复杂声明:读用右左法则(从变量名出发,右左右左交替),写用 typedef(新名字写在 * 右边)。
  • 一句话收尾:指针学到深处,就三件事——堆上内存要"手动申请手动还"、内存函数要"按字节操作"、复杂声明要"右左法则拆着读";把这三块补齐,指针才算是真正毕业了。

    拓展学习

    • 动态内存与结构体的结合:链表、栈、队列这些数据结构,都是靠"结构体指针 + malloc"堆出来的(数据结构课的核心)。
    • 内存泄漏排查工具:valgrind、AddressSanitizer(ASan)能帮你自动查出"哪块内存没释放、哪次越界了"。
    • restrict 关键字:C99 里给指针加"不重叠"的提示,帮助编译器优化(memcpy 的形参就用了 restrict)。
    • 上一讲:深入理解指针(5)(sizeof 与 strlen 对比、数组名三大规则、指针运算笔试题)
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » C语言指针进阶补充6:动态内存管理、mem系列内存函数、复杂指针声明,一次补齐指针的“三大盲区“
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!