摘要 :指针保存的是地址。本文从变量的地址讲起,说明
&与*的作用、指针类型怎样决定解引用的宽度和加减整数的步长;再讲指针运算、数组名的两个例外、一维和二维数组传参、数组指针与指针数组、函数指针与回调,最后落到malloc/free/realloc和二级指针。读完后应能读懂常见的指针声明,并写出不越界、不泄漏的动态内存代码。
文章目录
- [1. 从变量的地址说起](#1. 从变量的地址说起)
- [2. 指针类型决定两件事](#2. 指针类型决定两件事)
-
- [2.1 解引用访问几个字节](#2.1 解引用访问几个字节)
- [2.2 加减整数走多远](#2.2 加减整数走多远)
- [2.3 野指针与悬空指针](#2.3 野指针与悬空指针)
- [3. 指针运算与数组](#3. 指针运算与数组)
-
- [3.1 指针加减整数](#3.1 指针加减整数)
- [3.2 指针减指针](#3.2 指针减指针)
- [3.3 数组名的两个例外](#3.3 数组名的两个例外)
- [3.4 字符指针与字符串字面量](#3.4 字符指针与字符串字面量)
- [4. 指针与数组传参](#4. 指针与数组传参)
-
- [4.1 一维数组:传的是首元素地址](#4.1 一维数组:传的是首元素地址)
- [4.2 二维数组:传的是第一行地址](#4.2 二维数组:传的是第一行地址)
- [4.3 数组指针与指针数组](#4.3 数组指针与指针数组)
- [4.4 指针数组模拟二维数组](#4.4 指针数组模拟二维数组)
- [5. 指针与函数](#5. 指针与函数)
-
- [5.1 传值调用与传址调用](#5.1 传值调用与传址调用)
- [5.2 返回指针](#5.2 返回指针)
- [5.3 函数指针](#5.3 函数指针)
- [5.4 函数指针数组与转移表](#5.4 函数指针数组与转移表)
- [5.5 回调函数与 qsort](#5.5 回调函数与 qsort)
- [5.6 自己写一个泛型排序](#5.6 自己写一个泛型排序)
- [6. 动态内存](#6. 动态内存)
-
- [6.1 为什么需要堆](#6.1 为什么需要堆)
- [6.2 malloc 与 free](#6.2 malloc 与 free)
- [6.3 calloc 与 realloc](#6.3 calloc 与 realloc)
- [6.4 动态数组:一次完整的扩容](#6.4 动态数组:一次完整的扩容)
- [6.5 六种常见错误](#6.5 六种常见错误)
- [6.6 所有权规则](#6.6 所有权规则)
- [7. 二级指针](#7. 二级指针)
- [8. 小结](#8. 小结)
1. 从变量的地址说起
定义一个 int 变量,编译器要在内存里找 4 个连续字节。内存按字节编号,每个字节一个地址;这 4 个字节里编号最小的那个,就是这个变量的地址。
c
#include <stdio.h>
int main(void)
{
int a = 10;
int *pa = &a;
printf("sizeof(int *) = %zu\n", sizeof(int *));
printf("a = %d, *pa = %d\n", a, *pa);
*pa = 20; // 通过地址修改 a
printf("a = %d\n", a);
return 0;
}
输出:
text
sizeof(int *) = 8
a = 10, *pa = 10
a = 20
&a 取出 a 的地址,int *pa 声明一个存放 int 地址的指针(pointer)变量。*pa 叫解引用(dereference):先读出 pa 里保存的地址,再访问那个地址上的 int,所以 *pa 就是 a 本身。

地址的具体数值每次运行都可能不同,这是操作系统的地址空间布局随机化(address space layout randomization, ASLR)造成的。%p 的显示格式也由实现决定,读地址时只看两个地址的差值,不要把某次打印出来的值当成固定结果。
指针变量的大小和它指向的类型无关,只和平台的地址宽度有关:
| 平台 | 地址位数 | sizeof(int *) |
sizeof(double *) |
|---|---|---|---|
| 32 位(X86) | 32 | 4 | 4 |
| 64 位(X64) | 64 | 8 | 8 |
在 VS2026 里切换 X86 和 X64 配置,同一个 sizeof(char *) 会得到 4 和 8。换类型不会改变指针变量的大小,改变的只是解引用时访问几个字节、加减整数时走多远。
2. 指针类型决定两件事
同一个地址,用 char * 和 int * 保存,值一样,能做的事不一样。
2.1 解引用访问几个字节
c
#include <stdio.h>
int main(void)
{
int n = 0x11223344;
int *pi = &n;
char *pc = (char *)&n;
printf("pi + 1 走了 %td 字节\n", (char *)(pi + 1) - (char *)pi);
printf("pc + 1 走了 %td 字节\n", (pc + 1) - pc);
*pc = 0; // 只改最低地址的 1 个字节
printf("n = 0x%x\n", (unsigned)n);
return 0;
}
输出:
text
pi + 1 走了 4 字节
pc + 1 走了 1 字节
n = 0x11223300
*pi = 0 会把 n 的 4 个字节全部清零,*pc = 0 只改最低地址的 1 个字节。内存里 0x11223344 按 44 33 22 11 排列,是低地址放低位字节的小端序(little-endian)。
把最低地址那个字节改成 0,数值就变成 0x11223300。
| 写法 | 一次解引用访问 | 适合的场景 |
|---|---|---|
char * |
1 字节 | 字节序列、字符串、按字节拷贝 |
short * |
2 字节 | 16 位数据 |
int * |
4 字节 | 32 位整数 |
double * |
8 字节 | 双精度浮点数 |
不要用 char * 去改一个 int 的部分字节,除非你确实在处理字节序列。字节序、对齐和类型别名规则都会影响结果。
2.2 加减整数走多远
指针加减整数时,地址的变化量是 n * sizeof(*p),不是 n 个字节。pi + 1 跳过 4 字节,pc + 1 跳过 1 字节,就是上一条输出里的两个数。

这个规则让指针可以像数组下标一样移动。p + i 指向从 p 开始的第 i 个元素,*(p + i) 就是那个元素。前提是 p 指向一块足够大的连续内存,越界后继续加减就是未定义行为。
void * 是例外:它可以接收任意类型的地址,但标准不允许直接解引用,也不允许直接加减整数。编译器不知道它指向的类型多大,没法决定访问宽度和步长。用之前先转换成具体类型:
c
#include <stdio.h>
int main(void)
{
int a = 10;
int b = 20;
const int *p1 = &a; // 指向的内容只读
int *const p2 = &a; // p2 本身只读
*p2 = 30; // 可以改 a
p1 = &b; // 可以改指向
printf("a = %d, *p1 = %d\n", a, *p1);
void *vp = &a; // void * 可以接收任意类型地址
int *ip = (int *)vp;
*ip = 40;
printf("a = %d\n", a);
return 0;
}
输出:
text
a = 30, *p1 = 20
a = 40
const 写在 * 左边还是右边,锁的不是同一个东西:
| 写法 | 能否改 *p |
能否改 p |
读法 |
|---|---|---|---|
int *p |
可以 | 可以 | 普通指针 |
const int *p |
不可以 | 可以 | 指向常量的指针 |
int *const p |
可以 | 不可以 | 常量指针 |
const int *const p |
不可以 | 不可以 | 两者都锁 |
记法只有一句:const 紧挨谁就锁谁。const int *p 里 const 修饰 int,锁的是数据;int *const p 里 const 修饰 p,锁的是指针本身。函数只读字符串时,形参写 const char *,调用方才能放心传入字符串字面量。
2.3 野指针与悬空指针
指针最容易出的问题不是语法,而是它指向的地方已经不能用了。两个词要分开:
| 名称 | 含义 | 常见来源 |
|---|---|---|
| 野指针(wild pointer) | 从来没有指向过有效对象 | 局部指针没有初始化,值是栈上的残留数据 |
| 悬空指针(dangling pointer) | 曾经有效,现在失效 | free 之后继续使用;函数返回了局部变量的地址 |
野指针不一定是 NULL。局部指针变量不初始化时,里面可能是任何值,恰好看起来像一个合法地址,解引用就会改坏别的数据,或者直接崩溃。
避免这些问题,做法固定:
- 定义时就初始化:指向有效对象,或者先给
NULL。 - 使用前检查非空:
if (p != NULL);内部接口可以用assert(p != NULL)把错误尽早暴露。 - 不越界:指针只在同一块数组或同一块动态内存里移动。
free之后立刻把指针置NULL,重复释放就变成安全的空操作。
3. 指针运算与数组
指针的运算只有三种:加减整数、两个指针相减、比较大小。
3.1 指针加减整数
c
#include <stdio.h>
int main(void)
{
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;
while (p < arr + 5)
{
printf("%d ", *p);
p++;
}
printf("\n");
printf("arr + 5 - arr = %td\n", (arr + 5) - arr);
return 0;
}
输出:
text
10 20 30 40 50
arr + 5 - arr = 5
arr + 5 是最后一个元素的下一个位置,叫尾后指针(past-the-end pointer)。它可以形成、可以比较,但不能解引用。循环条件用 p < arr + 5,最后一次进入循环时 p 指向 arr[4],打印完再自增到 arr + 5,循环结束。
如果后面还要用数组首地址,别把原指针一路自增掉。可以另存一个 int *p = arr;,也可以改用下标 arr[i],下标写法更不容易丢起点。
3.2 指针减指针
两个同类型指针相减,得到的是它们之间相隔的元素个数,不是字节数。结果是 ptrdiff_t 类型,用 %td 打印:
c
int arr[5] = {1, 2, 3, 4, 5};
printf("%td\n", &arr[4] - &arr[0]); // 4
printf("%td\n", &arr[0] - &arr[4]); // -4
两个指针必须指向同一块内存,相减才有定义。拿两个不同数组的元素地址相减,结果没有意义。指针减指针和指针加整数互为逆运算:(arr + 5) - arr 是 5,arr + 5 再减 5 又回到 arr。
3.3 数组名的两个例外
数组名在大多数表达式里会转换成首元素地址,这个转换叫数组退化(array decay)。所以 arr 和 &arr[0] 的值相同,arr + 1 和 &arr[0] + 1 都跳过一个元素。
只有两个场合例外:
c
#include <stdio.h>
int main(void)
{
int arr[5] = {1, 2, 3, 4, 5};
printf("sizeof(arr) = %zu\n", sizeof(arr));
printf("sizeof(&arr[0]) = %zu\n", sizeof(&arr[0]));
printf("sizeof(&arr) = %zu\n", sizeof(&arr));
printf("arr + 1 走了 %td 字节\n", (char *)(arr + 1) - (char *)arr);
printf("&arr + 1 走了 %td 字节\n", (char *)(&arr + 1) - (char *)&arr);
return 0;
}
输出:
text
sizeof(arr) = 20
sizeof(&arr[0]) = 8
sizeof(&arr) = 8
arr + 1 走了 4 字节
&arr + 1 走了 20 字节
| 写法 | 含义 | 结果 |
|---|---|---|
sizeof(arr) |
整个数组 | 20 字节 |
&arr |
整个数组的地址,类型 int (*)[5] |
&arr + 1 跳过 20 字节 |
其他表达式里的 arr |
首元素地址,类型 int * |
arr + 1 跳过 4 字节 |
&arr 和 arr 的值一样,类型不一样,+1 的步长就不一样。sizeof(arr) 能算出整个数组的大小,也是同一个原因:sizeof 的操作数不触发数组退化。

数组下标和指针运算本来就是同一件事:
a r r i ≡ ∗ ( a r r + i ) ≡ ∗ ( p + i ) ≡ p i arri \equiv *(arr + i) \equiv *(p + i) \equiv pi arri≡∗(arr+i)≡∗(p+i)≡pi
加法满足交换律,i[arr] 也能编译通过,但可读性差,不要这么写。数组名不是指针变量,不能赋值,也不能自增;arr = arr2 和 arr++ 都不合法。
3.4 字符指针与字符串字面量
字符指针可以指向单个字符,也可以指向字符串字面量:
c
#include <stdio.h>
int main(void)
{
const char *s = "hello";
printf("%c\n", s[1]);
printf("%s\n", s);
return 0;
}
输出:
text
e
hello
s 保存的是字符串首字符 'h' 的地址,不是把整串字符装进指针。s[1] 等价于 *(s + 1),所以能取出第二个字符。
字符串字面量放在只读数据区,const char * 只是指向它,不要通过指针修改。s[0] = 'H' 是未定义行为,程序可能直接崩溃。需要修改时用字符数组:
c
char s[] = "hello"; // 字面量被复制到数组自己的可写空间
s[0] = 'H'; // 合法
4. 指针与数组传参
数组作函数参数时,函数拿到的不是整个数组,而是首元素的地址。这一点在《数组》和《函数》两篇已经出现过,这里从指针的角度再看一遍,重点放在形参类型和长度信息上。
4.1 一维数组:传的是首元素地址
c
#include <stdio.h>
void Print(int arr[], int sz)
{
printf("函数内 sizeof(arr) = %zu\n", sizeof(arr));
for (int i = 0; i < sz; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
}
int main(void)
{
int arr[5] = {1, 2, 3, 4, 5};
int sz = (int)(sizeof(arr) / sizeof(arr[0]));
printf("main 内 sizeof(arr) = %zu\n", sizeof(arr));
Print(arr, sz);
return 0;
}
输出:
text
main 内 sizeof(arr) = 20
函数内 sizeof(arr) = 8
1 2 3 4 5
main 里的 arr 是数组,sizeof 得到 20;传进函数后,形参 arr 已经退化成 int *,sizeof 得到 8。函数里算不出数组长度,必须在调用时把 sz 一起传进来。
形参写成 int arr[10]、int arr[]、int *arr 完全等价,方括号里的数字会被编译器忽略。写成数组形式只是为了读起来更像「这里接收一个数组」,实际类型仍然是指针。
4.2 二维数组:传的是第一行地址
二维数组可以看成「每个元素是一维数组的数组」。二维数组的数组名表示首元素地址,而首元素是第一行,所以传进去的是第一行的地址,类型是 int (*)[列数]。
c
#include <stdio.h>
void Print(int (*p)[3], int rows)
{
for (int i = 0; i < rows; i++)
{
for (int j = 0; j < 3; j++)
{
printf("%d ", p[i][j]);
}
printf("\n");
}
printf("p + 1 走了 %td 字节\n", (char *)(p + 1) - (char *)p);
}
int main(void)
{
int a[2][3] = {{1, 2, 3}, {4, 5, 6}};
Print(a, 2);
return 0;
}
输出:
text
1 2 3
4 5 6
p + 1 走了 12 字节
p 指向一行 3 个 int,所以 p + 1 跳过 12 字节,正好是一行。p[i][j] 先取第 i 行,再取该行第 j 个元素,和 *(*(p + i) + j) 等价。

形参写成 int a[3][5]、int a[][5]、int (*p)[5] 也可以互换。行数可以省略,列数不能省略:编译器必须知道一行有多少个元素,才能算出 p + 1 该走多远。
4.3 数组指针与指针数组
数组指针(array pointer)和指针数组(array of pointers)这两个名字只差一个字,含义完全不同。看声明时,变量名先和谁结合:
c
#include <stdio.h>
int main(void)
{
int *pa[3]; // 指针数组:3 个 int *
int (*pb)[3]; // 数组指针:指向 int[3]
printf("sizeof(pa) = %zu\n", sizeof(pa));
printf("sizeof(pb) = %zu\n", sizeof(pb));
return 0;
}
输出:
text
sizeof(pa) = 24
sizeof(pb) = 8
| 声明 | 读法 | sizeof(64 位) |
+1 的步长 |
|---|---|---|---|
int *pa[3] |
pa 是数组,3 个元素,每个是 int * |
24 | 8 字节(pa + 1 指向下一个指针元素) |
int (*pb)[3] |
pb 是指针,指向含 3 个 int 的数组 |
8 | 12 字节 |
[] 的优先级高于 *。int *pa[3] 里 pa 先和 [] 结合,所以是指针数组;int (*pb)[3] 用括号让 pb 先和 * 结合,所以是数组指针。少一对括号,声明就从「指针」变成了「数组」。
4.4 指针数组模拟二维数组
指针数组的每个元素都是地址,可以让它们分别指向几行一维数组:
c
#include <stdio.h>
int main(void)
{
int row1[] = {1, 2, 3};
int row2[] = {4, 5, 6};
int *rows[2] = {row1, row2};
for (int i = 0; i < 2; i++)
{
for (int j = 0; j < 3; j++)
{
printf("%d ", rows[i][j]);
}
printf("\n");
}
return 0;
}
输出:
text
1 2 3
4 5 6
rows[i] 是第 i 行的首地址,rows[i][j] 就是第 i 行第 j 列的元素。它看起来像二维数组,但行与行之间不要求连续,每行长度也可以不同;真正的二维数组每行必须等长,而且整块内存连续。
| 对比项 | 真正的二维数组 | 指针数组模拟 |
|---|---|---|
| 行之间 | 连续存放 | 不要求连续 |
| 每行长度 | 必须相同 | 可以不同 |
| 传参类型 | int (*p)[列数] |
int ** 或 int *rows[] |
| 释放 | 随数组生命周期自动回收 | 各行若是动态分配,要逐行释放 |
5. 指针与函数
5.1 传值调用与传址调用
函数参数默认是传值:形参是实参的副本。要在函数里修改调用方的变量,必须传地址:
c
#include <stdio.h>
void Swap(int *a, int *b)
{
int t = *a;
*a = *b;
*b = t;
}
int main(void)
{
int x = 3;
int y = 5;
Swap(&x, &y);
printf("x = %d, y = %d\n", x, y);
return 0;
}
输出:
text
x = 5, y = 3
Swap(&x, &y) 传的是 x、y 的地址,形参 int *a 接收地址;函数里 *a 访问的就是 main 里的 x。传值调用(pass by value)改的是副本,传址调用(pass by address)通过地址改原对象。
数组传参也是传地址,只是写法上看不出 &:数组名在表达式里已经退化成首元素地址。所以函数里改 arr[i] 会改到原数组。
5.2 返回指针
函数可以返回指针,但返回的地址必须在函数结束后仍然有效。返回数组内部某个元素的地址是安全的,因为数组的生命周期比函数调用长:
c
#include <stdio.h>
int *FindMax(int arr[], int n)
{
int *max = &arr[0];
for (int i = 1; i < n; i++)
{
if (arr[i] > *max)
{
max = &arr[i];
}
}
return max;
}
int main(void)
{
int arr[] = {3, 9, 2, 7, 9};
int *p = FindMax(arr, 5);
printf("最大值 = %d,下标 = %td\n", *p, p - arr);
return 0;
}
输出:
text
最大值 = 9,下标 = 1
FindMax 返回的是 arr 内部元素的地址,arr 在 main 里定义,函数返回后仍然存在,所以这个指针有效。返回局部变量的地址则不行:
c
int *Bad(void)
{
int a[3] = {1, 2, 3};
return a; // 函数返回后 a 已失效,返回的是悬空指针
}
局部数组在栈上,函数返回时栈帧被回收,地址还在,但内容随时可能被下一次函数调用覆盖。需要返回一块新内存时,用 malloc 申请,并在接口上写清楚由谁释放。
5.3 函数指针
函数指针(function pointer)是指向函数的指针。函数也有地址,函数名本身就是函数的地址,&函数名 取到的也是同一个地址。
c
#include <stdio.h>
int Add(int x, int y)
{
return x + y;
}
int main(void)
{
int (*pf)(int, int) = Add;
printf("%d\n", pf(2, 3));
printf("%d\n", (*pf)(3, 5));
return 0;
}
输出:
text
5
8
int (*pf)(int, int) 读作:pf 是一个指针,指向参数为两个 int、返回 int 的函数。括号不能省,int *pf(int, int) 是「返回 int * 的函数」,不是函数指针。
调用时 pf(2, 3) 和 (*pf)(3, 5) 等价。解引用函数指针得到的仍是函数,函数在调用时又会转换成地址,所以多写几层 * 也合法,但没有必要。注意 *pf(2, 3) 是另一种意思:先调用 pf(2, 3),再对返回值解引用,只有返回值是指针时才成立。
5.4 函数指针数组与转移表
把几个函数指针放进数组,就成了函数指针数组。数组下标可以直接当选中的功能编号:
c
#include <stdio.h>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int my_div(int a, int b) { return b == 0 ? 0 : a / b; }
int main(void)
{
int (*ops[4])(int, int) = {add, sub, mul, my_div};
int x = 7, y = 2;
for (int i = 0; i < 4; i++)
{
printf("ops[%d](%d, %d) = %d\n", i, x, y, ops[i](x, y));
}
return 0;
}
输出:
text
ops[0](7, 2) = 9
ops[1](7, 2) = 5
ops[2](7, 2) = 14
ops[3](7, 2) = 3
int (*ops[4])(int, int) 里 ops 先和 [] 结合,所以是数组;数组的每个元素是 int (*)(int, int) 类型的函数指针。这种数组常叫转移表(jump table),用一次下标访问代替一长串 switch。
类型写起来长时,可以用 typedef 简化:
c
typedef int (*pfun_t)(int, int);
pfun_t ops[4] = {add, sub, mul, my_div};
pfun_t 是「指向参数为两个 int、返回 int 的函数」的指针类型,ops 是这种类型的数组。宏不能替代 typedef:宏只是文本替换,#define PFUN int (*)(int, int) 后面声明数组时结合顺序会出错。

自定义的 div 不要和 <stdlib.h> 里的 div 重名,否则同时包含 <stdlib.h> 时会报类型冲突。上面改成了 my_div。
5.5 回调函数与 qsort
回调函数(callback function)是通过函数指针调用的函数:调用方把函数地址交给另一个函数,由后者在合适的时机调用。qsort 就是标准库里的例子:
c
void qsort(void *base, size_t num, size_t size,
int (*cmp)(const void *, const void *));
四个参数分别是:待排序数据的起始地址、元素个数、每个元素的字节数、比较函数的地址。qsort 不知道元素是什么类型,所以用 void * 接收地址,用 size 按字节移动,用 cmp 比较两个元素。
比较函数返回负数表示第一个参数排在前面,返回 0 表示相等,返回正数表示第一个参数排在后面。用相减的写法要注意溢出,整型比较更稳妥的写法是:
c
return (x > y) - (x < y);
排序整型数组:
c
#include <stdio.h>
#include <stdlib.h>
int CmpInt(const void *a, const void *b)
{
int x = *(const int *)a;
int y = *(const int *)b;
return (x > y) - (x < y);
}
int main(void)
{
int arr[] = {5, 2, 9, 1, 7};
int n = (int)(sizeof(arr) / sizeof(arr[0]));
qsort(arr, (size_t)n, sizeof(int), CmpInt);
for (int i = 0; i < n; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
输出:
text
1 2 5 7 9
排序结构体数组时,只换一个比较函数:
c
#include <stdio.h>
#include <stdlib.h>
typedef struct
{
char name[20];
int age;
} Stu;
int CmpAge(const void *a, const void *b)
{
const Stu *pa = (const Stu *)a;
const Stu *pb = (const Stu *)b;
return (pa->age > pb->age) - (pa->age < pb->age);
}
int main(void)
{
Stu s[] = {{"张三", 20}, {"李四", 18}, {"王五", 22}};
int n = (int)(sizeof(s) / sizeof(s[0]));
qsort(s, (size_t)n, sizeof(Stu), CmpAge);
for (int i = 0; i < n; i++)
{
printf("%s %d\n", s[i].name, s[i].age);
}
return 0;
}
输出:
text
李四 18
张三 20
王五 22
CmpAge 的参数是 const void *,先转回 const Stu * 再取成员。const 表示比较函数只读,不修改元素。qsort 只关心返回值的正负,不关心具体数值。
| 排序对象 | 比较函数里做什么 |
|---|---|
| 整型 | 转成 int *,比较两个整数 |
| 浮点型 | 转成 double *,比较两个浮点数 |
| 字符串 | 转成 char **,逐字符比较或调用字符串比较函数 |
| 结构体 | 转成结构体指针,挑一个成员比较 |
5.6 自己写一个泛型排序
把 qsort 的思路拆开,就是一个接收 void *、元素大小和比较函数的排序函数。下面用冒泡排序演示,重点看它怎样在不知道类型的情况下移动和交换元素:
c
#include <stdio.h>
static void SwapBytes(char *a, char *b, size_t size)
{
for (size_t i = 0; i < size; i++)
{
char t = a[i];
a[i] = b[i];
b[i] = t;
}
}
void MyQsort(void *base, size_t num, size_t size,
int (*cmp)(const void *, const void *))
{
char *p = (char *)base;
for (size_t i = 0; i < num; i++)
{
int swapped = 0;
for (size_t j = 0; j + 1 < num - i; j++)
{
char *left = p + j * size;
char *right = left + size;
if (cmp(left, right) > 0)
{
SwapBytes(left, right, size);
swapped = 1;
}
}
if (!swapped)
{
break;
}
}
}
int CmpInt(const void *a, const void *b)
{
int x = *(const int *)a;
int y = *(const int *)b;
return (x > y) - (x < y);
}
int main(void)
{
int arr[] = {5, 2, 9, 1, 7};
int n = (int)(sizeof(arr) / sizeof(arr[0]));
MyQsort(arr, (size_t)n, sizeof(int), CmpInt);
for (int i = 0; i < n; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
输出:
text
1 2 5 7 9
void * 不能直接加减,先转成 char *,按字节移动。第 j 个元素的地址是 p + j * size,下一个元素是 left + size。交换时不知道元素类型,只能逐个字节交换,正好交换 size 个字节。
这个版本是冒泡排序,时间复杂度 O ( n 2 ) O(n^2) O(n2),只用来展示泛型排序的机制。标准库的 qsort 通常用更快的排序算法,接口和这里的思路一致。
6. 动态内存
6.1 为什么需要堆
前面申请内存只有两种方式:定义变量和定义数组。它们的大小在编译时或进入作用域时确定,出了作用域就回收;全局变量则要等程序结束。程序运行时如果才知道需要多少空间,这两种方式都不够灵活。
C 语言把这种「运行时按需申请、用完手动归还」的内存叫动态内存(dynamic memory),放在堆区(heap)。程序运行时的内存通常分成几块:
| 区域 | 存放内容 | 生命周期 | 管理方式 |
|---|---|---|---|
| 栈区(stack) | 局部变量、形参、返回地址 | 函数调用期间 | 编译器自动分配和回收 |
| 堆区(heap) | malloc / calloc / realloc 申请的空间 |
从申请到 free |
程序员手动管理 |
| 数据段 | 全局变量、静态变量 | 整个程序运行期间 | 启动时分配,程序结束回收 |
| 代码区 | 函数的机器指令 | 整个程序运行期间 | 只读 |

栈通常只有几 MB,函数调用一深就可能栈溢出;堆的空间大得多,但申请后不会自动回收,忘记 free 就会一直占着。
6.2 malloc 与 free
malloc 按字节申请一块连续空间,成功返回起始地址,失败返回 NULL:
c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int n = 5;
int *p = (int *)malloc((size_t)n * sizeof(int));
if (p == NULL)
{
printf("malloc 失败\n");
return 1;
}
for (int i = 0; i < n; i++)
{
p[i] = i * i;
}
for (int i = 0; i < n; i++)
{
printf("%d ", p[i]);
}
printf("\n");
free(p);
p = NULL;
return 0;
}
输出:
text
0 1 4 9 16
malloc 返回 void *,赋给 int * 时可以显式转换;C 里不是必须,但加上后 C++ 编译器也能通过。参数是字节数,习惯写成 n * sizeof(int),不要直接写死 4。
malloc 不初始化空间,里面是残留数据。需要清零就用 calloc,或者自己逐个元素赋 0。申请后必须检查返回值,malloc 失败时返回 NULL,直接解引用会崩溃。
free 只释放堆内存,不会把指针置空。释放后指针还保存着原来的地址,这就是悬空指针。所以 free(p) 之后紧接着写 p = NULL,后面再判断 p != NULL 才有意义。
一次成功的申请对应一次 free,而且 free 的参数必须是申请的起始地址。free(NULL) 什么也不做,是安全的;同一块内存释放两次则会破坏堆结构。
6.3 calloc 与 realloc
calloc 申请 num 个大小为 size 的元素,并把每个字节初始化为 0:
c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *p = (int *)calloc(5, sizeof(int));
if (p == NULL)
{
return 1;
}
for (int i = 0; i < 5; i++)
{
printf("%d ", p[i]);
}
printf("\n");
free(p);
p = NULL;
return 0;
}
输出:
text
0 0 0 0 0
calloc 和 malloc 的区别只有初始化:calloc 把空间清零,malloc 不清零。参数上,calloc 接收元素个数和单个元素大小,malloc 接收总字节数。
realloc 用来调整一块已申请空间的大小,原有数据会保留:
c
void *realloc(void *ptr, size_t size);
调整时有两种情况。原空间后面够大,就在原地扩展,返回原地址;后面不够大,就找一块新空间,把旧数据拷贝过去,释放旧空间,返回新地址。所以 realloc 的返回值可能和原指针不同,不能假设它原地扩展。
失败时 realloc 返回 NULL,原来的内存块保持不变。如果直接写 p = realloc(p, new_size),失败后 p 被 NULL 覆盖,原来那块内存的地址就丢了,造成泄漏。正确写法是先用临时指针接住:
c
int *np = (int *)realloc(p, (size_t)new_size * sizeof(int));
if (np == NULL)
{
free(p);
return 1;
}
p = np;
6.4 动态数组:一次完整的扩容
把 malloc、realloc 和 free 放进一个结构里,就是一个可以自动扩容的动态数组:
c
#include <stdio.h>
#include <stdlib.h>
typedef struct
{
int *data;
int size;
int cap;
} IntVec;
int VecPush(IntVec *v, int x)
{
if (v->size == v->cap)
{
int newcap = (v->cap == 0) ? 4 : v->cap * 2;
int *np = (int *)realloc(v->data, (size_t)newcap * sizeof(int));
if (np == NULL)
{
return 0;
}
v->data = np;
v->cap = newcap;
}
v->data[v->size++] = x;
return 1;
}
void VecDestroy(IntVec *v)
{
free(v->data);
v->data = NULL;
v->size = 0;
v->cap = 0;
}
int main(void)
{
IntVec v = {NULL, 0, 0};
for (int i = 0; i < 10; i++)
{
if (!VecPush(&v, i))
{
printf("扩容失败\n");
VecDestroy(&v);
return 1;
}
}
printf("size = %d, cap = %d\n", v.size, v.cap);
for (int i = 0; i < v.size; i++)
{
printf("%d ", v.data[i]);
}
printf("\n");
VecDestroy(&v);
return 0;
}
输出:
text
size = 10, cap = 16
0 1 2 3 4 5 6 7 8 9
size 是已用元素个数,cap 是当前容量。size == cap 时,把容量翻倍,用 realloc 扩展;realloc 的返回值先交给 np,确认非空后再赋回 v->data。这样扩容失败时,原来的数组还在,不会泄漏。
容量按 4、8、16 这样翻倍,单次扩容要拷贝旧数据,但连续 push n 次的总拷贝量是 O ( n ) O(n) O(n),平均到每次是 O ( 1 ) O(1) O(1),这叫均摊 O ( 1 ) O(1) O(1)。
VecDestroy 负责释放 data 并把字段清零。谁申请谁释放:IntVec 内部的 data 由 VecPush 申请,就由 VecDestroy 释放,调用方不需要知道里面的细节。
6.5 六种常见错误
| 错误 | 典型写法 | 后果与改法 |
|---|---|---|
不检查 NULL |
int *p = malloc(...); *p = 1; |
申请失败时解引用空指针;先判断 p != NULL |
| 越界访问 | 申请 10 个 int,写到 p[10] |
破坏堆结构,可能到 free 时才崩溃;只访问 0 到 9 |
| 释放非堆内存 | int a; free(&a); |
未定义行为;free 只接收动态申请的起始地址 |
| 释放偏移过的指针 | p++; free(p); |
不是申请时的起始地址;保留原始指针,另用游标遍历 |
| 重复释放 | free(p); free(p); |
堆损坏;free 后置 NULL |
| 忘记释放 | 申请后直接返回或覆盖指针 | 内存泄漏;每条路径都要释放,或明确交给调用方 |
越界和重复释放有时「能跑」,因为堆布局碰巧没被破坏。换编译器、换优化等级、换运行环境,结果可能完全不同。不报错不等于没错。
6.6 所有权规则
动态内存的问题大多不是语法错误,而是所有权不清楚。写接口时先回答一个问题:这块内存由谁释放?
| 场景 | 谁释放 |
|---|---|
| 函数内部申请、内部使用 | 函数自己 free |
| 函数申请后返回指针 | 调用方 free,接口注释写明 |
| 结构体里有指针成员 | 先释放成员,再释放结构体 |
| 柔性数组 | 只释放结构体本身一次 |
| 指针作为输出参数 | 由调用方传入的指针接收,调用方释放 |
同一块内存只能有一个「负责人」。如果两个指针都以为自己该释放,就会重复释放;如果都以为对方会释放,就会泄漏。
7. 二级指针
指针变量也是变量,也有地址。存放指针变量地址的变量,就是二级指针(pointer to pointer):
c
#include <stdio.h>
int main(void)
{
int a = 10;
int b = 20;
int *pa = &a;
int **ppa = &pa;
*ppa = &b; // 改的是 pa 的指向
printf("*pa = %d\n", *pa);
**ppa = 30; // 改的是 b 的值
printf("b = %d\n", b);
return 0;
}
输出:
text
*pa = 20
b = 30
ppa 保存 pa 的地址。*ppa 先找到 pa,所以 *ppa = &b 等价于 pa = &b;**ppa 再对 pa 解引用,找到 b,所以 **ppa = 30 等价于 b = 30。
一级解引用改指针的指向,二级解引用改指针指向的数据。这个区别在函数需要修改调用方的指针时特别重要:
c
#include <stdio.h>
#include <stdlib.h>
int Allocate(int **p, int n)
{
int *arr = (int *)malloc((size_t)n * sizeof(int));
if (arr == NULL)
{
return 0;
}
for (int i = 0; i < n; i++)
{
arr[i] = i + 1;
}
*p = arr;
return 1;
}
int main(void)
{
int *data = NULL;
if (!Allocate(&data, 5))
{
printf("分配失败\n");
return 1;
}
for (int i = 0; i < 5; i++)
{
printf("%d ", data[i]);
}
printf("\n");
free(data);
data = NULL;
return 0;
}
输出:
text
1 2 3 4 5
Allocate 的形参是 int **p,调用时传 &data。函数里 *p = arr 改的是 main 里的 data,所以 main 能拿到申请到的数组。
如果形参写成 int *p,函数里 p = malloc(...) 改的只是形参这个副本,main 里的 data 仍然是 NULL,申请到的内存也没有人释放。要修改调用方的指针本身,就得把它的地址传进来,用二级指针接收。
8. 小结
- 指针保存地址,
&取地址,*解引用;指针大小只和平台有关,32 位 4 字节,64 位 8 字节。 - 指针类型决定解引用访问几个字节、加减整数走多远;
const紧挨谁就锁谁。 - 野指针从未有效,悬空指针曾经有效、现在失效;定义时初始化,使用前判空,
free后置NULL。 - 数组名在大多数表达式里退化为首元素地址,只有
sizeof(arr)和&arr两个例外;一维数组传首元素地址,二维数组传第一行地址,数组指针和指针数组只差一对括号。 - 函数名就是函数地址;函数指针可以调用函数,函数指针数组可以当转移表,回调函数把「调用谁」交给调用方决定。
- 动态内存申请在堆区:
malloc不初始化,calloc清零,realloc调整大小;返回值要检查,realloc先用临时指针接住,一次申请对应一次free。 - 二级指针用来修改调用方的指针:
*ppa改指向,**ppa改数据;输出参数用int **接收&data。