前面的文章中,我们也有提过,一个变量由两部分组成,一部分是变量的类型,另一部分是数据的值,变量的类型决定了申请该变量时向内存申请的空间以及他们储存在内存中的方式,本文就深度剖析这些数据是如何在内存中存放的以及他们存放的规则。
目录
前言
Don't hide your enthusiasm for things.
不要掩饰自己对追求的热情。
Never be ashamed of trying .
不要为自己的努力而感到羞愧。
Effortlessness is amyth.
一蹴而就就是天方夜谭。
-----Taylor Swift
一. 数据类型
C语言的类型可用下图概括:
- 整型家族:
char
unsigned char
signed char
short
unsigned short
signed short
int
unsigned int
signed int
long
unsigned long
signed long
long long
unsigned long long
signed long long
注:char类型实际上是属于整型家族中的一员,储存的本质是整型,只是通过ASCII表按照一定的打印格式打印出对应的字符。
- 浮点型家族:
float
double
- 构造类型:
数组类型
结构体类型
联合体类型
枚举类型
- 指针类型:
char* p
int* p
float* p
double* p
......
- 空类型:
void
本文主要介绍基本数据类型,即整型家族和浮点型家族成员。
二.整型在内存中的存储
1.原码、反码以及补码
实际上,在计算机底层储存数据中,使用的都是二进制数字,计算机储存一个数字时,并不是直接储存进去的,而是先将该数字转换为二进制码,然后再储存进内存中。我们也称这些二进制码为机器数,机器数也是分正负的,通常规定机器数的最高位代表数字的正负,0代表正,1代表负,例如,某个char类型的变量在内存中储存的是 1111 1011(反码),它的真值是-5。
原码
将一个十进制的值转换为二进制的值,最高位为符号位(1负0正)
反码
根据正负有两种情况
正数:与原码相同
负数:将原码的符号位不变,其他位按位取反即得到反码
补码
根据正负有两种情况
正数:与原码相同
负数:反码+1
根据以上定义可总结为:正数的原码反码补码都相同,负数将原码除符号位按位取反得到反码, 将反码加一得到补码。
拓展:
了解了以上的定义,我们思考一下,为什么计算机设计原码反码补码呢,直接用二进制加减不更好么,实际上,计算机并不像我们人脑一样,可以通过正负号(+-)一眼看出数据的正负,为了让计算机底层逻辑更加简单,科学家们开始探索让符号位加入运算,并只保留了加法,将减法转换为加法,即1-1转换为1+(-1),这样让计算机的运算变得更加简单了,并且让符号位也加入了运算中。
2.大小端的介绍
首先我们通过编译器观察数据在内存中的储存情况,如下图。
说明:以上图是在VS2022编译器下调试出来的结果,红色方框圈起来的分别是这两个变量在内存中存放的数据,为了显示方便,数据是以十六进制的方式显示出来的。用下图来理解以上调试内容可能更简单。
有些读者看到这就觉得不对劲了,文章刚才不是说整型数据在内存中不是以二进制补码存放么,为什么会完全不同呢,比如a变量
图中为:
0000 0011 0000 0000 0000 0000 0000 0000
而实际应该为:
0000 0000 0000 0000 0000 0000 0000 0011
细心的小伙伴可能发现了,这两种表示方式是反着的,即a变量的第一个字节和第四个字节是倒着存放的,没错,这就是我们接下来要讲述的大小端问题。
- 大端储存方式:数据的低位保存在内存的高地址中,而数据的高位,保存在内存的低地址中。
- 小端储存方式:数据的低位保存在内存的低地址中,而数据的高位,保存在内存的高地址中。
解读:大家对地址的高低可能不陌生,可能有些小伙伴对数据的高低位有些不熟悉,比如十进制数字123,该数据的最低为个位上的3,最高位为百为上的1,二进制也同样如此,也有高低位,接着我们可以看到上面的倒置并非将所有位倒置,而是按照字节(8个比特位)倒置,字节内部并不会发生改变。
再这么看上面的储存方式就显得合理了很多,不难看出,我使用的VS2022编译器使用的是小段储存方式。
3.实战突破
百度2015年系统工程师笔试题:
请简述大端字节序和小端字节序的概念,设计一个小程序来判断当前机器的字节序。
思路:创建一个整型变量并将其赋值为1,接着我们用char类型的指针指向该整型,因为对char类型的指针解引用一次只会读取一个字节,因此,如果该指针解引用如果结果为1,说明当前编译环境为小端字节序,反之为大端字节序。
void checkSystem()
{
int a = 1;
char* pc = &a;
if (*pc)
{
printf("小端字节序");
}
else
{
printf("大端字节序");
}
}
int main()
{
checkSystem();
return 0;
}
观察以下程序,写出下列程序的输出结果。
//exe1
#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;
}
仔细分析上题,a和b均为有符号字符型,c为无符号字符型,将一个整型赋值给字符型时,会发生截断,又因为数据都是以补码的形式存放,因此a和b在内存中存放的是8个1,依次类推,c中也存放的是8个1,而当我们以十进制有符号形式(%d)输出有符号字符型的时候,a和b因为是有符号字符型,因此a和b输出的结果均为-1,而c为无符号字符型,8个全1的二进制值为255。
//exe2
#include <stdio.h>
int main()
{
char a = -128;
printf("%u\n",a);
return 0;
}
通过分析题目,我们可以看到a是一个有符号的字符型,在将-128存入a中时,会发生截断,实际存入内存中的是1000 0000,当我们将它看作无符号十进制打印时,发生整型提升,左边补符号位,也就是1111 1111 1111 1111 1111 1111 1000 0000。我们通过计算器算出结果为4,294,967,168。
//exe3
#include <stdio.h>
int main()
{
char a = 128;
printf("%u\n",a);
return 0;
}
观察分析此题,其实与上题类似,我们将128截断存入a中后,发现仍然是1000 0000,把它看作无符号整型发生整形提升,最高位为1,因此高位补1,最终结果与上题相同,都是4,294,967,168。
//exe4
#include <stdio.h>
int main()
{
int i= -20;
unsigned int j = 10;
printf("%d\n", i+j);
return 0;
}
在之前的操作符讲解中,我们提到过,当两个不同类型的数据进行运算式,会发生运算转换,这里当i和j相加时,i会转换为无符号整型,再与j相加,最终结果和运算过程如下。
//exe5
#include <stdio.h>
int main()
{
unsigned int i;
for (i = 9; i >= 0; i--)
{
printf("%u\n", i);
}
return 0;
}
观察题目,我们发现i是无符号字符型,而无符号的字符型的取值范围是0到255,因此无论怎么减都无法减到负数,可以用下图理解,当加到1111 1111时,再加1就回到了0,因此不可能小于0,所以以上会造成死循环。
//exe6
#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;
}
经过分析题目,我们可以看到a是一个有符号字符数组,通过循环将数据存放进字符数组中,strlen时统计字符串中的字符个数,其底层原理时数 '\0' (0) 前的字符个数,我们回到上题中的图,-1也就是图中的1111 1111,我们不断减1则是按照逆时针的方向存入数据,不断的减一后最终可以来到0,0前字符个数也就是我们的最终结果255。
//exe6
#include <stdio.h>
unsigned char i = 0;
int main()
{
for (i = 0; i <= 255; i++)
{
printf("hello world\n");
}
return 0;
}
分析题意后,上面我们提到过,无符号字符型的取值范围为1到255,而题目中的循环条件则是大于255才结束,因此,这里会造成死循环打印。
三.浮点型在内存中的存储
1.浮点家族的分类
浮点家族可分为以下几种:
float
double
long double
我们先看以下程序,分析输出结果:
#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;
}
以下为输出结果:
对于第二行和第三行的输出结果想必大家都会有一些疑惑,这是因为浮点数的储存方式造成的差异,在学习接下里的内容后大家的疑惑都会一一解开。
2.浮点数的储存规则
根据国际标准IEEE(电气和电子工程协会) 754,任意一个二进制浮点数可以表示成下面的形式:
(-1)^S * M * 2^E
- (-1)^S表示符号位,当S=0,浮点数为正数;当S=1,浮点数为负数。
- M表示有效数字,大于等于1,小于2。
- 2^E表示指数位。
例如:十进制浮点数5.5转化为二进制浮点数为101.1,按照以上规则来表示则,S=0,M=1.011,E=2,即(-1)^0*1.011*2^2
IEEE 754规定: 对于32位的浮点数,最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M。
对于64位的浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。
几个特别规定:
- 除了1 ≤ M≤<2的规定以外,我们在储存M时可以去掉小数点前面的1,因此每个M的小数点前的取值必定为1,因此没有储存的必要,我们通常储存小数点后面的数据,比如前面的1.011,我们通常储存后面的011即可这样做可以储存更多位,提高精度。
- 关于指数E情况较为复杂,首先我们知道E是一个无符号整型,如果E的位数为8,那么E的取值范围为(0~255),如果E的位数为11位,取值范围则为(0~2047),但是科学计数法中E可能为负值,因此我们在存入真实E时,我们需要加入一个中间值,E的位数为8时,这个中间值为127,E位数为11时,这个中间值为1023。因此在取出E时,可能会有以下3中情况:
(1)E不全为0或不全为1
此时E的真实值为E的计算值减去127(或1023),在将有效数字M前加上一个1。
(2)E全为0
这时,E的真实值等于1-127(或1-1023),有效数字M前这时不会加上一个1,而是加上一个0,这样做是为了表示该值无线接近于0。
(3)E全为1
这时,如果有效数字M全为0,表示+/-无穷大。
根据以上理论我们首先验证内存中是否真的按照以上规则来储存数据的,我们首先验证浮点数5.5,推理过程如下图:
右边的窗口为内存窗口,我们在其中查询到f中储存的数值为00 00 b0 40,我们通过验算,结果果真如此,且按照小端字节序来进行储存的,通过以上规则也可分别推演除本部分第一个程序的结果。