字符编码问题探索

433 阅读10分钟

引言

程序员或早或晚会被字符编码问题所困扰,也时不时遭遇乱码问题,但是把编码搞清楚的程序员未必很多。

笔者在写本文之前,也是对编码问题一知半解,没有系统性认知。由于经常被乱码问题所困扰,便决定好好学习一下,并把学习过程所获得的认知整理成此文,希望对其他人有所帮助。

限于笔者水平,并且是边学知识边写此文,如有错漏,请指出。

基础

graph LR
Code(编码)
Unicode[[Unicode系]]
ANSI[[ANSI系]]
GB[[国标系]]

Code---Unicode
Code---ANSI
Code---GB

UnicodeOrgs(两大组织)
Org1(Unicode联盟)
Org2(UCS)
Unicode---UnicodeOrgs
UnicodeOrgs---Org1
UnicodeOrgs---Org2

UnicodeEncodings(编码方法)
UnicodeEncodings---UCS-2
UnicodeEncodings---UCS-4
UnicodeEncodings---UTF-1
UnicodeEncodings---UTF-32
UnicodeEncodings---UTF-8
UnicodeEncodings---UTF-16

Unicode---UnicodeEncodings

GBEncodings(编码方法)
GBEncodings---GB2312
GBEncodings---GBK
GBEncodings---GB18030
GB18030--包括-->GBK
GBK--包括-->GB2312
GB---GBEncodings

classDef L1 fill:#f9f,stroke:#333,stroke-width:4px,font-size:16px,height:48px

class Code L1

缘由

世界上有很多文字,每种文字又有若干字符。把世界上所有的字符都加起来,数量至少达到数万。

在计算机世界是用一个数字来表示一个具体的字符的。但是如果各家公司不能实现约定好,每个字符字符分别用什么数字来表示,那就造成了各自为政的局面。

为了促进计算机行业的健康发展,在全世界范围内约定好“每个字符分别用什么数字来表示”,是有必要的。

Unicode是由苹果、施乐、Sun、微软、NeXT等公司组成的Unicode联盟(1988年成立)维护并发布的字符集项目。

UCS(Universal Character Set)是由ISO和IEC两家组织联合成立的工作组(1989年成立)设计的一套统一字符集项目。ISO/IEC 10646是UCS的国际标准号。

两者的目的一样,都是致力于开发出一款全世界通用的编码集。你也可以理解成他们都想编一个全世界通用的字符字典。

他们一开始并不清楚对方在做的事情和自己一样,后来才知道:英雄所见略同。既然是一条道上的,就不要各自为政了,于是双方就开始合并工作成果,目的是保证两个字符集标准的兼容和同步,但依旧保持两个项目独立运行,并各自独立公布标准。

1991年,两个项目决定合并字符集,今后也只发布一种字符集,那就是Unicode。除此以外,他们还修订此前发布的字符集,使得UCS的代码点(后文会解释)与Unicode的完全一致。

Unicode

可以这么来直观地理解:

Unicode就是一本很厚的大字典,我们称之为字符集,它规定了世界上所有字符所对应的二进制代码。

或者说:Unicode为世界上每一个字符指定一个表示该字符的数字。这个数字一般称之为代码点(Code Point)。

Unicode系

我们在后面会看到很多概念,其实都是Unicode系的,他们包括(但不局限于):

UCS-2

UCS-4

UTF-1

UTF-8

UTF-16

UTF-32

UCS-2和UCS-4

1990年, UCS就公布了第一套编码方法UCS-2,使用2个字节表示已经有代码点的字符。

这里还需要解释上面的编码方法是个什么概念。

代码点,用一个数字表示,它只是表示一个字符在Unocode字符集出现的位置。比如字母“y”,它的代码点是0x79。注意,在数值上,不管0x79、0x0079、0x000079都是表示0x79。“代码点用多少个字节表示”这种说法并不恰当。数字就是数字,不管你用多少字节表示它,都不改变数字本身的含义。

但是如果我们需要把一堆字符保存到文件,或者在网络上传播,就涉及到这样的考虑,因为这涉及占用多少存储空间的问题:

我要分别用多少字节表示这些字符?

UCS-2使用两个字节表示一个字符(代码点),最多能表示65536个字符,在当时是足够用的,后来就不够了。

后来为了表示更多的字符,UCS又提出了UCS-4,即用四个字节表示一个字符(代码点)。

UTF-8、UTF-16和UTF-32

UTF(Unicode Transformation Format)最早出现在ISO/IEC 10646-1中,它定义了一种UCS转换格式(把Unicode代码点转换为UCS编码),就是UTF-1。

UTF-1还存在不足,后来又定义出UTF32、UTF-8、UTF-16。

UTF-32比较简单,每个字符(Unicode代码点)使用4个字节来表示。

但是每个字符都使用4个字节表示,就有点浪费空间了。UTF-8就解决了这个问题。

UTF-8是一种变长的编码方法,每个字符使用1-4个字节表示。越是常用的字符,字节越短,最前面的128个字符,只使用一个字节表示,与ASCII码完全相同。

UTF-16是UCS-2的超集。两者关系简单说就是:UTF-16取代了UCS-2,或者说UCS-2整合进了UTF-16。所以,现在只有UTF-16,没有UCS-2。与UCS-2不同,UTF-16并不总是使用两个字节表示,有的字符它使用4个字节表示。

如果说UTF-16对应于UCS-2,那么UTF-32就对应于UCS-4。

ANSI

ANSI是American National Standards Institute的缩写,直译过来就是"美国国家标准研究所"。

ANSI和Unicode都是编码标准。不过从名称就能看出来,ANSI要更局限更古老(废话,美国的标准怎能和全球标准比)。

可知ANSI不是Unicode系的概念。

我们在Windows系统,使用Notepad的另存为功能时,发现编码有”ANSI"选项。我们也发现ANSI编码是能“正确处理”中文字符的。

image-20220712173724788

问题是,ANSI是灯塔国的老掉牙的标准,它为什么能“正确处理”其他国家的语言?

“正确处理”是加了引号的。笔者认为,不是ANSI能处理中文获其他非英文字符,而是在不同的Windows操作系统环境下,Notepad的ANSI编码表示的不再是原始ANSI的含义。

我们中国人用的是中文操作系统,实际上,在我的Win10电脑,这时的ANSI等同于GBK编码。

我们在千千秀字看看”啊〇𬌗“的编码,是这样的:

image-20220712181619866

如果我把”啊〇𬌗“这几个字符在Notepad保存为ANSI编码的文件,”𬌗“是没法被处理的,因为GBK没有”𬌗“的编码。

可见,在我的电脑ANSI等同于GBK。

GB2312、GBK、GB18030

GB2312、GBK、GB18030是我们国家制定的标准。GB就是国标二字的拼音缩写。

GB2312、GBK、GB18030的发布时间分别是:1980年、1995年、2000年。2005年又发布了GB18030的第二版:GB18030-2005。

2022年7月,GB18030,2022年版已发布,2023年实施。

以上标准是先后制定的,后制定的规范都完全兼容先制定的规范,或者说,后制定的规范是先制定的规范的超集。

GB2312发布的时候,Unicode还没有出世。所以据此可以知道,GB系编码规范和Unicode系规范是两套独立的规范。

那后来有了Unicode系规范了,那么GB系还有存在的必要吗?

作为一个程序员,我主观上不希望有这么多编码搞得我们晕头转向。不过GB系规范,还有没有必要存在,这体现了我们国家的意志,有了自己的规范,至少不会在这一方面受制于人。

另外字符编码规范虽多,但没有十全十美的,GB系规范作为一种可选项,也未尝不可。

实验

上面的基础理论部分,我们尽量不涉及繁琐的数学计算。笔者认为,即使熟练掌握各种编码的计算方法,编码之间数学关系,对我们理解各种编码没有太大的帮助。

我们来一些实践,以加深理解。

JavaScript使用什么编码?

<script>
    
    function string2HexString(str) {
        var hexStr = "";
        for (let ch of str) {
            hexStr += ch.charCodeAt(0).toString(16) + ",";
        }
        return hexStr;
    }

    function string2HexString2(str) {
        var hexStr = "";
        for (let ch of str) {
            hexStr += ch.codePointAt(0).toString(16) + ",";
        }
        return hexStr;
    }

    hexStr = string2HexString("𬌗读hé")
    console.log(hexStr)
    hexStr = string2HexString2("𬌗读hé")
    console.log(hexStr)

</script>
d870,8bfb,68,e9,
2c317,8bfb,68,e9,

在千千秀字(汉字字符集编码查询;中文字符集编码:GB2312、BIG5、GBK、GB18030、Unicode (qqxiuzi.cn))查询,得到如下结果:

image-20220712113508632

我们目前可以得到如下结论:

JavaScript(从ES6开始)看起来是使用UTF-16BE编码,但是”𬌗“的字符编码本应有四个字节,却只输出了2个字节,似乎哪里出了说明问题。

这是因为:

charCodeAt 总是返回一个小于 65536 的值。如果有的字符的UTF-16编码大于65536,需要在获取charCodeAt(i) 的值的同时获取 charCodeAt(i+1) 的值,两者一个作为高字节一个作为低字节,计算得到其UTF-16的编码值。
具体来讲就是charCodeAt(i)为高位,charCodeAt(i+1)为低位。

修正后代码如下:

<script>
    function fixedCharCodeAt (str, idx) {
        // ex. fixedCharCodeAt ('\uD800\uDC00', 0); // 65536
        // ex. fixedCharCodeAt ('\uD800\uDC00', 1); // false
        idx = idx || 0;
        var code = str.charCodeAt(idx);
        var hi, low;

        // High surrogate (could change last hex to 0xDB7F to treat high
        // private surrogates as single characters)
        if (0xD800 <= code && code <= 0xDBFF) {
            hi = code;
            low = str.charCodeAt(idx+1);
            if (isNaN(low)) {
                throw 'High surrogate not followed by low surrogate in fixedCharCodeAt()';
            }
            return hi*0x10000+low;
        }
        if (0xDC00 <= code && code <= 0xDFFF) { // Low surrogate
            // We return false to allow loops to skip this iteration since should have
            // already handled high surrogate above in the previous iteration
            return false;
            /*hi = str.charCodeAt(idx-1);
            low = code;
            return hi*0x10000+low;*/
        }
        return code;
    }

    function string2HexString(str) {
        var hexStr = "";
        for (let ch of str) {
            hexStr += fixedCharCodeAt(ch, 0).toString(16) + ",";
        }
        return hexStr;
    }

    function string2HexString2(str) {
        var hexStr = "";
        for (let ch of str) {
            hexStr += ch.codePointAt(0).toString(16) + ",";
        }
        return hexStr;
    }

    hexStr = string2HexString("𬌗读hé")
    console.log(hexStr)
    hexStr = string2HexString2("𬌗读hé")
    console.log(hexStr)

</script>

新的输出为:

d870df17,8bfb,68,e9,
59 2c317,8bfb,68,e9,

我们得到了预期的输出。

网络上传输的文字采用什么编码?

我们通过websocket向服务器发送”我爱你中国“,看看服务器收到的数据是什么。

注:服务器是基于libWebSockets编写的。

ws.send("我爱你中国");

在服务端收到的数据是:

[e6 88 91] [e7 88 b1] [e4 bd a0] [e4 b8 ad] [e5 9b bd]
    我         爱          你         中          国

通过编码比对,我们知道服务器收到的数据是UTF-8编码的。

但我还不能说,网络上传输的文字可能默认采用UTF-8编码,也应该可以支持其他编码。

由于学识所限,笔者暂不对网络传输的字符编码进行更加深入的研究。

在代码中敲入的文字在内存中体现为什么编码?

VS 2008中敲入如下代码:

#include <string> 
#include <iostream>

using namespace std;

int test()
{
    cout << "hello world" << endl;
    const char* lpszTemp = "我爱中国yes";
    return 0;
}

int main() 
{ 
    
    test();
    return 0; 
}

以上代码保存为GB2312,在内存中看到lpszTemp就是GB2312编码的:

ce d2 b0 ae d6 d0 b9 fa 79 65 73

以上代码保存为UTF-8,在内存中看到lpszTemp就是UTF-8编码的:

e6 88 91 e7 88 b1 e4 b8 ad e5 9b bd 79 65 73

可以得知,嵌入到源代码文件中的字符串,源码文件保存为什么编码,在内存中看到的字符串就是什么编码。

如果敲入如下代码:

#include <string> 
#include <iostream>

using namespace std;

int test()
{
    cout << "hello world" << endl;
    const wchar_t* lpszTemp = L"我爱中国yes";
    return 0;
}

int main() 
{ 
    
    test();
    return 0; 
}

情况会有什么不同?

以上代码保存为UTF-8,在内存中看到lpszTemp是这样的:

b4 93 20 62 cd 57 93 6d 5e e1 57 6d 79 00 65 00 73 00

出问题了,我们查不到b4 93 20 62 cd 57 93 6d 5e e1 57 6d对应什么编码,编码似乎跑飞了。这是什么情况?

笔者的观点是,要看VC编译器看到L"我爱中国yes"(UTF-8字符串)时,会把它解释成什么。

VS 2008的编译器可能不太智能,解释错了,于是我们在内存中看到了不认识的编码值。

如果将以上代码保存为GB2312编码格式,在内存中看到lpszTemp是这样的:

11 62 31 72 2d 4e fd 56 79 00 65 00 73 00

可知其为UTF-16LE编码。

参考文献

Unicode与JavaScript详解 - 阮一峰的网络日志 (ruanyifeng.com)

彻底弄懂 Unicode 编码 - crazyYong - 博客园 (cnblogs.com)

[Difference Between ANSI and Unicode - Ask Any Difference](askanydifference.com/difference-… is an American National Standards Institute that,of the encoding process in the operating systems.)