C语言- - 剖析数据在内存中的存储

C语言- - 剖析数据在内存中的存储


前言

还记得之前的指针类文章,我说过数据在内存中的存储是倒着存的。那么为什么是倒着存的呢?今天我们就来剖析一下原理,找出这个问题的答案。
长文警告!


一、数据类型

我们先来复习常见的数据类型

  1. char --- ---字符数据类型。占用 1 个字节
  2. short --- --- 短整形。占用 2 个字节
  3. int --- --- 整形。占用 4 个字节
  4. long --- --- 长整形。在x64环境占 8 个字节,在x86环占 4 个字节
  5. long long --- ----更长的整形。C99独占,占 8 个字节
  6. float --- ---单精度浮点型。占用 4 个字节
  7. double --- --- 双精度浮点型。占用 8 个字节

那么,这么多数据类型,他们的意义是什么呢?

其实,当你使用某个数据类型时,实际上是向内存申请开辟某某个字节用来存储数据

其中还能细分

char
unsigned cha r
signed char

...(其余的各种类型同理)

unsigned代表无符号型
signed代表有符号型

这里面,除了char的类型是未定义(取决于编译器)其他的比如int啊,long什么的都是默认signed类型。


那么,为什么要区分呢?

数据在存储时,我这举个例子:

比如温度。温度是有零下几度零上几度这一说的。这里以-5度与10度来举例。

int在内存中占 4 个字节,一个字节是八位(1 byte = 8 bit)所以要占32位

10000000000000000000000000000101(-5)

00000000000000000000000000001010(10)

这里:最高位代表的是符号位,1表示负数0表示正数。数据是这么存的,所以一共占用了2^31这么多位。

但是日常生活中总有那些没有负数的数据。比如身高,比如体重。他们就应该使用unsiged类型的数据。再举个例子比如身高是193cm

00000000000000000000000011000001(193)

因为unsigned是无符号型,所以最高位不用管是否是正负数,所以他一共会占用2^32这么多位。

所以,当你创建变量时,总是正数的变量应该使用unsigned类型比较规范。


1.构造类型

  1. 数组类型:
    比如 int arr[3] 实际就是 int[3],只是写法不同
  2. 结构体类型: struct
  3. 枚举类型: enum
  4. 联合类型:union

2.指针类型

  1. int* pi;
  2. float* pf;
  3. char* pc;
  4. void* pv;

3.空类型

void就是空类型,或者叫无类型。

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

这里的返回类型指的是:函数无返回类型

函数的参数表示:函数不需要传递参数

其中void* 也被称为最万能的指针类型


二、整形在内存中的存储

1.原码、反码、补码

注:正整数原反补码均相同

首先,必须先知道一个重要的概念:数据在内存中是以二进制的数进行存储的,并且是以补码的形式进行存储的(这里浮点数除外吗,我么后面会谈论到)

原码:原码是一种直接表示数值的方法

反码:反码是一种用于简化二进制加减运算的表示方法

补码:补码是目前计算机系统中最常用来表示的方法

原码反码补码的运算规律是什么呢?

比如25 与 -25

25的原码:

00000000 00000000 00000000 00011001

0x 00 00 00. 19

反码:

00000000 00000000 00000000 00011001

补码:

00000000 00000000 00000000 00011001


-25的原码:

10000000 00000000 00000000 00011001

0x 80 00 00 19

反码:

11111111 11111111 11111111 11100110

0x FF FF FF E6

补码:

11111111 11111111 11111111 11100111

0x FF FF FF E7


总结一下规律:

除了正数外,原码就等于是数据本身 ,符号位为1
反码就是原码符号位不变,其他位按位取反
补码就是反码+1

反过来也一样,补码取反+1就是原码

核心操作:取反+1

而对于二进制转16进制:

先写出十六进制的标识0x,

然后每四位二进制位计算一次,最后拼起来就是十六进制了


内存中存放的是二进制位,十六进制一般是因为二进制位看的不方便才显示的,本质上存放的是二进制位

对于整形来说:数据存放在内存中实际存的是补码。

为什么呢?

  1. 在计算机系统中,数值一律用补码来表示和存储。原因就是可以让符号位和数值域统一处理
  2. 加减法也可以统一处理。然后,补码与原码互相转换其运算过程是相通的,不需要额外的硬件电路。

举个例子:

比如 1 - 1, 1- 1可以转换成1 + (-1)

1的补码: 00000000 00000000 00000000 00000001

-1的补码:11111111 11111111 11111111 11111111

让这俩相加:

11111111 11111111 11111111 11111111(+1)

得到1 00000000 00000000 00000000 00000000

变成33位了,但是最高位会抛弃,所以得到全零。同时这也是 0 的补码。


2.大端存储与小端存储

前面的文章我们都说数据是存储在内存中的,并且是以相反的顺序存储的。但是为什么是反着来的呢?下面,我们就来探讨一下这个问题。

要谈论这个问题,我们就要引申出两个概念:大端存储小端存储

那么,什么是大端存储什么是小端存储呢?我们来举个例子:

假设这里有一个地址:0x11223344

在大端存储模式中,它是0x11223344

在小端存储模式中,它是0x44332211

其实说白了,一个是正着来的一个反着来的。

这里的字节序我们可以举个简单的例子来帮助理解:

比如十进制的150。1表示百位,5表示十位,0表示个位。

那么,这里的高位就是1,地位就是0
大端字节序存储就是高位放在低地址处,低位放在高地址处 。在计算机的存储中,就是150
小端字节序存储就是高位放在高地址处,低位放在低地址处 。在计算机的存储中,就是051

注意了:这里的数值并不影响我们的存储顺序,它是以字节为单位来存储的,不是以数值大小来判定

Ⅰ.大端存储:

大端存储常见于网络通信上,如TCP/IP协议在传输整型数据时一般使用大端存储模式表示。
优点:

  1. 符号位在所表示的数据的内存的第一个字节中,便于快速判断数据的正负和大小。
  2. 大端存储方式符合人类的直观认识,因为他高位优先并且是按顺序存储。

缺点: 计算机在处理时需要进行额外的字节顺序调整。

Ⅱ.小端存储:

小端存储常见于本地主机上,以及某些需要频繁进行读写操作且内存空间有限的应用中。
优点:

  1. 节约空间:对于小型数据类型,小端存储可以将多个数据类型的低字节合并到一个字节中,从而有效地节约内存空间。
  2. 提高读写速度:在进行网络传输时,可以减少读写操作次数,提高传输效率。

缺点: 不太符合我们的阅读习惯,毕竟是从小到大也就是俗称的从后往前显示


三、signed 与 unsiged

1.signed

signed被称为有符号数,也就是最高位为符号位,这里我们以char a来演示

char类型数据占一个字节,也就是8位,如下图所示,图为signed char的最大表示范围

然后,我们来剖析一下这些值代表了什么

首先,数据在内存中以补码的形式存储,所以上图皆是补码

我补充了一下图,解释一下

首先,将补码按位取反 + 1 就能得到原码,我们把原码计算出来可以发现一个字节最大的存储范围i是-128 ~ 127


2.unsigned

unsigned是无符号的数,不需要判断最高位的符号位,所以它的原码反码补码皆是相同的。这也意味着能直接看出来数值且不需要计算

他的表示范围为0 ~ 255


同理,这只是char的类型,如果是short,那么就有16个比特位,只需要将他们罗列出来再进行计算,就能得到这个数据类型的取值范围。

其余的原理相同。包括int,long...


三.整型提升

c 复制代码
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,b,c分别是什么呢?

要解决这个问题,我们得先学个知识,叫做整型提升

我们a的数据类型是char,但是打印的却是%d,%d是以整形打印的,可我们char只有1个字节,int是有4个字节的,所以我们需要进行整型提升

先把signed char b = -1 以二进制表示出来(char a 其实与 signed b 是一样的,char a 默认就是signed)

10000000 00000000 00000000 00000001

因为是signed,是有符号的,所以第一位是符号位,1代表负数,所以二进制如上。

然后这是补码,我们要转换成原码才能看得懂

11111111 11111111 11111111 1111110(反码)

11111111 11111111 11111111 1111111(原码)

然后,因为char只能存一个字节,我们要进行截断 操作

11111111(取最后一个字节)

然后前面补充24位1达到int类型的取值范围

11111111 11111111 11111111 1111111(现在这个为补码)

然后取反+1得到原码

10000000 00000000 00000000 00000001

得到的就是-1


那么unsigned char c = -1是怎么处理的呢?

首先补码为

10000000 00000000 00000000 00000001(这是-1的补码,先别管有无符号,上面的也一样)

求出原码

11111111 11111111 11111111 1111111(原码)

然后截断

11111111(补码)

然后,这里因为是unsigned类型的,所以没有符号位。前面直接补0

00000000 00000000 00000000 11111111(补码)

又因为无符号类型原码反码补码均相等 ,所以原码也是跟补码一样的。解出来得到值为255

所以

例子2

我们再来个例子可以很好的帮助我们理解整型提升

c 复制代码
int main() {
	char a = -10;
	printf("a=%u", a);
	return 0;
}

拿到手,首先不管三七二十一直接计算-10

10000000 00000000 00000000 00001010(补码)

11111111 11111111 11111111 11110110(原码)

然后因为char只有一个字节,所以我们截断拿最后一个字节

11110110,因为a又是有符号数,所以我们整型提升

11111111 11111111 11111111 11110110(补码)

到这,先打住。我们先看看题目打印的类型。题目要打印的是%u 也就是unsigned类型

unsigned类型是无符号的,所以这里我们没必要进行转换了,补码就等于原码

所以值为

反正就是很长很长。。。。。


到这整数的类型我们就讲完了。接下来,还有浮点数类型的呢

四、浮点数的存储

1.浮点数在计算机内部的表达方法

根据国际标准IEEE754,任何一个二进制V可以表示成如下形式

(-1)^S * M * 2^E

解释一下:

  1. (-1)^S表示符号位,S = 0 时,V表示正数;S = 1 时, V表示负数。
  2. M表示有效数字,大于等于1小于2
  3. 2^E表示指数位

假设V = 5.0f

5的二进制是101,5.0 就是 101.0

浮点型,点就是可以浮动的,那我们就把这个点放在第一个数的后面,也就是1.010 * 10^2
点向前移动几位就要乘上2的几次方

得到了1.010 * 10^2,那么根据上面的标准,我们可以得到 (-1) ^ 0 * 1.01 * 2^2

这样就能理解为什么M是要大于等于1小于2了

再举个例子

假设V = 9.5f

我们先处理下小数部分

图上蓝色字体为当前位的权重

比如1001

实际上就是1 * 2^3 + 0 * 2^2 + 0 * 2^1 + 1 * 2^0 = 9

-1也同理,但是-1实际上是2^-1次方,也就是1/2。而1/2就是0.5,所以

9.5 的二进制是1001.1

将小数点移动三位的1.0011 * 2^3

所以表示为(-1) ^ 0 * 1.0011 * 2^3


这就是浮点数的表达。

但是这种表达方式有弊端。比如9.6f

整数部分1001没有问题,但是小数部分呢?

先确定第一位是1也没问题,第二位呢?如果是1那么就是1/2^2 = 0.25。0.25 + 0.5超过了0.6

那么第二位就不能是1,只能为0。第三位如果是1那么就是1/2^3 = 0.125。0.125 + 0.5也超过了0.6,所以第三位也不能是1,只能这样反复排查下去寻找最最接近0.6的然后确认小数位。

这也是没什么浮点型数据在存储时会有误差的原因。

2.存储模型

float的存储模型如下

double的存储模型如下

这里,E的存储比较复杂

首先我们还是以0.5f为例子,它表示为(-1) ^ 0 * 1.0 * 2^-1

S = 0, M = 1.0,E为-1

那么-1要怎么存储呢?要知道,这里规定的E是无符号型的

所以,我们一般要在存入内存中时E的真实值要加上一个中间值。对于8bit的E,这个中间值为127;对于11bit的E,这个中间值为1023。

也就是说,float的0.5f里的E为-1, 要加上127 = 126。126转成二进制才是内存中所存储的E

double里的E要加上1023 = 1022,1022转成二进制才是内存中所存储的E

这里E = 2 需要加上127 = 129,再把129转成二进制序列(100 0000 1)后面M就是011,然后补零

注:1.011只要存011,不存1

这就是浮点数存入内存中的规则

内存中一般是小端的方式来显示的,所以是反着来输出的。

3.取出数据

说完了存入,那么,怎么取出来呢?

1.对于非全零或全一

  1. 将M完整取出来,并且前面加上1.
    也就是1.011 0000 0000 000000000000
  2. 然后E - 127得到真实值,也就是:
    100 0000 1 - 011 1111 1 = 129 - 127 = 2
  3. 最后就能得到 +1.011 0000 0000 000000000000 * 2^2 (+就是s为1的情况)

简化一下就是+1.011*2^2。

2.E为全0

当E为全零时,第一步需要改一下

  1. 将M完整取出来,前面不加上 1. 了,而是直接加上 0.,得到0.xxxxxxxx
  2. E直接就是1-127(或者1-1023),得到真实值(E不需要计算)

3.E为全1

这时,如果有效数字M全部都是0,那么直接表示为 ±无穷大

因为E想要全1,肯定就是初始值128,128+127=255就是全1

( ± 1.xxxxx * 2^128)不大才怪

五、浮点数指针

在进行这一板块之前,我们先来复习一下指针:

  1. 浮点数的指针类型通常是 float *(对于单精度浮点数)或 double *(对于双精度浮点数)。
  2. 指针类型决定了指针解引用时访问的数据大小。
  3. 指针变量中存储的是内存地址,这些地址指向了存储浮点数值的内存位置。
  4. 在32位系统上,指针通常占用4个字节;在64位系统上,指针通常占用8个字节。

复习完指针,我们就来看看一道经典的题目:

c 复制代码
int main() {
	int n = 9;
	float* pFloat = (float*)&n;
	printf("n的值为:%d\n", n);

	printf("*p的值为:%f\n", *pFloat);
	*pFloat = 9.0;
	printf("n的值为:%d\n", n);
	printf("*p的值为:%f\n", *pFloat);

	return 0;
}

这道题目大多数人在做的时候总会有疑问。诶?为什么第一个*p的值为0?我不是取了n的地址并且解引用了吗?为什么第二个n的值不是9,而是一个那么大的数字?

我们来一步步分析代码。

  • 第一个n打印的值是=9没有问题,因为前面赋值了。
  • 第一个*p ,首先我们进行了&n的操作,去除了n的地址,然后强制类型转换为浮点型的指针。并且把这个浮点指针赋值给pFloat。但是,int 通常以二进制补码形式存储整数,而 float 则遵循IEEE 754标准存储浮点数,他们的存储规则不一样,所以在存储时会存在问题。
    它是这么执行的:
    n的 * int 强制类型转换成 * float,然后根据IEEE754规则改写成
    1001.0 -> 1.001 * 2 ^ 3。此时,S = 0,E = 3, M = 1.001
    然后E + 真实值127 得到E = 130,然后将整段代码改写成二进制码:
    0 10000010 00100000000000000000000
    因为符号位是0,程序会判定这个地址是一个正数,正数的原码与补码相同,所以就会直接打印。但是要注意,这是n的地址,不是* pFloat的地址

    这也是为什么这里第二个*p不是我们想要的值的原因。至于为什么是0,当你通过 *pFloat 访问值时,编译器会尝试将该地址处的4个字节解释为 float 类型的值。由于 int 和 float 的表示方式不同,这4个字节在按照 float 的格式解释时,就会得到一个与原始 int 值完全不同的结果。而这个结果是不可预测的,可能在这个平台是0,在别的平台就不一定了。
  • 第三个n其实就是上面那个n强制类型转换取地址后再解引用得到的值,因为发生了从 int* 到 float* 的显式类型转换。所以值很大而且也不是我们所想的9
  • 第四个由于上面我们把*pFloat的值进行了赋值操作,所以打印的就是9

六、结语

以上就是数据在内存中的存储,也是我个人的学习经验总结。虽然可能是话痨了点,但大多确实是我的思考过程。希望以上的文章可有帮到你,如有错误也欢迎大佬指出。

那么,下篇文章再见,bye~

(PS.那一张signed的图解花了我不少心血,希望能够帮助你理解!)

相关推荐
喵手1 小时前
Java 与 Oracle 数据泵实操:数据导入导出的全方位指南
java·开发语言·oracle
硬汉嵌入式2 小时前
H7-TOOL的LUA小程序教程第16期:脉冲测量,4路PWM,多路GPIO和波形打印(2024-10-25, 更新完毕)
开发语言·junit·小程序·lua
加载中loading...2 小时前
Linux线程安全(二)条件变量实现线程同步
linux·运维·服务器·c语言·1024程序员节
Wx120不知道取啥名2 小时前
C语言之长整型有符号数与短整型有符号数转换
c语言·开发语言·单片机·mcu·算法·1024程序员节
Python私教3 小时前
Flutter颜色和主题
开发语言·javascript·flutter
代码吐槽菌3 小时前
基于SSM的汽车客运站管理系统【附源码】
java·开发语言·数据库·spring boot·后端·汽车
Ws_3 小时前
蓝桥杯 python day01 第一题
开发语言·python·蓝桥杯
zdkdchao3 小时前
jdk,openjdk,oraclejdk
java·开发语言
神雕大侠mu4 小时前
函数式接口与回调函数实践
开发语言·python
Y.O.U..4 小时前
STL学习-容器适配器
开发语言·c++·学习·stl·1024程序员节