引子:我们在之前的学习中仅仅只知道数据在计算机中是以二进制的形式存储的,那么我们今天就一起来探讨一下它到底是怎么存起来的。
目录
1. 整数在内存中的存储
在我们的计算中,整数的二进制表示有三种方法:原码,反码,补码。而这三种方法表示的二进制数均有符号位和数值位两部分组成,其中符号位都是用“0”表示“正”,用“1”表示“负”。而在一个二进制数中,最高位被当作符号位,剩余都是数值位。
正整数的原,反,补码都相同。
负整数的三种表示方法都各不相同:
- 原码:直接将数值按照正负数的形式翻译成二进制得到的就是原码。
- 反码:将原码的符号位不变,数值位依次按位取反(0变成1,1变成0),得到的就是反码。
- 补码:给反码加一就得到补码了。
对于整形来说:数据存放内存中其实存放的是补码。
例如:
a. 十进制数68 的二进制原码,反码,补码都是:00000000 00000000 00000000 01000100(这里最高位的0表示正)
b.十进制数-68
二进制源码:10000000 00000000 00000000 01000100(这里最高位的1就表示负)
二进制反码:11111111 11111111 11111111 10111011
二进制补码:11111111 11111111 11111111 10111100
2. 大小端字节序和字节序判断
当我们了解了整数在内存中的存储之后,我们来调试这样一段代码:
#include <stdio.h>
int main()
{
int num = 0x11223344;//0x 表示十六进制数
//十六进制数的一位为二进制数的四位,所以十六进制数的每两位占一个字节的内存空间。
//例如:这里的11,22,33,44分别各占一个字节
return 0;
}
在调试的过程中,我们会发现num中的0x11223344是以字节为单位在内存中倒着存储的,这是为什么呢?
2.1 什么是大小端
其实超过一个字节的数据在内存中存储的时候,就会有内存存储顺序的问题,对于不同的存储顺序,我们可以分为大端字节序存储和小端字节序存储。下面是具体概念:
大端存储模式:是指数据的低位字节内容保存到内存的高地址处,而数据的高位字节内容,保存在内存的低地址处。
小端存储模式:是指数据的低位字节内容保存到内存的低地址处,而数据的高位字节内容,保存到内存的高地址处。
注:这里的低位字节、高位字节可以理解为各进制下数字的权重,比如十六进制数123,3的权重是16^0,2的权重是16^1,1的权重是16^2,3的权重相对于1和2小,所以3相对于1和2就是低位字节内容 。
就比如说上面的0x11223344,它在win10,vs2022,x64环境下,在内存中存储的情况是:0x11223344中的44相较于11、22、33就是低位字节内容,储存到了内存的低地址处,如下图:
所以该储存模式是小端字节序储存。
2.2 为什么有大小端
这就有一个问题了:为什么会有大小端存储模式呢?
在我们的计算机系统中,中是以字节为单位的,每一个内存单元都对应一个字节,一个字节为8个bit位,但是我们C语言当中除了8bit位的char类型,还有16bit位的短整型short类型,也有32bit位的int类型,对于这些大于一个字节的数据,就存在着按照怎样的顺序将多个字节的数据存储起来,由此就导致了大端存储模式和小端存储模式。
那可能就有人问啦:那我们就整个行业规定一个顺序不就好了吗?
- 历史原因:不同厂商在早期设计计算机时,选择了不同的存储方式。
- 处理效率:某些操作在小端模式下可能更快,因为低位字节可以直接读取。
- 网络传输:网络协议通常使用大端模式,以便不同平台之间能正确理解数据。
2.3 练习
2.3.1 练习一
题目:请设计⼀个⼩程序来判断当前机器的字节序。
代码如下:(大家写的代码都不一样,小编在这献丑了)
#include <stdio.h>
int main()
{
int num = 0x00000011;
char* p1 = (char*)#
if (p1 > 0)
printf("小端字节序储存模式\n");
else
printf("大端字节序储存模式\n");
return 0;
}
2.3.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;
}
代码分析如下:
-1的原、反、补码如下:
- 10000000 00000000 00000000 00000001(-1的原码)
- 11111111 11111111 11111111 11111110(-1的反码)
- 11111111 11111111 11111111 11111111(-1的补码)
char a = -1;
- a是字符类型,总共就只占用内存1个字节,所以将整形的-1存放进a中的时候,就会发生截断,会将超出范围的高位字节部分丢弃,将低位一个字节的内容存放进字符变量a中,所以就将11111111存放进a中。
signed char b=-1;
unsigned char c = -1;
- 无论是有符号的字符类型,还是无符号的字符类型,总之都是字符类型,都占用内存一个字节的空间,所以与a同理,都将11111111存放其中。
printf("a = %d", a);
printf("b = %d", b);
- a的类型是char,char类型的符号性(有符号或无符号)取决于编译器的实现,小编用的编译软件是vs,在vs上 char == singed char。
- %d是整形的占位符。
- 对于a来说,本身是字符类型,有一个字节的内存空间,里面存放着11111111,现如今要将其计算成整形后打印出来,而整形有4个字节的空间,所以要对a的值进行拓展,因为a在vs中是有符号的,所以拓展的时候用符号位的值补在最前面,补够32个bit位即可。
- 拓展后:1111111 11111111 11111111 11111111
- 将补码转换成原码:10000000 00000000 00000000 00000001。
- 转换成十进制就是-1,并将其打印出来。
- b与a同理,最终将-1打印出来。
printf("c = %d", c);
- 对于c来说,它本身就是无符号的字符类型,对他里面存放的11111111进行扩展,只用在最前面补充0,使其达到32个bit位即可。
- 拓展后:00000000 00000000 00000000 11111111
- 无符号就相当于是正数,所以补码就等于源码,所以源码也是00000000 00000000 00000000 11111111
- 转换为十进制就是255,并将其打印出来。
2.3.3 练习三
第一个:
#include <stdio.h>
int main()
{
char a = -128;
printf("%u\n",a);
return 0;
}
代码分析如下:
-128的原反补码:
- 10000000 00000000 00000000 10000000(原码)
- 11111111 11111111 11111111 01111111(反码)
- 11111111 11111111 11111111 10000000(补码)
-128发生截断,存入a中的是 10000000
printf("%u\n",a);
- %u 是无符号整数的占位符。
- 站在%u的视角,a中存放的10000000就是无符号的类型,8个比特位全是数值位,没有符号位,所以10000000就被拓展成了:00000000 00000000 00000000 10000000
- 将其转换成原码:11111111 11111111 11111111 100000000
- 将原码转换为十进制 4294967168,并且将其打印出来
第二个:
#include <stdio.h>
int main()
{
char a = 128;
printf("%u\n",a);
return 0;
}
代码分析:(这道题其实和上面的第一个类似)
128的原、反、补码都是:00000000 00000000 00000000 10000000
128发生截断,存入a中的是 10000000
printf("%u\n",a);
- 站在%u的视角,a中存放的10000000就是无符号的类型,8个比特位全是数值位,没有符号位,所以10000000就被拓展成了:00000000 00000000 00000000 10000000
- 将其转换成原码:11111111 11111111 11111111 100000000
- 将原码转换为十进制 4294967168,并且将其打印出来
2.3.4 练习四
#include <stdio.h>
#include <string.h>
int main()
{
char a[1000];
int i;
for (i = 0; i < 1000; i++)
{
a[i] = -1 - i;
}
printf("%zd", strlen(a));
return 0;
}
代码分析如下:
在singed char中能存入的二进制数:
在上面的for循环中,程序会将11111111存入a[0]中,一次依次减一,存入a[i]中。当已经把00000000存入之后,程序会怎样呢?我们先来看一下存入00000000的过程,实际上是将11111111 11111111 11111111 00000000截断后的00000000存入到a[i]中,之后,程序对11111111 11111111 11111111 00000000进行减一,就变成了11111111 11111111 11111110 11111111,然后发生截断,就会将11111111存进a[i]中,我们发现又回到了存储的开头,周而复始,直到 i==1000 ,循环结束。
printf("%zd", strlen(a));
- strlen() 求的是字符串中 '\0' 之前的字符个数,而当循环进行256次时,就会将00000000存入到a[i]中,00000000对应的原码也是00000000,原码对应的十进制数是0,而 '\0' 对应的ASCll码值就是0,所以strlen()函数求出来的个数是255个。
2.3.5 练习五
第一个:
#include <stdio.h>
unsigned char i = 0;
int main()
{
for(i = 0;i<=255;i++)
{
printf("hello world\n");
}
return 0;
}
unsigned char的取值范围是0~255,所以 i 永远 <=255,所以程序会进入死循环,一直打印hello world。
在unsigned char中能存入的二进制数:
第二个:
#include <stdio.h>
int main()
{
unsigned int i;
for (i = 9; i >= 0; i--)
{
printf("%u\n", i);
}
return 0;
}
因为unsigned char的取值范围是0~255,所以 i 永远>=0 ,所以程序会进入死循环。
2.3.6 练习六
#include <stdio.h>
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 a[4] = { 1, 2, 3, 4 };
int *ptr1 = (int *)(&a + 1);
printf("%x", ptr1[-1]);
- &a:取出数组a的地址,此时&a的类型是 int (*) [4]。
- &a+1:给上一步取出的数组的地址加一,跳过整个数组(如下图),此时&a+1的类型依然是int(*)[4]。
- int *ptr1 = (int *)(&a + 1):然后将 &a+1 的类型转换成 int* 后赋值给 整形指针ptr1。
- printf("%x", ptr1[-1]):ptr1[-1] 可转换成 *(ptr-1)(如下图) ,找到 ptr1+1 所指位置,然后对其进行解引用操作,并输出将访问到的内容。
部分二:
int a[4] = { 1, 2, 3, 4 };
int *ptr2 = (int *)((int)a + 1);
printf("%x", *ptr2);
(int)a+1:数组名一般情况下表示首元素的地址,取出首元素的地址a,将其转换成整型类型后+1,然后有转换成整形的指针,所以 (int*)((int)a + 1) 是指针a向后跳过一个字节得到的,然后将其赋值给 ptr2,最后对ptr2进行解引用操作,并输出。
注:%x是十六进制整数的占位符。
由于是小端存储模式,所以解引用操作返回的是0x02000000.
3. 浮点数在内存中的存储
常⻅的浮点数:3.14、1E10等,浮点数家族包括: float(4Byte)、double(8Byte)、long(8Byte) 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;
}
运行结果如下:
要解决这个题目,就要知道浮点数在内存中是怎么存储的,所以,我们先将这道题留在这,我们了解了浮点数在内存中的存储的知识之后,再来回过头解决这道题。
3.2 浮点数的存储
上面的 n 和 *pFloat 明明是同一个值,可是为什么整型和浮点数类型的解读结果差异如此之大呢?
要理解这个问题,我们就一定要搞懂浮点数在计算机内部的表示方法:
根据IEEE(电⽓和电⼦⼯程协会)754规定,任意一个二进制的浮点数V可以表示成下面的形式:
- V = (-1)^S * M* 2^E
- (-1)^S:表示符号位。V为正时,S为0;V为负时,S为1。
- M:表示有效数字,它是大于一,小于二的。
- 2^E:表示指数位
例如:十进制数 5.5,用二进制表示为 101.1,用IEEE754就可以表示为 101.1 = (-1)^0 *1.011*2^2
所以,可以得出:S=0,M=1.011,E=2。
根据IEEE754规定:
- 对于32位的浮点数(float),最高的一位存储符号位 S,紧接着的8位存储指数位 E,剩下的23位存储有效数字 M。
-
- 对于64位的浮点数(double、long double),最高一位存储符号位 S,紧接着11位存储指数位 E,剩下的52位都存储有效数字 M。
-
3.2.1 浮点数存的过程
IEEE754 对有效数字 M 和指数位 E 还有一些特殊要求。
在前面提到过有效数字 M 是大于一,小于二的,所以 M 就可以写成 1.xxxxx 的形式,在计算机内部存储的时候,只存小数点后面的数字 xxxxx ,等到读取的时候再把前面的 1 加上。这样做可以节省一个bit位的存储有效数字的内存空间,比如32位的浮点数,有23个bit位可以用来存储有效数字 M,将第⼀位的1舍去以后,等于可以保 存24位有效数字。
对于指数 E,IEEE754规定为一个无符号整数。当E有8位时,E的取值范围是0~255;当E有11位时,E的取值范围是0~2047。但是我们知道,科学计数法中的E是可以出现负数的,那怎末办呢?IEEE754又规定啦,存⼊内存时E的真实值必须再加上 ⼀个中间数,对于8位的E,这个中间数是127;对于11位的E,这个中间数是1023。⽐如,2^10的E是 10,所以保存成32位浮点数时,必须保存成10+127=137,即10001001。
3.2.2 浮点数取的过程
指数E从内存中取出还可以再分成三种情况:
E不全我为0或者不全为1
这时,浮点数就采⽤下⾯的规则表⽰,即指数E的计算值减去127(或1023),得到真实值,再将有效 数字M前加上第⼀位的1。
E全为0
这时,浮点数的指数E等于1-127(或者1-1023)即为真实值,有效数字M不再加上第⼀位的1,⽽是还 原为0.xxxxxx的⼩数。这样做是为了表⽰±0,以及接近于0的很⼩的数字。
E全为1
这时,如果有效数字M全为0,表⽰±⽆穷⼤(正负取决于符号位s)。