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

林子er發表於2022-02-23

上一篇《字符集編碼(上):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。

相關文章