编码和字符集的一些总结

达芬奇密码2018-07-16 13:43

前言

编码和字符集是编程时经常遇到的问题,在数据的传递过程中,文本的编码经常会发生变化,需要我们在代码中进行转换。但是由于某些原因,这块内容经常容易混淆。因此这篇文章总结了一下这方面的知识。

字符集(charset)

为什么要有字符集:
  计算机中存储的是二进制,那么我们在传输数据的时候,怎么样去表示一个字呢?所以我们需要字符集来规定二进制与字符之间的映射关系
  最简单的例如要表示阿拉伯数字1,那么在ASCII这个字符集中就用 0x31 来表示。但是由于一开始ASCII字符集出生在美帝的时候,并没有过多考虑到其他第三世界的国家也能用得上计算机,因此并没有考虑到别的语言。而ASCII是固定采用1个字节来表示,所以很显然不能满足需求。为了解决这个问题,各国人民就各自制定出了自己语言对应的字符标准。例如在中国就依次出现了 gb2312 / gbk / gb18030 等。
  一个字符在不同字符集中的二进制可能一样(例如数字字母等),也有可能不一样(例如中文在GB2312和UNICODE中),因此如果选错了字符集,就会出现我们平时见到的能看到英文但是中文变成了乱码的情况。

字符编码(character encoding)

  如果说世界上只有ASCII一种字符集,那么一块二进制数据不会有任何歧义,二进制和数据是一一对应的关系。但是由于有多种字符集,也就是有多种二进制到字符的映射,因此如果一个中文使用GB2312来表示,但是用ASCII来解析,就会乱码。
  另外一个原因就是出于节省空间的考虑:
  例如在UNICODE字符集中,数字1是用0x31来表示的,这种情况如果也使用2个字节进行存储,显然是浪费的。因此就出现了对Unicode的编码,它的实现方式就叫做 UTF(Unicode Transformation Format)。其中UTF-8就是一种变长编码,可以实现压缩。(UTF-16也是)
  一般来说,一种字符集的名字都是它对应编码的名字,例如gbk字符集用gbk编码。也有的名字仅仅只是编码方式,例如UTF系列。

扩展

平时常见的各种字符集和编码

gb2312 / gbk / gb18030

  • gb2312: 也称GB 0,最早实施(1981年),兼容ASCII,也几乎满足汉字处理需求(汉字6K多,其他包含拉丁、希腊字母和部分日语)。 但是无法处理古汉语等罕见字。
  • gbk:k是扩展的意思,也就是对gb2312的扩展,共2W+汉字和图形,除了包含所有gb2312的内容,还有big5以及gb13000中的所有汉字,向下兼容gb2312,向上支持ISO 10646。是微软的标准。
  • gb18030:继续向下兼容gbk,收录7W+汉字,满足了少数民族的需求。对应的是gb18030编码,长度为1个、2个或4个。是国家标准。

Unicode / UCS

以上所说的都是我国人民为了计算机能正常显示我国文字而所作的努力,然而其他国家也在规定自己的一套字符映射方案。如此一来,就谁也看不懂谁了。因此ISO组织就发明了一种叫做 UCS(Universal Multiple-Octet Coded Character Set)的方案(对应ISO 10646标准),后面这个方案跟Unicode联盟合并,成为目前我们所知的Unicode。
UCS有UCS-2和UCS-4之分,UCS-2为了跟Unicode保持兼容,保留了最后的0xD800-0xDFFF之间的码位;UCS-4则保留了0x10FFFF以上的空间。

平面(plane)和BMP

Unicode编码空间是从U+000000到U+10FFFF,其中前面2位是表示平面,即从0x0到0x10,共17个平面。第一个平面也叫基本多语言平面(BMP),其他平面称为辅助平面,BMP与UCS-2对应。

UTF-8编码

UTF-8编码是Uicode字符集进行编码的一种实现形式。
它可以使用1--4个字节来表示一个字符.
规则:

  • 单字节的符号: 第一位是0,后7位为这个符号的unicode码. (因此, 英文的编码在UTF-8中和ASCII中是一样的)
  • N字节符号: 前N位1,第n+1位设为0, 后面字节前两位为10, 其余为这个符号的编码.
    Unicode符号范围     |   UTF-8编码方式 
    --------------------+---------------------------------------------
    0000 0000-0000 007F | 0xxxxxxx
    0000 0080-0000 07FF | 110xxxxx 10xxxxxx
    0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx
    0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
    
    UTF-16和UTF-32也类似。

UTF-16

UTF-16 以2个字节为单位,因此存在大小端的问题。依据大小端不同,它分为UTF-16LE和UTF-16BE。
在BMP里,它等价于UCS-2,但是它还可以进行扩展。因此UCS-2是UTF-16的一个子集

UTF-32

UTF-32 中每个字符占了4个字节,因此直接等价于UCS-4,涵盖了所有字符。由于浪费空间,因此基本不用。 但是有 快速定位第N个字符 的优点。

BOM

全称byte-order mark(wiki),作用即“在UTF-16或UTF-32中标记字节顺序”(UTF-8根本不需要字节序),只能出现在字节流的开头(从Unicode 3.2开始)。由于这个特性,它常被我们当作UTF编码的一个记号。
它的原理是将0xFEFF放在文件或字节流开头,以区分大小端。
也正因如此,BOM头会在某些场景下对逻辑造成干扰,例如:

  1. gcc编译器(可能?)无法识别
  2. PHP可能无法指定HTTP Header
  3. 对大部分未准备好处理UTF-8的编辑器,会把经过UTF-8编码的BOM头(EF BB BF)显示出来。
  4. unix shell在检测脚本时只检测 #! 标识,BOM头(可能?)会导致无法识别脚本。

然而在某些情况下,BOM头又必不可少,例如极端情况下VS在编译含有中文的UTF-8文件时

UCS及UTF编码的对比

|对比          | UTF-8   | UTF-16  | UTF-32  | UCS-2 | UCS-4     |
|编码空间      | 0-10FFFF| 0-10FFFF| 0-10FFFF| 0-FFFF| 0-7FFFFFFF|
|最少编码字节数| 1       | 2       | 4       | 2     | 4         |
|最多编码字节数| 4       | 4       | 4       | 2     | 4         |
|是否依赖字节序| 否      | 是      | 是      | 是    | 是        |

以上编码的联系可以在这张图中看到。

哪个更高效?

一般来说,选UTF8不会错。当也有UTF16比UTF8占用更少的空间的情况,例如大部分日语(这是一种临界情况,UTF16用2个字符而编码为UTF8后要用3个字符)。这我们就不考虑了。

代码页(codepage)

字符编码的别称,早期不同厂商用不同的数字来区分不同的字符编码,例如微软对UTF-8称作65001,把gbk称作936。
  windows系统都会有一个区域设置Locale),可以在区域和语言里面找到。每个区域都有一个默认的代码页,也叫做ANSI代码页(ACP),供非unicode程序显示文本的时候解释用。

判断

简单判断

下面是闪电邮中对字节流进行的简单判断,为必要不充分条件,供参考:

bool IsUTF8Charset_table(const std::string& src)
{
    static const int utf8_map[256] = {
        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
        1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
        2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
        3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
        4, 4, 4, 4, 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0
    };

    int i, j, k;
    int length = src.length();
    const unsigned char* stream = (const unsigned char*)src.c_str();

    for (i = 0; i < length; )
    {
        k = utf8_map[stream[i]];
        if (k == 0)
            break;
        for (j = 1; j < k; j++)
            if (utf8_map[stream[i + j]] != 0)
                return false;
        i += j;
    }
    return i == (int)src.length();
}

/*
“高位字节”使用了0x81-0xFE,“低位字节”使用了0x40-0x7E,及0xA1-0xFE
lead bytes of byte pairs in range 0xA1-0xF9 followed by trail bytes in the range 0x40-0xFE.

http://zh.wikipedia.org/w/index.php?title=大五碼&variant=zh-hans
http://interscript.sourceforge.net/interscript/doc/en_big5_0001.html
*/
bool IsBig5Charset(const std::string& src)
{
    unsigned char* start = (unsigned char*)src.c_str();
    unsigned char* end = (unsigned char*)src.c_str() + src.length();
    while(start < end)
    {
        if (0x00 <= *start && *start <= 0x7F)
        {
            // 忽略ascii
            start ++;
            continue;
        }
        if (! (0xA1 <= *start && *start <= 0xF9))
            return false;
        start ++;
        if (! ((0x40 <= *start && *start <= 0x7E) || (0xA1 <= *start && *start <= 0xFE)))
            return false;
        start ++;
    }
    return true;
}

/*
“高位字节”使用了0xA1-0xF7(把01-87区的区号加上0xA0),“低位字节”使用了0xA1-0xFE(把01-94加上0xA0)
由于一级汉字从16区起始,汉字区的“高位字节”的范围是0xB0-0xF7,“低位字节”的范围是0xA1-0xFE

http://zh.wikipedia.org/wiki/GB_2312

字符有一字节和双字节编码,00–7F范围内是一位,和ASCII保持一致,此范围内严格上说有96个文字和32个控制符号。
之后的双字节中,前一字节是双字节的第一位。
总体上说第一字节的范围是81–FE(也就是不含80和FF),第二字节的一部分领域在40–FE,其他领域在80–FE。

http://zh.wikipedia.org/zh-cn/GBK
*/
#define DeInRange(x,a,b) (x)>=(a)&&(x)<=(b)
bool IsGB2312Charset(const std::string& src)
{
    unsigned char* start = (unsigned char*)src.c_str();
    unsigned char* end = (unsigned char*)src.c_str() + src.length();
    while(start < end)
    {
        if (0x00 <= *start && *start <= 0x7F)
        {
            // 忽略ascii
            start ++;
            continue;
        }
        if (start + 2 > end)
            return false;
        if (!(DeInRange(*(start+1),0xa1,0xfe)&&(DeInRange(*start,0xb0,0xf7)||DeInRange(*start,0xa1,0xa9)))&&
            !(DeInRange(*start,0x81,0xa0)&&*(start+1)!=0x7f&&DeInRange(*(start+1),0x40,0xfe))&&
            !(DeInRange(*(start+1),0x40,0xa0)&&*(start+1)!=0x7f&&(DeInRange(*start,0xaa,0xfe)||DeInRange(*start,0xa8,0xa9))))
        {
            return false;
        }
        start += 2;
    }
    return start == end;
}

ICU (International Components for Unicode)

  ICU是一个用于软件国际化开源项目,有C++版本(icu4c)和JAVA版本(icu4j),这个库有一个功能是对内容进行 编码探测、解码、编码,当然既然要国际化,那么不能只是对语言进行统一,还要对例如各种度量单位、货币、时间等进行转换。

使用(假定使用chromium base库):

  • 探测编码:参考base::DetectAllEncodings()(还有一个bool DetectEncoding()只返回一个结果,然而效率并没有比前者高,因为内部也是匹配所有再挑选最有可能的那一个)。
  • 转换为UTF8:参考base::ConvertToUtf8AndNormalize()
  • 转换为某个编码:base::UTF16ToCodepage()

本文来自网易实践者社区,经作者郁利涛授权发布