引言
程序员或早或晚会被字符编码问题所困扰,也时不时遭遇乱码问题,但是把编码搞清楚的程序员未必很多。
笔者在写本文之前,也是对编码问题一知半解,没有系统性认知。由于经常被乱码问题所困扰,便决定好好学习一下,并把学习过程所获得的认知整理成此文,希望对其他人有所帮助。
限于笔者水平,并且是边学知识边写此文,如有错漏,请指出。
基础
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编码是能“正确处理”中文字符的。
问题是,ANSI是灯塔国的老掉牙的标准,它为什么能“正确处理”其他国家的语言?
“正确处理”是加了引号的。笔者认为,不是ANSI能处理中文获其他非英文字符,而是在不同的Windows操作系统环境下,Notepad的ANSI编码表示的不再是原始ANSI的含义。
我们中国人用的是中文操作系统,实际上,在我的Win10电脑,这时的ANSI等同于GBK编码。
我们在千千秀字看看”啊〇𬌗“的编码,是这样的:
如果我把”啊〇𬌗“这几个字符在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))查询,得到如下结果:
我们目前可以得到如下结论:
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.)