前言
指针是C语言最核心、最具特色,也最让初学者困惑的知识点。它是C语言的灵魂——正是因为有了指针,C语言才能直接操作内存,实现高效的底层开发,这也是它区别于许多高级语言的关键。
本篇我们将按照从基础到进阶的顺序,全面拆解指针的全部核心内容。读完这一篇,你将彻底建立起完整的指针知识体系,真正理解C语言的底层运行逻辑。
1.指针的定义
在理解指针前,我们先建立一个底层认知:计算机的内存被划分为一个个连续的字节存储单元,每个单元都有一个唯一的编号,这个编号就是内存地址。程序中的所有变量,都存放在这些带编号的内存单元里。
- 指针:本质就是内存地址,是内存单元的编号。
- 指针变量:专门用来存放内存地址的变量。
日常开发中,我们常把指针变量称为指针。它的作用就是记录某一块内存的位置,让我们可以通过地址间接操作对应内存里的数据。
#include<stdio.h>
int main()
{
int a = 0;
int* pa = &a;//将a的地址存放到指针变量pa中
return 0;
}
指针变量的大小只由编译运行的平台位数决定,与指针指向的数据类型无关。
对于32位系统,地址是32个二进制位指针大小固定为4字节;对于64位系统,地址是64个二进制位,指针大小固定为8字节。
2.指针的类型
指针的类型由它指向的数据类型决定。
int *是整型指针,指向int类型变量;
char*是字符指针,指向char类型变量;
float是浮点型指针,指向float类型变量;
其他类型同理。
指针的类型决定了节引用的访问宽度,解引用指针时,编译器会根据指针类型决定从起始地址开始读取多少字节的内存内容。例如, char*类型的指针解引用只读取1个字节;int*类型的指针解引用,读取4个字节。
指针的类型也决定了指针进行加减整数运算时移动的步长,char*类型的指针+1,地址向后移动1个字节;int*类型的指针+1,地址向后移动4个字节。
3.const修饰指针
3.1 const修饰变量
变量是可以修改的,如果把变量的地址交给一个指针变量,通过指针变量也可以修改这个变量。但是如果我们希望一个变量加上一些限制,不能被修改,怎么做呢?这就是const的作用。
#include <stdio.h>
int main()
{
int m = 0;
m = 20;//m是可以修改的
const int n = 0;
n = 20;//n是不能被修改的
return 0;
}
上述代码中n被const修饰,不能直接被修改,但是如果我们绕过n,使用n的地址去修改n,就能做到了。
#include <stdio.h>
int main()
{
const int n = 0;
printf("n = %d\\n", n);
int* p = &n;
*p = 20;
]printf("n = %d\\n", n);
return 0;
}
如果我们希望n也不能被通过地址修改。那么我们就可以用const修饰指针变量。
3.2 const修饰指针变量
一般来讲const修饰指针变量时,可以放在*的左边也可以放在*的右边,意义是不一样的。
const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变,但是指针变量本身的内容可变。
Const如果放在*的右边,修饰的是指针变量本身,保证了指针变量的内容不能修改,但是指针指向的内容可以通过指针改变。
4.野指针
野指针指的是指向非法、未知、已释放内存的空间的指针。它指向的内存地址没有被合法申请,不属于当前程序可访问范围,对野指针进行解引用操作,会引发未定义行为:轻则数据错乱,重则程序直接崩溃。
4.1 野指针形成的原因
1.野指针未初始化
#include<stdio.h>
int main()
{
int*p;//局部变量未初始化,存储的是随机地址
*p = 10;
return 0;
}
2.指针越界访问
#include<stdio.h>
int main()
{
int arr[5] = { 1,2,3,4,5 };
for(int i = 0; i <= 5; i++)//i=5时已超出数组范围
{
*(p + i) = 0;
}
return 0;
}
3.指针指向的空间被释放
#include<stdio.h>
int* test()
{
int a = 10;
return &a;
}
int main()
{
int* p = test();
printf("%d\\n",*p);//第一次可能碰巧读到残留值
printf("%d\\n",*p);//第二次大概率变成乱码,内存已被覆盖
return 0;
}
4.2 如何避免野指针
1.初始化明确指向
定义指针时,如果暂时不知道指向哪里,统一初始化为NULL(空指针)。
int* p = NULL;
NULL是C语言标准宏,本质是地址0,属于系统保护地址,解引用会直接报错,比随机地址的野指针更安全、更容易排查。
2.严格控制指针边界,小心指针越界。
3.内存释放后及时置空。
4.不返回局部变量的地址
5.解引用前检查指针的有效性。
6.assert断言
assert.h头文件定义了宏assert(),用于在运行时确保程序符合指定条件,如果不符合就报错终止运行,这个宏常常被称为“断言”。
assert(p != NULL);
上述代码在程序运行到这一语句时,验证变量p是否等于NULL,如果确实不等于NULL,程序继续运行,否则就会终止运行,并且给出报错信息提示。
assert()宏接受一个表达式作为参数,如果该表达式为真(返回值非零),assert()不会产生任何作用,程序继续运行。如果该表达式为假(返回值为零),assert()就会报错。在标准错误流stderr中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。
assret()还有一种无需更改代码就能开启或关闭的机制,如果已经确认程序没有问题,不需要再做断言。就在语句#include<assert.h>语句的前面定义一个宏NDEBUG。
#define NDEBUG
#include<assert.h>
然后,重新编译程序,编译器就会禁用文件中所有的assert()语句。如果程序有出现问题,可以移除这条#include<assert.h>指令或者把它注释掉,再次注释,这样就重新启用了assert()语句。
5.指针的使用和传址调用
5.1 strlen的模拟实现
库函数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;
}
5.2 传值调用和传址调用
传值调用指的是调用函数时将函数的值拷贝一份传递给形参,形参和实参是两个完全独立的变量,存储在不同的内存空间中。函数内部对形参的所有修改都不会影响外部的实参。
我们尝试写一个函数,交换两个整型变量的值。
#include <stdio.h>
void Swap1(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
}
int main()
{
int a = 10;
int b = 20;
scanf("%d %d", &a, &b);
printf("交换前:a=%d b=%d\\n", a, b);
Swap1(a, b);
printf("交换后:a=%d b=%d\\n", a, b);
return 0;
}
运行结果:

上面Swap1函数内的x、y只是a、b的临时拷贝,交换x、y不影响a、b。
我们希望当调用Swap函数的时候,Swap函数内部操作的就是main函数中的a和b,直接将a和b的值交换。那么就可以使用指针了,在main函数中将a和b的地址传递给Swap函数,Swap函数通过地址间接的操作main函数中的a和b,并达到交换的效果。
#include <stdio.h>
void Swap2(int*px, int*py)
{
int tmp = 0;
tmp = *px;
*px = *py;
*py = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a=%d b=%d\\n", a, b);
Swap2(&a, &b);
printf("交换后:a=%d b=%d\\n", a, b);
return 0;
}
运行结果:

这里调用Swap2函数的时候是将变量的地址传
递给了函数,这种函数调用方式叫:传址调用,函数内部通过指针解引用,可以直接操作地址对应的原始内存,修改会直接作用于外部的实参。
6.数组名的理解
6.1 数组名的本质
在绝大多数场景下,数组名等价于数组首元素的地址,也就是说arr等价于arr[0]。
只有在以下两种特殊情况中,数组名代表整个数组,而非首元素地址。
1.sizeof(数组名)
此时数组名表示整个数组,计算的是整个数组占用的总字节数,不是指针的大小。
2.&数组名
此时数组名表示整个数组,取出的是整个数组的地址而非首元素地址,它的地址数值和首元素地址相同,但步长完全不同。
arr+1:向后跳过一个数组元素
&arr+1:向后跳过一整个数组
6.2 使用指针访问数组
正因为数组名是首元素的地址,我们可以直接用指针遍历、访问数组元素,效果和下标访问完全等价
#include <stdio.h>
int main()
{
int arr[10] = {0};
//输⼊
int i = 0;
int sz = sizeof(arr)/sizeof(arr[0]);
//输⼊
int* p = arr;
for(i = 0; i < sz; i++)
{
scanf("%d", p+i);
//scanf("%d", arr+i);//也可以这样写
}
//输出
for(i = 0; i < sz; i++)
{
printf("%d ", *(p+i));
}
return 0;
}
这里数组名arr和p时等价的,我们可以用arr[i]访问数组元素,也可以用p[i]来访问数组元素。
#include <stdio.h>
int main()
{
int arr[10] = {0};
//输⼊
int i = 0;
int sz = sizeof(arr)/sizeof(arr[0]);
//输⼊
int* p = arr;
for(i = 0; i < sz; i++)
{
scanf("%d", p+i);
//scanf("%d", arr+i);//也可以这样写
}
//输出
for(i = 0; i < sz; i++)
{
printf("%d ", p[i]);
}
return 0;
}
6.3 一维数组传参的本质
一维数组作为函数参数传递时,数组名本质上是数组首元素的指针,在函数内部用sizeof计算形参数组的大小得到的永远是指针的大小,而非整个数组的大小。
#include<stdio.h>
void test(int arr[])
{
printf("sizeof(arr) = %zu\\n",sizeof(arr));
}
int main()
{
int arr[10] = {0};
printf("sizeof(arr) = %zu\\n",sizeof(arr));
test(arr);
return 0;
}

7.二级指针
二级指针是专门存放一级指针变量的指针。
#include<stdio.h>
int main()
{
int a = 10;
int* pa = &a;
int** ppa = &pa;
printf("%d",**ppa);//输出10
return 0;
}
*ppa:对二级指针解引用,得到的是一级指针pa;
**ppa两次解引用,得到的是a。
8.指针数组
指针数组是一个数组,数组中每一个元素都是指针变量。
#include<stdio.h>
int main()
{
int a = 10;
int b = 20;
int c = 30;
//定义数组指针,3个元素,每个元素都是int*类型
int* arr[3] = { &a,&b,&c };
printf("%d",*arr[0]);//输出10
return 0;
}
我们可以用指针数组来模拟二维指针
#include<stdio.h>
int main()
{
int arr1[] = {1,2,3,4,5};
int arr2[] = {2,3,4,5,6};
int arr3[] = {3,4,5,6,7};
//数组名是数组⾸元素的地址,类型是int*的,就可以存放在parr数组中
int* parr[3] = {arr1, arr2, arr3};
int i = 0;
int j = 0;
for(i = 0; i < 3; i++)
{
for(j = 0; j < 5; j++)
{
printf("%d ", parr[i][j]);
}
printf("\\n");
}
return 0;
}
parr[i]是访问parr数组的元素,parr[i]找到的数组元素指向了整型一维数组,parr[i][j]就是整型一维数组中的元素。
上述的代码模拟出二维数组的效果,实际上并非完全是二维数组,因为每一行并非是连续的。
9.字符指针
在指针的类型中有一种指针类型为字符指针char*。它的一般使用方法为:
include<stdio.h>
int main()
{
char ch = 'w';
char *pc = &ch;
*pc = 'w';
return 0;
}
还有一种使用方法
#include<stdio.h>
int main()
{
const char* pstr = "hello";
printf("%s\\n", pstr);
returm 0;
}
这里并不是把字符串''hello''放到了字符指针pstr里而是把字符串首字符的地址放到了pstr中。
这里的’'hello"是一个常量字符串,是不可以被修改的,在内存中占有固定的空间。
const char* str1 = "hello";
const char* str2 = "hello";
这里的str1和str2的值是相等的。
如果写成
const str3[]="hello";
const str4[]="hello";
用相同的常量字符串去初始化不同的数组的时候就会开辟出不同的内存空间。所以str3和str4不同。
10.数组指针
10.1 数组指针的定义和初始化
数组指针是指针,专门用来指向一整个数组,存储整个数组的地址。
这是一个数组指针变量
int (*p)[10];
p先和*结合,说明 p 是一个指针变量,该指针指向的是一个大小为10个整型的数组。这里要注意: []的优先级要高于*号的,所以必须加上()来保证p先和*结合。
数组指针接收整个数组的地址 &数组名
int arr[10] = { 0 };
int(*p)[5] = &arr;
10.2 二维数组传参的本质
#include <stdio.h>
void test(int a[3][5], int r, int c)
{
int i = 0;
int j = 0;
for (i = 0; i < r; i++)
{
for (j = 0; j < c; j++)
{
printf("%d ", a[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} };
test(arr, 3, 5);
return 0;
}
这里实参是二维数组,形参也可以写成二维数组的形式。
二维数组其实可以看作是每个元素是一维数组的数组,也就是二维数组的每个元素是一个一维数组,那么二维数组的首元素就是第一行,是个一维数组。
所以根据数组名是数组首元素的地址这个规则,二维数组的数组名表示的就是第一行的地址,是一个一维数组的地址。根据上面的例子第一行的一维数组的类型就是int[5],所以第一行的地址类型就是数组指针类型int(*)[5]。那就意味着二维数组传参本质上也是传递了地址,传递的是第一行这个一维数组的地址,那么形参也是可以写成指针形式的,如下:
#include <stdio.h>
void test(int (*p)[5], int r, int c)
{
int i = 0;
int j = 0;
for (i = 0; i < r; i++)
{
for (j = 0; j < c; 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} };
test(arr, 3, 5);
return 0;
}
也就是说,二维数组传参,形参可以写成数组的形式,也可以写成指针的形式。
11.函数指针
11.1函数指针的定义
每个函数都有一块独立内存,因此函数也有自己的内存地址,用来存储函数地址的指针变量就叫函数指针。
函数指针的语法形式是
返回值类型(*指针名)(参数类型1,参数类型2…);
*先和变量名结合,代表这是指针变量,后面的()代表该指针指向一个函数,前面的返回值、括号内的参数列表,必须和目标函数完美匹配。
11.2 函数指针的使用
函数名本身就是函数地址,给函数指针赋值时可以不用加&,两种写法完全等效。
同样地,拿到函数指针后,通过指针调用原函数,也可以不用加*,两种写法完全等效。
int Add(int x, int y)
{
return x+y;
}
int main()
{
int a = 10;
int b = 20;
int (*p)(int, int) = &Add;
//也可以写成 int (*p)(int, int) = Add;
int ret = (*p)(10,20)
//这里也可以写成 int ret = p(10,20)
printf("%d",ret);//30
return 0;
}
11.3 函数指针数组
把同类型函数指针存放到一个数组中,这个数组就称为函数指针数组。
函数指针数组的语法形式是:
返回值类型 (*数组名[数组长度])(参数列表);
例如
int (*parr[3])()
变量名先和[]结合表示这是一个数组,剩下的int(*)()表示数组中元素的类型。
我们可以用函数指针数组实现转移表,写一个简易计算器。
#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 div(int a, int b)
{
return a / b;
}
int main()
{
int x, y;
int input = 1;
int ret = 0;
int(*p[5])(int x, int y) = { 0, add, sub, mul, div }; //转移表
do
{
printf("*************************\\n");
printf(" 1:add 2:sub \\n");
printf(" 3:mul 4:div \\n");
printf(" 0:exit \\n");
printf("*************************\\n");
printf("请选择:");
scanf("%d", &input);
if ((input <= 4 && input >= 1))
{
printf("输入操作数:");
scanf("%d %d", &x, &y);
ret = (*p[input])(x, y);
printf("ret = %d\\n", ret);
}
else if (input == 0)
{
printf("退出计算器\\n");
}
else
{
printf("输入有误\\n");
}
} while (input);
return 0;
}
11.4 typedef关键字
函数指针原生写法非常冗长,我们可以用typedef关键字简化函数指针。
typedef的作用是给已存在的数据类型起别名,不会创造新类型,只是简化书、写提升可读性。
它的语法形式是:
typedef 原类型 新类型名;
#include<stdio.h>
int Add(int x, int y)
{
return x+y;
}
int Sub (int x, int y)
{
return x – y;
}
typedef int (*Func)(int int);
int main()
{
Func f1 = Add;
Func f2 = Sub;
printf("%d\\n",f1(10, 5));//15
printf("%d\\n",f2(10, 5));//5
Func arr[2] = { Add, Sub };//函数指针数组
printf("%d", arr[0](10,5));//15
return 0;
}
12.回调函数
回调函数就是一个通过函数指针调用的函数。
如果你把函数的指针作为参数传递给另一个函数,当这个指针被用来调用其所指的函数时,被调用的函数就是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的。用于对该事件或条件进行响应。
我们也可以用回调函数实现简易计算器
#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 div(int a, int b)
{
return a / b;
}
void calc(int(*pf)(int, int))
{
int ret = 0;
int x, y;
printf("输⼊操作数:");
scanf("%d %d", &x, &y);
ret = pf(x, y);
printf("ret = %d\\n", ret);
}
int main()
{
int input = 1;
do
{
printf("*************************\\n");
printf(" 1:add 2:sub \\n");
printf(" 3:mul 4:div \\n");
printf("*************************\\n");
printf("请选择:");
scanf("%d", &input);
switch (input)
{
case 1:
calc(add);
break;
case 2:
calc(sub);
break;
case 3:
calc(mul);
break;
case 4:
calc(div);
break;
case 0:
printf("退出程序\\n");
break;
default:
printf("选择错误\\n");
break;
}
} while (input);
return 0;
}
13.qsort函数
qsort是C语言标准库提供的快速排序函数,头文件:<stdlib.h>。
void qsort(void *base, size_t num, size_t size, int(*compar) (const void*,const void*)
- void* base:待排序数组首地址
- size_t num:数组元素个数
- size_t size:单个元素占用字节个数
- int(*compar) (const void*,const void*):比较函数指针,排序规则由它决定
比较函数的返回规则是:
设两个参数为a、b,a>b时返回大于零的数,a<b时返回小于零的数,a=b时返回0。
示例:int整型数组升序排序
#include <stdio.h>
#include <stdlib.h>
// 整型比较函数
int cmp_int(const void* a, const void* b)
{
// 强转成int* 解引用取值
return *(int*)a – *(int*)b; // 升序
// return *(int*)b – *(int*)a; // 降序
}
int main()
{
int arr[] = {5,2,9,1,5,6};
int sz = sizeof(arr)/sizeof(arr[0]);
qsort(arr, sz, sizeof(int), cmp_int);
for(int i=0; i<sz; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
为了更好地理解qsort函数,我们可以借用回调函数,模拟实现qsort函数,这里我们采用冒泡排序的方式
#include<stdio.h>
int cmp_int(const void* p1, const void* p2)
{
if (*(int*)p1 > *(int*)p2)
return 1;
else if (*(int*)p1 < *(int*)p2)
return -1;
else
return 0;
}
Swap(char* buf1, char* buf2, size_t width)
{
for (int i = 0; i < width; i++)
{
char temp = 0;
temp = *buf1;
*buf1 = *buf2;
*buf2 = temp;
buf1++;
buf2++
}
}
void bubble_sort2(void* base, size_t sz, size_t width,int (*cmp)(const void* p1,const void* p2))
{
for (int i = 0; i < sz – 1; i++)
{
for (int j = 0; j < sz – 1 – i; j++)
{
if (cmp((char*)base+j*width,(char*)base+(j+1)*width)>0)
{
Swap((char*)base + j * width, (char*)base + (j + 1) * width, width);
}
}
}
}
int main()
{
int arr[] = { 4,6,5,2,7,3,1,8,9,0 };
int sz = sizeof(arr) / sizeof(arr[0]);
print_arr(arr, sz);
bubble_sort2(arr, sz, sizeof(arr[0]),cmp_int);
print_arr(arr, sz);
return 0;
}

