C生万物 | 深度挖掘数据在计算机内部的存储
一、前言
在之前,我们学习了有关C语言中的各种数据类型以及它们的存储空间大小,如下图所示
类型的意义:
- 使用这个类型开辟内存空间的大小(大小决定了使用范围)
- 如何看待内存空间的视角
二、类型的基本归类
接下去我将上面的这些类型做一个分类,大致分为以下5类
1、整型家族
首先看到的是【整型家族】,分别有char
、short
、int
、long
可能有些同学看到上面的这些很多类型有点懵,什么signed
、unsigned
,下面我就为你来先做一个解答:mag:
👉char为何归到整型家族?
- [x] 因为char在字符存储的时候存的是一个ASCLL码值,而ASCLL码值是一个整数
👉为什么有unsigned和signed两个不同的类型呢
- [x] 因为数值有正数和负数之分
- 有些数值只有正数,没有负数(身高)—— unsigned
- 有些数值,有正数也有负数(温度)—— signed
👉子分类后面的[int]
是什么?
- [x] 因为像
short
、long
这些都是属于整型的范畴,其实应该写成【signed short int】和【unsigned short int】这样,只是为了简写忽略了后面的int
👉像[char]、[signed char]、[unsigned char]
这些该如何区分?
- char 分为【char】、【signed char】、【unsigned char】
- short 分为【short == signed short】、【unsigned short】
- int 分为【int == signed int】
- long 分为【long == signed long】
2、浮点数家族
浮点数只分为两类,一个是【float】,一个则是【double】,这里只是做介绍,下文会专门介绍浮点数在内存中的存储
3、构造类型
有关构造类型的话就分为以下这四种,对于【结构体】、【枚举】、【联合】这里不再细说,会专门开章节叙述
- 主要的话是要提一嘴这个数组类型。例如看到下面的这三个数组,它们都是互不相同的,只要你修改了它的元素类型或者是元素个数,那这就是个不同的数组
4、指针类型
接下去是指针类型,对于int
、char
、float
这三种类型的指针我们之前都见到过,但是可能有同学没有遇见过这个void
类型的指针
- 它叫做【空指针】
- 对于int类型的指针可以用来接收int类型的数据的地址
- 对于char类型的指针可以用来接收char类型的数据的地址
- 对于float类型的指针可以用来接收float类型的数据的地址
- [x] 对于
void
类型的指针可以用来接收任何类型数据的地址【它就像一个垃圾桶一样,起到临时存放的作用】
5、空类型
void 表示空类型(无类型)
通常应用于函数的返回类型、函数的参数、指针类型
三、整型在内存中的存储【⭐】
接下去我们来聊聊有关整型的数据在内存中的存储形式
1、原码、反码、补码
对于原码、反码、补码来说我们之前在学习【操作符】的时候有遇到过,这么我们再来正式地介绍一下
① 概念介绍
计算机中的整数有三种2进制表示方法,即原码、反码和补码。
- 三种表示方法均有符号位和数值位两部分,符号位都是用
0
表示“正”,用1
表示“负”,而数值位- 正数的原、反、补码都相同
- 负整数的三种表示方法各不相同
接下去就来分别讲讲正数和负数的原、反、补码有什么不同
int a = 10;
- 对于正数说,因为原、反、补都是相同的,所以当我们写出其原码的时候,其实就可以得出它的反码和补码了
int a = -10;
- 对于负数来说就不太一样了,要得到反码就将原码==除符号位外其余各位取反==,要得到补码的话就在==反码的基础上 + 1==
其实除了这三种之外,还有一种叫做【移码】,如果你学习过《计算机组成原理》这门课应该就可以知道移码就是符号位与补码相反,数值位与补码相同。本文不过过多细究
② 原码与补码的转换形式总结
学习了概念后,我们来总结一下有关原码与补码的之间的转换
- [x] 原码到补码 —— 1种方式
- 原码取反,+1得到补码
- [x] 补码到原码 —— 2种方式
- 补码 - 1,取反得到原码
- 补码取反,+1得到原码
- 第1种很直观,我们主要来说说第二种,也就是将补码取反,+1得到原码,回想原码是怎么到补码的,其实你也就学会了补码怎么转换回原码的,只是这一种转换方式大家可能没有怎听说过
③ 探究计算机内部的存储编码
上面说到了三种整型编码方式,但是真正到了计算机内部使用的是哪个呢?
==对于整形来说:数据存放内存中其实存放的是补码。==
- 通过去VS中进行调试观察【调试】- 【窗口】- 【内存】就可以看到其实在内存中是以补码的形式存放的
- 但是有同学说:这个
f6 ff ff ff
是啥呀,怎么就补码了?通过看前面的内存地址可以发现这其实是16进制的表示方式,若是以32位2进制来进行存放的话就太长了,所以采取十六进制的形式 - 在【进制转换】中,4位二进制表示1位16进制。通过将补码4位4位进行一个划分就可以得出8个16进制的数字为
ff ff ff f6
,但是仔细一看却可以发现这和VS中我们所观察的结果有所不同,感觉倒了一下【这就要涉及到我们下面所要将的大小端存储】
但是你有疑惑过在计算机内部要以【补码】的形式进行存放,而不是以原码的形式存放呢?
- 因为其实很简单,虽然原码的表示形式简单易懂【只需要将真值的+ - 号转换为01即可】,但是原码的加法却异常复杂,需要考虑到两数是同号还是异号以及其他复杂的问题,所以为了解决这些矛盾,人们找到了==补码表示法==
- 在计算机系统中,数值一律用补码来表示和存储。原因在于,使用补码,可以将符号位和数值域统一处理
- 同时,加法和减法也可以统一处理(CPU只有加法器)此外,补码与原码相互转换,其运算过程是相同的,不需要额外的硬件电路
- 其运算过程是相同的其实也就印证了我上面介绍的补码转换为原码的第二种方式
- 虽然在计算机内部是以补码的形式进行存储,但是当其与我们进行交互的时候使用的却是原码的形式。
- 可能也有同学会疑惑上面的第二点讲【加法和减法也可以统一处理】,我们通过一个最简单的例子就是两数相加
+
来看看
int a = 1;
int b = -1;
int c = a + b;
printf("c = %d\n", c);
- 首先,我们写出a与b的补码,因为在内存中要以补码的形式进行存放和计算
int a = 1;
00000000 00000000 00000000 00000001 - 原/反/补码
int b = -1;
10000000 00000000 00000000 00000001 - 原码
11111111 11111111 11111111 11111110 - 反码
11111111 11111111 11111111 11111111 - 补码
- 接下去对这两个补码进行相加,因为二进制逢二进一,所以可以看到最后进位开头多出了一位
int c = a + b;
00000000 00000000 00000000 00000001
11111111 11111111 11111111 11111111
---------------------------------------------
100000000 00000000 00000000 00000000
- 但是呢,因为c为int类型的整数,所以只能存的下4个字节,也就是32个比特位的数据,所以将最高位【截断】之后剩下的32位全为0
100000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000 - 整型只能存放4B,32b
- 所以可以得出最后的答案为0。在内存中
1 - 1 = 0
是这样计算的,你明白了吗?
【总结一下】:
- [x] 内存中存放的都是补码
- [x] 整型表达式计算使用的内存中补码计算的
- [x] 打印和我们看到的都是原码
2、大小端介绍【补码存储的顺序】
① 大小端的由来
我们在开始可以先看这样一个故事
有两个特别强大的国家在过去进行了36个月的战争,在这期间发生了件事情,就是吃鸡蛋的时候,原始的方法是打破鸡蛋较大的一端,可那时的皇帝的祖父由于小时侯吃鸡蛋,按这种方法把手指弄破了,因此他的父亲,就下令,命令所有的子民吃鸡蛋的时候,必须先打破鸡蛋较小的一端,违令者重罚。然后老百姓对此法令极为反感,期间发生了多次叛乱,其中一个皇帝因此送命,另一个丢了王位,产生叛乱的原因就是另一个国家Blefuscu的国王大臣煽动起来的,叛乱平息后,就逃到这个帝国避难。据估计,先后几次有11000余人情愿死也不肯去打破鸡蛋较小的端吃鸡蛋。这个其实讽刺当时英国和法国之间持续的冲突。Danny Cohen一位网络协议的开创者,第一次使用这两个术语指代字节顺序,后来就被大家广泛接受,这个就是关于大端小端名词的由来
- 看完后可以发现,原来大小端的由来就是因为鸡蛋🥚要从哪头剥引起的
② 为什么要有大端和小端之分?
在计算机系统中,我们是以字节为单位的,每个地址单元都对应着一个字节,一个字节为8 bit
- 上面我们介绍过很多的数据类型,有【char】【int】【double】等等,不过除了8bit的char之外,还有16 bit的short型,32 bit的long型(要看具体的编译器),这些数据类型所定义的数值在内存中存放的都超过了1个字节了,要存储到内存中,就有导致一个顺序问题
- 因为在内存中我们是以字节为单位来讨论数据的存放,就好像下面这个
0x12345678
在内存中12为1个字节,34为一个字节,56为一个字节,78为一个字节,所以通过右侧的【内存】我们就可以看出虽然呈现的是一个倒着存放样子,但是呢并不是完全倒着,像87 65 43 21
,而是78 56 34 12
。这就是因为它们整体作为一个字节,讨论的是每个字节顺序,而不是每个字节内部的顺序
这,也就导致了【大端】和【小端】的由来,接下去呢就正式地来给读者介绍一下这种倒着存放的方式
③ 大(小)端字节序存储
首先来看一下它们的概念,这至关重要⭐
- [x] 【大端(存储)模式】:是指数据的
低位
保存在内存的高地址
中,而数据的高位
,保存在内存的低地址
中; - [x] 【小端(存储)模式】:是指数据的
低位
保存在内存的低地址
中,而数据的高位
,,保存在内存的高地址
中;
- 可以看到,对于下面这一个十六进制数
0x11223344
,以进制的权重来看的话右边的权重低【0】,左边的权重高【3】,所以11为高位,44为低位。所以若是对其进行小端字节存储的话就要将44存放到低位,11存放到高位,这也就印证了为什么我们最后在看到内存中的存放是倒着的原因👈
- 讲完了小端,我们再来说说【大端字节序存储】,因为要将高位存放到低地址,低位存放到高地址,因此11要放在左边,44要放在右边,所以若是以【大端…】的形式进行存放的话最后看到的便是一个正序的样子
✍一道百度系统工程师笔试题
请简述大端字节序和小端字节序的概念,设计一个小程序来判断当前机器的字节序。(10分)
那有同学看到这个就懵逼了😐如何去判断一个机器的大端和小端呢?
- 如果在我上面的讲解中你有仔细观察的话应该可以知道在我当前的VS下采用的就是【小端字节序存储】。其实我们通过存储完后最前面的这个数就可以看出是大端还是小端,但是要怎么获取到这第一个数呢?
下面我通过一个简单的数作为案例来进行一个分析
int a = 1;
- 可以看到,对于a以【小端字节序存储】会将
01
放在低位;而以【大端字节序存储】会将01
放在高位,那么此时我们只需要获取到内存中规定最低位即可,因为01
在内存中表示一个字节,而一个【char】类型的数据就为1个字节,所以此时我们可以使用到一个==字符型指针==接受要存放到内存中的这个数,然后对其进行一个解引用,便可以获取到低位的第一个字节了
char* p = &a;
- 可以呢,若直接使用一个字符型指针去接收一个整型数值的地址,就会出现问题,因为一个字符型的指针只能放得下一个字节的数据,所以我们要对这个整型的数值去进行一个强制类型转换为字符型的地址
- 通过强制类型转换后,再对这个字符型指针进行解引用,就可以取到一个字节的数据,继而对其进行一个判断,如果为
1
的话那就是【小端】,反之则是【大端】
char* p = (char *)&a;
if (*p == 1){
printf("小端\n");
}
else {
printf("大端\n");
}
运行结果如下:
- 上面的代码,通过学习了函数之后,相信你一定会对其去做一个封装
int check_sys(int num)
{
char* p = (char*)#
if (*p == 1) {
return 1;
}
else {
return 0;
}
}
int ret = check_sys(1);
- 或者,对于这个if判断我们可以就直接写成解引用的形式,然后对其去进行一个判断
if (*(char*)&num == 1)
- 那既然是我们要return 1或者0的时候,其实在解引用获取到低地址的第一个字节时直接return即可
- [x] 这便是最后的简化形式,虽然阅读性不强,但是代码的逻辑很严谨,需要读者对指针的理解有一定的程度
int check_sys(int num)
{
return *(char*)#
}
- 最后再来展示下运行结果【我的机器只能是小端,可以放到其他机器上测试】
3、数据范围的介绍
在上面,我们说到了有关【原码】、【反码】、【补码】的一些知识,若是用前面的
1
和0
去进行标识,可以将它们称之为有符号数
① char与signed char数据范围
- 首先我们通过下面这幅图来看一看对于有符号的
char
和无符号的char
在内存中所能表示的范围各自是多少- 【signed char】:
-128 ~ 127
- 【unsigned char】:
0 ~ 255
- 【signed char】:
但是为什么可以表示成这个范围呢,我们来细讲一下💬
- 首先来看一下有符号位的char,什么是有符号位?
- ==也就是最高位的1或者是0不计入数值位,数值位 = 7。0可以用来表示正数,1可以用来表示负数==
- 因为char数据类型在内存中占1个字节,也就是8个比特位。若是从
00000000
开始存放,每次+1上去然后逢二进一,之后你就可以得出最大能表示的正整数为【127】,可是呢在继续+1后又会进行进位然后变为10000000
,符号位为1,表示为负数,但有同学说:“这不是-0
吗,怎么就-128
了呢?”继续看下去你就知道了👇
- 我们先从最下面开始讲起,在上文有说到过,在内存中都是以【补码】的形式进行存放,所以我们看到的
1 1111111
只不过是补码的形式,若是还要再输出到外界,则需要转换为【原码】的形式,两种方式任选其一,在转换完后就可以发现呈现的数便是我最早给出的数字
- 但是对于
10000000
我们直接将其记作【-128】,它就对应的【-128】在内存中的补码,为什么可以直接这么认为呢?通过去写出【-128】的原、反、补码可以发现是需要9个比特位来进行存放,但是我们知道,对于char
类型的数值而言只能存放8个比特位,因此在转换为补码之后会进行一个截断 - 最后剩下的就是
10000000
,即为有符号char的最小负数为【-128】
- 这么看可能还是有点抽象了,其实你仔细去想一想就可以发现这其实是一个轮回,中间以一条竖线作为分割,右上角从0开始,一直到右下角为正整数的最大值
127
,接下去如果再进一位的话那就只能变成10000000
即为负数的最小值-128
,接着再慢慢往上变为【-4】、【-3】、【-2】、【-1】 - 若此时
11111111
再+1的话就会变成100000000
,但是因为char类型的数据只能存放8个比特位,因此又需要做截断,只剩下00000000
,此时又变回了一开始的【0】,形成了一个轮回🧭
- [x] 所以有符号char类型的数据范围为
-128 ~ 127
,你明白了吗👈
② unsigned char数据范围
在看了有符号char的取值范围对于无符号char的数据范围就简单多了
- 因为是无符号char,所以第一位不作为符号位,算入数值位,此时数值位就不是像上面一样的7位了,而是8位,那么就是从0 ~ 2^8^-1即
0 ~ 255
学会了如何去分析有/无符号char的数据范围,那short呢?int呢?其实都是同理
- 对于【short】类型的数据,在内存中以2个字节的大小进行存放,也就是16个比特位,它的有符号整数的范围和无符号整数的范围如下图所示👇
- 那对于【int】类型的数据来说也是同样的道理,这里就不做展示,数据量太大,读者可以下去自己试着画画看
【总结一下】:
- [x] signed char —— 【-128 ~ 127】 | unsigned char —— 【0 ~ 255】
- [x] signed short—— 【-32768 ~ 32767】 | unsigned short —— 【0 ~ 65535】
③ 原码、反码、补码数据范围对比
在上面我们说到了有关无符号数和有符号的数据范围,都是在内存中的存放形式,也就是【补码】的形式,那【原码】和【反码】是怎样的呢?
- 我画了一张横轴图,读者可根据此图来进行记忆。仔细观察可以发现之前介绍的正数以及负数原、反、补码的规则在这里是成立的,可以先细细看一看下图👇
- 相信你对最感觉不一样的地方就是补码的这两个数【-128】和【0】
- 这里的
10000000
相信不用我多说了,认真看了上文的一定可以明白,主要来讲一下这个00000000
,为什么+0
和-0
的补码都是它们呢?还记得那个轮回的圈吗,当我们最后加到-1的时候,要继续再+1就又变回0了,本来应该是-0
才对,不过char类型的数据只能存放8个比特位,所以截断了最前面的1,也就看上去和+0
的位置发生了一个重合 - [x] 所以这样规定:
+0
和-0
的补码相同,均为00000000
能算出8个比特位的数据范围,那么16个、32个、64个…n个比特位的数据都可以算出来✒
✒七道非常经典笔试题
通过学习了各种数据的范围后,我们趁热打铁,练几道历年在各大厂笔试题中非常经典的一些笔试题:keyboard:
👉在看这一模块之前你要先了解什么是整型提升
- [x] 有符号的数在整型提升的时候补符号位,无符号的数在整型提升的时候补0
👉并且你要知道以%u
和%d
打印数据有什么区别
%u
是打印无符号整型,认为内存中存放的补码对应的是一个无符号数%d
是打印有符号整型,认为内存中存放的补码对应的是一个有符号数
① 第一道
#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;
}
- 我们来分析一下,可以看到【a】和【b】都是有符号位的char类型,那它们就是一样的,现在将
-1
存放到这两个数据中去,首先你应该要考虑到的是一个数据放到内存中去是以什么形式?没错,那就是【补码】 - 所以我们首先将
-1
转换为补码的形式
1 0000000 00000000 00000000 00000001
1 1111111 11111111 11111111 11111110
1 1111111 11111111 11111111 11111111
- 可是呢,需要存放的地方又是char类型的变量,只能存放8个字节,无法放得下这32个字节,因此便需要进行一个截断的操作,放到变量a和变量b中都只剩下
11111111
这8个字节。 - 对于变量c来说,它是一个无符号的char类型变量,不过
-1
存放到它里面还是11111111
这8个字节不会改变,只不过在内存中的变化与有符号char不太一样。接下去就来看看会如何进行打印
printf("a = %d, b = %d, c = %d", a, b, c);
- 可以看到,是以
%d
的形式进行一个打印,但是呢三个变量所存放的都是char类型的变量,因此会进行一个==整型提升==,只是有符号数的整型提升和无符号数不太一样
//a b - 有符号数
11111111111111111111111111111111 - 补符号位
//c - 无符号数
00000000000000000000000011111111 - 补0
- 在进行整型提升之后,这些二进制数据还是存放在内存中的,可是要输出打印在屏幕上的话还要转换为【原码】的形式。如何转换的话我上面也有说到过,正数与负数不一样,这里就不在过多赘述💬
11111111111111111111111111111111
10000000000000000000000000000000
10000000000000000000000000000001 ——> 【-1】
00000000000000000000000011111111 ——> 【255】
运行结果如下:
② 第二道
#include <stdio.h>
int main()
{
char a = -128;
printf("%u\n",a);
return 0;
}
- 同理,一个整数存放到内存中,首先要将其转换为【补码】的方式
10000000 00000000 00000000 10000000
11111111 11111111 11111111 01111111
11111111 11111111 11111111 10000000
- 接着因为这32个二进制位要存放到一个
char
类型的变量中,因为进行截断为10000000
- 然后在内存中需要进行一个整型提升,
char
类型的变量将会填充符号位11111111111111111111111110000000
- 执行打印语句,可以看到这里是以
%u
的形式进行打印,认为在内存中存放的是一个无符号整数。我们知道,对于无符号整数来说,不存在负数,所以其原、反、补码都是一样的,因此在打印的时候就直接将其转换为十进制进行输出
printf("%u\n",a);
- 可是这么大的数字,要如何去进行计算呢,有同学说计算机不就能计算吗,我们来来如何使用专属的【程序员】计算器
运行结果如下:
③ 第三道
#include <stdio.h>
int main()
{
char a = 128;
printf("%u\n",a);
return 0;
}
- 接下去我们来看第三道题,可以看出和上面那题基本基本一样,只是把
-128
变成了128
而已 - 如果是【128】的话放到内存中就不需要像负数那样还要进行很多的转化了,因为正数的原、反、补码都一致
00000000 00000000 00000000 10000000
- 同理进行截断操作后位为
10000000
,那后面就是一样的了 ,同上
运行结果如下:
④ 第四道
- 接下去我们来做第四道题,本题展示一下我做题的过程,希望读者也可以跟着我一起这样来做,工整地一步一步对内存中的数据进行运算,最后自信地算出答案,不要怕麻烦,不然你永远都做不成事情👈
int main(void)
{
int i = -20;
//1 0000000 00000000 00000000 00010100
//1 1111111 11111111 11111111 11101011
//1 1111111 11111111 11111111 11101100
unsigned int j = 10;
//0 0000000 00000000 00000000 00001010
printf("%d\n", i + j);
//1 1111111 11111111 11111111 11101100
//0 0000000 00000000 00000000 00001010
//------------------------------------------
//1 1111111 11111111 11111111 11110110
//1 1111111 11111111 11111111 11110110
//1 0000000 00000000 00000000 00001001
//1 0000000 00000000 00000000 00001010 —— 【-10】
//按照补码的形式进行运算,最后格式化成为有符号整数
return 0;
}
- 好,一样来进行讲解,本次我们用到的是两个
int
类型的数据,一个是有符号的,一个是无符号的。但无论是有符号还是无符号,放到内存中都是要转换为补码的形式,==所以若是你碰到很复杂的题目,不要害怕,先把数字在内存中补码的形式写出来,然后再慢慢地去分析:mag:== - 接下去很直观,就是对算出来的两个补码一个二进制数的相加运算,注意这里是将整数存放到
int
类型的变量中去,所以不需要进行【截断】和【整型提升】
1 1111111 11111111 11111111 11101100
0 0000000 00000000 00000000 00001010
------------------------------------------
1 1111111 11111111 11111111 11110110
- 在运算之后要以
%d
的形式进行打印输出,那就会将内部中存放的补码看做是一个有符号数,既然是有符号数的话就存正负,可以很明显地看到最前面的一个数字是1
,所以是负数,要转换为原码的形式进行输出
1 1111111 11111111 11111111 11110110
1 0000000 00000000 00000000 00001001
1 0000000 00000000 00000000 00001010 —— 【-10】
运行结果如下:
⑤ 第五道
- 接下去第五道,是一个for循环的打印
int main(void)
{
unsigned int i;
for (i = 9; i >= 0; i--)
{
printf("%u\n", i);
}
return 0;
}
我们可以先来看一下运行结果
- 有同学就很诧异😮为什么会陷入死循环呢?这不是就是一个正常的打印过程吗?
- 其实,问题就出在这个
unsigned
,把它去掉之后就可以正常打印了:computer:
- 回忆一下我们在将无符号整数的时它的数据范围是多少呢
- 对于
char
类型来说是0 ~ 255
; - 对于
short
来说是0 ~ 65536
; - 对于
int
类型来说是0 ~ 16,777,215
;
- 对于
- 对比进行观察其实可以发现它们的数值范围都是 > 0的,所以对于无符号整数来说就不会存在负数的情况。因此这个for循环的条件【i >= 0】其实是恒成立的,若是当
i == 0
再去--
,此时就会变成【-1】 - 对于【-1】我们有看过它在内存中的补码形式为
11...11
是全部都是1,而此时这这个变量i又是个无符号的整型,所以不存在符号位这一说,那么在计算机看来它就是一个很大的无符号整数。此时当i以这个数值再次进入循环的时候,继续进行打印,然后执行--i
,最后知道其为0的时候又变成了-1,然后继续进入循环。。。
光是这么说说太抽象了,我们可以通过Sleep()函数在打印完每个数之后停一会,来观察一下
#include <windows.h>
int main(void)
{
unsigned int i;
for (i = 9; i >= 0; i--)
{
printf("%u\n", i);
Sleep(200);
}
return 0;
}
- 接着你便可以发现,当
i
循环到0的时候,突然就变成了一个很大的数字,这也就是印证了我上面的说法
⑥ 第六道
- 本题和四五道的原理是一样的,对于
unsigned char
来说,最大的整数范围不能超过255
,所以当这里的【i】加到255之后又会再+1就会变成00000000
,此时又会进入循环从0开始,也就造成了死循环的结果
unsigned char i = 0;
int main()
{
for (i = 0; i <= 255; i++)
{
printf("hello world\n");
}
return 0;
}
所以对于有符号数和无符号数的数据范围一定要牢记于心:heart:对边界值做到非常敏感
⑦ 第七道
- 最后一道,我们来做做综合一些的,涉及到字符串函数strlen,如果有不了解的同学可以去了解一下👈
int main()
{
char a[1000];
int i;
for (i = 0; i < 1000; i++)
{
a[i] = -1 - i;
}
printf("%d", strlen(a));
return 0;
}
- 首先来看函数主体,也是一个for循环,在开头定义了一个char类型的数组,大小为1000,。在循环内部呢对它们进行一个初始化,那最后里面的数字一定是
-1 -2 -3 -4 -5 -6 -7 ...
- 但是呢,我们在上面学习过的有符号
signed char
,它和char
是一样的,数据的范围在【-128 ~ 127】,所以当i
加到128的时候,这个位置上的值变为【-129】,此时在计算机内部会将它识别成【127】,同理【-130】会被识别成为【126】。。。依次类推,最后当这个值为【0】的时候若再去减就会变成【-1】,然后又变成【-2】【-3】【-4】。。。一直当这个i
累加到1001的时候截止 - 如果忘记的可以再看一下下面这幅图
- 最后,我们要通过
strlen()
去求这个数字的长度,对于strlen()来说,求的长度是到\0
截止,那也就是上面的【0】,不需要去关心后面的第二、三轮回 - [x] 那其实这也就转变成了让我们去求有符号char在内存中可以存储多少个。这很简单,范围是
-128 ~ +127
,二者的绝对值一加便知为255
//-1 -2 -3 -4 -5 -6 -7 ...-128 127 126 ... 0 -1 -2 -3....
printf("%d", strlen(a));
来看一下运行结果
到这里,七道非常经典的笔试题就全部讲完了,你学会【废︿( ̄︶ ̄)︿】了吗
四、浮点型在内存中的存储【更深,更强💪】
在第二模块,我有提到过一个叫做【浮点数家族】,里面包含了[float]
和[double]
类型,对于浮点数其实我们不陌生,在上小学的时候就有接触到的3.14
圆周率,还有以科学计数法表示的1E10
- 在计算机中整型类型的取值范围限定在:
limits.h
;浮点型类型的取值范围限定在:float.h
- 我们可以在【everything】中找找有关
float.h
这个头文件
- 然后把它拖到VS中来,便可以观察到它的这个头文件中所定义的内容,如果有兴趣可以自己去看看
1、案例引入
首先要了解浮点数在内存中的存储规则,我们要通过一个案例来进行引入。请问下面四个输出语句分别会打印什么内容?
int main()
{
int n = 9;
float* pFloat = (float*)&n;
printf("n的值为:%d\n", n); //1
printf("*pFloat的值为:%f\n", *pFloat); //2
*pFloat = 9.0;
printf("num的值为:%d\n", n); //3
printf("*pFloat的值为:%f\n", *pFloat); //4
return 0;
}
- 来简单分析一下,整型变量n里面存放了9,然后通过强制类型转换为浮点型的指针,存放到pFloat中。
- 首先第一个去打印n的值毫无疑问就是
9
- 第二个对pFloat进行解引用访问,对于
float
类型的指针与int一样都可以访问四个字节的地址,所以解引用便访问到了n中的内容,又因为浮点数小数点后仅6位有效,因此打印出来应该是9.000000
- 接下去通过pFloat的解引用修改了n的值,不过第三个以
%d
的形式进行打印,应该也还是9
- 第四个的话也是以浮点数的形式进行打印,那应该也是
9.000000
- 首先第一个去打印n的值毫无疑问就是
- 可结果真的和我们想象的一样吗,这就来看一下运行结果👇
- 可以看到,我们猜测推理的4个里面对了两个,中间的两个出了问题,而且还是两个很古怪的数字,对于
n
和*pFloat
在内存中明明是同一个数,为什么浮点数和整数的解读结果会差别这么大?
这要涉及到浮点数在内存中【存】与【取】规则,接下去我们首先来了解一下这个规则
2、浮点数存储规则
① 概念理清
根据国际标准IEEE(电气和电子工程协会) 754,任意一个二进制浮点数V可以表示成下面的形式:
V = (-1)^S * M * 2^E
(-1)^S
表示符号位,占一位。当S = 0
,V为正数;当S = 1
,V为负数- M【尾数】表示有效数字,放在最低部分,占用23位(1 <= M < 2)
- 2^E表示指数位。
E
为指数,占用8位【阶符采用隐含方式,即采用移码方法来表示正负指数】
② 例题分析
在讲解例题之前,你首先要知道对于一个二进制数来说,其整数部分和小数部分每一位上所占的权重分别是多少👈
- 对于【整数部分】我们非常熟悉,但是对于【小数部分】而言,你是否知道呢?
- 看完上了上面这个,我们就通过三道例题来进行讲解巩固
- v = 5.5
- 整数部分的5可以写成
101
,这毋庸置疑,但是这个小数部分的5要如何去进行转换呢?对于0.5
来说我们刚才看了小数部分的权重之后知道是2^-1^,所以直接使这一位为1即可 - 接着我们就要去求出S、M和E,对于M来说是>= 1并且< 2的,不过这里的
101.1
却远远大于1,所以我们可以通过在操作符中学习的【移位】操作将这个数进行左移两位,但是左移之后又要保持与原来的数相同,所以可以再乘上2^2^使得与原来的二进制相同。接着根据公式就可以写出(-1)^0^ * 1.011 * 2^2^这个式子,然后可以得出S、M、E为多少了
- 整数部分的5可以写成
- v = -5.5
- 这个和上面的一样,就是前面加了一个负号,那只是符号位进行了修改的话我们只需要变动S就可以了,即
S == 1
- 这个和上面的一样,就是前面加了一个负号,那只是符号位进行了修改的话我们只需要变动S就可以了,即
- v = 9.5
- 如果知道了第一题如何计算的话这题也是同样的道理,只是整数部分发生了一个变化而已
- 可并不是所有数的小数位都是
0.5
,如果是0.25
的话你可以将【2^-2^】置为1,依次类推。。。可以对于下面这个3.3
里面的0.3
你会如何去凑数呢,首先0.5
肯定不能,那只能有0.25
,但若是再加上0.125
的话那就多出来了,那应该配几呢? - [x] 其实你将后面的数一个个地去列出来就可以发现是没有数字可以配成
0.3
的。所以我们可以得出这个数字其实是无法在内存中进行保存。==这也是为什么浮点数在内存中容易发生精度丢失的原因==
③ 进一步探索指数E与尾数M的特性🔍
在上面,我们通过知晓了一些概念和案例,对浮点数首先有了一个基本的了解,其实呢对于浮点数来说是有一个统一标准来进行保存的
==IEEE 754标准规定:==
📚对于32位
的浮点数【float】,最高的1位是符号位S
,接着的8位是指数E
,剩下的23位为有效数字M
📚对于64位
的浮点数【double】,最高的1位是符号位S
,接着的11位是指数E
,剩下的52位为有效数字M
==IEEE 754对有效数字M和指数E,还有一些特别规定:==
首先是对于有效数字(尾数)M
- 前面说过
1 ≤ M < 2
,也就是说,M可以写成1.xxxxxx
的形式,其中xxxxxx表示小数部分 - IEEE 754规定,在计算机内部保存M时,默认这个数的第一位总是
1
,因此可以被舍去,只保存后面的xxxxxx部分。比如保存1.01的时候,只保存01
,等到读取的时候,再把第一位的1加上去。这样做的目的,是节省1位有效数字。 - 以32位浮点数为例,留给M只有
22位
,但若是将第一位的1舍去以后,等于可以保存23位
有效数字,精度相当于又高了一位
==至于指数E,情况就比较复杂==
- [x] 首先,E为一个无符号整数(
unsigned int
) - [x] 那对于无符号整数来说,我们在上面有介绍过,如果E为8位,它的取值范围为
[0 - 255]
;如果E为11位,它的取值范围为[0 - 2047]
- 但是,我们知道,科学计数法中的E是可以出现负数的,所以IEEE 754规定,存入内存时E的真实值必须再加上一个中间数,对于8位的E,这个中间数是127;对于11位的E,这个中间数是1023
好,通过上面的叙述,相信你对浮点数如何放到内存中有了一个了解,接下去我们马上来练习巩固一下:computer:
- 简单一点,还是上面讲到过的
5.5
,注意如果定义成【float】类型的变量的话要在后面加上一个f
作为和【double】类型的区分
float f = 5.5f;
- 然后我们对
5.5
这个数字去进行分析它存入到内存中的样子。通过上面算出来的S = 0, M = 1.011, E = 2
去写出这32位浮点数存放到内存中是一个怎样的形式- 对于符号数S来说就是把0存进去,占一位
- 对于指数E来说为2,32位浮点数要加上一个中间值127,所以要存入的十进制数为129,再将其转换为8位二进制即为
10000001
- 对于尾数M来说,需要舍去整数位1,然后将【小数部分】的
011
这三位放到内存中,但是规定了M为23位,此时我们只需要在后面补上20个0即可
- 然后便可以对这个32个比特位进行划分,8位一个字节,得出
40 b0 00 00
- 还有一点莫要忘了!还记得我们上面讲到的【大小端】存放吗?要存放到内存中的最后一步就是将其进行小端存放【这是我的机器】,即为
00 00 b0 40
- 到VS中的【内存】来观察一下👀
好,了解了如何将浮点数存放到内存中,先来我们来考虑一下如何将浮点数从内存中【读取】出来呢👈
==指数E从内存中取出还可以再分成三种情况:==
1. E不全为0或不全为1
- 对于这种情况就是最普通的,若是E存放在内存中的8位二进制数不全为0或者不全为1的话,那么直接按照上面说到过多一些M与E【写入内存】的规则进行一个逆推即可
- 以32位浮点数为例,因为我们在计算指数E的时候加上了一个
127
,那么此时减去127即可;在计算尾数M的时候舍去了整数部分的1,那次此时再补上这个1即可
2. E全为0
- 对于E全0的这种情况很特殊,也就是意味着8位二进制全为0即
00000000
,这个情况是在指数E加上127之后的结果,那么原先最初的指数是多少呢?那也只能是-127
了。那如果这个指数是-127的话也就相当于是【1.xxxx * 2^-127^】,是一个非常小的数字,几乎是和0没有什么差别 - 这时,浮点数的指数E等于1-127(或者1-1023)即为真实值;对于尾数M不再加上第一位的1,而是还原为0.xxxxxx的小数。这样做是为了表示
±0
,以及接近于0的很小的数字
3. E全为1
- 最后一种就是当E全为1的时候即
11111111
,这个情况也是在指数E加上127之后的结果,那么原先最初的指数是多少呢?那便能是128
了,那也只能是-127
了。那如果这个指数是-127的话也就相当于是【1.xxxx * 2^128^】,是一个非常大的数字 - 这时,如果尾数M全为0,表示±无穷大(正负取决于符号位s);
以上就是有关浮点数如何【写入】内存和从内存中【读取】的所有相关知识,你学会︿( ̄︶ ̄)︿了吗
3、开局疑难解答
讲了这么多 ,相信你也看烦了,还记得在本模块一开头我们遗留下来的那道题,现在通过学习了浮点数存与取的相关知识,我们再来做一下这道题
- 首先写出【n = 9】在内存中的补码
0 0000000 00000000 00000000 00001001
。然后是将这个n的地址存放到了一个浮点型的指针中去
int n = 9;
float* pFloat = (float*)&n;
- 那么此时进行一个打印,以
%d
的形式打印n不用考虑就是9;但是后一个就不一样了,对浮点型的指针进行解引用,那也就是要将存放在内存中的浮点数进行读取出来
printf("n的值为:%d\n", n);
printf("*pFloat的值为:%f\n", *pFloat);
- 那编译器此时就会站在浮点数的角度去看待这个
00000000000000000000000000001001
,将第一位看做是符号位0,即S == 0
,然后接下去就是8个比特位E,不过可以发现这个E是全0呀00000000
,就是我们上面所讲到的这种特殊情况 - 那此时后面的尾数M就不可以添上前面的整数部分1了,而是应该写成
0.xxxxxx
的形式即0.000000000000000000001001
。那对于指数E应该等于1-127为【-126】。所以最后写出来v的形式为
(-1)^0 * 0.000000000000000000001001 * 2^ (-126)
- 此时我们再去计算这个值打印的结果是多少,其实完全不需要计算,仔细观察就可以发现S为1,M为一个很小的数,若E再去乘上这个很小的数那只会更小,然后无限接近0,。那最后根据这个浮点数小数点后6位有效,便可以看出最终的结果为
0.000000
- 然后我们再来看下面的。此时pFloat进行解引用,然后将
9.0
存放到n这块地址中去,那也就相当于是我们最先学习了如何将一个浮点数存放到内存中去
*pFloat = 9.0;
- 那我们可以很快将其转换为二进制的形式
1001.0
,然后通过v的公式得出(-1)^0 * 1.001 * 2^3
- 此时再去将其转换为IEEE 754标准规定的32位浮点数。首先看到指数E为3,加上127之后为130,那么二进制形式即为
10000010
,尾数M也是同理,舍去1后看到001
,后面添上20个0补齐23个尾数位。最后的结果即为
——> 0 10000010 00100000000000000000000
- 然后去执行打印语句,那我们以浮点数的形式放进去,但是以
%d
的形式打印n,那么这一串二进制就会被编译器看做是补码,既然是打印就得是原码的形式,不过看到这个符号位为0,那我们也不需要去做一个转换,它就是原码
printf("num的值为:%d\n", n);
- 那么最后机器就会将二进制形式的原码转换为十进制的形式然后打印。一样,我们可以将它放到【程序员】计计算器进行运行,然后找到十进制的形式,便是最后打印输出在屏幕上的结果
01000001000100000000000000000000 —— 1,091,567,616
最后再来看一下运行结果
五、总结与提炼
最后来总结一下本文所学习的内容:book:
- 首先我们介绍了有关C语言中的所有数据类型,将他们总体地分成了五大类
- 接下去重点讨论了整型在内存中的存储。
- 首先提到了【原码】、【反码】、【补码】的概念,知道了原来在计算机内部都是以补码的形式进行存放
- 但是在内存中进行观察的时候却发生是倒着的,从而就引出了大小端的概念,知道了原来还有这样一种存放到内存中的方式。
- 接着我们讲到了有关有符号数和无符号数的数据范围,对于有符号数来说存在一个轮回,范围是- 2^n^ ~ 2^n^ - 1;对于无符号来说不存在负数,所以范围是0 ~ 2^n^ - 1。接着又通过一张对比图展示了原、反、补码三者的有效数据范围,对内存中数据的分布又进一步有了了解
- 最后我们通过七道非常经典的笔试题,再一次对以上所学的内容进行了一个巩固,将其运行到了实际中来
- 压轴的部分当然是留给最难的【浮点数】,相信认真看完的老铁应该可以感觉到浮点数在内存中的存储比整数不是难处一点半点,如果你没有静下心来研究过,真的遇到了很多稀奇古怪的数字连怎么去推导排查都不知道,规则这里就不再过多赘述,如果觉得理解起来困难的可以先放一放,在前面的所有知识都理解的基础上再去做深入探究
以上就是本文要介绍的所有内容,由衷得感谢您的阅读:rose::rose::rose:
- 点赞
- 收藏
- 关注作者
评论(0)