字符集編碼(二):字符編碼模型
上一篇《字符集編碼(上):Unicode 之前》我們講了 Unicode 之前的傳統(tǒng)字符集編碼標(biāo)準(zhǔn)產(chǎn)生的歷史背景,以及因存在多種編碼標(biāo)準(zhǔn)而帶來(lái)的混亂,這種局面強(qiáng)烈要求一種新的、統(tǒng)一的現(xiàn)代編碼標(biāo)準(zhǔn)的出現(xiàn)——這個(gè)統(tǒng)一的編碼標(biāo)準(zhǔn)就是 Unicode。
不過(guò)本篇我們先不講 Unicode,而是講講字符集編碼設(shè)計(jì)的理論框架:字符編碼模型。
字符集編碼模型大體上分四層(也有說(shuō)五層的,這里只討論四層)。
第一層:抽象字符表 ACR(Abstract Character Repertoire)
它明確了該編碼標(biāo)準(zhǔn)可以對(duì)哪些字符進(jìn)行編碼。
有些標(biāo)準(zhǔn)是封閉性的,即其能夠編碼的字符是固定的(比如 ASCII、ISO 8859 系列等);有些是開(kāi)放性的,可以不斷地往里面加入新字符(比如 Unicode)。
這里強(qiáng)調(diào)了字符是抽象的,這一點(diǎn)和我們對(duì)“字符”的直覺(jué)理解不同。我們對(duì)“字符”的直覺(jué)上(視覺(jué)上)的理解其實(shí)是指字形。
有些字符是不可見(jiàn)的,比如控制字符(如 ASCII 中的前 32 個(gè)字符)。
有些字形是由多個(gè)字符組合成的,比如西班牙語(yǔ)的 ? 由 n 和 ~ 兩個(gè)字符組成(這一點(diǎn)上 Unicode 和傳統(tǒng)編碼標(biāo)準(zhǔn)不同,傳統(tǒng)編碼標(biāo)準(zhǔn)多是將 ? 視作一個(gè)獨(dú)立的字符,而 Unicode 中將其視為兩個(gè)字符的組合)。
抽象的另一層含義是,一個(gè)字符可能會(huì)有多種視覺(jué)上的字形表示。比如一個(gè)漢字有楷、行、草、隸等多種形體,阿拉伯字符根據(jù)其在文中出現(xiàn)的位置會(huì)表現(xiàn)為不同的形態(tài)。字符集編碼將這些形態(tài)都視為同一個(gè)字符(即字符集編碼是對(duì)字符而非字形編碼)。

漢字“山”的不同形態(tài)
第二層:編碼字符集 CCS(Coded Character Set)
有了第一層的抽象字符集列表,這層我們給列表中的每個(gè)字符分配一個(gè)唯一的數(shù)字編碼(一般是非負(fù)整數(shù))。這個(gè)數(shù)字編碼有個(gè)專門的名字叫碼點(diǎn)(Codepoint)。
注意這層我們沒(méi)有提計(jì)算機(jī),所謂碼點(diǎn)是人類意義上的編號(hào),就像你給面前的一堆水果,蘋(píng)果編 1 號(hào),香蕉編 2 號(hào)一樣,還扯不到計(jì)算機(jī)那里去。實(shí)踐中常用十六進(jìn)制編號(hào)表示,而且一些單字節(jié)編碼標(biāo)準(zhǔn)的碼點(diǎn)和其在計(jì)算機(jī)中的字節(jié)值是一致的,所以人們常常把兩者混用。在一些多字節(jié)編碼標(biāo)準(zhǔn)中,碼點(diǎn)和計(jì)算機(jī)字節(jié)值不是一回事,比如 GB 2312,其碼點(diǎn)(區(qū)位碼)和計(jì)算機(jī)表示(內(nèi)碼)是不同的。
雖然都是給字符編碼(編號(hào)),但不同標(biāo)準(zhǔn)的編碼方式是不同的。比如 GB 2312 是通過(guò) 94 × 94 的矩陣格子,也就是區(qū)位號(hào)的方式,而 Unicode 是通過(guò)加 1 的方式(從 0 開(kāi)始往后依次 1,2,3,......)。
在這層,每個(gè)字符都分配了唯一的編碼。要小心地理解這句話,它有兩層含義:1. 抽象字符表中字符是唯一的;2. 編碼字符集中編碼是唯一的。然而,如果你看了 Unicode 的字符集,你可能覺(jué)得不是這么回事。比如熱力學(xué)單位 K(開(kāi)爾文)和拉丁字母 K 本質(zhì)上是一個(gè)字符(在各個(gè)層面上長(zhǎng)得都一模一樣,僅僅是含義不一樣),在 Unicode 中卻分配了兩個(gè)碼點(diǎn)——難道 Unicode 將這兩個(gè) K 看作兩個(gè)不同的字符?
在 Unicode 中,字符并不是用圖形(字形)來(lái)表達(dá)的(因?yàn)樽址妥中问莾纱a事,一個(gè)字符可能會(huì)有多種字形,用哪種來(lái)表示?),而是用字符名稱來(lái)表達(dá)的。在 Unicode 字符集中,字符名稱是唯一的(而且不可變),這些名稱分配的碼點(diǎn)也是唯一的。拉丁字母 K 在 Unicode 中的字符名稱是“Latin Capital Letter K”,碼點(diǎn)是 004B,開(kāi)爾文 K 的名稱是“KELVIN SIGN”,碼點(diǎn)是 212A。
在人類意義上來(lái)說(shuō),誰(shuí)都清楚這兩個(gè)字符其實(shí)就是同一個(gè),難道 Unicode 是根據(jù)字符含義編碼的?不是的。因?yàn)?Unicode 出現(xiàn)得比較晚,在這之前存在大量的傳統(tǒng)編碼標(biāo)準(zhǔn),其中有些標(biāo)準(zhǔn)將這兩個(gè) K 視為不同的字符,Unicode 要兼容這些傳統(tǒng)編碼,也就只能違心地跟隨了(至于為啥必須跟隨,后面有詳細(xì)說(shuō)明)。
第三層:字符編碼形式 CEF(Character Encoding Form)
這層說(shuō)的是碼點(diǎn)如何在計(jì)算機(jī)中表示。
第二層的碼點(diǎn)是人類意義上的編碼,但字符集編碼最終是要讓計(jì)算機(jī)來(lái)處理的,所以還必須把人類意義上的碼點(diǎn)翻譯成計(jì)算機(jī)意義上的表達(dá)形式。
這里有兩步:
-
首先要定義計(jì)算機(jī)表達(dá)字符編碼的單位,術(shù)語(yǔ)叫編碼單元(Code Unit),簡(jiǎn)稱碼元。
-
然后定義表達(dá)規(guī)則,即如何用一個(gè)或多個(gè)碼元來(lái)表示碼點(diǎn)。
舉個(gè)快遞打包的例子:
假如快遞公司要運(yùn)送一車雞蛋,他肯定不能像堆煤一樣把雞蛋堆在集裝箱里。
首先他要準(zhǔn)備若干大小相同的包裝箱,比如 50 * 50 * 40 的紙板箱。
然后他要確定如何將雞蛋放在這些紙板箱里——一般是不能直接堆放的,要先將雞蛋放在蛋托里,然后將蛋托疊放入紙板箱里。
由于一方面蛋托的大小是固定的,而且為了裝箱便利,快遞公司決定所有的紙板箱都是一樣大的,所以即使最后只剩下一個(gè)雞蛋,也要裝入一個(gè)獨(dú)立的同樣大小的紙板箱里(空余部分用泡沫填充),而不能因?yàn)殡u蛋數(shù)量少就裝入一個(gè)小的比如 20 * 20 * 15 的紙板箱中。
如果將上面的一堆雞蛋比作待編碼字符的話,大小相同的包裝箱就是編碼單元(碼元),如何將雞蛋放入這些包裝箱中則是編碼規(guī)則。
所以這層說(shuō)的是在計(jì)算機(jī)層面用多大的碼元(容器)以及用什么樣的規(guī)則來(lái)表達(dá)第二層定義的(人類意義上的)碼點(diǎn)。
比如在上篇文章中我們提到 GB 2312,它本質(zhì)上是第二層的標(biāo)準(zhǔn),即它定義的是人類層面的碼點(diǎn)——區(qū)位碼。該區(qū)位碼如何在計(jì)算機(jī)中表示呢?現(xiàn)在使用最廣泛的編碼形式是 EUC-CN(比如微軟的 codepage 936 就是用該編碼形式編碼的),其碼元大小是 8 bit,GB 2312 使用該編碼形式編碼,簡(jiǎn)單說(shuō)就是在原始區(qū)碼和位碼基礎(chǔ)上加上十六進(jìn)制 A0 得到內(nèi)碼,然后放入兩個(gè)碼元中(詳情參見(jiàn)《字符集編碼(上):Unicode 之前》)。
這里有個(gè)問(wèn)題可能讓人迷惑:為什么非要定義個(gè)大小固定的碼元?比如 GB 2312 使用 EUC-CN 編碼方式時(shí),為什么是用兩個(gè) 8 bit 的碼元而不是用一個(gè) 16 bit?它倆不是一個(gè)意思嗎?
上面快遞運(yùn)輸?shù)睦又校爝f公司之所以采用統(tǒng)一大小的包裝箱,一方面因?yàn)榈巴写笮∈枪潭ǖ模硪环矫鏋榱搜b箱的便利,所以如果最后多出一部分雞蛋,會(huì)單獨(dú)使用一個(gè)包裝箱,而不是和前面的一起使用一個(gè)更大一點(diǎn)的,也不是自己?jiǎn)为?dú)使用一個(gè)更小一點(diǎn)的。
計(jì)算機(jī)也是如此。計(jì)算機(jī)為了處理上的便利,會(huì)定義若干種數(shù)據(jù)處理單元。計(jì)算機(jī)在物理層面的處理單元是比特,在邏輯層面上,存儲(chǔ)和傳輸使用的單位是字節(jié)(byte,8 bit)——這也是我們最熟悉的單位;在 CPU 指令執(zhí)行上,除了字節(jié),還有字(word,16 bit)、雙字(double words,32 bit)、四字(quad words,64 bit)——這些就是 CPU 指令的處理單元。
C 語(yǔ)言里面整型有 char、short、int、long 這些類型,它們映射到機(jī)器指令上一般就是 byte、word、double words 和 quad words。比如下面一段 C 語(yǔ)言代碼:
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
......
這里匯編只用到兩個(gè)指令:傳送指令 mov 和 加法指令 add。這些指令后面都有個(gè)后綴(w 或 q),這些后綴就表示指令操作數(shù)的寬度,w 表示一個(gè)字 16 bit,q 表示四字 64 bit。movw 和 movq 在機(jī)器級(jí)別是兩個(gè)不同的指令(雖然對(duì)于人類來(lái)說(shuō)做的是相同的事情)。
注意,在人類意義上,100000000 比 1 占用空間要大得多,理論上在計(jì)算機(jī)中 1 應(yīng)該比 100000000 占用更少的空間,但實(shí)際上它倆占用相同的空間,就算你把 l1 聲明成 short,在做加法運(yùn)算前計(jì)算機(jī)仍然要先將兩者轉(zhuǎn)換成相同的寬度(64 bit)再運(yùn)算—— CPU 使用相同的編碼單元處理這兩個(gè)數(shù)。
和快遞公司統(tǒng)一包裝箱尺寸來(lái)提高裝箱效率一樣,計(jì)算機(jī)使用統(tǒng)一大小的編碼單元(操作數(shù)的寬度)也是為了提高效率(以及計(jì)算機(jī)設(shè)計(jì)上的便捷性)。
上面舉的是數(shù)值處理的例子,在字符編碼上也是一樣的道理,計(jì)算機(jī)層面的字符編碼本質(zhì)上就是數(shù)值處理,最終還是要由 CPU 指令來(lái)執(zhí)行,不同大小的碼元 CPU 處理指令是不同的。
很多地方討論“字”的時(shí)候喜歡用“字節(jié)”來(lái)表示字的大小,比如雙字(double word)大小是 4 字節(jié)。這種表述在理論層面上并不可取,因?yàn)樽止?jié)和字是同一級(jí)別的計(jì)算機(jī)存儲(chǔ)、傳輸和處理信息的單位,它們之間在理論上并不存在必然的等效關(guān)系,比如在理論上可以定義一個(gè)字等于 15 個(gè)比特——雖然實(shí)際中由于計(jì)算機(jī)存儲(chǔ)使用字節(jié)作為單位,而為了處理上的方便,CPU 指令的處理單元也設(shè)計(jì)成存儲(chǔ)單位(字節(jié))的整數(shù)倍。
所以我們?cè)谟懻?CPU 指令處理單元時(shí),是用比特來(lái)表示其絕對(duì)寬度,我們說(shuō)一個(gè)字等于 16 比特,而不說(shuō)一個(gè)字等于 2 個(gè)字節(jié)。理解了這層含義,能更好地理解字符集的編碼單元,因?yàn)樽址幋a單元的寬度理論上可以定義成任意比特(而不是必須等于字節(jié)的整數(shù)倍),比如 UTF-7 和 ISO 2022 就是 7 比特編碼單元(一些早期的通信設(shè)備的傳輸寬度是 7 比特)。
雖然理論上碼元的大小可以是任意比特,不過(guò)實(shí)際上由于個(gè)人計(jì)算機(jī)的存儲(chǔ)和傳輸單位都是字節(jié)(8 bit),所以絕大部分的碼元寬度都是字節(jié)的整數(shù)倍,最常用的是 8 bit(如 UTF-8)、16 bit(如 UTF-16) 和 32 bit(如 UTF-32)。
一個(gè)碼點(diǎn)需要一個(gè)或多個(gè)碼元來(lái)表示,而且一種編碼方式中,一個(gè)碼點(diǎn)需要的碼元數(shù)可能不是固定的,比如 GB 3212 的 EUC-CN 編碼方式中,ASCII 字符需要一個(gè)碼元,漢字需要兩個(gè)碼元;UTF-8 中不同的字符可能需要 1 ~ 4 個(gè)碼元來(lái)表示——這種編碼方式稱為變長(zhǎng)編碼方式(相反,如果所有字符都使用固定數(shù)量的碼元表示,則稱為定長(zhǎng)編碼方式,如 UTF-32)。
字符集(第二層碼元)和編碼方式之間是多對(duì)多的關(guān)系。一種字符集可以使用多種編碼方式,比如 GB 2312 可以使用 EUC-CN 編碼方式,也可以使用 ISO 2022 編碼方式;反過(guò)來(lái),一種編碼方式可以應(yīng)用于多種字符集,比如 EUC 編碼方式可以用于 GB 2312,也可以用于 JIS X 0208(一種日語(yǔ)字符集編碼標(biāo)準(zhǔn))。
第四層:字符編碼方案 CES(Character Encoding Scheme)
理論上,第三層已經(jīng)定義了計(jì)算機(jī)層面的編碼方式,為什么還要第四層呢?
現(xiàn)代計(jì)算機(jī)采用 8 bit(1 字節(jié))存儲(chǔ)方案,對(duì)于超過(guò) 8 bit 的數(shù)據(jù)單元(如 short、int、long 以及超過(guò) 8 bit 寬度的碼元)要用多個(gè)字節(jié)來(lái)表示(比如 11 比特的碼元需要用到兩個(gè)字節(jié)即 16 比特,而不是 1 個(gè)字節(jié)加 3 比特)。
由于歷史原因,多字節(jié)數(shù)據(jù)單元在存儲(chǔ)(寄存器、內(nèi)存、磁盤等)方案上,不同軟硬件廠商存在不同的實(shí)現(xiàn)方式,大體分為大端(big-endian)和小端(little-endian)兩種方案。
我們以兩字節(jié)數(shù)據(jù)單元 short 類型為例,看看兩種方案的區(qū)別。
short foo = 0x2710;
變量 foo 是 short 類型,是一個(gè)占用兩個(gè)字節(jié)的數(shù)據(jù)單元。十六進(jìn)制 2710,對(duì)應(yīng)十進(jìn)制 10000,二進(jìn)制 00100111 00010000,其中左邊的 27(二進(jìn)制 00100111)稱為高字節(jié),右邊的 10(二進(jìn)制 00010000)稱為低字節(jié)——高低字節(jié)是從人類閱讀順序(從左到右)說(shuō)的。
大小端在存儲(chǔ)(以及傳輸)上的區(qū)別就在于,到底是先存放高字節(jié)還是先存放低字節(jié)。
存儲(chǔ)是從低地址往高地址進(jìn)行的,大端方案是先存放高字節(jié),即將高字節(jié)放在低地址位,低字節(jié)放在高地址位;小端方式是先存放低字節(jié),即將低字節(jié)放在低地址位,高字節(jié)放在高地址位。

大端存儲(chǔ)順序和人類閱讀順序一致
大小端僅針對(duì)多字節(jié)數(shù)據(jù)單元(或說(shuō)數(shù)據(jù)類型),典型地是各種 int 類型以及超過(guò) 8 bit 寬度的碼元。單字節(jié)數(shù)據(jù)單元(如 char、小于等于 8 bit 的碼元)不存在大小端問(wèn)題。
英特爾的 x86 處理器以及 Windows 操作系統(tǒng)都是使用的小端模式,Mac OS 以及網(wǎng)絡(luò)數(shù)據(jù)傳輸采用的是大端模式,有些 CPU 架構(gòu)可以切換大小端(如 MIPS 架構(gòu))。
關(guān)于網(wǎng)絡(luò)字節(jié)序:我們知道網(wǎng)絡(luò)上傳輸?shù)臄?shù)據(jù)本質(zhì)上是字節(jié)流(即網(wǎng)絡(luò)層面根本不關(guān)心你傳的內(nèi)容是不是多字節(jié)數(shù)據(jù)單元,它僅僅將其視為一個(gè)個(gè)字節(jié)而已),按理應(yīng)該不存在字節(jié)序的問(wèn)題啊。
其實(shí)網(wǎng)絡(luò)字節(jié)序說(shuō)的是網(wǎng)絡(luò)協(xié)議的首部涉及到多字節(jié)單元的部分應(yīng)該如何發(fā)送。比如 IP 協(xié)議首部有報(bào)文長(zhǎng)度以及 IP 地址信息,TCP 協(xié)議首部有端口號(hào)、序列號(hào)等信息,這些都是多字節(jié)數(shù)據(jù)單元,就會(huì)涉及到字節(jié)序的問(wèn)題,網(wǎng)絡(luò)協(xié)議要求采用大端序,也就是先發(fā)送高字節(jié),后發(fā)送低字節(jié)。
回到字符編碼方案上。由于在第三層定義碼元的時(shí)候,碼元是可以超過(guò)一個(gè)字節(jié)寬度的(比如 UTF-16 的 16 比特碼元、UTF-32 的 32 比特碼元),那么它就涉及到跟 int 數(shù)據(jù)類型一樣的問(wèn)題,即在存儲(chǔ)的時(shí)候,先存高字節(jié)還是低字節(jié)。
這里有個(gè)問(wèn)題:為什么要在字符編碼模型中單獨(dú)定義這一層來(lái)處理大小端問(wèn)題?大小端問(wèn)題難道不是操作系統(tǒng)和 CPU 關(guān)心和解決的事情嗎,對(duì)應(yīng)用層應(yīng)該透明才對(duì)啊?
如果文本只需要在內(nèi)存中存儲(chǔ),那根本不需要這一層,直接由操作系統(tǒng)處理大小端問(wèn)題即可。問(wèn)題在于,文本不僅需要在本地內(nèi)存中存儲(chǔ),還要在磁盤中存儲(chǔ)——這些存儲(chǔ)在磁盤上的文本文件很可能需要在多個(gè)異構(gòu)系統(tǒng)之間傳閱。
假如張三在自己的 Mac 電腦上創(chuàng)建了一個(gè)文本文件,寫(xiě)了個(gè)漢字“啊”,并用 UTF-16 保存(在 CEF 層面,“啊”字的 UTF-16 編碼值是 0x554A)。如果文本文件是按照操作系統(tǒng)的大小端來(lái)存儲(chǔ),那么該文本在 Mac OS 磁盤上的內(nèi)容就是 55 4A(Mac OS 是大端存儲(chǔ),低地址存 55,高地址存 4A)。
然后張三將該文件發(fā)給李四,李四用 Windows 打開(kāi)會(huì)怎樣?
Windows 用的是小端存儲(chǔ)方案,0x554A 在 Windows 上應(yīng)該是低地址存 4A,高地址存 55,和 Mac OS 相反。現(xiàn)在 Windows 拿到 55 4A字節(jié)序列,會(huì)按照小端序解釋為值 4A55,也就是它在 Windows 上是 0x4A55 對(duì)應(yīng)的字符,也就是漢字“?”。結(jié)果就雞同鴨講了。
所以一旦涉及到異構(gòu)系統(tǒng)之間的互操作,就必須明確字節(jié)序問(wèn)題。有兩種方案:
- 強(qiáng)制用一種字節(jié)序,比如網(wǎng)絡(luò)傳輸就強(qiáng)制使用大端序(網(wǎng)絡(luò)字節(jié)序);
- 使用字節(jié)序標(biāo)記。字符集編碼一般采用這種方案。Unicode 編碼方案中有個(gè)叫 BOM(Byte Order Mark)的東西,就是用來(lái)做這事的。
其實(shí) Unicode 完全可以采用第一種方案,也就是強(qiáng)制使用一種字節(jié)序,也就免去了那么多復(fù)雜問(wèn)題——字節(jié)序本來(lái)就是個(gè)歷史遺留問(wèn)題。
UTF-8 是單字節(jié)碼元,不存在字節(jié)序問(wèn)題,但一些 UTF-8 文件也有 BOM 頭,該 BOM 頭主要是用來(lái)識(shí)別該文件是 UTF-8 編碼的(不是必須的)。
講完了四層模型,下一篇我們正式講講 Unicode。

在聊 Unicode 之前先講講設(shè)計(jì)層面的東西。編碼模型是字符集編碼的設(shè)計(jì)指導(dǎo)框架,有助于我們更好更透徹地理解各具體的編碼標(biāo)準(zhǔn)。
浙公網(wǎng)安備 33010602011771號(hào)