1 内存和地址
2 指针变量和解引用操作符
3 指针变量类型的意义
4 const修饰指针
5 指针运算
6 野指针
7 assert断言
8 指针的使用和传址调用
1
1)
介绍内存之前,不妨看一下生活的例子,一栋小区,每个房间都有自己的编号,倘若不知道每个房间的号码,就只能挨个挨个访问,效率极其低下,但是当我们知道了每个房间的编号,就可以快速访问(当然是你需要钥匙,,)
在计算机中,计算机的cpu处理数据就是从内存里面读取的,那么相应的,生活中每个门牌号可以叫做地址,计算机中,内存单元是字节,字节在内存中的位置,即内存单元编号,就像是生活中的门牌号,我们知道了就可以进行访问。
生活中,门牌号是地址,计算机中,字节位置是地址,当然,在C语言里面,地址有一个全新的名字,叫指针。
可以这样理解:内存单元编号 = 地址 = 指针。就像这样
2)
cpu和内存之前传输数据不是说一下就传过去了的,它们之间的联系是许多的线,比如地址总线,数据总线,控制总线。
我们今天只需要关心一种线,地址总线。简单理解就是32位机器有32根线,线的状态只有0或者1,那么总共表示的含义就是2^32种,每种对应一个地址,64位机器同理,那么问题来了,地址也是会有大小的,不同的环境下指针的大小也是不一样的。稍后,您就知道了。
2
1) 取地址操作符 和 %p
我们现在知道了内存与地址的关系,那么我们应该如何取到我们想要的地址呢?这里就需要用到取地址操作符了,&
你一看,欸这不是按位与吗?是的,它也代表取地址,不然你看,我们使用scanf的时候,为什么加这个,就是为了取地址出来,然后把“外卖”送进去咯。
现在来看看一个整型在内存中的地址吧。
在调试的内存中,我输入&a,然后回车这么一按。
你看吧,在64位机器下,这就是a的地址,因为是0a 00 00 00 就是a的4个字节,因为VS是小端存储,所以0a在前面,a是16进制的10,明了的吧?我们这下就给a的内存看的明明白白的了。
所以a的占的四个字节的地址是 ……74 ……75 ……76 …… 77,那么我们打印它的地址看看呢?
可以看到,打印地址用到的占位符是%p,但是有细心的读者会发现地址怎么和刚才的不一样,因为程序运行的时候,地址是随机分配的。
但是!不是有4个字节吗,怎么打印了一个地址,这是因为打印地址的时候只打印低地址。
不信?你试试呢~
所以当我们知道了第一个字节的地址,要访问后面的地址,那不就顺腾摸瓜吗?
2)指针变量和解引用操作符
那我们现在知道了某个元素的地址,并且取出来了这个地址,那这个地址我们应该存在哪里呢?
答案是存在指针变量里面。
int a = 10;
int* p = &a;
这里的p就是一个指针变量,因为a是整型类型的,所以p是int* ,同理,如果a是char类型的,p的类型就是char*,那按道理来说,p存了a的地址,打印出来的结果应该是一样的,来试试。
哦吼,一样的。
那现在就好玩了,我们知道了a的地址,也就是我们有了任意访问a的权限,也就是说我们可以通过地址改变a的值,来试试。
你看,*p = 20,就是修改了a的值,*是解引用操作符,这个* 和int* 里面的*可不是一样的嗷,这个*就是一个操作符,int*这是一个整体,代表一个类型。
你甚至可以这样理解,*p就是a,解引用就像是你得到了a的地址,然后你去了a的房间对他任意操作一样。
现在修改a的值又多了一种途径,写代码方式就会有更多可能性了。
我们刚才提及,p是指针变量,那么p的大小是多大呢?现在介绍的就是最开始的内容了。32位平台下地址总线有32根,一根对应一个bit,那么32个bit就是4个字节,同理,64位平台就是64根线,64个bit位,8个字节,事实真的如此吗?看看咯
你看,x64和x86的环境下,指针变量的大小是不是如刚才所说,一个是8一个是4。
注意指针变量的大小和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的。
3
1)不同指针的解引用
既然同一平台下,指针变量的大小是一样的,那么为什么还有不同的指针类型呢?
先看两段代码,在内存中调试看一下呢?
int main()
{
int a = 257;
int* pa = &a;
*pa = 0;
return 0;
}
int main()
{
int a = 257;
char* pa = &a;
*pa = 0;
return 0;
}
先调试第一段代码。
改动前是01 01 00 00 ,改动后是00 00 00 00
再看看第二段代码。
欸?第二段为什么只改动了一个字节?
想必客官已经猜出来了,这与指针类型有关,int*的指针能一个改动四个字节,但是char*的指针一次就指针改动一个字节,所以不难推测short类型的指针一次可以修改2个字节,long*一次可以修改4个或8个字节。
那么就更好玩了,我不仅可以访问,我还可以有选择的访问。
#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;
}
再来看看这段代码,同上面的是一个道理,指针+-整数,打印出来的也是地址,但是不同类型的指针变量跳过的字节数是不一样的。
2)void*指针
当我们用char*来接收int的地址的时候,是会有警告的,
但是当我们用void*来接受就不会,你可以形象的理解void*为垃圾桶,啥啥都能往里面装。
所以void*被称为泛型指针,即是无具体类型的指针。但是void*也是有代价的,它不能进行指针+-整数的运算,也不能进行解引用操作。
看吧,直接就报错了。
那void*是不是没有用呢?还是那句话,存在即合理,后面会对它进行着重介绍。
4
1)const修饰变量
const是C语言里面的关键字,介绍指针必定少不了它,它的作用可以粗略的理解为修饰谁谁就变成了常变量,本质是变量,但是不能对其进行修改,一改,就报错。
看吧,说明a已经不能被修改了。
那么,现在把它运用到指针里面,先是修饰变量。
int main()
{
const int a = 10;
int* pa = &a;
*pa = 20;
printf("%d\n", a);
return 0;
}
语法上,const修饰的是不能被修改的,但是我们可以通过地址,间接的对它进行修改。
当然,有点打破语法规则的感觉。
2)const修饰指针变量
i) const在前面
什么?const修饰还分为前面后面的?是的,当const在最前面,像这样
int main()
{
int a = 10;
const int* pa = &a;
*pa = 20;
printf("%d\n", a);
return 0;
}
也就是const修饰*pa,也就是const 修饰了a,这下a就哦豁了,间接修改的方法也不行了。
这样也是一样的,只要const在*的前面,a的值就不能通过地址的方式进行修改了。
所以const在前面的时候,修饰的是*pa,会导致*pa指向的元素不能被修改。
ii)const在后面
第二种情况就是const放在了*的后面,同理,在*前面就是控制了*pa,那么在后面控制的就是pa,乍一看好像没区别,那就错辣!这次控制的是pa,pa是干嘛的,pa是用来指向地址的,那么const修饰了它,就会导致pa只能指向a的地址了,不能指向其他的地址了。
看看。没有修饰*pa可以修改。
int main()
{
int a = 10;
int* const pa = &a;
*pa = 20;
printf("%d\n", a);
return 0;
}
而当我重新创建了一个变量b,想要pa的地址变成b的地址,系统就报错了,哦豁,pa不能被修改了,这下pa就只能死心塌地的跟着a了。
int main()
{
int a = 10;
int* const pa = &a;
*pa = 20;
int b = 10;
pa = &b;
printf("%d\n", a);
return 0;
}
总结一下,const在*前面,修饰的是*pa,会导致a的值无法被改变,也即是指针变量指向的元素的值为定值,const在*后面,修饰的是pa,会导致pa存的地址无法被改变,也就是指针变量无法被修改。
5
指针运算分为3种,1是指针+-整数,2是指针-指针,3是指针的关系运算,且听我一一道来。
i)指针+-整数
文章最开头已经提及到指针+-整数,我们可以通过指针+-整数访问不同的空间,那么举例数组就是最好的例子。
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int* pa = arr;
for (int i = 0; i < 10; i++)
{
printf("%d ", *(pa + i));
}
return 0;
}
数组名就是首元素的地址,我们用int* pa来接收这个地址,因为指针类型是整型,所以加i就会跳过
4 * i个字节,也就可以完美访问整个数组了。
当然,写成*pa + i是错误的,*的优先级比+高,系统会先对pa解引用在加一个i,所以加个()是很好的选择。
ii)指针 - 指针
有人就问了,欸为什么没有指针+指针呢?不急,看看这串代码。
#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;
}
我们利用指针 - 指针求一下字符串的长度,我们传过去一个常量字符串“abc”,传过去的实际上是abc的首元素地址,所以我们使用char*的指针来接收。
定义p是首元素的指针,通过遍历找\0的方式,让*p指向\0,最后指针相减,得到的恰好是字符串的长度。
看吧,感觉就跟4 - 1一样是吧?但是需要注意的是,这里的指针指向的最好是同一块空间,不然能相减但是毫无意义。回到最开始问题,为什么指针不相加呢?能啊,但是好像,毫无意义?
指针 - 指针可以理解为它们之间的元素个数,当然,指针一定是同一块空间,也要是相同类型的指针。
iii)指针的关系运算
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;
}
关系运算无非就是比较谁的地址大谁的地址小,数组名就是首元素地址,首元素地址加上数组元素个数,指向了\0,令p是首元素地址,然后就是比较咯,这个我相信是很容易理解的,就不多介绍了。
6
野指针这玩意儿才厉害,不注意的话给程序直接整崩。
i)野指针的成因
1 指针变量未初始化
指针变量没有初始化的话,那你说它存的是谁的地址呢?反正没有给它地址,就随便存咯,跟深海鱼一样,反正没有人看到,随便长咯。
看吧,如果不初始化,指针变量的值是个随机值,且指向的位置不确定,是比较危险的。
2 超出范围越界访问
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int* p = arr;
for (int i = 0; i < 11; i++)
{
printf("%d ", *p);
p++;
}
return 0;
}
指针变量在原本的空间待得好好的,结果给人玩脱了,到其他空间去了,也就是越界访问了,这个时候p也就是野指针了。
3 指针指向的空间释放
int* test()
{
int n = 100;
return &n;
}
int main()
{
int*p = test();
printf("%d\n", *p);
return 0;
}
*p用来接收返回的n的地址,可是函数test一旦结束,内存为函数test创建的函数栈帧也被销毁了,n的地址指向的空间被销毁,值呢?肯定就没有咯。
肯定有人问了,为什么在打印*p的前面加一个打印666呢?因为当函数test的栈帧被释放之后,可能还没来得及利用,你马上调用,说不定是行得通的,但是不要以为写对了,是运气比较好而已。
这个时候p就是一个野指针了,指向的哪里自己都不知道了。
以上就是野指针的成因。
ii)如何有效的规避野指针
1)指针初始化
对症下药呗,第一种情况是指针没有初始化,那么我们就对它初始化,那初始化什么?随机赋一个值咯。当然要是地址。
还可以直接给NULL,就是把指针置为空指针的意思,C语言中NULL是0,无法使用的。
nt main()
{
int num = 10;
int*p1 = #
int*p2 = NULL;
return 0;
}
这里的p2是不能使用的,使用就报错了,但是你就说它有没有变成野指针吧。
2)小心指针越界
还是对症下药,指针越界访问数组了,那么我们就控制好不让它越界就行了。
3)指针闲置的时候置成空指针
int main()
{
int num = 10;
int* p = #
*p = 20;
printf("%d ", *p);
p = NULL;
return 0;
}
但后面需要重新用到该指针的时候,再重新赋值给它一个地址就行了。
7
assert就像是检察官一样,能判断指针是野指针还是正常的指针,但是assert使用需要引用头文件assert.h,该头文件有对assert的定义。
使用方法很简单,如下
int main()
{
int num = 0;
int* p = #
assert(p != NULL);
printf("%d ", *p);
return 0;
}
这是正常的情况。
那么我们把该指针故意置成空指针呢?
int main()
{
int num = 10;
int* p = NULL;
assert(p != NULL);
printf("%d ", *p);
return 0;
}
它就会在assert那行进行报错,告诉你是哪个指针置成了空指针。
当你进行多次测试后,发现不需要检测了,但是assert挺多,又不想挨个挨个删,这个时候只需要
使用 #define NDEBUG
#define NDENUG
int main()
{
int num = 10;
int* p = #
assert(p != NULL);
printf("%d ", *p);
return 0;
}
它的作用就是让asset作用消失。
当然,assert最好是在debug版本使用,毕竟这个是用来调试的。release版本不建议使用。assert常被称为“断言”,断言嘛,断言哪个是对的咯。
8
指针是访问地址的,但是不仅仅可以访问地址,还可以通过地址做出你意想不到的事。
比如利用指针模拟实现strlen函数。
int my_strlen(char* p)
{
int count = 0;
while (*p != '\0')
{
p++;
count++;
}
return count;
}
int main()
{
int ret = my_strlen("abcdefg");
printf("%d ", ret);
return 0;
}
指针指向第一个元素,然后让它每次自增,count就自增一次,指针指向\0结束,count也不再自增,最后返回count的值。怎么样,对初学者算是意想不到的效果吧?
那么,我们知道函数传参的时候有传址或者传值。
传值
int Add(int x, int y)
{
x = 20, y = 30;
return x + y;
}
int main()
{
int a = 0, b =0;
int c = Add(a, b);
printf("%d %d %d", a, b, c);
return 0;
}
这里的a 和 b的值不会被修改的。
因为这里是传值调用,只是传了两个值过去而已,地址没过去,所以不会被修改。
那么,传址就会让a , b的值受到改变。
int Add(int* x, int* y)
{
*x = 20, *y = 30;
return *x + *y;
}
int main()
{
int a = 0, b =0;
int c = Add(&a, &b);
printf("%d %d %d", a, b, c);
return 0;
}
这里传参传的就是a,b 的地址,用int* 的指针来接收,通过解引用操作改变a,b的值,顺便进行个加法。
而数组传参因为传的是首元素地址,所以也是传址调用,读者可自行进行一下实验。
感谢阅读!