【C语言】数据在内存中的存储(万字解析)
TANGLONG222 2024-10-06 14:05:01 阅读 99
文章目录
一、大小端字节序和字节序判断1.案例引入2.什么是大小端字节序3.大小端字节序判断
二、整数在内存中的存储以及相关练习1.整型在内存中的存储2.练习练习1:练习2练习3练习4练习5:练习6
三、浮点数在内存中的存储1.案例引入2.浮点数在内存中的存储规则3.浮点数存的过程4.浮点数取的过程5.案例最后解析
一、大小端字节序和字节序判断
1.案例引入
在讲解大小端字节序之前,我们先来调试一段代码,来看看一些特别的东西:
<code>#include <stdio.h>
int main()
{
int a = 0x11223344;
//整型a存放16进制数
return 0;
}
我们来调试一下,打开内存窗口,查看a在内存中的存储形式:
调试的时候,我们可以看到在a中的 0x11223344 这个数字是按照字节为单位,倒着存储的,这是为什么呢?这时就要引出我们的大小端字节序了
2.什么是大小端字节序
其实超过⼀个字节的数据在内存中存储的时候,就有存储顺序的问题,按照不同的存储顺序,我们分为⼤端字节序存储和⼩端字节序存储,下⾯是具体的概念:
大端存储模式:是指数据的低位字节内容保存在内存的⾼地址处,⽽数据的⾼位字节内容,保存在内存的低地址处小端存储模式:是指数据的低位字节内容保存在内存的低地址处,⽽数据的⾼位字节内容,保存在内存的⾼地址处
是不是有点懵,我们就以上面的那个图来举个例子,来说明什么是低位字节内容,哪里又是低地址:
在这里我们可以看出,VS是把高字节数据放在了高地址,把低字节数据放在了低地址处,所以VS中采用了小端字节序的存储方式
而它看起来倒起来了是因为内存中的地址是由低到高,而一个数字是从高位到低位书写,所以看起来是倒着的
3.大小端字节序判断
那么我们该怎么判断当前机器采用的字节序呢?这也是百度的一道笔试题,占据了10分,接下来我们就举一个例子来说明
我们创建一个a变量,让它存放1,来尝试思考一下它分别在大端和小端的存储在内存中的样子,如下:
<code>//大端:
0x 00 00 00 01
//小端:
0x 01 00 00 00
我们可以发现在存放1时,大端字节序的第一个字节存放的是0,而小端字节序的第一个字节存放的就是1,那我们能否拿到这个整型的第一个字节呢?
有经验的同学肯定想到了,我们可以创建一个指针变量存放a的地址,然后强制类型转换为字符指针,然后对它解引用,就可以只访问一个字节的内容,然后对解引用的内容进行判断,如果是0那么就是大端字节序,如果是1就说明是小端字节序
听起来是不是很简单呢?我们来实践一下:
#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举例:
<code>原码:10000000 00000000 00000000 00000101
反码:除了符号位,对原码其它位全部取反
11111111 11111111 11111111 11111010
补码:反码+1,是计算机内部存储整型的形式
11111111 11111111 11111111 11111011
2.练习
练习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;
}
首先就是字符型的a被赋值为整型-1,所以我们首先要求到-1的补码,然后对其进行截断,截断为1个字节,就是字符a真正的值,而截断的知识在操作符中已经讲过,忘记了的可以自行翻阅,我们现在开始处理a:
-1
原码:10000000 00000000 00000000 00000001
反码:11111111 11111111 11111111 11111110
补码:11111111 11111111 11111111 11111111
此时由于a是字符型,所以要截断前三个字节,留下最后一个字节,所以a最后就是这个:
11111111
由于char默认就是signed char,也就是有符号的char,所以这样最高位的1是符号位,此时a存放的是11111111
然后我们来看b,b是一个有符号char,跟我们上面的a一模一样,所以它存放的也是11111111
最后我们来看c,c是一个无符号char,却被赋值了有符号的-1,该怎么计算呢?这个就要看我们的赋值运算符的结合性了,在外面操作符那里也讲过,它是从右向左计算的,如图:
也就是会先计算赋值运算符右边的东西,然后再将结果赋值给左边,所以在这里我们还是要先看右边,不看左边的无符号char,我们通过上面的计算,知道了整型-1的补码为:
<code>11111111 11111111 11111111 11111111
这个时候我们要赋值给无符号char,首先截断为:
11111111
计算到这里后,我们知道了c里面也是11111111,但是由于赋值给了无符号char,所以这里所有位置都是数值位,不含符号位
但是问题又来了,最后打印时居然用的%d,%d表示打印有符号整型,%u是无符号整型,这里统一用的%d,以有符号整型打印abc,但是abc是字符型啊,怎么办?当然是要进行整形提升了,提升到整形一样的4个字节
整形提升的规则我们也讲过,就是有符号数高位补符号位,直到4个字节,无符号数就高位全部补0,凑齐4个字节
现在我们就对a进行整型提升,按符号位补齐4个字节:
11111111 11111111 11111111 11111111
这里得到的是补码,转换为原码为:
10000000 00000000 00000000 00000001
这就是得到的最后的答案,以%d的形式打印a结果为-1,由于b和a一样,所以打印的结果也是-1
最后来看c,先对c进行整型提升,由于c是无符号char,所以高位补0,补齐4个字节,如下:
00000000 00000000 00000000 11111111
经过整型提升后我们可以看到它变成了一个符号位为0的正整数,由于正数的原反补码相同,所以这就是它的原码,计算出来为正的255
通过我们的不懈努力终于将它分析清楚了,a和b打印出来是-1,而c打印出来是255,接下来我们来看看代码运行的结果:
练习2
试着计算以下代码的运行结果:
<code>#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的补码,这里直接给出转换过程,最好自己计算一遍,不要直接看答案:
原码:10000000 00000000 00000000 10000000
反码:11111111 11111111 11111111 01111111
补码:11111111 11111111 11111111 10000000
随后进行截断,变成:
10000000
然后我们来看b,128是个正数,所以原码就是补码,如下:
补码:00000000 00000000 00000000 10000000
经过截断后变成:
10000000
可以发现a和b存放的都是10000000,由于要以%u的形式打印,也就是无符号整型的方式打印,所以首先要进行整型提升,如下:
11111111 11111111 11111111 10000000
由于以无符号整型的形式打印,所以这里的所有位都表示数值位,有点大,所以我们可以将其复制进电脑的计算器来计算,如下:
由于a和b经过截断和整型提升都是一样的,所以答案应该也是一样,接下来我们就来运行一下程序,看看和我们计算器算出的答案是否一致,如下:
可以看到完全一致,所以我们的分析是正确的
练习3
试着计算以下代码的运行结果:
<code>#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是人为规定的,因为在计算机中有这么两个特殊的数:
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的补码:
原码:10000000 00000000 00000000 10000001
反码:11111111 11111111 11111111 01111110
补码:11111111 11111111 11111111 01111111
经过截断后变成了:
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
试着计算以下代码的运行结果:
<code>#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是多少:
原码:00000000 00000000 00000001 00000000
经过截断后:
00000000
我们发现居然将256放入unsigned char后,变成了0,此时i就重新变成了0,又开始循环,到下一个256又变成0,又循环,周而复始,造成了死循环
我们来看看运行结果:
果然是死循环的打印hello world
练习5:
试着计算以下代码的运行结果:
<code>#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的范围,所以要重新计算:
原码:10000000 00000000 00000000 00000001
反码:11111111 11111111 11111111 11111110
补码:11111111 11111111 11111111 11111111
经过截断后:
11111111
由于i是无符号char,没有符号位,全部是数值位,所以就变成了255,所以我们知道了,当i到达-1时会变成255,然后又开始循环,直到又到达-1,又变成255继续循环,构成了死循环,我们来看看运行结果:
数字这么大的原因是采用了%u的形式进行打印,会有整型提升,可以自己尝试整形提升后是否是这些值
经过上面两个题的练习,我们基本应该了解了一个东西,就是无符号整型的范围是0~255,如果使用循环时超出这个范围,可能就会经过一直截断,导致造成死循环,有符号整型也是如此,不能超出范围,否则会一直截断,一直循环
练习6
我们来看关于整型存储的最后一个练习:
<code>#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 中定义
我们用一个案例来分析引入一下:
<code>#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进制的科学计数法,如下:
19971400000000=1.99714×10^13
//其中的小数的范围是大于等于1,小于10
只是我们马上学习的公式是2进制版的,接下来我们会采用类比的方法来学习,这样比较好理解,现在就让我们正式开始学习:
根据国际标准IEEE(电⽓和电⼦⼯程协会) 754,任意⼀个⼆进制浮点数V可以表示成下面的形式:
我们一个一个来分析浮点数V在存储时,公式中的三个参数S、M、E,以及底数2的含义
S:公式中的S用于控制这个浮点数的正负性,当S=0时,浮点数V为正数,当S=1时,浮点数V为负数M:公式中M是一个大于等于1,小于2的有效数字,它类似于10进制科学计数法中的小数,如:19971400000000=1.99714×10^13中的1.99714这个小数,而十进制中这个小数范围是大于等于1,小于10,我们二进制就是大于等于1,小于2E:公式中的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次方可以还原回去
然后我们再次和十进制的科学计数法作类比:
<code>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,则其⼆进制表示形式为:
<code> 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中的案例拿过来练练手:
#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是正数,原码就是补码,如下:
00000000 00000000 00000000 00001001
然后以浮点型的角度来重新把它拆解一下,如下:
0 00000000 00000000000000000001001
S E M
我们上面讲过,如果E为全0,说明这个数非常非常的小,所以在还原时,就不会还原M前面的整数1,现在我们有了SME,带入我们的公式试试看这个数是多少:
(-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转为二进制:
1001
然后写出我们的SME
S:0
M:1.001
E:3
我们求出来E是3,加上中间值127就是130,所以我们写出E的八位二进制应该为:
10000010
所以最后浮点数9.0在内存中存储的二进制序列为(注意存放顺序为SEM):
0 10000010 00100000000000000000000
S E M
现在我们要以整型的角度来看待这段二进制,首先我们对它进行拆分:
01000001 00010000 00000000 00000000
可以看出来这个数是一个正数,并且非常非常大,我们可以拿进计算器计算一下:
可以想到我们第三个printf打印出来的值应该就是这样
最后我们来总结一下:第一个printf打印9,第二个printf打印0.000000,第三个printf打印1091567616,最后一个printf打印9.000000,我们来看运行结果:
可以看到结果和我们算出来的一模一样,是不是非常有成就感呢?
今天的数据在内存中的存储到此结束了,稍微有点难,可以多看多问,在评论区欢迎提问,或者私信我,一定倾囊相授
拜拜~~
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。