字符集編碼(二):字符編碼模型

上一篇《字符集編碼(上):Unicode 之前》我們講了 Unicode 之前的傳統字符集編碼標準產生的歷史背景,以及因存在多種編碼標準而帶來的混亂,這種局面強烈要求一種新的、統一的現代編碼標準的出現——這個統一的編碼標準就是 Unicode。

不過本篇我們先不講 Unicode,而是講講字符集編碼設計的理論框架:字符編碼模型

字符集編碼模型大體上分四層(也有說五層的,這裏只討論四層)。


第一層:抽象字符表 ACR(Abstract Character Repertoire)

它明確了該編碼標準可以對哪些字符進行編碼。

有些標準是封閉性的,即其能夠編碼的字符是固定的(比如 ASCII、ISO 8859 系列等);有些是開放性的,可以不斷地往裏面加入新字符(比如 Unicode)。

這裏強調了字符是抽象的,這一點和我們對“字符”的直覺理解不同。我們對“字符”的直覺上(視覺上)的理解其實是指字形

有些字符是不可見的,比如控制字符(如 ASCII 中的前 32 個字符)。

有些字形是由多個字符組合成的,比如西班牙語的 ñ 由 n 和 ~ 兩個字符組成(這一點上 Unicode 和傳統編碼標準不同,傳統編碼標準多是將 ñ 視作一個獨立的字符,而 Unicode 中將其視爲兩個字符的組合)。

抽象的另一層含義是,一個字符可能會有多種視覺上的字形表示。比如一個漢字有楷、行、草、隸等多種形體,阿拉伯字符根據其在文中出現的位置會表現爲不同的形態。字符集編碼將這些形態都視爲同一個字符(即字符集編碼是對字符而非字形編碼)。

image-20220222110131475

漢字“山”的不同形態


第二層:編碼字符集 CCS(Coded Character Set)

有了第一層的抽象字符集列表,這層我們給列表中的每個字符分配一個唯一的數字編碼(一般是非負整數)。這個數字編碼有個專門的名字叫碼點(Codepoint)。

注意這層我們沒有提計算機,所謂碼點是人類意義上的編號,就像你給面前的一堆水果,蘋果編 1 號,香蕉編 2 號一樣,還扯不到計算機那裏去。實踐中常用十六進制編號表示,而且一些單字節編碼標準的碼點和其在計算機中的字節值是一致的,所以人們常常把兩者混用。在一些多字節編碼標準中,碼點和計算機字節值不是一回事,比如 GB 2312,其碼點(區位碼)和計算機表示(內碼)是不同的。

雖然都是給字符編碼(編號),但不同標準的編碼方式是不同的。比如 GB 2312 是通過 94 × 94 的矩陣格子,也就是區位號的方式,而 Unicode 是通過加 1 的方式(從 0 開始往後依次 1,2,3,......)。

在這層,每個字符都分配了唯一的編碼。要小心地理解這句話,它有兩層含義:1. 抽象字符表中字符是唯一的;2. 編碼字符集中編碼是唯一的。然而,如果你看了 Unicode 的字符集,你可能覺得不是這麼回事。比如熱力學單位 K(開爾文)和拉丁字母 K 本質上是一個字符(在各個層面上長得都一模一樣,僅僅是含義不一樣),在 Unicode 中卻分配了兩個碼點——難道 Unicode 將這兩個 K 看作兩個不同的字符?

在 Unicode 中,字符並不是用圖形(字形)來表達的(因爲字符和字形是兩碼事,一個字符可能會有多種字形,用哪種來表示?),而是用字符名稱來表達的。在 Unicode 字符集中,字符名稱是唯一的(而且不可變),這些名稱分配的碼點也是唯一的。拉丁字母 K 在 Unicode 中的字符名稱是“Latin Capital Letter K”,碼點是 004B,開爾文 K 的名稱是“KELVIN SIGN”,碼點是 212A。

在人類意義上來說,誰都清楚這兩個字符其實就是同一個,難道 Unicode 是根據字符含義編碼的?不是的。因爲 Unicode 出現得比較晚,在這之前存在大量的傳統編碼標準,其中有些標準將這兩個 K 視爲不同的字符,Unicode 要兼容這些傳統編碼,也就只能違心地跟隨了(至於爲啥必須跟隨,後面有詳細說明)。


第三層:字符編碼形式 CEF(Character Encoding Form)

這層說的是碼點如何在計算機中表示。

第二層的碼點是人類意義上的編碼,但字符集編碼最終是要讓計算機來處理的,所以還必須把人類意義上的碼點翻譯成計算機意義上的表達形式。

這裏有兩步:

  1. 首先要定義計算機表達字符編碼的單位,術語叫編碼單元(Code Unit),簡稱碼元

  2. 然後定義表達規則,即如何用一個或多個碼元來表示碼點。

舉個快遞打包的例子:

假如快遞公司要運送一車雞蛋,他肯定不能像堆煤一樣把雞蛋堆在集裝箱裏。

首先他要準備若干大小相同的包裝箱,比如 50 * 50 * 40 的紙板箱。

然後他要確定如何將雞蛋放在這些紙板箱裏——一般是不能直接堆放的,要先將雞蛋放在蛋托里,然後將蛋託疊放入紙板箱裏。

由於一方面蛋託的大小是固定的,而且爲了裝箱便利,快遞公司決定所有的紙板箱都是一樣大的,所以即使最後只剩下一個雞蛋,也要裝入一個獨立的同樣大小的紙板箱裏(空餘部分用泡沫填充),而不能因爲雞蛋數量少就裝入一個小的比如 20 * 20 * 15 的紙板箱中。

如果將上面的一堆雞蛋比作待編碼字符的話,大小相同的包裝箱就是編碼單元(碼元),如何將雞蛋放入這些包裝箱中則是編碼規則。

所以這層說的是在計算機層面用多大的碼元(容器)以及用什麼樣的規則來表達第二層定義的(人類意義上的)碼點。

比如在上篇文章中我們提到 GB 2312,它本質上是第二層的標準,即它定義的是人類層面的碼點——區位碼。該區位碼如何在計算機中表示呢?現在使用最廣泛的編碼形式是 EUC-CN(比如微軟的 codepage 936 就是用該編碼形式編碼的),其碼元大小是 8 bit,GB 2312 使用該編碼形式編碼,簡單說就是在原始區碼和位碼基礎上加上十六進制 A0 得到內碼,然後放入兩個碼元中(詳情參見《字符集編碼(上):Unicode 之前》)。

這裏有個問題可能讓人迷惑:爲什麼非要定義個大小固定的碼元?比如 GB 2312 使用 EUC-CN 編碼方式時,爲什麼是用兩個 8 bit 的碼元而不是用一個 16 bit?它倆不是一個意思嗎?

上面快遞運輸的例子中,快遞公司之所以採用統一大小的包裝箱,一方面因爲蛋託大小是固定的,另一方面爲了裝箱的便利,所以如果最後多出一部分雞蛋,會單獨使用一個包裝箱,而不是和前面的一起使用一個更大一點的,也不是自己單獨使用一個更小一點的。

計算機也是如此。計算機爲了處理上的便利,會定義若干種數據處理單元。計算機在物理層面的處理單元是比特,在邏輯層面上,存儲和傳輸使用的單位是字節(byte,8 bit)——這也是我們最熟悉的單位;在 CPU 指令執行上,除了字節,還有字(word,16 bit)、雙字(double words,32 bit)、四字(quad words,64 bit)——這些就是 CPU 指令的處理單元。

C 語言裏面整型有 char、short、int、long 這些類型,它們映射到機器指令上一般就是 byte、word、double words 和 quad words。比如下面一段 C 語言代碼:

int main()
{
    short s1 = 1;
    long l1 = 1;
    long l2 = 100000000;
    long l3 = l1 + l2;
}

得到的彙編代碼:

......
movw	$1, -6(%rbp)
movq	$1, -16(%rbp)
movq	$100000000, -24(%rbp)
movq	-16(%rbp), %rcx
addq	-24(%rbp), %rcx
......

這裏彙編只用到兩個指令:傳送指令 mov 和 加法指令 add。這些指令後面都有個後綴(w 或 q),這些後綴就表示指令操作數的寬度,w 表示一個字 16 bit,q 表示四字 64 bit。movw 和 movq 在機器級別是兩個不同的指令(雖然對於人類來說做的是相同的事情)。

注意,在人類意義上,100000000 比 1 佔用空間要大得多,理論上在計算機中 1 應該比 100000000 佔用更少的空間,但實際上它倆佔用相同的空間,就算你把 l1 聲明成 short,在做加法運算前計算機仍然要先將兩者轉換成相同的寬度(64 bit)再運算—— CPU 使用相同的編碼單元處理這兩個數。

和快遞公司統一包裝箱尺寸來提高裝箱效率一樣,計算機使用統一大小的編碼單元(操作數的寬度)也是爲了提高效率(以及計算機設計上的便捷性)。

上面舉的是數值處理的例子,在字符編碼上也是一樣的道理,計算機層面的字符編碼本質上就是數值處理,最終還是要由 CPU 指令來執行,不同大小的碼元 CPU 處理指令是不同的。

很多地方討論“字”的時候喜歡用“字節”來表示字的大小,比如雙字(double word)大小是 4 字節。這種表述在理論層面上並不可取,因爲字節和字是同一級別的計算機存儲、傳輸和處理信息的單位,它們之間在理論上並不存在必然的等效關係,比如在理論上可以定義一個字等於 15 個比特——雖然實際中由於計算機存儲使用字節作爲單位,而爲了處理上的方便,CPU 指令的處理單元也設計成存儲單位(字節)的整數倍。

所以我們在討論 CPU 指令處理單元時,是用比特來表示其絕對寬度,我們說一個字等於 16 比特,而不說一個字等於 2 個字節。理解了這層含義,能更好地理解字符集的編碼單元,因爲字符集編碼單元的寬度理論上可以定義成任意比特(而不是必須等於字節的整數倍),比如 UTF-7 和 ISO 2022 就是 7 比特編碼單元(一些早期的通信設備的傳輸寬度是 7 比特)。

雖然理論上碼元的大小可以是任意比特,不過實際上由於個人計算機的存儲和傳輸單位都是字節(8 bit),所以絕大部分的碼元寬度都是字節的整數倍,最常用的是 8 bit(如 UTF-8)、16 bit(如 UTF-16) 和 32 bit(如 UTF-32)。

一個碼點需要一個或多個碼元來表示,而且一種編碼方式中,一個碼點需要的碼元數可能不是固定的,比如 GB 3212 的 EUC-CN 編碼方式中,ASCII 字符需要一個碼元,漢字需要兩個碼元;UTF-8 中不同的字符可能需要 1 ~ 4 個碼元來表示——這種編碼方式稱爲變長編碼方式(相反,如果所有字符都使用固定數量的碼元表示,則稱爲定長編碼方式,如 UTF-32)。

字符集(第二層碼元)和編碼方式之間是多對多的關係。一種字符集可以使用多種編碼方式,比如 GB 2312 可以使用 EUC-CN 編碼方式,也可以使用 ISO 2022 編碼方式;反過來,一種編碼方式可以應用於多種字符集,比如 EUC 編碼方式可以用於 GB 2312,也可以用於 JIS X 0208(一種日語字符集編碼標準)。


第四層:字符編碼方案 CES(Character Encoding Scheme)

理論上,第三層已經定義了計算機層面的編碼方式,爲什麼還要第四層呢?

現代計算機採用 8 bit(1 字節)存儲方案,對於超過 8 bit 的數據單元(如 short、int、long 以及超過 8 bit 寬度的碼元)要用多個字節來表示(比如 11 比特的碼元需要用到兩個字節即 16 比特,而不是 1 個字節加 3 比特)。

由於歷史原因,多字節數據單元在存儲(寄存器、內存、磁盤等)方案上,不同軟硬件廠商存在不同的實現方式,大體分爲大端(big-endian)和小端(little-endian)兩種方案。

我們以兩字節數據單元 short 類型爲例,看看兩種方案的區別。

short foo = 0x2710;

變量 foo 是 short 類型,是一個佔用兩個字節的數據單元。十六進制 2710,對應十進制 10000,二進制 00100111 00010000,其中左邊的 27(二進制 00100111)稱爲高字節,右邊的 10(二進制 00010000)稱爲低字節——高低字節是從人類閱讀順序(從左到右)說的。

大小端在存儲(以及傳輸)上的區別就在於,到底是先存放高字節還是先存放低字節。

存儲是從低地址往高地址進行的,大端方案是先存放高字節,即將高字節放在低地址位,低字節放在高地址位;小端方式是先存放低字節,即將低字節放在低地址位,高字節放在高地址位。

image-20220223153913598

大端存儲順序和人類閱讀順序一致

大小端僅針對多字節數據單元(或說數據類型),典型地是各種 int 類型以及超過 8 bit 寬度的碼元。單字節數據單元(如 char、小於等於 8 bit 的碼元)不存在大小端問題。

英特爾的 x86 處理器以及 Windows 操作系統都是使用的小端模式,Mac OS 以及網絡數據傳輸採用的是大端模式,有些 CPU 架構可以切換大小端(如 MIPS 架構)。

關於網絡字節序:我們知道網絡上傳輸的數據本質上是字節流(即網絡層面根本不關心你傳的內容是不是多字節數據單元,它僅僅將其視爲一個個字節而已),按理應該不存在字節序的問題啊。

其實網絡字節序說的是網絡協議的首部涉及到多字節單元的部分應該如何發送。比如 IP 協議首部有報文長度以及 IP 地址信息,TCP 協議首部有端口號、序列號等信息,這些都是多字節數據單元,就會涉及到字節序的問題,網絡協議要求採用大端序,也就是先發送高字節,後發送低字節。

回到字符編碼方案上。由於在第三層定義碼元的時候,碼元是可以超過一個字節寬度的(比如 UTF-16 的 16 比特碼元、UTF-32 的 32 比特碼元),那麼它就涉及到跟 int 數據類型一樣的問題,即在存儲的時候,先存高字節還是低字節。

這裏有個問題:爲什麼要在字符編碼模型中單獨定義這一層來處理大小端問題?大小端問題難道不是操作系統和 CPU 關心和解決的事情嗎,對應用層應該透明纔對啊?

如果文本只需要在內存中存儲,那根本不需要這一層,直接由操作系統處理大小端問題即可。問題在於,文本不僅需要在本地內存中存儲,還要在磁盤中存儲——這些存儲在磁盤上的文本文件很可能需要在多個異構系統之間傳閱。

假如張三在自己的 Mac 電腦上創建了一個文本文件,寫了個漢字“啊”,並用 UTF-16 保存(在 CEF 層面,“啊”字的 UTF-16 編碼值是 0x554A)。如果文本文件是按照操作系統的大小端來存儲,那麼該文本在 Mac OS 磁盤上的內容就是 55 4A(Mac OS 是大端存儲,低地址存 55,高地址存 4A)。

然後張三將該文件發給李四,李四用 Windows 打開會怎樣?

Windows 用的是小端存儲方案,0x554A 在 Windows 上應該是低地址存 4A,高地址存 55,和 Mac OS 相反。現在 Windows 拿到 55 4A字節序列,會按照小端序解釋爲值 4A55,也就是它在 Windows 上是 0x4A55 對應的字符,也就是漢字“䩕”。結果就雞同鴨講了。

所以一旦涉及到異構系統之間的互操作,就必須明確字節序問題。有兩種方案:

  1. 強制用一種字節序,比如網絡傳輸就強制使用大端序(網絡字節序);
  2. 使用字節序標記。字符集編碼一般採用這種方案。Unicode 編碼方案中有個叫 BOM(Byte Order Mark)的東西,就是用來做這事的。

其實 Unicode 完全可以採用第一種方案,也就是強制使用一種字節序,也就免去了那麼多複雜問題——字節序本來就是個歷史遺留問題。

UTF-8 是單字節碼元,不存在字節序問題,但一些 UTF-8 文件也有 BOM 頭,該 BOM 頭主要是用來識別該文件是 UTF-8 編碼的(不是必須的)。

講完了四層模型,下一篇我們正式講講 Unicode。

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