C 语言底层进阶:数据在内存中如何存储?(补码 + 大小端 + IEEE 754 详解)

0 阅读18分钟

C 语言底层进阶:数据在内存中如何存储?(补码 + 大小端 + IEEE 754 详解)

前言

在编程的世界里,我们每天都在和变量、数值、字符串打交道,但很少停下来思考这些数据在计算机的内存里究竟是什么样子。理解数据在内存中的存储方式,不仅能帮你写出更高效的代码,更能让你轻松解决那些因类型转换、符号问题引发的诡异 Bug。

1.整数在内存中的存储

1.1概念理解

首先我们需要知道整数的二进制有三种表示方法:原码,反码,补码

对于有符号的整数而言,三种表示方法均由符号位数值位两部分组成,其中符号位是二进制中最高位的那位,也就是二进制中的第一位,其中符号位为 “0“ 表示 整数,其中符号位为 ”1“ 表示 整数;在一个二进制中,除了开头的符号位,剩下的都是数值位.

正数的原码,反码,补码都是相同。 负整数的三种表示方法各不相同,接下来我们就详细介绍一下负整数的三种表示方法。

原码:直接将负整数的按照二进制化简的方式化简所得到的值就是它的原码。 反码:将负整数的原码保留符号位不变,其余位按位取反(即1变为00变为1。)就可以得到反码。 补码:对负整数的反码+1即可得到。

对于整形而言,数据在内存中的存放方式,其实就是将整形的补码存放在内存中。

为什么在计算机中,数值是用补码来表示和存储呢?

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

1.2整数在内存中的存储练习

1.2.1

#include<stdio.h>

int main()
{
	char a = -1;
	signed char b = -1;
	unsigned char c = -1;
	printf("%d %d %d", a, b, c);
	return 0;
}

让我们来对这段代码进行分析:

通过上述知识点我们可以得知,对于一个整数而言,内存中存放的其实是它的补码。

接下来我们对char a = -1进行分析。

-1是一个整数,先将它的补码求出来

在这里插入图片描述

我们可以得到-1在32为机器中补码应该是11111111 11111111 11111111 11111111.但是char类型最多存储一个字节(即8bit),而-1是int类型,要想把int类型的数据存储在char类型中会发生截断,也就是说只有补码最后8位会被存储在a中。(即a中存储的应该是11111111)

又因为对于%d这种打印数据的方式来说,它打印的是一个有符号的整数(即需要对它传入int类型的参数,这是因为在可变参数函数(如 printf)中,char 和 short 类型的参数会被自动提升为 int,所以 %d 本质上接收的是提升后的 int 值。),而a是char类型不符合上述规则,因此我们需要对a进行整形提升。

整形提升的规则取决于a的符号:

  1. 如果是 signed char或 char(默认): 用符号位(最高位)填充高位。char a = -1(二进制 11111111)提升为 int 后是 11111111 11111111 11111111 11111111,值仍为 -1。 2.如果是 unsigned char: 用 0 填充高位。例如 unsigned char c = -1(二进制 11111111)提升为 int 后是 00000000 00000000 00000000 11111111,值为 255。

因此上述代码的打印结果应为-1 -1 255; 在这里插入图片描述 注意:

char 的符号属性是由编译器决定的(虽然大部分编译器默认是 signed char),为了避免跨平台问题,建议在需要明确符号时显式使用 signed char 或 unsigned char。

1.2.2

#include <stdio.h>

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

该题分析步骤与上题类似 ,首先我们需要计算出-128的补码 在这里插入图片描述 -128存储在char类型中会发生截断,即a中存储的值为100000000.

%u打印的是无符号的整数,而char类型与其不匹配,因此会发生整形提升,即将a提升为整形:a为char类型,符号位为1,因此提升后的结果为11111111 11111111 11111111 10000000

提升后的二进制为11111111 11111111 11111111 10000000,而%u是将它当作一个无符号的整数来打印的,有因为无符号的整数和正整数是一样的它的原码,反码,补码是一样的。因此%u会11111111 11111111 11111111 10000000转化为十进制进行打印。

在这里插入图片描述 即上图中的结果。

1.2.3

#include<stdio.h>
#include<string.h>

int main()
{
	char a[1000] = { 0 };
	int i = 0;
	for (i = 0; i < 1000; i++)
	{
		a[i] = -1 - i;
	}
	printf("%d", strlen(a));
	return 0;
}

对该代码进行分析,首先创建一个char类型的数组,并将其命名为a,然后进入循环,i不断增大,并将- 1-i 的值赋给a[ i ],然后通过strlen函数来计算字符串的有效长度,并输出结果。

乍一看这道题好像很简单循环了1000次,也就是说a中存储了1000个值,那么打印的时候应该结果1000,但实际上呢,让我们把代码运行起来看看结果。

在这里插入图片描述 通过代码的运行我们发现结果并非像我们想象的那样是1000,而是255,这是为什么呢。原因涉及到两个重要的知识点,接下来让我们详细分析一下。

对于一个char类型的数而言,它的取值范围是-128127,因此上述题中无论 i 所取得值有多大,a[ i ]中存放的数据始终不会低于-128,那么当 i = 128时,a[ i ] = -129;但是char类型的取值范围为-128127,那么-129在char中该怎么存储呢?接下来我将通过一个图来展示一下: 在这里插入图片描述

由于数据在内存中以补码形式存在,因此上述二进制皆为补码,对127进行+1操作后,变为10000000,而这个补码二进制数他所对应的十进制数应为-128,然后对-128不断进行+1操作,当加到-1时,再进行详细分析,-1的补码为11111111,对这个补码进行+1操作后,变为100000000,而char类型存储会发生截断,因此保留下来的只有后面8位,即00000000,因此他就构成了一个循环,从0加1加到127后,对127再加1,就变为-128,对-128加1加到-1后,再加1,就会变为0.我将用图片来表示一下,以加深理解。

在这里插入图片描述

接下来我们再来介绍第二个知识点,strlen函数,让我们来深入了解一下strlen函数的工作原理,它不关心字符串的存储类型,只做一件事,那就是从传入的指针地址开始,逐个字节遍历内存,直到遇到 '\0'(ASCII 码为 0 的字符)停止,然后返回遍历过的字节数(不包括 '\0' )。

接下来我们来分析上述代码,当 i = 255时,a[255] = 0,这时候0是作为数值存储在a[255]中因此它的ASCII 码为 0,而如果把0当作字符来存储的话,它的ASCII 码为 48

因此当strlen读取到a[255]时,发现其存储数据的ASCII 码为 0 ,此时读取停止,然后返回其所遍历过的字节数,即255个(不包括a[255]),因此最后打印结果为255.

1.2.4

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

这道题其实与上一道题类似,上一道题我们讨论的是char(signed char),它的取值范围是-128~127,而对于unsigned char,它的取值范围是0 ~ 255.类比上一道题,由于 i 是unsigned char,因此当i = 255后,对它再加1,i的值就会变为0,因此对于该for循环而言,i的值会始终<=255,因此该循环是一个死循环,会无限打印hello world

1.2.5

#include <stdio.h>
int main()
{
 unsigned int i;
 for(i = 9; i >= 0; i--)
 {
 printf("%u\n",i);
 }
 return 0;
}

对于这道题而言,首先可以理解的是从9依次打印到0,而当 i 再 -1,那么i的值变为-1,但unsigned int为无符号整数,因此它不存在负数,也就是说它会把-1所对应的补码当作一个正数的补码来计算,因此它会是一个非常大的数,然后在这个数的基础上不断-1,因此而陷入死循环中

2.大小端字节序和字节序的判断

当我们了解整数在内存中的存储后,我们可以通过调试来观察一个现象:

#include<stdio.h>

int main()
{
	int a = 0x11223344;

	return 0;
}

在这里插入图片描述 通过调试我们可以发现,a中的0x11223344在内存中是倒着存放的。这是为什么呢?

这就涉及到数据在内存中存储时,存储顺序的问题了,接下来就让我们详细分析一下。

什么是大小端?

对于超过一个字节的数据在内存中存储时,就会涉及到存储顺序的问题了。按照不同的存储顺序,我们可以分为 大端字节序存储小端字节序存储 ,以下是其相关概念:

大端(存储)模式:

是指数据的低位字节内容保存在内存的⾼地址处,⽽数据的⾼位字节内容,保存在内存的低地址处。

小端(存储)模式:

是指数据的低位字节内容保存在内存的低地址处,⽽数据的⾼位字节内容,保存在内存的⾼地址处。

接下来我们通过分析上述的例子来更好的理解大小端。

在这里插入图片描述 我们通过调试可以发现数据的低位字节内容存放在在地址的低位(即44存放在了0x000000A0386FF8C4之中),数据的高位字节内容,保存在内存的高地址处(即11存放在了0x000000A0386FF8C7之中)。由此我们可以判断在vs的x86环境下,数据是小端存储

在这里插入图片描述 如果说数据在内存中的存储如上图所示,即数据的高位字节内容,保存在了低地址处(即11存放在了最左边,即低地址的地方);数据的低位字节内容,保存在了高地址处(即44存放在最右边,即高地址的地方);那就可以说该数据在内存中的存储方式是大端存储。

2.2为什么会有大小端之分

为什么会有⼤⼩端模式之分呢?

这是因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着⼀个字节,⼀个字节为8bit 位,但是在C语⾔中除了8 bit 的 char 之外,还有16 bit 的 short 型,32 bit 的 long 型(要看具体的编译器),另外,对于位数⼤于8位的处理器,例如16位或者32位的处理器,由于寄存器宽度⼤于⼀个字节,那么必然存在着⼀个如何将多个字节安排的问题。因此就导致了⼤端存储模式和⼩端存储模式。

例如:⼀个 16bit 的 short 型 x ,在内存中的地址为 0x0010 , x 的值为 0x1122 ,那么0x11 为⾼字节, 0x22 为低字节。对于⼤端模式,就将 0x11 放在低地址中,即 0x0010 中,0x22 放在⾼地址中,即 0x0011 中。⼩端模式,刚好相反。我们常⽤的 X86 结构是⼩端模式,⽽KEIL C51 则为⼤端模式。很多的ARM,DSP都为⼩端模式。有些ARM处理器还可以由硬件来选择是⼤端模式还是⼩端模式。

2.3例题

接下来我们就通过一个例题更加深入的了解大小端。

请简述⼤端字节序和⼩端字节序的概念,设计⼀个⼩程序来判断当前机器的字节序。(10分)-百度笔试题

#include<stdio.h>

int main()
{
	int a = 1;
	char* ch = (char*)&a;
	if (*ch == 1)
	{
		printf("小端模式\n");
	}
	else
	{
		printf("大端模式\n");
	}
	return 0;
}

这是一个经典的大小端检测代码,接下来就让我们详细分析以下

首先我们定义了一个int a = 1;将a的值转换为十六进制时,应为0x00000001;

根据大小端的概念我们可以得知,如果说这个数据在内存中是小端存储,那么它的低地址处存放的应该是01;反之如果说这个数据在内存中是大端存储,那么它的低地址处存放的应该是00

由此我们可以根据低地址处存放的数据来判断数据在内存中的存储方式。

根据指针的知识我们可以知道,一个char*类型的指针,它只会读取在内存中存放的第一个字节(即低地址处存放的字节);

因此我们可以设计一个指向a的地址的char类型的指针,并通过(char)&a来将指向int的指针强制转化为指向char的指针。这样我们可以通过该指针来读取a在内存中存储的低地址字节。(当然 (char*)&a并不会改变变量 a 在内存中的存储方式,只是改变了我们读取它的视角)

然后我们可以通过一个if语句判断该指针所指向的内容是否位1,来判断在当前编译环境下,数据在内存中的存储方式。(若ch = 1,则为小端存储,若ch = 0,则为大端存储)

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

常⻅的浮点数:3.14159、1E10等,浮点数家族包括: 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浮点数的存储

这段代码的输出结果会是什么呢? 让我们通过运行来观察一下结果。 在这里插入图片描述 为什么会是这个结果呢?让我们来详细分析一下

首先int n = 9,那么对n进行打印的话,输出结果即为9.接下来让我们来分析pFloat,float * pFloat = (float )&n,将int * 类型的数据强制转化为float * 类型。按理来说n和pFloat在内存中是同一个数,但是为什么输出结果却不一样。要想弄清这个问题我们首先需要了解浮点数在计算机内部的表示方法。

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

V = (−1) ∗ S M ∗ 2E • (−1)S 表⽰符号位,当S=0,V为正数;当S=1,V为负数 • M 表⽰有效数字,M是⼤于等于1,⼩于2的 • 2 E 表⽰指数位

举例来说: ⼗进制的5.0,写成⼆进制是 101.0 ,相当于 1.01×2^2 。

那么,按照上⾯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浮点数存的过程

EEE 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位,它的取值范围为0255;如果E为11位,它的取值范围为02047。但是,我们知道,科学计数法中的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题目详解

为什么 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)^0 × (1.001) × 2³,

那么,第⼀位的符号位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 。