目录
指针,即指针变量,是C语言底层逻辑的最重要构成。
在详细学习指针变量之前,我们需要从内存谈起。
一.内存(引子)
计算机的内存被实际划分为一个个内存单元,每个内存单元的大小是一个字节(这里暂时不解释为什么是一个字节)。CPU在访问内存的某个字节空间时,必须提前得知这一段内存在什么位置,于是,我们需要对每一个内存单元进行编号,这样一来,我们就可以借助这个编号去访问一块特定的空间(就像钥匙开锁一样)。而这种编号的行为,就被称为编址。那么,编址是如何实现的呢?
实际上,计算机中的编址,并不是把每个字节的地址记录下来,而是通过硬件设计完成的。计算机内有很多硬件单元,他们需要互相协同工作,在协同中,他们必然需要相互传递数据。
但是硬件和硬件之间是相互独立的,要如何实现通信呢,答案是,用“线”连起来。
而CPU和内存之间也是有大量的数据交互的,所以,两者也必须用线连起来。在这里,我们暂时只关心一组线:地址总线。这些硬件与硬件,硬件与CPU之间的1根地址线,传递的电信号有1,0两种(电脉冲的有无)。而2根地址线传递的就有2^2=4种,3根地址线传递的就有2^3=8种,...n根地址线传递的就有2^n种。
我们常说的32位(x86),64位机器(x64)各自可以简单理解为有32根,64根地址线。这样一来,他们分别能存储2^32和2^64种电信号。而这每一种电信号,都代表一个地址。这些地址的信息,就被下达给内存。在内存上,就可以找到该地址对应的数据,将数据再通过地址总线传入CPU内寄存器。
在计算机中我们把内存单元的编号也称为地址。C语言中给地址起了新的名字叫:指针。
二.指针
(一)取地址操作符&
在C语言中创建变量其实就是向内存申请空间。
这个地址是怎么得来的呢?
这里就涉及到一个操作符——取地址操作符&(这是一个单目操作符,后面跟一个操作变量),比如我们要取出a的地址,我们就&a,这个&a表达式将会得到一个结果,而这个结果是一个地址。实际上,这个地址就是变量a存储的四个字节中,地址较低的字节空间的地址。
而变量a的另外三个字节的地址也顺藤摸瓜地被确定:
当我们写出如下的代码时,
就会得到:0x00DCF7A0(不同时间打印的结果可能不同,因为内存中为变量a开辟的不一定是同一块空间。)
注意到这里我在使用打印函数的时候,占位符采用的是%p,而%p是打印指针(地址)的专属占位符。在今后的学习中,我们肯定经常需要使用变量的地址,因此,也需要把它储存起来,便于后期使用。而我们用于储存这些变量的地址的"容器",就被我们称为指针变量。
(二)指针变量的创建
指针变量是能存放地址的变量,凡是存进指针变量里的数据,都会被认为是地址。指针变量这个容器里,存放的是什么类型的数据的地址,那他就是什么类型的指针。为了方便,我们把某个指针变量存储着某个变量地址称为该指针指向什么变量。那么我们如何创建一个指针变量呢?
上图是创建一个整型指针变量的小栗子。其中,变量p就是指针变量,p里面存放的是整型变量a的地址。显而易见的一点是,指针变量作为一种能够存放地址的变量,有着它自己独有的数据类型(type)*,其中的type就是这个指针变量指向的那个变量的类型。任何一种变量的类型都有其对应的指针变量。例如,指向一个字符变量的指针类型为char*,指向一个长整型变量的指针类型为long*。
注意到,如果对指针变量的数据类型进行拆分,除了其指向的数据类型名,还有一个*,这个*就是判断指针变量的关键。不仅如此,这个*其实是一个非常关键的操作符——解引用操作符。
(三)解引用操作符*
这个代码其实就用到了解引用操作符,*p 的意思就是通过p中存放的地址,找到指向的空间。可以简单粗暴的认为这里*p=a。这里的*p=30,实际上就是把a的值改成了30。至于*这个单目操作符更多的运用,就暂时等到后面再讨论了。
三.指针变量的大小
(一)初探操作符sizeof
在了解指针变量大小问题前,需要先了解一个单目操作符——sizeof 。
sizeof 计算变量所占内存内存空间大小的,单位是字节,如果操作数是类型的话,计算的是使用类型创建的变量所占内存空间的大小。
表达式sizeof(a)的计算结果是一个size_t类型的值(注意不是int)。size_t定义是unsigned int 64位(无符号64位整数),如果尝试使用%d读取这个值,你可能会输入负数,导致错误编译,因此我们选择了把sizeof定义成一个类型,用%zd格式。
显然,同种类型的指针变量占用空间的大小理应是相同的。我们直接运行以下代码(x86环境下):
显然,指针变量大小与其类型是无关的。我们在x64环境下再次运行代码。
这说明了:指针变量的大小由平台决定 ,与类型无关。
32位平台(32根地址线)下地址是32个bit位(即4个字节);
64位平台(64根地址线)下地址是64个bit位(即8个字节)。
(二)指针变量类型的意义
1.解引用时有对应修改的权限
解引用时有对应修改的权限,即到底能访问几个字节。
显然,使用char*类型指针去修改int类型的数据时,解引用失效,仅修改了最前面一个字节88。
总之,什么类型的指针解引用什么类型的数据。
2.指针加减整数
运行以下代码(x86环境下):
运行结果可以看出,&n、pc、pi打印结果相同。而2-4个地址递增1,5-6地址递增4。即:char* 类型的指针变量+1跳过1个字节, int* 类型的指针变量+1跳过了4个字节。这就是指针变量的类型差异带来的变化。
int类型指针+1,往后跳过1个sizeof(int),int类型指针-1,往前跳过1个sizeof(int)。
char类型指针+1,往后跳过1个sizeof(char),char类型指针-1,往前跳过1个sizeof(char)。
sizeof(int)=4,相当于跳过了4个字节的空间;sizeof(char)=1,相当于跳过了1个字节的空间
以此类推,x类型的指针加减n相当于往后(前)跳过了n个大小为sizeof(x)的空间。
总之,指针的类型决定了指针向前或者向后走一步有多大(距离)。
3.指针自增,自减
指针除了+-整数的操作外,还可以进行自身的加减。如下面这个例子:
int main()
{
int arr[5] = { 1,2,3,4,5 };
int* p = arr;
//分别试验一下p+1,++p和p++的影响
printf("p+1的情况下: ");
printf("*(p+1)=%d ", *(p + 1));
printf("*p=%d\n", *p);
p = arr;//重置于起点位置
printf("++p的情况下: ");
printf("*++p=%d ", *++p);
printf("*p=%d\n", *p);
p = arr;//重置于起点位置
printf("p++的情况下: ");
printf("*p++=%d ", *p++);
printf("*p=%d\n", *p);
return 0;
}
打印结果:
我们知道,++p和p++的区别是,p+1的效果是先使用还是后使用。从三次的*p结果看出,p不管是前置++还是后置++,它本身的位置都发生了改变。下面这张示意图能更好地描述这个问题:
前置--和后置--的规律与++大致相同,只是指针移动的方向是不同的。
四.const修饰指针与指针运算
(一)const修饰指针变量
指针能够通过解引用操作更改变量的值。如
int main()
{
int n = 10;
int* pa = &n;
*pa = 20;
printf("%d\n",n);//此时n被改成了20
return 0
}
结合前面学过的知识我们知道,const修饰的常变量是具有常量属性的变量,对其修改就会报错。尝试对上面这个例子使用const“保护”变量n:
int main()
{
const int n = 10;
int* pa = &n;
*pa = 20;
printf("%d\n",n);//此时n仍然被改成了20
return 0
}
打印出来的结果:n的值仍被修改了。这是因为const修饰的是n,而指针变量存储了n的地址,相当于绕过了n,通过地址间接修改了n。
那该怎么让指针pa拿到n的地址却不能修改n的值呢?
int const * pa = &n;//const 放在*的左边做修饰
int * const pa = &n;//const 放在*的右边做修饰
int const * const pa = &n;//const 放在*的左右两边做修饰
对这三种情况进行测试:
#include<stdio.h>
void test01()
{
int n = 10;
int m = 30;
int* pa = &n;
//测试项目
*pa = 20;
pa = &m;
}
void test02()
{
int n = 10;
int m = 30;
const int* pa = &n;
//测试项目
*pa = 20;//报错
pa = &m;
}
void test03()
{
int n = 10;
int m = 30;
int* const pa = &n;
//测试项目
*pa = 20;
pa = &m;//报错
}
void test04()
{
int n = 10;
int m = 30;
int const* const pa = &n;
//测试项目
*pa = 20;//报错
pa = &m;//报错
}
int main()
{
test01();//没有const修饰的对照组
test02();//const在*左边
test03();//const在*右边
test04();//const在*左右两边
return 0;
}
1.const在 * 的左边
此时const修饰的是指针指向的内容,即变量n的值无法被改变,但指针pa存放的内容(n的地址)可以被改变。
2.const在 * 的右边
此时const修饰的是指针变量本身,即指针pa存放的内容(n的地址)无法被改变,n的值仍然能够通过指针修改。
3.const在*的左右两边
上述两种情况同时满足,指针变量本身(pa)和指针指向的内容(n)均无法被修改。
(二)指针运算
1.指针加减整数
指针加减整数的意义前面已经提到。由于变量在内存中通常是连续存放的(如数组),因此拿到一个变量的地址便可以通过对应类型指针顺藤摸瓜拿到其他变量的地址。
这里举一个数组的例子:
int main()
{
//指针+-整数
int arr[10] = { 0,1,2,3,4,5,6,7,8,9 };
int* pa = &arr[0];//拿到数组第一个元素的地址
int sz = sizeof(arr) / sizeof(arr[0]);
for (int i = 0; i < sz; i++)
{
printf("%d ", *(pa + i)); //pa+i即指针加减整数
}
return 0;
}
倒着再进行一遍:
int main()
{
int arr[10] = { 0,1,2,3,4,5,6,7,8,9 };
int sz = sizeof(arr) / sizeof(arr[0]);
int* pa = &arr[sz-1];
for (int i = 0; i < sz; i++)
{
printf("%d ", *pa);
pa--;//打印一位后,把指针往前挪动一位
}
return 0;
}
2.指针-指针
指针-指针的绝对值的意义是指针和指针之间元素的个数。另外,指针-指针计算的前提条件是两个指针指向同一块空间。
int main()
{
//指针-指针
int arr[10] = { 0,1,2,3,4,5,6,7,8,9 };
printf("%zd\n", &arr[9] - &arr[0]); //打印结果:9
printf("%zd\n", &arr[0] - &arr[9]); //打印结果:-9
return 0;
}
3.指针的关系运算
指针大小关系的判断其实是指针变量存放地址高低的判断。在数组中,元素地址随下标递增而递增。
int main()
{
//指针的⼤⼩⽐较
int arr[10] = { 0,1,2,3,4,5,6,7,8,9 };
int* pa = &arr[0];//拿到数组第一个元素的地址
int sz = sizeof(arr) / sizeof(arr[0]);
while(pa<arr+sz) //指针的⼤⼩⽐较
{
printf("%d ", *pa);
pa++;
}
return 0;
}
五.两种特殊的指针类型
(一)void*指针
void*指针是一种类型为 void* 特殊的指针,它没有具体类型,但可以用来接收任意类型的地址(将其进行类型转换即可)。其作用就像一个中转站,在之后很多特殊场合时,当我们不能决定要使用哪种类型的指针,一般都暂时使用void*类型的指针,再进行类型转换。如一个函数的参数不知道该传什么时候,可以将函数参数定为void*。
但也因为类型的限制,void*指针不能进行指针的+-整数和解引用操作。
#include<stdio.h>
int main()
{
int a = 10;
void* pa = &a;
void* pc = &a;
*pa = 10;
*pc = 0;
return 0;
}
VS编译结果:
这里我们可以看到, void* 类型的指针可以接收不同类型的地址,但是无法直接进⾏指针运算。
(二)野指针
从定义上看,“野指针”指的是指针变量中的值是非法的内存地址,但“野指针”不是空指针(NULL),“野指针”指向的内存是不可用的,“野指针”往往会造成内存越界、段错误等问题。
可以造成野指针的几种情况:
1.局部指针变量没有初始化(局部指针变量不初始化话的话,它的值就是随机值,指向的是一块程序员无法把控的内存)
例子:
int main()
{
int *pa;//局部变量指针未初始化,默认为随机值
*pa = 20;
return 0;
}
2.指针所指向的变量在指针使用之前就被销毁了
例子:
int* test()
{
int n = 100;
return &n;
}
int main()
{
int*pa = test();//局部变量n出了作用域被销毁,其地址无法掌握,此时pa就是一个野指针
printf("%d\n", *pa);
return 0;
}
3.使用已经释放过的指针(比如malloc申请的堆空间通过free释放后又去调用该指针,一定要在释放过后将指针变量的值赋值为NULL)
例子:涉及到未提及的malloc,calloc,realloc,free等动态内存管理的知识。
4.指针运算错误(为避免这种情况,一定要确保字符数组要以‘\0’结尾,自己编写的内存相关函数指定长度信息(防止内存越界))
例子:
int main()
{
int arr[10] = {0};
int *p = &arr[0];
int i = 0;
for(i=0; i<=11; i++)
{
//当指针指向的范围超出数组arr的范围时,p就是野指针
*(p++) = i;
//i=10时越界
}
return 0;
}
5.进行了错误的强制类型转换
例子:比如在写嵌入式程序的时候,会将int类型的一个数据强制转换成一个指针类型用来表示寄存器的地址,这个时候有可能会因为这个数字取值不当,正好对应的内存已经被使用。(例如某些数字如97强制转换成的地址是归系统所有的,程序员无权访问,此时产生野指针)
野指针是很危险的,因此应注意:
①指针要初始化:不知道指针该指向哪里,可以给指针赋值NULL
(NULL是C语言中定义的一个标识符常量,它的值是0。这个“0”也是地址。这个地址是无法使用的,读写该地址会报错)
②空指针强行访问程序会崩掉,建议不要访问
③小心指针越界(注意数组下标)
④指针变量不再使用时,及时将它的值置为NULL。使用指针前对指针进行有效性检查(if pa==NULL,如果是不再访问)
⑤一定不要返回局部变量和局部数组的地址
检验指针有效性的一个重要办法是使用宏assert(),用于在运行时确保程序符合指定条件,
如果不符合,就报错终止运行。这个宏常常被称为“断言”。
assert(pa != NULL);
上面一行代码在程序运行到这一行语句时,验证变量 p 是否等于 NULL 。如果确实不等于 NULL ,程序继续运行,否则就会终止运行,并且给出报错信息提示。这行代码还可以简写为:assert(pa)。
另外,assert宏括号里 输入的是一个表达式,如果该表达式为真,则程序正常执行;否则程序中断,在标准错误流stderr中输出错误信息。assert宏的表达式参数也可以是除指针外其他变量。
六.操作符的优先级
认识操作符优先级对后续指针学习和理解是非常有必要的。不一样的优先级塑造了不一样的指针形式,给我们的理解,运用带来难度。
在学习优先级顺序前,我们需要知道:
- 优先级可以用括号改变,括号内的优先结合。
- 在所有有效的操作符组合中,优先级高的操作符优先和变量等进行结合,或者优先进行运算。
我们需要认识到的优先级顺序(同一行优先级相同):
- 后置++--,函数调用符,数组下标访问操作符[],结构体运算符.->
- 前置++--,逻辑非!,强制类型转换,取地址&,sizeof,负号-,正号+
- 乘法×,除法/,取模%
- 加法+,减法-
- ...
- 逗号操作符,(优先级最低)
附上优先级,结合性总表:
https://zh.cppreference.com/w/c/language/operator_precedence
结尾:
第一次发布这种知识分享的长文,由于缺乏经验和仅仅作为个人笔记,行文琐碎,可能有错误。希望读者能多多交流,指正。