【C语言】指针

摘要 :指针保存的是地址。本文从变量的地址讲起,说明 & 与 * 的作用、指针类型怎样决定解引用的宽度和加减整数的步长;再讲指针运算、数组名的两个例外、一维和二维数组传参、数组指针与指针数组、函数指针与回调,最后落到 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。局部指针变量不初始化时,里面可能是任何值,恰好看起来像一个合法地址,解引用就会改坏别的数据,或者直接崩溃。

避免这些问题,做法固定:

  1. 定义时就初始化:指向有效对象,或者先给 NULL。
  2. 使用前检查非空:if (p != NULL);内部接口可以用 assert(p != NULL) 把错误尽早暴露。
  3. 不越界:指针只在同一块数组或同一块动态内存里移动。
  4. 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。
相关推荐
小小数媒成员1 小时前
UGUI原理(一)
开发语言·游戏·unity·游戏引擎
速易达网络2 小时前
Three.js 动画入门教学:从场景搭建到流畅动效
开发语言·javascript·ecmascript
福大大架构师每日一题2 小时前
excelize v2.11.1发布:Go 1.26.0起步,16项安全修复与嵌入对象能力全面升级
开发语言·安全·golang
Sarvartha2 小时前
集合概述 & Collection 接口
开发语言
lhbweilai2 小时前
QT项目(串口助手)
开发语言·qt
eggwyw2 小时前
C++相交链表和反转链表详解
开发语言·c++
Wang's Blog2 小时前
Java 项目实战:交友项目 - 保存访客记录
java·开发语言·交友
Raas1003 小时前
AI网关如何接入?MAI Gateway部署实操教程
java·开发语言·人工智能·gateway·ai网关·mai gateway·企业级产品
小小龙学IT3 小时前
Go 语言 pprof 性能剖析与调优深度解析:把“卡在哪“变成一张可执行的数据图
开发语言·后端·golang