深入剖析数据在内存中的存储

这是一篇朴实无华的C语言知识讲解文章,里面的每个点和题目都有我个人的一部分理解,你既可以把它当作知识的补充,也可以把它当作上课或者复习笔记,接下来我会随着自己的学习进度慢慢更新一些知识性的文章以及一些经典的笔试题目,如果你觉得写得不错可以点一波赞或者收藏一波哦!欢迎各位大佬在评论区批评指正!

1.数据类型介绍

基本的内置类型:

char               //字符数据类型
short              //短整型
int                //整型
long               //长整
long long          //更长的整型
float              //单精度浮点型
double             //双精度浮点型注:
复制代码

注:

1.在C语言中,字符类型在存储时存的是字符的ASCLL值,所以char类型往往也算整型中的一种;
2.C语言中并没有字符串类型,因为字符串类型是当作字符数组来处理的。

1.1 类型的基本归类:

整型家族:

char
unsigned char
signed char
short
unsigned short(int)
signed short(int)
int
unsigned int
signed int
long
unsigned long(int)
signed long(int)
复制代码

浮点数家族:

float
double
复制代码

构造类型:

> 数组类型
> 结构体类型 struct
> 枚举类型 enum
> 联合类型 union
复制代码

指针类型:

int *pi;
char *pc;
float* pf;
void* pv;
复制代码

空类型:

void 表示空类型(无类型) ;

通常应用于函数的返回类型、函数的参数、指针类型。 

2. 整形在内存中的存储

我们都知道,变量的创建是要在内存中开辟空间,而空间的大小是根据不同的类型而决定的,那数据在所开辟内存中到底是如何存储的?

比如:

int a=20;//为a分配4个字节的空间
int b=-10;//为b分配4个字节的空间
复制代码

那就不得不提到如下的几个概念了

2.1原码、反码、补码

计算机中的整数有三种表示方法,即原码、反码和补码

三种表示方法均有符号位和数值位两部分,符号位都是用0表示“正”,用1表示“负”,而数值位

的三种表示方法各不相同。

原码 :
直接将二进制按照正负数的形式翻译成二进制就可以。

反码:
将原码的符号位不变,其他位依次按位取反就行。

补码 :
反码加1就能得到补码。

注:正数的原、反、补码都相同。

举个例子:

#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
int main()
{
	//数据在内存中以二进制的形式存储,例如:
	int a = -10;
	//10000000000000000000000000001010---原码
	//11111111111111111111111111110101---反码
	//11111111111111111111111111110110---补码(反码+1)
	
	return 0;
}
复制代码

那么问题来了,数值在计算机中到底是以什么形式存储的,是原码、反码还是补码?

在计算机系统中,数值一律用补码来表示和存储。

原因在于,使用补码,可以将符号位和数值位统一处理; 同时,加法和减法也可以统一处理(CPU只有加法器);此外,补码与原码相互转换,其运算过程是相同的,不需要额外的硬件电路。

为了更清楚的理解数据的存储方式,我们来看下面几个例子:

例1:

图片中框起来的部分,就是int a=-10在内存中的存储形式,那么这个F6FFFFFF(十六进制)转化为二进制到底是a的原码还是反码还是补码呢?

#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
int main()
{
	//数据在内存中以二进制的形式存储,例如:
	int a = -10;
	//10000000000000000000000000001010---原码
	//11111111111111111111111111110101---反码
	//11111111111111111111111111110110---补码(反码+1)
	//FFFFFFF6(顺序和图中不一样问题,在下面2.2大小端的介绍中会说明)

	return 0;
}
复制代码

由此可见,整数在内存中存储的是补码

上面那个例子,让我们了解了整数在内存中是怎么以什么形式存储的,接下来我们来看看整数在计算机中到底是以什么方式运算的:

例2:

#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
int main()
{
	//1-1
	//1+(-1)
	//原码相加:
	//00000000000000000000000000000001--1的原码
	//10000000000000000000000000000001--负1的原码
	//10000000000000000000000000000010--结果为-2
	//错误

	//补码相加:
	//00000000000000000000000000000001--1的补码
	//11111111111111111111111111111111--负1的补码
	//100000000000000000000000000000000--结果为33位,多出1位
	//又因为二进制只能存32个比特位,所以多出来的1位就会放不下,丢了
	//00000000000000000000000000000000--最终结果:0
	//正确
	
	return 0;
}
复制代码

由上面的注释可知,只有用补码来进行计算的时候,结果才会结算得正确(合适)

现在让我们回过头来看看,一个正数和负数在内存中的存储:

#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
int main()
{
	int a = -10;
	//补码:11111111111111111111111111110110
	//FFFFFFF6
	int b = 10;
	//补码:00000000000000000000000000001010
	//0000000a
	
	return 0;
}
复制代码

补充:16进制与2进制的转换规则是4位一转换,8进制是3位一转换,4进制是2位以转换。一般地,有2^n进制与2进制的转换规律是n位一转换。

此时我们发现了另一个问题,为什么a和b存储的时候顺序都是反着存的?这些跟数据的大小端有关。

2.2 大小端字节序的介绍与判断

什么叫大小端:

大端(存储)模式,是指数据的低位保存在内存的高地址中,而数据的高位,保存在内存的低地 址中; 小端(存储)模式,是指数据的低位保存在内存的低地址中,而数据的高位,,保存在内存的高地 址中。

举个例子:

对于16进制数a,因为地址是从左往右由低到高变化

又因为44是数据的低位,11是数据的高位,存在地址中时44在左边,11在右边,相当于数据的低位被保存在内存的低地址中,数据的高位被保存在内存的高地址中,所以在当前的编译器中,采用的是小端(字节序)的存储模式。

那么为什么会有大小端之分呢?

这是因为在计算机系统中,我们是以字节为单位的,每个地址单 元都对应着一个字节,一个字节为8 bit。

但是在C语言中除了8 bit的char之外,还有16 bit的 short型,32 bit的long型(要看具体的编译器),另外,对于位数大于8位的处理器,例如16位 或者32位的处理器,由于寄存器宽度大于一个字节,那么必然存在着一个**如何将多个字节安排的问题(就是多个字节的数据如果存到内存当中,就会产生如何往里放的问题)。**因此就导致了大端存储模式和小端存储模式。

例如:一个 16bitshort 型的数x ,在内存中的地址为 0x0010x 的值为 0x1122 ,那么 0x11 为 高字节0x22 为低字节。对于大端模式,就将 0x11 放在低地址中,即 0x0010 中, 0x22 放在高地址中,即 0x0011 中小端模式,刚好相反。我们常用的 X86 结构是小端模式,而 KEIL C51 则 为大端模式。很多的ARMDSP都为小端模式。有些ARM处理器还可以由硬件来选择是大端模式 还是小端模式。

下面我们来看看一道笔试题:

百度2015年系统工程师笔试题:

请简述大端字节序和小端字节序的概念,设计一个小程序来判断当前机器的字节序。

分析举例:

题目中的意思就是让我们定义一个数,通过一串代码来判断该机器的数据存储模式是大端模式还是小端模式,比如定义一个数int a=1,这个数有4个字节,分别是00 00 00 01,主要的问题就是如何知道a的第一个字节的内容,因此有如下思路:

如果对a进行取地址之后,访问的第一个字节的内容是0,那么机器的存储模式就是大端模式(高位00存储在内存的低地址中),反之,若访问的第一个字节的内容是1,则机器的存储模式就是小端模式(低位01存储在内存的低地址中)

刚好我们学过char类型就是1个字节,对char类型的指针解引用后得到的值就是该数第一个字节的内容,则具体代码如下:

#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
int check_sys()
{
	int a = 1;
	char* p = (char*)&a;
	//将int*类型的&a强制类型转换为char*类型
	return *p;
	//*p是1的时候返回1,*p是0的时候返回0
}

int main()
{
	//写代码判断当前机器字节序
	int ret= check_sys(); ;
	if (ret == 1)
	{
		printf("小端\n");
	}
	else
	{
		printf("大端\n");
	}

	return 0;
}
复制代码

2.3练习

练习题1:

//输出结果是什么?
#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. unsigned char是无符号字节型,char类型变量的大小通常为1个字节(1字节=8个比特位),且属于整型。
  2. ****整型提升:C的整型算术运算总是至少以缺少整型类型的精度来进行的。 为了获得这个精度,表达式中的字符和短整型操作数在使用之前被转换为普通整型,这种转换称为整型提升,且整形提升是按照变量的数据类型的符号位来提升的。
  3. char到底是unsigned char(无符号)还是signed char(有符号),C语言标准并没有规定,取决于编译器,但是对于int来说,int就是signed(有符号),short就是signed short(有符号)。

输出结果:

解析如下:

练习题2:

//输出结果是什么?
#include<stdio.h> 
int main()
{
    char a = -128;
    printf("%u\n",a);
    //%u为输出无符号整数

    return 0;
}
复制代码

输出结果:

解析如下:

注释:从下往上依次为2进制、8进制、10进制、16进制

练习题3

//输出结果是什么?
#include<stdio.h> 
int main()
{
    char a = 128;
    printf("%u\n",a);

    return 0;
}
复制代码

输出结果:

跟上题的结果一样

解析如下:

补充:有符号char的取值范围**:-128~+127,上题中的128是因为截断了,只放进去了一部分值。**

比如说定义一个char a=129,129最后求出来的补码后八位(被截断后)是10000001,对char来说就相当于-1的值

有个巧计的办法:如果超出是数是正数,则减去256,如果超出的数是负数,则加上256

练习题4

//输出的结果是什么?
#include<stdio.h>
int main()
{
    int i= -20;
    unsigned  int  j = 10;
    printf("%d\n", i+j); 
    //按照补码的形式进行运算,最后格式化成为有符号整数(%d)

    return 0;
}
复制代码

输出结果:

老老实实按补码的形式进行运算就行,解析如下:

练习题5

//输出结果是什么?
#include<stdio.h>
int main()
{
    unsigned int i;
    for (i = 9; i >= 0; i--)
    {
        printf("%u\n", i);
    }

    return 0;
}
复制代码

输出结果(输出结果不对的可以前几步先自己一步一步编译,弄清规律):死循环

解析如下:

练习题6

//输出结果是什么?
#include<stdio.h>
#include<string.h>
    int main()
    {
        char a[1000];
        int i;
        for (i = 0; i < 1000; i++)
        {
            a[i] = -1 - i;
        }
        printf("%d", strlen(a));

        return 0;
    }
复制代码

输出结果:

解析如下:

练习题7

//输出结果是啥?
#include<stdio.h>
    int main()
    {

        for (i = 0; i <= 255; i++)
        {
            printf("hello world\n");
        }

        return 0;
    }
复制代码

输出结果(hello world的死循环):

这道题只要知道无符号数是怎么一回事,还是挺容易理解的,解析如下:

相信这几道题目弄清之后,我们会对整型家族在内存中的存储会有一个更加清楚的认识,那么接下来让我们来看看浮点型在内存中又是怎么存储的

3.浮点型在内存中的存储

常见的浮点数:

3.14159

 1e10 (1e是C语言中规定的浮点数的科学计数法,1e10表示1.0×10的10次方)

浮点数家族包括: float、double、long double 类型。

为了更深入了解浮点型在内存中的存储,接下来看一个例子:

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 浮点数存储规则

num*pFloat 在内存中明明是同一个数,为什么浮点数和整数的解读结果会差别这么大? 要理解这个结果,一定要搞懂浮点数在计算机内部的表示方法。

详细解读:

根据国际标准IEEE(电气和电子工程协会) 754,任意一个二进制浮点数V可以表示成下面的形式:

  • (-1)^S * M * 2^E
  • (-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。
  • M表示有效数字,大于等于1,小于2。
  • 2^E表示指数位。

举个例子:

十进制的5.0,写成二进制是 101.0 ,相当于(-1)^0*1.01×2^2 。

补充:(对于一个10进制数,1.01变成101要乘以10的2次方,以此类推,对于一个2进制数,1.01变成101就要乘以2的2次方);

那么,按照上面V的格式,可以得出s=0,M=1.01,E=2。

十进制的-5.0,写成二进制是 -101.0 ,相当于 (-1)^1*1.01×2^2 。那么,s=1,M=1.01,E=2。

IEEE 754规定: 

对于32位的浮点数(float),最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M。

对于64位的浮点数(double),最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。

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位有效数字。 

假如M存进去时不够23位,则在后面补上0就行。

至于指数E,情况就比较复杂:

首先,E为一个无符号整数(unsigned int),

这说明:

如果E为8位,它的取值范围为0~255;如果E为11位,取值范围为0~2047

但是,我们知道,科学计数法中的E是可以出现负数的(例如2的负1次方,此时E就等于负1),所以IEEE 754规定,存入内存时E的真实值必须再加上一个中间数对于8位的E,这个中间数是127对于11位的E,这个中间数是1023。比如,2^10的E是10,所以保存成32位浮点数时,必须保存成10+127=137,即10001001。

什么意思呢?我们来看下面这个例子:

例题:

#include<stdio.h>
int main()
{
    float f = 5.5;
    //浮点数f在内存中是什么样的?

    return 0;
}
复制代码

浮点数f在内存中是什么样的?,一步一步来看:

如图:

上面展示的是浮点数在内存中的存储情况,然后,指数E内存中取出还可以再分成三种情况

1.E不全为0或不全为1

这时,浮点数就采用下面的规则表示,即指数E的计算值减去127(或1023),得到真实值,再将有效数字M前加上第一位的1。 

比如: 0.5(1/2)的二进制形式为0.1,由于规定正数部分必须为1,即将小数点右移1位,则为 1.0*2^(-1),其阶码为-1+127=126,表示为01111110,而尾数1.0去掉整数部分为0,补齐0到23位00000000000000000000000,则其二进制表示形式为:

0 01111110 00000000000000000000000

2.E全为0

这时,浮点数的指数E等于1减127(或者1减1023)即为真实值, 有效数字M不再加上第一位的1(因为真实数太小),而是还原为0.xxxxxx的小数(规定)。这样做是为了表示±0(表示无限接近与0的正数和负数),以及接近于0的很小的数字。 

3.E全为1

这时,如果有效数字M全为0,表示±无穷大(正负取决于符号位s);

好了,关于浮点数的表示规则,就说到这里,让我们再回头看看前面那道令人怀疑的题目:

解释前面的题目:

#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;
}
复制代码

我们一步一步来:

首先是n的值:

这个好解释,因为int n=9,是整型,而且输出的形式是%d,所以输出结果自然是9

再来看*pFloat的第一个值:

float *pFloat = (float *)&n;
复制代码

这行代码的意思是,取整型n的地址,强制转换为float类型的地址(内容不变),然后用float类型的指针pFloat来接收

printf("*pFloat的值为:%f\n",*pFloat);
复制代码

接收之后解引用,因为是以%f (小数) 的形式输出,所以之前整型9的二进制序列:
0000 0000 0000 0000 0000 0000 0000 1001
就会被认为成一个浮点数,那么就要按照我们前面讲的计算浮点数真实值的方法来求:
0 00000000 00000000000000000001001
则S=0,E=0,M=0.00000000000000000001001(E为全0时,M前面补0)
所以用浮点数来表示就是:0.00000···01001*2^(-126), 无限接近0,又因为**%f只能打印小数点后6位**,所以最后输出的结果就是0.000000

然后再来看num的值:

*pFloat = 9.0;
复制代码

9.0为浮点数,存放在浮点型指针里,要以写成科学计数法再存进去,所以
9.0写成二进制:1001.0 = (-1)^0*1.001*2^3,S=0,M=001,E=3+127=130
所以表示为二进制:0 10000010 00100000000000000000000
又因为最后是以%d(整型)的形式输出,该二进制数符号位是0,是正数,所以原码等于补码
所以之间把该二进制数转化为10进制打印处来,最后的结果就是:

最后再来看*pFloat的第二个值:

printf("*pFloat的值为:%f\n",*pFloat);
复制代码

这个解释起来就很简单了,定义的*pFloat = 9.0是小数,输出也是以%f(小数)的形式输出,所以最后输出的结果就是朴实无华的9.000000

通过这些解释是不是感觉恍然大悟了呢?

这是博主写的第一篇博客,内容有点多,也花了博主几天的时间,如果你觉得写得不错,或是多多少少对你有些帮助,那么可以点赞支持一波,同时也欢迎大佬在评论区批评指正,我们下次再见!

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值