编程中的字符编码问题

以Python为例进行说明

Posted by tianhaoo on April 4, 2019 本文阅读量

背景

前辈的话

一旦走上了编程之路,如果你不把编码问题搞清楚,那么它一定会像幽灵一般纠缠着你整个职业生涯,各种灵异事件会接踵而来,挥之不去。只有发挥程序员死磕到底的精神你才有可能彻底摆脱编码问题带来的烦恼。

所有的编码方式

随着计算机的发展,我们在中文环境下能遇到的编码方式大概有以下几种:

  1. ASCII只有英文字母、数字和符号等
  2. GB2312包含ASCII,还有全部的汉字
  3. GBK在GB2312的基础上增加了藏文等字符
  4. ANSI 在简体中文系统下,ANSI 编码代表 GB2312 编码,在日文操作系统下,ANSI 编码代表 JIS 编码,具体代表哪种取决于系统默认的编码方式
  5. Unicode包含所有的字符编码,但体积比较大
  6. UTF-8压缩版的Unicode,类似霍夫曼编码的方式(概率大的码长短)

然而无论怎么编码,我们的目的只有一个,那就是把非数字的文本存到计算机里去。我们知道计算机只能存储二进制的数据(也就是0和1)以上几种不同的编码方式 只是意味着映射的关系不同例如在ASCII中十进制数字65(也就是二进制的1000001)代表字母A,而在Unicode中则对应着一个另一个完全不同的字符。

总之,编码方式是文本与二进制数据之间的一种对应规则

编码带来的困惑

根据前面的分析,不同的字符集有不同的编码方式,不同的操作系统,不同的文本编辑器都会使用不同的编码方式,这下简直乱了套了,那么操作系统和编辑器是怎么解决这个问题的呢?

操作系统的角度

作为操作系统,有责任将文件系统中每一个完好的文件正确的解码显示出来,但操作系统只能看到磁盘上面010101011这种二进制数据。那么这个文件是使用何种编码方式进行编码的?不同的文件类型有不同的方法:

字节序标记BOM

有的文件使用“字节序标记BOM” (Byte Order Mark),也就是放置于编码字节序列开始处的一段特殊字节序列,用于表示文本序列的大小端序。当操作系统看到后缀名是.xxx的时候,就会先去读前几个字节,确定文件是使用何种编码方式进行编码的,然后后面的内容都是用这种方式进行解码,这样文档的内容就可以正确的显示出来了。

这本来是很好的方法,但不符合Linux&Unix系的设计理念,因为字节标记序那一段内容是对用户不可见的,但Linux&Unix觉得任何文件内容都应该是可编辑的,所以在linux系统中很少见到使用BOM的方法。

类似WINDOWS自带的记事本等软件,在保存一个以UTF-8编码的文件时,会在文件开始的地方插入三个不可见的字符(0xEF 0xBB 0xBF,即BOM)。它是一串隐藏的字符,用于让记事本等编辑器识别这个文件是否以UTF-8编码。对于一般的文件,这样并不会产生什么麻烦。但有些情况下,BOM是个大麻烦,它会将前面几个字符当成是文本内容进行读取,造成意料之外的后果。

其他方式进行标记

实际上有些程序无法使用BOM的标记方式,例如PHP文件,如果用记事本打开一个.php文件,没问题,正常显示,但是再用php.exe去解析这个.php文件的时候就报了错。因为我们知道php文件开头必须有<?php的字样,但是记事本在打开该文件后往开头插入了BOM标记,但php解释器对这一点并不知情,所以造成了错误。

在python2中则是使用了另一种更好的方法:在python源文件的开头加上#coding=utf-8之类的标记,官网的介绍是这样的:

This PEP proposes to introduce a syntax to declare the encoding of a Python source file. The encoding information is then used by the Python parser to interpret the file using the given encoding. Most notably this enhances the interpretation of Unicode literals in the source code and makes it possible to write Unicode literals using e.g. UTF-8 directly in an Unicode aware editor. 也就是说python2使用这种方式来声明Python2源文件的编码,然后Python解析器将会这个编码信息来使用给定的编码解释文件。

而在python3中,python脚本文件是默认使用UTF-8的方式进行编码和解码的,除非使用另外的编码方式,否则不用特别声明编码方式。

使用默认的编码方式

如果操作系统在面对一个二进制文件时,没有任何明确的信息告诉操作系统这一堆二进制数字是使用何种编码方式进行编码的,而这个文件的后缀名(Windows下)又明确的告诉操作系统这是一个文本文件,用户需要打开它。那么操作系统只好先看看文本编辑器的默认编码方式,像记事本的话默认是ANSI的(在中国的话也就是GB2312),那就使用ANSI进行解码,如果文本编辑器也没有指定编码方式,那就使用系统默认的编码方式进行解码。

实际上,绝大多数编码问题也都是出现在这里,如果这个未知编码方式的文本的解码方式跟编码方式不一致,那么必然会有乱码的情况出现,如果二者的编码方式是包含的关系(像UTF-8与ASCII),那么就会出现部分乱码,也就是经常出现的打开一个文件,英文正常显示,中文全是乱码。

文本编辑器的角度

这里说的是广义上的文本编辑器,不仅仅包括记事本、VS code等,包括python、C语言等所有能往磁盘上写文本文件的程序都在我们的讨论范围之内。

常规的文本编辑器

作为一个合格的文本编辑器,以记事本为例,作为一个拥有图形化界面,旨在帮助用户更方便的编辑并保存文本文件的应用程序,文本编辑器有责任使用一定的方法让自己所编辑保存的文本文件能够被操作系统识别并正确地打开。所以当使用记事本编辑文本,保存的时候,可以选择你打算使用的编码方式,然后文本编辑器就会将你输入的字符使用该编码方式进行编码,转换成二进制文件,然后写到磁盘上。

这时候可能有小伙伴问了,我在编辑文件的时候,还没有指定我希望的编码方式,那么这个文件必然是存在内存(也是一种存储器,所以当然也需要编码)里的,那这时的编码方式是怎么确定的呢?如果跟我指定的编码方式不一致,又该怎么办呢?

实际上,由于PC机上面的内存空间相对较大,所以编码都是采用Unicode的方式,只有在网络传输或者对利用空间要求高的场合才会使用诸如UTF8等方式,所以在输入文本到内存的时候,基本上都是使用Unicode的编码方式,而在存储成UTF-8的时候,只需要进行相应的转换就好了,上面我们说过编码方式只是一种对应规则,而且是双射函数,那么这个转换可以很容易做到。需要注意的是,编辑器只需要关心两件事,一是打开一个文件的时候如何确定该文件的编码方式并正确打开,二是在保存一个文件的时候如何按照用户要求的方式进行编码并合理地的标记出来。

程序语言

以python为例,让我们分析一下其中的过程。

我们先写一个python脚本文件,然后将他存在磁盘上,然后python test.py,这时候python解释器再去读取这个二进制文件,将它转换成python解释器认识的字符,最后才是执行。

在这个过程中涉及到了两次编解码的操作:

  1. 将你用pycharm等编辑器输入的字符按照一定规则编码成2进制文件,写入磁盘。这一步的编码方式在python2中是使用#coding=UTF-8来指定的,而python3中无需指定,默认就是UTF-8
  2. 第二次是发生在python解释器解释脚本文件的时候。python解释器先读取要运行的脚本文件,按照文件指定的编码方式(大部分情况下是UTF-8,但不是必须,只要能还原成Unicode里面的字符就行)进行解码成字符,然后使用Unicode的方式对字符进行编码成2进制文件,这样python解释器才可以去执行它。(至于为什么是Unicode,那么你只能去问python的创始人了,不过原因想来无非是Unicode支持所有字符,而且编辑器运行在内存中,不必担心字符占用空间的问题,自然就使用Unicode作为编码方式了)。

其他文件的编码规则

说了这么多文本文件的编码方式,其实对于非文本而言,也是有一套固定的规则进行解码的,比如图片、视频、音频文件,而这些只需要根据后缀名就可以判断使用何种方式进行读取和解码,例如.jpeg格式和.bmp格式的图片各自有相应的解码规则,改一下后缀名后操作系统便无法读取。这让我回想起几周前学院的csteaching网站面临的一次恶意攻击,一个用户在实验报告平台上上传了一段代码和一个txt文件,这个txt文件用记事本打开显示的是一堆乱码,显然这要么是解码文本的方式不对,要么这根本就不是个文本文件。在尝试使用二进制编辑器打开该文件后,发现开头几个二进制字节是89 50 4E 47 0D 0A 1A 0A(16进制),上网搜索后发现这是PNG的文件格式,然后将该文件放入虚拟机中文件名改成.png,刚改完这边windows defender就报“发现恶意软件,正在删除”,双击打开后发现是一张模糊的二次元人物图像,但该文件有700多k大,照理说不会像现在一样这么糊,我们推断其中应该有隐藏的恶意代码,经过一番探究发现是一个老掉牙的web shell攻击China Chopper,又名中国菜刀,fireeye上面给了详细的介绍,(不知道我们学院的实验报告网站有什么好黑的)。扯远了…总之这个例子告诉我们,理解掌握文件的编码非常重要。

python中的字符串和字节串

作为一个合格的程序员,我们要明确字符串和字节串的区别。

其实很简单,上面讲到,字符在计算机里无法直接存储,只能存储二进制的信息。

比如字符串str_a="hello world",str_b="你好",如果编码方式是UTF-8的话,在存储器中这两个字符串可能就会是001101010110,10101011101,这里只是随便写了两个二进制数字,具体是什么还要看字符在相应的编码规则中对应着哪一个字符。

那么”hello world”和”你好”就是两个字符串也就是编码前的字符组成的串,可能是任何语言,后两者就是字节串,是一个个字节组成的串(每个字节含8位),字节串仅有可能由01组成的字节组成。

python3对字符串和字节串都进行了很好的支持:

  • 定义字符串 str_a="你好" 或者 str_b=u"你好"。在python3中字符串默认是使用Unicode编码的,所以二者的结果等价。

  • 定义字节串 bytes_a="你好".encode("GBK") 或者 bytes_b=b"你好"。前者是将”你好“这两个字符按照GBK的编码规则进行编码得到的二进制字节串,后者是按照python默认的Unicode方式对”你好“进行编码得到的二进制字节串。

判断是字节串和字符串除了可以用type方式之外,可以根据长度判断,例如”你好“字符串的长度一定是2,而字节串则是大于2的。

python中编码方式的转换

既然有encode那么也有decode,利用这两个函数组合我们可以实现任意编码的转换。但需要注意的是不能由GBK编码直接转换成UTF-8编码,一定要先从GBK->Unicode,然后Unicode->UTF-8,一定要以Unicode作为中间媒介。