C语言之指针的奥秘(一)

一、内存和地址

1.内存

计算机上CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也会放回内存中,那当我们买电脑时,电脑上内存有8GB/16/32GB等,那这些内存空间如何高效管理呢?

其实把内存划分为一个个内存单元,每个内存单元的大小取1个字节。

计算机中常见的单位:

一个比特位可以存储一个2进制的位1或者0

bit - 比特位                                                    1byte=8bit

byte - 字节                                                     1KB=1024byte

KB                                                                 1MB=1024KB

MB                                                                 1GB=1024MB

GB                                                                 1TB=1024GB

TB                                                                  1PB=1024TB

PB

假设有一栋宿舍楼里,当你的朋友来找你玩,如果想找到你,就要挨个房间找,这样效率很低,但是当你的朋友知道楼层以及房间号,就很快的找到你。

其实每个内存单元相当于一个宿舍,一个字节放8个比特位,就好比住八人间。

每个内存单元都有一个编号(相当于宿舍的门牌号),有了这个内存单元的编号,CPU就可以快速找到一个内存空间。

生活中我们把门牌号叫做地址,在计算机中我们把内存单元的编号也称为地址。C语言中给地址起了个新的名字:指针

内存单元的编号==地址==指针

2.如何理解编址 

首先,计算机内有很多的硬件,而硬件单元是要互相协同工作的。所谓的协同,至少相互之间要能够进行数据传递。但是硬件与硬件之间是互相独立的,如何通信呢?答案就是:用“线”连起来(地址总线、数据总线、控制总线)。而CPU和内存之间也是有大量数据交互的,所以,两者必须也用线连起来。

CPU访问内存中的某个字节空间,必须知道这个字节空间在内存的什么位置,因为内存中字节很多,所以需要给内存进行编址(就如同宿舍很多,需要给宿舍编号一样)。计算机中的编址并不是每个字节的地址记录下来,而是通过硬件设计完成的。

硬件编址,32位机器有32跟地址总线,每根线只有两态,表示0,1【电脉冲有无】,那么一根线就能表示两种含义,2根线就能表示4种含义。32根线就能表示2^32种含义,每种含义都代表一个地址。

地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据在通过数据总线传入CPU内寄存器。

二、指针变量和地址

1.取地址操作符(&)

在C语言中创建变量就是像内存申请空间。

 上述代码就是创建整型变量a,创建变量的本质是向内存申请一块空间,为a申请4个字节的空间,用于存放整数10,其中每个字节都有地址。

取地址操作符 - &(单目操作符)

#include<stdio.h>
int main()
{
  	int a = 10;
	printf("%p\n", &a);
	return 0;
}

&a取出的是所占4个字节中地址较小的字节的地址。

虽然整形变量占用4个字节,只要知道了第一个字节地址,就能访问到4个字节的数据也是可行的。 

2.指针变量和解引用操作符(*)

Ⅰ.指针变量
#include<stdio.h>
int main()
{
	int a = 10;
	int* p = &a;
	return 0;
}

p是变量(指针变量),是一块空间,取出的a的地址存储到指针变量p中。指针变量也是一种变量,是用来存放地址的,存放在指针变量中的值理解为地址。

Ⅱ.如何拆解指针类型

p的类型是int*

int a=10;
int* p=&a;

p左边写的是int*,*是在说明p是指针变量,前面的int是在说p指向的是整型类型的对象。

Ⅲ.解引用操作符(*)

在C语言中,我们拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象,这里就用到了解引用操作符(*),或者叫间接访问操作符。

#include<stdio.h>
int main()
{
	int a = 10;
	int* p = &a;
	*p = 0;
	printf("%d", a);//0
	return 0;
}
*p的意思是通过p中存放的地址,找到指向的空间,*p其实就是a变量,所以*p=0,这个操作符把a改成了0。有同学肯定在想,这里的目的如果就是把a改成0,写a=0不就行了,为啥要使用指针呢?其实这里是把a的修改交给p来操作,这样对a的修改就多了一种途径,写代码更灵活。

3.指针变量的大小

32位机器有32跟地址总线,每根地址线出来的电信号转换成数字信号后是1或0,那我们把32跟地址线产生的2进制序列当做一个地址,那么一个地址就是32个bit位,需要4个字节才能存储。指针变量的大小就是4个字节(与类型无关)

同理64位机器,假设有64根地址线,一个地址就是64个二进制位组成的二进制序列,存储起来就需要8个字节的空间,指针变量的大小就是8个字节(与类型无关)。 

指针变量的大小取决于地址的大小

32位平台下地址是32个bit位(即4个字节)   x86

64位平台下地址是64个bit位(即8个字节)   x64

注意指针变量的大小和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的。

三、指针变量类型的意义

 指针变量的大小和类型无关,只要是指针变量,在同一个平台下,大小都是一样的,为什么还要有各种各样的指针类型呢?

1.指针的解引用

调试我们可以看到,代码1将a的4个字节全部改为0,但是代码2只将a的第一个字节改为0。 

结论:指针类型决定了指针进行解引用操作符的时候访问了几个字节,也就是决定指针的权限。 

2.指针+-整数

 我们可以看到,char*类型的指针变量+1跳过1个字节,int*类型的指针变量+1跳过了4个字节,这就是指针变量的类型差异带来的变化。

结论:指针类型决定了指针+1,-1的时候,一次走多远的距离。

3.void指针 

在指针类型中有一种特殊的类型void*类型的,可以理解为无具体类型的指针(或泛型指针),这种类型的指针可以用来接受任意类型地址。但也有局限性,void*类型的指针不能直接进行指针的+-整数和解引用的运算。举例:

#include<stdio.h>
int main()
{
	int a = 10;
	int* pa = &a;
	char* pc = &a;
	return 0;
}

 生成解决方案:

使用void*类型不会出现这样的问题。 

举例:

#include<stdio.h>
int main()
{
	int a = 10;
	void* p = &a;
	p = p + 1;
	return 0;
}

这里可以看到,void*类型的指针可以接收不同类型的地址,但是 无法直接进行指针运算。

void*类型的指针作用:

一般void*类型的指针是使用在函数参数的部分,用来接收不同类型的地址,这样的设计可以实现泛型编程的效果。

 四、const修饰指针

1.const修饰变量

变量可以修改,如果把变量的地址交给⼀个指针变量,通过指针变量的也可以修改这个变量。但是如果我们希望⼀个变量加上⼀些限制,不能被修改,怎么做呢?这就是const的作⽤。

#include<stdio.h>
int main()
{
	const int a = 10;//具有了常属性(不能被修改)
	//虽然a不能被修改,但本质上还是变量————常变量
	// 在C++中const修饰的变量就是常量
	//a = 20;
    int arr[a];
	printf("%d\n", a);
	return 0;
}

打破语法规则:

我们可以看到这⾥⼀个确实修改了,但是我们还是要思考⼀下,为什么n要被const修饰呢?就是为了 不能被修改,如果p拿到n的地址就能修改n,这样就打破了const的限制,这是不合理的。

2. const修饰指针变量

结论:const修饰指针变量的时候

const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。 但是指针变量本身的内容可变(修改指针变量的指向)。

const如果放在*的右边,修饰的是指针变量本⾝,保证了指针变量的内容不能修改,但是指针指 向的内容,可以通过指针改变。

五、指针运算

1.指针+-整数

因为数组在内存中是连续存放的,只要知道第⼀个元素的地址,顺藤摸瓜就能找到后⾯的所有元素。

 ①

② 

2.指针-指针

指针1+整数==指针2

整数==指针2-指针1

指针-指针-->得到两个指针之间的元素个数

 指针-指针计算前提条件:两个指针指向同一块空间!

#include<stdio.h>
#include<string.h>
int my_strlen(char* str)
{
	char* start = str;
	while (*str != '\0')
		str++;
	return str - start;
}
int main()
{
	//strlen--求字符串的长度,strlen统计的是字符串\0之前的字符个数
	char arr[] = "abcdef";
	int len = my_strlen(arr);
	printf("%d\n", len);
	return 0;
}

3.指针的关系运算

指针和指针比较大小

地址和地址比较大小

 六、野指针

野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)

1.野指针成因

 Ⅰ.指针未初始化
#include <stdio.h>
int main()
{
	int* p;//局部变量指针未初始化,默认为随机值
	*p = 20;
	return 0;
}

正确:

#include <stdio.h>
int main()
{
	int a = 10;
	int* p = &a;
	*p = 20;
	return 0;
}
 Ⅱ.指针越界访问

 

Ⅲ.指针指向的空间释放
#include <stdio.h>
int* test()
{
	int n = 100;
	return &n;
}
int main()
{
	int* p = test();
	printf("%d\n", *p);
	return 0;
}

test函数创建n,n出函数还给操作系统。主函数再用p就属于非法访问,即野指针。

2.如何规避野指针

Ⅰ.野指针初始化

如果明确知道指针指向哪里就直接赋值地址;如果不知道指针应该指向哪里,可以给指针赋值NULL。NULL 是C语言定义的⼀个标识符常量,值是0,0也是地址,这个地址是无法使用的,读写该地址会报错。

Ⅱ.小心指针越界

⼀个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出了就是越界访问。

Ⅲ.指针变量不再使用时,及时置NULL,指针使用之前检查有效性
当指针变量指向⼀块区域的时候,我们可以通过指针访问该区域,后期不再使用这个指针访问空间的 时候,我们可以把该指针置为NULL。因为约定俗成的⼀个规则就是:只要是NULL指针就不去访问,同时使用指针之前可以判断指针是否为NULL。
对于指针,在使用之前,我们也要判断是否为NULL,看看是不是被拴起来起来的野狗,如果是不能直接使用,如果不是我们再去使用。
Ⅳ.避免返回局部变量的地址

七、assert断言

assert.h 头文件定义了宏 assert() ,用于在运行时确保程序符合指定条件,如果不符合,就报错终止运行。这个宏常常被称为“断言”。
saaert(p!=NULL);

 上面代码在程序运行到这一行语句时,验证变量 p 是否等于 NULL 。如果确实不等于 NULL ,程序继续运行,否则就会终止运行,并且给出报错信息提示。 assert() 宏接受⼀个表达式作为参数。如果该表达式为真(返回值非零),assert() 不会产生任何作用,程序继续运行。如果该表达式为假(返回值为零),assert() 就会报错,在标准错误 流 stderr 中写入⼀条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。

assert() 的使用对程序员是非常友好的,使用assert() 有几个好处:它不仅能自动标识文件和出问题的行号,还有⼀种无需更改代码就能开启或关闭 assert() 的机制。如果已经确认程序没有问题,不需要再做断言,就在 #include <assert.h> 语句的前⾯,定义⼀个宏 NDEBUG 。

 然后,重新编译程序,编译器就会禁用文件中所有的 assert() 语句。如果程序又出现问题,可以移除这条 #define NDBUG 指令(或者把它注释掉),再次编译,这样就重新启用了 assert() 语 句。

assert() 的缺点是,因为引⼊了额外的检查,增加了程序的运⾏时间。

⼀般我们可以在 Debug 中使用,在 Release 版本中选择禁用 assert 就行,在 VS 这样的集成开发环境中,在 Release 版本中,直接就是优化掉了。这样在debug版本写有利于程序员排查问题,在 Release 版本不影响用户使用时程序的效率。

八、指针的使用和传址调用

1.strlen模拟实现

库函数strlen的功能是求字符串长度,统计的是字符串中 \0 之前的字符的个数。

参数str接收⼀个字符串的起始地址,然后开始统计字符串中 \0 之前的字符个数,最终返回长度。 如果要模拟实现只要从起始地址开始向后逐个字符的遍历,只要不是 \0 字符,计数器就+1,这样直到 \0 就停止。

#include<stdio.h>
#include<assert.h>
int my_strlen(const char* str)
{
	int count = 0;
	assert(str);
	while (*str != '\0')
	{
		count++;
		str++;
	}
	return count;
}
int main()
{
	int len = my_strlen("abcdef");
	printf("%d\n", len);
	return 0;
}

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;
	printf("交换前:a=%d b=%d\n", a, b);
	Swap1(a, b);
	printf("交换后:a=%d b=%d\n", a, b);
	return 0;
}

运行结果:

发现没有产生交换的效果,为什么呢?

当实参传递给形参是实参的一份拷贝!对实参的修改不会影响实参。

Swap1函数在使用的时候,是把变量本身直接传递给了函数,这种调用函数的方式我们之前在函数的时候就知道了,这种叫传值调用
结论:实参传递给形参的时候,形参会单独创建⼀份临时空间来接收实参,对形参的修改不影响实参。所以Swap1是失败的了。
解决:
#include<stdio.h>
void Swap2(int* pa, int* pb)
{
	int tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}
int main()
{
	int a = 10;
	int b = 20;
	printf("交换前:a=%d b=%d\n", a, b);
	Swap2(&a, &b);
	printf("交换后:a=%d b=%d\n", a, b);
	return 0;
}
运行结果:
我们可以看到实现成Swap2的方式,顺利完成了任务,这里调用Swap2函数的时候是将变量的地址传递给了函数,这种函数调用方式叫:传址调用
传址调用,可以让函数和主调函数之间建立真正的联系,在函数内部可以修改主调函数中的变量;所以未来函数中只是需要主调函数中的变量值来实现计算,就可以采用传值调用。如果函数内部要修改主调函数中的变量的值,就需要传址调用
  • 37
    点赞
  • 47
    收藏
    觉得还不错? 一键收藏
  • 55
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 55
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值