内存与地址
计算机上CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也会放回内存中,那这些内存空间如何⾼效的管理呢?
其实计算机会把内存划分为⼀个个的内存单元,以字节为一个基础的内存单元来进行管理。
每个内存单元也都有⼀个编号,有了这个内存单元的编号,CPU就可以快速找到⼀个内存空间。⽣活中我们把⻔牌号也叫地址,在计算机中我们把内存单元的编号也称为地址。C语⾔中给地址起了新的名字叫:指针
计算机内是有很多的硬件单元,而硬件单元是要互相协同⼯作的。所谓的协同,⾄少相互之间要能够进⾏数据传递。但是硬件与硬件之间是互相独⽴的,那么如何通信呢?
CPU与内存是计算机中分别独立的硬件,如果想要让不同的硬件实现数据交互,那么就需要用电线连起来。而当CPU要访问内存的数据,就需要通过地址来确定访问的内存单元,这个传输地址的数据线就叫做地址总线。
每根电线有两种形态:有电/没电,此时32根地址线就可以表示 2 ^ 32种地址,64根地址线就可以表示2 ^ 64种地址。
指针变量
取地址
理解了内存和地址的关系,我们再回到C语⾔,在C语⾔中创建变量其实就是向内存申请空间,⽐如:
int a = 10;
上述的代码就是创建了整型变量a,内存中申请4个字节,⽤于存放整数10,其中每个字节都有地址。
那我们如何能得到a的地址呢?
这⾥就得学习⼀个操作符:&
取地址操作符
int a = 10; &a;//取出a的地址
&a取出的是a所占4个字节中编址最小的字节的地址。
指针变量
那我们通过取地址操作符拿到的地址是⼀个数值,⽐如:0x006FFD70
,这个数值有时候也是需要存储起来,⽅便后期再使⽤的,那我们把这样的地址值存放在哪⾥呢?
C语言提供了指针变量,用于存储地址。
int a = 10; int * pa = &a;
这里的pa
就是一个指针变量,其内部存储了a
的地址。
这⾥pa
左边写的是 int*
, *
是在说明pa
是指针变量,⽽前⾯的 int
是在说明pa
指向的是整型int
类型的对象。
当一个指针指向类型
xxx
,这个指针的类型就是xxx*
比如一个指向char类型变量的指针:
char ch = 'w'; char* pc = &ch;
由于指针pc
指向的类型是char
,所以pc
的类型就是char*
。
解引用
我们将地址保存起来,未来是要使⽤的,那怎么使⽤呢?
在现实⽣活中,我们使⽤地址要找到⼀个房间,在房间⾥可以拿去或者存放物品。
C语⾔中其实也是⼀样的,我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象。
获取指针指向对象的值的操作符叫解引⽤操作符*
。
int a = 100; int* pa = &a; *pa = 0;
上⾯代码中第3⾏就使⽤了解引⽤操作符, *pa
的意思就是通过pa
中存放的地址,找到指向的空间,*pa
其实就是a
变量了;所以*pa = 0
这个操作相当于a = 0
。
指针的大小
地址是通过地址线传输的,对于32位机器,其有32根地址总线,那么就需要32个比特位来标识每根地址线的状态,需要4字节来存储指针;对于64位的机器,那么就需要64个比特位来标识每根地址线的状态,需要8字节来存储指针
32位计算机指针大小为4字节
64位计算机指针大小为8字节
注意指针内部只存储地址,与指针的类型无关,只要是指针,大小就是4/8字节。
指针运算
指针也是可以进行运算的,指针有以下运算:
指针 + - 整数
指针对整数的 + - 用于到达下一个指针的位置。
示例:
int n = 10; int* pi = &n; printf("%p\n", pi); printf("%p\n", pi + 1);
输出结果:
001EF868 001EF86C
通过16进制运算,可以发现两个地址之间相差4,而int
类型刚好占用4字节。
再试试char*
指针:
char c = 'w'; char* pc = &c; printf("%p\n", pc); printf("%p\n", pc + 1);
输出结果:
00FFFE77 00FFFE78
通过16进制运算,可以发现两个地址之间相差1,而char
类型刚好占用1字节。
指针指向的类型占用多少个字节,那么指针+ -整数时就以多少个字节为单位
指针 - 指针
指针 - 指针得到两个指针之间的距离。
示例:
int arr[10] = { 0 }; int* p1 = &arr[0]; int* p2 = &arr[5]; int x = p2 - p1; printf("%d", x);
以上示例中,p1
指向了数组的第1个元素,p2
指向了数组的6个元素,此时两个指针相减得到多少?是指针之间的元素个数5
,还是指针之间的字节数4 * 5 = 20
?
输出结果:
5
可以看到,指针之间的减法也与类型是有关的,指针相减得到的是两个指针有几个指向的元素,而不是单纯的地址相减。
再看一个案例:
int arr[10] = { 0 }; int* p1 = &arr[0]; int* p2 = &arr[5]; int x = (char*)p2 - (char*)p1; printf("%d", x);
与刚才代码的唯一区别就是,我们在(char*)p2 - (char*)p1
做指针减法的时候,将两个指针转化为了char*
类型,此时输出结果是多少?
输出结果:
20
因为char*
的指针指向的类型占用1字节,所以5个int
类型的空间可以存储20个char
,而我们将指针从int*
转化为了char*
,计算规则就从原来计算可以存放几个int
,变成了可以存放几个char
了。
指针关系运算
指针关系运算
< 和 > 比两个指针的地址大小
== 和 != 判断两个指针是否相等
const修饰指针
当一个变量被const
修饰,那么这个变量就不能被修改。指针也可以被const
修饰,但是它的修饰规则不太一样。
怎么样才算修改指针呢?
当我们获得一个指针变量,我们可以修改指针指向内容的值,比如这样:
int a = 10; int* pa = &a; *pa = 5;
我们通过指针把变量a
的值修改了。
我们也可以修改指针的指向:
int a = 10; int* pa = &a; pa = &b;
此时pa
这个指针从指向变量a
,变成了指向变量b
。
那么const
修饰时,到底时禁止哪一项不能修改?
这就需要讲解指针的特殊的const
修饰规则了:
当
const
放在 * 左边,指针指向的内容不能被指针修改
示例1:
const int* pa = &a; pa = &b;//允许修改 *pa = 5;//不允许修改
示例2:
int const* pa = &a; pa = &b;//允许修改 *pa = 5;//不允许修改
对指针来说,const
可以放在int
左边,也可以放在int
右边,效果是一样的。
当const放在 * 右边,指针的指向不能改
示例:
int a = 10; int b = 5; int* const pa = &a; pa = &b;//不允许修改 *pa = 5;//允许修改
字符指针
看到一串代码:
const char* p = "hello";
char*
类型的指针用于存放char
类型的数据,但是以上代码却可以把一个字符串存进char*
类型的指针中,这是为什么?
所有常量字符串做表达式时,本质都是首个字符的地址
因此我们也可以把字符串当作指针来输出:
printf("%p", "hello");
输出结果:
00C57BD8
可以看出, "hello"
这个字符串整体,代表了一个指针。
所以const char* p = "hello";
的本质其实是把字符串“hello”
的第一个元素‘h’
的地址交给了p
指针。
为什么要加const
修饰指针?
用双引号引起来的字符串叫做常量字符串,存储在静态区不可以修改,所以在用指针接收时,需要const
修饰常量字符串做表达式时,本质都是首个字符的地址。
野指针
野指针的概念:
内存中的一块空间在使用前,是需要申请的。而指针作为直接访问内存的一种手段,当指针指向到、没有申请的空间,那么这就是一个野指针。
野指针的成因:
指针没有初始化
int *p;//局部变量指针未初始化,默认为随机值 *p = 20; return 0;
此处的p指针,没有设置初始值,就直接解引用了。此时指针的值就是一个随机值,而随机的地址,很有可能就访问到了没有向内存申请的空间,故p
是一个野指针。
指针越界访问
int arr[10] = {0}; int *p = &arr[0]; for(int i = 0; i <= 11; i++) { *(p++) = i; }
以上代码中,我们用指针对数组进行了遍历,但是数组的下标是从0-9的,而我们访问到了10与11的位置,此时超过10的空间没被数组申请,访问到了没有申请的空间,p就是野指针了。
指针指向的空间被释放
int* test() { int n = 100; return &n; } int main() { int*p = test(); printf("%d\n", *p); return 0; }
此时的指针看似指向了&n
,即n
的地址。但是函数test
调用时,会创建独立的栈帧,当函数调用结束,函数内部的变量n
也会一起销毁,所以此时的n
已经被释放了。p
指向了被释放的空间,变成一个野指针。
如果我们的指针一开始没有想好赋什么值,那我们就得到了一个野指针,我们有没有办法让一个没有想好值的指针不是野指针呢?此时就需要空指针了。
NULL
是C语言中的空指针,它本质上是数值为0的地址。
当我们没想好一个指针给什么值的时候,就可以给一个空指针:
int* ptr = NULL:
assert断言
assert.h
头⽂件定义了宏 assert()
,⽤于在运⾏时确保程序符合指定条件,如果不符合,就报错终⽌运⾏。这个宏常常被称为“断⾔”。
assert(p != NULL);
上⾯代码在程序运⾏到这⼀⾏语句时,验证变量 p
是否等于 NULL
。如果确实不等于 NULL
,程序继续运⾏,否则就会终⽌运⾏,并且给出报错信息提⽰。
assert()
宏接受⼀个表达式作为参数。如果该表达式为真(返回值⾮零), assert()
不会产⽣任何作⽤,程序继续运⾏。如果该表达式为假(返回值为零), assert()
就会报错,在标准错误流 stderr
中写⼊⼀条错误信息,显⽰没有通过的表达式,以及包含这个表达式的⽂件名和⾏号。
assert()
的使⽤对程序员是⾮常友好的,使⽤ assert()
有⼏个好处:
它不仅能⾃动标识⽂件和出问题的⾏号,还有⼀种⽆需更改代码就能开启或关闭 assert()
的机制。如果已经确认程序没有问题,不需要再做断⾔,就在 #include <assert.h>
语句的前⾯,定义⼀个宏 NDEBUG
。
#define NDEBUG #include <assert.h>
然后,重新编译程序,编译器就会禁⽤⽂件中所有的 assert()
语句。
如果程序⼜出现问题,可以移除这条 #define NDBUG
指令(或者把它注释掉),再次编译,这样就重新启⽤了 assert() 语句。
assert()
的缺点是,因为引⼊了额外的检查,增加了程序的运⾏时间。⼀般我们可以在debug
中使⽤,在release
版本中选择禁⽤assert
就⾏,这样在debug
版本写有利于程序员排查问题,在release
版本不影响⽤⼾使⽤时程序的效率。
传址调用
写⼀个函数,交换两个整型变量的值:
思考后,你可能会给出这样的答案。
void Swap(int x, int y) { int tmp = x; x = y; y = tmp; }
在main函数中调用试试:
int main() { int a = 0; int b = 0; scanf("%d %d", &a, &b); printf("交换前:a=%d b=%d\n", a, b); Swap(a, b); printf("交换后:a=%d b=%d\n", a, b); return 0; }
输出结果:
10 20 交换前:a=10 b=20 交换后:a=10 b=20
我们发现其实没产⽣交换的效果,这是为什么呢?
在main
函数内部,创建了a
和b
,在调⽤Swap
函数时,将a
和b
传递给了Swap
函数,在Swap
函数内部创建了形参x
和y
接收a
和b
的值。x
和y
确实接收到了a
和b
的值,不过x
的地址和a
的地址不⼀样,y
的地址和b
的地址不⼀样,相当于x
和y
是独⽴的空间,它们只得到了实参的值。
那么在Swap
函数内部交换x
和y
的值,⾃然不会影响a
和b
。当Swap
函数调⽤结束后回到main
函数,a
和b
其实没交换。
Swap
函数在调用的时候,是把变量本⾝的值传递给了函数,这种叫传值调⽤。
结论:实参传递给形参的时候,形参会单独创建⼀份临时空间来接收实参,对形参的修改不影响实参。
我们现在要解决的就是当调⽤Swap
函数的时候,Swap
函数内部操作的就是main
函数中的a
和b
,直接将a
和b
的值交换了。那么就可以使⽤指针了,在main
函数中将a
和b
的地址传递给Swap
函数,Swap
函数⾥边通过地址间接的操作main
函数中的a
和b
就好了。
修改后代码:
void Swap2(int*px, int*py) { int tmp = *px; *px = *py; *py = tmp; }
输出结果:
10 20 交换前:a=10 b=20 交换后:a=20 b=10
我们可以看到实现成传递地址的的⽅式,顺利完成了任务,这⾥调⽤Swap
函数的时候是将变量的地址传递给了函数,这种函数调⽤⽅式叫:传址调⽤。