編碼與亂碼——追根究底

原文發表自 我的簡書

亂碼問題是不但是新手程序員之痛,也常常讓許多資深 coder 束手無策。最近在社區接連收到關於亂碼問題的求助,五花八門,我覺得是時候深入討論一下這個問題了。本文試圖讓讀者深入理解編碼的概念以及亂碼的產生的原理,以至於今後再遇到亂碼問題,能夠獨立分析、解決。
由於 Sublime Text 是筆者最青睞的編輯器,因此文中的所有截圖和實驗均以 Sublime Text 爲例,其他編輯器或 IDE 在原理上是類似的。

一、什麼是編碼?

什麼是編碼?這要從「文件」的概念說起。根據呈現形式,文件可分爲兩種類型:「文本文件」和「二進制文件」。

二者的區別非常明顯,文本文件中保存的是各種字符,包括英文字母如 abc、漢字如 你好、日文如 こんにちは 等;而二進制文件中保存的則是 0101 等二進制數值。如果你用 Sublime Text 分別打開文本文件和二進制文件,那麼它們呈現的樣子大致如下:

文本文件與二進制文件

注:我們習慣採用十六進制的方式簡化二進制數據的顯示,這樣對人類用戶稍微友好一些,避免了過長的 0-1 串使得人們眼花繚亂。

爲什麼會產生這兩種類型的文件呢?一個非常直接的原因是,文本文件主要是給人類用戶看的,例如我們常使用的 txt、markdown 文件,各種代碼文件如 .cpp.java.py.js 等,以及各種配置文件如 .ini.json 等;而二進制文件則是給操作系統或應用程序看的,如 .exe 交給 Windows 系統執行、Word 文檔交給 Office Word 軟件打開、.class 文件交給 java 虛擬機執行,許多應用程序都會設計自己專用的二進制文件格式。

儘管我們把文件分爲文本文件和二進制文件兩種類型,但從計算機硬件層面上來看,它只能存儲 0101 這樣的二進制數據,不可能直接存儲 abc 這樣的字符。那麼該如何解釋文本文件的存在呢?

事實上,從存儲方式上來看,文件確實只有一種類型,那就是二進制文件。至於文本文件,它只是二進制文件的一種特殊情況。在計算機最初發明的時候,確實只有二進制文件,那時的人們通過「打孔的紙帶」作爲存儲程序的載體,而紙帶上小孔的有無就代表二進制的 1 和 0。那時候的計算機根本沒有字符的概念,更不要說文本文件。

後來,人們爲了方便就制定了一套規則,規定二進制數值 01100001 代表字符 a01100010 代表字符 b、……、01111010 代表字符 z。於是,最早的編碼「ASCII 編碼」就產生了。現在,如果我在一個文件中寫入二進制數據 011000010110001001100011,從表面上看,它就是一個常規的二進制文件,沒有任何特殊之處,但如果我用 ASCII 編碼的規則去解釋它,就會看到一串字符 abc。這時候,我們就可以認爲這個文件是文本文件。

從上面的描述中,你應該已經發現:

  • 所謂的「編碼」就是一種規則,它規定了二進制數值與字符之間的映射關係
  • 所謂的「文本文件」就是一種二進制文件,只不過能用某種編碼解釋得通

說回到 ASCII 編碼,它使用 8 個二進制位——也就是 1 個字節來映射一個字符,這意味着它最多隻能映射 2^8=256 個字符。256 個字符對於純英文來說已經足夠了,但世界上的語言太多了,要囊括英文、德文、法文、中文、日文、韓文、阿拉伯文、希伯來文等所有語言文字,至少需要十幾萬的字符量。隨着各種文字不斷被引入計算機,字符編碼的長度也不斷擴張,從 1 個字節逐漸增加到 2 個、3 個、4 個字節。同時,各個組織、各個國家都在制定自己的編碼體系,形成了錯綜複雜的編碼“方言”。最終,到了 1994 年,人們終於制定出了一套統一的、無所不包的編碼——Unicode 編碼,成爲編碼界的“世界語”,因此也被稱爲萬國碼。

Unicode 編碼使用 4 個字節來保存字符映射關係,因此共支持 2^(4*8)=4294967296 個字符,遠遠超出了地球上所有文字的總量。這徹底解決了字符數量不夠用的擔憂,但也帶來了存儲空間的浪費:即使僅僅保存一個簡單的英文字母 a,Unicode 編碼也需要 4 個字節,但事實上只需要 1 個字節(ASCII 編碼)。如果一個文本文件中絕大部分字符都是英文字母,那麼 Unicode 就浪費了 75% 的存儲空間。鑑於上述問題,人們又制定了一系列“改良版”的 Unicode 編碼,包括 UTF-8、UTF-16、UTF-32 等,它們同樣能夠編碼所有已知的字符,但佔用更少的空間。

以 UTF-8 爲例,對於常見的英文字符,它採用 1 個字節編碼,常見的中文、日文等字符采用 2 個字節,不常見的中文字符等採用 3 到 4 個字節,對於極不常見的字符,它會採用 6 個字節進行編碼。因此,在通常情況下,UTF-8 編碼要比 Unicode 編碼節省超過一半的空間。UTF-8 編碼無所不包、節省空間,且具有良好的跨平臺性,因此推薦一切文本文件都使用 UTF-8 編碼。目前,主流的文本編輯器都把 UTF-8 作爲默認編碼方式。

最後解釋一下所謂的「ANSI 編碼」。ANSI 編碼常被稱爲標準編碼,但它並不是指某種明確的編碼方式。爲了更容易地理解 ANSI 編碼,我們不妨把它與「官方語言」的概念做類比。正如中國的官方語言是漢語,日本的官方語言是日語一樣,中文 Windows 系統的 ANSI 編碼爲 GBK 編碼,而日文 Windows 系統的 ANSI 編碼爲 Shift_JIS 編碼。正如「官方語言」不是某種語言,「ANSI 編碼」也不是某種編碼,它是另一個維度的概念,與國家和地區有關,不同國家和地區的 ANSI 編碼是不兼容的。可想而知,如果都採用 ANSI 編碼,那麼不同國家的開發者在互相交換代碼時將非常糟糕。因此,不推薦以 ANSI 作爲 coding 編碼。

二、什麼是亂碼?

什麼是亂碼?用某種編碼方式去解讀一個文件,得到了無意義的字符,這就是亂碼。打個通俗的比方:我寫了一段英文,你非要把它當作拼音來讀,那麼得到的解釋就是無意義的,就相當於亂碼;反過來,我寫了一段拼音,你非要用英語的語法去解釋它,也是解釋不通的。

舉幾個實際的例子:

  • 用 UTF-8 編碼打開一個二進制文件會出現亂碼:

用 UTF-8 編碼打開一個二進制文件

  • 用 UTF-8 編碼打開一個 GBK 編碼的文本文件會出現亂碼:

用 UTF-8 編碼打開一個 GBK 編碼的文本文件

  • 用 UTF-8 編碼打開一個 UTF-8 編碼的文本文件不會亂碼:

用 UTF-8 編碼打開一個 UTF-8 編碼的文本文件

綜上,亂碼的根源就是編碼與解碼用的不是同一套規則。 但不管文件是否亂碼,它裏面保存的二進制數據總是不變的。通常情況下,亂碼並不是文件本身有問題,而是打開方式(解碼方式)不正確

三、編程中出現亂碼的原因與類型

我們在日常使用文本編輯器、IDE、命令行等編寫和執行程序的過程中,常常會遇到亂碼現象,而出現亂碼的原因是多種多樣的。這裏試圖從根源上理解亂碼,並將其歸類。

一般,我們編寫和執行程序的流程如下:

  1. 編寫代碼並保存;
  2. 調用編譯器編譯代碼,並執行程序;
  3. 查看輸出結果。

在這短短的三步操作中,隱含着兩次編碼和解碼過程,也就是下圖中的過程 1 和過程 2:

代碼編寫和執行過程中的編碼和解碼

在過程 1 和過程 2 中,任意一個過程兩端的編碼方式都必須一致,否則就會出現亂碼。其中,對於「代碼文件的編碼」以及「展示器的編碼」,我們可以在編輯器和控制檯中進行設置。最不可控的是編譯器的輸入編碼和輸出編碼,常見編譯器/解釋器的默認輸入輸出編碼如下表所示:

編譯器/解釋器 默認輸入編碼 默認輸出編碼 設置輸入編碼 設置輸出編碼
python UTF-8 ANSI # coding=xxx 環境變量 PYTHONIOENCODING
gcc/g++ UTF-8 UTF-8 未知 未知
javac ANSI ANSI -encoding 參數 未知
matlab ANSI ANSI 修改配置文件 未知

注:該結果是筆者在自己的 Windows 10 家庭中文版上測試得到的,不同的平臺可能有差異。


接下來,我們將以 Sublime Text 執行一段 Python 腳本爲例來展示這 2 種亂碼,通過設置編譯器輸入編碼、輸出編碼、展示器編碼來探究亂碼產生的不同原因。

這段 Python 腳本非常簡單,只有一句話:print('你好'),以 UTF-8 編碼保存。正常執行的結果如下:

正常無亂碼

從上上圖中不難看出,過程 1 和過程 2 均能導致亂碼,其組合可形成如下三種亂碼類型:

類型 1:過程 1 亂碼

我們在 Python 腳本頭部添加一行 # -*- coding: gbk -*-,即把 Python 解釋器的輸入編碼指定爲 GBK,但腳本的編碼保持 UTF-8 不變。執行結果將發生亂碼,如下:

亂碼類型 1

從這裏我們也可以看出,Python 解釋器的默認輸入編碼爲 UTF-8。

類型 2:過程 2 亂碼。

這裏又分爲兩種情況,一是編譯器的輸出編碼錯誤;二是展示器的輸入編碼錯誤:

2-1. 編譯器輸出編碼不當。

打開 Python.sublime-build 文件(可藉助 PackageResourceViewer 插件),其初始內容如下:

{
    "shell_cmd": "python -u \"$file\"",
    "file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
    "selector": "source.python",
    "env": {"PYTHONIOENCODING": "utf-8"},
}

我們把末尾的行改爲 "env": {"PYTHONIOENCODING": "gbk"},,即把 Python 解釋器的輸出編碼設爲 UTF-8。執行腳本,再次得到亂碼,如下:

亂碼類型 2-1

注意:這裏雖然也是亂碼,但與類型 1 不同。

2-2. 展示器輸入編碼不當。

我們首先撤銷對 Python.sublime-build 的所有更改,然後在其末尾增加一行內容 "encoding": "gbk",,即把 Sublime Text 控制檯的編碼設爲 GBK。此時 Python.sublime-build 配置如下:

{
    "shell_cmd": "python -u \"$file\"",
    "file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
    "selector": "source.python",
    "env": {"PYTHONIOENCODING": "utf-8"},
    "encoding": "gbk",
}

執行腳本,得到亂碼,如下:

亂碼類型 2-2

注意:這裏的亂碼與類型 1 相同,都是用 GBK 編碼解釋 UTF-8 字符串造成的。

類型 3:過程 1 與過程 2 同時亂碼。

亂碼是可以疊加的,即亂碼後的字符串可以再次被亂碼,得到的亂碼與疊加前的亂碼均不同。

我們讓 Python.sublime-build 文件保持上一步的狀態,然後在 Python 腳本的開頭重新加上一行 # -*- coding: gbk -*-。執行腳本,會得到前兩種完全不同的亂碼,如下:

亂碼類型 3

以上就是編程中出現亂碼的 3 種典型情況。需要指出的是,以上採用 Sublime Text 的控制檯作爲展示器,其編碼可以通過 Build System 中的 encoding 參數進行設置。如果你直接使用命令行如 cmd、bash、cmder 等來編譯和運行程序,那就完全省去這些麻煩了,命令行一般會自動識別你的輸出編碼,因此總能使用正確解碼方式,基本不會出現類型 2 亂碼,但無法避免類型 1 亂碼

希望本文對你有所啓發,如果你在編程中遇到了亂碼,不妨對下圖中的 2 個過程進行控制變量式的排除,如果能夠解決你的問題,那便是本文最大的成功。

代碼編寫和執行過程中的編碼和解碼

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