搞懂字符编码

Clloz · · 312次浏览 ·

前言

我们经常听到 ASCIIUTF-8UTF-16,这些都是字符的编码格式,它们之间有什么区别,为什么要搞这么多字符的编码格式,在写代码的过程中我们会遇到各种编码格式的字符,所以经常需要 encodedecode,如果不搞清楚字符编码到底是什么,每次遇到编码问题都会很头疼,搜索引擎都很难帮助你解决问题,久而久之对字符编码产生厌恶。所以还是一次性把这个问题搞清楚最好,免除后顾之忧。

给字符编码

我们的计算机只能处理数字,所有的信息无论是在硬盘还是内存里都是二进制字节流,8个二进制位 bit 组成一个字节 byte,每一个二进制位可以表示 01 两种状态,一个字节就可以表示2^8256种状态,从 0000000011111111。但是我们生活中使用的不只有数字,还有各种各样的字符,我们怎么才能在计算机中使用和操作字符呢?所以我们就需要制定一种规则,把我们要使用的字符和计算机能识别的二进制数一一对应起来,这样计算机在解析到字符对应的二进制数就知道要显示哪个字符,再把这个字符渲染到显示器上就可以了。这样我们就把我们使用的字符转化为计算机能识别的数字,最后计算机再把这个数字渲染成我们认识的字符,就实现了我们在计算机中操作字符的需求。

现在还有一个问题是,我们要显示多少种字符,每一个字符对应一个状态,有多少字符我们就有多少种状态,从而知道我们要用多少位二进制数来显示全部字符。由于计算机最早是在美国发明的,上世纪60年代的时候,计算机科学家就根据当时的需求制定了一套字符编码,就是我们现在说的 ASCII 码,这套编码一直到今天还在使用。ASCII 码一共规定了 128 个字符的编码,包含常见的英语字符和一些控制符号,比如空格 SPACE32(二进制 00100000 ),大写的字母 A65(二进制 01000001 )。这 128 个符号(包括 32 个不能打印出来的控制符号),只占用了一个字节的后面 7 位,最前面的一位统一规定为 0

ascii

编码扩展

由于当时计算机刚刚开始发展,使用的人还很少,在英语环境中,ASCII 码基本上也够用了。可是这个世界上语言众多,不是所有国家都是用英文的,当欧洲人开始使用计算机发现,我们的字母也需要编码使用呀,比如,在法语中,字母上方有注音符号,它就无法用 ASCII 码表示。不过一个字节表示 ASCII 码不是还空余一位嘛,用上这一位又可以表示 128 个字符了,于是欧洲国家纷纷用这一位闲置的位来表示新的符号,比如,法语中的 é 的编码为 10000010。这样一来,这些欧洲国家使用的编码体系,可以表示最多 256 个符号。

但是世界上的语言实在是太多了,就 128个编码空间实在是不够,于是各个国家用后 128 个二进制数表示自己的语言,比如,10000010 在法语编码中代表了 é,在希伯来语编码中却代表了字母 Gimel (ג),在俄语编码中又会代表另一个符号。每个国家的字符编码都兼容 ASCII 码,也就是前 128 个编码都是 ASCII,后面的 128 个就根据自己国家的语言来。

而到了东亚这边,情况就更严重了,中文,日文,韩文等等东亚国家的文字都非常多,远不是一个字节能表示的。光是中文就好几千常用字,而且很多人的姓名里面有一些生僻字,总不能连自己的名字也打不出来把。一个字符明显不够,于是我们就加一个字节,设计出了 GB2312 字符集。一个字节功能表示256种状态,两个字节一共是 16 位二进制数,可以表示2^{16}65546 种状态。不过 GB2312 只收录了一些常用汉字 7445 个。由于这些常用字符还是不太够用,后来有扩展成 21886 个字符的 GKB,也就是现在最常用的中文字符集,windows的中文系统就用的 GBK 编码。

我们的 GBKGB2312 同样是兼容 ASCII 码的。

字符集和字符编码

这两个词容易引起误解,要清楚地解释它们的区别不太容易,特别是因为大家对 ASCII 的概念比较深就容易混淆,因为 ASCII 因为字符较少可以直接把字符集中的元素按自然数排列拿来做编码。因为 ASCII 中每一个字符都在一个字节里面,我们直接就用这种最简单的方式实现就可以了,不会引起计算机的误解。但是中文是两个字节表示,英文是一个字节表示,如果这两种字符混合在一起,计算机该怎么分辨呢?可能你会觉得英文也用两个字节不就可以了,但是这回造成空间的浪费,如果一篇全是英文的文章,用这样的方法大小就会是原来的两倍。那么混合的编码怎么处理呢?在 GB2312 里面,当一个字节的第一位是 0,那么就代表这是一个 ASCII 码,而其他字符都是第一位为 1 的两个字节组成。这样计算机在解码的时候就知道,遇到字节是以 0 开头的,就知道这一个字节就表示了一个字符;遇到字节是以1开头的,就知道要加上下一个字节合起来表示一个字符。这样就在 GB2312 中既把 ASCII 的字符包含了进来,又能将它们区分出来,能达到兼容的效果了。

比如用 GB2312 来写 我叫ABC ,那么二进制编码是 11001110 11010010 10111101 11010000 01000001 01000002 01000003,解码的时候,当遇到 1 开头的字节,就把两个字节合起来解释为一个字符,于是 11001110 11010010 会被解释为我;遇到 0 开头的字节,就只把这个字节解释为一个字符,于是 01000001 就会被解释为 A 了。

我认为可以这么理解, 字符集就是我们所有要用的字符的集合,集合的三大特性相信大家都学过 确定性,无序性,互异性 (实际操作不会是无序的,会有一个最简单的映射,比如自然数排列),而字符编码用二进制数对集合中的字符进行一一映射,这种一一映射可以有无数种,比如我有 100 个字符,我可以是从 0~99 的自然数,我也可以是从 0~198 的偶数,甚至如果我高兴,我可以是从 1000~901 的倒序数,对于集合中的元素也是,比如 这个字,我可以把它放在映射的第一个,也可以把它放在最后一个,最重要的是,我们选择的这种映射能够最有效率地利用字节空间同时让计算机能够轻松地识别每一组映射,这因为这个需求我们的 GB2312 字符编码才选择了上述的映射方式,因为这是比较有效率,计算机也能轻松识别的。而 ASCII 选择的映射就是最简单的从 0 开始按自然数排列,因为它的字符少也不需要考虑兼容,这中方式就是最有效率最合适的,但是对于一些字符数量非常多还要考虑兼容其他字符的字符集来说,就需要考虑更好的实现方案。理解这两者的却别对于后面的 Unicode 字符集和它的多种编码方式有帮助。

维基百科上有现代编码模型,整体我的理解还是没问题的。

Unicode

在早期,网路还不是那么发达的时候,大家基本都在自己同语言范围内的网络进行活动,大家的系统以及软件的字符编码方式几乎都是差不多的,所以并不会引起什么大问题。但是随着网络越来越发达,各个国家之间的交流越来越频繁,不同字符编码导致的乱码问题让出现一个同一的编码的需求越来越强烈,这也就是现在大家所知的 Unicode

Unicode 对世界上大部分的文字系统进行了整理、编码,使得计算机可以用更为简单的方式来呈现和处理文字。Unicode 有两种格式:UCS-2UCS-4UCS-2 就是用两个字节编码,一共 16 个比特位,这样理论上最多可以表示 65536 个字符,不过要表示全世界所有的字符显示 65536 个数字还远远不过,因为光汉字就有近 10 万个,因此 Unicode4.0规范定义了一组附加的字符编码,UCS-4 就是用4 个字节(实际上只用了 31 位,最高位必须为 0 )。理论上完全可以涵盖一切语言所用的符号。世界上任何一个字符都可以用一个 Unicode 编码来表示,一旦字符的 Unicode 编码确定下来后,就不会再改变了。

这样的大型字符集在实现的时候就需要解决我们上面说的两个问题:
– 对于不需要多个字节表示的字符,怎么避免存储空间的浪费。
– 对于多个字节,比如两个字节,到底是一个两字节字符还是两个单字节字符。

Unicode 字符集有一套自己的字符和编码的映射,但是具体到计算机上的实现需要考虑上述两个问题。如 字的 Unicode 编码是 6C49,我们可以直接按这个编码传输,也可以用 utf-8 编码的3个连续的字节 E6 B1 89 来表示它。关键在于通信双方都要认可。因此Unicode编码有不同的实现方式,比如:UTF-8UTF-16 等等。Unicode 作为各种实现的一个中介。

UTF-8

UTF-8Unicode Transformation Format )作为 Unicode 的一种实现方式,广泛应用于互联网,它是一种变长的字符编码,可以根据具体情况用 1-4个字节来表示一个字符。比如英文字符这些原本就可以用 ASCII 码表示的字符用UTF-8表示时就只需要一个字节的空间,和 ASCII 是一样的。对于多字节( n 个字节)的字符,第一个字节的前 n 为都设为 1 ,第 n+1位设为 0,后面字节的前两位都设为10。剩下的二进制位全部用该字符的 unicode码填充。

Unicode符号范围 (十六进制) UTF-8编码方式(二进制) 十进制表示
U+0000 0000 – U+0000 007F 0xxxxxxx 0 – 127
U+0000 0080 – U+0000 07FF 110xxxxx 10xxxxxx 128 – 2047
U+0000 0800 – U+0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx 2048 – 65535
U+0001 0000 – U+0010 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 65536 – 1114111(2097152)

比如 的 Unicode 是 4E25(100111000100101,根据上表,可以发现 4E25 处在第三行的范围内 (0000 0800 - 0000 FFFF),因此严的 UTF-8 编码需要三个字节,即格式是 1110xxxx 10xxxxxx 10xxxxxx。然后,从严的最后一个二进制位开始,依次从后向前填入格式中的 x,多出的位补 0。这样就得到了,严的 UTF-8 编码是 11100100 10111000 10100101,转换成十六进制就是 E4B8A5

乱码

由于字符编码多种多样,不同的字符编码之间互相不能兼容就会造成乱码现象。首先要明确的一点是,我们在显示器上看到的字符都是经过计算机用对应的字符编码解码以后渲染给我们的,在计算机存储设备上保存的,以及在网络上传输的,都是字符经过编码后的二进制字节流。打个简单的比方,你在一个网页复制了一段文本到你的 word 或者 txt 里面去,在计算机内部,你也不过是复制了这个字符对应的编码值过去,比如我在 vscode 里面创建了一个 GBK 格式的文本,然后在用 UTF-8 的格式打开,那么就会出现乱码。但是如果我们直接复制文本到其他 utf-8 的文本中去,不会乱码,这应该是软件自动帮你转码过了。

encode-gkb

encode-utf-8

不同的系统,不同的编辑器,不同的程序编译器解释器,默认支持的编码可能都是不同的,我们复制或者从浏览器获取的字符,都只是一段二进制字符编码,如果我们所在的环境的字符编码中没有我们所要显示的这段字符的映射,自然就会出现乱码。比如 windows 的中文系统默认编码是 GBK,那么在 windows 的命令行运行的程序如果输出的是 utf-8 的字符,将会出现乱码。不过现在大部分系统和环境都是支持Unicode的,比如windows系统就能够把Unicode映射到 GBK,映射表见Unicode 12.0 Character Code Charts,那么如果你的环境能够帮你把你的字符编码转换成 Unicode 编码,那么大部分的程序和系统都能够识别了。所以在写代码的过程中如果遇到乱码,检查我们的字符的编码格式和环境的编码格式是否一直,如果不一致可以利用语言提供的转码工具转成 Unicode 来解决乱码问题。比如 python 中的 encodedecode

UTF-8 –> decode 解码 –> UnicodeUnicode –> encode 编码 –> GBK / UTF-8

编程语言中的字符编码

我们经常会看到某某语言默认用的什么编码,这种说法让人很疑惑,因为我们把编程语言的默认编码和我们使用的编辑器支持的编码当成了同一个东西,我们的编辑器可以支持各种编码,但当我们写好程序,要用进行程序的编译或者解释运行的时候,编译器或解释器对变量在内存中的处理使用的字符编码就是程序的默认编码。比如 python2 默认编码就是 ascii,也就是说当我们的程序被 python 解释器装载到内存运行的时候,解释器会把编辑器中保存的编码识别成 ascii,比如我们在编辑器中用了 utf-8,而我们保存了一个字符 ,它的 utf-8 编码是 11100100 10111000 10100101 三个字节,可是 python2 的解释器会把它当作三个 ascii 来处理,这必然会出错,所以要在程序文件的开头声明文档的编码格式,这样解释器才知道怎么转码。不过 python3 已经会自动把我们的编码转成 unicodeUnicode 是能够被各种环境识别的。如果你还想更多地了解,可以看这篇文章:Unicode之痛

前端使用 unicode

字符 unicode 编码查询点击查询链接

CSS 中的使用

比如在伪元素的 content 中使用 unicode,使用方法是 \ 后加上 unicode 编码的 16 进制的表示,比如unicode 编码的 16 进制的表示是 4F60,我们可以这样使用:

h4::after {
    content: '\4F60';
    font-size: 20px;
    color: red;
}

HTML 中的使用

HTML 中我们经常使用的 HTML entity 实体 比如 &unicode 的使用方法也与这个相同就是在 & 后加上 #unicode 对应的十进制表示,上面的 就用如下方法表示:

<h4>你  </h4>

JavaScript 中的使用

JavaScript 中的使用是大家最熟悉的,使用方法是 \u 后加上 unicode 对应的 16进制表示

var h4 = document.querySelector('h4')
h4.innerText = '\u4F60'

总结

任何技术的产生都是有历史原因的,也都是为了解决问题的。所以我们学习知识要带着问题去学,知道这个技术是为了解决什么问题而产生的,自然能把知识形成体系,而不容易遗忘,并且运用到合适的地方。如果不知道问题只是背了个答案,那么可能你并没有真的“学会”这个知识。

如果你也和我一样对字符是如何从键盘敲击到渲染到屏幕上的过程很感兴趣,你可以看看知乎答主乌鸦给出的答案:计算机系统是如何显示一个字符的?

参考文章

  1. The Unicode Consortium
  2. Unicode in JavaScript
  3. Python 编码为什么那么蛋疼?

Clloz

人生をやり直す

发表评论

电子邮件地址不会被公开。 必填项已用*标注

我不是机器人*

 出现新的回复时用邮件提醒我。

EA PLAYER &

历史记录 [ 注意:部分数据仅限于当前浏览器 ]清空

      00:00/00:00