字符集編碼(四):UTF

在前面文章《字符集編碼(中):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》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章