一、内存和地址
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;
}
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;
}
打破语法规则:
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,指针使用之前检查有效性
Ⅳ.避免返回局部变量的地址
七、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函数在使用的时候,是把变量本身直接传递给了函数,这种调用函数的方式我们之前在函数的时候就知道了,这种叫传值调用。
#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函数的时候是将变量的地址传递给了函数,这种函数调用方式叫:传址调用。
传址调用,可以让函数和主调函数之间建立真正的联系,在函数内部可以修改主调函数中的变量;所以未来函数中只是需要主调函数中的变量值来实现计算,就可以采用传值调用。如果函数内部要修改主调函数中的变量的值,就需要传址调用。