数据类型存储原理数据的存储 - 深度剖析数据在内存中的存储

Posted Aaronskr

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据类型存储原理数据的存储 - 深度剖析数据在内存中的存储相关的知识,希望对你有一定的参考价值。

🌹前言

我们在敲代码的时候总是会定义各种变量,对各种数据进行存储,比如int a = 10;就是将10这个数据存放进变量a中,而变量a,就是我们在内存中申请开辟的一块空间。
在内存中如何开辟空间给变量的问题博主已经在函数栈帧里用反汇编的方式将其原理剖析了,具体可看图解函数栈帧 - 函数栈帧的创建及销毁
本文将进一步剖析在已经开辟好存储单元的情况下,各种数据是如何存储的。


在了解数据如何存储之前,应该先了解我们常见的数据类型。

✨数据类型汇总

在C99标准中,我们可将数据类型划分为以下几大类。

  1. 整型家族
  2. 浮点型家族(实型家族)
  3. 自定义类型(构造类型)
  4. 指针类型
  5. 空类型

下面一一介绍这五种类型的基本情况。

🎁整型家族

char
		unsigned char
		signed char
short
		unsigned short [int]
		signed short [int]
int
		unsigned int
		signed int
long
		unsigned long [int]
		signed long [int]

注:在C99之后的标准规定,将char类型数据划分为整型家族,因为字符在内存中会将其转化为ASCII码值进行存储。

如上所示,所有的整型家族都被分为有符号整型和无符号整型,并且signed都是可以被省略的,换言之,signed int完全等价于int,其他以此类推,但其中有一个例外: char类型和signed char并不等价,只写一个char ch = 0;我们将无法分辨这个ch变量到底是有符号字符型还是无符号字符型,他完全取决于编译器,但经博主测试,大部分编译器下char类型都被编译器翻译为有符号的char类型。

在C99中还引入了long long - 长长整型,用法和long类型一致,但C语言语法规定,sizeof(long)<= sizeof(long long),而long类型所占内存大小为4/8字节,所以long long类型所占内存空间大小一定为8个字节。

🙈浮点型家族

float
double

浮点型家族只有float和double这两种类型,float类型所占空间大小为4byte,double类型所占空间大小为8byte。

他们之间的区别除了所占空间大小不同之外还有精度的区别,float称为单精度浮点型,有效精度为小数点后6位,而double类型称为双精度浮点型,精确到小数点后15位,但其有效数字只有11位左右。

🦝自定义类型

> 数组类型
> 结构体类型 struct
> 枚举类型 enum
> 联合类型 union

这里可能会有很多人无法李姐为什么数组类型也被划分为自定义类型,这里稍微做一些解释。

我们知道数组类型的变量定义形式:数据类型+数组名+[数组大小];

如:

int arr[10] = { 0 };

这里可能会让很多人产生误区,认为arr数组的类型是int类型,也就把这条语句理解为是int类型的、数组名为arr的数组大小为10的数组,其实不然,这个数组的数组名确实是arr,但其数据类型是int [10],这里可能让大部分人无法接受,

举个简单的例子即可解释:

我们知道,sizeof操作符是用来计算所占内存空间大小的,其操作数既可以是变量名,也可以是变量类型。

#define _CRT_SECURE_NO_WARNINGS 1

#include <stdio.h>

int main()
{
	int a = 10;

	printf("%d\\n", sizeof(a));
	printf("%d\\n", sizeof(int));

	return 0;
}

这两种写法都正确,打印结果为:

而对于数组,操作数也同样可以是数组名或者数组类型:

#define _CRT_SECURE_NO_WARNINGS 1

#include <stdio.h>

int main()
{
	/*int a = 10;

	printf("%d\\n", sizeof(a));
	printf("%d\\n", sizeof(int));*/

	int arr[10] = { 0 };

	printf("%d\\n", sizeof(arr));
	printf("%d\\n", sizeof(int[10]));

	return 0;
}

其打印结果为:


这么一来,就验证了int [10]是数组类型。

知道了这点,解释为什么数组类型是自定义类型就更清晰了,用上面解释的结论就可以知道,int arr[10]和int arr[9]的数组类型不同,并不都是int类型的,数组大小是我们程序员人为规定的,所以可以把他划分为自定义类型。

其他的自定义类型比较明显,这里就不一一解释。

🐱‍🏍指针类型。

指针类型很特殊。

我们常说的指针有两个含义:

  1. 某一个变量的地址,也就是其在内存中的编号,我们可称其为指针。
  2. 用于存放地址(编号)的变量,我们称其为指针变量,常简称指针。

指针类型的定义方式为:

数据类型+*(用于标识指针类型)+指针变量名

常见的指针类型有:

int* pi;
char* pc;
float* pf;
void* pv;

这里着重介绍一点,指针变量赋值大部分都是取出某变量地址存放进指针变量,如int pc = &c;

但有一个例外:

int main()
{
	char* pc = "hello world";

	printf("%c\\n", *pc);

	return 0;
}

这里之间将一个字符串常量赋值给指针变量pc,我们知道,字符串常量时放在常量区的,他的值不可修改,并且这里的字符串加上隐藏的’\\0’总共是12个字节,而我们的指针变量根据平台的不同只能是4/8个字节,怎么都不可能放的下这个字符串常量,所以这么理解是错误的。

我们将其打印看看结果:


打印结果为单字母h,这么一来其实就解释的通了,将整个常量字符串赋值给指针变量,其实并不会把整个字符串放进去,而是把整个字符串的首地址赋给指针变量,比较指针存放的就是地址,这和将字符数组名赋值给指针变量类似,存放的都是首元素地址。

🐥空类型

void 用于表示空类型(无类型)
通常应用于函数的返回类型、函数的参数、指针类型。

下面举几空类型的例子帮助理解:

  • 返回类型:
void test(int x)
{
	printf("%d\\n", x);
}

int main()
{
	int a = 10;
	test(a);

	return 0;
}

这里test函数的返回类型就是void。

  • 函数的参数:
int test(void)
{
	return 1;
}

int main()
{
	int ret = test();

	printf("%d\\n", ret);

	return 0;
}

这个代码就是将函数的参数置为空,表示不允许主调函数传参,如果非要传参,编译器将给出警告。

int test(void)
{
	return 1;
}

int main()
{
	int a = 10;
	int ret = test(a);

	printf("%d\\n", ret);

	return 0;
}

  • 指针类型:
void* pc;

表示定义一个指针pc,但他什么都不指向,作为一个空指针存在。


🕸大小端字节序说明

我们知道不管是什么样的数据,最终都会被编译器编译为二进制机器码进行存储,并且我们的内存是以字节为最小存储单元划分而进行存储的,那么就存在了一个问题,数据以字节为单位进行存储的时候,是以怎样的顺序进行存储的呢?这就引出了大小端字节序的概念。

🧠出现大小端字节序的原因

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

例如:一个16bit位的short类型变量x ,在内存中的地址为0x0010,变量x 的值为0x1122 ,那么0x11为高字节,0x22为低字节。对于大端模式,就将 0x11放在低地址中,即0x0010中,0x22 放在高地址中,即0x0011中。小端模式,刚好相反。我们常用的X86(32位平台)结构是小端模式,而KEILC51则为大端模式。很多的ARM,DSP都为小端模式。有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。

🐉字节序的概念

字节序,即字节顺序,又称端序或尾序,在计算机科学领域中,指「存储器」中或者「数字通信链路」中,组成多字节的字节排列顺序 。在几乎所有的机器上,多字节对象都被存储为连续的字节序列 。例如在C语言中,一个 int类型的变量x地址为0x100,那么其对应的地址表达式&x的值为0x100 且 x 的4个字节将被存储在存储器的0x100, 0x101, 0x102, 0x103位置。字节的排列方式有2个通用规则。

  1. 顺序排列 - 大端字节序
  2. 逆序排列 - 小端字节序

上面的文字描述也许过于抽象,接下来用较为容易理解的方式分别简单的介绍大端字节序和小端字节序的概念。

✋大小端字节序

所谓大小端字节序,就是将多字节数据中的高低字节位按不同顺序存放在内存中的高低地址处,相当于顺(逆)序存放。接下来博主将把上述抽象概念划分逐一介绍:

  1. 首先理解什么叫做多字节数据。

我们知道一个数据根据大小不同被划分为不同的数据类型,各数据类型所占字节数不同,我们也就据此根据数据字节大小来将其存放于不同的数据类型中。

比如字符类型 - 其扩展之后的ASCII码值为0~255,我们知道一个字节是8位,按照无符号字符型的理解也就是从00000000 ~ 11111111,刚好是0 ~ 255,所以字符类型被称为单字符类型数据。

而十六进制数,如:0x11223344则为多字节数据,其中有4个字节,分别是0x11、0x22、0x33、0x44,像这样的数据则被称为多字节数据。


  1. 理解什么叫做多字节数据的高字节位。

在一个二进制序列中,

如:01010110101001011010100101101001

我们把前方高亮部分的0101称为高字节位,把后端加删除线的1001 部分称为低字节位,以此区分。

其实很好理解,因为最后一个1的的权重为20,也就是2的0次方,而第一个0的权重为231,也就是2的31次方,以此来区分高低字节位也是很不错的选择。


接下来介绍大小端字节序的存储方式:

大端字节序

所谓大端字节序,就是将处于高字节位的数据存放在内存的低地址处,将处于低字节位的数据存放在内存的高地址处

如今给一数据:0x11223344

在内存中的存放形式为:


以这样的形式存放的模式,就称为大端存储模式,这样的存放顺序,也就被称为大端字节序。

小端字节序

所谓小端字节序,就是将处于高字节位的数据存放在内存的高地址处,将处于低字节位的数据存放在内存的低地址处

今给一数据:0x11223344

在内存中的存放形式为:


以这样的形式存放的模式,就称为小端存储模式,这样的存放顺序,也就被称为小端字节序。

在博主使用的VS2019编译器上,采用的就是小端字节序:

例:

int main()
{
	int a = 0x0000ff40;

	return 0;
}

调试 - 内存窗口(&a):


0x001DFEFC就是该代码中a变量的地址,存放情况为40 ff 00 00。

也就是小端存储模式。

👩‍🍳百度系统工程师笔试题(通过编程判断该编译器为大端存储还是小端存储)

百度2015年系统工程师笔试题:

请简述大端字节序和小端字节序的概念,设计一个小程序来判断当前机器的字节序。(10分)

该题前半部分在上文其实已经解决了,这里博主将分析问题,并实现代码。

🧣问题分析

要判断编译系统到底是大端存储还是小端存储,其实并不复杂。

如0x11223344

如果是在大端存储模式下:
存储方式为:11 22 33 44

如果是在小端存储模式下:
存储方式为:44 33 22 11

所以其实只需要知道第一个字节的内容到底是11还是44就可以判断了。
但这样的数据太过于复杂,不如换简单一点的数字,比如1。

1的高字节位就是00,低字节位就是01,比较好判断。

🎒代码演示

int check_sys(int x)
{
	return *(char*)&x;
}

int main()
{
	int a = 1;

	//约定:
	//如果是大端,返回0
	//如果是小端,返回1
	int ret = check_sys(a);
	if (ret)
	{
		printf("是小端存储模式\\n");
	}
	else
	{
		printf("是大端存储模式\\n");
	}

	return 0;
}

运行结果:

之前也分析了,我的编译器VS2019是小端存储模式,所以代码的结果正确,下面分析代码。

🎮代码分析

  1. 想要在4个字节中拿到第一个字节,只需要在取地址时将整型强制类型转换为字符型即可,拿到存放第一个字节的地址后对其解引用便可拿到第一个字节数据。

  2. 如果拿到的是01,说明存储方式是01 00 00 00,也就是小端存储模式,反之则为大端存储模式。

这里如果有没有讲清楚的地方,欢迎评论区留言或者私信博主解决嗷。


🧶整型数据在内存中的存储

数据在内存中的存储遵循一定的法则,而整型数据和浮点型数据在内存中所遵循的法则是不同的,这里我们先介绍整型数据在内存中是如何存储的。

介绍整型数据的存储需要先引进一个概念:原反补码。

💣原码、反码、补码

计算机中的有符号数有三种表示方法,即原码、反码和补码。三种表示方法均有符号位和数值位(或称有效位)两部分,符号位都是用0表示“正”,用1表示“负”,而数值位,三种表示方法各不相同。在计算机系统中,数值一律用补码来表示和存储。原因在于:使用补码,可以将符号位和数值域统一处理;同时,加法和减法也可以统一处理。
而补码其实是针对负数存储设定的,对于无符号数来说,其反码和补码都和原码相等。

原码:

所谓原码,就是将数据直接翻译为二进制序列。

拿32位平台举例,最高位作为符号位,正数的符号位为0,负数的符号位为1,后面的31位称为有效位,以不同的权重计算出不同的数字,最低位的权重为20,其次为21,以此类推。

如:

13的原码为:00000000000000000000000000001101

-3的原码为:10000000000000000000000000000011

反码:

反码,顾名思义,就是将原码的二进制序列按位取反,但这里需要注意,并不是将所有的二进制位都按位取反,符号位是特殊独立出来的,他表示一个数的正负,随意取反可能会遭遇意想不到的结果。

所以反码应该通过原码除符号位,其他位按位取反获得。
(注:正数的反码和原码相等。)

如:

13的反码为:00000000000000000000000000001101

-3的反码为:11111111111111111111111111111100

补码:

整数在内存中的存储存的都是补码,所以要通过上面的反码求出补码,补码的获取规则是原码按位取反(除符号位)再加一。
(注:正数的补码和原码相等。)

如:

13的补码为:00000000000000000000000000001101

-3的补码为:11111111111111111111111111111101

因为整数在内存中的存储形式是补码,所以引出原反补的意义就是求出补码,而补码的计算公式为:补码 = 原码按位取反(除符号位)再加一

这里我们通过VS2019编译器进行验证内存中存储的是数据的补码:

int main()
{
	int a = 13;
	//原码:00000000 00000000 00000000 00001101
	//反码:01111111 11111111 11111111 11110010
	//补码:01111111 11111111 11111111 11110011

	int b = -3;
	//原码:10000000 00000000 00000000 00000011
	//反码:11111111 11111111 11111111 11111100
	//补码:11111111 11111111 11111111 11111101

	return 0;
}

编译器下调试 - 内存 - &a:


内存中存储的是:0d 00 00 00

为小端存储模式,00001101转换为十六进制就是0d。

编译器下调试 - 内存 - &b:


内存中存储的是:fd ff ff ff

为小端存储模式,1111 1111转换为十六进制就是ff,1111 1101转换为十六进制就是fd。

如此说来,在内存中真的存放的就是补码,所以为了弄清楚整型数据在内存中的存储,必须牢牢掌握原反补的概念。


🔨截断与整型提升

我们知道int类型的变量所占空间大小是4个字节32个bit位(32位平台下),而char类型的变量所占空间大小是1个字节8个bit位,那我要怎么将一个整型的数据存放在一个char类型的变量里呢?这里教大家一个很有用的办法,那就是没办法,32个比特位是不可能放进8个小格子里的,所以就会发生所谓的截断

我们知道,一个char类型只能存放8个比特位,那如果我要将char类型的数据以%d的形式打印,也就是看做32位数据将其打印,那有要怎么做呢?再教大家一个办法,那依然是没办法,所以编译器只能对char类型的数据进行整型提升

接下来简单讲解截断和整型提升的原理。

截断

假设我有一个32位二进制序列:
01010011001000110001000100100011

这是一个非常大的数字:

有一个char类型的空间:

在把32位数字往里放的时候会发现放不下,便会发生截断,只保留低八位的数字,其他24位数字直接舍弃,

最终存放的结果为:

这就是截断的过程。

整型提升

当我要将char类型的数据以%d的形式打印时,我们知道,%d是打印有符号整型,打印的是32位0/1序列的最终结果,但我们的char类型里只存放了8位,这个时候就会发生整型提升。

整型提升规则:

  1. 如果对无符号数进行整型提升,则在前面补24位0。
  2. 如果对有符号数进行整型提升,则判断该数在当前的二进制0/1序列的首元素,相当于符号位。
    - 如果是0,则全补0
    - 如果是1,则全补1

如:

今有一8位无符号数。

unsigned char a = 148;

首先我们写出该数的二进制序列。

10010100 - 148

由于变量a是无符号类型的,所以不管该二进制序列首元素是0还是1,都将全部补0

获得:

00000000000000000000000010010100

最终打印的结果就是148

🎉整型数据存储练习

对以下代码分析输出结果:

1.
//输出什么?
int main()
{
	char a = -1;
	signed char b = -1;
	
	unsigned char c = -1;

	printf("a=%d b=%d c=%d\\n", a, b, c);

	return 0;
}

首先VS2019编译器对char类型的处理为默认认为是有符号的char,所以变量a和变量b属于同一类型。

先计算出-1的补码。

int main()
{
	//-1
	//原码:10000000000000000000000000000001
	//反码:11111111111111111111111111111110
	//补码:11111111111111111111111111111111
	
	char a = -1;
	signed char b = -1;
	

	unsigned char c = -1;
	

	printf("a=%d b=%d c=%d\\n", a, b, c);

	return 0;
}

三个变量都是char类型,所以存储时都将发生截断

int main()
{
	//-1
	//原码:10000000000000000000000000000001
	//反码:11111111111111111111111111111110
	//补码:11111111111111111111111111111111

	char a = -1;
	//存储的补码:11111111
	signed char b = -1;
	//存储的补码:11111111

	unsigned char c = -1;
	//存储的补码:11111111

	printf("a=%d b=%d c=%d\\n", a, b, c);

	return 0;
}

现在要将三个变量以%d形式打印,则会发生整型提升

  • 而对于变量a和变量b来说,存放的是有符号的char,根据第一个二进制位决定提升的数为1,所以

变量a和变量b整型提升后的结果为:

11111111111111111111111111111111
  • 而对于变量c来说,它是无符号的char,直接全部补0,所以

变量c整型提升后的结果为:

00000000000000000000000011111111

因为提升后的c符号位是0,所以原反补码均相等。

而按%d形式打印需要将补码转化为原码后转化为十进制进行打印,

所以:

int main()
{
	//-1
	//原码:10000000000000000000000000000001
	//反码:11111111111111111111111111111110
	//补码:11111111111111111111111111111111

	char a = -1;
	//存储的补码:11111111
	//提升后的补码:11111111111111111111111111111111
	//提升后的反码:10000000000000000000000000000000
	//提升后的原码:10000000000000000000000000000001
	signed char b = -1;
	//存储的补码:11111111
	//提升后的补码:11111111111111111111111111111111
	//提升后的反码:10000000000000000000000000000000
	//提升后的原码:10000000000000000000000000000001

	unsigned char c = -1;
	//存储的补码:11111111
	//提升后的补码:00000000000000000000000011111111
	//提升后的反码:00000000000000000000000011111111
	//提升后的原码:00000000000000000000000011111111

	printf("a=%d b=%d c=%d\\n", a, b, c);

	return 0;
}

这么一来,打印的结果就应该是-1 -1 255

打印结果:

  1. 下面程序输出什么?
2.
int main()
{
	char a = -128;

	printf("%u\\n", a);

	return 0;
}

这道题的变量a是有符号的char类型的。

首先计算出-128的原反补码。

int main()
{
	char a = -128;
	//-128
	//原码:10000000000000000000000010000000
	//反码:11111111111111111111111101111111
	//补码:11111111111111111111111110000000

	printf("%u\\n", a);

	return 0;
}

将01111111111111111111111110000000这样一个二进制序列存放进a中将会发生截断

截断之后a中存放的结果为:10000000

这时以%u的形式打印,也就是以无符号整型的形式打印,要进行整型提升,而变量a是一个有符号的char类型,第一个元素是1,所以整型提升24个1。

int main()
{
	char a = -128;
	//-128
	//原码:10000000000000000000000010000000
	//反码:11111111111111111111111101111111
	//补码:11111111111111111111111110000000

	//截断的结果:10000000
	//整型提升后的结果:11111111111111111111111110000000

	printf("%u\\n", a);

	return 0;
}

这时要将提升之后的补码转换为原码后以十进制的形式进行打印。

而%u的形式将把补码中的符号位看做是有效位,所以其原反补都是一样的。

int main()
{
	char a = -128;
	//-128
	//原码:10000000000000000000000010000000
	//反码:11111111111111111111111101111111
	//补码:11111111111111111111111110000000

	//截断的结果:10000000
	//整型提升后的结果:11111111111111111111111110000000
	
	//补码:11111111111111111111111110000000
	//反码:11111111111111111111111110000000
	//原码:11111111111111111111111110000000

	printf("%u\\n", a);

	return 0;
}

而11111111111111111111111110000000的值应该是4,294,967,168

所以输出结果:

3.
int main()
{
	char a = 128;

	printf("%u\\n", a);

	return 0;
}

还是一样,先求出128的补码,由于128是正数,所以其原反补都是相同的为:

00000000000000000000000010000000

存放进变量a中将发生整型截断:

10000000

而变量a为有符号的char类型,所以整型提升为

11111111111111111111111110000000

变量a以%u形式打印,则把符号位看成有效位,则此时原码反码补码相同,直接进行计算,11111111111111111111111110000000的十进制形式为4,294,967,168

所以打印结果为:

4.
int mian()
{
	int i = -20;
	unsigned int j = 10;

	//按照补码的形式进行运算,最后格式化成为有符号整数
	printf("%d\\n", i + j);
	
	return 0;
}

还是先把-20和10的补码计算出来,但是这里的i和j都是整型变量,所以不会发生截断和整型提升。

int mian()
{
	int i = -20;
	//-20
	//原码:10000000000000000000000000010100
	//反码:11111111111111111111111111101011
	//补码:11111111111111111111111111101100
	unsigned int j = 10;
	//10
	//补码:00000000000000000000000000001010


	//按照补码的形式进行运算,最后格式化成为有符号整数
	printf("%d\\n", i + j);
	
	return 0;
}

数据的计算是按照二进制补码的形式进行计算的,最后的结果再根据打印要求或者存储要求进行调整更改。

计算的结果

int mian()
{
	int i = -20;
	//-20
	//原码:10000000000000000000000000010100
	//反码:11111111111111111111111111101011
	//补码:11111111111111111111111111101100
	unsigned int j = 10;
	//10
	//补码:00000000000000000000000000001010

	//计算:
	//11111111111111111111111111101100
	//00000000000000000000000000001010
	//11111111111111111111111111110110 - 补码相加的结果

	//按照补码的形式进行运算,最后格式化成为有符号整数
	printf("%d\\n", i + j);
	
	return 0;
}

要求按%d的形式打印,则将计算的结果转化为原码以有符号十进制数打印。

补码:11111111111111111111111111110110
反码:10000000000000000000000000001001
原码:10000000000000000000000000001010

计算结果为-10

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

	return 0;
}

程序分析:

变量i从9开始自减到0时,都可以正常进入程序打印的值就是

9 8 7 6 5 4 3 2 1 0

在打印完0之后,变量i再自减1,变成-1,按道理来说应该跳出循环,但我们注意,这里的变量i为无符号整型,而-1的补码为11111111111111111111111111111111,所以会被解析为一个特别大的正整数:4294967295。


那么他也符合循环控制条件(i >= 0),所以循环会继续4294967295次,而一直自减到0的时候,再次自减又变成-1,有被解析为4294967295,所以该程序将无限循环下去。

这里博主随便截两张打印结果的图供大家参考。

    以上是关于数据类型存储原理数据的存储 - 深度剖析数据在内存中的存储的主要内容,如果未能解决你的问题,请参考以下文章

    深度剖析数据在内存中的存储之整形在内存中的存储以及大小端介绍

    深度剖析数据在内存中的存储1——数据类型

    C语言之深度剖析数据在内存中的存储

    深度剖析数据在内存中的存储

    深度剖析数据在内存中的存储

    C语言 - 深度剖析数据的存储