一文讀懂字符編碼

前言

說起字符編碼,讓我想起了科幻鉅作《三體 - 黑暗深林》人類遇到外星文明魔戒的畫面

人類第一次近距離看到四維物體魔戒,

卓文用中頻電波發送了一個問候語。這是一幅簡單的點陣圖,圖中由六行不同數量的點組成了一個質數數列: 1,3,5,7,11,13.

他們沒有指望得到應答,但應答立刻出現了

.....

太空艇收到了來自 “魔戒” 的一系列點陣圖,第一幅是很整齊的一個 8×8 點陣,共六十四個點; 第二幅圖中點陣的一角少了一個點,剩下六十三個; 第三幅圖中又少一點,剩六十二個……“這是倒計數,也相當於一個進度條,可能表示‘它’已經收到了羅塞塔,正在譯解,讓我們等侍。”韋斯特說。

“可爲什麼是六十四點呢?”

“使用二進制時一個不大不小的數唄, 與十進制的一百差不多。”

卓文和關一帆都很慶幸能帶韋斯特來,在與未知的智慧體建立交流方面、心理學家確實很有才能。

在倒計數達到五十七時,令人激動的事情出現了: 下一個計數沒有用點陣表示,“魔戒” 發來的圖片上赫然顯示出人類的阿拉伯數字 56!

.....

在人類探索外星文明,邁向星辰大海的宇宙征程裏,也離不開這種最最基礎的編碼問題。前一陣跟同事碰到了字符亂碼的問題,瞭解後發現這個問題存在兩年了,我們每天都在跟編碼打交道,但是大家對字符編碼都是一知半解,我們 “天天喫豬肉卻很少見過豬跑”,今天我們就把它講講透。

什麼是字符編碼

我們知道計算機的世界只有 0 和 1,如果沒有字符編碼,我們看到的就是一串 "110010100101100111001....",我們的溝通就好像是在對牛彈琴,我看不懂它,它看不懂我。字符編碼就好比人類和機器之間的翻譯程序,把我們熟知的字符文字翻譯成機器能讀懂的二進制,同時把二進制翻譯成我們能看懂的字符。

以下是百科對字符編碼的解釋

字符編碼(Character encoding)也稱字集碼,是把字符集中的字符,編碼爲指定集合中的某一對象(例如:比特模式、自然數序列、8 位組或者電脈衝),以便文本在計算機中存儲或者通信網絡的傳遞。常見的例子是將拉丁字母表編碼成摩斯電碼和 ASCII,比如 ASCII 編碼是將字母、數字和其它符號進行編號,並用 7 比特的二進制來表示這個整數。

字符集(Character set)是多個字符的集合,字符集種類較多,每個字符集包含的字符個數不同,常見字符集名稱:ASCII 字符集、GB2312 字符集、BIG5 字符集、 GB18030 字符集、Unicode 字符集等。計算機要準確的處理各種字符集文字,就需要進行字符編碼,以便計算機能夠識別和存儲各種文字。

爲什麼計算機需要編碼

編碼(Encode)是信息從一種形式轉換爲另一種形式的過程,比如用預先規定的方法將字符(文字、數字、符號等)、圖像、聲音或其它對象轉換成規定的電脈衝信號或二進制數字。

我們現在看到的一幅幅圖畫,聽到的一首首音樂,甚至我們寫的一行行代碼,敲下的一個個字符,所看到的所聽到的都是那麼的真實,但其實在背後都是一串「01」的數字,你昨天在手機上看到的那個心動女孩,真實世界中並不存在,只是計算機用「01」數字幫你生成的 “骷髏” 而已。

二進制其實不存在

你可能認爲計算機中的數據就是「01」二進制,但是實際上計算機中並沒有二進制,即便我們知道所有的內容都是存儲在硬盤中,但是你把它拆開可找不到裏面有任何「0101」的數字,裏面也只有盤片、磁道。就算我們放大了去看盤片,也只有凹凸不平的盤面,凸起的地方是被磁化過的,凹進去的地方是沒有被磁化的;只是我們給凸起的地方取了個名字叫數字「1」,凹進的地方取名叫數字「0」。

同樣內存裏你也找不到二進制數字,內存放大了看就是一堆電容組,內存單元存儲的是「0」還是「1」取決於電容是否有電荷,有電荷我們認爲他是「1」,無電荷認爲他是「0」。但是電容是會放電的,時間一長,代表「1」的電容會放電,代表「0」的電容會吸電,這也是我們內存不能斷電的原因,需要定期對電容進行充電,保證「1」的電容電量有電。

再說顯示器,這個大家感受是最直接的,你透過顯示器看到的美女畫皮、日月山川,其實就是一個個不同顏色的發光二極管發出強弱不一的光點,顯示器就是一羣發光二極管組成的矩陣,其中每一個二極管可以被稱爲一個像素,「1」表示亮,「0」表示滅,而我們平時能看到五彩的顏色,是把三種顏色 (紅綠藍三原色) 的發光二極管做到了一起。那對於一個 ASCII 編碼「65」最後又怎麼顯示成「A」的呢?這就是顯卡的功勞,顯卡中存儲了每一個字符的圖形數據(也稱字形碼),將二維矩陣的圖形數據傳給顯示器成像。

因此,所謂的 0 和 1 都是電流脈衝信號,二進制其實是我們抽象出來的數學邏輯概念,那我們爲什麼要用二進制表示?

因爲二進制只有兩種狀態,使用有兩個穩定狀態的物理器件就可以表示二進制中的每一位,例如用高低電平或電荷的正負性、燈的亮和滅都可以很方便地用「0」和「1」來表示,這爲計算機實現邏輯運算和邏輯判斷提供了便利條件。

計算機編碼轉換過程

正因爲計算機只能表示「01」的邏輯概念,無法直接表示圖片以及文字,所以我們需要一定的轉換過程。

這其實就是我們按照一定的規則維護了字符 - 數字的映射關係,比如我們把「A」抽象成計算機中的「1」,當我們看到 1 的時候就認爲這是「A」,本質上就是一張映射表,理論上你可以隨意給每個字符分配一個獨一無二的編號(character code,字符編碼),比如下表

4rhHOs

接下來我們來看下一個文字從輸入 - 轉碼存儲 - 輸出(顯示 / 打印)的簡單流程,首先我們知道計算機是美國人發明的,規則是美國人定的,鍵盤上的按鍵也都是英文字母,所以編號不是你想怎麼分配就怎麼分配。對於英文字母的輸入,鍵盤和 ASCII 碼之間是直接對應的,鍵盤按鍵「A」對應的編號「65」,存儲到磁盤上也是「65」的二進制直譯「01000001」,這很好理解。

但是對於漢字輸入就不是這麼回事了,鍵盤上可沒有漢字對應的輸入按鍵,我們不可能直接敲出漢字字符。於是就有了輸入碼、機內碼、字形碼的轉換關係,輸入碼幫助我們把英文鍵盤按鍵轉換成漢字字符,機內碼幫助我們把漢字字符轉換成二進制序列,字形碼幫助我們把二進制序列輸出到顯示器成像。

輸入碼

我們模擬下漢字的輸入過程,首先打開 txt 文本敲下「nihao」的拼音字母,然後輸入欄會彈出多個符合條件的漢字詞組,最後我們會選擇相應的編號,就能實現漢字的輸入。那這過程又是如何實現的呢?

計算機領域有一句如同摩西十誡般的神聖哲言:"計算機科學領域的任何問題都可以通過增加一個間接的中間層來解決"。

這裏我們再加一層按鍵字母組合和漢字的映射表,好比英漢字典,這層我們稱爲輸入碼,輸入碼到內碼的過程就是一次查錶轉換操作,比如「nihao」這幾個 ASCII 字符,大家可以隨便修改映射表以及候選編號,我可以把他映射成「你好驍颺」。

機內碼

機內碼也稱內碼,是字符編碼最核心的部分,是字符集在計算機中實際存儲、交換、通信使用的二進制編碼,通過內碼我們可以達到高效率的存儲、傳輸文本的目的。我們的外碼(輸入碼)實現了鍵盤按鍵和字符的映射轉換,但是機內碼是讓字符真正變成了機器能讀懂的二進制語言。

字形碼

計算機中的字符都是以內碼的二進制形式表示,我們怎麼把數字對應的字符在顯示器上顯示出來呢,比如數字「1」代表漢字「你」,怎麼把「1」顯示成「你」?

這就需要依賴字形碼,字形碼本質上是一個 n*n 的像素點陣,把某些位置的像素設置爲白色(用 1 表示),其它位置像素設置爲黑色(用 0 表示),每一個字符的字形都是預先存放在計算機內,而這樣的字形信息庫我們稱爲字庫

比如中文「你」的點陣圖,這樣一個 16*16 的像素矩陣,需要 16 * 16 / 8 = 32 字節的空間來表示,右邊的字模信息稱爲字形碼。不同的字庫(如宋體、黑體)對同一個字符的字形編碼是不同的。

所以字符編碼到顯示的字形碼,其實又是另一張查找表,也就是字符編碼 - 字形碼的映射關係表。

其實我們也可以認爲字符編碼是字形碼的一種壓縮方式,一個佔 32 字節的像素點陣壓縮成了 2 字節的機內碼。

BsWRCF

字符編碼的歷史

電報編碼

從廣義上來說,編碼的歷史很悠久,一直可以追溯到結繩記事的遠古時期,但跟現代字符編碼比較接近的還是摩爾斯電碼的發明,自此開啓了信息通信時代的大門。

摩爾斯電碼是由美國人摩爾斯在 1837 年發明的,比起 ASCII 還要早 100 多年,在早期的無線電上作用非常大,它是每個無線電通訊者需必知的,它的是由點 dot「.」和劃 dash「-」這兩種符號所組成的,電報中表達爲短滴和長嗒,跟二進制一樣也是二元碼。一個二元肯定不夠表示我們的字母,那麼就用多個二元來表示,比如嘀嗒「.-」代表字母「A」,嗒嘀嘀嘀「-...」代表字母「B」。

摩爾斯電碼表

編碼紀元

計算機一開始發明出來時是用來解決數學計算問題的,後來人們發現,計算機還可以做更多的事,例如文本處理等,那個時候的機器都很大,機器之間都是隔離的,沒考慮過機器的通信問題,各大廠商也各幹各個的,搞自己的硬件搞自己的軟件,想怎麼編碼就怎麼編碼。

後來機器間需要相互通信的時候,發現在不同計算機上顯示出來的字符不一樣,在 IBM 上「00010100」數字代表「A」,跑到微軟系統上顯示成了「B」,大家就傻眼了。於是美國的標準化組織就跑出來制定了 ASCII 編碼 (American Standard Code for Information Interchange),統一了遊戲規則,規定了常用符號用哪些二進制數來表示。

百花齊放

統一 ASCII 碼標準對於英語國家很開心,但是 ASCII 編碼只考慮了英文字母,後來計算機傳到歐洲地區,法國人需要加個字母符號(如:é),德國人又需要加幾個字母(Ä ä、Ö ö、Ü ü、ß),幸好 ASCII 只用了前 127 個編號,於是歐洲人就將 ASCII 沒用完的編碼(128-255)爲自己特有的符號編碼,也能很好的一起玩耍。

但是等傳到我們中國後,做爲博大精深的漢語言就徹底蒙圈了,我們有幾萬個漢字,255 個編號完全不夠用啊,所以有了後來的多字節編碼… 因此,各個國家都推出了本國語言的編碼表,也就有了後來的 ISO 8859 系列、GB 系列(GB2312、GBK、GB18030、GB13000)、Big5、EUC-KR、JIS … ,不過爲了能在計算機系統中通用,這些擴展的編碼均直接或間接兼容 ASCII 碼。

而微軟 / IBM 這些國際化產商爲了把自己的產品賣到全世界,就需要支持各個國家的語言,要在不同的地方採用當地的編碼方式,於是他們就把全世界的編碼方式都集中到一起並編上號,並且起了個名字叫代碼頁(Codepage,又稱內碼錶),所以我們有時候也會看到 xx 代碼頁來指代某種字符編碼,比如在微軟系統裏 中文 GBK 編碼對應的是 936 代碼頁,繁體中文 Big5 編碼對應的是 950 代碼頁。

這些既兼容 ASCII 又互相之間不兼容的字符編碼,後來又統稱爲 ANSI 編碼。看到下面這張圖估計大家就很熟悉了,window 下面我們基本上都用 ANSI 編碼保存。

ANSI 的字面意思並非指字符編碼,而是美國的一個非營利組織,是美國國家標準學會 (American National Standards Institute) 的縮寫,ANSI 這個組織爲字符編碼做了很多標準制定工作,後來大家習慣把這類混亂的多字節編碼叫 ANSI 編碼或者標準代碼頁。

ANSI 編碼只是一個範稱,一般代表系統默認的編碼方式,而且並不是確定的某一種編碼方式——比如在 Window 操作系統裏,中國區 ANSI 編碼指的是 GB 編碼,在香港地區 ANSI 編碼指的是 Big5 編碼,在韓國 ANSI 編碼指的是 EUC-KR 編碼。

天下一統

由於各個國家各搞各的字符編碼,如果有些人想裝逼中文裏飈兩句韓文怎麼辦呢?不好意思,你的逼級太高,沒法支持,你選擇了 GB2312 就只能打出中文字符。同時各大國際廠商在兼容各種字符編碼問題上也深受折磨,於是忍無可忍之下,決定開發一套能容納全世界所有字符的編碼,就有了後面大名鼎鼎的 Unicode。

Unicode 也叫萬國碼,包括字符集、編碼方案等,Unicode 是爲了解決傳統的字符編碼方案的侷限而產生的,它爲每種語言中的每個字符設定了統一併且唯一的二進制編碼,在這種語言環境下,不會再有語言的編碼衝突,在同屏下也可以顯示任何國家語言的內容,這就是 Unicode 的最大好處。

在 Unicode 編碼方案裏常見的有四種編碼實現方案 UTF-7、 UTF-8、UTF-16、UTF-32,最爲知名的就是 UTF-8,不過 Unicode 設計之初是採用雙字節定長編碼的 UTF-16,但是發現歷史包袱太重推不動,最後出了個變長的 UTF-8 才被廣泛接受。

字符編碼模型

傳統編碼模型

在傳統字符編碼模型中,基本上都是將字符集裏的字符用十進制進行逐一的編號,然後把十進制編號直接轉成對應的二進制碼,可以說該字符編號就是字符的編碼。

計算機在處理字符與數字的轉換關係上其實就是查找映射表的過程。像 ASCII 編碼就是給每個英文字符編一個獨一無二的數字,整個編碼處理過程相對還是比較簡單的,計算機內部直接就映射成了二進制,十進制的編號只是方便我們看的。

MiWZWz

現代編碼模型

Unicode 編碼模型採用了一個全新的編碼思路,將編碼模型劃分爲 4 個層次,也有說 5 個層次的,不過第五層是傳輸層的編碼適配,放在編碼模型裏嚴格來說不是很恰當。

第一層:抽象字符集 ACR

所謂抽象字符集,就是抽象字符的合集,是一個無序集合,這裏強調了字符是抽象的,也就是不僅包括我們視覺上能看到的狹義字符,比如「a」這樣的有形字符,也包括一些我們看不到的無形字符,比如一些控制字符「DELETE」、「NULL」等。

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

同時一個字符也可能會有多種視覺上的字形表示,比如一個漢字有楷、行、草、隸等多種形體,這些都視爲同一個抽象字符(即字符集編碼是對字符而非字形編碼),如何顯示是字形庫的事。

漢字「人」的不同形態

抽象字符集有開放與封閉之分。開放的字符集指還會不斷新增字符的字符集,封閉字符集是指不會新增字符的字符集。比如 ASCII 就是封閉式的,只有 128 個字符,以後也不會再加,但是 Unicode 是開放式的,會不斷往裏加新字符的,已經從最初的 7163 個增加到現在的 144,697 個字符。

第二層:編號字符集 CCS

編號字符集就是對抽象字符集裏的每個字符進行編號,映射到一個非負整數的集合

編號一般用方便人類閱讀的十進制、十六進制來表示,比如「A」字符編號「65」,「B」字符編號是「66」;

大家需要清楚對於有些字符編碼的編號就是存儲的二進制序列,如 ASCII 編碼;有些字符編碼的編號跟存儲的二進制序列並不一樣,比如 GB2312、Unicode 等。

另外,編號字符集合是有範圍限制的,比如 ASCII 字符集範圍是 0~127,ISO-8859-1 範圍是 0~256,而 GB2312 是用一個 94*94 的二維矩陣空間來表示,Unicode 是用 Plane 平面空間的概念來表示,這稱爲字符集的編號空間。

編號空間中的一個位置稱爲碼點( Code Point 代碼點 )。一個字符佔用的碼點所在的座標(非負整數值對)或所代表的非負整數值,就是該字符的碼值(碼點編號)。

ASCII 碼點編號

第三層:字符編碼方式 CEF

抽象字符集和編號字符集是站在方便我們理解的角度來看的,所以最後我們需要翻譯成計算機能懂的語言,將十進制的編號轉換成二進制的形式。

因此字符編碼方式就是將字符集的碼點編號,轉換成二進制碼元序列( Code Unit Sequence )的過程。

碼元:字符編碼的最小處理單元,比如 ASCII 一個字符等於一個字節,屬於單字節碼元;UTF-16 一個字符等於兩個字節,處理過程是按字「word」來處理,所以是雙字節碼元;UTF-8 是多字節編碼,有單字節字符,也有多字節字符,每次處理是按單個單個字節解析處理,所以處理最小單位是字節,也屬於單字節碼元

這裏大家可能會有疑問,十進制直接轉二進制不就好了嗎,爲什麼要單獨抽出這麼一層?

早期的字符編碼確實也是這麼處理的,十進制和二進制之間是直接轉換過去的,比如 ASCII 碼,字符「A」的十進制是「65」,那對應的二進制就是「1000001」,同時存儲到硬盤裏的也是這個二進制,所以那時候的編碼比較簡單。

隨着後來多字節字符編碼(Muilti-Bytes Character Set,MBCS 多字節字符集)的出現,字符編號和二進制之間不是直接轉換過去的,比如 GB2312 編碼,「萬」字的區位編號是「45,82」,對應的二進制機內碼卻是「1100 1101 1111 0010」(其十進制是「205,242」)。

如果這裏不轉換直接映射成二進制碼會出什麼問題呢?「萬」字的字符編號「45,82」,45 在 ASCII 裏是「-」,82 是「U」,那到底是顯示兩個字符「-U」還是顯示一個字符「萬」字,爲了避免這種衝突 所以增加了前綴處理,詳細的過程會在下文具體來講解。

第四層:字符編碼方案 CES

字符編碼方案也稱作 “序列化格式 “( Serialization Format ),指的是將字符編號進行編碼之後的碼元序列映射爲字節序列(即字節流)的形式,以便經過編碼後的字符能在計算機中進行處理、存儲和傳輸。

字符編碼方式 CEF 有點像我們數據庫結構設計裏的邏輯設計,而這一層編碼方案 CES 就像是物理設計了,將碼元序列映射爲跟特定的計算機系統平臺相關的物理意義上的二進制過程。

這裏大家可能又會有疑問,爲什麼二進制的碼元序列和實際存儲的二進制又會不一樣呢?這主要是計算機的大小端序造成的,具體端序內容會在 UTF-16 編碼部分詳細介紹。

大小端序名詞出自 Jonathan Swift 的《格列夫遊記》一書 :

所有人都認爲,喫雞蛋前,原始的方法是打破雞蛋較大的一端。可是當今皇帝的祖父小時候喫雞蛋,一次按古法打雞蛋時碰巧將一個手指弄破了,因此他的父親,當時的皇帝,就下了一道敕令,命令全體臣民喫雞蛋時打破雞蛋較小的一端,違令者重罰。

老百姓們對這項命令極爲反感。歷史告訴我們,由此曾發生過六次叛亂,其中一個皇帝送了命,另一個丟了王位… 關於這一爭端,曾出版過幾百本大部著作,不過大端派的書一直是受禁的,法律也規定該派的任何人不得做官。

常見字符編碼

ASCII

很久以前,計算機制造商都是按各自的方式來將字符渲染到屏幕上,當時的計算機動不動可就是一套房子的大小,這傢伙可不是誰都能玩的起的,那時人們並不關心計算機如何交流。隨着上世紀七八十年代微處理器的出現,計算機變得越來越小,個人計算機開始進入大衆的視線,隨後出現了井噴式的發展,但是之前廠商都是各自爲政,沒考慮過自家的產品要兼容別人家的東西,導致在不同計算機體系間的數據轉換變得十分蛋疼,因此美國的標準協會在 1967 年制定出了 ASCII 編碼,到目前爲止共定義了 128 個字符。

ASCII 編碼(注意該表是列表示字節高 4 位)

其中前 32 個(0~31)是不可見的控制字符,32~126 是可見字符,127 是 DELETE 命令(鍵盤上的 DEL 鍵)。

其實早在 ASCII 之前,IBM 在 1963 年也推出過一套字符編碼系統 EBCDIC,跟 ASCII 碼一樣囊括了控制字符、數字、常用標點、大小寫英文字母。

EBCDIC 編碼

但是他的字符編號並不是連續的,這給後續程序處理帶來了麻煩,後來 ASCII 編碼吸取了 EBCDIC 的經驗教訓,給英文單詞分配了連續的編碼,方便程序處理,因此被後來廣泛接受。

ASCII 和 EBCDIC 編碼相比,除了字符連續排列之外,最大的優點是 ASCII 只用了一個字節的低 7 位,最高位永遠是 0。可別小看了這個最高位的 0,看似無足輕重,但這是 ASCII 設計最成功的地方,後面介紹各編碼原理的時候你會發現,正是因爲這個高位 0,其它編碼規範才能對 ASCII 碼無縫兼容,使得 ASCII 被廣泛接受。

ISO-8859 系列

美國市場雖然統一了字符編碼,但是計算機制造商在進入歐洲市場的時候又遇到了麻煩,歐洲的主流語言雖然也是用拉丁字母,但卻存在很多擴展體,比如法語的「é」,挪威語中的「Å」,都無法用 ASCII 表示。但是大家發現 ASCII 後面的 128 個還沒有被使用可以利用起來,這對於歐洲主流語言就足夠了。

於是就有了大家所熟知的這個 ISO-8859-1(Latin-1), 它只是擴展了 ASCII 後 128 個字符,還是屬於單字節編碼;同時爲了兼容原先的 ASCII 碼,當最高位是 0 的時候仍然表示原先的 ASCII 字符不變,當最高位是 1 的時候表示擴展的歐洲字符。

但是到這裏還沒有完,剛說了這只是歐洲主流的語言,但主流語言裏沒有法語使用的 œ、Œ、Ÿ 三個字母,也沒有芬蘭語使用的 Š、š、Ž、ž ,而單字節編碼裏的 256 個碼點都被用完了,於是就出現了更多的變種 ISO-8859-2/3/.../16 系列,他們都兼容 ASCII,但彼此間又不完全兼容。

ISO-8859-n 系列字符集如下:

ISO8859-1 字符集,也就是 Latin-1,是西歐常用字符,包括德法兩國的字母。

ISO8859-2 字符集,也稱爲 Latin-2,收集了東歐字符。

ISO8859-3 字符集,也稱爲 Latin-3,收集了南歐字符。

ISO8859-4 字符集,也稱爲 Latin-4,收集了北歐字符。

ISO8859-5 字符集,也稱爲 Cyrillic,收集了斯拉夫語系字符。

ISO8859-6 字符集,也稱爲 Arabic,收集了阿拉伯語系字符。

ISO8859-7 字符集,也稱爲 Greek,收集了希臘字符。

.......

GB 系列

當計算機進入東亞國家的時候,廠商們更傻眼了,美國和歐洲國家語言基本都是表音字符,一個字節就足夠用了,但亞洲國家有不少是表意字符,字符個數動輒幾萬十幾萬的,一個字節完全不夠用,所以我們國家有關部門按照 ISO 規範設計了 GB2312 雙字節編碼,但是 GB2312 是一個封閉字符集,只收錄了常用字符總共也就 7000 多個字符,因此爲了擴充更多的字符包括一些生僻字,纔有了之後的 GBK、GB18030、GB13000(“GB” 爲 “國標” 的漢語拼音首字母縮寫)。

按照 GB 系列編碼方案,在一段文本中,如果一個字節是 0~127,那麼這個字節的含義與 ASCII 編碼相同,否則,這個字節和下一個字節共同組成漢字(或是 GB 編碼定義的其他字符),所以 GB 系列都是兼容 ASCII 編碼的。

GB2312

GB2312 是使用兩個字節來表示漢字的編碼標準,共收入漢字 6763 個和非漢字圖形字符 682 個,爲了避免與 ASCII 字符編碼(0~127)相沖突,規定表示一個漢字的編碼字節其值必須大於 127(即字節的最高位爲 1 ),並且必須是兩個大於 127 的字節連在一起來共同表示一個漢字( GB2312 爲雙字節編碼),所以 GB2312 屬於變長編碼,當是英文字符的時候佔一個字節,中文字符的時候佔兩個字節,可以認爲 GB2312 是對 ASCII 的中文擴展。

GB2312 字符集編號空間是一個 94*94 的二維表,行表示區(高位字節),列表示位(低位字節),每區有 94 個位,每個區位對應一個字符,稱爲區位碼。區位碼上加 2020H,就得到國標碼,國標碼上加 8080H,就得到常用的計算機機內碼。這裏引入了區位碼、國標碼、機內碼概念,下面我們說下三者的關係

國標碼

國標碼是我國漢字信息交換的標準編碼,規定由 4 位 16 進制數組成,用兩個低 7 位字節表示,爲了避開 ASCII 字符中的前 32 個控制指令字符,所以每個字節都是從第 33 個編號開始,如下圖所示

區位碼

由於上述國標碼的 16 進制可編碼區不夠直觀不方便我們使用,所以我們把他映射成了十進制的 94*94 二維表編號空間,我們稱之爲區位碼,同時區位碼也可以當成一種外碼使用,輸入法可以直接切換成區位碼進行漢字輸入,不過這種輸入法無規則可言 人們很難記住區位編號,用的人也不多了。

下圖是區位碼的二維表,比如「萬」字是 45 區 82 位,所以「萬」 字的區位碼是「45,82」。

其中:

  • 01~09 區 (682 個):特殊符號、數字、英文字符、製表符等,包括拉丁字母、希臘字母、日文平假名及片假名字母、俄語西裏爾字母等在內的 682 個全角字符;

  • 10~15 區:空區,留待擴展;

  • 16~55 區 (3755 個):常用漢字 (也稱一級漢字),按拼音排序;

  • 56~87 區 (3008 個):非常用漢字 (也稱二級漢字),按部首 / 筆畫排序;

  • 88~94 區:空區,留待擴展。

機內碼

GB2312 國標碼規範是覆蓋掉 ASCII 中可見部分的符號和英文字母,使用兩個 7 位碼將其中的英文字母和符號重新編入,但是這樣產生一個弊端,早期用 ASCII 碼編碼的英文文章無法打開,一打開就是亂碼,也就是說應該要兼容早期 ASCII 碼而不是覆蓋它,後來微軟爲了解決這個問題,將字節的最高位設爲 1,因爲 ASCII 中使用 7 位,最高位爲 0,轉換後的編碼稱爲機內碼 (內碼),這種方式本質上是修改了 GB2312 的編碼標準,最後被大家接受沿用。

總結下三者轉換關係:區位碼 ---> 區碼和位碼分別 + 32(即 + 20H )得到國標碼 ---> 再分別 + 128(即 + 80H)得到機內碼(與 ACSII 碼不再衝突)

GBK

GBK 即 “國標擴展” 的意思,因爲 GB2312 雙字節的最高位都要求大於 1,上限也不會超過 1 萬個字符,所以對此進行了擴展,對 GB2312 的字符不重新編碼直接沿用,因此完全兼容 GB2312。GBK 雖然也是雙字節編碼,但是隻要求第一個字節大於 127 就固定表示這是一個漢字的開始,正因爲如此,GBK 的編碼空間比 GB2312 大很多。

GBK 整體編碼範圍爲 8140-FEFE,首字節在 81-FE 之間,尾字節在 40-FE 之間,剔除 xx7F 一條線,總計 23940 個碼位,共收入 21886 個漢字和圖形符號;其中 GBK/1 收錄除 GB 2312 字符外的其他增補字符,GBK/2 收錄 GB2312 字符,GBK/3 收錄 CJK 字符,GBK/4 收錄 CJK 字符和增補字符,GBK/5 爲非中文字符,UDC 爲用戶自定義字符。

詳細如下如所示:

→ 這裏大家可能會有兩個疑問,爲什麼尾字節要從 40 開始,而不是 00 開始;爲什麼要排除 FF、xx7F 這兩條線的編號?

GBK 的尾字節編碼高位沒有強制要求是 1,當高位是 0 時跟 ASCII 碼是衝突的,ASCII 碼裏 00-40 之間大部分都是控制字符,所以排除控制字符主要是爲了防止丟失高字節導致出現系統性嚴重後果;

排除 FF 是爲了兼容 GB2312,GB2312 這個位是保留不使用的;而 7F 表示 DEL 字符就是向後刪除一個字符,如果傳輸過程中丟失首字節那麼就會出現嚴重的後果,所以需要將 xx7F 也排除,這是所有編碼方案都需要注意的地方。

GB18030

隨着計算機的發展,GBK 的 2 萬多個字符也還是扛不住,於是 2000 年我國又制定了新標準 GB18030,用來替代 GBK 標準。GB18030 是強制性標準,現在在中國大陸銷售的軟件都支持 GB18030。

GB18030 其實是對齊 Unicode 標準的,裏面包括了所有 Unicode 字符集,也算是 Unicode 的一種實現 (UTF)。

那既然有了 UTF 我們爲什麼還要搞一套 Unicode 實現?

主要是 UTF-8/UCS-2 他們是不兼容 GB2312 的,如果直接升級那麼就全亂碼了,所以 GB18030 是爲了兼容 GB 系列,是 GBK、GB2312 的超集,當我們原先的 GB2312(GBK) 軟件考慮升級到國際化 Unicode 時,可以直接使用 GB18030 進行升級。

GB18030 雖然也是 GB2312 的擴展,但它和 GBK 的擴展方式不一樣,GBK 主要是充分利用了 GB2312 的一些沒定義的編碼空間,而 GB18030 採用的是字節變長編碼,單字節區兼容 ASCII、雙字節區兼容 GBK、四字節區對齊所有 Unicode 碼位。

實現原理上主要是採用第二字節未使用到的 0x30~0x39 編碼空間來判斷是否四字節。

rfiU2S

UNICODE

背景介紹

在統一碼之前,各國創造了大量的節編碼標準,有單字節的、雙字節的(如 GB 2312、Shift JIS、Big5 、ISO8859 等),各自又相互不兼容。在 1987 年,蘋果、Sun、微軟等公司開始討論囊括全世界所有字符的統一編碼標準,組成了 Unicode 聯盟,這個期間做了很多研討工作,討論核心要點如下:

工作組統計了當時全世界的報紙等刊物,結論是兩個字節足以囊括全世界有實用意義的字符(當然這隻統計了當前使用的字符,不包括古代語言或者廢棄語言)。

一種採用變長編碼形式,對於 ASCII 字符使用一個字節,其他字符使用兩個字節,類似 GBK;另一種採用定長編碼形式,不管是不是 ASCII 字符統一使用兩個字節。

方案選擇上主要從計算機處理過程中的時間和空間兩個維度,也就是編解碼的執行效率和存儲大小兩方面,最後結論是採用雙字節定長編碼,因爲定長帶來的空間變大在整體傳輸、存儲成本上其實影響並不大,而定長編碼處理效率會明顯高於變長編碼,所以早期 Unicode 採用了定長編碼形式。

由於漢字表意文字字符量較大,如果可以統一那麼能大幅減少收錄漢字的數量,

所以最初收錄漢字遵循兩個基本原則:表意文字認同原則和字源分離原則。

所謂表意文字認同原則,即 “只對字,不對形” 編碼,將同一字的不同字形(即異體字)合併。例如 “房” 字的第一筆,在中日韓的寫法都不同,但它本身是同一個字,只給一個編碼,而寫法的不同交由字體進行區分。

字源分離原則,是指一個字源中同時收錄了同一個字的不同字形,則給予兩個字形分別編碼。例如:之前 GBK 中就收錄了 “戶”、“戶”、“戸” 三個字,那麼 Unicode 也需要保留三個字,如果直接合並會造成使用上的困擾。

例如下面這句話如果不做字源分離,會是什麼情況呢?

原句 :戶有三種寫法,分別是 “戶”、“戶”、“戸”,

改寫後:戶有三種寫法,分別是 “戶”、“戶”、“戶”

Unicode 介紹

Unicode 稱爲統一碼(也叫萬國碼),是按現代編碼模型進行設計的一套字符編碼體系,涵蓋抽象字符集、編號、邏輯編碼、編碼實現。Unicode 是爲了解決傳統的字符編碼方案的侷限而產生的,在這種語言環境下,不會再有語言的編碼衝突,可以在同屏下顯示任何國家的語言。

UTF-n 編碼(Unicode Transformation Format Unicode 字符集轉換格式,n 表示碼元位數)是 Unicode 這套編碼體系裏的編碼實現 CES 部分,像 UTF-8、UTF-16、UTF-32 都是將數字轉換到實際的二進制編碼實現,Unicode 的編碼實現除了 UTF 系列之外,還有 UCS-2/4,GB18030 等。但是現在很多人誤把 Unicode 當成只是一個字符編號,這其實是不對的。

Unicode 可以容納世界上所有國家的文字和符號,其編號範圍是 0-0x10FFFF,有 1,114,112 個碼位,爲了方便管理劃分成 17 個平面,現已定義的碼位有 238,605 個,分佈在平面 0、平面 1、平面 2、平面 14、平面 15、平面 16。其中平面 0 又稱爲基本多語言平面(Basic Multilingual Plane,簡稱 BMP),這個平面基本涵蓋了當今世界上正在使用中的常用字符。我們平常用到的字符,一般都是位於 BMP 平面上的,其範圍擁有 65,536 個碼點,其他平面統稱增補平面,關於平面的概念會在 UTF-16 章節詳細介紹。

與 UCS 的關係

說起 Unicode 我們不得不提 UCS(全稱 Universal Multiple-Octet Coded Character Set 通用多八位編碼字符集),國際標準編號 ISO/IEC 10646,是由 ISO 和 IEC 兩家國際標準組織聯合成立的工作組設計的一套新的統一字符集項目,目的與 Unicode 聯盟一樣致力於開發一款全世界通用的編碼集。

早在 1984 年 ISO 和 IEC 兩家組織就成立了一個聯合工作組來設計一套新的統一字符集標準,但是這兩個組織都不知道對方的存在,直到 Unicode 聯盟 1988 年發佈了 Unicode 草案(UCS 草案 1989 年發佈),才發現大家在做同一件事,沒有必要搞兩套標準 所以後面又考慮合併,

由於 UCS 最初設計的是 31 位編碼空間 (UCS-4 編碼實現),可以容納 2^31 約 21 億個字符,而 Unicode 是 16 位空間(UTF-16 編碼實現),所以最開始 Unicode 打算作爲 UCS 的真子集,即 Unicode 中的每個字符都存在於 UCS 中,而且兩者的碼點相同,但 UCS 中的字符(編號超過 65,536 的)則不一定存在於 Unicode 中。

不過由於雙方利益關係並沒有說誰解散誰,最後雙方作出一些妥協保持一致共同發展,兩個標準中相同字符的編碼(碼點)必須是一樣的,這是一個屁股決定腦袋的決策,如果最初 Unicode 知道 UCS 的存在,就不會再出現 Unicode 了。當然合併工作不是一蹴而就的而是經過多輪迭代, ISO/IEC 和 Unicode 在 1993 年發佈了第一版相互兼容版本,到了 1996 年 Unicode 2.0 標準發佈時,Unicode 字符集和 UCS 字符集(即 ISO/IEC 10646-1 )基本保持了一致,同時 Unicode 爲了跟 UCS 的四字節保持一致推出了 UTF-32 編碼實現,UCS 爲了跟 Unicode 的兩字節保持一致推出了 UCS-2 編碼實現。

所以現在我們可以認爲 UCS 和 Unicode 是同一個東西,比如我們常見的 java 內部運行就採用的是 UTF-16 編碼,而 window 操作系統採用的是 UCS-2,他們都是同一個 Unicode 標準。

→ 爲什麼這裏使用的是 2 字節編碼,而不是 4 字節呢?先留個懸念,後續會詳細講解

UTF-16(Java 內部編碼)

UTF 是 Unicode Transfer Format 的縮寫,即把 Unicode 轉做某種格式的意思,所以 UTF-16 是 Unicode 編碼裏的其中一種實現方式,16 代表的是字節位數,佔兩個字節(UTF-32 則表示 4 個字節)。

Unicode 設計之初是採用 UTF-16 這種雙字節定長編碼的,其字符編號就是對應的二進制編號,也就是說第二層的 CCS 和第三層的 CEF 是一致的。比如漢字「萬」的 Unicode 碼點是 「U+4E07」,其二進制序列就是直譯的「0100 1110 0000 0111 」,這種編碼方式的優點是高效,不需要檢查標誌位,但缺點是不兼容 ASCII,ASCII 編碼的文本都會顯示亂碼。

不過後來 Unicode 聯盟發現 16 位編碼空間根本不夠用,與此同時 ISO/IEC 組織也覺得 UCS 的 32 位編碼空間太多了,實際中根本沒有幾十億字符,也挺浪費空間的,所以最終 Unicode 聯盟和 ISO/IEC 工作組達成一致:兩者使用統一的編碼空間「 0000 ~ 10FFFF」(即 UCS 保證永遠不分配大於 10FFFF 的字符碼點),而且雙方在字符編碼上保持同步,即一方標準中增加了字符,也要通知另一方同步。

於是 Unicode 在 UTF-16 基礎上拓展編碼空間到 21 位,UCS 則搞了一個雙字節的 UCS-2 編碼實現。

→ UTF-16 編碼是雙字節的,上限也只有 6w 多個碼點,怎麼讓他支持到 10FFFF(100w+) 個碼點呢?

本質就是多加幾個字節來表示更多的字符,只是 UTF-16 不像 UCS 那樣採用定長 4 字節,而是使用變長的形式,但是這個跟 UTF-8 變長方式又不太一樣,他是採用代理對的方式實現,大部分常用字符用一個碼元表示 (定長 2 個字節),其他擴展的特殊字符用兩個碼元表示 (定長 4 字節)。

代理對

UTF-16 跟 UTF-8、GB 系列等都算是變長字節,但是設計初衷卻不一樣,像 GBK 是爲了兼容 ASCII,但是 UTF-16 一開始就沒考慮要兼容 ASCII,所以他的變長是爲了節約存儲空間而採用的自然增長方案,當空間不夠的時候增長到 4 個字節。

那問題來了,我怎麼知道存儲的 4 個字節是表示一個字符,還是兩個字符呢?比如當程序遇到字節序列 01001110 00101101 01010110 11111101 時,到底是判斷成一個字符還是兩個字符?

這就需要一個前導識別,比如 GB2312 識別第一個字節高位是不是 1 來判斷是單字節還是雙字節,但是 UTF-16 的高位 1 已經被用來編碼了,當然這也難不倒我們,第一位被用了那麼就用前幾位的組合形式。

UTF-16 採用了代理對來解決,也就是高半區編碼(前兩個字節)範圍 D800-DBFF(稱爲代理碼點),低半區編碼(後兩個字節)範圍 DC00-DFFF,組成一個四個字節表示的字符。

上述前導 6 位組合也是有講究的,ISO 組織要求編號範圍是 0~10FFFF(),也就是說用 20 位就可以表示 10FFFF 個字符,對於雙碼元就是每個碼元各自負責 10 位,一個碼元是 16 位,數字位佔去 10 位後,剩下的 6 位做爲前導位。

當 UTF-16 使用一個碼元表示的時候,Unicode 字符編號跟碼元序列是等值映射的,但是當採用雙碼元后,字符編號跟碼元序列就需要轉換了,下面是碼元和 Unicode 編號值之間的計算公式:

換算碼元序列 (CH 高半區 / CL 低半區)

換算字符編號 (CH 高半區 / CL 低半區)

平面空間

UTF-16 把編碼空間 0000 ~ 10FFFF 切成了 17 個平面,其實就是劃分成 17 個區塊,每個平面空間碼點數都是 = 65536 個,第一個平面稱爲基本多語言平面(Basic Multilingual Plane,簡稱 BMP),這個平面涵蓋了當今世界上最常用的字符,固定使用定長兩個字節,除此之外的字符都放到增補平面裏,都是使用兩個碼元的定長 4 個字節,下面是各個平面的用途

增補平面的編號是採用雙碼元 4 個字節來表示的,去除代理對之後有效位數是 20 位,然後將這 20 位的編號再劃成 16 個平面區域,其中高半區的數字位裏取出 4 位表示平面,剩下的 16 位表示每個平面可以表示的字符數也就是 2 的 16 次方 65536 個(兩個字節大小)

UTF-16 可看成是 UCS-2 的父集。在沒有輔助平面前,UTF-16 與 UCS-2 所指的是同一的意思。但當引入輔助平面字符後,就稱爲 UTF-16 了。

字節序

字節序顧名思義是指字節的順序,對於單字節編碼來說,一個字符對應一個字節,也就不存在字節序問題;但是對於 UTF-16 這種定長多字節編碼,就有字節順序問題了。字節序其實跟操作系統和底層硬件有關,不僅只是 UTF-16 這種多字節編碼存在字節序,只要是多字節類型的數據都存在字節順序問題,比如 short、int、long。

爲了方便說明,我們這裏舉個例子,比如存一個整數值「305419896」對應 16 進制是 0x12345678,有人習慣從左到右按順序去存,也有人說高位當然要放到高位地址而低位放到低位地址,要從右往左存。於是就有了下面兩種存取方式。

其實這兩種方式沒有孰優孰劣,只是我們認知習慣有所不同 最終的設計不同,說來這都是阿拉伯人的鍋啊,爲什麼數字高位非要在左邊,這也引起了著名的大小端之爭。

因此字節序也就有了大端和小端的概念,也形成了各自的陣營,比如 Windows、FreeBSD、Linux 是小端序,Mac 是大端序。其實大小端序並沒有技術上的好壞之分。

小端序 (Little-Endian) 就是低位字節(即小端字節、尾端字節)存放在內存的低地址而高位字節(即大端字節、頭端字節)存放在內存的高地址

大端序 (Big-Endian) 就是高位字節(即大端字節、頭端字節)存放在內存的低地址,低位字節(即小端字節、尾端字節)存放在內存的高地址。

UTF-8

簡介 & 規則

Unicode 還是 UCS 最初都是採用多字節定長編碼,由於沒有兼容現有的 ASCII 標準的文件和軟件,新標準很難被推廣,於是兼容 ASCII 版本的 UTF-8 就誕生了。

UTF-8(8-bit Unicode Transformation Format)是一種針對 Unicode 的可變長度字符編碼,是現代字符編碼模型中的第三層 CEF 。它可以用一至四個字節對 Unicode 字符集中的所有有效編碼點進行編碼,屬於 Unicode 標準的一部分,UTF-8 就是爲了解決向後兼容 ASCII 碼而設計,Unicode 中前 128 個字符(與 ASCII 碼一一對應),使用與 ASCII 碼相同的二進制值的單個字節進行編碼,這使得原來處理 ASCII 字符的軟件無須或只須做少部分修改,即可繼續使用。因此,它逐漸成爲電子郵件、網頁及其他存儲或發送文字優先採用的編碼方式。

—— 維基百科

UTF-8 需要兼容 ASCII,所以也需要有前綴碼來控制,前綴規則如下:

理論上 UTF-8 變長可以超過 4 個字節,只是 Unicode 聯盟規範上限是 10FFFF,所以 UTF-8 規則設計上也限制了大小。

程序算法

用文字不太好描述算法結構,我們就直接來欣賞一下 UTF-8 鼻祖寫的這段解析代碼,這是 Ken 和 Rob 用一個晚上寫出來的編解碼算法,代碼非常簡短精煉,爲了方便閱讀我加了註釋解讀。

typedef struct
{
  int   cmask; //前綴碼掩碼
  int   cval;  //前綴碼
  int   shift; //移動位數
  long  lmask; //Unicode值掩碼
  long  lval;  //Unicode下限值
} Tab;

static Tab  tab[] =
{
  0x80, 0x00, 0*6, 0x7F,       0,         /* 1 byte sequence */
  0xE0, 0xC0, 1*6, 0x7FF,      0x80,      /* 2 byte sequence */
  0xF0, 0xE0, 2*6, 0xFFFF,     0x800,     /* 3 byte sequence */
  0xF8, 0xF0, 3*6, 0x1FFFFF,   0x10000,   /* 4 byte sequence */
  0xFC, 0xF8, 4*6, 0x3FFFFFF,  0x200000,  /* 5 byte sequence */
  0xFE, 0xFC, 5*6, 0x7FFFFFFF, 0x4000000, /* 6 byte sequence */
  0, /* end of table */
};
/**
* 把一個多字節序列轉換爲一個寬字符
* 
* @param p 存放計算後的unicode值
* @param s 需要解析的UTF-8字節序列
* @param n 字節長度
* @return 解析的字節長度
*/
int mbtowc(wchar_t *p, char *s, size_t n)
{
  long l;  int c0, c, nc;  Tab *t;
  if(s == 0) return 0;
  nc = 0;
  //異常校驗(可不用關注)
  if(n <= nc) return -1;
  //c0 此處備份一下首字節,後續需要用到前綴碼
  c0 = *s & 0xff;
  //l 保存 Unicode 結果
  l = c0;
  /* 遍歷tab,從單字節結構->2字節結構->..依次檢查找到對應tab */
  for(t=tab; t->cmask; t++) {
    //字節數+1,字節數和tab結構是對應的,也就是當nc=1時 tab結構是單字節,nc=2是tab是兩字節
    nc++;
    /* 判斷前綴碼跟當前的tab是否一致, 如果一致計算最終unicode值並返回*/
    if((c0 & t->cmask) == t->cval) {
      //通過 & Unicode有效值掩碼,移除高位前綴碼,得到最終unicode值
      l &= t->lmask;
      //異常校驗
      if(l < t->lval) return -1;
      //保存結果並反回
      *p = l;
      return nc;
    }
    //異常校驗
    if(n <= nc) return -1;
    //讀取下個字節;如果上面判斷前綴碼不一致,說明需要再讀取下個字節
    s++;
    //計算有效位的值,目的是去除UTF-8 編碼從第二個字節開始的高兩位10
    // 例如 s=10101111、0x80=10000000 計算結果是00101111,這樣就去除了高位前綴10
    c = (*s ^ 0x80) & 0xFF;
    //異常校驗
    if(c & 0xC0) return -1;
    //重新計算unicode值,根據UTF-8規則c只有低 6 位有效,所以通過移位把c填入到l的低6位
    l = (l<<6) | c;
  }
  //返回異常
  return -1;
}

容錯性

通過上面的程序我們知道解析過程是一個字節一個字節往下處理的,我們在傳輸過程中如果發生局部的字節錯誤、丟失,或者中間有一個字節規則對不上,會不會影響整個文本的解析?

我們先來看下其他編碼的容錯情況,從對於單字節的 ASCII 碼來說,丟失一個字節就丟失一個字符,並不影響後續文本的內容,比如 Hello world,丟失 b2 字節後內容是 Hllo world 少個 e 而已

我們再來看 GB2312 這種多字節編碼,如果丟失了 b2 字節那麼整個文本都亂套了,這是最糟糕的,大部分多字節編碼都有類似問題,一旦出現錯誤可能導致整個文件都需要重傳。

接下來我們看看 UTF-8 是如何避免這種 “一顆老鼠屎壞了一鍋粥” 的情況,UTF-8 的碼元序列的第一個字節指明瞭後面所跟字節的個數,比如首字節高位是 0 就表示單字節,110 表示總共兩個字節,1110 表示三個字節依次類推,除首字節之外後續字節都是 10 開頭。所以 UTF-8 的前綴碼具有很強的魯棒性,即使丟失、增加、改變個別字節也不會導致後續字符全部錯亂這樣的傳遞性、連鎖性的錯誤問題。

總結

單單一個字符編碼,深入瞭解之後發現也有這麼濃重的發展歷程,試想一下,如果計算機還是跟之前大型機一樣,個人計算機沒有井噴式發展起來就沒有這些字符編碼的事了,如果 ASCII 當初就設計成多字節編碼,也沒有後面 UNICODE 什麼事了。

這是一個很典型的架構設計問題,到底好的架構是設計出來的,還是演化出來的?

一個好的架構是既要靠設計又要靠演化,老話說的好三分靠設計七分靠演化,我們既要學會務實,也要懂得前瞻,至少我們首先需要活下來。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5pAgcjk_lFGrPhSUp2Na3Q