内存与地址
计算机上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函数的时候是将变量的地址传递给了函数,这种函数调⽤⽅式叫:传址调⽤。