【逐步剖C】-第七章-数据的存储

一、数据类型介绍

1. C语言基本内置类型:

char 	//字符数据类型
short 	//短整型
int 	//整形
long 	//长整型
long long 	//更长的整形
float 	//单精度浮点数
double 	//双精度浮点数

2. 类型的基本归类

(1)整型:

char
	unsigned char
	signed char
short
	unsigned short
	signed short
int
	unsigned int
	signed int
long
	unsigned long
	signed long

char类型也为整型原因:其底层存储的是字符的ASCII码值
(2)浮点型:

float
double

(3)构造类型/自定义类型:

> 数组类型	int[常数]
> 结构体类型 struct
> 枚举类型   enum
> 联合类型   union

(4)指针类型:

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

(5)空类型:

void

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

二、整型的存储形式

这里的知识在【逐步剖C】第四章-操作符中也有介绍,这里再详细化一些。

1. 原码、反码、补码

计算机中的整数有三种2进制表示方法,即原码、反码和补码。
三种表示方法均有符号位和数值位两部分,符号位用0表示“正”用1表示“负”
对于正数来说,原、反、补码都相同,也就是数值位都相同
对于负数来说,原、反、补码之间存在着转换关系,也就是数值位不同

  • 原码
    直接将数值按照正负数的形式翻译成二进制就可以得到原码。
  • 反码
    将原码的符号位不变,其他位依次按位取反就可以得到反码。
  • 补码
    反码+1就得到补码。
    (PS:补码-1按位取反就能得到原码;补码+1按位取反也能得到原码)

对于整型数据来说,在内存中其实存放的是补码。
原因如下:

在计算机系统中,数值一律用补码来表示和存储。原因在于,使用补码,可以将符号位和数值域统一处理;
同时,加法和减法也可以统一处理(CPU只有加法器)此外,补码与原码相互转换,其运算过程是相同的,不需要额外的硬件电路。

如下代码:

int main()
{
	int a = 20;
	int b = -10;
	return 0;
}

我们从调试的内存窗口中可以验证这一点:
在这里插入图片描述
20的补码的二进制序列为:

0000 0000 0000 0000 0000 0000 0001 0100

转换为十六进制为:

00 00 00 14

-10的补码的二进制序列为:

1111 1111 1111 1111 1111 1111 1111 0110

转换为十六进制为:

ff ff ff f6

由此我们知道整型在内存中确实是以补码的形式进行存储的,但和内存窗口相比我们发现有点不对劲:转换出来的十六进制和内存窗口中展现的十六进制的以字节为单位(两个十六进制位,也就是8个比特位,为一个字节)的排列顺序是反着的。下面我们就来讲解这一个问题。

三、大小端

1. 大小端的概念

正式讲解之前先简单说明把数据往内存中存储的规则:怎么放进去,就要怎么拿出来。假设int a = 0x11223344(十六进制数);那么把a放到内存中就有许多种种方法:

//假设从左到右地址由低到高
//低地址------------------------------------>高地址
11 22 33 44
44 33 22 11
11 33 22 44
......

但考虑到怎么放进去,就怎么取出来的规则,我们只采取前两种存储方式(有迹可循)。
再回到0x11223344这个十六进制数,44是它的低位,11是它的高位
此时我们就把第一种存储方式(11223344)称为大端存储;把第二种存储方式(44332211)称为小端存储,下面是完整概念:

  • 大端字节序存储模式:是指数据的低位保存在内存的高地址中;而数据的高位,保存在内存的低地址中。
  • 小端字节序存储模式:是指数据的低位保存在内存的低地址中;而数据的高位,,保存在内存的高地
    址中。

2. 大小端的由来

关于大端小端名词的由来,有一个有趣的故事,来自于Jonathan Swift的《格利佛游记》:Lilliput和Blefuscu这两个强国在过去的36个月中一直在苦战。战争的原因:大家都知道,吃鸡蛋的时候,原始的方法是打破鸡蛋较大的一端,可以那时的皇帝的祖父由于小时侯吃鸡蛋,按这种方法把手指弄破了,因此他的父亲,就下令,命令所有的子民吃鸡蛋的时候,必须先打破鸡蛋较小的一端,违令者重罚。然后老百姓对此法令极为反感,期间发生了多次叛乱,其中一个皇帝因此送命,另一个丢了王位,产生叛乱的原因就是另一个国家Blefuscu的国王大臣煽动起来的,叛乱平息后,就逃到这个帝国避难。据估计,先后几次有11000余人情愿死也不肯去打破鸡蛋较小的端吃鸡蛋。这个其实讽刺当时英国和法国之间持续的冲突。Danny Cohen一位网络协议的开创者,第一次使用这两个术语指代字节顺序,后来就被大家广泛接受。——来源于网络

3. 大小端的意义

为什么会有大小端模式之分呢?这是因为在计算机系统中,我们是以字节为单位的,每个地址单元 都对应着一个字节,一个字节为8 bit。但是在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处理器还可以由硬件来选择是大端模式 还是小端模式。

4. 检验大小端

一道来自百度的经典笔试题:

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

我们解决这道题的思路是:对于1这个整型,若是小端字节序,那么其每个字节在内存中的存储序列就应该为:

//内存:
//低地址------------------------------------>高地址
01 00 00 00

若是大端字节序,那么其每个字节在内存中的存储序列就应该为:

//内存:
//低地址------------------------------------>高地址
00 00 00 01

这样看来,我们就只需要检验一下1的第一个字节是否等于1就行,由前面指针类型的知识可知,我们可以用 char* 类型的指针拿到它的第一个字节,代码如下:

#include<stdio.h>
int check_sys()			//大端返回0,小端返回1
{
	int i = 1;
	char* pc = (char*)&i;
	if (*pc == 1)
		return 1;
	else
		return 0;
}

int main()
{
	int ret = check_sys();
	if (ret == 1)
		printf("小端\n");
	else
		printf("大端\n");
	return 0;
}

仔细想想,我们还可对代码的函数部分进行优化,使其更简洁一些,优化思路如下:
(1)对于值为1这个整型变量i而言,*pc在小端字节序中就是1;在大端字节序中就是0;故可以不需要判断而直接返回解引用的结果,即:return (*pc);
(2)对于指针变量pc而言,其本质上存储的是变量i的地址,那么对pc的解引用操作(*pc)就可以简化为在取到i的地址并强制转换后直接进行解引用,即*(char*)&i
那么整个函数优化后的结果为:

int check_sys()
{
	int i = 1;
	return (*(char *)&i);
}

四、有关整型数据存储的经典题目

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;
}

输出结果:
在这里插入图片描述
解释:abc在二进制中的存储其实都相同,都为11111111,但对a,b来说,最高位上的1代表了负号,整型提升时会补1(关于整型提升的详细讲解,可参考:【逐步剖C】第四章-操作符);
而c(无符号字符)的最高位就是数字1,整型提升时会补0PS:这里可以理解为无符号数总大于等于0,故符号位就为0,也就是提升时会补0)。最后需要注意的一点是,打印是以原码形式打印,而正数(无符号)的原反补码相同,不需要再用补码往回转换为原码了。
补充一点:char到底是signed还是unsigned,C语言无明确规定,取决于编译器,一般来说都是有符号char。但int和short指的就都是signed。

2. 程序的输出结果是?

#include <stdio.h>
int main()
{
	char a = -128;
	printf("%u\n",a);
	return 0;
}

输出结果:
在这里插入图片描述
解释:字符a在内存中的二进制序列为:10000000,输出语句要求以无符号整数的形式输出,此时a发生整型提升,高位补a的符号位,提升后的二进制序列为:

1111 1111 1111 1111 1111 1111 1000 0000

对于无符号数来说,原、反、补码也都是相同的,故最后打印的数就是按如上二进制序列转换出的十进制数。我们可以通过计算器得到验证:
在这里插入图片描述

3. 程序的输出结果是?

#include <stdio.h>
int main()
{
	char a = 128;
	printf("%u\n",a);
	return 0;
}

输出结果和上题一样。

这里先关于char类型做一个补充:char类型所能表示的数据范围为 -128 ~ 127。但我们知道char类型的大小为1个字节, 也就是八个比特位, 所以对于-128而言,是无法在八个比特范围内直接用二进制表示的,故直接规定了二进制序列1000 0000就表示-128;而二进制序列1000 0000 就由二进制序列 0111 1111(也就是十进制的127) +1 而来.那么其实整个数据范围就可看作是一个环:
在这里插入图片描述

不管将来加减多少,数据范围都只会在这个环中变动,这一点对于所有数据类型都是如此

解释:由上面补充的知识点可得,把128存入a中实际上是把-128存入a中,故最后输出结果与上一题相同。

4. 程序的输出结果是?

#include <stdio.h>
int main()
{
	int i = -20;
	unsigned int j = 10;
	printf("%d\n", i + j);
}

输出结果:
在这里插入图片描述
解释: 在执行i+j时变量i确实会被提升为无符号整型,但本质上二进制序列的运算是一样的。即提升前后, -20的二进制序列都为:

1111 1111 1111 1111 1111 1111 0001 0100

10的二进制序列为:

0000 0000 0000 0000 0000 0000 0000 1010

两二进制序列进行相加,最后把补码转换为原码后,再转换为十进制数进行输出,故最终结果为-10。

5. 程序运行的结果是?

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

答案是:死循环.
解释: 结合上面补充的关于类型范围的知识可知, 当i--到0时, 再进行自减就会变为无符号整型能表示的最大的那一个数, 然后继续自减, 但永远不会跳出我们上面所提到的那个 “环”.。
还有一种理解方式就是: 无符号数恒大于等于0

6. 程序输出的结果是?

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

运行结果:
在这里插入图片描述
解释: 从循环体中我们得知,第一个存进数组中的元素为-1, 然后是-2, -3 …-128, 127, 126…最后到0完成了char类型所能表示的数据范围的一个循环 ( 还是那个"环" ) 直至不满足循环条件, 循环停止。在用strlen函数计算长度的时候,其实就是在数组中找0 (因为是字符数组, 所以0存进去就等价于是 '\0') , 看0之前有几个元素, 故输出结果为255。

7. 程序运行的结果是?

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

答案: 死循环
解释: 同第5题,知识点还是那个 “环”

四、浮点型的存储

1. 浮点数的存储规则

根据国际标准IEEE(电气和电子工程协会)754,任意一个二进制浮点数 V 可以表示成下面的形式:

  • (-1)S * M * 2E
  • (-1)S表示符号位,当S=0,V为正数;当S=1,V为负数。
  • M表示有效数字,大于等于1,小于2。
  • 2E表示指数位

来两个例子:
十进制的5.0,写成二进制就是101.0,按标准就写成:(-1)0 * 1.01 * 22
其中 S=0;M=1.01;E=2
十进制的5.5,写成二进制就是101.1,按标准就写成:(-1)0 * 1.011 * 22
其中 S=0;M=1.011;E=2
IEEE 754规定:

  • 对于32位的浮点数,最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M
    在这里插入图片描述
  • 对于64位的浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M
    在这里插入图片描述

(1) 对有效数字 M 的规定:
前面说过, 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应该为一个无符号整数(unsigned int)。这意味着,如果E为8位,它的取值范围为0 ~ 255;如果E为11位,它的取值范围为0 ~ 2047。但是,我们知道,科学计数法中的E是可以出现负数的,所以,为了保证E为无符号数的取值范围,IEEE 754规定,存入内存时E的真实值必须再加上一个中间数对于8位的E,这个中间数是127对于11位的E,这个中间数是1023。比如,2^10的E是10(十进制),所以保存成32位浮点数时,必须保存成10+127=137,即10001001(二进制)。
比如:
十进制的5.0,二进制为101.0,按标准写成:(-1)0 * 1.01 * 22
其中 S=0;M=1.01;E=2,而按存入的规定就变成了:
S=0;M=0.01;E=2+127=129
故浮点数5.0在内存中的二进制序列就为:

0 10000001 01000000000000000000000
S     E              M

可转换为16进制后通过编译器调试进行验证
转换为16进制:

0100 0000 1010 0000 0000 0000 0000 0000	//二进制
40 a0 00 00	//十六进制

验证代码:

#include<stdio.h>

int main()
{
	float f = 5.0f;
	return 0;
}

验证结果:
在这里插入图片描述

取出时:
可分为三种情况:

  • E不为全0或不为全1:
    可以理解为是“正常”的情况,即怎么存进去就怎么拿出来。即指数E的计算值减去127(或1023),得到真实值,再将有效数字M前加上第一位的1。
  • E为全0:
    这时,浮点数的指数E等于1-127(或者1-1023)即为真实值,有效数字M不再加上第一位的1,而是直接还原为0.xxxxxx的小数。这样做是为了表示±0(正负取决于符号位S),以及接近于0的很小的数字。(PS:可以想想,如果E+127后为全0,那原来就是M * 2-127,非常之小)
  • E为全1
    这时,如果有效数字M全为0,表示±无穷大(正负取决于符号位S)(PS:和E为全0刚好相反)

2. 浮点数存储的例子

看下面这段代码,可以利用前面的介绍的知识想想程序会输出什么:

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;
}

程序输出结果:
在这里插入图片描述
解释
首先,9在内存中的二进制序列为:

0000 0000 0000 0000 0000 0000 0000 1001

因为n本身为整型,所以第一条输出语句printf("n的值为:%d\n",n);输出的就是9;
接着语句float *pFloat = (float *)&n;的作用是让指针pFloat以浮点型的视角去看上面9在内存中的二进制序列进而进行解引用操作,即在pFloat的视角中,9的二进制序列就为:

0 00000000 00000000000000000001001
S    E               M    

由上述指数E取出的规则可知,此时E为全0,那么原来的数就为一个非常接近于0的数字,故第二条输出语句printf("*pFloat的值为:%f\n",*pFloat);输出的结果就为0.000000

语句*pFloat = 9.0;就将整型变量n中的值改为了9.0
然后,根据上述规则,我们可以得到9.0在内存中的二进制序列:

0 10000010 00100000000000000000000
S    E               M   

接着第三条输出语句printf("num的值为:%d\n",n);同理就是以整型的视角去看上面9.0在内存中的二进制序列,即在n的视角中,9.0的二进制序列就为:

0100 0001 0001 0000 0000 0000 0000 0000

用计算器我们可以算出这是一个很大的数:
在这里插入图片描述

最后一条输出语句printf("*pFloat的值为:%f\n",*pFloat);以浮点数的视角去看9.0在内存中的二进制序列,故正常输出9.000000

3. 一点说明

大家可以发现,上面所介绍的知识所用的例子都是一位小数。因为对于两位及以上的小数,其实是无法完美保存的。因为小数点后的权重可能永远无法在相加之后精确地得到那个小数点后的部分。如3.14后面的.14,无论怎么算都会有误差。所以,所谓double的更精确只是相对的。
这一点从调试中也能体现:
在这里插入图片描述
在这里插入图片描述

本章完。

看完觉得有觉得帮助的话不妨点赞收藏鼓励一下,有疑问或有误地方的地方还恳请过路的朋友们留个评论,多多指点,谢谢朋友们!🌹🌹🌹

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值