探索编码的世界

844 阅读18分钟

本文从二进制编码讲起,到整数到小数,再到字符编码,中间穿插相应js代码,希望通过这次探索,能让读者对js大数/精度、乱码、node的Buffer等等有更进一步的理解,以及对计算机编码的认识更加深刻。

从二进制讲起

计算机最基本的存储单位就是bit(位元),就是二进制0/1,一字节(byte)为8比特(bit)
当然人们日常使用的是十进制,要转换这两者其实就是按幂次来的

二进制=>十进制
1001 = 1 x 2^3 + 0 x 2^2 + 0 x 2^1 + 1 x 2^0

2^10为1024,通常称为1K,接着2^11则为2K以此类推,到2^20就是1M,2^30为1G

不过一串的01太难表示了,怎么把它变短一点呢,八进制/十六进制就可以帮你啦~

// 一个八进制正好跟三位二进制对应
二进制 => 八进制
001 110 101 => 165

// 一个十六进制正好跟四位二进制对应
1001 0001 => 91

有符号/无符号整数

通常计算机使用16位或32位无符号整数

前者就是「整数」integer
范围也就是2^16 (0~2^16-1) (16位0~16位1)最大65,535

后者就是「长整数」long integer
范围是2^32 (0~2^32-1) 最大4,294,967,295

当然正数并不能满足我们,所以就有了有符号
怎么做呢,我们需要一个符号位去判断正负,最简单的方式就是把无符号拆分成两份
第一位为0则为正,为1则为负

// 如下对四位数值拆分
0000 = 十进制0
0001 = 十进制1
0010 = 十进制2
0011 = 十进制3
0100 = 十进制4
0101 = 十进制5
0110 = 十进制6
0111 = 十进制7
1000 = 十进制-8
1001 = 十进制-7
1010 = 十进制-6
1011 = 十进制-5
1100 = 十进制-4
1101 = 十进制-3
1110 = 十进制-2
1111 = 十进制-1

当然这里还涉及一些补码/反码的知识,这里不多述说
从上面其实可以看到-7其实就是+7的二进制均取反再加1

拆分成两份,就不难理解有符号整数的范围就更小了
16位有符号整数那就是一半的无符号位-32,768到32,767
32位即-2,147,483,648到2,147,483,647

定点/浮点小数

整数表达完了,小数也是必不可少的,如何表达小数?

0.5 = 2^-1
十进制 => 二进制(假如32位)
0.5 => 0000 0000 0000 0000.1000 0000 0000 0000

其他就同理了
但是定点小数,顾名思义,就是确定的小数点位置,前面后面都是确定位数的,就意味着无法表示大范围的数,因此我们需要大范围的数,于是出现了浮点小数。
电气电子工程师学会(IEEE)定义了一种IEEE二进制浮点数算术标准(IEEE 754)被广泛使用

这里就讲下64位浮点格式
符号位(1位)+指数位(即阶码,11位)+尾数位(52位)
如何转换位十进制呢:

符号(正负) *  (1 + 尾数) * 2^(阶码-1023)
比如1的转化
浮点内存模型:
0 01111111111 0000000000000000000000000000000000000000000000000000
按照公式 =>
1* (1 + 0)*2^(1023-1023)= 1

0.5的转化
浮点内存模型:
0 01111111110 0000000000000000000000000000000000000000000000000000
按照公式 =>
1* (1 + 0)*2^(1022-1023)= 0.5

为什么是超1023格式(减去1023),是因为11位表示无符号整数是0~2047,所以为了出现负数,便-1023做出拆分

所以可以表示最大的数就是

按照公式
1*(1 + 0.1111111111111111111111111(2进制))^(2047-1023)
转化为十进制就是
1.7976931348623157e+308

因此整体可以表示的范围
(来自维基百科 )

                   最大值                 最小值
正数    1.797693134862231E+308    4.940656458412465E-324
负数    -4.940656458412465E-324    -1.797693134862231E+308

在javascript,Number类型就是浮点数,也是遵循IEEE754,使用64位浮点(也叫做双精度浮点数),我们可以使用js来验证下

> Number.MAX_VALUE
1.7976931348623157e+308

> Number.MIN_VALUE
5e-324

> Number.MAX_SAFE_INTEGER
9007199254740991

> Number.MIN_SAFE_INTEGER
-9007199254740991

前两者就是javascript中的最大值和最小值(小值js直接使用5了)

可以看到最大数后面几位小数好像跟维基百科上面说的范围有些许不同,这里不加以考究,在2的幂次转换成10的幂次上估计精度有点不同,有兴趣的读者可以去验证下正确性

还可以注意到我们后面有个最大/最小安全整数
这又是什么?
从前面我们可以注意到尾数位是52位,再加上前面一位隐藏位1,总共53位,这就代表着精度最多表示也就是53位,这53位我们基本可以任意数值标示精度(其实就是有效数字了),所以这是安全的,超过以后,就得按指数位2的倍数来,就丢失了精度(也就变得不安全了),所以安全的最大数应该是2^53 - 1,最小即-(2^53 - 1)

依照这里,我们可以解释javascript以下的表现

> Number.MAX_SAFE_INTEGER
9007199254740991

> Math.pow(2, 53)
9007199254740992

> Math.pow(2, 53) + 1
9007199254740992

> Math.pow(2, 53) + 2
9007199254740994

> Math.pow(2, 53) + 3
9007199254740996

> Math.pow(2, 53) + 4
9007199254740996

可以看到Number.MAX_SAFE_INTEGER加的是2的幂次就正常,如果是其他就丢失精度了

再看下以下行为

> 0.1 + 0.2
0.30000000000000004

> 0.1 + 0.2 === 0.3
false

> 0.1 + 0.2 - 0.3
5.551115123125783e-17

> 0.1 + 0.2 - 0.3 < Math.pow(2, -53)
true

为什么0.1 + 0.2 === 0.3出了问题呢?
其实这也是浮点数惹的祸
表示0.1,怎么表示?
只能用无穷循环的二进制小数0.000110011……
不过计算机不可能可以表示无穷位,所以这里就丢失了精度,同理0.2也无法正常表示

> (0.1).toString(2)
"0.0001100110011001100110011001100110011001100110011001101"

> (0.2).toString(2)
"0.001100110011001100110011001100110011001100110011001101"

> (0.3).toString(2)
"0.010011001100110011001100110011001100110011001100110011"

> (0.1+0.2).toString(2)
"0.0100110011001100110011001100110011001100110011001101"

从上面我们可以很清楚看到精度丢失了,所以这样直接使用===是有问题的,那有什么办法呢?
我们可以判断它的差值是否小于最小的有效数字表达,如果小则判断为相等

function isEqualNum(x, y) {
    return x -y < Math.pow(2, -53);
}
> isEqualNum(0.1+0.2, 0.3)
true

到这里就大概讲完了,计算机数字表达有两个很重要的点

  • 精度
  • 范围

前者就是有效数字(在浮点数就是尾码),表现为丢失精度,计算误差
后者在浮点数就是指数位,超出范围就表现为溢出

IEEE754有给出5种异常情况,表示无法给出精确的值的

部分翻译引用 2ality.com/2012/04/num…

// 不合法
> Math.sqrt(-1) 
NaN

// 除以0
> 1 / 0
Infinity

// 不准确
> 0.1 + 0.2
0.30000000000000004

// 上溢出(指数大于等于1024)
> Math.pow(2, 2048)
Infinity

// 下溢出(指数小于等于-1023, js好像可以到-1074)
> Math.pow(2, -2048)
0

小结:计算机不是万能的,我们应该去认识它然后合理使用它😸

字符编码

可以表示数字并不能满足我们的需求,我们还需要表示文本语言

ASCII码

首先是西方字母的表达,就是使用ASCII码了
ASCII码(American Standard Code for Information Interchange,美国信息交换标准代码)
标准的ASCII码使用7位二进制表示(第一位二进制为0)表示大写/小写字母、数字0~9、标点符号还有一些控制字符( 换行键、回车键等)
比如以下的ASCII码

注:以下均使用nodejs的Buffer来处理二进制数据
以下我们使用Buffer.from将字符串转换为相应编码数据
nodejs Buffer使用little-endian(小端序)(低位字节在前,高位字节在后)

> Buffer.from('A', 'ascii')
<Buffer 41>
// 即0010 0001

> Buffer.from(' ', 'ascii')
<Buffer 20>
// 即0010 0000

EASCII && ISO 8859

当然单纯的英文字母数字并不能满足,前面的128个完全是为美国服务的,外国的一些特殊符号,一些拼音标注等等问题就出现了
所以出现了两套方案,一个是EASCII,一个是 ISO 8859

扩展ASCII码从0x80到0xff(128到255),叫做EASCII(Extended ASCII,延伸美国标准信息交换码),支持表格符号、计算符号、希腊字母和特殊的拉丁符号。

ISO 8859,全称ISO/IEC 8859,是国际标准化组织(ISO)及国际电工委员会(IEC)联合制定的一系列8位元字符集的标准,现时定义了15个字符集。后者较EASCII比较常用。

它们都是单字节编码方案

ISO 2022 (GB 2312/ GBK遵循)

出现ISO 2022(全称ISO/IEC 2022,由国际标准化组织(ISO)及国际电工委员会(IEC)联合制定)

它是字符集标准,遵循它的才是字符集

ISO 2022等同于欧洲标准组织(ECMA)的ECMA-35。中国国标GB 2312、日本工业规格JIS X 0202(旧称JIS C 6228)及韩国工业规格KS X 1004(旧称KS C 5620)均遵从ISO 2022。

英语可用7位编码储存,而其他使用拉丁字母、希腊字母、西里尔字母、希伯来字母等的语文,由于只使用数十个字母,传统上均使用8位编码的ISO/IEC 8859标准来表示。但由于汉语、日语及朝鲜语字数众多,无法用单一个8位字元来表达,故需要多于一个字节来代表一个字。于是,ISO 2022就设计出来让汉语、日语及朝鲜语可以使用数个7位编码的字元来示。

引用 zh.wikipedia.org/wiki/ISO/IE…

强势插入中文编码介绍

GB2312
GB 2312 或 GB 2312–80 是中华人民共和国国家标准简体中文字符集
规定对收录的每个字符采用两个字节表示,第一个字节为“高字节”,对应94个区;第二个字节为“低字节”,对应94个位,所以它的区位码范围是:0101-9494。区号和位号分别加上0xA0就是GB2312编码。

01-09区收录除汉字外的682个字符。
10-15区为空白区,没有使用。
16-55区收录3755个一级汉字,按拼音排序。
56-87区收录3008个二级汉字,按部首/笔画排序。
88-94区为空白区,没有使用。

其具体的字符编码方案(Character Encoding Scheme):字节值在0x00-0x7F,为单字节表示一个字符,构成了C0、G0区,与ASCII码兼容。因此,GB 2312是单、双字节混合编码。

原来127以内的ASCII保留,但是其实标点、数字也放进了双字节编码中
前者称为“半角”,后者为“全角”
GB2312可以说是ASCII码的中文扩展

GBK
汉字内码扩展规范,称GBK,(简体中文Windows操作系统的缺省的语言locale设置), 向下兼容GB2312,扩展了一些汉字(比如繁体字等等)

GB2312升级版

GB18030
全称:“国家标准GB 18030-2005《信息技术 中文编码字符集》”,是中华人民共和国现时最新的变长度多字节字符集。对GB 2312-1980完全向后兼容,与GBK基本向后兼容;支持GB 13000(Unicode)的所有码位;共收录汉字70,244个

GB18030是再升级版(加了大量少数民族字)

当然以上都是汉字编码的标准,它们的字符集被统称成 DBCS(Double Byte Charecter Set 双字节字符集),也就是拥有一字节长度的英文字符,两字节长的中文字符。

Unicode

可以看到每个国家都搞一套自己的编码标准,做了一套字符集,做了兼容ASCII但是互相不兼容的编码方案,同一个二进制代码在不同的编码方案下就是不同的字符,于是就出现了统一的编码方案,有一个Unicode组织出了Unicode,被称为统一码(万国码等等),当时ISO-10646工作小组也在为UCS字符集(ISO/IEC 10646-1)(通用多八位编码字符集)工作,后面两者发现了双方共同的协同合作,unicode如今成为通用字符集的代名词。

Unicode字符有个平面映射

目前的Unicode字符分为17组编排,每组称为平面(Plane),而每平面拥有65536(即216)个代码点。
除了第0平面其他为辅助平面

中文就在表意文字补充平面里面


可以看到Unicode表示字符就是在十六进制数前面加上“U+”
范围从U+xx0000到U+xxFFFF

UTF和UCS

UCS全称为"Universal Character Set",就是统一字符集的意思
UTF是“Unicode Transformation Format”的缩写
Unicode和UCS都是字符集,UTF系列才是具体的编码实现和相应规则

UTF-32和UCS-4

上面说到Unicode的协作有两个组织,UCS-4就是ISO-10646工作小组做出来的,由0到十六进制的7FFFFFFF的31位数值表示(符号位未使用且零),空间约20亿个码位,后面协作以后,发现使用范围没必要那么大没了兼容Unicode,就限制在Unicode范围内,也就是U+10FFFF,就提出了UTF-32,所以UTF-32就是UCS-4早期字符集(说早期是因为后面UCS-4自己也提出限制了)的一个实现子集。

UTF-16和UCS-2

UCS-2跟UCS-4差不多,不过它是两个字节,也就是16位,也就是65536个码位,其实它就是unicode第0号平面,那UTF-16是什么,这两者一样吗,其实UTF-16是它的父集,它不止包括0号平面,还有其他辅助平面,所以它是个变长编码,2个字节表示0号平面,4个字节表示其他辅助平面,UTF-16可以为Unicode中所有的字符编码,具体如何表示可以看UTF-16-维基百科

UTF-8

从上面的描述可以看到,UTF-16/UTF-32都是多字节编码,比如英文字母其实只需要一个字节就够了,但是它们还是使用很多字节去表示,那前面就很多个0,在存储和传输方面就出现很大的浪费,所以UTF-8出现了,它也是一种变长的编码,ASCII使用1个字节,然后其他超出的使用多字节,这样就可以节省很多空间

UTF-8编码规则是什么呢,可以看从维基百科截图过来的


可以得到以下结论

  • 一个字节兼容ASCII码(这里可以说ASCII是UTF-8一个子集)
  • 多字节的话,看Byte1前面几个1,就可以判断有几个字节
// 可以看出utf8与ascii兼容
> Buffer.from('A', 'utf8')
<Buffer 41>

> Buffer.from('A', 'ascii')
<Buffer 41>

// node文档
// 'utf16le' - 2 or 4 bytes, little-endian encoded Unicode characters.
//  Surrogate pairs (U+10000 to U+10FFFF) are supported.
//
// 'ucs2' - Alias of 'utf16le'.
//
// 在node里这两者一样,2或4个字节编码(小端序)
> Buffer.from('A', 'ucs2')
<Buffer 41 00>

> Buffer.from('A', 'utf16le')
<Buffer 41 00>

ANSI

在windows经常看到ANSI编码,这是什么呢?
查了一下?

美国国家标准学会(American National Standards Institute,ANSI)是负责制定美国国家标准的非营利组织

再查下它的历史

早期,代码页是IBM称呼计算机的BIOS所支持的字符集编码。当时通用的操作系统都是命令行界面,这些操作系统直接使用BIOS提供的字符绘制功能来显示字符(或者是一组嵌入在显卡字符生成器中的字形)。这些BIOS代码页也被称为OEM代码页。图形操作系统使用自己的字符呈现引擎(rendering engine),可以支持多个不同的字符集编码,这类代码页被称作ANSI代码页。

其实是ANSI这个组织制作出的标准,ANSI编码不是一个特定的字符编码,就是为了解决不同国家有自己的编码的问题,所以它在不同系统下默认是不同的编码,简体中文默认GB系列,繁体中文默认BIG5,windows本地可以通过locale去修改,从而得到相应的对应代码页然后使用不同的编码

Base64

这里再提一下比较常见的Base64编码
从名字看,就是基础的64,也正如名字,它确实就是64个基础的字符,大小写字母各26个(共52个),10个数字,加号“+”,斜杠“/”(最后有个等号“=”用来作为后缀用途)

Base64索引表

下面讲一下Base64转换
举一个Man的例子,其实就是文本 => ASCII => 二进制 => 3个byte(每次取6个bit) => 找索引 => 得到对应符号

因为这样所以编码后的数据是原来的4/3

你可能想问,假如不是正好3个字节的倍数怎么办,那就补0,全是0的话就补“=”(这里就是等号的用途了),可以看下面的例子

从上面的分析可以看出,Base64不是一种加密的方式(所以要用它加密是完全错误的),因为它完全就是一个映射表,但是它可以做到简单的保密,让数据不可读

// 下面使用node验证下base64转换

> Buffer.from('Man', 'ascii')
<Buffer 4d 61 6e>

> Buffer.from('Man', 'ascii').toString('base64')
TWFu

> Buffer.from('TWFu', 'base64')
<Buffer 4d 61 6e>

还有个问题是,我们经常可以看到图像base64格式,图像是怎么转换base64格式的

之前使用过canvas.toDataURL(),它是怎么实现的呢?

其实就是canvas => 二进制数据 (然后接下来就跟上面变成ASCII后一样了) => 3个byte(每次取6个bit) => 找索引 => 得到对应符号

至于Base64图到底用不用,其实就是看大小,小的话是可以使用的,大的话因为它的编码是会变大的,为原来的4/3,这样就很浪费资源了(当然除了这些,还有网络请求、缓存的相关考虑,这里就不赘述)

总结

整体编码探索就到这里了,还有很多细节没有深究,有些地方也确实很繁琐,但是了解编码确实很重要,能在开发中减少很多时间,数据精度/大数的处理上能更加得心应手,并且数据传输/存储(比如乱码)的问题能了解得更加深入,希望读者阅读此文,有所收获,当然,如果有什么错误的话也可以及时指出~

本文相关链接、引用来源、参考资料

很大部分是维基百科,这部分就只贴部分链接了

最后

谢谢阅读~
欢迎follow我哈哈github.com/BUPT-HJM
欢迎继续观光我的新博客~

欢迎关注