【C语言】数据在内存中的存储(万字解析)

文章目录

一、大小端字节序和字节序判断

1.案例引入

在讲解大小端字节序之前,我们先来调试一段代码,来看看一些特别的东西:

c 复制代码
#include <stdio.h>

int main()
{
	int a = 0x11223344;
	//整型a存放16进制数
	return 0;
}

我们来调试一下,打开内存窗口,查看a在内存中的存储形式:

调试的时候,我们可以看到在a中的 0x11223344 这个数字是按照字节为单位,倒着存储的,这是为什么呢?这时就要引出我们的大小端字节序了

2.什么是大小端字节序

其实超过⼀个字节的数据在内存中存储的时候,就有存储顺序的问题,按照不同的存储顺序,我们分为⼤端字节序存储和⼩端字节序存储,下⾯是具体的概念:

  1. 大端存储模式:是指数据的低位字节内容保存在内存的⾼地址处,⽽数据的⾼位字节内容,保存在内存的低地址处
  2. 小端存储模式:是指数据的低位字节内容保存在内存的低地址处,⽽数据的⾼位字节内容,保存在内存的⾼地址处

是不是有点懵,我们就以上面的那个图来举个例子,来说明什么是低位字节内容,哪里又是低地址:

在这里我们可以看出,VS是把高字节数据放在了高地址,把低字节数据放在了低地址处,所以VS中采用了小端字节序的存储方式

而它看起来倒起来了是因为内存中的地址是由低到高,而一个数字是从高位到低位书写,所以看起来是倒着的

3.大小端字节序判断

那么我们该怎么判断当前机器采用的字节序呢?这也是百度的一道笔试题,占据了10分,接下来我们就举一个例子来说明

我们创建一个a变量,让它存放1,来尝试思考一下它分别在大端和小端的存储在内存中的样子,如下:

c 复制代码
//大端:
0x 00 00 00 01
c 复制代码
//小端:
0x 01 00 00 00

我们可以发现在存放1时,大端字节序的第一个字节存放的是0,而小端字节序的第一个字节存放的就是1,那我们能否拿到这个整型的第一个字节呢?

有经验的同学肯定想到了,我们可以创建一个指针变量存放a的地址,然后强制类型转换为字符指针,然后对它解引用,就可以只访问一个字节的内容,然后对解引用的内容进行判断,如果是0那么就是大端字节序,如果是1就说明是小端字节序

听起来是不是很简单呢?我们来实践一下:

c 复制代码
#include <stdio.h>

int main()
{
	int a = 1;
	char* p = (char*)&a;
	if (*p == 0)
		printf("大端字节序\n");
	else
		printf("小端字节序\n");
	return 0;
}

我们来看看运行结果:

可以看到运行结果跟我们分析的一样,VS确实采用的是小端字节序存放数据

二、整数在内存中的存储以及相关练习

1.整型在内存中的存储

在讲解操作符的时候,我们就讲过了下⾯的内容:

整数的2进制表示方法有三种,即 原码、反码和补码

有符号的整数,三种表示方法均有符号位和数值位两部分,符号位都是用0表示"正",用1表示"负",最⾼位的⼀位是被当做符号位,剩余的都是数值位,接下我们来看看它们的特点:

  • 正整数的原、反、补码都相同
  • 负整数的三种表示方法各不相同
  • 原码:直接将数值按照正负数的形式翻译成⼆进制得到的就是原码
  • 反码:将原码的符号位不变,其他位依次按位取反就可以得到反码
  • 补码:反码+1就得到补码

我们之前也讲过,对于整型来说,在内存中其实存放的是补码,为什么呢?

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

正整数的原反补码相同,这里就不再举例说明了,我们再复习一下负整数转为补码的过程,就以-5举例:

c 复制代码
原码:10000000 00000000 00000000 00000101

反码:除了符号位,对原码其它位全部取反

c 复制代码
11111111 11111111 11111111 11111010

补码:反码+1,是计算机内部存储整型的形式

c 复制代码
11111111 11111111 11111111 11111011

2.练习

练习1:

试着计算以下代码的运行结果:

c 复制代码
#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被赋值为整型-1,所以我们首先要求到-1的补码,然后对其进行截断,截断为1个字节,就是字符a真正的值,而截断的知识在操作符中已经讲过,忘记了的可以自行翻阅,我们现在开始处理a:

c 复制代码
-1
原码:10000000 00000000 00000000 00000001
反码:11111111 11111111 11111111 11111110
补码:11111111 11111111 11111111 11111111

此时由于a是字符型,所以要截断前三个字节,留下最后一个字节,所以a最后就是这个:

c 复制代码
11111111

由于char默认就是signed char,也就是有符号的char,所以这样最高位的1是符号位,此时a存放的是11111111

然后我们来看b,b是一个有符号char,跟我们上面的a一模一样,所以它存放的也是11111111

最后我们来看c,c是一个无符号char,却被赋值了有符号的-1,该怎么计算呢?这个就要看我们的赋值运算符的结合性了,在外面操作符那里也讲过,它是从右向左计算的,如图:

也就是会先计算赋值运算符右边的东西,然后再将结果赋值给左边,所以在这里我们还是要先看右边,不看左边的无符号char,我们通过上面的计算,知道了整型-1的补码为:

c 复制代码
11111111 11111111 11111111 11111111

这个时候我们要赋值给无符号char,首先截断为:

c 复制代码
11111111

计算到这里后,我们知道了c里面也是11111111,但是由于赋值给了无符号char,所以这里所有位置都是数值位,不含符号位

但是问题又来了,最后打印时居然用的%d,%d表示打印有符号整型,%u是无符号整型,这里统一用的%d,以有符号整型打印abc,但是abc是字符型啊,怎么办?当然是要进行整形提升了,提升到整形一样的4个字节

整形提升的规则我们也讲过,就是有符号数高位补符号位,直到4个字节,无符号数就高位全部补0,凑齐4个字节

现在我们就对a进行整型提升,按符号位补齐4个字节:

c 复制代码
11111111 11111111 11111111 11111111

这里得到的是补码,转换为原码为:

c 复制代码
10000000 00000000 00000000 00000001

这就是得到的最后的答案,以%d的形式打印a结果为-1,由于b和a一样,所以打印的结果也是-1

最后来看c,先对c进行整型提升,由于c是无符号char,所以高位补0,补齐4个字节,如下:

c 复制代码
00000000 00000000 00000000 11111111

经过整型提升后我们可以看到它变成了一个符号位为0的正整数,由于正数的原反补码相同,所以这就是它的原码,计算出来为正的255

通过我们的不懈努力终于将它分析清楚了,a和b打印出来是-1,而c打印出来是255,接下来我们来看看代码运行的结果:

练习2

试着计算以下代码的运行结果:

c 复制代码
#include <stdio.h>

int main()
{
	char a = -128;
	char b = 128;
	printf("a = %u, b = %u\n", a, b);
	return 0;
}

经过练习1的练习,我们现在应该已经会做一点类似的题了,我们再拿相似的练习2来练练手

首先我们来看a,要计算出-128的补码,这里直接给出转换过程,最好自己计算一遍,不要直接看答案:

c 复制代码
原码:10000000 00000000 00000000 10000000 
反码:11111111 11111111 11111111 01111111
补码:11111111 11111111 11111111 10000000

随后进行截断,变成:

c 复制代码
10000000

然后我们来看b,128是个正数,所以原码就是补码,如下:

c 复制代码
补码:00000000 00000000 00000000 10000000

经过截断后变成:

c 复制代码
10000000

可以发现a和b存放的都是10000000,由于要以%u的形式打印,也就是无符号整型的方式打印,所以首先要进行整型提升,如下:

c 复制代码
11111111 11111111 11111111 10000000

由于以无符号整型的形式打印,所以这里的所有位都表示数值位,有点大,所以我们可以将其复制进电脑的计算器来计算,如下:

由于a和b经过截断和整型提升都是一样的,所以答案应该也是一样,接下来我们就来运行一下程序,看看和我们计算器算出的答案是否一致,如下:

可以看到完全一致,所以我们的分析是正确的

练习3

试着计算以下代码的运行结果:

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

这道题有一些难度,要了解一些关于char的知识,char默认为有符号char,它的取值范围为 -128 ~ 127 ,其中-128是人为规定的,因为在计算机中有这么两个特殊的数:

c 复制代码
00000000
10000000

按照原本的计算方式计算,就会发现前面是0,后面那个是-0,但是由于并不存在-0,所以人为规定为了-128

随后我们来做这个题,这个题的关键在于那个strlen(a),我们知道strlen计算字符串长度时的结束条件是碰到\0时,统计\0前的字符的个数

而\0的值实际是0,所以这道题的关键就在于经历多少个字符之后,数组中出现了0,算出0之前出现的字符的个数

我们现在就开始分析,当i=0时,a[0]就是-1,当i=1,a[1]就是-2,依次类推,我们来找一个分界点,没错就是-129,-1到-128一共128个数都是char能存放的,而-129就超出了边界

接下来我们来求-129的补码:

c 复制代码
原码:10000000 00000000 00000000 10000001
反码:11111111 11111111 11111111 01111110
补码:11111111 11111111 11111111 01111111

经过截断后变成了:

c 复制代码
01111111

是不是非常神奇,居然直接变成了127,也就是我们可以记住,在char中-128再-1就变成了127,

然后我们就又这样推下去,从127慢慢开始减1,如:126,125,124······,那么这段数据的关键在哪里呢?没错,就在0这里,经过-1操作,最后会变成0

而strlen碰到这个0就结束计算了,那我们来看看这个0之前有多少个数,首先是从-1到-128,一共128个数,然后就是127到1,一共127个数,注意不会算上0,所以一共就是128+127=255个数

我们来看看运行结果,看看我们分析的是否正确:

可以看到跟我们预想的完全正确

在解决这道题之后,我们可以来总结一下char的规律,刚刚我们是每个数慢慢减1,产生了轮回,那如果我们从0开始一直加1会产生什么循环呢?如图:

可以看到,只要从1开始,一直加1,就会按照上图的形式,用顺时针的方式构成轮回,而我们上面做的那个题就是从-1开始,一直减1,就会以逆时针的方式构成轮回

练习4

试着计算以下代码的运行结果:

c 复制代码
#include <stdio.h>
unsigned char i = 0;
int main()
{
 for(i = 0;i<=255;i++)
 {
 printf("hello world\n");
 }
 return 0;
}

这里会打印多少个hello world呢?是不是256个呢?很明显不会这么简单,我们来分析一下,unsigned char是无符号char它的范围是0~255

前面都没有问题,问题在于,当i=255时,也符合条件,会打印一次hello world,然后i+1后变成了256,但是256超出了unsigned char的范围,所以我们要来计算一下256如果放入unsigned char是多少:

c 复制代码
原码:00000000 00000000 00000001 00000000

经过截断后:

c 复制代码
00000000

我们发现居然将256放入unsigned char后,变成了0,此时i就重新变成了0,又开始循环,到下一个256又变成0,又循环,周而复始,造成了死循环

我们来看看运行结果:

果然是死循环的打印hello world

练习5:

试着计算以下代码的运行结果:

c 复制代码
#include <stdio.h>

int main()
{
	unsigned int i;
	for (i = 9; i >= 0; i--)
	{
		printf("%u\n", i);
	}
	return 0;
}

这道题和上一道题很相似,它的关键在于i=0时,进入循环打印了i,然后减1,变成了-1,但是超过了无符号char的范围,所以要重新计算:

c 复制代码
原码:10000000 00000000 00000000 00000001
反码:11111111 11111111 11111111 11111110
补码:11111111 11111111 11111111 11111111

经过截断后:

c 复制代码
11111111

由于i是无符号char,没有符号位,全部是数值位,所以就变成了255,所以我们知道了,当i到达-1时会变成255,然后又开始循环,直到又到达-1,又变成255继续循环,构成了死循环,我们来看看运行结果:

数字这么大的原因是采用了%u的形式进行打印,会有整型提升,可以自己尝试整形提升后是否是这些值

经过上面两个题的练习,我们基本应该了解了一个东西,就是无符号整型的范围是0~255,如果使用循环时超出这个范围,可能就会经过一直截断,导致造成死循环,有符号整型也是如此,不能超出范围,否则会一直截断,一直循环

练习6

我们来看关于整型存储的最后一个练习:

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

这道题有点难度,它结合了我们指针的知识,但是我们已经刷了很多指针题了,这道题也是出自我们练过的某题,如果还没有刷过指针的题,看以参照我的博客:【C语言】手把手带你拿捏指针(完)(指针笔试、面试题解析)

这道题要求的是x86环境下,并且是小端字节序,我们的VS都符合这个要求,这个题最好画图求解,如下图:

把图画出来了这道题就比较简单了,首先这里是用%x方式打印,也就是打印16进制,ptr1[-1]相当于就是*(ptr1 - 1),这里ptr1是整型指针,所以它会指向最后一个整型,如图:

所以第一个ptr[-1]打印出来就是4,我们接着来看第二个,这里是对ptr2解引用,ptr2解引用后访问4个字节,也就是上图的00 00 00 02,由于是小端字节序,所以第二个里面存放的是02 00 00 00,由于这本身就是16进制,所以打印出来就是2000000

我们来看看最后的运行结果:

与我们分析的别无二致

三、浮点数在内存中的存储

1.案例引入

常见的浮点数:3.14159、1E10等,浮点数家族包括: float、double、long double 类型。浮点数表⽰的范围: float.h 中定义

我们用一个案例来分析引入一下:

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

(1)首先第一个printf是正常打印9,就不再说了

(2)然后我们来看第二个printf,我们就要来看一下pFloat是什么,pFloat是int的地址强制类型转换成float的地址,所以现在*pFloat就是以浮点型float的角度来解引用n,但是我们不知道浮点型是怎么存储的,就不知道结果,就暂时放在这里,等我们后面讲完再来计算

(3)接着我们来看第三个printf,上面通过对pFloat解引用,以浮点型的角度来把n改成了9.0,所以这里我们要探究的是通过浮点型角度更改整型是什么样子的,后面我们讲到了再回来计算

(4)最后来看最后一个printf,上面通过对pFloat解引用,以浮点型的角度来把n改成了9.0,此时站在浮点型的角度对它进行打印,必然就能打印出来9.0的效果,这个稍加分析就可以得到

所以经过我们的分析,发现了第二个和第三个printf的结果还是未知的,接下来我们就来学习浮点数在内存中的存储,然后再回来解决这两个问题

2.浮点数在内存中的存储规则

浮点数在内存中的存储有一个公式,这个公式类似于我们平常学的10进制的科学计数法,我们举一个10进制的科学计数法,如下:

c 复制代码
19971400000000=1.99714×10^13
//其中的小数的范围是大于等于1,小于10

只是我们马上学习的公式是2进制版的,接下来我们会采用类比的方法来学习,这样比较好理解,现在就让我们正式开始学习:

根据国际标准IEEE(电⽓和电⼦⼯程协会) 754,任意⼀个⼆进制浮点数V可以表示成下面的形式:

我们一个一个来分析浮点数V在存储时,公式中的三个参数S、M、E,以及底数2的含义

  1. S:公式中的S用于控制这个浮点数的正负性,当S=0时,浮点数V为正数,当S=1时,浮点数V为负数
  2. M:公式中M是一个大于等于1,小于2的有效数字,它类似于10进制科学计数法中的小数,如:19971400000000=1.99714×10^13中的1.99714这个小数,而十进制中这个小数范围是大于等于1,小于10,我们二进制就是大于等于1,小于2
  3. E:公式中的E表示指数,类似于10进制科学计数法中的那个指数,如:19971400000000=1.99714×10^13中的次方13,而十进制中的底数是10,二进制中的底数是2

现在我们举一个例子来更好的说明,比如浮点型数字5.0,将其转换为2进制为101.0

按照上面我们讲的公式套的话就是:

其中我们的S为0,表示该浮点数是一个正数,M为1.01,是用来表示该浮点数的有效数字,最后就是E为2,表示我们的指数,也就是我们小数点移动了几位,在这里就是小数点移动了2位,通过乘以2的2次方可以还原回去

然后我们再次和十进制的科学计数法作类比:

c 复制代码
19971400000000=1.99714×10^13

其中的13就是指数,用来表示小数点移动了多少位,这里就是小数点移动了13位,所以可以通过乘以10的13次方就可以让那个小数还原

通过类比是不是就感觉好理解多了呢!

接下来我们继续学习,在存储浮点数时,SME这三个重要参数分别占的空间大小:

IEEE 754规定:

  • 对于float类型的32位的浮点数,最⾼的1位存储符号位S,接着的8位存储指数E,剩下的23位存储有效数字M,如图:

  • 对于double类型的64位的浮点数,最⾼的1位存储符号位S,接着的11位存储指数E,剩下的52位存储有效数字M,如图:

3.浮点数存的过程

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

(2)⾄于指数E,情况就⽐较复杂,⾸先,E为⼀个⽆符号整数,这意味着,如果E为8位,它的取值范围为0 ~ 255;如果E为11位,它的取值范围为0 ~ 2047

但是,我们知道,科学计数法中的E是可以出现负数的,例如2的-1次方,这里的E就是-1,所以IEEE 754规定,存⼊内存时E的真实值必须再加上⼀个中间数:

对于8位的E,取值范围为0 ~ 255,这个中间数是127;对于11位的E,取值范围为0 ~ 2047,这个中间数是1023

⽐如,2 ^ 10的E是10,所以保存成32位浮点数时,必须保存成10+127=137,即10001001,而2 ^ -1的E是-1,所以保存为32为浮点数时,必须保存为-1+127=126,即01111110

4.浮点数取的过程

指数E从内存中取出还可以再分成三种情况:

(1)加上中间值后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,则其⼆进制表示形式为:

c 复制代码
 0 01111110 00000000000000000000000
 S     E           M

此时的E不为全0或全1,是126,所以在取出时就要重新给E减去中间值127,然后把M去掉的整数1补回来

(2)加上中间值后E全为0

我们知道32位的中间值是127,E加上了127后还是全0,说明此时原本的E,也就是真实值就是-127,想象一下2^(-127),是一个非常非常小的数,无限接近于0了,然后再乘以我们的M也无济于事

所以此时有效数字M不再加上第⼀位的1,⽽是还原为0.xxxxxx的⼩数。这样做是为了表⽰±0,以及接近于0的很⼩的数字

(3)加上中间值后E全为1

我们知道32位的中间值是127,E加上了127后是全1,也就是255,它原本的E,也就是真实值就是128,说明这是一个无限接近于无穷大的情况

所以如果有效数字M全为0,表⽰±⽆穷⼤(正负取决于符号位s)

5.案例最后解析

最后我们知道了浮点数的存储,我们再次把1中的案例拿过来练练手:

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

我们来看第二个printf,现在我们尝试着以浮点型的角度来解析n,首先我们写出n的补码,n是正数,原码就是补码,如下:

c 复制代码
00000000 00000000 00000000 00001001

然后以浮点型的角度来重新把它拆解一下,如下:

c 复制代码
0 00000000 00000000000000000001001
S    E               M

我们上面讲过,如果E为全0,说明这个数非常非常的小,所以在还原时,就不会还原M前面的整数1,现在我们有了SME,带入我们的公式试试看这个数是多少:

c 复制代码
(-1)^0 * 0.00000000000000000001001 * 2^(-127)

我们可以看到这里的M已经非常小了,往后数6位小数点都看不到一个1,何况后面还要乘以2的-127次方,乘下来会非常非常小,在碰到1前会有100多个0,小数点后100多个0,可以想象出来有多小,无限接近于0,所以在打印时,就会显示0

我们来看第三个printf,首先我们就要算出上面以浮点型角度往n存放了一个9.0,我们来看看9.0存储到内存是什么样子的,主要是求SME,首先将9.0转为二进制:

c 复制代码
1001

然后写出我们的SME

c 复制代码
S:0
M:1.001
E:3

我们求出来E是3,加上中间值127就是130,所以我们写出E的八位二进制应该为:

c 复制代码
10000010

所以最后浮点数9.0在内存中存储的二进制序列为(注意存放顺序为SEM):

c 复制代码
0 10000010 00100000000000000000000
S    E             M

现在我们要以整型的角度来看待这段二进制,首先我们对它进行拆分:

c 复制代码
01000001 00010000 00000000 00000000

可以看出来这个数是一个正数,并且非常非常大,我们可以拿进计算器计算一下:

可以想到我们第三个printf打印出来的值应该就是这样

最后我们来总结一下:第一个printf打印9,第二个printf打印0.000000,第三个printf打印1091567616,最后一个printf打印9.000000,我们来看运行结果:

可以看到结果和我们算出来的一模一样,是不是非常有成就感呢?

今天的数据在内存中的存储到此结束了,稍微有点难,可以多看多问,在评论区欢迎提问,或者私信我,一定倾囊相授

拜拜~~

相关推荐
深度学习lover33 分钟前
<项目代码>YOLOv8 苹果腐烂识别<目标检测>
人工智能·python·yolo·目标检测·计算机视觉·苹果腐烂识别
XiaoLeisj1 小时前
【JavaEE初阶 — 多线程】单例模式 & 指令重排序问题
java·开发语言·java-ee
paopaokaka_luck2 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
dayouziei2 小时前
java的类加载机制的学习
java·学习
API快乐传递者2 小时前
淘宝反爬虫机制的主要手段有哪些?
爬虫·python
励志成为嵌入式工程师2 小时前
c语言简单编程练习9
c语言·开发语言·算法·vim
捕鲸叉3 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer3 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
Peter_chq3 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
Yaml43 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍