这世上为什么要有乱码这个东西...

先给大家出个思考题吧,一个汉字占多少字节?是不是网上搜出的答案五花八门,那么读完本篇文章,我希望你至少可以准确知道这个问题的答案,我觉得就算是收获

计算机是用 0 和 1 这种二进制形式,来表示一切信息的。所以它需要对所有的信息进行编码,对整数、浮点数进行编码,对字符串进行编码,对声音、图片、视频进行编码。每一种编码都可以深入研究来品味,比如人们发明出补码来使计算机的减法变成加法,还有各种将声音、图片等看似不可能的媒体信息编码成 0 和 1。一切都很美妙,唯独字符编码很讨人厌,往往程序员们都不愿意碰,因为它实在是太乱了。

那今天就由我来帮你理一理。

一、什么是编码

这个问题很重要

编码是把数据从一种形式转换为另外一种形式的过程,而解码则是编码的你过程。

注意,这里可没有说计算机哟,所以编码是一个更大的概念,比如我们每个人都有名字,那你的名字就是你这个人的一种编码。你还有身份证号,那你的身份证号又是你的一种编码。别小看这个简单的例子,它能解释你经常混淆的两个概念。

二、字符集与字符编码

字符集是一个系统支持的所有抽象字符的集合,它是各种文字和符号的总称,比如 ASCII 字符集、GBK 字符集,这就好比刚刚说的所有人这个集合。

字符编码则是怎么把字符集里的这些字符一一用二进制表示的一个字典,或者说一个函数,比如 ASCll 字符编码、GBK 字符编码,这就好比刚刚说的名字表示法身份证号表示法

咦?ASCII 字符集对应的编码方式是 ASCII 字符编码,GBK 字符集对应的编码方式是 GBK 字符编码?没错,通常来说,字符集同时定义了一套同名的字符编码规则。有人就有疑问了,那人这个字符集,不是可以用名字和身份证号两种字符编码么?是的,字符也可以,比如 Unicode 字符集,就可以用 UTF-8、UTF-16 等多种字符编码来表示。

还需要注意的一点是,在计算机的世界里,不会有像名字表示法这样,一个名字可能对应好多人的情况,所以名字表示法在字符编码这里,就不是一个好的字符编码方式,自然也不会有人广泛采用了。

三、字符编码的起源 ASCII

世界上第一份字符集和编码标准,显然是由美国人起草的,就是大名鼎鼎的 ASCII,一共包含了 128 个字符以及对应的二进制,比如小写字母 a 对应 01100001。这 128 个字符包括了可显示的 26 个字母(大小写)、10 个数字、标点符号以及特殊的控制符,也就是英语与西欧语言中常见的字符,这 128 个字符用一个字节(可表示 256 个字符)来表示绰绰有余,所以当时只用了 7 位,还留了一个最高位当做奇偶校验。不奇怪,英语国家的人觉得这真的足够了,假如世界上所有人都用英语,那字符编码真的超级简洁,也就不会有什么乱码问题和这篇文章了。

每一种字符编码最好的描述方式就是简单粗暴的一个字典,或者说一张表,比如 ASCII,没什么可解释的,它就是一张表。编码就从右往左看,解码就从左往右看。详见附录 ASCII。

四、字符编码开始初步发展(欧洲)

EASCII:扩展的 ASCII

我们说 ASCII 发生于美国,因为一开始只有美国有计算机,所以 ASCII 足够了。可是随着计算机的普及,西欧等国家首先开始使用(注意这时候还没有中国)。西欧语言的字符虽然没有中文这么多,但有很多字符是 ASCII 表示不了的。于是他们就把 ASCII 扩充变成了 EASCII,这扩充的包括希腊字母、特殊的拉丁符号等。由于 ASCII 只占了 7 位,所以 EASCII 把第 8 位利用起来,仍然是一个字节来表示,这时表示的字符个数是 256。

但 EASCII 并没有成功,西欧国家以及各个 PC 厂商各自定义出了好多不同的编码字符集,这时候你自然就能想到,一定有一个组织站出来统一这个混乱的局面,制定一个标准,这个组织就是国际标准化组织 ISO国际电工委员会 IEC

ISO-8859

这两个组织制定了一系列的 8 位字符集标准,叫做 ISO-8859。请注意,这叫一系列,我们常说的 ISO-8859-1 才是一个字符集标准,只是 ISO-8859 系列中的一个。之所以定一个系列,因为那时候还想着用单字节来编码,但除去 ASCII 占有的 0x00~0x7F,就只剩下 0x80~0xFF 可以使用了,比如将ISO-8859-1,就是向下兼容 ASCII 的字符集标准,其编码范围是 0x00~0xFF,0x00~0x7F 之间完全和 ASCII 一致,0x80~0x9F 之间是控制字符,0xA0~0xFF 之间是文字符号。

各个国家的符号都容纳进来显然是不够的,于是就分成了好多个版本,你是哪个国家的就用哪个。我们之所以经常提到 ISO-8859-1,是因为它适用于西欧国家,而英国就是西欧国家。

以下是全部的 ISO-8859 系列

我们看到,ISO-8859-1 又叫做 Latin-1,所以现在你应该知道,有的地方比如 MySQL 里的字符集显示 Latin-1,不要陌生,他只是 ISO-8859-1 的别名。

还是那句话,字符编码就是一个字典或者对照表,说什么都不如列个表实在,ISO-8859-1 的对照表(只列出扩展了 ASCII 的部分)详见附录 ISO-8859-1

五、字符编码继续发展(来中国了)

GB2312

计算机开水普及到了中国,于是一切就不一样了。原本用一个字节就解决所有的符号编码,在中国是行不通的。于是 1981 年国家标准化管理委员会定了一套字符集叫 GB2312,每个汉字符号由两个字节组成,注意!这里变成了两个字节。理论上它可以表示 65536 个字符,不过它只收录了 7445 个字符,6763 个汉字和 682 个其他字符,同时它能够兼容 ASCII。

GBK

GB2312 所收录的汉字已经覆盖中国大陆 99.75% 的使用频率,但是对一些罕见的字和繁体字还有很多少数民族使用的字符都没法处理,于是后来就在 GB2312 的基础上创建了一种叫 GBK 的字符编码,GBK 不仅收录了 27484 个汉字,同时还收录了藏文、蒙文、维吾尔文等主要的少数民族文字。GBK 是利用了 GB2312 中未被使用的编码空间上进行扩充,所以它能完全兼容 GB2312 和 ASCII。

BIG5

台湾地区繁体中文标准字符集,采用双字节编码,共收录 13053 个中文字,1984 年实施。

GB18030 编码

2000 年 3 月 17 日发布的汉字编码国家标准,是对 GBK 编码的扩充,覆盖中文、日文、朝鲜语和中国少数民族文字,其中收录 27484 个汉字。GB18030 字符集采用单字节、双字节和四字节三种方式对字符编码。兼容 GBK 和 GB2312 字符集。

中文字符集总结

简单总结下上面的就是,ASCII < GB2312 < GBK < GB18030,后者是前者的扩充,也即兼容了前者。然后 BIG5 是台湾字符集,单独的一套。

彻底摆脱乱码的困惑-LMLPHP

这里拿最常用的 GBK 编码举例,GBK 的中文编码是双字节来表示的,英文编码是用 ASCII 码表示的,既用单字节表示。但 GBK 编码表中也有英文字符的双字节表示形式,所以英文字母可以有 2 种 GBK 表示方式。为区分中文,将其最高位都定成 1。英文单字节最高位都为 0。当用 GBK 解码时,若高字节最高位为 0,则用 ASCII 码表解码;若高字节最高位为 1,则用 GBK 编码表解码。你可以看到上图中第一个字节在 00~7F(二进制 00000000~01111111,所以第一位是 0) 之间的都是 ASCII。

理论上说,中文字符编码也应该列一个对照表,无奈它实在是太多了,不但汉字数量多,而且编码方式也多。所以这里只列一下数量最少的 GB2312 ,而且用链接的形式给你。

六、字符编码终极发展(遍布全球)

Unicode

跟 ISO-8859 的出现原因一样,只不过这次范围扩大到了全世界。当然世界各国都有自己国家的字符编码发展历史,这里就没必要一一展开了。1991 年,国际标准化组织和统一码联盟组织退出了 Unicode 项目,目的就是同一全世界的所有字符。

你可能知道 Unicode 分 UTF-8、UTF-16、UCS-2 等,而 ISO-8859 也分 ISO-8859-1、ISO-8859-2……你会不会觉得它们是一样的道理呢?错!

  • ISO-8859 是一个字符集的系列,分成 ISO-8859-1、ISO-8859-2 等好多字符集,而每个字符集对应的编码方式就是 ISO-8859-1 编码、ISO-8859-2 编码,是一对一的关系。
  • Unicode 本身就是一个字符集,是全世界所有字符的合集,它并不是字符集系列。而 Unicode 这个字符集特殊的地方在于,他的编码方式不叫 Unicode 编码,它的编码方式有很多种,分别是 UTF-8 编码、UTF-16 编码等。

所以字符集系列和字符集的区别,最好的例子就是 ISO-8859;而字符集和字符编码的区别,最好的例子就是 Unicode。

捋清了这个关系,下面我们就可以详细说说大家心心念念的 Unicode 了。Unicode 本身并没有规定一个字符究竟是怎么编码,甚至都没有规定用几个字节表示,所以也叫可变长度字符编码。Unicode 只规定了每个字符对应到唯一的代码值(code point),代码值从 0000~10FFFF 共 1114112 个值,你曾经看到的很讨人厌的 \u0300 这种,就是 Unicode 的代码值,这种代码值要想变成真正存储在机器里的字符串,一定要进行某种编码,如下。

ustr = \u0030;
str = ustr.encode("utf-8")

真正存储的时候需要多少个字节是由具体的编码格式决定的。比如:字符 「A」用 UTF-8 的格式编码来存储就只占用 1 个字节,用 UTF-16 就占用 2 个字节,而用 UTF-32 存储就占用 4 个字节。而 UTF-8 本身,又不是固定长度的,也是可变长度的。

所以从这里你可以看出,一般一种编码方式是如何兼容其他编码的,又是如何可变长度的。UTF-8 编码的第一位如果是 0,则只有一个字节,跟 ASCII 编码完全一样,所以兼容了。如果是 110 开头,则是两个字节,以此类推如上表所示。所以开头几位的值,是编码本身,同时又是判断是几个字节数的推码,可谓是一箭双雕。这种设计颇有点像 CPU 中的相联存储器的概念。

所以如果再有人问你这个问题:UTF-8 占几个字节汉字到底占几个字节?你就可以这样专业而又不装逼的回答:

首先汉字占几个字节这个问题本身就不明确,应该问 Unicode 字符集中的汉字用 UTF-8 编码方式编码,占几个字节?

那这个问题就演化成了 UTF-8 占几个字节。UTF-8 是可变长度字符编码,所以只能说占 1~4 个字节。

  • 单字节可编码的 Unicode 范围:\u0000~\u007F(0~127)
  • 双字节可编码的 Unicode 范围:\u0080~\u07FF(128~2047)
  • 三字节可编码的 Unicode 范围:\u0800~\uFFFF(2048~65535)
  • 四字节可编码的 Unicode 范围:\u10000~\u1FFFFF(65536~2097151)

那基于各种历史的或者是合理性原因,人们把占用 1 个字节的给了 ASCII;占 2 个字节的给了拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文等;占 3 个字节的给了大部分汉字,基本等同于 GBK,含 21000 多个汉字;占 4 个字节的中日韩超大字符集里面的汉字,有 5 万多个。

所以,汉字占三个字节这句话,应该说成

七、字符编码的总结

按时间线

  • 美国线:ASCII --> EASCII --> ISO-8859 --> Unicode
  • 中国线:GB2312 --> GBK --> GB18030 --> Unicode
  • 他国线:... ... --> Unicode

按含义

  • 字符集系列(对应 n 多个字符集):ISO-8859 系列
  • 字符集(对应 n 多个字符编码):ASCII、ISO-8859-1、GBK、Unicode
  • 字符编码:ASCII、ISO-8859-1、GBK、UTF-8、UTF-16

按字节数

  • 单字节:ASCII、ISO-8859 系列
  • 双字节:GB2312、GBK
  • 可变字节:UTF-8、UTF-16

八、为什么会产生乱码

前面说过,编码和解码,就是一个查表的过程,正着查是二进制的值到字符,反着查是字符到二进制的数值。所谓乱码,就是编码和解码查的不是一张表嘛。就好比你叫你丈夫的爹为“公公”,你是查现代汉语词典编码出的“公公”两个字,人家爸爸却拿着古代汉语词典来解码你这个“公公”的含义,这不乱才怪嘛。

自己构造一个乱码很简单,我们用 UTF-8 来编码一个“你好”这两个字,再用 GBK 解码来阅读,看看会怎么样。

  1. 首先新建一个 txt 文件,就叫【乱码.txt】吧,然后用二进制方式打开它(我用的是 Notepad++,下载了 Hex-Editor 插件)。

  2. 我们查 UTF-8 编码字典,发现你好两个字的编码是“E4BDA0”、“E5A5BD”,我们将编码输入。

  3. 好吧你说这哪是 2 进制啊,这不是 16 进制么。那我们就直接一点,用纯 2 进制的方式输入,“111001001011110110100000”,“111001011010010110111101”。

彻底摆脱乱码的困惑-LMLPHP

  1. 返回正常的文本状态,选择用 UTF-8 编码方式查看,发现是正常的“你好”两个字。但如果切换成 GBK(这里我选择了 GB2312,一样的),就变成了奇怪的文字。

彻底摆脱乱码的困惑-LMLPHP

  1. 这很好理解,UTF-8 编码汉字是 3 个字节,刚刚我们编码了“你好”,对应的字节序列是 “E4BDA0”、“E5A5BD”,一共 6 个字节。GBK 编码汉字是 2 个字节,于是解读成了 “E4BD“、”A0E5“、”A5BD”,三个汉字。那我们查 GBK 码表:

彻底摆脱乱码的困惑-LMLPHP

彻底摆脱乱码的困惑-LMLPHP

彻底摆脱乱码的困惑-LMLPHP

你有没有发现,居然跟我们看到的是一样的!这还是比较有好的乱码,起码还能对应上真正码表中存在的汉字。有的乱码字节数都不对,或者干脆码表中查不到,就难看极了。这就要看各个不同的文本查看软件怎么处理了,比如我 GBK 本来是双字节的,我硬生生把一个字的后一个字节删了,这种情况 Notepad++ 还算友好,可以让你看到是因为少了一个字节出的问题,如果用记事本打开,就是一个大写的 “?”。

彻底摆脱乱码的困惑-LMLPHP

彻底摆脱乱码的困惑-LMLPHP

编码我们都理解了,就是最终写在计算机内存里的 01010 字节序列。而对于解码,似乎还是有一点迷惑,解码解成了什么呢?要相信自己的判断,没错,解码就是解成了我们眼睛看到的这些东西,他们的本质就是屏幕上显示的光点。比如说汉字,其实解码我们所查的表,最终对应的就是一个 n*n 的矩阵,最终再经过一些列的转换,由串口输出到显示屏上,矩阵中的 1 就代表有,0 就代表无,经过放大缩小等线性变换,最终达到屏幕上的一个个小光点上,就变成了我们看到的字。老一辈的计算机从业者,有很多人力需要去造这张表,就是把每个编码对应的这个矩阵画出来,最终存成字库。如果你听老一辈的计算机从业者讲述,将听到很多关于这里的故事,像“区位码”,这里我就不展开说了,其实也是因为没经历过,不太了解。

九、如何解决乱码

上一环节我给你展示了一个文本文件如何产生了乱码,那如何解决文本文件的编码呢,很简单,保存的时候用什么格式编码保存的,读取的时候就用什么时候读。比如 Notepad++ 就很清晰,保存和读取都在【编码】这个菜单栏里。

对于记事本,我们似乎没看到编码这一项,其实如果你选择另存为,一般会有四个编码选项让你选择,分别是 ANSI、Unicode、Unicode big endian、UTF-8。如果你不选择的话,默认保存是用 ANSI,那 windows 平台一般是指的 GBK

这里你可能会困惑,刚刚不是说了 Unicode 不是字符集编码,而只是字符集么,这里怎么又出现在编码了。没错,这就是字符编码比较乱的地方之一,命名不规范,有很多潜规则。比如这个记事本的 Unicode:Windows 平台下默认的 Unicode 编码为 Little Endian 的 UTF-16,实际上它还是指的是一个具体的编码格式,只不过被潜规则在 Windows 平台下了,让很多人误以为 Unicode 就是特指某种具体的编码方式。

浏览器

刚刚解释了下记事本的乱码解决,其实所有工具都是一样的,只要有文本阅读的地方,一般都会有设置编码的地方。那么我们来看一下最常见也最容易出错的浏览器。我们打开一个网页发现是乱码,这尤其经常发生在我们开发测试阶段,那么我们先抛离服务端,单纯创建一个乱码的页面。

用 Notepad++ 写一个以 UTF-8 编码的文件 test.html:

<!DOCTYPE HTML>
<html>
    <head></head>
    <body>
        你好
    </body>
</html>

用 IE 浏览器打开,直接乱码:

彻底摆脱乱码的困惑-LMLPHP

而如果我们重新用 GBK 编码写一个一模一样的文件,再用 IE 打开,正常。这是为什么呢?原因很简单,IE 浏览器默认用 GBK 去解码一个 HTML。这又是一个潜规则。

而我们怎么才能让它显示正确呢?你可以强行改浏览器的编码方式,但你搞一个服务,总不能让用户去做这个事情吧?一个个试哪个编码方式正确?即使浏览器功能强大到可以智能分析编码,也最好有一个标识来告诉浏览器这个 HTML 是如何编码的,这个标识就是:

<meta charset = "utf-8"/>

把它加载 header 中:

<!DOCTYPE HTML>
<html>
    <head>
        <meta charset = "utf-8"/>
    </head>
    <body>
        你好
    </body>
</html>

再用 IE 浏览器打开,正常!

那么我们就知道了,页面产生乱码,不是我们的问题,要么是服务端没有设置这个 meta charset,要么就是服务端设置了,但实际上编码响应流的时候却用了其他编码方式。当然我们也能自己强行解决,那就是设置浏览器的编码方式,不同浏览器设置编码方式的位置不一样,而且我个人感觉也很难找,不同浏览器的默认编码方式也不同,而且还有可能有 Unicode 指的是 UTF-8 这样的潜规则。所以为了彻底杜绝这个问题,还是在 HTML 文件里面告知为好。

服务器

既然浏览的页面乱码怪服务端,那我就不得不说说服务端这边的事。

我们写一个 Spring Boot 程序:

@SpringBootApplication
@Controller
public class JavamateSpringbootApplication {

    public static void main(String[] args) {
        SpringApplication.run(JavamateSpringbootApplication.class, args);
    }

    @RequestMapping("hello")
    @ResponseBody
    public String hello() {
        return "你好";
    }
}

浏览器访问 /hello,完美显示“你好”,并没有乱码。

正因为 Spring Boot 为我们做了太多事,才这么容易发生不乱码的情况。其实与其说为什么会乱码,不如解释解释为什么这段代码没有乱码。

首先没有乱码,一定是编解码用的是同一套。那编码是什么呢,这里就又涉及到潜规则了,Spring Boot 默认情况下,@ResponseBody 会用 UTF-8 对字符串进行编码,而且会为响应体设置一个相应头:

Content-Type: text/javascript; charset=UTF-8

解码方面,IE 浏览器默认是 GBK,但你响应体里有这个头了,那 IE 会改成用 UTF-8 解码。

不知道你有没有经历过 Tomcat 时代的 ISO-8859-1 乱码的时代,那时候没有这些强大的开发框架,好多地方可能要 response 直接 write 数据出去,而 Tomcat 此时的默认编码是 ISO-8859-1。所以那时候好多地方都需要手动改成 UTF-8。由于 UTF-8 渐渐变成了国际标准,Spring Boot 框架也将内嵌的 Tomcat 默认编码格式改成了 UTF-8。

那我们怎么搞出一个乱码呢?很简单,我们用默认编码格式为 ISO-8859-1 的 Response 直接 write 出去,而不依赖于框架。

@RequestMapping("hello2")
public void hello2(HttpServletResponse response) throws IOException {
    PrintWriter writer = response.getWriter();
    writer.println("你好");
    writer.flush();
}

浏览器打开,直接乱码。

其实更直观的写法应该是这样,抛开所有默认的外套:

@RequestMapping("hello2")
public void hello2(HttpServletResponse response) throws IOException {
    response.setCharacterEncoding("gbk");
    response.addHeader("Content-Type", "text/html");
    ServletOutputStream out = response.getOutputStream();
    out.write("你好".getBytes("utf-8"));
    out.flush();
}

浏览器打开,看到了我们熟悉的乱码。

浣犲ソ

我们看到代码中,直接表达了:将“你好”用 UTF-8 格式编码,并通过响应头告诉浏览器,用 GBK 的方式解码。这自然就乱码了。

现在的服务端框架,已经达成了用 UTF-8 作为编解码标准的共识,而且 String 的 getBytes 方法默认的也是 UTF-8,也可以通过 file.encoding 参数配置。所以现在基本不用担心乱码问题了。在 Tomcat 乱码满天飞的时代,解决乱码问题也无非是:

  • 看字符串本身的编码
  • 看设置回响应头的编码
  • 看 HTML 文件的 meta 属性的编码

附录后文末有惊喜

十、附录

ASCII 和 ISO-8859-1 的编码表

ASCII

ISO-8859-1

逗你呢,没什么惊喜。

但这样搞大家有点于心不忍

给大家出一个题吧,1 斤 100 元的纸币和 100 斤 1 元的纸币,你选拿个?公众号下期文章揭晓答案

11-15 14:40