1. 程式人生 > >所謂編碼--泛談ASCII、Unicode、UTF8、UTF16、UCS-2等編碼格式

所謂編碼--泛談ASCII、Unicode、UTF8、UTF16、UCS-2等編碼格式

dia 做了 enter log 一起 數量 字符編碼 bmp src

  最近在看nodejs的源碼,看到stream的實現裏面滿地都是encoding,不由想起以前看過的一篇文章——在前面的隨筆裏面有提到過——阮一峰老師的《字符編碼筆記:ASCII,Unicode和UTF-8》。

  好的文章有一個好處,你每次看都會有新的收獲,它就像一款拼圖,你每次看都能收獲幾塊碎片,補齊之前的認識;而好文章與拼圖不一樣的是,好文章是一塊無垠的世界,當你不願局限於當前的眼界的時候,你可以主動走出去,外面要更寬廣、更精彩的多。

  閑話說到這,開始聊聊所謂的編碼。

  大家都知道,計算機只認識0和1,不認識什麽abc。如果想讓計算機顯示abc,那麽就要有一張1對1的表,用這張表告訴計算機,什麽樣的二進制串——比如(010101011100)——代表的是a,什麽樣的串代表的是b,這個表描述二進制串

符號的對應關系。ASCII就是這麽一張最早也是最簡單的表,這張表簡單到包含128個符號,比如10個數字、26個小寫字母、26個大寫字母,和一堆標點符號(如英文句號、逗號等)還有控制字符(如回車、tab等)。那為什麽是128,不是100或182?因為128正好用7個bit(一個0或者1為一個bit)表示。那麽為什麽不是256對或者更多?因為對於英語地區128個符號夠用了,而在ASCII推出的那個年代(1967),大家還沒開始著眼全球——這點從名字就可以看出來,ASCII = American Standard Code for Information Interchange(美國信息交換標準碼),根本就沒打算讓別人上車。

  然後其他語言地區很快入場了,而且隨著八位機的普及,1字節=8bit成為了共識,大家都盯上了多出來的128個位置。這個時代群魔亂舞,基本是個公司就想染指這塊標準,亂象直到1985年才通過ISO/IEC 8859把EASCII確定下來。

  而在這個過程中,有一個地區的人根本就不跟你玩,就是意表文字地區,明白的說,主要是中日韓。開玩笑,256個位置,你全讓出來都不夠我做兩句詩。老司機不帶怎麽辦,只能自己開車了,首先是日本站出來,於1978年出臺了最早的漢字編碼,然後中國大陸、中國臺灣、韓國都在80年代出臺了自己的漢字編碼。這個時候,大家各自玩各自的沒什麽問題,聚在一起,問題就來了。比如一篇文章中包含中日韓三種文字,一串01的組合在中國的編碼對應的是某個字,在日本的編碼對應的卻是另一個字,那計算機最後到底顯示哪個字,計算機也很為難。有沖突怎麽辦,開個會通通氣吧,於是大家坐在一起成立了個組織,叫CJK-JRG(China, Japan, Korea Joint Research Group)。雖然這個組織折騰了很多年,而且最終提案也被否決了,但是為另一個方案提供了足夠的信息,就是Unicode。

  Unicode項目於1987年啟動,在吸收了CJK-JRG的方案後於1992年6月份發布1.0.1版(之前的1.0.0沒有包含漢字),迄今為止還在增修,最新的版本是2017.6.20公布的10.0.0。最早的Unicode被設計為16bit,即每個符號占2byte,最多表示65536個符號。而後隨著內容的增加,又基於原有設計不變的原則,將最早的65536個字符集合稱為基本多文種平面(Basic Multilingual Plane, BMP),並添加16個輔助平面(總共支持65536 * 17 = 1114112個符號)。這樣一來,原來的16bit就不夠用了,需要21bit才能準確描述一個符號,相當於3byte不到,但是為了以後擴展方便及統一,輔助平面的符號要求使用4byte描述。

  Unicode解決了全世界人民用一套符號編碼的問題,但卻沒有解決另一個問題,就是怎麽存儲的問題。按照一般的想法,所有的符號都必須以最長的Unicode符號的標準來存儲,也就是4byte,這樣才不會有信息丟失。但是這樣的話,對於全部是英文的文檔,要浪費掉3/4的區域,對於大多數漢字,即BMP中的漢字,也要浪費掉1/2的區域。所以野蠻的使用4byte進行存儲是不可取的,那麽就要設計一套變長的規則來處理不同類型的符號,這時候UTF8、UTF16等就應運而生了,也就是說UTF8、UTF16是Unicode的一種實現方案(標準的說法,是Unicode字符編碼五層模型的第三層,如果你對五層模型感興趣,跳轉《刨根究底字符編碼》)。

  先說UTF8,UTF8是完全變長的,占用1-6byte,乍一看,怎麽比直接用4byte存儲還多出一半呢?其實占用4byte的情況是很少的,少到在幾乎可以忽略不計,而5、6byte基於當前16個輔助平面的情況下還用不上。一般來說,英文占用1byte,中文占用3byte(CJK-JRG最早提供給Unicode的20000多個符號位於位於U+4E00–U+9FFF,這塊區域的符號統一都占3byte),所以一般來說使用UTF8可以節省1/4到3/4的存儲區域。這樣似乎解決了存儲的問題,但卻帶來了另一個問題,即識別的問題。比如我給你3byte的二進制信息,告訴你這代表了一個字,那你肯定很快能知道是什麽字,但我如果不告訴你字數呢,是一個字,兩個字,還是三個字?你根本識別不出來這一串二進制是什麽。這就是變長的方案需要解決的第二個問題,告訴讀取方哪幾個byte是一組的,UTF8的規則很簡單,我直接從阮老師的博客裏搬運過來。

1)對於單字節的符號,字節的第一位設為0,後面7位為這個符號的unicode碼。因此對於英語字母,UTF-8編碼和ASCII碼是相同的。
2)對於n字節的符號(n>1),第一個字節的前n位都設為1,第n+1位設為0,後面字節的前兩位一律設為10。剩下的沒有提及的二進制位,全部為這個符號的unicode碼。

Unicode符號範圍 UTF-8編碼方式
(十六進制) (二進制)
0000 0000-0000 007F 0xxxxxxx
0000 0080-0000 07FF 110xxxxx 10xxxxxx
0000 0800-0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-001F FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
0020 0000-03FF FFFF 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
0400 0000-7FFF FFFF 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

  簡單的說,你收到很多個byte的二進制,從第一個byte開始讀,數第一個0出現之前的1,有幾個1就代表前面幾個byte是一組的,0個1就代表當前的這個byte孤家寡人一個。然後跳過這個組的所有byte,繼續之前數1的環節。分好組後,按組找到上表右邊的規則,把規則內x的位置保留下來,01的位置全部扔掉(01代表的位置是UTF8的元數據,x代表的位置才是Unicode的數據),拼成新的二進制串,這個串就是Unicode了。舉個栗子:

11100110 10001000 10010001 11100110 10011000 10101111 01110100 01100001 01110010 01101111 01101100

  以上有11個byte,我們從第一個byte11100110開始,數第一個0前面的1的數量,有3個1,代表3個byte是一組的。然後我們跳過這3個,第四個byte是11100110,繼續數1得出有3個1,然後又給這3個byte分組。跳過這三個,到了第7個byte,這往後的5個byte都是以0開頭,說明每個byte為1組。現在我們分好組了,有7個組,分別是

[11100110, 10001000, 10010001], [11100110, 10011000, 10101111], [01110100], [01100001], [01110010], [01101111], [01101100]

  現在我們按組找到表右對應的行,第一組、第二組對應第五行,其他組對應第三行,我們把行內x對應的位置保留,10的位置刪除,得到新的數組

[0110, 001000, 010001], [0110, 011000, 101111], [1110100], [1100001], [1110010], [1101111], [1101100]

  然後把組內的二進制串起來得到Unicode

[0110001000010001], [0110011000101111], [1110100], [1110100], [1100001], [1110010], [1101111], [1101100]

  這時候我們再按byte進行拆分以便閱讀,並且在高位補0

[01100010 00010001], [01100110 00101111], [01110100], [01110100], [01100001], [01110010], [01101111], [01101100]

  再轉換成16進制

[62 11], [66 2F], [74], [61], [72], [6F], [6C]

  這時候我們打開F12,在控制臺輸入對應的Unicode(語法要求必須使用4位16進制數字)

‘\u6211\u662f\u0074\u0061\u0072\u006f\u006c‘

  得到了對應的字符串“我是tarol”。

  好了,以上是UTF8的內容,之所以叫UTF8是因為這個規則下的符號,最少占8bit。那麽UTF16就好理解了,在這個規則下,每個符號最少占16bit。UTF16的規則說起來更簡單,當符號位於BMP中時,占用2byte,在符號位於輔助平面時,占用4byte。那既然是變長的,又碰到了上面的問題,怎麽識別這一塊是4byte為一組還是2byte為一組?

  這裏就要提到Unicode的保留區塊了,Unicode規定從U+D800到U+DFFF之間是永久保留不賦予任何符號的。也就是正常情況下,2byte如果落在這個範圍內,那麽就是Unicode的非法字節。而UTF16的做法就是把輔助平面的Unicode碼進行處理,變成4個字節,並且兩兩落在非法區域內,讀取方讀到了非法字節,就可以界定這裏是4byte為一組,不然就是2byte為一組。那麽UTF16這個轉換的算法又是怎樣的呢?

  1. 首先按現在17個平面的限制,輔助平面的碼位是U+10000到U+10FFFF,我們得到了一個輔助平面的Unicode碼時,先減去BMP的碼數0x10000,得到的數介於0到0xFFFFF之間,最多用20bit表示
  2. 然後我們把20bit從中間隔開,分為高位的10bit和低位的10bit
  3. 我們知道10bit的取值範圍是0到0x3FF,高位的10bit加上固定值0xD800,得到的值叫做前導代理(lead surrogate),範圍是0xD800到0xDBFF
  4. 低位的10bit加上固定值0xDC00,得到的值叫做後尾代理(tail surrogate),範圍是0xDC00到0xDFFF。這樣一來,不僅高位和低位都落在了保留區塊內,而且彼此還做了區分。

  還是舉個例子。

  ??,這個字是個異體字,通“碎”,位於輔助平面,Unicode碼位是U+24B62,我們來算一下它的UTF16編碼結果

  1. 首先0x24B62減去10000得到0x14B62,根據這5個byte得到20bit,0001 0100 1011 0110 0010
  2. 然後分成高位的10bit(0001010010)和低位的10bit(1101100010)
  3. 高位+0xD800得到(1101 1000 0101 0010)
  4. 低位+0xDC00得到(1101 1111 0110 0010)
  5. 轉換為16進制就是0xD852和0xDF62,這就是??的UTF16表示。

  然後我們驗證一下答案,照常打開控制臺,鍵入‘??‘.charCodeAt(0).toString(16)得到’d852‘,鍵入‘??‘.charCodeAt(1).toString(16)得到’df62‘,驗證成功!而且這裏還透露了一個細節,ES規定string是經過UTF16編碼的(ES5標準文檔)。

  UTF16的事還沒完,如果用過nodejs裏面的string_decoder接口的人肯定註意到了,其中對UTF16編碼的支持叫utf16le,這個le是什麽?其實這個是Little Endian的簡稱,對應的是Big Endian。我們之前舉的例子就是Big Endian,Little Endian不同在於每個2byte組裏面的順序是反過來的,即上面的0xD852和0xDF62改成0x52D8和0x62DF就是??的utf16le編碼了。至於為什麽會有這麽蛋疼的區分,那是操作系統的遺留問題,就像window的CRLF和unix的LF一樣。

  UTF16告一段落了,新問題又來了。我們有UTF8、UTF16LE、UTF16BE這麽多種編碼,那一串二進制流過來我們用哪種編碼方式去解析呢?尤其是UTF16LE和UTF16BE,它們大部分規則是一樣的,只是反過來了罷了。這裏就要提到文件的一個元數據叫BOM(byte-order mark)了,BOM位於文件二進制流的最前方,標識當前文件的編碼格式。UTF16LE的BOM為FF FE,UTF16BE的BOM為FE FF,UTF8的BOM為EF BB BF,但是一般不建議UTF8文件帶BOM。舉個栗子,如果文件內容只有’0‘(十六進制編碼為30),那麽三種編碼方式生成的文件的十六進制編碼分別為

編碼 十六進制內容
UTF8 EF BB BF 30
UTF16BE FE FF 00 30
UTF16LE FF FE 00 30

  從上面的例子也可以看到,UTF16最大的問題在於:哪怕是ASCII標準字符0,也占用了2byte。這不僅僅浪費了存儲空間,關鍵在於UTF16和ASCII不兼容,比如我新建一個文件,內容為1234567890,使用系統自帶的記事本打開,再另存為Unicode編碼(就是UTF16LE)

技術分享技術分享

  然後再選擇打開,選擇當前文件,再ANSI編碼

技術分享技術分享

  你會看到這個樣子的內容

 技術分享

  可以發現,不僅信息錯亂了,每個數字之間還有空格。

  本來這篇文章到此就結束了,直到我在nodepad++裏面看到了這個

技術分享

  UCS-2是什麽鬼,好像在哪裏見過?瞬間腦子裏像偵探回憶線索般閃過畫面,後來整理發現這就是UTF16的low版。為什麽說是low版,因為USC-2是定長的,是不支持輔助平面的UTF16。我們驗證下,把‘??’復制到用notepad++打開的以UCS-2編碼的文件裏,沒看出有什麽問題,這時候關閉notepad++再打開。

技術分享

  可以看到,‘??’字碎的只剩渣了。

所謂編碼--泛談ASCII、Unicode、UTF8、UTF16、UCS-2等編碼格式