徹底弄懂UTF-8、Unicode、寬字元、locale

gatsby123發表於2019-07-08

最近使用到了wchar_t型別,所以準備詳細探究下,沒想到水還挺深,網上的資料大多都是複製貼上,只有個結論,也沒個驗證過程。本文記錄探究的過程及結論,如有不對請指正。

Unicode、UCS

UCS(Universal Character Set)本質上就是一個字符集。
Unicode的開發結合了國際標準化組織所制定的 ISO/IEC 10646,即通用字符集(
Universal Character Set, UCS)。Unicode 與 ISO/IEC 10646 在編碼的運作原理相同,但 The Unicode Standard 包含了更詳盡的實現資訊、涵蓋了更細節的主題,諸如位元編碼(bitwise encoding)、校對以及呈現等。摘自(Unicode)
所以也可以簡單的理解為,Unicode和UCS等價,都是字符集。

UCS編碼的長度是31位,可用4個位元組表示,可以表示2的31次方個字元。如果兩個字元的高位相同,只有低16位不同,則它們屬於同一平面,所以一個平面由2的16次方個字元組成。目前大部分字元都位於第一個平面稱為BMP。BMP的編碼通常以U+xxxx這種形式表示,其中x是16進位制數。
比如中文“你”對應的UCS編碼為U+4f60,“好”對應的UCS編碼為U+597d。更多中文編碼可以在Unicode編碼表中查詢。

有了UCS編碼,任何一個字元在計算機中都最多可以用四個位元組來表示,稱為碼點。

UTF8

現在有了UCS字符集,那麼一個字元在計算機中真的要按四個位元組(UTF-32)來儲存嗎?
答案是否定的,一方面每個字元都按四位元組來儲存非常浪費空間,因為大部分字元都在BMP,只有後16位有效,前16位都是0。另一方面這與c語言不相容,在c語言中0位元組表示字串的結尾,庫函式strlen等函式依賴這一點,如果按UTF-32儲存,其中有很多0位元組並不表示字串結尾。

Ken Thompson發明了UTF-8編碼,可以很好的解決以上問題。Unicode 和 UTF-8 之間的轉換關係表如下:

碼點起值 碼點終值 位元組序列 Byte1 Byte2 Byte3 Byte4 Byte5 Byte6
U+0000 U+007F 1 0xxxxxxx
U+0080 U+07FF 2 110xxxxx 10xxxxxx
U+0800 U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000 U+1FFFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
U+200000 U+3FFFFFF 5 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
U+4000000 U+7FFFFFFF 6 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

第一個位元組要麼最高位是0(ASCII碼),要麼最高位都是1,最高位之後的1的個數決定了後面的有多少個位元組也屬於當前字元編碼,例如111110xx,最高位之後還有4個1,表示後面的4個位元組屬於當前編碼。後面的每個位元組的最高位都是10,可以和第一個位元組區分開來。後面位元組的x表示的就是UCS編碼。所以UTF-8就像一列火車,第一個位元組是車頭,包含了後面的哪幾個位元組也屬於當前這列火車的資訊,後面的位元組是車廂,其中承載著UCS編碼。

以中文字元“你”為例,對應的Unicode為"U+4f60",二進位制表示為0100 1111 0110 0000。按照表中的規則編碼成UTF-8就是11100100 10111101 10100000(0xe4 0xbd 0xa0)。

結論:

Unicode本質是字符集,在這個集合中的任意一個字元都可以用一個四位元組來表示。

UTF-8是編碼規則,可以通過這個規則將Unicode字符集中任一字元對應的位元組轉換為另一個位元組序列。UTF-8只是編碼規則中的一種,其它的編碼規則還有UTF-16,UTF-32等。

寬字元型別wchar_t

在介紹寬字元前先了解下locale。因為多位元組字串和寬字串的轉換和locale相關。

locale

什麼是locale

區域設定(locale),也稱作“本地化策略集”、“本地環境”,是表達程式使用者地區方面的軟體設定。在linux執行locale可以檢視當前locale設定:

ubuntu@VM-0-16-ubuntu:~$ locale
LANG=zh_CN.UTF-8
LANGUAGE=
LC_CTYPE="zh_CN.UTF-8"
LC_NUMERIC="zh_CN.UTF-8"
LC_TIME="zh_CN.UTF-8"
LC_COLLATE="zh_CN.UTF-8"
LC_MONETARY="zh_CN.UTF-8"
LC_MESSAGES="zh_CN.UTF-8"
LC_PAPER="zh_CN.UTF-8"
LC_NAME="zh_CN.UTF-8"
LC_ADDRESS="zh_CN.UTF-8"
LC_TELEPHONE="zh_CN.UTF-8"
LC_MEASUREMENT="zh_CN.UTF-8"
LC_IDENTIFICATION="zh_CN.UTF-8"
LC_ALL=

可以將locale理解為一系列環境變數。locale環境變數值的格式為language_area.charset。languag表示語言,例如英語或中文;area表示使用該語言的地區,例如美國或者中國大陸;charset表示字符集編碼,例如UTF-8或者GBK。
這些環境變數會對日期格式,數字格式,貨幣格式,字元處理等多個方面產生影響。

參考資料:

  1. locale wiki
  2. Environment Variables

如何設定系統預設的locale

修改配置檔案/etc/default/locale,比如要將locale設為zh_CN.UTF-8,新增如下語句LANG=zh_CN.UTF-8

locale環境變數有何作用

以LC_TIME為例,該變數會影響strftime()等函式。size_t strftime(char *str, size_t maxsize, const char *format, const struct tm *timeptr)
strftime根據format中定義的格式化規則,格式化結構timeptr表示的時間,並把它儲存在str中。

#include <locale.h>
#include <stdio.h>
#include <time.h>

int main () {
    time_t currtime;
    struct tm *timer;
    char buffer[80];

    time( &currtime );
    timer = localtime( &currtime );

    printf("Locale is: %s\n", setlocale(LC_TIME, "en_US.iso88591"));
    strftime(buffer,80,"%c", timer );
    printf("Date is: %s\n", buffer);

    printf("Locale is: %s\n", setlocale(LC_TIME, "zh_CN.UTF-8"));
    strftime(buffer,80,"%c", timer );
    printf("Date is: %s\n", buffer);

    printf("Locale is: %s\n", setlocale(LC_TIME, ""));
    strftime(buffer,80,"%c", timer );
    printf("Date is: %s\n", buffer);
    return(0);
}

編譯後執行結果如下:

Locale is: en_US.iso88591
Date is: Sun 07 Jul 2019 04:08:39 PM CST
Locale is: zh_CN.UTF-8
Date is: 2019年07月07日 星期日 16時08分39秒
Locale is: zh_CN.UTF-8
Date is: 2019年07月07日 星期日 16時08分39秒

可以看到對LC_TIME設定不同的值後,呼叫strftime()會產生不同的結果。
char* setlocale (int category, const char* locale);可以用來對當前程式進行地域設定。
category:用於指定設定影響的範圍,LC_CTYPE影響字元分類和字元轉換,LC_TIME影響日期和時間的格式,LC_ALL影響所有內容。
locale:用於指定變數的值,上例中分別使用了"en_US.iso88591","zh_CN.UTF-8"和空字串"",""表示使用當前作業系統預設的區域設定。

參考資料:
setlocale()

為什麼需要寬字元型別

“你好”對應的Unicode分別為"U+4f60"和"U+597d”,對應的UTF-8編碼分別為“0xe4 0xbd 0xa0”和“0xe5 0xa5 0xbd”

多位元組字串在編譯後的可執行檔案以UTF-8編碼儲存

#include <stdio.h>
#include <string.h>

int main(void) {
    char s[] = "你好";
    size_t len = strlen(s);
    printf("len = %d\n", (int)len);
    printf("%s\n", s);
    return 0;
}

編譯後執行,輸出如下:

len = 6
你好

od編譯後的可執行檔案,可以發現"你好"以UFT-8編碼儲存,也就是“0xe4 0xbd 0xa0”和“0xe5 0xa5 0xbd”6個位元組。
strlen()函式只管結尾的0位元組而不管字串裡存的是什麼,所以len是6,也就是“你好”的UFT-8編碼的位元組數。
printf("%s\n", s);相當於將“0xe4 0xbd 0xa0”和“0xe5 0xa5 0xbd”6個位元組write到當前終端的裝置檔案,如果當前終端的驅動程式能識別UTF-8編碼就能列印漢字,如果當前字元終端的驅動程式不能識別UTF-8就列印不出漢字。

寬字串在編譯後可執行檔案中以Unicode儲存

#include <wchar.h>
#include <stdio.h>
#include <locale.h>

int main(void) {
    setlocale(LC_ALL, "zh_CN.UTF-8");   //設定locale
    wchar_t s[] = L"你好";
    size_t len = wcslen(s);
    printf("len = %d\n", (int)len);
    printf("%ls\n", s);
    return 0;
}

編譯後執行,輸出如下:

len = 2
你好

對編譯後的可執行檔案執行od命令,可以找到如下這些位元組:

193 0003020 001  \0 002  \0   `   O  \0  \0   }   Y  \0  \0  \n  \0  \0  \0
194                00020001        00004f60        0000597d        0000000a

00004f60正是“你”對應的Unicode,0000597d是“好”對應的Unicode。所以對於寬字串是按Unicode儲存在可執行檔案中的。
wchar_t是寬字元型別。在字元常量或者字串前加L就表示寬字元常量或者寬字串。所以len是2。
wcslen()和strlen()不同,不是見到0位元組就結束而是要遇到UCS編碼為0的字元才結束。
目前寬字元在記憶體中以Unicode進行儲存,但是要write到終端仍然需要以多位元組編碼輸出,這樣終端驅動程式才能識別,所以printf在內部把寬字串轉換成多位元組字串,然後write出去。這個轉換過程受locale影響,setlocale(LC_ALL, "zh_CN.UTF-8");設定當前程式的LC_ALL為zh_CN.UTF-8,所以printf將Unicode轉成多位元組的UTF-8編碼,然後write到終端裝置。如果將setlocale(LC_ALL, "zh_CN.UTF-8");改為setlocale(LC_ALL, en_US.iso88591):列印結果中將不會輸出"你好"。

一般來說程式在記憶體計算時通常以寬字元編碼,存檔或者網路傳送則用多位元組編碼。

多位元組字串和寬字串相互轉換

c語言中提供了多位元組字串和寬字串相互轉換的函式。

#include <stdlib.h>
size_t mbstowcs(wchar_t *dest, const char *src, size_t n);
size_t wcstombs(char *dest, const wchar_t *src, size_t n);

mbstowcs()將多位元組字串轉換為寬字串。
wcstombs()將寬字串轉換為多位元組字串。
考慮下面的例子:

#include <locale.h>
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <wchar.h>
#include <string.h>

wchar_t* str2wstr(const char const* s) {
    const size_t buffer_size = strlen(s) + 1;
    wchar_t* dst_wstr = (wchar_t *)malloc(buffer_size * sizeof (wchar_t));
    wmemset(dst_wstr, 0, buffer_size);
    mbstowcs(dst_wstr, s, buffer_size); 
    return dst_wstr;
}

void printBytes(const unsigned char const* s, int len) {
    for (int i = 0; i < len; i++) {
        printf("0x%02x ", *(s + i));
    }
    printf("\n");
}

int main () {
    char s[10] = "你好";          //記憶體中對應0xe4 0xbd 0xa0 0xe5 0xa5 0xbd 0x00 
    wchar_t ws[10] = L"你好";  //記憶體中對應0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00 

    printf("Locale is: %s\n", setlocale(LC_ALL, "zh_CN.UTF-8")); //Locale is: zh_CN.UTF-8
    printBytes(s, 7);       //0xe4 0xbd 0xa0 0xe5 0xa5 0xbd 0x00 
    printBytes((char *)ws, 12);  //0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00 

    printBytes((char *)str2wstr(s), 12); //0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00 

    return(0);
}

編譯後,執行結果如下:

Locale is: zh_CN.UTF-8
0xe4 0xbd 0xa0 0xe5 0xa5 0xbd 0x00 
0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00 
0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00 

第二行輸出也印證了我們之前說的多位元組字串在記憶體中以UTF-8儲存,"0xe4 0xbd 0xa0 0xe5 0xa5 0xbd"正是"你好"的UTF-8編碼。
第三行輸出印證了之前說的寬字串在記憶體中以Unicode儲存,"0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00"正好是寬字串L"你好"對應的Unicode。
setlocale(LC_ALL, "zh_CN.UTF-8")設定locale,程式將以UTF-8解碼寬字串。呼叫mbstowcs()後,可以看到“你好”的UTF-8編碼 "0xe4 0xbd 0xa0 0xe5 0xa5 0xbd 0x00"確實被轉換成了“你好”對應的Unicode "0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00"。
如果將setlocale(LC_ALL, "zh_CN.UTF-8")換成setlocale(LC_ALL, "en_US.iso88591 ");那麼最後一行的輸出也就會不一樣。

相關文章