今天,在學(xué)習(xí) Node.js 中的 Buffer 對(duì)象時(shí),注意到它的 alloc 和 from 方法會(huì)默認(rèn)用 UTF-8 編碼,在數(shù)組中每位對(duì)應(yīng) 1 字節(jié)的十六進(jìn)制數(shù)。想到了之間學(xué)習(xí) ES6 時(shí)關(guān)于字符串的 Unicode 表示法,突然就很想知道 UTF-16 是如何進(jìn)行編碼的,我嘗試將一些漢字轉(zhuǎn)換成二進(jìn)制數(shù),然后簡(jiǎn)單的按 2 個(gè)字節(jié)一組轉(zhuǎn)換成十六進(jìn)制,發(fā)現(xiàn)對(duì)于那些碼點(diǎn)較大的漢字,結(jié)果并不僅僅是簡(jiǎn)單的二進(jìn)制轉(zhuǎn)十六進(jìn)制。于是,我開(kāi)始在網(wǎng)上找資料,決心徹底弄明白 Unicode 編碼。
ASCII碼
在學(xué)校學(xué) C 語(yǔ)言的時(shí)候,了解到一些計(jì)算機(jī)內(nèi)部的機(jī)制,知道所有的信息最終都表示為一個(gè)二進(jìn)制的字符串,每一個(gè)二進(jìn)制位有 0 和 1 兩種狀態(tài),通過(guò)不同的排列組合,使用 0 和 1 就可以表示世界上所有的東西,感覺(jué)有點(diǎn)中國(guó)“太極”的感覺(jué)——“太極生兩儀,兩儀生四象,四象生八卦”。
在計(jì)算機(jī)種中,1 字節(jié)對(duì)應(yīng) 8 位二進(jìn)制數(shù),而每位二進(jìn)制數(shù)有 0、1 兩種狀態(tài),因此 1 字節(jié)可以組合出 256 種狀態(tài)。如果這 256 中狀態(tài)每一個(gè)都對(duì)應(yīng)一個(gè)符號(hào),就能通過(guò) 1 字節(jié)的數(shù)據(jù)表示 256 個(gè)字符。美國(guó)人于是就制定了一套編碼(其實(shí)就是個(gè)字典),描述英語(yǔ)中的字符和這 8 位二進(jìn)制數(shù)的對(duì)應(yīng)關(guān)系,這被稱為 ASCII 碼。
ASCII 碼一共定義了 128 個(gè)字符,例如大寫(xiě)的字母 A 是 65(這是十進(jìn)制數(shù),對(duì)應(yīng)二進(jìn)制是0100 0001)。這 128 個(gè)字符只使用了 8 位二進(jìn)制數(shù)中的后面 7 位,最前面的一位統(tǒng)一規(guī)定為 0。
歷史問(wèn)題
英語(yǔ)用 128 個(gè)字符來(lái)編碼完全是足夠的,但是用來(lái)表示其他語(yǔ)言,128 個(gè)字符是遠(yuǎn)遠(yuǎn)不夠的。于是,一些歐洲的國(guó)家就決定,將 ASCII 碼中閑置的最高位利用起來(lái),這樣一來(lái)就能表示 256 個(gè)字符。但是,這里又有了一個(gè)問(wèn)題,那就是不同的國(guó)家的字符集可能不同,就算它們都能用 256 個(gè)字符表示全,但是同一個(gè)碼
點(diǎn)(也就是 8 位二進(jìn)制數(shù))表示的字符可能可能不同。例如,144 在阿拉伯人的 ASCII 碼中是 ?,而在俄羅斯的 ASCII 碼中是 ?。
因此,ASCII 碼的問(wèn)題在于盡管所有人都在 0 - 127 號(hào)字符上達(dá)成了一致,但對(duì)于 128 - 255 號(hào)字符上卻有很多種不同的解釋。與此同時(shí),亞洲語(yǔ)言有更多的字符需要被存儲(chǔ),一個(gè)字節(jié)已經(jīng)不夠用了。于是,人們開(kāi)始使用兩個(gè)字節(jié)來(lái)存儲(chǔ)字符。
各種各樣的編碼方式成了系統(tǒng)開(kāi)發(fā)者的噩夢(mèng),因?yàn)樗麄兿氚衍浖u(mài)到國(guó)外。于是,他們提出了一個(gè)“內(nèi)碼表”的概念,可以切換到相應(yīng)語(yǔ)言的一個(gè)內(nèi)碼表,這樣才能顯示相應(yīng)語(yǔ)言的字母。在這種情況下,如果使用多語(yǔ)種,那么就需要頻繁的在內(nèi)碼表內(nèi)進(jìn)行切換。
Unicode
最終,美國(guó)人意識(shí)到他們應(yīng)該提出一種標(biāo)準(zhǔn)方案來(lái)展示世界上所有語(yǔ)言中的所有字符,出于這個(gè)目的,Unicode誕生了。
Unicode 當(dāng)然是一本很厚的字典,記錄著世界上所有字符對(duì)應(yīng)的一個(gè)數(shù)字。具體是怎樣的對(duì)應(yīng)關(guān)系,又或者說(shuō)是如何進(jìn)行劃分的,就不是我們考慮的問(wèn)題了,我們只用知道 Unicode 給所有的字符指定了一個(gè)數(shù)字用來(lái)表示該字符。
對(duì)于 Unicode 有一些誤解,它僅僅只是一個(gè)字符集,規(guī)定了符合對(duì)應(yīng)的二進(jìn)制代碼,至于這個(gè)二進(jìn)制代碼如何存儲(chǔ)則沒(méi)有任何規(guī)定。它的想法很簡(jiǎn)單,就是為每個(gè)字符規(guī)定一個(gè)
用來(lái)表示該字符的數(shù)字,僅此而已。
Unicode 編碼方案
之前提到,Unicode 沒(méi)有規(guī)定字符對(duì)應(yīng)的二進(jìn)制碼如何存儲(chǔ)。以漢字“漢”為例,它的 Unicode 碼點(diǎn)是 0x6c49,對(duì)應(yīng)的二進(jìn)制數(shù)是 110110001001001,二進(jìn)制數(shù)有 15 位,這也就說(shuō)明了它至少需要 2 個(gè)字節(jié)來(lái)表示??梢韵胂螅?Unicode 字典中往后的字符可能就需要 3 個(gè)字節(jié)或者 4 個(gè)字節(jié),甚至更多字節(jié)來(lái)表示了。
這就導(dǎo)致了一些問(wèn)題,計(jì)算機(jī)怎么知道你這個(gè) 2 個(gè)字節(jié)表示的是一個(gè)字符,而不是分別表示兩個(gè)字符呢?這里我們可能會(huì)想到,那就取個(gè)最大的,假如 Unicode 中最大的字符用 4 字節(jié)就可以表示了,那么我們就將所有的字符都用 4 個(gè)字節(jié)來(lái)表示,不夠的就往前面補(bǔ) 0。這樣確實(shí)可以解決編碼問(wèn)題,但是卻造成了空間的極大浪費(fèi),如果是一個(gè)英文文檔,那文件大小就大出了 3 倍,這顯然是無(wú)法接受的。
于是,為了較好的解決 Unicode 的編碼問(wèn)題, UTF-8 和 UTF-16 兩種當(dāng)前比較流行的編碼方式誕生了。當(dāng)然還有一個(gè) UTF-32 的編碼方式,也就是上述那種定長(zhǎng)編碼,字符統(tǒng)一使用 4 個(gè)字節(jié),雖然看似方便,但是卻不如另外兩種編碼方式使用廣泛。
UTF-8
UTF-8 是一個(gè)非常驚艷的編碼方式,漂亮的實(shí)現(xiàn)了對(duì) ASCII 碼的向后兼容,以保證 Unicode 可以被大眾接受。
UTF-8 是目前互聯(lián)網(wǎng)上使用最廣泛的一種 Unicode 編碼方式,它的最大特點(diǎn)就是可變長(zhǎng)。它可以使用 1 - 4 個(gè)字節(jié)表示一個(gè)字符,根據(jù)字符的不同變換長(zhǎng)度。編碼規(guī)則如下:
對(duì)于單個(gè)字節(jié)的字符,第一位設(shè)為 0,后面的 7 位對(duì)應(yīng)這個(gè)字符的 Unicode 碼點(diǎn)。因此,對(duì)于英文中的 0 - 127 號(hào)字符,與 ASCII 碼完全相同。這意味著 ASCII 碼那個(gè)年代的文檔用 UTF-8 編碼打開(kāi)完全沒(méi)有問(wèn)題。
對(duì)于需要使用 N 個(gè)字節(jié)來(lái)表示的字符(N > 1),第一個(gè)字節(jié)的前 N 位都設(shè)為 1,第 N + 1 位設(shè)為0,剩余的 N - 1 個(gè)字節(jié)的前兩位都設(shè)位 10,剩下的二進(jìn)制位則使用這個(gè)字符的 Unicode 碼點(diǎn)來(lái)填充。
編碼規(guī)則如下:
| Unicode 十六進(jìn)制碼點(diǎn)范圍 | UTF-8 二進(jìn)制 |
|---|---|
| 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 |
根據(jù)上面編碼規(guī)則對(duì)照表,進(jìn)行 UTF-8 編碼和解碼就簡(jiǎn)單多了。下面以漢字“漢”為利,具體說(shuō)明如何進(jìn)行 UTF-8 編碼和解碼。
“漢”的 Unicode 碼點(diǎn)是 0x6c49(110 1100 0100 1001),通過(guò)上面的對(duì)照表可以發(fā)現(xiàn),0x0000 6c49 位于第三行的范圍,那么得出其格式為 1110xxxx 10xxxxxx 10xxxxxx。接著,從“漢”的二進(jìn)制數(shù)最后一位開(kāi)始,從后向前依次填充對(duì)應(yīng)格式中的 x,多出的 x 用 0 補(bǔ)上。這樣,就得到了“漢”的 UTF-8 編碼為 11100110 10110001 10001001,轉(zhuǎn)換成十六進(jìn)制就是 0xE6 0xB7 0x89。
解碼的過(guò)程也十分簡(jiǎn)單:如果一個(gè)字節(jié)的第一位是 0 ,則說(shuō)明這個(gè)字節(jié)對(duì)應(yīng)一個(gè)字符;如果一個(gè)字節(jié)的第一位1,那么連續(xù)有多少個(gè) 1,就表示該字符占用多少個(gè)字節(jié)。
UTF-16
在了解 UTF-16 編碼方式之前,先了解一下另外一個(gè)概念——"平面"。
在上面的介紹中,提到了 Unicode 是一本很厚的字典,她將全世界所有的字符定義在一個(gè)集合里。這么多的字符不是一次性定義的,而是分區(qū)定義。每個(gè)區(qū)可以存放 65536 個(gè)($2^{16}$)字符,稱為一個(gè)平面(plane)。目前,一共有 17 個(gè)($2^{5}$)平面,也就是說(shuō),整個(gè) Unicode 字符集的大小現(xiàn)在是 $2^{21}$。
最前面的 65536 個(gè)字符位,稱為基本平面(簡(jiǎn)稱 BMP ),它的碼點(diǎn)范圍是從 0 到 $2^{16}-1$,寫(xiě)成 16 進(jìn)制就是從 U+0000 到 U+FFFF。所有最常見(jiàn)的字符都放在這個(gè)平面,這是 Unicode 最先定義和公布的一個(gè)平面。剩下的字符都放在輔助平面(簡(jiǎn)稱 SMP ),碼點(diǎn)范圍從 U+010000 到 U+10FFFF。
基本了解了平面的概念后,再說(shuō)回到 UTF-16。UTF-16 編碼介于 UTF-32 與 UTF-8 之間,同時(shí)結(jié)合了定長(zhǎng)和變長(zhǎng)兩種編碼方法的特點(diǎn)。它的編碼規(guī)則很簡(jiǎn)單:基本平面的字符占用 2 個(gè)字節(jié),輔助平面的字符占用 4 個(gè)字節(jié)。也就是說(shuō),UTF-16 的編碼長(zhǎng)度要么是 2 個(gè)字節(jié)(U+0000 到 U+FFFF),要么是 4 個(gè)字節(jié)(U+010000 到 U+10FFFF)。那么問(wèn)題來(lái)了,當(dāng)我們遇到兩個(gè)字節(jié)時(shí),到底是把這兩個(gè)字節(jié)當(dāng)作一個(gè)字符還是與后面的兩個(gè)字節(jié)一起當(dāng)作一個(gè)字符呢?
這里有一個(gè)很巧妙的地方,在基本平面內(nèi),從 U+D800 到 U+DFFF 是一個(gè)空段,即這些碼點(diǎn)不對(duì)應(yīng)任何字符。因此,這個(gè)空段可以用來(lái)映射輔助平面的字符。
輔助平面的字符位共有 $2^{20}$ 個(gè),因此表示這些字符至少需要 20 個(gè)二進(jìn)制位。UTF-16 將這 20 個(gè)二進(jìn)制位分成兩半,前 10 位映射在 U+D800 到 U+DBFF,稱為高位(H),后 10 位映射在 U+DC00 到 U+DFFF,稱為低位(L)。這意味著,一個(gè)輔助平面的字符,被拆成兩個(gè)基本平面的字符表示。
因此,當(dāng)我們遇到兩個(gè)字節(jié),發(fā)現(xiàn)它的碼點(diǎn)在 U+D800 到 U+DBFF 之間,就可以斷定,緊跟在后面的兩個(gè)字節(jié)的碼點(diǎn),應(yīng)該在 U+DC00 到 U+DFFF 之間,這四個(gè)字節(jié)必須放在一起解讀。
接下來(lái),以漢字"??"為例,說(shuō)明 UTF-16 編碼方式是如何工作的。
漢字"??"的 Unicode 碼點(diǎn)為 0x20BB7,該碼點(diǎn)顯然超出了基本平面的范圍(0x0000 - 0xFFFF),因此需要使用四個(gè)字節(jié)表示。首先用 0x20BB7 - 0x10000 計(jì)算出超出的部分,然后將其用 20 個(gè)二進(jìn)制位表示(不足前面補(bǔ) 0 ),結(jié)果為0001000010 1110110111。接著,將前 10 位映射到 U+D800 到 U+DBFF 之間,后 10 位映射到 U+DC00 到 U+DFFF 即可。U+D800 對(duì)應(yīng)的二進(jìn)制數(shù)為 1101100000000000,直接填充后面的 10 個(gè)二進(jìn)制位即可,得到 1101100001000010,轉(zhuǎn)成 16 進(jìn)制數(shù)則為 0xD842。同理可得,低位為 0xDFB7。因此得出漢字"??"的 UTF-16 編碼為 0xD842 0xDFB7。
Unicode3.0 中給出了輔助平面字符的轉(zhuǎn)換公式:
H = Math.floor((c-0x10000) / 0x400)+0xD800
L = (c - 0x10000) % 0x400 + 0xDC00
根據(jù)編碼公式,可以很方便的計(jì)算出字符的 UTF-16 編碼。