内存和地址
内存
我们知道计算上CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也会放回内存中,那我们电脑上的哪些内存空间如何高效的管理呢?
其实也是把内存划分为一个个的内存单元,每个内存单元的大小取1个字节。
其中,每个内存单元都有一个编号,有了这个内存单元的编号,CPU就可以快速找到一个内存空间。在计算机中我们把内存单元的编号称为地址。C语言中给地址起了新的名字叫:指针。
所以我们可以理解为:
内存单元的编号 == 地址 == 指针
在计算机中地址实际为 2 进制,但 VS 中为了方便展示,写成 16 进制。
究竟该如何理解编址
计算机中的编址,并不是把每个字节的地址记录下来,而是通过硬件设计完成的。
首先,必须理解,计算机内是有很多的硬件单元,而硬件单元是要互相协同工作的。所谓的协同,至少相互之间要能够进行数据传递。但是硬件与硬件之间是互相独立的,那么如何通信呢?答案很简单,用**“线”**连起来。
而CPU和内存之间也是有大量的数据交互的,所以,两者必须也用线连起来。
我们可以简单理解,32位机器有32根地址总线,每根线只有两态,表示0
,1
(电脉冲有无),那么一根线,就能表示2种含义,2根线就能表示4种含义,依次类推。32根地址线,就能表示2^32
种含义,每一种含义都代表一个地址。
通过控制总线下达命令,从内存中读取某个数据并进行相应计算。
CPU 将需要读取的数据所在的地址通过地址总线下达给内存。
在内存上,就可以找到该地址对应的数据,将数据在通过数据总线传入CPU内寄存器。
在 CPU 中完成计算。
将计算完成的数据要存放的地址通过数据总线下达给内存。
将计算完成的数据通过数据总线传入内存。
指针变量和地址
取地址操作符(&)
理解了内存和地址的关系,我们再回到C语言,在C语言中创建变量其实就是向内存申请空间,比如:
#include<stdio.h>
int main()
{int a = 10;return 0;
}
比如,上述的代码就是创建了整型变量a
,内存中申请4个字节,用于存放整数10,其中每个字节都有地址。
那我们如何能得到a的地址呢?
这里就得学习一个操作符&
——取地址操作符。
#include<stdio.h>
int main()
{int a = 10;&a;printf("%p", &a);return 0;
}
按照右图,会打印输出:006FFD70
&a
取出的是a
所占4个字节中地址较小的字节的地址。
整型变量占用4个字节,但只要知道了第一个字节地址,就可以顺藤摸瓜访问到4个字节的数据。
指针变量和解引用操作符(*)
指针变量
那我们通过取地址操作符&
拿到的地址是一个数值,比如:0x006FFD70
,这个数值有时候也是需要存储起来,方便后期再使用的。
那我们把这样的地址值存放在哪里呢?答案是:指针变量中。
#include<stdio.h>
int main()
{int a = 10;int* pa = &a;//取出a的地址并存储到指针变量pa中return 0;
}
指针变量也是一种变量,这种变量就是用来存放地址的,存放在指针变量中的值都会理解为地址。
如何拆解指针类型
我们看到pa
的类型是int*
,我们该如何理解指针的类型呢?
int a = 10;
int* pa = &a;
这里pa
左边写的是int*
,*
是在说明pa
是指针变量,而前面的int
是在说明pa
指向的是整型int
类型的对象。
解引用操作符
C语言中,我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象。
这里必须学习一个操作符叫解引用操作符*****
。
#include<stdio.h>
int main()
{int a = 10;int* pa = &a;*pa = 20;printf("%d", a);//输出20return 0;
}
*pa
为pa
所指向的对象,意思就是通过pa
中存放的地址,找到指向的空间,*pa
其实就是a
变量了;所以*pa = 20
,这个操作符是把a
改成了 20.
指针变量的大小
32位机器,假设有32根地址总线,每根地址线出来的电信号转换成数字信号后是1
或者0
,那我们把32根地址线产生的2进制序列当做一个地址,那么一个地址就是32个**bit**
位,需要4个字节才能存储。
如果指针变量是用来存放地址的,那么指针变量的大小就得是4个字节的空间才可以。
同理,64位机器,假设有64根地址线,一个地址就是64个二进制位组成的二进制序列,存储起来就需要8个字节的空间,指针变的大小就是8个字节。
#include<stdio.h>
int main()
{//指针变量的大小取决于地址的大小//32位平台下地址是32个bit位(即4个字节)//64位平台下地址是64个bit位(即8个字节)printf("%d\n", sizeof(char*));printf("%d\n", sizeof(int*));printf("%d\n", sizeof(float*));printf("%d\n", sizeof(double*));return 0;
}
结论:
- 32位平台下地址是32个
bit
位,指针变量大小是4个字节 - 64位平台下地址是64个
bit
位,指针变量大小是8个字节 - 注意指针变量的大小和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的。
指针变量类型的意义
指针变量的大小和类型无关,只要是指针变量,在同一个平台下,大小都是一样的,为什么还要有各种各样的指针类型呢?其实指针类型是有意义的。
指针的解引用
#include<stdio.h>
int main()
{int a = 0x11223344;int* pa = &a;*pa = 0;return 0;
}
int会将n的4个字节全部改为0
#include<stdio.h>
int main()
{int a = 0x11223344;char* pa = &a;*pa = 0;return 0;
}
char
** 只是将n的第一个字节改为0**
结论:
指针的类型决定了,对指针解引用的时候有多大的权限(一次能操作几个字节)。
比如:char*
的指针解引用就只能访问一个字节,而int*
的指针的解引用就能访问四个字节。
指针 +-
整数
先看一段代码,调试观察地址的变化。
#include<stdio.h>
int main()
{int a = 10;char* pc = (char*)&a;int* pi = &a;printf("&n = %p\n", &a);printf("pc = %p\n", pc);printf("pc + 1 = %p\n", pc + 1);printf("pi = %p\n", pi);printf("pi + 1 = %p\n", pi + 1);return 0;
}
代码运行的结果如下:
结论:指针的类型决定了指针向前或者向后走一步有多大(距离)。
我们可以看出,虽然char*
类型的指针和int*
类型的指针输出结果相同,char*
类型的指针变量+1
跳过1个字节,int*
类型的指针变量+1
跳过了4个字节。这就是指针变量的类型差异带来的变化。
void*
指针
在指针类型中有一种特殊的类型是void*
类型的,可以理解为无具体类型的指针(或者叫泛型指针)。
这种类型的指针可以用来接受任意类型地址。但是也有局限性,void*
类型的指针不能直接进行指针的+-
整数和解引用的运算。
#include<stdio.h>
int main()
{int a = 10;int* pi = &a;char* pc = &a;return 0;
}
在上面的代码中,将一个int
类型的变量的地址赋值给一个char*
类型的指针变量。编译器给出了一个警告,是因为类型不兼容。
而使用void*
类型就不会有这样的问题。
#include<stdio.h>
int main()
{int a = 10;void* p1 = &a;void* p2 = &a;*p1 = 20;*p2 = 0;return 0;
}
这里我们可以看到,void*
类型的指针可以接收不同类型的地址,但是无法直接进行指针运算。
void*
类型的指针进行指针运算,需要进行强制类型转换。
#include<stdio.h>
int main()
{int a = 10;void* p1 = &a;*(int*)p1 = 20;printf("%d", *(int*)p1);return 0;
}
那么void*
类型的指针到底有什么用呢?
一般void*
类型的指针是使用在函数参数的部分,用来接收不同类型数据的地址,这样的设计可以实现泛型编程的效果,使得一个函数来处理多种类型的数据。
指针运算
指针的基本运算有三种,分别是:
- 指针
+-
整数 - 指针
-
指针 - 指针的关系运算
指针+-
整数
因为数组在内存中是连续存放的,只要知道第一个元素的地址,顺藤摸瓜就能找到后面的所有元素。
#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;
}
`
指针-指针
#include<stdio.h>
int main()
{int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };int n = &arr[9] - &arr[0];printf("%d", n);return 0;
}
结论:
- 指针
-
指针的时候,两个指针必须指向同一块区域。 - 指针
-
指针得到的值的绝对值,是两个指针间的元素的个数
练习:写一个函数,用于求字符串的长度
#include<stdio.h>
int my_strlen(char* s)
{char* q = s;while (*q != '\0')q++;return q - s;
}
int main()
{char s[] = "abc";int len = my_strlen(s);printf("%d", len);return 0;
}
实际上,上述代码可简化:
#include<stdio.h>
int my_strlen(char* s)
{char* q = s;while (*q != '\0')q++;return q - s;
}
int main()
{int len = my_strlen("abc");printf("%d", len);return 0;
}
这个是因为在c语言中,类似于“abc”
这样的字符字面量,本质上就是一个字符数组的指针,编译器会将字符串 "abc"
的首地址,就是即指向字符 'a'
的指针传递给你调用的这个函数。
指针的关系运算
#include<stdio.h>
int main()
{int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };int sz = sizeof(arr) / sizeof(arr[0]);int* p = arr;while (p < &arr[sz]){printf("%d ", *p);p++;}return 0;
}
二级指针
指针变量也是变量,是变量就有地址,那指针变量的地址存放在哪里?
存放在二级指针变量中。
对于二级指针的运算有:
*ppa
先通过对ppa
中的地址进行解引用,这样找到的是pa
int b = 10;
*ppa = &b
//等价于 pa = &b
**ppa
先通过*ppa
找到pa
,然后对pa
进行解引用操作:*pa
,那找到的是a
。
***ppa = 30;
//等价于*pa = 30
//等价于a = 30
野指针
野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
野指针成因
指针未初始化
#include<stdio.h>
int main()
{int* p;//局部变量指针未初始化,默认为随机值*p = 20;return 0;
}
指针越界访问
#include<stdio.h>
int main()
{int arr[] = { 1, 2, 3, 4, 5 };int* p = arr;int sz = sizeof(arr) / sizeof(arr[0]);int i = 0;for (i = 0; i <= sz; i++)//i = sz时越界访问{*p = i;p++;}return 0;
}
指针指向的空间释放
#include<stdio.h>
int* test()
{int n = 10;return &n;
}
int main()
{//n所占的四个字节的空间,在函数test返回之后,就已经返回给操作系统int* p = test();printf("%d", *p);return 0;
}
如何规避野指针
指针初始化
如果明确知道指针指向哪里,就直接赋值地址,如果不知道指针应该指向哪里,可以给指针赋值NULL
。
NULL
是一个标识符常量,值是0
,0
也是地址,这个地址是无法使用的,读写该地址会报错。
NULL
的定义:
#ifdef __cplusplus//c++中为0#define NULL 0
#else#define NULL ((void *)0)//c语言中强制类型转换为void*类型
#endif
#include<stdio.h>
int main()
{int* p1 = NULL;return 0;
}
小心指针越界
一个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出了就是越界访问。
指针变量不再使用时,及时置NULL
,指针使用之前检查有效性
当指针变量指向一块区域的时候,我们可以通过指针访问该区域。后期不再使用这个指针访问空间的时候,我们可以把该指针置为NULL
。
因为约定俗成的一个规则就是:只要是NULL
指针就不去访问。
同时使用指针之前可以判断指针是否为NULL
,如果是则不能直接使用,如果不是我们再去使用。
#include<stdio.h>
int main()
{int arr[] = { 1, 2, 3, 4, 5 };int* p = arr;int sz = sizeof(arr) / sizeof(arr[0]);int i = 0;for (i = 0; i <= sz; i++){*p = i;p++;}//此时p已经越界了,可以把p置为NULLp = NULL;//下次再使用时,判断p不为NULL时再使用p = &arr[0];//重新让p获得地址if (p != NULL)//判断{}return 0;
}
避免返回局部变量的地址
不要返回局部变量的地址。
const修饰指针变量
assert断言
assert.h
头文件定义了宏assert()
,用于在运行时确保程序符合指定条件,如果不符合,就报错终止运行。这个宏常常被称为“断言”。
assert(p != NULL);
上面代码在程序运行到这一行语句时,验证变量p 是否等于NULL 。如果确实不等于NULL ,程序继续运行,否则就会终止运行,并且给出报错信息提示。
assert(
) 宏接受一个表达式作为参数:
如果该表达式为真(返回值非零),assert()
不会产生任何作用,程序继续运行。
如果该表达式为假(返回值为零),assert()
就会报错,在标准错误流stderr
中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。
使用assert()
的好处:它不仅能自动标识文件和出问题的行号,还有一种无需更改代码就能开启或关闭assert()
的机制。如果确认程序没有问题,不需要再做断言,就在#include <assert.h>
语句的前面,定义一个宏NDEBUG
。
然后,重新编译程序,编译器就会禁用文件中所有的assert()
语句。如果程序又出现问题,可以移除这条#define NDBUG
指令(或者把它注释掉),再次编译,这样就重新启用了assert()
语句。
#define NDEBUG
#include<assert.h>
assert()
的缺点是,因为引入了额外的检查,增加了程序的运行时间。
一般我们可以在Debug
中使用,在Release
版本中选择禁用assert
就行。
在VS
这样的集成开发环境中,在Release
** 版本中,直接就是优化掉了。这样在debug
版本写有利于程序员排查问题,在Release
版本不影响用户使用时程序的效率。