博客 / 詳情

返回

詳解字符編碼與 Unicode

人類交流使用 ABC 等字符,但計算機只認識 01。因此,就需要將人類的字符,轉換成計算機認識的二進制編碼。這個過程就是字符編碼。

ASCII

最簡單、常用的字符編碼就是 ASCII(American Standard Code for Information Interchange,美國信息交換標準代碼),它將美國人最常用的 26 個英文字符的大小寫和常用的標點符號,編碼成 0127 的數字。例如 A 映射成 65 (0x41),這樣計算機中就可以用 0100 0001 這組二進制數據,來表示字母 A 了。

ASCII 編碼的字符可以分成兩類:

  • 控制字符:0 - 31127 (0x00 - 0x1F0x7F)
  • 可顯示字符:32 - 126 (0x20 - 0x7E)

具體字符表可以參考:ASCII - 維基百科,自由的百科全書。

Unicode

ASCII 只編碼了美國常用的 128 個字符。顯然不足以滿足世界上這麼多國家、這麼多語言的字符使用。於是各個國家和地區,就都開始對自己需要的字符設計其他編碼方案。例如,中國有自己的 GB2312,不夠用了之後又擴展了 GBK,還是不夠用,又有了 GB18030。歐洲有一系列的 ISO-8859 編碼。這樣各國人民就都可以在計算機上處理自己的語言文字了。

但每種編碼方案,都只考慮了自己用到的字符,沒辦法跨服交流。如果一篇文檔裏,同時使用了多種語言的字符,總不能分別指定哪個字符使用了那種編碼方式。

如果能統一給世界上的所有字符分配編碼,就可以解決跨服交流的問題了,Unicode 就是來幹這個事情的。

Unicode 統一編碼了世界上大部分的字符,例如將 A 編碼成 0x00A1,將 編碼成 0x4E2D,將 α 編碼成 0x03B1。這樣,中國人、美國人、歐洲人,就可以使用同一種編碼方式交流了。

一個 Unicode 字符可以使用 U+ 和 4 到 6 個十六進制數字來表示。例如 U+0041 表示字符 AU+4E2D 表示字符 U+03B1 表示字符 α

Unicode 最初編碼的範圍是 0x00000xFFFF,也就是兩個字節,最多 65536 (2^16) 個字符。但隨着編碼的字符越來越多,兩個字節的編碼空間已經不夠用,因此又引入了 16 個輔助平面,每個輔助平面同樣最多包含 65536 個字符。原來的編碼範圍稱為基本平面,也叫第 0 平面。

各平面的字符範圍和名稱如下表:

平面 字符範圍 名稱
0 號平面 U+0000 - U+FFFF 基本多文種平面 (Basic Multilingual Plane, BMP)
1 號平面 U+10000 - U+1FFFF 多文種補充平面 (Supplementary Multilingual Plane, SMP)
2 號平面 U+20000 - U+2FFFF 表意文字補充平面 (Supplementary Ideographic Plane, SIP)
3 號平面 U+30000 - U+3FFFF 表意文字第三平面 (Tertiary Ideographic Plane, TIP)
14 號平面 U+E0000 - U+EFFFF 特別用途補充平面
15 號平面 U+F0000 - U+FFFFF 保留作為私人使用區(A 區)(Private Use Area-A, PUA-A)
16 號平面 U+100000 - U+10FFFF 保留作為私人使用區(B 區)(Private Use Area-B, PUA-B)

每個平面內還會進一步劃分成不同的區段。每個平面和區段具體説明參考 Unicode字符平面映射 - 維基百科,自由的百科全書;漢字相關的區段説明參考 中日韓統一表意文字 - 維基百科,自由的百科全書。Unicode 所有字符按平面和區段查找,可以參考 Roadmaps to Unicode;按區域和語言查找可以參考 Unicode Character Code Charts。

字符編碼的基本概念

“字符編碼”是一個模糊、籠統的概念,為了進一步説明字符編碼的過程,需要將其拆解為一些更加明確的概念:

字符 (Character)

人類使用的字符。例如:

  • A
  • 等。

編碼字符集 (Coded Character Set, CCS)

把一些字符的集合 (Character Set) 中的每個字符 (Character),映射成一個編號或座標。例如:

  • 在 ASCII 中,把 A 編號為 65 (0x41);
  • 在 Unicode 中,把 編號為 0x4E2D
  • 在 GB2312 中,把 映射到第 54 區第 0 位。

這個映射的編號或座標,叫做 Code Point。

Unicode 就是一個 CCS。

字符編碼表 (Character Encoding Form, CEF)

把 Code Point 轉換成特定長度的整型值的序列。這個特定長度的整型值叫做 Code Unit。例如:

  • 在 ASCII 中,0x41 這個 Code Point 會被轉換成 0x41 這個 Code Unit;
  • 在 UTF-8 中,0x4E2D 這個 Code Point 會被轉換成 0xE4 B8 AD 這三個 Code Unit 的序列。

我們常用的 UTF-8、UTF-16 等,就是 CEF。

字符編碼方案 (Character Encoding Scheme, CES)

把 Code Unit 序列轉換成字節序列(也就是最終編碼後的二進制數據,供計算機使用)。例如 :

  • 0x0041 這個 Code Unit,使用大端序會轉換成 0x00 41 兩個字節;
  • 使用小端序會轉換成 0x41 00 兩個字節。

UTF-16 BE、UTF-32 LE 等,就是 CES。


這些概念間的關係如下:

因此,我們説 ASCII 是“字符編碼”時,“字符編碼”指的是上面從 Character 到字節數組的整個過程。因為 ASCII 足夠簡單,中間的 Code Point 到 Code Unit,再到字節數組,都是一樣的,沒必要拆開説。

而我們説 Unicode 是“字符編碼”時,“字符編碼”其實指的僅是上面的 CCS 部分。

同理,ASCII、Unicode、UTF-8、UTF-16、UTF-16 LE,都可以籠統的叫做“字符編碼”,但每個“字符編碼”表示的含義都是不同的。可能是 CCS、CEF、CES,也可能是整個過程。

Unicode 轉換格式

Unicode 只是把字符映射成了 Code Point (字符編碼表,CCS)。將 Code Point 轉換成 Code Unit 序列(字符編碼表,CEF),再最終將 Code Unit 序列轉換成字節序列(字符編碼方案,CES),有多種不同的實現方式。這些實現方式叫做 Unicode 轉換格式 (Unicode Transformation Format, UTF)。主要包括:

  • UTF-32
  • UTF-16
  • UTF-8

UTF-32

UTF-32 將每個 Unicode Code Point 轉換成 1 個 32 位長的 Code Unit。

UTF-32 是固定長度的編碼方案,每個 Code Unit 的值就是其 Code Point 的值。例如 0x00 00 00 41 這個 Code Unit,就表示了 0x0041 這個 Code Point。

UTF-32 的一個 Code Unit,需要轉換成 4 個字節的序列。因此,有大端序 (UTF-32 BE) 和小端序 (UTF-32 LE) 兩種轉換方式。

例如 0x00 00 00 41 這個 Code Unit,使用 UTF-32 BE 最終會編碼為 0x00 00 00 41;使用 UTF-32 LE 最終會編碼為 0x41 00 00 00

UTF-16

UTF-16 將每個 Unicode Code Point 轉換成 1 到 2 個 16 位長的 Code Unit。

對於基本平面的 Code Point(0x00000xFFFF),每個 Code Point 轉換成 1 個 Code Unit,Code Unit 的值就是其對應 Code Point 的值。例如 0x0041 這個 Code Unit,就表示了 0x0041 這個 Code Point。

對於輔助平面的 Code Point(0x0100000x10FFFF),每個 Code Point 轉換成 2 個 Code Unit 的序列。如果還是直接使用 Code Point 數值轉換成 Code Unit,就有可能和基本平面的編碼重疊。例如 U+010041 如果轉換成 0x00010x0041 這兩個 Code Unit,解碼的時候沒辦法知道這是 U+010041 一個字符,還是 U+0001U+0041 兩個字符。

為了讓輔助平面編碼的兩個 Code Unit,都不與基本平面編碼的 Code Unit 重疊,就需要利用基本平面中一個特殊的區段了。基本平面中規定了從 0xD8000xDFFF 之間的區段,是永久保留不映射任何字符的。UTF-16 將輔助平面的 Code Point,編碼成一對在這個範圍內的 Code Unit,叫做代理對。這樣解碼的時候,如果解析到某個 Code Unit 在 0xD8000xDFFF 範圍內,就知道他不是基本平面的 Code Unit,而是要兩個 Code Unit 組合在一起去表示 Code Point。

具體轉換方式是:

  1. 將輔助平面的 Code Point 的值 (0x010000 - 0x10FFFF),減去 0x010000,得到 0x000000xFFFFF 範圍內的一個數值,也就是最多 20 個比特位的數值
  2. 將前 10 位的值(範圍在 0x00000x03FF),加上 0xD800,得到範圍在 0xD8000xDBFF 的一個值,作為第一個 Code Unit,稱作高位代理或前導代理
  3. 將後 10 位的值(範圍在 0x00000x03FF),加上 0xDC00,得到範圍在 0xDC000xDFFF 的一個只,作為第二個 Code Unit,稱作低位代理或後尾代理

基本平面中的 0xD800 - 0xDBFF0xDC00 - 0xDFFF 這兩個區段,也分別叫做 UTF-16 高半區 (High-half zone of UTF-16) 和 UTF-16 低半區 (Low-half zone of UTF-16)。

UTF-16 的一個 Code Unit,需要轉換成 2 個字節的序列。因此,有大端序 (UTF-16 BE) 和小端序 (UTF-16 LE) 兩種轉換方式。

例如 0x0041 這個 Code Unit,使用 UTF-16 BE 最終會編碼為 0x0041;使用 UTF-16 LE 最終會編碼為 0x4100

UTF-8

UTF-8 將每個 Unicode Code Point 轉換成 1 到 4 個 8 位長的 Code Unit。

UTF-8 是不定長的編碼方案,使用前綴來標識 Code Unit 序列的長度。解碼時,根據前綴,就知道該將哪幾個 Code Unit 組合在一起解析成一個 Code Point 了。

具體編碼方式是:

Code Point 範圍 Code Unit 個數 每個 Code Unit 前綴 示例 Code Point 示例 Code Unit 序列
7 位以內 (0 - 0xEF) 1 0b0 0b0zzz zzzz 0b0zzz zzzz
8 到 11 位 (0x80 - 0x07FF) 2 第一個 0b110,剩下的 0b10 0b0yyy yyzz zzzz 0b110y yyyy 10zz zzzz
12 到 16 位 (0x0800 - 0xFFFF) 3 第一個 0b1110,剩下的 0b10 0bxxxx yyyy yyzz zzzz 0b1110 xxxx 10yy yyyy 10zz zzzz
17 到 21 位 (0x10000 - 10FFFF) 4 第一個 0b11110,剩下的 0b10 0b000w wwxx xxxx yyyy yyzz zzzz 0b1111 0www 10xx xxxx 10yy yyyy 10zz zzzz

解碼時,拿到每個 Code Unit 的前綴,就知道這是對應第幾個 Code Unit:

  • 前綴是 0b0,説明這個 Code Point 是一個 Code Unit 組成
  • 前綴是 0b110,説明這個 Code Point 是兩個 Code Unit 組成,後面還會有 1 個 0b10 前綴的 Code Unit
  • 前綴是 0b1110,説明這個 Code Point 是三個 Code Unit 組成,後面還會有 2 個 0b10 前綴的 Code Unit
  • 前綴是 0b11110,説明這個 Code Point 是四個 Code Unit 組成,後面還會有 3 個 0b10 前綴的 Code Unit

UTF-8 的一個 Code Unit,剛好轉換成 1 個字節,因此不需要考慮字節序。

參考上表,對於 ASCII 範圍內的字符,使用 ASCII 和 UTF-8 編碼的結果是一樣的。所以 UTF-8 是 ASCII 的超集,使用 ASCII 編碼的字節流也可以使用 UTF-8 解碼。

UTF-8 與 UTF-16 對比

Code Point 範圍 UTF-8 編碼長度 UTF-16 編碼長度
7 位以內 (0x00 - 0xEF) 1 2
8 到 11 位 (0x0080 - 0x07FF) 2 2
12 到 16 位 (0x0800 - 0xFFFF) 3 2
17 到 21 位 (0x10000 - 10FFFF) 4 4

可以看出只有在 0x000xEF 範圍的字符,UTF-8 編碼比 UTF-16 短;而在 0x0800 - 0xFFFF 範圍內,UTF-8 編碼是比 UTF-16 長的。

而中文主要在 0x4E000x9FFF,如果寫一篇文檔,全都是中文,一個英文字母和符號都沒有。那使用 UTF-8 編碼,可能比 UTF-16 編碼還要多佔用一半的空間。

字節順序標記

UTF-32 和 UTF-16 的一個 Code Unit,需要轉換成多個字節的序列,因此存在字節序的問題。

可以在 UTF-32 或 UTF-16 編碼的字節流開頭,添加字節順序標記 (byte-order mark, BOM),來標識字節序。

BOM 是 U+FEFF 字符的名稱。編碼時,將 U+FEFF 編碼在字節流的開頭。解碼時,讀取前幾個字節,就知道編碼時的字節序了。

例如 UTF-16 的大端序,U+FEFF 會被編碼成 0xFEFF,而小端序則會編碼成 0xFFFE。這樣根據開頭是 0xFEFF 還是 0xFFFE,就知道編碼時使用的大端序還是小端序了。

同理 UTF-32 的大端序,U+FEFF 會被編碼成 0x00 00 FE FF,而小端序則會編碼成 0xFF FE 00 00。這樣根據開頭,不光能區分出字節序,還能區分出是 UTF-32 還是 UTF-16。

UTF-8 的一個 Code Unit 只需要轉換為 1 個字節,因此不存在字節序的問題,也就不需要 BOM。而且 0xFEFF0xFFFE 字節序列,在 UTF-8 中都是不可能出現的。所以根據 BOM,也能區分出編碼方式是不是 UTF-8。

如果硬要給 UTF-8 加 BOM,那就是將 0xFEFF (0b1111 1110 1111 1111) 進行 UTF-8 編碼,得到 0xEF BB BF (0b1110 1111 1011 1011 1011 1111),放在字節流的最前面。

之所以使用 U+FEFF 這個字符來標識字節序,可能是因為這個字符本身就表示“零寬非斷空格”的含義。把他放在最前面,解碼的時候支持 BOM,就把他按照字節序去理解;不支持的就把他解析成一個“零寬非斷空格”,展示起來也沒有任何影響。當然這是我瞎猜的,而且從 Unicode 3.2 開始,U+FEFF 已經專門用來標記字節序,沒有其他含義了。

Unicode 標準化

Unicode 中有些特殊的字符,可以由其他不同的特殊字符組合出來。例如 ñ (U+00F1) 和 (U+006E U+0303)。這兩個字符在展現和含義上是完全等價的,但其編碼卻是不同的。為了對這種字符進行比較,就需要在比較前先進行標準化 (Normalization) 處理。

Unicode 定義了四種標準化形式 (Unicode Normalization Form):

分解 分解再重組
標準等價 NFD (Normalization Form Canonical Decomposition) NFC (Normalization Form Canonical Composition)
兼容等價 NFKD (Normalization Form Compatibility Decomposition) NFKC (Normalization Form Compatibility Composition)

説明:

  • 分解與重組:

    • 分解:就是把字符能拆的全拆開,例如:

      • ñ (U+00F1) 拆成 U+006E U+0303。
    • 重組:就是把拆開的字符能組的再全組起來,例如:

      • (U+006E U+0303) 組合成 U+00F1。
  • 標準與兼容:

    • 標準等價:就是隻有含義和長得完全相同的兩個字符才相等,例如:

      • ñ (U+00F1) 和 (U+006E U+0303) 可以相等;
      • (U+FB00) 和 ff (U+0066 U+0066) 不能相等。
    • 兼容等價:就是隻要長得差不多就可以相等了,標準等價的一定也是兼容等價的,例如:

      • (U+FB00) 和 ff (U+0066 U+0066) 也可以相等;
      • ñ (U+00F1) 和 (U+006E U+0303) 更是可以相等了。

示例:

説明 顯示 標準化形式 標準化後
分解與重組的區別 ñ NFD/NFKD U+006E U+0303
分解與重組的區別 NFC/NFKC U+00F1
標準與兼容的區別 NFD/NFC U+FB00
標準與兼容的區別 NFKD/NFKC U+0066 U+0066
標準與兼容的區別 ff NFD/NFC/NFKD/NFKC U+0066 U+0066

Unicode 與 UCS

通用字符集 (Universal Character Set, UCS) 和 Unicode 可以理解就是兩個組織乾的相同的事情,他們都想給世界上的所有字符統一編碼。現在他們也都相互兼容,就是説對於同一個字符,UCS 和 Unicode 都會把他們映射成同一個 Code Point,反過來也一樣。所以可以把他們當成是一回事。

有一些不同的地方,UCS 的編碼空間本來是 00x7F FF FF FF (32 位,第一位固定為 0)。但因為 UTF-16 代理對的實現方式,只能編碼到 0x10 FF FF 範圍。所以 UCS 標準也規定了只使用 0x10 FF FF 範圍內的編碼。

UCS-4 與 UCS,類似於 UTF-32 與 Unicode 的關係。因為 UCS 也規定了只使用 0x10 FF FF 範圍內的編碼,所以它兩實際就是一回事。

UCS-2 與 UCS,類似於 UTF-16 與 Unicode 的關係。但不同的是,UCS-2 是固定兩字節的,沒有考慮輔助平面。可以把 UCS-2 當做是不支持輔助平面的 UTF-16。

Unicode 與編程語言

編程語言中的 Unicode

因為 Unicode 可以給世界上大部分字符編碼,因此大部分編程語言內部,都是使用 Unicode 來處理字符的。例如在 Java 中定義一個字符 char c = '中',這個字符實際是使用兩個字節在內存中存儲着他的 UTF-16 編碼。所以如果將這個字符強轉成整型 int i = (int) c,得到的結果 20013 (0x4E2D),就是 在 Unicode 中的 Code Point 值。

這個説法不完全準確,因為大部分編程語言定義的時候,Unicode 還沒有輔助平面,所以一般都是固定的用兩個字節來存儲一個字符。

在有了輔助平面以後,輔助平面的字符,會被 UTF-16 編碼成兩個 Code Unit,需要 4 個字節來存儲。而編程語言為了兼容性,不太可能將原有的 char 類型長度改為 4 個字節。所以就有可能需要用兩個 char 來存儲一個實際的字符。而原有的獲取字符串長度的 API,實際獲取到的是字符串中 Code Unit 的個數,不是實際字符的個數。獲取某個位置字符的 API 也是同理,獲取到的可能是一對 Code Unit 中的一個。因此需要使用編程語言提供的新的 API 或者通過額外的代碼,來正確處理輔助平面的字符。

在編程語言中使用 Unicode

主要涉及以下操作:

這其中最關鍵的就是字符和 Code Point 之間的轉換。因為這裏涉及字符集的映射,如果編程語言不支持,我們就要自己外掛編碼表才能實現,否則無論如何都是沒辦法通過枚舉實現的。

而有了 Code Point 以後,根據 UTF 系列編碼的規則,我們自己也可以通過代碼來實現 Code Point 和字節序列的轉換。當然如果編程語言內置了相關的 API,那就更方便了。

這裏省略了 Code Unit 的概念,因為一般在代碼中,不會有這個中間過程,直接就編碼成字節序列了。

Java

char 和 String 中可以使用 \uXXXX 來表示一個 Unicode 字符。String 中可以使用兩個 \uXXXX 表示一個輔助平面的字符,但 char 中不行,因為一個輔助平面字符需要用兩個 char 存儲:

char c = '\u4E2D';
String s = "\uD840\uDC21";

String to Code Point count:

int count = "𠀡".codePointCount(0, "𠀡".length());

String/char to CodePoint:

int i1 = Character.codePointAt(new char[] {0xD840, 0xDC21}, 0);
int i2 = "𠀡".codePointAt(0);

Code Point to String/char:

String s = new String(new int[] {0x20021}, 0, 1);
char[] c = Character.toChars(0x20021);

String to byte array:

byte[] bytes = "𠀡".getBytes(StandardCharsets.UTF_8);

Byte array to String:

String s = new String(new byte[] {(byte) 0xF0, (byte) 0xA0, (byte) 0x80, (byte) 0xA1}, StandardCharsets.UTF_8);

Normalize:

String s = Normalizer.normalize("ñ", Normalizer.Form.NFD);

JavaScript

String 中可以使用 \uXXXX 來表示一個 Unicode 字符。對於輔助平面的字符,可以使用 \u{XXXXXX} 來表示:

'\u{20021}'

String to Code Point count:

Array.from('𠀡').length

String to Code Point:

'𠀡'.codePointAt(0).toString(16)

Code Point to String:

String.fromCodePoint(0x20021)

String to byte array:

new TextEncoder().encode('𠀡')

只支持 UTF-8,其他編碼方式需要自己寫代碼根據 Code Point 轉換。


Byte array to String:

new TextDecoder('utf-8').decode(new Uint8Array([0xF0, 0xA0, 0x80, 0xA1]))

Normalize:

'ñ'.normalize('NFD')

原文鏈接:詳解字符編碼與 Unicode
版權聲明:CC BY-NC-ND 4.0
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.