搞懂字符编码

首先是unicode、utf-32、utf-16、ucs-2和utf-8编码原理的简单解释,介绍的先后顺序并不代表它们在历史上出现的先后顺序,只是为了逻辑连贯,我并不是很清楚它们的历史。

unicode是字符集,它囊括了世界上的所有字符,规定了每个字符与一个二进制数的一一映射关系,这个二进制数的范围是 0 ~ 1 0000 1111 1111 1111 1111 ,用十六进制数表示是 0x0 ~ 0x10FFFF 。 将每16位二进制数表示的范围作为一个平面,第一个平面称为基本多语言平面,用于与常用字符对应,其范围是 0000 0000 0000 0000 ~ 1111 1111 1111 1111,用十六进制表示为 0x0000 ~ 0xFFFF ;剩余十六个平面称为辅助平面,与一些辅助字符对应。在基本多语言平面中,有两个范围没有对应字符,分别是 1101 1000 0000 0000 ~ 1101 1011 1111 11111101 1100 0000 0000 ~ 1101 1111 1111 1111,用十六进制表示为 0xD800 ~ 0xDBFF0xDC00 ~ 0xDFFF,之所以没有对应字符,其实是为了保留给utf-16编码,前一个范围称为高位代理,后一个范围称为低位代理(utf-16才是unicode的亲儿子啊)。

unicode只定义了字符与二进制数之间的对应关系,不涉及二进制数如何存储在内存和磁盘中,所以该二进制数是一个数学概念,是字符的标识,和用几个字节存储无关。为了方便表述,我们暂且把字符所对应的二进制数称为unicode编码(一定要记得这句话,一定啊,亲),其实更准确的叫法应该是把字符所对应的二进制数称为码点(code point),码点有个专门的记法是用前缀 U+ 加上二进制数的4到6位十六进制形式,如用这种记法表示码点的范围:U+0000 ~ U+10FFFF

首先,我们能想到的最直接的方式就是用定长字节存储unicode编码,于是就出现了utf-32编码,utf-32直接用4个字节存字符对应的unicode编码,但是最大的unicode编码也只需要3个字节,utf-32似乎有点费空间,尤其是在内存中,是非常难以容忍的。好的,那我们就用统一用3个字节来存呀,又可以表示全部字符,又是定长字节,编码解码也快;哎,其实也不行,因为剩余十六个平面所表示的字符其实很少用到,为了容纳他们用3个字节,还是太浪费。好的,那我们用变长编码,基本多语言平面用两个字节,剩余十六个平面用三个字节;好吧,似乎想法不错,但是,你能区分下一个字符是以2个字节在存储,还是以3个字节在存储吗,如果不在基本多语言平面里保留一个范围用于表示这不是一个2字节字符,而是一个多字节字符,你是分不清的。所以出现了utf-16,utf-16的编码方式如下:

  • 如果 unicode编码 <= 0xFFFF , 直接用两个字节存unicode编码
  • 如果 unicode编码 > 0xFFFF , 先计算 U = unicode编码 - 0x10000,然后将 U 写成二进制形式:yyyy yyyy yyxx xxxx xxxx ,接着用4个字节这样存:110110yyyyyyyyyy 110111xxxxxxxxxx ,前缀就是上图我用红色标记的那部分。

utf-16真的很巧妙,unicode编码最大的数是0x10FFFF,也就是第十七个平面最后一个数,用utf-16编码表示刚好是110110111111111 110111111111111 ,unicode编码再大一点都不行,utf-16表示不了第十八个平面的第一个数,不过还好没有第十八个平面。每当utf-16解码程序读到一个2字节是处于代理范围,那么utf-16就会再多读2个字节,用4个字节去解码。

ucs-2可以看作是utf-16的简化版,不像utf-16那样是变长编码,它是定长编码,只用2个字节直接存unicode的基本多语言平面的二进制数值,存不了剩余十六个平面的unicode编码,所以拿ucs-2去解码utf-16的编码,遇到处于代理范围的2个字节就乱码。正因为ucs-2是2个字节的定长编码,如果在内存中使用ucs-2的话,可以实现随机访问,降低算法的时间复杂度。

由于utf-32、utf-16和usc-2都是用多个字节来表示一个字符,所以会涉及到要区分文件的字节序是大端模式(big endian,be)还是小端模式的问题(little endian,le),为了解决这个问题,可以在文件的开头添加几个字节的编码用于表示该文件是哪种字节序,这几个字节的编码称为字节序标记(Byte Order Mark,BOM)。unicode也为BOM专门腾出了1个码点U+FEFF,处于基本多语言平面:

  • 码点U+FEFF的utf-32大端编码是0x0000 FEFF,所以以0x0000 FEFF开头的文件是大端utf-32文件;码点U+FEFF的utf-32小端编码是0xFFFE 0000,所以以0xFFFE 0000开头的文件是小端utf-32文件。
  • 码点U+FEFF的usc-2和utf-16编码是一样的,大端编码是0xFEFF,所以usc-2和utf-16的大端编码文件以0xFEFF开头;小端编码是0xFFFE,所以usc-2和utf-16的大端编码文件以0xFFFE开头。

utf-16是挺不错的哈,但是在磁盘用utf-16上存ASCII码上的字符的时候,有一个字节是全0的,还是感觉有点浪费空间,我们还想再改进改进,于是就出现了utf-8,utf-8也是变长编码,它的编码方式是:

  • 对于ASCII码中的字符,用1个字节存储,和ASCII码完全一样
  • 其他字符用多个字节存储,见下表,根据unicode编码的范围填进相应的模板即可得到多字节的utf-8编码

utf-8也很巧妙,从图中可以看到,utf-8在读完第一个字节后就知道还需要再读几个字节才能正确地完成解码;另外,对于多字节编码,编码的第一个字节的开头与后面字节的开头都不一样,于是虽然utf-8是用多字节表示一个字符,但是它并不用管字节序的问题,所以特别适合网络传输,而且它最少只用了一个字节,所以也特别适合在磁盘中储存。

虽然utf-8不用管字节序的问题,但是毕竟对于utf-8来说码点U+FEFF闲着也是闲着,于是utf-8就把它利用起来了,码点U+FEFF的utf-8编码是0xEF BB BF,占三个字节,放在文件的开头,用于表示这是一个utf-8文件,更准确的说应该是带有BOM的utf-8文件,这三个字节在带有BOM的utf-8文件里也称为signature。这个signature对于像windows记事本这类应用程序来说是很有用的,因为记事本就靠它来自动识别文件的编码,但是对于咱程序员来说不是很友好,因为很多编译器不支持识别这种带BOM的文件。

再次强调,unicode编码与字符之间具有一一对应关系,但不涉及储存,只是字符的标识;utf-8、utf-16、ucs-2和utf-32编码既与字符之间具有一一对应关系,也是具体的储存方式。

关于unicode、utf-32、utf-8、utf-16、ucs-2、ucs-4、gb2312、gbk、big5、ASCII、latin-1、latin-2、latin-3、iso-8859-1 ~ iso-8859-15等等编码的介绍可以参考以下链接:
知乎:对于字符编码,程序员的话应该了解它的哪些方面?@科言君的回答
https://www.cnblogs.com/yaoyu126/p/12674225.html
https://www.cnblogs.com/skynet/archive/2011/05/03/2035105.html
https://www.cnblogs.com/malecrab/p/5300503.html
https://www.cnblogs.com/batsing/p/charset.html

关于ANSI是什么编码可以参考下面的链接。ANSI其实不是一种具体的编码,操作系统会根据设置的区域来确定ANSI,比如你使用的是简体中文,那么ANSI就是gbk。gbk是变长编码,用1个或者2个字节进行存储,1个字节时就是ASCII码,所以gbk和utf-8一样都完全兼容ASCII码,但gbk不是unicode系的编码,与unicode没有关系。

ANSI的具体介绍可以参考博客 ANSI是什么编码?

综上来看有些字符编码适合于内存,有些适合于外存。那程序在内存中用的是什么编码呢,这个问题真的是我的心思你别猜。

以我目前的认知水平得到的程序从编译到运行的抽象流程是这样的: 首先你在文本编辑器或者IDE里打的代码,会用你选定的编码保存下来的,接下来是由编译器来解码源代码文件并编译,除了双引号和单引号里面的字符,其它代码都最终被编译成了表示指令的二进制序列保存在可执行文件里面了(或者字节码文件),但是双引号和单引号里面的字符在可执行文件里面肯定不是一串表示指令的二进制序列,而应该是把源码文件中的字符编码解码后再编码成某种字符编码保存在可执行件中了,当可执行文件运行时,再把这种字符编码赋给内存里的字符变量。

这中间的字符编码转换过程可能很复杂,需要在各种编码之间转换好几次。像C/C++比较靠近底层的编程语言可能还好,源代码中的字符串转换成某种编码保存在可执行文件中,运行时直接赋给内存中的字符变量;但是Java和python这类封装层次很高的语言就不一定,源码到字节码一种转换,运行时字节码到内存变量又可能一种转换。还有程序向控制台输出打印的过程可能还需字符编码的转换,太复杂,咱们还是把问题简单化吧,重点解决现阶段常见的编码问题,即编译器使用什么编码解码源文件和编程语言在内存中用什么编码存储字符变量和字符串。

参考知友的回答:
知乎:C语言的printf打印中文是如何实现的?@yang leonier的回答
知乎:C语言的printf打印中文是如何实现的?@zr scat的回答
知乎:字符在内存中最终的表示形式是什么?@呵呵一笑百媚生的回答

C语言:

C语言里内存用的是哪种字符编码请参考博客 https://www.iteye.com/blog/jimmee-2165685 ,写的很棒,建议先进去看看,但博客年代有些久远了,编译器可能有变动。

我在VS2019上简单实验了一下,C语言的char类型只占一个字节,所以在编译时,会对变量进行类型检查,如果源代码中的字符在转换编码后超过1个字节会直接进行截断,然后再保存到可执行文件中,用字符数组则不会截断:

为什么会输出的是问号?因为VS默认是用gbk编码将字符保存到可执行文件中,而VS2019默认以utf-8 BOM保存源cpp文件,所以在编译时会先用utf-8解码源文件,然后再用gbk进行编码,但是gbK字符中没有emoji,于是编译器就将转换失败的字符统一编码成 0x3f 再写到可执行文件中,0x3f 就是 ? 的ASCII码。可以在VS2019中将源文件的编码和可执行文件中的字符编码都设置成utf-8,具体设置过程参见链接:Microsoft文档: 将源和可执行字符集设置为 UTF-8

VS2019的编译器支持用 \u (小写的u)转义基本多语言平面的字符的unicode编码,\u后面需要用4位十六进制数来表示unicode编码;用 \U (大写的U)转义剩余16个平面的字符的unicode编码,\U后面需要用8位十六进制数来表示unicode编码:

Python:

pyton在内存中使用的是什么字符编码可以参考这本博客https://juejin.cn/post/6844904056062754829 ,写的很好,同样建议先进去瞅瞅。下面先讲一下字符编码在python中的用法,看完你应该知道为什么总强调的是unicode编码。实验使用到的python版本是python3.8,因为cmd打印不了emoji,也用到了jupter notebook。

python同样支持用 \u\u转义unicode编码,其中,\u(小写的u)用于转义基本多语言平面的字符的unicode编码,\u后面跟用4位十六进制数表示的unicode编码:

\U(大写的U)用于转义剩余16个平面的字符的unicode编码,\U后面跟用8位十六进制数表示的unicode编码:

如果超出unicode编码范围就会报错:

python希望程序员在用python进行编程时的思想是,字符就是字符,字符对应的就是unicode编码(码点),像上图那样,用4个或者8个十六进制数字表示。字符和unicode编码只存在于你的脑子里面,然后在打代码的时候用到,不存在于内存和磁盘中,底层存储的事由python来负责给你屏蔽掉,我们可以用encode()函数和decode()函数实现unicode编码与具体储存编码之间的转换,也即字符与字节之间的转换。

将unicode编码转换成字节序的过程就是编码,用encode()函数实现,下图是一些很好的示例:

使用utf-16不指定大小端模式,就默认是小端模式,并加上utf-16小端模式的前缀,指定了大小端就按对应模式编码,而不加前缀;显示时的前缀b表示是这是bytes类型的字节序列,其中 \x 表示后面紧跟着的两个字符是十六进制数,如果这个字节处于ASCII码的范围,就直接显示对应的ASCII码字符;当然在表示bytes类型时,如果这个字节处于ASCII码的范围,你既可以使用ASCII码字符,也可以改为对应的十六进制。

将字节序转换成unicode编码的过程是解码,用decode()函数实现,编码和解码的方式要一样,不然就乱码了:

上图中的ord()函数返回的是字符的unicode编码的十进制,hex()函数将十进制转换成十六进制。

然后我们还要知道python编译器能够识别的编码,python编译器默认的编码是utf-8,也可以识别utf-8 BOM,但养成习惯,写代码最好不要用utf-8 BOM,还支持gbk,但不支持其他utf编码。我们用utf-8保存带有中文字符的源文件时,无论开头有没有声明编码:# coding: utf-8 ,都可以成功编译并运行:

但是如果用gbk编码保存有中文字符的源文件时,编译就会出错,'啊'的gbk编码是0xb0a1,从下图中的报错信息也可以看出在 b0 那里无法用utf-8解码,并且指出没有声明编码,所以我们要加上编码声明。

加上编码声明 # coding: gbk 或者 # coding: ansi,仍以gbk的编码保存:

运行成功,可见编译器会先识别第一行的编码声明,然后再用声明的编码去解码后面的内容,之后再编译,所以编码声明要与保存源文件的编码一致。无论是gbk编码还是utf-8编码,编译器识别第一行是不会出现问题的,因为这两种编码对在ASCII码范围的字符的编码都是相同的,都与ASCII码相同。但如果把编码声明 # coding: gbk 放到第三行,并以gbk保存源文件是会出错的:

因为编译器只会对源文件的前两行代码做预处理,所以前两行最好是 #!/usr/bin/python# coding: utf-8

Java:

Java编译器默认的编码跟随操作系统,也就是ANSI。所以用gbk编码保存带有中文的Java源文件在编译时不会出错:

但是如果以utf-8保存源文件,那么编译时就会出错,见下图,'啊' 的utf-8编码是 0xE5 95 8A ,在解码时,gbk会先读一个字节,如果该字节不在ASCII码的范围,那么还会再读1个字节然后解码,所以报错会指出gbk不可解码 0x8A :

可以想象如果再加1个英文字符,凑成4个字节,那么编译就会成功,但是输出会乱码:

那怎么正确编译utf-8的Java文件呢,答案是编译时加上 -encoding utf-8 选项就不出错了:

还有另外一种指定Java编译器用何种编码解码源码文件的方式,就是配置环境变量JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8",具体可以参见博客:https://blog.csdn.net/huangshaotian/article/details/7472662,Java编译器并不支持utf-8 BOM等其他UTF编码。

不像python可能只需要了解一下内存中的字符编码方式,Java程序员必须记住Java内存中的字符编码是utf-16,并理解《Java核心技术卷1》中讲到了两个概念,一个是码点,也就是我们上面所说的码点或unicode编码;另一个是代码单元,一个代码单元就是一个char大小,Java中的char占2个字节,也就是一个代码单元就是2个字节,unicode剩余十六个平面的码点用utf-16编码需要4个字节,也就是2个char大小,这时码点与代码单元的关系是1个码点对应2个代码单元。下面实验的Java环境是Java 15。

因为Java中的char只有2个字节大小,所以不能存储4个字节的utf-16编码,比如emoji 的码点是0x1F37A,utf-16be编码是0xd83c df7a,如果用char存 ,在编译时对字符变量进行类型检查,发现源码文件中的字符转换编码后超过2个字节大小,那么编译就会报错。由此可见Java比更接近底层的C语言要严格。注意此时源文件应该用utf-8保存,不能用ANSI保存,因为gbk的字符集中没有emoji。

Java不支持用 \U (大写的U)转义剩余十六个平面的unicode编码,只支持用 \u (小写的u)转义基本多语言平面的unicode编码:

如果非要用转义的方式表示剩余十六个平面的unicode编码,那只能用 \u转义utf-16be编码了, 的码点是0x1F37A,utf-16be编码是0xD83C DF7A,于是:

只显示一个 ? 就代表双引号里面的内容确实只被解码成了一个字符,至于为什么是 ? ,可能是因为控制台的字符集是gbk,这中间有个编码转换的过程。

接下来测试代码单元和码点之间的关系,String类的方法length()返回代码单元的数量,codePointCount()返回码点的数量。

public class code{
    public static void main(String[] args){
        String str = " ";
        System.out.println(str);
        int n = str.length();//2
        System.out.println(n);
        int cpCount = str.codePointCount(0,str.length());//1
        System.out.println(cpCount);
    }
}

结果正确。 如果我们直接使用Java编译器默认跟随操作系统的gbk编码来编译源文件会怎么样呢,因为 的utf-8编码占4个字节(见下图),使用gbk解码源文件,应该不会报编译错误,只是运行得结果可能不一样。经实验得到下面的结果:

发现输出结果果然与上面的结果不一致,分析是因为编译时 被gbk解码成了两字符,这两个字符在内存中又分别转换成了两个utf-16le编码,馃0x8399嵑0x515D,都是2个字节utf-16编码,于是导致码点和代码单元的数量变成一样了。

推荐阅读: 深入分析 Java 中的中文编码问题

windows记事本:

简单来说,windows打开文件就是将保存在磁盘中的二进制直接加载到内存,然后使用编码对文件进行解码并显示字符。windows记事本支持ANSI、utf-8、带BOM的utf-8、utf-16le和utf-16BE这5种编码。后面3种编码格式的文本文件都是带有BOM的,记事本可以根据BOM自动识别;但是前面两种,也就是gbk和utf-8,记事本无法区分,只能是两种编码都试一下,然后感觉哪个更合理,就选择用哪个编码解码文件。

还记得馃嵑这两个字吗,试着把这两个字单独粘贴到记事本中,然后另存为以ANSI编码保存,关闭接着再打开,嘻嘻,是不是变成了 ,然后再点一下另存为,看看显示的编码格式是不是变成了utf-8,这是记事本认为的该文件的编码,所以记事本更喜欢带有BOM的utf-8。题外话,到底是谁才是乱码呢,我确实是想存馃嵑,但是记事本给我显示的是 ,所以 对于我来说是个乱码。

关于windows记事本自动识别编码的过程详情请参考 https://liam.page/2017/08/27/mojibake-in-Windows-Notepad-due-to-wrong-encoding-detect/

疑问:有知友回答说windows采用utf-16le编码,我不太清楚怎么体现。
可能意思是在调用Windows api处理字符时,Windows在内存中用的是utf-16le编码吧,参考知友 @冯东的回答

nodepad++:

nodepad++打开文件并自动识别文件编码的过程和windows记事本一样。nodepad++编码菜单下的使用xxx码选项,真的让人摸不着头脑,按正常的逻辑应该是使用XXX码读就是使用这个xxx编码进行解码,就是想看看用这个编码解码显示的是啥内容,没有让你给我转换编码,但是它有时候只是解码,有时候却是解码加转换编码,很迷。不过下面转为xxx编码逻辑是正常的,由一种编码转为另一种编码。编码菜单下只有ucs-2编码而不是utf-16,试了一下确实不支持识别包含4个字节的utf-16编码的文件。

我们可以给nodepad++安装一个可以查看文件二进制的插件HEX-Editor。

安装过程参考: nodepad++安装HEX-Editor插件

HEX-Editor可以查看到文件的二进制形式,使用很方便的一个插件。查看文件编码的二进制(以十六进制表示):

利用nodepad++分析Java编译器将字符或字符串以什么编码保存到字节码文件(.class文件)之中:

首先使用gbk保存下图中的代码,然后编译得到字节码文件,见下图。使用 "abc啊abc" 的原因是前后的英文字母的编码在gbk和utf-8里都一样,都是ASCII码,即使在utf-16里,其中一个字节也是ASCII码,这可以使我们很快的在字节码中定位到该字符串;中间的中文字符可以帮助我们区分到底是gbk、utf-8、utf-16中的哪一种编码。

接着用notepad++打开字节码文件,默认是用ANSI读文件,在字节码中看到的是:

乱码了,很显然不是gbk,使用utf-8解码试一下:

ok,破案了,Java字节码使用utf-8对字符串进行编码,然后运行时会转换成utf-16编码保存在内存中。大家可以用同样的方法打开.exe文件看看VS2019的C语言编译器是不是默认以gbk编码保存字符到可执行文件。

所以,正如我在前面说,在程序从编译到运行的中间过程中,字符编码转换可能很复杂,需要在各种编码之间转换好几次。不过我们一般不太需要过分关注这中间的的细节,不然真的使人头大。只需要知道编译器使用什么编码解码源文件和编程语言在内存中用什么编码存储字符变量和字符串这些问题就可以解决在编程过程中遇到的许多常见的问题。

最后总结一下:

  • unicode编码只是字符的标识,不是存储形式
  • utf-32用4个字节存unicode编码,utf-32文件有BOM
  • utf-16是变长编码,用2个或者4个字节存unicode编码,utf-16文件有BOM
  • usc-2用2个字节存unicode基本多语言平面的编码,usc-2文件有BOM
  • utf-8用1至4个字节存unicode编码,1个字节时与ASCII码完全相同,utf-8文件可以有BOM,但BOM的作用不是标记字节序,而是标记文件的编码是utf-8,最好不要使用带BOM的utf-8文件保存源代码
  • ANSI根据操作系统的区域设置确定具体编码,简体中文环境下是gbk,它不是unicode派系的编码,与unicode没有关系,gbk是变长编码,占1个或者2个字节,1个字节时与ASCII码完全相同
  • 编译器一般识别不了带BOM的源文件,所以源码要么用ANSI保存,要么用utf-8保存,最好是用utf-8
  • 用什么编码保存,就用什么编码编译。Java编译器解码源文件的编码默认跟随操作系统,可以用-encoding选项指定用什么编码编译源文件;python默认用utf-8编译源文件,可以在源文件的前2行中添加 # code: 指定用什么编码编译文件。
  • 可以简单了解一下pythoy的内存字符编码,但是要知道Java的内存编码是utf-16

关于字符编码还有其他很多问题,以后遇到再慢慢解决吧。如果这篇文章对你有点帮助,请给我给个 U+0001F44D 吧,如果文中有什么错误欢迎给我指出 。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值