本章我们将要介绍的是数据在C语言中是如何存储在内存中的,数据在内存中的存储是一个重点,它能更加地帮助我们从更深的层次去理解之前的很多知识点,让你明白这些知识点为什么是这样,而不是停留在因为别人告诉你是这样所以它就是这样的阶段。
下面就让我们开始本章的学习吧!
目录
1. 整数在内存中的存储
整数的2进制表示方法有三种,即原码、反码和补码 三种表示方法均有符号位和数值位两部分,符号位都是用0表示“正”,用1表示“负”,而数值位最高位的⼀位是被当做符号位,剩余的都是数值位。
正整数的原、反、补码都相同。
负整数的三种表示方法各不相同:
- 原码:直接将数值按照正负数的形式翻译成⼆进制得到的就是原码。
- 反码:将原码的符号位不变,其他位依次按位取反就可以得到反码。
- 补码:反码+1就得到补码。 对于整形来说:数据存放内存中其实存放的是补码。
对于整形来说:数据存放内存中其实存放的是补码。
有没有同学好奇过,这是为什么呢?
这是因为啊,在计算机系统中,数值⼀律用补码来表示和存储。
原因在于,使用补码,可以将符号位和数值域统⼀处理; 同时,加法和减法也可以统⼀处理(CPU只有加法器)此外,补码与原码相互转换,其运算过程是相同的,不需要额外的硬件电路。
上面讲到的内容都是非常重要的,他将贯彻我们本章内容的学习,特别是最后这个——对于整形来说:数据存放内存中其实存放的是补码。
2. 大小端字节序和字节序判断
2.0 引入
可能大伙在看到标题这个大小端字节序会有点懵懵,这又是什么新的东西?别急,让我们给大家看个东西先
我们设置一个int类型(大小4个字节)的变量a,并将0x11223344赋值给a
int main()
{
int a = 0x11223344;
return 0;
}
调试的时候,我们可以看到在a中的 0x11223344 这个数字是按照字节为单位,倒着存储的。
(关于0x11223344的一些拓展解释)
总的来说就是:
- 整数在内存中储存的是二进制的补码
- 可能有同学会有疑问,难道a在内存中储存的就是11223344吗?在调试窗口中观察内存的时候,为了方便展示,显示的是16进制的值 。相当于我们投其所好写了一个16进制的数,方便在调试窗口观察,实际上在内存里还是由01组成的数字串。
- 储存的顺序是倒过来的
那为什么会是倒着存储的呢?为了解开这个疑问,接下来就请我们本part的主角登场了。
2.1 什么是大小端
在超过⼀个字节的数据在内存中存储的时候,就有存储顺序的问题,
按照不同的存储顺序,我们分为大端字节序存储和小端字节序存储,
下⾯是具体的概念:
- 大端(存储)模式:是指数据的低位字节内容保存在内存的高地址处,而数据的高位字节内容,保存在内存的低地址处。
- 小端(存储)模式:是指数据的低位字节内容保存在内存的低地址处,而数据的高位字节内容,保存在内存的高地址处。
上述概念需要记住,⽅便分辨大小端。
(一些概念补充)
2.2 为什么有大小端
为什么会有⼤⼩端模式之分呢?
这是因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着⼀个字节,⼀个字节为8 bit 位,但是在C语⾔中除了8 bit 的 char (大小为1个字节)之外,还有16 bit 的 short 型(大小为2个字节),32 bit 的 long 型(大小为4个字节)(要看具体的编译器),另外,对于位数⼤于8位的处理器,例如16位或者32位的处理器,由于寄存器宽度大于⼀个字节,那么必然存在着⼀个如何将多个字节安排的问题。因此就导致了⼤端存储模式和小端存储模式。
例如:⼀个 16bit 的 short 型 x ,在内存中的地址为 0x0010 , x 的值为 0x1122 ,那么 0x11 为⾼字节, 0x22 为低字节。对于⼤端模式,就将 0x11 放在低地址中,即 0x0010 中, 0x22 放在高地址中,即 0x0011中。小端模式,刚好相反。我们常⽤的 X86 结构是小端模式,⽽ KEIL C51 (单片机)则为大端模式。很多的ARM,DSP都为小端模式。有些ARM处理器还可以由硬件来选择是 ⼤端模式还是小端模式。
简单来讲就是:
- 大小端字节序的存储模式都是合理的,它主要取决于各个开发者自己的喜好。
- 存储的数据的顺序是客观存在的,在内存中它是以什么顺序存放也取决于开发者自己的喜好,只要最后能从内存中提取出正确顺序的数据即可。只是大小端的方式更加简单高效易理解,被广泛使用而已。
- 由于大小端的存在,不同机器和软件使用的字节序存储方式不同,因此有时候我们在转移或使用数据时要注意下(大小端)字节序存储方式是否相同。因为即使是相同的数据,采用不同的字节序存储方式,最后读取出来的内容是不一样的。
2.3 练习
让我们通过一些练习来加深本章讲解的知识点
2.3.1 练习1
设计⼀个小函数来判断当前机器的字节序。
思路:
- 通过前面的大小端板块的学习,我们可以从数据的高低位保存的位置入手。,
- 以1为例,在内存中的以 0x00 00 00 01的形式(这里为了方笔用16进制表示实际上在内存中以32位的形式)存储,
- 我们可以通过 01 的所处的位置来判断是大端字节序还是小端字节序。( 01 存放在低地址处是小端;01 存放在高地址处是大端)
源代码:
#include <stdio.h>
int check_sys()
{
int i = 1;
return (*(char *)&i);
}
int main()
{
int ret = check_sys();
if(ret == 1)
{
printf("⼩端\n");
}
else
{
printf("⼤端\n");
}
return 0;
}
代码解析:
- 我们通过设置一个int 类型的变量 i ,并将1赋值给i
- 如果是小端模式,则存储方式为0x01 00 00 00; 如果是大端模式,则存储方式为0x00 00 00 01,
- 所以我们只要取出高位的第一个字节便可得知是大端还是小端,因此我们取出i的地址并强制类型转换成char*类型,然后再解引用(因为char*指针类型解引用一次只能访问一个字节,刚好满足我们的需求)
- 最后将得到值返回给主函数,是1即是小端,是0就是大端。
在这里可能同学会有自己的想法,就是在强制类型转换的时候,我们是否可以先将 i 强制类型转换成char类型,然后将得到的值赋给一个char类型的变量 n (即 char n = (char)i ),最后通过 n的值来判断大小端。
注意这是不可行的,这是因为不管大端还是小端,强制类型转换总是拿到我们要存储的数据的最后一个字节,也就是最低位的那个字节。所以无论大小端,n 存储的数据都是01这个字节,因此无法判断到底是小端还是大端。
2.3.2 练习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;
}
不知道大家看到这些代码,特别是一些基础比较好的比较机灵的同学,会不会比较疑惑,unsigned char 不是无符号类型吗?为什么可以存储一个负数 -1 呢?而且 printf 打印格式化用的还是整型打印 %d ,明明上面都是 char 类型啊。
解析:
接下里就让我来解惑:
- 首先我们明确一点 —— -1 是客观存在的数据,就像我们把一个正方形物体放进一个三角形容器里面,你不能说因为正方形物体与三角形容器不匹配,所以正方形物体就不存在,就是错误的吧?
- 那为什么我们说不能存储 -1 ,其实是因为 -1 存储进去后,你再解读出的值很可能就不是 -1 了,但 -1 仍然还是 -1 ,不变的。正方形物体可不可以放进三角形容具里?你如果一定要硬塞进去,或是还是可以的。但是此时容器里面的物体还会是正方体吗?应该不是了,但物体还是原来那个物体,只是形状不同罢了
好,在了解这些后,我们再回过头来看我们的题目
- 首先 -1 的二进制值要如何表示?是11111111111111111111111111111111对吧,一共32个比特位(整形数据的大小是4个字节),
- 然后我们将 -1 存进 a 中,因为 char 类型的大小只有一个字节,所以 a = 11111111,(8个1,一个字节八个比特位)
- a 、b 、c 均是如此,存放的时候可不会管那么多,全部都是 -1 ,至于对不同的类型来说这些 1 最后会被解读成什么,这可不该存储数据的管。
- 来到打印环节,我们先从 b 入手,因为 b 是 signed char 类型,那么内存中的最高位就会被当作是符号位,可以存放负数,所以 b = -1;
- 重点讲解c:因为 c 是无符号变量,所以 c 没有符号位,全都是数值位,根据二进制计算最终得出 c = 255.
- 最后是 a ,这涉及到 char 到底是等同于 signed char 还是 unsigned char ,这取决于编译器。在vs2022中,char = signed char 。所以 a = -1。
让我们运行来验证一下:
完全符合。
知识点补充:
其实说到这里就在做一个补充,char 数据在内存中是怎么存储的
*取反就是符号位不变,数值位按位取反
上图其实就解释了为什么 signed char 的取值范围是 -1 ~ 127 ,而我们也就可以以此类推出 unsigned char 的取值范围是 0 ~255 。当最高位的符号位也变成数值位,那么内存中的补码就是原码本身了,并不用取反+1了,这时候 1000 0000 根据二进制算法就是 2 ^ 7 = 128 了,接下去的数据也是如此。
2.3.3 练习3
请大家看看下面的 a 和 b 的值打印出来是多少
源代码:
int main()
{
char a = -128;
printf("%u\n",a);
char b = 128;
printf("%u\n",b);
return 0;
}
温馨提示一下,这里的 %u 是打印无符号,它会认为 a 中存放的是无符号数。
让我们先来揭开答案,然后再来分析:
结果是不是让大家有点大跌眼镜,不敢相信。为什么答案会是这样呢?
解析:
- 首先我们先写出 -128 的原码 —— 1000 0000 0000 0000 0000 0000 1000 0000
- 然后符号位不变 ,数值位取反 —— 1111 1111 1111 1111 1111 1111 0111 11111
- 接着再 + 1得到补码 —— 1111 1111 1111 1111 1111 1111 1000 0000
- 因为 a 是 char 类型,所以只取最后的八个比特位,即 a = 1000 0000
- 又因为 %u 的形式打印,把 a 当作无符号数 ,所以进行整形提升时用 0 补齐,得 a = 0000 0000 0000 0000 0000 0000 1000 0000
- 然后进行按位取反在 + 1的操作,得1111 1111 1111 1111 1111 1111 1000 0000
- 我们把得到的二进制数字串放到计算器中 求出它的十进制数
我们发现它跟我们我们编译器输出的结果如出一折。
b也是同样的
- 我们先写出 128 的二进制数字串 —— 0000 0000 0000 0000 0000 0000 1000 0000 b 跟 a 的区别就是正数的补码跟原码是一样的,且他的字符位为0,
- 同样的将最后的八个比特位,即 1000 0000 放到 b 中,
- 同样的 %u 的形式打印,补码先整型提升,用 0 补齐得到——0000 0000 0000 0000 0000 0000 1000 0000
- 按位取反再 + 1,最后会发现依然是得到 —— 111 1111 1111 1111 1111 1111 1000 0000
- 所以 a 和 b 的最后值一样。
2.3.4 练习4
源代码:
#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;
}
解析:
这道题创建了一个 char 类型的字符数组 a ,然后要我们求 a 的长度。
因为 strlen 函数遇到 “\0” 便停下来,而 "\0" 的ASCII码值是 0 ,所以这道题就变成求经过多少个 a 的第几个元素上是 0 。
细心的同学不知道有没有发现,在 for 循环里面,随着 i 的值的增大,a[ i ] 的值会超过 -128 。那 -128 之后又会是什么值呢? 是 -129 还是其他数值呢?我们接着讲。
我们假设这里有一个圆,圆上各点对应一个二进制数字串
我们看出,当 -128 减到 -1 时,再减下去它会来到 0111 1111 ,也就是 127 ,如果真是这样的话,那么理论上 a 的长度就会是一整个圆的长度,也就是 128 + 127 共 255个数。
我们开始验证
结果正确,也就证明我们的假设与推断是正确的,for 循环内部的运行就是这样 ,进行着循环。
2.3.5 练习5
接下里请大家试着写出下列这组代码的输出结果:
源代码:
int main()
{
unsigned char i = 0;
for(i = 0;i<=255;i++)
{
printf("hello world\n");
}
return 0;
}
int main()
{
unsigned char i = 0;
for(i = 9;i>=0;i--)
{
printf("%u\n",i);
}
return 0;
}
解析:
相信有了练习四的学习,这道题应该就很容易了,这两道题其实也是再次验证我们那个圆,就是char 类型数据的循环的正确性。
- 先说第一个代码, i 是 unsigned char 无符号类型,其实跟 signed char 类型的循环是一样的,只不过后者的范围是 -128 ~ 127 ;而前者是 0 ~ 255,所以当 i++ 到 255 后,下一个数便是 0 ,开始新一轮循环。所以第一个代码的输出结果是死循环。
- 第二个代码也同样,i 是 unsigned char 无符号类型,当 i-- 到 0 后,下一个数便是 255 ,待 255 自减到 0 后便开始新一轮循环。所以第二个代码同样是死循环。
2.3.6 练习6
来到我们最后一道题,依然还是写出输出结果。这道题稍稍有点难度
源代码:
#include <stdio.h>
//X86环境(必须是X86环境,X64会报错),小端字节序
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);//%x是以16进制的形式打印的意思
return 0;
}
让我们先揭晓答案,然后再来解析答案
不知道屏幕前的你是否做对了?、
解析:
- 首先这道题需要结合我们先前学习的指针的相关知识,我们先取数组 a 的地址再 +1,这是跳过整个数组的长度,也就是说指针 ptr1 指向的是数组a的元素4后面的地址。
(这里为了方便显示,我们采用16进制显示)
- 接着是 a ,我们经过调试发现:
- ptr2 的地址比 a 多了一 ,而这个一正是我们 a + 1而来的。
- 必须要强调的是,也是必须要弄清楚的一点就是 a 前面的这个强制类型转换( int ),它相当于把 a 的地址 0x00ffb7c 强制转换成一个整形 ,即 0x00ffb7c 不再是地址了,而是一个十六进制的整形数据,跟 1 、2 、1000 这些整型数据是一样的,只是它是以十六进制显示的而已,所以这时候的 +1 就是单纯的 +1。
- 最后它又被强制类型转换成 int* ,又重新变回指针了,而我们 a 的地址即使数组的指针,也是首元素的地址,而加 1 后也就是指向了 a + 1后的地址。如图下图所示:
此时 ptr2 指向的就是 01 后面的 00 ,以 00 为起点,重新填满一个新的数组,此时数组的元素就会发生改变。所以这便是我们要强调是小端字节序还是大端字节序的原因,不同的字节序带来的不同的排序会导致数组的元素不同。
2.4 小结
不i知道经过上面几道题的练习与讲解,大家对这些数据在内存存储是否有了些些醍醐灌顶的感觉。其实这些题目是为了出题而出题,主要目的是为了让大家更清晰地去了解其内部的运行与背后的逻辑,平时并不提倡大家这么写代码,该遵守的规范与语法我们还是得遵守的。
3. 浮点数在内存中的存储
常⻅的浮点数:3.14159、1E10等,浮点数家族包括: float、double、long double 类型。 浮点数表⽰的范围: float.h 中定义。整数表⽰的范围:limist.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 浮点数的存储
上⾯的代码中, num 和 *pFloat 在内存中明明是同⼀个数,为什么浮点数和整数的解读结果会差别 这么⼤?
要理解这个结果,⼀定要搞懂浮点数在计算机内部的表⽰⽅法。
根据国际标准IEEE(电气和电子工程协会) 754,任意⼀个⼆进制浮点数V可以表示成下⾯的形式:
V = (−1) ∗ S M ∗ 2E
- (−1)S 表示符号位,当S=0,V为正数;当S=1,V为负数
- M 表示有效数字,M是大于等于1,小于2的数。
指数位
所以浮点数的存储其实存储的十S、M、E
举例来说: ⼗进制的5.0,写成⼆进制是 101.0 ,相当于 1.01×2^2 。(用 2 做底数是因为这是二进制数。如果此时是十进制的1234,则是 1.234 * 10 ^ 3 ,以十为底数)
那么,按照上⾯V的格式,可以得出S=0,M=1.01,E=2。
⼗进制的-5.0,写成⼆进制是 -101.0 ,相当于 -1.01×2^2 。那么,S=1,M=1.01,E=2。
IEEE 754规定: 对于32位的浮点数,最⾼的1位存储符号位S,接着的8位存储指数E,剩下的23位存储有效数字M ,
对于64位的浮点数,最⾼的1位存储符号位S,接着的11位存储指数E,剩下的52位存储有效数字M。
3.2.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位有效数字。
至于指数E,情况就⽐较复杂
首先,E为⼀个⽆符号整数(unsigned int)
这意味着,如果E为8位,它的取值范围为0~255;如果E为11位,它的取值范围为0~2047。但是,我 们知道,科学计数法中的E是可以出现负数的,所以IEEE 754规定,存⼊内存时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。 ⽐如:0.5 的⼆进制形式为0.1,由于规定正数部分必须为1,即将⼩数点右移1位,则为1.0*2^(-1),其 阶码为-1+127(中间值)=126,表⽰为01111110,⽽尾数1.0去掉整数部分为0,补⻬0到23位 00000000000000000000000,则其⼆进制表⽰形式为:
0 01111110 00000000000000000000000
用图例表示就是
E全为0
这时,浮点数的指数E等于1-127(或者1-1023)即为真实值,有效数字M不再加上第⼀位的1,⽽是还 原为0.xxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字。
0 00000000 00100000000000000000000
E全为1
这时,如果有效数字M全为0,表⽰±⽆穷⼤(正负取决于符号位s);
0 11111111 00010000000000000000000
好了,关于浮点数的表⽰规则,就说到这⾥。
3.3 题目解析
OK,讲了这么多,是时候让我们收回时间线了,让我们回到引入的题目,不过相信不少人现在心里也已经有自己的答案了。
先看第1环节,为什么 9 还原成浮点数,就成了 0.000000?
9以整型的形式存储在内存中,得到如下⼆进制序列:
0000 0000 0000 0000 0000 0000 0000 1001
⾸先,将 9 的⼆进制序列按照浮点数的形式拆分,得到第⼀位符号位s=0,后⾯8位的指数 E=00000000 ,
最后23位的有效数字M=000 0000 0000 0000 0000 1001。
由于指数E全为0,所以符合E为全0的情况。因此,浮点数V就写成:
V=(-1)^0 × 0.00000000000000000001001×2^(-126)=1.001×2^(-146)
显然,V是⼀个很⼩的接近于0的正数,所以⽤⼗进制⼩数表⽰就是0.000000。
再看第2环节,浮点数9.0,为什么整数打印是 1091567616?
⾸先,浮点数9.0 等于⼆进制的1001.0,即换算成科学计数法是:1.001×2^3
所以: 9.0 = * (1.001) ∗
,
那么,第⼀位的符号位S=0,有效数字M等于001后⾯再加20个0,凑满23位,指数E等于3+127=130, 即10000010,
所以,写成⼆进制形式,应该是S+E+M,即
0 10000010 001 0000 0000 0000 0000 0000
这个32位的⼆进制数,被当做整数来解析的时候,就是整数在内存中的补码,原码正是 1091567616/
4.总结
本章主要讲了各种数据在内存中是如何存储的,进一步加深我们对各种数据类型的理解。关于浮点数的一些知识点和规则我们记住怎么用就行,不必去深究太多为什么,规则是别人制定的,现阶段的我们学会去遵守就行了,假以时日我们自己足够强大成了权威,那时候我们才有底气与能力去制定规则。
最后感谢每一个看到这里的人。有任何不足请多多包涵并希望您能在评论区不吝提出。谢谢。
完