大家可能在学习的时候会经常疑惑数据在内存中是怎样存储的,今天用一篇博客给你讲清楚!!!从此不再疑惑!!!
文章目录
1. 整数在内存中的存储
整数的2进制表示方法有三种,即 原码、反码和补码。
有符号的整数,三种表示方法均有符号位和数值位两部分,符号位都是用0表⽰“正”,用1表示“负”,最⾼位的⼀位是被当做符号位,剩余的都是数值位。
正整数的原、反、补码都相同。
负整数的三种表示方法各不相同。
原码:直接将数值按照正负数的形式翻译成⼆进制得到的就是原码。
反码:将原码的符号位不变,其他位依次按位取反就可以得到反码。
补码:反码+1就得到补码。
对于整型来说:数据存放内存中其实存放的是补码。
为什么呢?
在计算机系统中,数值⼀律⽤补码来表⽰和存储。
原因在于,使⽤补码,可以将符号位和数值域统⼀处理;
同时,加法和减法也可以统⼀处理(CPU只有加法器)此外,补码与原码相互转换,其运算过程是相同的,不需要额外的硬件电路。
2. 大小端字节序和字节序判断
我们来看一段代码:
int main()
{
int n = 0x11223344;//十六进制数字
return 0;
}
我们调试一下,在内存中观察一下:
我们可以看到,它在内存中是反着存的(以字节为单位存储,但是字节的存储是倒着的)。
这里进行几点说明:
- 整数在内存中存储的是二进制的补码。
- 在调试窗口中观察的时候,为了方便观察展示,显示的是16进制的值。
- 存储的顺序是倒过来的。
那么为什么是倒着存储的呢?这个就和我们下面要讲到的大小端的问题有关了。
2.1 什么是大小端
- 其实超过⼀个字节的数据在内存中存储的时候,就有存储顺序的问题。
比如:
或者这样设计:
或者还有其他的顺序存储,按照理论上讲,这几种都是可以的。数据在往里面存的时候,再怎样把它拿出来就行了啊!
但是我们说如果那么没有顺序的存储方法就不是很常规,就比较乱,所以最终就留下来了两种方式(1. 按照正的顺序存储,2. 按照反的顺序存储):
按照不同的存储顺序,我们分为大端字节序存储和小端字节序存储,下面是具体的概念:
大端(存储)模式:
是指数据的低位字节内容保存在内存的⾼地址处,而数据的⾼位字节内容,保存在内存的低地址处。
小端(存储)模式:
是指数据的低位字节内容保存在内存的低地址处,⽽数据的⾼位字节内容,保存在内存的⾼地址处。
上述概念需要记住,方便分辨大小端。
- 那么我们是怎么理解高位节和低位节呢?
- 0x 11 22 33 44这样一个十六进制的数字中的44是低位字节,11是高位字节,我们可以会借助数学中的知识来记忆(比如:123中的3在个位上,是处于低位,1是在百位上,处于高位)。
当前VS上使用的就是小端存储。
2.2 为什么有大小端
为什么会有大小端模式之分呢?
这是因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着⼀个字节,⼀个字节为8bit 位,但是在C语⾔中除了8 bit 的 char 之外,还有16 bit 的 short 型,32 bit 的 long 型(要看具体的编译器),另外,对于位数大于8位的处理器,例如16位或者32位的处理器,由于寄存器宽度大于⼀个字节,那么必然存在着⼀个如何将多个字节安排的问题。因此就导致了大端存储模式和小端存储模式。
例如:⼀个 16bit 的 short 型 x ,在内存中的地址为 0x0010 , x 的值为 0x1122 ,那么0x11 为⾼字节, 0x22 为低字节。对于大端模式,就将 0x11 放在低地址中,即 0x0010 中,0x22 放在⾼地址中,即 0x0011 中。小端模式,刚好相反。我们常用的 X86 结构是小端模式,而KEIL C51 则为大端模式。很多的ARM,DSP都为小端模式。有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。
2.3 练习
2.3.1 练习1
-
题目:
请简述⼤端字节序和⼩端字节序的概念,设计⼀个⼩程序来判断当前机器的字节序。(10分)-百度笔试题。 -
思路:
这里我是用1来作为验证的对象的,其实我们想到的就是如果1在内存中是大端存储的话是00 00 00 01,小端存储的话是01 00 00 00。那么我们只要看一下在内存中存储的第一个字节是什么就可以了,所以接下来的问题就是如何只取出一个字节,我们知道char类型的数据是1个字节的,我们就想到了将其强转成char类型。
- 代码:
#include<assert.h>
#include<stdio.h>
int judge(int* p)
{
assert(p);
if (*(char*)p == 1)
{
return 1;
}
else
{
return 0;
}
}
int main()
{
int n = 1;
int ret = judge(&n);
if (ret == 1)
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
- 代码优化:
#include<assert.h>
#include<stdio.h>
int judge(int* p)
{
assert(p);
return *(char*)p;
}
int main()
{
int n = 1;
int ret = judge(&n);
if (ret == 1)
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
2.3.2 练习2
- 题目:
#include <stdio.h>
int main()
{
char a= -1;
signed char b=-1;
unsigned char c=-1;
printf("a=%d,b=%d,c=%d",a,b,c);
return 0;
}
请问上面代码的输出结果是什么?
在解决这个题之前,我们大家要明确signed char(有符号的) 和 unsigned char(无符号的),这两个东西究竟是什么玩意儿?只有搞明白,我们才能正确做出这道题。
- signed char :
我们知道char类型的数据在内存中占1个字节,8个bit位。
那接下来我们大概列一下char类型的数据在内存中的补码;
在char类型的数据中,如果是signed char类型的,那么内存中最高位就会被当作符号位。
我们可以看一下signed char类型的数据的取值范围大概是多少:
注意大家在算负数的时候,注意要还原到原码进行计算(补码取反+1 或者 -1取反)。
所以我们可以看到signed char 类型的数据的取值范围是:( -128 ~127)- unsigned char:
我们还是先来大概列一下char类型的数据在内存中的补码:
因为是unsigned char是无符号的,所以它是没有符号位的,8个bit位每一位都是有效位。
所以:
所以unsigned char 类型的数据的取值范围是:( 1~255 )。
明确了以上的这些,我们才能着手去做这道题。
题目解析:
#include <stdio.h>
int main()
{
//这里说明一下,char 是有符号的char还是无符号的char是取决于编译器的
//在vs上char==signed char
char a = -1;
//10000000 00000000 00000000 00000001 - 原码
//11111111 11111111 11111111 11111110 - 反码
//11111111 11111111 11111111 11111111 - 补码
//但是由于我们定义的是 char 类型的,所以只存进去1个bit位,这个时候就会发生截断
//只存 11111111 - a
signed char b = -1;
//11111111 - b
unsigned char c = -1;
//11111111 - c
printf("a=%d,b=%d,c=%d", a, b, c);
return 0;
}
注意: 上面写的补码都是在内存中存的补码,但是站在不同类型的数据往外面取的时候就不一定是这样了,我们接着往下看:
以%d的形式打印a
我们以char的类型存进去的时候是11111111
我们取出来的时候是以%d的视角去取的,它是4个字节的,并且char是有符号的。
所以我们在取的时候会认为11111111的第一位是符号位,并且会发生整型提升(补符号位),所以取出来的是:11111111 11111111 11111111 11111111,然后再还原成原码10000000 00000000 00000000 00000001,所以打印出来是 -1。以%d的形式打印b
我们以signed cha的视角存进去的是11111111
我们取出来的时候是以%d的视角去取的,它是4个字节的,并且singed char是有符号的。
所以我们在取的时候会认为11111111的第一位是符号位,并且会发生整型提升(补符号位),所以取出来的是:11111111 11111111 11111111 11111111,然后再还原成原码10000000 00000000 00000000 00000001,所以打印出来是 -1。以%d的形式打印c
我们以unsigned cha的视角存进去的是11111111。
我们取出来的时候是以%d的视角去取的,它是4个字节的,并且unsinged char是无符号的。
所以我们在取的时候会认为11111111的第一位是符号位,并且会发生整型提升(补符号位),所以取出来的是:00000000 00000000 00000000 11111111,因为是以%d(有符号)的形式打印的,所以认为最高位是符号位,这里的最高位是0,所以是正数,又因为正数的原码,反码,补码相同,所以直接打印出来是 255。
2.3.3 练习3
- 题目:
#include <stdio.h>
int main()
{
char a = -128;
printf("%u\n", a);//以%u打印是无符号的打印
return 0;
}
我们来看这道题目的输出结果是什么?
- 题目解析:
#include <stdio.h>
int main()
{
char a = -128;
//100000000 - -128
//111111111 11111111 11111111 10000000 - 整型提升
//而%u又认为内存中存的是一个无符号数,
//正数的原码反码补码都相同,所以就会直接打印。
printf("%u\n", a);//以%u打印是无符号的打印
return 0;
}
- 代码结果:
题目:
#include <stdio.h>
int main()
{
char a = 128;
printf("%u\n",a);
return 0;
}
我们再来看这道题目的输出结果是什么?
- 题目解析:
我们可以看到,这道题和上面的那一道不一样的就是-128变成了128。那么结果会有什么不一样呢?
我们前面分析了char==signed char,而signed char的取值范围是( -128~127 ),那么很明显是存不下的,那这个该怎么办呢?。大家要明确我们该怎样存就怎样存,存不下是因为我们再取的时候就可能不是这个数了。所以我们存进去的还是10000000。只是取出来就不一定是128了。
既然存进去的和上一题一样,都是10000000,那么后面的分析和上一题就完全一样了,代码的运行结果也是一样的。
2.3.4 练习4
- 题目:
#include <stdio.h>
int main()
{
char a[1000];
int i;
for(i=0; i<1000; i++)
{
a[i] = -1-i;
}
printf("%d",strlen(a));
return 0;
}
那么这个代码的结果又是什么呢?
- 题目解析:
我们这里定义了一个char类型的数组,有1000个元素。然后用循环对数组中的元素进行赋值,然后最后求的是字符串的长度,统计的是’\0‘(ASCII码值是0)之前的字符个数。
那么,我们前面分析过char类型的数据的取值范围是( -128~127 ),所以,很明显a[i] = -1-i;
计算出来的-1, -2, -3…, -127,-128,-129, -130…是无法正确进行存储的。那么究竟存的是多少呢?
这个其实就是我们的char类型数据的一个变化图,那么,我们-1, -2, -3…, -127,-128, -129, -130…这个数据是逆时针走的,那么-128之后就是127,126,125…一直往后走直到遇见了0,那么负数时128个数字,正数是127个数字,所以这道题答案是255。
2.3.5 练习5
- 题目:
#include <stdio.h>
unsigned char i = 0;
int main()
{
for(i = 0;i<=255;i++)
{
printf("hello world\n");
}
return 0;
}
- 题目解析:
这个答案是死循环,为什么呢?
大家还记得这个signed char 类型数据的一个轮回图吧!
我们再来看一下unsigned char 类型数据的轮回图:
我们这里的数据是顺时针的,所以当我们的 i=255 时,i再进行+1操作时候不是256,而是0了,如此往复,一直这样循环,没有终止。
- 题目:
#include <stdio.h>
int main()
{
unsigned int i;
for(i = 9; i >= 0; i--)
{
printf("%u\n",i);
}
return 0;
}
- 题目解析:
这个题就很简单了,因为我们定义的i是unsigned int 类型的,所以,它不可能小于0的,所以i >= 0,这个条件是恒成立的,所以这个代码也是死循环。
2.3.6 练习6
- 题目:
#include <stdio.h>
//X86环境 ⼩端字节序
int main()
{
int a[4] = { 1, 2, 3, 4 };
int *ptr1 = (int *)(&a + 1);
int *ptr2 = (int *)((int)a + 1);
printf("%x,%x", ptr1[-1], *ptr2);
return 0;
}
同样的,这段代码的运行结果是什么?
- 题目解析:
我们先来看
int *ptr1 = (int *)(&a + 1);
,这里的(&a + 1)
是表示&a(取出整个数组的地址),然后进行+1操作,这个时候就跳过了整个数组。
&a是int类型的,&a+1之后也是int类型的,然后我们对其进行强制类型转换成了( int* )类型,然后将其赋给了int类型的指针变量ptr1。
那么,ptr[-1]就等价于( ptr1-1),ptr1是int* 类型的,-1操作时候向后移动了4个字节,然后对其解引用4个字节,答案就是4。
接下来,我们再看int *ptr2 = (int *)((int)a + 1);
,这里的((int)a + 1)
中的()的优先级比较高,所以我们先执行对a进行强制类型转换为int类型,然后再进行+1操作,此时,就有坑了,因为我们已经a强制类型转换成了int类型,那么此时它就是一个数,不再是地址,+1操作的时候也是一个数+1得到另一个数。
假设:a是0x0012ff40
+1: 0x0012ff41
那么此时再将其强制类型转换成( int* )类型。那么此时它又是一个地址了,大家可以看一下我们假设的0x0012ff40和0x0012ff41差了1个字节,那么此时这个指针只是向后偏移了1个字节。
这是1,2 ,3, 4在内存中的存储,这里的个红框框代表4个字节,里面是按照十六进制进行存储的。
这里的一个篮框框就代表1个字节,我们在输出的时候输出的是ptr2,对ptr2进行了解引用,而ptr2的类型是int类型的,解引用的时候会访问4个字节:
即解引用时解引用的是黑色框框内的内容。我们又知道vs中是小端存储的,所以输出结果是02000000。
3. 浮点数在内存中的存储
- 常⻅的浮点数:3.14159、1E10(1.0*10^10)等,浮点数家族包括: float、double、long double 类型。
- 浮点数表⽰的范围: float.h 中定义
那我们可以打开这个文件看一下(这里给大家截取一部分):
3.1 练习
接下来,我们先来看一段代码:
#include <stdio.h>
int main()
{
int n = 9;
float *pFloat = (float *)&n;
printf("n的值为:%d\n",n);
printf("*pFloat的值为:%f\n",*pFloat);
*pFloat = 9.0;
printf("num的值为:%d\n",n);
printf("*pFloat的值为:%f\n",*pFloat);
return 0;
}
大家先来自己看一下这个代码运行的结果是多少,然后再往下看答案,感受一下自己的思路和正确的思路偏差在哪.
我们从上往下一行一行地先大概来分析一下这段代码。
#include <stdio.h>
int main()
{
int n = 9; //定义了一个int类型的常量n, 并赋值为9.
float* pFloat = (float*)&n;//取出了n的地址,强制类型转换成( float* )类型,紧接着将其赋给float*类型的指针变量pFloat。
printf("n的值为:%d\n", n);//以%d的形式打印n
printf("*pFloat的值为:%f\n", *pFloat);//以%f的形式打印*pFloat
*pFloat = 9.0;//通过指针间接访问n,并将其值改为9.0
printf("num的值为:%d\n", n);//以%d的形式打印n
printf("*pFloat的值为:%f\n", *pFloat);//以%f的形式打印*pFloat
return 0;
}
看到这里你是不是还以为答案是9,9.0,9,9.0.
其实并不是,我们来看一下运行结果:
看完答案,你是不是有很多疑惑,没关系,我们带着疑问往下走,会得到答案的,我们一点一点地分析到底为什么是这样的一个答案。
我们知道,&n是int类型的,占4个字节,强制类型转换成float类型的之后,也是占4个字节啊,我们在解引用的时候应该是恰好能访问它本来存储的4个字节的空间的呀!
那么按理说,答案就应该是我们上面所说的答案啊,可是为什么那不是正确答案呢?
那就说明我们的浮点数在内存中的存储并不是像我们想的那样,不是和整数一样的。
3.2 浮点数的存储
根据国际标准IEEE(电⽓和电⼦⼯程协会) 754,任意⼀个⼆进制浮点数V可以表⽰成下⾯的形式:
V = (−1)^S * M *2^E
- (−1)S 表示符号位,当S=0,V为正数;当S=1,V为负数
- M 表⽰有效数字,M是⼤于等于1,⼩于2的
- 2^E 表⽰指数位
举例来说:
⼗进制的5.0,写成⼆进制是 101.0 ,相当于 1.01×2^2 (小数点向左移动两位,那么就是2(因为这里是2进制,所以底数是2,我们大家熟悉的是10进制,那么科学计数法的底数就是10)的2次方)。
那么,按照上⾯V的格式,可以得出S=0,M=1.01,E=2。
⼗进制的-5.0,写成⼆进制是 -101.0 ,相当于 -1.01×2^2 。那么,S=1,M=1.01,E=2。
前面 ,我们举例的是一个小数点之后是0的,那么如果是5.5,怎么来用二进制来表示呢?
是101.101吗?
不是的哈,可不敢这样表示,我们来看一下每一位的权重。
那么这样对应下来,算出来的就不是5.5了,而是5.875了,5.5正确的二进制表示是101.1.
那么我们按照国际标准表示的5.5就是5.5 = (-1)^0 * 1.011 * 2 ^ 2(这样写不太好看,请看如下图)
在这里,S=0, M=1.011, E=2,因为所有的浮点数都能按照这个形式表示出来,只不过是不同的浮点数的S, M, E的值是不同的,而这个形式中的其他数据是不变的,那么就意味着,我们在存储浮点数的时候只需要将S, M, E存储好就行了,事实上,浮点数在内存中的存储,九四存储的S, M, E相关的值。
那么浮点数在内存中究竟是怎么存储的呢?
IEEE 754规定:
- 对于32位的浮点数(float),最高的1位存储符号位S,接着8位存储指数E,剩下的23位存储有效数字M。
对于64位的浮点数(double),最高的1位存储符号位S,接着的11位存储指数E,剩下的52位存储有效数字M。
3.2.1 浮点数存的过程
IEEE 754 对有效数字M和指数E,还有⼀些特别的规定。
前⾯说过, 1≤M<2 ,也就是说,M可以写成 1.xxxxxx 的形式,其中 xxxxxx 表⽰⼩数部分。
IEEE 754 规定,在计算机内部保存M时,默认这个数的第⼀位总是1,因此可以被舍去,只保存后⾯的xxxxxx部分。⽐如保存1.01的时候,只保存01,等到读取的时候,再把第⼀位的1加上去。这样做的⽬的是节省1位有效数字。以32位浮点数为例,留给M只有23位,将第⼀位的1舍去以后,等于可以保存24位有效数字。
⾄于指数E,情况就比较复杂了
⾸先,E为⼀个⽆符号整数(unsigned int)
这意味着,如果E为8位,它的取值范围为0 ~ 255;如果E为11位,它的取值范围为0~2047。但是,我们知道,科学计数法中的E是可能出现负数的(比如:0.5的二进制表示的指数是-1),所以IEEE 754规定,存⼊内存时E的真实值必须再加上⼀个中间数,对于8位的E,这个中间数是127;对于11位的E,这个中间数是1023。
⽐如,2^10的E是10,所以保存成32位浮点数时,必须保存成10+127=137,即10001001。
这里大家可能会有一个疑惑,如果这个负数加上中间数之后,还是一个负数该怎么办呢?
大家在这里需要注意,即使是浮点数它也是有最大值和最小值的,那就意味着E是有最大值和最小值的,既然它是这样设计的,那么就是设计的时候就已经计算好的,所以才这样规定的。
接下来,我们就还以5.5为例来看一下浮点数在内存中的存储究竟是怎样的。
int mian()
{
float f = 5.5;
//二进制表示:(-1)^0 * 1.011 * 2^2
//其中,S = 0, M = 1.011, E = 2
//S(1位)在内存中的存储:0
//E(8位)在内存中的存储:2+127(float类型) = 129, 10000001
//M(23位)在内存中的存储:01100000000000000000000(位数不够时,补0)
//所以浮点数5.5在内存中的存储是01000000101100000000000000000000
return 0;
}
那这个时候是不是感觉这个二进制太长了,看着不得劲,那么我们可以将其写成16进制的数字,会比较好看些。
//我们知道,一位16进制的数字对应的是4个2进制的数字。
//0100 0000 1011 0000 0000 0000 0000 0000
//4 0 B 0 0 0 0 0
//40 B0 00 00
我们来调试一下,看看内存中的存储方式是不是和我们说的一样。
我们在这里看到果真和我们写的一样(这里提示一下,大家看到内存中是反过来存储的,这是小端存储,不太了解的同学可以往这篇博客的上面翻翻,翻到最上面,看一下目录,就可以直接找到大小端的讲解)。
大家在举例子的时候可不敢乱举哈,你看完之后,特别兴奋,感觉自己又掌握了一个新知识,你就开始随便举例子,比如,你举了一个3.14这个浮点数,你会发现你用二进制表示的时候,无法精确的表示3.14,无论你怎么用小数点后的01来凑0.14,你可能都无法精确的凑出来。也就是说,如果小数点后的位太多,就可能导致浮点数在内存中无法精确的保存。所以,浮点数在内存中可能会出现这种情况,无法在内存中精确保存。
3.2.2 浮点数取的过程
前面介绍了浮点数存的过程,那么浮点数在取的时候又是怎样的呢?
常规情况下,就是按照存的相反过程往回返就可以了。
但是也有不常规的情况。在浮点数取的过程分为3种情况。
- E不全为0或不全为1
- 这时,浮点数就采⽤下⾯的规则表示,即指数E的计算值减去127(或1023),得到真实值,再将有效数字M前加上第⼀位的1。
- ⽐如:0.5 的⼆进制形式为0.1,由于规定正数部分必须为1,即将⼩数点右移1位,则为1.0*2^(-1),其阶码为-1+127(中间值)=126,表⽰为01111110,⽽尾数1.0去掉整数部分为0,补⻬0到23位00000000000000000000000,则其⼆进制表示形式为:
0 01111110 00000000000000000000000
- E全为0
这时,浮点数的指数E等于1-127(或者1 -1023),但是如果其加上一个中间数之后为0,就代表这个指数是非常小的,比如说这个指数是-127,那么此时1.xxx * 2^-127是一个非常非常小的。那么,有效数字M不再加上第⼀位的1,⽽是还原为0.xxxxxx的⼩数。这样做是为了表⽰±0,以及接近于0的一个很⼩的数字。
1 0 00000000 00100000000000000000000- E全为1
- 我们以float为例,E全为1的时候,这个时候存的是255,255是通过( 真实的E+127) 得到的,那么此时E也是非常大的,可能是128这样的值,那么此时1.xxx * 2^128,这也是一个非常非常大的数字。
- 这时,如果有效数字M全为0,表⽰±⽆穷⼤(正负取决于符号位s);
0 11111111 00010000000000000000000
3.3 题目解析
下⾯,让我们回到上面的练习,来看解析吧,同志们!
int main()
{
int n = 9;
//00000000 00000000 00000000 00001001----9在内存中的二进制补码
float* pFloat = (float*)&n;
printf("n的值为:%d\n", n);
//n是int类型的,又以%d的形式输出,这里输出9是没有任何问题的
printf("*pFloat的值为:%f\n", *pFloat);
//这里是以%f的形式打印的,那么站在pFloat的角度来看,
//它会认为自己指向的是一个float类型的数据,那么9在内存中的存储就是下面这样的了
// 0 00000000 00000000000000000001001
// S E M
//那么,这个时候我们要取出来这个数据,就是我们说的E全为0的情况,
//那么,它就是一个非常非常小的数字,是一个无限接近于0的数字
*pFloat = 9.0;
//将9.0这个浮点数通过指针变量pFloat间接访问n,pFloatde 的类型是float*类型的
//此时,pFloat是以浮点数的视角存储9.0的
//我们按照标准规定写出9.0
//(-1)^0 * 1.001 * 2^3
//此时,S=0, M=1.001, E=3
//0 10000010 00100000000000000000000
printf("num的值为:%d\n", n);
//此时,n已经被pFloat按照浮点数的视角存进去了,此时打印要以整数的的视角往回拿
//那么此时整数的视角的二进制序列就是下面这样的:
//01000001 00010000 00000000 00000000
//那么此时以%d的形式打印,%d表示的是有符号的,那么最高位就是符号位,最高位是0,是正数
//那么补码就是其原码,直接 打印出来就好了。
printf("*pFloat的值为:%f\n", *pFloat);
//前面我们说过存的时候是按照float的视角存进去的,那取的时候也是按照float取的话,
//那么,和存进去的时候是没差的,所以打印的就是9.0。
return 0;
}