作者:小牛呼噜噜 | https://xiaoniuhululu.com
计算机内功、JAVA底层、面试、职业成长相关资料等更多精彩文章在公众号「小牛呼噜噜」
大家好,我是呼噜噜。我们都知道现代计算机采用 0 和 1 组成的二进制,来表示所有的信息。那大家是不是有时候会有这些疑问:为什么计算机采用了二进制?二进制是如何表示计算机的相关信息的?比如数字、字符串、声音、图片、视频等等
进制
进位计算法是一种常见的计算方式,常见的有十进制,二进制,十六进制
- 十进制
十进制,都是以0-9这九个数字组成,不能以0开头, 逢十进一。
十进制是我们从小就潜移默化般学习的,我们大多数人拥有的手指或脚趾的数目就是10,天生让我们适合十进制为基础的数字系统
- 二进制
二进制,数字中只有 0 和 1,逢二进一
- 八进制
八进制,数字0-7,逢八进一
- 十六进制
十六进制,数字有 0,1,2,3,4,5,6,7,8,9,A,B,C,D,E,F组成,逢十六进一。
其表示形式比较特殊,因为10~15不能用数字来展示,所以强制规定:10 用 A 表示、11 用 B 表示、12 用 C 表示、13 用 D 表示、14 用 E 表示、15用F表示
进制间的转换
- R进制 → 十进制:按权展开
- 十进制 → R进制:整数小数分开处理
- 整数部分的转换方法是:“除基取余,上右下左”。即用要转换的十进制整数去除以基数R,将得到的余数作为结果数据中各位的数字,直到余数为0为止。上面的余数(先得到的余数) 作为右边低位上的数位,下面的余数作为左边高位上的数位。
- 小数部分的转换方法是:“乘基取整,上左下右”。即用要转换的十进制小数去乘以基数R,将得到的乘积的整数部分作为结果数据中各位的数字,小数部分继续与基数R相乘。以此类推,直到某步乘积的小数部分为0或已得到希望的位数为止。最后,将上面的整数部分作为左边高位上的数位,下面的整数部分作为右边低位上的数位。
我们需要注意的是:在转换过程中,可能乘积的小数部分总得不到0,即转换得到希望的位数后还有余数,这种情况下得到的是近似值。
- 二进制转八进制、十六进制
由于把二进制的三位看成一个整体就是八进制的数,二进制的四位也就是十六进制的数。通过这个规律,我们很容易地就能实现二进制与八进制、十六进制的相互转换。
整数部分从低向高每3或4位数用一个等值八/十六进制数替换,不足时高位补0;小数部分从高向低每3或4位数用一个等值八或十六进制数替换,不足时低位补0
- 八进制、十六进制 转二进制
每一位数改成等值的3或4位二进制数,整数部分高位0省略;小数部分低位0省略
计算机为什么使用二进制?
我们从小更熟悉十进制的运算,0、1、2、3、4、5、6、7、8、9十个数字,逢十进一。但是计算机中使用二进制,只有0和1两个数字,逢二进一。
采用二进制的原因:
- 二进制在自然界中最容易被表现出来。自然界中二值系统非常多,电压的高低、水位的高低、门的开关、电流的有无等等都可以组成二值系统。
- 计算机使用二进制和现代计算机系统的硬件实现有关。制造二个稳定态的物理器件容易,使得组成计算机系统的逻辑电路通常只有两个状态,即开关的接通与断开。由于每位数据只有断开与接通两种状态,因此二进制的数据表达具有抗干扰能力强、可靠性高的优点
- 二进制非常适合逻辑运算,可方便地用逻辑电路实现 算术运算
机器数和真值
机器数
一个数在计算机中的二进制表示形式, 叫做这个数的机器数、机器码。
由于我们平时不仅使用的是正数,还有大量的负数,而计算机是无法识别符号"+","-", 所以计算机规定,用二进制数的最高位0表示正数,如果是1则表示负数。机器数是带符号的
如果十进制中的数 +3
,计算机字长为8位,转换成二进制的机器数就是0000 0011
。如果是-3
,就是 10000011
真值
带符号位的机器数对应的真正数值是 机器数的真值,我们知道机器数的第一位是符号位, 比如1000 0011
直接转换成十进制为131
,但实际上最高位1 是负号,其真正的值为 [-3]
机器数的编码形式有哪些?
原码
原码就是符号位加上真值的绝对值, 即用最高位表示符号, 其余位表示值
比如如果是8位二进制:
- [+1] = (0000 0001)原
- [ -1] = (1000 0001)原
我们人类根据二进制的规则,可以一眼就明白原码代表的数字,方便了人类
面试的时候有一个经典的问题:8位二进制数原码的取值范围是?
我们只需将除了最高位,用来表示符号,其他位都是1,即[1111 1111 , 0111 1111]
,换算成十进制:[-127 , 127]
那n位二进制数呢?
取值范围:
现在看起来都是那么美好,然而当我们将正负数相加时,遇到了问题:2个[+1]
相减 ,其实就相当于[+1]
和 [-1]
相加,我们的预期是0
,
但计算机实际上计算时:(0000 0001)原+(1000 0001)原=(1000 0010)原 =[-2]
为了解决这个问题,反码就应运而生了
反码
反码主要是针对负数的,正数的反码是其本身,负数的反码是在其原码的基础上, 符号位不变,其余各个位取反
- [+1] = (0000 0001)原 = (0000 0001)反
- [-1] = (10000001)原 = (1111 1110)反
反码如果是表示的一个正数,那我们还是一眼就能知道他的数值,但如果是负数的反码时,我们就需要转换成原码才能看出它的真值。
如果最高位有进位出现,则要把它送回到最低位去相加(循环进位)的
[- 1] = (1000 0001)原 = (1111 1110)反
[+7] = (0000 0111)原 =(0000 0111)反
[-1] + [+7] = (1111 1110)反 +(0000 0111)反= (1 0000 0101)反 = (0000 0110)反 =[+ 6]
2个正数相减:[+1] - [+1] = [+1] + [-1] = (0000 0001)反 + (1111 1110)反 = (1111 1111)反 =(1000 0000)原 = [**-0**]
这样就完美实现了“正负相加等于0",但奇怪的是 ,这个[-0]是有符号的,这就要归因于 原码的设计之初,存在的问题,
- (1000 0000)原=[- 0]
- (0000 0000)原=[+0]
对的,你没看错,零竟然有2个,习惯计算机的万事万物一一对应,严谨认真的工程师们表示无法接受,得想办法去掉[-0],最后他们就发现了神奇的补码
补码
补码的规则:针对负数继续改进了思路:正数的补码就是其本身。负数的补码是在其原码的基础上, 符号位不变, 其余各位取反, 最后一位+1。即在反码的基础上最后一位+1
[+1] = (0000 0001)原 = (0000 0001)反 = (0000 0001)补
[-1] = (1000 0001)原 = (1111 1110)反 = (1111 1111)补
[+1] - [+1] = [+1] + [-1] = (0000 0001)补 + (1111 1111)补 = (1 0000 0000)补 = (0000 0000)补 = [0]
如果补码在补一位1的时候,发生最高位进位,会自动丢掉最高位。期间引用了计算机对符号位的自动处理,利用了最高位进位的自动丢弃实现了符号的自然处理。
那(1000 0000)补
那现在表示多少?-128
- (1000 0000)补 =-1 * 2^7 =[-128]
- (1011)补 = -1 * 2^3 + 02^2 + 12^1 + 1*2^0 = -5
- (0011)补 = 0 * 2^3 + 02^2 + 12^1 + 1*2^0 =3
如果是8位二进制, 使用原码或反码表示的范围为[-127, +127], 而使用补码表示的范围为[-128, 127],使用补码还能够多表示一个最低数
补码其实脱胎于 模运算系统:
比如一天中的24小时是一个模运算系统,任意时刻的钟点数都是0到23间的一个整数,这有点类似24进制
- 今天的第24点,就是明天的0点;
- 今天的25点,就是明天的凌晨1点;
- 今天的-4点,就是昨天的20点,我们称20是-4对模24的补码,模就是容量、极值的意思
再举个例子:钟表上的12个刻度也是一个模运算系统。假定时钟现在指向10,要把指针只向6,有两种方法
- 倒拨4格:10 - 4 = 6
- 正拨8格:10 + 8 = 18 = 6 (mod 12)
所以模12系统中 -4 = 8 (mod 12),我们称8是-4对模12的补码
一个模运算系统中:一个负数可以用它的正补数(负数的补码)代替,一个负数的补码 = 模 - 该负数的绝对值
那我们之前公式 一个负数的补码 = 符号位不变, 其余各位取反, 最后一位+1
,是怎么来的?
负数的原码 取反 再加1, 这只是方便大家记忆的手段,实际上它相当于加一个模256也就是2^8,为什么要拆,这是由于8位机,8位2进制数,至能表示0~255个数,一共256个数,所以它是表示不了256这个值的,只能是255+1
。由于计算机系统里面不仅只有正数,还有负数呢,这个该怎么表示?
计算机大师就想到了,可以将256个数一分为二,规定最高位为符号位,最高位1开头的表示为负数,最高位0开头表示正数。我们这里需要注意一下,特殊的0,所以8位2进制数表示范围就变成了[-128,127],这个范围是不是很熟悉!
[-1] = (1000 0001)原 = (1111 1110)反 = (1111 1111)补
,如果符号位参与计算,(1111 1111)补 的十进制 等于 255。而255 + |-1|= 256
,也就是模。补码本天成,妙手偶得之
小结一下:
- 补码不仅解决了[-0]的问题,更核心的是让计算机做减法运算,变成加法运算。
A - B = A + B的补码
- 使用补码,将减法变成加法运算,这样硬件上只需有加法器即可,不需要其他硬件,降低了电路的复杂度
- 使用补码,不浪费编码个数,存储空间利用率高
- 补码可以用n&0判断负数奇偶
- 所以计算机底层存储数据时使用的是二进制数字,但是计算机在存储一个数字时并不是直接存储该数字对应的二进制数字,而是存储该数字对应二进制数字的补码
定点数和浮点数
定点数
定点数的意思是:即约定机器中所有数据的小数点位置是固定不变的。通常将定点数据表示成纯小数或纯整数,为了将数表示成纯小数,通常把小数点固定在数值部分的最高位之前;而为了将数表示成纯整数,则把小数点固定在数值部分的最后面。
例如:十进制的 25.125
- 整数部分:25使用二进制表示为:11001
- 小数部分:0.125使用二进制表示为:.001
- 所以合起来使用11001.001 表示十进制的25.125
本文的原码、反码、补码概念都是基于定点数
浮点数
定点数表示法的缺点在于其形式过于僵硬,固定的小数点位置决定了固定位数的整数部分和小数部分,不利于同时表达特别大或特别小的数,最终,绝大多数现代的计算机系统采纳了浮点数表达方式,这种表达方式利用科学计数法来表达实数,即用一个尾数(Mantissa,尾数有时也称为有效数字,它实际上是有效数字的非正式说法),一个基数(Base),一个指数(Exponent)以及一个表示正负的符号来表达实数
例如:
- 352.47 = 3.5247 * 10的2次方
- 178.125转化为二进制为 10110010.001,又可表示为:1.0110010001 乘以 2的111次方(111是7的二进制表示)
- 123.45用十进制科学计数法可以表示为1.2345x10的2次方,其中1.2345为尾数,10为基数,2为指数。浮点数利用指数达到了浮动小数点的效果,从而可以灵活地表达更大范围的实数。
字符串编码
ASCII 码
在计算机中, 不仅数值可以用二进制表示,字符串也能用二进制表示。上世纪美国制定了一套字符编码,对英语字符与二进制位之间的关系,加上数字和一些特殊符号, 然后用 8 位的二进制,就能表示我们日常需要的所有字符了,这个就是我们常常说的ASCII 码
ASCII 码就好比一个字典,将二进制和字符一一对应。其中我们看几个典型的例子:
小写字母 a
在 ASCII 里面,十进制97,也就是二进制的 0 110 0001
,而大写字母 A
,十进制65,对应的二进制 0 100 0001
- 需要注意的是,里面的数字,比如
数字1
,二进制对应0000 0001
在ASCII 里面,表示的其实是字符"1"
,对应的二进制是0 011 0001
- 字符串 15 也不是用
0000 1111
这 8 位二进制来表示,而是变成两个字符 1 和 5 连续放在一起,也就是0 011 0001
和 0 011 0101
,需要用两个 8 位二进制来表示 。所以**计算机储存数据时,二进制序列化会比直接存储文本能节省大量空间 **
EASCII:扩展的 ASCII
一开始美国编写ASCII表,英语用128个符号编码就够了,但随着计算机的普及,西欧国家不全是英语国家,有德语,法语等等
比如 字母上方有注音符号,它就无法用 ASCII 码表示。于是欧洲工程师就决定,利用字节中闲置的最高位编入新的符号。他们把 ASCII 扩充变成了 EASCII,这扩充的包括希腊字母、特殊的拉丁符号等。由于 ASCII 只占了 7 位,所以 EASCII 把第 8 位利用起来,仍然是一个字节来表示,这时表示的字符个数是 256。
Unicode
但 EASCII 并没有成功,西欧国家以及各个 PC 厂商各自定义出了好多不同的编码字符集,ISO-8859将西欧国家的编码一起包含进去。
但随着计算机来到中国,那些欧美国家把 现有的字典都用完,而且汉字有十多万个,所以急需新的"字典"。GB2312编码就出来了,使用两个字节表示一个汉字(汉字太多),所以理论上最多可以表示 256 x 256 = 65536 个符号。后来GBK编码 将古汉字等生僻字加进来。台湾地区又创造了BIG5编码,再后来GB18030对东南亚地区的文字,进行了统一。简单了解下这些编码,就不具体展开了
再后来计算机全球普及,各个国家地区的文字编码太多太乱,Unicode编码的出现,为了统一全世界的所有字符。Unicode 是一个很大的集合,现在的规模可以容纳100多万个符号。
由于Unicode 只是一个字符集(Charset),它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储,也就是字符编码(Character Encoding),这就导致计算机无法区别 Unicode 和 ASCII ,比如三个字节表示一个符号,而不是分别表示三个符号呢?
随着互联网的崛起,UTF-8 就是在互联网上使用最广的一种 Unicode 的实现方式。UTF-8 它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。Unicode 字符集中的大部分汉字,如果用 UTF-8 编码的话,是占 3 个字节的。
下面我们看看UTF-8是如何兼容Unicode的:
UTF-8编码致力于统一世界上所有的字符集,所以它的设计上既向下兼容ASCII码的编码方式,同时又考虑了可拓展性,规则如下:
1)对于单字节的符号:字节的第一位设为0,后面7位为这个符号的 Unicode 码。与 ASCII 编码规则相同;
2)对于n字节的符号(n > 1):第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。
Unicode |
UTF-8 |
byte 数 |
0000~007F |
0XXX XXXX |
1 |
0080~07FF |
110X XXXX 10XX XXXX |
2 |
0800~FFFF |
1110 XXXX 10XX XXXX 10XX XXXX |
3 |
1 0000~1F FFFF |
1111 0XXX 10XX XXXX 10XX XXXX 10XX XXXX |
4 |
我们可以发现,UTF-8 编码的第一位如果是 0,则只有一个字节,跟 ASCII 编码完全一样,所以兼容了。如果是 110 开头,则是两个字节,以此类推如上表所示。所以开头几位的值,是编码本身,同时又是判断后续还有几个字节数的推码(通过推码才能判断这个字节之后还有几个字节共同参与一个字符的表示)
乱码的来源
编码是把数据从一种形式转换为另外一种形式的过程,而解码则是编码的逆向过程。编码是一种格式,解码是另一种格式,当然会出问题。下面我们举个例子,来看看这个问题:
- 创建hello.txt文件,用
Notepad++
打开编辑,以 UTF-8
格式写入你好
- 然后我们改变
Notepad++
的formaat
格式,改为GB2312
,然后你好
就变成了浣犲ソ
在UTF-8 字典中,你好
两个字的16进制编码分别是E4BDA0
、E5A5BD
在GB2312字典中,浣犲ソ
三个字的16进制编码分别是 E4BD
、A0E5
、A5BD
由于在UTF-8 编码汉字是 3 个字节,在GB2312编码汉字却是2个字节,计算机用GB2312去解析UTF-8,硬生生的把3个字节以每2个字节为一组去解码,所以才会有出现这种乱码。当我们知道乱码出现的原因,如何解决就变的非常简单了
参考资料:
《编码:隐匿在计算机软硬件背后的语言》
《深入理解计算机系统 第三版》
《计算机组成原理》
《深入计算机组成原理》
https://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html
https://www.zhihu.com/question/20159860
https://blog.csdn.net/f919976711/article/details/116714860
本篇文章到这里就结束啦,很感谢靓仔你能看到最后,如果觉得文章对你有帮助,别忘记关注我!
计算机内功、JAVA源码、职业成长、项目实战、面试相关资料等更多精彩文章在公众号「小牛呼噜噜」