1. 程式人生 > 其它 >Unicode、UTF-8、UTF-16 終於懂了

Unicode、UTF-8、UTF-16 終於懂了

計算機起源於美國,上個世紀,他們對英語字元與二進位制位之間的關係做了統一規定,並制定了一套字元編碼規則,這套編碼規則被稱為ASCII編碼

ASCII 編碼一共定義了128個字元的編碼規則,用七位二進位制表示 ( 0x00 - 0x7F ), 這些字元組成的集合就叫做 ASCII 字符集

隨著計算機的普及,在不同的地區和國家又出現了很多字元編碼,比如: 大陸的 GB2312、港臺的 BIG5, 日本的 Shift JIS等等

由於字元編碼不同,計算機在不同國家之間的交流變得很困難,經常會出現亂碼的問題,比如:對於同一個二進位制資料,不同的編碼會解析出不同的字元

當網際網路迅猛發展,地域限制打破之後,人們迫切的希望有一種統一的規則, 對所有國家和地區的字元進行編碼,於是 Unicode 就出現了

Unicode 簡介

Unicode 是國際標準字符集,它將世界各種語言的每個字元定義一個唯一的編碼,以滿足跨語言、跨平臺的文字資訊轉換

Unicode 字符集的編碼範圍是 0x0000 - 0x10FFFF , 可以容納一百多萬個字元, 每個字元都有一個獨一無二的編碼,也即每個字元都有一個二進位制數值和它對應,這裡的二進位制數值也叫 碼點 , 比如:漢字 "中" 的 碼點是 0x4E2D, 大寫字母 A 的碼點是 0x41, 具體字元對應的 Unicode 編碼可以查詢 Unicode字元編碼表

字符集和字元編碼

字符集是很多個字元的集合,例如 GB2312 是簡體中文的字符集,它收錄了六千多個常用的簡體漢字及一些符號,數字,拼音等字元

字元編碼是 字符集的一種實現方式,把字符集中的字元對映為特定的位元組或位元組序列,它是一種規則

比如:Unicode 只是字符集,UTF-8、UTF-16、UTF-32 才是真正的字元編碼規則

Unicode 字元儲存

Unicode 是一個符號集, 它只規定了每個符號的二進位制值,但是符號具體如何儲存它並沒有規定

前面提到, Unicode 字符集的編碼範圍是 0x0000 - 0x10FFFF,因此需要 1 到 3 個位元組來表示

那麼,對於三個位元組的 Unicode字元,計算機怎麼知道它表示的是一個字元而不是三個字元呢 ?

如果所有字元都用三個位元組表示,那麼對於那些一個位元組就能表示的字元來說,有兩個位元組是無意義的,對於儲存來說,這是極大的浪費,假如 , 一個普通的文字, 大部分字元都只需一個位元組就能表示,現在如果需要三個位元組才能表示,文字的大小會大出三倍左右

因此,Unicode 出現了多種儲存方式,常見的有 UTF-8、UTF-16、UTF-32,它們分別用不同的二進位制格式來表示 Unicode 字元

UTF-8、UTF-16、UTF-32 中的 "UTF" 是 "Unicode Transformation Format" 的縮寫,意思是"Unicode 轉換格式",後面的數
字表明至少使用多少個位元位來儲存字元, 比如:UTF-8 最少需要8個位元位也就是一個位元組來儲存,對應的, UTF-16 和 UTF-32 分別需要最少 2 個位元組 和 4 個位元組來儲存

UTF-8 編碼

UTF-8: 是一種變長字元編碼,被定義為將碼點編碼為 1 至 4 個位元組,具體取決於碼點數值中有效二進位制位的數量

UTF-8 的編碼規則:

  1. 對於單位元組的符號,位元組的第一位設為 0,後面 7 位為這個符號的 Unicode 碼。因此對於英語字母,UTF-8 編碼和 ASCII 碼是相同的, 所以 UTF-8 能相容 ASCII 編碼,這也是網際網路普遍採用 UTF-8 的原因之一

  2. 對於 n 位元組的符號( n > 1),第一個位元組的前 n 位都設為 1,第 n + 1 位設為 0,後面位元組的前兩位一律設為 10 。剩下的沒有提及的二進位制位,全部為這個符號的 Unicode 碼

下表是Unicode編碼對應UTF-8需要的位元組數量以及編碼格式

Unicode編碼範圍(16進位制) UTF-8編碼方式(二進位制)
000000 - 00007F 0xxxxxxx ASCII碼
000080 - 0007FF 110xxxxx 10xxxxxx
000800 - 00FFFF 1110xxxx 10xxxxxx 10xxxxxx
01 0000 - 10 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

表格中第一列是Unicode編碼的範圍,第二列是對應UTF-8編碼方式,其中紅色的二進位制 "1""0" 是固定的字首, 字母 x 表示可用編碼的二進位制位

根據上面表格,要解析 UTF-8 編碼就很簡單了,如果一個位元組第一位是 0 ,則這個位元組就是一個單獨的字元,如果第一位是 1 ,則連續有多少個 1 ,就表示當前字元佔用多少個位元組

下面以 "中" 字 為例來說明 UTF-8 的編碼,具體的步驟如下圖, 為了便於說明,圖中左邊加了 1,2,3,4 的步驟編號

首先查詢 "中" 字的 Unicode 碼 0x4E2D, 轉成二進位制, 總共有 16 個二進位制位, 具體如上圖 步驟1 所示

通過前面的 Unicode 編碼和 UTF-8 編碼的表格知道,Unicode 碼 0x4E2D 對應 000800 - 00FFFF 的範圍,所以, "中" 字的 UTF-8 編碼 需要 3 個位元組,即格式是 1110xxxx 10xxxxxx 10xxxxxx

然後從 "中" 字的最後一個二進位制位開始,按照從後向前的順序依次填入格式中的 x 字元,多出的二進位制補為 0, 具體如上圖 步驟2、步驟3 所示

於是,就得到了 "中" 的 UTF-8 編碼是 11100100 10111000 10101101, 轉換成十六進位制就是 0xE4B8AD, 具體如上圖 步驟4 所示

UTF-16 編碼

UTF-16 也是一種變長字元編碼, 這種編碼方式比較特殊, 它將字元編碼成 2 位元組 或者 4 位元組

具體的編碼規則如下:

  1. 對於 Unicode 碼小於 0x10000 的字元, 使用 2 個位元組儲存,並且是直接儲存 Unicode 碼,不用進行編碼轉換

  2. 對於 Unicode 碼在 0x100000x10FFFF 之間的字元,使用 4 個位元組儲存,這 4 個位元組分成前後兩部分,每個部分各兩個位元組,其中,前面兩個位元組的前 6 位二進位制固定為 110110,後面兩個位元組的前 6 位二進位制固定為 110111, 前後部分各剩餘 10 位二進位制表示符號的 Unicode 碼 減去 0x10000 的結果

  3. 大於 0x10FFFF 的 Unicode 碼無法用 UTF-16 編碼

下表是Unicode編碼對應UTF-16編碼格式

Unicode編碼範圍(16進位制) 具體Unicode碼(二進位制) UTF-16編碼方式(二進位制) 位元組
0000 0000 - 0000 FFFF xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx 2
0001 0000 - 0010 FFFF yy yyyyyyyy xx xxxxxxxx 110110yy yyyyyyyy 110111xx xxxxxxxx 4

表格中第一列是Unicode編碼的範圍,第二列是 具體Unicode碼的二進位制 ( 第二行的第二列表示的是 Unicode 碼 減去 0x10000 後的二進位制 ) , 第三列是對應UTF-16編碼方式,其中紅色的二進位制 "1""0" 是固定的字首, 字母 xy 表示可用編碼的二進位制位, 第四列表示 編碼佔用的位元組數

前面提到過,"中" 字的 Unicode 碼是 4E2D, 它小於 0x10000,根據表格可知,它的 UTF-16 編碼佔兩個位元組,並且和 Unicode 碼相同,所以 "中" 字的 UTF-16 編碼為 4E2D

我從 Unicode字元表網站 找了一個老的南阿拉伯字母, 它的 Unicode 碼是: 0x10A6F , 可以訪問 https://unicode-table.com/cn/10A6F/ 檢視字元的說明, Unicode 碼對應的字元如下圖所示

下面以這個 老的南阿拉伯字母的 Unicode 碼 0x10A6F 為例來說明 UTF-16 4 位元組的編碼,具體步驟如下,為了便於說明,圖中左邊加了 1,2,3,4 、5 的步驟編號

首先把 Unicode 碼 0x10A6F 轉成二進位制, 對應上圖的 步驟 1

然後把 Unicode 碼 0x10A6F 減去 0x10000, 結果為 0xA6F 並把這個值轉成二進位制 00 00000010 10 01101111,對應上圖的 步驟 2

然後 從二進位制 00 00000010 10 01101111 的最後一個二進位制為開始,按照從後向前的順序依次填入格式中的 xy 字元,多出的二進位制補為 0, 對應上圖的 步驟 3、 步驟 4

於是,就計算出了 Unicode 碼 0x10A6F 的 UTF-16 編碼是 11011000 00000010 11011110 01101111 , 轉換成十六進位制就是 0xD802DE6F, 對應上圖的 步驟 5

UTF-32 編碼

UTF-32 是固定長度的編碼,始終佔用 4 個位元組,足以容納所有的 Unicode 字元,所以直接儲存 Unicode 碼即可,不需要任何編碼轉換。雖然浪費了空間,但提高了效率。

UTF-8、UTF-16、UTF-32 之間如何轉換

前面介紹過,UTF-8、UTF-16、UTF-32 是 Unicode 碼錶示成不同的二進位制格式的編碼規則,同樣,通過這三種編碼的二進位制表示,也能獲得對應的 Unicode 碼,有了字元的 Unicode 碼,按照上面介紹的 UTF-8、UTF-16、UTF-32 的編碼方法 就能轉換成任一種編碼了

UTF 位元組序

最小編碼單元是多位元組才會有位元組序的問題存在,UTF-8 最小編碼單元是一位元組,所以 它是沒有位元組序的問題,UTF-16 最小編碼單元是 2 個位元組,在解析一個 UTF-16 字元之前,需要知道每個編碼單元的位元組序

比如:前面提到過,"中" 字的 Unicode 碼是 4E2D, "ⵎ" 字元的 Unicode 碼是 2D4E, 當我們收到一個 UTF-16 位元組流 4E2D 時,計算機如何識別它表示的是字元 "中" 還是 字元 "ⵎ" 呢 ?

所以,對於多位元組的編碼單元,需要有一個標記顯式的告訴計算機,按照什麼樣的順序解析字元,也就是位元組序,位元組序分為 大端位元組序 和 小端位元組序

小端位元組序簡寫為 LE( Little-Endian ), 表示 低位位元組在前,高位位元組在後, 高位位元組儲存在記憶體的高地址端,而低位位元組儲存在記憶體的低地址端

大端位元組序簡寫為 BE( Big-Endian ), 表示 高位位元組在前,低位位元組在後,高位位元組儲存在記憶體的低地址端,低位位元組儲存在在記憶體的高地址端

下面以 0x4E2D 為例來說明大端和小端,具體參見下圖:

資料是從高位位元組到低位位元組顯示的,這也更符合人們閱讀資料的習慣,而記憶體地址是從低地址向高地址增加

所以,字元0x4E2D 資料的高位位元組是 4E,低位位元組是 2D

按照大端位元組序的高位位元組儲存記憶體低地址端的規則,4E 儲存到低記憶體地址 0x10001 上,2D 則儲存到高記憶體地址 0x10002

對於小端位元組序,則正好相反,資料的高位位元組儲存到記憶體的高地址端,低位位元組儲存到記憶體低地址端的,所以 4E 儲存到高記憶體地址 0x10002 上,2D 則儲存到低記憶體地址 0x10001

BOM

BOM 是 byte-order mark 的縮寫,是 "位元組序標記" 的意思, 它常被用來當做標識檔案是以 UTF-8、UTF-16 或 UTF-32 編碼的標記

在 Unicode 編碼中有一個叫做 "零寬度非換行空格" 的字元 ( ZERO WIDTH NO-BREAK SPACE ), 用字元 FEFF 來表示

對於 UTF-16 ,如果接收到以 FEFF 開頭的位元組流, 就表明是大端位元組序,如果接收到 FFFE, 就表明位元組流 是小端位元組序

UTF-8 沒有位元組序問題,上述字元只是用來標識它是 UTF-8 檔案,而不是用來說明位元組順序的。"零寬度非換行空格" 字元 的 UTF-8 編碼是 EF BB BF, 所以如果接收到以 EF BB BF 開頭的位元組流,就知道這是UTF-8 檔案


下面的表格列出了不同 UTF 格式的固定檔案頭

UTF編碼 固定檔案頭
UTF-8 EF BB BF
UTF-16LE FF FE
UTF-16BE FE FF
UTF-32LE FF FE 00 00
UTF-32BE 00 00 FE FF

根據上面的 固定檔案頭,下面列出了 "中" 字在檔案中的儲存 ( 包含檔案頭 )

編碼 固定檔案頭
Unicode 編碼 0X004E2D
UTF-8 EF BB BF E4 B8 AD
UTF-16BE FE FF 4E 2D
UTF-16LE FF FE 2D 4E
UTF-32BE 00 00 FE FF 00 00 4E 2D
UTF-32LE FF FE 00 00 2D 4E 00 00

常見的字元編碼的問題

  • Redis 中文key的顯示

有時候我們需要向redis中寫入含有中文的資料,然後在檢視資料,但是會看到一些其他的字元,而不是我們寫入的中文

上圖中,我們向redis 寫入了一個 "中" 字,通過 get 命令檢視的時候無法顯示我們寫入的 "中" 字

這時候加一個 --raw 引數,重新啟動 redis-cli 即可,也即 執行 redis-cli --raw 命令啟動redis客戶端,具體的如下圖所示

  • MySQL 中的 utf8 和 utf8mb4

MySQL 中的 "utf8" 實際上不是真正的 UTF-8, "utf8" 只支援每個字元最多 3 個位元組, 對於超過 3 個位元組的字元就會出錯, 而真正的 UTF-8 至少要支援 4 個位元組

MySQL 中的 "utf8mb4" 才是真正的 UTF-8


下面以 test 表為例來說明, 表結構如下:
mysql> show create table test\G
*************************** 1. row ***************************
       Table: test
Create Table: CREATE TABLE `test` (
  `name` char(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

test 表分別插入 "中" 字 和 Unicode 碼為 0x10A6F 的字元,這個字元需要從 https://unicode-table.com/cn/10A6F/ 直接複製到 MySQL 控制檯上,手工輸入會無效,具體的執行結果如下圖:

從上圖可以看出,插入 "中" 字 成功,插入 0x10A6F 字元失敗,錯誤提示無效的字串,\xF0\X90\XA9\xAF 正是 0x10A6F 字元的 UTF-8 編碼,佔用 4 個位元組, 因為 MySQL 的 utf8 編碼最多隻支援 3 個位元組,所以插入會失敗


test 表的字符集改成 utf8mb4 , 排序規則 改成 utf8bm4_unicode_ci, 具體如下圖所示:


字符集和排序方式修改之後,再次插入 0x10A6F 字元, 結果是成功的,具體執行結果如下圖所示

上圖中,set names utf8mb4 是為了測試方便,臨時修改當前會話的字符集,以便保持和 伺服器一致,實際解決這個問題需要修改 my.cnf 配置中 伺服器和客戶端的字符集

小結

本文從字元編碼的歷史介紹了 Unicode 出現的原因,接著介紹了 Unicode 字符集中 三種不同的編碼方式: UTF-8、UTF-16、UTF-32 以及它們的的編碼方法,緊接著介紹了 位元組序、BOM ,最後講到了字符集在 MySQL 和 Redis 應用中常見的問題以及解決方案 ,更多關於 Unicode 的介紹請參考 Unicode 的 RFC 文件