字符集編碼(四):UTF

林子er發表於2022-03-12

在前面文章《字符集編碼(中):Unicode》中我們聊了 Unicode 標準並提到其有三種實現形式:UTF-16、UTF-8 和 UTF-32,本篇我們就具體聊聊這三種 UTF 是怎麼實現的。

UTF 是 Unicode Translation Format 的縮寫,翻譯過來是 Unicode 轉換格式,對應字元編碼模型中的第三、四層(字元編碼形式和字元編碼方案),負責將 Unicode 碼點以特定的碼元儲存在計算機中

UTF-X 中的 X 表示碼元的寬度(位元數),如 UTF-16 表示使用 16 位碼元儲存資料。

UTF-16

Unicode 最初是打算使用 16 位定長編碼形式的,在這種情況下 Unicode 標量值(也就是碼點)和其在計算機中的碼元表示是一致的。

比如漢字“啊”的 Unicode 標量值(碼點)是 554A,其碼元表示也是 55 4A(二進位制是 01010101 01001010)。

這種表示方式的優點是簡單快速,不需要任何標誌位,也不需要做任何轉換,所以在 Unicode 設計之初選用雙位元組定長編碼。

不過這種方式只能表示 2^16 也就是 65536 個字元,但 Unicode 聯盟很快發現兩個位元組無法容納世界上所有的字元,於是很快將編碼空間擴充套件到 0~10FFFF,顯然原先的表示方式不再可行了。

於是他們對原先的 UTF-16 做了改進。

改進後的 UTF-16 採用變長編碼形式,使用一個或兩個碼元來表示字元編碼。具體來說是基本平面(BMP,範圍是 0000~FFFF)的字元使用一個碼元表示,補充平面的使用兩個碼元。

然而這裡有個問題:程式在解析一段文字時,如何知道某個碼元(16 bit)是表示一個單獨的字元呢,還是和另一個碼元一起表示一個字元呢?

比如當程式遇到位元組序列01001110 00101101 01010110 11111101時,應該將其解析為兩個字元(每個字元佔一個碼元)呢還是解析為一個字元呢?

這種問題我們在以前講 GB 2312 時也遇到過。GB 2312 中為了相容 ASCII,使用一個位元組編碼 ASCII 字元,而用兩個位元組編碼其它字元。為了識別某個位元組到底是單獨表示字元還是和另一個位元組一起表示字元,GB 2312 將位元組最高位作為標識位:如果一個位元組的最高位是 0,則表示該位元組是單獨表示 ASCII 字元,否則就是和另一個相鄰位元組一起表示一個字元。

UTF-16(以及後面要講的 UTF-8)也是採用類似標識位的方式解決問題的:每個碼元的高 n 位作為標識位,說明該碼元是單獨表示一個字元還是和另一個碼元一起表示字元,因而改進後的 UTF-16 碼元表示類似xxxxyyyyyyyyyyyy,其中 x 表示標識位,y 表示實際值,程式根據一系列的 x 的值決定如何處理該碼元。

Unicode 最大編碼值(標量值)是 10FFFF,對應二進位制是100001111111111111111。該範圍的值中,FFFF 以內的值(二進位制:000001111111111111111,注意其中高 5 為全部是 0,只有低 16 位是變化的)可以只用一個碼元表示,大於 FFFF 的值要用兩個碼元表示,形如:xxxxyyyyyyyyyyyy xxxxyyyyyyyyyyyy

那麼,當用兩個碼元表示的時候,需要做到:

  1. 標誌位(x 表示的)需要具有某種固定特性(如固定位數的固定值),讓程式能夠據此做出正確處理;
  2. 資料位(y 表示的)能夠放得下 10FFFF - FFFF = 100000(十進位制是 1114111 - 65535 = 1048576)個值;
  3. 兩個碼元的標識位需要有所區別,這樣程式才能知道誰在前誰在後;

1048576 = 1024 * 1024,所以要求碼元中 y 位要有 10 位(2^10=1024),這樣兩個碼元在一起能表示的數值的個數就是 1024*1024 了,於是確定可以用高 6 位(16 - 10)作為標識位。

UTF-16 兩個碼元的表示大致就是這樣:

// 1. 每個碼元的高 6 位是標識位,剩下的是資料位;
// 2. 兩個碼元的標識位需要有所區別,讓程式能夠識別是是高位碼元,誰是低位碼元;
xxxxxxyyyyyyyyyy xxxxxxyyyyyyyyyy

在 UTF-16 中標識位的值是固定不變的,所以兩個碼元中每個碼元能表達 1024(2^10)個數值,這些值必然會跟基本平面 BMP 中某些值衝突,所以必須將 BMP 中某 2048 個值(兩個碼元,每個佔用 1024 個)保留起來不做碼點分配。

Unicode 在基本平面(BMP)中將 D800~DFFF 共 2048 個值(包括 D800 和 DFFF 自身)劃出來給 UTF-16 專用,這段空間的值不能作為字元碼點分配。

這 2048 個值中,前 1024 個(D800~DBFF)叫做高位代理(high surrogates),對應兩個碼元中左邊的那個碼元;後 1024 個(DC00~DFFF)叫做低位代理(low surrogates),對應右邊的碼元。高低位代理一起稱為代理對(surrogate pair),UTF-16 就是用代理對來表示擴充套件平面的字元編碼。

D800~DBFF 的數值(二進位制)都是以 110110 開頭(高 6 位),因此高位碼元(我們稱兩個碼元中的左邊那個為高位碼元)用 110110 作為標識位;DC00~DFFF 的數值都是以 110111 開頭,因此低位碼元用 110111 作為標識位。

舉個例子,當程式遇到下面這串碼元序列該如何解析呢:

image-20220311113923321

作為 UTF-16 編碼形式,上面一共有三個碼元。程式發現第一個碼元(01010101 01001010)不是 11011 開頭,則將其作為單碼元字元解析(漢字“啊”);第二個碼元(11011000 01000000)是 110110 開頭,說明它是雙碼元字元的高位碼元(高代理),於是繼續獲取下一個碼元(11011100 00000000)檢查其標識位 110111,無誤,於是將這兩個碼元一起解析成一個字元(是一個不常用的漢字,很多編輯器顯示不出來)。

於是上面的碼元序列解析出來就是:

image-20220311115128181

接下來的問題是,上面 Unicode 碼點 U+20000(二進位制:10 00000000 00000000)是如何對映到兩個碼元中的資料位的呢?

簡單的理解是下面的對映關係:

image-20220311115808636

對於 小於 FFFF 的值,由於一個碼元能直接放得下,就直接放進去,沒什麼說的(注意 D800~DFFF 保留出來了)。

大於 FFFF 的值,我們將其分為圖中 u、x、y 三部分,然後將 u - 1、x、y 直接填入碼元的資料位中即可。

注意 Unicode 編碼空間最大值是 10FFFF,其二進位制有 21 位(100001111111111111111),而上面兩個碼元的資料位一共只有 20 位,少了一位。

但我們發現,最大值 高 5 位 10000 減去 1 就變成 4 位了,加上剩下的 16 位正好是 20 位。

當然,這是我們從直覺上這麼理解的,其實 UTF-16 的這個對映關係是有數學公式的:

image-20220311121421312

其中 CH 和 CL 分別表示高低代理碼元的值,U 表示 Unicode 碼點值(又叫標量值);下標 16 表示 16 進位制;/ 是整除,mod 是取模。

是不是看得一臉懵逼?

翻譯過來其實很簡單:假設碼點值是 X,則將 X - 10000 取 10000 以上部分,該部分除以 400(十進位制 1024),得到的整數加上高代理偏移量 D800 作為高位碼元的值,得到的餘數加上低代理偏移量 DC00 作為低位碼元的值。

反過來,通過碼元值推導碼點值:

image-20220311122128225

因為前面我們是減掉了 10000,所以這裡要加回去。


UTF-8

Unicode 最初決定採用雙位元組定長編碼方案,後來發現沒法徹底相容現有的 ASCII 標準的檔案和軟體,導致新標準無法快速廣泛推廣使用,於是 Unicode 聯盟很快推出 8 位編碼方案以相容 ASCII,這就是 UTF-8。

(由於 UTF-8 的碼元寬度是一個位元組,下面會混合使用位元組與碼元的概念。)

UTF-8 使用一到四個位元組的位元組序列來表示整個 Unicode 編碼空間。和 UTF-16 一樣,UTF-8 也是變長編碼方案,所以它的每個碼元(位元組)同樣需要包含標識位和資料位兩部分,形如xxxyyyyy。這裡的標識位需要做到:

  1. 存在某種固定規則,讓程式能夠判斷出它是標識位;
  2. 和 UTF-16 要麼一個碼元要麼兩個碼元的設計不同,UTF-8 涉及到1~4 個碼元(理論上可以不止 4 個),所以標識位還應包含碼元數量資訊;
  3. 碼元序列中第一個碼元的標識位和其他碼元的應該有所不同,這樣程式才能知道應該從哪個碼元開始解析;
  4. 需完全相容 ASCII 碼,即 ASCII 碼字元只需要一個碼元(一個位元組);

UTF-8 編碼邏輯是這樣的:

  1. 先看位元組最高位,如果是 0,則說明是用一個位元組表示字元,也就是 ASCII 字元(這裡再次見識到 ASCII 編碼標準中最高位恆 0 的重要性);
  2. 反之,如果最高位是 1,說明是用多位元組編碼(至少兩個位元組)。此時首先要區分首位元組和後續位元組,讓程式知道從哪個位元組開始解析。UTF-8 規定,此時首位元組最高兩位一定是 11,而後續位元組最高兩位一定是 10,程式據此區分;
  3. 首位元組高位有幾個 1 就表示用多少個位元組表示字元,比如 1110XXXX 表示用三個位元組表示一個字元(如常用漢字);

UTF-8 的規則看起來還是挺簡單的,總結起來就是:通過最高位判斷是否單位元組字元;如果是多位元組,通過 11、10 分別識別首位元組和後續位元組;通過首位元組高位連續有多少個 1 識別該字元是由多少個位元組表示。

比如程式遇到位元組序列01100001 11100101 10010101 10001010該如何解析呢?

第一個位元組最高位是 0,說明是單位元組字元,直接按字面意思解析得到拉丁字母 a。

第二個位元組是 1 開頭,說明是多位元組字元;最高兩位是 11,說明該位元組是多位元組字元的首位元組,於是從該位元組高位解析字元位元組數:高位有連續的三個 1(標誌位是 1110),說明該位元組和它後面的兩個位元組一起表示一個字元,然後檢查後續兩個位元組,確實以 10 開頭,符合規則,於是將這三個位元組一起解析得到漢字“啊”。

接下來的問題是,漢字“啊”的 Unicode 碼點是如何存入多位元組碼元中呢?

UTF-8 規則直觀理解如下:

image-20220311173929297

這個規則是很直觀的,直接將二進位制標量值中的位拷貝到碼元相應位置即可。

比如漢字“啊”的碼點是 U+554A,二進位制標量值是 00000 01010101 01001010,從表中可知需要用三個位元組存放其低 16 位(16 位以上都是 0)。三個位元組一共有 24 位,減去 8 個標識位,剛好還剩 16 個位可用:

image-20220311175109239

當然除了以上這種直觀理解,UTF-8 的規則也是可以用數學公式表達的,需要對四個編碼範圍分別表述,此處不再貼出公式。


UTF-32

Unicode 還有一種最直觀但最佔用空間(也最不常用)的編碼表示:UTF-32,它採用 4 位元組(32 位)碼元,任何 Unicode 碼點都是用 4 個位元組表示。由於 4 位元組足以容納任何 Unicode 標量值(我們稱 Unicode 碼點的二進位制表示為標量值),所以它是最直觀的表示方式,無需做任何標識和轉換。

比如漢字“啊”的 Unicode 碼點是 U+554A,其二進位制標量值是1010101 01001010,其 UTF-32 表示就是00000000 00000000 01010101 01001010(此處沒有考慮大小端)。

和 UTF-16 一樣,UTF-32 也不能相容 ASCII 標準。


大小端與 BOM

我們在《字符集編碼(補):字元編碼模型》的第四層字元編碼方案 CES中提到字元編碼在計算機中儲存時存在大小端問題(那裡也詳細講解了大小端的概念,不熟悉的同學可以先看下那邊文章)。在那篇文章中我們說過只有多位元組碼元(UTF-16、UTF-32)才存在大小端問題,單位元組碼元(UTF-8)不存在大小端問題。

我們還是以漢字“啊”為例,其 UTF-8、UTF-16 和 UTF-32 的編碼形式在編碼模型第三層(字元編碼形式 CEF)分別表示如下:

// “啊”的碼點是 U+554A
UTF-8: 11100101 10010101 10001010 // 十六進位制:E5 95 8A
UTF-16:01010101 01001010 // 十六進位制:55 4A
UTF-32:00000000 00000000 01010101 01001010 // 十六進位制:00 00 55 4A

其中 UTF-8 用了三個碼元,但由於其碼元寬度是 1 個位元組,不存在大小端問題,不用討論。

UTF-16 和 UTF-32 都只用了一個碼元,但由於兩者的碼元寬度大於 1 個位元組,需要考慮位元組序問題。

大端序儲存規則是先存高位(也就是將高位放在低地址。我們將一個數左邊的叫高位,右邊叫低位);小端序儲存規則是先存低位。“啊”字的編碼方案考慮大小端後是這樣的:

UTF-16BE:01010101 01001010 // 大端序。十六進位制:55 4A
UTF-16LE:01001010 01010101 // 小端序。十六進位制:4A 55
UTF-32BE:00000000 00000000 01010101 01001010 // 大端序。十六進位制:00 00 55 4A
UTF-32LE:01001010 01010101 00000000 00000000 // 小端序。十六進位制:4A 55 00 00

在前面的文章中我們還提到,之所以需要考慮大小端問題,是因為文字需要儲存到磁碟檔案系統並在多個異構系統之間分享。那麼,當一個程式拿到一個檔案後,它怎麼知道該檔案是按大端序儲存的還是按小端序儲存的呢?

為了解決這個問題,Unicode 中定義了一個特殊的字元叫 ZERO WIDTH NOBREAK SPACE(零寬度無中斷空白符,就是說這個字元既沒有寬度,也不能造成文字換行),其碼點是 U+FEFF。Unicode 的多位元組碼元編碼方案(UTF-16、UTF-32)就是在檔案開頭用這個字元

的相應編碼值來表示該檔案是怎麼編碼的。

我們先看看碼點 U+FEFF 用 UTF-8、UTF-16BE、UTF-16LE、UTF-32BE、UTF-32LE 分別如何表示(BE 是 Big Endian 大端序的意思,LE 是小端序:

// 碼點:U+FEFF。下面的編碼表示僅用十六進位制
UTF-8: EF BB BF
UTF-16BE: FE FF
UTF-16LE: FF FE
UTF-32BE: 00 00 FE FF
UTF-32LE: FF FE 00 00

那麼怎麼用這個字元來表示檔案的編碼方式呢?很簡單,就是將這個字元的相應編碼方案的編碼值放在檔案開頭就行了。

比如當程式發現開頭兩個位元組是 FE FF,就知道該檔案是 UTF-16 大端編碼方式,如果遇到 FF FE 就知道是 UTF-16 小端編碼方式——等等!憑什麼說遇到 FF FE 開頭就是 UTF-16 小端?難道它不能是字元 U+FFFE 的 UTF-16 大端編碼值嗎?Unicode 設計時考慮到了這個問題,所以規定 U+FFFE 不能表示任何字元,直接將該值廢棄掉了,於是就不會出現上面說的衝突了。

這些位元組值是用來標識檔案的大小端儲存方式的,所以它們有個專門的名字叫 BOM(Byte Order Mark,位元組序標記)。UTF-8 是不需要標記位元組序的,但有些 UTF-8 檔案也有 BOM 頭(EF BB BF),這主要是用來標記該檔案是 UTF-8 編碼的(不是必須的)。

注意,對於 UTF-8 的 BOM 頭,有些軟體是不支援的。比如 PHP 直譯器是無法識別 BOM 頭的,所以如果將 PHP 程式碼檔案儲存為 UTF-8 BOM 檔案格式,PHP 直譯器會將 BOM 頭(前三個位元組 EF BB BF)視作普通字元解析(注意 UTF-8 BOM 頭是合法的 UTF-8 編碼字元,不會導致 UTF-8 解析錯誤),在 PHP-FPM 模式下會將該字元返回給瀏覽器,有些瀏覽器無法正確處理該字元,可能會在頁面出現一小塊空白;更嚴重的是由於該字元在 Cookie 設定之前就傳送給瀏覽器了,會導致 Cookie 設定失敗。所以 PHP 檔案一定要儲存為 UTF-8 不帶 BOM 的格式。

至此,字符集編碼系列就寫完了,大家可以通過下面的連結檢視前面的系列文章:

《字符集編碼(上):Unicode 之前》

《字符集編碼(補):字元編碼模型》

《字符集編碼(中):Unicode》

相關文章