目录
[1.2 深入理解计算机编址](#1.2 深入理解计算机编址)
[2.1 取地址操作符(&)](#2.1 取地址操作符(&))
[2.2 指针变量和解应用操作符](#2.2 指针变量和解应用操作符)
[2.2.1 指针变量](#2.2.1 指针变量)
[2.2.2 解引用操作符](#2.2.2 解引用操作符)
[3.1 指针的解引用](#3.1 指针的解引用)
[4.1 const修饰指针](#4.1 const修饰指针)
[4.2 const修饰指针变量](#4.2 const修饰指针变量)
[5.1 指针+-整数 运算](#5.1 指针+-整数 运算)
[5.2 指针-指针](#5.2 指针-指针)
[5.3 指针的关系运算](#5.3 指针的关系运算)
[6.1.1 指针未初始化](#6.1.1 指针未初始化)
[6.1.2 指针越界访问编辑](#6.1.2 指针越界访问编辑)
[6.1.3 指针指向的空间释放](#6.1.3 指针指向的空间释放)
[6.2 如何规避野指针](#6.2 如何规避野指针)
[6.2.1 指针初始化](#6.2.1 指针初始化)
[6.2.2 避免越界访问](#6.2.2 避免越界访问)
[6.2.3 当指针不再使用时,即使置NULL,指针使用前检查有效性](#6.2.3 当指针不再使用时,即使置NULL,指针使用前检查有效性)
[6.2.4 避免返回局部变量的地址](#6.2.4 避免返回局部变量的地址)
一、内存和地址
1.1内存
只要讲指针就离不开内存
因为指针就是访问内存的
计算上CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数
据也会放回内存中,那我们买电脑的时候,电脑上内存是8GB/16GB/32GB等,那这些内存空间如何高效的管理呢?
其实也是把内存划分为⼀个个的内存单元,每个内存单元的大小取1个字节
计算机常见单位
bit - ⽐特位
byte - 字节
KB
MB
GB
TB
PB
1byte = 8bit
1KB = 1024byte
1MB = 1024KB
1GB = 1024MB
1TB = 1024GB
1PB = 1024TB
内存单元编号==地址==指针
1.2 深入理解计算机编址
CPU访问内存中的某个字节空间,必须知道这个字节空间在内存的什么位置,⽽因为内存中字节很多,所以需要给内存进⾏编址(就如同宿舍很多,需要给宿舍编号⼀样)。计算机中的编址,并不是把每个字节的地址记录下来,⽽是通过硬件设计完成的。
⾸先,必须理解,计算机内是有很多的硬件单元,⽽硬件单元是要互相协同⼯作的。所谓的协同,⾄少相互之间要能够进⾏数据传递。但是硬件与硬件之间是互相独⽴的,那么如何通信呢?答案很简单,⽤"线"连起来。⽽CPU和内存之间也是有⼤量的数据交互的,所以,两者必须也⽤线连起来。不过,我们今天关⼼⼀组线,叫做地址总线。
32位机器有32根地址总线, 每根线只有两态,表⽰0,1【电脉冲有⽆】,那么 ⼀根线,就能表⽰2种含义,2根线就能表⽰4种含义,依次类推。32根地址线,就能表⽰2^32种含义,每⼀种含义都代表⼀个地址。地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据在通过数据总线传⼊CPU内寄存器。
int main()
{
int a = 10;//创建变量的本质是向内存申请一块空间,为a申请4个字节的空间
return 0;
}
对于地址总线、数据总线、控制总线可以这样理解的:
控制总线:相当于一个控制台,传递指令。
数据总线:相当于内存数据传输的通道。
地址总线:相当于一个内存仓库。
二、指针变量和地址
2.1 取地址操作符(&)
int main()
{
int a = 10;
//&a --- &取地址操作符
//& 单目操作符
printf("%p\n",&a);
return 0;
}
对int a=10来说,创建了四个字节的空间,通过调试发现这四个字节都是有地址的,**当我们通过&取地址符来得到a的地址(%p是专门用来取地址的占位符)时,实际上取出的时a所占4个字节中地址较小的字节的地址。**虽然整形变量占了4个字节,但是只要知道了第1个字节的地址,顺藤摸瓜就可以访问到4个字节的数据。
2.2 指针变量和解应用操作符
2.2.1 指针变量
通过2.1我们通过&取地址符拿到了地址,这个地址是一个数值,而将这个数值存储起来方便后期使用,就需要我们把地址值存在指针变量里。
int main()
{
int a = 10;
//&a --- &取地址操作符
//& 单目操作符
printf("%p\n",&a);
return 0;
}
2.2.2 解引用操作符
通过2.2.1,我们学会了怎么将地址保存起来,那未来我们也要有方法去取用他,就跟我们生活中我们找到一个房间,我们希望可以在这个房间里存放或者取走物品,同理,我们通过了指针变量存储的地址,通过地址找到了该地址指向的空间,这里就需要用到解引用操作符*,来取用空间里数据。
//指针--地址
//指针变量--存放地址的变量
int main()
{
int a = 10;
//&a --- &取地址操作符
//& 单目操作符
//printf("%p\n",&a);
int* p = &a;//p是一个变量(指针变量),是一块空间
//编号-地址-指针
//int说明p指向对象是int类型的
//*在说明p是指针变量
return 0;
}
int main()
{
char ch = 'w';
char* pc = &ch;
return 0;
}
int main()
{
int a =10;
int * p= &a;
*p =0;//* -解引用操作符(间接访问操作符)
//a =0;】
//*&a = 0;//a = 0
printf("%d",a);//0?
return 0;
}
2.3指针变量的大小
32位机器假设有32根地址总线,每根地址线出来的电信号转换成数字信号后是1或者0,那我们把32根地址线产⽣的2进制序列当做⼀个地址,那么⼀个地址就是32个bit位,需要4 个字节才能存储。
如果指针变量是⽤来存放地址的,那么指针变的⼤⼩就得是4个字节的空间才可以。同理64位器,假设有64根地址线,⼀个地址就是64个⼆进制位组成的⼆进制序列,存储起来就需要8个字节的空间,指针变的⼤⼩就是8个字节。
指针的变量大小与类型是无关的,只要是指针类型的变量,在相同的平台下,大小都是一样的(32位平台指针大小是4个字节,64位平台下指针大小是8个字节)
指针变量 - 存放地址的
地址产生:地址线上传输的
32根地址线 ------>地址是:32个0/1组成的二进制序列
要储存这样的地址:32bit位的空间 ==4个字节
int main()
{
printf("%zd\n", sizeof(char *));
printf("%zd\n", sizeof(short *));
printf("%zd\n", sizeof(int *));
printf("%zd\n", sizeof(double *));
return 0;
}
32位:4 4 4 4
64位:8 8 8 8
结论:
• 32位平台下地址是32个bit位,指针变量⼤⼩是4个字节
• 64位平台下地址是64个bit位,指针变量⼤⼩是8个字节
• 注意指针变量的⼤⼩和类型是⽆关的,只要指针类型的变量,在相同的平台下,⼤⼩都是相同的。
三、指针变量类型的意义
指针类型决定了指针进行解应用操作符的时候访问几个字节,也就是决定指针的权限。
3.1 指针的解引用
int main()
{
int a = 0x11223344;
int * pa = &a;
*pa =0;
return 0;
}
//代码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只是将n的第⼀个字节改为0。
结论:指针的类型决定了,对指针解引⽤的时候有多⼤的权限(⼀次能操作⼏个字节)。
⽐如: char* 的指针解引⽤就只能访问⼀个字节,⽽ int* 的指针的解引⽤就能访问四个字节。
3.1指针+-整数
int main()
{
int a =10;
int *pa = &a;
char* pc = &a;
printf("pa=%p\n",pa);
printf("pa+1 = %p\n",pa+1);
printf("pc = %p\n",pc);
printf("pc+1 = %p\n",pc+1);
return 0;
}
指针类型决定了指针进行+1,-1的时候,一次走远的距离
int * +1 --->走4个字节(整型大小)
char* +1--->走了1个字节(字符大小)
3.3void*:无具体类型的指针
指针类型:
char*:指向字符的指针
short*:指向短整型的指针
int*:指向整型的指针
float*:指向单精度浮点型的指针
........
void*:无具体类型的指针,这类指针可以用来接受任意类型的地址,但是也有局限性,就是void*不能直接进行指针的+-整数和解引用运算。
#include<stdio.h>
int main()
{
int a = 0;
char*p = &a;//int*
return 0;
}
上面这个代码我们可以证实这个结论,我们可以把void*想象成一个垃圾桶,可以收集任意类型数据的指针,但是无法直接去运用(解引用和+-运算)。其实void*的设计可以实现泛型编程的效果,使得一个函数可以处理多种类型的数据。
#include<stdio.h>
int main()
{
int a = 0;
float f = 0.0f;
void* p = &a;//int*
p = &f;//float*
return 0;
}
#include<stdio.h>
{
int a =10;
void* p = &a;
//*p = 20;//err
//p = p +1;//err
reutrn 0;
}
四、const修饰变量
4.1 const修饰指针
int main()
{
const int a 10;//a 具有了常属性(不能被修改了)
//a是不是常量?虽然a是不能被修改的,但是本质上还是变量
//常变量
//
//a = 20;
//在C++中const修饰的变量就是常量
//
int arr[a] ;
printf("%d\n",a);
retrun 0;
}
int main()
{
const int a = 10;
//a = 20;//err
int* p = &a;
*p = 0;
ptintf("a = %d\n",a);
return 0;
}
4.2 const修饰指针变量
创建指针变量p(int*p=&a)之前,我们首先要知道3点含义。
1.p内部存放的是a的地址,*p可以通过这个地址访问到a。
2.p本身也是变量,他有自己的地址。
3.*p是p指向的空间,也可以理解成解引用p,改变*p其实就是改变a。
int main()
{
int a = 10;//&a--0x0012ff40
int b = 20;//&b--0x0012ff44
int * const p =&a;//0x0012ff40
//p = &b;//err
*p = 100;
//const 修饰指针变量的时候。放*右边
//const 限制的是指针变量本身,指针变量不能再指向其他变量了
//但是可以通过指针变量,修改指针变量指向的内容
return 0;
}
int main()
{
int a = 10;
int b = 20;
int const * p =&a;
//p = &b;//OK
//*p = 100;//err
//const 修饰指针变量的时候。放*左边,限制的是:指针指向的内容,不能通过指针来修改指向的内容
//const 限制的是指针变量本身,指针变量不能再指向其他变量了
//但是可以修改指针变量本身的值(修改的指针变量的指向)
return 0;
}
int main()
{
int a = 10;
int b = 100;
int const * const p =&a;
//p = &b;//err
//*p = 0;//err
return 0;
}
const结论:
1.const如果在*左边,const修饰的是*p,也就是修饰指针指向的内容,保证指针指向的内容不能通过指针来改变,但是指针变量p本身的内容是可以改变的。
2.const如果在*右边,const修饰的是p本身,保证指针变量p的内容不能被修改,但是指针指向的内容是可以改变的。
3.如果*的两边都有const,则const不仅修饰了*p,也修饰了p本身,所以无论是指针指针指向的内容,还是指针变量本身,都是不可以被改变的。
五、指针运算
5.1 指针+-整数 运算
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int i = 0;
int sz = sizeof(arr) / sizeof(arr[0])\;
for (i = 0; i < sz;i++)
{
printf("%d",arr[i]);
}
return 0;
}
int main()
{
int arr[10];
int i = 0;
for (i = 0; i < 10; i++)
{
arr[i] = i + 1;//数组内10个元素分别为1 2 3 4 5 6 7 8 9 10
}
//通过指针来访问并打印这个数组
int sz = sizeof(arr) / sizeof(arr[0]);//sz为数组元素个数
//我们需要知道arr的首地址,再通过+-运算顺藤摸瓜找到后面所有元素
int* p = &arr;//数组名代表数组首元素的地址
for (i = 0; i < sz; i++)//如果我想访问1-10
{
printf("%d ", *p);
p++;
} //如果想访问1 3 5 7 9,则改成p+=2即可
return 0;
}
5.2 指针-指针
通过5.1,我们知道指针+整数=指针。所以指针-指针得到的是一个整数。
可以模拟实现strlen函数来观察指针的减法,strlen函数本质是字符串/0前面出现的元素个数,其实strlen函数传入的是字串串首元素的地址,如何通过该地址顺藤摸瓜地寻找后面的元素,知道遇到/0。
int my_strlen(char* s)
{
char* p = s;
while (*p != '\0')//这里也可以写成*p,因为'\0'的ascii值是0
p++;//p加1一次就往后移动4个字节
return p - s;//指针-指针得到的绝对值是指针之间的元素个数(前提条件:两个指针指向同一块空间。)
}
int main()
{
int ret = my_strlen("abc");
printf("%d", ret);
return 0;
}
指针-指针得到的是一个整数,而这个整数其实就是指针与指针之间的元素个数,但是有个前提条件就是两个指针必须指向同一块空间(比如arr[0]-crr[1]就不行)。
5.3 指针的关系运算
指针的关系运算就是指针比较大小,可以通过运用该知识来访问数组。
int main()
{
int arr[10];
int i = 0;
for (i = 0; i < 10; i++)
{
arr[i] = i + 1;//数组内10个元素分别为1 2 3 4 5 6 7 8 9 10
}
//通过指针来访问并打印这个数组
int sz = sizeof(arr) / sizeof(arr[0]);//sz为数组元素个数
//我们需要知道arr的首地址,再通过+-运算顺藤摸瓜找到后面所有元素
int* p = &arr;//数组名代表数组首元素的地址
while (p < arr + sz)
{
printf("%d ", *p);
p++;
}
return 0;
}
p接收的是arr的首地址,而sz是元素个数,所以通过p++,p会无限接近arr最后一个元素arr[sz-1],直到打印出来之后,while循环结束。
六、野指针
概念:野指针就是指针指向的位置是不可知的
6.1.野指针产生原因
6.1.1 指针未初始化
未初始化的变量(int *p),变量的值是随机的,无法访问(此时写*p=20会报错)
6.1.2 指针越界访问
将for循环中的i<10改成i<20,此时出现越界访问。
当指针指向的返回超出数组的范围,就是越界访问,此时p是野指针。
6.1.3 指针指向的空间释放
上面这段代码中,调用test函数,test函数的返回值是一个局部变量,test运行后已经被释放了,但是第一张图运行还是可以运行出10这个数据,原因是我们理解的销毁其实时空间所有权被释放,当其他函数执行需要开栈帧时,会把这里给占用,但是第一张图运行时还没有函数来占用,所以10这个数据被保存了下来,而第二张图在调用test函数后面又加了一段打印hehe的代码,此时printf的调用占用了这块空间,此时再去访问得到的就是一个随机值。
当指针指向的空间已经被释放(常见的就是调用的函数的返回值是一个局部变量,函数一调用结束该变量立刻被销毁。),p指向一块无法访问的内容,此时p是野指针。
6.2 如何规避野指针
6.2.1 指针初始化
在指针变量创建的时候就要进行初始化,如果不知道指针应该指向哪里,那么可以将指针赋值给NULL,NULL是C函数中定义的一个标识符常量,他的值是0,地址也是0,所以读取该地址时程序会报错,相当于程序会提醒你这是个野指针,不要去使用。
6.2.2 避免越界访问
比如程序向内存申请了一个存放arr数组的空间,那么指针也只能访问这些空间,一定不要超出这个范围去访问。
6.2.3 当指针不再使用时,即使置NULL,指针使用前检查有效性
当我们后期不需要使用这个指针去访问空间时,即使内置NULL,因为将指针变量设置成NULL,一旦误用后系统就会报错,这样可以把野指针暂时管理起来。
另一方面,当我们书写了大量代码后,可能会没有及时发现野指针的出现,这时候我们可以在使用前判断是否是NULL,根据情况决定是否继续使用这个指针
6.2.4 避免返回局部变量的地址
局部变量在函数执行完,空间所有权就会被释放,一但其他函数执行需要开栈帧,就会占用该空间。
七、assert断言
assert.h 头⽂件定义了宏 assert() ,⽤于在运⾏时确保程序符合指定条件,如果不符合,就报 错终⽌运⾏。这个宏常常被称为"断⾔"。
assert() 宏接受⼀个表达式作为参数。如果该表达式为真(返回值⾮零), assert() 不会产⽣ 任何作⽤,程序继续运⾏。如果该表达式为假(返回值为零), assert() 就会报错,在标准错误 流 stderr 中写⼊⼀条错误信息,显⽰没有通过的表达式,以及包含这个表达式的⽂件名和⾏号。
assert() 的好处:
1.⾃动标识⽂件和 出问题的⾏号
2.⽆需更改代码就能开启或关闭 assert() 的机制。如果已经确认程序没有问 题,不需要再做断⾔,就在 #include 语句的前⾯,定义⼀个宏 NDEBUG (#define NDEBUG)。
assert() 的坏处:1.因为引入了额外的检查,增加了程序的运行时间。
2.release版本中需要确保代码没问题的情况下禁用assert操作,确保影响用户使用程序的效率。(一些编译器的release需要禁用,但是vs这样的集成开发环境直接就是优化掉了)
八、理解传值调用和传址调用
传值调用和传址调用本质区别就是有无用到指针,指针-指针运算模拟strlen函数的实现,其实就是传址调用的一种方法,其实有一些问题的解决不使用指针是无法解决的,比方说下面模拟swap函数的实现。
swap函数,即通过这个函数交换两个整型变量的值。在没学习指针前,我会这样写----
但是没有产生我们想要的效果,原因是实参传递给形参时,形参会单独创建一份临时空间来接受实参,对形参的修改不会影响到实参的值,x和y确实接收到了a和b的值,不过x的地址和a不一样,y的地址和b不一样,所以在swap函数内部去交换x和y的值,本质上不会影响到a和b,说明swap函数是失败的,这种函数调用方法在学习函数的时候就已经了解了,就是传值调用,其特点就是对形参的改变不会影响实参的数据。
所以我们想要实现swap函数,就需要使用传址调用,让swap函数可以通过地址间接操作main函数中的a和b,达到交换的效果。
void swap2(int* px, int* py)
{
int temp = *px;
*px = *py;
*py = temp;
}
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);
}
通过传址调用,swap函数成功了,我们可以总结出以下结论:传址调用可以让函数和主调函数之间建立真正的联系,在函数内部可以修改主调函数中的变量,所以未来我们仅仅只是需要主调函数中的变量值来进行计算而不改变变量值,那么可以采用传值调用,如果函数内部要修改主调函数中变量的值,那么就需要传址调用。