欢迎来到白刘的领域 Miracle_86.-CSDN博客
系列专栏 C语言知识
先赞后看,已成习惯
创作不易,多多支持!
一、内存和地址
1.1 内存
在介绍知识之前,先来想一个生活中的小栗子:
假如把你放在一个有100间屋子的酒店里,但是没有门牌号,你想让你的好朋友来找你玩,这种情况好朋友就得一个房间一个房间去找,非常的麻烦。但是如果有了门牌号,我们就可以告诉好朋友,他就可以迅速精准地找到你所在的房间。
把上述的栗子,映射到计算机上,就成为了我们今天所介绍的内存。
我们知道,在计算上CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也是会放回内存中。我们在买电脑的时候,内存会有8GB/16GB/32GB等等,这些内存是如何高效管理的呢?
事实上,也是通过把内存划分为一个个的小单元格,每个单元格的大小取1个字节。
bit - ⽐特位
byte - 字节
KB
MB
GB
TB
PB
1byte = 8bit
1KB = 1024byte
1MB = 1024KB
1GB = 1024MB
1TB = 1024GB
1PB = 1024TB
生活中,我们把门牌号叫作地址,计算机中也不例外,我们刚说了内存就像宿舍,就像屋子,那它的编号(门牌号)就是它的地址。而在C语言里,我们给它取了个新的名字——指针。
所以我们可以理解为:
内存单元的编号 == 地址 == 指针
1.2 如何理解编址
CPU访问内存中的某个字节空间,必须知道这个字节空间在内存的什么位置,⽽因为内存中字节很多,所以需要给内存进⾏编址(就如同宿舍很多,需要给宿舍编号⼀样) 。
计算机中的编址,并不是把每个字节的地址记录下来,⽽是通过硬件设计完成的。
钢琴、吉他上⾯没有写上“都瑞咪发嗦啦”这样的信息,但演奏者照样能够准确找到每⼀个琴弦的每⼀个位置,这是为何?因为制造商已经在乐器硬件层⾯上设计好了,并且所有的演奏者都知道。本质是⼀种约定出来的共识!
硬件编址亦是如此。
⾸先,必须理解,计算机内是有很多的硬件单元,⽽硬件单元是要互相协同⼯作的。所谓的协同,⾄少相互之间要能够进⾏数据传递。但是硬件与硬件之间是互相独⽴的,那么如何通信呢?答案很简单,⽤"线"连起来。⽽CPU和内存之间也是有⼤量的数据交互的,所以,两者必须也⽤线连起来。不过,我们今天关⼼⼀组线,叫做地址总线。
二、指针变量和地址
2.1 取地址操作符(&)
我们在操作符那里挖了个坑,详见:
武器大师——操作符详解(下)-CSDN博客
理解了内存与指针的关系,我们再回到C语言,在C语言中创建变量的过程其实本质就是向内存申请空间,eg:
#include <stdio.h>
int main()
{
int a = 10;
return 0;
}
上述代码,我们创建了整型变量a,我们知道整型是4个字节,所以向内存申请了这四个空间:
0x006FFD70
0x006FFD71
0x006FFD72
0x006FFD73
我们如何得到a的地址呢?这里用到了取地址操作符(&),长得像我们之前学过的按位与。
#include <stdio.h>
int main()
{
int a = 10;
&a;//取出a的地址
printf("%p\n", &a);
return 0;
}
虽然我们整型占用四个字节,但是实际上我们只需要知道第一个字节的地址,就可以顺藤摸瓜获取接下来的地址。
2.2 指针变量与解引用操作符(*)
2.2.1 指针变量
那我们通过取地址操作符(&)拿到的地址是⼀个数值,⽐如:0x006FFD70,这个数值有时候也是需要存储起来,⽅便后期再使⽤的,那我们把这样的地址值存放在哪⾥呢?答案是:指针变量中。eg:
#include <stdio.h>
int main()
{
int a = 10;
int* pa = &a;//取出a的地址并存储到指针变量pa中
return 0;
}
指针变量也是⼀种变量,这种变量就是⽤来存放地址的,存放在指针变量中的值都会理解为地址。
2.2.2 如何拆解指针类型
我们看到pa的类型是 int* ,我们该如何理解指针的类型呢?
int a = 10;
int * pa = &a;
2.2.3 解引用操作符( * )
我们将地址保存起来,未来是要使⽤的,那怎么使⽤呢?
在现实⽣活中,我们使⽤地址要找到⼀个房间,在房间⾥可以拿去或者存放物品。
C语⾔中其实也是⼀样的,我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象,这⾥必须学习⼀个操作符叫解引⽤操作符(*)。
#include <stdio.h>
int main()
{
int a = 100;
int* pa = &a;
*pa = 0;
return 0;
}
上述代码第7行就使用了解引用,*pa其实就是表示的指针指向的对象,也就是变量a,这步操作相当于把a的值改成了0。有人会问道:“那我直接改a=0不是更简单嘛,为什么非要写一个指针呢?”,其实这样对a多了一种途径,会让你的代码更有灵活性和可操作性。
2.3 指针变量的大小
这里先说结论:32位处理器(x86)下是4个字节,64位处理器(x64)下是8个字节。
前⾯的内容我们了解到,32位机器假设有32根地址总线,每根地址线出来的电信号转换成数字信号后是1或者0,那我们把32根地址线产⽣的2进制序列当做⼀个地址,那么⼀个地址就是32个bit位,需要4个字节才能存储。
如果指针变量是⽤来存放地址的,那么指针变的⼤⼩就得是4个字节的空间才可以。
同理64位机器,假设有64根地址线,⼀个地址就是64个⼆进制位组成的⼆进制序列,存储起来就需要8个字节的空间,指针变量的⼤⼩就是8个字节。
#include <stdio.h>
//指针变量的⼤⼩取决于地址的⼤⼩
//32位平台下地址是32个bit位(即4个字节)
//64位平台下地址是64个bit位(即8个字节)
int main()
{
printf("%zd\n", sizeof(char *));
printf("%zd\n", sizeof(short *));
printf("%zd\n", sizeof(int *));
printf("%zd\n", sizeof(double *));
return 0;
}
x86环境下结果:
x64环境下结果:
注意指针变量的大小跟类型无关!只要是指针类型的变量,在相同的平台下,大小是相同的。
三、指针变量类型的意义
既然我们前面说了,指针变量的大小在相同的平台下是相同的,那不同类型的指针变量有什么意义呢?
3.1 指针的解引用
我们来看下面两段代码:
//代码1
#include <stdio.h>
int main()
{
int n = 0x11223344;
int *pi = &n;
*pi = 0;
return 0;
}
//代码2
#include <stdio.h>
int main()
{
int n = 0x11223344;
char *pc = (char *)&n;
*pc = 0;
return 0;
}
经过调试我们可以看到,代码1将n的4个字节全改为0,而代码2只改了一个字节。
结论:指针的类型决定了,对指针解引⽤的时候有多⼤的权限(⼀次能操作⼏个字节)。⽐如: char* 的指针解引⽤就只能访问⼀个字节,⽽ int* 的指针的解引⽤就能访问四个字节。
3.2 指针+-整数
#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;
}
我们直接看上述代码以及结果:
我们可以看出,char*类型的指针变量+1的话,跳过了1个字节,而int*类型的跳过了4个字节。这就是指针类型导致的变化。
结论:指针的类型决定了指针向前或向后走的距离有多大
3.3 void*指针
这是一种特殊类型的指针,void 译为无效、空的,可以理解为无具体类型的指针或者泛型指针,我们在前面学过void类型的函数。它可以接收任意类型的地址,但是我们说人无完人,它也有局限性的,它不可以进行+-以及解引用的操作。
来举个例子:
#include <stdio.h>
int main()
{
int a = 10;
int* pa = &a;
char* pc = &a;
return 0;
}
在上⾯的代码中,将⼀个int类型的变量的地址赋值给⼀个char*类型的指针变量。编译器给出了⼀个警告(如下图),是因为类型不兼容。⽽使⽤void*类型就不会有这样的问题。
使用void*接收:
#include <stdio.h>
int main()
{
int a = 10;
void* pa = &a;
void* pc = &a;
*pa = 10;
*pc = 0;
return 0;
}
一般void*类型的指针是用在函数参数的部分的,我们以后也会讲到(又挖个坑 *^▽^* )。
四、const修饰指针
4.1 const修饰变量
#include <stdio.h>
int main()
{
int m = 0;
m = 20;//m是可以修改的
const int n = 0;
n = 20;//n是不能被修改的
return 0;
}
上述代码中n是不能被修改的,其实n本质是变量,只不过被const修饰后,在语法上加了限制,只要我们在代码中对n就⾏修改,就不符合语法规则,就报错,致使没法直接修改n。
但是我们如果绕过n,使用n的地址,去修改n,就可以做到了:
#include <stdio.h>
int main()
{
const int n = 0;
printf("n = %d\n", n);
int*p = &n;
*p = 20;
printf("n = %d\n", n);
return 0;
}
来看运行结果:
我们可以看到这⾥⼀个确实修改了,但是我们还是要思考⼀下,为什么n要被const修饰呢?就是为了不能被修改,如果p拿到n的地址就能修改n,这样就打破了const的限制,这是不合理的,所以应该让p拿到n的地址也不能修改n,那接下来怎么做呢?
4.2 const修饰指针变量
这里讲个小故事,说有一对小情侣在逛街,女生想吃凉皮,一碗凉皮十块钱,但是男生兜里只有十块钱,女生看了看男生,男生说凉皮一看就不好吃,女生说你要是不想买,我就换男朋友,男生着急了,一狠心说了句,买可以但是你不可以换男朋友,女生点了点头,最后他们幸福的生活在了一起。
这个故事其实就类似于const修饰指针变量。
int *const p;
int const* p;
我们把p看成钱,第一行代码就是,const放在右边,修饰限制指针p所指向的值,这个值不可以更改,就像刚开始,男生不想动自己仅剩的钱。
第二行,const放在左边,就可以看成,男生不想让女生换对象,const修饰限制p指向的对象不可以变。
const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。但是指针变量本⾝的内容可变。const如果放在*的右边,修饰的是指针变量本⾝,保证了指针变量的内容不能修改,但是指针指向的内容,可以通过指针改变。
五、指针运算
指针运算一共分为三种:指针+-运算、指针-指针、指针的关系运算。
5.1 指针+-整数
由于数组在内存中的存放是连续的,我们根据这一特性,只要知道第一个元素的地址,就可以顺藤摸瓜得到任意想要的元素,这就是指针的灵活性所在。
#include <stdio.h>
//指针+- 整数
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = &arr[0];
int i = 0;
int sz = sizeof(arr)/sizeof(arr[0]);
for(i=0; i<sz; i++)
{
printf("%d ", *(p+i));//p+i 这⾥就是指针+整数
}
return 0;
}
这里主要看的是p所指向的对象类型,第6行写道,p指向的是arr[0],类型是int,所以+1会跳过4个字节,找到arr[1]。
5.2 指针-指针
//指针-指针
#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;
}
这段代码是什么意思呢,这段代码是模拟的strlen函数,首先传进来字符串的首地址s,然后char了个*p指向s所指的地址,也就是p和s都指向一个地方,之后,p开始动了,当p指向的是'\0'的时候停止,用p-s就是p动了几下,也就是两个指针之间有多少个元素。
5.3 指针的关系运算
//指针的关系运算
#include <stdio.h>
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = &arr[0];
int i = 0;
int sz = sizeof(arr)/sizeof(arr[0]);
while(p<arr+sz) //指针的⼤⼩⽐较
{
printf("%d ", *p);
p++;
}
return 0;
}
这段代码就诠释了指针的大小比较。p<arr+sz(这里补充一点,数组名其实也是个指针,我们后面也会讲这一点的,这里先铺垫一下),p指向的arr的首元素,arr+sz是指向的数组末端(不是最后一个元素,而是最后一个元素的后面)。p++会慢慢让p接近这个地址,也就构成了数组遍历。
六、野指针
什么叫野指针呢,你可以理解为,很野的指针,我们说一个人比较野,因为他很放浪不羁、不受拘束。野指针也是如此,它是瞎乱指的,随机的,没有限制的。
6.1 野指针成因
1.指针初始化
#include <stdio.h>
int main()
{
int *p;//局部变量指针未初始化,默认为随机值
*p = 20;
return 0;
}
2.指针越界访问
#include <stdio.h>
int main()
{
int arr[10] = {0};
int *p = &arr[0];
int i = 0;
for(i=0; i<=11; i++)
{
//当指针指向的范围超出数组arr的范围时,p就是野指针
*(p++) = i;
}
return 0;
}
3.指针指向的空间被释放
#include <stdio.h>
int* test()
{
int n = 100;
return &n;
}
int main()
{
int*p = test();
printf("%d\n", *p);
return 0;
}
6.2 指针初始化
为了避免野指针瞎乱指,我们提供了NULL这个宏定义,它的值为0,但是我们不可以访问0的地址。初始化如下:
#include <stdio.h>
int main()
{
int num = 10;
int*p1 = #
int*p2 = NULL;
return 0;
}
6.2.1小心指针越界
⼀个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出了就是越界访问。
6.2.2指针变量不再使用时,及时置NULL,指针使用之前检查有效性
当指针变量指向⼀块区域的时候,我们可以通过指针访问该区域,后期不再使⽤这个指针访问空间的时候,我们可以把该指针置为NULL。因为约定俗成的⼀个规则就是:只要是NULL指针就不去访问,同时使⽤指针之前可以判断指针是否为NULL。
int main()
{
int arr[10] = {1,2,3,4,5,67,7,8,9,10};
int *p = &arr[0];
for(i=0; i<10; i++)
{
*(p++) = i;
}
//此时p已经越界了,可以把p置为NULL
p = NULL;
//下次使⽤的时候,判断p不为NULL的时候再使⽤
//...
p = &arr[0];//重新让p获得地址
if(p != NULL) //判断
{
//...
}
return 0;
}
6.2.3避免返回局部变量的地址
如造成野指针的第三个例子。
七、assert断言
assert(p != NULL);
上⾯代码在程序运⾏到这⼀⾏语句时,验证变量 p 是否等于 NULL 。如果确实不等于 NULL ,程序继续运⾏,否则就会终⽌运⾏,并且给出报错信息提⽰。
#define NDEBUG
#include <assert.h>
八、传值调用和传址调用
8.1 strlen的模拟实现
size_t strlen ( const char * str );
int my_strlen(const char * str)
{
int count = 0;
assert(str);
while(*str)
{
count++;
str++;
}
return count;
}
int main()
{
int len = my_strlen("abcdef");
printf("%d\n", len);
return 0;
}
8.2 传值调用和传址调用
我们可能会写出这段代码:
#include <stdio.h>
void Swap1(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a=%d b=%d\n", a, b);
Swap1(a, b);
printf("交换后:a=%d b=%d\n", a, b);
return 0;
}
但是来看运行结果:
这是为什么呢?我们来调试一下。
结论:实参传递给形参的时候,形参会单独创建⼀份临时空间来接收实参,对形参的修改不影响实 参。
那我们怎么解决这个问题呢?
#include <stdio.h>
void Swap2(int*px, int*py)
{
int tmp = 0;
tmp = *px;
*px = *py;
*py = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a=%d b=%d\n", a, b);
Swap1(&a, &b);
printf("交换后:a=%d b=%d\n", a, b);
return 0;
}
来看运行结果: