一文說清文本編碼那些事
- 2020 年 3 月 13 日
- 筆記
一直以來,編碼問題像幽靈一般,不少開發人員都受過它的困擾。
試想你請求一個數據,卻得到一堆亂碼,丈二和尚摸不著頭腦。有同事質疑你的數據是亂碼,雖然你很確定傳了 UTF-8 ,卻也無法自證清白,更別說幫同事 debug 了。
有時,靠著百度和一手瞎調的手藝,亂碼也能解決。儘管如此,還是很羨慕那些骨灰級程式設計師。為什麼他們每次都能犀利地指出問題,並快速修復呢?原因在於,他們早就把編碼問題背後的各種來龍去脈搞清楚了。
本文從 ASCII 碼說起,帶你扒一扒編碼背後那些事。相信搞清編碼的原理後,你將不再畏懼任何編碼問題。
從 ASCII 碼說起
現代電腦技術從英文國家興起,最先遇到的也是英文文本。英文文本一般由 26 個字母、 10 個數字以及若干符號組成,總數也不過 100 左右。
電腦中最基本的存儲單位為 位元組 ( byte ),由 8 個比特位( bit )組成,也叫做 八位位元組 ( octet )。8 個比特位可以表示 $ 2^8 = 256 $ 個字元,看上去用位元組來存儲英文字元即可?
電腦先驅們也是這麼想的。他們為每個英文字元編號,再加上一些控制符,形成了我們所熟知的 ASCII 碼錶。實際上,由於英文字元不多,他們只用了位元組的後 7 位而已。

根據 ASCII 碼錶,由 01000001 這 8 個比特位組成的八位位元組,代表字母 A 。
順便提一下,比特本身沒有意義,比特 在 上下文 ( context )中才構成資訊。舉個例子,對於記憶體中一個位元組 01000001 ,你將它看做一個整數,它就是 65 ;將它作為一個英文字元,它就是字母 A ;你看待比特的方式,就是所謂的上下文。
所以,猜猜下面這個程式輸出啥?
#include <stdio.h> int main(int argc, char *argv[]) { char value = 0x41; // as a number, value is 65 or 0x41 in hexadecimal printf("%dn", value); // as a ASCII character, value is alphabet A printf("%cn", value); return 0; } latin1
西歐人民來了,他們主要使用拉丁字母語言。與英語類似,拉丁字母數量並不大,大概也就是幾十個。於是,西歐人民打起 ASCII 碼錶那個未用的比特位( b8 )的主意。
還記得嗎?ASCII 碼錶總共定義了 128 個字元,範圍在 0~127 之間,位元組最高位 b8 暫未使用。於是,西歐人民將拉丁字母和一些輔助符號(如歐元符號)定義在 128~255 之間。這就構成了 latin1 ,它是一個 8 位字符集,定義了以下字元:

圖中綠色部分是不可列印的( unprintable )控制字元,左半部分是 ASCII 碼。因此,latin1 字符集是 ASCII 碼的超集:
一個位元組掰成兩半,歐美兩兄弟各用一半。至此,歐美人民都玩嗨了,東亞人民呢?
GB2312、GBK和GB18030
由於受到漢文化的影響,東亞地區主要是漢字圈,我們便以中文為例展開介紹。
漢字有什麼特點呢?—— 光常用漢字就有幾千個,這可不是一個位元組能勝任的。一個位元組不夠就兩個唄。道理雖然如此,操作起來卻未必這麼簡單。
首先,將需要編碼的漢字和 ASCII 碼整理成一個字符集,例如 GB2312 。為什麼需要 ASCII 碼呢?因為,在電腦世界,不可避免要跟數字、英文字母打交道。至於拉丁字母,重要性就沒那麼大,也就無所謂了。
GB2312 字符集總共收錄了 6 千多個漢字,用兩個位元組來表示足矣,但事情遠沒有這麼簡單。同樣的數字字元,在 GB2312 中佔用 2 個位元組,在 ASCII 碼中佔用 1 個位元組,這不就不兼容了嗎?電腦里太多東西涉及 ASCII 碼了,看看一個 http 請求:
GET / HTTP/1.1 Host: www.example.com 那麼,怎麼兼容 GB2312 和 ASCII 碼呢?天無絕人之路, 變長 編碼方案應運而生。
變長編碼方案,字元由長度不一的位元組表示,有些字元只需 1 位元組,有些需要 2 位元組,甚至有些需要更多位元組。GB2312 中的 ASCII 碼與原來保持一致,還是用一個位元組來表示,這樣便解決了兼容問題。
在 GB2312 中,如果一個位元組最高位 b8 為 0 ,該位元組便是單位元組編碼,即 ASCII 碼。如果位元組最高位 b8 為 1 ,它就是雙位元組編碼的首位元組,與其後位元組一起表示一個字元。
變長編碼方案目的在於兼容 ASCII 碼,但也帶來一個問題:由於位元組編碼長度不一,定位第 N 個字元只能通過遍歷實現,時間複雜度從 $ O(1) $ 退化到 $ O(N) $ 。好在這種操作場景並不多見,因此影響可以忽略。
GB2312 收錄的漢字個數只有常用的 6 千多個,遇到生僻字還是無能為力。因此,後來又推出了 GBK 和 GB18030 字符集。GBK 是 GB2312 的超集,完全兼容 GB2312 ;而 GB18030 又是 GBK 的超集,完全兼容 GBK 。
因此,對中文編碼文本進行解碼,指定 GB18030 最為健壯:
>>> raw = b'xfdx88xb5xc4xb4xabxc8xcb' >>> raw.decode('gb18030') '龍的傳人' 指定 GBK 或 GB2312 就只好看運氣了,GBK 多半還沒事:
>>> raw.decode('gbk') '龍的傳人' GB2312 經常直接拋錨不商量:
>>> raw.decode('gb2312') Traceback (most recent call last): File "<stdin>", line 1, in <module> UnicodeDecodeError: 'gb2312' codec can't decode byte 0xfd in position 0: illegal multibyte sequence chardet 是一個不錯的文本編碼檢測庫,用起來很方便,但對中文編碼支援不是很好。經常中文編碼的文本進去,檢測出來的結果是 GB2312 ,但一用 GB2312 解碼就跪:
>>> import chardet >>> raw = b'xd6xd0xb9xfaxc8xcbxcaxc7xfdx88xb5xc4xb4xabxc8xcb' >>> chardet.detect(raw) {'encoding': 'GB2312', 'confidence': 0.99, 'language': 'Chinese'} >>> raw.decode('GB2312') Traceback (most recent call last): File "<stdin>", line 1, in <module> UnicodeDecodeError: 'gb2312' codec can't decode byte 0xfd in position 8: illegal multibyte sequence 掌握 GB2312 、 GBK 、 GB18030 三者的關係後,我們可以略施小計。如果 chardet 檢測出來結果是 GB2312 ,就用 GB18030 去解碼,大概率可以成功!
>>> raw.decode('GB18030') '中國人是龍的傳人' Unicode
GB2312 、 GBK 與 GB18030 都是中文編碼字符集。雖然 GB18030 也包含日韓表意文字,算是國際字符集,但畢竟是以中文為主,無法適應全球化應用。
在電腦發展早期,不同國家都推出了自己的字符集和編碼方案,互不兼容。中文編碼的文本在使用日文編碼的系統上是無法顯示的,這就給國際交往帶來障礙。
這時,英雄出現了。統一碼聯盟 站出來說要發展一個通用的字符集,收錄世界上所有字元,這就是 Unicode 。經過多年發展, Unicode 已經成為世界上最通用的字符集,也是電腦科學領域的業界標準。
Unicode 已經收錄的字元數量已經超過 13 萬個,每個字元需佔用超過 2 位元組。由於常用程式語言一般沒有 24 位數字類型,因此一般用 32 位數字表示一個字元。這樣一來,同樣的一個英文字母,在 ASCII 中只需佔用 1 位元組,在 Unicode 則需要佔用 4 位元組!英美人民都要哭了,試想你磁碟中的文件大小都增大了 4 倍是什麼感受!
UTF-8
為了兼容 ASCII 並優化文本空間佔用,我們需要一種變長位元組編碼方案,這就是著名的 UTF-8 。與 GB2312 等中文編碼一樣,UTF-8 用不固定的位元組數來表示字元:
- ASCII 字元 Unicode 碼位由 U+0000 至 U+007F ,用 1 個位元組編碼,最高位為 0 ;
- 碼位由 U+0080 至 U+07FF 的字元,用 2 個位元組編碼,首位元組以 110 開頭,其餘位元組以 10 開頭;
- 碼位由 U+0800 至 U+FFFF 的字元,用 3 個位元組編碼,首位元組以 1110 開頭,其餘位元組同樣以 10 開頭;
- 4 至 6 位元組編碼的情況以此類推;
如圖,以 0 開頭的位元組為 單位元組 編碼,總共 7 個有效編碼位,編碼範圍為 U+0000 至 U+007F ,剛好對應 ASCII 碼所有字元。以 110 開頭的位元組為 雙位元組 編碼,總共 11 個有效編碼位,最大值是 0x7FF ,因此編碼範圍為 U+0080 至 U+07FF ;以 1110 開頭的位元組為 三位元組 編碼,總共 16 個有效編碼位,最大值是 0xFFFF 因此編碼範圍為 U+0800 至 U+FFFF 。
根據開頭不同, UTF-8 流中的位元組,可以分為以下幾類:
| 位元組最高位 | 類別 | 有效位 |
|---|---|---|
| 0 | 單位元組編碼 | 7 |
| 10 | 多位元組編碼非首位元組 | |
| 110 | 雙位元組編碼首位元組 | 11 |
| 1110 | 三位元組編碼首位元組 | 16 |
| 11110 | 四位元組編碼首位元組 | 21 |
| 111110 | 五位元組編碼首位元組 | 26 |
| 1111110 | 六位元組編碼首位元組 | 31 |
至此,我們已經具備了讀懂 UTF-8 編碼位元組流的能力,不信來看一個例子:
概念回顧
一直以來,字符集 和 編碼 這兩個詞一直是混著用的。現在,我們總算有能力釐清這兩者間的關係了。
字符集 顧名思義,就是由一定數量字元組成的集合,每個字元在集合中有唯一編號。前文提及的 ASCII 、 latin1 、 GB2312 、GBK 、GB18030 以及 Unicode 等,無一例外,都是字符集。
電腦存儲和網路通訊的基本單位都是 位元組 ,因此文本必須以 位元組序列 的形式進行存儲或傳輸。那麼,字元編號如何轉化成位元組呢?這就是 編碼 要回答的問題。
在 ASCII 碼和 latin 中,字元編號與位元組一一對應,這是一種編碼方式。GB2312 則採用變長位元組,這是另一種編碼方式。而 Unicode 則存在多種編碼方式,除了 最常用的 UTF-8 編碼,還有 UTF-16 等。實際上,UTF-16 編碼效率比 UTF-8 更高,但由於無法兼容 ASCII ,應用範圍受到很大制約。
最佳實踐
認識文本編碼的前世今生之後,應該如何規避編碼問題呢?是否存在一些最佳實踐呢?答案是肯定的。
編碼選擇
項目開始前,需要選擇一種適應性廣的編碼方案,UTF-8 是首選,好處多多:
- Unicode 是業界標準,編碼字元數量最多,天然支援國際化;
- UTF-8 完全兼容 ASCII 碼,這是硬性指標;
- UTF-8 目前應用最廣;
如因歷史原因,不得不使用中文編碼方案,則優先選擇 GB18030 。這個標準最新,涵蓋字元最多,適應性最強。盡量避免採用 GBK ,特別是 GB2312 等老舊編碼標準。
編程習慣
如果你使用的程式語言,字元串類型支援 Unicode ,那問題就簡單了。由於 Unicode 字元串肯定不會導致諸如亂碼等編碼問題,你只需在輸入和輸出環節稍加留意。
舉個例子,Python 從 3 以後, str 就是 Unicode 字元串了,而 bytes 則是 位元組序列 。因此,在 Python 3 程式中,核心邏輯應該統一用 str 類型,避免使用 bytes 。文本編碼、解碼操作則統一在程式的輸入、輸出層中進行。
假如你正在開發一個 API 服務,資料庫數據編碼是 GBK ,而用戶卻使用 UTF-8 編碼。那麼,在程式 輸入層 , GBK 數據從資料庫讀出後,解碼轉換成 Unicode 數據,再進入核心層處理。在程式 核心層 ,數據以 Unicode 形式進行加工處理。由於核心層處理邏輯可能很複雜,統一採用 Unicode 可以減少問題的發生。最後,在程式的 輸出層 將數據以 UTF-8 編碼,再返回給客戶端。
整個過程偽程式碼大概如下:
# input # read gbk data from database and decode it to unicode data = read_from_database().decode('gbk') # core # process unicode data only result = process(data) # output # encoding unicode data into utf8 response_to_user(result.encode('utf8')) 這樣的程式結構看起來跟個三明治一樣,非常形象:

當然了,還有很多程式語言字元串還不支援 Unicode 。Python 2 中的 str 對象,跟 Python 3 中的 bytes 比較像,只是位元組序列;C 語言中的字元串甚至更原始。
這都無關緊要,好的編程習慣是相通的:程式核心層統一使用某種編碼,輸入輸出層則負責編碼轉換。至於核心層使用何種編碼,主要看程式中哪種編碼使用最多,一般是跟資料庫編碼保持一致即可。
附錄
更多 Python 技術文章,請查看:Python語言小冊 ,轉至 原文 可獲得最佳閱讀體驗。
