
个人主页: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 必须成对出现。申请了不释放是"内存泄漏",释放了不置 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 是一个指针,指向"有 5 个 int 的数组"(即数组指针)。
例 2:int (*p[5])(int)
读作:p 是一个数组,有 5 个元素,每个元素是"指向 int 函数的指针"(即函数指针数组,指针(3)里的"转移表")。
3.3 上强度:拆一个真正复杂的三级嵌套
看这个:
int (*(*p)[5])(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"堆出来的(数据结构课的核心)。
- 内存泄漏排查工具:valgrind、AddressSanitizer(ASan)能帮你自动查出"哪块内存没释放、哪次越界了"。
- restrict 关键字:C99 里给指针加"不重叠"的提示,帮助编译器优化(memcpy 的形参就用了 restrict)。
- 上一讲:深入理解指针(5)(sizeof 与 strlen 对比、数组名三大规则、指针运算笔试题)
网硕互联帮助中心






评论前必须登录!
注册