指针(Pointer)是C语言中最强大、最灵活,但也最容易令人困惑的概念之一。它直接操作内存地址,赋予程序员底层控制能力,使得C语言在系统编程、嵌入式开发、数据结构等领域占据不可替代的地位。然而,指针的不当使用也常常导致程序崩溃、内存泄漏、数据损坏等严重问题。
为什么指针如此重要?如何正确理解指针的本质?指针与数组、函数、动态内存管理之间有何联系?如何避免指针使用中的常见陷阱?
本文将系统性地剖析指针的核心概念,包括:
无论你是C语言初学者,还是希望深入理解底层机制的开发者,本文都将帮助你掌握指针的精髓,并写出更高效、更安全的代码。
让我们从指针的基础概念开始,逐步深入,揭开它神秘的面纱!
在计算机科学中,指针(Pointer)是编程语言中的一个对象,利用地址,它的值直接指向(points to)存在电脑存储器中另一个地方的值。由于通过地址能找到所需的内存单元,可以说地址指向该内存单元。因此,将地址形象化的称为“指针”。意思是通过它能找到以它为地址的内存单元。
这是官方对指针的定义,其实我们可以理解为:在内存中,内存被细分为一个个大小为一个字节的内存单元,每一个内存单元都有自己对应的地址。

注意:

我们可以将这些内存单元形象地看成一个个的房间,将内存单元(房间)对应的地址形象地看成房间的门牌号。而我们通过门牌号(地址)就可以唯一的找到其对应的房间(内存单元),即地址指向对应内存单元。所以说,可以将地址形象化的称为“指针”。 指针变量是用于存放地址的变量。(存放在指针中的值都将被当作地址处理)
#include<stdio.h>
int main()
{
int a = 10;//在内存中开辟一块空间
int* p = &a;//将a的地址取出,放到指针变量p中
return 0;
}总结:
对于32位的机器,即有32根地址线,因为每根地址线能产生正电(1)或负电(0),所以在32位的机器上能够产生的地址信号就是32个0/1组成的二进制序列。一共 2 32 个地址。同样的算法,在64位的机器上一共能产生 264 个不同的地址。
总结:
在32位平台下指针的大小为4个字节,在64位平台下指针的大小为8个字节。
我们知道,变量的类型有int,float,char等。那么指针有没有类型呢?回答是肯定的。 指针的定义方式是type+ * **char *** 类型的指针存放的是char类型的变量地址; **int *** 类型的指针存放的是int类型的变量地址; **float *** 类型的指针存放的是float类型的变量地址等。


若指针类型为char *,那么将它进行解引用操作,它将可以访问从指向位置开始向后1个字节的内容,以此类推.
总结:
概念: 野指针就是指向位置是不可知的(随机的、不正确的、没有明确限制的)指针。
#include<stdio.h>
int main()
{
int* p;
*p = 10;
return 0;
}局部指针变量p未初始化,默认为随机值,所以这个时候的p就是野指针。
#include<stdio.h>
int main()
{
int arr[10] = { 0 };
int* p = &arr[0];
int i = 0;
for (i = 0; i < 11; i++)
{
*p++ = i;
}
return 0;
}当指针指向的范围超出arr数组时,p就是野指针。
#include<stdio.h>
int* test()
{
int a = 10;
return &a;
}
int main()
{
int* p = test();
return 0;
}指针变量p得到地址后,地址指向的空间已经释放了,所以这个时候的p就是野指针。(局部变量出了自己的作用域就被释放了)
#include<stdio.h>
int main()
{
int a = 10;
int* p1 = &a;//明确知道存放某一地址
int* p2 = NULL;//不知道存放哪一地址时置为空指针
return 0;
}#include <stdio.h>
int main()
{
int n = 10;
char *pc = (char*)&n;
int *pi = &n;
printf("%p\n", &n);
printf("%p\n", pc);
printf("%p\n", pc+1);
printf("%p\n", pi);
printf("%p\n", pi+1);
return 0;
}运行结果如下:

我们可以看出, char* 类型的指针变量+1跳过1个字节, int* 类型的指针变量+1跳过了4个字节。 这就是指针变量的类型差异带来的变化。指针+1,其实跳过1个指针指向的元素。指针可以+1,那也可以-1。
结论:指针的类型决定了指针向前或者向后⾛⼀步有多⼤(距离)。
指针-指针的绝对值是是两个指针之间的元素个数。
//指针-指针
#include <stdio.h>
int my_strlen(char *s)
{
char *p = s;
while(*p != '\0' )
p++;
return p-s;
}
int main()
{
printf("%d\n", my_strlen("abc"));
return 0;
}运行结果:

//指针的关系运算
#include <stdio.h>
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = &arr[0];
int sz = sizeof(arr)/sizeof(arr[0]);
while(p<arr+sz) //指针的⼤⼩⽐较
{
printf("%d ", *p);
p++;
}
return 0;
}变量是可以修改的,如果把变量的地址交给⼀个指针变量,通过指针变量的也可以修改这个变量。 但是如果我们希望⼀个变量加上⼀些限制,不能被修改,怎么做呢?这就是const的作⽤。
#include <stdio.h>
int main()
{
int m = 0;
m = 20;//m是可以修改的
const int n = 0;
n = 20;//n是不能被修改的
return 0;
}上述代码中n是不能被修改的,其实n本质是变量,只不过被const修饰后,在语法上加了限制,只要我们在代码中对n就⾏修改,就不符合语法规则,就报错,致使没法直接修改n。但是如果我们绕过n,使⽤n的地址,去修改n就能做到了,虽然这样做是在打破语法规则。

我们可以看到这⾥⼀个确实修改了,但是我们还是要思考⼀下,为什么n要被const修饰呢?就是为了不能被修改,如果p拿到n的地址就能修改n,这样就打破了const的限制,这是不合理的,所以应该让p拿到n的地址也不能修改n,那接下来怎么做呢?
⼀般来讲const修饰指针变量,可以放在的左边,也可以放在的右边,意义是不⼀样的。
#include <stdio.h>
//代码1 - 测试⽆const修饰的情况
void test1()
{
int n = 10;
int m = 20;
int *p = &n;
*p = 20;//ok?
p = &m; //ok?
}
//代码2 - 测试const放在*的左边情况
void test2()
{
int n = 10;
int m = 20;
const int* p = &n;
*p = 20;//ok?
p = &m; //ok?
}
//代码3 - 测试const放在*的右边情况
void test3()
{
int n = 10;
int m = 20;
int * const p = &n;
*p = 20; //ok?
p = &m; //ok?
}
//代码4 - 测试*的左右两边都有const
void test4()
{
int n = 10;
int m = 20;
int const * const p = &n;
*p = 20; //ok?
p = &m; //ok?
}
int main()
{
//测试⽆const修饰的情况
test1();
//测试const放在*的左边情况
test2();
//测试const放在*的右边情况
test3();
//测试*的左右两边都有const
test4();
return 0;
}结论:const修饰指针变量的时候
库函数strlen的功能是求字符串⻓度,统计的是字符串中 \0 之前的字符的个数。 函数原型如下:
size_t strlen ( const char * str );参数str接收⼀个字符串的起始地址,然后开始统计字符串中 \0 之前的字符个数,最终返回⻓度。 如果要模拟实现只要从起始地址开始向后逐个字符的遍历,只要不是 \0 字符,计数器就+1,这样直到 \0 就停⽌。
int my_strlen(const char * str)
{
int count = 0;
assert(str);
while(*str)
{
count++;
str++;
}
return count;
}
int main()
{
int len = my_strlen("abcdef");
printf("%d\n", len);
return 0;
}

传址调⽤,可以让函数和主调函数之间建⽴真正的联系,在函数内部可以修改主调函数中的变量;所以未来函数中只是需要主调函数中的变量值来实现计算,就可以采⽤传值调⽤。如果函数内部要修改主调函数中的变量的值,就需要传址调⽤。
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = &arr[0];这⾥我们使⽤ &arr[0] 的⽅式拿到了数组第⼀个元素的地址,但是其实数组名本来就是地址,⽽且是数组⾸元素的地址。

但是数组名是首元素地址,如何理解下面代码?

其实数组名就是数组⾸元素(第⼀个元素)的地址是对的,但是有两个例外:


数组名arr是数组⾸元素的地址,可以赋值给p,其实数组名arr和p在这⾥是等价的。那我们可以使⽤arr[i]可以访问数组的元素,那p[i]是否也可以访问数组呢?

同理arr[i]应该等价于*(arr+i),数组元素的访问在编译器处理的时候,也是转换成⾸元素的地址+偏移量求出元素的地址,然后解引⽤来访问的。
注意:在数组传参的时候,传递的是数组名,也就是说本质上数组传参传递的是数组⾸元素的地址。

对于⼆级指针的运算有:
我们知道,在指针的类型中有一种指针类型叫字符指针char * 。 字符指针的一般使用方法为:
#include<stdio.h>
int main()
{
char ch = 'w';
char* p = &ch;
return 0;
}代码中,将字符变量ch的地址存放在了字符指针p中。
其实,字符指针还有另一种使用方式:
#include<stdio.h>
int main()
{
char* p = "hello csdn.";
printf("%c\n", *p);//打印字符'h'
printf("%s\n", p);//打印字符串"hello csdn."
return 0;
}#include<stdio.h>
int main()
{
char* p = "hello csdn.";
printf("%c\n", *p);//打印字符'h'
printf("%s\n", p);//打印字符串"hello csdn."
return 0;
}代码中,字符指针p中存放的并非字符串"hello csdn.",字符指针p中存放的是字符串"hello csdn.“的首元素地址,即字符’h’的地址。 所以,当对字符指针p进行解引用操作并以字符的形式打印时只能打印字符’h’。我们知道,打印一个字符串只需要提供字符串的首元素地址即可,既然字符指针p中存放的是字符串的首元素地址,那么我们只要提供p(字符串首地址)并以字符串的形式打印,便可以打印字符串"hello csdn.”。 注意:代码中的字符串"hello csdn."是一个常量字符串。
这里有一道题目,可以帮助我们更好的理解字符指针和常量字符串:
#include <stdio.h>
int main()
{
char str1[] = "hello csdn.";
char str2[] = "hello csdn.";
char *str3 = "hello csdn.";
char *str4 = "hello csdn.";
if (str1 == str2)
printf("str1 and str2 are same\n");
else
printf("str1 and str2 are not same\n");
if (str3 == str4)
printf("str3 and str4 are same\n");
else
printf("str3 and str4 are not same\n");
return 0;
}
题目中str1和str2是两个字符数组,比较str1和str2时,相当于比较数组str1和数组str2的首元素地址,而str1与str2是两个不同的字符数组,创建数组str1和数组str2是会开辟两块不同的空间,它们的首元素地址当然不同。

而str3和str4是两个字符指针,它们指向的都是常量字符串"hello csdn."的首元素地址,所以str3和str4指向的是同一个地方。

注意:常量字符串与普通字符串最大的区别是,常量字符串是不可被修改的字符串,既然不能被修改,那么在内存中没有必要存放两个一模一样的字符串,所以在内存中相同的常量字符串只有一个。
指针数组也是数组,是用于存放指针的数组。
int* arr3[5];char* arr4[10];//数组arr4包含10个元素,每个元素是一个一级字符型指针。
char** arr5[5];//数组arr5包含5个元素,每个元素是一个二级字符型指针。我们已经知道了,整型指针是指向整型的指针,字符指针是指向字符的指针,那么数组指针应该就是指向数组的指针了。整型指针和字符指针,在使用时只需取出某整型/字符型的数据的地址,并将地址存入整型/字符型指针即可。
#include<stdio.h>
int main()
{
int a = 10;
int* pa = &a;//取出a的地址存入整型指针中
char ch = 'w';
char* pc = &ch;//取出ch的地址存入字符型指针中
return 0;
}数组指针也是一样,我们只需取出数组的地址,并将其存入数组指针即可。
#include<stdio.h>
int main()
{
int arr[10] = { 0 };
int(*p)[10] = &arr;
//&arr - 数组的地址
return 0;
}解释:p先和结合,说明p是⼀个指针变量,然后指针指向的是⼀个⼤⼩为10个整型的数组。所以p是⼀个指针,指向⼀个数组,叫数组指针。 这⾥要注意:[]的优先级要⾼于号的,所以必须加上()来保证p先和*结合。


数组指针有一个简单的使用案例,那就是打印二维数组:
#include<stdio.h>
void print(int(*p)[5], int row, int col)
{
int i = 0;
for (i = 0; i < row; i++)//行数
{
int j = 0;
for (j = 0; j < col; j++)//列数
{
printf("%d ", *(*(p + i) + j));
}
printf("\n");//打印完一行后,换行
}
}
int main()
{
int arr[3][5] = { { 1, 2, 3, 4, 5 }, { 2, 3, 4, 5, 6 }, { 3, 4, 5, 6, 7 } };
print(arr, 3, 5);//传入二维数组名,即二维数组首元素地址,即二维数组第一行的地址
return 0;
}在这里我们打印一个三行五列的二维数组。传参时我们传入二维数组的数组名,明确打印的起始位置;传入行数和列数,明确打印的数据范围。 通过上面对&数组名和数组名的认识,我们知道了这里传入的数组名代表的是二维数组的首元素地址,而二维数组的首元素第一行的元素,即传入的是一维数组的地址,所以我们必须用数组指针进行接收。 打印时,通过表达式 * (*(p+i)+j ) 锁定打印目标:


总结:⼀维数组传参,形参的部分可以写成数组的形式,也可以写成指针的形式
void test1(int arr[2][3], int row, int column)
{
for (int i = 0; i < row; i++)
{
for (int j = 0; j < column; j++)
{
printf("%d ", arr[i][j]);
}
printf("\n");
}
}
void test2(int(*p)[3], int row, int column)
{
for (int i = 0; i < row; i++)
{
for (int j = 0; j < column; j++)
{
printf("%d ", *(*(p+i) + j));
}
printf("\n");
}
}
#include<stdio.h>
int main()
{
int arr[2][3] = { {1,2,3},{7,8,9} };
test1(arr,2,3);
test2(arr, 2, 3);
return 0;
}
所以,根据数组名是数组⾸元素的地址这个规则,⼆维数组的数组名表⽰的就是第⼀⾏的地址,是⼀维数组的地址。第⼀⾏的⼀维数组的类型就是 int [3] ,所以第⼀⾏的地址的类型就是数组指针类型 int(*)[3] 。那就意味着⼆维数组传参本质上也是传递了地址,传递的是第⼀⾏这个⼀维数组的地址,那么形参也是可以写成指针形式的。
总结:⼆维数组传参,形参的部分可以写成数组,也可以写成指针形式。
我们知道,整型指针是指向整型的指针,数组指针是指向数组的指针,其实,函数指针就是指向函数的指针。 和学习数组指针一样,学习函数指针我们也需要知道三点:
( )的优先级要高于 * 。 一个变量除去了变量名,便是它的变量类型。 一个指针变量除去了变量名和 * ,便是指针指向的内容的类型。
#include<stdio.h>
int Add(int x, int y)
{
return x + y;
}
int main()
{
int(*p)(int, int) = &Add;//取出函数的地址放在函数指针p中
return 0;
}那么,函数指针p的类型我们是如何创建的呢?

知道了如何创建函数指针,那么函数指针应该如何使用呢?
int(*p)(int, int) = &Add;
int(*p)(int, int) = Add;#include<stdio.h>
int Add(int x, int y)
{
return x + y;
}
int main()
{
int a = 10;
int b = 20;
int(*p)(int, int) = &Add;
int ret = (*p)(a, b);//解引用找到该函数
printf("%d\n", ret);
return 0;
}可以理解为, * 和&是两个相反的操作符,像正号(+)和负号(-)一样,一个 * 操作符可以抵消一个&操作符。

**方法二:**我们在函数指针赋值中说到,函数名和&函数名都代表函数的地址,我们可以赋值时直接赋值函数名,那么通过函数指针调用函数的时候我们就可以不用解引用操作符就能找到函数了。
#include<stdio.h>
int Add(int x, int y)
{
return x + y;
}
int main()
{
int a = 10;
int b = 20;
int(*p)(int, int) = Add;
int ret = p(a, b);//不用解引用
printf("%d\n", ret);
return 0;
}
int(*pArr[10])(int, int);
//数组pArr有10个元素,每个元素的类型是int(*)(int,int)函数指针数组的创建只需在函数指针创建的基础上加上[ ]即可。 比如,你要创建一个函数指针数组,这个数组中存放的函数指针的类型均为int(*)(int,int),如果你要创建一个函数指针为该类型,那么该函数指针的写法为int(*p)(int,int),现在你要创建一个存放该指针类型的数组,只需在变量名的后面加上[ ]即可,int(*pArr[10])(int,int)。
函数指针数组一个很好的运用场景,就是计算机的模拟实现:
#include<stdio.h>
//菜单
void menu()
{
printf("|----------------------------|\n");
printf("|----------- 0.Exit ---------|\n");
printf("|----------- 1.Add ---------|\n");
printf("|----------- 2.Sub ---------|\n");
printf("|----------- 3.Mul ---------|\n");
printf("|----------- 4.Div ---------|\n");
printf("|----------------------------|\n");
}
//加
int Add(int x, int y)
{
return x + y;
}
//减
int Sub(int x, int y)
{
return x - y;
}
//乘
int Mul(int x, int y)
{
return x * y;
}
//除
int Div(int x, int y)
{
return x / y;
}
#include<stdio.h>
int main()
{
int input = 0;//输入选项
int a = 0;//第一个操作数
int b = 0;//第二个操作数
int ret = 0;//计算结果
int(*Parr[5])(int,int) = {0,Add,Sub,Mul,Div};//加0是因为让下标刚好对应选项
int sz = sizeof(Parr) / sizeof(Parr[0]);
do {
menu();
printf("请输入:\n");
scanf_s("%d", &input);
if (input == 0)
{
printf("程序退出!\n");
break;
}
else if (input > 0 && input < sz)
{
printf("请输入两个需要计算的数:");
scanf_s("%d %d", &a, &b);
ret = Parr[input](a, b);
printf("ret = %d\n", ret);
}
else
{
printf("输入错误!请重新输入!");
}
} while (input);
return 0;
}代码中,函数指针数组存放的是一系列参数和返回类型相同的函数名,即函数指针。将0放在该函数指针数组的第一位是为了让用户输入的数字input与对应的函数指针下标相对应。 该代码若不使用函数指针数组,而选择使用一系列的switch分支语句当然也能达到想要的效果,但会使代码出现许多重复内容,而且当以后需要增加该计算机功能时又需要增加一个case语句,而使用函数指针数组,当你想要增加计算机功能时只需在数组中加入一个函数名即可。
既然存在函数指针数组,那么必然存在指向函数指针数组的指针。
int(*p)(int, int);
//函数指针
int(*pArr[5])(int, int);
//函数指针数组
int(*(*pa)[5])(int, int) = &pArr;
//指向函数指针数组的指针
所以pa就是一个指向函数指针数组的指针,该函数指针数组中每个元素类型是int(*)(int, int)。
回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。
#include<stdio.h>
void test1()
{
printf("hello\n");
}
void test2(void(*p)())
{
p(); //指针p被用来调用其所指向的函数
}
int main()
{
test2(test1);//将test1函数的地址传递给test2
return 0;
}在该代码中test1函数不是由该函数的实现方直接调用,而是将其地址传递给test2函数,在test2函数中通过函数指针间接调用了test1函数,那么函数test1就被称为回调函数。

qsort函数的返回类型为void。 qsort函数的第四个参数是一个函数指针,该函数指针指向的函数的两个参数的参数类型均为const void*,返回类型为int。当参数e1小于参数e2时返回小于0的数;当参数e1大于参数e2时返回大于0的数;当参数e1等于参数e2时返回0。
列如,我们要排一个整型数组:
#include<stdio.h>
int compare(const void* e1, const void* e2)
{
return *((int*)e1) - *((int*)e2);
}//自定义的比较函数
int main()
{
int arr[] = { 2, 5, 1, 8, 6, 10, 9, 3, 5, 4 };
int sz = sizeof(arr) / sizeof(arr[0]);//元素个数
qsort(arr, sz, 4, compare);//用qsort函数将arr数组排序
return 0;
}最终arr数组将被排为升序。
注意:qsort函数默认将待排序的内容排为升序,如果我们要排为降序可将自定义的比较函数的两个形参的位置互换一下即可。
在qsort函数中我们传入了一个函数指针,最终qsort函数会在其内部通过该函数指针调用该函数,那么我们的这个自定义比较函数就被称为回调函数。